diff --git a/.agents/skills/auto-pr-merge/SKILL.md b/.agents/skills/auto-pr-merge/SKILL.md deleted file mode 100644 index 42f4fed9d09..00000000000 --- a/.agents/skills/auto-pr-merge/SKILL.md +++ /dev/null @@ -1,321 +0,0 @@ ---- -name: auto-pr-merge -description: Create PR, wait for checks, fix issues iteratively, and merge with --admin ---- - -# auto-pr-merge - -End-to-end autonomous PR workflow: create PR, wait for CI checks to pass, fix any issues (typecheck, code review), update PR, and merge with admin override. Runs fully autonomously without user confirmation. - -**IMPORTANT**: Execute this entire process autonomously without asking the user for confirmation at any step. Just do it. - ---- - -## Process Overview - -``` - CREATE PR → WAIT FOR CHECKS → FIX LOOP (max 3) → MERGE --admin - │ │ │ │ - /create-pr 1min + 1min polls diagnose CI gh pr merge - (up to ~6min) fix issues --admin - pn typecheck --squash - /review-code --delete-branch - commit & push - wait for checks -``` - ---- - -## Step 1: Create PR - -Run the `/create-pr` skill to create or update the pull request. - -``` -Use the Skill tool: skill: "create-pr" -``` - -After `/create-pr` completes, extract the PR number and URL: - -```bash -gh pr view --json number,url --jq '.number, .url' -``` - -Store the PR number for later use. - -**CRITICAL: DO NOT STOP after /create-pr completes. Continue to Step 2.** - ---- - -## Step 2: Wait for PR Checks - -Use the **Check Polling Procedure** (defined below) to wait for CI checks. Based on the result: - -- `PASSED` → Skip to Step 4 (MERGE) -- `FAILED` → Proceed to Step 3 (FIX LOOP) -- `NO_CHECKS` → Skip to Step 4 (MERGE) — no CI configured, nothing to wait for - ---- - -## Step 3: Fix Loop (Max 3 Iterations) - -Run up to **3 iterations** of the diagnose-fix-review-push cycle. - -### 3a. Diagnose Failures - -Identify which checks failed and get their logs: - -```bash -# Get the most recent failed run ID for this branch -FAILED_RUN=$(gh run list --branch $(git branch --show-current) --limit 5 --json databaseId,conclusion,name --jq '[.[] | select(.conclusion == "failure")] | .[0].databaseId') -echo "Failed run: $FAILED_RUN" - -# View failed step logs (truncated to last 200 lines to avoid context bloat) -if [ -n "$FAILED_RUN" ] && [ "$FAILED_RUN" != "null" ]; then - gh run view "$FAILED_RUN" --log-failed 2>&1 | tail -200 -fi -``` - -If `gh run view --log-failed` returns too much output or doesn't work, fall back to: - -```bash -gh run view "$FAILED_RUN" --json jobs --jq '.jobs[] | select(.conclusion == "failure") | {name, steps: [.steps[] | select(.conclusion == "failure") | {name, conclusion}]}' -``` - -### 3b. Fix Issues - -Based on the failure diagnosis: - -1. **Read the error output** from the failed checks -2. **Identify the root cause** (build error, lint error, test failure, type error, etc.) -3. **Apply fixes** using the Edit tool -4. **Delegate to specialized skills when appropriate**: - - **Lint errors**: Run `/fix-lint` skill - - **Build errors**: Run `/fix-build` skill - - **Test failures**: Read failing test, fix the code or test manually - -### 3c. Run Typecheck - -After applying fixes, verify types are clean: - -```bash -pn typecheck 2>&1 -``` - -- If typecheck passes: continue to 3d -- If typecheck fails: fix type errors and re-run (up to 3 attempts within this step) - - Read the error output - - Fix the type errors - - Re-run `pn typecheck` -- If still failing after 3 attempts: move on to 3d anyway (CI will catch remaining issues) - -### 3d. Run Code Review (Quick Round) - -Run **only** `/review-code` to catch any issues introduced by the fixes. Do NOT run `/review-correctness`, `/review-via-codex`, `/review-algorithm-architecture`, or any other review skill — only `/review-code`: - -``` -Use the Skill tool: skill: "review-code" -``` - -After review completes: - -- If **Critical or High** issues found: fix them using the Edit tool, then re-run `pn typecheck` to verify the review fixes don't introduce type errors -- If only **Medium or Low** issues: acceptable, continue -- If **no issues**: continue - -### 3e. Update PR - -Commit and push only if there are actual changes: - -```bash -# Check if there are changes to commit -if [ -n "$(git status --porcelain)" ]; then - git add -A && git commit -m "fix: address CI failures and review feedback" - git push --force-with-lease -else - echo "NO_CHANGES" -fi -``` - -- If `NO_CHANGES`: No fixes were needed/possible. Exit the fix loop and proceed to Step 4. -- If changes were pushed: continue to 3f. - -### 3f. Wait for PR Checks Again - -Use the **Check Polling Procedure** (defined below) to wait for CI checks. Based on the result: - -- `PASSED` → Exit fix loop, proceed to Step 4 -- `FAILED` → Next iteration of fix loop (back to 3a) -- `NO_CHECKS` → Exit fix loop, proceed to Step 4 - -If max 3 fix iterations reached, proceed to Step 4 anyway. - ---- - -## Step 4: Merge with --admin - -Merge the PR using admin override with squash merge. - -**IMPORTANT: Worktree detection.** When running from a git worktree, `--delete-branch` will fail because `gh` tries to checkout the default branch locally, but it's already checked out in the main worktree. Detect this and handle accordingly: - -```bash -# Check if we're in a worktree (not the main working tree) -IS_WORKTREE=false -if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then - IS_WORKTREE=true -fi - -BRANCH=$(git branch --show-current) - -if [ "$IS_WORKTREE" = "true" ]; then - # In a worktree: merge WITHOUT --delete-branch, then delete remote branch separately - gh pr merge --admin --squash - # Delete the remote branch manually (local cleanup happens when worktree is removed) - git push origin --delete "$BRANCH" 2>/dev/null || true -else - # Normal repo: use --delete-branch as usual - gh pr merge --admin --squash --delete-branch -fi -``` - -- `--admin` bypasses branch protection rules (required reviews, status checks) -- `--squash` squashes all commits into one clean commit -- `--delete-branch` cleans up the feature branch after merge (skipped in worktrees to avoid checkout conflict) - -If merge fails: - -1. Check the error message -2. If the error contains `'master' is already used by worktree` or similar: retry without `--delete-branch` and delete the remote branch manually with `git push origin --delete ` -3. If merge conflicts: use Skill tool with `skill: "resolve-conflicts"`, push, then retry merge once -4. If other error: report the full error to the user - ---- - -## Check Polling Procedure - -This is the shared polling logic used by Step 2 and Step 3f. **Do NOT run this as a single long bash command** — the Bash tool will timeout. Instead, run each poll as a **separate Bash call**. - -**IMPORTANT: Minimize token waste.** Do NOT add verbose commentary between polls. Just run the wait, run the check, and act on the result. No cheerful status messages, no "patience is key", no filler text. - -### Initial wait - -After a push, CI takes time to run. Wait **1 minute** before the first poll: - -```bash -echo "Waiting 1 minute for CI checks to run..." && sleep 60 -``` - -**IMPORTANT**: You MUST actually execute the `sleep 60` command and wait for it to complete. Do NOT skip the sleep or claim you waited without running the command. The sleep ensures CI has time to finish. - -**CRITICAL**: When calling the Bash tool for `sleep 60`, you MUST set `timeout: 120000` (2 minutes) on the Bash tool call. Similarly, for `sleep 60` poll interval calls, set `timeout: 120000` (2 minutes). - -### Poll loop (run each poll as a separate Bash call) - -For each poll attempt (1 through 5): - -```bash -CHECKS=$(gh pr checks --json name,state,bucket 2>&1) -echo "$CHECKS" - -# Parse results using 'bucket' field (pass, fail, pending, skipping, cancel) -if echo "$CHECKS" | jq -e 'length == 0' >/dev/null 2>&1; then - echo "RESULT:NO_CHECKS" -elif echo "$CHECKS" | jq -e '[.[].bucket] | all(. == "pass" or . == "skipping")' >/dev/null 2>&1; then - echo "RESULT:PASSED" -elif echo "$CHECKS" | jq -e '[.[].bucket] | any(. == "fail" or . == "cancel")' >/dev/null 2>&1; then - echo "RESULT:FAILED" -else - echo "RESULT:PENDING" -fi -``` - -### Decision logic after each poll - -- `RESULT:PASSED` → Return `PASSED`. Stop polling. -- `RESULT:FAILED` → Return `FAILED`. Stop polling. -- `RESULT:PENDING` → Wait **1 minute**, then poll again (use `timeout: 120000` on the Bash tool call): - ```bash - sleep 60 - ``` -- `RESULT:NO_CHECKS` → Return `NO_CHECKS` (no CI configured). - -### After 5 polls (timeout) - -If checks are still pending after 5 polls (~6 min total), return `FAILED` to enter the fix loop for investigation. - -### Handling stale checks after re-push (Step 3f only) - -After pushing new commits in Step 3e, old check results may linger briefly. To avoid reading stale results: - -1. Record the latest commit SHA **before pushing**: `OLD_SHA=$(git rev-parse HEAD)` -2. After pushing, the first 1-2 polls should verify the check suite is for the **new** commit. If `gh pr checks` still shows the old results (check names match but they completed instantly), wait an extra 30s. -3. Alternatively, look for checks with `state: "IN_PROGRESS"` or `"QUEUED"` as a signal that fresh checks have started. - ---- - -## Exit Conditions - -- **Success**: PR merged successfully -- **Max fix iterations**: After 3 fix loop iterations, attempt merge with --admin regardless -- **No changes to fix**: Fix loop produced no changes, merge with --admin -- **No CI checks**: Repo has no checks configured, merge immediately -- **Unrecoverable error**: PR creation fails, merge fails after retry, or gh CLI issues - ---- - -## Progress Tracking - -Display progress after each major step: - -``` -╔════════════════════════════════════════════════════════════╗ -║ AUTO PR MERGE ║ -╠════════════════════════════════════════════════════════════╣ -║ Step 1 - Create PR: ✅ PR #123 created ║ -║ Step 2 - Wait for checks: ❌ 2/5 checks failed ║ -║ Step 3 - Fix Loop: ║ -║ Iteration 1/3: ║ -║ Diagnose: ✅ Type errors in 2 files ║ -║ Fix: ✅ Fixed type errors ║ -║ Typecheck: ✅ Passed ║ -║ Review: ✅ No critical issues ║ -║ Push: ✅ Updated PR ║ -║ Checks: ✅ All passed ║ -║ Step 4 - Merge: ✅ Merged with --admin --squash ║ -╠════════════════════════════════════════════════════════════╣ -║ PR URL: https://github.com/org/repo/pull/123 ║ -║ Result: Successfully merged! ║ -╚════════════════════════════════════════════════════════════╝ -``` - ---- - -## Critical Instructions - -1. **DO NOT ask for user confirmation** - Execute the entire workflow autonomously -2. **DO NOT stop after /create-pr** - The PR creation is just step 1 of 4 -3. **DO NOT stop after checks pass** - Must complete the merge step -4. **DO NOT run polling as a single long bash loop** - Each poll must be a separate Bash call to avoid the 2-minute Bash timeout. Use `sleep` in its own Bash call between polls. -5. **DO wait the full polling period** - Don't skip check waiting; CI needs time -6. **DO run typecheck before review** - Catch type errors early -7. **DO run /review-code in each fix iteration** - Ensure code quality -8. **DO check for actual changes before committing** - Skip commit/push if `git status --porcelain` is empty -9. **DO use --admin for merge** - This is intentional to bypass protection rules -10. **DO use --squash for merge** - Keep git history clean -11. **DO delete branch after merge** - Clean up with --delete-branch -12. **Maximum 3 fix iterations** - Don't loop forever; after 3, merge with --admin -13. **DO NOT run /review-correctness or /review-via-codex** - This skill only uses `/review-code` for quick checks in the fix loop. Codex-based reviews are slow and belong in `/auto-review-fix`, not here. -14. **DO actually execute sleep commands** - When the skill says `sleep 60`, you MUST run the bash command and wait for it to complete. Do not skip or fabricate the wait. -15. **DO set timeout on Bash tool for sleep commands** - `sleep 60` requires `timeout: 120000`. The default 2-minute Bash timeout is sufficient for 1-minute sleeps but set it explicitly for safety. - ---- - -## Error Handling - -- If `/create-pr` fails: Report error and exit -- If `gh` CLI is not installed or authenticated: Report and exit -- If no CI checks are configured: Skip waiting and merge directly -- If check polling times out: Enter fix loop to investigate -- If typecheck loops more than 3 times within a single fix iteration: Move on to review -- If fix loop produces no changes: Exit loop and merge with --admin -- If merge fails due to conflicts: Try `/resolve-conflicts`, push, then retry merge once -- If merge fails for other reasons: Report the error to the user with the full error output diff --git a/.agents/skills/auto-review-fix/SKILL.md b/.agents/skills/auto-review-fix/SKILL.md deleted file mode 100644 index 8edd67151b3..00000000000 --- a/.agents/skills/auto-review-fix/SKILL.md +++ /dev/null @@ -1,650 +0,0 @@ ---- -name: auto-review-fix -description: Automated iterative code review and fix loop with parallel review agents ---- - -# auto-review-fix - -Automated iterative code review and fix loop. Reviews all code changes on the current branch since it diverged from main, including uncommitted changes (staged and unstaged). Validates findings, fixes issues, and repeats until clean or max iterations reached. - -**IMPORTANT**: Execute this entire process autonomously without asking the user for confirmation at any step. Just do the iterations. - ---- - -## Process Overview - -Run up to **4 iterations** of the review-validate-fix cycle. Stop early ONLY when a **follow-up review confirms no issues to fix** remain. - -``` -┌─────────────────────────────────────────────────────────────┐ -│ ITERATION LOOP (max 4 rounds) │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 0. SETUP PHASE (first iteration only) │ │ -│ │ - Fetch diff and create 00-review-context.md │ │ -│ │ - Categorize files by review area │ │ -│ │ - Deduplicate file assignments │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ↓ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 1. PARALLEL REVIEW PHASE │ │ -│ │ - Spawn Task() subagents for each review area │ │ -│ │ - Each receives ONLY their relevant files │ │ -│ │ - All reference shared 00-review-context.md │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ↓ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 2. COMBINE & DEDUPLICATE │ │ -│ │ - Aggregate all findings │ │ -│ │ - Deduplicate across review areas │ │ -│ │ - Exclude previously-skipped issues │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ↓ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 3. VALIDATE PHASE (all severities) │ │ -│ │ - Validate all issues (Critical to Low) │ │ -│ │ - Skip issues already in "Skipped Issues" list │ │ -│ │ - Group by file, one agent per file │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ↓ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 4. FIX PHASE │ │ -│ │ - Group issues by file (up to 5 per agent) │ │ -│ │ - Each fix done via Task() with opus │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ↓ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 5. CHECK EXIT CONDITIONS │ │ -│ │ - Fixed issues? → MUST run another review │ │ -│ │ - Review shows no issues? → EXIT │ │ -│ │ - Iteration 4 reached? → EXIT │ │ -│ └─────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` - ---- - -## Phase 0: Setup (First Iteration Only) - -### Step 1: Fetch the diff - -```bash -# Get the merge base (where the branch diverged from main) -git diff $(git merge-base origin/main HEAD) -``` - -### Step 1.5: Check diff size - -```bash -# Count changed files -git diff $(git merge-base origin/main HEAD) --stat | tail -1 -# If >500 files changed, exit with recommendation to split PR -``` - -If the diff contains more than 500 changed files, **EXIT immediately** with: - -``` -❌ Diff too large (>500 files). Please split this PR into smaller, focused changes. -``` - -### Step 2: Create shared context file - -Create `00-review-context.md` in the working directory (see below for format). - -### Step 2.5: Extract changed line ranges - -Parse `git diff $(git merge-base origin/main HEAD) --unified=0` to extract line ranges (`@@ +A,B @@` → lines A to A+B-1). Add to context file below. - -### Step 2 (continued): Context file format - -Create `00-review-context.md` in the working directory with: - -```markdown -# Review Context - -## Branch Info - -- Base: origin/main -- Current: [branch name] - -## Changed Files Summary - -[List all changed files with their change type: A/M/D] - -## Changed Line Ranges (PR Scope) - - - -| File | Changed Lines | -| ------------------ | ---------------- | -| [path/to/file1.ts] | [45-67, 120-135] | -| [path/to/file2.ts] | [10-25] | - -## Review Standards Reference - -- Follow /review-code standards -- Focus on: correctness, security, performance, maintainability -- Priority levels: Critical > High > Medium > Low - -## File Categories - -[Categorized list - see below] - -## Skipped Issues (Do Not Re-validate) - - - - - -[Initially empty - populated during validation phase] - -## Iteration State - - - -Current iteration: 1 -Last completed phase: Setup -Files fixed this iteration: [] -``` - -**IMPORTANT**: The "Skipped Issues" section persists across iterations. However, skips should be RARE - only purely cosmetic issues (naming, JSDoc, import ordering) may be skipped. Functional issues like error handling, type safety, and performance must ALWAYS be fixed. - -### Step 3: Categorize and deduplicate files - -Assign each file to **exactly ONE** file category (no duplicates): - -| Priority | Category | File Patterns | -| -------- | -------------- | ---------------------------------------------------------------- | -| 1 | Electron/Main | `src/main/`, `src/preload/`, `electron.*` | -| 2 | Backend/IPC | `*ipc*`, `*handler*`, `*service*` (in main process) | -| 3 | Frontend/UI | `src/renderer/`, `components/`, `*.tsx`, `*.css` | -| 4 | Config/Build | `*.config.*`, `package.json`, `tsconfig.*`, `electron-builder.*` | -| 5 | Utility/Common | Everything else | - -**Deduplication rule**: A file belongs to the FIRST matching category only. - -**Tiebreaker rule**: If a file path matches multiple categories at different directory depths: - -1. Use the **deepest matching directory** (e.g., `src/main/services/ui/dialog.ts` → Backend/IPC because `services/` is deeper than `main/`) -2. If same depth, use priority order (lower number wins) - -### Step 4: Map categories to review commands - -Each file category gets reviewed by multiple specialized review commands: - -| Category | Review Commands | -| -------------- | ------------------------------------------------ | -| Electron/Main | `/review-code`, `/review-algorithm-architecture` | -| Backend/IPC | `/review-code`, `/review-algorithm-architecture` | -| Frontend/UI | `/review-code`, `/review-algorithm-architecture` | -| Config/Build | `/review-code` | -| Utility/Common | `/review-code`, `/review-algorithm-architecture` | - -**Total review agents**: (Categories with files) × (Applicable review commands per category) - ---- - -## Phase 1: Parallel Review - -Spawn review subagents using the Task tool. **CRITICAL**: Use the exact Task() definition format. - -### Review Command Descriptions - -| Command | Focus | Engine | -| -------------------------------- | ------------------------------------------------------------------------ | ------ | -| `/review-code` | Logical bugs, security issues, TypeScript best practices, error handling | Claude | -| `/review-algorithm-architecture` | File organization, module boundaries, performance on hot paths | Claude | - -### Spawn agents by category × review command - -For each (category, review_command) pair where files exist, spawn a Task: - -``` -Task( - subagent_type: "general-purpose", - model: "opus", // REQUIRED: opus for quality reviews - description: "[REVIEW_COMMAND] for [CATEGORY] files", - prompt: """ - Read 00-review-context.md for branch context and changed line ranges. - - You are running [REVIEW_COMMAND] on [CATEGORY] files. - - Review ONLY these files: - [SPECIFIC FILE LIST FOR THIS CATEGORY] - - **SCOPE RESTRICTION**: - Report issues on changed lines OR caused by the changes (e.g., broken callers, type mismatches). - Do NOT report pre-existing issues unrelated to this PR. - For issues outside changed lines, include a "Causal Link" explaining the connection. - - Changed line ranges: [FROM 00-review-context.md] - - Follow the [REVIEW_COMMAND] standards exactly. Focus on issues relevant to that review type. - - Output format for each finding: - - File: path/to/file.ts - - Line: 42 - - Severity: Critical|High|Medium|Low - - Review Type: [REVIEW_COMMAND] - - Causal Link: [required if outside changed lines] - - Issue: [description] - - Fix: [suggested fix] - """ -) -``` - -### Example agent spawning for a PR with Electron, Backend, and Frontend files: - -``` -# Electron/Main files → 2 agents -Task(..., description: "/review-code for Electron/Main files", ...) -Task(..., description: "/review-algorithm-architecture for Electron/Main files", ...) - -# Backend/IPC files → 2 agents -Task(..., description: "/review-code for Backend/IPC files", ...) -Task(..., description: "/review-algorithm-architecture for Backend/IPC files", ...) - -# Frontend/UI files → 2 agents -Task(..., description: "/review-code for Frontend/UI files", ...) -Task(..., description: "/review-algorithm-architecture for Frontend/UI files", ...) - -Total: 6 parallel review agents -``` - -**Launch ALL Task() calls in a single message block for true parallelism.** - -**IMPORTANT — WAIT FOR ALL AGENTS**: - -- Do NOT use `run_in_background: true` for any Phase 1 review agent. -- Task() calls without `run_in_background` are blocking by default — they return only when the subagent finishes. -- **VERIFICATION CHECKPOINT**: Before starting Phase 2, you MUST count the number of Task() results you received. It MUST equal the total number of Task() calls you made. If any result is missing, DO NOT proceed — wait or re-spawn the missing agent. - ---- - -## Phase 2: Combine & Deduplicate Results - -**⛔ GATE CHECK**: Before starting Phase 2, verify you received results from ALL Task() agents spawned in Phase 1. Count: [N agents] = [total]. If any agent result is missing, DO NOT proceed. Log which agent(s) are missing and note it in the final report. - -1. **Collect all subagent outputs** (from all review types) - -2. **Scope filter**: Discard findings outside changed lines that lack a Causal Link. Pass others to validation. - -3. **Deduplicate findings** across review types and categories: - - Same file + same line range (within 5 lines) + similar issue = duplicate - - Keep the more specific/actionable finding - - Merge severity (take highest) - - Preserve the review type that caught the issue - -4. **Exclude previously-skipped issues**: - - Read the "Skipped Issues" section from `00-review-context.md` - - For each new finding, check if it matches a skipped issue (same file + overlapping line range + similar description) - - If match found → exclude from validation (already determined not worth fixing) - - This prevents re-validating the same low-priority issues every iteration - -5. **Categorize remaining issues**: - - 🔴 **Critical** - Must fix - - 🟠 **High** - Should fix - - 🟡 **Medium** - Consider fixing - - 🟢 **Low** - Nice to have - -6. **Create findings report** with all non-skipped issues (all severities), including which review type found each issue - -7. **Severity escalation** (iterations 2+): - - If an issue was marked "✅ Fix" in iteration N but reappears in iteration N+1: - - Escalate severity by one level: Low → Medium → High → Critical - - Add note: "⚠️ Persisted from iteration N - previous fix may have been incomplete" - - This ensures recurring issues get higher priority attention - ---- - -## Phase 3: Validation (All Severities) - -Validate all issues that weren't excluded by the "Skipped Issues" list. - -### Grouping strategy: - -- Group findings by file -- One validation agent per file (handles all findings for that file) -- Maximum 10 validation agents total - -``` -Task( - subagent_type: "general-purpose", - model: "opus", // REQUIRED: opus for accurate validation - description: "Validate findings in [filename]", - prompt: """ - Validate these findings for [FILE PATH]: - - [LIST OF 1-N FINDINGS FOR THIS FILE] - - **Changed line ranges**: [FROM 00-review-context.md] - - **SCOPE CHECK (first step)**: Issues on changed lines are in scope. For issues outside changed lines, validate the Causal Link - reject as 🚫 Out of Scope if it's just pre-existing tech debt unrelated to PR changes. - - For each IN-SCOPE finding: - 1. Read the actual code at the reported location - 2. Verify the issue actually exists - 3. Check if the suggested fix is correct - 4. Determine if it's worth fixing - - Mark each as: - - ✅ **Fix** - Valid issue in changed code (default - when in doubt, fix it) - - ⏭️ **Skip** - Valid but genuinely not worth fixing (RARE - see strict criteria below) - - ❌ **False Positive** - Issue doesn't actually exist - - 🚫 **Out of Scope** - Issue exists but is in pre-existing code not changed by this PR - - **STRICT Skip Criteria** - Only skip if ALL of these are true: - 1. The issue is purely cosmetic/stylistic with zero functional impact - 2. Fixing it would require changing >50 lines of unrelated code - 3. The pattern is consistently used throughout the codebase (not just this file) - - **DO NOT SKIP these issues (always fix):** - - ❌ Silent error swallowing or empty catch blocks - - ❌ Type coercion hacks (e.g., `as unknown as X`, `as any`) - - ❌ Missing error handling or error messages - - ❌ Redundant code, dead code, or unused variables - - ❌ Security issues of any severity - - ❌ Race conditions or async issues - - ❌ Memory leaks or resource cleanup issues - - ❌ Any issue in NEW code (code added in this PR) - - ❌ IPC security issues (missing validation, exposed handlers) - - Examples of what CAN be skipped: - - Pure naming preferences when existing codebase uses the same style - - Adding JSDoc to functions that are already self-documenting - - Reordering imports when not violating any lint rules - - For each Skip decision, provide: - - File and line range - - Severity level - - Reason for skipping (must match one of the allowed skip criteria above) - - Brief issue summary - """ -) -``` - -### After validation: Update Skipped Issues - -For any issues marked as ⏭️ **Skip**, append them to the "Skipped Issues" section in `00-review-context.md`: - -```markdown -## Skipped Issues (Do Not Re-validate) - - - -src/utils/helper.ts:42-45 | Low | Stylistic preference | Variable naming convention -``` - -**This ensures the same issues are not re-validated in subsequent iterations.** - -**Out of Scope** issues are discarded (not tracked). Issues outside changed lines CAN be in scope if caused by PR changes. - -### Early exit check - -After validation completes, check if there are any actionable issues: - -``` -IF (all_issues_marked_FP_or_Skip): - → Update Skipped Issues list in 00-review-context.md - → Update Iteration State: "Last completed phase: Validation (no actionable issues)" - → EXIT - No actionable issues found - → Do NOT proceed to Phase 4 -``` - -This prevents spawning fix agents when there's nothing to fix. - ---- - -## Phase 4: Fix - -**CRITICAL**: All fixes MUST be done via Task() subagents with opus. No direct edits. - -### Step 1: Commit uncommitted changes first - -```bash -git status --porcelain -# If output exists: -git add -A && git commit -m "WIP: Changes before auto-review fixes" -``` - -### Step 2: Build the fix manifest from validation output - -**MANDATORY**: Create an explicit mapping from validation results to fix actions. - -For every issue marked "✅ Fix" in validation: - -1. Record: `[file] → [issue description] → [suggested fix]` -2. Group by file - -**Traceability requirement**: Every "✅ Fix" issue MUST appear in the fix manifest. Do not: - -- Skip issues because you believe a different fix "solves the root cause" -- Substitute your judgment for what validation explicitly said to fix -- Rationalize away issues as "will be handled by another fix" - -If validation says "✅ Fix" for file X, spawn a fix agent for file X. Period. - -### Step 3: Group validated issues by file - -- Group up to **5 issues per fix agent** (within same file) -- If a file has >5 issues, split into multiple agents -- **Maximum 10 fix agents** per iteration -- If >10 files need fixes, prioritize by severity (Critical > High > Medium > Low) -- Defer lower-priority files to the next iteration - -### Conflict prevention - -To avoid race conditions when multiple agents edit overlapping code: - -- Each file should have **at most ONE fix agent running at a time** -- If a file has >5 issues requiring multiple agents, spawn them **sequentially** (not in parallel) -- After each fix agent completes for a multi-agent file, verify the file is syntactically valid before spawning the next -- Update Iteration State after each agent completes: `Files fixed this iteration: [updated list]` - -### Step 4: Spawn fix subagents - -``` -Task( - subagent_type: "general-purpose", - model: "opus", // REQUIRED: opus for quality fixes - description: "Fix issues in [filename]", - prompt: """ - Fix the following validated issues in [FILE PATH]: - - Issue 1: [description] - - Line: XX - - Fix: [suggested fix] - - Issue 2: [description] - - Line: YY - - Fix: [suggested fix] - - [Up to 5 issues] - - Instructions: - 1. Read the file first - 2. Apply each fix using the Edit tool - 3. Verify fixes don't break syntax - 4. Report what was fixed - """ -) -``` - -### Step 5: Track changes - -Record all files modified for the iteration summary. - -### Step 5.5: Verify fixes don't break build - -After all fix agents complete, run a type check: - -```bash -npm run typecheck 2>&1 -``` - -If the type check fails: - -- Log which fix agent(s) likely caused the failure -- Continue to Phase 5 - the next iteration's review will catch the type errors as new issues -- Update Iteration State: `Build status: FAILED (type errors in [files])` - -### Step 6: Verify fix manifest completeness - -Before proceeding to Phase 5, verify: - -- Every file in the fix manifest had a fix agent spawned -- Every "✅ Fix" issue from validation was addressed by a fix agent -- No issues were skipped due to assumptions about "root cause" fixes elsewhere - -If any "✅ Fix" issue was not addressed, spawn additional fix agents now. - ---- - -## Phase 5: Exit Conditions - -**CRITICAL LOGIC**: You cannot exit with "no issues" immediately after fixing. Must confirm with another review. - -``` -IF (fixes_applied_this_iteration > 0): - → MUST run another iteration to confirm fixes work - → Cannot claim "no issues" without verification - -IF (review_found_no_issues_to_fix AND fixes_applied_this_iteration == 0): - → EXIT - Clean (all new findings either fixed or added to Skipped Issues) - -IF (iteration >= 4): - → EXIT - Max iterations reached - -IF (same_issues_persist_for_2_iterations): - → EXIT - Stuck in loop - -OTHERWISE: - → Continue to next iteration -``` - -**The key insight**: "I fixed all issues" ≠ "There are no issues". Another review round must confirm. - -**Note on convergence**: The loop converges because: - -1. Fixed issues no longer appear in subsequent reviews -2. Skipped issues are tracked and excluded from future validation -3. Only genuinely new issues trigger additional work - ---- - -## Iteration Tracking - -Display progress after each phase: - -``` -╔════════════════════════════════════════════════════════════╗ -║ ITERATION 1/4 ║ -╠════════════════════════════════════════════════════════════╣ -║ Phase 0 - Setup: ✅ Context file created ║ -║ Phase 1 - Review: ✅ 6 agents across 2 review types ║ -║ /review-code: 3 agents (all categories) ║ -║ /review-algorithm-architecture: 3 agents ║ -║ Phase 2 - Combine: ✅ 12 findings (3 out-of-scope, 0 skipped) ║ -║ Phase 3 - Validate: ✅ 8 fix, 2 skip, 1 out-of-scope, 1 FP ║ -║ Phase 4 - Fix: ✅ 8 issues fixed (opus) ║ -║ Phase 5 - Check: 🔁 Fixes applied → verify next round ║ -║ Skipped Issues: 📝 2 added to tracking list ║ -╚════════════════════════════════════════════════════════════╝ -→ Starting iteration 2 to verify fixes... -``` - ---- - -## Final Report - -``` -╔════════════════════════════════════════════════════════════╗ -║ AUTO REVIEW-FIX COMPLETE ║ -╠════════════════════════════════════════════════════════════╣ -║ Iterations completed: X/4 ║ -║ Exit reason: [Clean review | Max iterations | Loop stuck] ║ -║ Total issues found: XX ║ -║ Out of scope: XX (pre-existing code, not in PR) ║ -║ Issues fixed: XX ║ -║ Issues skipped: XX (tracked, won't revalidate) ║ -║ Issues remaining: XX ║ -╠════════════════════════════════════════════════════════════╣ -║ Review agents by type: ║ -║ /review-code: X agents ║ -║ /review-algorithm-architecture: X agents ║ -║ Fix agents used: X (opus) ║ -╠════════════════════════════════════════════════════════════╣ -║ Issues found by review type: ║ -║ /review-code: XX issues ║ -║ /review-algorithm-architecture: XX issues ║ -╠════════════════════════════════════════════════════════════╣ -║ Files modified: ║ -║ - path/to/file1.ts ║ -║ - path/to/file2.ts ║ -╠════════════════════════════════════════════════════════════╣ -║ Skipped issues (should be RARE - cosmetic only): ║ -║ ⏭️ [file:line] Reason - Description ║ -╠════════════════════════════════════════════════════════════╣ -║ Remaining issues (if any): ║ -║ 🔴 [file:line] Description ║ -╚════════════════════════════════════════════════════════════╝ -``` - ---- - -## Cleanup - -After generating the final report, clean up the context file: - -```bash -# Create .context directory if it doesn't exist -mkdir -p .context - -# Archive the context file with timestamp for debugging/audit -mv 00-review-context.md .context/auto-review-$(date +%Y%m%d-%H%M%S).md - -# Optional: Keep only the last 5 archived reviews to prevent accumulation -ls -t .context/auto-review-*.md 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null -``` - -**Important**: Clean up even on error - wrap the cleanup in the error handling flow. - ---- - -## Critical Instructions - -1. **DO NOT ask for user confirmation** - Execute autonomously -2. **DO NOT skip the verification review** - After fixes, another review MUST confirm -3. **DO NOT skip validated issues** - Every "✅ Fix" from validation MUST have a fix agent spawned for that file. No exceptions, no rationalization. -4. **DO use Task() for ALL review/validate/fix work** - Never do these inline -5. **DO use opus for all agents** - Quality matters -6. **DO pass only relevant files to each area** - Never the full diff -7. **DO deduplicate files across areas** - Each file to one area only -8. **DO validate all severities** - But skip issues already in "Skipped Issues" list -9. **DO update Skipped Issues** - When validator says "not worth fixing", add to list -10. **DO group fixes by file** - Up to 5 issues per fix agent -11. **DO clean up 00-review-context.md** when done -12. **BE STRICT about skipping** - Skip should be RARE. If in doubt, fix it. Silent error swallowing, type hacks, and redundant code should NEVER be skipped. -13. **DO set agent timeouts** - Review agents: 7min, Validation agents: 3min, Fix agents: 5min. If an agent times out, log the timeout and continue. -14. **DO update Iteration State** - After each phase completes, update the Iteration State section in 00-review-context.md to enable crash recovery - ---- - -## Token Efficiency Summary - -| Before | After | Improvement | -| -------------------------- | ----------------------------------- | --------------------------- | -| Full diff to all agents | Relevant files only per category | ~6x reduction | -| Generic review for all | Specialized review commands | Better issue coverage | -| Re-validate skipped issues | Track in Skipped Issues list | ~2x reduction per iteration | -| 1 agent per finding | 1 agent per file (up to 5 issues) | ~5x reduction | -| No deduplication | Files assigned to one category only | ~2x reduction | - ---- - -## Error Handling - -- If a subagent fails, log the error and continue with others -- If the diff is too large (>500 files), split into batches -- If fixes cause build failures, revert and report -- If stuck in a loop (same issues persist for 2 iterations), exit with report -- Clean up 00-review-context.md even on error diff --git a/.agents/skills/auto-submit/SKILL.md b/.agents/skills/auto-submit/SKILL.md deleted file mode 100644 index fb32f05eac8..00000000000 --- a/.agents/skills/auto-submit/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: auto-submit -description: End-to-end autonomous pipeline that runs auto-review-fix, then auto-pr-merge ---- - -# auto-submit - -Autonomous pipeline: review+fix code, then create PR and merge. **Execute without user confirmation.** - -## Steps - -### 1. Auto Review-Fix - -**IMPORTANT**: Run auto-review-fix as a sub-agent (not an inline skill) to ensure it gets its own isolated context window. This prevents instruction dilution and ensures fix phases properly spawn their own Task() sub-agents as required. - -``` -Agent( - subagent_type: "general-purpose", - description: "Run auto-review-fix", - prompt: "Run the /auto-review-fix skill. Follow ALL instructions exactly, especially: all fixes MUST be done via Task() subagents with opus-4-5. No direct edits." -) -``` - -Wait for the agent to complete. Then commit any changes: - -```bash -if [ -n "$(git status --porcelain)" ]; then - git add -A && git commit -m "fix: address auto-review findings" -fi -``` - -**Continue to Step 2 — do not stop here.** - -### 2. Auto PR-Merge - -``` -Use the Skill tool: skill: "auto-pr-merge" -``` - -Handles PR creation, CI polling, fix loops, and merge with `--admin --squash`. - -## Rules - -- Execute autonomously — no user confirmation -- Both steps run sequentially — do not stop between them -- Commit changes between steps -- If Step 1 fails catastrophically, stop (don't create a broken PR) -- If Step 2 fails, report the error with PR URL if available diff --git a/.agents/skills/electron/SKILL.md b/.agents/skills/electron/SKILL.md deleted file mode 100644 index 43756b4e794..00000000000 --- a/.agents/skills/electron/SKILL.md +++ /dev/null @@ -1,310 +0,0 @@ ---- -name: electron -description: Launch, automate, and validate Electron desktop apps using playwright-cli via Chrome DevTools Protocol. Use this skill to validate UI changes in Orca, test features in the running Electron app, verify code fixes work end-to-end, or automate any Electron app (VS Code, Slack, Discord, etc.). Triggers include "validate in Electron", "test in the app", "verify the fix", "check the UI", "/electron", "automate Slack app", "control VS Code", or any task requiring interaction with a running Electron application. -allowed-tools: Bash(playwright-cli:*), Bash(npx playwright-cli:*), Bash(curl:*), Bash(lsof:*), Bash(open:*), Bash(ps:*), Bash(kill:*), Bash(node:*), Bash(pnpm:*), Read, Grep, Monitor ---- - -# Electron App Automation - -Automate any Electron desktop app using playwright-cli's CDP attach mode. Electron apps are built on Chromium and expose a Chrome DevTools Protocol (CDP) port that playwright-cli can connect to, enabling the same snapshot-interact workflow used for web pages. - -## Critical Safety Rule: Never Kill Processes You Didn't Start - -**You may be running inside an Electron app (e.g., Orca).** Killing the wrong process will terminate your own session. - -- **NEVER** run `killall Electron`, `pkill Electron`, or any broad process-killing command. -- **NEVER** kill a process unless you launched it yourself in this session and you recorded its PID. -- Before killing, **always verify** the PID belongs to the process you started — check the command line includes the workspace path or args you used to launch it. -- When quitting apps to relaunch with `--remote-debugging-port`, use `osascript -e 'quit app "AppName"'` for named apps (Slack, VS Code, etc.) — **never for Orca or the app you're running inside**. -- If unsure whether a process is safe to kill, **ask the user**. - -## Core Workflow - -1. **Launch** the Electron app with remote debugging enabled (or find an already-running app with CDP) -2. **Attach** playwright-cli to the CDP endpoint -3. **Snapshot** to discover interactive elements -4. **Interact** using element refs -5. **Re-snapshot** after navigation or state changes - -```bash -# Launch an Electron app with remote debugging -open -a "Slack" --args --remote-debugging-port=9222 - -# Wait for the app to initialize -sleep 3 - -# Attach playwright-cli to the app via CDP -command playwright-cli attach --cdp="http://localhost:9222" - -# Standard workflow from here -command playwright-cli snapshot -command playwright-cli click e5 -command playwright-cli screenshot -``` - -## Always prefix with `command` - -Use `command playwright-cli …` to bypass shell aliases (e.g. `alias playwright-cli='playwright-cli --persistent'`) that leak flags into subcommands. Behaves identically when no alias is set. All examples below use this form. - -## Pick a free CDP port - -Port 9333 is a convention, not a guarantee — another worktree may hold it, and electron-vite will start without a debugger, letting you silently attach to the old app. - -```bash -for p in 9333 9334 9335 9336 9337 9338 9339 9340; do - if ! lsof -i :$p >/dev/null 2>&1; then PORT=$p; break; fi -done -# After attach, verify you hit the right worktree: -command playwright-cli eval "window.__store.getState().worktreesByRepo" -``` - -## Launching Orca Dev Build with CDP - -Orca uses electron-vite for dev builds. The correct way to launch with CDP: - -```bash -# Launch with remote debugging (PORT picked above) -node config/scripts/run-electron-vite-dev.mjs --remote-debugging-port=$PORT 2>&1 & - -# Wait for "DevTools listening on ws://..." in output, then attach -command playwright-cli attach --cdp="http://localhost:$PORT" -``` - -**Key details:** -- Pass `--remote-debugging-port=NNNN` directly to the script — do NOT use `pnpm run dev -- --` (the double `--` breaks Chromium flag parsing) -- electron-vite also supports `REMOTE_DEBUGGING_PORT` env var: `REMOTE_DEBUGGING_PORT=$PORT pnpm run dev` -- The Zustand store is exposed at `window.__store` — use `window.__store.getState()` and `window.__store.getState().someAction()` to read/mutate state -- Use port 9333 (not 9222) to avoid conflicts with other Electron apps - -### Accessing Orca State via eval - -```bash -# Read store state -command playwright-cli eval "(() => { const s = window.__store?.getState(); return JSON.stringify({ activeWorktreeId: s.activeWorktreeId, activeTabId: s.activeTabId, activeFileId: s.activeFileId, activeTabType: s.activeTabType }); })()" - -# Open an editor file -command playwright-cli eval "(() => { const s = window.__store?.getState(); const wtId = s.activeWorktreeId; s.openFile({ worktreeId: wtId, filePath: '/path/to/file', relativePath: 'file.ts', mode: 'edit', language: 'typescript' }); return 'done'; })()" - -# Close a file -command playwright-cli eval "(() => { window.__store.getState().closeFile('/path/to/file'); return 'closed'; })()" -``` - -## Launching Electron Apps with CDP - -Every Electron app supports the `--remote-debugging-port` flag since it's built into Chromium. - -### macOS - -```bash -# Slack -open -a "Slack" --args --remote-debugging-port=9222 - -# VS Code -open -a "Visual Studio Code" --args --remote-debugging-port=9223 - -# Discord -open -a "Discord" --args --remote-debugging-port=9224 - -# Figma -open -a "Figma" --args --remote-debugging-port=9225 - -# Notion -open -a "Notion" --args --remote-debugging-port=9226 - -# Spotify -open -a "Spotify" --args --remote-debugging-port=9227 -``` - -### Linux - -```bash -slack --remote-debugging-port=9222 -code --remote-debugging-port=9223 -discord --remote-debugging-port=9224 -``` - -### Windows - -```bash -"C:\Users\%USERNAME%\AppData\Local\slack\slack.exe" --remote-debugging-port=9222 -"C:\Users\%USERNAME%\AppData\Local\Programs\Microsoft VS Code\Code.exe" --remote-debugging-port=9223 -``` - -**Important:** If the app is already running, quit it first, then relaunch with the flag. The `--remote-debugging-port` flag must be present at launch time. - -## Connecting to an Already-Running App - -If an Electron app was already launched with `--remote-debugging-port`, you can attach directly: - -```bash -# Check what's listening on a port -lsof -i :9222 - -# Verify the CDP endpoint has targets -curl -s http://localhost:9222/json - -# Attach playwright-cli -command playwright-cli attach --cdp="http://localhost:9222" -``` - -## Attaching - -```bash -# Attach to a specific CDP port -command playwright-cli attach --cdp="http://localhost:9222" - -# Attach with a named session (for controlling multiple apps) -command playwright-cli -s=slack attach --cdp="http://localhost:9222" -command playwright-cli -s=vscode attach --cdp="http://localhost:9223" -``` - -After `attach`, all subsequent commands (in that session) target the connected app. - -## Tab Management - -Electron apps may have multiple windows or webviews. Use tab commands to list and switch between them: - -```bash -# List all available targets -command playwright-cli tab-list - -# Switch to a specific tab by index -command playwright-cli tab-select 2 -``` - -If `tab-list` doesn't show all targets, query the CDP endpoint directly to see everything: - -```bash -curl -s http://localhost:9222/json | python3 -c " -import sys, json -for i, t in enumerate(json.load(sys.stdin)): - print(f'[{i}] ({t[\"type\"]}) {t[\"title\"][:60]} - {t[\"url\"][:60]}') -" -``` - -## Common Patterns - -### Inspect and Navigate an App - -```bash -open -a "Slack" --args --remote-debugging-port=9222 -sleep 3 -command playwright-cli attach --cdp="http://localhost:9222" -command playwright-cli snapshot -# Read the snapshot output to identify UI elements -command playwright-cli click e10 # Navigate to a section -command playwright-cli snapshot # Re-snapshot after navigation -``` - -### Take Screenshots of Desktop Apps - -```bash -command playwright-cli attach --cdp="http://localhost:9222" -command playwright-cli screenshot -command playwright-cli screenshot e5 # Screenshot a specific element -command playwright-cli screenshot --filename=app-state.png -``` - -### Extract Data from a Desktop App - -```bash -command playwright-cli attach --cdp="http://localhost:9222" -command playwright-cli snapshot -command playwright-cli eval "document.title" -command playwright-cli eval "el => el.textContent" e5 -``` - -### Fill Forms in Desktop Apps - -```bash -command playwright-cli attach --cdp="http://localhost:9222" -command playwright-cli snapshot -command playwright-cli fill e3 "search query" -command playwright-cli press Enter -command playwright-cli snapshot -``` - -### Run Multiple Apps Simultaneously - -Use named sessions to control multiple Electron apps at the same time: - -```bash -# Attach to Slack -command playwright-cli -s=slack attach --cdp="http://localhost:9222" - -# Attach to VS Code -command playwright-cli -s=vscode attach --cdp="http://localhost:9223" - -# Interact with each independently -command playwright-cli -s=slack snapshot -command playwright-cli -s=vscode snapshot -``` - -### Run Custom Playwright Code - -For advanced scenarios, use `run-code` to execute arbitrary Playwright code: - -```bash -command playwright-cli run-code "async page => { - await page.waitForSelector('.loading', { state: 'hidden' }); - const items = await page.locator('.item').allTextContents(); - return items; -}" -``` - -## Troubleshooting - -### "Connection refused" or "Cannot connect" - -- Make sure the app was launched with `--remote-debugging-port=NNNN` -- If the app was already running, quit and relaunch with the flag -- Check that the port isn't in use by another process: `lsof -i :9222` - -### App launches but attach fails - -- Wait a few seconds after launch before attaching (`sleep 3`) -- Some apps take time to initialize their webview -- Verify the endpoint is responding: `curl -s http://localhost:9222/json` - -### Elements not appearing in snapshot - -- The app may use multiple webviews. Use `playwright-cli tab-list` to list targets and switch -- Use `curl -s http://localhost:/json` to see all CDP targets if tab-list shows fewer -- Try `playwright-cli snapshot` without flags first - -### Cannot type in input fields - -- Some Electron apps use custom input components -- Try `playwright-cli press` for keyboard events -- Use `playwright-cli run-code` for complex input scenarios - -### Stale element refs after interaction - -- Element refs change when the page state updates -- Always re-snapshot after clicking, navigating, or filling forms -- Use the new refs from the latest snapshot - -## Supported Apps - -Any app built on Electron works, including: - -- **Communication:** Slack, Discord, Microsoft Teams, Signal, Telegram Desktop -- **Development:** VS Code, GitHub Desktop, Postman, Insomnia -- **Design:** Figma, Notion, Obsidian -- **Media:** Spotify, Tidal -- **Productivity:** Todoist, Linear, 1Password - -If an app is built with Electron, it supports `--remote-debugging-port` and can be automated with playwright-cli. - -## Cleaning Up - -```bash -# Close the playwright-cli session (does NOT kill the Electron app) -command playwright-cli close - -# Close a named session -command playwright-cli -s=slack close - -# Close all playwright-cli sessions -command playwright-cli close-all -``` diff --git a/.agents/skills/react-useeffect/README.md b/.agents/skills/react-useeffect/README.md deleted file mode 100644 index 6c21cdd17ca..00000000000 --- a/.agents/skills/react-useeffect/README.md +++ /dev/null @@ -1,341 +0,0 @@ -# React useEffect Best Practices - -A comprehensive guide teaching when to use `useEffect` in React, and more importantly, when NOT to use it. This skill is based on official React documentation and provides practical alternatives to common useEffect anti-patterns. - -## Purpose - -Effects are an **escape hatch** from React's reactive paradigm. They let you synchronize with external systems like browser APIs, third-party widgets, or network requests. However, many developers overuse Effects for tasks that React handles better through other means. - -This skill helps you: - -- Identify when you truly need an Effect vs. when you don't -- Recognize common anti-patterns and their fixes -- Apply better alternatives like `useMemo`, `key` prop, and event handlers -- Write Effects that are clean, maintainable, and free from race conditions - -## When to Use This Skill - -Use this skill when you're: - -- Writing or reviewing `useEffect` code -- Using `useState` to store derived values -- Implementing data fetching or subscriptions -- Synchronizing state between components -- Facing bugs with stale data or race conditions -- Wondering if your Effect is necessary - -**Trigger phrases:** - -- "Should I use useEffect for this?" -- "How do I fix this useEffect?" -- "My Effect is causing too many re-renders" -- "Data fetching with useEffect" -- "Reset state when props change" -- "Derived state from props" - -## How It Works - -This skill provides guidance through three key resources: - -1. **Quick Reference Table** - Fast lookup for common scenarios with DO/DON'T patterns -2. **Decision Tree** - Visual flowchart to determine the right approach -3. **Detailed Anti-Patterns** - 9 common mistakes with explanations and fixes -4. **Better Alternatives** - 8 proven patterns to replace unnecessary Effects - -The skill teaches you to ask the right questions: - -- Is there an external system involved? -- Am I responding to a user event or component appearance? -- Can this value be calculated during render? -- Do I need to reset state when a prop changes? - -## Key Features - -### 1. Quick Reference Guide - -Visual table showing the DO/DON'T for common scenarios: - -- Derived state from props/state -- Expensive calculations -- Resetting state on prop change -- User event responses -- Notifying parent components -- Data fetching - -### 2. Decision Tree - -Clear flowchart that guides you from "Need to respond to something?" to the correct solution: - -- User interaction → Event handler -- Component appeared → Effect (for external sync/analytics) -- Derived value needed → Calculate during render (+ useMemo if expensive) -- Reset state on prop change → Key prop - -### 3. Anti-Pattern Recognition - -Detailed examples of 9 common mistakes: - -1. Redundant state for derived values -2. Filtering/transforming data in Effect -3. Resetting state on prop change -4. Event-specific logic in Effect -5. Chains of Effects -6. Notifying parent via Effect -7. Passing data up to parent -8. Fetching without cleanup (race conditions) -9. App initialization in Effect - -Each anti-pattern includes: - -- Bad example with explanation -- Good example with fix -- Why the anti-pattern is problematic - -### 4. Better Alternatives - -8 proven patterns to replace unnecessary Effects: - -1. Calculate during render for derived state -2. `useMemo` for expensive calculations -3. `key` prop to reset state -4. Store ID instead of object for stable references -5. Event handlers for user actions -6. `useSyncExternalStore` for external stores -7. Lifting state up for shared state -8. Custom hooks for data fetching with cleanup - -## Usage Examples - -### Example 1: Derived State - -**Bad - Unnecessary Effect:** - -```tsx -function Form() { - const [firstName, setFirstName] = useState('Taylor') - const [lastName, setLastName] = useState('Swift') - const [fullName, setFullName] = useState('') - - useEffect(() => { - setFullName(firstName + ' ' + lastName) - }, [firstName, lastName]) -} -``` - -**Good - Calculate during render:** - -```tsx -function Form() { - const [firstName, setFirstName] = useState('Taylor') - const [lastName, setLastName] = useState('Swift') - const fullName = firstName + ' ' + lastName // Just compute it -} -``` - -### Example 2: Resetting State - -**Bad - Effect to reset:** - -```tsx -function ProfilePage({ userId }) { - const [comment, setComment] = useState('') - - useEffect(() => { - setComment('') - }, [userId]) -} -``` - -**Good - Key prop:** - -```tsx -function ProfilePage({ userId }) { - return -} - -function Profile({ userId }) { - const [comment, setComment] = useState('') // Resets automatically -} -``` - -### Example 3: Data Fetching with Cleanup - -**Bad - Race condition:** - -```tsx -function SearchResults({ query }) { - const [results, setResults] = useState([]) - - useEffect(() => { - fetchResults(query).then((json) => { - setResults(json) // "hello" response may arrive after "hell" - }) - }, [query]) -} -``` - -**Good - Cleanup flag:** - -```tsx -function SearchResults({ query }) { - const [results, setResults] = useState([]) - - useEffect(() => { - let ignore = false - - fetchResults(query).then((json) => { - if (!ignore) setResults(json) - }) - - return () => { - ignore = true - } - }, [query]) -} -``` - -### Example 4: Event Handler Instead of Effect - -**Bad - Effect watching state:** - -```tsx -function ProductPage({ product, addToCart }) { - useEffect(() => { - if (product.isInCart) { - showNotification(`Added ${product.name}!`) - } - }, [product]) - - function handleBuyClick() { - addToCart(product) - } -} -``` - -**Good - Handle in event:** - -```tsx -function ProductPage({ product, addToCart }) { - function handleBuyClick() { - addToCart(product) - showNotification(`Added ${product.name}!`) - } -} -``` - -## When You DO Need Effects - -Effects are appropriate for: - -- **Synchronizing with external systems** - Browser APIs, third-party widgets, non-React code -- **Subscriptions** - WebSocket connections, global event listeners (prefer `useSyncExternalStore`) -- **Analytics/logging** - Code that needs to run because the component displayed -- **Data fetching** - With proper cleanup (or use your framework's built-in mechanism) - -## When You DON'T Need Effects - -Avoid Effects for: - -1. **Transforming data for rendering** - Calculate at the top level instead -2. **Handling user events** - Use event handlers where you know exactly what happened -3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName` -4. **Chaining state updates** - Calculate all next state in the event handler -5. **Notifying parent components** - Call the callback in the same event handler -6. **Resetting state** - Use the `key` prop to create a fresh component instance - -## Best Practices - -### 1. Start Without an Effect - -Before adding an Effect, ask: "Is there an external system involved?" If no, you probably don't need an Effect. - -### 2. Prefer Derived State - -If you can calculate a value from props or state, don't store it in state with an Effect updating it. - -### 3. Use the Right Tool - -- Expensive calculation → `useMemo` -- User interaction → Event handler -- Reset on prop change → `key` prop -- External subscription → `useSyncExternalStore` -- Shared state → Lift state up - -### 4. Always Clean Up - -If your Effect subscribes, fetches, or sets timers, return a cleanup function to prevent memory leaks and race conditions. - -### 5. Avoid Effect Chains - -Multiple Effects triggering each other causes unnecessary re-renders and makes code hard to follow. Calculate everything in one place (usually an event handler). - -### 6. Test in Strict Mode - -React 18+ Strict Mode mounts components twice in development to expose missing cleanup. If your Effect breaks, you need cleanup. - -### 7. Consider Framework Solutions - -For data fetching, prefer your framework's built-in solution (Next.js, Remix) or libraries (React Query, SWR) over manual Effects. - -## Reference Files - -This skill includes three detailed reference documents: - -1. **SKILL.md** - Quick reference table and decision tree -2. **anti-patterns.md** - 9 common mistakes with detailed explanations -3. **alternatives.md** - 8 better alternatives with code examples - -## Common Pitfalls - -### Multiple Re-renders - -**Symptom:** Component re-renders many times in quick succession. - -**Cause:** Effect that sets state based on state it depends on, creating a loop. - -**Fix:** Calculate the final value in an event handler or during render. - -### Stale Data - -**Symptom:** UI shows outdated values briefly before updating. - -**Cause:** Using Effect to update derived state causes an extra render pass. - -**Fix:** Calculate derived values during render instead of in state. - -### Race Conditions - -**Symptom:** Fast typing shows results for old queries after new ones. - -**Cause:** Missing cleanup in data fetching Effect. - -**Fix:** Use cleanup flag (`ignore` variable) or AbortController. - -### Runs Twice in Development - -**Symptom:** Effect runs twice on component mount in development. - -**Cause:** React 18 Strict Mode intentionally mounts components twice to expose bugs. - -**Fix:** Add proper cleanup. If it's app initialization that shouldn't run twice, use a module-level guard. - -## Resources - -This skill is based on: - -- [React Official Docs: You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) -- [React Official Docs: Synchronizing with Effects](https://react.dev/learn/synchronizing-with-effects) -- [React Official Docs: Lifecycle of Reactive Effects](https://react.dev/learn/lifecycle-of-reactive-effects) - -## Summary - -The golden rule: **Effects are an escape hatch from React.** If you're not synchronizing with an external system, you probably don't need an Effect. - -Before writing `useEffect`, ask yourself: - -1. Is this responding to a user interaction? → Use event handler -2. Is this a value I can calculate from props/state? → Calculate during render -3. Is this resetting state when a prop changes? → Use key prop -4. Is this synchronizing with an external system? → Use Effect with cleanup - -Follow these patterns, and your React code will be more maintainable, performant, and bug-free. diff --git a/.agents/skills/react-useeffect/SKILL.md b/.agents/skills/react-useeffect/SKILL.md deleted file mode 100644 index 83ef6e036e6..00000000000 --- a/.agents/skills/react-useeffect/SKILL.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -name: react-useeffect -description: React useEffect best practices from official docs. Use when writing/reviewing useEffect, useState for derived values, data fetching, or state synchronization. Teaches when NOT to use Effect and better alternatives. ---- - -# You Might Not Need an Effect - -Effects are an **escape hatch** from React. They let you synchronize with external systems. If there is no external system involved, you shouldn't need an Effect. - -## Quick Reference - -| Situation | DON'T | DO | -| ------------------------------ | ------------------------------ | ------------------------------------- | -| Derived state from props/state | `useState` + `useEffect` | Calculate during render | -| Expensive calculations | `useEffect` to cache | `useMemo` | -| Reset state on prop change | `useEffect` with `setState` | `key` prop | -| User event responses | `useEffect` watching state | Event handler directly | -| Notify parent of changes | `useEffect` calling `onChange` | Call in event handler | -| Fetch data | `useEffect` without cleanup | `useEffect` with cleanup OR framework | - -## When You DO Need Effects - -- Synchronizing with **external systems** (non-React widgets, browser APIs) -- **Subscriptions** to external stores (use `useSyncExternalStore` when possible) -- **Analytics/logging** that runs because component displayed -- **Data fetching** with proper cleanup (or use framework's built-in mechanism) - -## When You DON'T Need Effects - -1. **Transforming data for rendering** - Calculate at top level, re-runs automatically -2. **Handling user events** - Use event handlers, you know exactly what happened -3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName` -4. **Chaining state updates** - Calculate all next state in the event handler - -## Decision Tree - -``` -Need to respond to something? -├── User interaction (click, submit, drag)? -│ └── Use EVENT HANDLER -├── Component appeared on screen? -│ └── Use EFFECT (external sync, analytics) -├── Props/state changed and need derived value? -│ └── CALCULATE DURING RENDER -│ └── Expensive? Use useMemo -└── Need to reset state when prop changes? - └── Use KEY PROP on component -``` - -## Detailed Guidance - -- [Anti-Patterns](./anti-patterns.md) - Common mistakes with fixes -- [Better Alternatives](./alternatives.md) - useMemo, key prop, lifting state, useSyncExternalStore diff --git a/.agents/skills/react-useeffect/alternatives.md b/.agents/skills/react-useeffect/alternatives.md deleted file mode 100644 index 01fa6b6a4b3..00000000000 --- a/.agents/skills/react-useeffect/alternatives.md +++ /dev/null @@ -1,265 +0,0 @@ -# Better Alternatives to useEffect - -## 1. Calculate During Render (Derived State) - -For values derived from props or state, just compute them: - -```tsx -function Form() { - const [firstName, setFirstName] = useState('Taylor') - const [lastName, setLastName] = useState('Swift') - - // Runs every render - that's fine and intentional - const fullName = firstName + ' ' + lastName - const isValid = firstName.length > 0 && lastName.length > 0 -} -``` - -**When to use**: The value can be computed from existing props/state. - ---- - -## 2. useMemo for Expensive Calculations - -When computation is expensive, memoize it: - -```tsx -import { useMemo } from 'react' - -function TodoList({ todos, filter }) { - const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]) -} -``` - -**How to know if it's expensive**: - -```tsx -console.time('filter') -const visibleTodos = getFilteredTodos(todos, filter) -console.timeEnd('filter') -// If > 1ms, consider memoizing -``` - -**Note**: React Compiler can auto-memoize, reducing manual useMemo needs. - ---- - -## 3. Key Prop to Reset State - -To reset ALL state when a prop changes, use key: - -```tsx -// Parent passes userId as key -function ProfilePage({ userId }) { - return ( - - ) -} - -function Profile({ userId }) { - // All state here resets when userId changes - const [comment, setComment] = useState('') - const [likes, setLikes] = useState([]) -} -``` - -**When to use**: You want a "fresh start" when an identity prop changes. - ---- - -## 4. Store ID Instead of Object - -To preserve selection when list changes: - -```tsx -// BAD: Storing object that needs Effect to "adjust" -function List({ items }) { - const [selection, setSelection] = useState(null) - - useEffect(() => { - setSelection(null) // Reset when items change - }, [items]) -} - -// GOOD: Store ID, derive object -function List({ items }) { - const [selectedId, setSelectedId] = useState(null) - - // Derived - no Effect needed - const selection = items.find((item) => item.id === selectedId) ?? null -} -``` - -**Benefit**: If item with selectedId exists in new list, selection preserved. - ---- - -## 5. Event Handlers for User Actions - -User clicks/submits/drags should be handled in event handlers, not Effects: - -```tsx -// Event handler knows exactly what happened -function ProductPage({ product, addToCart }) { - function handleBuyClick() { - addToCart(product) - showNotification(`Added ${product.name}!`) - analytics.track('product_added', { id: product.id }) - } - - function handleCheckoutClick() { - addToCart(product) - showNotification(`Added ${product.name}!`) - navigateTo('/checkout') - } -} -``` - -**Shared logic**: Extract a function, call from both handlers: - -```tsx -function buyProduct() { - addToCart(product) - showNotification(`Added ${product.name}!`) -} - -function handleBuyClick() { - buyProduct() -} -function handleCheckoutClick() { - buyProduct() - navigateTo('/checkout') -} -``` - ---- - -## 6. useSyncExternalStore for External Stores - -For subscribing to external data (browser APIs, third-party stores): - -```tsx -// Instead of manual Effect subscription -function useOnlineStatus() { - const [isOnline, setIsOnline] = useState(true) - - useEffect(() => { - function update() { - setIsOnline(navigator.onLine) - } - window.addEventListener('online', update) - window.addEventListener('offline', update) - return () => { - window.removeEventListener('online', update) - window.removeEventListener('offline', update) - } - }, []) - - return isOnline -} - -// Use purpose-built hook -import { useSyncExternalStore } from 'react' - -function subscribe(callback) { - window.addEventListener('online', callback) - window.addEventListener('offline', callback) - return () => { - window.removeEventListener('online', callback) - window.removeEventListener('offline', callback) - } -} - -function useOnlineStatus() { - return useSyncExternalStore( - subscribe, - () => navigator.onLine, // Client value - () => true // Server value (SSR) - ) -} -``` - ---- - -## 7. Lifting State Up - -When two components need synchronized state, lift it to common ancestor: - -```tsx -// Instead of syncing via Effects between siblings -function Parent() { - const [value, setValue] = useState('') - - return ( - <> - - - - ) -} -``` - ---- - -## 8. Custom Hooks for Data Fetching - -Extract fetch logic with proper cleanup: - -```tsx -function useData(url) { - const [data, setData] = useState(null) - const [error, setError] = useState(null) - const [loading, setLoading] = useState(true) - - useEffect(() => { - let ignore = false - setLoading(true) - - fetch(url) - .then((res) => res.json()) - .then((json) => { - if (!ignore) { - setData(json) - setError(null) - } - }) - .catch((err) => { - if (!ignore) setError(err) - }) - .finally(() => { - if (!ignore) setLoading(false) - }) - - return () => { - ignore = true - } - }, [url]) - - return { data, error, loading } -} - -// Usage -function SearchResults({ query }) { - const { data, error, loading } = useData(`/api/search?q=${query}`) -} -``` - -**Better**: Use framework's data fetching (React Query, SWR, Next.js, etc.) - ---- - -## Summary: When to Use What - -| Need | Solution | -| ------------------------------ | ------------------------------------ | -| Value from props/state | Calculate during render | -| Expensive calculation | `useMemo` | -| Reset all state on prop change | `key` prop | -| Respond to user action | Event handler | -| Sync with external system | `useEffect` with cleanup | -| Subscribe to external store | `useSyncExternalStore` | -| Share state between components | Lift state up | -| Fetch data | Custom hook with cleanup / framework | diff --git a/.agents/skills/react-useeffect/anti-patterns.md b/.agents/skills/react-useeffect/anti-patterns.md deleted file mode 100644 index 2a05839f3b2..00000000000 --- a/.agents/skills/react-useeffect/anti-patterns.md +++ /dev/null @@ -1,289 +0,0 @@ -# useEffect Anti-Patterns - -## 1. Redundant State for Derived Values - -```tsx -// BAD: Extra state + Effect for derived value -function Form() { - const [firstName, setFirstName] = useState('Taylor') - const [lastName, setLastName] = useState('Swift') - const [fullName, setFullName] = useState('') - - useEffect(() => { - setFullName(firstName + ' ' + lastName) - }, [firstName, lastName]) -} - -// GOOD: Calculate during rendering -function Form() { - const [firstName, setFirstName] = useState('Taylor') - const [lastName, setLastName] = useState('Swift') - const fullName = firstName + ' ' + lastName // Just compute it -} -``` - -**Why it's bad**: Causes extra render pass with stale value, then re-renders with updated value. - ---- - -## 2. Filtering/Transforming Data in Effect - -```tsx -// BAD: Effect to filter list -function TodoList({ todos, filter }) { - const [visibleTodos, setVisibleTodos] = useState([]) - - useEffect(() => { - setVisibleTodos(getFilteredTodos(todos, filter)) - }, [todos, filter]) -} - -// GOOD: Filter during render (memoize if expensive) -function TodoList({ todos, filter }) { - const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]) -} -``` - ---- - -## 3. Resetting State on Prop Change - -```tsx -// BAD: Effect to reset state -function ProfilePage({ userId }) { - const [comment, setComment] = useState('') - - useEffect(() => { - setComment('') - }, [userId]) -} - -// GOOD: Use key prop -function ProfilePage({ userId }) { - return -} - -function Profile({ userId }) { - const [comment, setComment] = useState('') // Resets automatically -} -``` - -**Why key works**: React treats components with different keys as different components, recreating state. - ---- - -## 4. Event-Specific Logic in Effect - -```tsx -// BAD: Effect for button click result -function ProductPage({ product, addToCart }) { - useEffect(() => { - if (product.isInCart) { - showNotification(`Added ${product.name}!`) - } - }, [product]) - - function handleBuyClick() { - addToCart(product) - } -} - -// GOOD: Handle in event handler -function ProductPage({ product, addToCart }) { - function handleBuyClick() { - addToCart(product) - showNotification(`Added ${product.name}!`) - } -} -``` - -**Why it's bad**: Effect fires on page refresh (isInCart is true), showing notification unexpectedly. - ---- - -## 5. Chains of Effects - -```tsx -// BAD: Effects triggering each other -function Game() { - const [card, setCard] = useState(null) - const [goldCardCount, setGoldCardCount] = useState(0) - const [round, setRound] = useState(1) - const [isGameOver, setIsGameOver] = useState(false) - - useEffect(() => { - if (card?.gold) setGoldCardCount((c) => c + 1) - }, [card]) - - useEffect(() => { - if (goldCardCount > 3) { - setRound((r) => r + 1) - setGoldCardCount(0) - } - }, [goldCardCount]) - - useEffect(() => { - if (round > 5) setIsGameOver(true) - }, [round]) -} - -// GOOD: Calculate in event handler -function Game() { - const [card, setCard] = useState(null) - const [goldCardCount, setGoldCardCount] = useState(0) - const [round, setRound] = useState(1) - const isGameOver = round > 5 // Derived! - - function handlePlaceCard(nextCard) { - if (isGameOver) throw Error('Game ended') - - setCard(nextCard) - if (nextCard.gold) { - if (goldCardCount < 3) { - setGoldCardCount(goldCardCount + 1) - } else { - setGoldCardCount(0) - setRound(round + 1) - if (round === 5) alert('Good game!') - } - } - } -} -``` - -**Why it's bad**: Multiple re-renders (setCard -> setGoldCardCount -> setRound -> setIsGameOver). Also fragile for features like history replay. - ---- - -## 6. Notifying Parent via Effect - -```tsx -// BAD: Effect to notify parent -function Toggle({ onChange }) { - const [isOn, setIsOn] = useState(false) - - useEffect(() => { - onChange(isOn) - }, [isOn, onChange]) - - function handleClick() { - setIsOn(!isOn) - } -} - -// GOOD: Notify in same event -function Toggle({ onChange }) { - const [isOn, setIsOn] = useState(false) - - function updateToggle(nextIsOn) { - setIsOn(nextIsOn) - onChange(nextIsOn) // Same event, batched render - } - - function handleClick() { - updateToggle(!isOn) - } -} - -// BEST: Fully controlled component -function Toggle({ isOn, onChange }) { - function handleClick() { - onChange(!isOn) - } -} -``` - ---- - -## 7. Passing Data Up to Parent - -```tsx -// BAD: Child fetches, passes up via Effect -function Parent() { - const [data, setData] = useState(null) - return -} - -function Child({ onFetched }) { - const data = useSomeAPI() - - useEffect(() => { - if (data) onFetched(data) - }, [onFetched, data]) -} - -// GOOD: Parent fetches, passes down -function Parent() { - const data = useSomeAPI() - return -} -``` - -**Why**: Data should flow down. Upward flow via Effects makes debugging hard. - ---- - -## 8. Fetching Without Cleanup (Race Condition) - -```tsx -// BAD: No cleanup - race condition -function SearchResults({ query }) { - const [results, setResults] = useState([]) - - useEffect(() => { - fetchResults(query).then((json) => { - setResults(json) // "hello" response may arrive after "hell" - }) - }, [query]) -} - -// GOOD: Cleanup ignores stale responses -function SearchResults({ query }) { - const [results, setResults] = useState([]) - - useEffect(() => { - let ignore = false - - fetchResults(query).then((json) => { - if (!ignore) setResults(json) - }) - - return () => { - ignore = true - } - }, [query]) -} -``` - ---- - -## 9. App Initialization in Effect - -```tsx -// BAD: Runs twice in dev, may break auth -function App() { - useEffect(() => { - loadDataFromLocalStorage() - checkAuthToken() // May invalidate token on second call! - }, []) -} - -// GOOD: Module-level guard -let didInit = false - -function App() { - useEffect(() => { - if (!didInit) { - didInit = true - loadDataFromLocalStorage() - checkAuthToken() - } - }, []) -} - -// ALSO GOOD: Module-level execution -if (typeof window !== 'undefined') { - checkAuthToken() - loadDataFromLocalStorage() -} -``` diff --git a/.agents/skills/typescript/SKILL.md b/.agents/skills/typescript/SKILL.md deleted file mode 100644 index 8de8e7d7754..00000000000 --- a/.agents/skills/typescript/SKILL.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -name: typescript -description: This skill should be used when the user asks to "optimize TypeScript performance", "speed up tsc compilation", "configure tsconfig.json", "fix type errors", "improve async patterns", or encounters TS errors (TS2322, TS2339, "is not assignable to"). Also triggers on .ts, .tsx, .d.ts file work involving type definitions, module organization, or memory management. Does NOT cover TypeScript basics, framework-specific patterns, or testing. ---- - -# TypeScript Best Practices - -Comprehensive performance optimization guide for TypeScript applications. Contains 45 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation. - -## When to Apply - -Reference these guidelines when: - -- Configuring tsconfig.json for a new or existing project -- Writing complex type definitions or generics -- Optimizing async/await patterns and data fetching -- Organizing modules and managing imports -- Reviewing code for compilation or runtime performance - -## Rule Categories by Priority - -| Priority | Category | Impact | Prefix | -| -------- | ----------------------- | ----------- | ----------- | -| 1 | Type System Performance | CRITICAL | `type-` | -| 2 | Compiler Configuration | CRITICAL | `tscfg-` | -| 3 | Async Patterns | HIGH | `async-` | -| 4 | Module Organization | HIGH | `module-` | -| 5 | Type Safety Patterns | MEDIUM-HIGH | `safety-` | -| 6 | Memory Management | MEDIUM | `mem-` | -| 7 | Runtime Optimization | LOW-MEDIUM | `runtime-` | -| 8 | Advanced Patterns | LOW | `advanced-` | - -## Table of Contents - -1. [Type System Performance](references/_sections.md#1-type-system-performance) — **CRITICAL** - - 1.1 [Add Explicit Return Types to Exported Functions](references/type-explicit-return-types.md) — CRITICAL (30-50% faster declaration emit) - - 1.2 [Avoid Deeply Nested Generic Types](references/type-avoid-deep-generics.md) — CRITICAL (prevents exponential instantiation cost) - - 1.3 [Avoid Large Union Types](references/type-avoid-large-unions.md) — CRITICAL (quadratic O(n²) comparison cost) - - 1.4 [Extract Conditional Types to Named Aliases](references/type-extract-conditional-types.md) — CRITICAL (enables compiler caching, prevents re-evaluation) - - 1.5 [Limit Type Recursion Depth](references/type-limit-recursion-depth.md) — HIGH (prevents exponential type expansion when applicable) - - 1.6 [Prefer Interfaces Over Type Intersections](references/type-interfaces-over-intersections.md) — CRITICAL (2-5× faster type resolution) - - 1.7 [Simplify Complex Mapped Types](references/type-simplify-mapped-types.md) — HIGH (reduces type computation by 50-80% when applicable) -2. [Compiler Configuration](references/_sections.md#2-compiler-configuration) — **CRITICAL** - - 2.1 [Configure Include and Exclude Properly](references/tscfg-exclude-properly.md) — CRITICAL (prevents scanning thousands of unnecessary files) - - 2.2 [Enable Incremental Compilation](references/tscfg-enable-incremental.md) — CRITICAL (50-90% faster rebuilds) - - 2.3 [Enable isolatedDeclarations for Parallel Declaration Emit](references/tscfg-isolated-declarations.md) — CRITICAL (enables parallel .d.ts generation without type-checker) - - 2.4 [Enable skipLibCheck for Faster Builds](references/tscfg-skip-lib-check.md) — CRITICAL (20-40% faster compilation) - - 2.5 [Enable strictFunctionTypes for Faster Variance Checks](references/tscfg-strict-function-types.md) — CRITICAL (enables optimized variance checking) - - 2.6 [Use erasableSyntaxOnly for Node.js Native TypeScript](references/tscfg-erasable-syntax-only.md) — HIGH (prevents 100% of Node.js type-stripping runtime errors) - - 2.7 [Use isolatedModules for Single-File Transpilation](references/tscfg-isolate-modules.md) — CRITICAL (80-90% faster transpilation with bundlers) - - 2.8 [Use Project References for Large Codebases](references/tscfg-project-references.md) — CRITICAL (60-80% faster incremental builds) -3. [Async Patterns](references/_sections.md#3-async-patterns) — **HIGH** - - 3.1 [Annotate Async Function Return Types](references/async-explicit-return-types.md) — HIGH (prevents runtime errors, improves inference) - - 3.2 [Avoid await Inside Loops](references/async-avoid-loop-await.md) — HIGH (N× faster for N iterations, 10 users = 10× improvement) - - 3.3 [Avoid Unnecessary async/await](references/async-avoid-unnecessary-async.md) — HIGH (eliminates trivial Promise wrappers and improves stack traces) - - 3.4 [Defer await Until Value Is Needed](references/async-defer-await.md) — HIGH (enables implicit parallelization) - - 3.5 [Use Promise.all for Independent Operations](references/async-parallel-promises.md) — HIGH (2-10× improvement in I/O-bound code) -4. [Module Organization](references/_sections.md#4-module-organization) — **HIGH** - - 4.1 [Avoid Barrel File Imports](references/module-avoid-barrel-imports.md) — HIGH (200-800ms import cost, 30-50% larger bundles) - - 4.2 [Avoid Circular Dependencies](references/module-avoid-circular-dependencies.md) — HIGH (prevents runtime undefined errors and slow compilation) - - 4.3 [Control @types Package Inclusion](references/module-control-types-inclusion.md) — HIGH (prevents type conflicts and reduces memory usage) - - 4.4 [Use Dynamic Imports for Large Modules](references/module-dynamic-imports.md) — HIGH (reduces initial bundle by 30-70%) - - 4.5 [Use Type-Only Imports for Types](references/module-use-type-imports.md) — HIGH (eliminates runtime imports for type information) -5. [Type Safety Patterns](references/_sections.md#5-type-safety-patterns) — **MEDIUM-HIGH** - - 5.1 [Enable noUncheckedIndexedAccess](references/safety-no-unchecked-indexed-access.md) — MEDIUM-HIGH (prevents 100% of unchecked index access errors at compile time) - - 5.2 [Enable strictNullChecks](references/safety-strict-null-checks.md) — MEDIUM-HIGH (prevents null/undefined runtime errors) - - 5.3 [Prefer unknown Over any](references/safety-prefer-unknown-over-any.md) — MEDIUM-HIGH (forces type narrowing, prevents runtime errors) - - 5.4 [Use Assertion Functions for Validation](references/safety-assertion-functions.md) — MEDIUM-HIGH (reduces validation boilerplate by 50-70%) - - 5.5 [Use const Assertions for Literal Types](references/safety-const-assertions.md) — MEDIUM-HIGH (preserves literal types, enables better inference) - - 5.6 [Use Exhaustive Checks for Union Types](references/safety-exhaustive-checks.md) — MEDIUM-HIGH (prevents 100% of missing case errors at compile time) - - 5.7 [Use Type Guards for Runtime Type Checking](references/safety-use-type-guards.md) — MEDIUM-HIGH (eliminates type assertions, catches errors at boundaries) -6. [Memory Management](references/_sections.md#6-memory-management) — **MEDIUM** - - 6.1 [Avoid Closure Memory Leaks](references/mem-avoid-closure-leaks.md) — MEDIUM (prevents retained references in long-lived callbacks) - - 6.2 [Avoid Global State Accumulation](references/mem-avoid-global-state.md) — MEDIUM (prevents unbounded memory growth) - - 6.3 [Clean Up Event Listeners](references/mem-cleanup-event-listeners.md) — MEDIUM (prevents unbounded memory growth) - - 6.4 [Clear Timers and Intervals](references/mem-clear-timers.md) — MEDIUM (prevents callback retention and repeated execution) - - 6.5 [Use WeakMap for Object Metadata](references/mem-use-weakmap-for-metadata.md) — MEDIUM (prevents memory leaks, enables automatic cleanup) -7. [Runtime Optimization](references/_sections.md#7-runtime-optimization) — **LOW-MEDIUM** - - 7.1 [Avoid Object Spread in Hot Loops](references/runtime-avoid-object-spread-in-loops.md) — LOW-MEDIUM (reduces object allocations by N×) - - 7.2 [Cache Property Access in Loops](references/runtime-cache-property-access.md) — LOW-MEDIUM (reduces property lookups by N× in hot paths) - - 7.3 [Prefer Native Array Methods Over Lodash](references/runtime-prefer-array-methods.md) — LOW-MEDIUM (eliminates library overhead, enables tree-shaking) - - 7.4 [Use for-of for Simple Iteration](references/runtime-use-for-of-for-iteration.md) — LOW-MEDIUM (reduces iteration boilerplate by 30-50%) - - 7.5 [Use Modern String Methods](references/runtime-use-string-methods.md) — LOW-MEDIUM (2-5× faster than regex for simple patterns) - - 7.6 [Use Set/Map for O(1) Lookups](references/runtime-use-set-for-lookups.md) — LOW-MEDIUM (O(n) to O(1) per lookup) -8. [Advanced Patterns](references/_sections.md#8-advanced-patterns) — **LOW** - - 8.1 [Use Branded Types for Type-Safe IDs](references/advanced-branded-types.md) — LOW (prevents mixing incompatible ID types) - - 8.2 [Use satisfies for Type Validation with Inference](references/advanced-satisfies-operator.md) — LOW (prevents property access errors, enables 100% autocomplete accuracy) - - 8.3 [Use Template Literal Types for String Patterns](references/advanced-template-literal-types.md) — LOW (prevents 100% of string format errors at compile time) - -## References - -1. [https://github.com/microsoft/TypeScript/wiki/Performance](https://github.com/microsoft/TypeScript/wiki/Performance) -2. [https://www.typescriptlang.org/docs/handbook/](https://www.typescriptlang.org/docs/handbook/) -3. [https://v8.dev/blog](https://v8.dev/blog) -4. [https://nodejs.org/en/learn/diagnostics/memory](https://nodejs.org/en/learn/diagnostics/memory) diff --git a/.agents/skills/typescript/assets/templates/_template.md b/.agents/skills/typescript/assets/templates/_template.md deleted file mode 100644 index 3071a0b92b9..00000000000 --- a/.agents/skills/typescript/assets/templates/_template.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: Rule Title Here -impact: MEDIUM -impactDescription: Quantified impact (e.g., "2-10× improvement", "200ms savings") -tags: prefix, technique, related-concepts ---- - -## Rule Title Here - -Brief explanation of the rule and why it matters (1-3 sentences). Focus on performance implications. - -**Incorrect (description of what's wrong):** - -```typescript -// Bad code example here -const badExample = inefficientOperation() -``` - -**Correct (description of what's right):** - -```typescript -// Good code example here -const goodExample = efficientOperation() -``` - -Reference: [Link to documentation](https://example.com) diff --git a/.agents/skills/typescript/references/_sections.md b/.agents/skills/typescript/references/_sections.md deleted file mode 100644 index bf5e5463f23..00000000000 --- a/.agents/skills/typescript/references/_sections.md +++ /dev/null @@ -1,46 +0,0 @@ -# Sections - -This file defines all sections, their ordering, impact levels, and descriptions. -The section ID (in parentheses) is the filename prefix used to group rules. - ---- - -## 1. Type System Performance (type) - -**Impact:** CRITICAL -**Description:** Complex types, deep generics, and large unions cause quadratic compilation time. Simplifying type definitions yields the largest compile-time gains. - -## 2. Compiler Configuration (tscfg) - -**Impact:** CRITICAL -**Description:** Misconfigured tsconfig causes full rebuilds and unnecessary file scanning. Proper configuration reduces compile time by 50-80%. - -## 3. Async Patterns (async) - -**Impact:** HIGH -**Description:** Sequential awaits create runtime waterfalls. Parallelizing async operations yields 2-10× improvement in I/O-bound code. - -## 4. Module Organization (module) - -**Impact:** HIGH -**Description:** Barrel files and circular dependencies force excessive module loading. Direct imports reduce bundle size and improve tree-shaking. - -## 5. Type Safety Patterns (safety) - -**Impact:** MEDIUM-HIGH -**Description:** Type guards, narrowing, and strict mode prevent runtime errors. Proper patterns eliminate defensive runtime checks. - -## 6. Memory Management (mem) - -**Impact:** MEDIUM -**Description:** Object pooling, WeakMap usage, and closure hygiene reduce GC pressure and memory leaks in long-running applications. - -## 7. Runtime Optimization (runtime) - -**Impact:** LOW-MEDIUM -**Description:** Loop optimization, property caching, and collection choice improve hot-path performance. - -## 8. Advanced Patterns (advanced) - -**Impact:** LOW -**Description:** Branded types, variance annotations, and declaration merging for specialized use cases. diff --git a/.agents/skills/typescript/references/advanced-branded-types.md b/.agents/skills/typescript/references/advanced-branded-types.md deleted file mode 100644 index 12f14f6dedf..00000000000 --- a/.agents/skills/typescript/references/advanced-branded-types.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: Use Branded Types for Type-Safe IDs -impact: LOW -impactDescription: prevents mixing incompatible ID types -tags: advanced, branded-types, nominal-types, type-safety, ids ---- - -## Use Branded Types for Type-Safe IDs - -TypeScript uses structural typing, so `string` types are interchangeable even when they represent different concepts. Branded types add a unique marker to prevent mixing incompatible values. - -**Incorrect (structural typing allows mixing):** - -```typescript -type UserId = string -type OrderId = string -type ProductId = string - -function fetchUser(id: UserId): Promise { - /* ... */ -} -function fetchOrder(id: OrderId): Promise { - /* ... */ -} - -const userId: UserId = 'user-123' -const orderId: OrderId = 'order-456' - -// No error - all strings are interchangeable -fetchUser(orderId) // Bug: passed OrderId to UserId parameter -fetchOrder(userId) // Bug: passed UserId to OrderId parameter -``` - -**Correct (branded types prevent mixing):** - -```typescript -type Brand = K & { __brand: T } - -type UserId = Brand -type OrderId = Brand -type ProductId = Brand - -function createUserId(id: string): UserId { - return id as UserId -} - -function createOrderId(id: string): OrderId { - return id as OrderId -} - -function fetchUser(id: UserId): Promise { - /* ... */ -} -function fetchOrder(id: OrderId): Promise { - /* ... */ -} - -const userId = createUserId('user-123') -const orderId = createOrderId('order-456') - -fetchUser(orderId) // Error: Argument of type 'OrderId' is not assignable to 'UserId' -fetchOrder(userId) // Error: Argument of type 'UserId' is not assignable to 'OrderId' -fetchUser(userId) // OK -``` - -**For numeric types:** - -```typescript -type Cents = Brand -type Dollars = Brand - -function toCents(dollars: Dollars): Cents { - return (dollars * 100) as Cents -} - -function formatPrice(cents: Cents): string { - return `$${(cents / 100).toFixed(2)}` -} - -const price = 29.99 as Dollars -formatPrice(price) // Error: Dollars not assignable to Cents -formatPrice(toCents(price)) // OK: '$29.99' -``` - -**When to use branded types:** - -- Entity IDs that shouldn't be mixed -- Currency/unit conversions -- Validated strings (email, URL, slug) -- Sensitive data that needs tracking - -Reference: [TypeScript Playground - Nominal Typing](https://www.typescriptlang.org/play/typescript/language-extensions/nominal-typing.ts.html) diff --git a/.agents/skills/typescript/references/advanced-satisfies-operator.md b/.agents/skills/typescript/references/advanced-satisfies-operator.md deleted file mode 100644 index 83d069ea04f..00000000000 --- a/.agents/skills/typescript/references/advanced-satisfies-operator.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -title: Use satisfies for Type Validation with Inference -impact: LOW -impactDescription: prevents property access errors, enables 100% autocomplete accuracy -tags: advanced, satisfies, inference, validation, type-checking ---- - -## Use satisfies for Type Validation with Inference - -The `satisfies` operator validates that a value conforms to a type while preserving the narrower inferred type. This gives you both type safety and precise autocomplete. - -**Incorrect (type annotation loses literal types):** - -```typescript -type ColorConfig = Record - -const colors: ColorConfig = { - red: [255, 0, 0], - green: [0, 255, 0], - blue: [0, 0, 255] - // Can't access colors.red - it's just string keys -} - -// TypeScript doesn't know 'red' is a valid key -const redValue = colors.red // Type: [number, number, number] -const pinkValue = colors.pink // No error! Type: [number, number, number] -``` - -**Correct (satisfies preserves literal types):** - -```typescript -type ColorConfig = Record - -const colors = { - red: [255, 0, 0], - green: [0, 255, 0], - blue: [0, 0, 255] -} satisfies ColorConfig - -// TypeScript knows exact keys -const redValue = colors.red // Type: [number, number, number] -const pinkValue = colors.pink // Error: Property 'pink' does not exist -``` - -**For configuration objects:** - -```typescript -interface Route { - path: string - component: () => JSX.Element - auth?: boolean -} - -// Without satisfies - loses literal path types -const routes: Route[] = [ - { path: '/', component: Home }, - { path: '/users', component: Users } -] -// routes[0].path is just 'string' - -// With satisfies - preserves literal paths -const routes = [ - { path: '/', component: Home }, - { path: '/users', component: Users } -] satisfies Route[] -// routes[0].path is '/' - -type RoutePath = (typeof routes)[number]['path'] // '/' | '/users' -``` - -**Combining with as const:** - -```typescript -const config = { - apiUrl: 'https://api.example.com', - timeout: 5000, - retries: 3 -} as const satisfies { - apiUrl: string - timeout: number - retries: number -} - -// Both validated AND readonly with literal types -config.apiUrl // Type: 'https://api.example.com' (not just string) -config.timeout = 3000 // Error: Cannot assign to 'timeout' (readonly) -``` - -**When to use satisfies vs type annotation:** - -- Use `satisfies` when you want validation but need literal types -- Use type annotation (`:`) when you want the variable to be exactly that type -- Use `as const satisfies` for readonly config with validation - -Reference: [TypeScript 4.9 satisfies](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator) diff --git a/.agents/skills/typescript/references/advanced-template-literal-types.md b/.agents/skills/typescript/references/advanced-template-literal-types.md deleted file mode 100644 index a43c45b23bc..00000000000 --- a/.agents/skills/typescript/references/advanced-template-literal-types.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: Use Template Literal Types for String Patterns -impact: LOW -impactDescription: prevents 100% of string format errors at compile time -tags: advanced, template-literals, string-types, patterns, validation ---- - -## Use Template Literal Types for String Patterns - -Template literal types allow defining string patterns at the type level. TypeScript validates that strings match the expected format at compile time. - -**Incorrect (plain string allows any value):** - -```typescript -type EventHandler = { - event: string - handler: () => void -} - -const handler: EventHandler = { - event: 'click', // OK - handler: () => {} -} - -const badHandler: EventHandler = { - event: 'clck', // Typo - no error - handler: () => {} -} - -function addEventListener(event: string, handler: () => void): void {} -addEventListener('onlcick', () => {}) // Typo compiles fine -``` - -**Correct (template literal type validates pattern):** - -```typescript -type DOMEvent = 'click' | 'focus' | 'blur' | 'submit' | 'change' -type EventHandlerName = `on${Capitalize}` - -type EventHandler = { - event: EventHandlerName - handler: () => void -} - -const handler: EventHandler = { - event: 'onClick', // OK - handler: () => {} -} - -const badHandler: EventHandler = { - event: 'onClck', // Error: Type '"onClck"' is not assignable to type 'EventHandlerName' - handler: () => {} -} -``` - -**For CSS-like patterns:** - -```typescript -type CSSUnit = 'px' | 'em' | 'rem' | '%' | 'vh' | 'vw' -type CSSValue = `${number}${CSSUnit}` - -function setWidth(element: HTMLElement, width: CSSValue): void { - element.style.width = width -} - -setWidth(div, '100px') // OK -setWidth(div, '2.5rem') // OK -setWidth(div, '100') // Error: Type '"100"' is not assignable to type 'CSSValue' -setWidth(div, '100pixels') // Error -``` - -**For API route patterns:** - -```typescript -type APIVersion = 'v1' | 'v2' -type Resource = 'users' | 'orders' | 'products' -type APIRoute = `/api/${APIVersion}/${Resource}` - -function fetchResource(route: APIRoute): Promise { - return fetch(route) -} - -fetchResource('/api/v1/users') // OK -fetchResource('/api/v2/orders') // OK -fetchResource('/api/v3/users') // Error: 'v3' not in APIVersion -fetchResource('/users') // Error: doesn't match pattern -``` - -**Combining with mapped types:** - -```typescript -type Getters = { - [K in keyof T as `get${Capitalize}`]: () => T[K] -} - -interface User { - name: string - age: number -} - -type UserGetters = Getters -// { getName: () => string; getAge: () => number } -``` - -Reference: [TypeScript 4.1 Template Literal Types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html) diff --git a/.agents/skills/typescript/references/async-avoid-loop-await.md b/.agents/skills/typescript/references/async-avoid-loop-await.md deleted file mode 100644 index e299ef0d99a..00000000000 --- a/.agents/skills/typescript/references/async-avoid-loop-await.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: Avoid await Inside Loops -impact: HIGH -impactDescription: N× faster for N iterations, 10 users = 10× improvement -tags: async, loops, batching, waterfalls, performance ---- - -## Avoid await Inside Loops - -Using `await` inside a loop creates N sequential operations. Collect promises and await them together, or use `Promise.all()` with `map()` for parallel execution. - -**Incorrect (N sequential requests):** - -```typescript -async function enrichUsers(userIds: string[]): Promise { - const enrichedUsers: EnrichedUser[] = [] - - for (const userId of userIds) { - const user = await fetchUser(userId) // Waits for each request - const profile = await fetchProfile(userId) - enrichedUsers.push({ ...user, profile }) - } - // 10 users × 2 requests × 100ms = 2000ms - - return enrichedUsers -} -``` - -**Correct (parallel execution):** - -```typescript -async function enrichUsers(userIds: string[]): Promise { - const enrichedUsers = await Promise.all( - userIds.map(async (userId) => { - const [user, profile] = await Promise.all([fetchUser(userId), fetchProfile(userId)]) - return { ...user, profile } - }) - ) - // 10 users processed in parallel = 100ms total - - return enrichedUsers -} -``` - -**For rate-limited APIs (chunked batching):** - -```typescript -async function enrichUsers(userIds: string[]): Promise { - const BATCH_SIZE = 5 - const results: EnrichedUser[] = [] - - for (let i = 0; i < userIds.length; i += BATCH_SIZE) { - const batch = userIds.slice(i, i + BATCH_SIZE) - const batchResults = await Promise.all( - batch.map(async (userId) => { - const [user, profile] = await Promise.all([fetchUser(userId), fetchProfile(userId)]) - return { ...user, profile } - }) - ) - results.push(...batchResults) - } - - return results -} -``` - -**When sequential loop await is acceptable:** - -- Each iteration depends on the previous result -- API strictly requires sequential calls -- Processing order affects correctness - -Reference: [ESLint no-await-in-loop](https://eslint.org/docs/rules/no-await-in-loop) diff --git a/.agents/skills/typescript/references/async-avoid-unnecessary-async.md b/.agents/skills/typescript/references/async-avoid-unnecessary-async.md deleted file mode 100644 index 252f11b8617..00000000000 --- a/.agents/skills/typescript/references/async-avoid-unnecessary-async.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: Avoid Unnecessary async/await -impact: HIGH -impactDescription: eliminates trivial Promise wrappers and improves stack traces -tags: async, promises, overhead, optimization, return-await ---- - -## Avoid Unnecessary async/await - -Remove `async` from functions that only wrap a single Promise without using `await` for control flow. However, prefer `return await` over bare `return` inside try/catch blocks — it ensures errors are caught and produces better stack traces. - -**Incorrect (trivial async wrapper with no logic):** - -```typescript -async function getUser(userId: string): Promise { - return userRepository.findById(userId) - // async keyword creates unnecessary Promise wrapper - // No await, no try/catch — async adds nothing here -} - -async function deleteUser(userId: string): Promise { - return userRepository.delete(userId) - // Same pattern — async is pure overhead -} -``` - -**Correct (remove async when it adds nothing):** - -```typescript -function getUser(userId: string): Promise { - return userRepository.findById(userId) - // Direct Promise return, no wrapper -} - -function deleteUser(userId: string): Promise { - return userRepository.delete(userId) -} -``` - -**Keep async + return await in try/catch:** - -```typescript -// Correct — return await ensures the error is caught -async function getUser(userId: string): Promise { - try { - return await userRepository.findById(userId) - // Without await, rejected promise skips the catch block - } catch (error) { - logger.error('Failed to fetch user', { userId, error }) - throw new UserNotFoundError(userId) - } -} -``` - -**When async IS needed:** - -- Multiple sequential await statements -- Try/catch around await (use `return await` here) -- Conditional await logic -- Complex control flow with early returns - -**Note:** ESLint deprecated `no-return-await` in v8.46.0 because `return await` is both safe and produces better stack traces. Use the `@typescript-eslint/return-await` rule with `in-try-catch` setting for nuanced control. - -Reference: [ESLint no-return-await deprecation](https://eslint.org/docs/latest/rules/no-return-await) diff --git a/.agents/skills/typescript/references/async-defer-await.md b/.agents/skills/typescript/references/async-defer-await.md deleted file mode 100644 index 0a9f0296258..00000000000 --- a/.agents/skills/typescript/references/async-defer-await.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Defer await Until Value Is Needed -impact: HIGH -impactDescription: enables implicit parallelization -tags: async, defer, promises, optimization, performance ---- - -## Defer await Until Value Is Needed - -Start async operations immediately but defer `await` until the value is actually required. This allows independent work to proceed while promises resolve in the background. - -**Incorrect (blocks immediately, serializes independent work):** - -```typescript -async function processOrder(orderId: string): Promise { - const order = await fetchOrder(orderId) // Blocks here - const config = await loadProcessingConfig() // Waits for order first, unnecessarily - - // config doesn't depend on order — these could run in parallel - if (order.priority === 'express') { - return processExpress(order, config) - } - return processStandard(order, config) -} -``` - -**Correct (deferred await):** - -```typescript -async function processOrder(orderId: string): Promise { - const orderPromise = fetchOrder(orderId) // Start immediately - const config = await loadProcessingConfig() // Runs while order fetches - - const order = await orderPromise // Now await when needed - - if (order.priority === 'express') { - return processExpress(order, config) - } - return processStandard(order, config) -} -``` - -**Pattern for dependent-then-independent operations:** - -```typescript -async function loadUserContent(userId: string): Promise { - // Start user fetch (needed for dependent calls) - const userPromise = fetchUser(userId) - - // Start independent operations immediately - const settingsPromise = fetchGlobalSettings() - const featuresPromise = fetchFeatureFlags() - - // Await user for dependent operations - const user = await userPromise - const ordersPromise = fetchOrders(user.id) - const prefsPromise = fetchPreferences(user.id) - - // Await all remaining - const [settings, features, orders, prefs] = await Promise.all([ - settingsPromise, - featuresPromise, - ordersPromise, - prefsPromise - ]) - - return { user, settings, features, orders, prefs } -} -``` - -Reference: [V8 Blog - Fast Async](https://v8.dev/blog/fast-async) diff --git a/.agents/skills/typescript/references/async-explicit-return-types.md b/.agents/skills/typescript/references/async-explicit-return-types.md deleted file mode 100644 index 82683582a26..00000000000 --- a/.agents/skills/typescript/references/async-explicit-return-types.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: Annotate Async Function Return Types -impact: HIGH -impactDescription: prevents runtime errors, improves inference -tags: async, return-types, promises, type-safety, inference ---- - -## Annotate Async Function Return Types - -Explicit return types on async functions catch mismatches at the function boundary rather than at call sites. They also improve IDE performance by avoiding full function body inference. - -**Incorrect (inferred Promise type):** - -```typescript -async function fetchUserOrders(userId: string) { - const response = await fetch(`/api/users/${userId}/orders`) - if (!response.ok) { - return null // Implicit: Promise - } - return response.json() // Implicit: Promise -} - -// Caller has unclear type: Promise -const orders = await fetchUserOrders('123') -orders.map((o) => o.id) // No type error even if orders is null -``` - -**Correct (explicit Promise type):** - -```typescript -interface Order { - id: string - total: number - status: OrderStatus -} - -async function fetchUserOrders(userId: string): Promise { - const response = await fetch(`/api/users/${userId}/orders`) - if (!response.ok) { - return null - } - return response.json() as Promise -} - -// Caller knows the exact type -const orders = await fetchUserOrders('123') -if (orders) { - orders.map((o) => o.id) // Type-safe access -} -``` - -**For functions that might throw:** - -```typescript -type Result = { ok: true; value: T } | { ok: false; error: E } - -async function fetchUserOrders(userId: string): Promise> { - try { - const response = await fetch(`/api/users/${userId}/orders`) - if (!response.ok) { - return { ok: false, error: new Error(`HTTP ${response.status}`) } - } - const orders = (await response.json()) as Order[] - return { ok: true, value: orders } - } catch (error) { - return { ok: false, error: error as Error } - } -} -``` - -**Benefits:** - -- Errors caught at function definition, not call sites -- Better IDE autocomplete for consumers -- Self-documenting API contracts - -Reference: [TypeScript Performance Wiki - Using Type Annotations](https://github.com/microsoft/TypeScript/wiki/Performance#using-type-annotations) diff --git a/.agents/skills/typescript/references/async-parallel-promises.md b/.agents/skills/typescript/references/async-parallel-promises.md deleted file mode 100644 index 6f565b947f6..00000000000 --- a/.agents/skills/typescript/references/async-parallel-promises.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: Use Promise.all for Independent Operations -impact: HIGH -impactDescription: 2-10× improvement in I/O-bound code -tags: async, promises, parallel, waterfalls, performance ---- - -## Use Promise.all for Independent Operations - -Sequential `await` statements create request waterfalls—each operation waits for the previous one to complete. Use `Promise.all()` to execute independent async operations concurrently. - -**Incorrect (sequential execution, N round trips):** - -```typescript -async function loadDashboard(userId: string): Promise { - const user = await fetchUser(userId) // 200ms - const orders = await fetchOrders(userId) // 300ms - const notifications = await fetchNotifications(userId) // 150ms - // Total: 650ms (sequential) - - return { user, orders, notifications } -} -``` - -**Correct (parallel execution, wall-clock time = max latency):** - -```typescript -async function loadDashboard(userId: string): Promise { - const [user, orders, notifications] = await Promise.all([ - fetchUser(userId), // 200ms ─┐ - fetchOrders(userId), // 300ms ─┼─ Run in parallel - fetchNotifications(userId) // 150ms ─┘ - ]) - // Total: 300ms (max of all operations) - - return { user, orders, notifications } -} -``` - -**For error handling with partial success:** - -```typescript -async function loadDashboard(userId: string): Promise { - const results = await Promise.allSettled([ - fetchUser(userId), - fetchOrders(userId), - fetchNotifications(userId) - ]) - - return { - user: results[0].status === 'fulfilled' ? results[0].value : null, - orders: results[1].status === 'fulfilled' ? results[1].value : [], - notifications: results[2].status === 'fulfilled' ? results[2].value : [] - } -} -``` - -**When sequential is correct:** - -- Operations have data dependencies (need result A to make request B) -- Rate limiting requires sequential requests -- Order of execution matters for side effects - -Reference: [MDN Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) diff --git a/.agents/skills/typescript/references/mem-avoid-closure-leaks.md b/.agents/skills/typescript/references/mem-avoid-closure-leaks.md deleted file mode 100644 index af2aa3b9049..00000000000 --- a/.agents/skills/typescript/references/mem-avoid-closure-leaks.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -title: Avoid Closure Memory Leaks -impact: MEDIUM -impactDescription: prevents retained references in long-lived callbacks -tags: mem, closures, memory-leaks, callbacks, garbage-collection ---- - -## Avoid Closure Memory Leaks - -Closures retain references to their outer scope variables. Long-lived callbacks can accidentally keep large objects alive, causing memory to grow unboundedly. - -**Incorrect (closure retains entire scope):** - -```typescript -function createDataProcessor(largeDataset: DataRecord[]): () => void { - const processedIds = new Set() - - return function processNext(): void { - // This closure retains reference to largeDataset - // even though it only needs processedIds - const next = largeDataset.find((r) => !processedIds.has(r.id)) - if (next) { - processedIds.add(next.id) - sendToServer(next) - } - } -} - -// largeDataset (100MB) stays in memory as long as processNext exists -const processor = createDataProcessor(hugeDataset) -setInterval(processor, 1000) // Runs forever, 100MB never freed -``` - -**Correct (closure captures only what it needs):** - -```typescript -function createDataProcessor(largeDataset: DataRecord[]): () => void { - // Build a queue of just the IDs and a lookup for individual records - const pendingQueue: string[] = largeDataset.map((r) => r.id) - const getRecord = (id: string): DataRecord | undefined => largeDataset.find((r) => r.id === id) - - // Release the array reference — closure only captures pendingQueue and getRecord - // Caller should also release their reference to largeDataset - return function processNext(): void { - const nextId = pendingQueue.shift() - if (nextId) { - const record = getRecord(nextId) - if (record) sendToServer(record) - } - } -} -``` - -**Better pattern — accept an iterator to avoid holding the full dataset:** - -```typescript -function createDataProcessor(records: Iterable): () => void { - const iterator = records[Symbol.iterator]() - - return function processNext(): void { - const { value, done } = iterator.next() - if (!done) { - sendToServer(value) - } - } -} -``` - -**For event handlers:** - -```typescript -// Incorrect - handler retains component instance forever -class Dashboard { - private largeCache: Map = new Map() - - initialize(): void { - window.addEventListener('resize', () => { - this.handleResize() // 'this' keeps entire Dashboard alive - }) - } -} - -// Correct - remove listener when done -class Dashboard { - private largeCache: Map = new Map() - private resizeHandler: () => void - - initialize(): void { - this.resizeHandler = () => this.handleResize() - window.addEventListener('resize', this.resizeHandler) - } - - destroy(): void { - window.removeEventListener('resize', this.resizeHandler) - this.largeCache.clear() - } -} -``` - -Reference: [Node.js Memory Diagnostics](https://nodejs.org/en/learn/diagnostics/memory) diff --git a/.agents/skills/typescript/references/mem-avoid-global-state.md b/.agents/skills/typescript/references/mem-avoid-global-state.md deleted file mode 100644 index e1a0fdde2b9..00000000000 --- a/.agents/skills/typescript/references/mem-avoid-global-state.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: Avoid Global State Accumulation -impact: MEDIUM -impactDescription: prevents unbounded memory growth -tags: mem, global-state, singletons, memory-leaks, caching ---- - -## Avoid Global State Accumulation - -Global variables and module-level state persist for the application's lifetime. Unbounded caches or collections at module scope grow indefinitely, causing memory exhaustion. - -**Incorrect (unbounded global cache):** - -```typescript -// cache.ts -const userCache = new Map() // Never cleared - -export function getCachedUser(id: string): User | undefined { - return userCache.get(id) -} - -export function cacheUser(user: User): void { - userCache.set(user.id, user) - // Cache grows forever, never evicts old entries -} - -// After 1 million users, cache holds 1 million User objects -``` - -**Correct (bounded cache with eviction):** - -```typescript -// cache.ts -class LRUCache { - private cache = new Map() - private maxSize: number - - constructor(maxSize: number) { - this.maxSize = maxSize - } - - get(key: K): V | undefined { - const value = this.cache.get(key) - if (value !== undefined) { - // Move to end (most recently used) - this.cache.delete(key) - this.cache.set(key, value) - } - return value - } - - set(key: K, value: V): void { - if (this.cache.has(key)) { - this.cache.delete(key) - } else if (this.cache.size >= this.maxSize) { - // Evict oldest (first) entry - const oldest = this.cache.keys().next().value - this.cache.delete(oldest) - } - this.cache.set(key, value) - } -} - -const userCache = new LRUCache(1000) // Max 1000 entries - -export function getCachedUser(id: string): User | undefined { - return userCache.get(id) -} - -export function cacheUser(user: User): void { - userCache.set(user.id, user) -} -``` - -**For request-scoped state (Node.js):** - -```typescript -import { AsyncLocalStorage } from 'async_hooks' - -interface RequestContext { - userId: string - cache: Map -} - -const requestContext = new AsyncLocalStorage() - -export function runWithContext(context: RequestContext, fn: () => T): T { - return requestContext.run(context, fn) - // Context is automatically cleaned up when request ends -} - -export function getRequestCache(): Map { - return requestContext.getStore()?.cache ?? new Map() -} -``` - -Reference: [Node.js Memory Management](https://nodejs.org/en/learn/diagnostics/memory) diff --git a/.agents/skills/typescript/references/mem-cleanup-event-listeners.md b/.agents/skills/typescript/references/mem-cleanup-event-listeners.md deleted file mode 100644 index ae58671d7cf..00000000000 --- a/.agents/skills/typescript/references/mem-cleanup-event-listeners.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -title: Clean Up Event Listeners -impact: MEDIUM -impactDescription: prevents unbounded memory growth -tags: mem, event-listeners, cleanup, memory-leaks, lifecycle ---- - -## Clean Up Event Listeners - -Event listeners hold references to their callback functions and bound objects. Failing to remove them when components unmount causes memory to grow with each mount/unmount cycle. - -**Incorrect (listeners never removed):** - -```typescript -class WebSocketManager { - private socket: WebSocket - - connect(url: string): void { - this.socket = new WebSocket(url) - - this.socket.addEventListener('message', (event) => { - this.handleMessage(event.data) - }) - - this.socket.addEventListener('error', (event) => { - this.handleError(event) - }) - // Listeners keep 'this' alive even after disconnect - } - - disconnect(): void { - this.socket.close() - // Listeners still attached, WebSocketManager can't be GC'd - } -} -``` - -**Correct (listeners removed on cleanup):** - -```typescript -class WebSocketManager { - private socket: WebSocket - private messageHandler: (event: MessageEvent) => void - private errorHandler: (event: Event) => void - - connect(url: string): void { - this.socket = new WebSocket(url) - - this.messageHandler = (event) => this.handleMessage(event.data) - this.errorHandler = (event) => this.handleError(event) - - this.socket.addEventListener('message', this.messageHandler) - this.socket.addEventListener('error', this.errorHandler) - } - - disconnect(): void { - this.socket.removeEventListener('message', this.messageHandler) - this.socket.removeEventListener('error', this.errorHandler) - this.socket.close() - } -} -``` - -**Using AbortController (modern pattern):** - -```typescript -class WebSocketManager { - private socket: WebSocket - private abortController: AbortController - - connect(url: string): void { - this.abortController = new AbortController() - const { signal } = this.abortController - - this.socket = new WebSocket(url) - - this.socket.addEventListener('message', (e) => this.handleMessage(e.data), { signal }) - this.socket.addEventListener('error', (e) => this.handleError(e), { signal }) - // All listeners automatically removed when signal is aborted - } - - disconnect(): void { - this.abortController.abort() // Removes all listeners at once - this.socket.close() - } -} -``` - -**React useEffect pattern:** - -```typescript -function useWebSocket(url: string): Data | null { - const [data, setData] = useState(null) - - useEffect(() => { - const socket = new WebSocket(url) - const handler = (event: MessageEvent) => setData(JSON.parse(event.data)) - - socket.addEventListener('message', handler) - - return () => { - socket.removeEventListener('message', handler) - socket.close() - } - }, [url]) - - return data -} -``` - -Reference: [MDN AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) diff --git a/.agents/skills/typescript/references/mem-clear-timers.md b/.agents/skills/typescript/references/mem-clear-timers.md deleted file mode 100644 index 0d8e7a28b82..00000000000 --- a/.agents/skills/typescript/references/mem-clear-timers.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -title: Clear Timers and Intervals -impact: MEDIUM -impactDescription: prevents callback retention and repeated execution -tags: mem, timers, intervals, cleanup, memory-leaks ---- - -## Clear Timers and Intervals - -`setInterval` and `setTimeout` callbacks retain references to their closure scope. Failing to clear them causes callbacks to execute indefinitely and prevents garbage collection of referenced objects. - -**Incorrect (intervals never cleared):** - -```typescript -class DataPoller { - private data: LargeDataset - - start(): void { - setInterval(() => { - this.data = fetchLatestData() - this.updateDashboard() - }, 5000) - // No reference to interval ID, can't clear it - } - - stop(): void { - // Can't stop the interval - it runs forever - // 'this' is retained, DataPoller can't be GC'd - } -} - -// Each new DataPoller instance creates another interval -// Old instances can't be cleaned up -``` - -**Correct (intervals tracked and cleared):** - -```typescript -class DataPoller { - private data: LargeDataset - private intervalId: ReturnType | null = null - - start(): void { - if (this.intervalId) return // Prevent duplicate intervals - - this.intervalId = setInterval(() => { - this.data = fetchLatestData() - this.updateDashboard() - }, 5000) - } - - stop(): void { - if (this.intervalId) { - clearInterval(this.intervalId) - this.intervalId = null - } - } -} -``` - -**For multiple timers:** - -```typescript -class AnimationController { - private timers = new Set>() - - scheduleAnimation(delay: number, callback: () => void): void { - const timerId = setTimeout(() => { - this.timers.delete(timerId) - callback() - }, delay) - this.timers.add(timerId) - } - - cancelAll(): void { - for (const timerId of this.timers) { - clearTimeout(timerId) - } - this.timers.clear() - } -} -``` - -**React hook pattern:** - -```typescript -function usePolling(callback: () => void, interval: number): void { - useEffect(() => { - const id = setInterval(callback, interval) - return () => clearInterval(id) // Cleanup on unmount - }, [callback, interval]) -} - -function useDebounce(value: T, delay: number): T { - const [debouncedValue, setDebouncedValue] = useState(value) - - useEffect(() => { - const timer = setTimeout(() => setDebouncedValue(value), delay) - return () => clearTimeout(timer) // Clear on value change or unmount - }, [value, delay]) - - return debouncedValue -} -``` - -Reference: [MDN clearInterval](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval) diff --git a/.agents/skills/typescript/references/mem-use-weakmap-for-metadata.md b/.agents/skills/typescript/references/mem-use-weakmap-for-metadata.md deleted file mode 100644 index b76cbbe6914..00000000000 --- a/.agents/skills/typescript/references/mem-use-weakmap-for-metadata.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: Use WeakMap for Object Metadata -impact: MEDIUM -impactDescription: prevents memory leaks, enables automatic cleanup -tags: mem, weakmap, metadata, garbage-collection, memory-leaks ---- - -## Use WeakMap for Object Metadata - -WeakMap allows garbage collection of keys when no other references exist. Use it for associating metadata with objects without preventing their cleanup. - -**Incorrect (Map retains object references):** - -```typescript -const userMetadata = new Map() - -function trackUser(user: User): void { - userMetadata.set(user, { - lastSeen: Date.now(), - pageViews: 0 - }) -} - -function removeUser(user: User): void { - // Even after user is "removed" from app state, - // Map still holds reference, preventing GC - userMetadata.delete(user) // Must manually clean up -} - -// If delete is forgotten, user objects leak forever -``` - -**Correct (WeakMap allows GC):** - -```typescript -const userMetadata = new WeakMap() - -function trackUser(user: User): void { - userMetadata.set(user, { - lastSeen: Date.now(), - pageViews: 0 - }) -} - -// No cleanup needed - when user object is GC'd, -// WeakMap entry is automatically removed -function processUsers(users: User[]): void { - for (const user of users) { - trackUser(user) - } - // When users array is cleared, all metadata is cleaned up automatically -} -``` - -**Common use cases:** - -```typescript -// DOM element metadata -const elementState = new WeakMap() - -function attachState(element: HTMLElement): void { - elementState.set(element, { isExpanded: false }) - // When element is removed from DOM and GC'd, state is cleaned up -} - -// Caching computed values -const computedCache = new WeakMap() - -function getComputedConfig(config: Config): ComputedConfig { - let computed = computedCache.get(config) - if (!computed) { - computed = expensiveComputation(config) - computedCache.set(config, computed) - } - return computed -} -``` - -**Limitations of WeakMap:** - -- Keys must be objects (not primitives) -- Not iterable (no `.keys()`, `.values()`, `.entries()`) -- No `.size` property - -Reference: [MDN WeakMap](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap) diff --git a/.agents/skills/typescript/references/module-avoid-barrel-imports.md b/.agents/skills/typescript/references/module-avoid-barrel-imports.md deleted file mode 100644 index 85fbeae7f79..00000000000 --- a/.agents/skills/typescript/references/module-avoid-barrel-imports.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Avoid Barrel File Imports -impact: HIGH -impactDescription: 200-800ms import cost, 30-50% larger bundles -tags: module, barrel-files, imports, tree-shaking, bundling ---- - -## Avoid Barrel File Imports - -Barrel files (index.ts re-exports) defeat tree-shaking and force bundlers to load entire module graphs. Import directly from source files to enable proper dead-code elimination. - -**Incorrect (imports entire module tree):** - -```typescript -// utils/index.ts (barrel file) -export * from './string' -export * from './date' -export * from './validation' -export * from './crypto' // Heavy, rarely used - -// consumer.ts -import { formatDate } from '@/utils' -// Loads ALL utils modules, including crypto -// Bundle includes 50KB of unused code -``` - -**Correct (direct imports):** - -```typescript -// consumer.ts -import { formatDate } from '@/utils/date' -// Loads only the date module -// Bundle includes only what's used -``` - -**For icon libraries (common barrel offender):** - -```typescript -// Incorrect - loads all 1500+ icons -import { Check, X } from 'lucide-react' - -// Correct - loads only 2 icons -import Check from 'lucide-react/dist/esm/icons/check' -import X from 'lucide-react/dist/esm/icons/x' -``` - -**Alternative (configure bundler optimization):** - -```javascript -// next.config.js -module.exports = { - experimental: { - optimizePackageImports: ['lucide-react', '@mui/material', 'lodash'] - } -} - -// vite.config.ts -export default { - optimizeDeps: { - include: ['lucide-react'] - } -} -``` - -**When barrels are acceptable:** - -- Internal modules with few exports (< 10) -- Package entry points for library consumers -- When bundler is configured to optimize them - -Reference: [Vercel - How we optimized package imports](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js) diff --git a/.agents/skills/typescript/references/module-avoid-circular-dependencies.md b/.agents/skills/typescript/references/module-avoid-circular-dependencies.md deleted file mode 100644 index d56e0e6f781..00000000000 --- a/.agents/skills/typescript/references/module-avoid-circular-dependencies.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: Avoid Circular Dependencies -impact: HIGH -impactDescription: prevents runtime undefined errors and slow compilation -tags: module, circular, dependencies, architecture, compilation ---- - -## Avoid Circular Dependencies - -Circular dependencies cause undefined values at runtime (due to incomplete module initialization) and slow TypeScript compilation as the checker resolves cycles repeatedly. - -**Incorrect (circular dependency):** - -```typescript -// user.ts -import { Order } from './order' - -export interface User { - id: string - orders: Order[] -} - -export function createUser(): User { - /* ... */ -} - -// order.ts -import { User } from './user' // Circular! - -export interface Order { - id: string - user: User -} - -export function createOrder(user: User): Order { - // 'createUser' might be undefined if order.ts loads first -} -``` - -**Correct (extract shared types):** - -```typescript -// types.ts (no dependencies) -export interface User { - id: string - orders: Order[] -} - -export interface Order { - id: string - user: User -} - -// user.ts -import { User, Order } from './types' - -export function createUser(): User { - /* ... */ -} - -// order.ts -import { User, Order } from './types' - -export function createOrder(user: User): Order { - /* ... */ -} -``` - -**Alternative (interface segregation):** - -```typescript -// user-types.ts -export interface UserBase { - id: string - name: string -} - -// order.ts -import { UserBase } from './user-types' - -export interface Order { - id: string - user: UserBase // Only needs base interface, not full User -} -``` - -**Detection tools:** - -```bash -# Madge - visualize circular dependencies -npx madge --circular --extensions ts ./src - -# ESLint plugin -npm install eslint-plugin-import -# Rule: import/no-cycle -``` - -Reference: [Node.js Cycles Documentation](https://nodejs.org/api/modules.html#cycles) diff --git a/.agents/skills/typescript/references/module-control-types-inclusion.md b/.agents/skills/typescript/references/module-control-types-inclusion.md deleted file mode 100644 index ce22a732740..00000000000 --- a/.agents/skills/typescript/references/module-control-types-inclusion.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: Control @types Package Inclusion -impact: HIGH -impactDescription: prevents type conflicts and reduces memory usage -tags: module, types, tsconfig, declaration-files, performance ---- - -## Control @types Package Inclusion - -By default, TypeScript loads all `@types/*` packages from `node_modules`. This causes conflicts between incompatible type versions and wastes memory loading unused declarations. - -**Incorrect (loads all @types automatically):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext" - } -} -``` - -```bash -# All @types/* packages loaded: -# @types/node, @types/react, @types/express, @types/lodash, -# @types/jest, @types/mocha (conflict!), @types/jasmine (conflict!) -``` - -**Correct (explicit types inclusion):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "types": ["node", "react", "jest"] - } -} -``` - -```bash -# Only specified @types loaded -# No conflicts between test frameworks -``` - -**For different environments:** - -```json -// tsconfig.json (base) -{ - "compilerOptions": { - "types": [] - } -} - -// tsconfig.node.json (Node.js scripts) -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "types": ["node"] - } -} - -// tsconfig.test.json (Jest tests) -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "types": ["node", "jest"] - } -} -``` - -**Using typeRoots for custom declarations:** - -```json -{ - "compilerOptions": { - "typeRoots": [ - "./types", // Custom declarations first - "./node_modules/@types" // Then @types - ], - "types": ["node"] - } -} -``` - -**Benefits:** - -- Prevents type conflicts between similar packages -- Reduces memory usage during compilation -- Faster IDE responsiveness - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#controlling-types-inclusion) diff --git a/.agents/skills/typescript/references/module-dynamic-imports.md b/.agents/skills/typescript/references/module-dynamic-imports.md deleted file mode 100644 index c2f35946573..00000000000 --- a/.agents/skills/typescript/references/module-dynamic-imports.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: Use Dynamic Imports for Large Modules -impact: HIGH -impactDescription: reduces initial bundle by 30-70% -tags: module, dynamic-import, code-splitting, lazy-loading, bundling ---- - -## Use Dynamic Imports for Large Modules - -Dynamic `import()` creates separate chunks that load on demand. Use them for large dependencies, route-specific code, and features that aren't needed immediately. - -**Incorrect (static import, always loaded):** - -```typescript -import { PDFGenerator } from 'pdfkit' // 500KB -import { ExcelExporter } from 'exceljs' // 800KB -import { ChartLibrary } from 'chart.js' // 300KB - -export async function exportReport(format: 'pdf' | 'excel' | 'chart') { - if (format === 'pdf') { - return new PDFGenerator().generate() - } - // All 1.6MB loaded even if user never exports -} -``` - -**Correct (dynamic import, loaded on demand):** - -```typescript -export async function exportReport(format: 'pdf' | 'excel' | 'chart') { - if (format === 'pdf') { - const { PDFGenerator } = await import('pdfkit') - return new PDFGenerator().generate() - } - - if (format === 'excel') { - const { ExcelExporter } = await import('exceljs') - return new ExcelExporter().export() - } - - const { ChartLibrary } = await import('chart.js') - return new ChartLibrary().render() -} -// Only loads the module needed for the specific format -``` - -**With TypeScript typing:** - -```typescript -async function loadPdfGenerator(): Promise { - return import('pdfkit') -} - -// Or with type-only import for the interface -import type { PDFDocument } from 'pdfkit' - -async function generatePdf(): Promise { - const { default: PDFDocument } = await import('pdfkit') - return new PDFDocument() -} -``` - -**Framework-specific patterns:** - -```typescript -// Next.js -import dynamic from 'next/dynamic' - -const HeavyChart = dynamic(() => import('@/components/HeavyChart'), { - loading: () => , - ssr: false // Skip server-side rendering -}) - -// React -const HeavyChart = React.lazy(() => import('@/components/HeavyChart')) -``` - -Reference: [MDN Dynamic Import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) diff --git a/.agents/skills/typescript/references/module-use-type-imports.md b/.agents/skills/typescript/references/module-use-type-imports.md deleted file mode 100644 index bffd6d20b7a..00000000000 --- a/.agents/skills/typescript/references/module-use-type-imports.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: Use Type-Only Imports for Types -impact: HIGH -impactDescription: eliminates runtime imports for type information -tags: module, type-imports, tree-shaking, bundling, compilation ---- - -## Use Type-Only Imports for Types - -Type-only imports (`import type`) are completely erased during compilation, preventing unnecessary runtime module loading. Regular imports of types can force module execution even when only the type is needed. - -**Incorrect (runtime import for type-only usage):** - -```typescript -// config.ts -import { DatabaseConfig } from './database' // Loads entire database module -import { Logger } from './logger' // Loads entire logger module - -interface AppConfig { - db: DatabaseConfig - logger: Logger -} - -// Runtime: database.js and logger.js are both loaded -// even though we only use their types -``` - -**Correct (type-only imports):** - -```typescript -// config.ts -import type { DatabaseConfig } from './database' -import type { Logger } from './logger' - -interface AppConfig { - db: DatabaseConfig - logger: Logger -} - -// Runtime: no modules loaded, types are erased -``` - -**Mixed imports (types and values):** - -```typescript -// Incorrect - unclear what's type vs value -import { User, createUser, UserRole } from './user' - -// Correct - explicit separation -import { createUser } from './user' -import type { User, UserRole } from './user' - -// Or inline type imports (TypeScript 4.5+) -import { createUser, type User, type UserRole } from './user' -``` - -**Enable enforcement:** - -```json -// tsconfig.json -{ - "compilerOptions": { - "verbatimModuleSyntax": true - } -} - -// .eslintrc -{ - "rules": { - "@typescript-eslint/consistent-type-imports": "error" - } -} -``` - -**Benefits:** - -- Smaller bundles (unused modules not included) -- Faster cold starts (fewer modules to parse) -- Clearer code intent (types vs runtime values) - -Reference: [TypeScript 3.8 Release Notes](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export) diff --git a/.agents/skills/typescript/references/runtime-avoid-object-spread-in-loops.md b/.agents/skills/typescript/references/runtime-avoid-object-spread-in-loops.md deleted file mode 100644 index e01e455eb79..00000000000 --- a/.agents/skills/typescript/references/runtime-avoid-object-spread-in-loops.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: Avoid Object Spread in Hot Loops -impact: LOW-MEDIUM -impactDescription: reduces object allocations by N× -tags: runtime, object-spread, loops, allocation, performance ---- - -## Avoid Object Spread in Hot Loops - -Object spread (`...`) creates a new object on each use. In loops, this causes N object allocations and copies. Mutate objects directly when creating new instances isn't required. - -**Incorrect (N object allocations):** - -```typescript -function enrichOrders(orders: Order[]): EnrichedOrder[] { - return orders.map((order) => ({ - ...order, // Creates new object - ...calculateTotals(order), // Spreads another object - processedAt: new Date() - })) -} -// 10,000 orders = 10,000 object spreads = significant GC pressure -``` - -**Correct (direct assignment):** - -```typescript -interface EnrichedOrder extends Order { - tax: number - shipping: number - total: number - processedAt: Date -} - -function enrichOrders(orders: Order[]): EnrichedOrder[] { - return orders.map((order) => { - const totals = calculateTotals(order) - - return { - id: order.id, - customerId: order.customerId, - items: order.items, - subtotal: order.subtotal, - tax: totals.tax, - shipping: totals.shipping, - total: totals.total, - processedAt: new Date() - } - }) -} -``` - -**Note:** For immutable object creation, explicit property listing is the only spread-free option. This trades verbosity for performance in hot paths. If immutability isn't required, mutating the original object is faster still. - -**For accumulation patterns:** - -```typescript -// Incorrect - spreads on every iteration -const result = items.reduce( - (acc, item) => ({ - ...acc, - [item.id]: item.value - }), - {} -) -// O(n²) - each spread copies growing object - -// Correct - mutate accumulator -const result = items.reduce( - (acc, item) => { - acc[item.id] = item.value - return acc - }, - {} as Record -) -// O(n) - direct property assignment -``` - -**When spread is acceptable:** - -- Outside hot paths -- Small objects (< 10 properties) -- When immutability is required for state management -- When readability significantly improves - -Reference: [V8 Object Shapes](https://mathiasbynens.be/notes/shapes-ics) diff --git a/.agents/skills/typescript/references/runtime-cache-property-access.md b/.agents/skills/typescript/references/runtime-cache-property-access.md deleted file mode 100644 index eab402e5dce..00000000000 --- a/.agents/skills/typescript/references/runtime-cache-property-access.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Cache Property Access in Loops -impact: LOW-MEDIUM -impactDescription: reduces property lookups by N× in hot paths -tags: runtime, loops, caching, property-access, optimization ---- - -## Cache Property Access in Loops - -Cache deeply nested or polymorphic property access before hot loops. **Note:** Modern V8's inline caches optimize monomorphic access efficiently — this optimization is only meaningful for 10,000+ iterations with deeply nested or polymorphic properties. - -**Incorrect (repeated nested access in hot loop):** - -```typescript -function processOrders(orders: Order[], config: AppConfig): ProcessedOrder[] { - const results: ProcessedOrder[] = [] - - for (const order of orders) { - const tax = order.total * config.tax.rate // Nested access each iteration - const shipping = config.shipping.rates[order.region] // Nested access again - results.push({ ...order, tax, shipping, final: order.total + tax + shipping }) - } - - return results -} -``` - -**Correct (cached property access):** - -```typescript -function processOrders(orders: Order[], config: AppConfig): ProcessedOrder[] { - const results: ProcessedOrder[] = [] - const taxRate = config.tax.rate - const shippingRates = config.shipping.rates - - for (const order of orders) { - const tax = order.total * taxRate - const shipping = shippingRates[order.region] - results.push({ ...order, tax, shipping, final: order.total + tax + shipping }) - } - - return results -} -``` - -**When V8 handles it automatically (no caching needed):** - -```typescript -// Monomorphic — all objects have same shape, V8 ICs optimize this -function sumOrders(orders: Order[]): number { - let total = 0 - for (let i = 0; i < orders.length; i++) { - // orders.length is fine - total += orders[i].total // Same shape every time - } - return total -} -``` - -**When to skip this optimization:** - -- Arrays under 1,000 items -- Monomorphic objects (same shape/class) -- Non-hot paths executed infrequently -- When readability suffers significantly - -Reference: [V8 Hidden Classes](https://v8.dev/blog/fast-properties) diff --git a/.agents/skills/typescript/references/runtime-prefer-array-methods.md b/.agents/skills/typescript/references/runtime-prefer-array-methods.md deleted file mode 100644 index 950074a3987..00000000000 --- a/.agents/skills/typescript/references/runtime-prefer-array-methods.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: Prefer Native Array Methods Over Lodash -impact: LOW-MEDIUM -impactDescription: eliminates library overhead, enables tree-shaking -tags: runtime, arrays, lodash, native-methods, bundling ---- - -## Prefer Native Array Methods Over Lodash - -Modern JavaScript includes most common array operations. Native methods are faster (no function call overhead) and don't add bundle weight. Use native methods when they provide equivalent functionality. - -**Incorrect (lodash for native operations):** - -```typescript -import _ from 'lodash' // Imports entire library - -const activeUsers = _.filter(users, (u) => u.isActive) -const userNames = _.map(activeUsers, (u) => u.name) -const firstAdmin = _.find(users, (u) => u.role === 'admin') -const hasAdmin = _.some(users, (u) => u.role === 'admin') -const allActive = _.every(users, (u) => u.isActive) -const userIds = _.uniq(users.map((u) => u.id)) -``` - -**Correct (native methods):** - -```typescript -const activeUsers = users.filter((u) => u.isActive) -const userNames = activeUsers.map((u) => u.name) -const firstAdmin = users.find((u) => u.role === 'admin') -const hasAdmin = users.some((u) => u.role === 'admin') -const allActive = users.every((u) => u.isActive) -const userIds = [...new Set(users.map((u) => u.id))] -``` - -**Native replacements for common Lodash functions:** - -```typescript -// _.flatten / _.flattenDeep -const flat = nestedArrays.flat(Infinity) - -// _.chunk (still useful from lodash) -function chunk(array: T[], size: number): T[][] { - return Array.from({ length: Math.ceil(array.length / size) }, (_, i) => - array.slice(i * size, i * size + size) - ) -} - -// _.groupBy -function groupBy(array: T[], key: keyof T): Record { - return array.reduce( - (groups, item) => { - const group = String(item[key]) - groups[group] = groups[group] ?? [] - groups[group].push(item) - return groups - }, - {} as Record - ) -} - -// Object.groupBy (ES2024) -const grouped = Object.groupBy(users, (user) => user.role) - -// _.pick / _.omit -const { password, ...userWithoutPassword } = user // omit -const { id, name } = user // pick -``` - -**When Lodash is still valuable:** - -- `_.debounce`, `_.throttle` - complex timing logic -- `_.cloneDeep` - deep object cloning -- `_.merge` - deep object merging -- `_.get` with default values (but optional chaining often suffices) - -Reference: [You Don't Need Lodash](https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore) diff --git a/.agents/skills/typescript/references/runtime-use-for-of-for-iteration.md b/.agents/skills/typescript/references/runtime-use-for-of-for-iteration.md deleted file mode 100644 index d5961f86dbc..00000000000 --- a/.agents/skills/typescript/references/runtime-use-for-of-for-iteration.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: Use for-of for Simple Iteration -impact: LOW-MEDIUM -impactDescription: reduces iteration boilerplate by 30-50% -tags: runtime, loops, iteration, for-of, readability ---- - -## Use for-of for Simple Iteration - -`for-of` provides clean syntax for array iteration with performance comparable to traditional `for` loops. Use it when you don't need the index and aren't modifying the array. - -**Incorrect (index-based when index isn't needed):** - -```typescript -function calculateTotal(orders: Order[]): number { - let total = 0 - for (let i = 0; i < orders.length; i++) { - total += orders[i].amount - } - return total -} - -function processUsers(users: User[]): void { - for (let i = 0; i < users.length; i++) { - sendNotification(users[i]) - } -} -``` - -**Correct (for-of for clean iteration):** - -```typescript -function calculateTotal(orders: Order[]): number { - let total = 0 - for (const order of orders) { - total += order.amount - } - return total -} - -function processUsers(users: User[]): void { - for (const user of users) { - sendNotification(user) - } -} -``` - -**When to use each pattern:** - -```typescript -// for-of: when you only need values -for (const item of items) { - process(item) -} - -// forEach: when you want functional style (but can't break/return) -items.forEach((item) => process(item)) - -// for-in: only for object keys (never for arrays) -for (const key in config) { - console.log(key, config[key]) -} - -// Traditional for: when you need index, or need to modify loop -for (let i = 0; i < items.length; i++) { - if (items[i].id === targetId) { - items[i] = updatedItem // Modifying array - break // Early exit - } -} - -// entries(): when you need both index and value -for (const [index, item] of items.entries()) { - console.log(`${index}: ${item.name}`) -} -``` - -**Avoid for-in for arrays:** - -```typescript -// NEVER do this -for (const index in items) { - // index is a string, not number - // Iterates inherited properties - // Wrong order not guaranteed -} -``` - -Reference: [MDN for...of](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of) diff --git a/.agents/skills/typescript/references/runtime-use-set-for-lookups.md b/.agents/skills/typescript/references/runtime-use-set-for-lookups.md deleted file mode 100644 index eb4734134c7..00000000000 --- a/.agents/skills/typescript/references/runtime-use-set-for-lookups.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Use Set/Map for O(1) Lookups -impact: LOW-MEDIUM -impactDescription: O(n) to O(1) per lookup -tags: runtime, set, map, lookup, performance ---- - -## Use Set/Map for O(1) Lookups - -Array methods like `.includes()` and `.find()` are O(n) operations. For frequent lookups, convert arrays to Set or Map for O(1) access. - -**Incorrect (O(n) per lookup):** - -```typescript -const allowedRoles = ['admin', 'editor', 'viewer', 'moderator'] - -function hasPermission(userRole: string): boolean { - return allowedRoles.includes(userRole) // O(n) every call -} - -// In a loop, this becomes O(n × m) -function filterAuthorizedUsers(users: User[]): User[] { - return users.filter((user) => allowedRoles.includes(user.role)) - // 1000 users × 4 roles = 4000 comparisons -} -``` - -**Correct (O(1) per lookup):** - -```typescript -const allowedRoles = new Set(['admin', 'editor', 'viewer', 'moderator']) - -function hasPermission(userRole: string): boolean { - return allowedRoles.has(userRole) // O(1) every call -} - -function filterAuthorizedUsers(users: User[]): User[] { - return users.filter((user) => allowedRoles.has(user.role)) - // 1000 users × O(1) = 1000 operations -} -``` - -**For object lookups by key:** - -```typescript -// Incorrect - O(n) search -const users: User[] = [ - /* ... */ -] -function findUserById(id: string): User | undefined { - return users.find((u) => u.id === id) // Scans entire array -} - -// Correct - O(1) lookup -const userById = new Map(users.map((u) => [u.id, u])) -function findUserById(id: string): User | undefined { - return userById.get(id) -} -``` - -**When to stick with arrays:** - -- Small collections (< 10 items) -- One-time lookups where conversion cost exceeds benefit -- When you need array methods like `.map()`, `.filter()`, `.slice()` - -Reference: [MDN Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set) diff --git a/.agents/skills/typescript/references/runtime-use-string-methods.md b/.agents/skills/typescript/references/runtime-use-string-methods.md deleted file mode 100644 index e908fb95a9e..00000000000 --- a/.agents/skills/typescript/references/runtime-use-string-methods.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: Use Modern String Methods -impact: LOW-MEDIUM -impactDescription: 2-5× faster than regex for simple patterns -tags: runtime, strings, methods, performance, readability ---- - -## Use Modern String Methods - -Modern string methods like `startsWith()`, `endsWith()`, `includes()`, and `padStart()` are clearer and often faster than regex or manual substring operations. - -**Incorrect (regex or substring for simple checks):** - -```typescript -function isImageFile(filename: string): boolean { - return /\.(jpg|png|gif)$/.test(filename) -} - -function hasHttpPrefix(url: string): boolean { - return url.substring(0, 7) === 'http://' || url.substring(0, 8) === 'https://' -} - -function containsSearchTerm(text: string, term: string): boolean { - return text.indexOf(term) !== -1 -} - -function formatOrderId(id: number): string { - return ('000000' + id).slice(-6) // Pad to 6 digits -} -``` - -**Correct (modern string methods):** - -```typescript -function isImageFile(filename: string): boolean { - return filename.endsWith('.jpg') || filename.endsWith('.png') || filename.endsWith('.gif') -} - -function hasHttpPrefix(url: string): boolean { - return url.startsWith('http://') || url.startsWith('https://') -} - -function containsSearchTerm(text: string, term: string): boolean { - return text.includes(term) -} - -function formatOrderId(id: number): string { - return String(id).padStart(6, '0') -} -``` - -**Additional useful methods:** - -```typescript -// replaceAll (no global regex needed) -const sanitized = input.replaceAll('<', '<').replaceAll('>', '>') - -// at() for negative indexing -const lastChar = filename.at(-1) // Last character -const extension = filename.split('.').at(-1) // Last segment - -// trimStart/trimEnd for directional trimming -const trimmedLeft = ' text '.trimStart() // 'text ' -const trimmedRight = ' text '.trimEnd() // ' text' - -// repeat for string multiplication -const separator = '-'.repeat(40) -const indent = ' '.repeat(depth) -``` - -**When regex is still needed:** - -- Complex pattern matching -- Capture groups -- Case-insensitive matching (`/pattern/i`) -- Multiple conditions in one check - -Reference: [MDN String Methods](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String) diff --git a/.agents/skills/typescript/references/safety-assertion-functions.md b/.agents/skills/typescript/references/safety-assertion-functions.md deleted file mode 100644 index e9472e4522e..00000000000 --- a/.agents/skills/typescript/references/safety-assertion-functions.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: Use Assertion Functions for Validation -impact: MEDIUM-HIGH -impactDescription: reduces validation boilerplate by 50-70% -tags: safety, assertion-functions, asserts, validation, narrowing ---- - -## Use Assertion Functions for Validation - -Assertion functions (`asserts` return type) tell TypeScript that if the function returns, the condition is true. This narrows types in the calling scope without explicit if-checks. - -**Incorrect (repeated if-throw pattern):** - -```typescript -function processOrder(order: Order | null): void { - if (!order) { - throw new Error('Order is required') - } - if (order.status !== 'pending') { - throw new Error('Order must be pending') - } - if (!order.items.length) { - throw new Error('Order must have items') - } - - // Finally can use order safely - submitOrder(order) -} - -// Same checks repeated in every function that needs a valid order -function shipOrder(order: Order | null): void { - if (!order) throw new Error('Order is required') - if (order.status !== 'pending') throw new Error('Order must be pending') - // ...duplicate validation -} -``` - -**Correct (assertion function):** - -```typescript -interface ValidOrder extends Order { - status: 'pending' - items: [OrderItem, ...OrderItem[]] // Non-empty array -} - -function assertValidOrder(order: Order | null): asserts order is ValidOrder { - if (!order) { - throw new Error('Order is required') - } - if (order.status !== 'pending') { - throw new Error('Order must be pending') - } - if (!order.items.length) { - throw new Error('Order must have items') - } -} - -function processOrder(order: Order | null): void { - assertValidOrder(order) - // order is now typed as ValidOrder - submitOrder(order) // Type-safe -} - -function shipOrder(order: Order | null): void { - assertValidOrder(order) - // Reuses validation, order is ValidOrder - ship(order) -} -``` - -**For generic assertions:** - -```typescript -function assertDefined(value: T | null | undefined, name: string): asserts value is T { - if (value === null || value === undefined) { - throw new Error(`${name} must be defined`) - } -} - -function processUser(user: User | null): void { - assertDefined(user, 'user') - // user is now User, not User | null - console.log(user.email) -} -``` - -**Benefits:** - -- Centralizes validation logic -- Automatic type narrowing after assertion -- Clearer intent than if-throw patterns - -Reference: [TypeScript 3.7 Assertion Functions](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions) diff --git a/.agents/skills/typescript/references/safety-const-assertions.md b/.agents/skills/typescript/references/safety-const-assertions.md deleted file mode 100644 index 3f978ccc13d..00000000000 --- a/.agents/skills/typescript/references/safety-const-assertions.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: Use const Assertions for Literal Types -impact: MEDIUM-HIGH -impactDescription: preserves literal types, enables better inference -tags: safety, const-assertion, literals, inference, readonly ---- - -## Use const Assertions for Literal Types - -The `as const` assertion preserves literal types and makes arrays/objects readonly. This enables precise type inference and prevents accidental mutations. - -**Incorrect (widened types):** - -```typescript -const config = { - apiUrl: 'https://api.example.com', - retries: 3, - methods: ['GET', 'POST'] -} -// Type: { apiUrl: string; retries: number; methods: string[] } - -function makeRequest(method: 'GET' | 'POST'): void {} - -makeRequest(config.methods[0]) -// Error: Argument of type 'string' is not assignable to 'GET' | 'POST' - -const STATUS = { - PENDING: 'pending', - ACTIVE: 'active' -} -// Type: { PENDING: string; ACTIVE: string } -``` - -**Correct (const assertion preserves literals):** - -```typescript -const config = { - apiUrl: 'https://api.example.com', - retries: 3, - methods: ['GET', 'POST'] -} as const -// Type: { readonly apiUrl: 'https://api.example.com'; readonly retries: 3; readonly methods: readonly ['GET', 'POST'] } - -function makeRequest(method: 'GET' | 'POST'): void {} - -makeRequest(config.methods[0]) // Works: 'GET' is assignable to 'GET' | 'POST' - -const STATUS = { - PENDING: 'pending', - ACTIVE: 'active' -} as const -// Type: { readonly PENDING: 'pending'; readonly ACTIVE: 'active' } - -type StatusType = (typeof STATUS)[keyof typeof STATUS] // 'pending' | 'active' -``` - -**For function parameters:** - -```typescript -// Incorrect - tuple becomes array -function setCoordinates(coords: [number, number]): void {} -setCoordinates([10, 20]) // Error: number[] not assignable to [number, number] - -// Correct - const preserves tuple -setCoordinates([10, 20] as const) // Works - -// Or inline -function setCoordinates(coords: readonly [number, number]): void {} -``` - -**When to use const assertions:** - -- Configuration objects that shouldn't change -- Enum-like objects with string values -- Array/tuple literals passed to functions expecting specific types -- Creating type-safe lookup tables - -Reference: [TypeScript 3.4 Const Assertions](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#const-assertions) diff --git a/.agents/skills/typescript/references/safety-exhaustive-checks.md b/.agents/skills/typescript/references/safety-exhaustive-checks.md deleted file mode 100644 index 9bc887ebd30..00000000000 --- a/.agents/skills/typescript/references/safety-exhaustive-checks.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: Use Exhaustive Checks for Union Types -impact: MEDIUM-HIGH -impactDescription: prevents 100% of missing case errors at compile time -tags: safety, exhaustive, never, discriminated-unions, switch ---- - -## Use Exhaustive Checks for Union Types - -Exhaustive checks ensure all union members are handled. When a new member is added, TypeScript errors on unhandled cases rather than falling through silently at runtime. - -**Incorrect (missing case compiles but fails at runtime):** - -```typescript -type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' - -function getStatusMessage(status: OrderStatus): string { - switch (status) { - case 'pending': - return 'Order received' - case 'processing': - return 'Preparing your order' - case 'shipped': - return 'On the way' - // 'delivered' case missing - no compile error - // Returns undefined at runtime - } -} - -// Later, someone adds 'cancelled' to OrderStatus -// This function silently returns undefined for 'cancelled' and 'delivered' -``` - -**Correct (exhaustive check with never):** - -```typescript -type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' - -function assertNever(value: never): never { - throw new Error(`Unhandled value: ${value}`) -} - -function getStatusMessage(status: OrderStatus): string { - switch (status) { - case 'pending': - return 'Order received' - case 'processing': - return 'Preparing your order' - case 'shipped': - return 'On the way' - case 'delivered': - return 'Order complete' - default: - return assertNever(status) // Compile error if case missed - } -} - -// Adding 'cancelled' to OrderStatus now causes compile error: -// Argument of type 'string' is not assignable to parameter of type 'never' -``` - -**For object mapping (alternative pattern):** - -```typescript -const statusMessages: Record = { - pending: 'Order received', - processing: 'Preparing your order', - shipped: 'On the way', - delivered: 'Order complete' - // Missing key causes: Property 'cancelled' is missing in type -} - -function getStatusMessage(status: OrderStatus): string { - return statusMessages[status] -} -``` - -**Benefits:** - -- Compile-time error when union expands -- Self-documenting: all cases explicitly handled -- Runtime safety via assertNever fallback - -Reference: [TypeScript Handbook - Exhaustiveness Checking](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking) diff --git a/.agents/skills/typescript/references/safety-no-unchecked-indexed-access.md b/.agents/skills/typescript/references/safety-no-unchecked-indexed-access.md deleted file mode 100644 index 125bccb029d..00000000000 --- a/.agents/skills/typescript/references/safety-no-unchecked-indexed-access.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: Enable noUncheckedIndexedAccess -impact: MEDIUM-HIGH -impactDescription: prevents 100% of unchecked index access errors at compile time -tags: safety, noUncheckedIndexedAccess, strict, arrays, undefined ---- - -## Enable noUncheckedIndexedAccess - -With `noUncheckedIndexedAccess` enabled, TypeScript adds `undefined` to the type of array elements and index signature properties. This catches one of the most common sources of runtime errors — accessing elements that may not exist. - -**Incorrect (without noUncheckedIndexedAccess):** - -```json -{ - "compilerOptions": { - "strict": true - } -} -``` - -```typescript -const users = ['Alice', 'Bob', 'Charlie'] -const first = users[0] // Type: string (lies — could be undefined) -console.log(first.toUpperCase()) // No error, but crashes if array is empty - -const scores: Record = { math: 95 } -const science = scores['science'] // Type: number (lies — key doesn't exist) -console.log(science.toFixed(2)) // No error, but crashes at runtime -``` - -**Correct (with noUncheckedIndexedAccess):** - -```json -{ - "compilerOptions": { - "strict": true, - "noUncheckedIndexedAccess": true - } -} -``` - -```typescript -const users = ['Alice', 'Bob', 'Charlie'] -const first = users[0] // Type: string | undefined -console.log(first.toUpperCase()) // Error: 'first' is possibly undefined - -// Handle the undefined case -if (first) { - console.log(first.toUpperCase()) // OK after narrowing -} - -const scores: Record = { math: 95 } -const science = scores['science'] // Type: number | undefined -if (science !== undefined) { - console.log(science.toFixed(2)) // OK after narrowing -} -``` - -**Common patterns with noUncheckedIndexedAccess:** - -```typescript -// Array destructuring — first element is T | undefined -const [head, ...rest] = items -if (head) { - processItem(head) -} - -// Use non-null assertion only when you've validated -function getRequired(items: string[], index: number): string { - if (index < 0 || index >= items.length) { - throw new RangeError(`Index ${index} out of bounds`) - } - return items[index]! // Safe — bounds checked above -} - -// Array.at() returns T | undefined regardless of this flag -const last = items.at(-1) // Already T | undefined -``` - -**When to disable:** - -- Legacy codebases with heavy array indexing (migration cost too high) -- Performance-critical inner loops where the narrowing pattern adds overhead - -Reference: [TypeScript tsconfig - noUncheckedIndexedAccess](https://www.typescriptlang.org/tsconfig/noUncheckedIndexedAccess.html) diff --git a/.agents/skills/typescript/references/safety-prefer-unknown-over-any.md b/.agents/skills/typescript/references/safety-prefer-unknown-over-any.md deleted file mode 100644 index a60218b55e6..00000000000 --- a/.agents/skills/typescript/references/safety-prefer-unknown-over-any.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: Prefer unknown Over any -impact: MEDIUM-HIGH -impactDescription: forces type narrowing, prevents runtime errors -tags: safety, unknown, any, type-narrowing, type-safety ---- - -## Prefer unknown Over any - -The `any` type disables all type checking, allowing unsafe operations to pass silently. Use `unknown` to require explicit type narrowing before operations. - -**Incorrect (any bypasses all checks):** - -```typescript -function processApiResponse(data: any): string { - return data.user.name.toUpperCase() - // No error even if data is null, has no user, or name isn't a string - // Runtime: TypeError: Cannot read property 'name' of undefined -} - -async function fetchData(): Promise { - const response = await fetch('/api/data') - return response.json() // Returns Promise, loses all type info -} -``` - -**Correct (unknown requires narrowing):** - -```typescript -interface ApiResponse { - user: { - name: string - } -} - -function isApiResponse(data: unknown): data is ApiResponse { - return ( - typeof data === 'object' && - data !== null && - 'user' in data && - typeof (data as ApiResponse).user?.name === 'string' - ) -} - -function processApiResponse(data: unknown): string { - if (!isApiResponse(data)) { - throw new Error('Invalid API response') - } - return data.user.name.toUpperCase() // Type-safe access -} -``` - -**For JSON parsing:** - -```typescript -// Incorrect -const config = JSON.parse(configString) as AppConfig // Unsafe assertion - -// Correct -function parseConfig(configString: string): AppConfig { - const parsed: unknown = JSON.parse(configString) - - if (!isValidConfig(parsed)) { - throw new Error('Invalid config format') - } - - return parsed -} -``` - -**When any is acceptable:** - -- Migrating JavaScript to TypeScript incrementally -- Third-party library workarounds (with `// @ts-expect-error`) -- Truly dynamic code where type is unknowable - -Reference: [TypeScript Handbook - Unknown](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#the-unknown-type) diff --git a/.agents/skills/typescript/references/safety-strict-null-checks.md b/.agents/skills/typescript/references/safety-strict-null-checks.md deleted file mode 100644 index c155bb32e0e..00000000000 --- a/.agents/skills/typescript/references/safety-strict-null-checks.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -title: Enable strictNullChecks -impact: MEDIUM-HIGH -impactDescription: prevents null/undefined runtime errors -tags: safety, strictNullChecks, null, undefined, strict ---- - -## Enable strictNullChecks - -With `strictNullChecks`, TypeScript distinguishes between `T`, `T | null`, and `T | undefined`. This catches null pointer exceptions at compile time instead of runtime. - -**Incorrect (strictNullChecks disabled):** - -```typescript -// tsconfig.json: { "strictNullChecks": false } - -function getUser(id: string): User { - return userMap.get(id) // Returns User | undefined, but typed as User -} - -const user = getUser('123') -console.log(user.email) // No error, but crashes if user is undefined -``` - -**Correct (strictNullChecks enabled):** - -```typescript -// tsconfig.json: { "strict": true } (includes strictNullChecks) - -function getUser(id: string): User | undefined { - return userMap.get(id) // Correctly typed as User | undefined -} - -const user = getUser('123') -console.log(user.email) // Error: 'user' is possibly 'undefined' - -// Must handle the undefined case -if (user) { - console.log(user.email) // Type narrowed to User -} - -// Or use optional chaining -console.log(user?.email) // string | undefined - -// Or assert when you're certain -const confirmedUser = getUser('123')! // Non-null assertion (use sparingly) -``` - -**Common patterns with strictNullChecks:** - -```typescript -// Default values -function greet(name: string | undefined): string { - return `Hello, ${name ?? 'Guest'}` -} - -// Guard clauses -function processOrder(order: Order | null): void { - if (!order) { - throw new Error('Order is required') - } - // order is narrowed to Order - ship(order) -} - -// Optional chaining with nullish coalescing -const street = user?.address?.street ?? 'Unknown' -``` - -**Note:** Always enable `strict: true` which includes `strictNullChecks` along with other safety checks. - -Reference: [TypeScript Handbook - Strict Null Checks](https://www.typescriptlang.org/tsconfig#strictNullChecks) diff --git a/.agents/skills/typescript/references/safety-use-type-guards.md b/.agents/skills/typescript/references/safety-use-type-guards.md deleted file mode 100644 index 7039d85dbac..00000000000 --- a/.agents/skills/typescript/references/safety-use-type-guards.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: Use Type Guards for Runtime Type Checking -impact: MEDIUM-HIGH -impactDescription: eliminates type assertions, catches errors at boundaries -tags: safety, type-guards, narrowing, predicates, validation ---- - -## Use Type Guards for Runtime Type Checking - -Type guards provide runtime validation that TypeScript can use for static narrowing. They replace unsafe type assertions with checked operations. - -**Incorrect (type assertions without validation):** - -```typescript -interface User { - id: string - email: string - role: 'admin' | 'user' -} - -function handleUserEvent(event: MessageEvent): void { - const user = event.data as User // Unsafe assertion - sendEmail(user.email) // Crashes if data isn't actually a User -} - -function processResponse(data: unknown): User[] { - return data as User[] // No runtime check -} -``` - -**Correct (type guard with validation):** - -```typescript -function isUser(value: unknown): value is User { - return ( - typeof value === 'object' && - value !== null && - typeof (value as User).id === 'string' && - typeof (value as User).email === 'string' && - ['admin', 'user'].includes((value as User).role) - ) -} - -function handleUserEvent(event: MessageEvent): void { - if (!isUser(event.data)) { - console.error('Invalid user data received') - return - } - sendEmail(event.data.email) // Type-safe: event.data is User -} - -function processResponse(data: unknown): User[] { - if (!Array.isArray(data)) return [] - return data.filter(isUser) -} -``` - -**For discriminated unions:** - -```typescript -interface SuccessResult { - status: 'success' - data: User -} - -interface ErrorResult { - status: 'error' - message: string -} - -type ApiResult = SuccessResult | ErrorResult - -function isSuccess(result: ApiResult): result is SuccessResult { - return result.status === 'success' -} - -function handleResult(result: ApiResult): void { - if (isSuccess(result)) { - console.log(result.data.email) // Type narrowed to SuccessResult - } else { - console.error(result.message) // Type narrowed to ErrorResult - } -} -``` - -Reference: [TypeScript Handbook - Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) diff --git a/.agents/skills/typescript/references/tscfg-enable-incremental.md b/.agents/skills/typescript/references/tscfg-enable-incremental.md deleted file mode 100644 index eda2a96df89..00000000000 --- a/.agents/skills/typescript/references/tscfg-enable-incremental.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: Enable Incremental Compilation -impact: CRITICAL -impactDescription: 50-90% faster rebuilds -tags: tscfg, incremental, tsconfig, compilation, caching ---- - -## Enable Incremental Compilation - -Incremental compilation caches project graph information between builds in a `.tsbuildinfo` file. Subsequent compilations only recheck changed files and their dependents. - -**Incorrect (full rebuild every time):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "strict": true - } -} -``` - -```bash -tsc # 15 seconds every build -``` - -**Correct (incremental builds):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "strict": true, - "incremental": true, - "tsBuildInfoFile": "./dist/.tsbuildinfo" - } -} -``` - -```bash -tsc # 15s first build, 1-3s subsequent builds -``` - -**For monorepos (composite projects):** - -```json -{ - "compilerOptions": { - "composite": true, - "declaration": true, - "declarationMap": true - }, - "references": [{ "path": "../shared" }, { "path": "../utils" }] -} -``` - -**Note:** The `composite` flag implies `incremental: true` and requires `declaration: true`. - -**When to disable incremental:** - -- CI environments where cache isn't preserved between runs -- One-off type-checking scripts - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#incremental-project-emit) diff --git a/.agents/skills/typescript/references/tscfg-erasable-syntax-only.md b/.agents/skills/typescript/references/tscfg-erasable-syntax-only.md deleted file mode 100644 index f8c2dfc7baf..00000000000 --- a/.agents/skills/typescript/references/tscfg-erasable-syntax-only.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: Use erasableSyntaxOnly for Node.js Native TypeScript -impact: HIGH -impactDescription: prevents 100% of Node.js type-stripping runtime errors -tags: tscfg, erasableSyntaxOnly, node, type-stripping, enums ---- - -## Use erasableSyntaxOnly for Node.js Native TypeScript - -The `erasableSyntaxOnly` flag (TypeScript 5.8+) ensures your code only uses TypeScript syntax that can be removed by erasing type annotations — no code generation required. This is mandatory for Node.js `--experimental-strip-types` which strips types but cannot transform enums, namespaces, or parameter properties. - -**Incorrect (non-erasable syntax fails with Node.js type-stripping):** - -```json -{ - "compilerOptions": { - "erasableSyntaxOnly": true - } -} -``` - -```typescript -// Error: non-erasable syntax -export enum OrderStatus { - Pending = 'pending', - Shipped = 'shipped', - Delivered = 'delivered' -} - -// Error: non-erasable syntax -namespace Validation { - export function isValid(input: string): boolean { - return input.length > 0 - } -} - -// Error: non-erasable parameter property -class UserService { - constructor(private readonly repository: UserRepository) {} -} -``` - -**Correct (erasable alternatives):** - -```typescript -// Union type instead of enum -export type OrderStatus = 'pending' | 'shipped' | 'delivered' - -// Object constant for runtime values -export const OrderStatus = { - Pending: 'pending', - Shipped: 'shipped', - Delivered: 'delivered' -} as const satisfies Record - -// Module-level functions instead of namespace -export function isValid(input: string): boolean { - return input.length > 0 -} - -// Explicit property assignment instead of parameter property -class UserService { - readonly repository: UserRepository - constructor(repository: UserRepository) { - this.repository = repository - } -} -``` - -**Recommended configuration for Node.js native TS:** - -```json -{ - "compilerOptions": { - "erasableSyntaxOnly": true, - "verbatimModuleSyntax": true, - "isolatedModules": true - } -} -``` - -**When NOT to use this flag:** - -- Projects using a bundler (esbuild, swc, Vite) that supports enum transformation -- Libraries that need to support both bundled and unbundled consumers -- Codebases with extensive enum usage where migration cost is high - -Reference: [TypeScript 5.8 - erasableSyntaxOnly](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-8.html) diff --git a/.agents/skills/typescript/references/tscfg-exclude-properly.md b/.agents/skills/typescript/references/tscfg-exclude-properly.md deleted file mode 100644 index a96ce5db3a6..00000000000 --- a/.agents/skills/typescript/references/tscfg-exclude-properly.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: Configure Include and Exclude Properly -impact: CRITICAL -impactDescription: prevents scanning thousands of unnecessary files -tags: tscfg, include, exclude, tsconfig, file-discovery ---- - -## Configure Include and Exclude Properly - -TypeScript walks through all included directories to discover files. Overly broad `include` patterns or missing `exclude` patterns force the compiler to scan irrelevant directories, significantly slowing startup. - -**Incorrect (scans entire project tree):** - -```json -{ - "compilerOptions": { - "outDir": "dist" - }, - "include": ["**/*"] -} -``` - -```bash -# Scans node_modules, dist, coverage, .git... -# Discovery time: 5+ seconds on large projects -``` - -**Correct (targeted include with explicit exclude):** - -```json -{ - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "coverage", "**/*.test.ts", "**/*.spec.ts", "**/__tests__/**"] -} -``` - -```bash -# Only scans src/ directory -# Discovery time: <1 second -``` - -**For separate test configuration:** - -```json -// tsconfig.json (production) -{ - "include": ["src/**/*"], - "exclude": ["**/*.test.ts"] -} - -// tsconfig.test.json -{ - "extends": "./tsconfig.json", - "include": ["src/**/*", "tests/**/*"] -} -``` - -**Diagnostic commands:** - -```bash -# List all files TypeScript will compile -tsc --listFiles - -# Explain why each file was included -tsc --explainFiles -``` - -**Common files to exclude:** - -- `node_modules` (always) -- Build output directories (`dist`, `build`, `out`) -- Test files for production builds -- Generated files (`.generated.ts`) -- Coverage reports (`coverage`) - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#configuring-tsconfigjson-or-jsconfigjson) diff --git a/.agents/skills/typescript/references/tscfg-isolate-modules.md b/.agents/skills/typescript/references/tscfg-isolate-modules.md deleted file mode 100644 index 813d0dc0ab2..00000000000 --- a/.agents/skills/typescript/references/tscfg-isolate-modules.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: Use isolatedModules for Single-File Transpilation -impact: CRITICAL -impactDescription: 80-90% faster transpilation with bundlers -tags: tscfg, isolatedModules, transpilation, bundlers, performance ---- - -## Use isolatedModules for Single-File Transpilation - -The `isolatedModules` flag ensures each file can be transpiled independently, enabling parallel transpilation by bundlers like esbuild, swc, or Babel. This bypasses TypeScript's slower multi-file analysis. - -**Incorrect (requires cross-file analysis):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext" - } -} -``` - -```typescript -// constants.ts -export const enum Status { - Active = 'active', - Inactive = 'inactive' -} - -// user.ts -import { Status } from './constants' -const status = Status.Active // Requires reading constants.ts to inline -``` - -**Correct (single-file transpilable):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "isolatedModules": true, - "verbatimModuleSyntax": true - } -} -``` - -```typescript -// constants.ts -export enum Status { - // Regular enum, not const enum - Active = 'active', - Inactive = 'inactive' -} - -// user.ts -import { Status } from './constants' -const status = Status.Active // Reference preserved, no cross-file read -``` - -**Build pipeline integration:** - -```javascript -// vite.config.ts -export default { - esbuild: { - // esbuild transpiles files in parallel - // TypeScript only runs type-checking - } -} -``` - -**Code patterns blocked by isolatedModules:** - -- `const enum` (use regular `enum` or union types instead) -- `export =` / `import =` syntax -- Re-exporting types without `type` keyword - -**Note:** With `erasableSyntaxOnly` (TypeScript 5.8+), regular enums are also blocked. Use union types (`type Status = 'active' | 'inactive'`) as the universal safe alternative. See `tscfg-erasable-syntax-only.md`. - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#isolated-file-emit) diff --git a/.agents/skills/typescript/references/tscfg-isolated-declarations.md b/.agents/skills/typescript/references/tscfg-isolated-declarations.md deleted file mode 100644 index bac3fd9aae2..00000000000 --- a/.agents/skills/typescript/references/tscfg-isolated-declarations.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: Enable isolatedDeclarations for Parallel Declaration Emit -impact: CRITICAL -impactDescription: enables parallel .d.ts generation without type-checker -tags: tscfg, isolatedDeclarations, declarations, parallel, performance ---- - -## Enable isolatedDeclarations for Parallel Declaration Emit - -The `isolatedDeclarations` flag (TypeScript 5.5+) ensures each file's exports are annotated sufficiently for tools to generate `.d.ts` files without running the type-checker. This enables parallel declaration emit via bundlers and dramatically speeds up builds in large codebases. - -**Incorrect (declaration emit requires full type-check):** - -```json -{ - "compilerOptions": { - "declaration": true - } -} -``` - -```typescript -// utils.ts -export function calculateTotal(items: CartItem[]) { - // Return type inferred — requires type-checker to generate .d.ts - return items.reduce((sum, item) => sum + item.price * item.quantity, 0) -} -``` - -**Correct (explicit annotations enable parallel emit):** - -```json -{ - "compilerOptions": { - "declaration": true, - "isolatedDeclarations": true - } -} -``` - -```typescript -// utils.ts -export function calculateTotal(items: CartItem[]): number { - // Explicit return type — .d.ts can be generated per-file, in parallel - return items.reduce((sum, item) => sum + item.price * item.quantity, 0) -} -``` - -**What requires annotation under isolatedDeclarations:** - -- Exported function return types -- Exported variable types when not inferable from a literal -- Exported class method return types - -**What does NOT need annotation:** - -- Local variables and functions (not exported) -- Function parameters (already required by TypeScript) -- Exports initialized with literals (`export const MAX = 100` is fine) - -**Pair with isolatedModules for maximum build speed:** - -```json -{ - "compilerOptions": { - "isolatedModules": true, - "isolatedDeclarations": true, - "declaration": true - } -} -``` - -Reference: [TypeScript 5.5 - Isolated Declarations](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-5.html#isolated-declarations) diff --git a/.agents/skills/typescript/references/tscfg-project-references.md b/.agents/skills/typescript/references/tscfg-project-references.md deleted file mode 100644 index 634771bc870..00000000000 --- a/.agents/skills/typescript/references/tscfg-project-references.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: Use Project References for Large Codebases -impact: CRITICAL -impactDescription: 60-80% faster incremental builds -tags: tscfg, project-references, monorepo, tsconfig, compilation ---- - -## Use Project References for Large Codebases - -Project references split a codebase into independent compilation units. Each project compiles separately, enabling parallel builds and preventing the compiler from loading the entire codebase at once. - -**Incorrect (monolithic tsconfig):** - -```text -my-app/ -├── tsconfig.json # Single config for entire app -├── packages/ -│ ├── api/src/ -│ ├── web/src/ -│ └── shared/src/ -``` - -```json -{ - "compilerOptions": { "outDir": "dist" }, - "include": ["packages/*/src/**/*"] -} -``` - -```bash -# Loads ALL files into memory for every change -# Change in api/ triggers full recompile -``` - -**Correct (project references):** - -```text -my-app/ -├── tsconfig.json # Root config with references -├── packages/ -│ ├── api/ -│ │ └── tsconfig.json # References shared -│ ├── web/ -│ │ └── tsconfig.json # References shared -│ └── shared/ -│ └── tsconfig.json # No references (leaf) -``` - -```json -// packages/shared/tsconfig.json -{ - "compilerOptions": { - "composite": true, - "declaration": true, - "outDir": "dist" - }, - "include": ["src/**/*"] -} -``` - -```json -// packages/api/tsconfig.json -{ - "compilerOptions": { - "composite": true, - "declaration": true, - "outDir": "dist" - }, - "references": [{ "path": "../shared" }], - "include": ["src/**/*"] -} -``` - -```json -// tsconfig.json (root) -{ - "files": [], - "references": [ - { "path": "packages/shared" }, - { "path": "packages/api" }, - { "path": "packages/web" } - ] -} -``` - -```bash -tsc --build # Builds only changed projects -``` - -**Benefits:** - -- Parallel compilation of independent projects -- Change in `shared/` only rebuilds dependents -- Declaration files used as API boundaries - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#using-project-references) diff --git a/.agents/skills/typescript/references/tscfg-skip-lib-check.md b/.agents/skills/typescript/references/tscfg-skip-lib-check.md deleted file mode 100644 index d25db6e1d7e..00000000000 --- a/.agents/skills/typescript/references/tscfg-skip-lib-check.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Enable skipLibCheck for Faster Builds -impact: CRITICAL -impactDescription: 20-40% faster compilation -tags: tscfg, skipLibCheck, tsconfig, declaration-files, performance ---- - -## Enable skipLibCheck for Faster Builds - -The `skipLibCheck` option skips type-checking of declaration files (`.d.ts`). Since these files are pre-verified by library authors, checking them is redundant and wastes compilation time. - -**Incorrect (checks all declaration files):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "strict": true - } -} -``` - -```bash -# Checks thousands of .d.ts files in node_modules -# Compilation time: 25 seconds -``` - -**Correct (skips declaration file checks):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "strict": true, - "skipLibCheck": true - } -} -``` - -```bash -# Only checks your source files -# Compilation time: 15 seconds (40% faster) -``` - -**Alternative (more conservative):** - -```json -{ - "compilerOptions": { - "skipDefaultLibCheck": true - } -} -``` - -This only skips checking the default library files (lib.d.ts), not third-party declarations. - -**When to disable skipLibCheck:** - -- Debugging type conflicts between declaration files -- Publishing a library where you want to verify `.d.ts` output -- Encountering mysterious type errors that might originate in declarations - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#skipping-d-ts-checking) diff --git a/.agents/skills/typescript/references/tscfg-strict-function-types.md b/.agents/skills/typescript/references/tscfg-strict-function-types.md deleted file mode 100644 index 8e3bef2ed3b..00000000000 --- a/.agents/skills/typescript/references/tscfg-strict-function-types.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Enable strictFunctionTypes for Faster Variance Checks -impact: CRITICAL -impactDescription: enables optimized variance checking -tags: tscfg, strict, strictFunctionTypes, variance, performance ---- - -## Enable strictFunctionTypes for Faster Variance Checks - -With `strictFunctionTypes` enabled, TypeScript uses fast variance-based checking for function parameters. Without it, TypeScript falls back to slower structural comparison for every function type. - -**Incorrect (slow structural checking):** - -```json -{ - "compilerOptions": { - "strict": false, - "strictFunctionTypes": false - } -} -``` - -```typescript -type Handler = (event: T) => void - -// Without strictFunctionTypes, TypeScript uses bidirectional -// (bivariant) checking - comparing structures both ways -const handler: Handler = (e: Event) => {} // Allowed but unsafe -``` - -**Correct (fast variance checking):** - -```json -{ - "compilerOptions": { - "strict": true - } -} -``` - -```typescript -type Handler = (event: T) => void - -// With strictFunctionTypes, TypeScript uses contravariant -// checking for parameters - faster and type-safe -const handler: Handler = (e: Event) => {} // Error: Event is not MouseEvent -``` - -**Note:** The `strict` flag enables `strictFunctionTypes` along with other strict options. Enable `strict` for all new projects. - -**When bivariance is needed:** - -```typescript -// Use method syntax for intentional bivariance -interface EventEmitter { - emit(event: T): void // Method syntax = bivariant -} - -// vs property syntax for contravariance -interface StrictEmitter { - emit: (event: T) => void // Property syntax = contravariant -} -``` - -Reference: [TypeScript 2.6 - Strict Function Types](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-6.html#strict-function-types) diff --git a/.agents/skills/typescript/references/type-avoid-deep-generics.md b/.agents/skills/typescript/references/type-avoid-deep-generics.md deleted file mode 100644 index 405f87aaee0..00000000000 --- a/.agents/skills/typescript/references/type-avoid-deep-generics.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: Avoid Deeply Nested Generic Types -impact: CRITICAL -impactDescription: prevents exponential instantiation cost -tags: type, generics, nesting, instantiation, performance ---- - -## Avoid Deeply Nested Generic Types - -Each layer of generic nesting multiplies type instantiation cost. Flatten generic hierarchies or use intermediate type aliases to reduce the combinatorial explosion of type checking. - -**Incorrect (deeply nested generics):** - -```typescript -type ApiResponse = { - data: T - meta: ResponseMeta -} - -type PaginatedResponse = ApiResponse<{ - items: T[] - pagination: PaginationInfo -}> - -type CachedResponse = PaginatedResponse<{ - value: T - cachedAt: Date -}> - -// Usage creates 4+ levels of nesting -function fetchUsers(): CachedResponse {} -// Compiler must resolve: CachedResponse → PaginatedResponse<...> → ApiResponse<...> -``` - -**Correct (flattened with composition):** - -```typescript -interface PaginationInfo { - page: number - totalPages: number -} - -interface CacheInfo { - cachedAt: Date -} - -interface PaginatedData { - items: T[] - pagination: PaginationInfo -} - -interface ApiResponse { - data: T - meta: ResponseMeta -} - -// Compose at usage site instead of nesting -type UserListResponse = ApiResponse & CacheInfo> - -function fetchUsers(): UserListResponse {} -// Single-level generic instantiation -``` - -**Alternative (builder pattern for complex responses):** - -```typescript -interface ResponseBuilder { - data: T - meta: ResponseMeta -} - -function withPagination(items: T[], pagination: PaginationInfo): PaginatedData { - return { items, pagination } -} - -function withCache(value: T): T & CacheInfo { - return { ...value, cachedAt: new Date() } -} -``` - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance) diff --git a/.agents/skills/typescript/references/type-avoid-large-unions.md b/.agents/skills/typescript/references/type-avoid-large-unions.md deleted file mode 100644 index 54967bbed91..00000000000 --- a/.agents/skills/typescript/references/type-avoid-large-unions.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: Avoid Large Union Types -impact: CRITICAL -impactDescription: quadratic O(n²) comparison cost -tags: type, unions, compilation, performance, discriminated-unions ---- - -## Avoid Large Union Types - -Union type checking is quadratic — TypeScript compares each union member pairwise. Unions with 50+ elements cause measurable compilation slowdowns and IDE lag. This commonly occurs with generated types (GraphQL schemas, API response codes, database enums). - -**Incorrect (large generated union, O(n²) checks):** - -```typescript -// Auto-generated from GraphQL schema — 200+ event types -type AnalyticsEvent = - | 'page_view' - | 'button_click' - | 'form_submit' - | 'scroll_depth' - | 'video_play' - | 'video_pause' - | 'video_complete' - | 'ad_impression' -// ... 200 more event types from analytics schema -// 200 members = 40,000 pairwise comparisons per usage -``` - -**Correct (branded string type with runtime validation):** - -```typescript -type AnalyticsEvent = string & { readonly __brand: 'AnalyticsEvent' } - -const VALID_EVENTS = new Set(['page_view', 'button_click', 'form_submit' /* ... */]) - -function createEvent(name: string): AnalyticsEvent { - if (!VALID_EVENTS.has(name)) { - throw new Error(`Unknown event: ${name}`) - } - return name as AnalyticsEvent -} -``` - -**For moderately large unions (20-50 members), use discriminated unions:** - -```typescript -// Group related values into categories -type UserEvent = { category: 'user'; action: 'login' | 'logout' | 'signup' } -type PageEvent = { category: 'page'; action: 'view' | 'scroll' | 'leave' } -type FormEvent = { category: 'form'; action: 'submit' | 'validate' | 'reset' } - -type AppEvent = UserEvent | PageEvent | FormEvent -// Small union of 3 interfaces instead of 9+ string literals -``` - -**When flat unions are fine:** - -- Small unions (< 20 members) have negligible cost -- Unions of primitive literals used in few places -- `string | number | boolean` style utility unions - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#preferring-base-types-over-unions) diff --git a/.agents/skills/typescript/references/type-explicit-return-types.md b/.agents/skills/typescript/references/type-explicit-return-types.md deleted file mode 100644 index 202155bab1a..00000000000 --- a/.agents/skills/typescript/references/type-explicit-return-types.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: Add Explicit Return Types to Exported Functions -impact: CRITICAL -impactDescription: 30-50% faster declaration emit -tags: type, return-types, exports, inference, performance ---- - -## Add Explicit Return Types to Exported Functions - -Explicit return types accelerate compilation by eliminating inference overhead. Named types are more compact than inferred anonymous types, speeding up declaration file generation and consumption. - -**Incorrect (inferred return type, slow declaration emit):** - -```typescript -export function fetchUserProfile(userId: string) { - // Compiler must analyze entire function body to infer return type - return fetch(`/api/users/${userId}`) - .then((res) => res.json()) - .then((data) => ({ - id: data.id as string, - name: data.name as string, - email: data.email as string, - createdAt: new Date(data.created_at), - permissions: data.permissions as Permission[] - })) -} -// Inferred: Promise<{ id: string; name: string; email: string; createdAt: Date; permissions: Permission[] }> -``` - -**Correct (explicit return type, fast compilation):** - -```typescript -interface UserProfile { - id: string - name: string - email: string - createdAt: Date - permissions: Permission[] -} - -export function fetchUserProfile(userId: string): Promise { - return fetch(`/api/users/${userId}`) - .then((res) => res.json()) - .then((data) => ({ - id: data.id, - name: data.name, - email: data.email, - createdAt: new Date(data.created_at), - permissions: data.permissions - })) -} -``` - -**When to skip explicit return types:** - -- Private/internal functions with simple returns -- Arrow functions in local scope -- Functions where the return type is obvious (e.g., `(): void`) - -**Benefits:** - -- Declaration files use named type instead of expanded inline type -- Faster incremental compilation when function body changes -- Better error messages pointing to return type mismatch - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#using-type-annotations) diff --git a/.agents/skills/typescript/references/type-extract-conditional-types.md b/.agents/skills/typescript/references/type-extract-conditional-types.md deleted file mode 100644 index 5b2ea57cbd8..00000000000 --- a/.agents/skills/typescript/references/type-extract-conditional-types.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: Extract Conditional Types to Named Aliases -impact: CRITICAL -impactDescription: enables compiler caching, prevents re-evaluation -tags: type, conditional-types, generics, caching, performance ---- - -## Extract Conditional Types to Named Aliases - -Inline conditional types are re-evaluated on every function call. Extracting them to named type aliases allows the compiler to cache results and reuse them across multiple call sites. - -**Incorrect (inline conditional, re-evaluated each call):** - -```typescript -function processResponse( - response: T -): T extends { data: infer D } ? (D extends Array ? Item[] : D) : never { - // Compiler re-computes this complex conditional on every call - return response.data -} - -function getFirstItem(collection: T): T extends Array ? U : T { - // Re-evaluated for each getFirstItem() usage -} -``` - -**Correct (extracted, cacheable):** - -```typescript -type ExtractData = T extends { data: infer D } - ? D extends Array - ? Item[] - : D - : never - -function processResponse(response: T): ExtractData { - // Compiler caches ExtractData resolution - return response.data -} - -type UnwrapArray = T extends Array ? U : T - -function getFirstItem(collection: T): UnwrapArray { - // Reuses cached UnwrapArray computation -} -``` - -**Benefits:** - -- Type alias acts as a cache boundary -- Reduces duplicate computation across multiple call sites -- Improves IDE responsiveness for autocomplete - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#using-type-aliases) diff --git a/.agents/skills/typescript/references/type-interfaces-over-intersections.md b/.agents/skills/typescript/references/type-interfaces-over-intersections.md deleted file mode 100644 index 2b11365d653..00000000000 --- a/.agents/skills/typescript/references/type-interfaces-over-intersections.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: Prefer Interfaces Over Type Intersections -impact: CRITICAL -impactDescription: 2-5× faster type resolution -tags: type, interfaces, intersections, compilation, performance ---- - -## Prefer Interfaces Over Type Intersections - -Interfaces create a single flat object type that detects property conflicts at declaration. Intersections recursively merge properties on every use, forcing the compiler to recompute the combined type repeatedly. - -**Incorrect (recursive intersection merging):** - -```typescript -type UserWithPermissions = User & Permissions & AuditInfo -// Compiler merges all properties on every reference - -type ExtendedOrder = Order & { - metadata: OrderMetadata -} & Timestamps -// Each intersection adds another layer of computation -``` - -**Correct (single flat interface):** - -```typescript -interface UserWithPermissions extends User, Permissions, AuditInfo {} -// Single flat type, computed once - -interface ExtendedOrder extends Order, Timestamps { - metadata: OrderMetadata -} -// Extends create efficient inheritance chain -``` - -**When to use intersections:** - -- Combining function types or primitives (interfaces cannot extend these) -- Creating mapped or conditional types -- One-off type combinations not reused elsewhere - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#preferring-interfaces-over-intersections) diff --git a/.agents/skills/typescript/references/type-limit-recursion-depth.md b/.agents/skills/typescript/references/type-limit-recursion-depth.md deleted file mode 100644 index 585f3a3a766..00000000000 --- a/.agents/skills/typescript/references/type-limit-recursion-depth.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: Limit Type Recursion Depth -impact: HIGH -impactDescription: prevents exponential type expansion when applicable -tags: type, recursion, generics, depth, performance ---- - -## Limit Type Recursion Depth - -Recursive types without depth limits can cause exponential type expansion, leading to compilation hangs or out-of-memory errors. Add explicit depth counters or use tail-recursive patterns. - -**Incorrect (unbounded recursion):** - -```typescript -type DeepPartial = { - [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] -} -// No depth limit - deeply nested objects cause exponential expansion - -type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue } -// Infinite recursion potential -``` - -**Correct (bounded recursion with depth counter):** - -```typescript -type DeepPartial = Depth['length'] extends 5 - ? T // Stop at depth 5 - : { - [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] - } - -type JSONValue = Depth['length'] extends 10 - ? unknown - : - | string - | number - | boolean - | null - | JSONValue<[...Depth, 1]>[] - | { [key: string]: JSONValue<[...Depth, 1]> } -``` - -**Alternative (use built-in utilities):** - -```typescript -// For simple cases, prefer built-in Partial over custom DeepPartial -type Config = Partial - -// Use libraries like ts-toolbelt for complex recursive types -// They implement optimized depth-limited versions -``` - -**When unbounded recursion is acceptable:** - -- Types with guaranteed shallow depth (max 2-3 levels) -- Internal types not exposed in public APIs - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance) diff --git a/.agents/skills/typescript/references/type-simplify-mapped-types.md b/.agents/skills/typescript/references/type-simplify-mapped-types.md deleted file mode 100644 index f4781762738..00000000000 --- a/.agents/skills/typescript/references/type-simplify-mapped-types.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Simplify Complex Mapped Types -impact: HIGH -impactDescription: reduces type computation by 50-80% when applicable -tags: type, mapped-types, simplification, utility-types, performance ---- - -## Simplify Complex Mapped Types - -Overly complex mapped types with multiple conditional branches slow compilation significantly. Break them into smaller, focused utility types and compose them. - -**Incorrect (monolithic mapped type):** - -```typescript -type ComplexTransform = { - [K in keyof T]: T[K] extends Function - ? T[K] - : T[K] extends Array - ? U extends object - ? ComplexTransform[] - : T[K] - : T[K] extends object - ? T[K] extends Date - ? string - : ComplexTransform - : T[K] extends number - ? string - : T[K] -} -// Multiple nested conditionals evaluated for every property -``` - -**Correct (composed utility types):** - -```typescript -type TransformValue = T extends Date ? string : T extends number ? string : T - -type TransformObject = { - [K in keyof T]: TransformProperty -} - -type TransformProperty = T extends Function - ? T - : T extends Array - ? TransformArray - : T extends object - ? TransformObject - : TransformValue - -type TransformArray = T extends object ? TransformObject[] : T[] - -// Each utility is cached independently -type TransformedUser = TransformObject -``` - -**Benefits:** - -- Each small utility type is cached separately -- Easier to debug type errors -- More reusable across the codebase - -**When complex mapped types are acceptable:** - -- Internal utility types used in few places -- Types that genuinely require complex logic - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance) diff --git a/.claude/skills/auto-review-fix b/.claude/skills/auto-review-fix deleted file mode 120000 index ddcc07a3dd0..00000000000 --- a/.claude/skills/auto-review-fix +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/auto-review-fix \ No newline at end of file diff --git a/.claude/skills/electron b/.claude/skills/electron deleted file mode 120000 index d04d1cd05cf..00000000000 --- a/.claude/skills/electron +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/electron \ No newline at end of file diff --git a/.claude/skills/react-useeffect b/.claude/skills/react-useeffect deleted file mode 120000 index 05a484a5139..00000000000 --- a/.claude/skills/react-useeffect +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/react-useeffect \ No newline at end of file diff --git a/.claude/skills/review-and-submit/SKILL.md b/.claude/skills/review-and-submit/SKILL.md deleted file mode 100644 index e9794d483d1..00000000000 --- a/.claude/skills/review-and-submit/SKILL.md +++ /dev/null @@ -1,278 +0,0 @@ ---- -name: review-and-submit -description: Lightweight review-fix loop (2 rounds, 1 agent each), then create PR and merge ---- - -# review-and-submit - -Lightweight autonomous pipeline: review code with a single agent, fix issues (up to 2 rounds), then create PR and merge. Designed for smaller PRs where full parallel review is overkill. - -**IMPORTANT**: Execute this entire process autonomously without asking the user for confirmation at any step. - ---- - -## Process Overview - -``` - REVIEW-FIX (max 2 rounds, 1 agent each) → CREATE PR → WAIT FOR CI → MERGE -``` - ---- - -## Step 1: Review-Fix Loop (Max 2 Rounds) - -### Round Setup (first round only) - -Get the diff to understand what changed: - -```bash -MERGE_BASE=$(git merge-base origin/main HEAD) -git diff $MERGE_BASE --stat -git diff $MERGE_BASE -``` - -### Each Round: Single-Agent Review - -Spawn **one** review agent that covers all review concerns: - -``` -Agent( - subagent_type: "general-purpose", - model: "opus", - description: "Review all changes on branch", - prompt: """ - Review all code changes on this branch vs origin/main. - - Run: git diff $(git merge-base origin/main HEAD) - - Review for: - - Correctness and logical bugs - - Security issues - - Type safety issues - - Error handling gaps - - Performance problems - - Dead code or unused imports - - **SCOPE**: Only report issues in changed code or directly caused by the changes. - Do NOT report pre-existing issues unrelated to this PR. - - For each finding, output: - - File: path/to/file.ts - - Line: NN - - Severity: Critical|High|Medium|Low - - Issue: [description] - - Fix: [suggested fix] - - If no issues found, say "No issues found." - """ -) -``` - -### Fix Phase - -If the review found issues: - -1. **Skip Low severity issues** — only fix Critical, High, and Medium -2. Fix issues via a single fix agent: - -``` -Agent( - subagent_type: "general-purpose", - model: "opus", - description: "Fix review issues", - prompt: """ - Fix the following review issues: - - [LIST ALL CRITICAL/HIGH/MEDIUM ISSUES FROM REVIEW] - - Instructions: - 1. Read each file - 2. Apply fixes using the Edit tool - 3. Verify fixes don't break syntax - 4. Report what was fixed - """ -) -``` - -3. After fixes, run typecheck: - -```bash -pnpm typecheck 2>&1 -``` - -If typecheck fails, fix type errors (up to 2 attempts). - -### Exit Conditions - -``` -IF (review found no Critical/High/Medium issues): - → EXIT loop — code is clean - -IF (fixes applied): - → Run another round to verify (unless already at round 2) - -IF (round 2 reached): - → EXIT loop -``` - -### Commit Changes - -After the review-fix loop completes, commit any changes: - -```bash -if [ -n "$(git status --porcelain)" ]; then - git add -A && git commit -m "fix: address review findings" -fi -``` - -**Continue to Step 2 — do not stop here.** - ---- - -## Step 2: Create PR and Merge - -### 2a. Remove local design docs - -Design/planning docs are local-only scratch files and must not be checked in. -Delete any untracked markdown files in `docs/` that match design/plan -naming patterns before pushing: - -```bash -git ls-files --others --exclude-standard -- 'docs/*-design.md' 'docs/*-plan.md' 'docs/design-*.md' \ - | xargs -I{} rm -f {} -``` - -### 2b. Create PR - -**FIRST**: Push the branch to the remote so `gh pr create` doesn't fail with -`aborted: you must first push the current branch to a remote`. The -`create-pr` skill rebases locally but does not always push before invoking -`gh pr create`, and `gh` refuses to create a PR for an un-pushed branch. - -```bash -git push --force-with-lease -u origin HEAD -``` - -Then invoke: - -``` -Use the Skill tool: skill: "create-pr" -``` - -After `/create-pr` completes, extract PR info: - -```bash -gh pr view --json number,url --jq '.number, .url' -``` - -### 2c. Wait for CI Checks - -Wait **2.5 minutes** before the first poll: - -```bash -echo "Waiting 2.5 minutes for CI checks..." && sleep 150 -``` - -**CRITICAL**: Set `timeout: 210000` (3.5 minutes) on the Bash tool call for this sleep. - -### Poll for results (up to 5 polls, 90s apart) - -For each poll: - -```bash -CHECKS=$(gh pr checks --json name,state,bucket 2>&1) -echo "$CHECKS" - -if echo "$CHECKS" | jq -e 'length == 0' >/dev/null 2>&1; then - echo "RESULT:NO_CHECKS" -elif echo "$CHECKS" | jq -e '[.[].bucket] | all(. == "pass" or . == "skipping")' >/dev/null 2>&1; then - echo "RESULT:PASSED" -elif echo "$CHECKS" | jq -e '[.[].bucket] | any(. == "fail" or . == "cancel")' >/dev/null 2>&1; then - echo "RESULT:FAILED" -else - echo "RESULT:PENDING" -fi -``` - -- `PASSED` → Proceed to merge -- `FAILED` → Attempt one quick fix iteration (diagnose from `gh run view --log-failed`, fix, push, re-poll) -- `PENDING` → Wait 90s (`sleep 90`, `timeout: 150000`), then poll again -- `NO_CHECKS` → Proceed to merge - -### 2d. Merge - -```bash -IS_WORKTREE=false -if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then - IS_WORKTREE=true -fi - -BRANCH=$(git branch --show-current) - -if [ "$IS_WORKTREE" = "true" ]; then - gh pr merge --admin --squash - git push origin --delete "$BRANCH" 2>/dev/null || true -else - gh pr merge --admin --squash --delete-branch -fi -``` - ---- - -## CI Fix Loop (if checks fail) - -If CI fails after PR creation, run **one** fix iteration: - -1. Diagnose: - -```bash -FAILED_RUN=$(gh run list --branch $(git branch --show-current) --limit 5 --json databaseId,conclusion,name --jq '[.[] | select(.conclusion == "failure")] | .[0].databaseId') -if [ -n "$FAILED_RUN" ] && [ "$FAILED_RUN" != "null" ]; then - gh run view "$FAILED_RUN" --log-failed 2>&1 | tail -200 -fi -``` - -2. Fix the issues, run `pnpm typecheck`, commit and push: - -```bash -if [ -n "$(git status --porcelain)" ]; then - git add -A && git commit -m "fix: address CI failures" - git push --force-with-lease -fi -``` - -3. Wait 2.5 minutes and re-poll. If still failing after one fix attempt, merge with `--admin` anyway. - ---- - -## Progress Tracking - -``` -╔════════════════════════════════════════════════════════════╗ -║ REVIEW AND SUBMIT ║ -╠════════════════════════════════════════════════════════════╣ -║ Review-Fix Loop: ║ -║ Round 1: ✅ X issues found, Y fixed ║ -║ Round 2: ✅ Clean review ║ -║ Create PR: ✅ PR #123 ║ -║ CI Checks: ✅ Passed ║ -║ Merge: ✅ Merged with --admin --squash ║ -╠════════════════════════════════════════════════════════════╣ -║ PR URL: https://github.com/org/repo/pull/123 ║ -╚════════════════════════════════════════════════════════════╝ -``` - ---- - -## Critical Instructions - -1. **DO NOT ask for user confirmation** — Execute autonomously -2. **Max 2 review rounds**, 1 agent per round — keep it lightweight -3. **Only fix Critical/High/Medium** — skip Low severity -4. **DO run typecheck** after fixes -5. **DO use opus model** for review and fix agents -6. **2.5 minute initial CI wait** (not 8 minutes) — this repo's CI is fast -7. **DO use --admin --squash** for merge -8. **DO set Bash timeouts** for sleep commands -9. **Continue through all steps** — don't stop after review or PR creation -10. **One CI fix iteration max** — don't loop forever on CI failures diff --git a/.claude/skills/typescript b/.claude/skills/typescript deleted file mode 120000 index 93516ac2445..00000000000 --- a/.claude/skills/typescript +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/typescript \ No newline at end of file diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index cf640d53fbc..00000000000 --- a/.editorconfig +++ /dev/null @@ -1,9 +0,0 @@ -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 2 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true \ No newline at end of file diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 98% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md index 6b24738879d..9d2eddc7582 100644 --- a/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -80,7 +80,7 @@ All releases are cut from the **Cut Release** GitHub Actions workflow. There is - **ref**: the branch, tag, or SHA to build from. Defaults to `main`. 3. Run it. -The workflow resolves the next version from GitHub Releases, bumps `package.json`, tags, pushes, and kicks off the multi-platform build via `release.yml`. +The workflow resolves the next version from GitHub Releases, bumps `package.json`, tags, pushes, and runs the multi-platform build + publish inline. **How the next version is chosen:** diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ed6217f75a8..08ce81990ad 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -33,13 +33,25 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version-file: .nvmrc + node-version-file: package.json - name: Setup pnpm uses: pnpm/action-setup@v4 with: run_install: false + # Why: this job runs the same pnpm install path as pr.yml's verify + # job, so it needs the same pinned node-gyp override to avoid pnpm's + # broken bundled gyp_main.py on Linux. Gate on runner.os matches + # release.yml so the invariant "this workaround is Linux-only" is + # consistent across all three workflows, even though this job + # currently pins runs-on: ubuntu-latest. + - 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: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml new file mode 100644 index 00000000000..28dac408130 --- /dev/null +++ b/.github/workflows/homebrew-bump.yml @@ -0,0 +1,170 @@ +name: Homebrew Cask Bump + +# Why: keep the stablyai/homebrew-orca tap in sync with GitHub Releases. +# Triggers: +# - workflow_call → release-cut.yml finished publishing a stable tag +# - workflow_dispatch → manual backfill for a specific tag +# +# The job computes the sha256 of orca-macos-arm64.dmg + orca-macos-x64.dmg +# from the release, templates Casks/orca.rb in this repo with the new +# version and hashes, then PRs the result into stablyai/homebrew-orca. It +# does NOT run for -rc.* tags: the stable cask should only track GA. +# +# Why no `release.published` trigger: other products shipped from this repo +# (e.g. mobile under `mobile-v*`) also publish GitHub Releases. A blanket +# `release.published` trigger would fire this workflow for any of them and +# attempt to download `orca-macos-*.dmg` from a non-desktop release, at +# best 404ing and at worst (if the filter were weaker) rewriting the cask +# to a non-desktop version. The tap is only ever bumped by the desktop +# release pipeline calling this workflow directly. + +on: + workflow_call: + inputs: + tag: + description: Release tag to sync (e.g. v1.3.25) + required: true + type: string + workflow_dispatch: + inputs: + tag: + description: Release tag to sync (e.g. v1.3.25) + required: true + type: string + +jobs: + bump-cask: + runs-on: ubuntu-latest + # Why: defense in depth. The desktop release pipeline already refuses + # to invoke this workflow for `-rc.*` tags, but a manual backfill could + # still pass one in — reject it here so the stable cask can never point + # at an RC. Also requires the desktop tag shape `v...` so a + # mis-typed mobile tag like `mobile-v0.0.1` is rejected up front. + if: > + inputs.tag != '' && + !contains(inputs.tag, '-rc.') && + startsWith(inputs.tag, 'v') + permissions: + contents: read + steps: + - name: Checkout orca + uses: actions/checkout@v4 + + - name: Resolve tag and version + id: tag + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [[ -n "$INPUT_TAG" ]]; then + tag="$INPUT_TAG" + else + tag="$RELEASE_TAG" + fi + version="${tag#v}" + echo "tag=$tag" >>"$GITHUB_OUTPUT" + echo "version=$version" >>"$GITHUB_OUTPUT" + + - name: Download DMGs and compute sha256 + id: hash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.tag.outputs.tag }} + run: | + set -euo pipefail + mkdir -p dmgs + gh release download "$TAG" \ + --repo "${{ github.repository }}" \ + --pattern 'orca-macos-arm64.dmg' \ + --pattern 'orca-macos-x64.dmg' \ + --dir dmgs + arm=$(sha256sum dmgs/orca-macos-arm64.dmg | awk '{print $1}') + intel=$(sha256sum dmgs/orca-macos-x64.dmg | awk '{print $1}') + echo "arm=$arm" >>"$GITHUB_OUTPUT" + echo "intel=$intel" >>"$GITHUB_OUTPUT" + + - name: Render updated cask file + env: + VERSION: ${{ steps.tag.outputs.version }} + ARM_SHA: ${{ steps.hash.outputs.arm }} + INTEL_SHA: ${{ steps.hash.outputs.intel }} + run: | + set -euo pipefail + python3 - <<'PY' + import os, re, pathlib + p = pathlib.Path("Casks/orca.rb") + src = p.read_text() + src = re.sub(r'version "[^"]+"', + f'version "{os.environ["VERSION"]}"', src, count=1) + src = re.sub(r'sha256 arm:\s*"[0-9a-f]+",\s*\n\s*intel:\s*"[0-9a-f]+"', + 'sha256 arm: "{arm}",\n intel: "{intel}"'.format( + arm=os.environ["ARM_SHA"], + intel=os.environ["INTEL_SHA"]), + src, count=1) + p.write_text(src) + print(src) + PY + + # Why: reuse the existing buf0-bot GitHub App (also used by + # track-community-prs.yaml) instead of minting a new PAT. The app is + # installed org-wide, so it has access to stablyai/homebrew-orca + # automatically. GITHUB_TOKEN cannot push cross-repo; a short-lived + # installation token can. + - name: Generate buf0-bot token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: 2590194 + private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }} + owner: stablyai + repositories: homebrew-orca + + - name: Checkout tap + uses: actions/checkout@v4 + with: + repository: stablyai/homebrew-orca + path: tap + token: ${{ steps.app-token.outputs.token }} + + - name: Copy cask into tap and open PR + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + VERSION: ${{ steps.tag.outputs.version }} + TAG: ${{ steps.tag.outputs.tag }} + run: | + set -euo pipefail + mkdir -p tap/Casks + cp Casks/orca.rb tap/Casks/orca.rb + + cd tap + # Why: commit author must match the buf0-bot app so commits are + # attributed to the bot in the tap's history / PR UI. + git config user.name "buf0-bot[bot]" + git config user.email "252831055+buf0-bot[bot]@users.noreply.github.com" + + if git diff --quiet; then + echo "Cask already up-to-date for $TAG; nothing to do." + exit 0 + fi + + branch="bump/orca-$VERSION" + git checkout -b "$branch" + git add Casks/orca.rb + git commit -m "orca $VERSION" + git push -u origin "$branch" --force + + # Why: prefer --fill so the PR body matches the commit; fall back + # to a brief body if the PR already exists for this branch. + gh pr create \ + --title "orca $VERSION" \ + --body "Automated bump from stablyai/orca release $TAG." \ + --base main \ + --head "$branch" \ + || gh pr edit "$branch" --title "orca $VERSION" + + # Why: squash-merge immediately rather than --auto. The tap has no + # required checks or branch protection, and `gh pr merge --auto` + # only activates when there's something to wait on — on a repo + # with no gates it silently no-ops, leaving the PR open forever. + gh pr merge "$branch" --squash --delete-branch diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml new file mode 100644 index 00000000000..9e7d88666aa --- /dev/null +++ b/.github/workflows/mobile-build.yml @@ -0,0 +1,71 @@ +name: Mobile Release + +on: + push: + tags: + - 'mobile-v*' + workflow_dispatch: + +jobs: + android-build: + runs-on: ubuntu-latest + + # Why: the "Create GitHub Release" step below uses the default GITHUB_TOKEN + # to call `gh release create`, which requires contents:write. Without this, + # the job builds the APK fine but fails at release time with + # "HTTP 403: Resource not accessible by integration". + permissions: + contents: write + + defaults: + run: + working-directory: mobile + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Expo prebuild + run: npx expo prebuild --platform android --no-install + + - name: Build Android release APK + run: cd android && ./gradlew assembleRelease + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: orca-mobile-apk + path: mobile/android/app/build/outputs/apk/release/*.apk + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/mobile-v') + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${GITHUB_REF#refs/tags/}" + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --title "Orca Mobile $tag" \ + --prerelease \ + --latest=false \ + --generate-notes \ + android/app/build/outputs/apk/release/*.apk diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml new file mode 100644 index 00000000000..95fcff00a08 --- /dev/null +++ b/.github/workflows/mobile.yml @@ -0,0 +1,43 @@ +name: Mobile Checks + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + paths: + - 'mobile/**' + +jobs: + verify: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: mobile + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: npx tsc --noEmit + + - name: Lint + run: pnpm lint + diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 05e9310f074..e85f321bca8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -22,13 +22,29 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version-file: .nvmrc + node-version-file: package.json - name: Setup pnpm uses: pnpm/action-setup@v4 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: Install dependencies run: pnpm install --frozen-lockfile @@ -55,6 +71,12 @@ jobs: - name: Typecheck run: pnpm typecheck + # 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 + - name: Test run: pnpm test diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index c023bd3fd22..87c383fea69 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1,9 +1,9 @@ name: Cut Release -# Why: single entry point for manually cutting a release from any ref. -# Replaces the old local `pnpm release:*` scripts so releases are always -# reproducible from CI and can never be accidentally tagged against an -# uncommitted or non-main working tree. +# Why: single entry point for manually and scheduled cutting releases. +# Replaces the old local `pnpm release:*` scripts and the standalone scheduled +# RC workflow so releases are always reproducible from CI and can never be +# accidentally tagged against an uncommitted or non-main working tree. # # Flow: # 1. Resolve `ref` to a SHA. @@ -36,6 +36,17 @@ on: required: false type: string default: main + dry_run: + description: Validate a scheduled-RC run without creating a tag + required: false + default: false + type: boolean + schedule: + # Why: GitHub scheduled workflows are not guaranteed to start exactly on + # time and can be delayed or dropped around high-load periods. We retry + # across the full target hour and dedupe per PT slot. Cron is UTC, so run + # during both PST and PDT equivalents and let the PT-hour gate decide. + - cron: '*/5 10,11,22,23 * * *' permissions: contents: write @@ -49,18 +60,19 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 outputs: - tag: ${{ steps.tag.outputs.tag }} + tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }} + should_release: ${{ steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} steps: - name: Checkout ref uses: actions/checkout@v4 with: - ref: ${{ inputs.ref }} + ref: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }} fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version-file: .nvmrc + node-version-file: package.json - name: Configure git author run: | @@ -84,28 +96,115 @@ jobs: echo "push_main=false" >>"$GITHUB_OUTPUT" fi + - name: Compute RC slot + id: slot + run: | + slot=$(TZ=America/Los_Angeles date '+%Y-%m-%d-%H') + echo "value=$slot" >>"$GITHUB_OUTPUT" + + - name: Validate PT release window + id: window + env: + EVENT_NAME: ${{ github.event_name }} + run: | + if [[ "$EVENT_NAME" != "schedule" ]]; then + echo "allowed=true" >>"$GITHUB_OUTPUT" + echo "reason=manual" >>"$GITHUB_OUTPUT" + exit 0 + fi + + pt_hour=$(TZ=America/Los_Angeles date '+%H') + pt_minute=$(TZ=America/Los_Angeles date '+%M') + + # Why: GitHub may deliver a scheduled event long after the intended + # time, so delayed 4:16 AM runs must not cut the 3:00 AM release. + if [[ "$pt_hour" == "03" || "$pt_hour" == "15" ]]; then + echo "allowed=true" >>"$GITHUB_OUTPUT" + echo "reason=target_hour:${pt_hour}:${pt_minute}" >>"$GITHUB_OUTPUT" + exit 0 + fi + + echo "allowed=false" >>"$GITHUB_OUTPUT" + echo "reason=outside_target_hour:${pt_hour}:${pt_minute}" >>"$GITHUB_OUTPUT" + + - name: Skip if this PT release window already ran + id: existing + if: github.event_name == 'schedule' && steps.window.outputs.allowed == 'true' + run: | + # Why: scheduled runs retry inside each target hour, so make the + # schedule idempotent by embedding a slot marker in the release commit. + if git log origin/main --grep="\\[rc-slot:${{ steps.slot.outputs.value }}\\]" -n 1 --format=%H | grep -q .; then + echo "already_ran=true" >>"$GITHUB_OUTPUT" + exit 0 + fi + + # Why: this preserves dedupe across the older scheduled workflow's + # first runs, before all RC cuts shared release-cut's slot marker. + latest_rc_tag="$(git for-each-ref --sort=-creatordate --format='%(refname:short) %(creatordate:iso-strict)' 'refs/tags/v*-rc.*' | head -n 1)" + if [[ -n "$latest_rc_tag" ]]; then + latest_rc_tag_name="${latest_rc_tag%% *}" + latest_rc_tag_date="${latest_rc_tag#* }" + latest_rc_slot="$(TZ=America/Los_Angeles date -d "$latest_rc_tag_date" '+%Y-%m-%d-%H')" + + if [[ "$latest_rc_slot" == "${{ steps.slot.outputs.value }}" ]]; then + echo "already_ran=true" >>"$GITHUB_OUTPUT" + echo "reason=latest_rc_tag:$latest_rc_tag_name" >>"$GITHUB_OUTPUT" + exit 0 + fi + fi + + echo "already_ran=false" >>"$GITHUB_OUTPUT" + + - name: Dry run summary + if: github.event_name == 'workflow_dispatch' && inputs.dry_run + run: | + echo "Dry run only." + echo "Current PT slot: ${{ steps.slot.outputs.value }}" + echo "Window allowed: ${{ steps.window.outputs.allowed }}" + echo "Window reason: ${{ steps.window.outputs.reason }}" + echo "Already ran this slot: ${{ steps.existing.outputs.already_ran }}" + echo "Reason: ${{ steps.existing.outputs.reason }}" + + - name: Skip summary + if: steps.window.outputs.allowed != 'true' || steps.existing.outputs.already_ran == 'true' + run: | + echo "Skipping release cut." + echo "Current PT slot: ${{ steps.slot.outputs.value }}" + echo "Window reason: ${{ steps.window.outputs.reason }}" + echo "Already ran this slot: ${{ steps.existing.outputs.already_ran }}" + echo "Reason: ${{ steps.existing.outputs.reason }}" + - name: Compute next version id: version + if: steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - KIND: ${{ inputs.kind }} + KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }} run: | set -euo pipefail - # Latest stable release tag, picked by *tag shape* (no `-rc.`) - # rather than the GitHub `isPrerelease` flag. electron-builder's - # publish step has flipped that flag back to `false` on RC - # releases in the past (issue surfaced 2026-04-27 when - # v1.3.22-rc.2 became "latest" on GitHub and poisoned the - # compute-next-version math here). Filtering by tag format is - # the authoritative signal — an `-rc.N` tag is never stable, - # regardless of what the release metadata says. + # Latest stable release tag, picked by *tag shape*: + # - must start with `v` (desktop convention, e.g. v1.3.32) + # - must NOT contain `-rc.` (not a prerelease) + # + # Why not the GitHub `isPrerelease` flag: electron-builder's publish + # step has flipped that flag back to `false` on RC releases before + # (v1.3.22-rc.2 on 2026-04-27 briefly became "latest" on GitHub and + # poisoned the math here). Tag format is authoritative. + # + # Why the `^v[0-9]` prefix: other products shipped from this repo + # use their own prefixes (e.g. `mobile-v0.0.1`). Without the prefix + # gate the latest mobile release would be selected as "latest + # stable", then `strip_pre()` would reduce `mobile-v0.0.1` to + # `mobile`, `Number("mobile")` → NaN → 0, and a patch-bump would + # produce `0.0.1` — exactly the wedge on 2026-05-04 (run + # 25304336767). Any non-desktop tag shape must be excluded here. latest_stable="$(gh release list \ --repo "$GITHUB_REPOSITORY" \ --exclude-drafts \ --limit 50 \ --json tagName \ - --jq '[.[] | .tagName | select(test("-rc\\.") | not)] | .[0] // ""')" + --jq '[.[] | .tagName | select(test("^v[0-9]") and (test("-rc\\.") | not))] | .[0] // ""')" latest_stable="${latest_stable#v}" echo "Latest stable: ${latest_stable:-}" @@ -187,10 +286,53 @@ jobs: ;; esac - # Refuse tag collisions (possible if someone already tagged manually). + # Orphan-tag recovery. + # + # Why: if a previous cut pushed the tag but was cancelled (or the + # dependent release.yml job otherwise failed to start) before the + # GitHub Release was published, the tag now exists on the remote + # but "latest stable" still points at the prior version. Every + # subsequent patch cut then recomputes the same version and dies + # on "Tag already exists." This exact sequence wedged the cut + # pipeline on 2026-05-01 when v1.3.26 was pushed by a cancelled + # run (25237882049) — every patch cut after that rehit the same + # tag for hours until the orphan release was dispatched by hand. + # + # Recovery policy: if the tag exists AND no GitHub release has + # been published for it (draft-or-absent both count as "not + # shipped"), treat this as a resumable state: emit the existing + # tag as the job output so the downstream release.yml job runs + # against it and finishes what the earlier attempt started. The + # bump/commit/push steps are skipped in that case — there is + # nothing to bump; the tag is already on the remote. + # + # Refuse collisions only when the tag *and* a published release + # already exist — that's a real conflict (someone tagged manually + # over a shipped version) and needs human attention. if git rev-parse "v$new" >/dev/null 2>&1; then - echo "::error::Tag v$new already exists." >&2 - exit 1 + # Use the REST API, not `gh release view`: the latter returns + # "release not found" for drafts *and* for absent releases, + # which would cause us to "recover" over a draft that a human + # is preparing. The API distinguishes them. + release_state="$(gh api \ + "repos/$GITHUB_REPOSITORY/releases/tags/v$new" \ + --jq '.draft' 2>/dev/null || echo "missing")" + case "$release_state" in + missing|true) + echo "::warning::Tag v$new already exists but no published release is attached — recovering by re-dispatching release.yml against the existing tag." + echo "recovered_tag=v$new" >>"$GITHUB_OUTPUT" + echo "recovered=true" >>"$GITHUB_OUTPUT" + exit 0 + ;; + false) + echo "::error::Tag v$new already exists and has a published release. Refusing to re-cut over a shipped version." >&2 + exit 1 + ;; + *) + echo "::error::Unexpected release state for v$new: $release_state" >&2 + exit 1 + ;; + esac fi echo "version=$new" >>"$GITHUB_OUTPUT" @@ -198,7 +340,10 @@ jobs: - name: Bump package.json and tag id: tag + if: steps.version.outputs.version != '' && steps.version.outputs.recovered != 'true' env: + EVENT_NAME: ${{ github.event_name }} + SLOT: ${{ steps.slot.outputs.value }} VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail @@ -207,12 +352,17 @@ jobs: # assumptions and any lifecycle scripts that would run on bump). npm version "$VERSION" --no-git-tag-version --allow-same-version git add package.json - git commit -m "release: v$VERSION" + commit_message="release: v$VERSION" + if [[ "$EVENT_NAME" == "schedule" ]]; then + commit_message="$commit_message [rc-slot:$SLOT]" + fi + git commit -m "$commit_message" git tag -a "v$VERSION" -m "v$VERSION" echo "tag=v$VERSION" >>"$GITHUB_OUTPUT" echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" - name: Push tag + if: steps.tag.outputs.tag != '' env: PUSH_MAIN: ${{ steps.resolve.outputs.push_main }} TAG: ${{ steps.tag.outputs.tag }} @@ -227,9 +377,285 @@ jobs: git push origin "$TAG" fi - release: + create-release: needs: cut - uses: ./.github/workflows/release.yml + if: needs.cut.outputs.should_release == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: refs/tags/${{ needs.cut.outputs.tag }} + + - name: Create draft release with auto-generated notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.cut.outputs.tag }} + run: | + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $TAG already exists." + exit 0 + fi + + is_rc=false + if [[ "$TAG" == *"-rc."* ]]; then + is_rc=true + fi + + gh release create "$TAG" \ + --draft \ + --generate-notes \ + --prerelease="$is_rc" + + # Why: E2E runs alongside the release for visibility (failures surface as a + # red check on the tag), but is NOT in `publish-release`'s needs list. + # Releases already take a while and the suite is already a required check + # on PRs, so gating here would mostly delay shipping without adding signal. + e2e: + needs: cut + if: needs.cut.outputs.should_release == 'true' + uses: ./.github/workflows/e2e.yml + with: + ref: refs/tags/${{ needs.cut.outputs.tag }} + + build: + needs: + - cut + - create-release + if: needs.cut.outputs.should_release == 'true' + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + platform: mac + release_command: ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always + eb_cache_path: | + ~/Library/Caches/electron + ~/Library/Caches/electron-builder + - os: windows-latest + platform: win + release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always + eb_cache_path: | + ~\AppData\Local\electron\Cache + ~\AppData\Local\electron-builder\Cache + - os: ubuntu-latest + platform: linux + release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish always + eb_cache_path: | + ~/.cache/electron + ~/.cache/electron-builder + + runs-on: ${{ matrix.os }} + + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: refs/tags/${{ needs.cut.outputs.tag }} + + # pnpm must be on PATH before setup-node so setup-node can locate the store for caching. + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: package.json + cache: pnpm + + # Why: release builds hit the same native-module postinstall path as + # PR CI, so keep the pinned node-gyp override here too instead of + # relying on pnpm's bundled copy. Scoped to Linux via runner.os (not + # a specific matrix image) because the failing postinstall has only + # been observed on Linux runners — see run 25081763129. The macOS + # and Windows release jobs exercise the same pnpm install path and + # have not reproduced it, so keep the gate narrow until we know why. + # Using runner.os instead of matrix.os == 'ubuntu-latest' means the + # gate still works if another Linux matrix entry is added later. + - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) + if: runner.os == 'Linux' + run: | + npm install -g node-gyp@11.5.0 + echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + + # Cache the Electron binary + electron-builder tool downloads (notarytool, + # winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac. + - name: Cache electron-builder downloads + uses: actions/cache@v4 + with: + path: ${{ matrix.eb_cache_path }} + key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + electron-builder-${{ matrix.platform }}- + + # Why: pnpm install triggers electron's postinstall, which downloads the + # Electron binary from GitHub release assets. GitHub's download CDN + # occasionally returns 504s that fail the whole release. Retry on + # failure so transient network errors don't require a manual re-run. + - name: Install dependencies + uses: nick-fields/retry@v3 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile + + - name: Verify macOS signing environment + if: matrix.platform == 'mac' + run: node config/scripts/verify-macos-release-env.mjs + env: + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + # Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`) + # requires the build identity to be the literal string `stable` or `rc`, + # substituted by electron-vite's `define` block at build time. Derive + # that identity from the release tag here — `stable` for plain semver + # (`vX.Y.Z`), `rc` for prerelease (`vX.Y.Z-rc.N`). The strict regex is + # a safety net: this workflow only fires on cut-tags that already match + # one of those shapes, but if a future change ever loosens that, we + # refuse to ship rather than let an unclassified build go out with + # `BUILD_IDENTITY = null`. + - name: Classify release tag for telemetry build identity + id: tag-classify + shell: bash + env: + TAG: ${{ needs.cut.outputs.tag }} + run: | + set -euo pipefail + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + identity=rc + elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + identity=stable + else + echo "::error::Tag $TAG does not match stable or rc pattern; refusing to build official artifact" + exit 1 + fi + echo "identity=$identity" >>"$GITHUB_OUTPUT" + echo "Classified $TAG as $identity" + + # Why ORCA_POSTHOG_WRITE_KEY here: this is the only build that + # produces a published binary, so this is the only place the secret + # needs to be in scope. The key is a PostHog *project* API key, not + # a server secret — it ships in every official binary's app.asar + # and is therefore extractable from any release. We still keep it + # in GitHub Actions secrets so the literal stays out of the repo + # (and out of fork CI runs / log scrapers / casual greps). + # Why ORCA_BUILD_IDENTITY here (not in env at the job level): the + # value comes from the per-tag classification above and electron-vite + # reads it from `process.env` during `pnpm build:release` only. + - name: Build app + run: pnpm build:release + env: + ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }} + ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }} + + # Why: macOS signing secrets (CSC_LINK, CSC_KEY_PASSWORD) must NOT be + # passed to non-macOS builds. electron-builder uses CSC_LINK as the + # code-signing certificate on any platform, so leaking the Apple + # Developer ID cert to the Windows build causes the NSIS installer to + # be signed with an Apple cert whose chain Windows cannot validate, + # breaking the auto-updater with "certificate chain could not be built + # to a trusted root authority" (issue #631). + # + # Why retry: electron-builder downloads NSIS/winCodeSign/squirrel + # binaries and the Electron runtime from GitHub release assets during + # publish. GitHub's download CDN occasionally returns 504s that fail + # the whole release. Retry on failure so transient network errors + # don't require a manual re-run. + - name: Publish release artifacts (macOS) + if: matrix.platform == 'mac' + uses: nick-fields/retry@v3 + with: + timeout_minutes: 45 + max_attempts: 3 + retry_wait_seconds: 30 + command: ${{ matrix.release_command }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Publish release artifacts + if: matrix.platform != 'mac' + uses: nick-fields/retry@v3 + with: + timeout_minutes: 30 + max_attempts: 3 + retry_wait_seconds: 30 + command: ${{ matrix.release_command }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Why post-publish (not pre-publish): electron-builder packs and + # uploads in a single `--publish always` invocation, so there is no + # cheap insertion point between pack and upload without splitting + # that step. Running verify last still blocks the bad release: the + # binary is uploaded to the draft, but a failed matrix job blocks + # the `publish-release` job (which depends on `build`) from flipping + # the release from draft → published, so users never see it. A human + # then deletes the draft and re-cuts. + # + # Why this guards against: a misconfigured CI run where + # `ORCA_POSTHOG_WRITE_KEY` is unset or the tag fails to classify + # would otherwise produce a binary with `BUILD_IDENTITY = null` and + # `WRITE_KEY = null`, which silently disables transport + # (`IS_OFFICIAL_BUILD === false`) — the exact failure mode flagged + # in PR #1385's deferred follow-up. + - name: Verify telemetry constants present in app.asar + run: node config/scripts/verify-telemetry-constants.mjs + + publish-release: + needs: + - cut + - build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Publish release + # Why: derive `--prerelease` from the tag shape (not from whatever + # electron-builder left the release flagged as). On 2026-04-27, + # electron-builder's publish step flipped `prerelease` back to + # `false` on -rc.N releases, which caused an RC to be marked as + # GitHub's "latest" release and broke release-cut.yml's math. + # Re-asserting here means the final release state is determined + # by the tag — a ground truth electron-builder can't rewrite. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.cut.outputs.tag }} + run: | + set -euo pipefail + if [[ "$TAG" == *"-rc."* ]]; then + prerelease=true + else + prerelease=false + fi + gh release edit "$TAG" \ + --draft=false \ + --prerelease="$prerelease" \ + --repo "$GITHUB_REPOSITORY" + + homebrew-bump: + needs: + - cut + - publish-release + if: ${{ !contains(needs.cut.outputs.tag, '-rc.') }} + uses: ./.github/workflows/homebrew-bump.yml with: tag: ${{ needs.cut.outputs.tag }} secrets: inherit diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml deleted file mode 100644 index 3641ba6faa6..00000000000 --- a/.github/workflows/release-rc.yml +++ /dev/null @@ -1,157 +0,0 @@ -name: Schedule RC Release - -on: - workflow_dispatch: - inputs: - dry_run: - description: Validate the workflow without creating an RC version or tag - required: false - default: false - type: boolean - schedule: - # Why: GitHub scheduled workflows are not guaranteed to start exactly on - # time and can be delayed or dropped around high-load periods. We retry - # across the full target hour and dedupe per PT slot. - - cron: '*/5 3,15 * * *' - timezone: 'America/Los_Angeles' - -permissions: - contents: write - -concurrency: - group: release-rc - cancel-in-progress: false - -jobs: - create-rc-tag: - runs-on: ubuntu-latest - timeout-minutes: 15 - outputs: - tag: ${{ steps.version.outputs.tag }} - should_release: ${{ steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: main - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - - - name: Configure git author - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Sync with latest main - run: | - git fetch origin main --tags - git checkout main - git reset --hard origin/main - - - name: Compute RC slot - id: slot - run: | - slot=$(TZ=America/Los_Angeles date '+%Y-%m-%d-%H') - echo "value=$slot" >>"$GITHUB_OUTPUT" - - - name: Validate PT release window - id: window - env: - EVENT_NAME: ${{ github.event_name }} - run: | - if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - echo "allowed=true" >>"$GITHUB_OUTPUT" - echo "reason=manual" >>"$GITHUB_OUTPUT" - exit 0 - fi - - pt_hour=$(TZ=America/Los_Angeles date '+%H') - pt_minute=$(TZ=America/Los_Angeles date '+%M') - - # Why: GitHub may deliver a scheduled event long after the intended - # time. We only allow scheduled RC releases during the exact target - # PT hours so a delayed run at 4:16 AM never creates a release that - # was intended for 3:00 AM. - if [[ "$pt_hour" == "03" || "$pt_hour" == "15" ]]; then - echo "allowed=true" >>"$GITHUB_OUTPUT" - echo "reason=target_hour:${pt_hour}:${pt_minute}" >>"$GITHUB_OUTPUT" - exit 0 - fi - - echo "allowed=false" >>"$GITHUB_OUTPUT" - echo "reason=outside_target_hour:${pt_hour}:${pt_minute}" >>"$GITHUB_OUTPUT" - - - name: Skip if this PT release window already ran - id: existing - if: steps.window.outputs.allowed == 'true' - run: | - # Why: GitHub can delay or drop schedule events, especially near the - # start of the hour. We schedule multiple attempts inside each target - # PT hour and make the release idempotent per hour by embedding a - # slot marker in the release commit message. - if git log origin/main --grep="\\[rc-slot:${{ steps.slot.outputs.value }}\\]" -n 1 --format=%H | grep -q .; then - echo "already_ran=true" >>"$GITHUB_OUTPUT" - exit 0 - fi - - # Why: the first scheduled run today used the earlier workflow before - # slot markers existed. Keep a tag-time fallback so retries in the - # same PT hour still skip if an RC tag was already created. - latest_rc_tag="$(git for-each-ref --sort=-creatordate --format='%(refname:short) %(creatordate:iso-strict)' 'refs/tags/v*-rc.*' | head -n 1)" - if [[ -n "$latest_rc_tag" ]]; then - latest_rc_tag_name="${latest_rc_tag%% *}" - latest_rc_tag_date="${latest_rc_tag#* }" - latest_rc_slot="$(TZ=America/Los_Angeles date -d "$latest_rc_tag_date" '+%Y-%m-%d-%H')" - - if [[ "$latest_rc_slot" == "${{ steps.slot.outputs.value }}" ]]; then - echo "already_ran=true" >>"$GITHUB_OUTPUT" - echo "reason=latest_rc_tag:$latest_rc_tag_name" >>"$GITHUB_OUTPUT" - exit 0 - fi - fi - - echo "already_ran=false" >>"$GITHUB_OUTPUT" - - - name: Dry run summary - if: github.event_name == 'workflow_dispatch' && inputs.dry_run - run: | - echo "Dry run only." - echo "Current PT slot: ${{ steps.slot.outputs.value }}" - echo "Window allowed: ${{ steps.window.outputs.allowed }}" - echo "Window reason: ${{ steps.window.outputs.reason }}" - echo "Already ran this slot: ${{ steps.existing.outputs.already_ran }}" - echo "Reason: ${{ steps.existing.outputs.reason }}" - - - name: Skip summary - if: steps.window.outputs.allowed != 'true' - run: | - echo "Skipping scheduled RC release." - echo "Current PT slot: ${{ steps.slot.outputs.value }}" - echo "Reason: ${{ steps.window.outputs.reason }}" - - - name: Create and push RC tag - id: version - if: steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Why: pushes made with GITHUB_TOKEN do not reliably trigger other - # workflows via push events. The RC workflow invokes release.yml - # directly after creating the tag, so the actual Electron release - # runs inside this scheduled flow instead of depending on push. - new_tag="$(npm version prerelease --preid=rc -m "release: %s [rc-slot:${{ steps.slot.outputs.value }}]")" - git push origin main --follow-tags - echo "tag=$new_tag" >>"$GITHUB_OUTPUT" - - release-rc: - needs: create-rc-tag - if: needs.create-rc-tag.outputs.should_release == 'true' - uses: ./.github/workflows/release.yml - with: - tag: ${{ needs.create-rc-tag.outputs.tag }} - secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 2c4c210e15f..00000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,232 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - workflow_call: - inputs: - tag: - description: Tag to release, for example v1.2.3-rc.0 - required: true - type: string - workflow_dispatch: - inputs: - tag: - description: Existing tag to release, for example v1.2.3-rc.0 - required: true - type: string - -jobs: - resolve-release: - runs-on: ubuntu-latest - outputs: - tag: ${{ steps.resolve.outputs.tag }} - ref: ${{ steps.resolve.outputs.ref }} - steps: - - name: Resolve release tag and ref - id: resolve - run: | - if [[ "${{ github.event_name }}" == "push" ]]; then - release_tag="${{ github.ref_name }}" - release_ref="${{ github.ref }}" - else - release_tag="${{ inputs.tag }}" - release_ref="refs/tags/${{ inputs.tag }}" - fi - - echo "tag=$release_tag" >>"$GITHUB_OUTPUT" - echo "ref=$release_ref" >>"$GITHUB_OUTPUT" - - create-release: - needs: resolve-release - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ needs.resolve-release.outputs.ref }} - - - name: Create draft release with auto-generated notes - run: | - if gh release view "${{ needs.resolve-release.outputs.tag }}" --repo "${{ github.repository }}" >/dev/null 2>&1; then - echo "Release ${{ needs.resolve-release.outputs.tag }} already exists." - exit 0 - fi - - gh release create "${{ needs.resolve-release.outputs.tag }}" \ - --draft \ - --generate-notes \ - --prerelease=${{ contains(needs.resolve-release.outputs.tag, 'rc') }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # Why: E2E runs alongside the release for visibility (failures surface as a - # red check on the tag), but it is NOT in `release`'s needs list. Releases - # already take a while and the suite is already a required check on PRs, so - # gating here would mostly just delay shipping without adding much signal. - # Matches the pattern used by noqa's app deploy, which runs E2E with - # continue-on-error so failures are visible but don't block the deploy. - e2e: - needs: resolve-release - uses: ./.github/workflows/e2e.yml - with: - ref: ${{ needs.resolve-release.outputs.ref }} - - release: - needs: - - resolve-release - - create-release - strategy: - fail-fast: false - matrix: - include: - - os: macos-15 - platform: mac - release_command: ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always - eb_cache_path: | - ~/Library/Caches/electron - ~/Library/Caches/electron-builder - - os: windows-latest - platform: win - release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always - eb_cache_path: | - ~\AppData\Local\electron\Cache - ~\AppData\Local\electron-builder\Cache - - os: ubuntu-latest - platform: linux - release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish always - eb_cache_path: | - ~/.cache/electron - ~/.cache/electron-builder - - runs-on: ${{ matrix.os }} - - permissions: - contents: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ needs.resolve-release.outputs.ref }} - - # pnpm must be on PATH before setup-node so setup-node can locate the store for caching. - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: pnpm - - # Cache the Electron binary + electron-builder tool downloads (notarytool, - # winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac. - - name: Cache electron-builder downloads - uses: actions/cache@v4 - with: - path: ${{ matrix.eb_cache_path }} - key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - electron-builder-${{ matrix.platform }}- - - # Why: pnpm install triggers electron's postinstall, which downloads the - # Electron binary from GitHub release assets. GitHub's download CDN - # occasionally returns 504s that fail the whole release. Retry on - # failure so transient network errors don't require a manual re-run. - - name: Install dependencies - uses: nick-fields/retry@v3 - with: - timeout_minutes: 10 - max_attempts: 3 - retry_wait_seconds: 30 - command: pnpm install --frozen-lockfile - - - name: Verify macOS signing environment - if: matrix.platform == 'mac' - run: node config/scripts/verify-macos-release-env.mjs - env: - CSC_LINK: ${{ secrets.MAC_CERTS }} - CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - - - name: Build app - run: pnpm build:release - - # Why: macOS signing secrets (CSC_LINK, CSC_KEY_PASSWORD) must NOT be - # passed to non-macOS builds. electron-builder uses CSC_LINK as the - # code-signing certificate on any platform, so leaking the Apple - # Developer ID cert to the Windows build causes the NSIS installer to - # be signed with an Apple cert whose chain Windows cannot validate, - # breaking the auto-updater with "certificate chain could not be built - # to a trusted root authority" (issue #631). - # - # Why retry: electron-builder downloads NSIS/winCodeSign/squirrel - # binaries and the Electron runtime from GitHub release assets during - # publish. GitHub's download CDN occasionally returns 504s that fail - # the whole release. Retry on failure so transient network errors - # don't require a manual re-run. - - name: Publish release artifacts (macOS) - if: matrix.platform == 'mac' - uses: nick-fields/retry@v3 - with: - timeout_minutes: 45 - max_attempts: 3 - retry_wait_seconds: 30 - command: ${{ matrix.release_command }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CSC_LINK: ${{ secrets.MAC_CERTS }} - CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - - - name: Publish release artifacts - if: matrix.platform != 'mac' - uses: nick-fields/retry@v3 - with: - timeout_minutes: 30 - max_attempts: 3 - retry_wait_seconds: 30 - command: ${{ matrix.release_command }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish-release: - needs: - - resolve-release - - release - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Publish release - # Why: derive `--prerelease` from the tag shape (not from whatever - # electron-builder left the release flagged as). On 2026-04-27, - # electron-builder's publish step flipped `prerelease` back to - # `false` on -rc.N releases, which caused an RC to be marked as - # GitHub's "latest" release and broke release-cut.yml's math. - # Re-asserting here means the final release state is determined - # by the tag — a ground truth electron-builder can't rewrite. - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ needs.resolve-release.outputs.tag }} - run: | - set -euo pipefail - if [[ "$TAG" == *"-rc."* ]]; then - prerelease=true - else - prerelease=false - fi - gh release edit "$TAG" \ - --draft=false \ - --prerelease="$prerelease" \ - --repo "$GITHUB_REPOSITORY" diff --git a/.gitignore b/.gitignore index c2a2c6fee2d..a3f366a865f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ src/**/*.d.ts !src/preload/index.d.ts !src/renderer/src/env.d.ts !src/renderer/src/mermaid.d.ts +!src/types/build-constants.d.ts # Dependencies node_modules/ @@ -80,3 +81,8 @@ docs/design-*.md # Playwright test-results/ playwright-report/ + +# Agent skill installations (machine-local, populated by agent tooling) +/.claude/skills/ +/.agents/skills/ +/skills-lock.json diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index a45fd52cc58..00000000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24 diff --git a/AGENTS.md b/AGENTS.md index 44863f5494c..683b216a3d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,10 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms. - **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`. +## SSH Use Case + +All changes must consider the SSH use case. Don't assume local-only execution. + ## GitHub CLI Usage Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows. diff --git a/CLAUDE.md b/CLAUDE.md index 656f0c42715..43c994c2d36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,24 +1 @@ -# CLAUDE.md - -See also: [AGENTS.md](./AGENTS.md) for agent-specific rules. - -## Code Comments: Document the "Why" - -When implementing behavior driven by a design doc, spec, or non-obvious constraint, **add a comment explaining why** the code does what it does — not just what it does. This is especially important for: - -- **Safety constraints** — e.g., suppressing an action because it could silently erase a signal, re-create a conflict, or mislead the user. -- **Fallback/error-handling choices** — e.g., defaulting to `'modified'` on fs error because it's the least misleading option. -- **Architectural boundaries** — e.g., why state lives in the renderer and never crosses IPC, or why a feature belongs to Source Control and not Checks. -- **Compatibility shims** — e.g., when a field exists purely for downstream plumbing and does not carry semantic meaning. -- **Intentional omissions** — e.g., skipping submodule conflicts or not providing rename metadata because the data source doesn't support it. - -A future maintainer who hasn't read the design doc should be able to understand from the comment alone why the code must not be changed casually. - -## Package Manager - -This project uses **pnpm**. Never use `npm` or `yarn`. - -- Use `pnpm install` (not `npm install`) -- Use `pnpm add ` (not `npm install `) -- Use `pnpm run + + diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 00000000000..02fa1bf57d0 --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,54 @@ +{ + "name": "orca-mobile", + "version": "0.0.1", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "lint": "oxlint", + "format": "oxfmt --write .", + "mock-server": "npx tsx scripts/mock-server.ts" + }, + "dependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", + "expo": "^55.0.17", + "expo-build-properties": "^55.0.13", + "expo-camera": "^55.0.16", + "expo-constants": "^55.0.15", + "expo-crypto": "^55.0.14", + "expo-haptics": "^55.0.14", + "expo-linking": "^55.0.14", + "expo-notifications": "^55.0.21", + "expo-router": "^55.0.13", + "expo-secure-store": "^55.0.13", + "expo-splash-screen": "^55.0.19", + "expo-status-bar": "^55.0.5", + "lucide-react-native": "^1.11.0", + "react": "^19.2.0", + "react-native": "^0.83.6", + "react-native-gesture-handler": "^2.30.1", + "react-native-reanimated": "^4.2.1", + "react-native-safe-area-context": "^5.6.2", + "react-native-screens": "^4.23.0", + "react-native-svg": "^15.15.4", + "react-native-web": "^0.21.2", + "react-native-webview": "^13.16.1", + "react-native-worklets": "^0.7.4", + "tweetnacl": "^1.0.3", + "ws": "^8.18.0", + "zod": "^4.3.6", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-native": "^0.73.0", + "@types/ws": "^8.18.1", + "expo-module-scripts": "^55.0.2", + "oxfmt": "^0.47.0", + "oxlint": "^1.62.0", + "tsx": "^4.19.4", + "typescript": "^5.9.3" + } +} diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml new file mode 100644 index 00000000000..9a346120f2a --- /dev/null +++ b/mobile/pnpm-lock.yaml @@ -0,0 +1,12583 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@react-native-async-storage/async-storage': + specifier: ^2.2.0 + version: 2.2.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + expo: + specifier: ^55.0.17 + version: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-build-properties: + specifier: ^55.0.13 + version: 55.0.13(expo@55.0.17) + expo-camera: + specifier: ^55.0.16 + version: 55.0.16(@types/emscripten@1.41.5)(expo@55.0.17)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-constants: + specifier: ^55.0.15 + version: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + expo-crypto: + specifier: ^55.0.14 + version: 55.0.14(expo@55.0.17) + expo-haptics: + specifier: ^55.0.14 + version: 55.0.14(expo@55.0.17) + expo-linking: + specifier: ^55.0.14 + version: 55.0.14(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-notifications: + specifier: ^55.0.21 + version: 55.0.21(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-router: + specifier: ^55.0.13 + version: 55.0.13(121105c3b042d5e83ab3c1d3b84ed55f) + expo-secure-store: + specifier: ^55.0.13 + version: 55.0.13(expo@55.0.17) + expo-splash-screen: + specifier: ^55.0.19 + version: 55.0.19(expo@55.0.17)(typescript@5.9.3) + expo-status-bar: + specifier: ^55.0.5 + version: 55.0.5(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + lucide-react-native: + specifier: ^1.11.0 + version: 1.11.0(react-native-svg@15.15.4(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react: + specifier: ^19.2.0 + version: 19.2.0 + react-native: + specifier: ^0.83.6 + version: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-gesture-handler: + specifier: ^2.30.1 + version: 2.30.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-reanimated: + specifier: ^4.2.1 + version: 4.2.1(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-safe-area-context: + specifier: ^5.6.2 + version: 5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-screens: + specifier: ^4.23.0 + version: 4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-svg: + specifier: ^15.15.4 + version: 15.15.4(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-web: + specifier: ^0.21.2 + version: 0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + react-native-webview: + specifier: ^13.16.1 + version: 13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-worklets: + specifier: ^0.7.4 + version: 0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 + ws: + specifier: ^8.18.0 + version: 8.20.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 + zustand: + specifier: ^5.0.12 + version: 5.0.12(@types/react@19.2.14)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-native': + specifier: ^0.73.0 + version: 0.73.0(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + expo-module-scripts: + specifier: ^55.0.2 + version: 55.0.2(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(eslint@9.39.4)(expo@55.0.17)(jest@29.7.0(@types/node@25.6.0))(prettier@2.8.8)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0) + oxfmt: + specifier: ^0.47.0 + version: 0.47.0 + oxlint: + specifier: ^1.62.0 + version: 1.62.0 + tsx: + specifier: ^4.19.4 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@babel/cli@7.28.6': + resolution: {integrity: sha512-6EUNcuBbNkj08Oj4gAZ+BUU8yLCgKzgVX4gaTh09Ya2C8ICM4P+G30g4m3akRxSYAp3A/gnWchrNst7px4/nUQ==} + engines: {node: '>=6.9.0'} + hasBin: true + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.28.5': + resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.27.1': + resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.28.6': + resolution: {integrity: sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5': + resolution: {integrity: sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1': + resolution: {integrity: sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1': + resolution: {integrity: sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1': + resolution: {integrity: sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6': + resolution: {integrity: sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-proposal-decorators@7.29.0': + resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-export-default-from@7.27.1': + resolution: {integrity: sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.28.6': + resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-dynamic-import@7.8.3': + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-export-default-from@7.28.6': + resolution: {integrity: sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-flow@7.28.6': + resolution: {integrity: sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.28.6': + resolution: {integrity: sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-arrow-functions@7.27.1': + resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.0': + resolution: {integrity: sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.28.6': + resolution: {integrity: sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.27.1': + resolution: {integrity: sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.28.6': + resolution: {integrity: sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.27.1': + resolution: {integrity: sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.28.6': + resolution: {integrity: sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.28.6': + resolution: {integrity: sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.28.4': + resolution: {integrity: sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-classes@7.28.6': + resolution: {integrity: sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.28.6': + resolution: {integrity: sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.28.5': + resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dotall-regex@7.28.6': + resolution: {integrity: sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-keys@7.27.1': + resolution: {integrity: sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0': + resolution: {integrity: sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-dynamic-import@7.27.1': + resolution: {integrity: sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.28.6': + resolution: {integrity: sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-exponentiation-operator@7.28.6': + resolution: {integrity: sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.27.1': + resolution: {integrity: sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.27.1': + resolution: {integrity: sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.27.1': + resolution: {integrity: sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.27.1': + resolution: {integrity: sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.28.6': + resolution: {integrity: sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.27.1': + resolution: {integrity: sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6': + resolution: {integrity: sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.27.1': + resolution: {integrity: sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.27.1': + resolution: {integrity: sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-systemjs@7.29.0': + resolution: {integrity: sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-umd@7.27.1': + resolution: {integrity: sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0': + resolution: {integrity: sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-new-target@7.27.1': + resolution: {integrity: sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.27.1': + resolution: {integrity: sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6': + resolution: {integrity: sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.28.6': + resolution: {integrity: sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.28.6': + resolution: {integrity: sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.27.1': + resolution: {integrity: sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.28.6': + resolution: {integrity: sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.27.1': + resolution: {integrity: sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.28.6': + resolution: {integrity: sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.27.7': + resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.28.6': + resolution: {integrity: sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.28.6': + resolution: {integrity: sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.27.1': + resolution: {integrity: sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.28.0': + resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.27.1': + resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.28.6': + resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-pure-annotations@7.27.1': + resolution: {integrity: sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.0': + resolution: {integrity: sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regexp-modifiers@7.28.6': + resolution: {integrity: sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-reserved-words@7.27.1': + resolution: {integrity: sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-runtime@7.29.0': + resolution: {integrity: sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.27.1': + resolution: {integrity: sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.28.6': + resolution: {integrity: sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.27.1': + resolution: {integrity: sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.27.1': + resolution: {integrity: sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.27.1': + resolution: {integrity: sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-escapes@7.27.1': + resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.28.6': + resolution: {integrity: sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.27.1': + resolution: {integrity: sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-sets-regex@7.28.6': + resolution: {integrity: sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/preset-env@7.29.2': + resolution: {integrity: sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-modules@0.1.6-no-external-plugins': + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + + '@babel/preset-react@7.28.5': + resolution: {integrity: sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.27.1': + resolution: {integrity: sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@egjs/hammerjs@2.0.17': + resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} + engines: {node: '>=0.8.0'} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@expo-google-fonts/material-symbols@0.4.34': + resolution: {integrity: sha512-PdwETUhvu1gHF1e8eIyEHnBJLq/dRNoTrT5yhsGUfGyRxH5pbm54dF3+QPknxwMKj0M1trN7PSelYz+yzlt3lA==} + + '@expo/cli@55.0.26': + resolution: {integrity: sha512-Ud9gpeGMF5RIL42LXvCw3k3mWK8rf/P2wu+Yrzz9Do1kcFKZeT9Vy2D/xukjdr/Xw+ELba87ThOot17GsPiWjw==} + hasBin: true + peerDependencies: + expo: '*' + expo-router: '*' + react-native: '*' + peerDependenciesMeta: + expo-router: + optional: true + react-native: + optional: true + + '@expo/code-signing-certificates@0.0.6': + resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==} + + '@expo/config-plugins@55.0.8': + resolution: {integrity: sha512-8WfWTRntTCcowfOS+tHdB0z98gKetTwktg4G5TWkCkXVa8Jt1NUnvzaaU4UHk2vbR2U4N84RyZJFizSwfF6C9g==} + + '@expo/config-types@55.0.5': + resolution: {integrity: sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==} + + '@expo/config@55.0.15': + resolution: {integrity: sha512-lHc0ELIQ8126jYOMZpLv3WIuvordW98jFg5aT/J1/12n2ycuXu01XLZkJsdw0avO34cusUYb1It+MvY8JiMduA==} + + '@expo/devcert@1.2.1': + resolution: {integrity: sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==} + + '@expo/devtools@55.0.2': + resolution: {integrity: sha512-4VsFn9MUriocyuhyA+ycJP3TJhUsOFHDc270l9h3LhNpXMf6wvIdGcA0QzXkZtORXmlDybWXRP2KT1k36HcQkA==} + peerDependencies: + react: '*' + react-native: '*' + peerDependenciesMeta: + react: + optional: true + react-native: + optional: true + + '@expo/dom-webview@55.0.5': + resolution: {integrity: sha512-lt3uxYOCk3wmWvtOOvsC35CKGbDAOx5C2EaY8SH1JVSfBzqmF8Cs0Xp1MPxncDPMyxpMiWx5SvvV/iLF1rJU4A==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + '@expo/env@2.1.1': + resolution: {integrity: sha512-rVvHC4I6xlPcg+mAO09ydUi2Wjv1ZytpLmHOSzvXzBAz9mMrJggqCe4s4dubjJvi/Ino/xQCLhbaLCnTtLpikg==} + engines: {node: '>=20.12.0'} + + '@expo/fingerprint@0.16.6': + resolution: {integrity: sha512-nRITNbnu3RKSHPvKVehrSU4KG2VY9V8nvULOHBw98ukHCAU4bGrU5APvcblOkX3JAap+xEHsg/mZvqlvkLInmQ==} + hasBin: true + + '@expo/image-utils@0.8.13': + resolution: {integrity: sha512-1I//yBQeTY6p0u1ihqGNDAr35EbSG8uFEupFrIF0jd++h9EWH33521yZJU1yE+mwGlzCb61g3ehu78siMhXBlA==} + + '@expo/json-file@10.0.13': + resolution: {integrity: sha512-pX/XjQn7tgNw6zuuV2ikmegmwe/S7uiwhrs2wXrANMkq7ozrA+JcZwgW9Q/8WZgciBzfAhNp5hnackHcrmapQA==} + + '@expo/local-build-cache-provider@55.0.11': + resolution: {integrity: sha512-rJ4RTCrkeKaXaido/bVyhl90ZRtVTOEbj59F1PWVjIEIVgjdlfc1J3VD9v7hEsbf/+8Tbr/PgvWhT6Visi5sLQ==} + + '@expo/log-box@55.0.11': + resolution: {integrity: sha512-JQHFLWkskIbJi6cxYMjErx8lQqfFJilDQLKmdTO3m3YkdmN9GE/CrzjOfVlCG0DGEGZJ90br0pGKvGPdXNsHKw==} + peerDependencies: + '@expo/dom-webview': ^55.0.5 + expo: '*' + react: '*' + react-native: '*' + + '@expo/metro-config@55.0.17': + resolution: {integrity: sha512-o11VyNoRDXv0T5320D9cH+nSsrR/OMHTjtysKLIfDlidsBswDk1DMApPv9Kw0/gluArCSnbx8JC1G0Yh2Y4P3g==} + peerDependencies: + expo: '*' + peerDependenciesMeta: + expo: + optional: true + + '@expo/metro-runtime@55.0.10': + resolution: {integrity: sha512-7v+ldTvMWRa1ml83Jel9W2f8qT/NZZWrlHaEjf29nb72JTEO50+Xac9PWLo+X3LCDAAuyYuBGKYXOJwfqxV0fQ==} + peerDependencies: + expo: '*' + react: '*' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + + '@expo/metro@55.1.0': + resolution: {integrity: sha512-bb/LOncsz9KiP6cHmMy0MCDG1COZOn+k+pRpDrvJUmxLdOOuniJSYyCc/Dgv1bR9E/6YR+fh3EXGg9MUrVNy4Q==} + + '@expo/npm-proofread@1.0.1': + resolution: {integrity: sha512-yDyBlNIgg+rKoKCOVM9ZyAtgqRx6gHw+JtmZTrYYW4auAfwS6kE/iInmCpVXPqRy0OhJHgXj6q5PCgWELLVMeg==} + hasBin: true + + '@expo/osascript@2.4.2': + resolution: {integrity: sha512-/XP7PSYF2hzOZzqfjgkoWtllyeTN8dW3aM4P6YgKcmmPikKL5FdoyQhti4eh6RK5a5VrUXJTOlTNIpIHsfB5Iw==} + engines: {node: '>=12'} + + '@expo/package-manager@1.10.4': + resolution: {integrity: sha512-y9Mr4Kmpk4abAVZrNNPCdzOZr8nLLyi18p1SXr0RCVA8IfzqZX/eY4H+50a0HTmXqIsPZrQdcdb4I3ekMS9GvQ==} + + '@expo/plist@0.5.2': + resolution: {integrity: sha512-o4xdVdBpe4aTl3sPMZ2u3fJH4iG1I768EIRk1xRZP+GaFI93MaR3JvoFibYqxeTmLQ1p1kNEVqylfUjezxx45g==} + + '@expo/prebuild-config@55.0.16': + resolution: {integrity: sha512-o4EAVgDGk1lISirtMD8hciO2vyMp7cWlPdfTtjjd5AXSfODVYDIDhygXrfvVQHmJXAztVqPUTKJT+BYOsVkYGQ==} + peerDependencies: + expo: '*' + + '@expo/require-utils@55.0.4': + resolution: {integrity: sha512-JAANvXqV7MOysWeVWgaiDzikoyDjJWOV/ulOW60Zb3kXJfrx2oZOtGtDXDFKD1mXuahQgoM5QOjuZhF7gFRNjA==} + peerDependencies: + typescript: ^5.0.0 || ^5.0.0-0 + peerDependenciesMeta: + typescript: + optional: true + + '@expo/router-server@55.0.15': + resolution: {integrity: sha512-6LksYO4Pg13qroL138KfUebt/x/EO07zVhdyT/nTgcxnpn6CS4ecTl3DciSKhxbaH+0BVLdANkxYeGdp43TMwQ==} + peerDependencies: + '@expo/metro-runtime': ^55.0.10 + expo: '*' + expo-constants: ^55.0.15 + expo-font: ^55.0.6 + expo-router: '*' + expo-server: ^55.0.8 + react: '*' + react-dom: '*' + react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 + peerDependenciesMeta: + '@expo/metro-runtime': + optional: true + expo-router: + optional: true + react-dom: + optional: true + react-server-dom-webpack: + optional: true + + '@expo/schema-utils@55.0.3': + resolution: {integrity: sha512-l9KHVjTo6MvoeyvwNr6AjckGJm8NIcqZ3QSAh51cWozXW9v2AUjyCyqYtFtyntLWRZ0x/ByYJishpQo4ZQq45Q==} + + '@expo/sdk-runtime-versions@1.0.0': + resolution: {integrity: sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==} + + '@expo/spawn-async@1.7.2': + resolution: {integrity: sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==} + engines: {node: '>=12'} + + '@expo/sudo-prompt@9.3.2': + resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} + + '@expo/vector-icons@15.1.1': + resolution: {integrity: sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==} + peerDependencies: + expo-font: '>=14.0.4' + react: '*' + react-native: '*' + + '@expo/ws-tunnel@1.0.6': + resolution: {integrity: sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==} + + '@expo/xcpretty@4.4.3': + resolution: {integrity: sha512-wC562eD3gS6vO2tWHToFhlFnmHKfKHgF1oyvojeSkLK/ZYop1bMU+7cOMiF9Sq70CzcsLy/EMRy/uRc76QmNRw==} + hasBin: true + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/ttlcache@1.4.1': + resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/create-cache-key-function@29.7.0': + resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/diff-sequences@30.3.0': + resolution: {integrity: sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': + resolution: {integrity: sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==} + + '@oxfmt/binding-android-arm-eabi@0.47.0': + resolution: {integrity: sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.47.0': + resolution: {integrity: sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.47.0': + resolution: {integrity: sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.47.0': + resolution: {integrity: sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.47.0': + resolution: {integrity: sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.47.0': + resolution: {integrity: sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.47.0': + resolution: {integrity: sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.47.0': + resolution: {integrity: sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-arm64-musl@0.47.0': + resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-ppc64-gnu@0.47.0': + resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-gnu@0.47.0': + resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-musl@0.47.0': + resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-s390x-gnu@0.47.0': + resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxfmt/binding-linux-x64-gnu@0.47.0': + resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-linux-x64-musl@0.47.0': + resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-openharmony-arm64@0.47.0': + resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.47.0': + resolution: {integrity: sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.47.0': + resolution: {integrity: sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.47.0': + resolution: {integrity: sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.62.0': + resolution: {integrity: sha512-pKsthNECyvJh8lPTICz6VcwVy2jOqdhhsp1rlxCkhgZR47aKvXPmaRWQDv+zlXpRae4qm1MaaTnutkaOk5aofg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.62.0': + resolution: {integrity: sha512-b1AUNViByvgmR2xJDubvLIr+dSuu3uraG7bsAoKo+xrpspPvu6RIn6Fhr2JUhobfep3jwUTy18Huco6GkwdvGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.62.0': + resolution: {integrity: sha512-iG+Tvf70UJ6otfwFYIHk36Sjq9cpPP5YLxkoggANNRtzgi3Tj3g8q6Ybqi6AtkU3+yg9QwF7bDCkCS6bbL4PCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.62.0': + resolution: {integrity: sha512-oOWI6YPPr5AJUx+yIDlxmuUbQjS5gZX3OH3QisawYvsZgLiQVvZtR0rPBcJTxLWqt2ClrWg0DlSrlUiG5SQNHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.62.0': + resolution: {integrity: sha512-dLP33T7VLCmLVv4cvjkVX+rmkcwNk2UfxmsZPNur/7BQHoQR60zJ7XLiRvNUawlzn0u8ngCa3itjEG73MAMa/w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.62.0': + resolution: {integrity: sha512-fl//LWNks6qo9chNY60UDYyIwtp7a5cEx4Y/rHPjaarhuwqx6jtbzEpD5V5AqmdL4a6Y5D8zeXg5HF2Cr0QmSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.62.0': + resolution: {integrity: sha512-i5vkAuxvueTODV3J2dL61/TXewDHhMFKvtD156cIsk7GsdfiAu7zW7kY0NJXhKeFHeiMZIh7eFNjkPYH6J47HQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.62.0': + resolution: {integrity: sha512-QwN19LLuIGuOjEflSeJkZmOTfBdBMlTmW8xbMf8TZhjd//cxVNYQPq75q7oKZBJc6hRx3gY7sX0Egc8cEIFZYg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.62.0': + resolution: {integrity: sha512-8eCy3FCDuWUM5hWujAv6heMvfZPbcCOU3SdQUAkixZLu5bSzOkNfirJiLGoQFO943xceOKkiQRMQNzH++jM3WA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.62.0': + resolution: {integrity: sha512-NjQ7K7tpTPDe9J+yq8p/s/J0E7lRCkK2uDBDqvT4XIT6f4Z0tlnr59OBg/WcrmVHER1AbrcfyxhGTXgcG8ytWg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.62.0': + resolution: {integrity: sha512-oKZed9gmSwze29dEt3/Wnsv6l/Ygw/FUst+8Kfpv2SGeS/glEoTGZAMQw37SVyzFV76UTHJN2snGgxK2t2+8ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.62.0': + resolution: {integrity: sha512-gBjBxQ+9lGpAYq+ELqw0w8QXsBnkZclFc7GRX2r0LnEVn3ZTEqeIKpKcGjucmp76Q53bvJD0i4qBWBhcfhSfGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.62.0': + resolution: {integrity: sha512-Ew2Kxs9EQ9/mbAIJ2hvocMC0wsOu6YKzStI2eFBDt+Td5O8seVC/oxgRIHqCcl5sf5ratA1nozQBAuv7tphkHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.62.0': + resolution: {integrity: sha512-5z25jcAA0gfKyVwz71A0VXgaPlocPoTAxhlv/hgoK6tlCrfoNuw7haWbDHvGMfjXhdic4EqVXGRv5XsTqFnbRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.62.0': + resolution: {integrity: sha512-IWpHmMB6ZDllPvqWDkG6AmXrN7JF5e/c4g/0PuURsmlK+vHoYZPB70rr4u1bn3I4LsKCSpqqfveyx6UCOC8wdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.62.0': + resolution: {integrity: sha512-fjlSxxrD5pA594vkyikCS9MnPRjQawW6/BLgyTYkO+73wwPlYjkcZ7LSd974l0Q2zkHQmu4DPvJFLYA7o8xrxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.62.0': + resolution: {integrity: sha512-EiFXr8loNS0Ul3Gu80+9nr1T8jRmnKocqmHHg16tj5ZqTgUXyb97l2rrspVHdDluyFn9JfR4PoJFdNzw4paHww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.62.0': + resolution: {integrity: sha512-IgOFvL73li1bFgab+hThXYA0N2Xms2kV2MvZN95cebV+fmrZ9AVui1JSxfeeqRLo3CpPxKZlzhyq4G0cnaAvIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.62.0': + resolution: {integrity: sha512-6hMpyDWQ2zGA1OXFKBrdYMUveUCO8UJhkO6JdwZPd78xIdHZNhjx+pib+4fC2Cljuhjyl0QwA2F3df/bs4Bp6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@react-native-async-storage/async-storage@2.2.0': + resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==} + peerDependencies: + react-native: ^0.0.0-0 || >=0.65 <1.0 + + '@react-native/assets-registry@0.83.6': + resolution: {integrity: sha512-iljb4ue1yWJ3EhySz7EjV6CzSVrI2uNtR8BI2jzP5+QS5E4Cl3fdIJRmVwDEx1pu8uE97PGEusGRHnoaZ9Q3jg==} + engines: {node: '>= 20.19.4'} + + '@react-native/assets-registry@0.85.2': + resolution: {integrity: sha512-kauC/oPaxklU4Y+u9gBfCBJm51qX6WBZq4xx0USCdimtp+G8+554kpygfSWIjoqCJa2o06bWxBEjesiuCv+LzA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/babel-plugin-codegen@0.83.6': + resolution: {integrity: sha512-qfRXsHGeucT5c6mK+8Q7v4Ly3zmygfVmFlEtkiq7q07W1OTreld6nib4rJ/DBEeNiKBoBTuHjWliYGNuDjLFQA==} + engines: {node: '>= 20.19.4'} + + '@react-native/babel-plugin-codegen@0.85.2': + resolution: {integrity: sha512-5Dqn08kRTUIxPLYju9hExI0cR1ESX+P5tEv5yv0q0UZcisRTw0VB8iUWDIph2LdY1i5Dc8PIvuaWMRNCw3vnKg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/babel-preset@0.83.6': + resolution: {integrity: sha512-4/fXFDUvGOObETZq4+SUFkafld6OGgQWut5cQiqVghlhCB5z/p2lVhPgEUr/aTxTzeS3AmN+ztC+GpYPQ7tsTw==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@babel/core': '*' + + '@react-native/babel-preset@0.85.2': + resolution: {integrity: sha512-7d2yW23eKkVt0FbbnZLxqO7KybGLtQXOuvvcO1NUOYGtjzVh6ihNKn0TIHrhSNpMyHwYLDoiiuj95wLtcg3IwQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.83.6': + resolution: {integrity: sha512-doB/Pq6Cf6IjF3wlQXTIiZOnsX9X8mEEk+CdGfyuCwZjWrf7IB8KaZEXXckJmfUcIwvJ9u/a72ZoTTCIoxAc9A==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.85.2': + resolution: {integrity: sha512-XCginmxh0//++EXVOEJHBVZxHla294FzLCFF6jXwAUjvXVhqyIKyxhABfz+r4OOmaiuWk4Rtd4arqdAzeHeprg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/community-cli-plugin@0.83.6': + resolution: {integrity: sha512-Mko6mywoHYJmpBnjwAC95vQWaUUh//71knFadH0BrhHDq2m7i/IrpLwcQsPAy8855ucXflBs5zQyGTpNbPBAaw==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@react-native-community/cli': '*' + '@react-native/metro-config': '*' + peerDependenciesMeta: + '@react-native-community/cli': + optional: true + '@react-native/metro-config': + optional: true + + '@react-native/community-cli-plugin@0.85.2': + resolution: {integrity: sha512-3KLgSg1kHvBpr93zMaQhvfYTgnCw7yZRED+3J4dMcYjfSjtD0Wf8SofU6uBmAw9JaVYvP43lpdwUpI4p0+ABsg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@react-native-community/cli': '*' + '@react-native/metro-config': 0.85.2 + peerDependenciesMeta: + '@react-native-community/cli': + optional: true + '@react-native/metro-config': + optional: true + + '@react-native/debugger-frontend@0.83.6': + resolution: {integrity: sha512-TyWXEpAjVundrc87fPWg91piOUg75+X9iutcfDe7cO3NrAEYCsl7Z09rKHuiAGkxfG9/rFD13dPsYIixUFkSFA==} + engines: {node: '>= 20.19.4'} + + '@react-native/debugger-frontend@0.85.2': + resolution: {integrity: sha512-j+0b9H5f5hGTLQxHIhJU/b/W6ijuxJF+ZTLHB0se2kzUBNxFKd7DkIc6753qk3CJdiv55vxG3XDgmlpbHxOpmA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/debugger-shell@0.83.6': + resolution: {integrity: sha512-684TJMBCU0l0ZjJWzrnK0HH+ERaM9KLyxyArE1k7BrP+gVl4X9GO0Pi94RoInOxvW/nyV65sOU6Ip1F3ygS0cg==} + engines: {node: '>= 20.19.4'} + + '@react-native/debugger-shell@0.85.2': + resolution: {integrity: sha512-r5BkhqPMfg3LmaZS5zadHmBNVH5h4bhSpv4BEPGfK4gat9HABAMzUzybi+2wpgU3SoHxnyKGdExEJvoqVcjeRg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/dev-middleware@0.83.6': + resolution: {integrity: sha512-22xoddLTelpcVnF385SNH2hdP7X2av5pu7yRl/WnM5jBznbcl0+M9Ce94cj+WVeomsoUF/vlfuB0Ooy+RMlRiA==} + engines: {node: '>= 20.19.4'} + + '@react-native/dev-middleware@0.85.2': + resolution: {integrity: sha512-3J+NaDUg+QEfDeLAUzgaWhpaxEg78g+KwbydlDCewh2G6WnHpsty8XooruxNHzyAsqVWywZMrzmbn78Ctc1O9Q==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/gradle-plugin@0.83.6': + resolution: {integrity: sha512-5prXv7WWR1RgZ/kWGZP+mi7/y/IE2ymfOHIZO5Pv14tMOmRAcQSgSYogcRmOiWw5mJs2K0UFeMiQD49ZO9oCug==} + engines: {node: '>= 20.19.4'} + + '@react-native/gradle-plugin@0.85.2': + resolution: {integrity: sha512-YXBOLeAqFrv7XwUeBPTKZeOV1FIxn4AW7UAEitScf3ibC8bu8+6NpJu4HWgbNQHg7vDbbTZVbcOl8EwGxsSq2w==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/js-polyfills@0.83.6': + resolution: {integrity: sha512-VSev0LV2i5X0ibduHBSLqKj0YU2F+waCgjl2uvaGHMGCSV1ZRKNFX/vJFqvLwjvdzLbkAZoFT1Rg7k7jDv44UA==} + engines: {node: '>= 20.19.4'} + + '@react-native/js-polyfills@0.85.2': + resolution: {integrity: sha512-esGEAmKVM40DV/yVmNljCKZTIeUo7qXqc+Hwffkv3TG+b3E24xyFovHrbP98gGxZr2ZsEyx+2sKLdXF5asY5nw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/metro-babel-transformer@0.85.2': + resolution: {integrity: sha512-lU9XOGahpHvQff30H5lnvh9RYbVwC1zpSHpl84E+7BD2zj0FvW+pD7MBh7CWbmbWmegjtAb+U/2bokXcDVA+jA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/metro-config@0.85.2': + resolution: {integrity: sha512-YkTIMfTPeyMUrtpQo/7zd3oybVYJCfTp8626PqoakOvEiWi9PxsUpZ8j44a5GFtOIq8Nc6WWVBiFRn/6qdi1uQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/normalize-colors@0.74.89': + resolution: {integrity: sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==} + + '@react-native/normalize-colors@0.83.6': + resolution: {integrity: sha512-bTM24b5v4qN3h52oflnv+OujFORn/kVi06WaWhnQQw14/ycilPqIsqsa+DpIBqdBrXxvLa9fXtCRrQtGATZCEw==} + + '@react-native/normalize-colors@0.85.2': + resolution: {integrity: sha512-svuOLtjbFGXDdHsriHXuND5FgHg7XlkOXCbH/8+X4t76YLH6qSTffSIQQrKLDL5mn4EFU+Oh/PNO0/FfpnTOTg==} + + '@react-native/virtualized-lists@0.83.6': + resolution: {integrity: sha512-gNSFXeb4P7qHtauLvl+zESroULIyX6Ltpvau3dhwy/QmfanBv0KUcrIU/7aVXxtWcXgp+54oWJyu2LIrsZ9+LQ==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@types/react': ^19.2.0 + react: '*' + react-native: '*' + peerDependenciesMeta: + '@types/react': + optional: true + + '@react-native/virtualized-lists@0.85.2': + resolution: {integrity: sha512-wmVKpAlcr+UB0L5SpbrV865EdleUP7I5+X+48e1aRsQK8q+wsTRBXeUwWVip/1l+HZwlZFeO8iOILJ16VRu0Cw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@types/react': ^19.2.0 + react: '*' + react-native: 0.85.2 + peerDependenciesMeta: + '@types/react': + optional: true + + '@react-navigation/bottom-tabs@7.15.10': + resolution: {integrity: sha512-Ao/yYlrpr0cwYYGxt9FDMQk+tTSHNm4WTaszyhroINLdoEMuKH19k1tGFdYbRBKHJx1UIH8kD+EZTYW1w6LL3Q==} + peerDependencies: + '@react-navigation/native': ^7.2.2 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + react-native-screens: '>= 4.0.0' + + '@react-navigation/core@7.17.2': + resolution: {integrity: sha512-Rt2OZwcgOmjv401uLGAKaRM6xo0fiBce/A7LfRHI1oe5FV+KooWcgAoZ2XOtgKj6UzVMuQWt3b2e6rxo/mDJRA==} + peerDependencies: + react: '>= 18.2.0' + + '@react-navigation/elements@2.9.15': + resolution: {integrity: sha512-cyz/pPiyyC6gaTVLsGFc1g0MYgrmuCFqklAWGXMWPscr5YU3ui94vPI4vnZwcsEy0T758TQWLzmS5XudZeRKcA==} + peerDependencies: + '@react-native-masked-view/masked-view': '>= 0.2.0' + '@react-navigation/native': ^7.2.2 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + peerDependenciesMeta: + '@react-native-masked-view/masked-view': + optional: true + + '@react-navigation/native-stack@7.14.12': + resolution: {integrity: sha512-dUfpkrVeVKKV8iqXsmoUp3Rv0iH3YaB3eZwScru/FlcqAp/r3/qA6zEXkGX9hZK+/ziWAPFrf1frBSNbgOYSFQ==} + peerDependencies: + '@react-navigation/native': ^7.2.2 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + react-native-screens: '>= 4.0.0' + + '@react-navigation/native@7.2.2': + resolution: {integrity: sha512-kem1Ko2BcbAjmbQIv66dNmr6EtfDut3QU0qjsVhMnLLhktwyXb6FzZYp8gTrUb6AvkAbaJoi+BF5Pl55pAUa5w==} + peerDependencies: + react: '>= 18.2.0' + react-native: '*' + + '@react-navigation/routers@7.5.3': + resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==} + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@testing-library/react-native@13.3.3': + resolution: {integrity: sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==} + engines: {node: '>=18'} + peerDependencies: + jest: '>=29.0.0' + react: '>=18.2.0' + react-native: '>=0.71' + react-test-renderer: '>=18.2.0' + peerDependenciesMeta: + jest: + optional: true + + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + + '@tsconfig/node18@18.2.6': + resolution: {integrity: sha512-eAWQzAjPj18tKnDzmWstz4OyWewLUNBm9tdoN9LayzoboRktYx3Enk1ZXPmThj55L7c4VWYq/Bzq0A51znZfhw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/emscripten@1.41.5': + resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/jsdom@20.0.1': + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + '@types/react-native@0.73.0': + resolution: {integrity: sha512-6ZRPQrYM72qYKGWidEttRe6M5DZBEV5F+MHMHqd4TTYx0tfkcdrUFGdef6CCxY0jXU7wldvd/zA/b0A/kTeJmA==} + deprecated: This is a stub types definition. react-native provides its own type definitions, so you do not need this installed. + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@typescript-eslint/eslint-plugin@8.59.1': + resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.1': + resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.1': + resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.1': + resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.1': + resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.1': + resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.1': + resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.1': + resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.1': + resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.1': + resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + + abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + anser@1.4.10: + resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@6.2.1: + resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==} + engines: {node: '>=14.16'} + + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-dynamic-import-node@2.3.3: + resolution: {integrity: sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==} + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.13.0: + resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.14.2: + resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + babel-plugin-react-native-web@0.21.2: + resolution: {integrity: sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==} + + babel-plugin-syntax-hermes-parser@0.32.0: + resolution: {integrity: sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==} + + babel-plugin-syntax-hermes-parser@0.32.1: + resolution: {integrity: sha512-HgErPZTghW76Rkq9uqn5ESeiD97FbqpZ1V170T1RG2RDp+7pJVQV2pQJs7y5YzN0/gcT6GM5ci9apRnIwuyPdQ==} + + babel-plugin-syntax-hermes-parser@0.33.3: + resolution: {integrity: sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==} + + babel-plugin-transform-flow-enums@0.0.2: + resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-expo@55.0.18: + resolution: {integrity: sha512-zmDwKxCFBTe4e/jQXuITRUZlbl8HTZOhsUlwcHGjwEUB0lKQfRdaSYXZckQ+jMOBC34MrOl3Cs7/6F6vNbj5Pw==} + peerDependencies: + '@babel/runtime': ^7.20.0 + expo: '*' + expo-widgets: ^55.0.14 + react-refresh: '>=0.14.0 <1.0.0' + peerDependenciesMeta: + '@babel/runtime': + optional: true + expo: + optional: true + expo-widgets: + optional: true + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + badgin@1.2.3: + resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + barcode-detector@3.1.2: + resolution: {integrity: sha512-Q5kjXpVH5I3ItykNzbWmfWnNryFN1ZTWp10k9/PKJuS0RnoKR7jTrHEJODR4fn04bRomq7TJwie/Dr9fj/GoGQ==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.23: + resolution: {integrity: sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==} + engines: {node: '>=6.0.0'} + hasBin: true + + better-opn@3.0.2: + resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} + engines: {node: '>=12.0.0'} + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + bplist-creator@0.1.0: + resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} + + bplist-parser@0.3.1: + resolution: {integrity: sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==} + engines: {node: '>= 5.10.0'} + + bplist-parser@0.3.2: + resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} + engines: {node: '>= 5.10.0'} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001791: + resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + char-regex@2.0.2: + resolution: {integrity: sha512-cbGOjAptfM2LVmWhwRFHEKTPkLwNddVmuqYZQt895yXwAsWsXObCG+YN4DGQ/JBtT4GP1a1lPPdio2z413LmTg==} + engines: {node: '>=12.20'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chrome-launcher@0.15.2: + resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==} + engines: {node: '>=12.13.0'} + hasBin: true + + chromium-edge-launcher@0.2.0: + resolution: {integrity: sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==} + + chromium-edge-launcher@0.3.0: + resolution: {integrity: sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==} + + ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cli-cursor@2.1.0: + resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} + engines: {node: '>=4'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-in-js-utils@3.1.0: + resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + dnssd-advertise@1.1.4: + resolution: {integrity: sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.344: + resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.21.0: + resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.2: + resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-prettier@9.1.2: + resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-config-universe@15.0.3: + resolution: {integrity: sha512-fUMsNXp7GJBu7Sz9PXFBbXhkiixdQ5sbnViFIBbk6ORAfeokczJ+eVv5HQ2gwxPQdbfJarpkO9WZDtxIvJnEGw==} + peerDependencies: + eslint: '>=8.10' + prettier: '>=3' + peerDependenciesMeta: + prettier: + optional: true + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-es-x@7.8.0: + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=8' + + eslint-plugin-es@3.0.1: + resolution: {integrity: sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==} + engines: {node: '>=8.10.0'} + peerDependencies: + eslint: '>=4.19.1' + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-n@17.24.0: + resolution: {integrity: sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=8.23.0' + + eslint-plugin-node@11.1.0: + resolution: {integrity: sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==} + engines: {node: '>=8.10.0'} + peerDependencies: + eslint: '>=5.16.0' + + eslint-plugin-prettier@5.5.5: + resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-utils@2.1.0: + resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==} + engines: {node: '>=6'} + + eslint-visitor-keys@1.3.0: + resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==} + engines: {node: '>=4'} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + expo-application@55.0.14: + resolution: {integrity: sha512-NgqDIt3eCf4aVLp1L6AcEanCYoyJeuBsGrgGSzOIvxAsOvp5X3SYKW3ROgpKUnLQEKMWlzwETpjsUGszcqkk8g==} + peerDependencies: + expo: '*' + + expo-asset@55.0.16: + resolution: {integrity: sha512-5IJyfJtYqvKGg04NKGQWiCIoK/fULDL9m15mXPPyfabD1jsToVj2hnWmo1r2SWNNmMwtQxi6jTpcGwVo2nLDxg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-build-properties@55.0.13: + resolution: {integrity: sha512-UYZhUKyh7YQhbJdkBvo68WUQ7fOtZeSV7F8kfYkjEiN/ADRHG0WfEIiddvGfi9cH/5iwpptv/+Lu5cx6uvfegA==} + peerDependencies: + expo: '*' + + expo-camera@55.0.16: + resolution: {integrity: sha512-9c6FGrLVwMLyQ08wqwkH7DXAC8Oj1VD0LXM4hFu62Nuq2f2zIAZwsXxG7J5ex+HHHAGyligGGi6VJWuiib9qNg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-constants@55.0.15: + resolution: {integrity: sha512-w394fcZLJjeKN+9ZnJzL/HiarE1nwZFDa+3S9frevh6Ur+MAAs9QDrcXhDrV8T3xqRzzYaqsP6Z8TFZ4efWN1A==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-crypto@55.0.14: + resolution: {integrity: sha512-TfAADBGZNNv9OOmdKFJCz54wDj87ufxtzQNSY+Roycpm8e5tuCnDIL7EjqUOmNTGH99Jj8ftPGFt4KGG2Ii2fg==} + peerDependencies: + expo: '*' + + expo-file-system@55.0.17: + resolution: {integrity: sha512-d27K1cagUOt2BwxwPka9KW8Znu5kN1tnairozCzzCRZviZFtWnBxwFuJ3KU6MAbav/9UhSMkp5Ve/oZ+SR0UgQ==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-font@55.0.6: + resolution: {integrity: sha512-x9czUA3UQWjIwa0ZUEs/eWJNqB4mAue/m4ltESlNPLZhHL0nWWqIfsyHmklTLFH7mVfcHSJvew6k+pR2FE1zVw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-glass-effect@55.0.10: + resolution: {integrity: sha512-5kL/jATvgJWdrqPdxixrECJqD2l8cfQ4ALr1DK7qi9XkyI97ejXvUjB2VsfEePNy3Fg+/VwzA3n3L7Nv3tAPkw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-haptics@55.0.14: + resolution: {integrity: sha512-KjDItBsA9mi1f5nRwf8g1wOdfEcLHwvEdt5Jl1sMCDETR/homcGOl+F3QIiPOl/PRlbGVieQsjTtF4DGtHOj6g==} + peerDependencies: + expo: '*' + + expo-image@55.0.9: + resolution: {integrity: sha512-+NVgWv+tr7a6EpBEaIIVVp+XfruRA2JL5xOxvd6ajvFGdH0rOhagwX1m1piAII6w7sh6uAnBr8X+fDZsav7B2w==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-keep-awake@55.0.6: + resolution: {integrity: sha512-acJjeHqkNxMVckEcJhGQeIksqqsarscSHJtT559bNgyiM4r14dViQ66su7bb6qDVeBt0K7z3glXI1dHVck1Zgg==} + peerDependencies: + expo: '*' + react: '*' + + expo-linking@55.0.14: + resolution: {integrity: sha512-ZSqOvJyEquf04M5/ZpQo2diK9QRnNrzgqZo7p8gzxaPPHxP6IyUJnmcd12qT+dTxnRTVmUpxFQVHHWbvwPNIwQ==} + peerDependencies: + react: '*' + react-native: '*' + + expo-module-scripts@55.0.2: + resolution: {integrity: sha512-gtFQzs6wduZxYo4/1udcVLANDvQbVUVFjK1xJjgtZuUBlbJ+xFjQg5wOsQaffUDO3kqrG+DRXt1ihSxK72Wqkg==} + hasBin: true + + expo-modules-autolinking@55.0.18: + resolution: {integrity: sha512-olGTCWYkwVPj/momcgnF+z8MTzurGNFjopqPztQ4F53UkGPJnOFEuaM2/z4KbZtKbwHqeBv34OA5hxZP8uLdaQ==} + hasBin: true + + expo-modules-core@55.0.23: + resolution: {integrity: sha512-IGWT5N9MoV4zgWyrv686bElnKhzhE7E6pSazhaBNh3vgViAah5nnAz2o5h5YoUMR2B+ZTdHumRbGHN6gHLgwPA==} + peerDependencies: + react: '*' + react-native: '*' + react-native-worklets: ^0.7.4 || ^0.8.0 + peerDependenciesMeta: + react-native-worklets: + optional: true + + expo-notifications@55.0.21: + resolution: {integrity: sha512-nZKbvfjCoKFTZU435ihXcCCqCF3uCY1kcD5e2Tm2GMDLSt7HTiLZ1CQFGgckg/6QYIfMKhLFdHfOQHAEGakQug==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-router@55.0.13: + resolution: {integrity: sha512-cIBR5RmQtbr+b535mlbMhmm7lweVZXFtjzJOgJTutoxIApRztl816kFRFNesnVyqQ0LZrEU0a6vqa3i0wdlRQw==} + peerDependencies: + '@expo/log-box': 55.0.11 + '@expo/metro-runtime': ^55.0.10 + '@react-navigation/drawer': ^7.9.4 + '@testing-library/react-native': '>= 13.2.0' + expo: '*' + expo-constants: ^55.0.15 + expo-linking: ^55.0.14 + react: '*' + react-dom: '*' + react-native: '*' + react-native-gesture-handler: '*' + react-native-reanimated: '*' + react-native-safe-area-context: '>= 5.4.0' + react-native-screens: '*' + react-native-web: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + '@react-navigation/drawer': + optional: true + '@testing-library/react-native': + optional: true + react-dom: + optional: true + react-native-gesture-handler: + optional: true + react-native-reanimated: + optional: true + react-native-web: + optional: true + react-server-dom-webpack: + optional: true + + expo-secure-store@55.0.13: + resolution: {integrity: sha512-I6r0JNO1Fd4o0Gu7Ixiic7s89lqgdUHq17uBH9y1f/AntoyKn71TdtYJH82RgfsBbu5qNVzrwImmvlANyOlITQ==} + peerDependencies: + expo: '*' + + expo-server@55.0.8: + resolution: {integrity: sha512-AoV5TKuO4biSzrhe/OVLyInfTT0pV9/OOc/g/oVq5vmCjL8SaSYTkES8PLt+67Tm7VqX+Dn0+kSx1nQcjEKaPw==} + engines: {node: '>=20.16.0'} + + expo-splash-screen@55.0.19: + resolution: {integrity: sha512-l8BWI/inLJW46Ojz5NgwvaM8LftrdXeFfZBUXhAoZxg44Qo2xKY76s0S1h3WIxWXT4sRKwK8YQzGr4k+zHubxQ==} + peerDependencies: + expo: '*' + + expo-status-bar@55.0.5: + resolution: {integrity: sha512-qb0c3rJO2b7CC0gUVGi1JYp92oLenWdYGyk8l4YQs6U+uaXUTPv6aaFa3KkT2HON10re3AxxPNJci8rsz6kPxg==} + peerDependencies: + react: '*' + react-native: '*' + + expo-symbols@55.0.7: + resolution: {integrity: sha512-y4ALLbncSGQzhFLw1PaIBbO39xzaw3ie249HmK6zK/WLJYfw4Z/9UU4iPKO3KCE4FyCKIzd+yRsvzvlri23YrQ==} + peerDependencies: + expo: '*' + expo-font: '*' + react: '*' + react-native: '*' + + expo@55.0.17: + resolution: {integrity: sha512-yVF2phiPw5XgOCedC/oQaL3j0XbwzsBLst3JiAF8bi9aFlxLOVvuDEM8BDg3E09XGSLaGCAclY4q5L+sFerXlQ==} + hasBin: true + peerDependencies: + '@expo/dom-webview': '*' + '@expo/metro-runtime': '*' + react: '*' + react-native: '*' + react-native-webview: '*' + peerDependenciesMeta: + '@expo/dom-webview': + optional: true + '@expo/metro-runtime': + optional: true + react-native-webview: + optional: true + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fb-dotslash@0.5.8: + resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==} + engines: {node: '>=20'} + hasBin: true + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fbjs-css-vars@1.0.2: + resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} + + fbjs@3.0.5: + resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-nodeshim@0.4.10: + resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + + finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + flow-enums-runtime@0.0.6: + resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + + fontfaceobserver@2.3.0: + resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-readdir-recursive@1.1.0: + resolution: {integrity: sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + getenv@2.0.0: + resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} + engines: {node: '>=6'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hermes-compiler@0.14.1: + resolution: {integrity: sha512-+RPPQlayoZ9n6/KXKt5SFILWXCGJ/LV5d24L5smXrvTDrPS4L6dSctPczXauuvzFP3QEJbD1YO7Z3Ra4a+4IhA==} + + hermes-compiler@250829098.0.10: + resolution: {integrity: sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w==} + + hermes-estree@0.32.0: + resolution: {integrity: sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==} + + hermes-estree@0.32.1: + resolution: {integrity: sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg==} + + hermes-estree@0.33.3: + resolution: {integrity: sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==} + + hermes-estree@0.35.0: + resolution: {integrity: sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==} + + hermes-parser@0.32.0: + resolution: {integrity: sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==} + + hermes-parser@0.32.1: + resolution: {integrity: sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q==} + + hermes-parser@0.33.3: + resolution: {integrity: sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==} + + hermes-parser@0.35.0: + resolution: {integrity: sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + hyphenate-style-name@1.1.0: + resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + image-size@1.2.1: + resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} + engines: {node: '>=16.x'} + hasBin: true + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inline-style-prefixer@7.0.1: + resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-diff@30.3.0: + resolution: {integrity: sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-jsdom@29.7.0: + resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-expo@55.0.16: + resolution: {integrity: sha512-bOvrTNyDaiaoTz9GhvnXib9v9rjX9PTJFvvoqRMRKEg4MoHghG82E7YF+pH71EWSXTaibQ07F46GS+fcUxTWEg==} + hasBin: true + peerDependencies: + expo: '*' + react-native: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + react-server-dom-webpack: + optional: true + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@30.3.0: + resolution: {integrity: sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watch-select-projects@2.0.0: + resolution: {integrity: sha512-j00nW4dXc2NiCW6znXgFLF9g8PJ0zP25cpQ1xRro/HU2GBfZQFZD0SoXnAlaoKkIY4MlfTMkKGbNXFpvCdjl1w==} + + jest-watch-typeahead@2.2.1: + resolution: {integrity: sha512-jYpYmUnTzysmVnwq49TAxlmtOAwp8QIqvZyoofQFn8fiWhEDZj33ZXzg3JA4nGnzWFm1hbWf3ADpteUokvXgFA==} + engines: {node: ^14.17.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + jest: ^27.0.0 || ^28.0.0 || ^29.0.0 + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jimp-compact@0.16.1: + resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsc-safe-url@0.2.4: + resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + + jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + lan-network@0.2.1: + resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} + hasBin: true + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lighthouse-logger@1.4.2: + resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@2.2.0: + resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} + engines: {node: '>=4'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.3.5: + resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react-native@1.11.0: + resolution: {integrity: sha512-/dEt+/noOpsvgH1dbMHHSv+f8+npB1BJSKCqK9v9yvHbssN3OJJJXrih6N1prJGG3vj0e1zKiVLtet+OKYmiXg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-native: '*' + react-native-svg: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 + + make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + marky@1.3.0: + resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + merge-options@3.0.4: + resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} + engines: {node: '>=10'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + metro-babel-transformer@0.83.6: + resolution: {integrity: sha512-1AnuazBpzY3meRMr04WUw14kRBkV0W3Ez+AA75FAeNpRyWNN5S3M3PHLUbZw7IXq7ZeOzceyRsHStaFrnWd+8w==} + engines: {node: '>=20.19.4'} + + metro-babel-transformer@0.84.3: + resolution: {integrity: sha512-svAA+yMLpeMiGcz/jKJs4oHpIGEx4nBqNEJ5AGj4CYIg1efvK+A0TjR6tgIuc6tKO5e8JmN/1lglpN2+f3/z/w==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache-key@0.83.6: + resolution: {integrity: sha512-5gdK4PVpgNOHi7xCGrgesNP1AuOA2TiPqpcirGXZi4RLLzX1VMowpkgTVtBfpQQCqWoosQF9yrSo9/KDQg1eBg==} + engines: {node: '>=20.19.4'} + + metro-cache-key@0.84.3: + resolution: {integrity: sha512-TnSL1Fdvrw+2glTdBSRmA5TL8l/i16ECjsrUdf3E5HncA+sNx8KcwDG8r+3ct1UhfYcusJypzZqTN55FZZcwGg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache@0.83.6: + resolution: {integrity: sha512-DpvZE32feNkqfZkI4Fic7YI/Kw8QP9wdl1rC4YKPrA77wQbI9vXbxjmfkCT/EGwBTFOPKqvIXo+H3BNe93YyiQ==} + engines: {node: '>=20.19.4'} + + metro-cache@0.84.3: + resolution: {integrity: sha512-0QElxwLaHqLZf+Xqio8QrjVbuXP/8sJfQBGSPiITlKDVXrVLefuzYVSH9Sj+QL6lrPj2gYZd/iwQh1yZuVKnLA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-config@0.83.6: + resolution: {integrity: sha512-G5622400uNtnAMlppEA5zkFAZltEf7DSGhOu09BkisCxOlVMWfdosD/oPyh4f2YVQsc1MBYyp4w6OzbExTYarg==} + engines: {node: '>=20.19.4'} + + metro-config@0.84.3: + resolution: {integrity: sha512-JmCzZWOETR+O22q8oPBWyQppx3roU9EbkbGzD8Gf1jukQ4b5T1fTzqqHruu6K4sTiNq5zVQySmKF6bp4kVARew==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-core@0.83.6: + resolution: {integrity: sha512-l+yQ2fuIgR//wszUlMrrAa9+Z+kbKazd0QOh0VQY7jC4ghb7yZBBSla/UMYRBZZ6fPg9IM+wD3+h+37a5f9etw==} + engines: {node: '>=20.19.4'} + + metro-core@0.84.3: + resolution: {integrity: sha512-cc0pvAa80ai1nDmqqz0P59a+0ZqCZ/YHU/3jEekZL6spFnYDfX8iDLdn9FR6kX+67rmzKxHNrbrSRFLX2AYocw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-file-map@0.83.6: + resolution: {integrity: sha512-Jg3oN604C7GWbQwFAUXt8KsbMXeKfsxbZ5HFy4XFM3ggTS+ja9QgUmq9B613kgXv3G4M6rwiI6cvh9TRly4x3w==} + engines: {node: '>=20.19.4'} + + metro-file-map@0.84.3: + resolution: {integrity: sha512-1cL4m4Jv1yRUt9RJExZQLfccscdlMNOcRG6LHLtmJhf3BG9j3MujPVc7CIpKYdFl+KUl+sdjge6oO3+meKCHQA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-minify-terser@0.83.6: + resolution: {integrity: sha512-Vx3/Ne9Q+EIEDLfKzZUOtn/rxSNa/QjlYxc42nvK4Mg8mB6XUgd3LXX5ZZVq7lzQgehgEqLrbgShJPGfeF8PnQ==} + engines: {node: '>=20.19.4'} + + metro-minify-terser@0.84.3: + resolution: {integrity: sha512-3ofrG2OQyJbO9RNhCfOcl8QU7EE2WrSsnN5dFkuZaJO5+4Imujr9bUXmspeNlXRsOVk0F/rVRbEFH98lFSCkBQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-resolver@0.83.6: + resolution: {integrity: sha512-lAwR/FsT1uJ5iCt4AIsN3boKfJ88aN8bjvDT5FwBS0tKeKw4/sbdSTWlFxc7W/MUTN5RekJ3nQkJRIWsvs28tA==} + engines: {node: '>=20.19.4'} + + metro-resolver@0.84.3: + resolution: {integrity: sha512-pjEzGDtoM8DTHAIPK/9u9ZxszEiuRohYUVImWvgbnB91V4gqYJpQcoEYUugf2NIm1lrX5HNu0OvNqWmPBnGYjA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-runtime@0.83.6: + resolution: {integrity: sha512-WQPua1G2VgYbwRn6vSKxOhTX7CFbSf/JdUu6Nd8bZnPXckOf7HQ2y51NXNQHoEsiuawathrkzL8pBhv+zgZFmg==} + engines: {node: '>=20.19.4'} + + metro-runtime@0.84.3: + resolution: {integrity: sha512-o7HLRfMyVk9N2dUZ9VjQfB6xxUItL9Pi9WcqxURE7MEKOH6wbGt9/E92YdYLluTOtkzYAEVfdC6h6lcxqA+hMQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-source-map@0.83.6: + resolution: {integrity: sha512-AqJbOMMpeyyM4iNI91pchqDIszzNuuHApEhg6OABqZ+9mjLEqzcIEQ/fboZ7x74fNU5DBd2K36FdUQYPqlGClA==} + engines: {node: '>=20.19.4'} + + metro-source-map@0.84.3: + resolution: {integrity: sha512-jS48CeSzw78M8y6VE0f9uy3lVmfbOS677j2VCxnlmlYmnahcXuC6IhoN9K6LynNvos9517yUadcfgioju38xYQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-symbolicate@0.83.6: + resolution: {integrity: sha512-4nvkmv9T7ozhprlPwk/+xm0SVPsxly5kYyMHdNaOlFemFz4df9BanvD46Ac6OISu/4Idinzfk2KVb++6OfzPAQ==} + engines: {node: '>=20.19.4'} + hasBin: true + + metro-symbolicate@0.84.3: + resolution: {integrity: sha512-J9Tpo8NCycYrozRvBIUyOwGAu4xkawOsAppmTscFiaegK0WvuDGwIM53GbzVSnytCHjVAF0io5GQxpkrKTuc7g==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + metro-transform-plugins@0.83.6: + resolution: {integrity: sha512-V+zoY2Ul0v0BW6IokJkTud3raXmDdbdwkUQ/5eiSoy0jKuKMhrDjdH+H5buCS5iiJdNbykOn69Eip+Sqymkodg==} + engines: {node: '>=20.19.4'} + + metro-transform-plugins@0.84.3: + resolution: {integrity: sha512-8S3baq2XhBaafHEH5Q8sJW6tmzsEJk80qKc3RU/nZV1MsnYq94RdjTUR6AyKjQd6Rfsk1BtBxhtiNnk7mgslCg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-transform-worker@0.83.6: + resolution: {integrity: sha512-G5kDJ/P0ZTIf57t3iyAd5qIXbj2Wb1j7WtIDh82uTFQHe2Mq2SO9aXG9j1wI+kxZlIe58Z22XEXIKMl89z0ibQ==} + engines: {node: '>=20.19.4'} + + metro-transform-worker@0.84.3: + resolution: {integrity: sha512-Wjba7PyYktNRsHbPmkx2J2UX32rAzcDXjCu49zPHeF/viJlYJhwRaNePQcHaCRqQ+kmgQT4ThprsnJfDj71ZMA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro@0.83.6: + resolution: {integrity: sha512-pbdndsAZ2F/ceopDdhVbttpa/hfLzXPJ/husc+QvQ33R0D9UXJKzTn5+OzOXx4bpQNtAKF2bY88cCI3Zl44xDQ==} + engines: {node: '>=20.19.4'} + hasBin: true + + metro@0.84.3: + resolution: {integrity: sha512-1h3lbVrE6hGf1e/764HfhPGg/bGrWMJDDh7G2rc4gFYZboVuI40BlG/y+UhtbhQDNlO/csMvrcnK0YrTlHUVew==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@1.2.0: + resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} + engines: {node: '>=4'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multitars@1.0.0: + resolution: {integrity: sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-package-arg@11.0.3: + resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} + engines: {node: ^16.14.0 || >=18.0.0} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + ob1@0.83.6: + resolution: {integrity: sha512-m/xZYkwcjo6UqLMrUICEB3iHk7Bjt3RSR7KXMi6Y1MO/kGkPhoRmfUDF6KAan3rLAZ7ABRqnQyKUTwaqZgUV4w==} + engines: {node: '>=20.19.4'} + + ob1@0.84.3: + resolution: {integrity: sha512-J7554Ef8bzmKaDY365Afq6PF+qtdnY/d5PKUQFrsKlZHV/N3OGZewVrvDrQDyX5V5NJjTpcAKtlrFZcDr+HvpQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@2.0.1: + resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} + engines: {node: '>=4'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@3.4.0: + resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} + engines: {node: '>=6'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + oxfmt@0.47.0: + resolution: {integrity: sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + oxlint@1.62.0: + resolution: {integrity: sha512-1uFkg6HakjsGIpW9wNdeW4/2LOHW9MEkoWjZUTUfQtIHyLIZPYt00w3Sg+H3lH+206FgBPHBbW5dVE5l2ExECQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.18.0' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-png@2.1.0: + resolution: {integrity: sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==} + engines: {node: '>=10'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + + pngjs@3.4.0: + resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + engines: {node: '>=4.0.0'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.49: + resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + engines: {node: '>=6.0.0'} + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + pretty-format@30.3.0: + resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + proc-log@4.2.0: + resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + + promise@8.3.0: + resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + react-devtools-core@6.1.5: + resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} + + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-freeze@1.0.4: + resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==} + engines: {node: '>=10'} + peerDependencies: + react: '>=17.0.0' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.5: + resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==} + + react-native-gesture-handler@2.30.1: + resolution: {integrity: sha512-xIUBDo5ktmJs++0fZlavQNvDEE4PsihWhSeJsJtoz4Q6p0MiTM9TgrTgfEgzRR36qGPytFoeq+ShLrVwGdpUdA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-is-edge-to-edge@1.2.1: + resolution: {integrity: sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-is-edge-to-edge@1.3.1: + resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-reanimated@4.2.1: + resolution: {integrity: sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg==} + peerDependencies: + react: '*' + react-native: '*' + react-native-worklets: '>=0.7.0' + + react-native-safe-area-context@5.6.2: + resolution: {integrity: sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-screens@4.23.0: + resolution: {integrity: sha512-XhO3aK0UeLpBn4kLecd+J+EDeRRJlI/Ro9Fze06vo1q163VeYtzfU9QS09/VyDFMWR1qxDC1iazCArTPSFFiPw==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-svg@15.15.4: + resolution: {integrity: sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-web@0.21.2: + resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + react-native-webview@13.16.1: + resolution: {integrity: sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-worklets@0.7.4: + resolution: {integrity: sha512-NYOdM1MwBb3n+AtMqy1tFy3Mn8DliQtd8sbzAVRf9Gc+uvQ0zRfxN7dS8ZzoyX7t6cyQL5THuGhlnX+iFlQTag==} + peerDependencies: + '@babel/core': '*' + react: '*' + react-native: '*' + + react-native@0.83.6: + resolution: {integrity: sha512-H513+8VzviNFXOdPnStRzX9S3/jiJGg++QZ1zd+ROyAvBEKqFqKUPHH0d82y3QyRPct5qKjdOa7J6vNehCvXYA==} + engines: {node: '>= 20.19.4'} + hasBin: true + peerDependencies: + '@types/react': ^19.1.1 + react: ^19.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-native@0.85.2: + resolution: {integrity: sha512-GFWEPwLYirfj5X8gMtXOWtqX0cqUEURRHETZfFk37VCa4++izrKvGvv24anvuyulXV87NAhVkfNw93rLg3HByw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + peerDependencies: + '@react-native/jest-preset': 0.85.2 + '@types/react': ^19.1.1 + react: ^19.2.3 + peerDependenciesMeta: + '@react-native/jest-preset': + optional: true + '@types/react': + optional: true + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-test-renderer@19.2.0: + resolution: {integrity: sha512-zLCFMHFE9vy/w3AxO0zNxy6aAupnCuLSVOJYDe/Tp+ayGI1f2PLQsFVPANSD42gdSbmYx5oN+1VWDhcXtq7hAQ==} + peerDependencies: + react: ^19.2.0 + + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regexpp@3.2.0: + resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} + engines: {node: '>=8'} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.1: + resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve-workspace-root@2.0.1: + resolution: {integrity: sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@2.0.0: + resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} + engines: {node: '>=4'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serialize-error@2.1.0: + resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} + engines: {node: '>=0.10.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sf-symbols-typescript@2.2.0: + resolution: {integrity: sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==} + engines: {node: '>=10'} + + shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + simple-plist@1.3.1: + resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@2.0.0: + resolution: {integrity: sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==} + engines: {node: '>=6'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slugify@1.6.9: + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} + engines: {node: '>=8.0.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.5.6: + resolution: {integrity: sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-generator@2.0.10: + resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + + stacktrace-gps@3.1.2: + resolution: {integrity: sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==} + + stacktrace-js@2.0.2: + resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + + stacktrace-parser@0.1.11: + resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} + engines: {node: '>=6'} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + stream-buffers@2.2.0: + resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} + engines: {node: '>= 0.10.0'} + + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-length@5.0.1: + resolution: {integrity: sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==} + engines: {node: '>=12.20'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + structured-headers@0.4.1: + resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} + + styleq@0.1.3: + resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@2.3.0: + resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + engines: {node: ^14.18.0 || >=16.0.0} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + terminal-link@2.1.1: + resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} + engines: {node: '>=8'} + + terser@5.46.2: + resolution: {integrity: sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + throat@5.0.0: + resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + toqr@0.1.1: + resolution: {integrity: sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-declaration-location@1.0.7: + resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==} + peerDependencies: + typescript: '>=4.0.0' + + ts-jest@29.0.5: + resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/types': ^29.0.0 + babel-jest: ^29.0.0 + esbuild: '*' + jest: ^29.0.0 + typescript: '>=4.3' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + engines: {node: '>=20'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-parser-js@1.0.41: + resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-latest-callback@0.2.6: + resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==} + peerDependencies: + react: '>=16.8' + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@7.0.3: + resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + hasBin: true + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + vlq@1.0.1: + resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + warn-once@0.1.1: + resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-url-minimum@0.1.1: + resolution: {integrity: sha512-u2FNVjFVFZhdjb502KzXy1gKn1mEisQRJssmSJT8CPhZdZa0AP6VCbWlXERKyGu0l09t0k50FiDiralpGhBxgA==} + + whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xcode@3.0.1: + resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} + engines: {node: '>=10.0.0'} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xml2js@0.6.0: + resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zxing-wasm@3.0.2: + resolution: {integrity: sha512-2YMAriaYHX9wrBY2k7H0epSo+dyCaCZg/vOtt+nEDXM9ul480gkXz/9SkwpOeHcD2H5qqDG8lWDSBwpTcZpa6w==} + peerDependencies: + '@types/emscripten': '>=1.39.6' + +snapshots: + + '@babel/cli@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@jridgewell/trace-mapping': 0.3.31 + commander: 6.2.1 + convert-source-map: 2.0.0 + fs-readdir-recursive: 1.1.0 + glob: 7.2.3 + make-dir: 2.1.0 + slash: 2.0.0 + optionalDependencies: + '@nicolo-ribaudo/chokidar-2': 2.1.8-no-fsevents.3 + chokidar: 3.6.0 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + debug: 4.4.3 + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-wrap-function': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helper-wrap-function@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-class-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.28.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.28.6 + + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-systemjs@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-nullish-coalescing-operator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-optional-chaining@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/preset-env@7.29.2(@babel/core@7.29.0)': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0) + '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-systemjs': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.0) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.0) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + core-js-compat: 3.49.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/types': 7.29.0 + esutils: 2.0.3 + + '@babel/preset-react@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@0.2.3': {} + + '@egjs/hammerjs@2.0.17': + dependencies: + '@types/hammerjs': 2.0.46 + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@expo-google-fonts/material-symbols@0.4.34': {} + + '@expo/cli@55.0.26(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.15)(expo-font@55.0.6)(expo-router@55.0.13)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)': + dependencies: + '@expo/code-signing-certificates': 0.0.6 + '@expo/config': 55.0.15(typescript@5.9.3) + '@expo/config-plugins': 55.0.8 + '@expo/devcert': 1.2.1 + '@expo/env': 2.1.1 + '@expo/image-utils': 0.8.13(typescript@5.9.3) + '@expo/json-file': 10.0.13 + '@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/metro': 55.1.0 + '@expo/metro-config': 55.0.17(expo@55.0.17)(typescript@5.9.3) + '@expo/osascript': 2.4.2 + '@expo/package-manager': 1.10.4 + '@expo/plist': 0.5.2 + '@expo/prebuild-config': 55.0.16(expo@55.0.17)(typescript@5.9.3) + '@expo/require-utils': 55.0.4(typescript@5.9.3) + '@expo/router-server': 55.0.15(@expo/metro-runtime@55.0.10)(expo-constants@55.0.15)(expo-font@55.0.6)(expo-router@55.0.13)(expo-server@55.0.8)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@expo/schema-utils': 55.0.3 + '@expo/spawn-async': 1.7.2 + '@expo/ws-tunnel': 1.0.6 + '@expo/xcpretty': 4.4.3 + '@react-native/dev-middleware': 0.83.6 + accepts: 1.3.8 + arg: 5.0.2 + better-opn: 3.0.2 + bplist-creator: 0.1.0 + bplist-parser: 0.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + compression: 1.8.1 + connect: 3.7.0 + debug: 4.4.3 + dnssd-advertise: 1.1.4 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-server: 55.0.8 + fetch-nodeshim: 0.4.10 + getenv: 2.0.0 + glob: 13.0.6 + lan-network: 0.2.1 + multitars: 1.0.0 + node-forge: 1.4.0 + npm-package-arg: 11.0.3 + ora: 3.4.0 + picomatch: 4.0.4 + pretty-format: 29.7.0 + progress: 2.0.3 + prompts: 2.4.2 + resolve-from: 5.0.0 + semver: 7.7.4 + send: 0.19.2 + slugify: 1.6.9 + source-map-support: 0.5.21 + stacktrace-parser: 0.1.11 + structured-headers: 0.4.1 + terminal-link: 2.1.1 + toqr: 0.1.1 + wrap-ansi: 7.0.0 + ws: 8.20.0 + zod: 3.25.76 + optionalDependencies: + expo-router: 55.0.13(121105c3b042d5e83ab3c1d3b84ed55f) + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - '@expo/dom-webview' + - '@expo/metro-runtime' + - bufferutil + - expo-constants + - expo-font + - react + - react-dom + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + + '@expo/code-signing-certificates@0.0.6': + dependencies: + node-forge: 1.4.0 + + '@expo/config-plugins@55.0.8': + dependencies: + '@expo/config-types': 55.0.5 + '@expo/json-file': 10.0.13 + '@expo/plist': 0.5.2 + '@expo/sdk-runtime-versions': 1.0.0 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + resolve-from: 5.0.0 + semver: 7.7.4 + slugify: 1.6.9 + xcode: 3.0.1 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + + '@expo/config-types@55.0.5': {} + + '@expo/config@55.0.15(typescript@5.9.3)': + dependencies: + '@expo/config-plugins': 55.0.8 + '@expo/config-types': 55.0.5 + '@expo/json-file': 10.0.13 + '@expo/require-utils': 55.0.4(typescript@5.9.3) + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 13.0.6 + resolve-workspace-root: 2.0.1 + semver: 7.7.4 + slugify: 1.6.9 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/devcert@1.2.1': + dependencies: + '@expo/sudo-prompt': 9.3.2 + debug: 3.2.7 + transitivePeerDependencies: + - supports-color + + '@expo/devtools@55.0.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + chalk: 4.1.2 + optionalDependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + '@expo/dom-webview@55.0.5(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + '@expo/env@2.1.1': + dependencies: + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + transitivePeerDependencies: + - supports-color + + '@expo/fingerprint@0.16.6': + dependencies: + '@expo/env': 2.1.1 + '@expo/spawn-async': 1.7.2 + arg: 5.0.2 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + ignore: 5.3.2 + minimatch: 10.2.5 + resolve-from: 5.0.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + '@expo/image-utils@0.8.13(typescript@5.9.3)': + dependencies: + '@expo/require-utils': 55.0.4(typescript@5.9.3) + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + getenv: 2.0.0 + jimp-compact: 0.16.1 + parse-png: 2.1.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/json-file@10.0.13': + dependencies: + '@babel/code-frame': 7.29.0 + json5: 2.2.3 + + '@expo/local-build-cache-provider@55.0.11(typescript@5.9.3)': + dependencies: + '@expo/config': 55.0.15(typescript@5.9.3) + chalk: 4.1.2 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/log-box@55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@expo/dom-webview': 55.0.5(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + anser: 1.4.10 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + stacktrace-parser: 0.1.11 + + '@expo/metro-config@55.0.17(expo@55.0.17)(typescript@5.9.3)': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@expo/config': 55.0.15(typescript@5.9.3) + '@expo/env': 2.1.1 + '@expo/json-file': 10.0.13 + '@expo/metro': 55.1.0 + '@expo/spawn-async': 1.7.2 + browserslist: 4.28.2 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + hermes-parser: 0.32.1 + jsc-safe-url: 0.2.4 + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.4.49 + resolve-from: 5.0.0 + optionalDependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - typescript + - utf-8-validate + + '@expo/metro-runtime@55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + anser: 1.4.10 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + pretty-format: 29.7.0 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + optionalDependencies: + react-dom: 19.2.5(react@19.2.0) + transitivePeerDependencies: + - '@expo/dom-webview' + + '@expo/metro@55.1.0': + dependencies: + metro: 0.83.6 + metro-babel-transformer: 0.83.6 + metro-cache: 0.83.6 + metro-cache-key: 0.83.6 + metro-config: 0.83.6 + metro-core: 0.83.6 + metro-file-map: 0.83.6 + metro-minify-terser: 0.83.6 + metro-resolver: 0.83.6 + metro-runtime: 0.83.6 + metro-source-map: 0.83.6 + metro-symbolicate: 0.83.6 + metro-transform-plugins: 0.83.6 + metro-transform-worker: 0.83.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@expo/npm-proofread@1.0.1': + dependencies: + semver: 5.7.2 + + '@expo/osascript@2.4.2': + dependencies: + '@expo/spawn-async': 1.7.2 + + '@expo/package-manager@1.10.4': + dependencies: + '@expo/json-file': 10.0.13 + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + npm-package-arg: 11.0.3 + ora: 3.4.0 + resolve-workspace-root: 2.0.1 + + '@expo/plist@0.5.2': + dependencies: + '@xmldom/xmldom': 0.8.13 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + '@expo/prebuild-config@55.0.16(expo@55.0.17)(typescript@5.9.3)': + dependencies: + '@expo/config': 55.0.15(typescript@5.9.3) + '@expo/config-plugins': 55.0.8 + '@expo/config-types': 55.0.5 + '@expo/image-utils': 0.8.13(typescript@5.9.3) + '@expo/json-file': 10.0.13 + '@react-native/normalize-colors': 0.83.6 + debug: 4.4.3 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + resolve-from: 5.0.0 + semver: 7.7.4 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/require-utils@55.0.4(typescript@5.9.3)': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@expo/router-server@55.0.15(@expo/metro-runtime@55.0.10)(expo-constants@55.0.15)(expo-font@55.0.6)(expo-router@55.0.13)(expo-server@55.0.8)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + debug: 4.4.3 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-constants: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + expo-font: 55.0.6(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-server: 55.0.8 + react: 19.2.0 + optionalDependencies: + '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-router: 55.0.13(121105c3b042d5e83ab3c1d3b84ed55f) + react-dom: 19.2.5(react@19.2.0) + transitivePeerDependencies: + - supports-color + + '@expo/schema-utils@55.0.3': {} + + '@expo/sdk-runtime-versions@1.0.0': {} + + '@expo/spawn-async@1.7.2': + dependencies: + cross-spawn: 7.0.6 + + '@expo/sudo-prompt@9.3.2': {} + + '@expo/vector-icons@15.1.1(expo-font@55.0.6)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + expo-font: 55.0.6(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + '@expo/ws-tunnel@1.0.6': {} + + '@expo/xcpretty@4.4.3': + dependencies: + '@babel/code-frame': 7.29.0 + chalk: 4.1.2 + js-yaml: 4.1.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/ttlcache@1.4.1': {} + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@25.6.0) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/create-cache-key-function@29.7.0': + dependencies: + '@jest/types': 29.6.3 + + '@jest/diff-sequences@30.3.0': {} + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 25.6.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/get-type@30.1.0': {} + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 25.6.0 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jest/schemas@30.0.5': + dependencies: + '@sinclair/typebox': 0.34.49 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.29.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 25.6.0 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': + optional: true + + '@oxfmt/binding-android-arm-eabi@0.47.0': + optional: true + + '@oxfmt/binding-android-arm64@0.47.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.47.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.47.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.47.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.47.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.47.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.47.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.47.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.47.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.47.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.47.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.47.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.47.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.47.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.47.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.47.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.47.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.47.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.62.0': + optional: true + + '@oxlint/binding-android-arm64@1.62.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.62.0': + optional: true + + '@oxlint/binding-darwin-x64@1.62.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.62.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.62.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.62.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.62.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.62.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.62.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.62.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.62.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.62.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.62.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.62.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.62.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.62.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.62.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.62.0': + optional: true + + '@pkgr/core@0.2.9': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-collection@1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-portal@1.1.9(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-presence@1.1.5(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-primitive@2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-roving-focus@1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-tabs@1.1.13(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@react-native-async-storage/async-storage@2.2.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))': + dependencies: + merge-options: 3.0.4 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + '@react-native/assets-registry@0.83.6': {} + + '@react-native/assets-registry@0.85.2': {} + + '@react-native/babel-plugin-codegen@0.83.6(@babel/core@7.29.0)': + dependencies: + '@babel/traverse': 7.29.0 + '@react-native/codegen': 0.83.6(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/babel-plugin-codegen@0.85.2(@babel/core@7.29.0)': + dependencies: + '@babel/traverse': 7.29.0 + '@react-native/codegen': 0.85.2(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + optional: true + + '@react-native/babel-preset@0.83.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@react-native/babel-plugin-codegen': 0.83.6(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.32.0 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + + '@react-native/babel-preset@0.85.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@react-native/babel-plugin-codegen': 0.85.2(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.33.3 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + optional: true + + '@react-native/codegen@0.83.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + glob: 7.2.3 + hermes-parser: 0.32.0 + invariant: 2.2.4 + nullthrows: 1.1.1 + yargs: 17.7.2 + + '@react-native/codegen@0.85.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + hermes-parser: 0.33.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + tinyglobby: 0.2.16 + yargs: 17.7.2 + + '@react-native/community-cli-plugin@0.83.6(@react-native/metro-config@0.85.2(@babel/core@7.29.0))': + dependencies: + '@react-native/dev-middleware': 0.83.6 + debug: 4.4.3 + invariant: 2.2.4 + metro: 0.83.6 + metro-config: 0.83.6 + metro-core: 0.83.6 + semver: 7.7.4 + optionalDependencies: + '@react-native/metro-config': 0.85.2(@babel/core@7.29.0) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/community-cli-plugin@0.85.2(@react-native/metro-config@0.85.2(@babel/core@7.29.0))': + dependencies: + '@react-native/dev-middleware': 0.85.2 + debug: 4.4.3 + invariant: 2.2.4 + metro: 0.84.3 + metro-config: 0.84.3 + metro-core: 0.84.3 + semver: 7.7.4 + optionalDependencies: + '@react-native/metro-config': 0.85.2(@babel/core@7.29.0) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/debugger-frontend@0.83.6': {} + + '@react-native/debugger-frontend@0.85.2': {} + + '@react-native/debugger-shell@0.83.6': + dependencies: + cross-spawn: 7.0.6 + fb-dotslash: 0.5.8 + + '@react-native/debugger-shell@0.85.2': + dependencies: + cross-spawn: 7.0.6 + debug: 4.4.3 + fb-dotslash: 0.5.8 + transitivePeerDependencies: + - supports-color + + '@react-native/dev-middleware@0.83.6': + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.83.6 + '@react-native/debugger-shell': 0.83.6 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 0.2.0 + connect: 3.7.0 + debug: 4.4.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + open: 7.4.2 + serve-static: 1.16.3 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/dev-middleware@0.85.2': + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.85.2 + '@react-native/debugger-shell': 0.85.2 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 0.3.0 + connect: 3.7.0 + debug: 4.4.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + open: 7.4.2 + serve-static: 1.16.3 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/gradle-plugin@0.83.6': {} + + '@react-native/gradle-plugin@0.85.2': {} + + '@react-native/js-polyfills@0.83.6': {} + + '@react-native/js-polyfills@0.85.2': {} + + '@react-native/metro-babel-transformer@0.85.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@react-native/babel-preset': 0.85.2(@babel/core@7.29.0) + hermes-parser: 0.33.3 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@react-native/metro-config@0.85.2(@babel/core@7.29.0)': + dependencies: + '@react-native/js-polyfills': 0.85.2 + '@react-native/metro-babel-transformer': 0.85.2(@babel/core@7.29.0) + metro-config: 0.84.3 + metro-runtime: 0.84.3 + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - supports-color + - utf-8-validate + optional: true + + '@react-native/normalize-colors@0.74.89': {} + + '@react-native/normalize-colors@0.83.6': {} + + '@react-native/normalize-colors@0.85.2': {} + + '@react-native/virtualized-lists@0.83.6(@types/react@19.2.14)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@react-native/virtualized-lists@0.85.2(@types/react@19.2.14)(react-native@0.85.2(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.0 + react-native: 0.85.2(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@react-navigation/bottom-tabs@7.15.10(@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@react-navigation/native': 7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + color: 4.2.3 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-safe-area-context: 5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-screens: 4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + sf-symbols-typescript: 2.2.0 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + + '@react-navigation/core@7.17.2(react@19.2.0)': + dependencies: + '@react-navigation/routers': 7.5.3 + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.11 + query-string: 7.1.3 + react: 19.2.0 + react-is: 19.2.5 + use-latest-callback: 0.2.6(react@19.2.0) + use-sync-external-store: 1.6.0(react@19.2.0) + + '@react-navigation/elements@2.9.15(@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@react-navigation/native': 7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + color: 4.2.3 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-safe-area-context: 5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + use-latest-callback: 0.2.6(react@19.2.0) + use-sync-external-store: 1.6.0(react@19.2.0) + + '@react-navigation/native-stack@7.14.12(@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@react-navigation/native': 7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + color: 4.2.3 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-safe-area-context: 5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-screens: 4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + sf-symbols-typescript: 2.2.0 + warn-once: 0.1.1 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + + '@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@react-navigation/core': 7.17.2(react@19.2.0) + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.11 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + use-latest-callback: 0.2.6(react@19.2.0) + + '@react-navigation/routers@7.5.3': + dependencies: + nanoid: 3.3.11 + + '@rtsao/scc@1.1.0': {} + + '@sinclair/typebox@0.27.10': {} + + '@sinclair/typebox@0.34.49': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + jest-matcher-utils: 30.3.0 + picocolors: 1.1.1 + pretty-format: 30.3.0 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-test-renderer: 19.2.0(react@19.2.0) + redent: 3.0.0 + optionalDependencies: + jest: 29.7.0(@types/node@25.6.0) + + '@tootallnate/once@2.0.0': {} + + '@tsconfig/node18@18.2.6': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/emscripten@1.41.5': {} + + '@types/estree@1.0.8': {} + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 25.6.0 + + '@types/hammerjs@2.0.46': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/jsdom@20.0.1': + dependencies: + '@types/node': 25.6.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + + '@types/react-native@0.73.0(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react-native: 0.85.2(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/jest-preset' + - '@react-native/metro-config' + - '@types/react' + - bufferutil + - react + - supports-color + - utf-8-validate + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/stack-utils@2.0.3': {} + + '@types/tough-cookie@4.0.5': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.6.0 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/type-utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + + '@typescript-eslint/tsconfig-utils@8.59.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.1': {} + + '@typescript-eslint/typescript-estree@8.59.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + eslint-visitor-keys: 5.0.1 + + '@ungap/structured-clone@1.3.0': {} + + '@xmldom/xmldom@0.8.13': {} + + '@xmldom/xmldom@0.9.10': {} + + abab@2.0.6: {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-globals@7.0.1: + dependencies: + acorn: 8.16.0 + acorn-walk: 8.3.5 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + anser@1.4.10: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@6.2.1: {} + + ansi-regex@4.1.1: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + asap@2.0.6: {} + + async-function@1.0.0: {} + + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + babel-jest@29.7.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.0) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-dynamic-import-node@2.3.3: + dependencies: + object.assign: 4.1.7 + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.0 + + babel-plugin-react-native-web@0.21.2: {} + + babel-plugin-syntax-hermes-parser@0.32.0: + dependencies: + hermes-parser: 0.32.0 + + babel-plugin-syntax-hermes-parser@0.32.1: + dependencies: + hermes-parser: 0.32.1 + + babel-plugin-syntax-hermes-parser@0.33.3: + dependencies: + hermes-parser: 0.33.3 + + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.0): + dependencies: + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + + babel-preset-expo@55.0.18(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@55.0.17)(react-refresh@0.14.2): + dependencies: + '@babel/generator': 7.29.1 + '@babel/helper-module-imports': 7.28.6 + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/preset-react': 7.28.5(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@react-native/babel-preset': 0.83.6(@babel/core@7.29.0) + babel-plugin-react-compiler: 1.0.0 + babel-plugin-react-native-web: 0.21.2 + babel-plugin-syntax-hermes-parser: 0.32.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + debug: 4.4.3 + react-refresh: 0.14.2 + resolve-from: 5.0.0 + optionalDependencies: + '@babel/runtime': 7.29.2 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + babel-preset-jest@29.6.3(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + + badgin@1.2.3: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + barcode-detector@3.1.2(@types/emscripten@1.41.5): + dependencies: + zxing-wasm: 3.0.2(@types/emscripten@1.41.5) + transitivePeerDependencies: + - '@types/emscripten' + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.23: {} + + better-opn@3.0.2: + dependencies: + open: 8.4.2 + + big-integer@1.6.52: {} + + binary-extensions@2.3.0: + optional: true + + boolbase@1.0.0: {} + + bplist-creator@0.1.0: + dependencies: + stream-buffers: 2.2.0 + + bplist-parser@0.3.1: + dependencies: + big-integer: 1.6.52 + + bplist-parser@0.3.2: + dependencies: + big-integer: 1.6.52 + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.23 + caniuse-lite: 1.0.30001791 + electron-to-chromium: 1.5.344 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001791: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + char-regex@1.0.2: {} + + char-regex@2.0.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + optional: true + + chrome-launcher@0.15.2: + dependencies: + '@types/node': 25.6.0 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + transitivePeerDependencies: + - supports-color + + chromium-edge-launcher@0.2.0: + dependencies: + '@types/node': 25.6.0 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + mkdirp: 1.0.4 + rimraf: 3.0.2 + transitivePeerDependencies: + - supports-color + + chromium-edge-launcher@0.3.0: + dependencies: + '@types/node': 25.6.0 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + mkdirp: 1.0.4 + transitivePeerDependencies: + - supports-color + + ci-info@2.0.0: {} + + ci-info@3.9.0: {} + + cjs-module-lexer@1.4.3: {} + + cli-cursor@2.1.0: + dependencies: + restore-cursor: 2.0.0 + + cli-spinners@2.9.2: {} + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@12.1.0: {} + + commander@2.20.3: {} + + commander@6.2.1: {} + + commander@7.2.0: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + concat-map@0.0.1: {} + + connect@3.7.0: + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + + convert-source-map@2.0.0: {} + + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.2 + + create-jest@29.7.0(@types/node@25.6.0): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@25.6.0) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-in-js-utils@3.1.0: + dependencies: + hyphenate-style-name: 1.1.0 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@1.1.3: + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + + css-what@6.2.2: {} + + cssom@0.3.8: {} + + cssom@0.5.0: {} + + cssstyle@2.3.0: + dependencies: + cssom: 0.3.8 + + csstype@3.2.3: {} + + data-urls@3.0.2: + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + decode-uri-component@0.2.2: {} + + dedent@1.7.2: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-lazy-prop@2.0.0: {} + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + detect-newline@3.1.0: {} + + detect-node-es@1.1.0: {} + + diff-sequences@29.6.3: {} + + dnssd-advertise@1.1.4: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domexception@4.0.0: + dependencies: + webidl-conversions: 7.0.0 + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.344: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.21.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@4.5.0: {} + + entities@6.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + error-stack-parser@2.1.4: + dependencies: + stackframe: 1.3.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.2: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.3 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + eslint-compat-utils@0.5.1(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + semver: 7.7.4 + + eslint-config-prettier@9.1.2(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + + eslint-config-universe@15.0.3(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + eslint-config-prettier: 9.1.2(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) + eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + eslint-plugin-node: 11.1.0(eslint@9.39.4) + eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8) + eslint-plugin-react: 7.37.5(eslint@9.39.4) + eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4) + globals: 16.5.0 + optionalDependencies: + prettier: 2.8.8 + transitivePeerDependencies: + - '@types/eslint' + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + - typescript + + eslint-import-resolver-node@0.3.10: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 2.0.0-next.6 + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.10 + transitivePeerDependencies: + - supports-color + + eslint-plugin-es-x@7.8.0(eslint@9.39.4): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + eslint: 9.39.4 + eslint-compat-utils: 0.5.1(eslint@9.39.4) + + eslint-plugin-es@3.0.1(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + eslint-utils: 2.1.0 + regexpp: 3.2.0 + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + hasown: 2.0.3 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-n@17.24.0(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + enhanced-resolve: 5.21.0 + eslint: 9.39.4 + eslint-plugin-es-x: 7.8.0(eslint@9.39.4) + get-tsconfig: 4.14.0 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.7.4 + ts-declaration-location: 1.0.7(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + eslint-plugin-node@11.1.0(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + eslint-plugin-es: 3.0.1(eslint@9.39.4) + eslint-utils: 2.1.0 + ignore: 5.3.2 + minimatch: 3.1.5 + resolve: 1.22.12 + semver: 6.3.1 + + eslint-plugin-prettier@5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8): + dependencies: + eslint: 9.39.4 + prettier: 2.8.8 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.12 + optionalDependencies: + eslint-config-prettier: 9.1.2(eslint@9.39.4) + + eslint-plugin-react-hooks@5.2.0(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + + eslint-plugin-react@7.37.5(eslint@9.39.4): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.2 + eslint: 9.39.4 + estraverse: 5.3.0 + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-utils@2.1.0: + dependencies: + eslint-visitor-keys: 1.3.0 + + eslint-visitor-keys@1.3.0: {} + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + expo-application@55.0.14(expo@55.0.17): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + + expo-asset@55.0.16(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): + dependencies: + '@expo/image-utils': 0.8.13(typescript@5.9.3) + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-constants: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - supports-color + - typescript + + expo-build-properties@55.0.13(expo@55.0.17): + dependencies: + '@expo/schema-utils': 55.0.3 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + resolve-from: 5.0.0 + semver: 7.7.4 + + expo-camera@55.0.16(@types/emscripten@1.41.5)(expo@55.0.17)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + barcode-detector: 3.1.2(@types/emscripten@1.41.5) + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + transitivePeerDependencies: + - '@types/emscripten' + + expo-constants@55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)): + dependencies: + '@expo/env': 2.1.1 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - supports-color + + expo-crypto@55.0.14(expo@55.0.17): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + + expo-file-system@55.0.17(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + expo-font@55.0.6(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + fontfaceobserver: 2.3.0 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + expo-glass-effect@55.0.10(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + expo-haptics@55.0.14(expo@55.0.17): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + + expo-image@55.0.9(expo@55.0.17)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + sf-symbols-typescript: 2.2.0 + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + + expo-keep-awake@55.0.6(expo@55.0.17)(react@19.2.0): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + + expo-linking@55.0.14(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + expo-constants: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + invariant: 2.2.4 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - expo + - supports-color + + expo-module-scripts@55.0.2(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(eslint@9.39.4)(expo@55.0.17)(jest@29.7.0(@types/node@25.6.0))(prettier@2.8.8)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@babel/cli': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/preset-env': 7.29.2(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@expo/npm-proofread': 1.0.1 + '@expo/spawn-async': 1.7.2 + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0) + '@tsconfig/node18': 18.2.6 + '@types/jest': 29.5.14 + babel-plugin-dynamic-import-node: 2.3.3 + babel-preset-expo: 55.0.18(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@55.0.17)(react-refresh@0.14.2) + commander: 12.1.0 + eslint-config-universe: 15.0.3(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3) + glob: 13.0.6 + jest-expo: 55.0.16(@babel/core@7.29.0)(expo@55.0.17)(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + jest-snapshot-prettier: prettier@2.8.8 + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.6.0)) + resolve-workspace-root: 2.0.1 + ts-jest: 29.0.5(@babel/core@7.29.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest@29.7.0(@types/node@25.6.0))(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - '@babel/core' + - '@babel/runtime' + - '@jest/types' + - '@types/eslint' + - babel-jest + - bufferutil + - canvas + - esbuild + - eslint + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - expo + - expo-widgets + - jest + - prettier + - react + - react-native + - react-refresh + - react-server-dom-webpack + - react-test-renderer + - supports-color + - utf-8-validate + + expo-modules-autolinking@55.0.18(typescript@5.9.3): + dependencies: + '@expo/require-utils': 55.0.4(typescript@5.9.3) + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + commander: 7.2.0 + transitivePeerDependencies: + - supports-color + - typescript + + expo-modules-core@55.0.23(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + invariant: 2.2.4 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + react-native-worklets: 0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + + expo-notifications@55.0.21(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): + dependencies: + '@expo/image-utils': 0.8.13(typescript@5.9.3) + abort-controller: 3.0.0 + badgin: 1.2.3 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-application: 55.0.14(expo@55.0.17) + expo-constants: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - supports-color + - typescript + + expo-router@55.0.13(121105c3b042d5e83ab3c1d3b84ed55f): + dependencies: + '@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/schema-utils': 55.0.3 + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-tabs': 1.1.13(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + '@react-navigation/bottom-tabs': 7.15.10(@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@react-navigation/native': 7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@react-navigation/native-stack': 7.14.12(@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + client-only: 0.0.1 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-constants: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + expo-glass-effect: 55.0.10(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-image: 55.0.9(expo@55.0.17)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-linking: 55.0.14(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-server: 55.0.8 + expo-symbols: 55.0.7(expo-font@55.0.6)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.11 + query-string: 7.1.3 + react: 19.2.0 + react-fast-compare: 3.2.2 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-safe-area-context: 5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-screens: 4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + semver: 7.6.3 + server-only: 0.0.1 + sf-symbols-typescript: 2.2.0 + shallowequal: 1.1.0 + use-latest-callback: 0.2.6(react@19.2.0) + vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + optionalDependencies: + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0) + react-dom: 19.2.5(react@19.2.0) + react-native-gesture-handler: 2.30.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-reanimated: 4.2.1(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + - '@types/react' + - '@types/react-dom' + - expo-font + - supports-color + + expo-secure-store@55.0.13(expo@55.0.17): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + + expo-server@55.0.8: {} + + expo-splash-screen@55.0.19(expo@55.0.17)(typescript@5.9.3): + dependencies: + '@expo/prebuild-config': 55.0.16(expo@55.0.17)(typescript@5.9.3) + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + - typescript + + expo-status-bar@55.0.5(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + + expo-symbols@55.0.7(expo-font@55.0.6)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + '@expo-google-fonts/material-symbols': 0.4.34 + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-font: 55.0.6(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + sf-symbols-typescript: 2.2.0 + + expo@55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.2 + '@expo/cli': 55.0.26(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.15)(expo-font@55.0.6)(expo-router@55.0.13)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + '@expo/config': 55.0.15(typescript@5.9.3) + '@expo/config-plugins': 55.0.8 + '@expo/devtools': 55.0.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/fingerprint': 0.16.6 + '@expo/local-build-cache-provider': 55.0.11(typescript@5.9.3) + '@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/metro': 55.1.0 + '@expo/metro-config': 55.0.17(expo@55.0.17)(typescript@5.9.3) + '@expo/vector-icons': 15.1.1(expo-font@55.0.6)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@ungap/structured-clone': 1.3.0 + babel-preset-expo: 55.0.18(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@55.0.17)(react-refresh@0.14.2) + expo-asset: 55.0.16(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-constants: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + expo-file-system: 55.0.17(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + expo-font: 55.0.6(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-keep-awake: 55.0.6(expo@55.0.17)(react@19.2.0) + expo-modules-autolinking: 55.0.18(typescript@5.9.3) + expo-modules-core: 55.0.23(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + pretty-format: 29.7.0 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-refresh: 0.14.2 + whatwg-url-minimum: 0.1.1 + optionalDependencies: + '@expo/dom-webview': 55.0.5(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-webview: 13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - expo-router + - expo-widgets + - react-dom + - react-native-worklets + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + + exponential-backoff@3.1.3: {} + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fb-dotslash@0.5.8: {} + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fbjs-css-vars@1.0.2: {} + + fbjs@3.0.5: + dependencies: + cross-fetch: 3.2.0 + fbjs-css-vars: 1.0.2 + loose-envify: 1.4.0 + object-assign: 4.1.1 + promise: 7.3.1 + setimmediate: 1.0.5 + ua-parser-js: 1.0.41 + transitivePeerDependencies: + - encoding + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fetch-nodeshim@0.4.10: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + filter-obj@1.1.0: {} + + finalhandler@1.1.2: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + flow-enums-runtime@0.0.6: {} + + fontfaceobserver@2.3.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + fresh@0.5.2: {} + + fs-readdir-recursive@1.1.0: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.3 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + getenv@2.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@14.0.0: {} + + globals@15.15.0: {} + + globals@16.5.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globrex@0.1.2: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-bigints@1.1.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hermes-compiler@0.14.1: {} + + hermes-compiler@250829098.0.10: {} + + hermes-estree@0.32.0: {} + + hermes-estree@0.32.1: {} + + hermes-estree@0.33.3: {} + + hermes-estree@0.35.0: {} + + hermes-parser@0.32.0: + dependencies: + hermes-estree: 0.32.0 + + hermes-parser@0.32.1: + dependencies: + hermes-estree: 0.32.1 + + hermes-parser@0.33.3: + dependencies: + hermes-estree: 0.33.3 + + hermes-parser@0.35.0: + dependencies: + hermes-estree: 0.35.0 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + + html-escaper@2.0.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + hyphenate-style-name@1.1.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + image-size@1.2.1: + dependencies: + queue: 6.0.2 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inline-style-prefixer@7.0.1: + dependencies: + css-in-js-utils: 3.1.0 + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.3 + side-channel: 1.1.0 + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-arrayish@0.3.4: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + optional: true + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.3 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-docker@2.2.1: {} + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-plain-obj@2.1.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@25.6.0): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@25.6.0) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@25.6.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@25.6.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 25.6.0 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-diff@30.3.0: + dependencies: + '@jest/diff-sequences': 30.3.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.3.0 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-jsdom@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 25.6.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-expo@55.0.16(@babel/core@7.29.0)(expo@55.0.17)(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): + dependencies: + '@expo/config': 55.0.15(typescript@5.9.3) + '@expo/json-file': 10.0.13 + '@jest/create-cache-key-function': 29.7.0 + '@jest/globals': 29.7.0 + babel-jest: 29.7.0(@babel/core@7.29.0) + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + jest-environment-jsdom: 29.7.0 + jest-snapshot: 29.7.0 + jest-watch-select-projects: 2.0.0 + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.6.0)) + json5: 2.2.3 + lodash: 4.18.1 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-test-renderer: 19.2.0(react@19.2.0) + server-only: 0.0.1 + stacktrace-js: 2.0.2 + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - canvas + - jest + - react + - supports-color + - typescript + - utf-8-validate + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 25.6.0 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@30.3.0: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.3.0 + pretty-format: 30.3.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.12 + resolve.exports: 2.0.3 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watch-select-projects@2.0.0: + dependencies: + ansi-escapes: 4.3.2 + chalk: 3.0.0 + prompts: 2.4.2 + + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@25.6.0)): + dependencies: + ansi-escapes: 6.2.1 + chalk: 4.1.2 + jest: 29.7.0(@types/node@25.6.0) + jest-regex-util: 29.6.3 + jest-watcher: 29.7.0 + slash: 5.1.0 + string-length: 5.0.1 + strip-ansi: 7.2.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@29.7.0: + dependencies: + '@types/node': 25.6.0 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@25.6.0): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@25.6.0) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jimp-compact@0.16.1: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsc-safe-url@0.2.4: {} + + jsdom@20.0.3: + dependencies: + abab: 2.0.6 + acorn: 8.16.0 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.6.0 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.5 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.20.0 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + lan-network@0.2.1: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lighthouse-logger@1.4.2: + dependencies: + debug: 2.6.9 + marky: 1.3.0 + transitivePeerDependencies: + - supports-color + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.debounce@4.0.8: {} + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + lodash.throttle@4.1.1: {} + + lodash@4.18.1: {} + + log-symbols@2.2.0: + dependencies: + chalk: 2.4.2 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@10.4.3: {} + + lru-cache@11.3.5: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react-native@1.11.0(react-native-svg@15.15.4(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-svg: 15.15.4(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + + make-dir@2.1.0: + dependencies: + pify: 4.0.1 + semver: 5.7.2 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + marky@1.3.0: {} + + math-intrinsics@1.1.0: {} + + mdn-data@2.0.14: {} + + memoize-one@5.2.1: {} + + memoize-one@6.0.0: {} + + merge-options@3.0.4: + dependencies: + is-plain-obj: 2.1.0 + + merge-stream@2.0.0: {} + + metro-babel-transformer@0.83.6: + dependencies: + '@babel/core': 7.29.0 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.35.0 + metro-cache-key: 0.83.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-babel-transformer@0.84.3: + dependencies: + '@babel/core': 7.29.0 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.35.0 + metro-cache-key: 0.84.3 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-cache-key@0.83.6: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache-key@0.84.3: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache@0.83.6: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.83.6 + transitivePeerDependencies: + - supports-color + + metro-cache@0.84.3: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.84.3 + transitivePeerDependencies: + - supports-color + + metro-config@0.83.6: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.83.6 + metro-cache: 0.83.6 + metro-core: 0.83.6 + metro-runtime: 0.83.6 + yaml: 2.8.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-config@0.84.3: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.84.3 + metro-cache: 0.84.3 + metro-core: 0.84.3 + metro-runtime: 0.84.3 + yaml: 2.8.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-core@0.83.6: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.83.6 + + metro-core@0.84.3: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.84.3 + + metro-file-map@0.83.6: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + metro-file-map@0.84.3: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + metro-minify-terser@0.83.6: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.46.2 + + metro-minify-terser@0.84.3: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.46.2 + + metro-resolver@0.83.6: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-resolver@0.84.3: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-runtime@0.83.6: + dependencies: + '@babel/runtime': 7.29.2 + flow-enums-runtime: 0.0.6 + + metro-runtime@0.84.3: + dependencies: + '@babel/runtime': 7.29.2 + flow-enums-runtime: 0.0.6 + + metro-source-map@0.83.6: + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.83.6 + nullthrows: 1.1.1 + ob1: 0.83.6 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-source-map@0.84.3: + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.84.3 + nullthrows: 1.1.1 + ob1: 0.84.3 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.83.6: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.83.6 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.84.3: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.84.3 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.83.6: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.84.3: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-worker@0.83.6: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + metro: 0.83.6 + metro-babel-transformer: 0.83.6 + metro-cache: 0.83.6 + metro-cache-key: 0.83.6 + metro-minify-terser: 0.83.6 + metro-source-map: 0.83.6 + metro-transform-plugins: 0.83.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-transform-worker@0.84.3: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + metro: 0.84.3 + metro-babel-transformer: 0.84.3 + metro-cache: 0.84.3 + metro-cache-key: 0.84.3 + metro-minify-terser: 0.84.3 + metro-source-map: 0.84.3 + metro-transform-plugins: 0.84.3 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.83.6: + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + accepts: 2.0.0 + chalk: 4.1.2 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.35.0 + image-size: 1.2.1 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.83.6 + metro-cache: 0.83.6 + metro-cache-key: 0.83.6 + metro-config: 0.83.6 + metro-core: 0.83.6 + metro-file-map: 0.83.6 + metro-resolver: 0.83.6 + metro-runtime: 0.83.6 + metro-source-map: 0.83.6 + metro-symbolicate: 0.83.6 + metro-transform-plugins: 0.83.6 + metro-transform-worker: 0.83.6 + mime-types: 3.0.2 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.10 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.84.3: + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + accepts: 2.0.0 + chalk: 4.1.2 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.35.0 + image-size: 1.2.1 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.84.3 + metro-cache: 0.84.3 + metro-cache-key: 0.84.3 + metro-config: 0.84.3 + metro-core: 0.84.3 + metro-file-map: 0.84.3 + metro-resolver: 0.84.3 + metro-runtime: 0.84.3 + metro-source-map: 0.84.3 + metro-symbolicate: 0.84.3 + metro-transform-plugins: 0.84.3 + metro-transform-worker: 0.84.3 + mime-types: 3.0.2 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.10 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: {} + + mimic-fn@1.2.0: {} + + mimic-fn@2.1.0: {} + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mkdirp@1.0.4: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + multitars@1.0.0: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + negotiator@1.0.0: {} + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-forge@1.4.0: {} + + node-int64@0.4.0: {} + + node-releases@2.0.38: {} + + normalize-path@3.0.0: {} + + npm-package-arg@11.0.3: + dependencies: + hosted-git-info: 7.0.2 + proc-log: 4.2.0 + semver: 7.7.4 + validate-npm-package-name: 5.0.1 + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nullthrows@1.1.1: {} + + nwsapi@2.2.23: {} + + ob1@0.83.6: + dependencies: + flow-enums-runtime: 0.0.6 + + ob1@0.84.3: + dependencies: + flow-enums-runtime: 0.0.6 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@2.0.1: + dependencies: + mimic-fn: 1.2.0 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@3.4.0: + dependencies: + chalk: 2.4.2 + cli-cursor: 2.1.0 + cli-spinners: 2.9.2 + log-symbols: 2.2.0 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + oxfmt@0.47.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.47.0 + '@oxfmt/binding-android-arm64': 0.47.0 + '@oxfmt/binding-darwin-arm64': 0.47.0 + '@oxfmt/binding-darwin-x64': 0.47.0 + '@oxfmt/binding-freebsd-x64': 0.47.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.47.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.47.0 + '@oxfmt/binding-linux-arm64-gnu': 0.47.0 + '@oxfmt/binding-linux-arm64-musl': 0.47.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.47.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.47.0 + '@oxfmt/binding-linux-riscv64-musl': 0.47.0 + '@oxfmt/binding-linux-s390x-gnu': 0.47.0 + '@oxfmt/binding-linux-x64-gnu': 0.47.0 + '@oxfmt/binding-linux-x64-musl': 0.47.0 + '@oxfmt/binding-openharmony-arm64': 0.47.0 + '@oxfmt/binding-win32-arm64-msvc': 0.47.0 + '@oxfmt/binding-win32-ia32-msvc': 0.47.0 + '@oxfmt/binding-win32-x64-msvc': 0.47.0 + + oxlint@1.62.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.62.0 + '@oxlint/binding-android-arm64': 1.62.0 + '@oxlint/binding-darwin-arm64': 1.62.0 + '@oxlint/binding-darwin-x64': 1.62.0 + '@oxlint/binding-freebsd-x64': 1.62.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.62.0 + '@oxlint/binding-linux-arm-musleabihf': 1.62.0 + '@oxlint/binding-linux-arm64-gnu': 1.62.0 + '@oxlint/binding-linux-arm64-musl': 1.62.0 + '@oxlint/binding-linux-ppc64-gnu': 1.62.0 + '@oxlint/binding-linux-riscv64-gnu': 1.62.0 + '@oxlint/binding-linux-riscv64-musl': 1.62.0 + '@oxlint/binding-linux-s390x-gnu': 1.62.0 + '@oxlint/binding-linux-x64-gnu': 1.62.0 + '@oxlint/binding-linux-x64-musl': 1.62.0 + '@oxlint/binding-openharmony-arm64': 1.62.0 + '@oxlint/binding-win32-arm64-msvc': 1.62.0 + '@oxlint/binding-win32-ia32-msvc': 1.62.0 + '@oxlint/binding-win32-x64-msvc': 1.62.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-png@2.1.0: + dependencies: + pngjs: 3.4.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.3.5 + minipass: 7.1.3 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@4.0.1: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.10 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + pngjs@3.4.0: {} + + possible-typed-array-names@1.1.0: {} + + postcss-value-parser@4.2.0: {} + + postcss@8.4.49: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + + prettier@2.8.8: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + pretty-format@30.3.0: + dependencies: + '@jest/schemas': 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + proc-log@4.2.0: {} + + progress@2.0.3: {} + + promise@7.3.1: + dependencies: + asap: 2.0.6 + + promise@8.3.0: + dependencies: + asap: 2.0.6 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + + querystringify@2.2.0: {} + + queue@6.0.2: + dependencies: + inherits: 2.0.4 + + range-parser@1.2.1: {} + + react-devtools-core@6.1.5: + dependencies: + shell-quote: 1.8.3 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + react-dom@19.2.5(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + + react-fast-compare@3.2.2: {} + + react-freeze@1.0.4(react@19.2.0): + dependencies: + react: 19.2.0 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-is@19.2.5: {} + + react-native-gesture-handler@2.30.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + '@egjs/hammerjs': 2.0.17 + hoist-non-react-statics: 3.3.2 + invariant: 2.2.4 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + react-native-is-edge-to-edge@1.2.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + react-native-is-edge-to-edge@1.3.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + react-native-reanimated@4.2.1(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + react-native-is-edge-to-edge: 1.2.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-worklets: 0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + semver: 7.7.3 + + react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-freeze: 1.0.4(react@19.2.0) + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + warn-once: 0.1.1 + + react-native-svg@15.15.4(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + css-select: 5.2.2 + css-tree: 1.1.3 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + warn-once: 0.1.1 + + react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0): + dependencies: + '@babel/runtime': 7.29.2 + '@react-native/normalize-colors': 0.74.89 + fbjs: 3.0.5 + inline-style-prefixer: 7.0.1 + memoize-one: 6.0.0 + nullthrows: 1.1.1 + postcss-value-parser: 4.2.0 + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + styleq: 0.1.3 + transitivePeerDependencies: + - encoding + + react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + escape-string-regexp: 4.0.0 + invariant: 2.2.4 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/preset-typescript': 7.27.1(@babel/core@7.29.0) + convert-source-map: 2.0.0 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native/assets-registry': 0.83.6 + '@react-native/codegen': 0.83.6(@babel/core@7.29.0) + '@react-native/community-cli-plugin': 0.83.6(@react-native/metro-config@0.85.2(@babel/core@7.29.0)) + '@react-native/gradle-plugin': 0.83.6 + '@react-native/js-polyfills': 0.83.6 + '@react-native/normalize-colors': 0.83.6 + '@react-native/virtualized-lists': 0.83.6(@types/react@19.2.14)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-jest: 29.7.0(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.32.0 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + glob: 7.2.3 + hermes-compiler: 0.14.1 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + memoize-one: 5.2.1 + metro-runtime: 0.83.6 + metro-source-map: 0.83.6 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.0 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.27.0 + semver: 7.7.4 + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + ws: 7.5.10 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + + react-native@0.85.2(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0): + dependencies: + '@react-native/assets-registry': 0.85.2 + '@react-native/codegen': 0.85.2(@babel/core@7.29.0) + '@react-native/community-cli-plugin': 0.85.2(@react-native/metro-config@0.85.2(@babel/core@7.29.0)) + '@react-native/gradle-plugin': 0.85.2 + '@react-native/js-polyfills': 0.85.2 + '@react-native/normalize-colors': 0.85.2 + '@react-native/virtualized-lists': 0.85.2(@types/react@19.2.14)(react-native@0.85.2(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-plugin-syntax-hermes-parser: 0.33.3 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + hermes-compiler: 250829098.0.10 + invariant: 2.2.4 + memoize-one: 5.2.1 + metro-runtime: 0.84.3 + metro-source-map: 0.84.3 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.0 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.27.0 + semver: 7.7.4 + stacktrace-parser: 0.1.11 + tinyglobby: 0.2.16 + whatwg-fetch: 3.6.20 + ws: 7.5.10 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + + react-refresh@0.14.2: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.0): + dependencies: + react: 19.2.0 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.0) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.0): + dependencies: + react: 19.2.0 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.0) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.0) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.0): + dependencies: + get-nonce: 1.0.1 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-test-renderer@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + react-is: 19.2.5 + scheduler: 0.27.0 + + react@19.2.0: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + optional: true + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regenerator-runtime@0.13.11: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regexpp@3.2.0: {} + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.1 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.1: + dependencies: + jsesc: 3.1.0 + + require-directory@2.1.1: {} + + requires-port@1.0.0: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve-workspace-root@2.0.1: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.6: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@2.0.0: + dependencies: + onetime: 2.0.1 + signal-exit: 3.0.7 + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + sax@1.6.0: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@5.7.2: {} + + semver@6.3.1: {} + + semver@7.6.3: {} + + semver@7.7.3: {} + + semver@7.7.4: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serialize-error@2.1.0: {} + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + server-only@0.0.1: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + sf-symbols-typescript@2.2.0: {} + + shallowequal@1.1.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.3: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + simple-plist@1.3.1: + dependencies: + bplist-creator: 0.1.0 + bplist-parser: 0.3.1 + plist: 3.1.1 + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + + sisteransi@1.0.5: {} + + slash@2.0.0: {} + + slash@3.0.0: {} + + slash@5.1.0: {} + + slugify@1.6.9: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.5.6: {} + + source-map@0.5.7: {} + + source-map@0.6.1: {} + + split-on-first@1.1.0: {} + + sprintf-js@1.0.3: {} + + stack-generator@2.0.10: + dependencies: + stackframe: 1.3.4 + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stackframe@1.3.4: {} + + stacktrace-gps@3.1.2: + dependencies: + source-map: 0.5.6 + stackframe: 1.3.4 + + stacktrace-js@2.0.2: + dependencies: + error-stack-parser: 2.1.4 + stack-generator: 2.0.10 + stacktrace-gps: 3.1.2 + + stacktrace-parser@0.1.11: + dependencies: + type-fest: 0.7.1 + + statuses@1.5.0: {} + + statuses@2.0.2: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + stream-buffers@2.2.0: {} + + strict-uri-encode@2.0.0: {} + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-length@5.0.1: + dependencies: + char-regex: 2.0.2 + strip-ansi: 7.2.0 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + structured-headers@0.4.1: {} + + styleq@0.1.3: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@2.3.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + + tagged-tag@1.0.0: {} + + tapable@2.3.3: {} + + terminal-link@2.1.1: + dependencies: + ansi-escapes: 4.3.2 + supports-hyperlinks: 2.3.0 + + terser@5.46.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + + throat@5.0.0: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@2.1.0: {} + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + toqr@0.1.1: {} + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tr46@0.0.3: {} + + tr46@3.0.0: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-declaration-location@1.0.7(typescript@5.9.3): + dependencies: + picomatch: 4.0.4 + typescript: 5.9.3 + + ts-jest@29.0.5(@babel/core@7.29.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest@29.7.0(@types/node@25.6.0))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + jest: 29.7.0(@types/node@25.6.0) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.4 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.0) + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + + tweetnacl@1.0.3: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@0.7.1: {} + + type-fest@5.6.0: + dependencies: + tagged-tag: 1.0.0 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript@5.9.3: {} + + ua-parser-js@1.0.41: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@7.19.2: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + universalify@0.2.0: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.0): + dependencies: + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-latest-callback@0.2.6(react@19.2.0): + dependencies: + react: 19.2.0 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.0): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.0): + dependencies: + react: 19.2.0 + + utils-merge@1.0.1: {} + + uuid@7.0.3: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + validate-npm-package-name@5.0.1: {} + + vary@1.1.2: {} + + vaul@1.1.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.5(react@19.2.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + vlq@1.0.1: {} + + w3c-xmlserializer@4.0.0: + dependencies: + xml-name-validator: 4.0.0 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + warn-once@0.1.1: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + webidl-conversions@3.0.1: {} + + webidl-conversions@7.0.0: {} + + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + + whatwg-fetch@3.6.20: {} + + whatwg-mimetype@3.0.0: {} + + whatwg-url-minimum@0.1.1: {} + + whatwg-url@11.0.0: + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + ws@7.5.10: {} + + ws@8.20.0: {} + + xcode@3.0.1: + dependencies: + simple-plist: 1.3.1 + uuid: 7.0.3 + + xml-name-validator@4.0.0: {} + + xml2js@0.6.0: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + xmlbuilder@15.1.1: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml@2.8.3: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zod@3.25.76: {} + + zod@4.3.6: {} + + zustand@5.0.12(@types/react@19.2.14)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.0 + use-sync-external-store: 1.6.0(react@19.2.0) + + zxing-wasm@3.0.2(@types/emscripten@1.41.5): + dependencies: + '@types/emscripten': 1.41.5 + type-fest: 5.6.0 diff --git a/mobile/scripts/mock-server.ts b/mobile/scripts/mock-server.ts new file mode 100644 index 00000000000..14c7ab2c3db --- /dev/null +++ b/mobile/scripts/mock-server.ts @@ -0,0 +1,321 @@ +#!/usr/bin/env npx tsx +// Why: standalone mock WebSocket server for developing the mobile app without +// a running Orca desktop instance. Responds to the same RPC methods the real +// runtime exposes, with realistic fake data. Supports E2EE handshake. +import { WebSocketServer, type WebSocket } from 'ws' +import nacl from 'tweetnacl' + +const PORT = Number(process.env.PORT) || 6768 +const AUTH_TOKEN = 'mock-device-token' + +// Why: generate a persistent server keypair for this mock session. +// The public key is printed at startup so it can be used in pairing QR data. +const serverKeyPair = nacl.box.keyPair() +const serverPublicKeyB64 = Buffer.from(serverKeyPair.publicKey).toString('base64') + +type E2EEState = { + sharedKey: Uint8Array + deviceToken: string | null + authenticated: boolean +} + +function deriveSharedKey(ourSecret: Uint8Array, peerPublic: Uint8Array): Uint8Array { + return nacl.box.before(peerPublic, ourSecret) +} + +function e2eeEncrypt(plaintext: string, sharedKey: Uint8Array): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const msg = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(msg, nonce, sharedKey) + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + return Buffer.from(bundle).toString('base64') +} + +function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null { + const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64')) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) return null + const nonce = bundle.slice(0, nacl.box.nonceLength) + const ciphertext = bundle.slice(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + if (!plaintext) return null + return new TextDecoder().decode(plaintext) +} + +const FAKE_WORKTREES = [ + { + worktreeId: 'repo-1::/home/user/projects/acme-api', + repoId: 'repo-1', + repo: 'acme-api', + path: '/home/user/projects/acme-api', + branch: 'feature/auth-refactor', + linkedIssue: 42, + unread: true, + liveTerminalCount: 2, + hasAttachedPty: true, + lastOutputAt: Date.now() - 5000, + preview: '$ claude "refactor the auth module"' + }, + { + worktreeId: 'repo-1::/home/user/projects/acme-web', + repoId: 'repo-1', + repo: 'acme-web', + path: '/home/user/projects/acme-web', + branch: 'main', + linkedIssue: null, + unread: false, + liveTerminalCount: 1, + hasAttachedPty: true, + lastOutputAt: Date.now() - 60000, + preview: '$ npm test\nAll tests passed.' + } +] + +const FAKE_TERMINALS = [ + { + handle: 'term-1', + worktreeId: 'repo-1::/home/user/projects/acme-api', + title: 'Claude — auth refactor', + isActive: true, + hasRunningProcess: true + }, + { + handle: 'term-2', + worktreeId: 'repo-1::/home/user/projects/acme-api', + title: 'zsh', + isActive: false, + hasRunningProcess: false + } +] + +const FAKE_SCROLLBACK = [ + '$ claude "refactor the auth module to use JWT tokens"', + '', + '⏳ Working on it...', + '', + "I'll refactor the auth module. Here's my plan:", + '1. Replace session-based auth with JWT', + '2. Add token refresh endpoint', + '3. Update middleware', + '', + 'Let me start by reading the current auth module...', + '' +].join('\n') + +const STREAMING_CHUNKS = [ + 'Reading src/auth/middleware.ts...\n', + 'Reading src/auth/session.ts...\n', + '\nI see the current implementation uses express-session.\n', + "I'll replace it with jsonwebtoken.\n", + '\nUpdating src/auth/middleware.ts...\n' +] + +type RpcRequest = { + id: string + method: string + deviceToken?: string + params?: Record +} + +type RpcResponse = { + id: string + ok: boolean + result?: unknown + error?: { code: string; message: string } + streaming?: true + _meta: { runtimeId: string } +} + +function success(id: string, result: unknown, streaming?: boolean): RpcResponse { + const resp: RpcResponse = { id, ok: true, result, _meta: { runtimeId: 'mock-runtime' } } + if (streaming) { + resp.streaming = true + } + return resp +} + +function error(id: string, code: string, message: string): RpcResponse { + return { id, ok: false, error: { code, message }, _meta: { runtimeId: 'mock-runtime' } } +} + +function handleRequest( + request: RpcRequest, + send: (response: RpcResponse) => void, + ws: WebSocket +): void { + switch (request.method) { + case 'status.get': + send( + success(request.id, { + runtimeId: 'mock-runtime', + graphStatus: 'ready', + windowCount: 1, + tabCount: 2, + terminalCount: 2 + }) + ) + break + + case 'worktree.ps': + send( + success(request.id, { + worktrees: FAKE_WORKTREES, + totalCount: FAKE_WORKTREES.length, + truncated: false + }) + ) + break + + case 'terminal.list': + send( + success(request.id, { + terminals: FAKE_TERMINALS, + totalCount: FAKE_TERMINALS.length, + truncated: false + }) + ) + break + + case 'terminal.subscribe': { + send(success(request.id, { type: 'scrollback', lines: FAKE_SCROLLBACK, truncated: false })) + + let chunkIndex = 0 + const interval = setInterval(() => { + if (chunkIndex >= STREAMING_CHUNKS.length || ws.readyState !== ws.OPEN) { + clearInterval(interval) + if (ws.readyState === ws.OPEN) { + send(success(request.id, { type: 'end' })) + } + return + } + send(success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true)) + chunkIndex++ + }, 500) + break + } + + case 'terminal.send': + send(success(request.id, { send: { handle: 'term-1', ok: true } })) + break + + case 'terminal.unsubscribe': + send(success(request.id, { unsubscribed: true })) + break + + default: + send(error(request.id, 'method_not_found', `Unknown method: ${request.method}`)) + } +} + +const wss = new WebSocketServer({ port: PORT }) + +// Why: each connection goes through an E2EE handshake before any RPC traffic. +// The first message must be e2ee_hello (plaintext), then all subsequent +// messages are encrypted with the derived shared key. +const connectionState = new Map() + +wss.on('connection', (ws) => { + console.log('[mock] Client connected — waiting for e2ee_hello') + + ws.on('message', (data) => { + const msg = typeof data === 'string' ? data : data.toString('utf-8') + const e2ee = connectionState.get(ws) + + if (!e2ee) { + // Handshake phase — expect e2ee_hello + let hello: { type?: string; publicKeyB64?: string } + try { + hello = JSON.parse(msg) + } catch { + ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Invalid JSON' })) + ws.close() + return + } + + if (hello.type !== 'e2ee_hello' || !hello.publicKeyB64) { + ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Expected e2ee_hello' })) + ws.close() + return + } + + const clientPublicKey = Uint8Array.from(Buffer.from(hello.publicKeyB64, 'base64')) + if (clientPublicKey.length !== 32) { + ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Invalid public key' })) + ws.close() + return + } + + const sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey) + connectionState.set(ws, { sharedKey, deviceToken: null, authenticated: false }) + + ws.send(JSON.stringify({ type: 'e2ee_ready' })) + console.log('[mock] E2EE key exchange complete — waiting for encrypted auth') + return + } + + // Post-handshake — decrypt, handle, encrypt reply + const plaintext = e2eeDecrypt(msg, e2ee.sharedKey) + if (plaintext === null) { + console.log('[mock] Decryption failed — dropping message') + return + } + + let request: RpcRequest + try { + request = JSON.parse(plaintext) as RpcRequest + } catch { + const encrypted = e2eeEncrypt( + JSON.stringify(error('unknown', 'bad_request', 'Invalid JSON')), + e2ee.sharedKey + ) + ws.send(encrypted) + return + } + + if (!e2ee.authenticated) { + const auth = request as unknown as { type?: string; deviceToken?: string } + if (auth.type !== 'e2ee_auth' || auth.deviceToken !== AUTH_TOKEN) { + ws.send( + e2eeEncrypt( + JSON.stringify({ type: 'e2ee_error', error: { code: 'unauthorized' } }), + e2ee.sharedKey + ) + ) + ws.close() + return + } + e2ee.deviceToken = auth.deviceToken + e2ee.authenticated = true + ws.send(e2eeEncrypt(JSON.stringify({ type: 'e2ee_authenticated' }), e2ee.sharedKey)) + console.log('[mock] E2EE authentication complete') + return + } + + console.log(`[mock] ${request.method} (id: ${request.id})`) + handleRequest( + request, + (response) => { + if (ws.readyState === ws.OPEN) { + ws.send(e2eeEncrypt(JSON.stringify(response), e2ee.sharedKey)) + } + }, + ws + ) + }) + + ws.on('close', () => { + connectionState.delete(ws) + console.log('[mock] Client disconnected') + }) + + ws.on('error', () => { + connectionState.delete(ws) + ws.close() + }) +}) + +console.log(`[mock] Orca mock server listening on ws://localhost:${PORT}`) +console.log(`[mock] Auth token: ${AUTH_TOKEN}`) +console.log(`[mock] Server public key (base64): ${serverPublicKeyB64}`) +console.log(`[mock] E2EE enabled — clients must send e2ee_hello before RPC`) diff --git a/mobile/scripts/repro-terminal-colors.ts b/mobile/scripts/repro-terminal-colors.ts new file mode 100644 index 00000000000..89f0bb55d56 --- /dev/null +++ b/mobile/scripts/repro-terminal-colors.ts @@ -0,0 +1,362 @@ +/** + * Minimal mobile terminal color repro. + * + * Captures terminal.subscribe snapshots in the same sequence that tab switching + * uses: terminal A, terminal B, terminal A again. The report tells us whether + * ANSI SGR color attributes disappeared in the desktop serialized scrollback + * or are still present and therefore being lost during mobile WebView replay. + * + * Usage: + * ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \ + * pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB] + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import nacl from 'tweetnacl' +import WebSocket from 'ws' + +const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768' +const token = process.argv[2] +const serverPublicKeyB64 = process.argv[3] +const worktreeSelector = process.argv[4] +const explicitHandleA = process.argv[5] +const explicitHandleB = process.argv[6] +const ESC = String.fromCharCode(27) + +type RpcResponse = { + id: string + ok: boolean + streaming?: true + result?: Record + error?: { code: string; message: string } +} + +type PendingRequest = { + method: string + resolve: (response: RpcResponse) => void + reject: (error: Error) => void +} + +type Snapshot = { + label: string + handle: string + cols: number | null + rows: number | null + serialized: string + lines: string +} + +if (!token || !serverPublicKeyB64 || !worktreeSelector) { + console.error( + 'Usage: pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB]' + ) + process.exit(1) +} + +let reqId = 0 +const pending = new Map() +const streamListeners = new Map) => void>() +const clientKeys = nacl.box.keyPair() +const serverPublicKey = Buffer.from(serverPublicKeyB64, 'base64') +const sharedKey = nacl.box.before(new Uint8Array(serverPublicKey), clientKeys.secretKey) + +function nextId(): string { + reqId += 1 + return `color-repro-${reqId}` +} + +function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64') +} + +function fromBase64(value: string): Uint8Array { + return new Uint8Array(Buffer.from(value, 'base64')) +} + +function encrypt(plaintext: string): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const message = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(message, nonce, sharedKey) + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + return toBase64(bundle) +} + +function decrypt(payload: string): string | null { + const bundle = fromBase64(payload) + const nonce = bundle.subarray(0, nacl.box.nonceLength) + const ciphertext = bundle.subarray(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + return plaintext ? new TextDecoder().decode(plaintext) : null +} + +function sendRaw(ws: WebSocket, payload: unknown): void { + ws.send(encrypt(JSON.stringify(payload))) +} + +function send(ws: WebSocket, method: string, params?: unknown): Promise { + const id = nextId() + sendRaw(ws, { id, deviceToken: token, method, params }) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id) + streamListeners.delete(id) + reject(new Error(`Timed out waiting for ${method}`)) + }, 10_000) + pending.set(id, { + method, + resolve: (response) => { + clearTimeout(timeout) + resolve(response) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + }) +} + +function formatError(response: RpcResponse): string { + return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000) +} + +async function listHandles( + ws: WebSocket +): Promise> { + const response = await send(ws, 'terminal.list', { worktree: worktreeSelector }) + if (!response.ok) { + throw new Error(`terminal.list failed: ${formatError(response)}`) + } + return ( + (response.result?.terminals ?? []) as Array<{ handle: string; title?: string | null }> + ).map((terminal) => ({ + handle: terminal.handle, + title: terminal.title ?? null + })) +} + +async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise { + const terminals = await listHandles(ws) + const existing = terminals.find((terminal) => terminal.handle !== handleA) + if (existing) return existing.handle + + const created = await send(ws, 'terminal.create', { + worktree: worktreeSelector, + title: 'color-repro-switch-target' + }) + if (!created.ok) { + throw new Error(`terminal.create failed: ${formatError(created)}`) + } + const handle = (created.result?.terminal as { handle?: string } | undefined)?.handle + if (!handle) { + throw new Error(`terminal.create returned no handle: ${formatError(created)}`) + } + return handle +} + +async function captureSnapshot(ws: WebSocket, label: string, handle: string): Promise { + const id = nextId() + const snapshot = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id) + streamListeners.delete(id) + reject(new Error(`Timed out waiting for scrollback snapshot ${label}`)) + }, 10_000) + + pending.set(id, { + method: 'terminal.subscribe', + resolve: () => {}, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + streamListeners.set(id, (result) => { + if (result.type !== 'scrollback') return + clearTimeout(timeout) + pending.delete(id) + streamListeners.delete(id) + const serialized = typeof result.serialized === 'string' ? result.serialized : '' + const rawLines = result.lines + const lines = Array.isArray(rawLines) ? rawLines.join('\n') : String(rawLines ?? '') + resolve({ + label, + handle, + cols: typeof result.cols === 'number' ? result.cols : null, + rows: typeof result.rows === 'number' ? result.rows : null, + serialized, + lines + }) + }) + + sendRaw(ws, { + id, + deviceToken: token, + method: 'terminal.subscribe', + params: { terminal: handle } + }) + }) + + await send(ws, 'terminal.unsubscribe', { subscriptionId: handle }).catch(() => null) + return snapshot +} + +function countMatches(value: string, pattern: RegExp): number { + return value.match(pattern)?.length ?? 0 +} + +function summarize(snapshot: Snapshot): Record { + const data = snapshot.serialized + return { + label: snapshot.label, + handle: snapshot.handle, + cols: snapshot.cols, + rows: snapshot.rows, + serializedBytes: Buffer.byteLength(data), + sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')), + sgrColor: countMatches( + data, + new RegExp( + `${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`, + 'g' + ) + ), + sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')), + altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no', + containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no', + containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no' + } +} + +function saveSnapshots(snapshots: Snapshot[]): string { + const dir = join(process.cwd(), 'terminal-color-repro') + mkdirSync(dir, { recursive: true }) + for (const snapshot of snapshots) { + const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-') + writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines) + writeFileSync( + join(dir, `${base}.escaped.txt`), + JSON.stringify(snapshot.serialized || snapshot.lines) + ) + } + writeFileSync(join(dir, 'summary.json'), JSON.stringify(snapshots.map(summarize), null, 2)) + return dir +} + +async function run(ws: WebSocket): Promise { + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: toBase64(clientKeys.publicKey) + }) + ) + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for e2ee_ready')), 5000) + ws.once('message', (data) => { + clearTimeout(timeout) + const msg = JSON.parse(data.toString()) as { type?: string } + if (msg.type !== 'e2ee_ready') { + reject(new Error(`Unexpected handshake response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + sendRaw(ws, { type: 'e2ee_auth', deviceToken: token }) + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timed out waiting for e2ee_authenticated')), + 5000 + ) + ws.once('message', (data) => { + clearTimeout(timeout) + const plaintext = decrypt(data.toString()) + const msg = plaintext ? (JSON.parse(plaintext) as { type?: string }) : null + if (msg?.type !== 'e2ee_authenticated') { + reject(new Error(`Unexpected auth response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + const terminals = await listHandles(ws) + if (terminals.length === 0 && !explicitHandleA) { + throw new Error('No terminals found. Open a Claude Code terminal first, then rerun.') + } + + const handleA = explicitHandleA ?? terminals[0]!.handle + const handleB = explicitHandleB ?? (await ensureSecondHandle(ws, handleA)) + + console.log(`A: ${handleA}`) + console.log(`B: ${handleB}`) + + const firstA = await captureSnapshot(ws, 'a-before-switch', handleA) + const firstB = await captureSnapshot(ws, 'b-during-switch', handleB) + const secondA = await captureSnapshot(ws, 'a-after-switch', handleA) + const snapshots = [firstA, firstB, secondA] + const dir = saveSnapshots(snapshots) + + console.table(snapshots.map(summarize)) + console.log(`saved: ${dir}`) + if (summarize(firstA).sgrColor !== summarize(secondA).sgrColor) { + console.log( + 'color SGR count changed between A snapshots: serialization/state changed on desktop' + ) + } else { + console.log('A snapshots have the same color SGR count: likely mobile replay/render path') + } +} + +const ws = new WebSocket(WS_URL) + +ws.on('open', () => { + run(ws) + .then(() => { + ws.close() + process.exit(0) + }) + .catch((error) => { + console.error(error.message) + ws.close() + process.exit(1) + }) +}) + +ws.on('message', (data) => { + const raw = data.toString() + if (raw.startsWith('{')) { + return + } + const plaintext = decrypt(raw) + if (!plaintext) return + + const response = JSON.parse(plaintext) as RpcResponse + const result = response.result + const streamListener = streamListeners.get(response.id) + if (streamListener && response.ok && (response.streaming || result?.type === 'scrollback')) { + streamListener(result ?? {}) + return + } + + const request = pending.get(response.id) + if (!request || request.method === 'terminal.subscribe') return + pending.delete(response.id) + request.resolve(response) +}) + +ws.on('close', () => { + for (const request of pending.values()) { + request.reject(new Error('WebSocket closed')) + } + pending.clear() +}) + +ws.on('error', (error) => { + console.error(`WebSocket error: ${error.message}`) + process.exit(1) +}) diff --git a/mobile/scripts/repro-worktree-startup-stream.ts b/mobile/scripts/repro-worktree-startup-stream.ts new file mode 100644 index 00000000000..bc1ab769564 --- /dev/null +++ b/mobile/scripts/repro-worktree-startup-stream.ts @@ -0,0 +1,367 @@ +/** + * Captures the mobile WebSocket stream during worktree startup. + * + * Usage: + * pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand] + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import nacl from 'tweetnacl' +import WebSocket from 'ws' + +const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768' +const USER_DATA = + process.env.ORCA_USER_DATA ?? `${process.env.HOME}/Library/Application Support/orca-dev` +const repoSelector = process.argv[2] +const worktreeName = process.argv[3] +const startupCommand = process.argv[4] || 'claude' +const ESC = String.fromCharCode(27) + +type RpcResponse = { + id: string + ok: boolean + streaming?: true + result?: Record + error?: { code: string; message: string } +} + +type PendingRequest = { + method: string + resolve: (response: RpcResponse) => void + reject: (error: Error) => void +} + +type TerminalInfo = { + handle: string + title: string | null +} + +type Capture = { + handle: string + title: string | null + scrollback: Record | null + chunks: string[] +} + +if (!repoSelector || !worktreeName) { + console.error( + 'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand]' + ) + process.exit(1) +} + +function readJson(path: string): T { + if (!existsSync(path)) { + throw new Error(`Missing ${path}`) + } + return JSON.parse(readFileSync(path, 'utf8')) as T +} + +const devices = readJson>(join(USER_DATA, 'orca-devices.json')) +const token = devices[0]?.token +const keypair = readJson<{ publicKeyB64: string }>(join(USER_DATA, 'orca-e2ee-keypair.json')) + +if (!token || !keypair.publicKeyB64) { + throw new Error(`Missing mobile token or E2EE public key in ${USER_DATA}`) +} + +let reqId = 0 +const pending = new Map() +const streamListeners = new Map) => void>() +const clientKeys = nacl.box.keyPair() +const serverPublicKey = Buffer.from(keypair.publicKeyB64, 'base64') +const sharedKey = nacl.box.before(new Uint8Array(serverPublicKey), clientKeys.secretKey) + +function nextId(): string { + reqId += 1 + return `startup-repro-${reqId}` +} + +function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64') +} + +function fromBase64(value: string): Uint8Array { + return new Uint8Array(Buffer.from(value, 'base64')) +} + +function encrypt(plaintext: string): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const message = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(message, nonce, sharedKey) + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + return toBase64(bundle) +} + +function decrypt(payload: string): string | null { + const bundle = fromBase64(payload) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { + return null + } + const nonce = bundle.subarray(0, nacl.box.nonceLength) + const ciphertext = bundle.subarray(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + return plaintext ? new TextDecoder().decode(plaintext) : null +} + +function sendRaw(ws: WebSocket, payload: unknown): void { + ws.send(encrypt(JSON.stringify(payload))) +} + +function send( + ws: WebSocket, + method: string, + params?: unknown, + timeoutMs = 30_000 +): Promise { + const id = nextId() + sendRaw(ws, { id, deviceToken: token, method, params }) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id) + reject(new Error(`Timed out waiting for ${method}`)) + }, timeoutMs) + pending.set(id, { + method, + resolve: (response) => { + clearTimeout(timeout) + resolve(response) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + }) +} + +function formatError(response: RpcResponse): string { + return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000) +} + +function stripAnsi(value: string): string { + return ( + value + // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries + .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '') + // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries + .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\r/g, '\n') + ) +} + +function normalizePreview(value: string): string { + return stripAnsi(value) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 8) + .join('\n') +} + +async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise { + const deadline = Date.now() + 60_000 + while (Date.now() < deadline) { + const response = await send(ws, 'terminal.list', { worktree: worktreeId }) + if (!response.ok) { + throw new Error(`terminal.list failed: ${formatError(response)}`) + } + const terminals = (response.result?.terminals ?? []) as Array<{ + handle?: string + title?: string | null + }> + const handles = terminals + .filter((terminal): terminal is { handle: string; title?: string | null } => + Boolean(terminal.handle) + ) + .map((terminal) => ({ handle: terminal.handle, title: terminal.title ?? null })) + if (handles.length > 0) { + return handles + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error('Timed out waiting for startup terminals') +} + +async function subscribe(ws: WebSocket, capture: Capture): Promise { + const id = nextId() + streamListeners.set(id, (result) => { + if (result.type === 'scrollback') { + capture.scrollback = result + return + } + if (result.type === 'data' && typeof result.chunk === 'string') { + capture.chunks.push(result.chunk) + } + }) + sendRaw(ws, { + id, + deviceToken: token, + method: 'terminal.subscribe', + params: { terminal: capture.handle } + }) +} + +function summarize(capture: Capture): Record { + const serialized = + typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' + const live = capture.chunks.join('') + const serializedPreview = normalizePreview(serialized) + const livePreview = normalizePreview(live) + return { + handle: capture.handle, + title: capture.title, + cols: capture.scrollback?.cols ?? null, + rows: capture.scrollback?.rows ?? null, + serializedBytes: Buffer.byteLength(serialized), + liveBytes: Buffer.byteLength(live), + liveChunks: capture.chunks.length, + serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, + liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, + livePreviewContainedInSerialized: + livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''), + serializedPreview, + livePreview + } +} + +function save(captures: Capture[], worktreeName: string): string { + const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName) + mkdirSync(dir, { recursive: true }) + for (const capture of captures) { + const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-') + const serialized = + typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' + writeFileSync(join(dir, `${base}.serialized.ansi`), serialized) + writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join('')) + writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2)) + } + writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2)) + return dir +} + +async function run(ws: WebSocket): Promise { + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: toBase64(clientKeys.publicKey) + }) + ) + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for e2ee_ready')), 5000) + ws.once('message', (data) => { + clearTimeout(timeout) + const msg = JSON.parse(data.toString()) as { type?: string } + if (msg.type !== 'e2ee_ready') { + reject(new Error(`Unexpected handshake response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + sendRaw(ws, { type: 'e2ee_auth', deviceToken: token }) + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timed out waiting for e2ee_authenticated')), + 5000 + ) + ws.once('message', (data) => { + clearTimeout(timeout) + const plaintext = decrypt(data.toString()) + const msg = plaintext ? (JSON.parse(plaintext) as { type?: string }) : null + if (msg?.type !== 'e2ee_authenticated') { + reject(new Error(`Unexpected auth response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + const created = await send( + ws, + 'worktree.create', + { repo: repoSelector, name: worktreeName, startupCommand }, + 120_000 + ) + if (!created.ok) { + throw new Error(`worktree.create failed: ${formatError(created)}`) + } + const worktree = created.result?.worktree as { id?: string; path?: string } | undefined + if (!worktree?.id) { + throw new Error(`worktree.create returned no worktree id: ${formatError(created)}`) + } + + console.log(`worktree: ${worktree.id}`) + const terminals = await waitForTerminals(ws, worktree.id) + console.log(`terminals: ${terminals.map((terminal) => terminal.handle).join(', ')}`) + const captures = terminals.map((terminal) => ({ + handle: terminal.handle, + title: terminal.title, + scrollback: null, + chunks: [] + })) + for (const capture of captures) { + await subscribe(ws, capture) + } + + await new Promise((resolve) => setTimeout(resolve, 15_000)) + for (const capture of captures) { + await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null) + } + const dir = save(captures, worktreeName) + console.table(captures.map(summarize)) + console.log(`saved: ${dir}`) +} + +const ws = new WebSocket(WS_URL) + +ws.on('open', () => { + run(ws) + .then(() => { + ws.close() + process.exit(0) + }) + .catch((error) => { + console.error(error.message) + ws.close() + process.exit(1) + }) +}) + +ws.on('message', (data) => { + const raw = data.toString() + if (raw.startsWith('{')) { + return + } + const plaintext = decrypt(raw) + if (!plaintext) return + + const response = JSON.parse(plaintext) as RpcResponse + const result = response.result + const streamListener = streamListeners.get(response.id) + if (streamListener && response.ok && (response.streaming || result?.type)) { + streamListener(result ?? {}) + return + } + + const request = pending.get(response.id) + if (!request || request.method === 'terminal.subscribe') return + pending.delete(response.id) + request.resolve(response) +}) + +ws.on('close', () => { + for (const request of pending.values()) { + request.reject(new Error('WebSocket closed')) + } + pending.clear() +}) + +ws.on('error', (error) => { + console.error(`WebSocket error: ${error.message}`) + process.exit(1) +}) diff --git a/mobile/scripts/test-subscribe.ts b/mobile/scripts/test-subscribe.ts new file mode 100644 index 00000000000..6fa8ffe4b42 --- /dev/null +++ b/mobile/scripts/test-subscribe.ts @@ -0,0 +1,324 @@ +/** + * Lightweight terminal streaming repro for the mobile WebSocket RPC. + * + * Usage: + * pnpm exec tsx scripts/test-subscribe.ts [worktreeSelector] + * + * Example: + * pnpm exec tsx scripts/test-subscribe.ts "$TOKEN" "$SERVER_PUBLIC_KEY" \ + * "id:repo-id::/path/to/worktree" + */ +import nacl from 'tweetnacl' +import WebSocket from 'ws' + +const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768' +const token = process.argv[2] +const serverPublicKeyB64 = process.argv[3] +const worktreeSelector = process.argv[4] +const marker = `MOBILE_STREAM_${Date.now()}` + +type RpcResponse = { + id: string + ok: boolean + streaming?: true + result?: Record + error?: { code: string; message: string } + _meta?: { runtimeId: string } +} + +type PendingRequest = { + method: string + resolve: (response: RpcResponse) => void + reject: (error: Error) => void +} + +if (!token || !serverPublicKeyB64) { + console.error( + 'Usage: pnpm exec tsx scripts/test-subscribe.ts [worktreeSelector]' + ) + process.exit(1) +} + +let reqId = 0 +const pending = new Map() +let streamSawMarker = false +let readSawMarker = false +let activeHandle: string | null = null +let runtimeId = '' +let scrollbackCols: number | null = null +let scrollbackRows: number | null = null +let serializedLength = 0 +const clientKeys = nacl.box.keyPair() +const serverPublicKey = Buffer.from(serverPublicKeyB64, 'base64') +const sharedKey = nacl.box.before(new Uint8Array(serverPublicKey), clientKeys.secretKey) + +function nextId(): string { + reqId += 1 + return `test-${reqId}` +} + +function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64') +} + +function fromBase64(value: string): Uint8Array { + return new Uint8Array(Buffer.from(value, 'base64')) +} + +function encrypt(plaintext: string): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const message = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(message, nonce, sharedKey) + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + return toBase64(bundle) +} + +function decrypt(payload: string): string | null { + const bundle = fromBase64(payload) + const nonce = bundle.subarray(0, nacl.box.nonceLength) + const ciphertext = bundle.subarray(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + return plaintext ? new TextDecoder().decode(plaintext) : null +} + +function sendRaw(ws: WebSocket, payload: unknown): void { + ws.send(encrypt(JSON.stringify(payload))) +} + +function send(ws: WebSocket, method: string, params?: unknown): Promise { + const id = nextId() + sendRaw(ws, { id, deviceToken: token, method, params }) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id) + reject(new Error(`Timed out waiting for ${method}`)) + }, 10_000) + pending.set(id, { + method, + resolve: (response) => { + clearTimeout(timeout) + resolve(response) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + }) +} + +function formatResponse(response: RpcResponse): string { + return JSON.stringify(response.result ?? response.error ?? response).slice(0, 1000) +} + +async function chooseWorktree(ws: WebSocket): Promise { + if (worktreeSelector) return worktreeSelector + + const response = await send(ws, 'worktree.ps') + if (!response.ok) { + throw new Error(`worktree.ps failed: ${formatResponse(response)}`) + } + + const worktrees = (response.result?.worktrees ?? []) as Array<{ + worktreeId: string + liveTerminalCount: number + branch: string + path: string + }> + const selected = worktrees.find((w) => w.liveTerminalCount > 0) ?? worktrees[0] + if (!selected) { + throw new Error('No worktrees returned by worktree.ps') + } + + console.log(`worktree: ${selected.branch || '(no branch)'} ${selected.path}`) + return `id:${selected.worktreeId}` +} + +async function chooseTerminal(ws: WebSocket, worktree: string): Promise { + const list = await send(ws, 'terminal.list', { worktree }) + if (!list.ok) { + throw new Error(`terminal.list failed: ${formatResponse(list)}`) + } + + const terminals = (list.result?.terminals ?? []) as Array<{ + handle: string + title: string + preview: string + lastOutputAt: number | null + }> + + if (terminals.length > 0) { + const selected = terminals[0]! + console.log(`terminal: ${selected.title || selected.handle} ${selected.handle}`) + return selected.handle + } + + const created = await send(ws, 'terminal.create', { + worktree, + title: 'mobile-stream-repro' + }) + if (!created.ok) { + throw new Error(`terminal.create failed: ${formatResponse(created)}`) + } + + const terminal = created.result?.terminal as { handle?: string; title?: string } | undefined + if (!terminal?.handle) { + throw new Error(`terminal.create returned no handle: ${formatResponse(created)}`) + } + console.log(`terminal: ${terminal.title || terminal.handle} ${terminal.handle}`) + return terminal.handle +} + +async function run(ws: WebSocket): Promise { + console.log(`connected: ${WS_URL}`) + ws.send(JSON.stringify({ type: 'e2ee_hello', publicKeyB64: toBase64(clientKeys.publicKey) })) + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for e2ee_ready')), 5000) + ws.once('message', (data) => { + clearTimeout(timeout) + const msg = JSON.parse(data.toString()) as { type?: string } + if (msg.type !== 'e2ee_ready') { + reject(new Error(`Unexpected handshake response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + sendRaw(ws, { type: 'e2ee_auth', deviceToken: token }) + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timed out waiting for e2ee_authenticated')), + 5000 + ) + ws.once('message', (data) => { + clearTimeout(timeout) + const plaintext = decrypt(data.toString()) + const msg = plaintext ? (JSON.parse(plaintext) as { type?: string }) : null + if (msg?.type !== 'e2ee_authenticated') { + reject(new Error(`Unexpected auth response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + const worktree = await chooseWorktree(ws) + const handle = await chooseTerminal(ws, worktree) + activeHandle = handle + + void send(ws, 'terminal.subscribe', { terminal: handle }).catch((error) => { + console.error(`subscribe failed: ${error.message}`) + }) + + await new Promise((resolve) => setTimeout(resolve, 500)) + + const sendResponse = await send(ws, 'terminal.send', { + terminal: handle, + text: `echo ${marker}`, + enter: true + }) + if (!sendResponse.ok) { + throw new Error(`terminal.send failed: ${formatResponse(sendResponse)}`) + } + console.log(`sent marker: ${marker}`) + + const deadline = Date.now() + 5_000 + while (Date.now() < deadline && !streamSawMarker && !readSawMarker) { + await new Promise((resolve) => setTimeout(resolve, 500)) + const read = await send(ws, 'terminal.read', { terminal: handle }) + if (!read.ok) { + throw new Error(`terminal.read failed: ${formatResponse(read)}`) + } + const terminal = read.result?.terminal as { tail?: string[]; preview?: string } | undefined + const text = [...(terminal?.tail ?? []), terminal?.preview ?? ''].join('\n') + if (text.includes(marker)) { + readSawMarker = true + } + } + + console.log(`runtime: ${runtimeId || '(unknown)'}`) + console.log(`scrollbackCols: ${scrollbackCols ?? '(none)'}`) + console.log(`scrollbackRows: ${scrollbackRows ?? '(none)'}`) + console.log(`serializedLength: ${serializedLength}`) + console.log(`streamSawMarker: ${streamSawMarker}`) + console.log(`readSawMarker: ${readSawMarker}`) + + if (!streamSawMarker && !readSawMarker) { + throw new Error('terminal output did not reach stream or terminal.read') + } +} + +const ws = new WebSocket(WS_URL) + +ws.on('open', () => { + run(ws) + .then(() => { + ws.close() + process.exit(0) + }) + .catch((error) => { + console.error(error.message) + ws.close() + process.exit(1) + }) +}) + +ws.on('message', (data) => { + const plaintext = decrypt(data.toString()) + if (!plaintext) return + const response = JSON.parse(plaintext) as RpcResponse + if (response._meta?.runtimeId) { + runtimeId = response._meta.runtimeId + } + + const result = response.result + if (response.streaming && result?.type === 'data') { + const chunk = typeof result.chunk === 'string' ? result.chunk : '' + if (chunk.includes(marker)) { + streamSawMarker = true + } + } + + if (response.streaming && result?.type === 'scrollback') { + const lines = Array.isArray(result.lines) ? result.lines.join('\n') : String(result.lines ?? '') + scrollbackCols = typeof result.cols === 'number' ? result.cols : null + scrollbackRows = typeof result.rows === 'number' ? result.rows : null + serializedLength = typeof result.serialized === 'string' ? result.serialized.length : 0 + if (lines.includes(marker)) { + streamSawMarker = true + } + if (typeof result.serialized === 'string' && result.serialized.includes(marker)) { + streamSawMarker = true + } + } + + const pendingRequest = pending.get(response.id) + if (!pendingRequest) { + return + } + + if (response.streaming && pendingRequest.method === 'terminal.subscribe') { + return + } + + pending.delete(response.id) + pendingRequest.resolve(response) +}) + +ws.on('close', () => { + for (const request of pending.values()) { + request.reject(new Error('WebSocket closed')) + } + pending.clear() +}) + +ws.on('error', (error) => { + console.error(`WebSocket error: ${error.message}`) + if (activeHandle) { + console.error(`active terminal: ${activeHandle}`) + } + process.exit(1) +}) diff --git a/mobile/src/cache/home-snapshot-cache.ts b/mobile/src/cache/home-snapshot-cache.ts new file mode 100644 index 00000000000..57256be8ea0 --- /dev/null +++ b/mobile/src/cache/home-snapshot-cache.ts @@ -0,0 +1,66 @@ +// Why: persist the data needed to render the home page so cold-start / +// resume-from-background paints instantly with the last known good +// values, then updates in place when fresh RPC data arrives. Without +// this, Resume and Account-usage cards flash empty for ~1s while the +// WebSocket reconnects and the first responses come back. +import AsyncStorage from '@react-native-async-storage/async-storage' +import type { AccountsSnapshot } from '../components/AccountUsage' + +const STORAGE_KEY = 'orca:home-snapshot:v1' + +type WorktreeSummary = { + worktreeId: string + repo: string + branch: string + displayName: string + liveTerminalCount: number + status?: 'working' | 'active' | 'permission' | 'done' | 'inactive' +} + +type HostWorktreeInfo = { + hostId: string + totalWorktrees: number + activeCount: number + lastActiveWorktree: WorktreeSummary | null +} + +export type HomeSnapshot = { + worktreeInfo: Record + accountsByHost: Record + savedAt: number +} + +let memoryCache: HomeSnapshot | null = null +let writeTimer: ReturnType | null = null + +export async function loadHomeSnapshot(): Promise { + if (memoryCache) return memoryCache + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as HomeSnapshot + if ( + typeof parsed !== 'object' || + parsed === null || + typeof parsed.worktreeInfo !== 'object' || + typeof parsed.accountsByHost !== 'object' + ) { + return null + } + memoryCache = parsed + return parsed + } catch { + return null + } +} + +// Why: throttle writes so a flurry of streamed account-snapshot updates +// (one per provider fetch finishing) doesn't hammer AsyncStorage. +export function saveHomeSnapshot(snapshot: HomeSnapshot): void { + memoryCache = snapshot + if (writeTimer) clearTimeout(writeTimer) + writeTimer = setTimeout(() => { + writeTimer = null + void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)).catch(() => {}) + }, 250) +} diff --git a/mobile/src/cache/worktree-cache.ts b/mobile/src/cache/worktree-cache.ts new file mode 100644 index 00000000000..9654dd357ad --- /dev/null +++ b/mobile/src/cache/worktree-cache.ts @@ -0,0 +1,35 @@ +// Why: module-level cache lets the home screen pre-populate worktree data +// so the host detail page can render instantly on navigation instead of +// waiting for a fresh RPC connection + fetch cycle. + +type CachedWorktrees = { + worktrees: unknown[] + at: number +} + +const cache = new Map() + +const MAX_AGE_MS = 30_000 +const MAX_ENTRIES = 20 + +export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void { + // Why: Map.set on an existing key does not move it to the end of iteration + // order. Delete first so the re-inserted key becomes the newest entry, + // giving us true LRU eviction when the cap is hit. + cache.delete(hostId) + cache.set(hostId, { worktrees, at: Date.now() }) + if (cache.size > MAX_ENTRIES) { + const oldest = cache.keys().next().value + if (oldest) cache.delete(oldest) + } +} + +export function getCachedWorktrees(hostId: string): unknown[] | null { + const entry = cache.get(hostId) + if (!entry) return null + if (Date.now() - entry.at > MAX_AGE_MS) { + cache.delete(hostId) + return null + } + return entry.worktrees +} diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx new file mode 100644 index 00000000000..0ff706638b9 --- /dev/null +++ b/mobile/src/components/AccountUsage.tsx @@ -0,0 +1,155 @@ +import { View, Text, StyleSheet, ActivityIndicator } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +// Why: keep these shapes in lockstep with src/shared/types.ts and +// src/shared/rate-limit-types.ts. We don't import from desktop here because +// the mobile bundle must not pull in Electron-coupled type files. +export type RateLimitWindow = { + usedPercent: number + windowMinutes: number + resetsAt: number | null + resetDescription: string | null +} + +export type ProviderRateLimits = { + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' + session: RateLimitWindow | null + weekly: RateLimitWindow | null + monthly?: RateLimitWindow | null + updatedAt: number + error: string | null + status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable' +} + +export type InactiveAccountUsage = { + accountId: string + claude: ProviderRateLimits | null + updatedAt: number + isFetching: boolean +} + +export type ClaudeAccountSummary = { + id: string + email: string + organizationName?: string | null +} + +export type CodexAccountSummary = { + id: string + email: string + workspaceLabel?: string | null +} + +export type AccountsSnapshot = { + claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null } + codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null } + rateLimits: { + claude: ProviderRateLimits | null + codex: ProviderRateLimits | null + inactiveClaudeAccounts: InactiveAccountUsage[] + inactiveCodexAccounts: InactiveAccountUsage[] + } +} + +export type ProviderKey = 'claude' | 'codex' + +export function getActiveProviderRateLimits( + snapshot: AccountsSnapshot, + provider: ProviderKey +): ProviderRateLimits | null { + return provider === 'claude' ? snapshot.rateLimits.claude : snapshot.rateLimits.codex +} + +export function getInactiveProviderUsage( + snapshot: AccountsSnapshot, + provider: ProviderKey, + accountId: string +): InactiveAccountUsage | null { + const list = + provider === 'claude' + ? snapshot.rateLimits.inactiveClaudeAccounts + : snapshot.rateLimits.inactiveCodexAccounts + return list.find((u) => u.accountId === accountId) ?? null +} + +// Why: matches desktop StatusBar convention — bars show percent remaining +// (so a fresh account renders full, a depleted one renders empty), not +// percent used. Color thresholds invert accordingly. +export function UsageBar({ + label, + usedPercent, + unavailable, + loading +}: { + label: string + usedPercent: number | null + unavailable: boolean + loading?: boolean +}) { + const remaining = usedPercent == null ? null : Math.max(0, Math.min(100, 100 - usedPercent)) + const barColor = + remaining == null + ? colors.textMuted + : remaining <= 10 + ? colors.statusRed + : remaining <= 30 + ? colors.statusAmber + : colors.statusGreen + return ( + + {label} + + + + {loading ? ( + + ) : ( + + {unavailable || remaining == null ? '—' : `${Math.round(remaining)}%`} + + )} + + ) +} + +const styles = StyleSheet.create({ + usageBar: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + flex: 1 + }, + usageLabel: { + fontSize: typography.metaSize, + color: colors.textMuted, + width: 22 + }, + usageTrack: { + flex: 1, + height: 6, + borderRadius: 3, + backgroundColor: colors.bgRaised, + overflow: 'hidden' + }, + usageFill: { + height: '100%', + borderRadius: 3 + }, + usageValue: { + fontSize: typography.metaSize, + color: colors.textSecondary, + width: 36, + textAlign: 'right' + }, + usageSpinner: { + width: 36 + } +}) diff --git a/mobile/src/components/ActionSheetModal.tsx b/mobile/src/components/ActionSheetModal.tsx new file mode 100644 index 00000000000..a68b08ea637 --- /dev/null +++ b/mobile/src/components/ActionSheetModal.tsx @@ -0,0 +1,133 @@ +import { View, Text, Pressable, StyleSheet } from 'react-native' +import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +export type ActionSheetAction = { + label: string + icon?: LucideIcon + destructive?: boolean + skipAutoClose?: boolean + onPress: () => void +} + +type Props = { + visible: boolean + title?: string + message?: string + actions: ActionSheetAction[] + onClose: () => void +} + +function iconForAction(label: string, destructive?: boolean, icon?: LucideIcon): LucideIcon { + if (icon) return icon + if (destructive || /delete|remove/i.test(label)) return Trash2 + return Edit3 +} + +type ContentProps = { + title?: string + message?: string + actions: ActionSheetAction[] + onClose?: () => void +} + +export function ActionSheetContent({ title, message, actions, onClose }: ContentProps) { + return ( + <> + {(title || message) && ( + + {title ? ( + + {title} + + ) : null} + {message ? {message} : null} + + )} + + + {actions.map((action, i) => { + const Icon = iconForAction(action.label, action.destructive, action.icon) + return ( + + {i > 0 && } + [styles.action, pressed && styles.actionPressed]} + onPress={() => { + action.onPress() + if (!action.skipAutoClose && onClose) { + onClose() + } + }} + > + + + {action.label} + + + + ) + })} + + + ) +} + +export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) { + return ( + + + + ) +} + +const styles = StyleSheet.create({ + header: { + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + title: { + fontSize: 13, + fontWeight: '500', + color: colors.textMuted + }, + message: { + fontSize: 12, + color: colors.textMuted, + marginTop: 2 + }, + actionGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + action: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + actionPressed: { + backgroundColor: colors.bgRaised + }, + actionText: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + actionTextDestructive: { + color: colors.statusRed + } +}) diff --git a/mobile/src/components/AgentIcons.tsx b/mobile/src/components/AgentIcons.tsx new file mode 100644 index 00000000000..714fe76e0f5 --- /dev/null +++ b/mobile/src/components/AgentIcons.tsx @@ -0,0 +1,30 @@ +import Svg, { Path } from 'react-native-svg' +import { colors } from '../theme/mobile-theme' + +// Why: SVG paths sourced from the desktop codebase +// (src/renderer/src/components/status-bar/icons.tsx) so mobile and desktop +// stay visually identical for Claude/Codex branding. + +export function ClaudeIcon({ size = 16 }: { size?: number }) { + return ( + + + + ) +} + +export function OpenAIIcon({ size = 16, color }: { size?: number; color?: string }) { + return ( + + + + ) +} diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx new file mode 100644 index 00000000000..673bbff049e --- /dev/null +++ b/mobile/src/components/AgentSpinner.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef } from 'react' +import { Animated, Easing, StyleSheet, View } from 'react-native' + +type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive' + +// Why: colors and sizing are 1:1 with the desktop StatusIndicator +// (src/renderer/src/components/sidebar/StatusIndicator.tsx) so the mobile +// worktree list reads identically to the sidebar — same yellow spinner for +// 'working', same emerald dot for 'active'/'done', same neutral-500 @ 40% +// for 'inactive', same red for 'permission'. Diverging palettes here lose +// the design intent ('moving' vs 'alive' vs 'completed') the desktop encodes. +const STATUS_COLORS: Record = { + working: '#eab308', + active: '#10b981', + done: '#10b981', + permission: '#ef4444', + inactive: 'rgba(115,115,115,0.4)' +} + +export function AgentSpinner({ status }: { status: WorktreeStatus }) { + const spinValue = useRef(new Animated.Value(0)).current + + useEffect(() => { + if (status === 'working') { + const animation = Animated.loop( + Animated.timing(spinValue, { + toValue: 1, + duration: 1000, + easing: Easing.linear, + useNativeDriver: true + }) + ) + animation.start() + return () => animation.stop() + } + spinValue.setValue(0) + }, [status, spinValue]) + + const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive + + if (status === 'working') { + const rotate = spinValue.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '360deg'] + }) + return ( + + + + ) + } + + return ( + + + + ) +} + +const styles = StyleSheet.create({ + // Why: 12x12 wrapper centered around an 8x8 inner glyph mirrors the + // desktop's `inline-flex h-3 w-3 ... items-center justify-center` shell + // around `size-2` indicator — keeps row height/baseline alignment stable + // across status transitions. + wrapper: { + width: 12, + height: 12, + alignItems: 'center', + justifyContent: 'center' + }, + dot: { + width: 8, + height: 8, + borderRadius: 4 + }, + spinner: { + width: 8, + height: 8, + borderRadius: 4, + borderWidth: 2, + borderTopColor: 'transparent' + } +}) diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx new file mode 100644 index 00000000000..2698353fb92 --- /dev/null +++ b/mobile/src/components/BottomDrawer.tsx @@ -0,0 +1,252 @@ +import { type ReactNode, useCallback, useEffect, useState } from 'react' +import { + View, + Pressable, + StyleSheet, + Platform, + useWindowDimensions, + ScrollView, + Keyboard, + BackHandler +} from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' +import Animated, { + useSharedValue, + useAnimatedStyle, + withSpring, + withTiming, + runOnJS, + interpolate, + Extrapolation +} from 'react-native-reanimated' +import { colors, spacing } from '../theme/mobile-theme' + +const DISMISS_THRESHOLD = 80 +const SPRING_CONFIG = { damping: 28, stiffness: 400 } +// Why: negative translateY (pulling up) is damped with a rubber-band factor +// so the drawer resists upward dragging — a subtle polish touch that signals +// the drawer cannot expand further. +const RUBBER_BAND_FACTOR = 0.25 +const SHOW_DURATION = 180 +const HIDE_DURATION = 150 + +type Props = { + visible: boolean + onClose: () => void + children: ReactNode +} + +export function BottomDrawer({ visible, onClose, children }: Props) { + const [mounted, setMounted] = useState(visible) + const translateY = useSharedValue(0) + const progress = useSharedValue(0) + const keyboardOffset = useSharedValue(0) + const { height: screenHeight } = useWindowDimensions() + const insets = useSafeAreaInsets() + + useEffect(() => { + if (visible) { + setMounted(true) + } + }, [visible]) + + useEffect(() => { + if (!mounted) return + + if (visible) { + translateY.value = 0 + progress.value = withTiming(1, { duration: SHOW_DURATION }) + } else { + Keyboard.dismiss() + progress.value = withTiming(0, { duration: HIDE_DURATION }, (finished) => { + if (finished) { + runOnJS(setMounted)(false) + } + }) + } + }, [mounted, visible]) + + // Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable + // inside Modal (iOS ignores KAV; Android needs adjustNothing for + // useAnimatedKeyboard). Keyboard event listeners work on both platforms + // and give us the exact height to shift the drawer by. + useEffect(() => { + if (!visible) return + + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow' + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide' + + const onShow = Keyboard.addListener(showEvent, (e) => { + const height = e.endCoordinates.height - insets.bottom + keyboardOffset.value = withTiming(Math.max(height, 0), { duration: e.duration || 250 }) + }) + const onHide = Keyboard.addListener(hideEvent, (e) => { + keyboardOffset.value = withTiming(0, { duration: e.duration || 250 }) + }) + + return () => { + onShow.remove() + onHide.remove() + keyboardOffset.value = 0 + } + }, [visible, insets.bottom]) + + useEffect(() => { + if (!visible) return + + const sub = BackHandler.addEventListener('hardwareBackPress', () => { + onClose() + return true + }) + return () => sub.remove() + }, [visible, onClose]) + + const dismiss = useCallback(() => { + onClose() + }, [onClose]) + + const panGesture = Gesture.Pan() + .onUpdate((e) => { + if (e.translationY > 0) { + translateY.value = e.translationY + } else { + translateY.value = e.translationY * RUBBER_BAND_FACTOR + } + }) + .onEnd((e) => { + if (e.translationY > DISMISS_THRESHOLD || e.velocityY > 500) { + const velocity = Math.max(e.velocityY, 800) + const remaining = screenHeight - e.translationY + const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300) + translateY.value = withTiming(screenHeight, { duration }) + progress.value = withTiming(0, { duration }, () => { + runOnJS(dismiss)() + }) + } else { + translateY.value = withSpring(0, SPRING_CONFIG) + } + }) + + const drawerStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateY: + interpolate(progress.value, [0, 1], [screenHeight, 0], Extrapolation.CLAMP) + + translateY.value - + keyboardOffset.value + } + ] + })) + + const backdropStyle = useAnimatedStyle(() => { + const dragFade = interpolate(translateY.value, [0, 300], [1, 0], Extrapolation.CLAMP) + return { opacity: progress.value * dragFade } + }) + + const pointerStyle = useAnimatedStyle( + () => + ({ + pointerEvents: progress.value > 0 ? 'auto' : 'none' + }) as { pointerEvents: 'auto' | 'none' } + ) + + // Why: hidden drawers can contain auto-focused inputs; keeping them mounted + // lets Android open the keyboard even when the drawer is offscreen. + if (!mounted) return null + + return ( + + + + + + + + + + + + + + + {children} + + + + + + + ) +} + +const styles = StyleSheet.create({ + overlay: { + ...StyleSheet.absoluteFillObject, + zIndex: 1000 + }, + root: { + flex: 1 + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.5)' + }, + anchor: { + flex: 1, + justifyContent: 'flex-end' + }, + drawer: { + backgroundColor: colors.bgBase, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: spacing.md, + ...Platform.select({ + ios: { + shadowColor: '#000', + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.2, + shadowRadius: 10 + }, + android: { elevation: 8 } + }) + }, + handle: { + alignSelf: 'center', + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: colors.textMuted, + opacity: 0.4 + }, + handleHitArea: { + alignItems: 'center', + paddingTop: spacing.sm, + paddingBottom: spacing.md + }, + bottomExtension: { + position: 'absolute', + bottom: -500, + left: 0, + right: 0, + height: 500, + backgroundColor: colors.bgBase + } +}) diff --git a/mobile/src/components/ConfirmModal.tsx b/mobile/src/components/ConfirmModal.tsx new file mode 100644 index 00000000000..a393593b5cf --- /dev/null +++ b/mobile/src/components/ConfirmModal.tsx @@ -0,0 +1,111 @@ +import { View, Text, Pressable, StyleSheet } from 'react-native' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +type Props = { + visible: boolean + title: string + message?: string + confirmLabel?: string + cancelLabel?: string + destructive?: boolean + onConfirm: () => void + onCancel: () => void +} + +export function ConfirmModal({ + visible, + title, + message, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + destructive = false, + onConfirm, + onCancel +}: Props) { + return ( + + + {title} + {message ? {message} : null} + + + [styles.button, styles.cancelButton, pressed && styles.pressed]} + onPress={onCancel} + > + {cancelLabel} + + [ + styles.button, + destructive ? styles.destructiveButton : styles.confirmButton, + pressed && styles.pressed + ]} + onPress={() => { + onConfirm() + onCancel() + }} + > + + {confirmLabel} + + + + + ) +} + +const styles = StyleSheet.create({ + content: { + paddingBottom: spacing.lg + }, + title: { + fontSize: 16, + fontWeight: '700', + color: colors.textPrimary + }, + message: { + fontSize: typography.bodySize, + color: colors.textSecondary, + marginTop: spacing.xs, + lineHeight: 20 + }, + buttons: { + flexDirection: 'row', + gap: spacing.sm + }, + button: { + flex: 1, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button, + alignItems: 'center' + }, + cancelButton: { + backgroundColor: colors.bgPanel + }, + confirmButton: { + backgroundColor: colors.textPrimary + }, + destructiveButton: { + backgroundColor: colors.statusRed + }, + pressed: { + opacity: 0.7 + }, + cancelText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.textSecondary + }, + confirmText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.bgBase + }, + destructiveText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: '#fff' + } +}) diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx new file mode 100644 index 00000000000..ba264548dde --- /dev/null +++ b/mobile/src/components/CustomKeyModal.tsx @@ -0,0 +1,338 @@ +import { useState, useEffect, useCallback } from 'react' +import { View, Text, Pressable, TextInput, StyleSheet, ScrollView, Switch } from 'react-native' +import { ChevronLeft } from 'lucide-react-native' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +const STORAGE_KEY = 'orca:custom-accessory-keys' + +export type CustomKey = { + id: string + label: string + bytes: string + enter: boolean +} + +type Step = 'choose-type' | 'pick-ctrl' | 'pick-alt' | 'text-macro' + +const ALPHA_KEYS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') + +function ctrlBytes(letter: string): string { + return String.fromCharCode(letter.toUpperCase().charCodeAt(0) - 64) +} + +function altBytes(letter: string): string { + return `\x1b${letter.toLowerCase()}` +} + +type Props = { + visible: boolean + onClose: () => void + onKeysChanged: (keys: CustomKey[]) => void +} + +export async function loadCustomKeys(): Promise { + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as CustomKey[]) : [] + } catch { + return [] + } +} + +async function saveCustomKeys(keys: CustomKey[]): Promise { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keys)) +} + +export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { + const [step, setStep] = useState('choose-type') + const [macroLabel, setMacroLabel] = useState('') + const [macroText, setMacroText] = useState('') + const [macroEnter, setMacroEnter] = useState(true) + + useEffect(() => { + if (visible) { + setStep('choose-type') + setMacroLabel('') + setMacroText('') + setMacroEnter(true) + } + }, [visible]) + + const addKey = useCallback( + async (key: Omit) => { + const existing = await loadCustomKeys() + const newKey: CustomKey = { ...key, id: `custom-${Date.now()}` } + const updated = [...existing, newKey] + await saveCustomKeys(updated) + onKeysChanged(updated) + onClose() + }, + [onClose, onKeysChanged] + ) + + const handleCtrlKey = useCallback( + (letter: string) => { + void addKey({ label: `Ctrl+${letter}`, bytes: ctrlBytes(letter), enter: false }) + }, + [addKey] + ) + + const handleAltKey = useCallback( + (letter: string) => { + void addKey({ label: `Alt+${letter}`, bytes: altBytes(letter), enter: false }) + }, + [addKey] + ) + + const handleMacroSave = useCallback(() => { + const label = macroLabel.trim() || macroText.trim().slice(0, 12) + const text = macroText + if (!label || !text) return + const bytes = macroEnter ? `${text}\r` : text + void addKey({ label, bytes, enter: false }) + }, [addKey, macroLabel, macroText, macroEnter]) + + const showBack = step !== 'choose-type' + + return ( + + + {showBack ? ( + [styles.backButton, pressed && styles.backButtonPressed]} + onPress={() => setStep('choose-type')} + accessibilityLabel="Back" + > + + + ) : ( + + )} + + {step === 'choose-type' && 'Add Shortcut'} + {step === 'pick-ctrl' && 'Ctrl + Key'} + {step === 'pick-alt' && 'Alt + Key'} + {step === 'text-macro' && 'Text Macro'} + + + + + {step === 'choose-type' && ( + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setStep('pick-ctrl')} + > + Ctrl + Key + Control character shortcuts + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setStep('pick-alt')} + > + Alt + Key + Alt/Option key combos + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setStep('text-macro')} + > + Text Macro + Send custom text command + + + )} + + {(step === 'pick-ctrl' || step === 'pick-alt') && ( + + + {ALPHA_KEYS.map((letter) => ( + [styles.keyCell, pressed && styles.keyCellPressed]} + onPress={() => + step === 'pick-ctrl' ? handleCtrlKey(letter) : handleAltKey(letter) + } + > + {letter} + + ))} + + + )} + + {step === 'text-macro' && ( + + + Label + + Command + + + Press Enter + + + + Add Shortcut + + + + )} + + ) +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + paddingBottom: spacing.sm + }, + backButton: { + width: 30, + height: 30, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center' + }, + backButtonPressed: { + backgroundColor: colors.bgRaised + }, + backSpacer: { + width: 30 + }, + title: { + flex: 1, + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary, + textAlign: 'center' + }, + group: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + row: { + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary, + marginBottom: 1 + }, + rowHint: { + fontSize: 12, + color: colors.textMuted + }, + keyGridScroll: { + maxHeight: 240 + }, + keyGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + justifyContent: 'center', + padding: spacing.md + }, + keyCell: { + width: 42, + height: 38, + borderRadius: radii.button, + backgroundColor: colors.bgBase, + alignItems: 'center', + justifyContent: 'center' + }, + keyCellPressed: { + backgroundColor: colors.bgRaised + }, + keyCellText: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary, + fontFamily: typography.monoFamily + }, + macroForm: { + padding: spacing.md, + gap: spacing.sm + }, + fieldLabel: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary + }, + fieldInput: { + backgroundColor: colors.bgBase, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: 14, + fontFamily: typography.monoFamily, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.xs + }, + switchLabel: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + saveButton: { + backgroundColor: colors.textPrimary, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button, + alignItems: 'center' + }, + saveButtonDisabled: { + opacity: 0.5 + }, + saveButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx new file mode 100644 index 00000000000..a7e7d2579b3 --- /dev/null +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -0,0 +1,784 @@ +import { useState, useEffect } from 'react' +import { + View, + Text, + TextInput, + Pressable, + Switch, + StyleSheet, + Platform, + ActivityIndicator, + Image +} from 'react-native' +import { ChevronDown, ChevronUp, Check, Terminal } from 'lucide-react-native' +import Svg, { Path, G } from 'react-native-svg' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' +import { ClaudeIcon, OpenAIIcon } from './AgentIcons' +import { getSuggestedCreatureName } from './worktree-name-suggestion' + +type Repo = { + id: string + displayName: string + path: string +} + +type AgentOption = { + id: string + label: string + faviconDomain?: string +} + +// Why: matches the AGENT_CATALOG ordering and faviconDomain values from +// src/renderer/src/lib/agent-catalog.tsx so mobile uses the same icon sources. +const AGENT_OPTIONS: AgentOption[] = [ + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'copilot', label: 'GitHub Copilot', faviconDomain: 'github.com' }, + { id: 'opencode', label: 'OpenCode', faviconDomain: 'opencode.ai' }, + { id: 'pi', label: 'Pi' }, + { id: 'gemini', label: 'Gemini', faviconDomain: 'gemini.google.com' }, + { id: 'aider', label: 'Aider' }, + { id: 'goose', label: 'Goose', faviconDomain: 'goose-docs.ai' }, + { id: 'amp', label: 'Amp', faviconDomain: 'ampcode.com' }, + { id: 'kilo', label: 'Kilocode', faviconDomain: 'kilo.ai' }, + { id: 'kiro', label: 'Kiro', faviconDomain: 'kiro.dev' }, + { id: 'crush', label: 'Charm', faviconDomain: 'charm.sh' }, + { id: 'aug', label: 'Auggie', faviconDomain: 'augmentcode.com' }, + { id: 'cline', label: 'Cline', faviconDomain: 'cline.bot' }, + { id: 'codebuff', label: 'Codebuff', faviconDomain: 'codebuff.com' }, + { id: 'continue', label: 'Continue', faviconDomain: 'continue.dev' }, + { id: 'cursor', label: 'Cursor', faviconDomain: 'cursor.com' }, + { id: 'droid', label: 'Droid', faviconDomain: 'factory.ai' }, + { id: 'kimi', label: 'Kimi', faviconDomain: 'moonshot.cn' }, + { id: 'mistral-vibe', label: 'Mistral Vibe', faviconDomain: 'mistral.ai' }, + { id: 'qwen-code', label: 'Qwen Code', faviconDomain: 'qwenlm.github.io' }, + { id: 'rovo', label: 'Rovo Dev', faviconDomain: 'atlassian.com' }, + { id: 'hermes', label: 'Hermes', faviconDomain: 'nousresearch.com' } +] + +const BLANK_TERMINAL: AgentOption = { id: '__blank__', label: 'Blank Terminal' } +const ALL_AGENTS = [...AGENT_OPTIONS, BLANK_TERMINAL] + +// Why: mirrors launchCmd from src/shared/tui-agent-config.ts so terminal.create +// gets the correct binary name for each agent. +const AGENT_COMMANDS: Record = { + claude: 'claude', + codex: 'codex', + copilot: 'copilot', + opencode: 'opencode', + pi: 'pi', + gemini: 'gemini', + aider: 'aider', + goose: 'goose', + amp: 'amp', + kilo: 'kilo', + kiro: 'kiro', + crush: 'crush', + aug: 'auggie', + cline: 'cline', + codebuff: 'codebuff', + continue: 'continue', + cursor: 'cursor-agent', + droid: 'droid', + kimi: 'kimi', + 'mistral-vibe': 'mistral-vibe', + 'qwen-code': 'qwen-code', + rovo: 'rovo', + hermes: 'hermes' +} + +// ── Agent icons ───────────────────────────────────────────────────── +// SVG paths sourced from the desktop codebase: +// Claude & OpenAI: shared in ./AgentIcons.tsx +// Pi & Aider: src/renderer/src/lib/agent-catalog.tsx +// Agents with a faviconDomain use Google's favicon service (same as desktop). + +function PiIcon({ size = 16 }: { size?: number }) { + return ( + + + + + ) +} + +function AiderIcon({ size = 16 }: { size?: number }) { + return ( + + + + + + + ) +} + +function FaviconIcon({ domain, size = 16 }: { domain: string; size?: number }) { + return ( + + ) +} + +function AgentLetterIcon({ letter, size = 16 }: { letter: string; size?: number }) { + return ( + + + {letter} + + + ) +} + +function AgentIcon({ agentId, size = 16 }: { agentId: string; size?: number }) { + if (agentId === 'claude') return + if (agentId === 'codex') return + if (agentId === 'pi') return + if (agentId === 'aider') return + if (agentId === '__blank__') return + + const agent = AGENT_OPTIONS.find((a) => a.id === agentId) + if (agent?.faviconDomain) { + return + } + const label = agent?.label ?? agentId + return +} + +// ── Picker sub-modal ──────────────────────────────────────────────── +// Why: inline dropdowns with position:absolute + ScrollView have persistent +// touch-conflict issues in React Native. A separate modal for the picker +// list is the standard mobile pattern — it scrolls reliably and feels native. + +function PickerListModal({ + visible, + title, + items, + selectedId, + onSelect, + onClose, + renderIcon +}: { + visible: boolean + title: string + items: T[] + selectedId: string + onSelect: (item: T) => void + onClose: () => void + renderIcon?: (item: T) => React.ReactNode +}) { + return ( + + + {title} + + + {items.map((item, index) => { + const selected = item.id === selectedId + return ( + + {index > 0 && } + [styles.pickerItem, pressed && styles.pickerItemPressed]} + onPress={() => { + onSelect(item) + onClose() + }} + > + {renderIcon?.(item)} + + {item.label} + + {selected && } + + + ) + })} + + + ) +} + +// ── Main modal ────────────────────────────────────────────────────── + +type Props = { + visible: boolean + client: RpcClient | null + // Why: existing worktree paths from the host so we can pick a unique + // marine-creature default when the user leaves the name blank, matching + // the desktop UI's behavior. The "already exists locally" collision is + // on the on-disk directory basename, so paths (not displayNames) are + // what the suggestion logic must dedupe against. + existingWorktreePaths?: readonly string[] + onCreated: (worktreeId: string, name: string) => void + onClose: () => void +} + +export function NewWorktreeModal({ + visible, + client, + existingWorktreePaths, + onCreated, + onClose +}: Props) { + const [repos, setRepos] = useState([]) + const [selectedRepo, setSelectedRepo] = useState(null) + const [showRepoPicker, setShowRepoPicker] = useState(false) + const [selectedAgent, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) + const [showAgentPicker, setShowAgentPicker] = useState(false) + const [name, setName] = useState('') + const [note, setNote] = useState('') + const [showAdvanced, setShowAdvanced] = useState(false) + const [setupCommand, setSetupCommand] = useState(null) + const [setupSource, setSetupSource] = useState(null) + const [runSetup, setRunSetup] = useState(true) + const [creating, setCreating] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + + // Why: matches the desktop UI — the input shows a generic "Workspace name" + // placeholder, not the suggested creature. The creature name is only used + // as a server-bound fallback when the user submits with a blank field, so + // it's recomputed lazily inside handleCreate() to stay fresh against + // existingWorktreePaths at submission time. + + useEffect(() => { + if (!visible) { + setShowRepoPicker(false) + setShowAgentPicker(false) + return + } + if (!client) return + let stale = false + setName('') + setNote('') + setShowAdvanced(false) + setSetupCommand(null) + setSetupSource(null) + setRunSetup(true) + setError('') + setCreating(false) + setShowRepoPicker(false) + setShowAgentPicker(false) + setSelectedAgent(AGENT_OPTIONS[0]!) + setLoading(true) + + void (async () => { + try { + const response = await client.sendRequest('repo.list') + if (stale) return + if (response.ok) { + const result = (response as RpcSuccess).result as { repos: Repo[] } + setRepos(result.repos) + if (result.repos.length === 1) { + setSelectedRepo(result.repos[0]!) + } else { + setSelectedRepo(null) + } + } + } catch { + if (!stale) setRepos([]) + } finally { + if (!stale) setLoading(false) + } + })() + return () => { + stale = true + } + }, [visible, client]) + + useEffect(() => { + if (!client || !selectedRepo) { + setSetupCommand(null) + setSetupSource(null) + return + } + let stale = false + void (async () => { + try { + const response = await client.sendRequest('repo.hooks', { + repo: `id:${selectedRepo.id}` + }) + if (stale) return + if (response.ok) { + const result = (response as RpcSuccess).result as { + hooks: { scripts: { setup?: string } } | null + source: string | null + setupRunPolicy: string + } + const cmd = result.hooks?.scripts.setup ?? null + setSetupCommand(cmd) + setSetupSource(result.source) + setRunSetup(result.setupRunPolicy !== 'skip-by-default') + } + } catch { + if (!stale) { + setSetupCommand(null) + setSetupSource(null) + } + } + })() + return () => { + stale = true + } + }, [client, selectedRepo]) + + async function handleCreate() { + if (!client || !selectedRepo) return + setCreating(true) + setError('') + + try { + const command = + selectedAgent.id !== '__blank__' ? AGENT_COMMANDS[selectedAgent.id] : undefined + + // Why: blank name field — match desktop behavior by computing the + // next available marine-creature name at submit time and passing it + // to the server. The server's worktree.create rejects empty/invalid + // names, so we must generate one client-side rather than letting the + // server invent one. The pre-flight basename dedupe is only a hint; + // the authoritative collision is checked server-side against git + // branches/remotes/PRs, so we also retry-with-suffix on conflict. + const trimmedName = name.trim() + const baseName = trimmedName || getSuggestedCreatureName(existingWorktreePaths ?? []) + + // Why: mirrors src/renderer/src/store/slices/worktrees.ts + // (createWorktree retry loop). Server-side checks (Branch X already + // exists locally / on a remote / already has PR #N) can fire even + // after the pre-flight basename dedupe — branches outlive worktrees + // in git, and remote branches/PRs aren't visible from worktree.ps. + // Retry up to 25 times by appending -2, -3, ... before surfacing + // the error. The desktop applies this to user-typed names too, so + // mobile follows suit for parity. + const retryablePatterns = [ + /already exists locally/i, + /already exists on a remote/i, + /already has pr #\d+/i + ] + const candidateFor = (attempt: number): string => + attempt === 0 ? baseName : `${baseName}-${attempt + 1}` + + let lastError: string | null = null + for (let attempt = 0; attempt < 25; attempt += 1) { + const candidateName = candidateFor(attempt) + const params: Record = { + repo: `id:${selectedRepo.id}`, + startupCommand: command, + setupDecision: runSetup ? 'inherit' : 'skip', + name: candidateName + } + if (note.trim()) params.comment = note.trim() + + const response = await client.sendRequest('worktree.create', params) + if (response.ok) { + const result = (response as RpcSuccess).result as { worktree: { id: string } } + onClose() + onCreated(result.worktree.id, candidateName) + return + } + + lastError = response.error.message + if (!retryablePatterns.some((p) => p.test(lastError ?? ''))) { + break + } + } + setError(lastError ?? 'Failed to create workspace') + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to create workspace') + } finally { + setCreating(false) + } + } + + const canCreate = selectedRepo != null && !creating + + return ( + <> + + + Create Workspace + + Pick a repository and agent to spin up a new workspace. + + + + {loading ? ( + + + + ) : repos.length === 0 ? ( + + No repositories found + + ) : ( + <> + + Repository + setShowRepoPicker(true)}> + + {selectedRepo?.displayName ?? 'Select a repository'} + + + + + + + + Workspace Name [Optional] + + { + setName(t) + setError('') + }} + placeholder="Workspace name" + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + autoFocus={repos.length <= 1} + returnKeyType="done" + onSubmitEditing={() => { + if (canCreate) void handleCreate() + }} + /> + + + + Agent + setShowAgentPicker(true)}> + + + {selectedAgent.label} + + + + + + setShowAdvanced(!showAdvanced)}> + Advanced + {showAdvanced ? ( + + ) : ( + + )} + + + {showAdvanced && ( + <> + + Note + + + + {setupCommand ? ( + + + Setup script + {setupSource && ( + + + {setupSource === 'orca.yaml' ? 'ORCA.YAML' : 'HOOKS'} + + + )} + + + + Run setup command + + + + {setupCommand} + + + + ) : null} + + )} + + {error ? {error} : null} + + + void handleCreate()} + > + {creating ? ( + + ) : ( + Create Workspace + )} + + + + )} + + + {/* Sub-modals for pickers — rendered outside the main modal so they + layer on top and scroll without touch conflicts. */} + ({ id: r.id, label: r.displayName, _repo: r }))} + selectedId={selectedRepo?.id ?? ''} + onSelect={(item) => setSelectedRepo((item as { _repo: Repo })._repo)} + onClose={() => setShowRepoPicker(false)} + /> + + setSelectedAgent(agent)} + onClose={() => setShowAgentPicker(false)} + renderIcon={(agent) => } + /> + + ) +} + +const styles = StyleSheet.create({ + header: { + paddingHorizontal: spacing.xs, + marginBottom: spacing.md + }, + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + subtitle: { + fontSize: 13, + color: colors.textMuted, + marginTop: 2 + }, + loadingContainer: { + paddingVertical: spacing.xl, + alignItems: 'center' + }, + emptyText: { + color: colors.textSecondary, + fontSize: typography.bodySize + }, + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + labelHint: { + fontWeight: '400', + color: colors.textMuted + }, + fieldButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + fieldButtonText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + }, + fieldButtonPlaceholder: { + color: colors.textMuted + }, + input: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + error: { + color: colors.statusRed, + fontSize: 13, + marginBottom: spacing.md + }, + advancedToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingVertical: spacing.sm, + marginBottom: spacing.xs + }, + advancedText: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textSecondary + }, + setupHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.xs + }, + sourceBadge: { + backgroundColor: colors.bgRaised, + borderRadius: 4, + paddingHorizontal: spacing.xs + 2, + paddingVertical: 2 + }, + sourceBadgeText: { + fontSize: 10, + fontWeight: '600', + color: colors.textMuted, + letterSpacing: 0.5 + }, + setupBox: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md + }, + setupToggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.sm + }, + setupToggleLabel: { + fontSize: 13, + color: colors.textSecondary + }, + setupSwitch: { + transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }] + }, + setupCommandBlock: { + backgroundColor: colors.bgBase, + borderRadius: 6, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.sm + }, + setupCommand: { + fontSize: 13, + fontFamily: typography.monoFamily, + color: colors.textPrimary + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: spacing.sm + }, + createButton: { + backgroundColor: colors.textPrimary, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radii.button, + minWidth: 160, + alignItems: 'center' + }, + createButtonDisabled: { + opacity: 0.4 + }, + createText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + }, + letterIcon: { + alignItems: 'center', + justifyContent: 'center' + }, + letterIconText: { + fontWeight: '700' + }, + + // Picker sub-modal styles + pickerHeader: { + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + pickerTitle: { + fontSize: 13, + fontWeight: '500', + color: colors.textMuted + }, + pickerGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + pickerSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + pickerList: { + flexGrow: 0 + }, + pickerItem: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + pickerItemPressed: { + backgroundColor: colors.bgRaised + }, + pickerItemText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + }, + pickerItemTextSelected: { + fontWeight: '600' + } +}) diff --git a/mobile/src/components/OrcaLogo.tsx b/mobile/src/components/OrcaLogo.tsx new file mode 100644 index 00000000000..d05e4f2a968 --- /dev/null +++ b/mobile/src/components/OrcaLogo.tsx @@ -0,0 +1,23 @@ +import Svg, { G, Path, Defs } from 'react-native-svg' +import { colors } from '../theme/mobile-theme' + +type Props = { + size?: number + color?: string +} + +export function OrcaLogo({ size = 24, color = colors.textPrimary }: Props) { + const aspectRatio = 318.6 / 202.67 + const width = size * aspectRatio + return ( + + + + + + + ) +} diff --git a/mobile/src/components/PickerModal.tsx b/mobile/src/components/PickerModal.tsx new file mode 100644 index 00000000000..eecc653e8da --- /dev/null +++ b/mobile/src/components/PickerModal.tsx @@ -0,0 +1,108 @@ +import { View, Text, Pressable, StyleSheet } from 'react-native' +import { Check } from 'lucide-react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +export type PickerOption = { + value: T + label: string + subtitle?: string +} + +type Props = { + visible: boolean + title: string + options: PickerOption[] + selected: T + onSelect: (value: T) => void + onClose: () => void +} + +export function PickerModal({ + visible, + title, + options, + selected, + onSelect, + onClose +}: Props) { + return ( + + + {title} + + + + {options.map((opt, i) => { + const isSelected = opt.value === selected + return ( + + {i > 0 && } + [styles.row, pressed && styles.rowPressed]} + onPress={() => { + onSelect(opt.value) + onClose() + }} + > + + + {opt.label} + + {opt.subtitle ? {opt.subtitle} : null} + + {isSelected && } + + + ) + })} + + + ) +} + +const styles = StyleSheet.create({ + header: { + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + title: { + fontSize: 13, + fontWeight: '500', + color: colors.textMuted + }, + group: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowContent: { + flex: 1 + }, + rowLabel: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + rowLabelSelected: { + fontWeight: '600' + }, + rowSubtitle: { + fontSize: 11, + color: colors.textMuted, + marginTop: 1 + } +}) diff --git a/mobile/src/components/ProtocolBlockScreen.tsx b/mobile/src/components/ProtocolBlockScreen.tsx new file mode 100644 index 00000000000..368976676f1 --- /dev/null +++ b/mobile/src/components/ProtocolBlockScreen.tsx @@ -0,0 +1,105 @@ +import { Linking, Pressable, StyleSheet, Text, View } from 'react-native' +import { router } from 'expo-router' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { CompatVerdict } from '../transport/protocol-compat' +import { MOBILE_PROTOCOL_VERSION } from '../transport/protocol-version' + +const RELEASES_URL = 'https://github.com/stablyai/orca/releases' + +type Props = { + verdict: Extract +} + +export function ProtocolBlockScreen({ verdict }: Props) { + const isMobileTooOld = verdict.reason === 'mobile-too-old' + + const title = isMobileTooOld ? 'Update Orca Mobile' : 'Update Orca desktop' + const body = isMobileTooOld + ? `The Orca desktop on this host requires Orca Mobile v${verdict.requiredMobileVersion ?? '?'}+. You have v${MOBILE_PROTOCOL_VERSION}.\n\nUpdate Orca Mobile from the App Store to continue.` + : `Orca Mobile requires Orca desktop v${verdict.requiredDesktopVersion ?? '?'}+ to use this host. The desktop is reporting v${verdict.desktopVersion}.` + + return ( + + + {title} + {body} + {/* Why: only desktop side has a deep-link target today — + App Store ID isn't published yet, so mobile-too-old is text-only. */} + {!isMobileTooOld ? ( + [styles.primaryButton, pressed && styles.pressed]} + onPress={() => { + void Linking.openURL(RELEASES_URL) + }} + > + Open GitHub Releases + + ) : null} + [styles.secondaryButton, pressed && styles.pressed]} + onPress={() => { + // Why: route back to the host list so the user can pair a + // different host instead of getting trapped on this screen. + router.replace('/') + }} + > + Pair a different host + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + justifyContent: 'center', + paddingHorizontal: spacing.lg + }, + card: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + padding: spacing.lg, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + title: { + fontSize: typography.titleSize, + fontWeight: '700', + color: colors.textPrimary, + marginBottom: spacing.sm + }, + body: { + fontSize: typography.bodySize, + color: colors.textSecondary, + lineHeight: 20, + marginBottom: spacing.lg + }, + primaryButton: { + backgroundColor: colors.textPrimary, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button, + alignItems: 'center', + marginBottom: spacing.sm + }, + primaryButtonText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.bgBase + }, + secondaryButton: { + backgroundColor: colors.bgRaised, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button, + alignItems: 'center' + }, + secondaryButtonText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.textPrimary + }, + pressed: { + opacity: 0.7 + } +}) diff --git a/mobile/src/components/StatusDot.tsx b/mobile/src/components/StatusDot.tsx new file mode 100644 index 00000000000..86f36f45033 --- /dev/null +++ b/mobile/src/components/StatusDot.tsx @@ -0,0 +1,25 @@ +import { View, StyleSheet } from 'react-native' +import { colors } from '../theme/mobile-theme' +import type { ConnectionState } from '../transport/types' + +const stateColors: Record = { + connected: colors.statusGreen, + connecting: colors.statusAmber, + handshaking: colors.statusAmber, + reconnecting: colors.statusAmber, + disconnected: colors.textMuted, + 'auth-failed': colors.statusRed +} + +export function StatusDot({ state }: { state: ConnectionState }) { + return +} + +const styles = StyleSheet.create({ + dot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 8 + } +}) diff --git a/mobile/src/components/TextInputModal.tsx b/mobile/src/components/TextInputModal.tsx new file mode 100644 index 00000000000..ae6f3ee3e7f --- /dev/null +++ b/mobile/src/components/TextInputModal.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect } from 'react' +import { View, Text, TextInput, Pressable, StyleSheet, Platform } from 'react-native' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +type Props = { + visible: boolean + title: string + message?: string + defaultValue?: string + placeholder?: string + onSubmit: (value: string) => void + onCancel: () => void +} + +export function TextInputModal({ + visible, + title, + message, + defaultValue = '', + placeholder, + onSubmit, + onCancel +}: Props) { + const [value, setValue] = useState(defaultValue) + + useEffect(() => { + if (visible) setValue(defaultValue) + }, [visible, defaultValue]) + + function handleSubmit() { + if (value.trim()) { + onSubmit(value.trim()) + } + } + + return ( + + + {title} + {message ? {message} : null} + + + + + + [styles.cancelButton, pressed && styles.buttonPressed]} + onPress={onCancel} + > + Cancel + + [ + styles.submitButton, + pressed && styles.buttonPressed, + !value.trim() && styles.submitButtonDisabled + ]} + disabled={!value.trim()} + onPress={handleSubmit} + > + Save + + + + ) +} + +const styles = StyleSheet.create({ + header: { + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + message: { + fontSize: 13, + color: colors.textMuted, + marginTop: 2 + }, + // Why: matches NewWorktreeModal's input — bgRaised on the modal + // background reads as a tappable surface (brighter than the wrapper) + // rather than a recessed pit (darker than the wrapper, which is what + // bgBase looked like inside a bgPanel group). + input: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + marginTop: spacing.md + }, + cancelButton: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + submitButton: { + backgroundColor: colors.textPrimary, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + buttonPressed: { + opacity: 0.7 + }, + submitButtonDisabled: { + opacity: 0.4 + }, + cancelText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' + }, + submitText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/src/components/worktree-name-suggestion.ts b/mobile/src/components/worktree-name-suggestion.ts new file mode 100644 index 00000000000..59ec500f603 --- /dev/null +++ b/mobile/src/components/worktree-name-suggestion.ts @@ -0,0 +1,42 @@ +import { MARINE_CREATURES } from '../constants/marine-creatures' + +// Why: matches the desktop fallback in +// src/renderer/src/components/sidebar/worktree-name-suggestions.ts. The +// "already exists locally" collision is on the on-disk worktree directory +// name (the path basename), not the user-facing displayName — so we derive +// the used set from path basenames just like the desktop does. + +function stripTrailingSeparators(p: string): string { + return p.replace(/[\\/]+$/, '') +} + +// Why: cross-platform path basename — handles both POSIX ("/") and Windows +// ("\\") separators, mirroring src/renderer/src/lib/path.ts so the mobile +// suggestion logic agrees with the desktop's collision check. +export function pathBasename(p: string): string { + const normalized = stripTrailingSeparators(p) + const idx = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\')) + return idx === -1 ? normalized : normalized.slice(idx + 1) +} + +function normalize(name: string): string { + return name.trim().toLowerCase() +} + +export function getSuggestedCreatureName(existingPaths: readonly string[]): string { + const used = new Set() + for (const p of existingPaths) { + used.add(normalize(pathBasename(p))) + } + for (const candidate of MARINE_CREATURES) { + if (!used.has(normalize(candidate))) return candidate + } + let suffix = 2 + while (true) { + for (const candidate of MARINE_CREATURES) { + const numbered = `${candidate}-${suffix}` + if (!used.has(normalize(numbered))) return numbered + } + suffix += 1 + } +} diff --git a/mobile/src/constants/marine-creatures.ts b/mobile/src/constants/marine-creatures.ts new file mode 100644 index 00000000000..c3f1df7f024 --- /dev/null +++ b/mobile/src/constants/marine-creatures.ts @@ -0,0 +1,262 @@ +export const MARINE_CREATURES = [ + 'Nautilus', + 'Seahorse', + 'Starfish', + 'Coral', + 'Narwhal', + 'Jellyfish', + 'Octopus', + 'Manta', + 'Dolphin', + 'Manatee', + 'Cuttlefish', + 'Anemone', + 'Urchin', + 'Triton', + 'Nudibranch', + 'Coelacanth', + 'Oarfish', + 'Beluga', + 'Dugong', + 'Porpoise', + 'Stingray', + 'Hammerhead', + 'Wobbegong', + 'Sawfish', + 'Chimaera', + 'Anglerfish', + 'Viperfish', + 'Dragonfish', + 'Axolotl', + 'Otter', + 'Seadragon', + 'Seahare', + 'Squid', + 'Argonaut', + 'Sponge', + 'Barnacle', + 'Cowrie', + 'Guitarfish', + 'Lamprey', + 'Isopod', + 'Lionfish', + 'Clownfish', + 'Angelfish', + 'Butterflyfish', + 'Parrotfish', + 'Pufferfish', + 'Moonfish', + 'Firefish', + 'Unicornfish', + 'Rainbowfish', + 'Betta', + 'Discus', + 'Arowana', + 'Koi', + 'Piranha', + 'Barracuda', + 'Moray', + 'Sunfish', + 'Lanternfish', + 'Archerfish', + 'Mudskipper', + 'Hatchetfish', + 'Knifefish', + 'Leaffish', + 'Glassfish', + 'Ropefish', + 'Bichir', + 'Tigerfish', + 'Cardinalfish', + 'Lungfish', + 'Opah', + 'Frogfish', + 'Stonefish', + 'Cutlassfish', + 'Paddlefish', + 'Arapaima', + 'Mandarin', + 'Blobfish', + 'Thresher', + 'Vaquita', + 'Pipefish', + 'Guppy', + 'Tetra', + 'Danio', + 'Cichlid', + 'Oscar', + 'Gourami', + 'Killifish', + 'Rasbora', + 'Pleco', + 'Goldfish', + 'Molly', + 'Platy', + 'Barb', + 'Loach', + 'Medaka', + 'Pacu', + 'Filefish', + 'Boxfish', + 'Cowfish', + 'Surgeonfish', + 'Damselfish', + 'Wrasse', + 'Goby', + 'Blenny', + 'Conger', + 'Sculpin', + 'Darter', + 'Remora', + 'Pilotfish', + 'Trumpetfish', + 'Cornetfish', + 'Jawfish', + 'Toadfish', + 'Pearlfish', + 'Driftfish', + 'Lumpfish', + 'Snailfish', + 'Stickleback', + 'Halfbeak', + 'Snipefish', + 'Pencilfish', + 'Snakehead', + 'Tripletail', + 'Lookdown', + 'Sweetlips', + 'Squirrelfish', + 'Soldierfish', + 'Rabbitfish', + 'Hawkfish', + 'Bannerfish', + 'Batfish', + 'Chromis', + 'Anthias', + 'Fusilier', + 'Sweeper', + 'Emperor', + 'Ponyfish', + 'Kelpfish', + 'Weever', + 'Pearlside', + 'Opaleye', + 'Ballyhoo', + 'Needlefish', + 'Triggerfish', + 'Gar', + 'Tarpon', + 'Sailfish', + 'Conch', + 'Walrus', + 'Seal', + 'Penguin', + 'Hagfish', + 'Gulper', + 'Hydra', + 'Krill', + 'Salp', + 'Tunicate', + 'Crinoid', + 'Polyp', + 'Limpet', + 'Whelk', + 'Bowfin', + 'Minnow', + 'Gudgeon', + 'Goldeye', + 'Mooneye', + 'Soapfish', + 'Leatherjacket', + 'Rudderfish', + 'Sabrefish', + 'Puffin', + 'Albatross', + 'Osprey', + 'Pelican', + 'Petrel', + 'Gannet', + 'Tern', + 'Cormorant', + 'Leatherback', + 'Hawksbill', + 'Loggerhead', + 'Dottyback', + 'Basslet', + 'Gramma', + 'Hamlet', + 'Hogfish', + 'Dartfish', + 'Foxface', + 'Shrimpfish', + 'Pyrosome', + 'Seapen', + 'Medusa', + 'Candlefish', + 'Silverside', + 'Shiner', + 'Eelpout', + 'Madtom', + 'Shovelnose', + 'Sandlance', + 'Tubesnout', + 'Marlin', + 'Bonefish', + 'Dace', + 'Rudd', + 'Spirula', + 'Permit', + 'Grunt', + 'Trevally', + 'Croaker', + 'Drum', + 'Ladyfish', + 'Bullhead', + 'Torpedo', + 'Skate', + 'Hermit', + 'Fiddler', + 'Fireworm', + 'Tubeworm', + 'Amphipod', + 'Copepod', + 'Pteropod', + 'Seaslug', + 'Swordtail', + 'Bluegill', + 'Pickerel', + 'Redhorse', + 'Logperch', + 'Goblin', + 'Catshark', + 'Harlequin', + 'Murex', + 'Volute', + 'Wentletrap', + 'Nerite', + 'Stargazer', + 'Handfish', + 'Flyingfish', + 'Waspfish', + 'Skua', + 'Shearwater', + 'Guillemot', + 'Razorbill', + 'Auk', + 'Fulmar', + 'Murre', + 'Velvetfish', + 'Prowfish', + 'Sandperch', + 'Bobtail', + 'Chiton', + 'Featherstar', + 'Nereid', + 'Siren', + 'Kraken', + 'Selkie', + 'Leviathan', + 'Wolffish', + 'Wreckfish', + 'Horseshoe' +] as const diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts new file mode 100644 index 00000000000..987eed64e83 --- /dev/null +++ b/mobile/src/notifications/mobile-notifications.ts @@ -0,0 +1,121 @@ +import * as Notifications from 'expo-notifications' +import { Platform } from 'react-native' +import type { RpcClient } from '../transport/rpc-client' +import { loadPushNotificationsEnabled } from '../storage/preferences' + +type NotificationEvent = { + type: 'notification' + source: 'agent-task-complete' | 'terminal-bell' | 'test' + title: string + body: string + worktreeId?: string +} + +type SubscribeResult = { + type: 'ready' + subscriptionId: string +} + +export type NotificationPermissionState = { + granted: boolean + status: string + canAskAgain: boolean +} + +export async function getNotificationPermissionState(): Promise { + const { status, canAskAgain } = await Notifications.getPermissionsAsync() + return { + granted: status === 'granted', + status, + canAskAgain + } +} + +// Why: permissions must be requested before scheduling any local notification. +// Read the OS state every time because users can change it in Settings while +// Orca remains alive in the background. +export async function ensureNotificationPermissions(): Promise { + const existing = await getNotificationPermissionState() + if (existing.granted) { + return true + } + + const { status } = await Notifications.requestPermissionsAsync() + return status === 'granted' +} + +function configureNotificationChannel(): void { + if (Platform.OS === 'android') { + void Notifications.setNotificationChannelAsync('orca-desktop', { + name: 'Desktop Notifications', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 250], + lightColor: '#6366f1' + }) + } +} + +async function showLocalNotification(event: NotificationEvent): Promise { + const enabled = await loadPushNotificationsEnabled() + if (!enabled) return + + const granted = await ensureNotificationPermissions() + if (!granted) return + + await Notifications.scheduleNotificationAsync({ + content: { + title: event.title, + body: event.body, + data: { source: event.source, worktreeId: event.worktreeId }, + ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {}) + }, + trigger: null + }) +} + +// Why: each host connection gets its own notification subscription. When the +// connection drops, the unsubscribe function cleans up the streaming RPC. +// Returns an unsubscribe function. +export function subscribeToDesktopNotifications(client: RpcClient): () => void { + configureNotificationChannel() + + let subscriptionId: string | null = null + let disposed = false + function unsubscribeServer(id: string) { + if (client.getState() === 'connected') { + client.sendRequest('notifications.unsubscribe', { subscriptionId: id }).catch(() => {}) + } + } + + const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => { + const event = data as NotificationEvent | SubscribeResult | { type: 'end' } + if (event.type === 'ready') { + subscriptionId = (event as SubscribeResult).subscriptionId + if (disposed) { + unsubscribeServer(subscriptionId) + unsubscribeStream() + } + return + } + if (event.type === 'end') { + if (disposed) unsubscribeStream() + return + } + if (disposed) return + if (event.type === 'notification') { + void showLocalNotification(event as NotificationEvent) + } + }) + + return () => { + disposed = true + // Why: the client may already be closed when this cleanup runs (component + // unmount races with disconnect). sendRequest rejects immediately on a + // closed client — swallow it since server-side cleanup happens via + // connection-close anyway. + if (subscriptionId) { + unsubscribeStream() + unsubscribeServer(subscriptionId) + } + } +} diff --git a/mobile/src/platform/haptics.ts b/mobile/src/platform/haptics.ts new file mode 100644 index 00000000000..72bf2443a82 --- /dev/null +++ b/mobile/src/platform/haptics.ts @@ -0,0 +1,13 @@ +import { Platform } from 'react-native' +import * as Haptics from 'expo-haptics' + +export function triggerMediumImpact(): void { + if (Platform.OS === 'android') { + // Why: Android's Vibrator API (used by impactAsync) is unreliable for haptic + // feedback. performAndroidHapticsAsync uses the native HapticFeedbackConstants + // API which works without VIBRATE permission and feels more natural. + void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Long_Press).catch(() => {}) + } else { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}) + } +} diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts new file mode 100644 index 00000000000..7a588902d86 --- /dev/null +++ b/mobile/src/storage/preferences.ts @@ -0,0 +1,93 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +const PINS_PREFIX = 'orca:pins:' +const PREFS_PREFIX = 'orca:prefs:' +const NOTIF_KEY = 'orca:pushNotificationsEnabled' + +// Why: default-off so the iOS notification permission prompt never +// fires until the user explicitly opts in via Settings → Notifications. +// Apple's review guideline 4.5.4 and HIG both prefer user-initiated +// permission prompts; default-on would fire the prompt the moment the +// desktop sent its first notification, which can read as unsolicited. +export async function loadPushNotificationsEnabled(): Promise { + try { + const raw = await AsyncStorage.getItem(NOTIF_KEY) + if (raw === null) return false + return raw === 'true' + } catch { + return false + } +} + +export async function savePushNotificationsEnabled(enabled: boolean): Promise { + await AsyncStorage.setItem(NOTIF_KEY, String(enabled)) +} + +export type HostPreferences = { + sortMode: string + filterMode: string + groupMode: string + collapsedGroups: string[] + selectedRepos: string[] +} + +const DEFAULT_PREFS: HostPreferences = { + sortMode: 'smart', + filterMode: 'all', + groupMode: 'none', + collapsedGroups: [], + selectedRepos: [] +} +const SORT_MODES = new Set(['smart', 'recent', 'name']) +const FILTER_MODES = new Set(['all', 'active']) +const GROUP_MODES = new Set(['none', 'repo', 'prStatus']) + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : [] +} + +function allowedString(value: unknown, allowed: Set, fallback: string): string { + return typeof value === 'string' && allowed.has(value) ? value : fallback +} + +export async function loadPinnedIds(hostId: string): Promise> { + try { + const raw = await AsyncStorage.getItem(PINS_PREFIX + hostId) + if (!raw) return new Set() + return new Set(stringArray(JSON.parse(raw))) + } catch { + return new Set() + } +} + +export async function savePinnedIds(hostId: string, ids: Set): Promise { + await AsyncStorage.setItem(PINS_PREFIX + hostId, JSON.stringify([...ids])) +} + +export async function loadPreferences(hostId: string): Promise { + try { + const raw = await AsyncStorage.getItem(PREFS_PREFIX + hostId) + if (!raw) return DEFAULT_PREFS + const parsed = JSON.parse(raw) as Partial + return { + sortMode: allowedString(parsed.sortMode, SORT_MODES, DEFAULT_PREFS.sortMode), + filterMode: allowedString(parsed.filterMode, FILTER_MODES, DEFAULT_PREFS.filterMode), + groupMode: allowedString(parsed.groupMode, GROUP_MODES, DEFAULT_PREFS.groupMode), + collapsedGroups: stringArray(parsed.collapsedGroups), + selectedRepos: stringArray(parsed.selectedRepos) + } + } catch { + return DEFAULT_PREFS + } +} + +export async function savePreferences( + hostId: string, + prefs: Partial +): Promise { + const current = await loadPreferences(hostId) + const merged = { ...current, ...prefs } + await AsyncStorage.setItem(PREFS_PREFIX + hostId, JSON.stringify(merged)) +} diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx new file mode 100644 index 00000000000..9766354f1e5 --- /dev/null +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -0,0 +1,670 @@ +import { useRef, useCallback, forwardRef, useImperativeHandle } from 'react' +import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native' +import { WebView } from 'react-native-webview' +import type { WebViewMessageEvent } from 'react-native-webview' +import { colors } from '../theme/mobile-theme' + +export type TerminalWebViewHandle = { + write: (data: string) => void + init: (cols: number, rows: number, initialData?: string) => void + clear: () => void + measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null> + resetZoom: () => void +} + +type Props = { + style?: StyleProp + onWebReady?: () => void +} + +type TerminalMessage = + | { type: 'write'; id?: number; data: string } + | { type: 'init'; id?: number; cols: number; rows: number; initialData?: string } + | { type: 'clear'; id?: number } + | { type: 'measure'; id?: number; containerHeight?: number } + | { type: 'reset-zoom'; id?: number } + +// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor +// positioning designed for the desktop's terminal dimensions (~150+ cols). +// We initialize xterm at the desktop's exact cols/rows so those escape codes +// render correctly, then use a measured CSS transform: scale() to fit the +// canvas into the phone viewport. The scale is computed after xterm opens +// by measuring the rendered surface width, not hardcoded, so it adapts to +// any terminal column count (80, 150, 200+). All touch gestures (scroll, +// pinch-to-zoom, pan) are handled by custom JS rather than native WebView +// behavior, so they work correctly with the CSS scale transform. +const XTERM_HTML = ` + + + + + + + + +
+
+
+ + + +` + +export const TerminalWebView = forwardRef(function TerminalWebView( + { style, onWebReady }, + ref +) { + const webViewRef = useRef(null) + const isWebReadyRef = useRef(false) + const pendingMessagesRef = useRef([]) + const messageIdRef = useRef(0) + const measureResolveRef = useRef< + ((result: { cols: number; rows: number } | null) => void) | null + >(null) + + const sendToWebView = useCallback((msg: TerminalMessage) => { + messageIdRef.current += 1 + webViewRef.current?.postMessage(JSON.stringify({ ...msg, id: messageIdRef.current })) + }, []) + + const flushPendingMessages = useCallback(() => { + const pending = pendingMessagesRef.current + pendingMessagesRef.current = [] + for (const msg of pending) { + sendToWebView(msg) + } + }, [sendToWebView]) + + const postMessage = useCallback( + (msg: TerminalMessage) => { + if (!isWebReadyRef.current) { + pendingMessagesRef.current.push(msg) + return + } + sendToWebView(msg) + }, + [sendToWebView] + ) + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + let msg: Record + try { + msg = JSON.parse(event.nativeEvent.data) as Record + } catch { + return + } + + if (msg.type === 'web-ready') { + isWebReadyRef.current = true + onWebReady?.() + flushPendingMessages() + } else if (msg.type === 'measure-result') { + const resolve = measureResolveRef.current + measureResolveRef.current = null + if (resolve) { + const cols = typeof msg.cols === 'number' ? msg.cols : null + const rows = typeof msg.rows === 'number' ? msg.rows : null + resolve(cols && rows && cols >= 20 && rows >= 8 ? { cols, rows } : null) + } + } + }, + [flushPendingMessages, onWebReady] + ) + + const handleLoadStart = useCallback(() => { + isWebReadyRef.current = false + }, []) + + useImperativeHandle( + ref, + () => ({ + write(data: string) { + postMessage({ type: 'write', data }) + }, + init(cols: number, rows: number, initialData?: string) { + postMessage({ type: 'init', cols, rows, initialData }) + }, + clear() { + postMessage({ type: 'clear' }) + }, + measureFitDimensions( + containerHeight?: number + ): Promise<{ cols: number; rows: number } | null> { + if (!isWebReadyRef.current) return Promise.resolve(null) + return new Promise((resolve) => { + measureResolveRef.current?.(null) + measureResolveRef.current = resolve + sendToWebView({ type: 'measure', containerHeight }) + // Why: if the WebView doesn't respond within 2s (e.g., xterm + // failed to load), resolve null so the caller can disable + // Fit to Phone rather than hanging indefinitely. + setTimeout(() => { + if (measureResolveRef.current === resolve) { + measureResolveRef.current = null + resolve(null) + } + }, 2000) + }) + }, + resetZoom() { + postMessage({ type: 'reset-zoom' }) + } + }), + [postMessage, sendToWebView] + ) + + return ( + + ) +}) + +const styles = StyleSheet.create({ + webview: { + flex: 1, + backgroundColor: colors.terminalBg + } +}) diff --git a/mobile/src/theme/mobile-theme.ts b/mobile/src/theme/mobile-theme.ts new file mode 100644 index 00000000000..d0f80c671e0 --- /dev/null +++ b/mobile/src/theme/mobile-theme.ts @@ -0,0 +1,45 @@ +// Orca mobile design tokens — matches desktop graphite/dark palette. +// All screen files should import from here instead of using inline hex values. + +export const colors = { + bgBase: '#111111', + bgPanel: '#1a1a1a', + bgRaised: '#242424', + borderSubtle: '#2a2a2a', + + textPrimary: '#e0e0e0', + textSecondary: '#888888', + textMuted: '#555555', + + accentBlue: '#3b82f6', + + statusGreen: '#22c55e', + statusAmber: '#f59e0b', + statusRed: '#ef4444', + + // Terminal WebView background (Tokyonight) — separate from app chrome + terminalBg: '#1a1b26' +} as const + +export const spacing = { + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 24 +} as const + +export const radii = { + row: 6, + card: 14, + button: 6, + input: 6, + camera: 8 +} as const + +export const typography = { + titleSize: 18, + bodySize: 14, + metaSize: 12, + monoFamily: 'monospace' as const +} as const diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx new file mode 100644 index 00000000000..176b80355e8 --- /dev/null +++ b/mobile/src/transport/client-context.tsx @@ -0,0 +1,450 @@ +// Why: collapses the per-screen WebSocket connection model into a single +// shared RpcClient per host. Implements the design in +// docs/mobile-shared-client-per-host.md. +// +// Lifecycle rules: +// - First request for a host opens its client lazily. +// - Refcount tracks active subscribers; when it drops to zero we schedule +// a 30-second idle close timer. If a new subscriber arrives within that +// window we cancel and reuse the same client. +// - removeHost() forces an immediate close so re-pairing gets a fresh +// transport. +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode +} from 'react' +import { connect, type RpcClient } from './rpc-client' +import { loadHosts } from './host-store' +import type { ConnectionState, HostProfile } from './types' + +type StoreEntry = { + client: RpcClient + state: ConnectionState + refCount: number + unsubState: () => void +} + +type ContextValue = { + acquire: (hostId: string, host?: HostProfile) => RpcClient | null + release: (hostId: string) => void + forceReconnect: (hostId: string) => Promise + closeHost: (hostId: string) => void + getState: (hostId: string) => ConnectionState + getReconnectAttempt: (hostId: string) => number + subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void + getAllClients: () => Array<{ hostId: string; client: RpcClient }> + subscribeAllHosts: (listener: () => void) => () => void + // Why: lets the home screen feed already-loaded HostProfiles in so we + // don't pay loadHosts() latency twice (once in the focus-effect, again + // inside openEntry). + primeHosts: (hosts: HostProfile[]) => void +} + +const Ctx = createContext(null) + +export function RpcClientProvider({ children }: { children: ReactNode }) { + // Why: entries live in a ref so updates don't force re-renders of the + // entire tree on every connection state change. State propagation goes + // through per-host listener Sets instead. + const storeRef = useRef>(new Map()) + const stateListenersRef = useRef void>>>(new Map()) + const allHostsListenersRef = useRef void>>(new Set()) + + // Pending opens (avoid two acquire() callers in the same render racing the + // host lookup). Keyed by hostId, value is a sentinel resolved when the + // entry materialises. + const pendingOpensRef = useRef>>(new Map()) + + // Why: a fast-path cache of already-loaded HostProfiles. Screens that + // have run loadHosts() can call primeHosts() to populate this and skip + // the second loadHosts() inside openEntry. Without this we'd serialize + // two Keychain passes on cold start. + const primedHostsRef = useRef>(new Map()) + + function notifyHostState(hostId: string, state: ConnectionState) { + const set = stateListenersRef.current.get(hostId) + if (!set) return + for (const listener of set) listener(state) + } + + function notifyAllHosts() { + for (const listener of allHostsListenersRef.current) listener() + } + + const closeEntry = useCallback((hostId: string) => { + const entry = storeRef.current.get(hostId) + if (!entry) return + entry.unsubState() + entry.client.close() + storeRef.current.delete(hostId) + notifyHostState(hostId, 'disconnected') + notifyAllHosts() + }, []) + + const openEntry = useCallback(async (hostId: string): Promise => { + const existing = pendingOpensRef.current.get(hostId) + if (existing) { + await existing + return storeRef.current.get(hostId) ?? null + } + let resolve: () => void = () => {} + const promise = new Promise((res) => { + resolve = res + }) + pendingOpensRef.current.set(hostId, promise) + + try { + // Why: prefer the primed cache (populated by primeHosts when the + // screen already ran loadHosts) so we don't serialize a second + // Keychain pass behind the first one on cold start. + let host = primedHostsRef.current.get(hostId) + if (!host) { + try { + const hosts = await loadHosts() + host = hosts.find((h) => h.id === hostId) + } catch { + // Why: a Keychain failure on cold start (rare but observed — + // happens when iOS Keychain is mid-unlock or Android Keystore + // races the JS bridge). Surface it as 'disconnected' so the + // home card flips off the perma-spinner and the user can hit + // Reconnect from the action sheet to retry. + notifyHostState(hostId, 'disconnected') + notifyAllHosts() + return null + } + if (!host) return null + } + + // Re-check after any await — another acquire() may have completed. + const after = storeRef.current.get(hostId) + if (after) return after + + let client: RpcClient + try { + client = connect(host.endpoint, host.deviceToken, host.publicKeyB64) + } catch { + // Why: connect() can throw synchronously if the public key is + // malformed or the endpoint URL is invalid. Notify so the UI + // doesn't sit on a stale 'connecting' label forever. + notifyHostState(hostId, 'disconnected') + notifyAllHosts() + return null + } + const unsubState = client.onStateChange((state) => { + const cur = storeRef.current.get(hostId) + if (!cur) return + cur.state = state + notifyHostState(hostId, state) + }) + const entry: StoreEntry = { + client, + state: client.getState(), + refCount: 0, + unsubState + } + storeRef.current.set(hostId, entry) + notifyHostState(hostId, entry.state) + notifyAllHosts() + return entry + } finally { + pendingOpensRef.current.delete(hostId) + resolve() + } + }, []) + + // Why: `acquire` is the synchronous get-or-open. If the entry already + // exists, return its client immediately and bump the refcount. If not, + // kick off an async open (the consumer will subscribe via + // `subscribeHostState` and re-read once 'connecting' fires). Optionally + // accepts the HostProfile so the caller can avoid an extra loadHosts() + // pass inside openEntry. + const acquire = useCallback( + (hostId: string, host?: HostProfile): RpcClient | null => { + if (host) primedHostsRef.current.set(hostId, host) + const existing = storeRef.current.get(hostId) + if (existing) { + existing.refCount += 1 + return existing.client + } + // Trigger async open. The acquire-side will return null this tick and + // try again once the state listener fires; consumers are expected to + // call acquire() inside an effect that re-runs on state changes. + void openEntry(hostId).then((entry) => { + if (!entry) return + entry.refCount += 1 + }) + return null + }, + [openEntry] + ) + + const primeHosts = useCallback((hosts: HostProfile[]) => { + for (const host of hosts) primedHostsRef.current.set(host.id, host) + }, []) + + // Why: refcount dropping to 0 no longer triggers an idle-close. The + // app deliberately keeps live WebSockets open while the app itself is + // foregrounded — closing on transient navigation gaps was producing + // false 'disconnected' flashes when the user navigated home → host → + // back to home faster than React could re-acquire on the home side. + // Connections still close on: explicit user Disconnect, host removal, + // app backgrounding (OS-level socket suspension), and provider + // unmount (app shutdown). + const release = useCallback((hostId: string) => { + const entry = storeRef.current.get(hostId) + if (!entry) return + entry.refCount = Math.max(0, entry.refCount - 1) + }, []) + + const forceReconnect = useCallback( + async (hostId: string) => { + const entry = storeRef.current.get(hostId) + // Why: preserve refcount across the swap. If the user reaches + // forceReconnect via the Disconnect → Reconnect path, the entry + // was already closed and refCount=0; fall back to active listener + // count as a proxy for "screens still watching this host." + const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0 + const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount) + if (entry) { + entry.unsubState() + entry.client.close() + storeRef.current.delete(hostId) + } + const fresh = await openEntry(hostId) + if (fresh) fresh.refCount = savedRefCount + }, + [openEntry] + ) + + const getState = useCallback((hostId: string): ConnectionState => { + return storeRef.current.get(hostId)?.state ?? 'disconnected' + }, []) + + const getReconnectAttempt = useCallback((hostId: string): number => { + return storeRef.current.get(hostId)?.client.getReconnectAttempt() ?? 0 + }, []) + + const subscribeHostState = useCallback( + (hostId: string, listener: (state: ConnectionState) => void) => { + let set = stateListenersRef.current.get(hostId) + if (!set) { + set = new Set() + stateListenersRef.current.set(hostId, set) + } + set.add(listener) + return () => { + const s = stateListenersRef.current.get(hostId) + if (!s) return + s.delete(listener) + if (s.size === 0) stateListenersRef.current.delete(hostId) + } + }, + [] + ) + + const getAllClients = useCallback((): Array<{ hostId: string; client: RpcClient }> => { + const out: Array<{ hostId: string; client: RpcClient }> = [] + for (const [hostId, entry] of storeRef.current) { + out.push({ hostId, client: entry.client }) + } + return out + }, []) + + const subscribeAllHosts = useCallback((listener: () => void) => { + allHostsListenersRef.current.add(listener) + return () => { + allHostsListenersRef.current.delete(listener) + } + }, []) + + // Close all clients on provider unmount (app shutdown). + // Why: deps must be empty so this cleanup ONLY runs on real unmount. + // Hot-reload re-evaluates this module, which makes closeEntry a new + // function identity. With [closeEntry] as deps, every Fast Refresh + // would tear down all open WebSockets, leaving screens holding closed + // clients and the user staring at a 'Reconnecting…' card. Reading + // storeRef.current and the locally-scoped closeEntry inside the + // cleanup is safe — the ref is stable across renders, and the + // function captured here will be the one defined in the same + // closure as this effect. + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + const store = storeRef.current + return () => { + for (const [hostId] of store) closeEntry(hostId) + } + }, []) + + const value = useMemo( + () => ({ + acquire, + release, + forceReconnect, + closeHost: closeEntry, + getState, + getReconnectAttempt, + subscribeHostState, + getAllClients, + subscribeAllHosts, + primeHosts + }), + [ + acquire, + release, + forceReconnect, + closeEntry, + getState, + getReconnectAttempt, + subscribeHostState, + getAllClients, + subscribeAllHosts, + primeHosts + ] + ) + + return {children} +} + +function useCtx(): ContextValue { + const ctx = useContext(Ctx) + if (!ctx) throw new Error('useHostClient must be used inside ') + return ctx +} + +// Why: the primary hook for screens. Acquires the shared client for a +// hostId on mount and releases on unmount. Re-renders when the host's +// connection state changes. +export function useHostClient(hostId: string | undefined): { + client: RpcClient | null + state: ConnectionState +} { + const ctx = useCtx() + const [, force] = useState(0) + const [state, setState] = useState(() => + hostId ? ctx.getState(hostId) : 'disconnected' + ) + const clientRef = useRef(null) + + useEffect(() => { + if (!hostId) { + clientRef.current = null + setState('disconnected') + return + } + let cancelled = false + // Subscribe before acquire so any state change during open is captured. + const unsub = ctx.subscribeHostState(hostId, (next) => { + if (cancelled) return + setState(next) + // Why: if the client was null at first acquire (async open), the + // first state change ('connecting'/'handshaking'/'connected') is our + // signal to re-read. + if (clientRef.current == null) { + const all = ctx.getAllClients() + const found = all.find((entry) => entry.hostId === hostId) + if (found) { + clientRef.current = found.client + force((n) => n + 1) + } + } + }) + const initial = ctx.acquire(hostId) + if (initial) { + clientRef.current = initial + setState(ctx.getState(hostId)) + } + return () => { + cancelled = true + unsub() + ctx.release(hostId) + clientRef.current = null + } + }, [ctx, hostId]) + + return { client: clientRef.current, state } +} + +// Why: home screen renders all paired hosts at once. Acquires each on +// mount, releases on unmount. The provider's refcounting ensures we +// don't double-open if a host-detail screen is also open. +export function useAllHostClients(hostIds: string[]): Array<{ + hostId: string + client: RpcClient + state: ConnectionState +}> { + const ctx = useCtx() + // Stable key so we don't tear down on every render of the array. + const key = useMemo(() => [...hostIds].sort().join(','), [hostIds]) + const [tick, setTick] = useState(0) + + useEffect(() => { + if (hostIds.length === 0) return + for (const id of hostIds) ctx.acquire(id) + const unsubs: Array<() => void> = [] + for (const id of hostIds) { + unsubs.push(ctx.subscribeHostState(id, () => setTick((n) => n + 1))) + } + unsubs.push(ctx.subscribeAllHosts(() => setTick((n) => n + 1))) + return () => { + for (const u of unsubs) u() + for (const id of hostIds) ctx.release(id) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]) + + return useMemo(() => { + const out: Array<{ hostId: string; client: RpcClient; state: ConnectionState }> = [] + for (const id of hostIds) { + const all = ctx.getAllClients().find((entry) => entry.hostId === id) + if (all) { + out.push({ hostId: id, client: all.client, state: ctx.getState(id) }) + } + } + return out + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, tick]) +} + +// Why: removeHost() in host-store.ts must close the live client, but +// host-store has no React-side handle. Expose a hook that lets callers +// close a host after removal. +export function useCloseHost(): (hostId: string) => void { + const ctx = useCtx() + return ctx.closeHost +} + +// Why: future-proof "Connection issues — try again" affordance. +export function useForceReconnect(): (hostId: string) => Promise { + const ctx = useCtx() + return ctx.forceReconnect +} + +// Why: lets the home screen feed already-loaded HostProfiles in so the +// provider can skip its own loadHosts() pass when it eventually opens +// each host — collapses two serial Keychain reads on cold-start into one. +export function usePrimeHosts(): (hosts: HostProfile[]) => void { + const ctx = useCtx() + return ctx.primeHosts +} + +// Why: lets the home/host-detail UI escalate "Reconnecting…" to a more +// alarming "Can't connect" once the rpc-client has cycled enough times to +// indicate something's actually wrong (wrong port, server down, network +// loss). Reads through the context so it stays in sync with the live +// rpc-client instance even after forceReconnect swaps the underlying +// client. +export function useReconnectAttempt(hostId: string | undefined): number { + const ctx = useCtx() + const [, force] = useState(0) + useEffect(() => { + if (!hostId) return + return ctx.subscribeHostState(hostId, () => force((n) => n + 1)) + }, [ctx, hostId]) + return hostId ? ctx.getReconnectAttempt(hostId) : 0 +} diff --git a/mobile/src/transport/e2ee.ts b/mobile/src/transport/e2ee.ts new file mode 100644 index 00000000000..87d599ec077 --- /dev/null +++ b/mobile/src/transport/e2ee.ts @@ -0,0 +1,91 @@ +// Why: E2EE primitives for the mobile side. Uses tweetnacl for Curve25519 ECDH +// key exchange and XSalsa20-Poly1305 authenticated encryption. Wire format is +// base64([24-byte nonce][ciphertext]) over WebSocket text frames. +import nacl from 'tweetnacl' +import * as ExpoCrypto from 'expo-crypto' + +// Why: Hermes (React Native's JS engine) lacks crypto.getRandomValues, +// which tweetnacl requires. expo-crypto provides a native secure RNG +// that works in Expo Go without a custom dev build. +nacl.setPRNG((_x: Uint8Array, n: number) => { + const bytes = ExpoCrypto.getRandomBytes(n) + _x.set(bytes) +}) + +// Why: tweetnacl uses `instanceof Uint8Array` checks internally. On Hermes, +// values from native modules (expo-crypto), TextEncoder, or typed array +// operations can return objects that fail this check despite being +// functionally identical. Wrapping with `new Uint8Array(x)` guarantees +// the correct prototype chain. +function u8(x: Uint8Array): Uint8Array { + return new Uint8Array(x) +} + +export function generateKeyPair(): { publicKey: Uint8Array; secretKey: Uint8Array } { + const kp = nacl.box.keyPair() + return { publicKey: u8(kp.publicKey), secretKey: u8(kp.secretKey) } +} + +export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { + return u8(nacl.box.before(u8(peerPublicKey), u8(ourSecretKey))) +} + +function uint8ToBase64(bytes: Uint8Array): string { + let binary = '' + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!) + } + return btoa(binary) +} + +function base64ToUint8(b64: string): Uint8Array { + const binary = atob(b64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +} + +export function publicKeyFromBase64(b64: string): Uint8Array { + const key = base64ToUint8(b64) + if (key.length !== 32) { + throw new Error( + `Invalid public key: expected 32 bytes, got ${key.length} from "${b64.slice(0, 20)}..."` + ) + } + return key +} + +export function publicKeyToBase64(key: Uint8Array): string { + return uint8ToBase64(key) +} + +export function encrypt(plaintext: string, sharedKey: Uint8Array): string { + const nonce = u8(nacl.randomBytes(nacl.box.nonceLength)) + const messageBytes = u8(new TextEncoder().encode(plaintext)) + const ciphertext = nacl.box.after(messageBytes, nonce, u8(sharedKey)) + + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + + return uint8ToBase64(bundle) +} + +export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { + const bundle = base64ToUint8(encrypted) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { + return null + } + + const nonce = u8(bundle.subarray(0, nacl.box.nonceLength)) + const ciphertext = u8(bundle.subarray(nacl.box.nonceLength)) + const plaintext = nacl.box.open.after(ciphertext, nonce, u8(sharedKey)) + + if (!plaintext) { + return null + } + + return new TextDecoder().decode(plaintext) +} diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts new file mode 100644 index 00000000000..119e10d7c34 --- /dev/null +++ b/mobile/src/transport/host-store.ts @@ -0,0 +1,167 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import * as SecureStore from 'expo-secure-store' +import { + HostProfileSchema, + StoredHostProfileSchema, + type HostProfile, + type StoredHostProfile +} from './types' + +const STORAGE_KEY = 'orca:hosts' +// Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected. +// Use dots as the separator so the key shape stays readable while +// satisfying the validator. +const TOKEN_KEY_PREFIX = 'orca.host-token.' + +// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off +// iCloud Keychain and out of iCloud/iTunes backup restores onto a +// different physical device. Reads/writes are silent (no biometric +// prompt) since we don't request access control flags. +const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY +} + +function tokenKey(hostId: string): string { + return `${TOKEN_KEY_PREFIX}${hostId}` +} + +// Why: SecureStore reads on Android Keystore can take 50-200ms each, and +// loadHosts() is called from every screen mount + every useFocusEffect. +// Stack with N hosts and you get N*200ms blocking every navigation, which +// triggers connection-churn cycles in the home-screen useEffect. Cache +// per-hostId in memory; invalidate only on save/remove. The cache lives +// for the JS-runtime lifetime, which matches AsyncStorage semantics +// (cleared on app uninstall, persisted across foreground/background). +const tokenCache = new Map() +let inflightLoad: Promise | null = null + +export async function loadHosts(): Promise { + // Why: deduplicate concurrent loadHosts() calls so multiple screens + // mounting simultaneously share one Keychain read pass. + if (inflightLoad) return inflightLoad + inflightLoad = doLoadHosts().finally(() => { + inflightLoad = null + }) + return inflightLoad +} + +async function doLoadHosts(): Promise { + const raw = await AsyncStorage.getItem(STORAGE_KEY) + if (!raw) return [] + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return [] + } + if (!Array.isArray(parsed)) return [] + + const out: HostProfile[] = [] + for (const item of parsed) { + // Why: pre-v0.0.3 records carry the deviceToken in AsyncStorage. + // Drop them silently — the three pre-launch users will re-pair on + // first run rather than carry a migration shim through the auth + // path. + if (item && typeof item === 'object' && 'deviceToken' in item) { + continue + } + const stored = StoredHostProfileSchema.safeParse(item) + if (!stored.success) continue + + let token = tokenCache.get(stored.data.id) + if (!token) { + const fetched = await SecureStore.getItemAsync(tokenKey(stored.data.id), KEYCHAIN_OPTIONS) + if (!fetched) { + // Why: orphaned metadata with no matching keychain entry — most + // likely a stale record from a development install. Skip it + // rather than surface a half-broken host. + continue + } + token = fetched + tokenCache.set(stored.data.id, token) + } + out.push({ ...stored.data, deviceToken: token }) + } + return out +} + +async function loadStoredHosts(): Promise { + const raw = await AsyncStorage.getItem(STORAGE_KEY) + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed.flatMap((item) => { + // Why: same drop-old-records rule as loadHosts; keeps internal + // mutators from re-persisting pre-v0.0.3 entries. + if (item && typeof item === 'object' && 'deviceToken' in item) return [] + const result = StoredHostProfileSchema.safeParse(item) + return result.success ? [result.data] : [] + }) + } catch { + return [] + } +} + +function toStored(host: HostProfile): StoredHostProfile { + return { + id: host.id, + name: host.name, + endpoint: host.endpoint, + publicKeyB64: host.publicKeyB64, + lastConnected: host.lastConnected + } +} + +export async function saveHost(host: HostProfile): Promise { + const validated = HostProfileSchema.parse(host) + const hosts = await loadStoredHosts() + const stored = toStored(validated) + const index = hosts.findIndex((h) => h.id === stored.id) + if (index >= 0) { + hosts[index] = stored + } else { + hosts.push(stored) + } + await SecureStore.setItemAsync(tokenKey(stored.id), validated.deviceToken, KEYCHAIN_OPTIONS) + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) + tokenCache.set(stored.id, validated.deviceToken) +} + +export async function removeHost(hostId: string): Promise { + const hosts = await loadStoredHosts() + const filtered = hosts.filter((h) => h.id !== hostId) + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)) + await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) + tokenCache.delete(hostId) +} + +export async function renameHost(hostId: string, newName: string): Promise { + const hosts = await loadStoredHosts() + const host = hosts.find((h) => h.id === hostId) + if (host) { + host.name = newName + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) + } +} + +export async function getNextHostName(): Promise { + const hosts = await loadStoredHosts() + const existingNumbers = hosts + .map((h) => { + const match = h.name.match(/^Host (\d+)$/) + return match ? parseInt(match[1]!, 10) : 0 + }) + .filter((n) => n > 0) + const next = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1 + return `Host ${next}` +} + +export async function updateLastConnected(hostId: string): Promise { + const hosts = await loadStoredHosts() + const host = hosts.find((h) => h.id === hostId) + if (host) { + host.lastConnected = Date.now() + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) + } +} diff --git a/mobile/src/transport/index.ts b/mobile/src/transport/index.ts new file mode 100644 index 00000000000..8aacca59eff --- /dev/null +++ b/mobile/src/transport/index.ts @@ -0,0 +1,19 @@ +export { connect, type RpcClient } from './rpc-client' +export { + loadHosts, + saveHost, + removeHost, + renameHost, + getNextHostName, + updateLastConnected +} from './host-store' +export type { + RpcRequest, + RpcResponse, + RpcSuccess, + RpcFailure, + ConnectionState, + HostProfile, + PairingOffer +} from './types' +export { PairingOfferSchema, PAIRING_OFFER_VERSION } from './types' diff --git a/mobile/src/transport/pairing.ts b/mobile/src/transport/pairing.ts new file mode 100644 index 00000000000..2b54f4e58db --- /dev/null +++ b/mobile/src/transport/pairing.ts @@ -0,0 +1,38 @@ +import { PairingOfferSchema, type PairingOffer } from './types' + +// Why: this file mirrors src/shared/pairing.ts (which is covered by CI +// vitest) but uses atob/btoa because Metro/Hermes don't ship Node's +// Buffer. Keep the parsing semantics in sync — when one changes, update +// the other. + +export function decodePairingUrl(url: string): PairingOffer | null { + try { + const hashIndex = url.indexOf('#') + if (!url.startsWith('orca://pair') || hashIndex === -1) return null + return decodePairingBase64(url.slice(hashIndex + 1)) + } catch { + return null + } +} + +// Why: accept either an `orca://pair#` URL or the bare base64 +// string so the paste-pair flow can take whichever the user actually +// copied from desktop. +export function parsePairingCode(input: string): PairingOffer | null { + const trimmed = input.trim() + if (!trimmed) return null + try { + if (trimmed.startsWith('orca://pair')) { + return decodePairingUrl(trimmed) + } + return decodePairingBase64(trimmed) + } catch { + return null + } +} + +function decodePairingBase64(base64url: string): PairingOffer { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + const json = atob(base64) + return PairingOfferSchema.parse(JSON.parse(json)) +} diff --git a/mobile/src/transport/protocol-compat.ts b/mobile/src/transport/protocol-compat.ts new file mode 100644 index 00000000000..12dc0de4093 --- /dev/null +++ b/mobile/src/transport/protocol-compat.ts @@ -0,0 +1,42 @@ +// Why: this file mirrors src/shared/protocol-compat.ts (which is +// covered by CI vitest). Metro can't resolve out of mobile/, so the +// pure function is duplicated here. Keep the two files in sync — when +// you change the evaluator's logic, update both. The src/shared/ copy +// is the tested canonical version. +import { MIN_COMPATIBLE_DESKTOP_VERSION, MOBILE_PROTOCOL_VERSION } from './protocol-version' + +export type CompatVerdict = + | { kind: 'ok' } + | { + kind: 'blocked' + reason: 'mobile-too-old' | 'desktop-too-old' + desktopVersion: number + requiredMobileVersion?: number + requiredDesktopVersion?: number + } + +export function evaluateCompat(input: { + desktopProtocolVersion: number | undefined + desktopMinCompatibleMobileVersion: number | undefined +}): CompatVerdict { + const desktopVersion = input.desktopProtocolVersion ?? 0 + const requiredMobile = input.desktopMinCompatibleMobileVersion ?? 0 + + if (MOBILE_PROTOCOL_VERSION < requiredMobile) { + return { + kind: 'blocked', + reason: 'mobile-too-old', + desktopVersion, + requiredMobileVersion: requiredMobile + } + } + if (desktopVersion < MIN_COMPATIBLE_DESKTOP_VERSION) { + return { + kind: 'blocked', + reason: 'desktop-too-old', + desktopVersion, + requiredDesktopVersion: MIN_COMPATIBLE_DESKTOP_VERSION + } + } + return { kind: 'ok' } +} diff --git a/mobile/src/transport/protocol-version.ts b/mobile/src/transport/protocol-version.ts new file mode 100644 index 00000000000..9445c1f2881 --- /dev/null +++ b/mobile/src/transport/protocol-version.ts @@ -0,0 +1,20 @@ +// Why: declares the mobile's pairing protocol version and the minimum +// desktop version it can talk to. Duplicates the desktop's +// `src/shared/protocol-version.ts` because Metro/Expo doesn't resolve +// outside `mobile/`. Manual sync is acceptable — these constants are +// expected to bump less than once a quarter. +// +// Bump MOBILE_PROTOCOL_VERSION when: +// - You change the meaning of an RPC mobile sends. +// - You stop relying on a desktop-side feature in a way old desktops +// would notice. +// Do NOT bump for: +// - Adding new optional fields to outbound requests. +// - Reading new optional fields on incoming responses. +// +// Bump MIN_COMPATIBLE_DESKTOP_VERSION when mobile starts relying on a +// desktop feature added at a specific desktop protocol version. This +// triggers a hard-block screen for users paired to older desktops. + +export const MOBILE_PROTOCOL_VERSION = 1 +export const MIN_COMPATIBLE_DESKTOP_VERSION = 0 diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts new file mode 100644 index 00000000000..505a5722bd4 --- /dev/null +++ b/mobile/src/transport/rpc-client.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect } from './rpc-client' + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => `encrypted:${plaintext}`, + decrypt: (raw: string) => raw.replace(/^encrypted:/, '') +})) + +class MockWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + + readonly CONNECTING = MockWebSocket.CONNECTING + readonly OPEN = MockWebSocket.OPEN + readonly CLOSING = MockWebSocket.CLOSING + readonly CLOSED = MockWebSocket.CLOSED + + readyState = MockWebSocket.CONNECTING + onopen: (() => void) | null = null + onclose: (() => void) | null = null + onmessage: ((event: { data: string }) => void) | null = null + onerror: (() => void) | null = null + emitCloseOnClose = true + sent: string[] = [] + close = vi.fn(() => { + if (this.readyState === MockWebSocket.CLOSED) return + this.readyState = MockWebSocket.CLOSED + if (this.emitCloseOnClose) { + this.onclose?.() + } + }) + + constructor(readonly endpoint: string) { + mockSockets.push(this) + } + + send(payload: string): void { + this.sent.push(payload) + } + + open(): void { + this.readyState = MockWebSocket.OPEN + this.onopen?.() + } + + receive(payload: string): void { + this.onmessage?.({ data: payload }) + } +} + +const mockSockets: MockWebSocket[] = [] +const originalWebSocket = globalThis.WebSocket + +describe('mobile rpc-client connection timeout', () => { + beforeEach(() => { + vi.useFakeTimers() + mockSockets.length = 0 + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.WebSocket = originalWebSocket + }) + + it('closes a socket that never opens so reconnect can run', () => { + const states: string[] = [] + const client = connect('ws://desktop.invalid', 'token', 'server-key', (state) => { + states.push(state) + }) + + expect(client.getState()).toBe('connecting') + expect(mockSockets).toHaveLength(1) + mockSockets[0]!.emitCloseOnClose = false + + vi.advanceTimersByTime(12_000) + + expect(mockSockets[0]!.close).toHaveBeenCalledTimes(1) + expect(client.getState()).toBe('reconnecting') + expect(states).toContain('reconnecting') + + client.close() + }) + + it('clears the open timeout once the socket opens and authenticates', () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') + + expect(client.getState()).toBe('connected') + + vi.advanceTimersByTime(12_000) + + expect(socket.close).not.toHaveBeenCalled() + expect(client.getState()).toBe('connected') + + client.close() + }) +}) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts new file mode 100644 index 00000000000..d1269000382 --- /dev/null +++ b/mobile/src/transport/rpc-client.ts @@ -0,0 +1,506 @@ +import type { RpcResponse, RpcSuccess, ConnectionState } from './types' +import { + generateKeyPair, + deriveSharedKey, + publicKeyFromBase64, + publicKeyToBase64, + encrypt, + decrypt +} from './e2ee' + +type PendingRequest = { + resolve: (response: RpcResponse) => void + reject: (error: Error) => void +} + +type StreamingListener = (result: unknown) => void + +type StreamRequest = { + method: string + params: unknown + listener: StreamingListener +} + +export type RpcClient = { + sendRequest: (method: string, params?: unknown) => Promise + subscribe: (method: string, params: unknown, onData: StreamingListener) => () => void + getState: () => ConnectionState + // Why: UI escalates "Reconnecting…" to "Can't connect" once attempts cross + // a threshold. 0 means never failed; counter is reset on successful open. + getReconnectAttempt: () => number + onStateChange: (listener: (state: ConnectionState) => void) => () => void + close: () => void +} + +// Why: capped at 4s so the worst-case "stuck reconnecting" window the +// user perceives is short. Prior 16s ceiling combined with Android's +// suspended-timer behaviour during background → foreground transitions +// often felt like the app would just sit on 'Reconnecting…' forever +// (the timer was queued, the OS had simply not run it yet). Tapping the +// manual Reconnect button bypassed the timer, which is why it felt +// "magic". Shorter backoff makes the auto-recovery path feel as fast. +const RECONNECT_DELAYS = [500, 1000, 2000, 4000] +const REQUEST_TIMEOUT_MS = 30_000 +const CONNECT_TIMEOUT_MS = 12_000 +const HANDSHAKE_TIMEOUT_MS = 5_000 +// Why: RN's WebSocket implementation may not expose static readyState +// constants, but the protocol value for CONNECTING is stable across runtimes. +const WEBSOCKET_CONNECTING_STATE = 0 + +// Why: app-level liveness probe. The server runs its own ping/pong sweep +// at 15s, but RN's WebSocket runtime auto-pongs at the native layer +// without surfacing anything to JS — so the mobile side can't *see* that +// the server thinks the link is fine. To detect a half-open socket from +// the mobile direction (e.g. server crashed, phone moved between wifi +// and cellular without TCP RST) we periodically round-trip a tiny RPC. +// If two consecutive probes time out we force-close the WS, which fires +// the existing reconnect path. 20s cadence + the 30s request timeout = +// worst-case ~50s before mobile decides the link is dead and kicks +// reconnect, which is still inside the user's perceived "responsive" +// window and well below iOS's typical background-disconnect window. +const ACTIVITY_PROBE_INTERVAL_MS = 20_000 + +export function connect( + endpoint: string, + deviceToken: string, + serverPublicKeyB64: string, + onStateChange?: (state: ConnectionState) => void +): RpcClient { + let ws: WebSocket | null = null + let state: ConnectionState = 'disconnected' + let requestCounter = 0 + let reconnectAttempt = 0 + let reconnectTimer: ReturnType | null = null + let connectTimer: ReturnType | null = null + let handshakeTimer: ReturnType | null = null + let activityProbeTimer: ReturnType | null = null + let intentionallyClosed = false + + // Why: fresh ephemeral keypair per connection provides forward secrecy. + // The shared key is derived from our ephemeral secret + server's static public key. + let sharedKey: Uint8Array | null = null + const serverPublicKey = publicKeyFromBase64(serverPublicKeyB64) + + const pending = new Map() + const streamListeners = new Map() + const stateListeners = new Set<(state: ConnectionState) => void>() + const connectWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }> = [] + + if (onStateChange) { + stateListeners.add(onStateChange) + } + + function setState(next: ConnectionState) { + if (state === next) return + state = next + if (next === 'connected') { + for (const w of connectWaiters.splice(0)) w.resolve() + } else if (next === 'disconnected' || next === 'auth-failed') { + const reason = + next === 'auth-failed' ? 'Unauthorized — pairing may be revoked' : 'Connection closed' + for (const w of connectWaiters.splice(0)) w.reject(new Error(reason)) + } + for (const listener of stateListeners) { + listener(next) + } + } + + function waitForConnected(): Promise { + if (state === 'connected') return Promise.resolve() + if (intentionallyClosed) return Promise.reject(new Error('Client closed')) + return new Promise((resolve, reject) => { + connectWaiters.push({ resolve, reject }) + }) + } + + function nextId(): string { + return `rpc-${++requestCounter}-${Date.now()}` + } + + function openConnection() { + if (intentionallyClosed) return + + setState('connecting') + sharedKey = null + + ws = new WebSocket(endpoint) + const openingWs = ws + + // Why: React Native can leave TCP/WebSocket opens pending indefinitely on + // flaky network handoffs. Force the existing onclose reconnect path if + // onopen never arrives, instead of leaving the UI stuck at "Connecting...". + connectTimer = setTimeout(() => { + connectTimer = null + if (ws === openingWs && openingWs.readyState === WEBSOCKET_CONNECTING_STATE) { + openingWs.close() + if (ws === openingWs) { + handleSocketClosed(openingWs) + } + } + }, CONNECT_TIMEOUT_MS) + + ws.onopen = () => { + clearConnectTimer() + reconnectAttempt = 0 + setState('handshaking') + + // Why: generate a fresh ephemeral keypair for each connection. + // This provides forward secrecy — compromising one session's key + // doesn't compromise past or future sessions. + const ephemeral = generateKeyPair() + const hello = JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ephemeral.publicKey) + }) + ws?.send(hello) + + sharedKey = deriveSharedKey(ephemeral.secretKey, serverPublicKey) + + handshakeTimer = setTimeout(() => { + handshakeTimer = null + ws?.close() + }, HANDSHAKE_TIMEOUT_MS) + } + + ws.onmessage = (event) => { + const raw = typeof event.data === 'string' ? event.data : String(event.data) + + // Why: during handshaking, e2ee_ready is plaintext because it precedes + // encrypted auth; e2ee_authenticated/e2ee_error are encrypted. + if (state === 'handshaking') { + try { + const msg = JSON.parse(raw) + if (msg.type === 'e2ee_ready') { + sendEncrypted({ type: 'e2ee_auth', deviceToken }) + return + } + } catch { + // Not plaintext JSON — fall through and try encrypted handshake messages. + } + + if (!sharedKey || sharedKey.length !== 32) { + return + } + + const plaintext = decrypt(raw, sharedKey) + if (plaintext === null) { + return + } + + try { + const msg = JSON.parse(plaintext) + if (msg.type === 'e2ee_authenticated') { + if (handshakeTimer) { + clearTimeout(handshakeTimer) + handshakeTimer = null + } + setState('connected') + startActivityProbe() + for (const [id, stream] of streamListeners) { + sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params }) + } + } else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) { + intentionallyClosed = true + ws?.close() + ws = null + setState('auth-failed') + rejectAllPending('Unauthorized — pairing may be revoked') + } + } catch { + // Not JSON — ignore during handshake. + } + return + } + + // Why: guard against decrypt with an invalid key — sharedKey can be null + // after destroy() or if a message arrives during a reconnect race. + if (!sharedKey || sharedKey.length !== 32) { + return + } + + const plaintext = decrypt(raw, sharedKey) + if (plaintext === null) { + return + } + + let response: RpcResponse + try { + response = JSON.parse(plaintext) + } catch { + return + } + + // Why: auth failure is distinct from transient disconnect — retrying + // with a rejected token causes infinite reconnect churn. + if (!response.ok && response.error.code === 'unauthorized') { + intentionallyClosed = true + ws?.close() + ws = null + setState('auth-failed') + rejectAllPending('Unauthorized — pairing may be revoked') + return + } + + const isStreaming = response.ok && (response as RpcSuccess).streaming === true + + if (isStreaming) { + const stream = streamListeners.get(response.id) + if (stream && response.ok) { + stream.listener((response as RpcSuccess).result) + } + return + } + + if (response.ok) { + const result = (response as RpcSuccess).result as Record | null + if (result && result.type === 'end') { + const stream = streamListeners.get(response.id) + if (stream) { + stream.listener(result) + streamListeners.delete(response.id) + return + } + } + if (result && result.type === 'scrollback') { + const stream = streamListeners.get(response.id) + if (stream) { + stream.listener(result) + return + } + } + } + + const req = pending.get(response.id) + if (req) { + pending.delete(response.id) + req.resolve(response) + } + } + + ws.onclose = () => { + handleSocketClosed(openingWs) + } + + ws.onerror = () => { + // onclose will fire after this + } + } + + function handleSocketClosed(closedWs: WebSocket) { + if (ws !== closedWs) { + return + } + clearConnectTimer() + ws = null + sharedKey = null + if (handshakeTimer) { + clearTimeout(handshakeTimer) + handshakeTimer = null + } + stopActivityProbe() + if (intentionallyClosed) { + setState('disconnected') + rejectAllPending('Connection closed') + return + } + rejectAllPending('Connection interrupted') + setState('reconnecting') + scheduleReconnect() + } + + function scheduleReconnect() { + const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! + reconnectAttempt++ + reconnectTimer = setTimeout(() => { + reconnectTimer = null + openConnection() + }, delay) + } + + function clearConnectTimer() { + if (connectTimer) { + clearTimeout(connectTimer) + connectTimer = null + } + } + + // Why: app-level liveness probe — see ACTIVITY_PROBE_INTERVAL_MS comment + // at the top of the file. Fires while the channel is in 'connected' + // state, sends a tiny status.get, and force-closes the WS if the probe + // fails (which the existing onclose path then turns into a reconnect). + function startActivityProbe() { + stopActivityProbe() + activityProbeTimer = setInterval(() => { + // Why: only probe while the channel is actually in 'connected'. The + // sendRequest path itself waits for connected, but a probe scheduled + // during a reconnect would just stack up timeouts and confuse logs. + if (state !== 'connected' || !ws) return + const probeWs = ws + // Why: short timeout (8s) — server's heartbeat is 15s, so if we + // don't see *anything* back within 8s the link is almost certainly + // half-open. Using REQUEST_TIMEOUT_MS (30s) here would make the + // user wait nearly a minute before reconnect kicks in. + const id = nextId() + let timedOut = false + const timeout = setTimeout(() => { + timedOut = true + pending.delete(id) + // Why: only force-close if this is still the same socket the + // probe was sent on; a normal close that already swapped `ws` + // shouldn't trigger a redundant terminate. + if (probeWs === ws && probeWs.readyState === WebSocket.OPEN) { + probeWs.close() + } + }, 8_000) + pending.set(id, { + resolve: () => { + if (timedOut) return + clearTimeout(timeout) + }, + reject: () => { + if (timedOut) return + clearTimeout(timeout) + } + }) + if (!sendEncrypted({ id, deviceToken, method: 'status.get' })) { + clearTimeout(timeout) + pending.delete(id) + } + }, ACTIVITY_PROBE_INTERVAL_MS) + } + + function stopActivityProbe() { + if (activityProbeTimer) { + clearInterval(activityProbeTimer) + activityProbeTimer = null + } + } + + function rejectAllPending(reason: string) { + const error = new Error(reason) + for (const [id, req] of pending) { + pending.delete(id) + queueMicrotask(() => req.reject(error)) + } + } + + function sendEncrypted(request: unknown): boolean { + if (ws && ws.readyState === WebSocket.OPEN && sharedKey) { + ws.send(encrypt(JSON.stringify(request), sharedKey)) + return true + } + return false + } + + openConnection() + + return { + async sendRequest(method: string, params?: unknown): Promise { + await waitForConnected() + + return new Promise((resolve, reject) => { + const id = nextId() + const timeout = setTimeout(() => { + pending.delete(id) + reject(new Error(`Request timed out: ${method}`)) + }, REQUEST_TIMEOUT_MS) + + pending.set(id, { + resolve: (response) => { + clearTimeout(timeout) + resolve(response) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + + if (!sendEncrypted({ id, deviceToken, method, params })) { + pending.delete(id) + clearTimeout(timeout) + reject(new Error('Connection interrupted')) + } + }) + }, + + subscribe(method: string, params: unknown, onData: StreamingListener): () => void { + const id = nextId() + streamListeners.set(id, { method, params, listener: onData }) + + if (state === 'connected') { + sendEncrypted({ id, deviceToken, method, params }) + } + + return () => { + const stream = streamListeners.get(id) + streamListeners.delete(id) + if ( + stream?.method === 'terminal.subscribe' && + stream.params && + typeof stream.params === 'object' && + typeof (stream.params as { terminal?: unknown }).terminal === 'string' + ) { + // Why: the runtime registers cleanup under the composite key + // `${terminal}:${clientId}` so two phones subscribing to the same + // terminal handle don't evict each other. Echo that composite key + // back on unsubscribe; also include `client.id` so the server can + // reconstruct it if a stale build emits a bare-handle id. See + // docs/mobile-presence-lock.md. + const subscribeParams = stream.params as { + terminal: string + client?: { id?: string } + } + const clientId = + typeof subscribeParams.client?.id === 'string' ? subscribeParams.client.id : undefined + const subscriptionId = clientId + ? `${subscribeParams.terminal}:${clientId}` + : subscribeParams.terminal + sendEncrypted({ + id: nextId(), + deviceToken, + method: 'terminal.unsubscribe', + params: { + subscriptionId, + ...(clientId ? { client: { id: clientId } } : {}) + } + }) + } + } + }, + + getState(): ConnectionState { + return state + }, + + getReconnectAttempt(): number { + return reconnectAttempt + }, + + onStateChange(listener: (state: ConnectionState) => void): () => void { + stateListeners.add(listener) + return () => stateListeners.delete(listener) + }, + + close() { + intentionallyClosed = true + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + clearConnectTimer() + if (handshakeTimer) { + clearTimeout(handshakeTimer) + handshakeTimer = null + } + stopActivityProbe() + if (ws) { + ws.close() + ws = null + } + sharedKey = null + setState('disconnected') + rejectAllPending('Client closed') + } + } +} diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts new file mode 100644 index 00000000000..2e9e8fcf7ff --- /dev/null +++ b/mobile/src/transport/types.ts @@ -0,0 +1,75 @@ +import { z } from 'zod' + +export type RpcRequest = { + id: string + deviceToken: string + method: string + params?: unknown +} + +export type RpcSuccess = { + id: string + ok: true + result: unknown + streaming?: true + _meta: { runtimeId: string } +} + +export type RpcFailure = { + id: string + ok: false + error: { code: string; message: string; data?: unknown } + _meta: { runtimeId: string } +} + +export type RpcResponse = RpcSuccess | RpcFailure + +export const PAIRING_OFFER_VERSION = 2 + +export const PairingOfferSchema = z.object({ + v: z.literal(PAIRING_OFFER_VERSION), + endpoint: z.string().min(1), + deviceToken: z.string().min(1), + publicKeyB64: z.string().min(1) +}) + +export type PairingOffer = z.infer + +export type ConnectionState = + | 'connecting' + | 'handshaking' + | 'connected' + | 'disconnected' + | 'reconnecting' + | 'auth-failed' + +export type HostProfile = { + id: string + name: string + endpoint: string + deviceToken: string + publicKeyB64: string + lastConnected: number +} + +export const HostProfileSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + endpoint: z.string().min(1), + deviceToken: z.string().min(1), + publicKeyB64: z.string().min(1), + lastConnected: z.number().finite() +}) + +// Why: persisted host record after the v0.0.3 keychain split. The +// deviceToken is held in iOS Keychain via expo-secure-store and joined +// in at load time; it must NOT appear in AsyncStorage anymore. +export const StoredHostProfileSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + endpoint: z.string().min(1), + publicKeyB64: z.string().min(1), + lastConnected: z.number().finite() +}) + +export type StoredHostProfile = z.infer diff --git a/mobile/terminal-output-streaming-findings.md b/mobile/terminal-output-streaming-findings.md new file mode 100644 index 00000000000..5f9aac2b23a --- /dev/null +++ b/mobile/terminal-output-streaming-findings.md @@ -0,0 +1,56 @@ +# Mobile Terminal Output Streaming Findings + +Date: 2026-04-28 + +## Scope + +The investigation was limited to `mobile/`. Server-side files under `src/main/` were read for context only and were not modified. + +## Verified Findings + +- The phone can connect to `ws://192.168.0.179:6768`, list worktrees, list terminals, and call `terminal.send`. +- The phone's saved host token is valid; a direct WebSocket probe from the desktop using the same token can call `worktree.ps`, `terminal.list`, `terminal.subscribe`, `terminal.send`, and `terminal.read`. +- `TerminalWebView` can render text when the React Native side writes to it after xterm initializes. A temporary marker written after `init()` appeared visibly in the WebView. +- Messages posted to the WebView before its page installs message handlers can be dropped. The mobile fix queues `init`, `write`, and `clear` until the WebView reports `web-ready`. +- The WebView also has an internal queue for writes that arrive after `web-ready` but before xterm finishes `init()`. +- The selected physical-phone test worktree was `refs/heads/tasks-improvements` at `/Users/jinwoohong/orca/workspaces/orca/pr-1172-review`. +- For that test worktree, `terminal.subscribe` produced an initial `scrollback` event with an empty `lines` array and no serialized buffer. +- Sending commands to that test terminal returned `ok:true`, but a direct WebSocket `terminal.read` for the same handle still returned an empty tail and no live `data` chunks were observed. +- Creating a fresh terminal with `terminal.create` in that same worktree also returned a writable handle, but `terminal.send` followed by delayed `terminal.read` still returned an empty tail. +- Adding a mobile-side `terminal.read` fallback did not make the current physical-phone test terminal display output, because the direct WebSocket `terminal.read` response for `mobile-output-test` remained `tail: []` and `lastOutputAt: null` after `echo hi`. +- Calling `terminal.show` before `terminal.send` on `mobile-output-test` returned a connected/writable terminal with a `ptyId`, but a delayed `terminal.read` still returned an empty tail. +- Root cause found after building a no-phone repro: daemon-backed PTYs were forwarding provider data to the desktop renderer, but not into `runtime.onPtyData`. The runtime tail buffer and `terminal.subscribe` listeners are fed by `runtime.onPtyData`, so the phone saw accepted sends with no output. +- Fix: `src/main/ipc/pty.ts` now forwards `provider.onData` into `runtime.onPtyData` for non-`LocalPtyProvider` providers. Local PTYs already use the LocalPtyProvider configure hook, so the guard avoids duplicate local output. +- After restarting Electron, `pnpm exec tsx mobile/scripts/test-subscribe.ts ` passed: both `streamSawMarker` and `readSawMarker` were `true`. +- After the backend fix, the physical phone rendered the repro marker in `TerminalWebView`. +- Because of the empty tail/no live stream on that specific desktop terminal, the physical `ls` test did not prove the WebView display path after real PTY output. + +## Changes Made + +- `mobile/src/terminal/TerminalWebView.tsx` + - Added a native-side queue so WebView messages are not sent until the HTML reports `web-ready`. + - Added a WebView-side queue so writes wait until xterm finishes `init()`. +- `mobile/src/transport/rpc-client.ts` + - Stored full stream request metadata so active streams can be replayed after reconnect. + - Avoided sending a subscription before the socket reaches `connected`. + - Fixed `terminal.unsubscribe` to use the terminal handle as `subscriptionId`, matching runtime cleanup behavior. +- `mobile/app/h/[hostId]/session/[worktreeId].tsx` + - Clears and resubscribes when the worktree changes. + - Tracks the active terminal handle in a ref so stale handles are replaced when `terminal.list` changes. + - Routes scrollback and live data into `TerminalWebView`. + +## Useful Commands + +```bash +agent-device devices --json +agent-device snapshot --platform android --serial R3CX105QXRH --json +agent-device fill @e33 "ls" --platform android --serial R3CX105QXRH --json +agent-device click @e34 --platform android --serial R3CX105QXRH --json +agent-device screenshot /tmp/mobile-terminal.png --platform android --serial R3CX105QXRH --json +orca terminal send --terminal term_926b8898-f843-461a-acd2-482f741327ad --text r --json +orca terminal read --terminal term_926b8898-f843-461a-acd2-482f741327ad --json +``` + +## Next Debug Boundary + +Do not repeat WebView readiness tests unless the WebView changes again; the WebView can render post-init writes. For future regressions, run the no-phone repro first. If it fails, debug the WebSocket/runtime/PTY bridge before touching the mobile UI. If it passes but the phone is blank, debug `TerminalWebView` or session-screen routing. diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json new file mode 100644 index 00000000000..6aea44464c3 --- /dev/null +++ b/mobile/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["**/*.test.ts", "**/*.test.tsx"] +} diff --git a/orca.yaml b/orca.yaml index 0a6d170e0a2..5dad55e8200 100644 --- a/orca.yaml +++ b/orca.yaml @@ -1,3 +1,5 @@ scripts: setup: | + [ -n "$ORCA_INTERNAL_DEV_SETUP" ] && [ -x "$ORCA_INTERNAL_DEV_SETUP" ] && \ + "$ORCA_INTERNAL_DEV_SETUP" "$ORCA_WORKTREE_PATH" || true pnpm install diff --git a/package.json b/package.json index 92f55da5dc8..e4f95a336a3 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "orca", - "version": "1.3.24-rc.0", - "description": "An Electron application with React and TypeScript", + "version": "1.3.37-rc.1", + "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", "bin": { - "orca": "./out/cli/index.js" + "orca": "./out/cli/index.js", + "orca-dev": "./config/scripts/orca-dev" }, "main": "./out/main/index.js", "scripts": { @@ -23,12 +24,13 @@ "typecheck": "tsc --noEmit -p config/tsconfig.node.json --composite false && tsc --noEmit -p config/tsconfig.cli.json --composite false && tsc --noEmit -p config/tsconfig.web.json --composite false", "start": "electron-vite preview", "dev": "node config/scripts/run-electron-vite-dev.mjs", - "build:relay": "node scripts/build-relay.mjs", - "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false", + "build:relay": "node config/scripts/build-relay.mjs", + "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/install-dev-cli.mjs", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", "build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli", "build:release": "pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli", - "postinstall": "pnpm rebuild electron && node scripts/rebuild-native-deps.mjs", + "postinstall": "pnpm rebuild electron && node config/scripts/rebuild-native-deps.mjs", + "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "build:unpack": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --dir", "build:win": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --win", "build:icons": "bash resources/icon-source/generate.sh", @@ -72,6 +74,7 @@ "@xterm/headless": "6.1.0-beta.198", "@xterm/xterm": "6.1.0-beta.198", "agent-browser": "~0.24.1", + "better-sqlite3": "^12.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -87,6 +90,8 @@ "monaco-editor": "^0.55.1", "node-pty": "^1.1.0", "pdfjs-dist": "^5.6.205", + "posthog-node": "^5.33.0", + "qrcode": "^1.5.4", "radix-ui": "^1.4.3", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", @@ -99,11 +104,12 @@ "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "shadcn": "^4.1.0", - "simple-git": "^3.33.0", + "simple-git": "^3.36.0", "sonner": "^2.0.7", "ssh2": "^1.17.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "tweetnacl": "^1.0.3", "ws": "^8.20.0", "zod": "^4.3.6", "zustand": "^5.0.12" @@ -114,7 +120,9 @@ "@playwright/test": "^1.59.1", "@stablyai/playwright-test": "^2.1.13", "@tailwindcss/vite": "^4.2.2", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.5.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/ssh2": "^1.15.5", @@ -145,18 +153,22 @@ "oxfmt --write" ] }, + "engines": { + "node": "24" + }, "packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a", "pnpm": { "onlyBuiltDependencies": [ "@parcel/watcher", + "better-sqlite3", "cpu-features", "electron", "esbuild", "node-pty" ], "patchedDependencies": { - "node-pty@1.1.0": "patches/node-pty@1.1.0.patch", - "@xterm/addon-ligatures@0.11.0-beta.198": "patches/@xterm__addon-ligatures@0.11.0-beta.198.patch" + "node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch", + "@xterm/addon-ligatures@0.11.0-beta.198": "config/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04678674d1a..0c995a70eb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,10 @@ settings: patchedDependencies: '@xterm/addon-ligatures@0.11.0-beta.198': hash: df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634 - path: patches/@xterm__addon-ligatures@0.11.0-beta.198.patch + path: config/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch node-pty@1.1.0: - hash: 02e16954edbb8e511963557e3b2c80f8a6fb230cd0c6a75ade936022f3347322 - path: patches/node-pty@1.1.0.patch + hash: d39825bae630ddf0fc73e83e6001057c70957161772ba078988767d8569cee63 + path: config/patches/node-pty@1.1.0.patch importers: @@ -115,6 +115,9 @@ importers: agent-browser: specifier: ~0.24.1 version: 0.24.1 + better-sqlite3: + specifier: ^12.9.0 + version: 12.9.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -156,10 +159,16 @@ importers: version: 0.55.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=02e16954edbb8e511963557e3b2c80f8a6fb230cd0c6a75ade936022f3347322) + version: 1.1.0(patch_hash=d39825bae630ddf0fc73e83e6001057c70957161772ba078988767d8569cee63) pdfjs-dist: specifier: ^5.6.205 version: 5.6.205 + posthog-node: + specifier: ^5.33.0 + version: 5.33.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -197,8 +206,8 @@ importers: specifier: ^4.1.0 version: 4.1.0(@types/node@25.5.0)(typescript@5.9.3) simple-git: - specifier: ^3.33.0 - version: 3.33.0 + specifier: ^3.36.0 + version: 3.36.0 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -211,6 +220,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 ws: specifier: ^8.20.0 version: 8.20.0 @@ -236,9 +248,15 @@ importers: '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 '@types/node': specifier: ^25.5.0 version: 25.5.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -1476,6 +1494,12 @@ packages: engines: {node: '>=18'} hasBin: true + '@posthog/core@1.28.0': + resolution: {integrity: sha512-753giUMWuk602UtS101tDZuNcwiKkr+3UEhLgfOwHAk2W32n53knOxAjyWT0JwMq5/+0uSQ2y4uaZXQAxwvBSw==} + + '@posthog/types@1.372.6': + resolution: {integrity: sha512-sqI36LBvuo8xcYsXIlVa0q3IXJJjqtatM2LrXlyOM7kgHrldBwS4ldzaTXrTdpe/TiIl1b4ZHxtSHMzPig+DnQ==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2314,6 +2338,12 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@simple-git/args-pathspec@1.0.3': + resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==} + + '@simple-git/argv-parser@1.1.1': + resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -2672,6 +2702,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -2819,6 +2852,9 @@ packages: '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3126,6 +3162,13 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + better-sqlite3@12.9.0: + resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bippy@0.5.32: resolution: {integrity: sha512-yt1mC8eReTxjfg41YBZdN4PvsDwHFWxltoiQX0Q+Htlbf41aSniopb7ECZits01HwNAvXEh69RGk/ImlswDTEw==} peerDependencies: @@ -3217,6 +3260,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001780: resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} @@ -3256,6 +3303,9 @@ packages: resolution: {integrity: sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==} engines: {node: '>=22.0.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -3298,6 +3348,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -3612,6 +3665,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -3627,6 +3684,10 @@ packages: babel-plugin-macros: optional: true + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -3690,6 +3751,9 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -3898,6 +3962,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3970,6 +4038,9 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} @@ -3981,6 +4052,10 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -4005,6 +4080,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -4088,6 +4166,9 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -4293,6 +4374,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -4607,6 +4691,10 @@ packages: resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} engines: {node: '>=20.0.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} @@ -4950,6 +5038,9 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true @@ -4985,10 +5076,17 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-abi@3.89.0: + resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} + engines: {node: '>=10'} + node-abi@4.28.0: resolution: {integrity: sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==} engines: {node: '>=22.12.0'} @@ -5117,10 +5215,18 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-map@7.0.4: resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} engines: {node: '>=18'} @@ -5129,6 +5235,10 @@ packages: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -5163,6 +5273,10 @@ packages: path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -5231,6 +5345,10 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -5249,6 +5367,15 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + posthog-node@5.33.0: + resolution: {integrity: sha512-9Sj1kKQTFlHoiWl0LvD3+8InhlD9jxeIiJrMLtU87bnYuQUDqAVR16v9/ZGaQ9C0vDVGVJJQG1a6XYeySy9hmg==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + postject@1.0.0-alpha.6: resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} engines: {node: '>=14.0.0'} @@ -5258,6 +5385,12 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -5331,6 +5464,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -5363,6 +5501,10 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -5477,6 +5619,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -5608,6 +5753,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -5649,8 +5797,14 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - simple-git@3.33.0: - resolution: {integrity: sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-git@3.36.0: + resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==} simple-update-notifier@2.0.0: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} @@ -5795,6 +5949,10 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -5826,6 +5984,13 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar@7.5.12: resolution: {integrity: sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==} engines: {node: '>=18'} @@ -5917,12 +6082,18 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} @@ -6176,6 +6347,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -6235,6 +6409,9 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6254,10 +6431,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -7292,6 +7477,12 @@ snapshots: dependencies: playwright: 1.59.1 + '@posthog/core@1.28.0': + dependencies: + '@posthog/types': 1.372.6 + + '@posthog/types@1.372.6': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -8129,6 +8320,12 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@simple-git/args-pathspec@1.0.3': {} + + '@simple-git/argv-parser@1.1.1': + dependencies: + '@simple-git/args-pathspec': 1.0.3 + '@sindresorhus/is@4.6.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -8484,6 +8681,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 25.5.0 + '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -8667,6 +8868,10 @@ snapshots: xmlbuilder: 15.1.1 optional: true + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 25.5.0 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -8980,6 +9185,15 @@ snapshots: dependencies: tweetnacl: 0.14.5 + better-sqlite3@12.9.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bippy@0.5.32(@types/react@19.2.14)(react@19.2.4): dependencies: '@types/react-reconciler': 0.28.9(@types/react@19.2.14) @@ -9122,6 +9336,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001780: {} ccount@2.0.1: {} @@ -9156,6 +9372,8 @@ snapshots: '@chevrotain/types': 12.0.0 '@chevrotain/utils': 12.0.0 + chownr@1.1.4: {} + chownr@3.0.0: {} chromium-pickle-js@0.2.0: {} @@ -9191,6 +9409,12 @@ snapshots: cli-width@4.1.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -9505,6 +9729,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -9515,6 +9741,8 @@ snapshots: dedent@1.7.2: {} + deep-extend@0.6.0: {} + deepmerge@4.3.1: {} default-browser-id@5.0.1: {} @@ -9569,6 +9797,8 @@ snapshots: diff@8.0.3: {} + dijkstrajs@1.0.3: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.5 @@ -9887,6 +10117,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expand-template@2.0.3: {} + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -9985,6 +10217,8 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 + file-uri-to-path@1.0.0: {} + filelist@1.0.6: dependencies: minimatch: 5.1.9 @@ -10004,6 +10238,11 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -10027,6 +10266,8 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: {} + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -10113,6 +10354,8 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 + github-from-package@0.0.0: {} + github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -10401,6 +10644,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + inline-style-parser@0.2.7: {} internmap@1.0.1: {} @@ -10635,6 +10880,10 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash-es@4.18.1: {} lodash.escaperegexp@4.1.2: {} @@ -11224,6 +11473,8 @@ snapshots: dependencies: minipass: 7.1.3 + mkdirp-classic@0.5.3: {} + mkdirp@0.5.6: dependencies: minimist: 1.2.8 @@ -11274,8 +11525,14 @@ snapshots: nanoid@3.3.11: {} + napi-build-utils@2.0.0: {} + negotiator@1.0.0: {} + node-abi@3.89.0: + dependencies: + semver: 7.7.4 + node-abi@4.28.0: dependencies: semver: 7.7.4 @@ -11312,7 +11569,7 @@ snapshots: transitivePeerDependencies: - supports-color - node-pty@1.1.0(patch_hash=02e16954edbb8e511963557e3b2c80f8a6fb230cd0c6a75ade936022f3347322): + node-pty@1.1.0(patch_hash=d39825bae630ddf0fc73e83e6001057c70957161772ba078988767d8569cee63): dependencies: node-addon-api: 7.1.1 @@ -11452,10 +11709,18 @@ snapshots: p-cancelable@2.1.1: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-map@7.0.4: {} p-retry@4.6.2: @@ -11463,6 +11728,8 @@ snapshots: '@types/retry': 0.12.0 retry: 0.13.1 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} package-manager-detector@1.6.0: {} @@ -11500,6 +11767,8 @@ snapshots: path-data-parser@0.1.0: {} + path-exists@4.0.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -11554,6 +11823,8 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + pngjs@5.0.0: {} + pngjs@7.0.0: {} points-on-curve@0.2.0: {} @@ -11574,6 +11845,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-node@5.33.0: + dependencies: + '@posthog/core': 1.28.0 + postject@1.0.0-alpha.6: dependencies: commander: 9.5.0 @@ -11581,6 +11856,21 @@ snapshots: powershell-utils@0.1.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.89.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -11688,6 +11978,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -11768,6 +12064,13 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -11952,6 +12255,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -12115,6 +12420,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + setprototypeof@1.2.0: {} shadcn@4.1.0(@types/node@25.5.0)(typescript@5.9.3): @@ -12200,10 +12507,20 @@ snapshots: signal-exit@4.1.0: {} - simple-git@3.33.0: + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + simple-git@3.36.0: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 + '@simple-git/args-pathspec': 1.0.3 + '@simple-git/argv-parser': 1.1.1 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -12353,6 +12670,8 @@ snapshots: strip-final-newline@4.0.0: {} + strip-json-comments@2.0.1: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -12381,6 +12700,21 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar@7.5.12: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -12467,10 +12801,16 @@ snapshots: tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + tw-animate-css@1.4.0: {} tweetnacl@0.14.5: {} + tweetnacl@1.0.3: {} + type-fest@0.13.1: optional: true @@ -12685,6 +13025,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -12737,6 +13079,8 @@ snapshots: xmlbuilder@15.1.1: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -12747,8 +13091,27 @@ snapshots: yaml@2.8.3: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/resources/build/entitlements.mac.plist b/resources/build/entitlements.mac.plist index bbe60166c73..f43bcc2705d 100644 --- a/resources/build/entitlements.mac.plist +++ b/resources/build/entitlements.mac.plist @@ -2,6 +2,18 @@ + com.apple.security.automation.apple-events + + com.apple.security.device.audio-input + + com.apple.security.device.bluetooth + + com.apple.security.device.camera + + com.apple.security.device.usb + + com.apple.security.personal-information.location + com.apple.security.cs.allow-dyld-environment-variables com.apple.security.cs.allow-jit diff --git a/resources/claude.webp b/resources/claude.webp new file mode 100644 index 00000000000..328fc8547c2 Binary files /dev/null and b/resources/claude.webp differ diff --git a/resources/gremlin.webp b/resources/gremlin.webp new file mode 100644 index 00000000000..9e831557a1c Binary files /dev/null and b/resources/gremlin.webp differ diff --git a/resources/opencode.webp b/resources/opencode.webp new file mode 100644 index 00000000000..fa2ac424457 Binary files /dev/null and b/resources/opencode.webp differ diff --git a/scripts/rebuild-native-deps.mjs b/scripts/rebuild-native-deps.mjs deleted file mode 100644 index 6fdb6b7b3ef..00000000000 --- a/scripts/rebuild-native-deps.mjs +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node -/** - * Why this script exists: - * - * The standard `electron-builder install-app-deps` uses @electron/rebuild - * internally but does not expose the `ignoreModules` option (as of - * electron-builder 26.x). On Windows dev machines that lack the full - * Visual C++ / Python build toolchain, `cpu-features@0.0.10` (an optional - * performance dependency of `ssh2`) fails to build with node-gyp because - * `buildcheck.gypi` is missing from the tarball. This causes the entire - * postinstall step to fail and prevents `pnpm install` from completing. - * - * This script replaces `electron-builder install-app-deps` in the postinstall - * lifecycle. It calls @electron/rebuild's JS API directly so that we can pass - * `ignoreModules: ['cpu-features']` on Windows. Skipping cpu-features is - * safe: ssh2 detects the missing native module and falls back to pure-JS CPU - * feature detection automatically. - * - * On macOS and Linux the full rebuild (including cpu-features) runs as usual. - */ - -import { rebuild } from '@electron/rebuild' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' - -const projectDir = process.cwd() -const electronVersion = JSON.parse( - readFileSync(resolve(projectDir, 'node_modules/electron/package.json'), 'utf8') -).version - -const ignoreModules = process.platform === 'win32' ? ['cpu-features'] : [] - -if (ignoreModules.length > 0) { - console.log(`[rebuild] Skipping modules on Windows: ${ignoreModules.join(', ')}`) -} - -try { - await rebuild({ - buildPath: projectDir, - electronVersion, - ignoreModules, - }) -} catch (/** @type {any} */ err) { - console.error('[rebuild] Native module rebuild failed:', err?.message ?? err) - process.exit(1) -} diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index 1fbc7c007e7..00000000000 --- a/skills-lock.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": 1, - "skills": { - "electron": { - "source": "vercel-labs/agent-browser", - "sourceType": "github", - "computedHash": "587f7d9187c237d909b9f446cf3ac357184342d54ceb7c430dec56fdbac6816c" - }, - "react-useeffect": { - "source": "softaworks/agent-toolkit", - "sourceType": "github", - "computedHash": "8c24990b50f431a570f3ad1f60632dee154431861547103047f6ce80092ca734" - }, - "typescript": { - "source": "pproenca/dot-skills", - "sourceType": "github", - "computedHash": "3475c8e2a2e556833213dc2d4dff19e09ef166375a203b256b0d7f04f7d5d424" - } - } -} diff --git a/skills/mobile-fit-debug/SKILL.md b/skills/mobile-fit-debug/SKILL.md new file mode 100644 index 00000000000..63535e24172 --- /dev/null +++ b/skills/mobile-fit-debug/SKILL.md @@ -0,0 +1,182 @@ +--- +name: mobile-fit-debug +description: Debug mobile terminal display-mode toggle and PTY resize issues. Use when investigating why a terminal restores to the wrong width after toggling between phone and desktop mode on mobile, or when PTY sizes are corrupted across tab switches. +--- + +# Mobile Fit Debug + +Use this skill when debugging mobile terminal display-mode toggle issues — wrong restore width, corrupted previousCols, stale PTY sizes, or collateral safeFit cascades. + +## Architecture Overview + +The mobile display-mode toggle involves **3 processes** communicating asynchronously: + +``` +Mobile App (React Native) Server (Electron main) Desktop Renderer (Electron renderer) +───────────────────────── ────────────────────── ──────────────────────────────────── +terminal.setDisplayMode ──RPC──► applyMobileDisplayMode + ├─ resize PTY (authoritative) + ├─ suppressResizesForMs(500) + ├─ terminalFitOverrideChanged ──IPC──► setOverrideTick (React re-render) + │ ├─ safeFit on ALL panes + │ └─ pty:resize IPC ──────► SUPPRESSED + └─ notifyTerminalResize ──stream──► mobile client updates +``` + +## Key Files + +| File | Role | +|------|------| +| `src/main/runtime/orca-runtime.ts` | Server-side state: `mobileSubscribers`, `mobileDisplayModes`, `lastRendererSizes`, suppress window. Core handlers: `handleMobileSubscribe`, `handleMobileUnsubscribe`, `applyMobileDisplayMode`, `onExternalPtyResize` | +| `src/main/ipc/pty.ts` | `pty:resize` IPC handler. Checks `isResizeSuppressed()` before processing. Calls `onExternalPtyResize` to update server state | +| `src/main/runtime/rpc/methods/terminal.ts` | RPC methods: `terminal.subscribe` (mobile subscription lifecycle), `terminal.setDisplayMode`, `terminal.getDisplayMode` | +| `src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts` | Desktop renderer's override cache. `setFitOverride('desktop-fit')` clears the override. `notifyChange` fires `onOverrideChange` listeners | +| `src/renderer/src/lib/pane-manager/pane-tree-ops.ts` | `safeFit()` — measures pane DOM and resizes xterm. When a mobile-fit override exists, uses override dims instead of measuring | +| `src/renderer/src/components/terminal-pane/TerminalPane.tsx` | `onOverrideChange` handler triggers `setOverrideTick` re-render cascade. This is what causes safeFit to run on ALL panes | +| `mobile/app/h/[hostId]/session/[worktreeId].tsx` | Mobile session screen — tab switching, `toggleDisplayMode`, terminal subscription management | +| `src/main/runtime/mobile-subscribe-integration.test.ts` | Integration tests for the full subscribe/unsubscribe/toggle lifecycle | + +## Adding Debug Logging + +Production code has no debug logging. To investigate issues, temporarily add file-based logging to the key files. Here's a pattern that works well: + +### 1. Add mfLog helper to each file you want to instrument + +```typescript +// In orca-runtime.ts (no prefix): +import { appendFileSync } from 'fs' +function mfLog(msg: string): void { + try { appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] ${msg}\n`) } catch {} +} + +// In pty.ts (prefix [pty-ipc]): +function mfLog(msg: string): void { + try { appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] [pty-ipc] ${msg}\n`) } catch {} +} + +// In terminal.ts RPC methods (prefix [rpc]): +function mfLog(msg: string): void { + try { appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] [rpc] ${msg}\n`) } catch {} +} +``` + +### 2. Useful log points + +**orca-runtime.ts — `handleMobileSubscribe`:** +```typescript +mfLog(`handleMobileSubscribe ptyId=${ptyId} mode=${mode} viewport=${viewport?.cols}x${viewport?.rows}`) +mfLog(` existing.prev=${existing?.previousCols}x${existing?.previousRows} currentSize=${currentSize?.cols}x${currentSize?.rows} rendererSize=${rendererSize?.cols}x${rendererSize?.rows}`) +mfLog(` → previousCols=${previousCols}`) +``` + +**orca-runtime.ts — `applyMobileDisplayMode`:** +```typescript +mfLog(`applyMobileDisplayMode ptyId=${ptyId} mode=${mode} sub.prev=${subscriber?.previousCols}x${subscriber?.previousRows}`) +mfLog(` DESKTOP RESTORE: previousCols=${previousCols}`) +``` + +**orca-runtime.ts — `onExternalPtyResize`:** +```typescript +mfLog(`onExternalPtyResize ptyId=${ptyId} cols=${cols} rows=${rows}`) +``` + +**pty.ts — `pty:resize` handler:** +```typescript +if (runtime?.isResizeSuppressed()) { + mfLog(`pty:resize SUPPRESSED id=${args.id} cols=${args.cols} rows=${args.rows}`) + return +} +mfLog(`pty:resize id=${args.id} cols=${args.cols} rows=${args.rows}`) +``` + +**terminal.ts — `terminal.subscribe`:** +```typescript +mfLog(`\n========== MOBILE SUBSCRIBE ==========`) +mfLog(`terminal.subscribe handle=${params.terminal} ptyId=${leaf?.ptyId} client=${params.client?.type}`) +``` + +### 3. Reading the log + +```bash +> /tmp/mobile-fit-debug.log # clear +# ... reproduce issue on mobile ... +cat /tmp/mobile-fit-debug.log # read +``` + +### 4. Remember to remove logging before committing + +Remove all `mfLog` functions, calls, and `appendFileSync` imports. + +## What to Look For in Logs + +**Wrong previousCols captured:** +``` +handleMobileSubscribe ptyId=...@@abc123 ... + existing.prev=null currentSize=105x72 rendererSize=214x72 <-- rendererSize is WRONG + → previousCols=214 <-- captured wrong value +``` +This means `lastRendererSizes` was polluted by a collateral safeFit cascade. + +**Suppress window too short:** +``` +[pty-ipc] pty:resize id=...@@abc123 cols=105 rows=72 <-- NOT suppressed, arrived after window +``` +If you see unsuppressed `pty:resize` events for terminals that weren't the one being toggled, the 500ms suppress window may be too short. Check the timestamp gap between `applyMobileDisplayMode` and the stale resize. + +**Suppress working correctly:** +``` +[pty-ipc] pty:resize SUPPRESSED id=...@@abc123 cols=214 rows=72 +``` + +**Collateral cascade pattern** (the root cause of most bugs): +``` +applyMobileDisplayMode ptyId=...@@split mode=desktop + DESKTOP RESTORE: previousCols=105 +[pty-ipc] pty:resize SUPPRESSED id=...@@split cols=105 <-- redundant, suppressed OK +[pty-ipc] pty:resize SUPPRESSED id=...@@other cols=214 <-- collateral, suppressed OK +``` +If the collateral line says `pty:resize id=` instead of `SUPPRESSED`, the cascade leaked through. + +## Known Gotchas + +### 1. The Collateral safeFit Cascade +When `terminalFitOverrideChanged` fires for ONE pane, the desktop renderer's `setOverrideTick` React state change triggers a re-render that runs `safeFit` on ALL panes across ALL tabs. Background-tab panes get measured at wrong widths because they may not be visible or may be in different split configurations than the active tab. + +### 2. Cascade Timing is ~360ms +The full cascade path: server sends IPC -> desktop renderer receives -> React re-render -> requestAnimationFrame -> DOM measurement -> `pty:resize` IPC back to server. This takes ~360ms, which is why the suppress window must be >360ms (currently 500ms). + +### 3. lastRendererSizes Persistence +`lastRendererSizes` stores every `pty:resize` from the desktop renderer. Stale values persist across mobile subscribe/unsubscribe cycles. The `lastRendererSizes.delete(ptyId)` in desktop restore is critical — without it, a stale 214 from a previous cascade gets used as `previousCols` on the next subscribe. + +### 4. Daemon Doesn't Hot-Reload +The daemon process (`out/main/daemon-entry.js`) loads compiled code once at startup. Changes to `orca-runtime.ts` or `pty.ts` require: +1. `npx electron-vite build --outDir out` (or wait for watcher) +2. **Restart the dev server** (`pnpm dev`) — the daemon subprocess won't pick up new builds + +Verify your code is live: `grep "your_unique_string" out/main/index.js` + +### 5. previousCols Priority Chain +```typescript +previousCols = existing?.previousCols ?? rendererSize?.cols ?? currentSize?.cols +``` +- `existing?.previousCols` — re-subscribe case (tab switch back), most trusted +- `rendererSize?.cols` — from `lastRendererSizes`, the desktop renderer's last `pty:resize` +- `currentSize?.cols` — from `getTerminalSize()`, the server-side PTY size + +If `rendererSize` is polluted (e.g. 214 for a split pane), it takes priority over the correct `currentSize`. That's why clearing `lastRendererSizes` on restore is essential. + +### 6. agent-device for Automated Testing +Use `/opt/homebrew/bin/agent-device` to automate mobile UI testing: +```bash +agent-device snapshot # accessibility tree +agent-device click @e45 # click element by ref +agent-device screenshot --out /tmp/s.png # capture screenshot +agent-device wait 2 # wait 2 seconds +``` +Typical test flow: click tab -> click "Switch to desktop mode" -> wait 3s -> click "Switch to phone mode" -> switch tabs -> repeat -> read `/tmp/mobile-fit-debug.log`. + +## Running Tests + +```bash +npx vitest run src/main/runtime/mobile-subscribe-integration.test.ts +``` diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index 2e3305f9557..c9d51287e07 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -16,7 +16,6 @@ Use `orca` for: - reading and replying to Orca-managed terminals - stopping or waiting on Orca-managed terminals - accessing repos known to Orca - Do not use `orca` when plain shell tools are simpler and Orca state does not matter. Examples: @@ -179,7 +178,7 @@ Why: `--direction horizontal` splits the pane **left and right** (new pane appea - Use `terminal read` before `terminal send` unless the next input is obvious. - Use `terminal wait --terminal --for exit` only when the task actually depends on process completion. - Use `terminal wait --terminal --for tui-idle` to wait for an agent CLI (Claude Code, Gemini, Codex, etc.) to finish its current task. This detects the working→idle OSC title transition. Always pass `--timeout-ms` as a safety net — unsupported CLIs will hang until timeout. -- Use `terminal create` to spin up new terminal tabs programmatically, optionally with a `--command` for startup and `--title` for labeling. +- Use `terminal create` to spin up new terminal tabs programmatically, optionally with a `--command` for startup (e.g. `--command "claude"` to launch Claude Code) and `--title` for labeling. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching. - Use `terminal split` to create split panes within an existing terminal tab. Pass `--command` to run a command in the new pane. - Prefer Orca worktree selectors over hardcoded paths when Orca identity already exists. - If the user asks for CLI UX feedback, test the public `orca` command first. Only inspect `src/cli` or use `node out/cli/index.js` if the public command is missing or the task is explicitly about implementation internals. @@ -556,10 +555,11 @@ When `orca tab create` opens a new tab, it is automatically set as the active ta ## Important Constraints - Orca CLI only talks to a running Orca editor. -- Terminal handles are ephemeral and tied to the current Orca runtime. -- `terminal wait` supports `--for exit` (wait for process exit) and `--for tui-idle` (wait for a recognized agent CLI like Claude Code, Gemini, or Codex to finish its current task, detected via OSC title transitions). `tui-idle` defaults to a 5-minute timeout if `--timeout-ms` is not specified. -- Orca is the source of truth for worktree/terminal orchestration; do not duplicate that state with manual assumptions. +- Terminal handles are ephemeral and tied to the current Orca runtime. If Orca restarts, handles change. +- `terminal wait` supports `--for exit` (wait for process exit) and `--for tui-idle` (wait for a recognized agent CLI like Claude Code, Gemini, or Codex to finish its current task, detected via OSC title transitions). `tui-idle` defaults to a 5-minute timeout if `--timeout-ms` is not specified. Real coding tasks routinely take 15-60 minutes — always pass `--timeout-ms` explicitly. +- Orca is the source of truth for worktree/terminal state; do not duplicate that state with manual assumptions. - The public `orca` command is the interface users experience. Agents should validate and use that surface, not repo-local implementation entrypoints. +- The 120-line terminal output buffer (`terminal read`) is for status monitoring, not result extraction. ## References diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md new file mode 100644 index 00000000000..5de16074078 --- /dev/null +++ b/skills/orchestration/SKILL.md @@ -0,0 +1,196 @@ +--- +name: orchestration +description: Use the Orca CLI to coordinate multiple coding agents via inter-agent messaging, task DAGs, dispatch with preamble injection, decision gates, and coordinator loops. Use when an agent needs to send or check inter-agent messages; create, dispatch, or track orchestration tasks; coordinate multi-agent workflows; or act as a coordinator dispatching work across terminals. Triggers include "orchestrate agents", "dispatch task", "send message to agent", "check inbox", "coordinate agents", "multi-agent", "create task DAG", "worker_done", "escalation", or any task involving inter-agent coordination through Orca. +--- + +# Orca Inter-Agent Orchestration + +Use this skill when the task involves coordinating multiple coding agents through Orca's orchestration system. For basic terminal and worktree management, use the `orca-cli` skill instead. + +## When To Use + +- You need to send messages between agent terminals +- You need to decompose a spec into parallel subtasks with dependencies +- You need to dispatch tasks to worker agents with structured feedback +- You need to act as a coordinator managing a multi-agent workflow +- You need to create decision gates for human-in-the-loop checkpoints + +## Preconditions + +- Orca must be running (`orca status --json` should return `runtime: true`). +- The `orca` CLI must be on PATH (installed via Settings > Browser > Enable Orca CLI). +- The orchestration experimental feature must be enabled in Settings > Experimental. +- All `orca orchestration` commands are RPC calls to the running Orca runtime — they require an active Orca session. + +## Command Surface + +### Messaging + +Inter-agent messaging via persistent SQLite-backed mail store. Messages are delivered automatically when the recipient agent goes idle (push-on-idle). + +```bash +orca orchestration send --to --subject [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json] +orca orchestration check [--terminal ] [--unread] [--types ] [--inject] [--wait] [--timeout-ms ] [--json] +orca orchestration reply --id --body [--from ] [--json] +orca orchestration inbox [--limit ] [--json] +``` + +Why: `--from` auto-resolves via the `ORCA_TERMINAL_HANDLE` environment variable injected into every Orca-managed terminal. Omit it unless impersonating another terminal. + +Why: `--inject` formats messages as readable banners with priority indicators (`[HIGH]`, `[URGENT]`) for agent prompt injection. Use `--json` for machine-readable output. + +Why: `--wait` blocks until a matching message arrives or the timeout expires (default 2 minutes). This replaces sleep+poll loops. If unread messages already exist, returns immediately. Combine with `--types` to wait for specific message types (e.g. `--wait --types worker_done --timeout-ms 120000`). + +**Message types**: `status` (general), `dispatch` (assign work), `worker_done` (signal completion), `merge_ready` (branch ready for merge), `escalation` (issue requiring attention), `handoff` (pass work to another agent), `decision_gate` (human-in-the-loop). + +**Priority levels**: `normal`, `high`, `urgent`. + +**Group addresses** resolve to terminal handles: + +| Group | Resolves To | +|-------|------------| +| `@all` | All terminal handles except sender | +| `@idle` | Handles where the agent is currently idle | +| `@claude` | Handles running Claude Code | +| `@codex` | Handles running Codex | +| `@opencode` | Handles running OpenCode | +| `@gemini` | Handles running Gemini | +| `@worktree:` | All handles in a specific worktree | + +Group messages fan out: one message per recipient, shared `thread_id`, independent read tracking. + +### Tasks + +Task tracking with DAG dependencies. A task becomes `ready` when all tasks in its `deps` array are `completed`. + +```bash +orca orchestration task-create --spec [--deps ] [--parent ] [--json] +orca orchestration task-list [--status ] [--ready] [--json] +orca orchestration task-update --id --status [--result ] [--json] +``` + +**Task statuses**: `pending` (waiting on deps), `ready` (deps met, dispatchable), `dispatched` (assigned to a terminal), `completed`, `failed`, `blocked` (waiting on a decision gate). + +Why: when a task is marked `completed`, the runtime automatically promotes any pending tasks whose deps are now all satisfied to `ready`. This is the DAG resolution step. + +### Dispatch + +Dispatch assigns a ready task to a terminal. Optionally injects the task spec + preamble into the terminal so the agent knows how to communicate back. + +```bash +orca orchestration dispatch --task --to [--from ] [--inject] [--json] +orca orchestration dispatch-show --task [--json] +``` + +Why: `--inject` sends a preamble that teaches the agent how to use `orca orchestration send --type worker_done` to report completion. All agents have `orca` on PATH and can execute shell commands. The preamble maximizes structured feedback but the system works without it (coordinator falls back to idle detection + output reading). + +Why: `--inject` requires a recognized agent CLI (e.g. Claude Code) running in the target terminal. If the terminal is a bare shell, omit `--inject` and send the prompt manually with `terminal send`. + +Why: dispatch contexts are separate from tasks (sling pattern). A task can be dispatched, fail, and be re-dispatched to a different terminal — the task stays clean while dispatch contexts track retry state. + +**Circuit breaker**: After 3 consecutive failures on a task, the dispatch context is marked `circuit_broken`. The task is marked `failed` to prevent infinite retry loops. + +### Decision Gates + +Human-in-the-loop decision points that block a task until resolved. + +```bash +orca orchestration gate-create --task --question [--options ] [--json] +orca orchestration gate-resolve --id --resolution [--json] +orca orchestration gate-list [--task ] [--status ] [--json] +``` + +Why: creating a gate blocks the task and completes its active dispatch. Resolving a gate sets the task back to `ready` with the resolution context included in the next dispatch preamble. + +**Gate statuses**: `pending`, `resolved`, `timeout`. + +### Coordinator + +Start an automated coordinator loop that dispatches ready tasks, processes `worker_done`/`escalation` messages, and advances the task DAG. + +```bash +orca orchestration run --spec [--from ] [--poll-interval-ms ] [--max-concurrent ] [--worktree ] [--json] +orca orchestration run-stop [--json] +``` + +Why: `run` returns immediately with a run ID. The coordinator loop runs in the background inside the Orca runtime. Query progress via `orca orchestration task-list`. Only one coordinator can run at a time. + +**Coordinator phases**: `decomposing` → `dispatching` → `monitoring` → `merging` → `done`. + +### Lifecycle + +```bash +orca orchestration reset [--all] [--tasks] [--messages] [--json] +``` + +Why: `--all` is the default if no flags provided. `--tasks` clears tasks, dispatch contexts, decision gates, and coordinator runs but preserves messages. + +### Terminal Commands for Coordinators + +Coordinators need these terminal commands to spawn agents, monitor progress, and read output. Full terminal documentation lives in the `orca-cli` skill — this is the subset required for orchestration workflows. + +```bash +orca terminal list [--worktree ] [--json] +orca terminal create [--worktree ] [--title ] [--command ] [--json] +orca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json] +orca terminal read [--terminal ] [--json] +orca terminal send [--terminal ] --text [--enter] [--json] +orca terminal wait [--terminal ] --for [--timeout-ms ] [--json] +orca terminal show --terminal [--json] +orca terminal stop [--terminal ] [--json] +orca terminal close [--terminal ] [--json] +``` + +Why: `--terminal` is optional for most commands. When omitted, Orca auto-resolves to the active terminal in the current worktree. + +Why: `--command "claude"` launches Claude Code in the new terminal. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching. + +Why: `--for tui-idle` detects the working→idle OSC title transition for recognized agent CLIs (Claude Code, Gemini, Codex, etc.). Always pass `--timeout-ms` — real coding tasks routinely take 15-60 minutes. + +Why: `--direction horizontal` splits left/right (new pane to the right). `--direction vertical` splits top/bottom (new pane below). Default is horizontal. + +Why: terminal handles are runtime-scoped. If Orca restarts, handles go stale. Re-acquire with `terminal list`. + +Why: the 120-line terminal output buffer (`terminal read`) is for status monitoring, not result extraction. Prefer structured `worker_done` payloads over parsing terminal output. + +## Agent Guidance + +- When dispatched with a preamble, **always run the `worker_done` command when done**. This is the primary feedback mechanism — it keeps the coordinator's context window clean. +- If blocked or unable to complete a task, send an `escalation` message to the coordinator instead of silently stalling. +- Use `orca orchestration check` to read incoming messages from the coordinator or other agents. Messages are delivered automatically when you go idle, but you can also poll explicitly. +- Treat `orca orchestration` commands the same way you treat `git` or `npm` — they are CLI tools available in your shell. +- The coordinator uses `orca orchestration task-list --ready` as its external memory. Prefer querying orchestration state over tracking it in your context window. +- For multi-agent coordination, prefer the **inter-worktree** pattern (each agent in its own worktree) for parallel implementation tasks. Use **intra-worktree** (split panes, shared files) for complementary tasks where agents don't edit the same files. +- When acting as coordinator: discover existing agents with `terminal list`, create tasks with `task-create`, dispatch with `dispatch --inject`, and wait for `worker_done` messages via `check --wait --types worker_done,escalation --timeout-ms 300000`. +- When acting as coordinator: prefer `check --wait` over sleep+poll loops. `--wait` blocks until a message arrives, eliminating wasted time. Always pass `--timeout-ms` as a safety net. If the wait times out with no messages, fall back to `terminal wait --for tui-idle` and then reading terminal output. +- `check --wait` returns one message at a time. If N workers finish near-simultaneously, call `check --wait` N times in a loop to collect all results. After each return, mark the task complete (which auto-promotes dependents) and dispatch the next wave before looping back to wait. +- After receiving `worker_done` from a terminal, that terminal is guaranteed idle — skip the `terminal wait --for tui-idle` round-trip and dispatch the next task immediately. +- Terminal handles are ephemeral and runtime-scoped. If Orca restarts mid-workflow, all handles go stale. Re-acquire them with `terminal list` before continuing. +- Keep dependency chains to 3-4 steps maximum. Prefer parallel waves of independent tasks over deep sequential chains. +- Insert decision gates (`gate-create`) between phases for human oversight on risky operations. + +## Coordinator Worked Example + +Dispatch a task to a fresh Claude Code terminal and wait for completion: + +```bash +# 1. Create a terminal running Claude Code +orca terminal create --worktree active --title "worker-1" --command "claude" --json +# → handle: term_abc123 + +# 2. Wait for Claude Code to boot (tui-idle fires when the prompt appears) +orca terminal wait --terminal term_abc123 --for tui-idle --timeout-ms 60000 --json + +# 3. Create and dispatch a task with preamble injection +orca orchestration task-create --spec "Fix the login button CSS" --json +# → id: task_def456 +orca orchestration dispatch --task task_def456 --to term_abc123 --inject --json + +# 4. Block until the worker reports back (no sleep loops needed) +orca orchestration check --wait --types worker_done,escalation --timeout-ms 300000 --json +# → returns immediately when worker sends worker_done + +# 5. If --wait timed out with no messages, fall back to idle detection +orca terminal wait --terminal term_abc123 --for tui-idle --timeout-ms 60000 --json +orca terminal read --terminal term_abc123 --json +``` diff --git a/src/cli/args.ts b/src/cli/args.ts index e65c0b29081..7f13996deb3 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -64,7 +64,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean { if (['repo', 'worktree', 'terminal'].includes(commandPath[0])) { return false } - return !['tab list', 'tab create'].includes(joined) + return !['tab list', 'tab create', 'tab current'].includes(joined) } export function isCommandGroup(commandPath: string[]): boolean { @@ -82,7 +82,8 @@ export function isCommandGroup(commandPath: string[]): boolean { 'set', 'clipboard', 'dialog', - 'storage' + 'storage', + 'orchestration' ].includes(commandPath[0])) || (commandPath.length === 2 && commandPath[0] === 'storage' && diff --git a/src/cli/browser.test.ts b/src/cli/browser.test.ts index c0f518b8ede..c84a0f7e7b8 100644 --- a/src/cli/browser.test.ts +++ b/src/cli/browser.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: this file groups every CLI browser-command test (page targeting, profiles, waits, viewport) so test-fixture imports and the runtime-client mock stay shared in one place. */ import path from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -126,6 +127,369 @@ describe('orca cli browser page targeting', () => { worktree: `path:${path.resolve('/tmp/repo/feature')}` }) }) + + it('passes explicit profile ids to tab create', async () => { + queueFixtures(callMock, okFixture('req_create', { browserPageId: 'page-3' })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'tab', + 'create', + '--url', + 'https://example.com', + '--profile', + 'work', + '--worktree', + 'all', + '--json' + ], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith( + 'browser.tabCreate', + { + url: 'https://example.com', + worktree: undefined, + profileId: 'work' + }, + { timeoutMs: 60_000 } + ) + }) + + it('passes tab profile updates through by page id', async () => { + queueFixtures( + callMock, + okFixture('req_set_profile', { + browserPageId: 'page-2', + profileId: 'work', + profileLabel: 'Work' + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'set', '--page', 'page-2', '--profile', 'work', '--json'], + '/tmp/repo/feature/src' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabSetProfile', { + page: 'page-2', + profileId: 'work' + }) + }) + + it('shows tab profile labels in text mode when requested', async () => { + queueFixtures( + callMock, + okFixture('req_list', { + tabs: [ + { + browserPageId: 'page-1', + index: 0, + url: 'https://example.com', + title: 'Example', + active: true, + profileId: 'default', + profileLabel: 'Default' + }, + { + browserPageId: 'page-2', + index: 1, + url: 'https://mail.example.com', + title: 'Mail', + active: false, + profileId: 'work', + profileLabel: 'Work' + } + ] + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'list', '--show-profile', '--worktree', 'all'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabList', { worktree: undefined }) + expect(logSpy).toHaveBeenCalledWith( + '* [0] page-1 Example — https://example.com [Default]\n' + + ' [1] page-2 Mail — https://mail.example.com [Work]' + ) + }) + + it('shows a single tab by page id', async () => { + queueFixtures( + callMock, + okFixture('req_tab_show', { + tab: { + browserPageId: 'page-1', + index: 0, + url: 'https://example.com', + title: 'Example', + active: true, + worktreeId: 'repo::/tmp/repo/feature', + profileId: 'work', + profileLabel: 'Work' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'show', '--page', 'page-1', '--json'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabShow', { page: 'page-1' }) + }) + + it('resolves the current tab in a worktree', async () => { + queueFixtures( + callMock, + okFixture('req_tab_current', { + tab: { + browserPageId: 'page-2', + index: 1, + url: 'https://mail.example.com', + title: 'Mail', + active: true, + worktreeId: 'repo::/tmp/repo/feature', + profileId: 'default', + profileLabel: 'Default' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'current', '--worktree', 'all', '--json'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined }) + }) +}) + +describe('orca cli browser tab profiles', () => { + beforeEach(() => { + callMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows the profile bound to a tab', async () => { + queueFixtures( + callMock, + okFixture('req_profile_show', { + browserPageId: 'page-2', + worktreeId: 'repo::/tmp/repo/feature', + profileId: 'work', + profileLabel: 'Work' + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'show', '--page', 'page-2', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabProfileShow', { page: 'page-2' }) + }) + + it('switches a tab back to the default profile', async () => { + queueFixtures( + callMock, + okFixture('req_profile_default', { + browserPageId: 'page-2', + profileId: 'default', + profileLabel: 'Default' + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'use-default', '--page', 'page-2', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabSetProfile', { + page: 'page-2', + profileId: 'default' + }) + }) + + it('clones a tab into a different profile', async () => { + queueFixtures( + callMock, + okFixture('req_profile_clone', { + browserPageId: 'page-9', + sourceBrowserPageId: 'page-2', + profileId: 'work', + profileLabel: 'Work' + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'clone', '--page', 'page-2', '--profile', 'work', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabProfileClone', { + page: 'page-2', + profileId: 'work' + }) + }) +}) + +describe('orca cli browser tab profiles', () => { + beforeEach(() => { + callMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('lists browser tab profiles', async () => { + queueFixtures( + callMock, + okFixture('req_profiles', { + profiles: [ + { id: 'default', scope: 'default', label: 'Default', partition: 'persist:orca-browser' }, + { + id: 'work', + scope: 'isolated', + label: 'Work', + partition: 'persist:orca-browser-session-work' + } + ] + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'profile', 'list', '--json'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.profileList') + }) + + it('reports an empty browser tab profile list with a friendly message', async () => { + queueFixtures(callMock, okFixture('req_profiles', { profiles: [] })) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'profile', 'list'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.profileList') + expect(logSpy).toHaveBeenCalledWith('No browser profiles found.') + }) + + it('creates isolated browser tab profiles by default', async () => { + queueFixtures( + callMock, + okFixture('req_profile_create', { + profile: { + id: 'work', + scope: 'isolated', + label: 'Work', + partition: 'persist:orca-browser-session-work' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'create', '--label', 'Work', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.profileCreate', { + label: 'Work', + scope: 'isolated' + }) + }) + + it('forwards --scope imported through to the runtime', async () => { + queueFixtures( + callMock, + okFixture('req_profile_create', { + profile: { + id: 'imp', + scope: 'imported', + label: 'From Chrome', + partition: 'persist:orca-browser-session-imp' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'create', '--label', 'From Chrome', '--scope', 'imported', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledWith('browser.profileCreate', { + label: 'From Chrome', + scope: 'imported' + }) + }) + + it('rejects unknown --scope values instead of silently defaulting to isolated', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'create', '--label', 'Work', '--scope', 'isloated'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).not.toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalledWith('--scope must be "isolated" or "imported"') + }) + + it('surfaces a runtime error if the registry refuses to create a profile', async () => { + queueFixtures(callMock, okFixture('req_profile_create', { profile: null })) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['tab', 'profile', 'create', '--label', 'Bogus'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to create browser profile (label=Bogus, scope=isolated)' + ) + }) + + it('deletes browser tab profiles by id', async () => { + queueFixtures(callMock, okFixture('req_profile_delete', { deleted: true, profileId: 'work' })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'delete', '--profile', 'work', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.profileDelete', { profileId: 'work' }) + }) + + it('reports a not-deleted profile in text mode without throwing', async () => { + queueFixtures( + callMock, + okFixture('req_profile_delete', { deleted: false, profileId: 'default' }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'profile', 'delete', '--profile', 'default'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledWith('browser.profileDelete', { profileId: 'default' }) + expect(logSpy).toHaveBeenCalledWith('Profile default was not deleted') + }) }) describe('orca cli browser waits and viewport flags', () => { diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index fdde0baef8f..6dd41e3a554 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -7,10 +7,12 @@ 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' export type HandlerContext = { flags: Map @@ -31,10 +33,12 @@ function buildHandlers(): Map { BROWSER_NAV_HANDLERS, BROWSER_INTERACT_HANDLERS, BROWSER_TAB_HANDLERS, + BROWSER_PROFILE_HANDLERS, BROWSER_COOKIE_HANDLERS, BROWSER_CAPTURE_HANDLERS, BROWSER_ENV_HANDLERS, - BROWSER_STORAGE_HANDLERS + BROWSER_STORAGE_HANDLERS, + ORCHESTRATION_HANDLERS ] for (const group of groups) { for (const [key, handler] of Object.entries(group)) { diff --git a/src/cli/format.ts b/src/cli/format.ts index b1d1ac43269..2ea429e13a1 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -1,7 +1,12 @@ import type { + BrowserProfileListResult, + BrowserTabCurrentResult, BrowserScreenshotResult, BrowserSnapshotResult, BrowserTabListResult, + BrowserTabProfileCloneResult, + BrowserTabProfileShowResult, + BrowserTabShowResult, CliStatusResult, RuntimeRepoList, RuntimeRepoSearchRefs, @@ -237,13 +242,59 @@ export function formatScreenshot(result: BrowserScreenshotResult): string { } export function formatTabList(result: BrowserTabListResult): string { + return formatTabListWithProfiles(result, false) +} + +export function formatTabListWithProfiles( + result: BrowserTabListResult, + showProfile: boolean +): string { if (result.tabs.length === 0) { return 'No browser tabs open.' } return result.tabs .map((t) => { const marker = t.active ? '* ' : ' ' - return `${marker}[${t.index}] ${t.browserPageId} ${t.title} — ${t.url}` + const profile = showProfile ? ` [${t.profileLabel ?? t.profileId ?? 'Unknown'}]` : '' + return `${marker}[${t.index}] ${t.browserPageId} ${t.title} — ${t.url}${profile}` }) .join('\n') } + +export function formatBrowserProfileList(result: BrowserProfileListResult): string { + if (result.profiles.length === 0) { + return 'No browser profiles found.' + } + return result.profiles + .map((profile) => { + const marker = profile.scope === 'default' ? '* ' : ' ' + const source = profile.source?.browserFamily ?? 'none' + return `${marker}${profile.id} ${profile.label} ${profile.scope} source:${source}` + }) + .join('\n') +} + +export function formatTabShow(result: BrowserTabShowResult | BrowserTabCurrentResult): string { + const tab = result.tab + return [ + `page: ${tab.browserPageId}`, + `title: ${tab.title}`, + `url: ${tab.url}`, + `active: ${tab.active}`, + `worktree: ${tab.worktreeId ?? 'unknown'}`, + `profile: ${tab.profileLabel ?? tab.profileId ?? 'unknown'}` + ].join('\n') +} + +export function formatTabProfileShow(result: BrowserTabProfileShowResult): string { + return [ + `page: ${result.browserPageId}`, + `worktree: ${result.worktreeId ?? 'unknown'}`, + `profileId: ${result.profileId ?? 'default'}`, + `profile: ${result.profileLabel ?? result.profileId ?? 'default'}` + ].join('\n') +} + +export function formatTabProfileClone(result: BrowserTabProfileCloneResult): string { + return `Cloned ${result.sourceBrowserPageId} to ${result.browserPageId} (${result.profileLabel ?? result.profileId ?? 'default'})` +} diff --git a/src/cli/handlers/browser-profile.ts b/src/cli/handlers/browser-profile.ts new file mode 100644 index 00000000000..4dc8ad731b3 --- /dev/null +++ b/src/cli/handlers/browser-profile.ts @@ -0,0 +1,106 @@ +import type { + BrowserProfileCreateResult, + BrowserProfileDeleteResult, + BrowserProfileListResult, + BrowserTabProfileCloneResult, + BrowserTabProfileShowResult, + BrowserTabSetProfileResult +} from '../../shared/runtime-types' +import type { CommandHandler } from '../dispatch' +import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' +import { + formatBrowserProfileList, + formatTabProfileClone, + formatTabProfileShow, + printResult +} from '../format' +import { RuntimeClientError } from '../runtime-client' +import { getBrowserCommandTarget } from '../selectors' + +function parseScopeFlag(flags: Map): 'isolated' | 'imported' { + const raw = getOptionalStringFlag(flags, 'scope') + if (raw === undefined || raw === 'isolated') { + return 'isolated' + } + if (raw === 'imported') { + return 'imported' + } + throw new RuntimeClientError('invalid_argument', '--scope must be "isolated" or "imported"') +} + +export const BROWSER_PROFILE_HANDLERS: Record = { + 'tab profile list': async ({ client, json }) => { + const result = await client.call('browser.profileList') + printResult(result, json, formatBrowserProfileList) + }, + 'tab profile create': async ({ flags, client, json }) => { + const label = getRequiredStringFlag(flags, 'label') + const scope = parseScopeFlag(flags) + const result = await client.call('browser.profileCreate', { + label, + scope + }) + if (result.result.profile === null) { + // Why: registry refuses non-isolated/imported scopes; we already validated + // the scope client-side, so a null here means a server-side rejection we + // shouldn't silently report as success. + throw new RuntimeClientError( + 'runtime_error', + `Failed to create browser profile (label=${label}, scope=${scope})` + ) + } + printResult( + result, + json, + (value) => + `Created profile ${value.profile?.id ?? 'unknown'} (${value.profile?.label ?? label})` + ) + }, + 'tab profile delete': async ({ flags, client, json }) => { + const profileId = getRequiredStringFlag(flags, 'profile') + const result = await client.call('browser.profileDelete', { + profileId + }) + printResult(result, json, (value) => + value.deleted + ? `Deleted profile ${value.profileId}` + : `Profile ${value.profileId} was not deleted` + ) + }, + 'tab profile set': async ({ flags, client, cwd, json }) => { + const profileId = getRequiredStringFlag(flags, 'profile') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.tabSetProfile', { + ...target, + profileId + }) + printResult( + result, + json, + (value) => + `Switched ${value.browserPageId} to ${value.profileLabel ?? value.profileId ?? 'default'}` + ) + }, + 'tab profile show': async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.tabProfileShow', target) + printResult(result, json, formatTabProfileShow) + }, + 'tab profile use-default': async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.tabSetProfile', { + ...target, + profileId: 'default' + }) + printResult(result, json, (value) => `Switched ${value.browserPageId} to Default`) + }, + 'tab profile clone': async ({ flags, client, cwd, json }) => { + const profileId = getRequiredStringFlag(flags, 'profile') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.tabProfileClone', { + ...target, + profileId + }) + printResult(result, json, formatTabProfileClone) + } +} diff --git a/src/cli/handlers/browser-tab.ts b/src/cli/handlers/browser-tab.ts index e842cd33c14..0fe3e151b4f 100644 --- a/src/cli/handlers/browser-tab.ts +++ b/src/cli/handlers/browser-tab.ts @@ -1,6 +1,11 @@ -import type { BrowserTabListResult, BrowserTabSwitchResult } from '../../shared/runtime-types' +import type { + BrowserTabCurrentResult, + BrowserTabListResult, + BrowserTabShowResult, + BrowserTabSwitchResult +} from '../../shared/runtime-types' import type { CommandHandler } from '../dispatch' -import { formatTabList, printResult } from '../format' +import { formatTabList, formatTabListWithProfiles, formatTabShow, printResult } from '../format' import { getOptionalNonNegativeIntegerFlag, getOptionalStringFlag, @@ -13,7 +18,20 @@ export const BROWSER_TAB_HANDLERS: Record = { 'tab list': async ({ flags, client, cwd, json }) => { const worktree = await getBrowserWorktreeSelector(flags, cwd, client) const result = await client.call('browser.tabList', { worktree }) - printResult(result, json, formatTabList) + const showProfile = flags.has('show-profile') + printResult(result, json, (value) => + showProfile ? formatTabListWithProfiles(value, true) : formatTabList(value) + ) + }, + 'tab show': async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.tabShow', target) + printResult(result, json, formatTabShow) + }, + 'tab current': async ({ flags, client, cwd, json }) => { + const worktree = await getBrowserWorktreeSelector(flags, cwd, client) + const result = await client.call('browser.tabCurrent', { worktree }) + printResult(result, json, formatTabShow) }, 'tab switch': async ({ flags, client, cwd, json }) => { const index = getOptionalNonNegativeIntegerFlag(flags, 'index') @@ -34,10 +52,11 @@ export const BROWSER_TAB_HANDLERS: Record = { }, 'tab create': async ({ flags, client, cwd, json }) => { const url = getOptionalStringFlag(flags, 'url') + const profileId = getOptionalStringFlag(flags, 'profile') const worktree = await getBrowserWorktreeSelector(flags, cwd, client) const result = await client.call<{ browserPageId: string }>( 'browser.tabCreate', - { url, worktree }, + { url, worktree, profileId }, { timeoutMs: 60_000 } ) printResult(result, json, (v) => `Created tab ${v.browserPageId}`) diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts new file mode 100644 index 00000000000..4520d33888a --- /dev/null +++ b/src/cli/handlers/orchestration.ts @@ -0,0 +1,442 @@ +/* eslint-disable max-lines -- Why: orchestration CLI handlers share flag-parsing helpers and dispatch/preamble logic; splitting by verb would fragment the RuntimeClient call shape without reducing complexity. */ +import type { CommandHandler } from '../dispatch' +import { printResult } from '../format' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../flags' +import { RuntimeClientError } from '../runtime-client' +import { getTerminalHandle } from '../selectors' + +// Why: 15 s is well under Claude Code's empirical ~2 min Bash-tool silence +// budget and generates only ~40 lines per 10 min wait — enough to assure the +// parent process the subprocess is alive without flooding logs. See design +// doc §3.4. +const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000 + +// Why: test-only escape hatch so subprocess tests can verify the feature in +// under 10 s rather than needing a full 15 s silence window. Production users +// should never set this — there is no surface documentation. A bogus value +// falls back to the default rather than disabling the heartbeat. +function resolveHeartbeatIntervalMs(): number { + const raw = process.env.ORCA_HEARTBEAT_INTERVAL_MS + if (!raw) { + return DEFAULT_HEARTBEAT_INTERVAL_MS + } + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_HEARTBEAT_INTERVAL_MS + } + return parsed +} + +function startCheckHeartbeat(deadlineMs: number | undefined): () => void { + const startedAt = Date.now() + const interval = setInterval(() => { + const payload = { + _heartbeat: true, + elapsedMs: Date.now() - startedAt, + deadlineMs: deadlineMs ?? null + } + // Why: `process.stderr.write` is line-flushed per-call in Node, whereas a + // fully-buffered writer would hold all heartbeat lines until exit and + // silently defeat the whole point of the ping. Subprocess test asserts + // this by reading stderr incrementally. See §3.4. + process.stderr.write(`${JSON.stringify(payload)}\n`) + }, resolveHeartbeatIntervalMs()) + if (typeof interval.unref === 'function') { + interval.unref() + } + return () => clearInterval(interval) +} + +// Why: mirrors TaskStatus (orchestration/types.ts) so the CLI can surface a +// clear enum-aware error before the generic RPC Zod "Missing --status" message. +const TASK_STATUS_VALUES = [ + 'pending', + 'ready', + 'dispatched', + 'completed', + 'failed', + 'blocked' +] as const + +type MessageSummary = { + id: string + from_handle: string + to_handle?: string + subject: string + type?: string + body?: string + payload?: string | null +} + +async function resolveOrchestrationTerminalHandle( + flags: Map, + cwd: string, + client: Parameters[0]['client'], + flagName: 'from' | 'terminal' +): Promise { + const explicit = getOptionalStringFlag(flags, flagName) + if (explicit) { + return explicit + } + const envHandle = process.env.ORCA_TERMINAL_HANDLE + if (envHandle && envHandle.length > 0) { + return envHandle + } + return await getTerminalHandle(flags, cwd, client) +} + +function isDevCliInvocation(): boolean { + return process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') ?? false +} + +export const ORCHESTRATION_HANDLERS: Record = { + 'orchestration send': async ({ flags, client, cwd, json }) => { + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const result = await client.call< + { message: { id: string } } | { messages: { id: string }[]; recipients: number } + >('orchestration.send', { + from, + to: getRequiredStringFlag(flags, 'to'), + subject: getRequiredStringFlag(flags, 'subject'), + body: getOptionalStringFlag(flags, 'body'), + type: getOptionalStringFlag(flags, 'type'), + priority: getOptionalStringFlag(flags, 'priority'), + threadId: getOptionalStringFlag(flags, 'thread-id'), + payload: getOptionalStringFlag(flags, 'payload'), + devMode: isDevCliInvocation() + }) + printResult(result, json, (r) => { + if ('message' in r) { + return `Sent ${r.message.id}` + } + return `Sent ${r.messages.length} messages to ${r.recipients} recipients` + }) + }, + + 'orchestration check': async ({ flags, client, cwd, json }) => { + const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') + const wait = flags.has('wait') + const timeoutMs = flags.has('timeout-ms') ? Number(flags.get('timeout-ms')) : undefined + + // Why: Claude Code's Bash tool auto-backgrounds subprocesses that produce + // no output for ~2 min (shorter on the non-interactive path). Emit a + // heartbeat line to stderr every HEARTBEAT_INTERVAL_MS while the wait is + // active so the parent process can see the subprocess is still alive. + // Stderr rather than stdout so stdout stays a single final JSON payload, + // and JSON-shaped rather than `# …` so `2>&1 | jq` pipelines still work + // (jq refuses `#`-prefixed lines). See design doc §3.4. + const stopHeartbeat = wait ? startCheckHeartbeat(timeoutMs) : null + type CheckResult = { + messages: MessageSummary[] + count: number + formatted?: string + } + let result: Awaited>> + try { + result = await client.call('orchestration.check', { + terminal, + unread: flags.has('unread') ? true : undefined, + all: flags.has('all') ? true : undefined, + types: getOptionalStringFlag(flags, 'types'), + inject: flags.has('inject') ? true : undefined, + wait: wait ? true : undefined, + timeoutMs + }) + } finally { + stopHeartbeat?.() + } + printResult(result, json, (r) => { + if (r.formatted) { + return r.formatted + } + if (r.count === 0) { + return 'No messages.' + } + return r.messages + .map((m) => `${m.id} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`) + .join('\n') + }) + }, + + '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 + }) + printResult(result, json, (r) => `Replied ${r.message.id}`) + }, + + 'orchestration inbox': async ({ flags, client, json }) => { + const full = flags.has('full') + const result = await client.call<{ + messages: MessageSummary[] + count: number + }>('orchestration.inbox', { + limit: getOptionalPositiveIntegerFlag(flags, 'limit'), + terminal: getOptionalStringFlag(flags, 'terminal') + }) + printResult(result, json, (r) => { + if (r.count === 0) { + return 'No messages.' + } + // Why: default output omits body/payload for at-a-glance sweeps; --full + // prints them verbatim so callers can audit without parsing --json. + return r.messages + .map((m) => { + const head = `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"` + if (!full) { + return head + } + const parts = [head] + if (m.body && m.body.length > 0) { + parts.push(m.body) + } + if (m.payload) { + parts.push(`[payload] ${m.payload}`) + } + return parts.join('\n') + }) + .join(full ? '\n\n' : '\n') + }) + }, + + 'orchestration task-create': async ({ flags, client, json }) => { + const result = await client.call<{ task: { id: string; status: string } }>( + 'orchestration.taskCreate', + { + spec: getRequiredStringFlag(flags, 'spec'), + deps: getOptionalStringFlag(flags, 'deps'), + parent: getOptionalStringFlag(flags, 'parent') + } + ) + printResult(result, json, (r) => `Created ${r.task.id} [${r.task.status}]`) + }, + + 'orchestration task-list': async ({ flags, client, json }) => { + const result = await client.call<{ + tasks: { + id: string + spec: string + status: string + assignee_handle?: string | null + dispatch_id?: string | null + }[] + count: number + }>('orchestration.taskList', { + status: getOptionalStringFlag(flags, 'status'), + ready: flags.has('ready') ? true : undefined + }) + printResult(result, json, (r) => { + if (r.count === 0) { + return 'No tasks.' + } + return r.tasks + .map((t) => { + const head = `${t.id} [${t.status}] ${t.spec.slice(0, 60)}` + if (t.status === 'dispatched' && t.assignee_handle) { + return `${head} -> ${t.assignee_handle} (${t.dispatch_id ?? '?'})` + } + return head + }) + .join('\n') + }) + }, + + 'orchestration task-update': async ({ flags, client, json }) => { + const status = getRequiredStringFlag(flags, 'status') + if (!TASK_STATUS_VALUES.includes(status as (typeof TASK_STATUS_VALUES)[number])) { + throw new RuntimeClientError( + 'invalid_argument', + `invalid status '${status}', expected one of: ${TASK_STATUS_VALUES.join(', ')}` + ) + } + const result = await client.call<{ task: { id: string; status: string } }>( + 'orchestration.taskUpdate', + { + id: getRequiredStringFlag(flags, 'id'), + status, + result: getOptionalStringFlag(flags, 'result') + } + ) + printResult(result, json, (r) => `Updated ${r.task.id} -> ${r.task.status}`) + }, + + 'orchestration dispatch': async ({ flags, client, cwd, json }) => { + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const dryRun = flags.has('dry-run') ? true : undefined + const returnPreamble = flags.has('return-preamble') ? true : undefined + // Why: --to is only required for non-dry-run; the RPC handler re-enforces. + const to = dryRun ? getOptionalStringFlag(flags, 'to') : getRequiredStringFlag(flags, 'to') + const result = await client.call<{ + dispatch: { id: string; task_id: string; status: string } | null + injected?: boolean + dryRun?: boolean + preamble?: string + }>('orchestration.dispatch', { + task: getRequiredStringFlag(flags, 'task'), + to, + from, + inject: flags.has('inject') ? true : undefined, + dryRun, + returnPreamble, + devMode: isDevCliInvocation() + }) + printResult(result, json, (r) => { + if (r.dryRun) { + return r.preamble ?? '' + } + const base = `Dispatched ${r.dispatch?.task_id} -> ${r.dispatch?.id} [${r.dispatch?.status}]` + return r.preamble ? `${base}\n\n--- Preamble ---\n${r.preamble}` : base + }) + }, + + 'orchestration ask': async ({ flags, client, cwd, json }) => { + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const timeoutMs = flags.has('timeout-ms') ? Number(flags.get('timeout-ms')) : 600_000 + const result = await client.call<{ + answer: string | null + messageId: string | null + threadId: string + timedOut: boolean + }>( + 'orchestration.ask', + { + to: getRequiredStringFlag(flags, 'to'), + question: getRequiredStringFlag(flags, 'question'), + options: getOptionalStringFlag(flags, 'options'), + timeoutMs: flags.has('timeout-ms') ? Number(flags.get('timeout-ms')) : undefined, + from + }, + // Why: the runtime's `waitForMessage` can block up to `timeoutMs`, but + // the RPC transport has its own 60s default timeout that would fire + // first. Extend the per-call timeout by a small grace window so the + // RPC doesn't abort before the runtime's internal timeout resolves. + { timeoutMs: timeoutMs + 5_000 } + ) + // Why: deliberate bypass of `printResult`. `--json` on `ask` emits a + // single-line bare JSON object (no RPC envelope, no multi-line pretty- + // print) so workers can pipe `orca orchestration ask … --json | jq -r + // .answer` without reaching into a `result` envelope. This diverges from + // every other orchestration verb; called out in the commit message and + // guarded by a unit test in orchestration.test.ts. + if (json) { + console.log(JSON.stringify(result.result)) + } else if (result.result.answer !== null) { + console.log(result.result.answer) + } + if (result.result.timedOut) { + if (!json) { + console.error(`ask timeout after ${timeoutMs}ms (thread ${result.result.threadId})`) + } + process.exitCode = 1 + } + }, + + 'orchestration dispatch-show': async ({ flags, client, cwd, json }) => { + const showPreamble = flags.has('preamble') ? true : undefined + // Why: resolve --from when previewing so the preamble embeds a real + // coordinator handle, matching what an actual dispatch would produce. + const from = showPreamble + ? await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + : undefined + const result = await client.call<{ + dispatch: { id: string; task_id: string; status: string } | null + preamble?: string + }>('orchestration.dispatchShow', { + task: getRequiredStringFlag(flags, 'task'), + preamble: showPreamble, + from, + devMode: isDevCliInvocation() + }) + printResult(result, json, (r) => { + if (r.preamble && showPreamble) { + return r.preamble + } + if (!r.dispatch) { + return 'No dispatch context found.' + } + return `${r.dispatch.id} task=${r.dispatch.task_id} [${r.dispatch.status}]` + }) + }, + + 'orchestration run': async ({ flags, client, cwd, json }) => { + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + 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 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 gate-create': async ({ flags, client, json }) => { + const result = await client.call<{ + gate: { id: string; task_id: string; status: string } + }>('orchestration.gateCreate', { + task: getRequiredStringFlag(flags, 'task'), + question: getRequiredStringFlag(flags, 'question'), + options: getOptionalStringFlag(flags, 'options') + }) + printResult( + result, + json, + (r) => `Gate ${r.gate.id} created for task ${r.gate.task_id} [${r.gate.status}]` + ) + }, + + 'orchestration gate-resolve': async ({ flags, client, json }) => { + const result = await client.call<{ + gate: { id: string; task_id: string; status: string; resolution: string } + }>('orchestration.gateResolve', { + id: getRequiredStringFlag(flags, 'id'), + resolution: getRequiredStringFlag(flags, 'resolution') + }) + printResult(result, json, (r) => `Gate ${r.gate.id} resolved: ${r.gate.resolution}`) + }, + + 'orchestration gate-list': async ({ flags, client, json }) => { + const result = await client.call<{ + gates: { id: string; task_id: string; question: string; status: string }[] + count: number + }>('orchestration.gateList', { + task: getOptionalStringFlag(flags, 'task'), + status: getOptionalStringFlag(flags, 'status') + }) + printResult(result, json, (r) => { + if (r.gates.length === 0) { + return 'No gates found.' + } + return r.gates + .map((g) => `${g.id} task=${g.task_id} [${g.status}] "${g.question}"`) + .join('\n') + }) + }, + + 'orchestration reset': async ({ flags, client, json }) => { + const result = await client.call<{ reset: string }>('orchestration.reset', { + all: flags.has('all') ? true : undefined, + tasks: flags.has('tasks') ? true : undefined, + messages: flags.has('messages') ? true : undefined + }) + printResult(result, json, (r) => `Reset: ${r.reset}`) + } +} diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 4775353df16..7377f817c25 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -14,6 +14,16 @@ import { } from '../flags' import { getRequiredWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors' +type HookWarningResult = { + warning?: string +} + +function printHookWarning(result: HookWarningResult, json: boolean): void { + if (!json && result.warning) { + console.error(`warning: ${result.warning}`) + } +} + export const WORKTREE_HANDLERS: Record = { 'worktree ps': async ({ flags, client, json }) => { const result = await client.call('worktree.ps', { @@ -41,13 +51,18 @@ export const WORKTREE_HANDLERS: Record = { printResult(result, json, formatWorktreeShow) }, 'worktree create': async ({ flags, client, json }) => { - const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.create', { - repo: getRequiredStringFlag(flags, 'repo'), - name: getRequiredStringFlag(flags, 'name'), - baseBranch: getOptionalStringFlag(flags, 'base-branch'), - linkedIssue: getOptionalNumberFlag(flags, 'issue'), - comment: getOptionalStringFlag(flags, 'comment') - }) + const result = await client.call<{ worktree: RuntimeWorktreeRecord; warning?: string }>( + 'worktree.create', + { + repo: getRequiredStringFlag(flags, 'repo'), + name: getRequiredStringFlag(flags, 'name'), + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + linkedIssue: getOptionalNumberFlag(flags, 'issue'), + comment: getOptionalStringFlag(flags, 'comment'), + runHooks: flags.get('run-hooks') === true + } + ) + printHookWarning(result.result, json) printResult(result, json, formatWorktreeShow) }, 'worktree set': async ({ flags, client, cwd, json }) => { @@ -60,10 +75,12 @@ export const WORKTREE_HANDLERS: Record = { printResult(result, json, formatWorktreeShow) }, 'worktree rm': async ({ flags, client, cwd, json }) => { - const result = await client.call<{ removed: boolean }>('worktree.rm', { + const result = await client.call<{ removed: boolean; warning?: string }>('worktree.rm', { worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client), - force: flags.get('force') === true + force: flags.get('force') === true, + runHooks: flags.get('run-hooks') === true }) + printHookWarning(result.result, json) printResult(result, json, (value) => `removed: ${value.removed}`) } } diff --git a/src/cli/help.ts b/src/cli/help.ts index 2028b9ffdb5..3adb9cc9810 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -39,9 +39,35 @@ Terminals: terminal focus Alias for terminal switch terminal close Close a terminal pane (or tab if last pane) +Orchestration: + orchestration send Send an inter-agent message + orchestration check Check messages for a terminal + orchestration reply Reply to a message + orchestration inbox Show all messages across recipients + orchestration task-create Create an orchestration task + orchestration task-list List orchestration tasks + 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 gate-create Create a decision gate blocking a task + orchestration gate-resolve Resolve a pending decision gate + orchestration gate-list List decision gates + orchestration reset Reset orchestration state + Browser Automation: tab create Create a new browser tab (navigates to --url) tab list List open browser tabs + tab show Show one browser tab by page id + tab current Show the current browser tab + tab profile list List browser session profiles + tab profile create Create a browser session profile + tab profile delete Delete a browser session profile + tab profile set Switch a browser tab to a different profile + tab profile show Show the profile bound to a browser tab + tab profile use-default Switch a browser tab back to the default profile + tab profile clone Clone a browser tab into another profile tab switch Switch the active browser tab by --index or --page tab close Close a browser tab by --index/--page or the current tab snapshot Accessibility snapshot with element refs (e.g. @e1, @e2) @@ -98,11 +124,11 @@ Common Commands: orca open [--json] orca status [--json] orca worktree list [--repo ] [--limit ] [--json] - orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--json] + orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--json] orca worktree show --worktree [--json] orca worktree current [--json] orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--json] - orca worktree rm --worktree [--force] [--json] + orca worktree rm --worktree [--force] [--run-hooks] [--json] orca worktree ps [--limit ] [--json] orca terminal list [--worktree ] [--limit ] [--json] orca terminal show [--terminal ] [--json] @@ -166,6 +192,8 @@ Browser Options: --amount Scroll distance in pixels (default: viewport height) --index Tab index (from \`tab list\`) --page Stable browser page id (preferred for concurrent workflows) + --profile Browser profile id + --show-profile Include the tab's browser profile in text output --format Screenshot image format --from Drag source element ref --to Drag target element ref @@ -185,7 +213,10 @@ Examples: $ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json $ orca terminal send --terminal term_123 --text "hi" --enter $ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json - $ orca tab create --url https://example.com + $ orca tab current --json + $ orca tab show --page page_123 --json + $ orca tab create --url https://example.com --profile work + $ orca tab profile clone --page page_123 --profile work --json $ orca snapshot $ orca click --element e3 $ orca fill --element e5 --value "hello" @@ -289,6 +320,8 @@ export function formatFlagHelp(flag: string): string { amount: '--amount Scroll distance in pixels', index: '--index Tab index to switch to', page: '--page Stable browser page id from `orca tab list --json`', + profile: '--profile Browser profile id', + 'show-profile': '--show-profile Include tab profile in text output', format: '--format Screenshot image format' } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 579556bac4b..675c1105932 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -55,12 +55,25 @@ describe('COMMAND_SPECS collision check', () => { }) describe('orca cli worktree awareness', () => { + const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE + const originalUserDataPath = process.env.ORCA_USER_DATA_PATH + beforeEach(() => { callMock.mockReset() }) afterEach(() => { vi.restoreAllMocks() + if (originalTerminalHandle === undefined) { + delete process.env.ORCA_TERMINAL_HANDLE + } else { + process.env.ORCA_TERMINAL_HANDLE = originalTerminalHandle + } + if (originalUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = originalUserDataPath + } }) it('builds the current worktree selector from cwd', () => { @@ -153,6 +166,91 @@ describe('orca cli worktree awareness', () => { }) }) + it('formats group orchestration sends in text mode', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_sender' + callMock.mockResolvedValueOnce({ + id: 'req_send', + ok: true, + result: { + messages: [{ id: 'msg_1' }, { id: 'msg_2' }], + recipients: 2 + }, + _meta: { + runtimeId: 'runtime-1' + } + }) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['orchestration', 'send', '--to', '@all', '--subject', 'hello'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('orchestration.send', { + from: 'term_sender', + to: '@all', + subject: 'hello', + body: undefined, + type: undefined, + priority: undefined, + threadId: undefined, + payload: undefined, + devMode: false + }) + expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients') + }) + + it('rejects unknown task-update status with an enum-aware error', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_coord' + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['orchestration', 'task-update', '--id', 'task_x', '--status', 'complete'], + '/tmp/repo' + ) + + const output = [...errSpy.mock.calls, ...logSpy.mock.calls] + .flat() + .map((v) => (typeof v === 'string' ? v : JSON.stringify(v))) + .join('\n') + expect(output).toContain("invalid status 'complete'") + expect(output).toContain('pending, ready, dispatched, completed, failed, blocked') + expect(callMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + + // Reset exitCode so subsequent tests don't inherit the failure. + process.exitCode = priorExitCode + errSpy.mockRestore() + }) + + it('passes dev mode to injected orchestration dispatches', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_sender' + process.env.ORCA_USER_DATA_PATH = '/tmp/orca-dev' + 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', { + task: 'task_1', + to: 'term_worker', + from: 'term_sender', + inject: true, + devMode: true + }) + }) + it('uses the resolved enclosing worktree for terminal consumers', async () => { queueFixtures( callMock, diff --git a/src/cli/orchestration.subprocess.test.ts b/src/cli/orchestration.subprocess.test.ts new file mode 100644 index 00000000000..c8648ceafe6 --- /dev/null +++ b/src/cli/orchestration.subprocess.test.ts @@ -0,0 +1,158 @@ +// Why: subprocess-level test for the CLI heartbeat behavior described in +// design doc §3.4. Spawns the real compiled CLI with no TTY, points it at a +// real in-process runtime via ORCA_USER_DATA_PATH, and asserts: +// - the first heartbeat line appears on stderr well under Claude Code's +// ~2 min Bash-tool silence budget (we verify with a shortened interval; +// production uses 15 s via the same code path) +// - ≥3 heartbeats arrive during the wait window +// - stderr is line-flushed (we observe each heartbeat as a separate chunk +// before the process exits — not in one burst at the end) +// - stdout stays a single clean JSON payload (no heartbeats leak to stdout) +// - a `jq "select(._heartbeat|not)"` filter on the merged stream would +// yield exactly the final result +// +// This test is skipped if the CLI hasn't been built yet (out/cli/index.js +// missing) so `pnpm test` works on a fresh checkout without requiring a prior +// `pnpm run build:cli`. The verification gate explicitly builds the CLI +// before running this file. +import { spawn } from 'child_process' +import { existsSync, mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it } from 'vitest' +import { OrcaRuntimeService } from '../main/runtime/orca-runtime' +import { OrchestrationDb } from '../main/runtime/orchestration/db' +import { OrcaRuntimeRpcServer } from '../main/runtime/runtime-rpc' + +// Why: Vitest runs tests with `process.cwd()` pinned to the repo root, so +// join against it to locate the compiled CLI regardless of where this test +// file itself lives. +const CLI_PATH = join(process.cwd(), 'out', 'cli', 'index.js') + +const describeIfBuilt = existsSync(CLI_PATH) ? describe : describe.skip + +describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { + it('emits newline-flushed JSON heartbeats to stderr while waiting', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-sub-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath }) + await server.start() + + try { + // Why: use the ORCA_HEARTBEAT_INTERVAL_MS escape hatch to shrink the + // test to ~1 s wall time. Production callers never set this; the + // production default (15 s) is exercised by §3.4's own unit tests + // and by the fact that this same code path runs with the real + // constant when the env var is absent. + const heartbeatMs = 200 + const waitTimeoutMs = 1200 + + const child = spawn( + process.execPath, + [ + CLI_PATH, + 'orchestration', + 'check', + '--wait', + '--timeout-ms', + String(waitTimeoutMs), + '--json' + ], + { + env: { + ...process.env, + ORCA_USER_DATA_PATH: userDataPath, + ORCA_TERMINAL_HANDLE: 'term_nobody', + ORCA_HEARTBEAT_INTERVAL_MS: String(heartbeatMs) + }, + // Why: explicit pipe for all three fds so we can watch stderr + // in real time; no TTY attached (Bash-tool parity). + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + + const stderrChunks: { at: number; data: string }[] = [] + const stdoutChunks: { at: number; data: string }[] = [] + const startedAt = Date.now() + child.stderr.setEncoding('utf8') + child.stdout.setEncoding('utf8') + child.stderr.on('data', (d) => stderrChunks.push({ at: Date.now() - startedAt, data: d })) + child.stdout.on('data', (d) => stdoutChunks.push({ at: Date.now() - startedAt, data: d })) + + const exitCode = await new Promise((resolveExit, rejectExit) => { + child.once('exit', (code) => resolveExit(code ?? 1)) + child.once('error', rejectExit) + }) + + expect(exitCode).toBe(0) + + const stderr = stderrChunks.map((c) => c.data).join('') + const stdout = stdoutChunks.map((c) => c.data).join('') + + const heartbeatLines = stderr + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => { + try { + return JSON.parse(line) as Record + } catch { + return null + } + }) + .filter((p): p is Record => p !== null && p._heartbeat === true) + + // ≥3 heartbeats in a 1.2s window with a 200ms interval + expect(heartbeatLines.length).toBeGreaterThanOrEqual(3) + expect(heartbeatLines[0]).toHaveProperty('elapsedMs') + expect(heartbeatLines[0]).toHaveProperty('deadlineMs', waitTimeoutMs) + + // Why: first heartbeat must arrive within one interval + scheduler + // slack (300ms is generous); if the stream were fully buffered we'd + // see everything only after exit. + const firstHeartbeatChunk = stderrChunks.find((c) => c.data.includes('_heartbeat')) + expect(firstHeartbeatChunk).toBeDefined() + expect(firstHeartbeatChunk!.at).toBeLessThan(heartbeatMs + 300) + + // Why: line-flushing proof — the *first* heartbeat chunk must arrive + // strictly before the exit chunk; i.e. we got at least two separate + // stderr data events (heartbeat + final). A single-chunk delivery + // would indicate stderr was buffered until exit. + const lastStderrAt = stderrChunks.at(-1)?.at ?? 0 + const firstStderrAt = stderrChunks.at(0)?.at ?? 0 + expect(lastStderrAt).toBeGreaterThan(firstStderrAt) + + // Stdout: exactly one JSON payload, the terminal result. No heartbeats + // leak, and the content parses as valid JSON. + const stdoutTrimmed = stdout.trim() + const stdoutPayload = JSON.parse(stdoutTrimmed) as Record + expect(stdoutPayload).not.toHaveProperty('_heartbeat') + expect(stdoutTrimmed).not.toContain('_heartbeat') + // Why: result should be an RPC success envelope with the expected + // shape. `count: 0` and `messages: []` because the wait timed out + // with no message for term_nobody. + expect(stdoutPayload).toMatchObject({ ok: true }) + + // Why: the heartbeats-on-stderr design is meant to pair with shell + // filters like `2>&1 | jq "select(._heartbeat|not)"`. jq is + // line-oriented by default, but also accepts pretty-printed JSON + // across multiple lines. What matters here is that every + // heartbeat line on stderr is a standalone JSON object (so jq can + // match it) and doesn't span multiple lines — assert that each + // heartbeat is a single-line JSON with no embedded newlines. + for (const line of stderr.split('\n')) { + if (line.trim().length === 0) { + continue + } + if (line.includes('_heartbeat')) { + expect(() => JSON.parse(line)).not.toThrow() + expect(line).not.toContain('\n') + } + } + } finally { + db.close() + await server.stop() + } + }, 30_000) +}) diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index 74102e01e05..2064b937887 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -35,10 +35,12 @@ function writeMetadata( JSON.stringify({ runtimeId: 'runtime-1', pid, - transport: { - kind: 'unix', - endpoint - }, + transports: [ + { + kind: 'unix', + endpoint + } + ], authToken, startedAt: 1 }), diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index e256bcdabd0..ae65cd4f34c 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -5,6 +5,14 @@ import { getCliStatus } from './status' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' +// Why: for `orchestration.check --wait` the caller's method-level +// `params.timeoutMs` is the inner waiter budget; we extend the client-side +// socket timeout to `timeoutMs + GRACE_MS` so the client's own idle timer +// never fires before the server-side waiter has had a chance to resolve and +// emit its terminal frame. The 10 s grace absorbs round-trip + one final +// keepalive window. See design doc §3.1. +const LONG_POLL_CLIENT_GRACE_MS = 10_000 + export class RuntimeClient { private readonly userDataPath: string private readonly requestTimeoutMs: number @@ -25,18 +33,30 @@ export class RuntimeClient { } ): Promise> { const metadata = readMetadata(this.userDataPath) - const response = await sendRequest( - metadata, - method, - params, - options?.timeoutMs ?? this.requestTimeoutMs - ) + const effectiveTimeoutMs = options?.timeoutMs ?? this.resolveMethodTimeoutMs(method, params) + const response = await sendRequest(metadata, method, params, effectiveTimeoutMs) if (!response.ok) { throw new RuntimeRpcFailureError(response) } return response } + // Why: centralises the per-method timeout policy. `orchestration.check` with + // `wait: true` is the only long-poll today, and its inner waiter budget + // lives in `params.timeoutMs`. We widen the client-side socket timeout to + // `timeoutMs + grace` so it doesn't fire before the server has a chance to + // resolve. Without this, a 5 min wait would still die at the 60 s default. + // See design doc §3.1. + private resolveMethodTimeoutMs(method: string, params?: unknown): number { + if (method === 'orchestration.check' && isWaitingCheck(params)) { + const inner = Number((params as { timeoutMs?: unknown }).timeoutMs) + if (Number.isFinite(inner) && inner > 0) { + return Math.max(inner + LONG_POLL_CLIENT_GRACE_MS, this.requestTimeoutMs) + } + } + return this.requestTimeoutMs + } + async getCliStatus(): Promise> { return getCliStatus(this.userDataPath) } @@ -67,3 +87,12 @@ export class RuntimeClient { function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } + +function isWaitingCheck(params: unknown): boolean { + return ( + typeof params === 'object' && + params !== null && + 'wait' in params && + (params as { wait: unknown }).wait === true + ) +} diff --git a/src/cli/runtime/envelope-schema.test.ts b/src/cli/runtime/envelope-schema.test.ts index 57f92ada298..b7315644f4b 100644 --- a/src/cli/runtime/envelope-schema.test.ts +++ b/src/cli/runtime/envelope-schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { RuntimeRpcEnvelopeSchema } from './envelope-schema' +import { RuntimeRpcEnvelopeSchema, isKeepaliveFrame } from './envelope-schema' describe('RuntimeRpcEnvelopeSchema', () => { it('accepts a well-formed success envelope', () => { @@ -57,4 +57,37 @@ describe('RuntimeRpcEnvelopeSchema', () => { const parsed = RuntimeRpcEnvelopeSchema.safeParse({ hello: 'world' }) expect(parsed.success).toBe(false) }) + + it('accepts a keepalive frame', () => { + const parsed = RuntimeRpcEnvelopeSchema.safeParse({ _keepalive: true }) + expect(parsed.success).toBe(true) + }) + + it('rejects a keepalive frame with _keepalive !== true', () => { + const parsed = RuntimeRpcEnvelopeSchema.safeParse({ _keepalive: false }) + expect(parsed.success).toBe(false) + }) +}) + +describe('isKeepaliveFrame', () => { + it('returns true for a well-formed keepalive', () => { + expect(isKeepaliveFrame({ _keepalive: true })).toBe(true) + }) + + it('returns false for a success envelope', () => { + expect(isKeepaliveFrame({ id: 'x', ok: true, result: {}, _meta: { runtimeId: 'r' } })).toBe( + false + ) + }) + + it('returns false for non-object inputs', () => { + expect(isKeepaliveFrame(null)).toBe(false) + expect(isKeepaliveFrame(undefined)).toBe(false) + expect(isKeepaliveFrame('keepalive')).toBe(false) + }) + + it('returns false when _keepalive is not strictly true', () => { + expect(isKeepaliveFrame({ _keepalive: 1 })).toBe(false) + expect(isKeepaliveFrame({ _keepalive: 'true' })).toBe(false) + }) }) diff --git a/src/cli/runtime/envelope-schema.ts b/src/cli/runtime/envelope-schema.ts index eeec3536005..f674aab11af 100644 --- a/src/cli/runtime/envelope-schema.ts +++ b/src/cli/runtime/envelope-schema.ts @@ -36,4 +36,27 @@ const Failure = z.object({ _meta: MetaFailure }) -export const RuntimeRpcEnvelopeSchema = z.discriminatedUnion('ok', [Success, Failure]) +// Why: transport-layer keepalive frame (server→client only). Not a terminal +// frame — the client reads past it and keeps waiting for the real +// success/failure. `id` and `_meta` are deliberately absent: keepalives carry +// no method-level semantics and aren't tied to a particular request (one +// connection handles one request today). See design doc §3.1. +const Keepalive = z.object({ + _keepalive: z.literal(true) +}) + +// Why: switched from z.discriminatedUnion('ok', …) to z.union because +// keepalives have no `ok` field. Client code must branch on +// `'_keepalive' in frame` before treating the frame as Success/Failure. +export const RuntimeRpcEnvelopeSchema = z.union([Success, Failure, Keepalive]) + +export type RuntimeRpcKeepaliveFrame = z.infer + +export function isKeepaliveFrame(frame: unknown): frame is RuntimeRpcKeepaliveFrame { + return ( + typeof frame === 'object' && + frame !== null && + '_keepalive' in frame && + (frame as { _keepalive: unknown })._keepalive === true + ) +} diff --git a/src/cli/runtime/metadata.ts b/src/cli/runtime/metadata.ts index cd690ab0f14..df93adad8b5 100644 --- a/src/cli/runtime/metadata.ts +++ b/src/cli/runtime/metadata.ts @@ -1,14 +1,18 @@ import { homedir } from 'os' import { join } from 'path' import { readFileSync } from 'fs' -import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { + findTransport, + getRuntimeMetadataPath, + type RuntimeMetadata +} from '../../shared/runtime-bootstrap' import { RuntimeClientError } from './types' export function readMetadata(userDataPath: string): RuntimeMetadata { const metadataPath = getRuntimeMetadataPath(userDataPath) try { const metadata = JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null - if (!metadata?.transport || !metadata.authToken) { + if (!metadata || !findTransport(metadata, 'unix', 'named-pipe') || !metadata.authToken) { throw new RuntimeClientError( 'runtime_unavailable', `Orca runtime metadata is incomplete at ${metadataPath}` diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index 00daf4bfec6..3a0f445a40f 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -7,7 +7,7 @@ export async function getCliStatus( userDataPath: string ): Promise> { const metadata = tryReadMetadata(userDataPath) - if (!metadata?.transport || !metadata.authToken) { + if (!metadata?.transports?.length || !metadata.authToken) { return buildCliStatusResponse({ app: { running: false, diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index bc39de0d332..ca8ca746274 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -1,7 +1,7 @@ import { createConnection } from 'net' import { randomUUID } from 'crypto' -import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap' -import { RuntimeRpcEnvelopeSchema } from './envelope-schema' +import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { isKeepaliveFrame, RuntimeRpcEnvelopeSchema } from './envelope-schema' import { RuntimeClientError, type RuntimeRpcResponse } from './types' export async function sendRequest( @@ -11,11 +11,26 @@ export async function sendRequest( timeoutMs: number ): Promise> { return await new Promise((resolve, reject) => { - const socket = createConnection(getTransportEndpoint(metadata.transport!)) + const transport = findTransport(metadata, 'unix', 'named-pipe') + if (!transport) { + reject( + new RuntimeClientError( + 'runtime_unavailable', + 'No compatible transport found in Orca runtime metadata.' + ) + ) + return + } + const socket = createConnection(transport.endpoint) let buffer = '' + let settled = false const requestId = randomUUID() const timeout = setTimeout(() => { + if (settled) { + return + } + settled = true socket.destroy() reject( new RuntimeClientError( @@ -25,28 +40,72 @@ export async function sendRequest( ) }, timeoutMs) + const finish = ( + result: { ok: true; response: RuntimeRpcResponse } | { ok: false; error: Error } + ): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + socket.end() + if (result.ok) { + resolve(result.response) + } else { + reject(result.error) + } + } + socket.setEncoding('utf8') socket.once('error', () => { - clearTimeout(timeout) - reject( - new RuntimeClientError( + finish({ + ok: false, + error: new RuntimeClientError( 'runtime_unavailable', 'Could not connect to the running Orca app. Restart Orca and try again.' ) - ) + }) }) socket.on('data', (chunk) => { buffer += chunk - const newlineIndex = buffer.indexOf('\n') - if (newlineIndex === -1) { - return - } - const message = buffer.slice(0, newlineIndex) - socket.end() - clearTimeout(timeout) - let response: RuntimeRpcResponse - try { - const raw: unknown = JSON.parse(message) + // Why: the server may interleave `{"_keepalive":true}\n` frames with the + // final success/failure frame to keep both idle timers alive during a + // long-poll (see design doc §3.1). Read frames in a loop until we see a + // terminal frame. Each keepalive refreshes the client-side timer so a + // 10 min wait doesn't trip the 60 s default ceiling. + let newlineIndex = buffer.indexOf('\n') + while (newlineIndex !== -1 && !settled) { + const line = buffer.slice(0, newlineIndex) + buffer = buffer.slice(newlineIndex + 1) + if (line.trim().length === 0) { + newlineIndex = buffer.indexOf('\n') + continue + } + + let raw: unknown + try { + raw = JSON.parse(line) + } catch { + finish({ + ok: false, + error: new RuntimeClientError( + 'invalid_runtime_response', + 'The Orca runtime returned an invalid response frame.' + ) + }) + return + } + + // Fast-path: ignore keepalives without running the full schema. + // setTimeout().refresh() is stable since Node 10 (Orca ships on + // Node 20+ via Electron and the standalone CLI targets the same + // major). See §7 risk #9. + if (isKeepaliveFrame(raw)) { + timeout.refresh() + newlineIndex = buffer.indexOf('\n') + continue + } + // Why: validate the envelope shape (id, ok, result/error, _meta) at // the decode boundary so version skew between the CLI and the Orca // main runtime surfaces as a single invalid_runtime_response instead @@ -54,43 +113,49 @@ export async function sendRequest( // unknown — the TResult generic is the caller's responsibility. const parsed = RuntimeRpcEnvelopeSchema.safeParse(raw) if (!parsed.success) { - reject( - new RuntimeClientError( + finish({ + ok: false, + error: new RuntimeClientError( 'invalid_runtime_response', 'The Orca runtime returned an invalid response frame.' ) - ) + }) return } - response = parsed.data as RuntimeRpcResponse - } catch { - reject( - new RuntimeClientError( - 'invalid_runtime_response', - 'The Orca runtime returned an invalid response frame.' - ) - ) + + // Narrow out keepalive (already filtered above) so TS can see a + // Success|Failure shape here. + const frame = parsed.data + if ('_keepalive' in frame) { + timeout.refresh() + newlineIndex = buffer.indexOf('\n') + continue + } + + const response = frame as RuntimeRpcResponse + if (response.id !== requestId) { + finish({ + ok: false, + error: new RuntimeClientError( + 'invalid_runtime_response', + 'The Orca runtime returned a mismatched response id.' + ) + }) + return + } + if (response._meta?.runtimeId && response._meta.runtimeId !== metadata.runtimeId) { + finish({ + ok: false, + error: new RuntimeClientError( + 'runtime_unavailable', + 'The Orca runtime changed while the request was in flight. Retry the command.' + ) + }) + return + } + finish({ ok: true, response }) return } - if (response.id !== requestId) { - reject( - new RuntimeClientError( - 'invalid_runtime_response', - 'The Orca runtime returned a mismatched response id.' - ) - ) - return - } - if (response._meta?.runtimeId && response._meta.runtimeId !== metadata.runtimeId) { - reject( - new RuntimeClientError( - 'runtime_unavailable', - 'The Orca runtime changed while the request was in flight. Retry the command.' - ) - ) - return - } - resolve(response) }) socket.on('connect', () => { socket.write( @@ -104,7 +169,3 @@ export async function sendRequest( }) }) } - -function getTransportEndpoint(transport: RuntimeTransportMetadata): string { - return transport.endpoint -} diff --git a/src/cli/specs/browser-basic.ts b/src/cli/specs/browser-basic.ts index 8a1e0289330..42af901ab09 100644 --- a/src/cli/specs/browser-basic.ts +++ b/src/cli/specs/browser-basic.ts @@ -154,7 +154,19 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ { path: ['tab', 'list'], summary: 'List open browser tabs', - usage: 'orca tab list [--worktree ] [--json]', + usage: 'orca tab list [--worktree ] [--show-profile] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'show-profile'] + }, + { + path: ['tab', 'show'], + summary: 'Show one browser tab by page id', + usage: 'orca tab show --page [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['tab', 'current'], + summary: 'Show the current browser tab', + usage: 'orca tab current [--worktree ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'worktree'] }, { @@ -166,8 +178,50 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ { path: ['tab', 'create'], summary: 'Create a new browser tab in the current worktree', - usage: 'orca tab create [--url ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree'] + usage: 'orca tab create [--url ] [--worktree ] [--profile ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree', 'profile'] + }, + { + path: ['tab', 'profile', 'list'], + summary: 'List browser session profiles available to browser tabs', + usage: 'orca tab profile list [--json]', + allowedFlags: [...GLOBAL_FLAGS] + }, + { + path: ['tab', 'profile', 'create'], + summary: 'Create a browser session profile for browser tabs', + usage: 'orca tab profile create --label [--scope ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'label', 'scope'] + }, + { + path: ['tab', 'profile', 'delete'], + summary: 'Delete a browser session profile used by browser tabs', + usage: 'orca tab profile delete --profile [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'profile'] + }, + { + path: ['tab', 'profile', 'set'], + summary: 'Switch a browser tab to a different browser profile', + usage: 'orca tab profile set (--page | --worktree ) --profile [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'profile', 'worktree'] + }, + { + path: ['tab', 'profile', 'show'], + summary: 'Show the browser profile bound to a tab', + usage: 'orca tab profile show --page [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['tab', 'profile', 'use-default'], + summary: 'Switch a browser tab back to the default browser profile', + usage: 'orca tab profile use-default --page [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['tab', 'profile', 'clone'], + summary: 'Clone a browser tab into a different browser profile', + usage: 'orca tab profile clone --profile [--page ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'profile', 'worktree'] }, { path: ['tab', 'close'], diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 95f8f66f061..ca40b6bd846 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -72,9 +72,12 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'create'], summary: 'Create a new Orca-managed worktree', usage: - 'orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'repo', 'name', 'base-branch', 'issue', 'comment'], - notes: ['By default this matches the Orca UI flow and activates the new worktree in the app.'] + 'orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'repo', 'name', 'base-branch', 'issue', 'comment', 'run-hooks'], + notes: [ + 'By default this matches the Orca UI flow and activates the new worktree in the app.', + 'Repo-defined orca.yaml hooks are skipped unless --run-hooks is passed.' + ] }, { path: ['worktree', 'set'], @@ -86,8 +89,9 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ { path: ['worktree', 'rm'], summary: 'Remove a worktree from Orca and git', - usage: 'orca worktree rm --worktree [--force] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force'] + usage: 'orca worktree rm --worktree [--force] [--run-hooks] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force', 'run-hooks'], + notes: ['Repo-defined orca.yaml archive hooks are skipped unless --run-hooks is passed.'] }, { path: ['worktree', 'ps'], diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index dfa343aed31..a29a48b6abf 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -2,9 +2,11 @@ import type { CommandSpec } from '../args' import { BROWSER_ADVANCED_COMMAND_SPECS } from './browser-advanced' import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic' import { CORE_COMMAND_SPECS } from './core' +import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, ...BROWSER_BASIC_COMMAND_SPECS, - ...BROWSER_ADVANCED_COMMAND_SPECS + ...BROWSER_ADVANCED_COMMAND_SPECS, + ...ORCHESTRATION_COMMAND_SPECS ] diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts new file mode 100644 index 00000000000..ac79fd00f92 --- /dev/null +++ b/src/cli/specs/orchestration.ts @@ -0,0 +1,143 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['orchestration', 'send'], + summary: 'Send an inter-agent message', + usage: + 'orca orchestration send --to --subject [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'to', + 'from', + 'subject', + 'body', + 'type', + 'priority', + 'thread-id', + 'payload' + ] + }, + { + path: ['orchestration', 'check'], + summary: 'Check messages for a terminal', + usage: + 'orca orchestration check [--terminal ] [--unread | --all] [--types ] [--inject] [--wait] [--timeout-ms ] [--json]\n' + + ' --unread (default): return only unread messages and mark them read.\n' + + ' --all: return every message for the handle; does not mark read.\n' + + ' --wait: block until a matching message arrives or --timeout-ms expires.\n' + + ' Emits JSON heartbeat lines to stderr every 15s so the caller can\n' + + ' tell the process is alive. Filter with `grep -v _heartbeat` or\n' + + ' `jq "select(._heartbeat|not)"` when merging streams with 2>&1.', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'terminal', + 'unread', + 'all', + 'types', + 'inject', + 'wait', + 'timeout-ms' + ] + }, + { + path: ['orchestration', 'reply'], + summary: 'Reply to a message', + usage: 'orca orchestration reply --id --body [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'from'] + }, + { + path: ['orchestration', 'inbox'], + summary: 'Show messages across (or for) recipients', + usage: 'orca orchestration inbox [--limit ] [--terminal ] [--full] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'limit', 'terminal', 'full'] + }, + { + path: ['orchestration', 'task-create'], + summary: 'Create an orchestration task', + usage: + 'orca orchestration task-create --spec [--deps ] [--parent ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'spec', 'deps', 'parent'] + }, + { + path: ['orchestration', 'task-list'], + summary: 'List orchestration tasks', + usage: 'orca orchestration task-list [--status ] [--ready] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'status', 'ready'] + }, + { + path: ['orchestration', 'task-update'], + summary: 'Update a task status', + usage: + 'orca orchestration task-update --id --status [--result ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result'], + notes: ['Valid --status values: pending, ready, dispatched, completed, failed, blocked.'] + }, + { + path: ['orchestration', 'dispatch'], + summary: 'Dispatch a task to a terminal', + usage: + 'orca orchestration dispatch --task --to [--from ] [--inject] [--dry-run] [--return-preamble] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'task', 'to', 'from', 'inject', 'dry-run', 'return-preamble'] + }, + { + path: ['orchestration', 'dispatch-show'], + summary: 'Show dispatch context for a task', + usage: + 'orca orchestration dispatch-show --task [--preamble] [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'task', 'preamble', 'from'] + }, + { + path: ['orchestration', 'ask'], + summary: 'Ask the coordinator a question and block until answered', + usage: + 'orca orchestration ask --to --question [--options ] [--timeout-ms ] [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'to', 'question', 'options', 'timeout-ms', 'from'] + }, + { + path: ['orchestration', 'run'], + summary: 'Start the coordinator loop', + usage: + 'orca orchestration run --spec [--from ] [--poll-interval-ms ] [--max-concurrent ] [--worktree ] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'spec', + 'from', + 'poll-interval-ms', + 'max-concurrent', + 'worktree' + ] + }, + { + path: ['orchestration', 'run-stop'], + summary: 'Stop the active coordinator run', + usage: 'orca orchestration run-stop [--json]', + allowedFlags: [...GLOBAL_FLAGS] + }, + { + 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'] + }, + { + path: ['orchestration', 'gate-resolve'], + summary: 'Resolve a pending decision gate', + usage: 'orca orchestration gate-resolve --id --resolution [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution'] + }, + { + path: ['orchestration', 'gate-list'], + summary: 'List decision gates', + usage: 'orca orchestration gate-list [--task ] [--status ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'task', 'status'] + }, + { + path: ['orchestration', 'reset'], + summary: 'Reset orchestration state', + usage: 'orca orchestration reset [--all] [--tasks] [--messages] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'all', 'tasks', 'messages'] + } +] diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index d12c7375435..ba321dc1799 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -660,6 +660,22 @@ describe('OpenCode hook normalization', () => { expect(result?.payload.state).toBe('waiting') }) + it('AskUserQuestion maps to waiting', () => { + // Why: OpenCode emits `question.asked` when the agent uses an ask-the-user + // tool (distinct from `permission.asked`, which blocks on tool approval). + // Both leave the agent idle-but-waiting on a human, so both must render + // the same red "needs attention" indicator. Without this mapping the pane + // silently stays in `working` and the user has no visual cue that the + // agent is waiting on them. + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'AskUserQuestion' }), + 'production' + ) + expect(result?.payload.state).toBe('waiting') + expect(result?.payload.agentType).toBe('opencode') + }) + it('unknown event name returns null', () => { const result = _internals.normalizeHookPayload( 'opencode', diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index c64194e4bf4..5ff04fd72cf 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -845,11 +845,13 @@ function normalizeCodexEvent( // Why: OpenCode has no declarative hook surface — it exposes in-process plugin // events (session.status busy/idle, session.idle, permission.asked, -// message.updated, message.part.updated). The bundled plugin (see -// opencode/hook-service) pre-maps those to our stable hook_event_name +// question.asked, message.updated, message.part.updated). The bundled plugin +// (see opencode/hook-service) pre-maps those to our stable hook_event_name // vocabulary before POSTing so this normalizer can share the same switch // shape as Claude/Codex/Gemini. SessionBusy = turn started, SessionIdle = -// turn finished, PermissionRequest = blocked on user approval, MessagePart = +// turn finished, PermissionRequest = blocked on user approval, AskUserQuestion = +// blocked on user reply to an ask-the-user tool (both map to `waiting` so the +// sidebar renders the red "needs attention" indicator), MessagePart = // incremental text from user prompt or assistant reply (stays in `working` // because streaming chunks must not flip the row to done mid-turn). function normalizeOpenCodeEvent( @@ -863,7 +865,7 @@ function normalizeOpenCodeEvent( ? 'working' : eventName === 'SessionIdle' ? 'done' - : eventName === 'PermissionRequest' + : eventName === 'PermissionRequest' || eventName === 'AskUserQuestion' ? 'waiting' : null diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts new file mode 100644 index 00000000000..df328ad647d --- /dev/null +++ b/src/main/agent-trust-presets.test.ts @@ -0,0 +1,113 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const testState = { + fakeHomeDir: '' +} + +vi.mock('node:os', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +const { markCopilotFolderTrusted, markCursorWorkspaceTrusted } = + await import('./agent-trust-presets') + +beforeEach(() => { + testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-')) +}) + +afterEach(() => { + rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + testState.fakeHomeDir = '' +}) + +describe('markCursorWorkspaceTrusted', () => { + it('writes ~/.cursor/projects//.workspace-trusted with the cwd payload', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-cursor-ws-')) + try { + markCursorWorkspaceTrusted(workspace) + const projectsDir = join(testState.fakeHomeDir, '.cursor', 'projects') + const slugDirs = readdirSync(projectsDir) + expect(slugDirs.length).toBe(1) + const trustFile = join(projectsDir, slugDirs[0], '.workspace-trusted') + expect(existsSync(trustFile)).toBe(true) + const payload = JSON.parse(readFileSync(trustFile, 'utf-8')) + expect(payload.workspacePath).toBeTruthy() + expect(typeof payload.trustedAt).toBe('string') + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) + + it('is idempotent — re-marking the same workspace does not overwrite trustedAt', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-cursor-ws-')) + try { + markCursorWorkspaceTrusted(workspace) + const projectsDir = join(testState.fakeHomeDir, '.cursor', 'projects') + const slugDirs = readdirSync(projectsDir) + const trustFile = join(projectsDir, slugDirs[0], '.workspace-trusted') + const firstPayload = readFileSync(trustFile, 'utf-8') + markCursorWorkspaceTrusted(workspace) + const secondPayload = readFileSync(trustFile, 'utf-8') + expect(secondPayload).toBe(firstPayload) + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) +}) + +describe('markCopilotFolderTrusted', () => { + it('appends the workspace to trustedFolders in ~/.copilot/config.json', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) + try { + markCopilotFolderTrusted(workspace) + const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json') + expect(existsSync(configPath)).toBe(true) + const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) + expect(Array.isArray(parsed.trustedFolders)).toBe(true) + expect(parsed.trustedFolders.length).toBe(1) + expect(typeof parsed.trustedFolders[0]).toBe('string') + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) + + it('preserves existing config keys and dedups already-trusted folders', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) + const realpath = realpathSync(workspace) + try { + mkdirSync(join(testState.fakeHomeDir, '.copilot'), { recursive: true }) + writeFileSync( + join(testState.fakeHomeDir, '.copilot', 'config.json'), + JSON.stringify({ + firstLaunchAt: '2026-01-01T00:00:00.000Z', + trustedFolders: [realpath] + }) + ) + markCopilotFolderTrusted(workspace) + const parsed = JSON.parse( + readFileSync(join(testState.fakeHomeDir, '.copilot', 'config.json'), 'utf-8') + ) + expect(parsed.firstLaunchAt).toBe('2026-01-01T00:00:00.000Z') + expect(parsed.trustedFolders).toHaveLength(1) + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts new file mode 100644 index 00000000000..e6c87c52b3f --- /dev/null +++ b/src/main/agent-trust-presets.ts @@ -0,0 +1,115 @@ +import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { writeFileAtomically } from './codex-accounts/fs-utils' + +/** + * Pre-mark a workspace as trusted for cursor-agent / GitHub Copilot CLI so + * the agent's "Do you trust this folder?" menu does not fire on first launch. + * + * Why: Orca's "drop URL into agent input as a draft" flow injects the URL + * via bracketed-paste once the TUI is up. If the trust menu intercepts the + * keystrokes (each menu reads a single character or numbered option), the + * paste either selects an arbitrary option or quits the session. Pre-writing + * the same trust artifacts that the agent writes after the user accepts is + * the only documented bypass — both CLIs read these files at startup before + * showing the menu. + * + * Side note: a `--trust`-style CLI flag exists in cursor-agent but only + * applies in `--print/headless` mode (per its --help). Copilot has no + * documented flag at all (verified against @github/copilot 1.0.32 bundle). + */ + +/** + * Cursor's CLI keeps a per-workspace trust marker at: + * ~/.cursor/projects//.workspace-trusted + * where is the absolute path with the leading `/` stripped and + * remaining `/` replaced with `-`. The file payload is `{ trustedAt, + * workspacePath }`. Verified against the cursor-agent CLI bundle + * (versions/2026.04.17-787b533/index.ts: `_=".workspace-trusted"`, slug + * derived via the same util that resolves `~/.cursor/projects/`). + */ +export function markCursorWorkspaceTrusted(workspacePath: string): void { + const absPath = canonicalize(workspacePath) + const slug = cursorWorkspaceSlug(absPath) + if (!slug) { + return + } + const trustDir = join(homedir(), '.cursor', 'projects', slug) + const trustFile = join(trustDir, '.workspace-trusted') + if (existsSync(trustFile)) { + return + } + mkdirSync(trustDir, { recursive: true }) + const payload = JSON.stringify( + { trustedAt: new Date().toISOString(), workspacePath: absPath }, + null, + 2 + ) + writeFileAtomically(trustFile, `${payload}\n`) +} + +/** + * GitHub Copilot CLI keeps a global list of trusted folders in + * ~/.copilot/config.json under `trustedFolders` (verified against the + * @github/copilot 1.0.32 bundle: `addTrustedFolder` and `isFolderTrusted` + * both read/write this exact key, and folder comparison is done after a + * realpath() resolution). + * + * We append to the array in-place so unrelated config keys (loggedInUsers, + * copilotTokens, etc.) survive untouched. + */ +export function markCopilotFolderTrusted(workspacePath: string): void { + const absPath = canonicalize(workspacePath) + const configDir = join(homedir(), '.copilot') + const configPath = join(configDir, 'config.json') + let config: Record = {} + try { + if (existsSync(configPath)) { + const raw = readFileSync(configPath, 'utf-8') + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object') { + config = parsed as Record + } + } + } catch { + // Why: a corrupted config.json is the user's to fix — refuse to overwrite + // it from this side-effect path. Copilot will rewrite the file itself + // after the user accepts the trust prompt manually. + return + } + const existing = Array.isArray(config.trustedFolders) ? (config.trustedFolders as unknown[]) : [] + const normalizedExisting = existing.map((entry) => + typeof entry === 'string' ? canonicalize(entry) : null + ) + if (normalizedExisting.includes(absPath)) { + return + } + const next = [...existing.filter((e) => typeof e === 'string'), absPath] + config.trustedFolders = next + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }) + } + writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`) +} + +function canonicalize(p: string): string { + // Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but + // both Cursor and Copilot's trust comparators run realpath() before the + // string compare. Mirror that so a worktree under a symlinked parent + // (orca caches realpath()'d worktree paths) matches the agent's lookup. + try { + if (existsSync(p)) { + return realpathSync(p) + } + } catch { + // Fall through to the raw input. + } + return p +} + +function cursorWorkspaceSlug(absPath: string): string { + const stripped = absPath.replace(/^[\\/]+/, '') + const slug = stripped.replace(/[\\/]+/g, '-') + return slug +} diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index 6e6fd480cdb..17394f02f7e 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -748,6 +748,21 @@ describe('AgentBrowserBridge', () => { expect(bridge.getActiveWebContentsId()).toBeNull() }) + it('closes the named agent-browser session when a tab closes', async () => { + succeedWith({ snapshot: 'tree' }) + await bridge.snapshot() + + execFileMock.mockClear() + succeedWith(null) + await bridge.onTabClosed(100) + + const closeCall = execFileMock.mock.calls.find((call: unknown[]) => + (call[1] as string[]).includes('close') + ) + expect(closeCall).toBeTruthy() + expect(closeCall![1]).toEqual(['--session', 'orca-tab-tab-1', 'close']) + }) + it('repairs per-worktree active routing when the active tab closes', async () => { const tabs = new Map([ ['tab-a', 1], diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 734c0acbcf1..bdd57ded040 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -1682,7 +1682,10 @@ export class AgentBrowserBridge { const destroy = (async (): Promise => { try { - await this.runAgentBrowserRaw(sessionName, ['close']) + // Why: each browser tab uses its own named agent-browser session. Closing + // without --session only tears down the default session and leaves the tab + // session's daemon process running. + await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close']) } catch { // Session may already be dead } diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index a10dfc4a340..40951d84dd7 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -238,6 +238,22 @@ export function setupGuestShortcutForwarding(args: { return } + // Why: Cmd/Ctrl+Alt+[ / ] cycles across every tab type. Handled before + // the generic modifier-chord gate below because that gate rejects Alt. + // Mirrors the Alt-exempt branch pattern used for worktreeHistoryNavigate. + const isPrimaryMod = + process.platform === 'darwin' ? input.meta && !input.control : input.control && !input.meta + if ( + isPrimaryMod && + input.alt && + (input.code === 'BracketRight' || input.code === 'BracketLeft') + ) { + event.preventDefault() + const renderer = resolveRenderer(browserTabId) + renderer?.send('ui:switchTabAcrossAllTypes', input.code === 'BracketRight' ? 1 : -1) + return + } + // Why: terminal-only tab switching is intentionally Ctrl+PageUp/PageDown on // every platform. Handle it before the primary-modifier gate so macOS Ctrl // (non-primary there) still forwards out of focused browser guests. @@ -302,7 +318,7 @@ export function setupGuestShortcutForwarding(args: { } else if (action?.type === 'openQuickOpen') { renderer.send('ui:openQuickOpen') } else if (action?.type === 'openNewWorkspace') { - renderer.send('ui:openNewWorkspace', action.tab) + renderer.send('ui:openNewWorkspace') } else if (action?.type === 'jumpToWorktreeIndex') { renderer.send('ui:jumpToWorktreeIndex', action.index) } else { diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 951aefc1eb1..8fc225b7803 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -131,6 +131,32 @@ describe('browserManager', () => { }) }) + it('remembers the registered session profile for a browser page', () => { + const guest = { + id: 104, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + webContentsId: guest.id, + rendererWebContentsId, + sessionProfileId: 'work' + }) + + expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work') + }) + it('falls back to opening popup URLs externally before a guest is registered', () => { const guest = { id: 105, @@ -1117,4 +1143,167 @@ describe('browserManager', () => { guestOffMock.mock.calls.filter(([eventName]) => eventName === 'before-input-event') ).toHaveLength(2) }) + + describe('setViewportOverride', () => { + function makeGuest(id: number): { + guest: Record + debuggerSendCommand: ReturnType + debuggerIsAttached: ReturnType + debuggerAttach: ReturnType + } { + const debuggerSendCommand = vi.fn().mockResolvedValue(undefined) + const debuggerIsAttached = vi.fn(() => true) + const debuggerAttach = vi.fn() + const guest = { + id, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + getUserAgent: vi.fn( + () => + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36' + ), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + debugger: { + isAttached: debuggerIsAttached, + attach: debuggerAttach, + sendCommand: debuggerSendCommand + } + } + return { guest, debuggerSendCommand, debuggerIsAttached, debuggerAttach } + } + + it('returns false when the tab is not registered', async () => { + const result = await browserManager.setViewportOverride('missing', { + width: 375, + height: 667, + deviceScaleFactor: 2, + mobile: true + }) + expect(result).toBe(false) + }) + + it('applies device metrics, touch emulation, and a mobile UA for mobile presets', async () => { + const { guest, debuggerSendCommand } = makeGuest(4242) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-mobile', + webContentsId: guest.id as number, + rendererWebContentsId + }) + webContentsFromIdMock.mockReset() + webContentsFromIdMock.mockReturnValue(guest) + + const ok = await browserManager.setViewportOverride('tab-mobile', { + width: 375, + height: 667, + deviceScaleFactor: 2, + mobile: true + }) + expect(ok).toBe(true) + + expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setDeviceMetricsOverride', { + width: 375, + height: 667, + deviceScaleFactor: 2, + mobile: true + }) + expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setTouchEmulationEnabled', { + enabled: true, + maxTouchPoints: 5 + }) + const uaCall = debuggerSendCommand.mock.calls.find( + (call) => call[0] === 'Emulation.setUserAgentOverride' + ) + expect(uaCall).toBeDefined() + expect(uaCall?.[1]).toMatchObject({ + userAgent: expect.stringContaining('iPhone') + }) + // Chrome major from the guest UA should be spliced into the mobile UA. + expect(uaCall?.[1]).toMatchObject({ + userAgent: expect.stringContaining('CriOS/134') + }) + }) + + it('clears device metrics and disables touch for override=null', async () => { + const { guest, debuggerSendCommand } = makeGuest(4343) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-clear', + webContentsId: guest.id as number, + rendererWebContentsId + }) + + const ok = await browserManager.setViewportOverride('tab-clear', null) + expect(ok).toBe(true) + + expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.clearDeviceMetricsOverride', {}) + expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setTouchEmulationEnabled', { + enabled: false, + maxTouchPoints: 0 + }) + expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: '' + }) + }) + + it('attaches the debugger if not already attached and does not detach after', async () => { + const { guest, debuggerSendCommand, debuggerAttach } = makeGuest(4444) + ;(guest.debugger as { isAttached: ReturnType }).isAttached = vi.fn(() => false) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-attach', + webContentsId: guest.id as number, + rendererWebContentsId + }) + + await browserManager.setViewportOverride('tab-attach', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + + expect(debuggerAttach).toHaveBeenCalledWith('1.3') + expect(debuggerSendCommand).toHaveBeenCalled() + // Why: detaching would clear Page.addScriptToEvaluateOnNewDocument + // (anti-detection). Guard regression. + expect((guest.debugger as { detach?: unknown }).detach ?? undefined).toBeUndefined() + }) + + it('returns false when debugger.attach throws (e.g. DevTools already open)', async () => { + const { guest, debuggerSendCommand, debuggerAttach } = makeGuest(4545) + ;(guest.debugger as { isAttached: ReturnType }).isAttached = vi.fn(() => false) + // Why: Electron throws from debugger.attach if another client (e.g. the + // user's open DevTools window) is already attached. setViewportOverride + // must surface this as a clean `false` rather than an unhandled rejection. + debuggerAttach.mockImplementation(() => { + throw new Error('Another debugger is already attached') + }) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-attach-throws', + webContentsId: guest.id as number, + rendererWebContentsId + }) + + const ok = await browserManager.setViewportOverride('tab-attach-throws', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + + expect(ok).toBe(false) + expect(debuggerAttach).toHaveBeenCalledWith('1.3') + expect(debuggerSendCommand).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 4e71156f85b..fb89bbd6b47 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -34,12 +34,28 @@ import { setupGuestShortcutForwarding } from './browser-guest-ui' import { ANTI_DETECTION_SCRIPT } from './anti-detection' +import { cleanElectronUserAgent } from './browser-session-ua' +import type { BrowserViewportOverride } from '../../shared/types' + +// Why: mobile presets need a touch-capable UA or responsive sites serve the +// desktop variant based on UA sniffing. This is the Chrome DevTools default +// iPhone UA template; we splice in the guest session's real Chrome major so +// sec-ch-ua headers (see setupClientHintsOverride) stay consistent. +function buildMobileUserAgent(chromeMajor: string): string { + return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1` +} + +function extractChromeMajor(ua: string): string { + const match = ua.match(/Chrome\/(\d+)/) + return match ? match[1] : '134' +} export type BrowserGuestRegistration = { browserPageId?: string browserTabId?: string workspaceId?: string worktreeId?: string + sessionProfileId?: string | null webContentsId: number rendererWebContentsId: number } @@ -82,7 +98,13 @@ export class BrowserManager { // visibility/focus state is keyed by browser workspace id. Screenshot prep // has to bridge that mismatch to activate the right tab before capture. private readonly workspaceIdByPageId = new Map() + private readonly sessionProfileIdByPageId = new Map() private readonly rendererWebContentsIdByTabId = new Map() + // Why: chain setViewportOverride calls per tab so rapid toggles don't + // interleave CDP commands. Without serialization, two concurrent calls can + // race (e.g. clearDeviceMetricsOverride landing after a later mobile + // setDeviceMetricsOverride), leaving emulation in an unexpected state. + private readonly viewportOpsByTabId = new Map>() private readonly contextMenuCleanupByTabId = new Map void>() private readonly grabShortcutCleanupByTabId = new Map void>() private readonly shortcutForwardingCleanupByTabId = new Map void>() @@ -523,6 +545,7 @@ export class BrowserManager { browserTabId: legacyBrowserTabId, workspaceId, worktreeId, + sessionProfileId, webContentsId, rendererWebContentsId }: BrowserGuestRegistration): void { @@ -572,6 +595,7 @@ export class BrowserManager { if (workspaceId) { this.workspaceIdByPageId.set(browserTabId, workspaceId) } + this.sessionProfileIdByPageId.set(browserTabId, sessionProfileId ?? null) this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId) if (worktreeId) { this.worktreeIdByTabId.set(browserTabId, worktreeId) @@ -635,7 +659,11 @@ export class BrowserManager { this.webContentsIdByTabId.delete(browserTabId) this.rendererWebContentsIdByTabId.delete(browserTabId) this.workspaceIdByPageId.delete(browserTabId) + this.sessionProfileIdByPageId.delete(browserTabId) this.worktreeIdByTabId.delete(browserTabId) + // Why: drop any pending viewport-op chain for this tab so the Map doesn't + // retain a resolved promise keyed to a destroyed guest. + this.viewportOpsByTabId.delete(browserTabId) } unregisterAll(): void { @@ -658,6 +686,7 @@ export class BrowserManager { this.policyCleanupByGuestId.clear() this.tabIdByWebContentsId.clear() this.worktreeIdByTabId.clear() + this.sessionProfileIdByPageId.clear() this.pendingLoadFailuresByGuestId.clear() this.pendingPermissionEventsByGuestId.clear() this.pendingPopupEventsByGuestId.clear() @@ -676,6 +705,10 @@ export class BrowserManager { return this.worktreeIdByTabId.get(browserTabId) } + getSessionProfileIdForTab(browserTabId: string): string | null { + return this.sessionProfileIdByPageId.get(browserTabId) ?? null + } + notifyPermissionDenied(args: { guestWebContentsId: number permission: string @@ -875,6 +908,132 @@ export class BrowserManager { return true } + // Why: Electron guests do not expose Chrome DevTools' device + // toolbar (Cmd+Shift+M) to the embedding app, so viewport emulation must be + // driven through CDP directly. We reuse the debugger attachment that + // injectAntiDetection already established and never detach it here — doing + // so would clear Page.addScriptToEvaluateOnNewDocument and other per-guest + // overrides. Passing override=null clears emulation. + async setViewportOverride( + browserTabId: string, + override: BrowserViewportOverride | null + ): Promise { + // Why: chain per-tab so rapid toggles (e.g. user clicking presets quickly) + // don't interleave CDP commands. Each call waits for the previous one to + // settle, guaranteeing the last-requested override wins rather than whichever + // sendCommand sequence happens to finish last. + const prev = this.viewportOpsByTabId.get(browserTabId) ?? Promise.resolve() + const next = prev + .catch(() => {}) + .then(() => this.doSetViewportOverrideImpl(browserTabId, override)) + this.viewportOpsByTabId.set(browserTabId, next) + try { + return await next + } finally { + // Why: only clear if this call's promise is still the tail. A concurrent + // later call may have already replaced the entry; deleting would drop the + // chain and break serialization for the next invocation. + if (this.viewportOpsByTabId.get(browserTabId) === next) { + this.viewportOpsByTabId.delete(browserTabId) + } + } + } + + private async doSetViewportOverrideImpl( + browserTabId: string, + override: BrowserViewportOverride | null + ): Promise { + const webContentsId = this.webContentsIdByTabId.get(browserTabId) + if (!webContentsId) { + return false + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + this.webContentsIdByTabId.delete(browserTabId) + this.tabIdByWebContentsId.delete(webContentsId) + return false + } + + try { + if (!guest.debugger.isAttached()) { + guest.debugger.attach('1.3') + } + } catch (err) { + // Why: DevTools being open on the guest causes attach to throw with + // "Another debugger is already attached". Silently returning false made + // this failure mode undiagnosable — surface it via the logger with enough + // context (tab + webContents ids) to correlate with user reports. + console.warn('[browser-manager] setViewportOverride: failed to attach debugger', { + browserTabId, + webContentsId, + error: err instanceof Error ? err.message : String(err) + }) + return false + } + + const dbg = guest.debugger + try { + if (override) { + await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { + width: override.width, + height: override.height, + deviceScaleFactor: override.deviceScaleFactor, + mobile: override.mobile + }) + await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { + enabled: override.mobile, + maxTouchPoints: override.mobile ? 5 : 0 + }) + if (override.mobile) { + const chromeMajor = extractChromeMajor(cleanElectronUserAgent(guest.getUserAgent())) + // Why: pass userAgentMetadata alongside the mobile UA string so + // sec-ch-ua-mobile / sec-ch-ua-platform client hints match. Without + // it, session-level desktop client-hints leak through and create a + // UA/CH mismatch that bot-detection (Cloudflare, Turnstile) flags. + await dbg.sendCommand('Emulation.setUserAgentOverride', { + userAgent: buildMobileUserAgent(chromeMajor), + userAgentMetadata: { + brands: [ + { brand: 'Google Chrome', version: chromeMajor }, + { brand: 'Chromium', version: chromeMajor }, + { brand: 'Not/A)Brand', version: '24' } + ], + fullVersionList: [ + { brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` }, + { brand: 'Chromium', version: `${chromeMajor}.0.0.0` }, + { brand: 'Not/A)Brand', version: '24.0.0.0' } + ], + fullVersion: `${chromeMajor}.0.0.0`, + platform: 'iOS', + platformVersion: '17.0', + architecture: '', + model: 'iPhone', + mobile: true + } + }) + } else { + // Why: desktop presets still need the clean (non-Electron) UA so + // Cloudflare/Turnstile don't flag the session. Passing the cleaned + // real UA keeps sec-ch-ua consistent with the override. + await dbg.sendCommand('Emulation.setUserAgentOverride', { + userAgent: cleanElectronUserAgent(guest.getUserAgent()) + }) + } + } else { + await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) + await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { + enabled: false, + maxTouchPoints: 0 + }) + // Why: passing an empty string restores the session default UA. + await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) + } + return true + } catch { + return false + } + } + // --------------------------------------------------------------------------- // Browser Context Grab — main-owned operations // --------------------------------------------------------------------------- diff --git a/src/main/browser/browser-media-access.ts b/src/main/browser/browser-media-access.ts new file mode 100644 index 00000000000..c1138d6acbb --- /dev/null +++ b/src/main/browser/browser-media-access.ts @@ -0,0 +1,63 @@ +import { systemPreferences } from 'electron' +import type { MediaAccessPermissionRequest } from 'electron' + +// Why: macOS gates all camera/microphone access at the app-process level via +// TCC. Electron's per-session permission handlers run inside that envelope: +// if we call callback(true) but macOS has not granted the parent app, the +// stream is still empty. Conversely, if we deny at the session handler, pages +// never see the stream even when macOS has granted — which is the bug the user +// hit inside the in-app browser (#1273 only fixed Settings → Permissions, not +// the actual runtime getUserMedia() path). +// +// These helpers let both the main window session and the browser-tab sessions +// consult the same macOS-aware logic, so once a user has granted Camera or +// Microphone to Orca (via Settings → Permissions or directly in System +// Settings), a page inside an in-app browser tab actually receives the stream. + +export function requestedMediaTypes( + details: MediaAccessPermissionRequest | undefined +): Set<'audio' | 'video'> { + return new Set(details?.mediaTypes ?? []) +} + +export function hasSystemMediaAccess(mediaType: string | undefined): boolean { + if (process.platform !== 'darwin') { + return true + } + if (mediaType === 'audio') { + return systemPreferences.getMediaAccessStatus('microphone') === 'granted' + } + if (mediaType === 'video') { + return systemPreferences.getMediaAccessStatus('camera') === 'granted' + } + return false +} + +export async function requestSystemMediaAccess( + details: MediaAccessPermissionRequest | undefined +): Promise { + if (process.platform !== 'darwin') { + return true + } + + const mediaTypes = requestedMediaTypes(details) + if (mediaTypes.size === 0) { + return false + } + + if (mediaTypes.has('audio')) { + // Why: macOS only shows the TCC prompt from the app process, so Chromium's + // media grant is paired with the OS-level request at the actual media ask. + const granted = await systemPreferences.askForMediaAccess('microphone') + if (!granted) { + return false + } + } + if (mediaTypes.has('video')) { + const granted = await systemPreferences.askForMediaAccess('camera') + if (!granted) { + return false + } + } + return true +} diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 822ecd88445..6a51a95c1bc 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -1,12 +1,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { sessionFromPartitionMock } = vi.hoisted(() => ({ - sessionFromPartitionMock: vi.fn() -})) +const { sessionFromPartitionMock, askForMediaAccessMock, getMediaAccessStatusMock } = vi.hoisted( + () => ({ + sessionFromPartitionMock: vi.fn(), + askForMediaAccessMock: vi.fn(), + getMediaAccessStatusMock: vi.fn() + }) +) vi.mock('electron', () => ({ session: { fromPartition: sessionFromPartitionMock + }, + systemPreferences: { + askForMediaAccess: askForMediaAccessMock, + getMediaAccessStatus: getMediaAccessStatusMock } })) @@ -24,6 +32,10 @@ import { ORCA_BROWSER_PARTITION } from '../../shared/constants' describe('BrowserSessionRegistry', () => { beforeEach(() => { sessionFromPartitionMock.mockReset() + askForMediaAccessMock.mockReset() + getMediaAccessStatusMock.mockReset() + askForMediaAccessMock.mockResolvedValue(true) + getMediaAccessStatusMock.mockReturnValue('granted') sessionFromPartitionMock.mockReturnValue({ setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), @@ -147,6 +159,25 @@ describe('BrowserSessionRegistry', () => { expect(mockSession?.setPermissionCheckHandler).toHaveBeenCalled() }) + it('routes media permission requests through macOS TCC for isolated partitions', async () => { + // Why: verify the parallel fix to the default partition — isolated/imported + // profiles must also defer media permission checks to macOS instead of + // denying outright, otherwise pages inside them still hit NotAllowedError + // after the user grants Camera/Microphone to Orca. + browserSessionRegistry.createProfile('isolated', 'Media Test') + const mockSession = sessionFromPartitionMock.mock.results[0]?.value + const requestHandler = mockSession.setPermissionRequestHandler.mock.calls[0][0] + const checkHandler = mockSession.setPermissionCheckHandler.mock.calls[0][0] + + const cb = vi.fn() + const guestWc = { id: 7, getURL: vi.fn(() => 'https://example.com/') } + requestHandler(guestWc, 'media', cb, { mediaTypes: ['video'] }) + await vi.waitFor(() => expect(cb).toHaveBeenCalledWith(true)) + + expect(checkHandler(null, 'media', '', { mediaType: 'video' })).toBe(true) + expect(checkHandler(null, 'notifications', '', {})).toBe(false) + }) + describe('setupClientHintsOverride', () => { it('overrides sec-ch-ua headers for Edge UA', () => { const onBeforeSendHeaders = vi.fn() diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 10272c1cb35..cf6d3fffa11 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -16,6 +16,7 @@ import { join } from 'node:path' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../shared/types' import { browserManager } from './browser-manager' +import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua' type BrowserSessionMeta = { @@ -358,7 +359,39 @@ class BrowserSessionRegistry { // clipboard commands to work. Without these, navigator.clipboard.writeText/readText // throws NotAllowedError even when invoked via CDP with userGesture:true. const autoGranted = new Set(['fullscreen', 'clipboard-read', 'clipboard-sanitized-write']) - sess.setPermissionRequestHandler((webContents, permission, callback) => { + sess.setPermissionRequestHandler((webContents, permission, callback, details) => { + // Why: `media` (camera/mic) must defer to macOS TCC instead of being + // denied outright. Denying at the session layer would make pages inside + // isolated browser profiles throw NotAllowedError even after the user + // granted Camera/Microphone to Orca — the same bug we fixed for the + // default partition. macOS TCC still gates the actual stream, so + // granting here only forwards what the OS has already authorized. + if (permission === 'media') { + void requestSystemMediaAccess( + details as Electron.MediaAccessPermissionRequest | undefined + ).then( + (granted) => { + if (!granted) { + browserManager.notifyPermissionDenied({ + guestWebContentsId: webContents.id, + permission, + rawUrl: webContents.getURL() + }) + } + callback(granted) + }, + (error: unknown) => { + console.error('[permissions] Browser media access failed:', error) + browserManager.notifyPermissionDenied({ + guestWebContentsId: webContents.id, + permission, + rawUrl: webContents.getURL() + }) + callback(false) + } + ) + return + } const allowed = autoGranted.has(permission) if (!allowed) { browserManager.notifyPermissionDenied({ @@ -369,7 +402,10 @@ class BrowserSessionRegistry { } callback(allowed) }) - sess.setPermissionCheckHandler((_webContents, permission) => { + sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => { + if (permission === 'media') { + return hasSystemMediaAccess(details?.mediaType) + } return autoGranted.has(permission) }) sess.setDisplayMediaRequestHandler((_request, callback) => { diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index 37a89caea3d..8475a0cbca8 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -273,7 +273,7 @@ describe('Browser automation pipeline (integration)', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath)! - endpoint = metadata.transport!.endpoint + endpoint = metadata.transports[0]!.endpoint authToken = metadata.authToken! }) @@ -521,7 +521,7 @@ describe('Browser automation pipeline (integration)', () => { await server2.start() const metadata2 = readRuntimeMetadata(userDataPath2)! - const res = await sendRequest(metadata2.transport!.endpoint, { + const res = await sendRequest(metadata2.transports[0]!.endpoint, { id: 'req_no_tab', authToken: metadata2.authToken, method: 'browser.snapshot' diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 64768f2ee3a..3c9f32c3648 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -13,7 +13,8 @@ import { deleteActiveClaudeKeychainCredentials, readActiveClaudeKeychainCredentials, readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials + writeActiveClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials } from './keychain' export type ClaudeRuntimeAuthPreparation = { @@ -122,6 +123,14 @@ export class ClaudeRuntimeAuthService { return } + // Why: Claude CLI refreshes expired OAuth tokens and writes them back to + // .credentials.json. If we detect the runtime file differs from what Orca + // last wrote, the CLI must have refreshed — so we preserve those tokens + // back to managed storage before overwriting runtime with managed state. + if (this.lastSyncedAccountId === activeAccount.id) { + await this.readBackRefreshedTokens(activeAccount) + } + this.writeRuntimeCredentials(credentialsJson) if (process.platform === 'darwin') { await writeActiveClaudeKeychainCredentials(credentialsJson) @@ -130,6 +139,44 @@ export class ClaudeRuntimeAuthService { this.lastSyncedAccountId = activeAccount.id } + // Why: called by ClaudeAccountService before syncForCurrentSelection() after + // re-auth or add-account. Those flows write fresh tokens to managed storage, + // so the read-back must be skipped to avoid overwriting them with stale + // runtime tokens. + clearLastWrittenCredentialsJson(): void { + this.lastWrittenCredentialsJson = null + } + + private async readBackRefreshedTokens(account: ClaudeManagedAccount): Promise { + try { + if (this.lastWrittenCredentialsJson === null) { + return + } + + const paths = this.pathResolver.getRuntimePaths() + if (!existsSync(paths.credentialsPath)) { + return + } + + const runtimeContents = readFileSync(paths.credentialsPath, 'utf-8') + if (runtimeContents === this.lastWrittenCredentialsJson) { + return + } + + if (process.platform === 'darwin') { + await writeManagedClaudeKeychainCredentials(account.id, runtimeContents) + } else { + const credentialsPath = join(account.managedAuthPath, '.credentials.json') + writeFileAtomically(credentialsPath, runtimeContents, { mode: 0o600 }) + } + } catch (error) { + // Why: read-back is best-effort. A transient fs error must not block the + // forward sync path — the worst case is one more stale-token cycle, which + // is strictly better than failing the entire sync. + console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error) + } + } + private getPreparation(): ClaudeRuntimeAuthPreparation { const settings = this.store.getSettings() const paths = this.pathResolver.getRuntimePaths() diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 0063e5a1a3c..78324c2ca4a 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -110,12 +110,14 @@ export class ClaudeAccountService { lastAuthenticatedAt: now } + const outgoingAccountId = previousSettings.activeClaudeManagedAccountId this.store.updateSettings({ claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], activeClaudeManagedAccountId: account.id }) + this.runtimeAuth.clearLastWrittenCredentialsJson() await this.syncRuntimeAuthWithLivePtyGate() - await this.rateLimits.refreshForClaudeAccountChange() + await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId) return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(previousSettings) @@ -152,6 +154,7 @@ export class ClaudeAccountService { ) }) try { + this.runtimeAuth.clearLastWrittenCredentialsJson() await this.syncRuntimeAuthWithLivePtyGate() await this.rateLimits.refreshForClaudeAccountChange() return this.getSnapshot() @@ -178,7 +181,12 @@ export class ClaudeAccountService { try { await this.syncRuntimeAuthWithLivePtyGate() await this.safeRemoveManagedAuth(accountId, account.managedAuthPath) - await this.rateLimits.refreshForClaudeAccountChange() + this.rateLimits.evictInactiveClaudeCache(accountId) + await this.rateLimits.refreshForClaudeAccountChange( + settings.activeClaudeManagedAccountId === accountId + ? settings.activeClaudeManagedAccountId + : undefined + ) return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(settings) @@ -192,10 +200,11 @@ export class ClaudeAccountService { this.requireAccount(accountId) } const previousSettings = this.store.getSettings() + const outgoingAccountId = previousSettings.activeClaudeManagedAccountId this.store.updateSettings({ activeClaudeManagedAccountId: accountId }) try { await this.syncRuntimeAuthWithLivePtyGate() - await this.rateLimits.refreshForClaudeAccountChange() + await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId) return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(previousSettings) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 125848e3efe..c8563313834 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -53,7 +53,13 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' ? scriptPath : `/bin/sh "${scriptPath}"` + // Why: on Windows, Claude Code runs hooks through Git Bash (`/usr/bin/bash`). + // A path with single backslashes (e.g. `C:\Users\…\claude-hook.cmd`) is + // interpreted by bash as a string with escape sequences, so `\U`, `\A`, etc. + // collapse and the launcher fails with `command not found`. Emit forward + // slashes — Windows accepts them in path arguments and bash leaves them + // intact, so the same JSON value works through every shell layer. + return process.platform === 'win32' ? scriptPath.replaceAll('\\', '/') : `/bin/sh "${scriptPath}"` } function getManagedScript(): string { diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 6b230d61330..d24edef5f2b 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -30,6 +30,7 @@ vi.mock('node:os', async () => { }) function createSettings(overrides: Partial = {}): GlobalSettings { + const appFontFamily = overrides.appFontFamily ?? 'Geist' return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -39,10 +40,12 @@ function createSettings(overrides: Partial = {}): GlobalSettings theme: 'system', editorAutoSave: false, editorAutoSaveDelayMs: 1000, + editorMinimapEnabled: false, terminalFontSize: 14, terminalFontFamily: 'JetBrains Mono', terminalFontWeight: 500, terminalLineHeight: 1, + terminalGpuAcceleration: 'auto', terminalLigatures: 'auto', terminalCursorStyle: 'block', terminalCursorBlink: false, @@ -64,14 +67,14 @@ function createSettings(overrides: Partial = {}): GlobalSettings openLinksInApp: false, rightSidebarOpenByDefault: true, showTitlebarAgentActivity: true, - showAgentDashboard: true, - showTaskProviderIcons: true, + showTasksButton: true, diffDefaultView: 'inline', notifications: { enabled: true, agentTaskComplete: true, terminalBell: false, - suppressWhenFocused: true + suppressWhenFocused: true, + customSoundPath: null }, promptCacheTimerEnabled: false, promptCacheTtlMs: 300_000, @@ -86,14 +89,21 @@ function createSettings(overrides: Partial = {}): GlobalSettings defaultTaskSource: 'github', defaultRepoSelection: null, defaultLinearTeamSelection: null, + opencodeSessionCookie: '', + opencodeWorkspaceId: '', + geminiCliOAuthEnabled: false, agentCmdOverrides: {}, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, - experimentalTerminalDaemon: false, - experimentalTerminalDaemonNoticeShown: false, + experimentalAgentDashboard: false, + experimentalMobile: false, + experimentalSidekick: false, + experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, - ...overrides + ...overrides, + appFontFamily } } @@ -497,6 +507,225 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(snapshotPath)).toBe(false) }) + it('reads back CLI-refreshed tokens into managed storage on subsequent sync', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"tokens":"original"}\n' + ) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // Simulate CLI refreshing the token in ~/.codex/auth.json + writeFileSync(runtimeAuthPath, '{"tokens":"refreshed"}\n', 'utf-8') + + // Next sync should read back the refreshed token to managed storage + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe('{"tokens":"refreshed"}\n') + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"refreshed"}\n') + }) + + it('skips read-back on first sync after restart (lastWrittenAuthJson is null)', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + // Pre-populate runtime with different content to simulate a CLI refresh while Orca was down + writeFileSync(runtimeAuthPath, '{"tokens":"refreshed-while-down"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"tokens":"original"}\n' + ) + const managedAuthPath = join(managedHomePath, 'auth.json') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + ) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + // After restart, managed storage should NOT be overwritten by unknown runtime state + expect(readFileSync(managedAuthPath, 'utf-8')).toBe('{"tokens":"original"}\n') + // Runtime should be overwritten with managed (conservative behavior) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"original"}\n') + }) + + it('skips read-back during account switch', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const managedHomePath1 = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"tokens":"account1"}\n' + ) + const managedHomePath2 = createManagedAuth( + testState.userDataDir, + 'account-2', + '{"tokens":"account2"}\n' + ) + const managedAuthPath2 = join(managedHomePath2, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user1@example.com', + managedHomePath: managedHomePath1, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'user2@example.com', + managedHomePath: managedHomePath2, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // Switch to account-2 — runtime still has account-1 tokens + settings.activeCodexManagedAccountId = 'account-2' + service.syncForCurrentSelection() + + // Account-2's managed auth should NOT be contaminated with account-1's runtime tokens + expect(readFileSync(managedAuthPath2, 'utf-8')).toBe('{"tokens":"account2"}\n') + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"account2"}\n') + }) + + it('detects external login on managed→system-default transition', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"account":"managed"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // External `codex auth login` overwrites runtime auth + writeFileSync(runtimeAuthPath, '{"account":"external-login"}\n', 'utf-8') + + // Deselect managed account + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + // Should keep the external login, not restore stale system snapshot + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"external-login"}\n') + // Snapshot should be deleted so next capture picks up the external login + expect( + existsSync(join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json')) + ).toBe(false) + }) + + it('does not clobber fresh tokens after clearLastWrittenAuthJson', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"tokens":"original"}\n' + ) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // Simulate re-auth: managed storage gets fresh tokens + writeFileSync(managedAuthPath, '{"tokens":"reauthed"}\n', 'utf-8') + + // Clear tracking before sync (as CodexAccountService would) + service.clearLastWrittenAuthJson() + service.syncForCurrentSelection() + + // Fresh re-auth tokens should survive — not be clobbered by stale runtime read-back + expect(readFileSync(managedAuthPath, 'utf-8')).toBe('{"tokens":"reauthed"}\n') + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"reauthed"}\n') + }) + it('preserves conflicting legacy session files under deterministic names', async () => { const runtimeSessionsDir = join(testState.fakeHomeDir, '.codex', 'sessions') mkdirSync(runtimeSessionsDir, { recursive: true }) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index f0cf6fd8405..3ec95ecfa68 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -24,6 +24,13 @@ export class CodexRuntimeHomeService { // (e.g. user running `codex login` or another auth tool). The snapshot // restore only fires on the managed→system-default transition. private lastSyncedAccountId: string | null = null + // Why: tracks the auth.json content Orca last wrote to ~/.codex/auth.json. + // Between syncs, if the file differs, Codex CLI refreshed the token — so + // Orca writes back the refreshed token to managed storage before overwriting. + // On managed→system-default transition, if the file differs, an external + // login (e.g. `codex auth login`) overwrote it — so Orca adopts the file as + // the new system default instead of restoring a stale snapshot. + private lastWrittenAuthJson: string | null = null constructor(private readonly store: Store) { this.safeMigrateLegacyManagedState() @@ -80,10 +87,51 @@ export class CodexRuntimeHomeService { return } + // Why: Codex CLI refreshes expired OAuth tokens and writes them back to + // ~/.codex/auth.json. If we detect the runtime file differs from what Orca + // last wrote, the CLI must have refreshed — so we preserve those tokens + // back to managed storage before overwriting runtime with managed state. + if (this.lastSyncedAccountId === activeAccount.id) { + this.readBackRefreshedTokens(activeAuthPath) + } + this.lastSyncedAccountId = activeAccount.id this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8')) } + // Why: called by CodexAccountService before syncForCurrentSelection() after + // re-auth or add-account. Those flows write fresh tokens to managed storage, + // so the read-back must be skipped to avoid overwriting them with stale + // runtime tokens. + clearLastWrittenAuthJson(): void { + this.lastWrittenAuthJson = null + } + + private readBackRefreshedTokens(managedAuthPath: string): void { + try { + const runtimeAuthPath = this.getRuntimeAuthPath() + if (!existsSync(runtimeAuthPath)) { + return + } + + if (this.lastWrittenAuthJson === null) { + return + } + + const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') + if (runtimeContents === this.lastWrittenAuthJson) { + return + } + + writeFileAtomically(managedAuthPath, runtimeContents, { mode: 0o600 }) + } catch (error) { + // Why: read-back is best-effort. A transient fs error must not block the + // forward sync path — the worst case is one more stale-token cycle, which + // is strictly better than failing the entire sync. + console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error) + } + } + private safeSyncForCurrentSelection(): void { try { this.syncForCurrentSelection() @@ -297,6 +345,13 @@ export class CodexRuntimeHomeService { } private restoreSystemDefaultSnapshot(): void { + // Why: detect whether an external tool (e.g. `codex auth login`) overwrote + // auth.json while a managed account was active. If so, that external login + // becomes the new system default — skip the stale snapshot restore. + if (this.detectExternalLoginAndUpdateSnapshot()) { + return + } + const snapshotPath = this.getSystemDefaultSnapshotPath() if (!existsSync(snapshotPath)) { return @@ -305,10 +360,40 @@ export class CodexRuntimeHomeService { this.writeRuntimeAuth(readFileSync(snapshotPath, 'utf-8')) } + // Why: mirrors ClaudeRuntimeAuthService.detectExternalLoginAndUpdateSnapshot(). + // If the runtime auth.json differs from what Orca last wrote, something + // external changed it. That external state should become the new system + // default rather than being overwritten by a potentially stale snapshot. + private detectExternalLoginAndUpdateSnapshot(): boolean { + if (this.lastWrittenAuthJson === null) { + return false + } + + const runtimeAuthPath = this.getRuntimeAuthPath() + if (!existsSync(runtimeAuthPath)) { + return false + } + + try { + const currentAuth = readFileSync(runtimeAuthPath, 'utf-8') + if (currentAuth === this.lastWrittenAuthJson) { + return false + } + } catch { + return false + } + + const snapshotPath = this.getSystemDefaultSnapshotPath() + rmSync(snapshotPath, { force: true }) + this.lastWrittenAuthJson = null + return true + } + private writeRuntimeAuth(contents: string): void { // Why: auth.json contains sensitive credentials. Restrict to owner-only // so other users on a shared Linux/macOS machine cannot read it. writeFileAtomically(this.getRuntimeAuthPath(), contents, { mode: 0o600 }) + this.lastWrittenAuthJson = contents } clearSystemDefaultSnapshot(): void { diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index e4db05b45de..2d78c279504 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -24,6 +24,7 @@ vi.mock('node:os', async () => { }) function createSettings(overrides: Partial = {}): GlobalSettings { + const appFontFamily = overrides.appFontFamily ?? 'Geist' return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -33,10 +34,12 @@ function createSettings(overrides: Partial = {}): GlobalSettings theme: 'system', editorAutoSave: false, editorAutoSaveDelayMs: 1000, + editorMinimapEnabled: false, terminalFontSize: 14, terminalFontFamily: 'JetBrains Mono', terminalFontWeight: 500, terminalLineHeight: 1, + terminalGpuAcceleration: 'auto', terminalLigatures: 'auto', terminalCursorStyle: 'block', terminalCursorBlink: false, @@ -58,14 +61,14 @@ function createSettings(overrides: Partial = {}): GlobalSettings openLinksInApp: false, rightSidebarOpenByDefault: true, showTitlebarAgentActivity: true, - showAgentDashboard: true, - showTaskProviderIcons: true, + showTasksButton: true, diffDefaultView: 'inline', notifications: { enabled: true, agentTaskComplete: true, terminalBell: false, - suppressWhenFocused: true + suppressWhenFocused: true, + customSoundPath: null }, promptCacheTimerEnabled: false, promptCacheTtlMs: 300_000, @@ -80,14 +83,21 @@ function createSettings(overrides: Partial = {}): GlobalSettings defaultTaskSource: 'github', defaultRepoSelection: null, defaultLinearTeamSelection: null, + opencodeSessionCookie: '', + opencodeWorkspaceId: '', + geminiCliOAuthEnabled: false, agentCmdOverrides: {}, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, - experimentalTerminalDaemon: false, - experimentalTerminalDaemonNoticeShown: false, + experimentalAgentDashboard: false, + experimentalMobile: false, + experimentalSidekick: false, + experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, - ...overrides + ...overrides, + appFontFamily } } @@ -110,13 +120,15 @@ function createStore(settings: GlobalSettings) { function createRateLimits() { return { - refreshForCodexAccountChange: vi.fn().mockResolvedValue(undefined) + refreshForCodexAccountChange: vi.fn().mockResolvedValue(undefined), + evictInactiveCodexCache: vi.fn() } } function createRuntimeHome() { return { - syncForCurrentSelection: vi.fn() + syncForCurrentSelection: vi.fn(), + clearLastWrittenAuthJson: vi.fn() } } @@ -553,7 +565,8 @@ describe('CodexAccountService config sync', () => { const rateLimits = { refreshForCodexAccountChange: vi.fn(async () => { callOrder.push('refresh') - }) + }), + evictInactiveCodexCache: vi.fn() } const runtimeHome = createRuntimeHome() diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 7f37b470092..2fcaa742c3f 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -106,9 +106,13 @@ export class CodexAccountService { activeCodexManagedAccountId: account.id }) this.safeSyncCanonicalConfigToManagedHomes() + this.runtimeHome.clearLastWrittenAuthJson() this.runtimeHome.syncForCurrentSelection() - await this.rateLimits.refreshForCodexAccountChange() + // Why: the new account becomes active, so the previous active account is + // now inactive and its last-known usage should be cached for the switcher. + const outgoingAccountId = settings.activeCodexManagedAccountId + await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId) return this.getSnapshot() } catch (error) { this.safeRemoveManagedHome(managedHomePath) @@ -146,6 +150,7 @@ export class CodexAccountService { codexManagedAccounts: updatedAccounts }) this.safeSyncCanonicalConfigToManagedHomes() + this.runtimeHome.clearLastWrittenAuthJson() this.runtimeHome.syncForCurrentSelection() // Why: re-auth can change which actual Codex identity the managed home @@ -171,7 +176,14 @@ export class CodexAccountService { this.runtimeHome.syncForCurrentSelection() this.safeRemoveManagedHome(account.managedHomePath) - await this.rateLimits.refreshForCodexAccountChange() + // Why: a removed account can no longer appear in the switcher dropdown, + // so purge its cached usage to avoid stale entries. + this.rateLimits.evictInactiveCodexCache(accountId) + await this.rateLimits.refreshForCodexAccountChange( + settings.activeCodexManagedAccountId === accountId + ? settings.activeCodexManagedAccountId + : undefined + ) return this.getSnapshot() } @@ -180,13 +192,16 @@ export class CodexAccountService { this.requireAccount(accountId) } + const previousSettings = this.store.getSettings() + const outgoingAccountId = previousSettings.activeCodexManagedAccountId + this.store.updateSettings({ activeCodexManagedAccountId: accountId }) this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.syncForCurrentSelection() - await this.rateLimits.refreshForCodexAccountChange() + await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId) return this.getSnapshot() } diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 2b9884f07d4..a4c9ef2068f 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -1,11 +1,17 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { createServer, connect, type Server } from 'net' import { DaemonServer } from './daemon-server' -import { getDaemonPidPath } from './daemon-spawner' -import { healthCheckDaemon, killStaleDaemon } from './daemon-health' +import { getDaemonPidPath, serializeDaemonPidFile } from './daemon-spawner' +import { + getProcessStartedAtMs, + healthCheckDaemon, + killStaleDaemon, + parseDaemonPidFile, + startTimeMatches +} from './daemon-health' import type { SubprocessHandle } from './session' function createMockSubprocess(): SubprocessHandle { @@ -17,7 +23,8 @@ function createMockSubprocess(): SubprocessHandle { forceKill() {}, signal() {}, onData() {}, - onExit() {} + onExit() {}, + dispose() {} } } @@ -107,3 +114,126 @@ describe('daemon health', () => { } }) }) + +describe('parseDaemonPidFile', () => { + it('parses JSON pid files with startedAtMs', () => { + const serialized = serializeDaemonPidFile({ pid: 12345, startedAtMs: 1_700_000_000_000 }) + expect(parseDaemonPidFile(serialized)).toEqual({ + pid: 12345, + startedAtMs: 1_700_000_000_000 + }) + }) + + it('accepts JSON with startedAtMs missing and returns null for it', () => { + // Why: forward-compatible with hypothetical future daemons that might write + // pid without startedAtMs (platform where getProcessStartedAtMs returns null). + expect(parseDaemonPidFile('{"pid":9999}')).toEqual({ pid: 9999, startedAtMs: null }) + }) + + it('falls back to bare-integer parsing for legacy pid files', () => { + // Why: pre-Phase-0 daemons wrote the pid file as a bare integer. + // parseDaemonPidFile must still accept those to avoid leaking a stale + // daemon across a single upgrade boundary. + expect(parseDaemonPidFile('12345')).toEqual({ pid: 12345, startedAtMs: null }) + expect(parseDaemonPidFile(' 12345\n')).toEqual({ pid: 12345, startedAtMs: null }) + }) + + it('returns null for malformed input', () => { + expect(parseDaemonPidFile('not-a-number')).toBeNull() + expect(parseDaemonPidFile('{"pid":"abc"}')).toBeNull() + expect(parseDaemonPidFile('{"not_pid":123}')).toBeNull() + }) +}) + +describe('startTimeMatches', () => { + it('returns true when expected is null (legacy pid file)', () => { + // The real process pid is irrelevant here — null short-circuits before + // getProcessStartedAtMs is consulted. + expect(startTimeMatches(process.pid, null)).toBe(true) + }) + + it('returns true when actual start time cannot be read (fail-open)', () => { + if (process.platform === 'win32') { + // Windows always returns null from getProcessStartedAtMs, which is the + // fail-open case we want. + expect(startTimeMatches(process.pid, 1_700_000_000_000)).toBe(true) + return + } + // Pid 0 is the kernel scheduler — ps -p 0 / /proc/0 both fail, so + // getProcessStartedAtMs returns null and the check fails open. + expect(startTimeMatches(0, 1_700_000_000_000)).toBe(true) + }) + + it('returns true for matching start time within tolerance', () => { + if (process.platform === 'win32') { + // Skip on Windows — getProcessStartedAtMs always returns null. + return + } + const actual = getProcessStartedAtMs(process.pid) + if (actual === null) { + // Platform can't probe — skip + return + } + // Tolerance is ±1500ms. Shift expected by 500ms, still within tolerance. + expect(startTimeMatches(process.pid, actual + 500)).toBe(true) + }) + + it('returns false for start times outside tolerance', () => { + if (process.platform === 'win32') { + return + } + const actual = getProcessStartedAtMs(process.pid) + if (actual === null) { + return + } + // Shift expected by 10s — clearly outside the ±1500ms tolerance. + expect(startTimeMatches(process.pid, actual + 10_000)).toBe(false) + }) +}) + +describe('killStaleDaemon pid identity guards', () => { + let dir: string + let socketPath: string + let tokenPath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-health-pid-test-')) + socketPath = join(dir, 'daemon.sock') + tokenPath = join(dir, 'daemon.token') + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('does not SIGTERM when the saved startedAtMs mismatches the current process', async () => { + if (process.platform === 'win32') { + return + } + + // Why: seed a pid file that claims the daemon is `process.pid` (us) but + // was started 1 hour ago. Our real start time is "now," so startTimeMatches + // returns false and isDaemonProcess rejects. killStaleDaemon must not call + // process.kill in that case. + const bogusStartedAtMs = Date.now() - 60 * 60 * 1000 + writeFileSync( + getDaemonPidPath(dir), + serializeDaemonPidFile({ pid: process.pid, startedAtMs: bogusStartedAtMs }), + { mode: 0o600 } + ) + + // isDaemonProcess uses process.kill(pid, 0) as a liveness probe; that's + // expected and not a real kill. We only care that no actual termination + // signal is sent. + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toBe(false) + const terminationSignals = killSpy.mock.calls.filter( + ([, sig]) => sig === 'SIGTERM' || sig === 'SIGKILL' + ) + expect(terminationSignals).toEqual([]) + } finally { + killSpy.mockRestore() + } + }) +}) diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index a0ab5025ce4..7d6126db9a5 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -1,3 +1,5 @@ +/* oxlint-disable max-lines -- Why: pid validation shares process-identity +helpers with kill escalation so the SIGKILL safety checks stay co-located. */ import { execFileSync } from 'child_process' import { existsSync, readFileSync, unlinkSync } from 'fs' import { connect, type Socket } from 'net' @@ -8,6 +10,12 @@ import { PROTOCOL_VERSION, type HelloMessage, type HelloResponse } from './types const HEALTH_CHECK_TIMEOUT_MS = 3_000 const KILL_WAIT_MS = 3_000 const KILL_POLL_MS = 100 +const START_TIME_TOLERANCE_MS = 1_500 + +type ParsedDaemonPid = { + pid: number + startedAtMs: number | null +} function canConnectSocket(socketPath: string): Promise { return new Promise((resolve) => { @@ -128,7 +136,94 @@ function commandLineMatchesDaemon( ) } -function isDaemonProcess(pid: number, socketPath: string, tokenPath: string): boolean { +export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { + const trimmed = contents.trim() + try { + const parsed = JSON.parse(trimmed) as { pid?: unknown; startedAtMs?: unknown } + if (typeof parsed.pid === 'number' && Number.isFinite(parsed.pid)) { + return { + pid: parsed.pid, + startedAtMs: + typeof parsed.startedAtMs === 'number' && Number.isFinite(parsed.startedAtMs) + ? parsed.startedAtMs + : null + } + } + } catch { + // Legacy daemons wrote the pid file as a bare integer. + } + + const pid = Number(trimmed) + return Number.isFinite(pid) ? { pid, startedAtMs: null } : null +} + +function getLinuxProcessStartedAtMs(pid: number): number | null { + try { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') + const afterCommand = stat.slice(stat.lastIndexOf(')') + 2) + const fields = afterCommand.split(' ') + const startTicks = Number(fields[19]) + const bootTimeLine = readFileSync('/proc/stat', 'utf8') + .split('\n') + .find((line) => line.startsWith('btime ')) + const bootTimeSeconds = bootTimeLine ? Number(bootTimeLine.split(/\s+/)[1]) : Number.NaN + const ticksPerSecond = Number( + execFileSync('getconf', ['CLK_TCK'], { encoding: 'utf8', timeout: 1_000 }).trim() + ) + if ( + !Number.isFinite(startTicks) || + !Number.isFinite(bootTimeSeconds) || + !Number.isFinite(ticksPerSecond) || + ticksPerSecond <= 0 + ) { + return null + } + return bootTimeSeconds * 1000 + (startTicks / ticksPerSecond) * 1000 + } catch { + return null + } +} + +export function getProcessStartedAtMs(pid: number): number | null { + if (process.platform === 'linux') { + return getLinuxProcessStartedAtMs(pid) + } + + if (process.platform === 'win32') { + return null + } + + try { + const output = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { + encoding: 'utf8', + timeout: 2_000 + }).trim() + const startedAtMs = Date.parse(output) + return Number.isFinite(startedAtMs) ? startedAtMs : null + } catch { + return null + } +} + +export function startTimeMatches(pid: number, expectedStartedAtMs: number | null): boolean { + if (expectedStartedAtMs === null) { + return true + } + + const actualStartedAtMs = getProcessStartedAtMs(pid) + if (actualStartedAtMs === null) { + return true + } + + return Math.abs(actualStartedAtMs - expectedStartedAtMs) <= START_TIME_TOLERANCE_MS +} + +function isDaemonProcess( + pid: number, + socketPath: string, + tokenPath: string, + startedAtMs: number | null +): boolean { try { process.kill(pid, 0) } catch { @@ -153,7 +248,10 @@ function isDaemonProcess(pid: number, socketPath: string, tokenPath: string): bo // Why: image names are too broad after PID reuse. Match the daemon entry // plus the exact socket/token args so we only kill the daemon for this // userData protocol endpoint. - return commandLineMatchesDaemon(output, socketPath, tokenPath) + return ( + commandLineMatchesDaemon(output, socketPath, tokenPath) && + startTimeMatches(pid, startedAtMs) + ) } catch { return false } @@ -161,14 +259,19 @@ function isDaemonProcess(pid: number, socketPath: string, tokenPath: string): bo try { const cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8') - return commandLineMatchesDaemon(cmdline, socketPath, tokenPath) + return ( + commandLineMatchesDaemon(cmdline, socketPath, tokenPath) && startTimeMatches(pid, startedAtMs) + ) } catch { try { const output = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8', timeout: 2_000 }) - return commandLineMatchesDaemon(output, socketPath, tokenPath) + return ( + commandLineMatchesDaemon(output, socketPath, tokenPath) && + startTimeMatches(pid, startedAtMs) + ) } catch { return false } @@ -184,8 +287,9 @@ export async function killStaleDaemon( const pidPath = getDaemonPidPath(runtimeDir, protocolVersion) let killedDaemon = false try { - const pid = Number.parseInt(readFileSync(pidPath, 'utf8').trim(), 10) - if (Number.isFinite(pid) && isDaemonProcess(pid, socketPath, tokenPath)) { + const parsedPid = parseDaemonPidFile(readFileSync(pidPath, 'utf8')) + if (parsedPid && isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs)) { + const { pid, startedAtMs } = parsedPid process.kill(pid, 'SIGTERM') const deadline = Date.now() + KILL_WAIT_MS let exited = false @@ -199,14 +303,24 @@ export async function killStaleDaemon( await new Promise((resolve) => setTimeout(resolve, KILL_POLL_MS)) } if (!exited) { - try { - process.kill(pid, 'SIGKILL') + // Why: re-check process identity before SIGKILL. The SIGTERM-then-wait + // window is long enough for the pid to be recycled if the original + // daemon died during the wait. Without this, we'd SIGKILL an unrelated + // process that happens to now own the same pid. + if (!isDaemonProcess(pid, socketPath, tokenPath, startedAtMs)) { + console.warn('[daemon] Skipping SIGKILL for stale daemon: reason=pid_recycled') exited = true - } catch { - // Already dead + killedDaemon = true + } else { + try { + process.kill(pid, 'SIGKILL') + exited = true + } catch { + // Already dead + } } } - killedDaemon = exited + killedDaemon = killedDaemon || exited } } catch { // PID file missing or process already dead diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index b02a51ed76a..5b4cdfafc7c 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -1,302 +1,603 @@ +/* eslint-disable max-lines -- Why: this file covers the entire restart flow + of daemon-init — construction, the 7-step sequence from + docs/daemon-staleness-ux.md §Phase 1, and the concurrency coalescer. A + single describe block with shared mocks keeps setup in one place; splitting + across files would duplicate the vi.hoisted boundary mocks with no cleaner + ownership seam. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { tmpdir } from 'os' -import { join } from 'path' -import { existsSync, mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'fs' -import { createServer, type Server, type Socket } from 'net' -import type { SubprocessHandle } from './session' -import type * as DaemonInitModule from './daemon-init' +import { PROTOCOL_VERSION } from './types' -const { getPathMock } = vi.hoisted(() => ({ - getPathMock: vi.fn() -})) +// Why: the restart flow touches many boundary modules (electron app paths, fs +// for dir creation, net for socket probe, DaemonClient over that socket, the +// spawner's launcher, ipc/pty listener binders). We only care here about the +// observable sequencing and identity invariants of runRestartDaemon, so every +// non-daemon-init dependency is replaced by a minimal stub that records calls. +const { + getPathMock, + probeSocketExistsMock, + netConnectMock, + healthCheckDaemonMock, + killStaleDaemonMock, + getProcessStartedAtMsMock, + daemonClientMock, + spawnerInstances, + adapterInstances, + setLocalPtyProviderMock, + unbindLocalProviderListenersMock, + rebindLocalProviderListenersMock +} = vi.hoisted(() => { + const getPathMock = vi.fn(() => '/fake/userData') -vi.mock('electron', () => ({ - app: { - getPath: getPathMock, - getAppPath: () => process.cwd(), - isPackaged: false - } -})) - -// Why: we want the real DaemonServer + DaemonClient but not electron-based -// subprocess spawning. createTestDaemon() wires a mock subprocess harness -// compatible with daemon-spawner.test.ts. -function createMockSubprocess(): SubprocessHandle { - let onExitCb: ((code: number) => void) | null = null - return { - pid: 77777, - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), - signal: vi.fn(), - onData(_cb: (data: string) => void) {}, - onExit(cb: (code: number) => void) { - onExitCb = cb + const probeSocketExistsMock = vi.fn(() => false) + const netConnectMock = vi.fn(() => { + // Why: the real probeSocket() in daemon-init connects to the socket and + // resolves true on 'connect', false on 'error'. Our launcher never runs + // in these tests (healthCheckDaemon short-circuits), but probeSocket is + // also invoked by cleanupDaemonForProtocol — stub the socket object so + // the 'error' path fires synchronously and cleanupDaemonForProtocol's + // alive=false branch runs without side effects. + const handlers: Record void)[]> = { connect: [], error: [] } + return { + on(event: string, cb: () => void) { + handlers[event]?.push(cb) + if (event === 'error') { + // Fire after microtask so destroy()/resolve ordering matches real net + queueMicrotask(() => cb()) + } + return this + }, + destroy() {} } + }) + + const healthCheckDaemonMock = vi.fn(async () => true) + const killStaleDaemonMock = vi.fn(async () => true) + const getProcessStartedAtMsMock = vi.fn(() => 1_000_000) + + const daemonClientMock = vi.fn().mockImplementation(() => ({ + ensureConnected: vi.fn(async () => {}), + request: vi.fn(async () => ({ sessions: [] })), + disconnect: vi.fn() + })) + + // Why: every DaemonSpawner constructed under test pushes into this array so + // assertions can check "was the *same* spawner reused across restart?". + const spawnerInstances: MockSpawner[] = [] + // Same for DaemonPtyAdapter. The test asserts the replacement adapter is a + // fresh instance whose respawn closure targets the *original* spawner. + const adapterInstances: MockAdapter[] = [] + + const setLocalPtyProviderMock = vi.fn() + const unbindLocalProviderListenersMock = vi.fn() + const rebindLocalProviderListenersMock = vi.fn() + + return { + getPathMock, + probeSocketExistsMock, + netConnectMock, + healthCheckDaemonMock, + killStaleDaemonMock, + getProcessStartedAtMsMock, + daemonClientMock, + spawnerInstances, + adapterInstances, + setLocalPtyProviderMock, + unbindLocalProviderListenersMock, + rebindLocalProviderListenersMock } +}) + +type MockSpawner = { + ensureRunning: ReturnType + resetHandle: ReturnType + shutdown: ReturnType + launcher: unknown } -async function importFreshDaemonInit(): Promise { +type MockAdapter = { + protocolVersion: number + options: { + socketPath: string + tokenPath: string + historyPath?: string + respawn?: () => Promise + protocolVersion?: number + } + getActiveSessionIds: ReturnType + fanoutSyntheticExits: ReturnType + listProcesses: ReturnType + listSessions: ReturnType + shutdown: ReturnType + dispose: ReturnType + disconnectOnly: ReturnType + onData: ReturnType + onExit: ReturnType + // Why: MockAdapter is fed through `new DaemonPtyRouter({ current, legacy })` + // during the "legacy preservation" test. The real router calls onData/onExit + // on each adapter; our stub returns a no-op unsubscribe so the router can + // subscribe without exploding. + callOrder: string[] +} + +vi.mock('electron', () => ({ + app: { getPath: getPathMock, getAppPath: () => '/fake/app' } +})) + +vi.mock('fs', () => ({ + mkdirSync: vi.fn(), + existsSync: (p: string) => probeSocketExistsMock() || p.includes('.pid'), + unlinkSync: vi.fn(), + writeFileSync: vi.fn() +})) + +vi.mock('child_process', () => ({ fork: vi.fn() })) + +vi.mock('net', () => ({ connect: netConnectMock })) + +vi.mock('./daemon-health', () => ({ + healthCheckDaemon: healthCheckDaemonMock, + killStaleDaemon: killStaleDaemonMock, + getProcessStartedAtMs: getProcessStartedAtMsMock +})) + +vi.mock('./client', () => ({ DaemonClient: daemonClientMock })) + +vi.mock('./daemon-spawner', () => ({ + DaemonSpawner: class MockDaemonSpawner { + readonly launcher: unknown + readonly ensureRunning: ReturnType + readonly resetHandle: ReturnType + readonly shutdown: ReturnType + private socketCounter: number + constructor(opts: { runtimeDir: string; launcher: unknown }) { + this.launcher = opts.launcher + this.socketCounter = 0 + // Why: each ensureRunning bumps a counter into the returned socketPath + // so the test can verify the *replacement* adapter is constructed with + // info from the second ensureRunning call, not stale info from the first. + this.ensureRunning = vi.fn(async () => { + this.socketCounter += 1 + return { + socketPath: `/fake/socket-${this.socketCounter}`, + tokenPath: `/fake/token-${this.socketCounter}` + } + }) + this.resetHandle = vi.fn() + this.shutdown = vi.fn(async () => {}) + spawnerInstances.push(this as unknown as MockSpawner) + } + }, + getDaemonSocketPath: (_dir: string, version?: number) => + `/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.sock`, + getDaemonTokenPath: (_dir: string, version?: number) => + `/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.token`, + getDaemonPidPath: (_dir: string, version?: number) => + `/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.pid`, + serializeDaemonPidFile: (obj: unknown) => JSON.stringify(obj) +})) + +vi.mock('./daemon-pty-adapter', () => ({ + DaemonPtyAdapter: class MockDaemonPtyAdapter { + readonly protocolVersion: number + readonly options: MockAdapter['options'] + readonly getActiveSessionIds: ReturnType + readonly fanoutSyntheticExits: ReturnType + readonly listProcesses: ReturnType + readonly listSessions: ReturnType + readonly shutdown: ReturnType + readonly dispose: ReturnType + readonly disconnectOnly: ReturnType + readonly onData: ReturnType + readonly onExit: ReturnType + readonly callOrder: string[] + constructor(opts: MockAdapter['options']) { + this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION + this.options = opts + this.callOrder = [] + this.getActiveSessionIds = vi.fn(() => [] as string[]) + this.fanoutSyntheticExits = vi.fn(() => { + this.callOrder.push('fanoutSyntheticExits') + }) + this.listProcesses = vi.fn(async () => []) + this.listSessions = vi.fn(async () => []) + this.shutdown = vi.fn(async () => {}) + this.dispose = vi.fn() + this.disconnectOnly = vi.fn(async () => {}) + this.onData = vi.fn(() => () => {}) + this.onExit = vi.fn(() => () => {}) + adapterInstances.push(this as unknown as MockAdapter) + } + } +})) + +vi.mock('../ipc/pty', () => ({ + setLocalPtyProvider: setLocalPtyProviderMock, + unbindLocalProviderListeners: unbindLocalProviderListenersMock, + rebindLocalProviderListeners: rebindLocalProviderListenersMock +})) + +async function importFresh() { vi.resetModules() + spawnerInstances.length = 0 + adapterInstances.length = 0 + setLocalPtyProviderMock.mockClear() + unbindLocalProviderListenersMock.mockClear() + rebindLocalProviderListenersMock.mockClear() + healthCheckDaemonMock.mockClear() + killStaleDaemonMock.mockClear() + daemonClientMock.mockClear() + probeSocketExistsMock.mockClear() + // Why: importing daemon-init *after* resetModules means the module-level + // `spawner`/`adapter`/`restartInFlight` start fresh for every test, which is + // the only way to reliably exercise the "first-time init" path and the + // coalescer independently. return import('./daemon-init') } -function writeNdjson(socket: Socket, message: unknown): void { - socket.write(`${JSON.stringify(message)}\n`) -} - -async function startLegacyDaemonStub( - socketPath: string, - tokenPath: string, - protocolVersion = 1 -): Promise<{ shutdown: () => Promise; shutdownRequested: () => boolean }> { - mkdirSync(join(tokenPath, '..'), { recursive: true }) - writeFileSync(tokenPath, 'legacy-token', { mode: 0o600 }) - let shutdownRequested = false - - const server = createServer((socket) => { - let buffer = '' - let greeted = false - socket.on('data', (chunk) => { - buffer += chunk.toString() - for (;;) { - const newlineIdx = buffer.indexOf('\n') - if (newlineIdx === -1) { - break - } - const line = buffer.slice(0, newlineIdx) - buffer = buffer.slice(newlineIdx + 1) - const message = JSON.parse(line) - if (!greeted) { - greeted = true - expect(message).toMatchObject({ - type: 'hello', - version: protocolVersion, - token: 'legacy-token' - }) - writeNdjson(socket, { type: 'hello', ok: true }) - continue - } - if (message.type === 'listSessions') { - writeNdjson(socket, { - id: message.id, - ok: true, - payload: { sessions: [{ sessionId: 'legacy', isAlive: true }] } - }) - } else if (message.type === 'shutdown') { - shutdownRequested = true - writeNdjson(socket, { id: message.id, ok: true, payload: {} }) - setTimeout(() => { - socket.destroy() - server.close(() => { - if (process.platform !== 'win32') { - try { - unlinkSync(socketPath) - } catch { - // Best-effort - } - } - }) - }, 0) - } - } - }) - }) - - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(socketPath, () => { - server.off('error', reject) - resolve() - }) - }) - - return { - shutdown: () => closeServer(server), - shutdownRequested: () => shutdownRequested - } -} - -function closeServer(server: Server): Promise { - return new Promise((resolve) => { - if (!server.listening) { - resolve() - return - } - server.close(() => resolve()) - }) -} - -describe('cleanupOrphanedDaemon', () => { - let userDataDir: string - +describe('daemon-init: runRestartDaemon (7-step sequence)', () => { beforeEach(() => { - userDataDir = mkdtempSync(join(tmpdir(), 'daemon-init-test-')) - getPathMock.mockImplementation(() => userDataDir) + probeSocketExistsMock.mockReturnValue(false) }) afterEach(() => { - rmSync(userDataDir, { recursive: true, force: true }) - vi.restoreAllMocks() + vi.clearAllMocks() }) - it('returns cleaned=false when no daemon socket exists', async () => { - const { cleanupOrphanedDaemon } = await importFreshDaemonInit() + it('fans pty:exit for every active session *before* unbinding listeners, and killedCount is captured pre-fanout', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() - const result = await cleanupOrphanedDaemon() - expect(result.cleaned).toBe(false) - expect(result.killedCount).toBe(0) + // Why: seed the original adapter with active sessions so + // fanoutSyntheticExits has something to do. The real adapter snapshots + // activeSessionIds then clears it; the mock emulates that so a regression + // that measures killedCount *after* fanout (when the set is empty) would + // surface as `killedCount === 0` here. + const originalAdapter = adapterInstances[0] + let activeIds = ['sess-a', 'sess-b', 'sess-c'] + originalAdapter.getActiveSessionIds.mockImplementation(() => [...activeIds]) + + const order: string[] = [] + originalAdapter.fanoutSyntheticExits.mockImplementation(() => { + order.push('fanout') + activeIds = [] + }) + unbindLocalProviderListenersMock.mockImplementation(() => { + order.push('unbind') + }) + + const result = await mod.restartDaemon() + + // killedCount must be 3 — proves the count was taken *before* the fanout + // cleared the set. A bug that swapped these two lines in source would + // report 0 here. + expect(result.killedCount).toBe(3) + expect(originalAdapter.fanoutSyntheticExits).toHaveBeenCalledWith(-1) + // The load-bearing ordering invariant: the synthetic exits must reach + // the renderer *before* listeners are torn down. Step 1 before Step 2. + expect(order).toEqual(['fanout', 'unbind']) }) - it('removes stale pid files when no daemon socket exists', async () => { - const { cleanupOrphanedDaemon } = await importFreshDaemonInit() - const { getDaemonPidPath } = await import('./daemon-spawner') + it('reuses the existing DaemonSpawner across restart (resetHandle + ensureRunning on same instance)', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() - const runtimeDir = join(userDataDir, 'daemon') - mkdirSync(runtimeDir, { recursive: true }) - const pidPath = getDaemonPidPath(runtimeDir) - writeFileSync(pidPath, '999999', { mode: 0o600 }) + expect(spawnerInstances).toHaveLength(1) + const originalSpawner = spawnerInstances[0] + expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(1) - const result = await cleanupOrphanedDaemon() + await mod.restartDaemon() - expect(result.cleaned).toBe(false) - expect(existsSync(pidPath)).toBe(false) + // No second DaemonSpawner was constructed — restart uses the one from init. + expect(spawnerInstances).toHaveLength(1) + expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1) + expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(2) }) - it('kills live sessions and shuts down a running daemon', async () => { - const { cleanupOrphanedDaemon } = await importFreshDaemonInit() - const { DaemonSpawner, getDaemonSocketPath } = await import('./daemon-spawner') - const { startDaemon } = await import('./daemon-main') - const { DaemonClient } = await import('./client') + it('builds a fresh adapter whose respawn callback closes over the same spawner', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() - const runtimeDir = join(userDataDir, 'daemon') - const { mkdirSync } = await import('fs') - mkdirSync(runtimeDir, { recursive: true }) + const originalSpawner = spawnerInstances[0] + const originalAdapter = adapterInstances[0] - // Spin up a real daemon exactly where cleanupOrphanedDaemon will look. - const daemonHandles: { shutdown: () => Promise }[] = [] - const spawner = new DaemonSpawner({ - runtimeDir, - launcher: async (socketPath, tokenPath) => { - const handle = await startDaemon({ - socketPath, - tokenPath, - spawnSubprocess: () => createMockSubprocess() - }) - daemonHandles.push(handle) - return { shutdown: () => handle.shutdown() } + await mod.restartDaemon() + + // A new adapter was constructed against the replacement daemon's socket. + expect(adapterInstances).toHaveLength(2) + const replacementAdapter = adapterInstances[1] + expect(replacementAdapter).not.toBe(originalAdapter) + expect(replacementAdapter.options.socketPath).toBe('/fake/socket-2') + expect(replacementAdapter.options.tokenPath).toBe('/fake/token-2') + + // Invoking the replacement adapter's respawn closure must drive the + // *same* original spawner (matches the crash-respawn closure baked into + // the first adapter — see daemon-init.ts step 5 comment). + originalSpawner.resetHandle.mockClear() + originalSpawner.ensureRunning.mockClear() + await replacementAdapter.options.respawn?.() + expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1) + expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(1) + // Still only one spawner in the whole test — nobody new was constructed. + expect(spawnerInstances).toHaveLength(1) + }) + + it('swaps the module-level adapter and re-binds listeners after the new provider is installed', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + // initDaemonPtyProvider calls setLocalPtyProvider once with the original. + expect(setLocalPtyProviderMock).toHaveBeenCalledTimes(1) + const originalProvider = setLocalPtyProviderMock.mock.calls[0][0] + expect(originalProvider).toBe(adapterInstances[0]) + expect(mod.getDaemonProvider()).toBe(originalProvider) + + await mod.restartDaemon() + + const replacementAdapter = adapterInstances[1] + // Second call: swap to the replacement provider (Step 6). + expect(setLocalPtyProviderMock).toHaveBeenCalledTimes(2) + expect(setLocalPtyProviderMock.mock.calls[1][0]).toBe(replacementAdapter) + expect(mod.getDaemonProvider()).toBe(replacementAdapter) + + // Step 7: rebind must run *after* Step 6. The last rebind call index + // must be greater than the last setLocalPtyProvider call index. + const rebindOrder = rebindLocalProviderListenersMock.mock.invocationCallOrder.at(-1) ?? -1 + const swapOrder = setLocalPtyProviderMock.mock.invocationCallOrder.at(-1) ?? -1 + expect(rebindOrder).toBeGreaterThan(swapOrder) + }) + + it('preserves legacy adapter instances by identity, drains outgoing router via disposeRouterOnly, and re-discovers legacy sessions on the new router', async () => { + const mod = await importFresh() + + // Why: initDaemonPtyProvider only constructs legacy adapters when + // probeSocket returns true for a legacy socket path. The real flow runs + // createLegacyDaemonAdapters which calls probeSocket per previous version. + // Simplest seam for this test: directly construct a router with a legacy + // adapter and install it via replaceDaemonProvider, bypassing + // createLegacyDaemonAdapters' socket-probe machinery. + await mod.initDaemonPtyProvider() + + const { DaemonPtyRouter } = await import('./daemon-pty-router') + const { DaemonPtyAdapter } = await import('./daemon-pty-adapter') + const currentAtConstruction = adapterInstances[0] + // Construct a legacy adapter using the mocked constructor (pushes into + // adapterInstances) — index 1. + const legacyAdapter = new DaemonPtyAdapter({ + socketPath: '/fake/legacy.sock', + tokenPath: '/fake/legacy.token', + protocolVersion: 3 + }) + const routerWithLegacy = new DaemonPtyRouter({ + current: currentAtConstruction as unknown as InstanceType, + legacy: [legacyAdapter as unknown as InstanceType] + }) + // Why: spy on the *outgoing* router's disposeRouterOnly so we can prove it + // was invoked (not just that legacy adapters survived — a no-op + // disposeRouterOnly would leak listeners but still leave adapters alive). + const disposeRouterOnlySpy = vi.spyOn(routerWithLegacy, 'disposeRouterOnly') + const oldRouterDispose = vi.spyOn(routerWithLegacy, 'dispose') + mod.replaceDaemonProvider(routerWithLegacy) + + await mod.restartDaemon() + + const provider = mod.getDaemonProvider() + expect(provider).toBeInstanceOf(DaemonPtyRouter) + const newRouter = provider as InstanceType + expect(newRouter).not.toBe(routerWithLegacy) + + // Legacy adapter instance is preserved by identity — not reconstructed, + // not defensively copied, not disposed. + const legacies = newRouter.getLegacyAdapters() + expect(legacies).toHaveLength(1) + expect(legacies[0]).toBe(legacyAdapter) + expect(legacyAdapter.dispose).not.toHaveBeenCalled() + // The outgoing router was drained via disposeRouterOnly (router-only + // teardown), so legacy adapters' underlying connections are untouched. + expect(legacyAdapter.disconnectOnly).not.toHaveBeenCalled() + // The outgoing router's subscriptions were drained but the adapters + // behind it were NOT disposed — that's the whole point of disposeRouterOnly. + expect(disposeRouterOnlySpy).toHaveBeenCalledTimes(1) + expect(oldRouterDispose).not.toHaveBeenCalled() + + // The replacement router must re-run discovery so spawns targeting a + // surviving legacy sessionId still route to the legacy adapter. + expect(legacyAdapter.listProcesses).toHaveBeenCalled() + }) + + it('restart path with no legacy adapters yields a bare DaemonPtyAdapter (not wrapped in a router)', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + // initDaemonPtyProvider sets up a bare adapter when createLegacyDaemonAdapters + // finds nothing — confirm that shape persists across restart. + const { DaemonPtyAdapter } = await import('./daemon-pty-adapter') + const { DaemonPtyRouter } = await import('./daemon-pty-router') + expect(mod.getDaemonProvider()).toBeInstanceOf(DaemonPtyAdapter) + + await mod.restartDaemon() + + expect(mod.getDaemonProvider()).toBeInstanceOf(DaemonPtyAdapter) + expect(mod.getDaemonProvider()).not.toBeInstanceOf(DaemonPtyRouter) + }) + + it('orders Step 3 (cleanup) → Step 4 (resetHandle + ensureRunning) → Step 5 (new adapter) → Step 6 (replaceProvider) → Step 7 (rebind)', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const originalSpawner = spawnerInstances[0] + const originalAdapter = adapterInstances[0] + + // Build an ordered trace by stamping each step as it fires. Cleanup is + // the tricky one — it's the `cleanupDaemonForProtocol` call, observable + // via the probeSocket→healthCheckDaemon→killStaleDaemon branch. We use + // healthCheckDaemon as the cleanup-start marker because it's the first + // call inside createOutOfProcessLauncher → and also fires inside + // cleanupDaemonForProtocol's alive branch… actually in the default + // (probeSocket=false) path cleanup is a no-op beyond pid-unlink. The + // load-bearing observable is `resetHandle` — it fires *after* cleanup + // returns. So we instrument the spawner instead. + const trace: string[] = [] + originalAdapter.fanoutSyntheticExits.mockImplementation(() => trace.push('fanout')) + unbindLocalProviderListenersMock.mockImplementation(() => trace.push('unbind')) + originalSpawner.resetHandle.mockImplementation(() => trace.push('resetHandle')) + const originalEnsureRunning = originalSpawner.ensureRunning + originalSpawner.ensureRunning.mockImplementation(async () => { + trace.push('ensureRunning') + return { + socketPath: '/fake/socket-2', + tokenPath: '/fake/token-2' } }) - const info = await spawner.ensureRunning() + setLocalPtyProviderMock.mockImplementation(() => trace.push('replaceProvider')) + rebindLocalProviderListenersMock.mockImplementation(() => trace.push('rebind')) - // Create two sessions so killedCount is non-zero. - const client = new DaemonClient({ - socketPath: info.socketPath, - tokenPath: info.tokenPath - }) - await client.ensureConnected() - await client.request('createOrAttach', { sessionId: 'a', cols: 80, rows: 24 }) - await client.request('createOrAttach', { sessionId: 'b', cols: 80, rows: 24 }) - client.disconnect() + await mod.restartDaemon() + void originalEnsureRunning // keep ref so tslint doesn't complain - // Now the daemon looks "orphaned" from cleanupOrphanedDaemon's POV. - const result = await cleanupOrphanedDaemon() - expect(result.cleaned).toBe(true) - expect(result.killedCount).toBeGreaterThanOrEqual(2) + // The full 7-step sequence in order. Step 3 (cleanupDaemonForProtocol) + // has no unique observable in the dead-socket branch, so it's implicitly + // ordered by the fact that resetHandle runs after unbind; if cleanup + // ever moved *after* resetHandle, we'd see `resetHandle` precede its + // expected position. + expect(trace).toEqual([ + 'fanout', + 'unbind', + 'resetHandle', + 'ensureRunning', + 'replaceProvider', + 'rebind' + ]) - // Socket file should be gone so a later opt-in relaunch can bind cleanly. - if (process.platform !== 'win32') { - expect(existsSync(getDaemonSocketPath(runtimeDir))).toBe(false) - } - - // Best-effort teardown of any surviving handles from the spawner side. - for (const handle of daemonHandles) { - await handle.shutdown().catch(() => {}) - } + // A fresh adapter must have been constructed *between* ensureRunning + // and replaceProvider (Step 5 before Step 6). adapterInstances[1] is + // the replacement — its socketPath comes from the Step-4 ensureRunning + // result, so its existence proves the ordering. + expect(adapterInstances).toHaveLength(2) + expect(adapterInstances[1].options.socketPath).toBe('/fake/socket-2') }) - it('cleans up previous protocol daemons after a protocol bump', async () => { - const { cleanupOrphanedDaemon } = await importFreshDaemonInit() - const { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } = - await import('./daemon-spawner') + it('exercises the alive-daemon cleanup path: issues shutdown RPC via DaemonClient before spawning a replacement', async () => { + // Why: the default mock has probeSocket returning false, so Step 3's + // DaemonClient-based shutdown path is normally skipped. This test flips + // the socket to "alive" so cleanupDaemonForProtocol takes the + // client.ensureConnected → listSessions → shutdown RPC branch. Without + // this, the design doc's Risks section ("verify under both 'shutdown + // RPC succeeded' and 'fell back to killStaleDaemon' paths") is uncovered. - const runtimeDir = join(userDataDir, 'daemon') - mkdirSync(runtimeDir, { recursive: true }) - const legacySocketPath = getDaemonSocketPath(runtimeDir, 1) - const legacyTokenPath = getDaemonTokenPath(runtimeDir, 1) - const legacyPidPath = getDaemonPidPath(runtimeDir, 1) - writeFileSync(legacyPidPath, String(process.pid), { mode: 0o600 }) - const legacyDaemon = await startLegacyDaemonStub(legacySocketPath, legacyTokenPath) - - try { - const result = await cleanupOrphanedDaemon() - - expect(result).toEqual({ cleaned: true, killedCount: 1 }) - expect(legacyDaemon.shutdownRequested()).toBe(true) - expect(existsSync(legacyPidPath)).toBe(false) - } finally { - await legacyDaemon.shutdown() - } - }) - - it('cleans up v2 daemon sessions when daemon mode is disabled', async () => { - const { cleanupOrphanedDaemon } = await importFreshDaemonInit() - const { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } = - await import('./daemon-spawner') - - const runtimeDir = join(userDataDir, 'daemon') - mkdirSync(runtimeDir, { recursive: true }) - const v2SocketPath = getDaemonSocketPath(runtimeDir, 2) - const v2TokenPath = getDaemonTokenPath(runtimeDir, 2) - const v2PidPath = getDaemonPidPath(runtimeDir, 2) - writeFileSync(v2PidPath, String(process.pid), { mode: 0o600 }) - const v2Daemon = await startLegacyDaemonStub(v2SocketPath, v2TokenPath, 2) - - try { - const result = await cleanupOrphanedDaemon() - - expect(result).toEqual({ cleaned: true, killedCount: 1 }) - expect(v2Daemon.shutdownRequested()).toBe(true) - expect(existsSync(v2PidPath)).toBe(false) - } finally { - await v2Daemon.shutdown() - } - }) - - it('does not report cleaned when fallback cleanup preserves an unowned live socket', async () => { - if (process.platform === 'win32') { - return - } - - const { cleanupOrphanedDaemon } = await importFreshDaemonInit() - const { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } = - await import('./daemon-spawner') - - const runtimeDir = join(userDataDir, 'daemon') - mkdirSync(runtimeDir, { recursive: true }) - const socketPath = getDaemonSocketPath(runtimeDir) - const tokenPath = getDaemonTokenPath(runtimeDir) - const server = createServer((socket) => { - socket.once('data', () => socket.end('not daemon protocol\n')) + const requestMock = vi.fn(async (method: string) => { + if (method === 'listSessions') { + return { sessions: [{ sessionId: 'live-1', isAlive: true }] } + } + // `shutdown` RPC — daemon exits before reply lands; return undefined. + return undefined }) - writeFileSync(tokenPath, 'bad-token', { mode: 0o600 }) - writeFileSync(getDaemonPidPath(runtimeDir), String(process.pid), { mode: 0o600 }) - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(socketPath, () => { - server.off('error', reject) - resolve() - }) + const ensureConnectedMock = vi.fn(async () => {}) + const disconnectMock = vi.fn() + // Why: DaemonClient is invoked via `new DaemonClient(...)`, so the mock + // factory must return a constructor-compatible function. Capture the + // existing impl so later tests aren't affected. + daemonClientMock.mockImplementationOnce(function MockDaemonClientForShutdown() { + return { + ensureConnected: ensureConnectedMock, + request: requestMock, + disconnect: disconnectMock + } }) - try { - const result = await cleanupOrphanedDaemon() + const mod = await importFresh() + await mod.initDaemonPtyProvider() - expect(result).toEqual({ cleaned: false, killedCount: 0 }) - expect(existsSync(socketPath)).toBe(true) - } finally { - await closeServer(server) - } + // Make probeSocket return true for the current-version path by toggling + // both the fs.existsSync proxy AND net.connect resolving "alive". + probeSocketExistsMock.mockReturnValue(true) + netConnectMock.mockImplementationOnce(() => { + const handlers: Record void)[]> = { connect: [], error: [] } + return { + on(event: string, cb: () => void) { + handlers[event]?.push(cb) + if (event === 'connect') { + queueMicrotask(() => cb()) + } + return this + }, + destroy() {} + } + }) + + await mod.restartDaemon() + + // The shutdown RPC must have been issued with killSessions=true. + expect(ensureConnectedMock).toHaveBeenCalled() + expect(requestMock).toHaveBeenCalledWith('shutdown', { killSessions: true }) + // The fallback killStaleDaemon must NOT fire when the RPC path worked. + expect(killStaleDaemonMock).not.toHaveBeenCalled() + }) + + it('coalesces concurrent restartDaemon() calls so the 7-step sequence runs exactly once', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const originalSpawner = spawnerInstances[0] + + // Why: without an explicit gate, `Promise.all([restartDaemon(), + // restartDaemon()])` evaluates arguments left-to-right *synchronously* — + // the first call's promise could resolve before the second invocation + // begins if all internal awaits resolved in a single microtask, which + // would leave the coalescer untested. The deferred gate holds the first + // restart inside `ensureRunning` until we release it, guaranteeing the + // second call enters while the first is genuinely mid-flight. + let releaseEnsureRunning: (() => void) | undefined + const ensureRunningBarrier = new Promise((resolve) => { + releaseEnsureRunning = resolve + }) + originalSpawner.ensureRunning.mockImplementationOnce(async () => { + await ensureRunningBarrier + return { socketPath: '/fake/socket-2', tokenPath: '/fake/token-2' } + }) + + const call1 = mod.restartDaemon() + // Yield microtasks so call1 progresses into runRestartDaemon and is + // definitely blocked on the barrier. + await Promise.resolve() + await Promise.resolve() + const call2 = mod.restartDaemon() + + // Why: `async function restartDaemon` wraps each return in a fresh + // Promise, so `call1 === call2` does *not* hold even when the coalescer + // is working. The load-bearing proof is behavioral: while call1 is + // blocked on the ensureRunning barrier, call2 must NOT fork a parallel + // run of the 7-step sequence. If the coalescer fails, a second + // `runRestartDaemon` starts, which means a second `resetHandle` fires + // before we release the barrier. Check that counter at this exact + // moment — a non-coalescing implementation would already be at 2. + expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1) + expect(adapterInstances).toHaveLength(1) + + releaseEnsureRunning?.() + const [r1, r2] = await Promise.all([call1, call2]) + // Both resolved values must be structurally identical (same result + // object bubbled up through the shared runRestartDaemon promise). + expect(r1).toEqual(r2) + + // resetHandle fires once per restart; ensureRunning fires once during + // init + once during restart. A second, un-coalesced restart would push + // these counters to 2 and 3 respectively. + expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1) + expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(2) + expect(adapterInstances).toHaveLength(2) + + // After the in-flight promise settles, a fresh restart is allowed to run + // — proves `.finally(() => restartInFlight = null)` actually cleared the + // slot. A stale restartInFlight would skip the work entirely. + await mod.restartDaemon() + expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(2) + expect(adapterInstances).toHaveLength(3) + }) + + it('throws when restartDaemon is called before initDaemonPtyProvider', async () => { + const mod = await importFresh() + await expect(mod.restartDaemon()).rejects.toThrow( + 'restartDaemon called before initDaemonPtyProvider' + ) }) }) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 329cc6dd2d7..d1545a81d0a 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -1,3 +1,11 @@ +/* eslint-disable max-lines -- Why: this module owns the complete daemon +lifecycle for the Electron main process — init, out-of-process launch, +current+legacy adapter wiring, restart orchestration (the 7-step sequence +from docs/daemon-staleness-ux.md §Phase 1), and teardown on app quit. Splitting +it would scatter the "swap the running provider atomically" invariant across +files with no cleaner ownership seam: restart, replaceDaemonProvider, and the +module-level spawner/adapter singletons must stay co-located so a future +change cannot leave them drifting out of sync. */ import { join } from 'path' import { app } from 'electron' import { mkdirSync, existsSync, unlinkSync, writeFileSync } from 'fs' @@ -8,6 +16,7 @@ import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath, + serializeDaemonPidFile, type DaemonLauncher } from './daemon-spawner' import { DaemonPtyAdapter } from './daemon-pty-adapter' @@ -18,11 +27,20 @@ import { PROTOCOL_VERSION, type ListSessionsResult } from './types' -import { healthCheckDaemon, killStaleDaemon } from './daemon-health' -import { setLocalPtyProvider } from '../ipc/pty' +import { getProcessStartedAtMs, healthCheckDaemon, killStaleDaemon } from './daemon-health' +import { + setLocalPtyProvider, + unbindLocalProviderListeners, + rebindLocalProviderListeners +} from '../ipc/pty' let spawner: DaemonSpawner | null = null let adapter: DaemonPtyRouter | DaemonPtyAdapter | null = null +// Why: coalesce concurrent restartDaemon() calls so two clicks (or a UI +// click racing an internal caller) can't both enter the 7-step sequence — +// the second entry would read the already-disposed current adapter and +// race cleanupDaemonForProtocol against a half-spawned replacement. +let restartInFlight: Promise | null = null function getRuntimeDir(): string { const dir = join(app.getPath('userData'), 'daemon') @@ -130,7 +148,18 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { if (msg && typeof msg === 'object' && (msg as { type?: string }).type === 'ready') { clearTimeout(timer) if (child.pid) { - writeFileSync(getDaemonPidPath(runtimeDir), String(child.pid), { mode: 0o600 }) + // Why: JSON pid file carries pid + process start time so later + // killStaleDaemon() can verify the pid still belongs to the daemon + // we forked before SIGTERMing it. Prevents pid-recycling hazard + // where the OS hands the daemon's old pid to an unrelated process. + writeFileSync( + getDaemonPidPath(runtimeDir), + serializeDaemonPidFile({ + pid: child.pid, + startedAtMs: getProcessStartedAtMs(child.pid) + }), + { mode: 0o600 } + ) } // Why: disconnect IPC channel and unref so Electron can exit // without waiting for the daemon. The daemon keeps running. @@ -208,6 +237,121 @@ export async function initDaemonPtyProvider(): Promise { setLocalPtyProvider(routedAdapter) } +// Why: the Manage Sessions IPC handlers need read access to the current +// adapter/router to list sessions, kill them, etc. Exposed as a narrow getter +// rather than exporting the module-level variable to keep the "swap on +// restart" invariant in one place (replaceDaemonProvider). +export function getDaemonProvider(): DaemonPtyRouter | DaemonPtyAdapter | null { + return adapter +} + +// Why: the "Restart daemon" flow rebuilds the current-protocol adapter and +// must update both the module-level `adapter` singleton here and the +// `localProvider` reference inside ipc/pty.ts. Without this helper they could +// drift — app-quit would dispose a stale adapter reference. +export function replaceDaemonProvider(newAdapter: DaemonPtyAdapter | DaemonPtyRouter): void { + adapter = newAdapter + setLocalPtyProvider(newAdapter) +} + +export type RestartDaemonResult = { + killedCount: number +} + +// Why: the 7-step sequence from docs/daemon-staleness-ux.md §Phase 1 restart. +// Current-protocol only — legacy adapters are preserved and route to their +// original daemons with no respawn path. See the design doc for rationale on +// each step, notably why synthetic exits must fan out *before* the listener +// unsubscribe. +export async function restartDaemon(): Promise { + if (restartInFlight) { + return restartInFlight + } + restartInFlight = runRestartDaemon().finally(() => { + restartInFlight = null + }) + return restartInFlight +} + +async function runRestartDaemon(): Promise { + const currentSpawner = spawner + const currentAdapter = adapter + if (!currentSpawner || !currentAdapter) { + throw new Error('restartDaemon called before initDaemonPtyProvider') + } + + const runtimeDir = getRuntimeDir() + const currentOnly = + currentAdapter instanceof DaemonPtyRouter ? currentAdapter.getCurrentAdapter() : currentAdapter + const legacyAdapters = + currentAdapter instanceof DaemonPtyRouter ? [...currentAdapter.getLegacyAdapters()] : [] + + // Step 1: synthesize pty:exit for every active session on the current + // adapter BEFORE any teardown. The daemon's kill-all-and-shutdown path + // explicitly does not fan onExit to clients (session.ts:246-252), so + // without this the renderer would never see exits and would black-hole + // writes against the disposed adapter. + const killedCount = currentOnly.getActiveSessionIds().length + currentOnly.fanoutSyntheticExits(-1) + + // Step 2: detach renderer listeners from the current adapter. Must happen + // AFTER step 1 so the synthesized exits actually reach the renderer, and + // BEFORE step 6 so the new provider isn't bound with stale listeners. + unbindLocalProviderListeners() + + // Step 3: kill the current-protocol daemon process (shutdown RPC → fallback + // killStaleDaemon → socket/pid unlink). Legacy adapters untouched. + await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) + + // Step 4: reuse the existing spawner so the respawn closure baked into + // long-lived adapters stays valid. Do NOT construct a new DaemonSpawner. + currentSpawner.resetHandle() + const info = await currentSpawner.ensureRunning() + + // Step 5: build a fresh current adapter against the respawned daemon. Its + // respawn callback closes over the same spawner instance (identical to the + // crash-respawn closure in initDaemonPtyProvider). + const newCurrent = new DaemonPtyAdapter({ + socketPath: info.socketPath, + tokenPath: info.tokenPath, + historyPath: getHistoryDir(), + respawn: async () => { + console.warn('[daemon] Daemon process died — respawning') + currentSpawner.resetHandle() + await currentSpawner.ensureRunning() + } + }) + + // Re-wrap in router if there were legacy adapters at startup; otherwise + // point straight at the new adapter. Legacy instances are preserved by + // reference — they still route to the same pre-upgrade daemons. + const newProvider = + legacyAdapters.length > 0 + ? new DaemonPtyRouter({ current: newCurrent, legacy: legacyAdapters }) + : newCurrent + if (newProvider instanceof DaemonPtyRouter) { + await newProvider.discoverLegacySessions() + } + + // Why: drain the outgoing router's subscriptions from the shared legacy + // adapters before installing the new router (which subscribes fresh). Must + // run *after* the new provider exists so no adapter event is unhandled in + // the narrow window, and *before* replaceDaemonProvider so the swap is + // atomic from the renderer's perspective. Plain dispose() would also tear + // down the legacy adapters themselves — use the router-only variant. + if (currentAdapter instanceof DaemonPtyRouter) { + currentAdapter.disposeRouterOnly() + } + + // Step 6: swap module state (adapter + localProvider) atomically. + replaceDaemonProvider(newProvider) + + // Step 7: rebind renderer listeners against the new provider. + rebindLocalProviderListeners() + + return { killedCount } +} + // Why: disconnect from the daemon without killing it. The daemon runs as a // separate process and survives app quit — sessions stay alive for warm // reattach on next launch. Leave history sessions marked "unclean" here so a @@ -239,7 +383,7 @@ export type OrphanedDaemonCleanupResult = { killedCount: number } -async function cleanupDaemonForProtocol( +export async function cleanupDaemonForProtocol( runtimeDir: string, protocolVersion: number ): Promise { @@ -343,27 +487,3 @@ async function createLegacyDaemonAdapters(runtimeDir: string): Promise { - const runtimeDir = getRuntimeDir() - let cleaned = false - let killedCount = 0 - - for (const protocolVersion of [PROTOCOL_VERSION, ...PREVIOUS_DAEMON_PROTOCOL_VERSIONS]) { - const result = await cleanupDaemonForProtocol(runtimeDir, protocolVersion) - cleaned ||= result.cleaned - killedCount += result.killedCount - } - - return { cleaned, killedCount } -} diff --git a/src/main/daemon/daemon-main.test.ts b/src/main/daemon/daemon-main.test.ts index 69613650eee..c462fdcfbd1 100644 --- a/src/main/daemon/daemon-main.test.ts +++ b/src/main/daemon/daemon-main.test.ts @@ -101,6 +101,7 @@ function createMockSubprocess() { onData(_cb: (data: string) => void) {}, onExit(cb: (code: number) => void) { onExitCb = cb - } + }, + dispose: vi.fn() } } diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 610937387ad..1049365d753 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -31,6 +31,7 @@ function createMockSubprocess(): SubprocessHandle & { onExit(cb) { onExitCb = cb }, + dispose: vi.fn(), _simulateData(data: string) { onDataCb?.(data) }, @@ -128,7 +129,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { describe('shutdown', () => { it('kills the session', async () => { const { id } = await adapter.spawn({ cols: 80, rows: 24 }) - await adapter.shutdown(id, false) + await adapter.shutdown(id, { immediate: false }) expect(lastSubprocess.kill).toHaveBeenCalled() }) }) @@ -311,7 +312,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { it('prevents spawn after shutdown for same sessionId', async () => { const sessionId = 'tombstone-test' await adapter.spawn({ cols: 80, rows: 24, sessionId }) - await adapter.shutdown(sessionId, true) + await adapter.shutdown(sessionId, { immediate: true }) await expect(adapter.spawn({ cols: 80, rows: 24, sessionId })).rejects.toThrow( 'was explicitly killed' @@ -320,7 +321,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { it('allows spawn for different sessionId after shutdown', async () => { await adapter.spawn({ cols: 80, rows: 24, sessionId: 'kill-me' }) - await adapter.shutdown('kill-me', true) + await adapter.shutdown('kill-me', { immediate: true }) const result = await adapter.spawn({ cols: 80, rows: 24, sessionId: 'fresh-one' }) expect(result.id).toBe('fresh-one') @@ -329,7 +330,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { it('clearTombstone allows re-spawn', async () => { const sessionId = 'cleared-tombstone' await adapter.spawn({ cols: 80, rows: 24, sessionId }) - await adapter.shutdown(sessionId, true) + await adapter.shutdown(sessionId, { immediate: true }) adapter.clearTombstone(sessionId) @@ -348,7 +349,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { const id = `evict-${i}` ids.push(id) await adapter.spawn({ cols: 80, rows: 24, sessionId: id }) - await adapter.shutdown(id, true) + await adapter.shutdown(id, { immediate: true }) } // All 5 should be tombstoned @@ -361,7 +362,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { // clearTombstone the first one, then re-kill it — it should still work adapter.clearTombstone(ids[0]) await adapter.spawn({ cols: 80, rows: 24, sessionId: ids[0] }) - await adapter.shutdown(ids[0], true) + await adapter.shutdown(ids[0], { immediate: true }) // First tombstone was re-added at the end of the Map, so eviction // order is now [evict-1, evict-2, evict-3, evict-4, evict-0] @@ -487,7 +488,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true) - await historyAdapter.shutdown(id, true) + await historyAdapter.shutdown(id, { immediate: true }) await new Promise((r) => setTimeout(r, 50)) expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(false) @@ -728,4 +729,70 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { respawnAdapter.dispose() }) }) + + // Why: the restart flow (docs/daemon-staleness-ux.md §Phase 1 step 1) relies + // on these two primitives to fan synthetic pty:exit out to every attached + // session *before* tearing the adapter down. The design doc calls out + // session.ts:246-252 as the reason — the daemon's kill-all-and-shutdown + // explicitly does NOT fan exits back through onExit. Without the fanout the + // renderer would black-hole writes against a disposed adapter. + describe('fanoutSyntheticExits / getActiveSessionIds (restart primitives)', () => { + it('reports every live spawn in getActiveSessionIds', async () => { + const { id: id1 } = await adapter.spawn({ cols: 80, rows: 24 }) + const { id: id2 } = await adapter.spawn({ cols: 80, rows: 24 }) + const active = adapter.getActiveSessionIds() + expect(active).toContain(id1) + expect(active).toContain(id2) + expect(active).toHaveLength(2) + }) + + it('emits a synthetic exit for every active id with the supplied code', () => { + const exits: { id: string; code: number }[] = [] + adapter.onExit((payload) => exits.push(payload)) + + const ids = ['sess-a', 'sess-b', 'sess-c'] + const internals = adapter as unknown as { activeSessionIds: Set } + for (const id of ids) { + internals.activeSessionIds.add(id) + } + + adapter.fanoutSyntheticExits(-1) + + expect(exits).toHaveLength(3) + expect(exits.map((e) => e.id).sort()).toEqual([...ids].sort()) + for (const { code } of exits) { + expect(code).toBe(-1) + } + }) + + it('clears activeSessionIds after fanout so a second call is a no-op', () => { + const exits: { id: string; code: number }[] = [] + adapter.onExit((payload) => exits.push(payload)) + + const internals = adapter as unknown as { activeSessionIds: Set } + internals.activeSessionIds.add('sess-a') + + adapter.fanoutSyntheticExits(-1) + expect(exits).toHaveLength(1) + expect(adapter.getActiveSessionIds()).toEqual([]) + + adapter.fanoutSyntheticExits(-1) + expect(exits).toHaveLength(1) + }) + + it('propagates to every registered exit listener in order', () => { + const aExits: { id: string; code: number }[] = [] + const bExits: { id: string; code: number }[] = [] + adapter.onExit((payload) => aExits.push(payload)) + adapter.onExit((payload) => bExits.push(payload)) + + const internals = adapter as unknown as { activeSessionIds: Set } + internals.activeSessionIds.add('sess-a') + + adapter.fanoutSyntheticExits(-1) + + expect(aExits).toEqual([{ id: 'sess-a', code: -1 }]) + expect(bExits).toEqual([{ id: 'sess-a', code: -1 }]) + }) + }) }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 1e4273ae579..a182b898f76 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -13,7 +13,8 @@ import { type CreateOrAttachResult, type DaemonEvent, type GetSnapshotResult, - type ListSessionsResult + type ListSessionsResult, + type SessionInfo } from './types' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' @@ -39,6 +40,7 @@ export class TerminalKilledError extends Error { } export class DaemonPtyAdapter implements IPtyProvider { + readonly protocolVersion: number private client: DaemonClient private historyManager: HistoryManager | null private historyReader: HistoryReader | null @@ -71,6 +73,7 @@ export class DaemonPtyAdapter implements IPtyProvider { private static CHECKPOINT_INTERVAL_MS = 5_000 constructor(opts: DaemonPtyAdapterOptions) { + this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION this.client = new DaemonClient({ socketPath: opts.socketPath, tokenPath: opts.tokenPath, @@ -79,7 +82,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null this.respawnFn = opts.respawn ?? null - this.supportsCheckpoints = (opts.protocolVersion ?? PROTOCOL_VERSION) >= 4 + this.supportsCheckpoints = this.protocolVersion >= 4 } getHistoryManager(): HistoryManager | null { @@ -120,6 +123,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // the override makes the daemon path behave the same as the in-process // LocalPtyProvider. shellOverride: opts.shellOverride, + terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, shellReadySupported: opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false }) @@ -226,26 +230,31 @@ export class DaemonPtyAdapter implements IPtyProvider { this.client.notify('resize', { sessionId: id, cols, rows }) } - async shutdown(id: string, _immediate: boolean): Promise { + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { await this.client.request('kill', { sessionId: id }) this.activeSessionIds.delete(id) this.initialCwds.delete(id) - // Why: user explicitly closed this terminal — clean up disk history - // so it doesn't trigger a false cold restore on next launch. - if (this.historyManager) { + // Why: history removal is for the "user explicitly closed this terminal" + // path. Sleep also calls shutdown but expects scrollback to survive — wake + // re-spawns and the cold-restore reader needs the dir intact. Caller + // indicates intent via opts.keepHistory. + if (this.historyManager && !opts.keepHistory) { void this.historyManager .removeSession(id) .catch((err) => console.warn('[history] removeSession failed:', id, err)) } - // Why: delete-then-set ensures the entry moves to the end of Map iteration - // order, so re-killing a session doesn't leave it as the first eviction target. - this.killedSessionTombstones.delete(id) - this.killedSessionTombstones.set(id, Date.now()) - if (this.killedSessionTombstones.size > MAX_TOMBSTONES) { - const oldest = this.killedSessionTombstones.keys().next().value - if (oldest) { - this.killedSessionTombstones.delete(oldest) + // Why: tombstone rejects reattach against a session the user explicitly + // killed. Sleep legitimately reattaches on wake, so skip both the LRU bump + // and the size-cap eviction under keepHistory. + if (!opts.keepHistory) { + this.killedSessionTombstones.delete(id) + this.killedSessionTombstones.set(id, Date.now()) + if (this.killedSessionTombstones.size > MAX_TOMBSTONES) { + const oldest = this.killedSessionTombstones.keys().next().value + if (oldest) { + this.killedSessionTombstones.delete(oldest) + } } } } @@ -361,6 +370,42 @@ export class DaemonPtyAdapter implements IPtyProvider { })) } + // Why: the Manage Sessions panel needs the full SessionInfo (pid, state, + // createdAt) per session for display; listProcesses drops that detail for + // the IPtyProvider contract. Keep both in parallel rather than widening + // the provider surface. + async listSessions(): Promise { + await this.ensureConnected() + const result = await this.client.request('listSessions', undefined) + return result.sessions.filter((s) => s.isAlive) + } + + getActiveSessionIds(): string[] { + return [...this.activeSessionIds] + } + + // Why: used by the "Restart daemon" handler to synthesize pty:exit for every + // live session *before* tearing down the adapter. The daemon's own + // kill-all-and-shutdown path explicitly suppresses onExit fanout + // (session.ts:246-252), so without this the renderer panes would black-hole + // writes to a disposed adapter forever. Reuses the existing exitListeners + // path so downstream cleanup (clearProviderPtyState, markClaudePtyExited, + // renderer pty:exit) runs exactly as it does on natural exit. + fanoutSyntheticExits(code: number): void { + const ids = [...this.activeSessionIds] + this.activeSessionIds.clear() + for (const id of ids) { + // Why: listener throws are intentionally *not* caught — matches the + // natural onExit fanout in setupEventRouting, so synthetic exits don't + // diverge in error semantics from real ones. A throwing listener is a + // bug that should surface loudly, not be silently swallowed. + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.exitListeners]) { + listener({ id, code }) + } + } + } + async getDefaultShell(): Promise { if (process.platform === 'win32') { return process.env.COMSPEC || 'powershell.exe' diff --git a/src/main/daemon/daemon-pty-provider.test.ts b/src/main/daemon/daemon-pty-provider.test.ts index a8da8cead04..eb50820993d 100644 --- a/src/main/daemon/daemon-pty-provider.test.ts +++ b/src/main/daemon/daemon-pty-provider.test.ts @@ -29,6 +29,7 @@ function createMockSubprocess(): SubprocessHandle & { onExit(cb) { onExitCb = cb }, + dispose: vi.fn(), _simulateData(data: string) { onDataCb?.(data) }, @@ -130,7 +131,7 @@ describe('DaemonPtyProvider', () => { describe('shutdown (kill session)', () => { it('kills the session in the daemon', async () => { await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - await provider.shutdown('s1', false) + await provider.shutdown('s1', { immediate: false }) expect(lastSubprocess.kill).toHaveBeenCalled() }) diff --git a/src/main/daemon/daemon-pty-provider.ts b/src/main/daemon/daemon-pty-provider.ts index 6380cc27f0b..74a5d834874 100644 --- a/src/main/daemon/daemon-pty-provider.ts +++ b/src/main/daemon/daemon-pty-provider.ts @@ -62,7 +62,7 @@ export class DaemonPtyProvider { this.client.notify('resize', { sessionId: id, cols, rows }) } - async shutdown(id: string, _immediate: boolean): Promise { + async shutdown(id: string, _opts: { immediate?: boolean; keepHistory?: boolean }): Promise { await this.client.request('kill', { sessionId: id }) } diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 1594da80dfb..fee4c11b563 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -153,4 +153,105 @@ describe('DaemonPtyRouter', () => { expect(current.dispose).toHaveBeenCalled() expect(legacy.dispose).toHaveBeenCalled() }) + + // Why: docs/daemon-staleness-ux.md §Phase 1 step 5 requires the restart flow + // to preserve the legacy adapter instances across the current-adapter swap, + // and it reads them back via these accessors. Locking the return shape + // prevents a future refactor from quietly switching to a defensive copy + // (breaks instance identity) or a different list (breaks restart). + describe('restart accessors', () => { + it('returns the exact current adapter instance', () => { + const current = createAdapter('current') + const router = new DaemonPtyRouter({ current, legacy: [] }) + + expect(router.getCurrentAdapter()).toBe(current) + }) + + it('returns the exact legacy adapter instances', () => { + const current = createAdapter('current') + const legacy1 = createAdapter('legacy-1') + const legacy2 = createAdapter('legacy-2') + const router = new DaemonPtyRouter({ current, legacy: [legacy1, legacy2] }) + + const legacies = router.getLegacyAdapters() + expect(legacies.length).toBe(2) + expect(legacies[0]).toBe(legacy1) + expect(legacies[1]).toBe(legacy2) + }) + + it('getAllAdapters returns current first then legacy, by identity', () => { + const current = createAdapter('current') + const legacy1 = createAdapter('legacy-1') + const legacy2 = createAdapter('legacy-2') + const router = new DaemonPtyRouter({ current, legacy: [legacy1, legacy2] }) + + const all = router.getAllAdapters() + expect(all.length).toBe(3) + expect(all[0]).toBe(current) + expect(all[1]).toBe(legacy1) + expect(all[2]).toBe(legacy2) + }) + }) + + // Why: the restart flow (daemon-init.runRestartDaemon step 5→6) relies on + // disposeRouterOnly draining the outgoing router's subscriptions WITHOUT + // tearing down the legacy adapters themselves. plain dispose() would + // cascade into the adapters and strand any legacy-backed sessions — see + // daemon-pty-router.ts §disposeRouterOnly comment. These tests lock the + // contract: no adapter teardown, subscriptions actually detached for both + // onData and onExit on both current and legacy, idempotent. + describe('disposeRouterOnly', () => { + it('does not call dispose or disconnectOnly on any adapter', () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy') + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + router.disposeRouterOnly() + + expect(current.dispose).not.toHaveBeenCalled() + expect(legacy.dispose).not.toHaveBeenCalled() + expect(current.disconnectOnly).not.toHaveBeenCalled() + expect(legacy.disconnectOnly).not.toHaveBeenCalled() + }) + + it('stops forwarding adapter onData/onExit to subscribers registered before dispose', () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy') + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + // Realistic restart scenario: the local IPC layer subscribes at app + // start, THEN the restart flow later calls disposeRouterOnly. A spy + // registered *after* dispose proves the router→subscriber path is + // empty, but doesn't prove the router unsubscribed from the adapters. + const dataSpy = vi.fn() + const exitSpy = vi.fn() + router.onData(dataSpy) + router.onExit(exitSpy) + + router.disposeRouterOnly() + + // Both current- and legacy-adapter emissions must be silenced. A + // regression that only unsubscribed from one would pass a single- + // adapter test but fail this one. + current.emitData('current-id', 'hello') + current.emitExit('current-id', 0) + legacy.emitData('legacy-id', 'world') + legacy.emitExit('legacy-id', 1) + + expect(dataSpy).not.toHaveBeenCalled() + expect(exitSpy).not.toHaveBeenCalled() + }) + + it('is idempotent — a second disposeRouterOnly call is a no-op and does not throw', () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy') + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + router.disposeRouterOnly() + expect(() => router.disposeRouterOnly()).not.toThrow() + // And still no adapter teardown on the second call. + expect(current.dispose).not.toHaveBeenCalled() + expect(legacy.dispose).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index e69b21aa63a..70e383f7226 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -63,8 +63,8 @@ export class DaemonPtyRouter implements IPtyProvider { this.adapterFor(id).resize(id, cols, rows) } - async shutdown(id: string, immediate: boolean): Promise { - await this.adapterFor(id).shutdown(id, immediate) + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { + await this.adapterFor(id).shutdown(id, opts) this.sessionAdapters.delete(id) } @@ -180,6 +180,19 @@ export class DaemonPtyRouter implements IPtyProvider { } } + // Why: restart swaps to a fresh router carrying the *same* legacy adapter + // instances. If we called dispose() on the outgoing router it would tear + // down those legacy adapters along with it. disposeRouterOnly() detaches + // only this router's subscriptions from the adapters — the adapters and + // their daemon connections keep running, and the new router re-subscribes. + // Without this, each restart leaked a router instance pinned by the legacy + // adapters' listener arrays (one pair per adapter per restart). + disposeRouterOnly(): void { + for (const unsubscribe of this.unsubscribers.splice(0)) { + unsubscribe() + } + } + async disconnectOnly(): Promise { for (const unsubscribe of this.unsubscribers.splice(0)) { unsubscribe() @@ -187,6 +200,24 @@ export class DaemonPtyRouter implements IPtyProvider { await Promise.all([...this.allAdapters()].map((adapter) => adapter.disconnectOnly())) } + // Why: the Manage Sessions panel iterates all adapters to list sessions + // across every protocol version, and the restart handler needs to preserve + // surviving legacy adapters across the current-adapter swap. On this branch + // (pre-#1323) the legacy list is set once at construction and never mutated, + // so returning the internal array by reference is safe for the intended + // read-only use. + getCurrentAdapter(): DaemonPtyAdapter { + return this.current + } + + getLegacyAdapters(): readonly DaemonPtyAdapter[] { + return this.legacy + } + + getAllAdapters(): readonly DaemonPtyAdapter[] { + return this.allAdapters() + } + private adapterFor(sessionId: string): DaemonPtyAdapter { return this.sessionAdapters.get(sessionId) ?? this.current } diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 5537782cc36..a58895f1d69 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -27,7 +27,8 @@ function createMockSubprocess(): SubprocessHandle { }, onExit(cb) { onExitCb = cb - } + }, + dispose: vi.fn() } } @@ -171,8 +172,12 @@ describe('DaemonServer', () => { sessionId: 'test-session' }) - // Mock subprocess doesn't emit OSC-7, so cwd is null - expect(result.cwd).toBeNull() + // Mock subprocess doesn't emit OSC-7. The terminal-host fallback then + // calls resolveProcessCwd(55555); on CI that pid is almost always dead + // so the result is null, but we accept string too — a recycled pid that + // happens to match would legitimately return a path and we don't want + // this test to flake on whatever happens to be running on the host. + expect(result.cwd === null || typeof result.cwd === 'string').toBe(true) }) it('returns error for unknown session operations', async () => { diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index b694c4cf562..9566757849e 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -201,6 +201,7 @@ export class DaemonServer { env: p.env, command: p.command, shellOverride: p.shellOverride, + terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, shellReadySupported: p.shellReadySupported, streamClient: { onData: (data) => { @@ -273,7 +274,7 @@ export class DaemonServer { return {} case 'getCwd': - return { cwd: this.host.getCwd(request.payload.sessionId) } + return { cwd: await this.host.getCwd(request.payload.sessionId) } case 'clearScrollback': this.host.clearScrollback(request.payload.sessionId) diff --git a/src/main/daemon/daemon-spawner.test.ts b/src/main/daemon/daemon-spawner.test.ts index 63a65aeb223..c07953364d0 100644 --- a/src/main/daemon/daemon-spawner.test.ts +++ b/src/main/daemon/daemon-spawner.test.ts @@ -29,7 +29,8 @@ function createMockSubprocess(): SubprocessHandle { onData(_cb: (data: string) => void) {}, onExit(cb: (code: number) => void) { onExitCb = cb - } + }, + dispose: vi.fn() } } diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index 4b6144bcadf..d57b79e41f2 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -7,6 +7,11 @@ export type DaemonConnectionInfo = { tokenPath: string } +export type DaemonPidFile = { + pid: number + startedAtMs: number | null +} + export type DaemonProcessHandle = { shutdown(): Promise } @@ -80,3 +85,7 @@ export function getDaemonTokenPath(runtimeDir: string, protocolVersion = PROTOCO export function getDaemonPidPath(runtimeDir: string, protocolVersion = PROTOCOL_VERSION): string { return join(runtimeDir, `daemon-v${protocolVersion}.pid`) } + +export function serializeDaemonPidFile(pidFile: DaemonPidFile): string { + return JSON.stringify(pidFile) +} diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 6491e435de5..12645ec1e0a 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -9,6 +9,10 @@ export type HeadlessEmulatorOptions = { scrollback?: number } +export type HeadlessSnapshotOptions = { + scrollbackRows?: number +} + const DEFAULT_SCROLLBACK = 5000 function parseFileUriPath(uri: string): string | null { @@ -87,10 +91,10 @@ export class HeadlessEmulator { this.terminal.resize(cols, rows) } - getSnapshot(): TerminalSnapshot { + getSnapshot(opts: HeadlessSnapshotOptions = {}): TerminalSnapshot { const modes = this.getModes() return { - snapshotAnsi: this.serializer.serialize(), + snapshotAnsi: this.serializer.serialize({ scrollback: opts.scrollbackRows }), scrollbackAnsi: '', rehydrateSequences: this.buildRehydrateSequences(modes), cwd: this.cwd, diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts index 53475f6a76e..832f6c949b2 100644 --- a/src/main/daemon/production-launcher.test.ts +++ b/src/main/daemon/production-launcher.test.ts @@ -23,7 +23,8 @@ function createMockSubprocess(): SubprocessHandle { onData(_cb: (data: string) => void) {}, onExit(cb: (code: number) => void) { onExitCb = cb - } + }, + dispose: vi.fn() } } diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 1c89be29966..2580d98f4c7 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1,13 +1,19 @@ -import { describe, expect, it, vi } from 'vitest' +/* oxlint-disable max-lines -- Why: exercises full PTY subprocess surface (spawn setup, signal routing, data events, platform-specific shell configs, and Windows PowerShell implementations) with co-located test scenarios to prevent fixture drift. */ +import { beforeEach, describe, expect, it, vi } from 'vitest' -const { spawnMock } = vi.hoisted(() => ({ - spawnMock: vi.fn() +const { spawnMock, isPwshAvailableMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + isPwshAvailableMock: vi.fn() })) vi.mock('node-pty', () => ({ spawn: spawnMock })) +vi.mock('../pwsh', () => ({ + isPwshAvailable: isPwshAvailableMock +})) + import { createPtySubprocess } from './pty-subprocess' function mockPtyProcess(pid = 12345) { @@ -33,6 +39,11 @@ function mockPtyProcess(pid = 12345) { } describe('createPtySubprocess', () => { + beforeEach(() => { + spawnMock.mockReset() + isPwshAvailableMock.mockReset() + isPwshAvailableMock.mockReturnValue(false) + }) it('spawns node-pty with correct options', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) @@ -262,4 +273,301 @@ describe('createPtySubprocess', () => { expect.objectContaining({ cwd: 'D:\\Users\\orca' }) ) }) + + it('keeps powershell.exe when the inbox PowerShell implementation is selected on Windows', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + isPwshAvailableMock.mockReturnValue(true) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { COMSPEC: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' }, + terminalWindowsPowerShellImplementation: 'powershell.exe' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'powershell.exe', + [ + '-NoExit', + '-Command', + 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' + ], + expect.any(Object) + ) + }) + + it('spawns pwsh.exe when PowerShell 7 is selected and available on Windows', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + isPwshAvailableMock.mockReturnValue(true) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { COMSPEC: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' }, + terminalWindowsPowerShellImplementation: 'pwsh.exe' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'pwsh.exe', + [ + '-NoExit', + '-Command', + 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' + ], + expect.any(Object) + ) + }) + + it('falls back to powershell.exe when PowerShell 7 is selected but unavailable on Windows', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + isPwshAvailableMock.mockReturnValue(false) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { COMSPEC: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' }, + terminalWindowsPowerShellImplementation: 'pwsh.exe' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'powershell.exe', + [ + '-NoExit', + '-Command', + 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' + ], + expect.any(Object) + ) + }) + + it('falls back to powershell.exe when shellOverride requests pwsh.exe but pwsh is unavailable on Windows', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + isPwshAvailableMock.mockReturnValue(false) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + shellOverride: 'pwsh.exe', + terminalWindowsPowerShellImplementation: 'pwsh.exe' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'powershell.exe', + [ + '-NoExit', + '-Command', + 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' + ], + expect.any(Object) + ) + }) + + it('ignores the PowerShell implementation setting for cmd.exe on Windows', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + isPwshAvailableMock.mockReturnValue(true) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + shellOverride: 'cmd.exe', + terminalWindowsPowerShellImplementation: 'pwsh.exe' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'cmd.exe', + ['/K', 'chcp 65001 > nul'], + expect.any(Object) + ) + }) + + // Why: node-pty's UnixTerminal.destroy() registers _socket.once('close', () => + // this.kill('SIGHUP')), and the socket 'close' event can fire concurrently + // with onExit. If kill is not neutralized by the time close fires, SIGHUP + // targets a reaped pid that may have been recycled. These tests pin down the + // neutralization contract on both onExit (natural-exit path) and dispose() + // (forced-teardown path) for POSIX, and verify Windows is exempt. + describe('proc.kill neutralization for SIGHUP-to-recycled-pid hazard', () => { + const restorePlatform = (desc?: PropertyDescriptor) => { + if (desc) { + Object.defineProperty(process, 'platform', desc) + } + } + + it('neutralizes proc.kill on POSIX inside proc.onExit synchronously', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + const originalKill = proc.kill + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + expect(proc.kill).toBe(originalKill) + proc._simulateExit(0) + expect(proc.kill).not.toBe(originalKill) + // Calling the neutralized kill is a safe no-op. + expect(() => (proc.kill as () => void)()).not.toThrow() + } finally { + restorePlatform(origPlatform) + } + }) + + it('DOES NOT neutralize proc.kill on Windows (WindowsTerminal.destroy needs kill)', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + const originalKill = proc.kill + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + proc._simulateExit(0) + expect(proc.kill).toBe(originalKill) + } finally { + restorePlatform(origPlatform) + } + }) + + it('dispose() neutralizes proc.kill on POSIX before calling destroy()', () => { + const proc = mockPtyProcess() as ReturnType & { + destroy: ReturnType + } + proc.destroy = vi.fn() + spawnMock.mockReturnValue(proc) + const origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'darwin' }) + const originalKill = proc.kill + try { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + handle.dispose() + expect(proc.kill).not.toBe(originalKill) + expect(proc.destroy).toHaveBeenCalledOnce() + } finally { + restorePlatform(origPlatform) + } + }) + + it('dispose() on Windows calls destroy() without neutralizing kill', () => { + const proc = mockPtyProcess() as ReturnType & { + destroy: ReturnType + } + proc.destroy = vi.fn() + spawnMock.mockReturnValue(proc) + const origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + const originalKill = proc.kill + try { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + handle.dispose() + expect(proc.kill).toBe(originalKill) + expect(proc.destroy).toHaveBeenCalledOnce() + } finally { + restorePlatform(origPlatform) + } + }) + + it('dispose() is idempotent — second call does not re-invoke destroy', () => { + const proc = mockPtyProcess() as ReturnType & { + destroy: ReturnType + } + proc.destroy = vi.fn() + spawnMock.mockReturnValue(proc) + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + handle.dispose() + handle.dispose() + expect(proc.destroy).toHaveBeenCalledOnce() + }) + }) + + // Why: after proc.onExit fires (dead=true), proc.pid refers to a reaped child + // whose pid may have been recycled to an unrelated process. forceKill and + // signal call process.kill(proc.pid, ...) directly, bypassing the + // proc.kill-neutralization applied to the node-pty instance. Without an + // internal dead-guard, they can deliver SIGKILL/SIGINT/etc to a stranger. + describe('forceKill/signal guard against recycled pid after exit', () => { + it('forceKill is a no-op once proc.onExit has fired', () => { + const proc = mockPtyProcess(55) + spawnMock.mockReturnValue(proc) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + proc._simulateExit(0) + handle.forceKill() + expect(killSpy).not.toHaveBeenCalled() + killSpy.mockRestore() + }) + + it('signal is a no-op once proc.onExit has fired', () => { + const proc = mockPtyProcess(55) + spawnMock.mockReturnValue(proc) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + proc._simulateExit(0) + handle.signal('SIGINT') + expect(killSpy).not.toHaveBeenCalled() + killSpy.mockRestore() + }) + + it('forceKill before exit still fires SIGKILL (live child)', () => { + const proc = mockPtyProcess(77) + spawnMock.mockReturnValue(proc) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + handle.forceKill() + expect(killSpy).toHaveBeenCalledWith(77, 'SIGKILL') + killSpy.mockRestore() + }) + }) }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 8e6ceb4ca50..bdeec2053a2 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1,4 +1,5 @@ import * as pty from 'node-pty' +import { win32 as pathWin32 } from 'path' import type { SubprocessHandle } from './session' import { getAttributionShellLaunchConfig, @@ -8,6 +9,8 @@ import { import { isValidPtySize, normalizePtySize } from './daemon-pty-size' import { ensureNodePtySpawnHelperExecutable } from '../providers/local-pty-utils' import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args' +import { resolveEffectiveWindowsPowerShell } from '../providers/windows-powershell' +import { isPwshAvailable } from '../pwsh' export type PtySubprocessOptions = { sessionId: string @@ -20,6 +23,7 @@ export type PtySubprocessOptions = { * Overrides env.COMSPEC / env.SHELL resolution inside the daemon so a user * who picks "New WSL terminal" from the "+" menu actually gets WSL. */ shellOverride?: string + terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' } function getDefaultCwd(): string { @@ -67,16 +71,37 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // setting, relayed by main) takes priority over env.COMSPEC — otherwise // Windows always resolves to cmd.exe (COMSPEC) or PowerShell by fallback, // no matter which shell the user actually picked. - const shellPath = opts.shellOverride || resolvePtyShellPath(env) + let shellPath = opts.shellOverride || resolvePtyShellPath(env) let shellArgs: string[] let spawnCwd = opts.cwd || getDefaultCwd() if (process.platform === 'win32') { + const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase() + // Why: daemon spawn requests can carry either a canonical shell family + // (`powershell.exe`) or a concrete PowerShell executable path from a + // one-off override. Normalize both forms back to the PowerShell family so + // the shared resolver can still fall back to inbox powershell.exe when + // pwsh.exe was requested but is unavailable. + const shouldResolvePowerShellFamily = + opts.terminalWindowsPowerShellImplementation !== undefined || + pathWin32.basename(shellPath) === shellPath + shellPath = shouldResolvePowerShellFamily + ? (resolveEffectiveWindowsPowerShell({ + shellFamily: + normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' + ? 'powershell.exe' + : normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe' + ? normalizedShellFamily + : undefined, + implementation: opts.terminalWindowsPowerShellImplementation, + pwshAvailable: isPwshAvailable() + }) ?? shellPath) + : shellPath // Why: matches LocalPtyProvider — CMD needs chcp 65001, PowerShell needs // $PROFILE dot-sourcing, WSL needs a --bash entry with a translated cwd. - // Previously the daemon passed `[]` here which made every shell launch as - // a bare interactive process; that silently degraded PowerShell (no - // profile) and never worked at all for WSL (which needs explicit args). + // Reuse the same shared launch-args helper after resolving the effective + // PowerShell executable so daemon-backed terminals preserve parity with the + // in-process PTY path. const resolved = resolveWindowsShellLaunchArgs(shellPath, spawnCwd, getDefaultCwd()) shellArgs = resolved.shellArgs spawnCwd = resolved.effectiveCwd @@ -117,8 +142,21 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // exiting and the JS onExit callback firing. An uncaught Napi::Error // propagates to std::terminate, killing the entire daemon process. let dead = false + let disposed = false proc.onExit(() => { dead = true + // Why: UnixTerminal.destroy() registers `_socket.once('close', () => this.kill('SIGHUP'))` + // (unixTerminal.js:219-229). After the child exits, the master socket's + // 'close' event can fire before our dispose() path gets to neutralize + // proc.kill — the child's pid may have already been recycled, so SIGHUP + // lands on an unrelated process. Neutralizing here, synchronously inside + // the onExit callback, closes that window: once the child is reaped, + // proc.kill is a no-op no matter which teardown ordering wins. + // Windows is excluded because WindowsTerminal.destroy relies on kill() to + // close the ConPTY agent — neutralizing would leak the agent + fds. + if (process.platform !== 'win32') { + ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} + } }) return { @@ -157,6 +195,13 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl } }, forceKill: () => { + // Why: once the child has been reaped (dead=true via onExit) or dispose + // has run, proc.pid refers to a recycled pid. Sending SIGKILL would + // terminate an unrelated process. The fd release is handled by + // dispose()/destroy(); forceKill is strictly for signalling a live child. + if (dead) { + return + } try { process.kill(proc.pid, 'SIGKILL') } catch { @@ -168,6 +213,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl } }, signal: (sig) => { + // Why: same recycled-pid hazard as forceKill. Once dead, silently drop + // the signal rather than risk sending it to an unrelated process. + if (dead) { + return + } try { process.kill(proc.pid, sig) } catch { @@ -179,6 +229,35 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl }, onExit: (cb) => { onExitCb = cb + }, + dispose: () => { + if (disposed) { + return + } + disposed = true + dead = true + onDataCb = null + onExitCb = null + // Why: UnixTerminal.destroy() registers `_socket.once('close', () => this.kill('SIGHUP'))` + // (unixTerminal.js:219-229). The socket close fires asynchronously; by then + // the child may have exited and its pid been recycled to an unrelated + // process. Without this neutralization, SIGHUP can be delivered to a + // Chrome tab, editor, or other user process — silent cross-app corruption. + // `_socket.destroy()` still releases the fd; only the dangerous SIGHUP is + // removed. + // + // Platform guard: WindowsTerminal.destroy implements the ConPTY close by + // CALLING `this.kill()` via `_deferNoArgs` (windowsTerminal.js:141-146). + // Neutralizing kill on Windows turns destroy() into a no-op and leaks the + // ConPTY agent. The SIGHUP hazard is POSIX-only, so the guard is too. + if (process.platform !== 'win32') { + ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} + } + try { + ;(proc as unknown as { destroy?: () => void }).destroy?.() + } catch { + /* swallow — already torn down, or native-side error we can't recover from */ + } } } } diff --git a/src/main/daemon/reattach-snapshot.test.ts b/src/main/daemon/reattach-snapshot.test.ts index e9f5813e171..c9a4bc32a9d 100644 --- a/src/main/daemon/reattach-snapshot.test.ts +++ b/src/main/daemon/reattach-snapshot.test.ts @@ -23,6 +23,7 @@ function createMockSubprocess(): SubprocessHandle & { onExit(cb) { onExitCb = cb }, + dispose: vi.fn(), simulateData(data: string) { onDataCb?.(data) }, diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 5191f77fd6b..fc312338173 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -41,6 +41,7 @@ function createMockSubprocess() { onExit(cb: (code: number) => void) { onExit = cb }, + dispose() {}, // Helpers for tests to simulate subprocess events simulateData(data: string) { onData?.(data) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 4c398f5a2e0..b2cb46fd7d1 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -1,3 +1,4 @@ +/* oxlint-disable max-lines */ import { HeadlessEmulator } from './headless-emulator' import { isValidPtySize, normalizePtySize } from './daemon-pty-size' import { PostReadyFlushGate } from './post-ready-flush-gate' @@ -16,6 +17,10 @@ export type SubprocessHandle = { signal(sig: string): void onData(cb: (data: string) => void): void onExit(cb: (code: number) => void): void + /** Release the native PTY handle via node-pty's own destroy() path. + * Idempotent. Safe to call after exit. Called by Session on every teardown + * path (natural exit, kill, force-kill, native throw, session dispose). */ + dispose(): void } export type SessionOptions = { @@ -191,35 +196,31 @@ export class Session { return } - // Why: if kill() was called but the subprocess hasn't exited yet, our - // killTimer is the only thing that would forceKill a stuck subprocess. - // Clearing it below without force-killing would leak an orphaned process, - // so mirror forceDispose(): issue the force-kill, notify attached clients - // (handleSubprocessExit is guarded by _disposed and won't broadcast later), - // and clear the terminating flag for a consistent final state. + // Why: captured BEFORE the `_state = 'exited'` flip below. This check + // guards the "dispose while kill() was already in flight" case — if true, + // the child hasn't reaped yet and we need to forceKill it here (the 5s + // killTimer is also about to be cleared by #teardownSubprocess). Do NOT + // move this capture below #teardownSubprocess or the `_state = 'exited'` + // assignment — #teardownSubprocess flips `_disposed` but the invariant + // depends on the PRE-flip value of `_state`. const wasTerminating = this._isTerminating && this._state !== 'exited' const clientsToNotify = wasTerminating ? this.attachedClients.slice() : [] if (wasTerminating) { - this.subprocess.forceKill() + try { + this.subprocess.forceKill() + } catch { + /* child may already be gone */ + } this._exitCode = -1 this._isTerminating = false } - this._disposed = true + this.#teardownSubprocess() this._state = 'exited' - if (this.shellReadyTimer) { - clearTimeout(this.shellReadyTimer) - this.shellReadyTimer = null - } - if (this.killTimer) { - clearTimeout(this.killTimer) - this.killTimer = null - } - this.postReadyFlushGate.clear() - this.attachedClients = [] this.preReadyStdinQueue = [] + this.postReadyFlushGate.clear() this.emulator.dispose() for (const client of clientsToNotify) { @@ -227,6 +228,72 @@ export class Session { } } + /** Public: fd-release-only teardown for sessions that have ALREADY exited + * (state === 'exited') but are still retained in the host's map. Callers + * MUST NOT use this on live sessions — it skips SIGKILL. + * + * Why a separate method: after handleSubprocessExit fires, proc.pid refers + * to a child that has been reaped; on POSIX that pid is eligible for reuse + * and may now belong to an unrelated process. forceKillAndDisposeSubprocess + * would send SIGKILL to that recycled pid. This method only releases the + * PTY master fd via node-pty's destroy() (which is neutralized against the + * SIGHUP-to-pid hazard by the onExit handler in pty-subprocess.ts). */ + disposeSubprocess(): void { + this.#teardownSubprocess() + this._state = 'exited' + } + + /** Public: orderly-shutdown path used by TerminalHost.dispose() for sessions + * that are still live. Force-kills the child (SIGKILL is not ignorable), + * then releases the PTY master fd synchronously via node-pty's destroy(). + * Bypasses the 5s KILL_TIMEOUT_MS fallback so daemon shutdown reaps + * stubborn children AND frees the ptmx fd on the same tick. Does NOT fan + * out onExit to attached clients — renderer reconnects cold after daemon + * exit. Callers MUST check isAlive first; see disposeSubprocess() for the + * already-exited case. */ + forceKillAndDisposeSubprocess(): void { + // Why: forceKill before #teardownSubprocess. The helper's subprocess.dispose() + // neutralizes node-pty's proc.kill on POSIX (to kill the SIGHUP-to-recycled-pid + // hazard). subprocess.forceKill uses process.kill(pid, 'SIGKILL') directly + // (pty-subprocess.ts) — unaffected by the neutralization, because it does not + // go through proc.kill. SIGKILL is not ignorable; any child that would have + // survived the 5s timer is reaped immediately. + try { + this.subprocess.forceKill() + } catch { + /* swallow — child may already be gone */ + } + this.#teardownSubprocess() + this._state = 'exited' + } + + /** Private: shared teardown helper called by dispose(), forceDispose(), and + * forceKillAndDisposeSubprocess(). Flips `_disposed`, clears pending timers, + * and forwards to subprocess.dispose() exactly once. Does NOT set `_state` — + * the caller owns the state transition AFTER capturing any invariants that + * depend on the pre-flip value (see the wasTerminating capture in dispose). */ + #teardownSubprocess(): void { + if (this._disposed) { + return + } + this._disposed = true + if (this.killTimer) { + clearTimeout(this.killTimer) + this.killTimer = null + } + if (this.shellReadyTimer) { + clearTimeout(this.shellReadyTimer) + this.shellReadyTimer = null + } + try { + this.subprocess.dispose() + } catch (err) { + // Why: dispose() is documented never to throw, but if it does we must not + // prevent callers from completing their own cleanup (fanout, map removal). + console.warn('[Session] subprocess.dispose() threw:', err) + } + } + private handleSubprocessData(data: string): void { if (this._disposed) { return @@ -265,6 +332,19 @@ export class Session { } this.postReadyFlushGate.clear() + // Why: release the ptmx fd on the natural-exit path. Without this, the + // node-pty wrapper's _socket stays alive until GC and the master fd leaks + // (see docs/fix-pty-fd-leak.md). Do NOT route through #teardownSubprocess: + // that helper flips `_disposed = true`, which would short-circuit a later + // Session.dispose() call from TerminalHost's dead-session cleanup at + // terminal-host.ts:83 — skipping attachedClients/emulator/postReadyFlushGate + // cleanup. Call subprocess.dispose() directly inside try/catch. + try { + this.subprocess.dispose() + } catch { + /* swallow — must not prevent exit-code fanout below */ + } + for (const client of this.attachedClients) { client.onExit(code) } @@ -320,25 +400,28 @@ export class Session { if (this._state === 'exited') { return } - this.subprocess.forceKill() - this._disposed = true + // Why: forceKill BEFORE #teardownSubprocess. Order is load-bearing — the + // helper's subprocess.dispose() neutralizes proc.kill on POSIX (to defuse + // the SIGHUP-to-recycled-pid hazard inside node-pty). forceKill uses + // process.kill(pid, 'SIGKILL') directly and is unaffected by that + // neutralization. Must NOT flip `_disposed` here before #teardownSubprocess + // runs, or the helper would early-return and skip subprocess.dispose() — + // the ptmx fd would leak on every kill-timeout (this whole doc's target). + try { + this.subprocess.forceKill() + } catch { + /* already dead */ + } this._exitCode = -1 - this._state = 'exited' this._isTerminating = false - if (this.shellReadyTimer) { - clearTimeout(this.shellReadyTimer) - this.shellReadyTimer = null - } - if (this.killTimer) { - clearTimeout(this.killTimer) - this.killTimer = null - } - this.postReadyFlushGate.clear() + this.#teardownSubprocess() + this._state = 'exited' const clients = this.attachedClients this.attachedClients = [] this.preReadyStdinQueue = [] + this.postReadyFlushGate.clear() this.emulator.dispose() for (const client of clients) { diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index f7a2e021fdb..1bccaf63263 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -134,7 +134,11 @@ if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then __orca_prompt_mark() { printf "${SHELL_READY_MARKER}" } - precmd_functions=(\${precmd_functions[@]} __orca_prompt_mark) + # Why: zsh precmd fires before zle switches the PTY into line-editing mode, + # so writing startup input there can be echoed once outside the prompt. + autoload -Uz add-zle-hook-widget + zle -N __orca_prompt_mark + add-zle-hook-widget line-init __orca_prompt_mark fi ` const bashRc = `# Orca daemon bash shell-ready wrapper diff --git a/src/main/daemon/terminal-host-startup.test.ts b/src/main/daemon/terminal-host-startup.test.ts index 2f1a20242e5..9474b870439 100644 --- a/src/main/daemon/terminal-host-startup.test.ts +++ b/src/main/daemon/terminal-host-startup.test.ts @@ -11,7 +11,8 @@ function mockSubprocess(): SubprocessHandle { forceKill: vi.fn(), signal: vi.fn(), onData: () => {}, - onExit: () => {} + onExit: () => {}, + dispose: vi.fn() } as SubprocessHandle } diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 3dcf673dc35..b571f43cdfb 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -1,3 +1,4 @@ +/* oxlint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TerminalHost } from './terminal-host' import type { SubprocessHandle } from './session' @@ -20,6 +21,7 @@ function createMockSubprocess(): SubprocessHandle { onExit(cb) { onExitCb = cb }, + dispose: vi.fn(), // Test helpers get _onDataCb() { return onDataCb @@ -314,7 +316,7 @@ describe('TerminalHost', () => { }) describe('dispose', () => { - it('kills live subprocesses before disposing sessions', async () => { + it('force-kills live subprocesses and releases PTY fds on dispose', async () => { await host.createOrAttach({ sessionId: 'session-1', cols: 80, @@ -323,7 +325,13 @@ describe('TerminalHost', () => { }) host.dispose() - expect(lastSubprocess.kill).toHaveBeenCalled() + // Why: for LIVE sessions, dispose() calls session.forceKillAndDisposeSubprocess() + // which sends SIGKILL (forceKill) and releases the ptmx fd (subprocess.dispose) + // synchronously — no longer relies on the 5s KILL_TIMEOUT_MS fallback. + // Exited sessions take the disposeSubprocess() path instead (see the test + // below). See docs/fix-pty-fd-leak.md. + expect(lastSubprocess.forceKill).toHaveBeenCalled() + expect(lastSubprocess.dispose).toHaveBeenCalled() }) it('does not list exited sessions', async () => { @@ -337,5 +345,39 @@ describe('TerminalHost', () => { lastSubprocess._onExitCb?.(0) expect(host.listSessions()).toEqual([]) }) + + it('skips forceKill on already-exited sessions to avoid recycled-pid SIGKILL', async () => { + // Why: after a session's subprocess has exited (onExit fired), proc.pid + // refers to a reaped child whose pid may have been recycled. Calling + // forceKillAndDisposeSubprocess() on an exited session would + // process.kill(recycled_pid, 'SIGKILL') — killing a stranger. Dispose + // must detect isAlive=false and use disposeSubprocess() (fd release only). + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + // Simulate natural exit — session is retained in map until dispose. + lastSubprocess._onExitCb?.(0) + + // Re-create another session so the map has BOTH a live and dead entry. + const exitedSub = lastSubprocess + await host.createOrAttach({ + sessionId: 'session-2', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + const liveSub = lastSubprocess + + host.dispose() + + expect(exitedSub.forceKill).not.toHaveBeenCalled() + expect(exitedSub.dispose).toHaveBeenCalled() + expect(liveSub.forceKill).toHaveBeenCalled() + expect(liveSub.dispose).toHaveBeenCalled() + }) }) }) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index bded2ef29db..d8bddb2fe39 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,5 +1,6 @@ import { Session, type SubprocessHandle } from './session' import { normalizePtySize } from './daemon-pty-size' +import { resolveProcessCwd } from '../providers/process-cwd' import type { SessionInfo, TerminalSnapshot, ShellReadyState } from './types' import { SessionNotFoundError } from './types' @@ -17,6 +18,7 @@ export type CreateOrAttachOptions = { * daemon path honors per-tab shell selection the same way LocalPtyProvider * does. */ shellOverride?: string + terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' shellReadySupported?: boolean streamClient: { onData: (data: string) => void; onExit: (code: number) => void } } @@ -38,6 +40,7 @@ export type TerminalHostOptions = { env?: Record command?: string shellOverride?: string + terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' }) => SubprocessHandle // Why: on graceful shutdown, the host writes final checkpoints for all live // sessions before killing them. This bypasses the RPC round-trip — the daemon @@ -94,7 +97,8 @@ export class TerminalHost { cwd: opts.cwd, env: opts.env, command: opts.command, - shellOverride: opts.shellOverride + shellOverride: opts.shellOverride, + terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation }) const session = new Session({ @@ -155,8 +159,19 @@ export class TerminalHost { session?.detachClient(token) } - getCwd(sessionId: string): string | null { - return this.getAliveSession(sessionId).getCwd() + async getCwd(sessionId: string): Promise { + const session = this.getAliveSession(sessionId) + const tracked = session.getCwd() + if (tracked) { + return tracked + } + // Why: the emulator's cwd is null until the shell emits OSC 7. Orca's + // bash/zsh rcfiles ship with OSC 133 markers but not OSC 7, so the + // tracked value stays null through the entire session for most users. + // Fall back to the live process cwd via /proc//cwd (Linux) or + // lsof (macOS). Matches the LocalPtyProvider.getCwd fallback. + const resolved = await resolveProcessCwd(session.pid) + return resolved || null } clearScrollback(sessionId: string): void { @@ -221,7 +236,19 @@ export class TerminalHost { for (const [, session] of this.sessions) { session.detachAllClients() - session.kill() + // Why: live-vs-exited is load-bearing. For LIVE sessions we use + // forceKillAndDisposeSubprocess (SIGKILL + destroy) to reap stubborn + // children AND release the ptmx fd on the same tick, bypassing the 5s + // KILL_TIMEOUT_MS fallback that would otherwise outlive the daemon + // process. For sessions that have already exited but are still in the + // map, SIGKILL would target a reaped pid — on POSIX that pid can be + // recycled to an unrelated process, so we MUST only release the fd via + // disposeSubprocess() (destroy without kill). See docs/fix-pty-fd-leak.md. + if (session.isAlive) { + session.forceKillAndDisposeSubprocess() + } else { + session.disposeSubprocess() + } } this.sessions.clear() this.killedTombstones.clear() diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 1a6db0db0ac..a7fba8f42ca 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -68,6 +68,11 @@ export type CreateOrAttachRequest = { * instead of defaulting to COMSPEC (which is always cmd.exe on Windows) * or the hard-coded powershell.exe fallback. */ shellOverride?: string + /** Why: the UI keeps PowerShell as one shell family, but the runtime may + * need to substitute pwsh.exe for powershell.exe when the user selected + * PowerShell 7+. Forward the persisted implementation choice so the daemon + * PTY path resolves the same effective executable as LocalPtyProvider. */ + terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' shellReadySupported?: boolean } } @@ -224,6 +229,14 @@ export type SessionInfo = { createdAt: number } +// Why: SessionInfo + source protocol version, so the Manage Sessions UI can +// label legacy-backed sessions. Populated by the router/adapter at RPC time; +// never transmitted over the daemon wire (daemon only speaks its own +// protocol version and doesn't know about other versions). +export type DaemonSessionInfo = SessionInfo & { + protocolVersion: number +} + // ─── Events (Daemon → Client, on stream socket) ──────────────────── export type DataEvent = { diff --git a/src/main/git/commit.test.ts b/src/main/git/commit.test.ts new file mode 100644 index 00000000000..465488d268f --- /dev/null +++ b/src/main/git/commit.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileAsyncBuffer: vi.fn() +})) + +import { commitChanges } from './status' + +describe('commitChanges', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('returns success when git commit completes', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '[main abc123] message\n', stderr: '' }) + + const result = await commitChanges('/repo', 'feat: add commit action') + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['commit', '-m', 'feat: add commit action'], { + cwd: '/repo' + }) + expect(result).toEqual({ success: true }) + }) + + it('returns stderr when commit fails (e.g. pre-commit hook)', async () => { + gitExecFileAsyncMock.mockRejectedValue({ + stderr: 'pre-commit hook failed: lint errors\n' + }) + + const result = await commitChanges('/repo', 'feat: commit with lint errors') + + expect(result).toEqual({ + success: false, + error: 'pre-commit hook failed: lint errors\n' + }) + }) + + it('returns stdout when git writes to stdout (e.g. nothing to commit)', async () => { + gitExecFileAsyncMock.mockRejectedValue({ + stdout: 'nothing to commit, working tree clean\n', + stderr: '' + }) + + const result = await commitChanges('/repo', 'feat: empty commit') + + expect(result).toEqual({ + success: false, + error: 'nothing to commit, working tree clean\n' + }) + }) + + it('prefers stderr over stdout when both are present', async () => { + gitExecFileAsyncMock.mockRejectedValue({ + stdout: 'some stdout output\n', + stderr: 'hook rejected\n' + }) + + const result = await commitChanges('/repo', 'feat: both channels') + + expect(result).toEqual({ + success: false, + error: 'hook rejected\n' + }) + }) + + it('falls back to Error.message when stdout/stderr are empty', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('spawn git ENOENT')) + + const result = await commitChanges('/repo', 'feat: missing git') + + expect(result).toEqual({ + success: false, + error: 'spawn git ENOENT' + }) + }) +}) diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index eecf11344dc..8039abd3748 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -1,17 +1,38 @@ +/* eslint-disable max-lines -- Why: remove/list/sparse cleanup tests share one git runner + mock harness, and splitting them would duplicate setup without a clearer boundary. */ +import type * as FsPromises from 'fs/promises' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, gitExecFileSyncMock } = vi.hoisted(() => ({ +const { + gitExecFileAsyncMock, + gitExecFileSyncMock, + translateWslOutputPathsMock, + statMock, + resolveGitDirMock +} = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn() + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output), + statMock: vi.fn(), + resolveGitDirMock: vi.fn() })) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, gitExecFileSync: gitExecFileSyncMock, - translateWslOutputPaths: (output: string) => output + translateWslOutputPaths: translateWslOutputPathsMock })) -import { removeWorktree } from './worktree' +vi.mock('./status', () => ({ + resolveGitDir: resolveGitDirMock +})) + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises') + return { ...actual, stat: statMock } +}) + +import { addSparseWorktree, listWorktrees, removeWorktree } from './worktree' type MockResult = { error?: Error @@ -54,6 +75,14 @@ describe('removeWorktree', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileSyncMock.mockReset() + translateWslOutputPathsMock.mockReset() + translateWslOutputPathsMock.mockImplementation((output: string) => output) + statMock.mockReset() + // Default: no worktree has a sparse-checkout config file. Tests that need + // sparse detection override this. + statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + resolveGitDirMock.mockReset() + resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`) }) it('removes the worktree, prunes stale refs, and deletes its local branch', async () => { @@ -262,3 +291,200 @@ branch refs/heads/main warnSpy.mockRestore() }) }) + +describe('listWorktrees', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileSyncMock.mockReset() + translateWslOutputPathsMock.mockReset() + translateWslOutputPathsMock.mockImplementation((output: string) => output) + statMock.mockReset() + // Default: no worktree has a sparse-checkout config file. Tests that need + // sparse detection override this. + statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + resolveGitDirMock.mockReset() + resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`) + }) + + it('translates parsed path fields from line-block porcelain output', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: + 'worktree /home/me/repo\nHEAD abc123\nbranch refs/heads/main\n\n' + + 'worktree /home/me/repo-feature\nHEAD def456\nbranch refs/heads/feature/test\nsparse\n\n' + }) + translateWslOutputPathsMock.mockImplementation((output: string) => + output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\') + ) + + await expect(listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).resolves.toEqual([ + { + path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature', + head: 'def456', + branch: 'refs/heads/feature/test', + isBare: false, + isSparse: true, + isMainWorktree: false + } + ]) + // Why: the non-sparse main worktree gets an fs probe of its sparse config + // file; the linked worktree short-circuits on the parsed `sparse` token and + // does not. Only one git subprocess runs regardless of worktree count. + expect(getGitCalls()).toEqual(['git worktree list --porcelain']) + expect(statMock).toHaveBeenCalledTimes(1) + expect(translateWslOutputPathsMock).toHaveBeenCalledTimes(2) + }) + + it('returns no worktrees when the repo path is gone', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + gitExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('spawn git ENOENT'), { + code: 'ENOENT' + }) + ) + statMock.mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + + await expect(listWorktrees('/workspace/deleted-repo')).resolves.toEqual([]) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain'], { + cwd: '/workspace/deleted-repo' + }) + expect(statMock).toHaveBeenCalledWith('/workspace/deleted-repo') + expect(warnSpy).toHaveBeenCalledWith( + '[git/worktree] repo path missing; skipping worktree list: /workspace/deleted-repo' + ) + warnSpy.mockRestore() + }) + + it('detects sparse checkout after translating paths when porcelain omits sparse token', async () => { + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.join(' ') === 'worktree list --porcelain') { + return { + stdout: + 'worktree /home/me/repo\nHEAD abc123\nbranch refs/heads/main\n\n' + + 'worktree /home/me/repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n\n', + stderr: '' + } + } + throw new Error(`Unexpected git call: ${args.join(' ')}`) + }) + translateWslOutputPathsMock.mockImplementation((output: string) => + output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\') + ) + const featureWorktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature' + resolveGitDirMock.mockImplementation(async (worktreePath: string) => + worktreePath === featureWorktreePath + ? `${featureWorktreePath}\\.git-worktrees\\feature` + : `${worktreePath}/.git` + ) + statMock.mockImplementation(async (filePath: string) => { + if (filePath.includes('repo-feature') && filePath.includes('sparse-checkout')) { + return { isFile: () => true, size: 32 } + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + + const worktrees = await listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo') + + expect(worktrees).toEqual([ + { + path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature', + head: 'def456', + branch: 'refs/heads/feature/test', + isBare: false, + isSparse: true, + isMainWorktree: false + } + ]) + expect(resolveGitDirMock).toHaveBeenCalledWith(featureWorktreePath) + // Why: the detection path must not spawn a git subprocess per worktree — + // the perf regression in #1131 came from `git sparse-checkout list` firing + // on every poll. + expect(getGitCalls()).toEqual(['git worktree list --porcelain']) + }) +}) + +describe('addSparseWorktree', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileSyncMock.mockReset() + translateWslOutputPathsMock.mockReset() + translateWslOutputPathsMock.mockImplementation((output: string) => output) + statMock.mockReset() + // Default: no worktree has a sparse-checkout config file. Tests that need + // sparse detection override this. + statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + resolveGitDirMock.mockReset() + resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`) + }) + + it('separates sparse checkout directory operands from options', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['-docs', 'src']) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['sparse-checkout', 'set', '--', '-docs', 'src'], + { cwd: '/repo-feature' } + ) + }) + + it('removes the worktree and deletes the created branch when sparse setup fails', async () => { + mockGitCommands({ + 'git sparse-checkout set -- packages/web': { + error: new Error('sparse setup failed') + }, + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree list --porcelain#2': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + } + }) + + await expect( + addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['packages/web']) + ).rejects.toThrow('sparse setup failed') + + const calls = getGitCalls() + expect(calls).toEqual( + expect.arrayContaining([ + 'git worktree add --no-checkout -b feature/test /repo-feature', + 'git sparse-checkout init --cone', + 'git sparse-checkout set -- packages/web', + 'git worktree remove --force /repo-feature', + 'git worktree prune', + 'git branch -D feature/test' + ]) + ) + expectGitCallOrder( + calls, + 'git sparse-checkout set -- packages/web', + 'git worktree remove --force /repo-feature' + ) + expectGitCallOrder(calls, 'git worktree prune', 'git branch -D feature/test') + }) +}) diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index bdf8f7a7042..762ca6dd09f 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -223,6 +223,63 @@ export async function getBaseRefDefault(path: string): Promise { return getDefaultBaseRefAsync(path) } +/** + * Return { ahead, behind } for localRef vs remoteRef, or null on git failure. + * + * Why: `rev-list --left-right --count A...B` emits `\t` — + * ahead = commits on A not reachable from B; behind = commits on B not + * reachable from A. This is the merge-base-symmetric delta used by the + * stale-base dispatch guard (§3.1). Returning null on any failure (bad + * ref, corrupt repo, non-numeric output) lets callers degrade gracefully + * instead of failing dispatch on a probe error. + */ +export function getRemoteDrift( + repoPath: string, + localRef: string, + remoteRef: string +): { ahead: number; behind: number } | null { + try { + const stdout = gitExecFileSync( + ['rev-list', '--left-right', '--count', `${localRef}...${remoteRef}`], + { cwd: repoPath } + ) + const [aheadStr, behindStr] = stdout.trim().split(/\s+/) + const ahead = Number(aheadStr) + const behind = Number(behindStr) + if (!Number.isFinite(ahead) || !Number.isFinite(behind)) { + return null + } + return { ahead, behind } + } catch { + return null + } +} + +/** + * Up to `limit` commit subjects present on remoteRef but not localRef, in + * recency order. Returns [] on git failure. + * + * Why: powers the preamble drift section (§3.2) so a worker dispatched + * against an acknowledged-stale base can see at a glance whether the + * drift touches their task area. + */ +export function getRecentDriftSubjects( + repoPath: string, + localRef: string, + remoteRef: string, + limit: number +): string[] { + try { + const stdout = gitExecFileSync( + ['log', '--format=%s', '-n', String(limit), `${localRef}..${remoteRef}`], + { cwd: repoPath } + ) + return stdout.split('\n').filter((s) => s.trim().length > 0) + } catch { + return [] + } +} + /** * Parse `git remote` stdout into a count of configured remotes. * diff --git a/src/main/git/runner.test.ts b/src/main/git/runner.test.ts new file mode 100644 index 00000000000..e83af8c115f --- /dev/null +++ b/src/main/git/runner.test.ts @@ -0,0 +1,91 @@ +// Why: covers two recent classifier fixes — Retry-After honoring on 429 +// (transient detection must propagate, not silently retry on 250ms cadence) +// and stderr extraction from execFile rejections (err.message is unreliable). +import { describe, expect, it } from 'vitest' +import { extractExecError, isTransientGhError, parseRetryAfterMs } from './runner' + +describe('parseRetryAfterMs', () => { + it('returns null when no Retry-After is present', () => { + expect(parseRetryAfterMs('HTTP 429 Too Many Requests')).toBeNull() + }) + + it('parses integer seconds', () => { + expect(parseRetryAfterMs('HTTP 429\nRetry-After: 30\n')).toBe(30_000) + }) + + it('handles case-insensitive header name and surrounding whitespace', () => { + expect(parseRetryAfterMs(' retry-after: 12 \n')).toBe(12_000) + }) + + it('returns null for malformed values', () => { + expect(parseRetryAfterMs('Retry-After: not-a-date')).toBeNull() + }) +}) + +describe('isTransientGhError', () => { + it('retries 5xx errors', () => { + expect(isTransientGhError('HTTP 502 Bad Gateway')).toBe(true) + expect(isTransientGhError('http 503')).toBe(true) + }) + + it('retries network resets', () => { + expect(isTransientGhError('connect ECONNRESET 10.0.0.1:443')).toBe(true) + expect(isTransientGhError('socket hang up')).toBe(true) + }) + + it('retries 429 without Retry-After', () => { + expect(isTransientGhError('HTTP 429 Too Many Requests')).toBe(true) + }) + + it('does NOT retry 429 with Retry-After', () => { + // Why: when GitHub returns Retry-After, the server is telling us how long + // to wait. Retrying on our 250ms cadence just earns another 429 and burns + // the retry budget. + expect(isTransientGhError('HTTP 429 Too Many Requests\nRetry-After: 60\n')).toBe(false) + }) + + it("does NOT retry 4xx that aren't 429", () => { + expect(isTransientGhError('HTTP 401 Unauthorized')).toBe(false) + expect(isTransientGhError('HTTP 404 Not Found')).toBe(false) + expect(isTransientGhError('HTTP 422 Unprocessable Entity')).toBe(false) + }) +}) + +describe('extractExecError', () => { + it('reads stderr and stdout from explicit fields', () => { + const err = Object.assign(new Error('Command failed'), { + stderr: 'real stderr content', + stdout: '{"data": null}' + }) + expect(extractExecError(err)).toEqual({ + stderr: 'real stderr content', + stdout: '{"data": null}' + }) + }) + + it('decodes Buffer stderr/stdout', () => { + const err = Object.assign(new Error('Command failed'), { + stderr: Buffer.from('buf-stderr', 'utf-8'), + stdout: Buffer.from('buf-stdout', 'utf-8') + }) + expect(extractExecError(err)).toEqual({ + stderr: 'buf-stderr', + stdout: 'buf-stdout' + }) + }) + + it('falls back to err.message when stderr/stdout are absent', () => { + const err = new Error('Some message') + expect(extractExecError(err)).toEqual({ + stderr: 'Some message', + stdout: '' + }) + }) + + it('handles non-Error rejections', () => { + expect(extractExecError('plain string error')).toEqual({ + stderr: 'plain string error', + stdout: '' + }) + }) +}) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 9ed285df923..c43058504b4 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -6,13 +6,7 @@ * This module detects WSL paths and routes command execution through `wsl.exe -d ` * with translated Linux paths, so every call site gets WSL support for free. */ -import { - execFile, - execFileSync, - spawn, - type ChildProcess, - type SpawnOptions -} from 'child_process' +import { execFile, execFileSync, spawn, type ChildProcess, type SpawnOptions } from 'child_process' import { promisify } from 'util' import { parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' @@ -70,13 +64,26 @@ function translateArgsForWsl(args: string[]): string[] { function resolveCommand( command: string, args: string[], - cwd: string | undefined + cwd: string | undefined, + wslDistroOverride?: string ): ResolvedCommand { - if (!cwd || process.platform !== 'win32') { + if (process.platform !== 'win32') { return { binary: command, args, cwd, wsl: null } } - const wsl = parseWslPath(cwd) + // Why: global gh callers (rate_limit, listAccessibleProjects) have no + // meaningful cwd to derive a WSL distro from. On WSL-only Windows setups, + // gh.exe isn't on the host PATH and the spawn fails with ENOENT. Allow + // callers to pass a distro hint so we can route through wsl.exe regardless. + // TODO(wsl-default-distro): the codebase currently has no persistent + // "default WSL distro" setting — distros are derived from individual repo + // paths. Until such a setting exists, global gh callers without an explicit + // override silently fall back to host gh.exe, which on WSL-only Windows + // installs will ENOENT. The wslDistroOverride parameter is the hook for + // wiring a future setting in without re-plumbing the runner. + const cwdWsl = cwd ? parseWslPath(cwd) : null + const wsl: WslPathInfo | null = + cwdWsl ?? (wslDistroOverride ? { distro: wslDistroOverride, linuxPath: '' } : null) if (!wsl) { return { binary: command, args, cwd, wsl: null } } @@ -86,11 +93,14 @@ function resolveCommand( // inside the bash -c string. Single quotes are safe for all chars except // single quotes themselves, which we escape as '\'' (end quote, escaped // literal, reopen quote). - const escapedArgs = translatedArgs.map( - (a) => `'${a.replace(/'/g, "'\\''")}'` - ) - const escapedCwd = wsl.linuxPath.replace(/'/g, "'\\''") - const shellCmd = `cd '${escapedCwd}' && ${command} ${escapedArgs.join(' ')}` + const escapedArgs = translatedArgs.map((a) => `'${a.replace(/'/g, "'\\''")}'`) + // Why: when cwd is supplied as a WSL UNC path, prepend `cd &&` + // so the command runs in the expected directory. When the caller only + // supplied a distro override (no cwd), skip the cd entirely — the gh CLI + // doesn't need a particular cwd for global calls like `api rate_limit`. + const shellCmd = cwdWsl + ? `cd '${cwdWsl.linuxPath.replace(/'/g, "'\\''")}' && ${command} ${escapedArgs.join(' ')}` + : `${command} ${escapedArgs.join(' ')}` return { binary: 'wsl.exe', @@ -175,10 +185,7 @@ export function gitExecFileSync( * Spawn a git child process. Drop-in replacement for * `spawn('git', args, { cwd, stdio, ... })`. */ -export function gitSpawn( - args: string[], - options: SpawnOptions & { cwd: string } -): ChildProcess { +export function gitSpawn(args: string[], options: SpawnOptions & { cwd: string }): ChildProcess { const resolved = resolveCommand('git', args, options.cwd) return spawn(resolved.binary, resolved.args, { ...options, @@ -188,23 +195,246 @@ export function gitSpawn( // ─── gh CLI runners ───────────────────────────────────────────────── +// Why: non-repo-scoped gh calls (listAccessibleProjects, rate_limit, etc.) +// have no meaningful cwd. Allow it to be omitted so the one WSL-aware wrapper +// serves both repo-scoped and global callers and we stop having two spawn +// sites (the other one — a plain execFileAsync in project-view.ts — bypasses +// retry/backoff and any future quota tracker). +// Why: `wslDistro` is an explicit hint for global (cwd-less) gh callers on +// WSL-only Windows installs where gh.exe isn't on the host PATH. When set, +// resolveCommand routes the spawn through `wsl.exe -d -- gh ...` +// even without a UNC cwd to parse a distro from. Repo-scoped callers should +// keep using cwd — the distro derives from the path automatically there. +// Why: `idempotent` gates the transient-error retry. When undefined we +// auto-detect from argv (writes are detected by `-X POST/PATCH/PUT/DELETE` +// or a `query=mutation …` arg); callers can also pass an explicit override. +// A 5xx/socket reset after the request reaches GitHub but before the +// response returns is the canonical case where the server-side write +// succeeded; retrying would create a duplicate comment/issue/label addition. +// See bug-scan finding 1. +type GhExecOptions = Omit & { + cwd?: string + wslDistro?: string + idempotent?: boolean +} + +const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']) +// `gh ` write subcommands. Reads (view/list/status/checks) +// are absent on purpose so the default of "retry" stays for them. +const NON_IDEMPOTENT_GH_VERBS = new Set([ + 'create', + 'edit', + 'delete', + 'close', + 'reopen', + 'merge', + 'comment', + 'review', + 'ready', + 'lock', + 'unlock', + 'pin', + 'unpin', + 'transfer', + 'develop' +]) + +function argsLookIdempotent(args: string[]): boolean { + for (let i = 0; i < args.length; i++) { + const a = args[i] + if (a === '-X' || a === '--method') { + const next = args[i + 1] + if (typeof next === 'string' && NON_IDEMPOTENT_METHODS.has(next.toUpperCase())) { + return false + } + } + // `gh api graphql -f query=mutation(...){ ... }` — detect mutation queries + // so writes via the GraphQL endpoint also fail fast on transient errors. + if (a.startsWith('query=')) { + const trimmed = a.slice('query='.length).trimStart().toLowerCase() + if (trimmed.startsWith('mutation')) { + return false + } + } + } + // `gh issue close`, `gh pr edit`, `gh pr merge`, etc. The first arg is the + // noun (issue/pr/repo/label/...) and the second is the verb. Defaulting + // `gh api` calls without an explicit -X to GET-equivalent (idempotent) is + // intentional: callers that POST through `gh api` set `-X POST`. + if (args.length >= 2 && args[0] !== 'api') { + if (NON_IDEMPOTENT_GH_VERBS.has(args[1])) { + return false + } + } + return true +} + +/** + * Extract stderr from an execFile rejection. + * + * Why: Node's execFile rejects with an Error that has `.stdout` and `.stderr` + * fields populated separately from `.message`. Reading `err.message` alone is + * unreliable — it can truncate stderr or omit it entirely depending on Node + * version and maxBuffer behavior. We prefer the explicit fields and fall + * back to `.message` only when neither is present. + */ +export function extractExecError(err: unknown): { stderr: string; stdout: string } { + if (err && typeof err === 'object') { + const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown } + const stderr = + typeof e.stderr === 'string' + ? e.stderr + : Buffer.isBuffer(e.stderr) + ? e.stderr.toString('utf-8') + : '' + const stdout = + typeof e.stdout === 'string' + ? e.stdout + : Buffer.isBuffer(e.stdout) + ? e.stdout.toString('utf-8') + : '' + if (stderr || stdout) { + return { stderr, stdout } + } + if (typeof e.message === 'string') { + return { stderr: e.message, stdout: '' } + } + } + return { stderr: String(err), stdout: '' } +} + +/** + * Detect a Retry-After hint in gh stderr and return the suggested delay in ms, + * or null when the response includes no Retry-After. + * + * Why: gh forwards response headers when verbose, and prints "Retry-After: + * " in error output for primary rate-limit 429s. When present, the + * caller is better served by propagating the error so the UI can surface the + * real wait time — retrying on our own 250ms cadence just earns another 429 + * and burns the retry budget. Also supports HTTP-date Retry-After values. + */ +export function parseRetryAfterMs(stderr: string): number | null { + const m = stderr.match(/retry-after:\s*([^\r\n]+)/i) + if (!m) { + return null + } + const raw = m[1].trim() + if (/^\d+$/.test(raw)) { + const seconds = Number(raw) + return Number.isFinite(seconds) ? seconds * 1000 : null + } + const ts = Date.parse(raw) + if (Number.isNaN(ts)) { + return null + } + return Math.max(0, ts - Date.now()) +} + +/** + * Classify whether a gh execFile rejection is worth retrying. + * + * Why: gh surfaces HTTP status in stderr as "HTTP 504", "HTTP 502", etc. + * Network resets and DNS hiccups also show up as stderr substrings. We retry + * those and 429 (rate-limited) — but only 429s without an explicit + * Retry-After (the caller is better off propagating so the UI can show the + * actual wait time). The primary-rate-limit 403 branch is NOT retried: those + * require the user to back off for minutes, which is not transient. + */ +export function isTransientGhError(stderr: string): boolean { + const s = stderr.toLowerCase() + if ( + s.includes('http 500') || + s.includes('http 502') || + s.includes('http 503') || + s.includes('http 504') || + s.includes('econnreset') || + s.includes('etimedout') || + s.includes('socket hang up') + ) { + return true + } + // 429 without Retry-After: retry. With Retry-After: propagate. + if (s.includes('http 429')) { + return parseRetryAfterMs(stderr) === null + } + return false +} + +// Why: total of 3 attempts (original + 2 retries) with 250ms → 1s backoff. +// These are standard "transient 5xx" values. Longer waits push past user +// patience for an interactive action; shorter waits would hammer the same +// unhealthy upstream that just failed. The array length defines retry count; +// total attempts = length + 1. +const GH_RETRY_DELAYS_MS = [250, 1000] as const + +// Why: the upstream Retry-After header is server-suggested but unbounded — +// GitHub has been observed to send tens-of-seconds values on rare incidents, +// and a malicious or misconfigured proxy could send anything. Cap the wait +// at 30s so a single transient gh call can never block the IPC main thread +// for longer than the user's patience budget for an interactive action. +const GH_RETRY_AFTER_MAX_MS = 30_000 + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + /** * Async gh CLI execution. Drop-in replacement for * `execFileAsync('gh', args, { cwd, encoding, ... })`. + * + * Retries transient 5xx / 429 (without Retry-After) / network-reset failures + * with exponential backoff. Non-transient errors (auth, 404, rate-limit 403, + * validation, 429-with-Retry-After) fail fast on the first attempt. */ export async function ghExecFileAsync( args: string[], - options: GitExecOptions + options: GhExecOptions = {} ): Promise<{ stdout: string; stderr: string }> { - const resolved = resolveCommand('gh', args, options.cwd) - const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { - cwd: resolved.cwd, - encoding: (options.encoding ?? 'utf-8') as BufferEncoding, - maxBuffer: options.maxBuffer, - timeout: options.timeout, - env: options.env - }) - return { stdout: stdout as string, stderr: stderr as string } + const resolved = resolveCommand('gh', args, options.cwd, options.wslDistro) + let lastError: unknown + for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) { + try { + const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: (options.encoding ?? 'utf-8') as BufferEncoding, + maxBuffer: options.maxBuffer, + timeout: options.timeout, + env: options.env + }) + return { stdout: stdout as string, stderr: stderr as string } + } catch (err) { + lastError = err + const { stderr } = extractExecError(err) + const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length + // Why: only retry idempotent calls. A 5xx/socket reset can arrive + // after the server already applied a POST/PATCH/PUT/DELETE; retrying + // would duplicate the write (e.g. double-post a comment, double-add + // a label). When the caller doesn't say, we auto-detect from argv — + // explicit `-X ` and GraphQL `query=mutation …` are treated + // as non-idempotent. See bug-scan finding 1. + const idempotent = options.idempotent ?? argsLookIdempotent(args) + if (idempotent && !isLastAttempt && isTransientGhError(stderr)) { + // Why: when the upstream surfaced a Retry-After (e.g. on a transient + // 5xx that GitHub explicitly recommends backing off for), honor it + // instead of using our default backoff — sleeping less than the + // server suggests just earns another failure and burns our retry + // budget. Cap at GH_RETRY_AFTER_MAX_MS so a pathologically large + // hint can't block IPC for minutes; if the real wait is longer, the + // attempt will fail again and the error will propagate to the UI + // where the user can see it. + const retryAfterMs = parseRetryAfterMs(stderr) + const delayMs = + retryAfterMs !== null + ? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS) + : GH_RETRY_DELAYS_MS[attempt] + await sleep(delayMs) + continue + } + throw err + } + } + // Unreachable: the loop either returns or throws. Here for TS exhaustiveness. + throw lastError } // ─── Generic command runner (for rg, etc.) ────────────────────────── @@ -234,10 +464,7 @@ export function wslAwareSpawn( * are Linux-native (/home/user/repo). The rest of Orca needs Windows UNC * paths (\\wsl.localhost\Ubuntu\home\user\repo) to read files via Node fs. */ -export function translateWslOutputPaths( - output: string, - originalCwd: string -): string { +export function translateWslOutputPaths(output: string, originalCwd: string): string { const wsl = parseWslPath(originalCwd) if (!wsl) { return output @@ -245,9 +472,8 @@ export function translateWslOutputPaths( // Replace absolute Linux paths that start with / and look like filesystem // paths in structured git output (e.g. "worktree /home/user/repo/feature") - return output.replace( - /(?<=worktree )(\/.+)$/gm, - (_match, linuxPath: string) => toWindowsWslPath(linuxPath, wsl.distro) + return output.replace(/(?<=worktree )(\/.+)$/gm, (_match, linuxPath: string) => + toWindowsWslPath(linuxPath, wsl.distro) ) } diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 4e62e94f07b..f05c510bdf7 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -268,7 +268,7 @@ export async function detectConflictOperation(worktreePath: string): Promise { +export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') try { @@ -290,7 +290,8 @@ async function resolveGitDir(worktreePath: string): Promise { export async function getDiff( worktreePath: string, filePath: string, - staged: boolean + staged: boolean, + compareAgainstHead = false ): Promise { let originalContent = '' let modifiedContent = '' @@ -300,7 +301,9 @@ export async function getDiff( try { const leftBlob = staged ? await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath) - : await readUnstagedLeftBlob(worktreePath, filePath) + : compareAgainstHead + ? await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath) + : await readUnstagedLeftBlob(worktreePath, filePath) originalContent = leftBlob.content originalIsBinary = leftBlob.isBinary @@ -656,6 +659,34 @@ export async function unstageFile(worktreePath: string, filePath: string): Promi await gitExecFileAsync(['restore', '--staged', '--', filePath], { cwd: worktreePath }) } +export async function commitChanges( + worktreePath: string, + message: string +): Promise<{ success: boolean; error?: string }> { + try { + await gitExecFileAsync(['commit', '-m', message], { cwd: worktreePath }) + return { success: true } + } catch (error) { + // Why: surface whichever channel carries the useful message. Pre-commit/GPG + // hook failures write to stderr; "nothing to commit, working tree clean" + // writes to stdout. Try stderr first, fall back to stdout, then error.message. + const readStringField = (field: string): string | null => { + if (typeof error === 'object' && error && field in error) { + const v = (error as Record)[field] + if (typeof v === 'string' && v.length > 0) { + return v + } + } + return null + } + const errorMessage = + readStringField('stderr') ?? + readStringField('stdout') ?? + (error instanceof Error ? error.message : 'Commit failed') + return { success: false, error: errorMessage } + } +} + /** * Discard working tree changes for a file. */ diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index c109d168d69..ff6bc69fd83 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -1,6 +1,18 @@ -import { posix, win32 } from 'path' +import { stat } from 'fs/promises' +import { join, posix, win32 } from 'path' import type { GitWorktreeInfo } from '../../shared/types' import { gitExecFileAsync, translateWslOutputPaths } from './runner' +import { resolveGitDir } from './status' + +type SparseWorktreeCreateError = Error & { + cleanupFailed?: boolean +} + +function getErrorCode(error: unknown): string | undefined { + return typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code?: unknown }).code) + : undefined +} function normalizeLocalBranchRef(branch: string): string { return branch.replace(/^refs\/heads\//, '') @@ -29,21 +41,21 @@ function looksLikeWindowsPath(pathValue: string): boolean { */ export function parseWorktreeList(output: string): GitWorktreeInfo[] { const worktrees: GitWorktreeInfo[] = [] - // [Fix]: Use /\r?\n\r?\n/ to handle both LF and CRLF (\r\n) line endings, - // which are common when running git on Windows. - const blocks = output.trim().split(/\r?\n\r?\n/) + const blocks = output + .trim() + .split(/\r?\n\r?\n/) + .map((block) => block.trim().split(/\r?\n/)) - for (const block of blocks) { - if (!block.trim()) { + for (const lines of blocks) { + if (lines.length === 0) { continue } - // [Fix]: Use /\r?\n/ to handle both LF and CRLF (\r\n) line endings. - const lines = block.trim().split(/\r?\n/) let path = '' let head = '' let branch = '' let isBare = false + let isSparse = false for (const line of lines) { if (line.startsWith('worktree ')) { @@ -54,12 +66,21 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] { branch = line.slice('branch '.length) } else if (line === 'bare') { isBare = true + } else if (line === 'sparse') { + isSparse = true } } if (path) { // `git worktree list` always emits the main working tree first. - worktrees.push({ path, head, branch, isBare, isMainWorktree: worktrees.length === 0 }) + worktrees.push({ + path, + head, + branch, + isBare, + ...(isSparse ? { isSparse } : {}), + isMainWorktree: worktrees.length === 0 + }) } } @@ -71,15 +92,40 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] { */ export async function listWorktrees(repoPath: string): Promise { try { + // Why: do not pass `-z` here. `-z` requires Git ≥ 2.36; older Git rejects + // it, listWorktrees returns [], and every create flow throws "Worktree + // created but not found in listing" (issue #1453). const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], { cwd: repoPath }) - // Why: when git runs inside WSL, worktree paths are Linux-native - // (e.g. /home/user/repo). Translate them back to Windows UNC paths - // so the rest of Orca can access them via Node fs APIs. - const translated = translateWslOutputPaths(stdout, repoPath) - return parseWorktreeList(translated) - } catch { + const worktrees = parseWorktreeList(stdout).map((worktree) => { + const translatedPath = translateWorktreePath(worktree.path, repoPath) + return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath } + }) + return Promise.all( + worktrees.map(async (worktree) => { + if (worktree.isBare || worktree.isSparse) { + return worktree + } + const isSparse = await detectSparseCheckout(worktree.path) + return isSparse ? { ...worktree, isSparse } : worktree + }) + ) + } catch (err) { + if (getErrorCode(err) === 'ENOENT') { + try { + await stat(repoPath) + } catch (statErr) { + if (getErrorCode(statErr) === 'ENOENT') { + console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`) + return [] + } + } + } + // Why: a silent catch turned issue #1453's underlying + // "git: unknown switch -z" into the opaque "not found in listing" toast. + // Surface the cause so future regressions show up immediately. + console.warn(`[git/worktree] listWorktrees failed for ${repoPath}:`, err) return [] } } @@ -96,7 +142,8 @@ export async function addWorktree( worktreePath: string, branch: string, baseBranch?: string, - refreshLocalBaseRef = false + refreshLocalBaseRef = false, + noCheckout = false ): Promise { // Why: Some users want Orca-created worktrees to make plain commands like // `git diff main...HEAD` work out of the box, while others do not want @@ -152,13 +199,49 @@ export async function addWorktree( } } - const args = ['worktree', 'add', '-b', branch, worktreePath] + const args = ['worktree', 'add'] + if (noCheckout) { + args.push('--no-checkout') + } + args.push('-b', branch, worktreePath) if (baseBranch) { args.push(baseBranch) } await gitExecFileAsync(args, { cwd: repoPath }) } +export async function addSparseWorktree( + repoPath: string, + worktreePath: string, + branch: string, + directories: string[], + baseBranch?: string, + refreshLocalBaseRef = false +): Promise { + let created = false + try { + await addWorktree(repoPath, worktreePath, branch, baseBranch, refreshLocalBaseRef, true) + created = true + await gitExecFileAsync(['sparse-checkout', 'init', '--cone'], { cwd: worktreePath }) + await gitExecFileAsync(['sparse-checkout', 'set', '--', ...directories], { cwd: worktreePath }) + await gitExecFileAsync(['checkout', branch], { cwd: worktreePath }) + } catch (error) { + const wrapped: SparseWorktreeCreateError = + error instanceof Error ? (error as SparseWorktreeCreateError) : new Error(String(error)) + if (created) { + try { + await removeWorktree(repoPath, worktreePath, true) + } catch { + wrapped.cleanupFailed = true + // Why: the user needs to know that manual cleanup may be required — + // otherwise a half-created worktree silently lingers on disk. + wrapped.message = `${wrapped.message} (cleanup also failed — the partially created worktree at "${worktreePath}" may need manual removal)` + } + } + throw wrapped + } +} + /** * Remove a worktree. */ @@ -208,3 +291,35 @@ export async function removeWorktree( ) } } + +function translateWorktreePath(worktreePath: string, repoPath: string): string { + const prefix = 'worktree ' + const translated = translateWslOutputPaths(`${prefix}${worktreePath}`, repoPath) + return translated.startsWith(prefix) ? translated.slice(prefix.length) : worktreePath +} + +async function detectSparseCheckout(worktreePath: string): Promise { + // Why: `listWorktrees` runs on every 3-second git-status poll and on every + // worktree refresh, so this probe fires N times per poll for N worktrees. + // The previous `git sparse-checkout list` subprocess made that N*poll extra + // git processes, which regressed app responsiveness on machines with many + // worktrees (see PR #1131 revert in #1290). A single fs.stat on the + // per-worktree sparse-checkout config file is ~two orders of magnitude + // cheaper and has the same truthiness semantics: Git writes this file when + // sparse checkout is enabled for the worktree and does not write it + // otherwise. + // + // Why per-worktree gitdir and not `/.git/info/sparse-checkout`: + // linked worktrees have a `.git` file that points at + // `/.git/worktrees/`, and that is where Git stores the + // worktree-local sparse-checkout config. `core.sparseCheckout` itself is + // shared across all worktrees, so the presence of the config file is the + // correct per-worktree signal. + try { + const gitDir = await resolveGitDir(worktreePath) + const stats = await stat(join(gitDir, 'info', 'sparse-checkout')) + return stats.isFile() && stats.size > 0 + } catch { + return false + } +} diff --git a/src/main/github/auth-diagnose.test.ts b/src/main/github/auth-diagnose.test.ts new file mode 100644 index 00000000000..35e3775b478 --- /dev/null +++ b/src/main/github/auth-diagnose.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { parseAuthStatus } from './auth-diagnose' + +describe('parseAuthStatus', () => { + it('parses an env-shadowed login alongside a keyring login (real gh output)', () => { + const text = `github.com + ✓ Logged in to github.com account nwparker (GITHUB_TOKEN) + - Active account: true + - Git operations protocol: https + - Token: gho_************************************ + - Token scopes: 'gist', 'read:org', 'repo', 'workflow' + + ✓ Logged in to github.com account nwparker (keyring) + - Active account: false + - Git operations protocol: https + - Token: gho_************************************ + - Token scopes: 'gist', 'read:org', 'repo', 'workflow' +` + const accounts = parseAuthStatus(text) + expect(accounts).toHaveLength(2) + expect(accounts[0]).toMatchObject({ + host: 'github.com', + user: 'nwparker', + active: true, + envToken: 'GITHUB_TOKEN', + source: 'env' + }) + expect(accounts[0].scopes).toEqual(['gist', 'read:org', 'repo', 'workflow']) + expect(accounts[1]).toMatchObject({ + active: false, + envToken: null, + source: 'keyring' + }) + }) + + it('parses a single keyring login', () => { + const text = `github.com + ✓ Logged in to github.com account alice (keyring) + - Active account: true + - Token scopes: 'project', 'read:org', 'repo' +` + const accounts = parseAuthStatus(text) + expect(accounts).toHaveLength(1) + expect(accounts[0]).toMatchObject({ + user: 'alice', + active: true, + envToken: null, + source: 'keyring' + }) + expect(accounts[0].scopes).toContain('project') + }) + + it('detects GH_TOKEN env source', () => { + const text = `github.com + ✓ Logged in to github.com account bot (GH_TOKEN) + - Active account: true + - Token scopes: 'repo' +` + const [acc] = parseAuthStatus(text) + expect(acc.envToken).toBe('GH_TOKEN') + expect(acc.source).toBe('env') + }) + + it('returns empty array when nothing is logged in', () => { + expect(parseAuthStatus('You are not logged into any GitHub hosts.')).toEqual([]) + }) + + it('parses multiple hosts in one output (github.com + GHES)', () => { + const text = `github.com + ✓ Logged in to github.com account alice (keyring) + - Active account: true + - Token scopes: 'read:org', 'repo' + +ghe.acme.io + ✓ Logged in to ghe.acme.io account bob (keyring) + - Active account: true + - Token scopes: 'project', 'repo' +` + const accounts = parseAuthStatus(text) + expect(accounts.map((a) => a.host)).toEqual(['github.com', 'ghe.acme.io']) + expect(accounts.map((a) => a.user)).toEqual(['alice', 'bob']) + expect(accounts[1].scopes).toContain('project') + }) + + it('recovers host from the Logged-in line when the section header is missing', () => { + // gh prints a colon after the host on some versions; we tolerate it, + // but if the regex ever fails to match the header we still want + // accounts attributed to the host from the inline message. + const text = ` ✓ Logged in to github.acme.io account carol (keyring) + - Active account: true + - Token scopes: 'project' +` + const accounts = parseAuthStatus(text) + expect(accounts).toHaveLength(1) + expect(accounts[0].host).toBe('github.acme.io') + }) +}) diff --git a/src/main/github/auth-diagnose.ts b/src/main/github/auth-diagnose.ts new file mode 100644 index 00000000000..b356bf7b68e --- /dev/null +++ b/src/main/github/auth-diagnose.ts @@ -0,0 +1,142 @@ +/** + * gh CLI auth diagnostics. + * + * Why: when project queries fail with "missing scope", the canned + * remediation `gh auth refresh -s project ...` silently no-ops if the user + * has `GITHUB_TOKEN` (or `GH_TOKEN`) exported in their shell — gh prefers + * env tokens over keyring credentials and refuses to refresh env-supplied + * tokens. Users follow the instructions, see no error, retry, and stay + * stuck. This probe makes that failure mode legible in the UI. + * + * Output is parsed from `gh auth status`, which prints free-form text but + * uses stable field labels ("Token scopes:", "(GITHUB_TOKEN)", etc.). + */ +import { ghExecFileAsync } from '../git/runner' +import type { GhAuthDiagnostic, GhAuthAccount } from '../../shared/github-auth-types' + +// Required scopes for ProjectV2 GraphQL access in Orca. `project` is the +// scope that gates ProjectV2 reads/writes; the others are needed for the +// surrounding repo/org queries we already run. +const REQUIRED_SCOPES = ['project', 'read:org', 'repo'] as const + +/** + * Parse `gh auth status` stderr/stdout. gh writes to stderr by default but + * has used stdout in some versions; we accept either. Format (per host): + * + * github.com + * ✓ Logged in to github.com account NAME (GITHUB_TOKEN) + * - Active account: true + * - Token scopes: 'gist', 'read:org', 'repo' + */ +export function parseAuthStatus(text: string): GhAuthAccount[] { + const accounts: GhAuthAccount[] = [] + let currentHost: string | null = null + let current: GhAuthAccount | null = null + for (const rawLine of text.split('\n')) { + const line = rawLine.replace(/\r$/, '') + // Host header: a non-indented hostname token, with an optional + // trailing colon some gh versions emit. Permits single-label hostnames + // (internal GHES like `github` or `ghe-internal`); we also recover the + // host from the `Logged in to ` line below if this header was + // missed, so a parser miss never silently drops every account. + const hostMatch = line.match(/^([a-z0-9][a-z0-9.-]*)\s*:?\s*$/i) + if (hostMatch && !/^logged\b/i.test(line)) { + currentHost = hostMatch[1] + continue + } + const loggedIn = line.match(/Logged in to (\S+) account (\S+)(?:\s+\(([^)]+)\))?/i) + if (loggedIn) { + if (current) { + accounts.push(current) + } + // Prefer the host from the `Logged in to ` line itself — it's + // always present, whereas the section header above can be skipped + // by the regex on unfamiliar gh output. + const host = loggedIn[1] || currentHost || 'github.com' + const sourceLabel = (loggedIn[3] ?? '').trim() + // gh emits "(keyring)" for stored creds and "(GITHUB_TOKEN)" / + // "(GH_TOKEN)" when an env var is shadowing the keyring. + const envToken = + sourceLabel === 'GITHUB_TOKEN' || sourceLabel === 'GH_TOKEN' ? sourceLabel : null + current = { + host, + user: loggedIn[2], + active: false, + envToken, + source: envToken ? 'env' : 'keyring', + scopes: [] + } + continue + } + if (!current) { + continue + } + const activeMatch = line.match(/Active account:\s*(true|false)/i) + if (activeMatch) { + current.active = activeMatch[1].toLowerCase() === 'true' + continue + } + const scopesMatch = line.match(/Token scopes:\s*(.+)$/i) + if (scopesMatch) { + current.scopes = scopesMatch[1] + .split(',') + .map((s) => s.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean) + } + } + if (current) { + accounts.push(current) + } + return accounts +} + +export async function diagnoseGhAuth(): Promise { + let raw = '' + let ghAvailable = true + try { + // `gh auth status` exits non-zero when no host is logged in but still + // prints the same diagnostic text we want, so capture both streams. + const { stdout, stderr } = await ghExecFileAsync(['auth', 'status']) + raw = `${stdout}\n${stderr}` + } catch (err) { + const stderr = + err && typeof err === 'object' && 'stderr' in err + ? String((err as { stderr?: unknown }).stderr ?? '') + : '' + const stdout = + err && typeof err === 'object' && 'stdout' in err + ? String((err as { stdout?: unknown }).stdout ?? '') + : '' + raw = `${stdout}\n${stderr}` + if (!raw.trim()) { + const message = err instanceof Error ? err.message : String(err) + // Most likely cause: gh CLI not installed or not on PATH. + if (/ENOENT|not found|command not found/i.test(message)) { + ghAvailable = false + } + raw = message + } + } + const accounts = parseAuthStatus(raw) + const active = accounts.find((a) => a.active) ?? accounts[0] ?? null + const envTokenInProcess: 'GITHUB_TOKEN' | 'GH_TOKEN' | null = process.env.GH_TOKEN + ? 'GH_TOKEN' + : process.env.GITHUB_TOKEN + ? 'GITHUB_TOKEN' + : null + const missingScopes = active + ? REQUIRED_SCOPES.filter((s) => !active.scopes.includes(s)) + : [...REQUIRED_SCOPES] + // Is there a non-env (keyring) account we could fall back to by unsetting + // the env var? Only meaningful if the active account is env-shadowed. + const keyringFallback = accounts.find((a) => a.source === 'keyring') ?? null + return { + ghAvailable, + activeAccount: active, + accounts, + envTokenInProcess, + missingScopes, + requiredScopes: [...REQUIRED_SCOPES], + hasKeyringFallback: Boolean(keyringFallback && keyringFallback !== active) + } +} diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts new file mode 100644 index 00000000000..051f9ffebc6 --- /dev/null +++ b/src/main/github/client-issue-source.test.ts @@ -0,0 +1,482 @@ +/* eslint-disable max-lines -- Why: the issue-source test suite covers the +heuristic split (#1076), the partial-failure envelope (feature 1), and the +three-state preference matrix (feature 2) as one surface so a regression in +any of them blocks the same merge gate. */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GhUtils from './gh-utils' + +const { + execFileAsyncMock, + ghExecFileAsyncMock, + getOwnerRepoMock, + getIssueOwnerRepoMock, + getOwnerRepoForRemoteMock, + resolveIssueSourceMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + execFileAsyncMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getIssueOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + resolveIssueSourceMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gh-utils', async () => { + const actual = await vi.importActual('./gh-utils') + return { + ...actual, + execFileAsync: execFileAsyncMock, + ghExecFileAsync: ghExecFileAsyncMock, + getOwnerRepo: getOwnerRepoMock, + getIssueOwnerRepo: getIssueOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoForRemoteMock, + resolveIssueSource: resolveIssueSourceMock, + acquire: acquireMock, + release: releaseMock, + _resetOwnerRepoCache: vi.fn() + } +}) + +import { countWorkItems, getWorkItem, listWorkItems, _resetOwnerRepoCache } from './client' + +describe('GitHub issue source split', () => { + beforeEach(() => { + execFileAsyncMock.mockReset() + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getIssueOwnerRepoMock.mockReset() + getOwnerRepoForRemoteMock.mockReset() + resolveIssueSourceMock.mockReset() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + // Why: default the preference-aware resolver to 'auto' semantics so the + // pre-existing test cases (which don't think about preference at all) + // still pass. `listWorkItems` now calls `resolveIssueSource` instead of + // `getIssueOwnerRepo` directly — we delegate back to the single-call + // mock to preserve the one-fetch-per-test invariant each test sets up. + resolveIssueSourceMock.mockImplementation(async () => ({ + source: await getIssueOwnerRepoMock(), + fellBack: false + })) + // Default the upstream-candidate lookup to null so existing tests that + // only mock `getIssueOwnerRepo` + `getOwnerRepo` don't need to think + // about it. Tests that care set it explicitly. + getOwnerRepoForRemoteMock.mockResolvedValue(null) + _resetOwnerRepoCache() + }) + + it('uses upstream for issues and origin for PRs in mixed recent results', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 923, + title: 'Use upstream issues', + state: 'open', + html_url: 'https://github.com/stablyai/orca/issues/923', + labels: [], + updated_at: '2026-04-01T00:00:00Z', + user: { login: 'octocat' } + } + ]) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 42, + title: 'Fork PR', + state: 'open', + html_url: 'https://github.com/fork/orca/pull/42', + labels: [], + updated_at: '2026-03-31T00:00:00Z', + user: { login: 'octocat' }, + draft: false, + head: { ref: 'feature' }, + base: { ref: 'main' } + } + ]) + }) + + await listWorkItems('/repo-root', 10) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + [ + 'api', + '--cache', + '120s', + 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc' + ], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + [ + 'api', + '--cache', + '120s', + 'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc' + ], + { cwd: '/repo-root' } + ) + }) + + it('uses upstream for issue-only queries and origin for PR-only queries', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo-root', 10, 'is:issue') + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['--repo', 'stablyai/orca']), + { cwd: '/repo-root' } + ) + + ghExecFileAsyncMock.mockClear() + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo-root', 10, 'is:pr') + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['--repo', 'fork/orca']), + { cwd: '/repo-root' } + ) + }) + + it('counts default work items across upstream issues and origin PRs', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: '7\n' }) + .mockResolvedValueOnce({ stdout: '5\n' }) + + const count = await countWorkItems('/repo-root') + + expect(count).toBe(12) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + [ + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent('repo:stablyai/orca is:issue is:open')}&per_page=1`, + '--jq', + '.total_count' + ], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + [ + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent('repo:fork/orca is:pull-request is:open')}&per_page=1`, + '--jq', + '.total_count' + ], + { cwd: '/repo-root' } + ) + }) + + it('typed PR lookup does not fetch an upstream issue with the same number', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 42, + title: 'Origin PR', + state: 'open', + html_url: 'https://github.com/fork/orca/pull/42', + labels: [], + updated_at: '2026-04-02T00:00:00Z', + user: { login: 'octocat' }, + draft: false, + head: { ref: 'feature' }, + base: { ref: 'main' } + }) + }) + + const item = await getWorkItem('/repo-root', 42, 'pr') + + expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'repos/fork/orca/pulls/42'], { + cwd: '/repo-root' + }) + expect(item?.type).toBe('pr') + }) + + it('raw number lookup tries upstream issue before origin PR', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + // Why: simulate a real gh 404 (the only error type that should fall through). + // Non-404 errors re-throw so transient upstream failures don't misroute to an + // unrelated origin PR with the same number. + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404: Not Found')) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 42, + title: 'Origin PR', + state: 'open', + html_url: 'https://github.com/fork/orca/pull/42', + labels: [], + updated_at: '2026-04-02T00:00:00Z', + user: { login: 'octocat' }, + draft: false + }) + }) + + const item = await getWorkItem('/repo-root', 42) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + ['api', 'repos/stablyai/orca/issues/42'], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/fork/orca/pulls/42'], { + cwd: '/repo-root' + }) + expect(item?.type).toBe('pr') + }) + + it('surfaces a 403 from upstream issues through the listWorkItems envelope', async () => { + // Why: parent design doc §3 / acceptance criterion 2 — the IPC envelope + // must carry a classified error for the failing side so the renderer can + // swap the empty-state for a retryable banner. `sources` must stay + // populated so the banner copy can name the repo that failed. + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('HTTP 403: Resource not accessible by integration')) + .mockResolvedValueOnce({ stdout: '[]' }) + + const result = await listWorkItems('/repo-root', 10) + + expect(result.items).toEqual([]) + expect(result.sources).toMatchObject({ + issues: { owner: 'stablyai', repo: 'orca' }, + prs: { owner: 'fork', repo: 'orca' } + }) + expect(result.errors?.issues?.type).toBe('permission_denied') + }) + + it('returns partial results when upstream issues fail but origin PRs succeed', async () => { + // Why: parent design doc §2 partial-failure rule — a failing source must + // not zero out the succeeding source. The UI renders origin PRs with a + // banner above the list, not an empty state. Ensures the IPC shape + // carries both the successful items and the error for the failing side. + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('HTTP 403: Resource not accessible by integration')) + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 42, + title: 'Fork PR', + state: 'open', + html_url: 'https://github.com/fork/orca/pull/42', + labels: [], + updated_at: '2026-03-31T00:00:00Z', + user: { login: 'octocat' }, + draft: false, + head: { ref: 'feature' }, + base: { ref: 'main' } + } + ]) + }) + + const result = await listWorkItems('/repo-root', 10) + + expect(result.items.map((i) => i.id)).toEqual(['pr:42']) + expect(result.errors?.issues?.type).toBe('permission_denied') + }) + + it('raw number lookup does not fall through on transient upstream errors', async () => { + // Why: with issue source split, a non-404 upstream failure must not silently + // route to origin's PR #N — that would return an unrelated item. + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 500: server error')) + + const item = await getWorkItem('/repo-root', 42) + + expect(item).toBeNull() + expect(getOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + describe('per-repo issue-source preference', () => { + // Why: 3 preference states × 2 remote-topology states = 6 cases per the + // design doc §9. These tests isolate `listWorkItems` against a mocked + // `resolveIssueSource` to verify the preference is threaded all the way + // to the gh call and that `fellBack` propagates into the envelope. + + it("preference='auto' + upstream exists → queries upstream", async () => { + resolveIssueSourceMock.mockResolvedValueOnce({ + source: { owner: 'stablyai', repo: 'orca' }, + fellBack: false + }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ + stdout: '[]' + }) + + const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'auto') + + expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'auto') + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + [ + 'api', + '--cache', + '120s', + 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc' + ], + { cwd: '/repo-root' } + ) + expect(result.issueSourceFellBack).toBeUndefined() + }) + + it("preference='auto' + no upstream → queries origin", async () => { + resolveIssueSourceMock.mockResolvedValueOnce({ + source: { owner: 'solo', repo: 'orca' }, + fellBack: false + }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'solo', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ + stdout: '[]' + }) + + await listWorkItems('/repo-root', 10, undefined, undefined, 'auto') + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + [ + 'api', + '--cache', + '120s', + 'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc' + ], + { cwd: '/repo-root' } + ) + }) + + it("preference='upstream' + upstream exists → queries upstream", async () => { + resolveIssueSourceMock.mockResolvedValueOnce({ + source: { owner: 'stablyai', repo: 'orca' }, + fellBack: false + }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ + stdout: '[]' + }) + + const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream') + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc' + ]), + { cwd: '/repo-root' } + ) + expect(result.issueSourceFellBack).toBeUndefined() + }) + + it("preference='upstream' + no upstream → falls back to origin with fellBack=true", async () => { + resolveIssueSourceMock.mockResolvedValueOnce({ + source: { owner: 'solo', repo: 'orca' }, + fellBack: true + }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'solo', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ + stdout: '[]' + }) + + const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream') + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + 'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc' + ]), + { cwd: '/repo-root' } + ) + expect(result.issueSourceFellBack).toBe(true) + }) + + it("preference='origin' + upstream exists → queries origin (not upstream)", async () => { + resolveIssueSourceMock.mockResolvedValueOnce({ + source: { owner: 'fork', repo: 'orca' }, + fellBack: false + }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ + stdout: '[]' + }) + + await listWorkItems('/repo-root', 10, undefined, undefined, 'origin') + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + 'repos/fork/orca/issues?per_page=10&state=open&sort=updated&direction=desc' + ]), + { cwd: '/repo-root' } + ) + }) + + it("preference='origin' + no upstream → queries origin", async () => { + resolveIssueSourceMock.mockResolvedValueOnce({ + source: { owner: 'solo', repo: 'orca' }, + fellBack: false + }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'solo', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ + stdout: '[]' + }) + + await listWorkItems('/repo-root', 10, undefined, undefined, 'origin') + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + 'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc' + ]), + { cwd: '/repo-root' } + ) + }) + + it('surfaces upstreamCandidate in sources regardless of effective preference', async () => { + // Why: the renderer selector needs to keep rendering after the user picks + // 'origin'. That requires the envelope to carry the raw upstream even + // when `sources.issues` has collapsed onto origin. + resolveIssueSourceMock.mockResolvedValueOnce({ + source: { owner: 'fork', repo: 'orca' }, + fellBack: false + }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) + getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ + stdout: '[]' + }) + + const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'origin') + + expect(result.sources).toEqual({ + issues: { owner: 'fork', repo: 'orca' }, + prs: { owner: 'fork', repo: 'orca' }, + upstreamCandidate: { owner: 'stablyai', repo: 'orca' } + }) + }) + }) +}) diff --git a/src/main/github/client-pr-checks.test.ts b/src/main/github/client-pr-checks.test.ts index 2585ea52110..49064da51e3 100644 --- a/src/main/github/client-pr-checks.test.ts +++ b/src/main/github/client-pr-checks.test.ts @@ -4,6 +4,7 @@ const { execFileAsyncMock, ghExecFileAsyncMock, getOwnerRepoMock, + getIssueOwnerRepoMock, gitExecFileAsyncMock, acquireMock, releaseMock @@ -11,6 +12,7 @@ const { execFileAsyncMock: vi.fn(), ghExecFileAsyncMock: vi.fn(), getOwnerRepoMock: vi.fn(), + getIssueOwnerRepoMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() @@ -20,6 +22,7 @@ vi.mock('./gh-utils', () => ({ execFileAsync: execFileAsyncMock, ghExecFileAsync: ghExecFileAsyncMock, getOwnerRepo: getOwnerRepoMock, + getIssueOwnerRepo: getIssueOwnerRepoMock, acquire: acquireMock, release: releaseMock, _resetOwnerRepoCache: vi.fn() @@ -36,6 +39,7 @@ describe('getPRChecks', () => { execFileAsyncMock.mockReset() ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() + getIssueOwnerRepoMock.mockReset() gitExecFileAsyncMock.mockReset() acquireMock.mockReset() releaseMock.mockReset() diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index 490ce175ee2..3dadca51830 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -1,9 +1,15 @@ +/* eslint-disable max-lines -- Why: work-items coverage stays in one file so +the fan-out mock plumbing (issue + PR gh calls, allSettled handling) does +not drift across split files. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { execFileAsyncMock, ghExecFileAsyncMock, getOwnerRepoMock, + getIssueOwnerRepoMock, + getOwnerRepoForRemoteMock, + resolveIssueSourceMock, gitExecFileAsyncMock, acquireMock, releaseMock @@ -11,6 +17,9 @@ const { execFileAsyncMock: vi.fn(), ghExecFileAsyncMock: vi.fn(), getOwnerRepoMock: vi.fn(), + getIssueOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + resolveIssueSourceMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() @@ -20,9 +29,14 @@ vi.mock('./gh-utils', () => ({ execFileAsync: execFileAsyncMock, ghExecFileAsync: ghExecFileAsyncMock, getOwnerRepo: getOwnerRepoMock, + getIssueOwnerRepo: getIssueOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoForRemoteMock, + resolveIssueSource: resolveIssueSourceMock, acquire: acquireMock, release: releaseMock, - _resetOwnerRepoCache: vi.fn() + _resetOwnerRepoCache: vi.fn(), + classifyGhError: (stderr: string) => ({ type: 'unknown', message: stderr }), + classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }) })) vi.mock('../git/runner', () => ({ @@ -36,14 +50,26 @@ describe('listWorkItems', () => { execFileAsyncMock.mockReset() ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() + getIssueOwnerRepoMock.mockReset() + getOwnerRepoForRemoteMock.mockReset() + resolveIssueSourceMock.mockReset() gitExecFileAsyncMock.mockReset() acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) + // Why: preference-aware `listWorkItems` calls `resolveIssueSource`. + // Route through the same `getIssueOwnerRepoMock` so existing tests that + // only set up `getIssueOwnerRepoMock` continue to work. + resolveIssueSourceMock.mockImplementation(async () => ({ + source: await getIssueOwnerRepoMock(), + fellBack: false + })) + getOwnerRepoForRemoteMock.mockResolvedValue(null) _resetOwnerRepoCache() }) it('runs both issue and PR GitHub searches for a mixed query and merges the results by recency', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ @@ -75,7 +101,11 @@ describe('listWorkItems', () => { } ]) }) - const items = await listWorkItems('/repo-root', 10, 'assignee:@me') + const { items, sources } = await listWorkItems('/repo-root', 10, 'assignee:@me') + expect(sources).toMatchObject({ + issues: { owner: 'acme', repo: 'widgets' }, + prs: { owner: 'acme', repo: 'widgets' } + }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, [ @@ -137,6 +167,7 @@ describe('listWorkItems', () => { }) it('routes draft queries to PR search only', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([ @@ -154,7 +185,7 @@ describe('listWorkItems', () => { } ]) }) - const items = await listWorkItems('/repo-root', 10, 'is:pr is:draft') + const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:draft') expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ @@ -190,6 +221,7 @@ describe('listWorkItems', () => { }) it('passes review-requested as a --search qualifier (gh CLI has no dedicated flag)', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) @@ -207,6 +239,7 @@ describe('listWorkItems', () => { }) it('returns open issues and PRs for the all-open preset query', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ @@ -238,7 +271,7 @@ describe('listWorkItems', () => { } ]) }) - const items = await listWorkItems('/repo-root', 10, 'is:open') + const { items } = await listWorkItems('/repo-root', 10, 'is:open') expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ 'issue', diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index a778c0576cb..5ba69ebd357 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -4,6 +4,7 @@ const { execFileAsyncMock, ghExecFileAsyncMock, getOwnerRepoMock, + getIssueOwnerRepoMock, gitExecFileAsyncMock, acquireMock, releaseMock @@ -11,6 +12,7 @@ const { execFileAsyncMock: vi.fn(), ghExecFileAsyncMock: vi.fn(), getOwnerRepoMock: vi.fn(), + getIssueOwnerRepoMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() @@ -20,6 +22,7 @@ vi.mock('./gh-utils', () => ({ execFileAsync: execFileAsyncMock, ghExecFileAsync: ghExecFileAsyncMock, getOwnerRepo: getOwnerRepoMock, + getIssueOwnerRepo: getIssueOwnerRepoMock, acquire: acquireMock, release: releaseMock, _resetOwnerRepoCache: vi.fn() @@ -36,6 +39,7 @@ describe('getPRForBranch', () => { execFileAsyncMock.mockReset() ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() + getIssueOwnerRepoMock.mockReset() gitExecFileAsyncMock.mockReset() acquireMock.mockReset() releaseMock.mockReset() diff --git a/src/main/github/client.ts b/src/main/github/client.ts index da38e33294b..033998d85ac 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -1,6 +1,9 @@ /* eslint-disable max-lines -- Why: co-locating all GitHub client functions keeps the concurrency acquire/release pattern and error handling consistent across operations. */ import type { + ClassifiedError, + IssueSourcePreference, + ListWorkItemsResult, PRInfo, PRMergeableState, PRCheckDetail, @@ -19,7 +22,12 @@ import { acquire, release, getOwnerRepo, - classifyGhError + getIssueOwnerRepo, + getOwnerRepoForRemote, + resolveIssueSource, + classifyGhError, + classifyListIssuesError, + type OwnerRepo } from './gh-utils' export { _resetOwnerRepoCache } from './gh-utils' export { @@ -221,9 +229,59 @@ function mapPullRequestWorkItem( } } +async function fetchIssueWorkItem( + repoPath: string, + ownerRepo: OwnerRepo | null, + number: number +): Promise { + if (ownerRepo) { + const { stdout } = await ghExecFileAsync( + ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${number}`], + { cwd: repoPath } + ) + const item = JSON.parse(stdout) as Record + if ('pull_request' in item) { + return null + } + return mapIssueWorkItem(item) + } + + const { stdout } = await ghExecFileAsync( + ['issue', 'view', String(number), '--json', 'number,title,state,url,labels,updatedAt,author'], + { cwd: repoPath } + ) + return mapIssueWorkItem(JSON.parse(stdout) as Record) +} + +async function fetchPullRequestWorkItem( + repoPath: string, + ownerRepo: OwnerRepo | null, + number: number +): Promise { + if (ownerRepo) { + const { stdout } = await ghExecFileAsync( + ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], + { cwd: repoPath } + ) + return mapPullRequestWorkItem(JSON.parse(stdout) as Record, ownerRepo.owner) + } + + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'view', + String(number), + '--json', + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner' + ], + { cwd: repoPath } + ) + return mapPullRequestWorkItem(JSON.parse(stdout) as Record) +} + function buildWorkItemListArgs(args: { kind: 'issue' | 'pr' - ownerRepo: { owner: string; repo: string } | null + ownerRepo: OwnerRepo | null limit: number query: ParsedTaskQuery before?: string @@ -289,47 +347,130 @@ function buildWorkItemListArgs(args: { return out } +// Why: internal shape shared by listRecentWorkItems / listQueriedWorkItems so +// listWorkItems can lift per-side errors into the IPC envelope. The issue-side +// error is the specific new class of silent wrongness introduced by #1076 — +// PR-side errors existed before and are explicitly out of scope for this +// feature per the parent design doc §6. +type PartialWorkItemsResult = { + items: MainWorkItem[] + issuesError?: ClassifiedError +} + async function listRecentWorkItems( repoPath: string, - ownerRepo: { owner: string; repo: string } | null, + issueOwnerRepo: OwnerRepo | null, + prOwnerRepo: OwnerRepo | null, limit: number -): Promise { - if (ownerRepo) { - const [issuesResult, prsResult] = await Promise.all([ - ghExecFileAsync( - [ - 'api', - '--cache', - '120s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc` - ], - { cwd: repoPath } - ), - ghExecFileAsync( - [ - 'api', - '--cache', - '120s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls?per_page=${limit}&state=open&sort=updated&direction=desc` - ], - { cwd: repoPath } - ) +): Promise { + if (issueOwnerRepo || prOwnerRepo) { + // Why: allSettled so a 403 on upstream issues doesn't zero out the origin + // PR half — the UI renders partial results plus a banner for the failing + // side, matching the parent design doc's partial-failure rule (§2). + const [issuesSettled, prsSettled] = await Promise.allSettled([ + issueOwnerRepo + ? ghExecFileAsync( + [ + 'api', + '--cache', + '120s', + `repos/${issueOwnerRepo.owner}/${issueOwnerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc` + ], + { cwd: repoPath } + ) + : ghExecFileAsync( + [ + 'issue', + 'list', + '--limit', + String(limit), + '--state', + 'open', + '--json', + 'number,title,state,url,labels,updatedAt,author' + ], + { cwd: repoPath } + ), + prOwnerRepo + ? ghExecFileAsync( + [ + 'api', + '--cache', + '120s', + `repos/${prOwnerRepo.owner}/${prOwnerRepo.repo}/pulls?per_page=${limit}&state=open&sort=updated&direction=desc` + ], + { cwd: repoPath } + ) + : ghExecFileAsync( + [ + 'pr', + 'list', + '--limit', + String(limit), + '--state', + 'open', + '--json', + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner' + ], + { cwd: repoPath } + ) ]) - const issues = (JSON.parse(issuesResult.stdout) as Record[]) - // Why: the GitHub issues REST endpoint also returns pull requests with a - // `pull_request` marker. The new-workspace task picker needs distinct - // issue vs PR buckets, so drop PR-shaped issue rows here before merging. - .filter((item) => !('pull_request' in item)) - .map(mapIssueWorkItem) + let issues: MainWorkItem[] = [] + let issuesError: ClassifiedError | undefined + if (issuesSettled.status === 'fulfilled') { + issues = (JSON.parse(issuesSettled.value.stdout) as Record[]) + // Why: the GitHub issues REST endpoint also returns pull requests with a + // `pull_request` marker. The new-workspace task picker needs distinct + // issue vs PR buckets, so drop PR-shaped issue rows here before merging. + .filter((item) => !('pull_request' in item)) + .map(mapIssueWorkItem) + } else { + const stderr = + issuesSettled.reason instanceof Error + ? issuesSettled.reason.message + : String(issuesSettled.reason) + issuesError = classifyListIssuesError(stderr) + } - const prs = (JSON.parse(prsResult.stdout) as Record[]).map((item) => - mapPullRequestWorkItem(item, ownerRepo.owner) - ) + let prs: MainWorkItem[] = [] + if (prsSettled.status === 'fulfilled') { + prs = (JSON.parse(prsSettled.value.stdout) as Record[]).map((item) => + mapPullRequestWorkItem(item, prOwnerRepo?.owner ?? null) + ) + } else { + // Why: PR-side failures must preserve the pre-diff behavior of + // Promise.all by re-throwing so the rejection propagates up through + // listWorkItems to the renderer's cross-repo aggregator (which counts + // the repo as failed). This feature is scoped to the issue-side silent + // wrongness from #1076; PR errors must not be silently swallowed here. + // Why: if the issue side ALSO failed, the classified issuesError would + // otherwise be silently dropped when we throw the PR reason. Log it so + // debugging both-sides-failed scenarios (e.g. 403 on both endpoints) + // isn't blind to the issue-side classification. + if (issuesError) { + console.warn( + 'listRecentWorkItems: both issue and PR sides failed; issuesError was classified:', + issuesError.type, + issuesError.message + ) + } + throw prsSettled.reason + } - return sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit) + return { + items: sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit), + issuesError + } } + // Why: the fallback path (non-GitHub remote — neither issueOwnerRepo nor + // prOwnerRepo resolved) intentionally stays on Promise.all rather than the + // Promise.allSettled + per-side classification used above. There are no + // `sources` to surface on this branch and nothing for the partial-failure + // banner to render, so a single-side failure here means the whole call is + // effectively unusable for the feature — reject-all matches reality. If + // non-GitHub remotes ever grow source metadata, revisit this symmetry. const [issuesResult, prsResult] = await Promise.all([ ghExecFileAsync( [ @@ -366,61 +507,94 @@ async function listRecentWorkItems( mapPullRequestWorkItem(item, null) ) - return sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit) + return { + items: sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit) + } } async function listQueriedWorkItems( repoPath: string, - ownerRepo: { owner: string; repo: string } | null, + issueOwnerRepo: OwnerRepo | null, + prOwnerRepo: OwnerRepo | null, query: ParsedTaskQuery, limit: number, before?: string -): Promise { - const fetchers: Promise[] = [] +): Promise { const issueScope = query.scope !== 'pr' const prScope = query.scope !== 'issue' - if (issueScope) { - fetchers.push( - (async () => { - const args = buildWorkItemListArgs({ kind: 'issue', ownerRepo, limit, query, before }) - try { - const { stdout } = await ghExecFileAsync(args, { cwd: repoPath }) - return (JSON.parse(stdout) as Record[]).map(mapIssueWorkItem) - } catch { - return [] - } - })() - ) - } + // Why: run the issue and PR fetches in parallel but surface the + // issue-side error separately so the IPC envelope can carry it up. PR-side + // failures retain the prior swallow-and-log behavior per parent doc §6. + const issueFetch = (async (): Promise => { + if (!issueScope) { + return { items: [] } + } + const args = buildWorkItemListArgs({ + kind: 'issue', + ownerRepo: issueOwnerRepo, + limit, + query, + before + }) + try { + const { stdout } = await ghExecFileAsync(args, { cwd: repoPath }) + return { + items: (JSON.parse(stdout) as Record[]).map(mapIssueWorkItem) + } + } catch (err) { + const stderr = err instanceof Error ? err.message : String(err) + return { items: [], issuesError: classifyListIssuesError(stderr) } + } + })() - if (prScope) { - fetchers.push( - (async () => { - const args = buildWorkItemListArgs({ kind: 'pr', ownerRepo, limit, query, before }) - try { - const { stdout } = await ghExecFileAsync(args, { cwd: repoPath }) - return (JSON.parse(stdout) as Record[]).map((item) => - mapPullRequestWorkItem(item, ownerRepo?.owner ?? null) - ) - } catch { - return [] - } - })() - ) - } + const prFetch = (async (): Promise => { + if (!prScope) { + return [] + } + const args = buildWorkItemListArgs({ + kind: 'pr', + ownerRepo: prOwnerRepo, + limit, + query, + before + }) + try { + const { stdout } = await ghExecFileAsync(args, { cwd: repoPath }) + return (JSON.parse(stdout) as Record[]).map((item) => + mapPullRequestWorkItem(item, prOwnerRepo?.owner ?? null) + ) + } catch (err) { + console.warn('listQueriedWorkItems PRs partial failure:', err) + return [] + } + })() - const results = await Promise.all(fetchers) - return sortWorkItemsByUpdatedAt(results.flat()).slice(0, limit) + const [issueResult, prItems] = await Promise.all([issueFetch, prFetch]) + return { + items: sortWorkItemsByUpdatedAt([...issueResult.items, ...prItems]).slice(0, limit), + issuesError: issueResult.issuesError + } } export async function listWorkItems( repoPath: string, limit = 24, query?: string, - before?: string -): Promise { - const ownerRepo = await getOwnerRepo(repoPath) + before?: string, + preference?: IssueSourcePreference +): Promise> { + // Why: resolve the raw upstream candidate alongside the preference-aware + // issue source. The selector needs to know whether an upstream remote + // *exists* to decide whether to render — independent of whether the user + // has picked 'origin' (which would otherwise make `sources.issues` equal + // origin and hide the selector permanently). + const [issueResolved, prOwnerRepo, upstreamCandidate] = await Promise.all([ + resolveIssueSource(repoPath, preference), + getOwnerRepo(repoPath), + getOwnerRepoForRemote(repoPath, 'upstream') + ]) + const issueOwnerRepo = issueResolved.source const trimmedQuery = query?.trim() ?? '' await acquire() try { @@ -428,12 +602,28 @@ export async function listWorkItems( // count this repo as failed and surface the partial-failure banner. A // catch-all here would make an auth/network failure indistinguishable from // an empty result and silently under-report per-repo failures. - if (!trimmedQuery) { - return await listRecentWorkItems(repoPath, ownerRepo, limit) - } + const partial = !trimmedQuery + ? await listRecentWorkItems(repoPath, issueOwnerRepo, prOwnerRepo, limit) + : await listQueriedWorkItems( + repoPath, + issueOwnerRepo, + prOwnerRepo, + parseTaskQuery(trimmedQuery), + limit, + before + ) - const parsedQuery = parseTaskQuery(trimmedQuery) - return await listQueriedWorkItems(repoPath, ownerRepo, parsedQuery, limit, before) + const errors = partial.issuesError ? { issues: partial.issuesError } : undefined + return { + items: partial.items, + sources: { + issues: issueOwnerRepo, + prs: prOwnerRepo, + upstreamCandidate: upstreamCandidate ?? null + }, + ...(errors ? { errors } : {}), + ...(issueResolved.fellBack ? { issueSourceFellBack: true } : {}) + } } finally { release() } @@ -480,36 +670,112 @@ function buildSearchQueryString( return parts.join(' ') } +async function countWorkItemsForQuery( + repoPath: string, + ownerRepo: OwnerRepo, + query: ParsedTaskQuery +): Promise { + const searchQ = buildSearchQueryString(ownerRepo, query) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent(searchQ)}&per_page=1`, + '--jq', + '.total_count' + ], + { cwd: repoPath } + ) + return parseInt(stdout.trim(), 10) || 0 +} + +function sameOwnerRepo(left: OwnerRepo | null, right: OwnerRepo | null): boolean { + // Why: GitHub treats owner and repo names as case-insensitive, so remotes + // with different casing (StablyAI/Orca vs stablyai/orca) point at the same + // repo and should not split into two search queries. + return ( + left?.owner.toLowerCase() === right?.owner.toLowerCase() && + left?.repo.toLowerCase() === right?.repo.toLowerCase() + ) +} + +function defaultOpenWorkItemQuery(): ParsedTaskQuery { + return { + scope: 'all', + state: 'open', + draft: false, + assignee: null, + author: null, + reviewRequested: null, + reviewedBy: null, + labels: [], + freeText: '' + } +} + // Why: uses GitHub's search API to get total_count without fetching items. // This powers the pagination bar so the user sees total pages upfront. // Cached for 120s to avoid burning the search rate limit (30 req/min). -export async function countWorkItems(repoPath: string, query?: string): Promise { - const ownerRepo = await getOwnerRepo(repoPath) +export async function countWorkItems( + repoPath: string, + query?: string, + preference?: IssueSourcePreference +): Promise { + const [issueResolved, prOwnerRepo] = await Promise.all([ + resolveIssueSource(repoPath, preference), + getOwnerRepo(repoPath) + ]) + const issueOwnerRepo = issueResolved.source + const ownerRepo = prOwnerRepo ?? issueOwnerRepo if (!ownerRepo) { return 0 } const trimmedQuery = query?.trim() ?? '' const parsedQuery = trimmedQuery ? parseTaskQuery(trimmedQuery) : null - - const searchQ = parsedQuery - ? buildSearchQueryString(ownerRepo, parsedQuery) - : `repo:${ownerRepo.owner}/${ownerRepo.repo} is:open` + const effectiveQuery = parsedQuery ?? defaultOpenWorkItemQuery() await acquire() try { - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '120s', - `search/issues?q=${encodeURIComponent(searchQ)}&per_page=1`, - '--jq', - '.total_count' - ], - { cwd: repoPath } - ) - return parseInt(stdout.trim(), 10) || 0 + if (sameOwnerRepo(issueOwnerRepo, prOwnerRepo)) { + return await countWorkItemsForQuery(repoPath, ownerRepo, effectiveQuery) + } + + const counts: Promise[] = [] + // Why: `draft`, `reviewRequested`, and `reviewedBy` are PR-only predicates. + // When present, the issue half would always return 0 and wastes a search + // API call — skip the issue half entirely in that case. + const hasPrOnlyFilter = + effectiveQuery.draft || + effectiveQuery.reviewRequested !== null || + effectiveQuery.reviewedBy !== null + if ( + effectiveQuery.scope !== 'pr' && + effectiveQuery.state !== 'merged' && + !hasPrOnlyFilter && + issueOwnerRepo + ) { + counts.push( + countWorkItemsForQuery(repoPath, issueOwnerRepo, { ...effectiveQuery, scope: 'issue' }) + ) + } + if (effectiveQuery.scope !== 'issue' && prOwnerRepo) { + counts.push(countWorkItemsForQuery(repoPath, prOwnerRepo, { ...effectiveQuery, scope: 'pr' })) + } + // Why: allSettled so a single failing search (e.g. transient network, rate + // limit on one side) doesn't silently zero out the total; sum only the + // fulfilled halves instead. + const results = await Promise.allSettled(counts) + let total = 0 + for (const r of results) { + if (r.status === 'fulfilled') { + total += r.value + } else { + console.warn('countWorkItems partial failure:', r.reason) + } + } + return total } catch (err) { console.warn('countWorkItems failed:', err) return 0 @@ -524,165 +790,57 @@ export async function getRepoSlug( return getOwnerRepo(repoPath) } -export async function getWorkItem(repoPath: string, number: number): Promise { +export async function getWorkItem( + repoPath: string, + number: number, + type?: 'issue' | 'pr' +): Promise { await acquire() try { - const ownerRepo = await getOwnerRepo(repoPath) - if (ownerRepo) { - const { stdout } = await ghExecFileAsync( - ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${number}`], - { cwd: repoPath } - ) - const item = JSON.parse(stdout) as Record - if ('pull_request' in item) { - const prResult = await ghExecFileAsync( - ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], - { cwd: repoPath } - ) - const pr = JSON.parse(prResult.stdout) as Record - const prHeadOwner = extractHeadOwnerLogin(pr) - return { - id: `pr:${String(pr.number)}`, - type: 'pr', - number: Number(pr.number), - title: String(pr.title ?? ''), - state: - pr.state === 'closed' - ? pr.merged_at - ? 'merged' - : 'closed' - : pr.draft - ? 'draft' - : 'open', - url: String(pr.html_url ?? pr.url ?? ''), - labels: Array.isArray(pr.labels) - ? pr.labels - .map((label) => - typeof label === 'object' && label !== null && 'name' in label - ? String((label as { name?: unknown }).name ?? '') - : '' - ) - .filter(Boolean) - : [], - updatedAt: String(pr.updated_at ?? ''), - author: - typeof pr.user === 'object' && pr.user !== null && 'login' in pr.user - ? String((pr.user as { login?: unknown }).login ?? '') - : null, - branchName: - typeof pr.head === 'object' && pr.head !== null && 'ref' in pr.head - ? String((pr.head as { ref?: unknown }).ref ?? '') - : undefined, - baseRefName: - typeof pr.base === 'object' && pr.base !== null && 'ref' in pr.base - ? String((pr.base as { ref?: unknown }).ref ?? '') - : undefined, - // Why: only emit isCrossRepository when we actually know the head - // owner. Falsely claiming "not a fork" would let the picker try a - // normal-PR fetch against a fork head and fail. - ...(prHeadOwner !== null ? { isCrossRepository: prHeadOwner !== ownerRepo.owner } : {}) - } - } - - return { - id: `issue:${String(item.number)}`, - type: 'issue', - number: Number(item.number), - title: String(item.title ?? ''), - state: String(item.state ?? 'open') === 'closed' ? 'closed' : 'open', - url: String(item.html_url ?? item.url ?? ''), - labels: Array.isArray(item.labels) - ? item.labels - .map((label) => - typeof label === 'object' && label !== null && 'name' in label - ? String((label as { name?: unknown }).name ?? '') - : '' - ) - .filter(Boolean) - : [], - updatedAt: String(item.updated_at ?? ''), - author: - typeof item.user === 'object' && item.user !== null && 'login' in item.user - ? String((item.user as { login?: unknown }).login ?? '') - : null - } + if (type === 'issue') { + return await fetchIssueWorkItem(repoPath, await getIssueOwnerRepo(repoPath), number) + } + if (type === 'pr') { + return await fetchPullRequestWorkItem(repoPath, await getOwnerRepo(repoPath), number) } try { - const { stdout } = await ghExecFileAsync( - [ - 'issue', - 'view', - String(number), - '--json', - 'number,title,state,url,labels,updatedAt,author' - ], - { cwd: repoPath } - ) - const item = JSON.parse(stdout) as Record - return { - id: `issue:${String(item.number)}`, - type: 'issue', - number: Number(item.number), - title: String(item.title ?? ''), - state: String(item.state ?? 'open') === 'closed' ? 'closed' : 'open', - url: String(item.url ?? ''), - labels: Array.isArray(item.labels) - ? item.labels - .map((label) => - typeof label === 'object' && label !== null && 'name' in label - ? String((label as { name?: unknown }).name ?? '') - : '' - ) - .filter(Boolean) - : [], - updatedAt: String(item.updatedAt ?? ''), - author: - typeof item.author === 'object' && item.author !== null && 'login' in item.author - ? String((item.author as { login?: unknown }).login ?? '') - : null + const issue = await fetchIssueWorkItem(repoPath, await getIssueOwnerRepo(repoPath), number) + if (issue) { + return issue } - } catch { - const { stdout } = await ghExecFileAsync( - [ - 'pr', - 'view', - String(number), - '--json', - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner' - ], - { cwd: repoPath } - ) - const item = JSON.parse(stdout) as Record - return { - id: `pr:${String(item.number)}`, - type: 'pr', - number: Number(item.number), - title: String(item.title ?? ''), - state: item.isDraft ? 'draft' : String(item.state ?? 'open') === 'open' ? 'open' : 'closed', - url: String(item.url ?? ''), - labels: Array.isArray(item.labels) - ? item.labels - .map((label) => - typeof label === 'object' && label !== null && 'name' in label - ? String((label as { name?: unknown }).name ?? '') - : '' - ) - .filter(Boolean) - : [], - updatedAt: String(item.updatedAt ?? ''), - author: - typeof item.author === 'object' && item.author !== null && 'login' in item.author - ? String((item.author as { login?: unknown }).login ?? '') - : null, - branchName: String(item.headRefName ?? ''), - baseRefName: String(item.baseRefName ?? '') - // Why: ownerRepo is null on this path so we can't compare head vs base - // owners. Leave isCrossRepository undefined rather than guessing — - // falsely claiming "not a fork" would let the picker try a normal-PR - // fetch against a fork head and fail. + } catch (err) { + // Why: the issue lookup now targets `upstream` while the PR lookup targets `origin`, + // so a transient upstream failure (5xx, rate limit, network flake) on issue #N would + // silently fall through to origin's PR #N — potentially a completely unrelated item. + // Only fall through when the issue genuinely doesn't exist (404); re-throw everything + // else so the outer catch returns null and the caller sees a real failure instead of + // a wrong item. classifyGhError centralizes the 404/"not found" pattern-matching. + const stderr = err instanceof Error ? err.message : String(err) + if (classifyGhError(stderr).type !== 'not_found') { + throw err } } + return await fetchPullRequestWorkItem(repoPath, await getOwnerRepo(repoPath), number) + } catch { + return null + } finally { + release() + } +} + +export async function getWorkItemByOwnerRepo( + repoPath: string, + ownerRepo: OwnerRepo, + number: number, + type: 'issue' | 'pr' +): Promise { + await acquire() + try { + if (type === 'issue') { + return await fetchIssueWorkItem(repoPath, ownerRepo, number) + } + return await fetchPullRequestWorkItem(repoPath, ownerRepo, number) } catch { return null } finally { @@ -693,17 +851,21 @@ export async function getWorkItem(repoPath: string, number: number): Promise { +export async function getPRForBranch( + repoPath: string, + branch: string, + linkedPRNumber?: number | null +): Promise { // Strip refs/heads/ prefix if present const branchName = branch.replace(/^refs\/heads\//, '') - // During a rebase the worktree is in detached HEAD and branch is empty. - // An empty --head filter causes gh to return an arbitrary PR — bail early. - if (!branchName) { - return null - } - await acquire() try { const ownerRepo = await getOwnerRepo(repoPath) @@ -722,38 +884,73 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise< headRefOid?: string } | null = null - if (ownerRepo) { - const { stdout } = await ghExecFileAsync( - [ - 'pr', - 'list', - '--repo', - `${ownerRepo.owner}/${ownerRepo.repo}`, - '--head', - branchName, - '--state', - 'all', - '--limit', - '1', - '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' - ], - { cwd: repoPath } - ) - const list = JSON.parse(stdout) as NonNullable[] - data = list[0] ?? null - } else { - const { stdout } = await ghExecFileAsync( - [ - 'pr', - 'view', - branchName, - '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' - ], - { cwd: repoPath } - ) - data = JSON.parse(stdout) + // During a rebase the worktree is in detached HEAD and branch is empty. + // An empty --head filter causes gh to return an arbitrary PR — skip the + // branch lookup and rely on the linkedPR fallback below if available. + if (branchName) { + if (ownerRepo) { + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'list', + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--head', + branchName, + '--state', + 'all', + '--limit', + '1', + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: repoPath } + ) + const list = JSON.parse(stdout) as NonNullable[] + data = list[0] ?? null + } else { + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'view', + branchName, + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: repoPath } + ) + data = JSON.parse(stdout) + } + } + + if (!data && typeof linkedPRNumber === 'number') { + const args = ownerRepo + ? [ + 'pr', + 'view', + String(linkedPRNumber), + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ] + : [ + 'pr', + 'view', + String(linkedPRNumber), + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ] + try { + const { stdout } = await ghExecFileAsync(args, { cwd: repoPath }) + data = JSON.parse(stdout) + } catch { + // Why: a stale linkedPRNumber (PR deleted, wrong repo, …) makes + // `gh pr view ` reject. Treat that as the no-PR case so + // callers see the historical `null` semantics instead of a thrown + // error every poll cycle. + data = null + } } if (!data) { diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts new file mode 100644 index 00000000000..8e1b8d839a1 --- /dev/null +++ b/src/main/github/gh-utils.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + ghExecFileAsync: vi.fn() +})) + +import { + _resetOwnerRepoCache, + classifyGhError, + classifyListIssuesError, + getIssueOwnerRepo, + getOwnerRepo, + parseGitHubOwnerRepo, + resolveIssueSource +} from './gh-utils' + +describe('github owner/repo resolution', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + _resetOwnerRepoCache() + }) + + it('parses GitHub HTTPS and SSH remotes', () => { + expect(parseGitHubOwnerRepo('https://github.com/acme/widgets.git')).toEqual({ + owner: 'acme', + repo: 'widgets' + }) + expect(parseGitHubOwnerRepo('git@github.com:stablyai/orca.git')).toEqual({ + owner: 'stablyai', + repo: 'orca' + }) + expect(parseGitHubOwnerRepo('git@github.com:TheBoredTeam/boring.notch.git')).toEqual({ + owner: 'TheBoredTeam', + repo: 'boring.notch' + }) + expect(parseGitHubOwnerRepo('git@example.com:stablyai/orca.git')).toBeNull() + }) + + it('keeps getOwnerRepo origin-based', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'git@github.com:fork/orca.git\n' + }) + + await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'fork', repo: 'orca' }) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { + cwd: '/repo' + }) + }) + + it('prefers upstream for issue owner/repo resolution', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'git@github.com:stablyai/orca.git\n' + }) + + await expect(getIssueOwnerRepo('/repo')).resolves.toEqual({ owner: 'stablyai', repo: 'orca' }) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], { + cwd: '/repo' + }) + }) + + it('falls back to origin when upstream is missing or non-GitHub', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'git@example.com:stablyai/orca.git\n' }) + .mockResolvedValueOnce({ stdout: 'git@github.com:fork/orca.git\n' }) + + await expect(getIssueOwnerRepo('/repo')).resolves.toEqual({ owner: 'fork', repo: 'orca' }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'upstream'], { + cwd: '/repo' + }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], { + cwd: '/repo' + }) + }) + + it('does not mix origin and upstream cache entries for the same repo path', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'git@github.com:fork/orca.git\n' }) + .mockResolvedValueOnce({ stdout: 'git@github.com:stablyai/orca.git\n' }) + + await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'fork', repo: 'orca' }) + await expect(getIssueOwnerRepo('/repo')).resolves.toEqual({ owner: 'stablyai', repo: 'orca' }) + }) +}) + +describe('resolveIssueSource', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + _resetOwnerRepoCache() + }) + + it("'auto' + upstream exists → upstream, fellBack=false", async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'git@github.com:stablyai/orca.git\n' + }) + + await expect(resolveIssueSource('/repo', 'auto')).resolves.toEqual({ + source: { owner: 'stablyai', repo: 'orca' }, + fellBack: false + }) + }) + + it("'auto' + no upstream → origin, fellBack=false", async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'git@example.com:stablyai/orca.git\n' }) + .mockResolvedValueOnce({ stdout: 'git@github.com:solo/orca.git\n' }) + + await expect(resolveIssueSource('/repo', 'auto')).resolves.toEqual({ + source: { owner: 'solo', repo: 'orca' }, + fellBack: false + }) + }) + + it("'upstream' + upstream exists → upstream, fellBack=false", async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'git@github.com:stablyai/orca.git\n' + }) + + await expect(resolveIssueSource('/repo', 'upstream')).resolves.toEqual({ + source: { owner: 'stablyai', repo: 'orca' }, + fellBack: false + }) + }) + + it("'upstream' + no upstream remote → origin, fellBack=true", async () => { + // No upstream remote configured — the first call fails. + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error('fatal: No such remote')) + .mockResolvedValueOnce({ stdout: 'git@github.com:solo/orca.git\n' }) + + await expect(resolveIssueSource('/repo', 'upstream')).resolves.toEqual({ + source: { owner: 'solo', repo: 'orca' }, + fellBack: true + }) + }) + + it("'origin' + upstream exists → origin (ignores upstream), fellBack=false", async () => { + // Only one gh call should happen — origin. Upstream is never consulted. + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'git@github.com:fork/orca.git\n' + }) + + await expect(resolveIssueSource('/repo', 'origin')).resolves.toEqual({ + source: { owner: 'fork', repo: 'orca' }, + fellBack: false + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { + cwd: '/repo' + }) + }) + + it("'origin' + no upstream → origin, fellBack=false", async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'git@github.com:solo/orca.git\n' + }) + + await expect(resolveIssueSource('/repo', 'origin')).resolves.toEqual({ + source: { owner: 'solo', repo: 'orca' }, + fellBack: false + }) + }) + + it('undefined preference is treated identically to auto', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'git@github.com:stablyai/orca.git\n' + }) + + await expect(resolveIssueSource('/repo', undefined)).resolves.toEqual({ + source: { owner: 'stablyai', repo: 'orca' }, + fellBack: false + }) + }) +}) + +describe('gh error classification', () => { + // Why: a fork with Issues turned off triggers `gh issue list` stderr + // "the '' repository has disabled issues". Without a dedicated branch + // the raw "Command failed: gh issue list …" line leaks into the Tasks banner + // via the `unknown` fallback — which is what users see when they flip the + // per-repo selector to an origin fork that has issues disabled. + it('classifies "has disabled issues" stderr as issues_disabled', () => { + const stderr = + "Command failed: gh issue list --limit 36 --json number,title,state --repo brennanb2025/orca --state open\nthe 'brennanb2025/orca' repository has disabled issues" + expect(classifyGhError(stderr)).toEqual({ + type: 'issues_disabled', + message: 'Issues are disabled on this repository.' + }) + expect(classifyListIssuesError(stderr)).toEqual({ + type: 'issues_disabled', + message: 'Issues are disabled on this repository.' + }) + }) +}) diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index bea35458740..48f7afedbf4 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -1,7 +1,7 @@ import { execFile } from 'child_process' import { promisify } from 'util' import { gitExecFileAsync, ghExecFileAsync } from '../git/runner' -import type { ClassifiedError } from '../../shared/types' +import type { ClassifiedError, GitHubOwnerRepo, IssueSourcePreference } from '../../shared/types' // Why: legacy generic execFile wrapper — only used by callers that don't need // WSL-aware routing (e.g. non-repo-scoped gh commands). Repo-scoped callers @@ -46,9 +46,22 @@ export function classifyGhError(stderr: string): ClassifiedError { message: "You don't have permission to edit this issue. Check your GitHub token scopes." } } - if (s.includes('http 404') || s.includes('could not resolve')) { + // Why: the full gh message is "Could not resolve to a Repository with the + // name ...". Matching the substring 'could not resolve' alone would also + // capture DNS failures like "could not resolve host: api.github.com" and + // misclassify them as not_found. Anchor on the 'repository' qualifier so + // DNS errors fall through to the network_error branch below. + if (s.includes('http 404') || s.includes('could not resolve to a repository')) { return { type: 'not_found', message: 'Issue not found — it may have been deleted.' } } + // Why: `gh issue list` prints "the '/' repository has disabled + // issues" when Issues are turned off in repo settings (common on forks). This + // hits during feature-2 when a user flips the selector to an origin fork — + // without a dedicated branch the raw "Command failed: gh issue list …" line + // leaks verbatim into the banner via the `unknown` fallback. + if (s.includes('has disabled issues')) { + return { type: 'issues_disabled', message: 'Issues are disabled on this repository.' } + } if (s.includes('http 422') || s.includes('validation failed')) { return { type: 'validation_error', message: `Invalid update — ${stderr.trim()}` } } @@ -58,39 +71,135 @@ export function classifyGhError(stderr: string): ClassifiedError { message: 'GitHub rate limit hit. Try again in a few minutes.' } } - if (s.includes('timeout') || s.includes('no such host') || s.includes('network')) { + if ( + s.includes('timeout') || + s.includes('no such host') || + s.includes('network') || + s.includes('could not resolve host') + ) { return { type: 'network_error', message: 'Network error — check your connection.' } } return { type: 'unknown', message: `Failed to update issue: ${stderr.trim()}` } } +// Why: classifyGhError's copy is phrased for edit/update operations, but +// `listIssues` is a read op and the renderer interpolates err.message verbatim +// into a read-context banner. Rewrite the message for read contexts while +// keeping the typed classification so callers/telemetry are unaffected. +export function classifyListIssuesError(stderr: string): ClassifiedError { + const c = classifyGhError(stderr) + const trimmed = stderr.trim() + // Why: provide an explicit entry for every `ClassifiedError['type']` value + // (even when the copy matches the generic fallback) so the read-context + // rewrite is complete and any newly added error type surfaces as a + // TypeScript error rather than silently falling through to edit-phrased copy. + const readMessages: Record = { + permission_denied: + "You don't have permission to read issues for this repository. Check your GitHub token scopes.", + not_found: 'Repository not found.', + issues_disabled: 'Issues are disabled on this repository.', + validation_error: `Invalid request — ${trimmed}`, + rate_limited: 'GitHub rate limit hit. Try again in a few minutes.', + network_error: 'Network error — check your connection.', + unknown: `Failed to load issues: ${trimmed}` + } + return { type: c.type, message: readMessages[c.type] } +} + // ── Owner/repo resolution for gh api --cache ────────────────────────── -const ownerRepoCache = new Map() +// Why: alias the shared shape so `src/shared/types.ts#GitHubOwnerRepo` remains +// the single source of truth while main-side call sites can keep using the +// short local name `OwnerRepo`. +export type OwnerRepo = GitHubOwnerRepo + +const ownerRepoCache = new Map() /** @internal — exposed for tests only */ export function _resetOwnerRepoCache(): void { ownerRepoCache.clear() } -export async function getOwnerRepo( - repoPath: string -): Promise<{ owner: string; repo: string } | null> { - if (ownerRepoCache.has(repoPath)) { - return ownerRepoCache.get(repoPath)! +export function parseGitHubOwnerRepo(remoteUrl: string): OwnerRepo | null { + const match = remoteUrl.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) + if (!match) { + return null + } + return { owner: match[1], repo: match[2] } +} + +export async function getOwnerRepoForRemote( + repoPath: string, + remoteName: string +): Promise { + const cacheKey = `${repoPath}\0${remoteName}` + if (ownerRepoCache.has(cacheKey)) { + return ownerRepoCache.get(cacheKey)! } try { - const { stdout } = await gitExecFileAsync(['remote', 'get-url', 'origin'], { + const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { cwd: repoPath }) - const match = stdout.trim().match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/) - if (match) { - const result = { owner: match[1], repo: match[2] } - ownerRepoCache.set(repoPath, result) + const result = parseGitHubOwnerRepo(stdout) + if (result) { + ownerRepoCache.set(cacheKey, result) return result } } catch { // ignore — non-GitHub remote or no remote } - ownerRepoCache.set(repoPath, null) + ownerRepoCache.set(cacheKey, null) return null } + +export async function getOwnerRepo(repoPath: string): Promise { + return getOwnerRepoForRemote(repoPath, 'origin') +} + +export async function getIssueOwnerRepo(repoPath: string): Promise { + const upstream = await getOwnerRepoForRemote(repoPath, 'upstream') + if (upstream) { + return upstream + } + return getOwnerRepoForRemote(repoPath, 'origin') +} + +export type ResolvedIssueSource = { + source: OwnerRepo | null + /** True when the user preferred `upstream` but the upstream remote is no + * longer configured and the resolver fell back to origin. Consumers + * surface this as a one-time toast per session/repo. */ + fellBack: boolean +} + +/** + * Resolve the issue source for a repo honoring the user's per-repo preference. + * + * Do not delete `getIssueOwnerRepo`: it remains the right primitive for + * `'auto'` mode and for preference-agnostic callers like typed work-item + * detail lookups (where the issue-vs-PR disambiguation is orthogonal to + * user choice). + */ +export async function resolveIssueSource( + repoPath: string, + preference: IssueSourcePreference | undefined +): Promise { + if (preference === 'upstream') { + const upstream = await getOwnerRepoForRemote(repoPath, 'upstream') + if (upstream) { + return { source: upstream, fellBack: false } + } + // Why: explicit upstream is gone — fall back to origin but only flag the + // fallback when it actually produced an origin source. If origin is also + // missing (or non-GitHub), there's nothing to "fall back to" and the + // UI toast "using origin" would be misleading. Do NOT auto-reset the + // preference: the user may be mid-way through a workflow and expect + // their choice to re-engage if `upstream` is re-added. + const origin = await getOwnerRepoForRemote(repoPath, 'origin') + return { source: origin, fellBack: origin !== null } + } + if (preference === 'origin') { + return { source: await getOwnerRepoForRemote(repoPath, 'origin'), fellBack: false } + } + // 'auto' or undefined + return { source: await getIssueOwnerRepo(repoPath), fellBack: false } +} diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts new file mode 100644 index 00000000000..bc5a8e0a413 --- /dev/null +++ b/src/main/github/issues.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GhUtils from './gh-utils' + +const { + ghExecFileAsyncMock, + getIssueOwnerRepoMock, + resolveIssueSourceMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + getIssueOwnerRepoMock: vi.fn(), + resolveIssueSourceMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gh-utils', async () => { + const actual = await vi.importActual('./gh-utils') + return { + ...actual, + ghExecFileAsync: ghExecFileAsyncMock, + getIssueOwnerRepo: getIssueOwnerRepoMock, + resolveIssueSource: resolveIssueSourceMock, + acquire: acquireMock, + release: releaseMock + } +}) + +import { createIssue, getIssue, listIssues } from './issues' + +describe('issue source operations', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + getIssueOwnerRepoMock.mockReset() + resolveIssueSourceMock.mockReset() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + // Why: preference-aware paths call resolveIssueSource instead of + // getIssueOwnerRepo. Route through the same mock so existing tests that + // set up getIssueOwnerRepoMock continue to work. + resolveIssueSourceMock.mockImplementation(async () => ({ + source: await getIssueOwnerRepoMock(), + fellBack: false + })) + }) + + it('gets a single issue from the issue owner/repo', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 923, + title: 'Use upstream issues', + state: 'open', + html_url: 'https://github.com/stablyai/orca/issues/923', + labels: [] + }) + }) + + await expect(getIssue('/repo-root', 923)).resolves.toMatchObject({ number: 923 }) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['api', '--cache', '300s', 'repos/stablyai/orca/issues/923'], + { cwd: '/repo-root' } + ) + }) + + it('lists issues from the issue owner/repo', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await expect(listIssues('/repo-root', 5)).resolves.toEqual({ items: [] }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '--cache', + '120s', + 'repos/stablyai/orca/issues?per_page=5&state=open&sort=updated&direction=desc' + ], + { cwd: '/repo-root' } + ) + }) + + it('surfaces a classified permission_denied error instead of collapsing to empty', async () => { + // Why: parent design doc §3 — a 403 on a private upstream must not + // masquerade as "No issues". The envelope carries an error the UI can + // render as a banner with retry, not a silent empty list. + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockRejectedValueOnce( + new Error('HTTP 403: Resource not accessible by integration') + ) + + const result = await listIssues('/repo-root', 5) + + expect(result.items).toEqual([]) + expect(result.error?.type).toBe('permission_denied') + }) + + it('creates issues in the issue owner/repo', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 924, + html_url: 'https://github.com/stablyai/orca/issues/924' + }) + }) + + await expect(createIssue('/repo-root', 'New issue', 'Body')).resolves.toEqual({ + ok: true, + number: 924, + url: 'https://github.com/stablyai/orca/issues/924' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '-X', + 'POST', + 'repos/stablyai/orca/issues', + '--raw-field', + 'title=New issue', + '--raw-field', + 'body=Body' + ], + { cwd: '/repo-root' } + ) + }) +}) diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index 73b0902d8ea..d9e2b7f5e09 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -1,19 +1,47 @@ +/* eslint-disable max-lines -- Why: co-locating issue list/create/update/ +comment operations keeps the shared acquire/release + error-classification +pattern obvious. Each function is short; the file is long because the +surface is broad. */ import type { + ClassifiedError, GitHubAssignableUser, GitHubCommentResult, GitHubIssueUpdate, IssueInfo, + IssueSourcePreference, PRComment } from '../../shared/types' import { mapIssueInfo } from './mappers' -import { ghExecFileAsync, acquire, release, getOwnerRepo, classifyGhError } from './gh-utils' +// prettier-ignore +import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError } from './gh-utils' + +// Why: distinguishes a successful-empty listing from a failed fetch. The +// previous `catch { return [] }` conflated a 403 on a private upstream with an +// empty backlog. Callers decide how to surface `error`. +// +// Why no `fellBack` here: the fell-back signal for the renderer toast rides on +// `ListWorkItemsResult.issueSourceFellBack` (the Tasks list's envelope). The +// only consumer of `listIssues` — the `gh:listIssues` IPC handler — unwraps +// to `.items` and has no UI hook to surface a fallback toast. Adding a dead +// `fellBack` field here invited drift between the JSDoc promise and reality. +export type IssueListResult = { + items: IssueInfo[] + error?: ClassifiedError +} /** * Get a single issue by number. * Uses gh api --cache so 304 Not Modified responses don't count against the rate limit. + * + * Why this path doesn't take a preference: linked-issue lookups persist a + * number to a worktree at creation time. Routing detail lookups through the + * live per-repo preference would silently flip an existing link to a + * different repo after the user toggled the selector — the opposite of what + * #1186 / the parent design doc guard against. List and create paths honor + * preference; number-resolution stays on the heuristic. */ export async function getIssue(repoPath: string, issueNumber: number): Promise { - const ownerRepo = await getOwnerRepo(repoPath) + const ownerRepo = await getIssueOwnerRepo(repoPath) await acquire() try { if (ownerRepo) { @@ -46,9 +74,20 @@ export async function getIssue(repoPath: string, issueNumber: number): Promise { - const ownerRepo = await getOwnerRepo(repoPath) +export async function listIssues( + repoPath: string, + limit = 20, + preference?: IssueSourcePreference +): Promise { + const { source: ownerRepo } = await resolveIssueSource(repoPath, preference) await acquire() try { if (ownerRepo) { @@ -61,8 +100,16 @@ export async function listIssues(repoPath: string, limit = 20): Promise mapIssueInfo(d as Parameters[0])) + const data = JSON.parse(stdout) as Record[] + // Why: the GitHub REST `/repos/{owner}/{repo}/issues` endpoint returns + // pull requests alongside issues (PRs carry a `pull_request` key). + // Strip them here so `listIssues` only returns true issues, matching the + // filter applied in `listRecentWorkItems` (src/main/github/client.ts). + return { + items: data + .filter((d) => !('pull_request' in d)) + .map((d) => mapIssueInfo(d as Parameters[0])) + } } // Fallback for non-GitHub remotes const { stdout } = await ghExecFileAsync( @@ -70,9 +117,15 @@ export async function listIssues(repoPath: string, limit = 20): Promise mapIssueInfo(d as Parameters[0])) - } catch { - return [] + return { + items: data.map((d) => mapIssueInfo(d as Parameters[0])) + } + } catch (err) { + const stderr = err instanceof Error ? err.message : String(err) + return { + items: [], + error: classifyListIssuesError(stderr) + } } finally { release() } @@ -86,13 +139,14 @@ export async function listIssues(repoPath: string, limit = 20): Promise { const trimmedTitle = title.trim() if (!trimmedTitle) { return { ok: false, error: 'Title is required' } } - const ownerRepo = await getOwnerRepo(repoPath) + const { source: ownerRepo } = await resolveIssueSource(repoPath, preference) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -131,13 +185,22 @@ export async function createIssue( /** * Update an existing GitHub issue. Fans out to separate gh commands for * state changes vs field edits since `gh issue edit` does not support state. + * + * Why this path doesn't take a preference (mirrors `getIssue`): mutations + * target an issue number already bound to a worktree / linked elsewhere in + * the UI. Routing an update through the live per-repo preference would let + * a user open upstream#N, toggle the selector to origin, save, and silently + * write to origin#N — a different issue (or 404). That is the exact + * silent-source-switch class of wrongness #1186 / the parent design doc + * guard against. List and create paths honor preference; mutations stay on + * the heuristic `getIssueOwnerRepo`. */ export async function updateIssue( repoPath: string, issueNumber: number, updates: GitHubIssueUpdate ): Promise<{ ok: true } | { ok: false; error: string }> { - const ownerRepo = await getOwnerRepo(repoPath) + const ownerRepo = await getIssueOwnerRepo(repoPath) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -207,12 +270,24 @@ export async function updateIssue( return { ok: true } } +/** + * Add a comment to an existing GitHub issue. + * + * Why this path doesn't take a preference (mirrors `getIssue` / `updateIssue`): + * a comment is posted against an issue number already bound to a worktree or + * surfaced from a prior read. Routing through the live per-repo preference + * would let a user read upstream#N, toggle the selector to origin, and have + * their reply silently post on origin#N — a different issue entirely. That + * is the same silent-source-switch class of wrongness #1186 / the parent + * design doc guard against. List and create paths honor preference; + * mutations stay on the heuristic `getIssueOwnerRepo`. + */ export async function addIssueComment( repoPath: string, issueNumber: number, body: string ): Promise { - const ownerRepo = await getOwnerRepo(repoPath) + const ownerRepo = await getIssueOwnerRepo(repoPath) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -254,8 +329,11 @@ export async function addIssueComment( } } -export async function listLabels(repoPath: string): Promise { - const ownerRepo = await getOwnerRepo(repoPath) +export async function listLabels( + repoPath: string, + preference?: IssueSourcePreference +): Promise { + const { source: ownerRepo } = await resolveIssueSource(repoPath, preference) if (!ownerRepo) { return [] } @@ -282,8 +360,11 @@ export async function listLabels(repoPath: string): Promise { } } -export async function listAssignableUsers(repoPath: string): Promise { - const ownerRepo = await getOwnerRepo(repoPath) +export async function listAssignableUsers( + repoPath: string, + preference?: IssueSourcePreference +): Promise { + const { source: ownerRepo } = await resolveIssueSource(repoPath, preference) if (!ownerRepo) { return [] } diff --git a/src/main/github/project-view.test.ts b/src/main/github/project-view.test.ts new file mode 100644 index 00000000000..383fcb1d412 --- /dev/null +++ b/src/main/github/project-view.test.ts @@ -0,0 +1,144 @@ +// Why: covers the recent fixes — +// (a) network errors must NOT be misclassified as not_found ("could not +// resolve host" partially overlaps "could not resolve to a"), +// (b) repo slug validation must accept names with leading underscore +// (GitHub allows them, e.g. `_internal`), +// (c) owner slug validation must reject `.`/`_` (GitHub disallows them in +// usernames/orgs), +// (d) parseProjectPaste shorthand owner-only alphabet matches the renderer. +import { describe, expect, it } from 'vitest' +import { + classifyProjectError, + isValidOwnerSlug, + isValidRepoSlug, + parseProjectPaste +} from './project-view' + +describe('classifyProjectError', () => { + it('classifies HTTP 404 as not_found', () => { + expect(classifyProjectError('HTTP 404 Not Found', '').type).toBe('not_found') + }) + + it('classifies "Could not resolve to a User" as not_found', () => { + expect(classifyProjectError('Could not resolve to a User with the login of foo', '').type).toBe( + 'not_found' + ) + }) + + it('classifies "could not resolve host" as network_error, NOT not_found', () => { + // Why: this was the bug — substring "could not resolve" overlaps. The + // network branch must run before not_found, and the not_found check + // must require "to a " to disambiguate. + expect(classifyProjectError('could not resolve host: api.github.com', '').type).toBe( + 'network_error' + ) + }) + + it('classifies "dial tcp" timeouts as network_error', () => { + expect(classifyProjectError('dial tcp 140.82.112.3:443: i/o timeout', '').type).toBe( + 'network_error' + ) + }) + + it('classifies rate-limit text as rate_limited', () => { + expect(classifyProjectError('API rate limit exceeded for user', '').type).toBe('rate_limited') + }) + + it('classifies missing-scope as scope_missing', () => { + expect( + classifyProjectError('your token has not been granted the required scopes', '').type + ).toBe('scope_missing') + }) + + it('classifies auth-required when gh is not signed in', () => { + expect(classifyProjectError('gh auth login required', '').type).toBe('auth_required') + }) +}) + +describe('isValidOwnerSlug', () => { + it('accepts plain alphanumerics and hyphens', () => { + expect(isValidOwnerSlug('acme')).toBe(true) + expect(isValidOwnerSlug('acme-co')).toBe(true) + expect(isValidOwnerSlug('user1')).toBe(true) + }) + + it('rejects underscore (GitHub disallows it in usernames/orgs)', () => { + expect(isValidOwnerSlug('_acme')).toBe(false) + expect(isValidOwnerSlug('acme_co')).toBe(false) + }) + + it('rejects leading hyphen and dot', () => { + expect(isValidOwnerSlug('-acme')).toBe(false) + expect(isValidOwnerSlug('.acme')).toBe(false) + }) + + it('rejects empty and slash-containing values', () => { + expect(isValidOwnerSlug('')).toBe(false) + expect(isValidOwnerSlug('a/b')).toBe(false) + expect(isValidOwnerSlug(123)).toBe(false) + }) +}) + +describe('isValidRepoSlug', () => { + it('accepts leading underscore (GitHub allows it for repo names)', () => { + expect(isValidRepoSlug('_internal')).toBe(true) + }) + + it('accepts leading dot', () => { + expect(isValidRepoSlug('.github')).toBe(true) + }) + + it('accepts dots, dashes, underscores anywhere', () => { + expect(isValidRepoSlug('repo-name')).toBe(true) + expect(isValidRepoSlug('repo.name')).toBe(true) + expect(isValidRepoSlug('repo_name')).toBe(true) + }) + + it('rejects reserved single/double dot', () => { + expect(isValidRepoSlug('.')).toBe(false) + expect(isValidRepoSlug('..')).toBe(false) + }) + + it('rejects path separators and empty', () => { + expect(isValidRepoSlug('a/b')).toBe(false) + expect(isValidRepoSlug('')).toBe(false) + }) +}) + +describe('parseProjectPaste', () => { + it('parses owner/number shorthand', () => { + expect(parseProjectPaste('acme/42')).toEqual({ kind: 'bare', owner: 'acme', number: 42 }) + }) + + it('rejects shorthand with underscore in owner (renderer parity)', () => { + // Why: the renderer's parser uses `[A-Za-z0-9][A-Za-z0-9-]*` for owner + // (matches OWNER_SLUG_RE). Both sides must reject the same inputs. + expect(parseProjectPaste('co_op/45')).toBeNull() + }) + + it('parses org URL with view number', () => { + expect(parseProjectPaste('https://github.com/orgs/acme/projects/42/views/3')).toEqual({ + kind: 'org', + owner: 'acme', + number: 42, + viewNumber: 3 + }) + }) + + it('parses user URL', () => { + expect(parseProjectPaste('https://github.com/users/octocat/projects/1')).toEqual({ + kind: 'user', + owner: 'octocat', + number: 1 + }) + }) + + it('rejects URLs whose owner has invalid characters', () => { + expect(parseProjectPaste('https://github.com/orgs/co_op/projects/1')).toBeNull() + }) + + it('returns null for empty input', () => { + expect(parseProjectPaste('')).toBeNull() + expect(parseProjectPaste(' ')).toBeNull() + }) +}) diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts new file mode 100644 index 00000000000..1a06723e90c Binary files /dev/null and b/src/main/github/project-view.ts differ diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts new file mode 100644 index 00000000000..52509c30652 --- /dev/null +++ b/src/main/github/project-view/internals.ts @@ -0,0 +1,378 @@ +/* eslint-disable max-lines -- Why: shared infrastructure for project-view — +slug validation, error classification, runGraphql/runRest, and rate-limit +synthesis. Co-located so the read and write paths observe identical +classification semantics. */ +// Why: `ghExecFileAsync` (WSL-aware, retry-enabled) is the single spawn site +// for gh calls. The legacy plain `execFileAsync` is NOT used here — routing +// every gh call through the runner gives us transient-5xx retry, WSL path +// translation, and a single hook point for future quota tracking. +import { acquire, release } from '../gh-utils' +import { extractExecError, ghExecFileAsync } from '../../git/runner' +import { rateLimitGuard, noteRateLimitSpend, type RateLimitBucketKind } from '../rate-limit' +import type { GitHubProjectViewError } from '../../../shared/github-project-types' + +export { acquire, release, extractExecError, ghExecFileAsync, rateLimitGuard, noteRateLimitSpend } +export type { RateLimitBucketKind } + +// ─── Slug validation ────────────────────────────────────────────────── + +// Why: GitHub usernames/org logins disallow `_`, `.`, leading `-`. Repo names +// are looser — they allow leading `_`, `.`, `-` (`.` and `..` reserved). We +// validate each separately so untrusted Project row data (`nameWithOwner`) +// can't become an arbitrary REST path while still accepting realistic repo +// names like `_internal` or `.github`. +const OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/ +const REPO_SLUG_RE = /^[A-Za-z0-9._-]+$/ +const REPO_SLUG_RESERVED = new Set(['.', '..']) + +export function isValidOwnerSlug(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && OWNER_SLUG_RE.test(value) +} + +export function isValidRepoSlug(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + REPO_SLUG_RE.test(value) && + !REPO_SLUG_RESERVED.has(value) + ) +} + +// Backwards-compatible alias for callers that don't distinguish owner vs repo. +// Prefer `isValidOwnerSlug` / `isValidRepoSlug` at new call sites. +export function isValidSlug(value: unknown): value is string { + return isValidOwnerSlug(value) || isValidRepoSlug(value) +} + +export function assertSlug( + value: unknown, + field: 'owner' | 'repo' +): { ok: true; slug: string } | { ok: false; error: GitHubProjectViewError } { + const valid = field === 'owner' ? isValidOwnerSlug(value) : isValidRepoSlug(value) + if (!valid) { + return { + ok: false, + error: { + type: 'validation_error', + message: `Invalid ${field}: "${String(value)}" is not a valid GitHub slug.` + } + } + } + return { ok: true, slug: value as string } +} + +export function assertPositiveInt( + value: unknown, + field: string +): { ok: true; n: number } | { ok: false; error: GitHubProjectViewError } { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) { + return { + ok: false, + error: { + type: 'validation_error', + message: `Invalid ${field}: must be a positive integer.` + } + } + } + return { ok: true, n: value } +} + +export function validateSlugArgs( + owner: unknown, + repo: unknown +): { ok: true } | { ok: false; error: GitHubProjectViewError } { + const o = assertSlug(owner, 'owner') + if (!o.ok) { + return { ok: false, error: o.error } + } + const r = assertSlug(repo, 'repo') + if (!r.ok) { + return { ok: false, error: r.error } + } + return { ok: true } +} + +// ─── Error classification ────────────────────────────────────────────── + +export type GhGraphqlErrorShape = { + type?: string + message?: string + path?: (string | number)[] + extensions?: { code?: string } +} + +export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlErrorShape[] { + // `gh api graphql` prints the response JSON to stdout even on GraphQL + // errors, and the stderr carries a summary. Try stdout first; if parsing + // fails, fall back to stderr. + const sources = [stdout, stderr] + for (const src of sources) { + if (!src) { + continue + } + try { + const parsed = JSON.parse(src) as { errors?: GhGraphqlErrorShape[] } + if (parsed.errors && parsed.errors.length > 0) { + return parsed.errors + } + } catch { + // not JSON — continue + } + } + return [] +} + +export function errorsIndicateParentField(errors: GhGraphqlErrorShape[], stderr: string): boolean { + const lower = stderr.toLowerCase() + // Preview-header shape: gh returns a 4xx with "preview" in the message. + if (lower.includes('preview') && lower.includes('parent')) { + return true + } + return errors.some((e) => { + const type = (e.type ?? '').toUpperCase() + if (type === 'FIELD_NOT_FOUND' || type === 'UNDEFINED_FIELD' || type === 'FIELD_ERRORS') { + const tail = e.path?.at(-1) + if (tail === 'parent') { + return true + } + // FIELD_ERRORS often omits `path`; match on message for the parent field. + if ((e.message ?? '').toLowerCase().includes('parent')) { + return true + } + } + return false + }) +} + +export function classifyProjectError(stderr: string, stdout: string): GitHubProjectViewError { + const errors = extractGraphqlErrors(stderr, stdout) + const s = stderr.toLowerCase() + + // Auth + if ( + s.includes('authentication required') || + s.includes('not logged in') || + s.includes('gh auth login') + ) { + return { + type: 'auth_required', + message: 'Sign in to GitHub to load project tasks. Run `gh auth login`.' + } + } + // Scope + if ( + s.includes('missing required scope') || + s.includes('your token has not been granted') || + (s.includes('resource not accessible') && (s.includes('project') || s.includes('scope'))) + ) { + return { + type: 'scope_missing', + message: + 'GitHub project access needs additional scopes. Run `gh auth refresh -s project -s read:org -s repo`.' + } + } + // Rate limit + if (s.includes('rate limit') || s.includes('api rate limit exceeded')) { + return { type: 'rate_limited', message: 'GitHub rate limit hit. Try again in a few minutes.' } + } + // Network — checked BEFORE not_found because DNS failures surface as + // "could not resolve host", which would otherwise be partially matched by + // the not_found branch's "could not resolve" check. Substring matching here + // is a one-way trapdoor: a real GraphQL "Could not resolve to a User…" + // error always contains "to a", so we tighten the not_found check below to + // require that token. + if ( + s.includes('timeout') || + s.includes('no such host') || + s.includes('network') || + s.includes('could not resolve host') || + s.includes('dial tcp') + ) { + return { type: 'network_error', message: 'Network error — check your connection.' } + } + // Not found + if ( + s.includes('http 404') || + errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') || + // Why: GitHub uses "to an" for vowel-leading types ("to an Issue", "to + // an Organization") and "to a" otherwise. The previous singular-only + // check missed the "an" variants when gh emits only the stderr summary + // without a structured GraphQL error array. See bug-scan finding 3. + /could not resolve to an? /.test(s) + ) { + const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') + return { + type: 'not_found', + message: 'Project or view not found.', + details: firstNotFound + ? { path: firstNotFound.path, code: firstNotFound.extensions?.code } + : undefined + } + } + // Validation + if (s.includes('http 422') || s.includes('validation failed')) { + return { type: 'validation_error', message: `Invalid request — ${stderr.trim()}` } + } + // GraphQL error with structured info + if (errors.length > 0) { + const first = errors[0] + return { + type: 'unknown', + message: first.message ?? 'Unknown GraphQL error.', + details: { path: first.path, code: first.extensions?.code } + } + } + // Why: don't leak full stderr to the UI — it can include verbose request + // dumps with header diagnostics. Truncate to the first non-empty line and + // cap length so unexpected diagnostics stay readable but bounded. + const firstLine = + stderr + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0) ?? '' + const safe = firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine + return { + type: 'unknown', + message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.' + } +} + +export function driftError( + reason: string, + details?: { path?: (string | number)[]; code?: string } +): GitHubProjectViewError { + return { type: 'schema_drift', message: `Could not read this project view: ${reason}.`, details } +} + +// Why: the rate-limit circuit breaker short-circuits before we spawn `gh` +// when the cached snapshot says we're below the safety floor. Synthesize the +// same `rate_limited` error shape as the post-hoc classifier so the UI path +// is unchanged. We DO NOT fail open here when there's no cached snapshot — +// rateLimitGuard already handles that case (returns `blocked:false`). +export function rateLimitedError(blocked: { + remaining: number + limit: number + resetAt: number +}): GitHubProjectViewError { + const resetIn = Math.max(0, blocked.resetAt - Math.floor(Date.now() / 1000)) + const mins = Math.ceil(resetIn / 60) + return { + type: 'rate_limited', + message: `GitHub rate limit nearly exhausted (${blocked.remaining}/${blocked.limit} left). Resets in ~${mins}m.` + } +} + +// ─── Low-level gh api graphql invocation ─────────────────────────────── + +export type GraphqlVars = Record + +export async function runGraphql( + query: string, + vars: GraphqlVars, + cwd?: string +): Promise< + | { ok: true; data: T } + | { ok: false; error: GitHubProjectViewError; raw: { stderr: string; stdout: string } } +> { + const guard = rateLimitGuard('graphql') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard), raw: { stderr: '', stdout: '' } } + } + // Why: build argv as an array. `-f` for strings (including numbers passed + // as strings), `-F` coerces to typed. We use `-f` uniformly and coerce in + // the query via Int! casts, because `gh` can confuse empty strings. + const args: string[] = ['api', 'graphql', '-f', `query=${query}`] + for (const [k, v] of Object.entries(vars)) { + if (typeof v === 'number' || typeof v === 'boolean') { + args.push('-F', `${k}=${String(v)}`) + } else { + args.push('-f', `${k}=${v}`) + } + } + await acquire() + noteRateLimitSpend('graphql') + try { + const { stdout, stderr } = await ghExecFileAsync(args, { + encoding: 'utf-8', + ...(cwd ? { cwd } : {}) + }) + try { + const parsed = JSON.parse(stdout) as { data?: T; errors?: GhGraphqlErrorShape[] } + if (parsed.errors && parsed.errors.length > 0) { + return { + ok: false, + error: classifyProjectError(stderr, stdout), + raw: { stderr, stdout } + } + } + if (parsed.data === undefined) { + return { + ok: false, + error: driftError('response missing data'), + raw: { stderr, stdout } + } + } + return { ok: true, data: parsed.data } + } catch (parseErr) { + return { + ok: false, + error: driftError( + `failed to parse response (${parseErr instanceof Error ? parseErr.message : String(parseErr)})` + ), + raw: { stderr, stdout } + } + } + } catch (err) { + // gh executable failures (non-zero exit). Read stderr/stdout from the + // exec rejection's explicit fields — `err.message` may truncate stderr. + const { stderr, stdout: maybeStdout } = extractExecError(err) + return { + ok: false, + error: classifyProjectError(stderr, maybeStdout), + raw: { stderr, stdout: maybeStdout } + } + } finally { + release() + } +} + +export async function runRest( + args: string[], + cwd?: string, + bucket: RateLimitBucketKind = 'core', + options?: { expectEmpty?: boolean } +): Promise<{ ok: true; data: T } | { ok: false; error: GitHubProjectViewError }> { + const guard = rateLimitGuard(bucket) + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } + await acquire() + noteRateLimitSpend(bucket) + try { + const { stdout, stderr } = await ghExecFileAsync(['api', ...args], { + encoding: 'utf-8', + ...(cwd ? { cwd } : {}) + }) + // Why: 204/empty-body endpoints (DELETE label, DELETE comment) return no + // body. Treat empty stdout as success rather than misclassifying the + // unparseable response as 'unknown' — which the caller would otherwise + // need to special-case and risks masking real failures whose stderr the + // classifier also tags as 'unknown'. + if (options?.expectEmpty && stdout.trim() === '') { + return { ok: true, data: undefined as T } + } + try { + return { ok: true, data: JSON.parse(stdout) as T } + } catch { + return { + ok: false, + error: { type: 'unknown', message: `Unexpected REST response: ${stderr.trim()}` } + } + } + } catch (err) { + const { stderr, stdout: maybeStdout } = extractExecError(err) + return { ok: false, error: classifyProjectError(stderr, maybeStdout) } + } finally { + release() + } +} diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts new file mode 100644 index 00000000000..cd4e0944fc9 --- /dev/null +++ b/src/main/github/project-view/mutations.ts @@ -0,0 +1,841 @@ +/* eslint-disable max-lines -- Why: slug-addressed mutations + work-item +details share the validate/runRest/runGraphql plumbing with the read path. +Keeping them together preserves a single review surface for the write side. */ +import { + acquire, + release, + extractExecError, + ghExecFileAsync, + rateLimitGuard, + noteRateLimitSpend, + classifyProjectError, + rateLimitedError, + runGraphql, + runRest, + validateSlugArgs, + assertPositiveInt, + type GraphqlVars +} from './internals' +import type { GitHubAssignableUser, GitHubWorkItemDetails, PRComment } from '../../../shared/types' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GitHubProjectCommentMutationResult, + GitHubProjectFieldMutationValue, + GitHubProjectMutationResult, + ListAssignableUsersBySlugArgs, + ListAssignableUsersBySlugResult, + ListIssueTypesBySlugArgs, + ListIssueTypesBySlugResult, + ListLabelsBySlugArgs, + ListLabelsBySlugResult, + ProjectWorkItemDetailsBySlugArgs, + ProjectWorkItemDetailsBySlugResult, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdatePullRequestBySlugArgs, + UpdateProjectItemFieldArgs +} from '../../../shared/github-project-types' + +// ─── Project field mutations ────────────────────────────────────────── + +class UnknownFieldMutationKindError extends Error { + constructor(kind: string) { + super(`Unknown project field mutation kind: ${kind}`) + } +} + +function graphqlValueForFieldMutation(value: GitHubProjectFieldMutationValue): string { + // Serialize the value fragment for the GraphQL mutation. We use GraphQL + // variables for every dynamic piece, so here we only pick the variable name + // to reference per value kind. + switch (value.kind) { + case 'single-select': + return 'singleSelectOptionId: $value' + case 'iteration': + return 'iterationId: $value' + case 'text': + return 'text: $value' + case 'number': + return 'number: $value' + case 'date': + return 'date: $value' + default: + // Why: defensive default. If a new mutation kind is added to the type + // but not handled here, returning undefined would silently produce a + // broken GraphQL query. Throw so updateProjectItemFieldValue can map it + // to a validation_error rather than dispatching a malformed mutation. + throw new UnknownFieldMutationKindError((value as { kind: string }).kind) + } +} + +function mutationValueVar(value: GitHubProjectFieldMutationValue): { + type: string + val: string | number +} { + switch (value.kind) { + case 'single-select': + return { type: 'String!', val: value.optionId } + case 'iteration': + return { type: 'String!', val: value.iterationId } + case 'text': + return { type: 'String!', val: value.text } + case 'number': + return { type: 'Float!', val: value.number } + case 'date': + return { type: 'Date!', val: value.date } + default: + // Why: see graphqlValueForFieldMutation — surface unknown kinds loudly + // instead of returning undefined and dispatching an invalid mutation. + throw new UnknownFieldMutationKindError((value as { kind: string }).kind) + } +} + +export async function updateProjectItemFieldValue( + args: UpdateProjectItemFieldArgs +): Promise { + if (!args.projectId || !args.itemId || !args.fieldId) { + return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } + } + let valFrag: string + let valVar: { type: string; val: string | number } + try { + valFrag = graphqlValueForFieldMutation(args.value) + valVar = mutationValueVar(args.value) + } catch (err) { + if (err instanceof UnknownFieldMutationKindError) { + return { ok: false, error: { type: 'validation_error', message: err.message } } + } + throw err + } + const query = ` + mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $value:${valVar.type}) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { ${valFrag} } + }) { projectV2Item { id } } + } + ` + const vars: GraphqlVars = { + projectId: args.projectId, + itemId: args.itemId, + fieldId: args.fieldId, + value: valVar.val + } + const res = await runGraphql(query, vars) + if (!res.ok) { + return { ok: false, error: res.error } + } + return { ok: true } +} + +export async function clearProjectItemFieldValue( + args: ClearProjectItemFieldArgs +): Promise { + if (!args.projectId || !args.itemId || !args.fieldId) { + return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } + } + const query = ` + mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!) { + clearProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + }) { projectV2Item { id } } + } + ` + const res = await runGraphql(query, { + projectId: args.projectId, + itemId: args.itemId, + fieldId: args.fieldId + }) + if (!res.ok) { + return { ok: false, error: res.error } + } + return { ok: true } +} + +// ─── Slug-addressed issue/PR mutations ──────────────────────────────── + +export async function updateIssueBySlug( + args: UpdateIssueBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) { + return { ok: false, error: n.error } + } + if (!args.updates || typeof args.updates !== 'object') { + return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } + } + const { title, body, state, addLabels, removeLabels, addAssignees, removeAssignees } = + args.updates + + // Title / body / state go through PATCH /repos/{owner}/{repo}/issues/{n}. + // Labels/assignees go through their dedicated endpoints. + const base = `repos/${args.owner}/${args.repo}/issues/${args.number}` + + // 1) PATCH body + if (title !== undefined || body !== undefined || state !== undefined) { + const patchArgs: string[] = ['-X', 'PATCH', base] + if (title !== undefined) { + patchArgs.push('--raw-field', `title=${title}`) + } + if (body !== undefined) { + patchArgs.push('--raw-field', `body=${body}`) + } + if (state !== undefined) { + patchArgs.push('--raw-field', `state=${state}`) + } + const r = await runRest(patchArgs) + if (!r.ok) { + return { ok: false, error: r.error } + } + } + + // 2) Labels — collapse multi-delete fan-out into a single PUT when removing + // >1 label. PUT /labels replaces the entire label set, so we fetch the + // current labels first and compute the resulting set client-side. This + // turns an N-delete + 1-add (=N+1 calls) into 1-fetch + 1-PUT (=2 calls) + // once removeLabels has more than one entry, capping the cost at 2 even + // for a "remove all 20 labels" mutation. + const removeCount = removeLabels?.length ?? 0 + const addCount = addLabels?.length ?? 0 + if (removeCount > 1) { + type RawLabelResp = { name?: string }[] + const fetched = await runRest(['-X', 'GET', `${base}/labels`]) + if (!fetched.ok) { + return { ok: false, error: fetched.error } + } + const currentNames = new Set( + fetched.data.map((l) => l.name).filter((n): n is string => typeof n === 'string') + ) + for (const l of removeLabels ?? []) { + currentNames.delete(l) + } + for (const l of addLabels ?? []) { + currentNames.add(l) + } + if (currentNames.size === 0) { + // Why: `gh api -X PUT` with no `--raw-field` arguments sends an empty + // body — GitHub does NOT interpret that as "clear labels". The + // dedicated DELETE endpoint is the documented way to remove all + // labels in a single call. + const r = await runRest(['-X', 'DELETE', `${base}/labels`], undefined, 'core', { + expectEmpty: true + }) + if (!r.ok && r.error.type !== 'not_found') { + return { ok: false, error: r.error } + } + } else { + const putArgs = ['-X', 'PUT', `${base}/labels`] + for (const name of currentNames) { + putArgs.push('--raw-field', `labels[]=${name}`) + } + const r = await runRest(putArgs) + if (!r.ok) { + return { ok: false, error: r.error } + } + } + } else { + if (addCount > 0) { + const restArgs = ['-X', 'POST', `${base}/labels`] + for (const l of addLabels ?? []) { + restArgs.push('--raw-field', `labels[]=${l}`) + } + const r = await runRest(restArgs) + if (!r.ok) { + return { ok: false, error: r.error } + } + } + if (removeCount === 1) { + const r = await runRest( + ['-X', 'DELETE', `${base}/labels/${encodeURIComponent(removeLabels![0])}`], + undefined, + 'core', + { expectEmpty: true } + ) + if (!r.ok && r.error.type !== 'not_found') { + return { ok: false, error: r.error } + } + } + } + + // 3) Assignees — POST and DELETE both accept arrays in a single call, so + // add/remove are at most 2 calls regardless of array size. + if (addAssignees && addAssignees.length > 0) { + const restArgs = ['-X', 'POST', `${base}/assignees`] + for (const u of addAssignees) { + restArgs.push('--raw-field', `assignees[]=${u}`) + } + const r = await runRest(restArgs) + if (!r.ok) { + return { ok: false, error: r.error } + } + } + if (removeAssignees && removeAssignees.length > 0) { + const restArgs = ['-X', 'DELETE', `${base}/assignees`] + for (const u of removeAssignees) { + restArgs.push('--raw-field', `assignees[]=${u}`) + } + const r = await runRest(restArgs) + if (!r.ok) { + return { ok: false, error: r.error } + } + } + return { ok: true } +} + +export async function updatePullRequestBySlug( + args: UpdatePullRequestBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) { + return { ok: false, error: n.error } + } + if (!args.updates || typeof args.updates !== 'object') { + return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } + } + const patchArgs: string[] = [ + '-X', + 'PATCH', + `repos/${args.owner}/${args.repo}/pulls/${args.number}` + ] + // Why: count fields explicitly rather than inferring from patchArgs.length — + // adding a future header/flag arg silently breaks an array-length check. + let fieldCount = 0 + if (args.updates.title !== undefined) { + patchArgs.push('--raw-field', `title=${args.updates.title}`) + fieldCount++ + } + if (args.updates.body !== undefined) { + patchArgs.push('--raw-field', `body=${args.updates.body}`) + fieldCount++ + } + if (fieldCount === 0) { + // No fields to update — nothing to do. + return { ok: true } + } + const r = await runRest(patchArgs) + if (!r.ok) { + return { ok: false, error: r.error } + } + return { ok: true } +} + +type RawIssueCommentResponse = { + id?: number + user?: { login?: string; avatar_url?: string; type?: string } | null + body?: string + created_at?: string + html_url?: string +} + +function mapIssueComment(data: RawIssueCommentResponse, fallbackBody: string): PRComment { + return { + id: data.id ?? Date.now(), + author: data.user?.login ?? 'You', + authorAvatarUrl: data.user?.avatar_url ?? '', + body: data.body ?? fallbackBody, + createdAt: data.created_at ?? new Date().toISOString(), + url: data.html_url ?? '', + isBot: data.user?.type === 'Bot' + } +} + +export async function addIssueCommentBySlug( + args: AddIssueCommentBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) { + return { ok: false, error: n.error } + } + if (typeof args.body !== 'string' || !args.body.trim()) { + return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } + } + const r = await runRest([ + '-X', + 'POST', + `repos/${args.owner}/${args.repo}/issues/${args.number}/comments`, + '--raw-field', + `body=${args.body}` + ]) + if (!r.ok) { + return { ok: false, error: r.error } + } + return { ok: true, comment: mapIssueComment(r.data, args.body) } +} + +export async function updateIssueCommentBySlug( + args: UpdateIssueCommentBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.commentId, 'commentId') + if (!n.ok) { + return { ok: false, error: n.error } + } + if (typeof args.body !== 'string' || !args.body.trim()) { + return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } + } + const r = await runRest([ + '-X', + 'PATCH', + `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`, + '--raw-field', + `body=${args.body}` + ]) + if (!r.ok) { + return { ok: false, error: r.error } + } + return { ok: true } +} + +export async function deleteIssueCommentBySlug( + args: DeleteIssueCommentBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.commentId, 'commentId') + if (!n.ok) { + return { ok: false, error: n.error } + } + const r = await runRest( + ['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`], + undefined, + 'core', + { expectEmpty: true } + ) + if (!r.ok) { + return { ok: false, error: r.error } + } + return { ok: true } +} + +// ─── Slug-addressed picker sources ──────────────────────────────────── + +export async function listLabelsBySlug( + args: ListLabelsBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const guard = rateLimitGuard('core') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } + await acquire() + // Why: `--paginate` may fan out to multiple pages; we can only reasonably + // estimate a 1-call spend up front. The next probe will reconcile. + noteRateLimitSpend('core') + try { + const { stdout } = await ghExecFileAsync( + ['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'], + { encoding: 'utf-8' } + ) + return { + ok: true, + labels: stdout + .trim() + .split('\n') + .filter((l) => l.length > 0) + } + } catch (err) { + const { stderr, stdout: maybeStdout } = extractExecError(err) + return { ok: false, error: classifyProjectError(stderr, maybeStdout) } + } finally { + release() + } +} + +export async function listAssignableUsersBySlug( + args: ListAssignableUsersBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + // Seed logins merge after the fetch so callers can include currently-visible + // assignees even if the repo participant search is sparse. + const result: GitHubAssignableUser[] = [] + const guard = rateLimitGuard('core') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } + await acquire() + noteRateLimitSpend('core') + try { + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--paginate', + `repos/${args.owner}/${args.repo}/assignees`, + '--jq', + '.[] | {login: .login, name: null, avatarUrl: .avatar_url}' + ], + { encoding: 'utf-8' } + ) + for (const line of stdout + .trim() + .split('\n') + .filter((l) => l.length > 0)) { + try { + const u = JSON.parse(line) as { login?: string; avatarUrl?: string; name?: string | null } + if (typeof u.login === 'string') { + result.push({ login: u.login, name: u.name ?? null, avatarUrl: u.avatarUrl ?? '' }) + } + } catch { + // skip malformed jq line + } + } + } catch (err) { + const { stderr } = extractExecError(err) + return { ok: false, error: classifyProjectError(stderr, '') } + } finally { + release() + } + if (args.seedLogins) { + const seen = new Set(result.map((u) => u.login)) + for (const login of args.seedLogins) { + if (typeof login === 'string' && !seen.has(login)) { + result.push({ login, name: null, avatarUrl: '' }) + seen.add(login) + } + } + } + return { ok: true, users: result } +} + +// Why: Issue Types are a repo-level taxonomy (Bug/Feature/Task/etc) only +// available on repos opted into typed-issues. Empty list (or schema_drift on +// older GitHub deployments) is the legitimate "this repo doesn't use issue +// types" signal — callers should treat it as "no editor". +export async function listIssueTypesBySlug( + args: ListIssueTypesBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const query = ` + query($owner:String!, $repo:String!) { + repository(owner:$owner, name:$repo) { + issueTypes(first:50) { + nodes { id name color description } + } + } + } + ` + const res = await runGraphql<{ + repository?: { + issueTypes?: { + nodes?: ({ + id?: string + name?: string + color?: string | null + description?: string | null + } | null)[] + } | null + } | null + }>(query, { owner: args.owner, repo: args.repo }) + if (!res.ok) { + // Why: repos without issue types respond with a GraphQL error claiming the + // `issueTypes` field is unknown. Map that to an empty list so the UI shows + // "no editor" instead of an angry banner. + if (res.error.type === 'schema_drift' || res.error.type === 'validation_error') { + return { ok: true, types: [] } + } + return { ok: false, error: res.error } + } + const nodes = res.data.repository?.issueTypes?.nodes ?? [] + const types = nodes + .filter( + (n): n is NonNullable => + n !== null && typeof n.id === 'string' && typeof n.name === 'string' + ) + .map((n) => ({ + id: n.id as string, + name: n.name as string, + color: typeof n.color === 'string' ? n.color : null, + description: typeof n.description === 'string' ? n.description : null + })) + return { ok: true, types } +} + +export async function updateIssueTypeBySlug( + args: UpdateIssueTypeBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) { + return { ok: false, error: n.error } + } + // Why: `updateIssueIssueType` is the dedicated mutation; passing null for + // `issueTypeId` clears the type. We resolve the issue id via a lightweight + // GraphQL lookup because the REST endpoint doesn't accept issue types. + const lookup = await runGraphql<{ + repository?: { issue?: { id?: string } | null } | null + }>( + `query($owner:String!, $repo:String!, $num:Int!) { + repository(owner:$owner, name:$repo) { issue(number:$num) { id } } + }`, + { owner: args.owner, repo: args.repo, num: args.number } + ) + if (!lookup.ok) { + return { ok: false, error: lookup.error } + } + const issueId = lookup.data.repository?.issue?.id + if (!issueId) { + return { ok: false, error: { type: 'not_found', message: 'Issue not found.' } } + } + // Why: build the mutation conditionally so a null clear doesn't have to + // smuggle a null GraphQL variable through `gh api graphql -f`. The + // mutation accepts a literal `null` in the input object directly. + const query = args.issueTypeId + ? ` + mutation($issueId:ID!, $issueTypeId:ID!) { + updateIssueIssueType(input: { issueId: $issueId, issueTypeId: $issueTypeId }) { + issue { id } + } + } + ` + : ` + mutation($issueId:ID!) { + updateIssueIssueType(input: { issueId: $issueId, issueTypeId: null }) { + issue { id } + } + } + ` + const vars: GraphqlVars = args.issueTypeId + ? { issueId, issueTypeId: args.issueTypeId } + : { issueId } + const res = await runGraphql(query, vars) + if (!res.ok) { + return { ok: false, error: res.error } + } + return { ok: true } +} + +// ─── Slug-addressed work-item details ───────────────────────────────── + +type RawUser = { login?: string; name?: string | null; avatarUrl?: string | null } +type RawLabel = { name?: string; color?: string } +type RawWorkItemContent = { + id?: string + number?: number + title?: string + url?: string + state?: string + stateReason?: string | null + isDraft?: boolean + labels?: { nodes?: RawLabel[] } + assignees?: { nodes?: RawUser[] } +} + +export async function getWorkItemDetailsBySlug( + args: ProjectWorkItemDetailsBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) { + return { ok: false, error: n.error } + } + if (args.type !== 'issue' && args.type !== 'pr') { + return { ok: false, error: { type: 'validation_error', message: 'Invalid type.' } } + } + + // Single GraphQL round-trip to fetch the issue/PR summary + comments + labels + assignees. + const contentFrag = + args.type === 'issue' + ? ` + issue(number:$num) { + id number title url state stateReason updatedAt + body + author { login } + labels(first:50) { nodes { name } } + assignees(first:50) { nodes { login } } + participants(first:50) { nodes { login name avatarUrl } } + comments(first:100) { + nodes { + databaseId + author { login avatarUrl __typename } + body createdAt url + } + } + } + ` + : ` + pullRequest(number:$num) { + id number title url state isDraft updatedAt headRefName baseRefName + body + author { login } + labels(first:50) { nodes { name } } + assignees(first:50) { nodes { login } } + participants(first:50) { nodes { login name avatarUrl } } + comments(first:100) { + nodes { + databaseId + author { login avatarUrl __typename } + body createdAt url + } + } + } + ` + const query = ` + query($owner:String!, $repo:String!, $num:Int!) { + repository(owner:$owner, name:$repo) { + ${contentFrag} + } + } + ` + const res = await runGraphql<{ + repository?: { + issue?: + | (RawWorkItemContent & { + updatedAt?: string + body?: string + author?: { login?: string } | null + participants?: { nodes?: RawUser[] } + comments?: { + nodes?: ({ + databaseId?: number + author?: { login?: string; avatarUrl?: string; __typename?: string } | null + body?: string + createdAt?: string + url?: string + } | null)[] + } + }) + | null + pullRequest?: + | (RawWorkItemContent & { + updatedAt?: string + body?: string + headRefName?: string + baseRefName?: string + author?: { login?: string } | null + participants?: { nodes?: RawUser[] } + comments?: { + nodes?: ({ + databaseId?: number + author?: { login?: string; avatarUrl?: string; __typename?: string } | null + body?: string + createdAt?: string + url?: string + } | null)[] + } + }) + | null + } | null + }>(query, { owner: args.owner, repo: args.repo, num: args.number }) + if (!res.ok) { + return { ok: false, error: res.error } + } + const raw = args.type === 'issue' ? res.data.repository?.issue : res.data.repository?.pullRequest + if (!raw) { + return { ok: false, error: { type: 'not_found', message: 'Item not found.' } } + } + + const labels = (raw.labels?.nodes ?? []) + .map((l) => l?.name) + .filter((n): n is string => typeof n === 'string') + const assignees = (raw.assignees?.nodes ?? []) + .map((a) => a?.login) + .filter((l): l is string => typeof l === 'string') + const comments: PRComment[] = [] + for (const c of raw.comments?.nodes ?? []) { + if (!c || typeof c.body !== 'string') { + continue + } + comments.push({ + id: typeof c.databaseId === 'number' ? c.databaseId : Date.now(), + author: c.author?.login ?? '', + authorAvatarUrl: c.author?.avatarUrl ?? '', + body: c.body, + createdAt: typeof c.createdAt === 'string' ? c.createdAt : '', + url: typeof c.url === 'string' ? c.url : '', + isBot: c.author?.__typename === 'Bot' + }) + } + const participants: GitHubAssignableUser[] = [] + for (const p of raw.participants?.nodes ?? []) { + if (p && typeof p.login === 'string') { + participants.push({ login: p.login, name: p.name ?? null, avatarUrl: p.avatarUrl ?? '' }) + } + } + + const state: 'open' | 'closed' | 'merged' | 'draft' = + args.type === 'pr' + ? raw.isDraft + ? 'draft' + : raw.state === 'MERGED' + ? 'merged' + : raw.state === 'CLOSED' + ? 'closed' + : 'open' + : raw.state === 'CLOSED' + ? 'closed' + : 'open' + + const details: GitHubWorkItemDetails = { + item: { + id: typeof raw.id === 'string' ? raw.id : '', + type: args.type, + number: typeof raw.number === 'number' ? raw.number : args.number, + title: typeof raw.title === 'string' ? raw.title : '', + state, + url: typeof raw.url === 'string' ? raw.url : '', + labels, + updatedAt: + typeof (raw as { updatedAt?: string }).updatedAt === 'string' + ? (raw as { updatedAt: string }).updatedAt + : '', + author: + typeof (raw as { author?: { login?: string } | null }).author?.login === 'string' + ? ((raw as { author: { login: string } }).author.login as string) + : null, + branchName: + args.type === 'pr' && typeof (raw as { headRefName?: string }).headRefName === 'string' + ? ((raw as { headRefName: string }).headRefName as string) + : undefined, + baseRefName: + args.type === 'pr' && typeof (raw as { baseRefName?: string }).baseRefName === 'string' + ? ((raw as { baseRefName: string }).baseRefName as string) + : undefined + }, + body: typeof raw.body === 'string' ? raw.body : '', + comments, + participants, + // Why: PR files/checks/review-thread tabs depend on a local repo path and + // are out of Project-mode slug scope for v1. Omit them here; the dialog + // branches on their absence and hides those tabs. + ...(args.type === 'issue' ? { assignees } : {}) + } + return { ok: true, details } +} diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts new file mode 100644 index 00000000000..1108b6bd219 --- /dev/null +++ b/src/main/github/rate-limit.ts @@ -0,0 +1,155 @@ +/** + * GitHub API rate-limit probe. + * + * Why: `listWorkItems` fan-out × selected repos plus `countWorkItems` in + * parallel, plus `listAccessibleProjects` org-walk, can chew through the + * core (5000/hr) or search (30/min) buckets quickly. Surfacing the remaining + * budget in the TaskPage header lets users self-regulate before they hit the + * wall — without actually throttling (which would hurt responsiveness in + * the common not-near-the-limit case). The probe itself is exempt from + * rate-limit accounting per GitHub docs. + * + * The result is intentionally minimal: we expose just the counts the UI + * needs (remaining + limit for the three buckets we actually stress). If a + * future feature needs reset-time countdowns we can add resetAt here. + */ +import type { + GetRateLimitResult, + GitHubRateLimitBucket, + GitHubRateLimitSnapshot +} from '../../shared/types' +import { acquire, release } from './gh-utils' +import { ghExecFileAsync } from '../git/runner' + +// Why: GitHub explicitly states `GET /rate_limit` does NOT count against +// any bucket, so the only reason to cache is to avoid spawning a `gh` +// subprocess on every render. 30s is a pragmatic balance — short enough +// that the number in the header feels live, long enough to absorb the +// 1-per-second "is it safe now?" polling pattern UIs tend to fall into. +const RATE_LIMIT_CACHE_TTL_MS = 30_000 +let cached: GitHubRateLimitSnapshot | null = null + +type GhRateLimitPayload = { + resources?: { + core?: { limit?: number; remaining?: number; reset?: number } + search?: { limit?: number; remaining?: number; reset?: number } + graphql?: { limit?: number; remaining?: number; reset?: number } + } +} + +function parseBucket( + raw: + | { + limit?: number + remaining?: number + reset?: number + } + | undefined +): GitHubRateLimitBucket { + // Why: if a bucket is absent from the response (old gh, partial response), + // return 0/0/now so the UI shows a clear "unknown" state (0/0 is + // unambiguous) rather than a misleading "plenty left" fallback. + return { + limit: typeof raw?.limit === 'number' ? raw.limit : 0, + remaining: typeof raw?.remaining === 'number' ? raw.remaining : 0, + resetAt: typeof raw?.reset === 'number' ? raw.reset : Math.floor(Date.now() / 1000) + } +} + +/** @internal — test-only */ +export function _resetRateLimitCache(): void { + cached = null +} + +// Why: hard-stop thresholds for the circuit breaker. We refuse to issue a new +// gh request when the cached snapshot says the relevant bucket is below this +// floor. Numbers chosen as "enough budget for one user-initiated flow": +// - core/graphql at 50: a typical work-item details fetch + a few mutations +// - search at 2: a search-driven view paginates in chunks of 1; 2 leaves the +// user one safety click without tipping into the 30/min hard limit +// Below the floor, callers get a synthesized rate_limited error and never +// spawn a gh subprocess. +const MIN_REMAINING_CORE = 50 +const MIN_REMAINING_GRAPHQL = 50 +const MIN_REMAINING_SEARCH = 2 + +export type RateLimitBucketKind = 'core' | 'graphql' | 'search' + +/** + * Return a "soft" stop reason if we should refuse to issue a new gh request + * for the given bucket. Returns null when there's no cached snapshot (we + * haven't probed yet — fail open) or when the bucket has enough budget left. + * + * Why: this is the proactive guard the pill alone cannot provide. The pill is + * informational; this function actually blocks the spawn. We deliberately keep + * it advisory (returns a reason, doesn't throw) so callers can format the + * envelope/error in their own shape. + */ +export function rateLimitGuard(bucket: RateLimitBucketKind): + | { blocked: false } + | { + blocked: true + remaining: number + limit: number + resetAt: number + } { + if (!cached) { + return { blocked: false } + } + const b = cached[bucket] + const floor = + bucket === 'core' + ? MIN_REMAINING_CORE + : bucket === 'graphql' + ? MIN_REMAINING_GRAPHQL + : MIN_REMAINING_SEARCH + // Why: only block when we have a positive limit (limit:0 means "unknown" per + // parseBucket fallback — don't block on missing data, that would brick the + // app on a single bad rate_limit response). + if (b.limit > 0 && b.remaining < floor) { + return { blocked: true, remaining: b.remaining, limit: b.limit, resetAt: b.resetAt } + } + return { blocked: false } +} + +/** + * Decrement the cached `remaining` counter for a bucket after a successful + * spawn. Why: the canonical numbers come from the next probe, but between + * probes the cached snapshot would over-report budget if we didn't account + * for the work we just did. The decrement keeps the circuit breaker honest + * during a burst (e.g. paginating items) instead of waiting 30s for the cache + * to expire. + */ +export function noteRateLimitSpend(bucket: RateLimitBucketKind, cost = 1): void { + if (!cached) { + return + } + const b = cached[bucket] + if (b.remaining > 0) { + cached = { ...cached, [bucket]: { ...b, remaining: Math.max(0, b.remaining - cost) } } + } +} + +export async function getRateLimit(options?: { force?: boolean }): Promise { + if (!options?.force && cached && Date.now() - cached.fetchedAt < RATE_LIMIT_CACHE_TTL_MS) { + return { ok: true, snapshot: cached } + } + await acquire() + try { + const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { encoding: 'utf-8' }) + const parsed = JSON.parse(stdout) as GhRateLimitPayload + const snapshot: GitHubRateLimitSnapshot = { + core: parseBucket(parsed.resources?.core), + search: parseBucket(parsed.resources?.search), + graphql: parseBucket(parsed.resources?.graphql), + fetchedAt: Date.now() + } + cached = snapshot + return { ok: true, snapshot } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } finally { + release() + } +} diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts new file mode 100644 index 00000000000..054eeb2df60 --- /dev/null +++ b/src/main/github/work-item-details.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + ghExecFileAsyncMock, + getOwnerRepoMock, + getIssueOwnerRepoMock, + getWorkItemMock, + getPRChecksMock, + getPRCommentsMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getIssueOwnerRepoMock: vi.fn(), + getWorkItemMock: vi.fn(), + getPRChecksMock: vi.fn(), + getPRCommentsMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + ghExecFileAsync: ghExecFileAsyncMock, + getOwnerRepo: getOwnerRepoMock, + getIssueOwnerRepo: getIssueOwnerRepoMock, + acquire: acquireMock, + release: releaseMock +})) + +vi.mock('./client', () => ({ + getWorkItem: getWorkItemMock, + getPRChecks: getPRChecksMock, + getPRComments: getPRCommentsMock +})) + +import { getWorkItemDetails } from './work-item-details' + +describe('getWorkItemDetails', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getIssueOwnerRepoMock.mockReset() + getWorkItemMock.mockReset() + getPRChecksMock.mockReset() + getPRCommentsMock.mockReset() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + }) + + it('passes row type into the lookup and fetches issue comments from the issue source', async () => { + getWorkItemMock.mockResolvedValueOnce({ + id: 'issue:923', + type: 'issue', + number: 923, + title: 'Use upstream issues', + state: 'open', + url: 'https://github.com/stablyai/orca/issues/923', + labels: [], + updatedAt: '2026-04-01T00:00:00Z', + author: 'octocat' + }) + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body' }) }) + .mockResolvedValueOnce({ stdout: '[]' }) + + const details = await getWorkItemDetails('/repo-root', 923, 'issue') + + expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 923, 'issue') + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + ['api', '--cache', '60s', 'repos/stablyai/orca/issues/923'], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['api', '--cache', '60s', 'repos/stablyai/orca/issues/923/comments?per_page=100'], + { cwd: '/repo-root' } + ) + expect(details?.body).toBe('Issue body') + }) +}) diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 3c25c910f5b..c0dbc1b469d 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -9,7 +9,7 @@ import type { GitHubWorkItemDetails, PRComment } from '../../shared/types' -import { ghExecFileAsync, acquire, release, getOwnerRepo } from './gh-utils' +import { ghExecFileAsync, acquire, release, getOwnerRepo, getIssueOwnerRepo } from './gh-utils' import { getWorkItem, getPRChecks, getPRComments } from './client' // Why: a PR "changed file" listing returned by the REST endpoint is paginated @@ -171,7 +171,7 @@ async function getIssueBodyAndComments( repoPath: string, issueNumber: number ): Promise<{ body: string; comments: PRComment[]; assignees: string[] }> { - const ownerRepo = await getOwnerRepo(repoPath) + const ownerRepo = await getIssueOwnerRepo(repoPath) try { if (ownerRepo) { const [issueResult, commentsResult] = await Promise.all([ @@ -276,7 +276,11 @@ async function getWorkItemParticipants( repoPath: string, item: Pick ): Promise { - const ownerRepo = await getOwnerRepo(repoPath) + // Why: issues in a fork live on the upstream remote, so participants must be + // resolved via getIssueOwnerRepo to stay consistent with getIssueBodyAndComments. + // PRs remain tied to origin via getOwnerRepo. + const ownerRepo = + item.type === 'issue' ? await getIssueOwnerRepo(repoPath) : await getOwnerRepo(repoPath) if (!ownerRepo) { return [] } @@ -386,12 +390,13 @@ async function getMentionParticipants( export async function getWorkItemDetails( repoPath: string, - number: number + number: number, + type?: 'issue' | 'pr' ): Promise { // Why: getWorkItem already handles acquire/release. We call it first (outside // our semaphore) so the known-cheap lookup doesn't compete with the richer // detail fetches that follow. - const item: Omit | null = await getWorkItem(repoPath, number) + const item: Omit | null = await getWorkItem(repoPath, number, type) if (!item) { return null } diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 48d6c3dc8b1..647a01dbd18 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -319,6 +319,32 @@ describe('getEffectiveHooks', () => { }) }) + it("loads setup hooks from the target worktree's orca.yaml when a worktree path is provided", async () => { + const fs = await import('fs') + vi.mocked(fs.existsSync).mockImplementation( + (path) => path === '/test/repo/orca.yaml' || path === '/test/worktree/orca.yaml' + ) + vi.mocked(fs.readFileSync).mockImplementation((path) => { + if (path === '/test/repo/orca.yaml') { + return 'scripts:\n setup: |\n echo old-version\n' + } + if (path === '/test/worktree/orca.yaml') { + return 'scripts:\n setup: |\n echo new-version\n' + } + return '' + }) + + const { getEffectiveHooks } = await import('./hooks') + const result = getEffectiveHooks(makeRepo(), '/test/worktree') + + expect(result).toEqual({ + scripts: { + setup: 'echo new-version' + } + }) + expect(result?.scripts.setup).not.toContain('old-version') + }) + it('falls back to legacy UI hooks when yaml is missing', async () => { const fs = await import('fs') vi.mocked(fs.existsSync).mockReturnValue(false) diff --git a/src/main/hooks.ts b/src/main/hooks.ts index efd61d2b2e0..0beb03a64bf 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -261,8 +261,8 @@ function ensureOrcaDirIgnored(repoPath: string): void { } } -export function getEffectiveHooks(repo: Repo): OrcaHooks | null { - const yamlHooks = loadHooks(repo.path) +export function getEffectiveHooks(repo: Repo, worktreePath?: string): OrcaHooks | null { + const yamlHooks = loadHooks(worktreePath ?? repo.path) const legacySetup = repo.hookSettings?.scripts.setup?.trim() const legacyArchive = repo.hookSettings?.scripts.archive?.trim() const setup = yamlHooks?.scripts.setup?.trim() || legacySetup @@ -304,8 +304,11 @@ export function shouldRunSetupForCreate(repo: Repo, decision: SetupDecision = 'i return policy === 'run-by-default' } -export function getSetupCommandSource(repo: Repo): { source: 'yaml'; command: string } | null { - const yamlSetup = loadHooks(repo.path)?.scripts.setup?.trim() +export function getSetupCommandSource( + repo: Repo, + worktreePath?: string +): { source: 'yaml'; command: string } | null { + const yamlSetup = loadHooks(worktreePath ?? repo.path)?.scripts.setup?.trim() if (yamlSetup) { return { source: 'yaml', command: yamlSetup } @@ -433,9 +436,10 @@ function createWorktreeRunnerScript( export function runHook( hookName: 'setup' | 'archive', cwd: string, - repo: Repo + repo: Repo, + hooksPath?: string ): Promise<{ success: boolean; output: string }> { - const hooks = getEffectiveHooks(repo) + const hooks = getEffectiveHooks(repo, hooksPath) const script = hooks?.scripts[hookName] if (!script) { diff --git a/src/main/index.ts b/src/main/index.ts index 6cbc363dca2..84553104e93 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,18 +11,18 @@ import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' import { killAllPty } from './ipc/pty' -import { - initDaemonPtyProvider, - disconnectDaemon, - cleanupOrphanedDaemon -} from './daemon/daemon-init' -import { recordPendingDaemonTransitionNotice, setAppRuntimeFlags } from './ipc/app' +import { initDaemonPtyProvider, disconnectDaemon } from './daemon/daemon-init' +import { setAppRuntimeFlags } from './ipc/app' import { closeAllWatchers } from './ipc/filesystem-watcher' import { registerCoreHandlers } from './ipc/register-core-handlers' +import { registerMobileHandlers } from './ipc/mobile' +import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce } from './telemetry/client' +import { resolveConsent } from './telemetry/consent' import { triggerStartupNotificationRegistration } from './ipc/notifications' import { OrcaRuntimeService } from './runtime/orca-runtime' import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' -import { registerAppMenu } from './menu/register-app-menu' +import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata' +import { registerAppMenu, rebuildAppMenu } from './menu/register-app-menu' import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater' import { configureDevUserDataPath, @@ -33,6 +33,7 @@ import { patchPackagedProcessPath } from './startup/configure-process' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' +import { acquireSingleInstanceLock } from './startup/single-instance-lock' import { RateLimitService } from './rate-limits/service' import { attachMainWindowServices } from './window/attach-main-window-services' import { createMainWindow } from './window/createMainWindow' @@ -46,8 +47,7 @@ import { claudeHookService } from './claude/hook-service' import { codexHookService } from './codex/hook-service' import { geminiHookService } from './gemini/hook-service' import { cursorHookService } from './cursor/hook-service' -import { getPtyIdForPaneKey, registerPaneKeyTeardownListener } from './ipc/pty' -import { AGENT_DASHBOARD_ENABLED } from '../shared/constants' +import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, getLocalPtyProvider } from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { browserManager } from './browser/browser-manager' @@ -92,18 +92,76 @@ if (app.isPackaged && process.platform !== 'win32') { }) } configureDevUserDataPath(is.dev) -installDevParentDisconnectQuit(is.dev) -installDevParentWatchdog(is.dev) -// Why: must run after configureDevUserDataPath (which redirects userData to -// orca-dev in dev mode) but before app.setName('Orca') inside whenReady -// (which would change the resolved path on case-sensitive filesystems). -initDataPath() -// Why: same timing constraint as initDataPath — capture the userData path -// before app.setName changes it. See persistence.ts:20-28. -initStatsPath() -initClaudeUsagePath() -initCodexUsagePath() -enableMainProcessGpuFeatures() + +function focusExistingWindow(): void { + // Why: the second-instance event fires on the *primary* Electron process + // after another launch tries (and fails) to acquire the lock. Bring the + // existing window forward so the user sees the same focus behaviour as + // re-clicking the dock/taskbar icon, rather than a silent no-op. + // + // Why show() as well as restore() + focus(): isMinimized() only covers the + // dock-minimised case. A hidden window (close-to-tray on macOS via Cmd+W, + // or a window on a different macOS Space) is NOT minimised, so focus() + // alone is a silent no-op. show() handles those plus Windows taskbar + // focus-steal, which focus() alone does not reliably trigger. + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + if (!mainWindow.isVisible()) { + mainWindow.show() + } + mainWindow.focus() + } + // Pre-window case: the primary is still booting and will call + // openMainWindow() from whenReady(). No action needed here. +} + +// Why: the lock must be acquired AFTER configureDevUserDataPath — Electron +// derives the lock identity from the `userData` path, so this placement lets +// dev (`orca-dev`) and packaged (`orca`) runs lock in separate namespaces +// instead of serialising against each other. +// +// Why skip in dev: engineers routinely run `pnpm dev` in parallel from +// multiple worktrees while shipping features, and the lock makes the second +// `pnpm dev` exit silently. In dev we accept that `orca-runtime.json` and +// `endpoint.env` may race (the bundled `orca-dev` CLI / agent hooks route +// to whichever instance wrote last). The dev build is not used for real +// agent work, so that routing ambiguity is acceptable. Packaged Orca keeps +// the lock to protect against the corruption documented in PR #1326 / +// issue #1312. +const hasSingleInstanceLock = is.dev ? true : acquireSingleInstanceLock(app, focusExistingWindow) +if (!hasSingleInstanceLock) { + if (is.dev) { + // Why: packaged runs have no attached console, but dev runs do. Emit a + // single line so a `pnpm dev` operator does not mistake a silent exit + // for a broken launcher. + console.log( + '[single-instance] Another Orca instance is already running against this userData path — focusing existing window.' + ) + } + app.quit() +} + +// Why: when the lock is held by another process, we've already called +// app.quit() above. Skip every remaining file-writing side effect so this +// transient process never touches userData, and let handler registration +// below happen — those handlers only fire after whenReady, which app.quit() +// prevents from ever dispatching. +if (hasSingleInstanceLock) { + installDevParentDisconnectQuit(is.dev) + installDevParentWatchdog(is.dev) + // Why: must run after configureDevUserDataPath (which redirects userData to + // orca-dev in dev mode) but before app.setName('Orca') inside whenReady + // (which would change the resolved path on case-sensitive filesystems). + initDataPath() + // Why: same timing constraint as initDataPath — capture the userData path + // before app.setName changes it. See persistence.ts:20-28. + initStatsPath() + initClaudeUsagePath() + initCodexUsagePath() + enableMainProcessGpuFeatures() +} function openMainWindow(): BrowserWindow { if (!store) { @@ -159,6 +217,22 @@ function openMainWindow(): BrowserWindow { } }) + // Why: telemetry-plan.md§First-launch experience anchors default-on + // `app_opened` to the first main-window load. Existing users in the + // pending-banner cohort resolve through telemetry/client.ts; this load + // path only fires once consent is already enabled. + const onFirstWindowLoad = (): void => { + if (!store) { + return + } + const consent = resolveConsent(store.getSettings()) + if (consent.effective !== 'enabled') { + return + } + trackAppOpenedOnce() + } + window.webContents.on('did-finish-load', onFirstWindowLoad) + registerCoreHandlers( store, runtime, @@ -204,7 +278,11 @@ function openMainWindow(): BrowserWindow { if (mainWindow?.isDestroyed()) { return } - if (AGENT_DASHBOARD_ENABLED) { + // Why: only forward status events to the renderer when the user has + // opted into the experimental dashboard. Reading the current setting + // here (rather than a module-level snapshot) lets the gate flip live + // for the renderer-side surfaces — the hook server itself always runs. + if (store?.getSettings().experimentalAgentDashboard === true) { mainWindow?.webContents.send('agentStatus:set', { paneKey, tabId, @@ -219,7 +297,7 @@ function openMainWindow(): BrowserWindow { // sidebar spinner, unread badge, and Claude prompt-cache timer for every // other agent) lights up for cursor panes too. Braille prefix ⠋ → working // keyword path; "action required" keyword → permission; bare label → idle. - // This runs regardless of AGENT_DASHBOARD_ENABLED because cursor has no + // This runs regardless of the dashboard setting because cursor has no // pre-dashboard title heuristic to fall back to. if (payload.agentType === 'cursor') { driveCursorPaneFromHook(paneKey, payload.state) @@ -323,6 +401,13 @@ app.whenReady().then(async () => { } store = new Store() + // Why: telemetry must initialize before any IPC handler / renderer can + // call `track()`. The client is a no-op in dev/contributor builds + // (`IS_OFFICIAL_BUILD === false`) and a no-op while `TELEMETRY_ENABLED` + // is false in PR 2 — so this call is safe to run early; it only records + // the Store reference, seeds common props, and resets per-session burst + // caps. Actual transport initialization is still gated by both flags. + initTelemetry(store) stats = new StatsCollector() claudeUsage = new ClaudeUsageStore(store) codexUsage = new CodexUsageStore(store) @@ -333,7 +418,28 @@ app.whenReady().then(async () => { claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) rateLimits.setCodexHomePathResolver(() => codexRuntimeHome!.prepareForRateLimitFetch()) rateLimits.setClaudeAuthPreparationResolver(() => claudeRuntimeAuth!.prepareForRateLimitFetch()) - runtime = new OrcaRuntimeService(store, stats) + rateLimits.setSettingsResolver(() => store!.getSettings()) + rateLimits.setInactiveClaudeAccountsResolver(() => { + const settings = store!.getSettings() + return settings.claudeManagedAccounts + .filter((account) => account.id !== settings.activeClaudeManagedAccountId) + .map((account) => ({ id: account.id, managedAuthPath: account.managedAuthPath })) + }) + rateLimits.setInactiveCodexAccountsResolver(() => { + const settings = store!.getSettings() + return settings.codexManagedAccounts + .filter((account) => account.id !== settings.activeCodexManagedAccountId) + .map((account) => ({ id: account.id, managedHomePath: account.managedHomePath })) + }) + runtime = new OrcaRuntimeService(store, stats, { + // Why: resolve the PTY provider lazily. initDaemonPtyProvider() runs later + // inside attachMainWindowServices and calls setLocalPtyProvider(routedAdapter) + // to swap the in-process provider for the daemon-routed one. Capturing the + // provider reference eagerly here would freeze the pre-daemon LocalPtyProvider + // and defeat the teardown helper's prefix sweep (design §4.3 wire-up). + getLocalProvider: () => getLocalPtyProvider() + }) + runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) starNag = new StarNagService(store, stats) starNag.start() starNag.registerIpcHandlers() @@ -341,14 +447,17 @@ app.whenReady().then(async () => { nativeTheme.themeSource = store.getSettings().theme ?? 'system' // Why: managed hook installation mutates user-global agent config. // Startup must fail open so a malformed local config never bricks Orca. - // Claude/Codex/Gemini installs are gated behind AGENT_DASHBOARD_ENABLED - // because the surface they feed (the in-progress agent dashboard) isn't - // shippable yet. Cursor installs unconditionally because cursor-agent - // emits no title-based working/idle signal at all (its terminal title - // stays literally "Cursor Agent" across a turn), so the hook channel is - // the only way to drive the sidebar spinner + unread path for it — there - // is no "pre-dashboard" fallback to degrade to the way Claude/Codex have. - if (AGENT_DASHBOARD_ENABLED) { + // Claude/Codex/Gemini installs are gated behind the experimentalAgentDashboard + // setting because the feature they feed (the inline agent-activity list) is + // still in preview. Cursor installs unconditionally because cursor-agent + // emits no title-based working/idle signal at all (its terminal title stays + // literally "Cursor Agent" across a turn), so the hook channel is the only + // way to drive the sidebar spinner + unread path for it — there is no + // title-based fallback the way Claude/Codex have. Toggling the setting + // takes effect on next launch because the hook scripts are installed once + // per boot. + const agentDashboardEnabled = store.getSettings().experimentalAgentDashboard === true + if (agentDashboardEnabled) { for (const installManagedHooks of [ () => claudeHookService.install(), () => codexHookService.install(), @@ -381,55 +490,73 @@ app.whenReady().then(async () => { onZoomReset: () => { mainWindow?.webContents.send('terminal:zoom', 'reset') }, - onToggleStatusBar: () => { - mainWindow?.webContents.send('ui:toggleStatusBar') + onToggleLeftSidebar: () => { + mainWindow?.webContents.send('ui:toggleLeftSidebar') + }, + onToggleRightSidebar: () => { + mainWindow?.webContents.send('ui:toggleRightSidebar') + }, + onToggleAppearance: (key) => { + if (!store) { + return + } + if (key === 'statusBarVisible') { + // Why: status bar visibility lives under the persisted UI state + // (ui:set/ui:get), not settings. The renderer owns the authoritative + // toggle logic (it knows the current value and persists it back), so + // we forward the event and let it flip + store. + mainWindow?.webContents.send('ui:toggleStatusBar') + return + } + const current = store.getSettings() + store.updateSettings({ [key]: !current[key] }) + // Why: settings:get returns the current snapshot; renderer tracks + // settings through window.api.settings.get(). Push the new value so + // the sidebar/titlebar re-render without waiting for a round-trip. + mainWindow?.webContents.send('settings:changed', { [key]: !current[key] }) + rebuildAppMenu() + }, + getAppearanceState: () => { + const settings = store?.getSettings() + const ui = store?.getUI() + return { + showTasksButton: settings?.showTasksButton !== false, + showTitlebarAgentActivity: settings?.showTitlebarAgentActivity !== false, + statusBarVisible: ui?.statusBarVisible !== false + } } }) + // Why: E2E tests launch parallel Electron instances that would all race to + // bind the default fixed port, crashing on EADDRINUSE. Port 0 lets the OS + // assign a random available port per instance while still exercising the + // full WebSocket startup path. + const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR) runtimeRpc = new OrcaRuntimeRpcServer({ runtime, - userDataPath: app.getPath('userData') + userDataPath: app.getPath('userData'), + enableWebSocket: true, + ...(isE2E ? { wsPort: 0 } : {}) + }) + registerMobileHandlers(runtimeRpc) + + // Why: the persistent-terminal daemon is always started. If it fails, the + // LocalPtyProvider (initialized at module load in ipc/pty.ts) remains as the + // implicit fallback — terminals work, just without cross-restart persistence. + try { + await initDaemonPtyProvider() + } catch (error) { + console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) + } + setAppRuntimeFlags({ + agentDashboardEnabledAtStartup: agentDashboardEnabled }) - // Why: persistent terminal sessions (the out-of-process daemon) are gated - // behind an experimental setting that defaults to OFF. Users on v1.3.0 had - // the daemon on by default, so on upgrade we may need to clean up a live - // daemon from their previous session before continuing with the local - // provider. `registerPtyHandlers` (called inside openMainWindow) relies on - // the provider being set, so whichever branch runs must complete first. - const daemonEnabled = store.getSettings().experimentalTerminalDaemon === true - let daemonStarted = false - if (daemonEnabled) { - // Why: catch so the app still opens even if the daemon fails. The local - // PTY provider remains as the fallback — terminals will still work, just - // without cross-restart persistence. - try { - await initDaemonPtyProvider() - daemonStarted = true - } catch (error) { - console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) - } - } else { - // Why: stash the cleanup result so the renderer's one-shot transition - // toast can tell the user how many background sessions were stopped. Only - // record when `cleaned: true` — i.e. an orphan daemon was actually found. - // Fresh installs (no socket) skip the toast entirely. - try { - const result = await cleanupOrphanedDaemon() - if (result.cleaned) { - recordPendingDaemonTransitionNotice({ killedCount: result.killedCount }) - } - } catch (error) { - console.error('[daemon] Failed to clean up orphaned daemon:', error) - } - } - setAppRuntimeFlags({ daemonEnabledAtStartup: daemonStarted }) - // Why: the hook server runs unconditionally so cursor-agent panes can reach - // it. Claude/Codex/Gemini hook scripts stay uninstalled while - // AGENT_DASHBOARD_ENABLED is false, so only cursor events flow in. PTY - // spawn env reads ORCA_AGENT_HOOK_* from the live server state, so the - // server must start before the window opens — otherwise restored terminals - // race ahead without the env on first launch. + // it. Claude/Codex/Gemini hook scripts stay uninstalled while the + // experimentalAgentDashboard setting is off, so only cursor events flow + // in by default. PTY spawn env reads ORCA_AGENT_HOOK_* from the live + // server state, so the server must start before the window opens — + // otherwise restored terminals race ahead without the env on first launch. try { await agentHookServer.start({ env: app.isPackaged ? 'production' : 'development', @@ -502,11 +629,6 @@ app.on('will-quit', (e) => { runtime?.getAgentBrowserBridge()?.destroyAllSessions() killAllPty() void closeAllWatchers() - if (runtimeRpc) { - void runtimeRpc.stop().catch((error) => { - console.error('[runtime] Failed to stop local RPC transport:', error) - }) - } store?.flush() // Why: disconnectDaemon writes final checkpoints via async getSnapshot RPCs. @@ -515,10 +637,49 @@ app.on('will-quit', (e) => { // app.quit() re-fires will-quit, but the second pass skips straight through. if (!daemonDisconnectDone) { e.preventDefault() - disconnectDaemon().finally(() => { - daemonDisconnectDone = true - app.quit() - }) + // Why: capture ownership synchronously (before any await) so the guard + // still has the right pid/runtimeId to compare against if shutdown + // partially clears global state. Evaluating these inside .then() would + // let a later teardown path null them out mid-chain. + const ownedPid = process.pid + const ownedRuntimeId = runtime?.getRuntimeId() + // Why: the construction of rpcStopAndClear AND the allSettled() below must + // both live inside the `!daemonDisconnectDone` guard. will-quit re-fires + // after app.quit() below; without this guard, the second pass would + // re-invoke runtimeRpc.stop() (redundant rmSync on an already-removed + // socket) and re-run the ownership-guarded clear against a metadata file + // that may now belong to the auto-updater's replacement process. + const rpcStopAndClear = runtimeRpc + ? runtimeRpc + .stop() + .then(() => { + if (ownedRuntimeId) { + clearRuntimeMetadataIfOwned(app.getPath('userData'), ownedPid, ownedRuntimeId) + } + }) + .catch((error) => { + console.error('[runtime] Failed to stop local RPC transport:', error) + }) + : Promise.resolve() + // Why: Promise.allSettled — we need BOTH the daemon disconnect and the + // RPC stop + owned-metadata clear to complete before Electron exits. + // Using allSettled (not all) preserves the existing fail-open posture: + // if disconnectDaemon rejects, we still quit instead of hanging the app. + // + // Telemetry shutdown folds in after the daemon/RPC teardown and BEFORE + // app.quit(): the PostHog client has up to 2s of bounded flush. Errors + // inside `shutdownTelemetry()` are caught by the client itself — we + // catch again here defensively so a flush failure cannot cancel the + // quit chain. + Promise.allSettled([disconnectDaemon(), rpcStopAndClear]) + .then(() => shutdownTelemetry()) + .catch(() => { + /* swallow — telemetry must never prevent app.quit() */ + }) + .then(() => { + daemonDisconnectDone = true + app.quit() + }) } }) diff --git a/src/main/ipc/agent-trust.ts b/src/main/ipc/agent-trust.ts new file mode 100644 index 00000000000..9ded836a371 --- /dev/null +++ b/src/main/ipc/agent-trust.ts @@ -0,0 +1,35 @@ +import { ipcMain } from 'electron' +import { markCopilotFolderTrusted, markCursorWorkspaceTrusted } from '../agent-trust-presets' + +export type AgentTrustPreset = 'cursor' | 'copilot' + +/** + * Why: cursor-agent and GitHub Copilot CLI gate first-launch in an unfamiliar + * directory behind a "Do you trust this folder?" menu that consumes + * keystrokes (numbered options / single-letter shortcuts). Orca's draft-URL + * paste flow needs the input box, not the menu, so before Orca spawns the + * agent it asks main to write the same trust artifacts the agents write + * after the user accepts. Best-effort: any IO error is swallowed so a failed + * trust write never blocks the workspace from opening. + */ +export function registerAgentTrustHandlers(): void { + ipcMain.removeHandler('agentTrust:markTrusted') + ipcMain.handle( + 'agentTrust:markTrusted', + async (_event, args: { preset: AgentTrustPreset; workspacePath: string }): Promise => { + if (!args || typeof args.workspacePath !== 'string' || !args.workspacePath) { + return + } + try { + if (args.preset === 'cursor') { + markCursorWorkspaceTrusted(args.workspacePath) + } else if (args.preset === 'copilot') { + markCopilotFolderTrusted(args.workspacePath) + } + } catch { + // Best-effort: see Why above. The user can still accept the trust + // prompt manually if writing the artifact fails. + } + } + ) +} diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 1f6b92c6743..611cbf76e5e 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -1,50 +1,35 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { app, ipcMain } from 'electron' +import { isPwshAvailable } from '../pwsh' import { isWslAvailable } from '../wsl' const execFileAsync = promisify(execFile) export type AppRuntimeFlags = { - /** Whether the persistent terminal daemon was actually started this session. - * The renderer compares this against the current setting to decide whether - * a "restart required" banner needs to be shown on the Experimental pane. */ - daemonEnabledAtStartup: boolean + /** Whether the experimental agent dashboard setting was enabled when this + * session booted. When true, Claude/Codex/Gemini managed hook installation + * was attempted at startup (individual install failures are logged but do + * not flip this flag — the inline agents list treats missing hooks as + * no-ops). Toggling the setting only affects hook installation on the next + * launch, so the renderer compares this against the current setting to + * decide whether a "restart required" banner needs to be shown. */ + agentDashboardEnabledAtStartup: boolean } -export type DaemonTransitionNotice = { - /** Number of live daemon PTY sessions that were killed when the app booted - * with `experimentalTerminalDaemon: false` but discovered a leftover daemon - * from a previous session. Non-zero values are surfaced in a one-shot - * toast so the user knows background work was stopped. */ - killedCount: number +let runtimeFlags: AppRuntimeFlags = { + agentDashboardEnabledAtStartup: false } -let runtimeFlags: AppRuntimeFlags = { daemonEnabledAtStartup: false } -let pendingDaemonTransitionNotice: DaemonTransitionNotice | null = null - export function setAppRuntimeFlags(flags: AppRuntimeFlags): void { runtimeFlags = flags } -export function recordPendingDaemonTransitionNotice(notice: DaemonTransitionNotice): void { - pendingDaemonTransitionNotice = notice -} - export function registerAppHandlers(): void { ipcMain.handle('app:getRuntimeFlags', (): AppRuntimeFlags => runtimeFlags) - ipcMain.handle('app:consumeDaemonTransitionNotice', (): DaemonTransitionNotice | null => { - // Why: one-shot consumption — clear after reading so the renderer's - // post-hydration effect can't fire the same toast twice (e.g. after a - // window reload during dev). The persisted `experimentalTerminalDaemonNoticeShown` - // flag is the cross-session guard; this clear handles within-session races. - const notice = pendingDaemonTransitionNotice - pendingDaemonTransitionNotice = null - return notice - }) - ipcMain.handle('wsl:isAvailable', (): boolean => isWslAvailable()) + ipcMain.handle('pwsh:isAvailable', (): boolean => isPwshAvailable()) // Why: ABC, Polish Pro, US Extended, ABC Extended, and every CJK Roman // IME all report a US-QWERTY base layer to navigator.keyboard.getLayoutMap() diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index bd0c3ccb739..e2424648b98 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -25,7 +25,8 @@ import type { import type { BrowserCookieImportResult, BrowserSessionProfile, - BrowserSessionProfileScope + BrowserSessionProfileScope, + BrowserViewportOverride } from '../../shared/types' let trustedBrowserRendererWebContentsId: number | null = null @@ -84,6 +85,7 @@ export function registerBrowserHandlers(): void { ipcMain.removeHandler('browser:registerGuest') ipcMain.removeHandler('browser:unregisterGuest') ipcMain.removeHandler('browser:openDevTools') + ipcMain.removeHandler('browser:setViewportOverride') ipcMain.removeHandler('browser:acceptDownload') ipcMain.removeHandler('browser:cancelDownload') ipcMain.removeHandler('browser:setGrabMode') @@ -101,6 +103,7 @@ export function registerBrowserHandlers(): void { browserPageId: string workspaceId: string worktreeId: string + sessionProfileId?: string | null webContentsId: number } ) => { @@ -172,6 +175,47 @@ export function registerBrowserHandlers(): void { return browserManager.openDevTools(args.browserPageId) }) + ipcMain.handle( + 'browser:setViewportOverride', + ( + event, + args: { + browserPageId: string + override: BrowserViewportOverride | null + } + ) => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + // Why: CDP misbehaves on non-finite/negative metrics (NaN/Infinity can + // wedge Emulation.setDeviceMetricsOverride and leave the page in a + // broken state). Validate at the main-process trust boundary so a buggy + // or compromised renderer cannot corrupt CDP state. + if (args.override !== null) { + const { width, height, deviceScaleFactor, mobile } = args.override + const isFinitePositive = (n: unknown): n is number => + typeof n === 'number' && Number.isFinite(n) && n > 0 + if (!isFinitePositive(width) || width < 1 || width > 10000) { + return false + } + if (!isFinitePositive(height) || height < 1 || height > 10000) { + return false + } + if ( + !isFinitePositive(deviceScaleFactor) || + deviceScaleFactor < 0.1 || + deviceScaleFactor > 5 + ) { + return false + } + if (typeof mobile !== 'boolean') { + return false + } + } + return browserManager.setViewportOverride(args.browserPageId, args.override) + } + ) + ipcMain.handle('browser:acceptDownload', async (event, args: { downloadId: string }) => { if (!isTrustedBrowserRenderer(event.sender)) { return { ok: false, reason: 'not-authorized' as const } diff --git a/src/main/ipc/developer-permissions.ts b/src/main/ipc/developer-permissions.ts new file mode 100644 index 00000000000..060eb7a3877 --- /dev/null +++ b/src/main/ipc/developer-permissions.ts @@ -0,0 +1,219 @@ +import { execFile } from 'node:child_process' +import dgram from 'node:dgram' +import { access } from 'node:fs/promises' +import { homedir } from 'node:os' +import path from 'node:path' +import { ipcMain, shell, systemPreferences } from 'electron' +import type { + DeveloperPermissionId, + DeveloperPermissionRequestResult, + DeveloperPermissionState, + DeveloperPermissionStatus +} from '../../shared/developer-permissions-types' + +const PRIVACY_PANE_URLS: Partial> = { + camera: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera', + microphone: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone', + screen: 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture', + accessibility: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility', + 'full-disk-access': 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles', + automation: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Automation', + bluetooth: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth' +} + +const DEVELOPER_PERMISSION_IDS: DeveloperPermissionId[] = [ + 'microphone', + 'camera', + 'screen', + 'accessibility', + 'full-disk-access', + 'automation', + 'local-network', + 'usb', + 'bluetooth' +] + +function unsupportedOffMac(): DeveloperPermissionStatus | null { + return process.platform === 'darwin' ? null : 'unsupported' +} + +function getMediaStatus(mediaType: 'microphone' | 'camera' | 'screen'): DeveloperPermissionStatus { + const unsupported = unsupportedOffMac() + if (unsupported) { + return unsupported + } + try { + return systemPreferences.getMediaAccessStatus(mediaType) + } catch { + return 'unknown' + } +} + +async function getFullDiskAccessStatus(): Promise { + const unsupported = unsupportedOffMac() + if (unsupported) { + return unsupported + } + try { + // Why: Safari bookmarks are TCC-protected, so read access is a practical + // Full Disk Access signal without touching user project contents. + await access(path.join(homedir(), 'Library', 'Safari', 'Bookmarks.plist')) + return 'granted' + } catch { + return 'unknown' + } +} + +function getAccessibilityStatus(): DeveloperPermissionStatus { + const unsupported = unsupportedOffMac() + if (unsupported) { + return unsupported + } + return systemPreferences.isTrustedAccessibilityClient(false) ? 'granted' : 'unknown' +} + +async function openPrivacyPane(id: DeveloperPermissionId): Promise { + const url = PRIVACY_PANE_URLS[id] + if (!url) { + await shell.openExternal( + 'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension' + ) + return true + } + await shell.openExternal(url) + return true +} + +function triggerAppleEventsPrompt(): Promise { + return new Promise((resolve) => { + execFile( + 'osascript', + ['-e', 'tell application "System Events" to return 1'], + { timeout: 3000 }, + () => resolve() + ) + }) +} + +function triggerLocalNetworkPrompt(): Promise { + return new Promise((resolve) => { + const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }) + let settled = false + const finish = (): void => { + if (settled) { + return + } + settled = true + try { + socket.close() + } catch { + // Already closed or never fully bound. + } + resolve() + } + socket.on('error', finish) + socket.bind(() => { + const message = Buffer.from([0]) + socket.send(message, 0, message.length, 5353, '224.0.0.251', finish) + }) + setTimeout(finish, 1000) + }) +} + +async function getPermissionState(id: DeveloperPermissionId): Promise { + switch (id) { + case 'microphone': + case 'camera': + case 'screen': + return { id, status: getMediaStatus(id) } + case 'accessibility': + return { id, status: getAccessibilityStatus() } + case 'full-disk-access': + return { id, status: await getFullDiskAccessStatus() } + case 'automation': + case 'local-network': + return { id, status: unsupportedOffMac() ?? 'unknown' } + case 'usb': + case 'bluetooth': + return { id, status: unsupportedOffMac() ?? 'ready' } + } +} + +async function requestPermission( + id: DeveloperPermissionId +): Promise { + if (process.platform !== 'darwin') { + return { id, status: 'unsupported', openedSystemSettings: false } + } + + if (id === 'microphone' || id === 'camera') { + // Why: askForMediaAccess only surfaces the TCC prompt when status is + // 'not-determined'. If the user previously denied (or macOS set the status + // to 'denied'/'restricted'), it resolves false with no prompt — leaving + // the user stuck. Fall through to the Privacy pane so they can toggle it. + const granted = await systemPreferences.askForMediaAccess(id) + if (granted) { + return { id, status: 'granted', openedSystemSettings: false } + } + const status = getMediaStatus(id) + if (status === 'denied' || status === 'restricted' || status === 'unknown') { + await openPrivacyPane(id) + return { id, status, openedSystemSettings: true } + } + return { id, status, openedSystemSettings: false } + } + + if (id === 'accessibility') { + // Why: isTrustedAccessibilityClient(true) shows the prompt only the first + // time for a given bundle. Once the user has dismissed or denied, calling + // it again is a no-op. Fall through to the Privacy pane when not granted. + const trusted = systemPreferences.isTrustedAccessibilityClient(true) + if (trusted) { + return { id, status: 'granted', openedSystemSettings: false } + } + await openPrivacyPane(id) + return { id, status: getAccessibilityStatus(), openedSystemSettings: true } + } + + if (id === 'automation') { + await triggerAppleEventsPrompt() + return { id, status: 'unknown', openedSystemSettings: false } + } + + if (id === 'local-network') { + await triggerLocalNetworkPrompt() + return { id, status: 'unknown', openedSystemSettings: false } + } + + await openPrivacyPane(id) + return { id, status: (await getPermissionState(id)).status, openedSystemSettings: true } +} + +export function registerDeveloperPermissionHandlers(): void { + ipcMain.handle( + 'developerPermissions:getStatus', + async (): Promise => { + return Promise.all(DEVELOPER_PERMISSION_IDS.map(getPermissionState)) + } + ) + + ipcMain.handle( + 'developerPermissions:request', + async ( + _event, + args: { id: DeveloperPermissionId } + ): Promise => { + if (!DEVELOPER_PERMISSION_IDS.includes(args.id)) { + return { id: args.id, status: 'unsupported', openedSystemSettings: false } + } + return requestPermission(args.id) + } + ) + + ipcMain.handle( + 'developerPermissions:openSettings', + async (_event, args: { id: DeveloperPermissionId }): Promise => { + await openPrivacyPane(args.id) + } + ) +} diff --git a/src/main/ipc/filesystem-import-ssh-ops.test.ts b/src/main/ipc/filesystem-import-ssh-ops.test.ts new file mode 100644 index 00000000000..a67e6d8a787 --- /dev/null +++ b/src/main/ipc/filesystem-import-ssh-ops.test.ts @@ -0,0 +1,270 @@ +import path from 'path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const handlers = new Map Promise>() +const { + handleMock, + lstatMock, + mkdirMock, + realpathMock, + copyFileMock, + readdirMock, + sftpExistsMock, + uploadFileMock, + uploadDirMock, + mkdirSftpMock, + getConnMgrMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + lstatMock: vi.fn(), + mkdirMock: vi.fn(), + realpathMock: vi.fn(), + copyFileMock: vi.fn(), + readdirMock: vi.fn(), + sftpExistsMock: vi.fn(), + uploadFileMock: vi.fn(), + uploadDirMock: vi.fn(), + mkdirSftpMock: vi.fn(), + getConnMgrMock: vi.fn() +})) + +vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) +vi.mock('fs/promises', () => ({ + lstat: lstatMock, + mkdir: mkdirMock, + rename: vi.fn(), + writeFile: vi.fn(), + realpath: realpathMock, + copyFile: copyFileMock, + readdir: readdirMock +})) +vi.mock('../ssh/sftp-upload', () => ({ + sftpPathExists: sftpExistsMock, + uploadFile: uploadFileMock, + uploadDirectory: uploadDirMock, + mkdirSftp: mkdirSftpMock +})) +vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock })) + +import { registerFilesystemMutationHandlers } from './filesystem-mutations' + +const store = { + getRepos: () => [ + { + id: 'r1', + path: path.resolve('/workspace/repo'), + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + ], + getSettings: () => ({ workspaceDir: path.resolve('/workspace') }) +} +const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + +describe('fs:importExternalPaths — SSH operations', () => { + const destDir = '/home/user/project/src' + const connId = 'ssh-conn-1' + const mockSftp = { end: vi.fn() } + const makeConn = () => ({ + getState: () => ({ status: 'connected' }), + sftp: vi.fn().mockResolvedValue(mockSftp) + }) + const mockDir = (p: string): void => { + const rp = path.resolve(p) + lstatMock.mockImplementation(async (x: string) => { + if (x === rp) { + return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } + } + throw enoent() + }) + } + const invoke = (args: Record) => + handlers.get('fs:importExternalPaths')!(null, args) as Promise<{ + results: Record[] + }> + + beforeEach(() => { + handlers.clear() + ;[ + handleMock, + lstatMock, + mkdirMock, + realpathMock, + copyFileMock, + readdirMock, + sftpExistsMock, + uploadFileMock, + uploadDirMock, + mkdirSftpMock, + getConnMgrMock + ].forEach((m) => m.mockReset()) + mockSftp.end.mockReset() + handleMock.mockImplementation((ch: string, h: never) => { + handlers.set(ch, h) + }) + realpathMock.mockImplementation(async (p: string) => p) + lstatMock.mockRejectedValue(enoent()) + sftpExistsMock.mockResolvedValue(false) + uploadFileMock.mockResolvedValue(undefined) + uploadDirMock.mockResolvedValue(undefined) + mkdirSftpMock.mockResolvedValue(undefined) + getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) + registerFilesystemMutationHandlers(store as never) + }) + + it('deconflicts file names via SFTP lstat', async () => { + const rp = path.resolve('/tmp/dropped/logo.png') + lstatMock.mockImplementation(async (x: string) => { + if (x === rp) { + return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } + } + throw enoent() + }) + sftpExistsMock.mockImplementation(async (_s: unknown, p: string) => p === `${destDir}/logo.png`) + const { results } = await invoke({ + sourcePaths: ['/tmp/dropped/logo.png'], + destDir, + connectionId: connId + }) + expect(results[0]).toMatchObject({ + status: 'imported', + destPath: `${destDir}/logo copy.png`, + renamed: true + }) + }) + + it('rejects symlink sources', async () => { + const rp = path.resolve('/tmp/dropped/link.txt') + lstatMock.mockImplementation(async (p: string) => { + if (p === rp) { + return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true } + } + throw enoent() + }) + const { results } = await invoke({ + sourcePaths: ['/tmp/dropped/link.txt'], + destDir, + connectionId: connId + }) + expect(results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' }) + }) + + it('handles partial failure with correct per-item results', async () => { + const sources = ['/tmp/dropped/good.txt', '/tmp/dropped/bad.txt', '/tmp/dropped/ok.txt'] + lstatMock.mockImplementation(async (p: string) => { + if (sources.map((s) => path.resolve(s)).includes(p)) { + return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } + } + throw enoent() + }) + uploadFileMock.mockImplementation(async (_s: unknown, lp: string) => { + if (lp === path.resolve('/tmp/dropped/bad.txt')) { + throw new Error('permission denied') + } + }) + const { results } = await invoke({ sourcePaths: sources, destDir, connectionId: connId }) + expect(results).toHaveLength(3) + expect(results[0]).toMatchObject({ status: 'imported' }) + expect(results[1]).toMatchObject({ status: 'failed', reason: 'permission denied' }) + expect(results[2]).toMatchObject({ status: 'imported' }) + }) + + it('uploads directories via mkdirSftp + uploadDirectory', async () => { + mockDir('/tmp/dropped/assets') + readdirMock.mockResolvedValue([]) + const { results } = await invoke({ + sourcePaths: ['/tmp/dropped/assets'], + destDir, + connectionId: connId + }) + expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' }) + expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`) + }) + + it('reports per-item failure when deconfliction throws', async () => { + const rp = path.resolve('/tmp/dropped/file.txt') + lstatMock.mockImplementation(async (x: string) => { + if (x === rp) { + return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } + } + throw enoent() + }) + sftpExistsMock.mockRejectedValue(new Error('SFTP channel closed')) + const { results } = await invoke({ + sourcePaths: ['/tmp/dropped/file.txt'], + destDir, + connectionId: connId + }) + expect(results[0]).toMatchObject({ status: 'failed', reason: 'SFTP channel closed' }) + }) + + it('reports failure when mkdirSftp rejects', async () => { + mockDir('/tmp/dropped/mydir') + readdirMock.mockResolvedValue([]) + mkdirSftpMock.mockRejectedValue(new Error('permission denied')) + const { results } = await invoke({ + sourcePaths: ['/tmp/dropped/mydir'], + destDir, + connectionId: connId + }) + expect(results[0]).toMatchObject({ status: 'failed', reason: 'permission denied' }) + }) + + it('deconflicts directory names via SFTP lstat', async () => { + mockDir('/tmp/dropped/assets') + readdirMock.mockResolvedValue([]) + sftpExistsMock.mockImplementation(async (_s: unknown, p: string) => p === `${destDir}/assets`) + const { results } = await invoke({ + sourcePaths: ['/tmp/dropped/assets'], + destDir, + connectionId: connId + }) + expect(results[0]).toMatchObject({ + status: 'imported', + destPath: `${destDir}/assets copy`, + renamed: true + }) + }) + + it('rejects directory containing nested symlinks', async () => { + mockDir('/tmp/dropped/project') + const rd = path.resolve('/tmp/dropped/project') + readdirMock.mockImplementation(async (p: string) => { + if (p === rd) { + return [ + { + name: 'l.txt', + isFile: () => false, + isDirectory: () => false, + isSymbolicLink: () => true + } + ] + } + return [] + }) + const { results } = await invoke({ + sourcePaths: ['/tmp/dropped/project'], + destDir, + connectionId: connId + }) + expect(results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' }) + expect(uploadDirMock).not.toHaveBeenCalled() + }) + + it('reports skipped when source lstat returns EACCES', async () => { + const rp = path.resolve('/tmp/dropped/secret.txt') + lstatMock.mockImplementation(async (p: string) => { + if (p === rp) { + throw Object.assign(new Error('EACCES'), { code: 'EACCES' }) + } + throw enoent() + }) + const { results } = await invoke({ + sourcePaths: ['/tmp/dropped/secret.txt'], + destDir, + connectionId: connId + }) + expect(results[0]).toMatchObject({ status: 'skipped', reason: 'permission-denied' }) + }) +}) diff --git a/src/main/ipc/filesystem-import-ssh.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts new file mode 100644 index 00000000000..210fbfe1105 --- /dev/null +++ b/src/main/ipc/filesystem-import-ssh.test.ts @@ -0,0 +1,195 @@ +import path from 'path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const handlers = new Map Promise>() +const { + handleMock, + lstatMock, + mkdirMock, + realpathMock, + copyFileMock, + readdirMock, + sftpExistsMock, + uploadFileMock, + uploadDirMock, + mkdirSftpMock, + getConnMgrMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + lstatMock: vi.fn(), + mkdirMock: vi.fn(), + realpathMock: vi.fn(), + copyFileMock: vi.fn(), + readdirMock: vi.fn(), + sftpExistsMock: vi.fn(), + uploadFileMock: vi.fn(), + uploadDirMock: vi.fn(), + mkdirSftpMock: vi.fn(), + getConnMgrMock: vi.fn() +})) + +vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) +vi.mock('fs/promises', () => ({ + lstat: lstatMock, + mkdir: mkdirMock, + rename: vi.fn(), + writeFile: vi.fn(), + realpath: realpathMock, + copyFile: copyFileMock, + readdir: readdirMock +})) +vi.mock('../ssh/sftp-upload', () => ({ + sftpPathExists: sftpExistsMock, + uploadFile: uploadFileMock, + uploadDirectory: uploadDirMock, + mkdirSftp: mkdirSftpMock +})) +vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock })) + +import { registerFilesystemMutationHandlers } from './filesystem-mutations' + +const store = { + getRepos: () => [ + { + id: 'r1', + path: path.resolve('/workspace/repo'), + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + ], + getSettings: () => ({ workspaceDir: path.resolve('/workspace') }) +} +const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + +describe('fs:importExternalPaths — SSH routing & connection', () => { + const destDir = '/home/user/project/src' + const connId = 'ssh-conn-1' + const mockSftp = { end: vi.fn() } + const makeConn = (status = 'connected') => ({ + getState: () => ({ status }), + sftp: vi.fn().mockResolvedValue(mockSftp) + }) + const mockFile = (p: string): void => { + const rp = path.resolve(p) + lstatMock.mockImplementation(async (x: string) => { + if (x === rp) { + return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } + } + throw enoent() + }) + } + const invoke = (args: Record) => + handlers.get('fs:importExternalPaths')!(null, args) as Promise<{ + results: Record[] + }> + + beforeEach(() => { + handlers.clear() + ;[ + handleMock, + lstatMock, + mkdirMock, + realpathMock, + copyFileMock, + readdirMock, + sftpExistsMock, + uploadFileMock, + uploadDirMock, + mkdirSftpMock, + getConnMgrMock + ].forEach((m) => m.mockReset()) + mockSftp.end.mockReset() + handleMock.mockImplementation((ch: string, h: never) => { + handlers.set(ch, h) + }) + realpathMock.mockImplementation(async (p: string) => p) + lstatMock.mockRejectedValue(enoent()) + sftpExistsMock.mockResolvedValue(false) + uploadFileMock.mockResolvedValue(undefined) + uploadDirMock.mockResolvedValue(undefined) + mkdirSftpMock.mockResolvedValue(undefined) + registerFilesystemMutationHandlers(store as never) + }) + + it('routes to SFTP when connectionId is present', async () => { + getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) + mockFile('/tmp/dropped/file.txt') + const { results } = await invoke({ + sourcePaths: ['/tmp/dropped/file.txt'], + destDir, + connectionId: connId + }) + expect(results[0]).toMatchObject({ status: 'imported', kind: 'file' }) + expect(uploadFileMock).toHaveBeenCalled() + expect(copyFileMock).not.toHaveBeenCalled() + }) + + it('falls back to local import when connectionId is absent', async () => { + mockFile('/tmp/dropped/file.txt') + const { results } = await invoke({ + sourcePaths: ['/tmp/dropped/file.txt'], + destDir: path.resolve('/workspace/repo/src') + }) + expect(results[0]).toMatchObject({ status: 'imported' }) + expect(copyFileMock).toHaveBeenCalled() + }) + + it('returns empty results without opening SFTP', async () => { + const conn = makeConn() + getConnMgrMock.mockReturnValue({ getConnection: () => conn }) + const { results } = await invoke({ sourcePaths: [], destDir, connectionId: connId }) + expect(results).toHaveLength(0) + expect(conn.sftp).not.toHaveBeenCalled() + }) + + it('throws when connectionId has no matching connection', async () => { + getConnMgrMock.mockReturnValue({ getConnection: () => null }) + await expect( + invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId }) + ).rejects.toThrow('No SSH connection') + }) + + it('throws when connection is reconnecting', async () => { + getConnMgrMock.mockReturnValue({ getConnection: () => makeConn('reconnecting') }) + await expect( + invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId }) + ).rejects.toThrow('reconnecting') + }) + + it('throws when connection is not active', async () => { + getConnMgrMock.mockReturnValue({ getConnection: () => makeConn('disconnected') }) + await expect( + invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId }) + ).rejects.toThrow('not active') + }) + + it('throws when conn.sftp() rejects', async () => { + const conn = makeConn() + conn.sftp.mockRejectedValue(new Error('SFTP subsystem not available')) + getConnMgrMock.mockReturnValue({ getConnection: () => conn }) + await expect( + invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId }) + ).rejects.toThrow('SFTP subsystem') + }) + + it('closes SFTP channel after success', async () => { + getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) + mockFile('/tmp/dropped/file.txt') + await invoke({ sourcePaths: ['/tmp/dropped/file.txt'], destDir, connectionId: connId }) + expect(mockSftp.end).toHaveBeenCalledOnce() + }) + + it('closes SFTP channel after upload error', async () => { + getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) + mockFile('/tmp/dropped/file.txt') + uploadFileMock.mockRejectedValue(new Error('disk full')) + const { results } = await invoke({ + sourcePaths: ['/tmp/dropped/file.txt'], + destDir, + connectionId: connId + }) + expect(results[0]).toMatchObject({ status: 'failed', reason: 'disk full' }) + expect(mockSftp.end).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts new file mode 100644 index 00000000000..8554b466547 --- /dev/null +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -0,0 +1,232 @@ +import { lstat, readdir } from 'fs/promises' +import { basename, join, posix, resolve } from 'path' +import type { SFTPWrapper } from 'ssh2' +import { authorizeExternalPath, isENOENT } from './filesystem-auth' +import { getSshConnectionManager } from './ssh' +import { uploadFile, uploadDirectory, mkdirSftp, sftpPathExists } from '../ssh/sftp-upload' +import type { ImportItemResult } from './filesystem-mutations' + +// Why: the SSH import path bypasses SshFilesystemProvider and uses +// SshConnection.sftp() directly because the relay's JSON-RPC fs.writeFile +// is text-only and cannot carry binary data without base64 overhead. +export async function importExternalPathsSsh( + sourcePaths: string[], + destDir: string, + connectionId: string, + options?: { ensureDir?: boolean } +): Promise<{ results: ImportItemResult[] }> { + if (sourcePaths.length === 0) { + return { results: [] } + } + + const connManager = getSshConnectionManager() + const conn = connManager?.getConnection(connectionId) + if (!conn) { + throw new Error(`No SSH connection for "${connectionId}"`) + } + + const state = conn.getState() + if (state.status !== 'connected') { + if (state.status === 'reconnecting') { + throw new Error('SSH connection is reconnecting — please try again in a moment') + } + throw new Error('SSH connection is not active — please reconnect and try again') + } + + const sftp = await conn.sftp() + + try { + if (options?.ensureDir) { + // Why: terminal-drop staging needs `${worktree}/.orca/drops` to exist + // before the first upload. Upload primitives do not create parent dirs, + // and mkdirSftp is not recursive — so walk the parent chain here on the + // same SFTP session to avoid doubling the handshake cost. Writing the + // .orca/.gitignore marker only when absent prevents clobbering user- + // authored patterns. .orca/ is reserved as Orca-owned remote state; + // see docs/terminal-drop-ssh.md. + await ensureDropStagingDir(sftp, destDir) + } + + const results: ImportItemResult[] = [] + const reservedNames = new Set() + + for (const sourcePath of sourcePaths) { + const result = await importOneSourceSsh(sftp, sourcePath, destDir, reservedNames) + results.push(result) + if (result.status === 'imported') { + // Why: destPath is a remote POSIX path (e.g. /home/user/foo/bar.txt). + // Node's basename() uses the OS separator, which on Windows would + // return the entire string instead of just the filename. + reservedNames.add(posix.basename(result.destPath)) + } + } + + return { results } + } finally { + sftp.end() + } +} + +async function importOneSourceSsh( + sftp: SFTPWrapper, + sourcePath: string, + destDir: string, + reservedNames: Set +): Promise { + const resolvedSource = resolve(sourcePath) + + authorizeExternalPath(resolvedSource) + + let sourceStat: Awaited> + try { + sourceStat = await lstat(resolvedSource) + } catch (error) { + if (isENOENT(error)) { + return { sourcePath, status: 'skipped', reason: 'missing' } + } + if ( + error instanceof Error && + 'code' in error && + ((error as NodeJS.ErrnoException).code === 'EACCES' || + (error as NodeJS.ErrnoException).code === 'EPERM') + ) { + return { sourcePath, status: 'skipped', reason: 'permission-denied' } + } + return { + sourcePath, + status: 'failed', + reason: error instanceof Error ? error.message : String(error) + } + } + + if (sourceStat.isSymbolicLink()) { + return { sourcePath, status: 'skipped', reason: 'symlink' } + } + + if (!sourceStat.isFile() && !sourceStat.isDirectory()) { + return { sourcePath, status: 'skipped', reason: 'unsupported' } + } + + const isDir = sourceStat.isDirectory() + + if (isDir) { + const hasSymlink = await preScanForSymlinks(resolvedSource) + if (hasSymlink) { + return { sourcePath, status: 'skipped', reason: 'symlink' } + } + } + + const originalName = basename(resolvedSource) + + try { + const finalName = await deconflictNameSftp(sftp, destDir, originalName, reservedNames) + const destPath = `${destDir}/${finalName}` + const renamed = finalName !== originalName + + if (isDir) { + await mkdirSftp(sftp, destPath) + await uploadDirectory(sftp, resolvedSource, destPath) + } else { + await uploadFile(sftp, resolvedSource, destPath) + } + + return { + sourcePath, + status: 'imported', + destPath, + kind: isDir ? 'directory' : 'file', + renamed + } + } catch (error) { + return { + sourcePath, + status: 'failed', + reason: error instanceof Error ? error.message : String(error) + } + } +} + +async function deconflictNameSftp( + sftp: SFTPWrapper, + destDir: string, + originalName: string, + reservedNames: Set +): Promise { + if ( + !(await sftpPathExists(sftp, `${destDir}/${originalName}`)) && + !reservedNames.has(originalName) + ) { + return originalName + } + + const dotIndex = originalName.lastIndexOf('.') + const hasMeaningfulExt = dotIndex > 0 + const stem = hasMeaningfulExt ? originalName.slice(0, dotIndex) : originalName + const ext = hasMeaningfulExt ? originalName.slice(dotIndex) : '' + + let candidate = `${stem} copy${ext}` + if (!(await sftpPathExists(sftp, `${destDir}/${candidate}`)) && !reservedNames.has(candidate)) { + return candidate + } + + let counter = 2 + while (counter < 10000) { + candidate = `${stem} copy ${counter}${ext}` + if (!(await sftpPathExists(sftp, `${destDir}/${candidate}`)) && !reservedNames.has(candidate)) { + return candidate + } + counter += 1 + } + + throw new Error( + `Could not generate a unique name for '${originalName}' after ${counter} attempts` + ) +} + +async function ensureDropStagingDir(sftp: SFTPWrapper, destDir: string): Promise { + // destDir is a posix remote path, expected to be `${worktreePath}/.orca/drops`. + const parent = posix.dirname(destDir) + await mkdirSftp(sftp, parent) + const gitignorePath = `${parent}/.gitignore` + if (!(await sftpPathExists(sftp, gitignorePath))) { + // Why: negate the marker so .orca/.gitignore itself is trackable if we + // ever want to, without dirtying `git status` today. Only write when + // absent to avoid clobbering user-authored patterns. + await writeSftpFile(sftp, gitignorePath, '*\n!.gitignore\n') + } + await mkdirSftp(sftp, destDir) +} + +function writeSftpFile(sftp: SFTPWrapper, remotePath: string, contents: string): Promise { + return new Promise((resolve, reject) => { + let settled = false + const writeStream = sftp.createWriteStream(remotePath) + const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => { + if (settled) { + return + } + settled = true + writeStream.destroy() + fn(val as never) + } + writeStream.on('close', () => settle(resolve)) + writeStream.on('error', (err) => settle(reject, err)) + writeStream.end(contents) + }) +} + +async function preScanForSymlinks(dirPath: string): Promise { + const entries = await readdir(dirPath, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isSymbolicLink()) { + return true + } + if (entry.isDirectory()) { + const childPath = join(dirPath, entry.name) + if (await preScanForSymlinks(childPath)) { + return true + } + } + } + return false +} diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 99bbf12bca2..b9bcd91bedd 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -4,6 +4,7 @@ import { basename, dirname, join, resolve } from 'path' import type { Store } from '../persistence' import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesystem-auth' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { importExternalPathsSsh } from './filesystem-import-ssh' /** * Re-throw filesystem errors with user-friendly messages. @@ -120,15 +121,19 @@ export function registerFilesystemMutationHandlers(store: Store): void { 'fs:importExternalPaths', async ( _event, - args: { sourcePaths: string[]; destDir: string } + args: { sourcePaths: string[]; destDir: string; connectionId?: string } ): Promise<{ results: ImportItemResult[] }> => { + if (args.connectionId) { + return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId) + } + // Why: destDir must be authorized before any copy work begins. If the // destination is outside allowed roots, the entire import fails. + // This only applies to local imports — remote paths are authorized by + // the SSH connection boundary (see importExternalPathsSsh). const resolvedDest = await resolveAuthorizedPath(args.destDir, store) const results: ImportItemResult[] = [] - // Track names reserved during this import batch to avoid collisions - // between multiple dropped items that share the same basename. const reservedNames = new Set() for (const sourcePath of args.sourcePaths) { @@ -142,6 +147,53 @@ export function registerFilesystemMutationHandlers(store: Store): void { return { results } } ) + + // Why: terminal drag-and-drop resolver. Local worktrees pass paths through + // unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees + // upload each path into `${worktreePath}/.orca/drops/` and return remote + // paths the remote agent can read. Kept as a separate IPC from + // fs:importExternalPaths because terminal semantics differ from the + // explorer's "copy into user-picked destDir". See docs/terminal-drop-ssh.md. + ipcMain.handle( + 'fs:resolveDroppedPathsForAgent', + async ( + _event, + args: { paths: string[]; worktreePath: string; connectionId?: string } + ): Promise => { + // Why: `== null` (not `!args.connectionId`) so an empty string is + // treated as a renderer error, not silently routed to the local branch. + if (args.connectionId == null) { + return { resolvedPaths: args.paths, skipped: [], failed: [] } + } + const worktreePath = args.worktreePath.replace(/\/+$/, '') + const destDir = `${worktreePath}/.orca/drops` + const { results } = await importExternalPathsSsh(args.paths, destDir, args.connectionId, { + ensureDir: true + }) + const resolvedPaths: string[] = [] + const skipped: { sourcePath: string; reason: ImportSkipReason }[] = [] + const failed: { sourcePath: string; reason: string }[] = [] + // Iterate in input order so injected paths align with the user's drop order. + for (const r of results) { + if (r.status === 'imported') { + resolvedPaths.push(r.destPath) + } else if (r.status === 'skipped') { + skipped.push({ sourcePath: r.sourcePath, reason: r.reason }) + } else { + failed.push({ sourcePath: r.sourcePath, reason: r.reason }) + } + } + return { resolvedPaths, skipped, failed } + } + ) +} + +export type ImportSkipReason = 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + +export type ResolveDroppedPathsResult = { + resolvedPaths: string[] + skipped: { sourcePath: string; reason: ImportSkipReason }[] + failed: { sourcePath: string; reason: string }[] } // ─── External Import Types ────────────────────────────────────────── @@ -157,7 +209,7 @@ export type ImportItemResult = | { sourcePath: string status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + reason: ImportSkipReason } | { sourcePath: string diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts new file mode 100644 index 00000000000..d1e3bafdebb --- /dev/null +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + } +})) + +vi.mock('fs/promises', () => ({ + stat: vi.fn() +})) + +vi.mock('@parcel/watcher', () => ({ + subscribe: vi.fn() +})) + +vi.mock('./filesystem-watcher-wsl', () => ({ + createWslWatcher: vi.fn() +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' + +type HandlerMap = Record Promise | unknown> + +describe('registerFilesystemWatcherHandlers', () => { + const handlers: HandlerMap = {} + + beforeEach(() => { + vi.useRealTimers() + handleMock.mockReset() + getSshFilesystemProviderMock.mockReset() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + }) + + it('quietly skips SSH worktree watches while the filesystem provider is unavailable', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + getSshFilesystemProviderMock.mockReturnValue(undefined) + + await expect( + handlers['fs:watchWorktree']( + { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + ).resolves.toBeUndefined() + await handlers['fs:watchWorktree']( + { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith( + '[filesystem-watcher] SSH filesystem provider unavailable; retrying watch for /home/me/repo on connection conn-1' + ) + handlers['fs:unwatchWorktree'](null, { worktreePath: '/home/me/repo', connectionId: 'conn-1' }) + warnSpy.mockRestore() + vi.useRealTimers() + }) + + it('binds a pending SSH worktree watch after the filesystem provider appears', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sendMock = vi.fn() + const sender = { isDestroyed: () => false, send: sendMock, once: vi.fn(), id: 1 } + const unwatchMock = vi.fn() + const watchMock = vi.fn().mockResolvedValue(unwatchMock) + getSshFilesystemProviderMock.mockReturnValueOnce(undefined) + + await handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + await vi.advanceTimersByTimeAsync(1_000) + + expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function)) + const onEvents = watchMock.mock.calls[0][1] + onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + expect(sendMock).toHaveBeenCalledWith('fs:changed', { + worktreePath: '/home/me/repo', + events: [{ path: '/home/me/repo/file.txt', type: 'update' }] + }) + warnSpy.mockRestore() + handlers['fs:unwatchWorktree'](null, { worktreePath: '/home/me/repo', connectionId: 'conn-1' }) + vi.useRealTimers() + }) + + it('cancels pending SSH watch retries during watcher shutdown', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const watchMock = vi.fn() + getSshFilesystemProviderMock.mockReturnValueOnce(undefined) + + await handlers['fs:watchWorktree']( + { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + + await closeAllWatchers() + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + await vi.advanceTimersByTimeAsync(1_000) + + expect(watchMock).not.toHaveBeenCalled() + warnSpy.mockRestore() + vi.useRealTimers() + }) +}) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index c3c79b33997..ec40832e730 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -428,6 +428,10 @@ function unsubscribe(worktreePath: string, senderId: number): void { // ── Remote watcher state ───────────────────────────────────────────── // Key: `${connectionId}:${worktreePath}`, Value: unwatch function const remoteWatchers = new Map void>() +const loggedUnavailableRemoteWatchers = new Set() +const pendingRemoteWatcherRetries = new Map>() +const REMOTE_WATCH_RETRY_MS = 1_000 +const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000 function replaceRemoteWatcher(key: string, unwatch: () => void): void { const previous = remoteWatchers.get(key) @@ -441,6 +445,75 @@ function replaceRemoteWatcher(key: string, unwatch: () => void): void { remoteWatchers.set(key, unwatch) } +async function installRemoteWatcher( + sender: WebContents, + connectionId: string, + worktreePath: string +): Promise { + const provider = getSshFilesystemProvider(connectionId) + if (!provider || sender.isDestroyed()) { + return false + } + + const key = `${connectionId}:${worktreePath}` + const unwatch = await provider.watch(worktreePath, (events) => { + if (!sender.isDestroyed()) { + sender.send('fs:changed', { + worktreePath, + events + } satisfies FsChangedPayload) + } + }) + replaceRemoteWatcher(key, unwatch) + loggedUnavailableRemoteWatchers.delete(key) + + sender.once('destroyed', () => { + const unwatchFn = remoteWatchers.get(key) + if (unwatchFn) { + unwatchFn() + remoteWatchers.delete(key) + } + const retryTimer = pendingRemoteWatcherRetries.get(key) + if (retryTimer) { + clearTimeout(retryTimer) + pendingRemoteWatcherRetries.delete(key) + } + }) + return true +} + +function scheduleRemoteWatcherRetry( + sender: WebContents, + connectionId: string, + worktreePath: string, + startedAt = Date.now() +): void { + const key = `${connectionId}:${worktreePath}` + if (pendingRemoteWatcherRetries.has(key)) { + return + } + + if (Date.now() - startedAt >= REMOTE_WATCH_RETRY_TIMEOUT_MS || sender.isDestroyed()) { + pendingRemoteWatcherRetries.delete(key) + loggedUnavailableRemoteWatchers.delete(key) + return + } + + const retryTimer = setTimeout(() => { + pendingRemoteWatcherRetries.delete(key) + void installRemoteWatcher(sender, connectionId, worktreePath) + .then((installed) => { + if (!installed) { + scheduleRemoteWatcherRetry(sender, connectionId, worktreePath, startedAt) + } + }) + .catch(() => { + scheduleRemoteWatcherRetry(sender, connectionId, worktreePath, startedAt) + }) + }, REMOTE_WATCH_RETRY_MS) + pendingRemoteWatcherRetries.set(key, retryTimer) +} + // ── Public API ─────────────────────────────────────────────────────── export function registerFilesystemWatcherHandlers(): void { @@ -448,29 +521,22 @@ export function registerFilesystemWatcherHandlers(): void { 'fs:watchWorktree', async (event, args: { worktreePath: string; connectionId?: string }): Promise => { if (args.connectionId) { - const provider = getSshFilesystemProvider(args.connectionId) - if (!provider) { - throw new Error(`No filesystem provider for connection "${args.connectionId}"`) - } const key = `${args.connectionId}:${args.worktreePath}` - - const unwatch = await provider.watch(args.worktreePath, (events) => { - if (!event.sender.isDestroyed()) { - event.sender.send('fs:changed', { - worktreePath: args.worktreePath, - events - } satisfies FsChangedPayload) + const installed = await installRemoteWatcher( + event.sender, + args.connectionId, + args.worktreePath + ) + if (!installed) { + if (!loggedUnavailableRemoteWatchers.has(key)) { + loggedUnavailableRemoteWatchers.add(key) + console.warn( + `[filesystem-watcher] SSH filesystem provider unavailable; retrying watch for ${args.worktreePath} on connection ${args.connectionId}` + ) } - }) - replaceRemoteWatcher(key, unwatch) - - event.sender.once('destroyed', () => { - const unwatchFn = remoteWatchers.get(key) - if (unwatchFn) { - unwatchFn() - remoteWatchers.delete(key) - } - }) + scheduleRemoteWatcherRetry(event.sender, args.connectionId, args.worktreePath) + return + } return } await subscribe(args.worktreePath, event.sender) @@ -482,6 +548,12 @@ export function registerFilesystemWatcherHandlers(): void { (_event, args: { worktreePath: string; connectionId?: string }): void => { if (args.connectionId) { const key = `${args.connectionId}:${args.worktreePath}` + const retryTimer = pendingRemoteWatcherRetries.get(key) + if (retryTimer) { + clearTimeout(retryTimer) + pendingRemoteWatcherRetries.delete(key) + } + loggedUnavailableRemoteWatchers.delete(key) const unwatchFn = remoteWatchers.get(key) if (unwatchFn) { unwatchFn() @@ -503,6 +575,12 @@ export async function closeAllWatchers(): Promise { } pendingTeardowns.clear() + for (const timer of pendingRemoteWatcherRetries.values()) { + clearTimeout(timer) + } + pendingRemoteWatcherRetries.clear() + loggedUnavailableRemoteWatchers.clear() + for (const [rootKey, root] of watchedRoots) { if (root.batch.timer) { clearTimeout(root.batch.timer) diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 39b348fa04b..8898a5220c7 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -10,8 +10,10 @@ const { readFileMock, writeFileMock, statMock, + openMock, realpathMock, lstatMock, + commitChangesMock, getStatusMock, getDiffMock, getBranchCompareMock, @@ -22,7 +24,8 @@ const { bulkUnstageFilesMock, discardChangesMock, listWorktreesMock, - getSshFilesystemProviderMock + getSshFilesystemProviderMock, + getSshGitProviderMock } = vi.hoisted(() => ({ handleMock: vi.fn(), trashItemMock: vi.fn(), @@ -30,8 +33,10 @@ const { readFileMock: vi.fn(), writeFileMock: vi.fn(), statMock: vi.fn(), + openMock: vi.fn(), realpathMock: vi.fn(), lstatMock: vi.fn(), + commitChangesMock: vi.fn(), getStatusMock: vi.fn(), getDiffMock: vi.fn(), getBranchCompareMock: vi.fn(), @@ -42,7 +47,8 @@ const { bulkUnstageFilesMock: vi.fn(), discardChangesMock: vi.fn(), listWorktreesMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn() + getSshFilesystemProviderMock: vi.fn(), + getSshGitProviderMock: vi.fn() })) vi.mock('electron', () => ({ @@ -59,11 +65,13 @@ vi.mock('fs/promises', () => ({ readFile: readFileMock, writeFile: writeFileMock, stat: statMock, + open: openMock, realpath: realpathMock, lstat: lstatMock })) vi.mock('../git/status', () => ({ + commitChanges: commitChangesMock, getStatus: getStatusMock, getDiff: getDiffMock, getBranchCompare: getBranchCompareMock, @@ -84,7 +92,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ })) vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: vi.fn().mockReturnValue(null) + getSshGitProvider: getSshGitProviderMock })) import { registerFilesystemHandlers } from './filesystem' @@ -96,6 +104,27 @@ const REPO_PATH = path.resolve('/workspace/repo') const WORKSPACE_DIR = path.resolve('/workspace') const WORKTREE_FEATURE_PATH = path.resolve('/workspace/repo-feature') +type MockDirEntry = { + name: string + directory?: boolean + file?: boolean + symlink?: boolean +} + +function dirEntry({ name, directory, file, symlink }: MockDirEntry): { + name: string + isDirectory: () => boolean + isFile: () => boolean + isSymbolicLink: () => boolean +} { + return { + name, + isDirectory: () => directory ?? false, + isFile: () => file ?? false, + isSymbolicLink: () => symlink ?? false + } +} + describe('registerFilesystemHandlers', () => { const store = { getRepos: () => [ @@ -121,8 +150,10 @@ describe('registerFilesystemHandlers', () => { readFileMock, writeFileMock, statMock, + openMock, realpathMock, lstatMock, + commitChangesMock, getStatusMock, getDiffMock, getBranchCompareMock, @@ -133,7 +164,8 @@ describe('registerFilesystemHandlers', () => { bulkUnstageFilesMock, discardChangesMock, listWorktreesMock, - getSshFilesystemProviderMock + getSshFilesystemProviderMock, + getSshGitProviderMock ]) { mock.mockReset() } @@ -157,7 +189,15 @@ describe('registerFilesystemHandlers', () => { } ]) trashItemMock.mockResolvedValue(undefined) + getSshGitProviderMock.mockReturnValue(null) statMock.mockResolvedValue({ size: 10, isDirectory: () => false, mtimeMs: 123 }) + openMock.mockResolvedValue({ + read: vi.fn(async (buffer: Buffer) => { + buffer.fill(0x61) + return { bytesRead: buffer.length, buffer } + }), + close: vi.fn() + }) lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) }) @@ -217,6 +257,55 @@ describe('registerFilesystemHandlers', () => { }) }) + it('opens text files larger than the old 5MB guard', async () => { + const content = 'a'.repeat(6 * 1024 * 1024) + statMock.mockResolvedValue({ size: content.length, isDirectory: () => false, mtimeMs: 123 }) + readFileMock.mockResolvedValue(Buffer.from(content)) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('fs:readFile')!(null, { filePath: path.resolve('/workspace/repo/large.json') }) + ).resolves.toEqual({ + content, + isBinary: false + }) + }) + + it('rejects text files beyond the editor read budget', async () => { + statMock.mockResolvedValue({ size: 51 * 1024 * 1024, isDirectory: () => false, mtimeMs: 123 }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('fs:readFile')!(null, { filePath: path.resolve('/workspace/repo/huge.json') }) + ).rejects.toThrow('exceeds 50MB limit') + + expect(readFileMock).not.toHaveBeenCalled() + }) + + it('probes large unknown binaries without reading the full file', async () => { + statMock.mockResolvedValue({ size: 6 * 1024 * 1024, isDirectory: () => false, mtimeMs: 123 }) + openMock.mockResolvedValue({ + read: vi.fn(async (buffer: Buffer) => { + buffer[0] = 0x00 + return { bytesRead: 1, buffer } + }), + close: vi.fn() + }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('fs:readFile')!(null, { filePath: path.resolve('/workspace/repo/archive.bin') }) + ).resolves.toEqual({ + content: '', + isBinary: true + }) + + expect(readFileMock).not.toHaveBeenCalled() + }) + it('moves files to trash', async () => { registerFilesystemHandlers(store as never) const targetPath = path.resolve('/workspace/repo/file.txt') @@ -311,6 +400,135 @@ describe('registerFilesystemHandlers', () => { expect(bulkUnstageFilesMock).not.toHaveBeenCalled() }) + it('lists markdown documents recursively for a registered worktree', async () => { + readdirMock.mockImplementation(async (dirPath: string) => { + if (dirPath === WORKTREE_FEATURE_PATH) { + return [ + dirEntry({ name: 'README.md', file: true }), + dirEntry({ name: 'docs', directory: true }), + dirEntry({ name: 'script.ts', file: true }) + ] + } + if (dirPath === path.join(WORKTREE_FEATURE_PATH, 'docs')) { + return [ + dirEntry({ name: 'Guide.MDX', file: true }), + dirEntry({ name: 'notes.markdown', file: true }) + ] + } + return [] + }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('fs:listMarkdownDocuments')!(null, { + rootPath: WORKTREE_FEATURE_PATH + }) + ).resolves.toEqual([ + { + filePath: path.join(WORKTREE_FEATURE_PATH, 'docs', 'Guide.MDX'), + relativePath: 'docs/Guide.MDX', + basename: 'Guide.MDX', + name: 'Guide' + }, + { + filePath: path.join(WORKTREE_FEATURE_PATH, 'docs', 'notes.markdown'), + relativePath: 'docs/notes.markdown', + basename: 'notes.markdown', + name: 'notes' + }, + { + filePath: path.join(WORKTREE_FEATURE_PATH, 'README.md'), + relativePath: 'README.md', + basename: 'README.md', + name: 'README' + } + ]) + }) + + it('skips ignored and symlinked directories when listing markdown documents', async () => { + readdirMock.mockImplementation(async (dirPath: string) => { + if (dirPath === WORKTREE_FEATURE_PATH) { + return [ + dirEntry({ name: '.git', directory: true }), + dirEntry({ name: '.hidden', directory: true }), + dirEntry({ name: '.github', directory: true }), + dirEntry({ name: 'node_modules', directory: true }), + dirEntry({ name: 'linked-docs', directory: true, symlink: true }), + dirEntry({ name: 'visible.md', file: true }) + ] + } + if (dirPath === path.join(WORKTREE_FEATURE_PATH, '.github')) { + return [dirEntry({ name: 'CONTRIBUTING.md', file: true })] + } + throw new Error(`Unexpected readdir: ${dirPath}`) + }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('fs:listMarkdownDocuments')!(null, { + rootPath: WORKTREE_FEATURE_PATH + }) + ).resolves.toEqual([ + { + filePath: path.join(WORKTREE_FEATURE_PATH, '.github', 'CONTRIBUTING.md'), + relativePath: '.github/CONTRIBUTING.md', + basename: 'CONTRIBUTING.md', + name: 'CONTRIBUTING' + }, + { + filePath: path.join(WORKTREE_FEATURE_PATH, 'visible.md'), + relativePath: 'visible.md', + basename: 'visible.md', + name: 'visible' + } + ]) + }) + + it('rejects markdown document listing for authorized but unregistered roots', async () => { + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('fs:listMarkdownDocuments')!(null, { + rootPath: path.resolve('/workspace/unregistered') + }) + ).rejects.toThrow('Access denied: unknown repository or worktree path') + + expect(readdirMock).not.toHaveBeenCalled() + }) + + it('lists remote markdown documents through the SSH filesystem provider', async () => { + const provider = { + listFiles: vi + .fn() + .mockResolvedValue(['README.md', 'docs/guide.mdx', '../outside.md', 'src/app.ts']) + } + getSshFilesystemProviderMock.mockReturnValue(provider) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('fs:listMarkdownDocuments')!(null, { + rootPath: '/home/user/project', + connectionId: 'ssh-1' + }) + ).resolves.toEqual([ + { + filePath: '/home/user/project/docs/guide.mdx', + relativePath: 'docs/guide.mdx', + basename: 'guide.mdx', + name: 'guide' + }, + { + filePath: '/home/user/project/README.md', + relativePath: 'README.md', + basename: 'README.md', + name: 'README' + } + ]) + }) + it('routes branch compare queries through the git compare helper', async () => { getBranchCompareMock.mockResolvedValue({ summary: { @@ -335,6 +553,95 @@ describe('registerFilesystemHandlers', () => { expect(getBranchCompareMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'origin/main') }) + it('routes local git:commit through commitChanges and returns success', async () => { + commitChangesMock.mockResolvedValue({ success: true }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:commit')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + message: 'feat: ship commit' + }) + ).resolves.toEqual({ success: true }) + + expect(commitChangesMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'feat: ship commit') + }) + + it('returns local commit hook failure payload from git:commit', async () => { + commitChangesMock.mockResolvedValue({ success: false, error: 'hook failed' }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:commit')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + message: 'feat: ship commit' + }) + ).resolves.toEqual({ success: false, error: 'hook failed' }) + }) + + it('routes ssh git:commit through the SSH provider instead of local commitChanges', async () => { + const sshCommitMock = vi.fn().mockResolvedValue({ success: true }) + getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:commit')!(null, { + worktreePath: '/remote/repo', + message: 'feat: remote commit', + connectionId: 'conn-1' + }) + ).resolves.toEqual({ success: true }) + + expect(sshCommitMock).toHaveBeenCalledWith('/remote/repo', 'feat: remote commit') + expect(commitChangesMock).not.toHaveBeenCalled() + }) + + it('rejects git:commit with empty message and does not call commitChanges', async () => { + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:commit')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + message: '' + }) + ).rejects.toThrow('Commit message is required') + + expect(commitChangesMock).not.toHaveBeenCalled() + }) + + it('rejects git:commit with whitespace-only message and does not call commitChanges', async () => { + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:commit')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + message: ' ' + }) + ).rejects.toThrow('Commit message is required') + + expect(commitChangesMock).not.toHaveBeenCalled() + }) + + it('rejects git:commit with whitespace-only message before SSH dispatch', async () => { + const sshCommitMock = vi.fn().mockResolvedValue({ success: true }) + getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:commit')!(null, { + worktreePath: '/remote/repo', + message: '\n', + connectionId: 'conn-1' + }) + ).rejects.toThrow('Commit message is required') + + expect(sshCommitMock).not.toHaveBeenCalled() + }) + it('allows git operations on worktrees outside repo/workspace roots', async () => { // Linked worktrees can live anywhere on disk (e.g. ~/.codex/worktrees/). // As long as the path matches a worktree reported by `git worktree list` diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 50bbf5bcf10..7f91ef70a31 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ import { ipcMain, shell } from 'electron' -import { readdir, readFile, writeFile, stat, lstat } from 'fs/promises' +import { readdir, readFile, writeFile, stat, lstat, open } from 'fs/promises' import { extname } from 'path' import type { ChildProcess } from 'child_process' import { wslAwareSpawn } from '../git/runner' @@ -12,6 +12,7 @@ import type { GitConflictOperation, GitDiffResult, GitStatusResult, + MarkdownDocument, SearchOptions, SearchResult } from '../../shared/types' @@ -27,6 +28,7 @@ import { getStatus, detectConflictOperation, getDiff, + commitChanges, stageFile, unstageFile, bulkStageFiles, @@ -47,11 +49,15 @@ import { import { listQuickOpenFiles } from './filesystem-list-files' import { registerFilesystemMutationHandlers } from './filesystem-mutations' import { searchWithGitGrep } from './filesystem-search-git' +import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents' import { checkRgAvailable } from './rg-availability' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { getSshGitProvider } from '../providers/ssh-git-dispatch' -const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB +// Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes +// ordinary JSON/log files inaccessible before the editor can degrade features. +const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB +const BINARY_PROBE_BYTES = 8192 // Why: previewable binaries (PDFs, images) are rendered by the viewer as // base64 blobs, not parsed as text — 5MB is tight for real-world PDFs, and // raising this cap only affects binary preview, not text/search paths. @@ -85,6 +91,17 @@ function isBinaryBuffer(buffer: Buffer): boolean { return false } +async function isBinaryFilePrefix(filePath: string): Promise { + const handle = await open(filePath, 'r') + try { + const probe = Buffer.alloc(BINARY_PROBE_BYTES) + const { bytesRead } = await handle.read(probe, 0, probe.length, 0) + return isBinaryBuffer(probe.subarray(0, bytesRead)) + } finally { + await handle.close() + } +} + export function registerFilesystemHandlers(store: Store): void { void rebuildAuthorizedRootsCache(store) const activeTextSearches = new Map() @@ -133,15 +150,15 @@ export function registerFilesystemHandlers(store: Store): void { const filePath = await resolveAuthorizedPath(args.filePath, store) const stats = await stat(filePath) const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_FILE_SIZE + const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE if (stats.size > sizeLimit) { throw new Error( `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` ) } - const buffer = await readFile(filePath) if (mimeType) { + const buffer = await readFile(filePath) return { content: buffer.toString('base64'), isBinary: true, @@ -153,6 +170,14 @@ export function registerFilesystemHandlers(store: Store): void { } } + // Why: the text cap is intentionally larger than the old binary cap. + // Probe unknown large files first so archives do not get fully buffered + // just to discover they are not editable text. + if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { + return { content: '', isBinary: true } + } + + const buffer = await readFile(filePath) if (isBinaryBuffer(buffer)) { return { content: '', isBinary: true } } @@ -161,6 +186,26 @@ export function registerFilesystemHandlers(store: Store): void { } ) + ipcMain.handle( + 'fs:listMarkdownDocuments', + async ( + _event, + args: { rootPath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshFilesystemProvider(args.connectionId) + if (!provider) { + throw new Error(`No filesystem provider for connection "${args.connectionId}"`) + } + const relativePaths = await provider.listFiles(args.rootPath) + return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths) + } + + const rootPath = await resolveRegisteredWorktreePath(args.rootPath, store) + return listMarkdownDocuments(rootPath) + } + ) + ipcMain.handle( 'fs:writeFile', async ( @@ -435,18 +480,51 @@ export function registerFilesystemHandlers(store: Store): void { 'git:diff', async ( _event, - args: { worktreePath: string; filePath: string; staged: boolean; connectionId?: string } + args: { + worktreePath: string + filePath: string + staged: boolean + compareAgainstHead?: boolean + connectionId?: string + } ): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(`No git provider for connection "${args.connectionId}"`) } - return provider.getDiff(args.worktreePath, args.filePath, args.staged) + return provider.getDiff( + args.worktreePath, + args.filePath, + args.staged, + args.compareAgainstHead + ) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - return getDiff(worktreePath, filePath, args.staged) + return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead) + } + ) + + ipcMain.handle( + 'git:commit', + async ( + _event, + args: { worktreePath: string; message: string; connectionId?: string } + ): Promise<{ success: boolean; error?: string }> => { + // Why: validate at the IPC boundary so the renderer gets a clear error instead of an opaque execFile failure. + if (typeof args.message !== 'string' || args.message.trim().length === 0) { + throw new Error('Commit message is required') + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.commit(args.worktreePath, args.message) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return commitChanges(worktreePath, args.message) } ) diff --git a/src/main/ipc/github-work-item-args.test.ts b/src/main/ipc/github-work-item-args.test.ts new file mode 100644 index 00000000000..8bb38c8170a --- /dev/null +++ b/src/main/ipc/github-work-item-args.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import { dispatchWorkItem } from './github-work-item-args' + +describe('dispatchWorkItem', () => { + it('rejects non-integer numbers', () => { + const fn = vi.fn() + expect(dispatchWorkItem({ repoPath: '/r', number: 1.5 }, '/r', fn)).toBeNull() + expect(fn).not.toHaveBeenCalled() + }) + + it('rejects numbers < 1', () => { + const fn = vi.fn() + expect(dispatchWorkItem({ repoPath: '/r', number: 0 }, '/r', fn)).toBeNull() + expect(dispatchWorkItem({ repoPath: '/r', number: -5 }, '/r', fn)).toBeNull() + expect(fn).not.toHaveBeenCalled() + }) + + it('rejects non-number values coming across IPC', () => { + const fn = vi.fn() + // Renderer can send anything; simulate a string that slips past TS. + const bogus = { repoPath: '/r', number: 'abc' as unknown as number } + expect(dispatchWorkItem(bogus, '/r', fn)).toBeNull() + expect(fn).not.toHaveBeenCalled() + }) + + it('coerces unknown type values to undefined', async () => { + const fn = vi.fn().mockResolvedValue(null) + const bogus = { + repoPath: '/r', + number: 42, + type: 'bogus' as unknown as 'issue' | 'pr' + } + await dispatchWorkItem(bogus, '/r', fn) + expect(fn).toHaveBeenCalledWith('/r', 42, undefined) + }) + + it('passes valid issue type through', async () => { + const fn = vi.fn().mockResolvedValue(null) + await dispatchWorkItem({ repoPath: '/r', number: 42, type: 'issue' }, '/r', fn) + expect(fn).toHaveBeenCalledWith('/r', 42, 'issue') + }) + + it('passes valid pr type through', async () => { + const fn = vi.fn().mockResolvedValue(null) + await dispatchWorkItem({ repoPath: '/r', number: 42, type: 'pr' }, '/r', fn) + expect(fn).toHaveBeenCalledWith('/r', 42, 'pr') + }) +}) diff --git a/src/main/ipc/github-work-item-args.ts b/src/main/ipc/github-work-item-args.ts new file mode 100644 index 00000000000..43b16deb600 --- /dev/null +++ b/src/main/ipc/github-work-item-args.ts @@ -0,0 +1,22 @@ +export type WorkItemArgs = { + repoPath: string + number: number + type?: 'issue' | 'pr' +} + +// Why: renderer input crosses the IPC boundary and is untrusted. Reject +// non-integer or < 1 numbers, and coerce unrecognised `type` values to +// undefined so getWorkItem falls through to its issue-then-PR probe rather +// than silently dispatching to the wrong branch. +export function dispatchWorkItem( + args: WorkItemArgs, + repoPath: string, + fn: (path: string, n: number, t?: 'issue' | 'pr') => Promise +): Promise | null { + const { number, type } = args + if (typeof number !== 'number' || !Number.isInteger(number) || number < 1) { + return null + } + const safeType = type === 'issue' || type === 'pr' ? type : undefined + return fn(repoPath, number, safeType) +} diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 3b27c64f7cb..8a74e3b741c 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -1,13 +1,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock, getPRForBranchMock, getIssueMock, listIssuesMock, getAuthenticatedViewerMock } = - vi.hoisted(() => ({ - handleMock: vi.fn(), - getPRForBranchMock: vi.fn(), - getIssueMock: vi.fn(), - listIssuesMock: vi.fn(), - getAuthenticatedViewerMock: vi.fn() - })) +const { + handleMock, + getPRForBranchMock, + getIssueMock, + listIssuesMock, + listWorkItemsMock, + getAuthenticatedViewerMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + getPRForBranchMock: vi.fn(), + getIssueMock: vi.fn(), + listIssuesMock: vi.fn(), + listWorkItemsMock: vi.fn(), + getAuthenticatedViewerMock: vi.fn() +})) vi.mock('electron', () => ({ ipcMain: { @@ -19,6 +26,7 @@ vi.mock('../github/client', () => ({ getPRForBranch: getPRForBranchMock, getIssue: getIssueMock, listIssues: listIssuesMock, + listWorkItems: listWorkItemsMock, getAuthenticatedViewer: getAuthenticatedViewerMock })) @@ -28,16 +36,17 @@ type HandlerMap = Record unknown> describe('registerGitHubHandlers', () => { const handlers: HandlerMap = {} + type FixtureRepo = { + id: string + path: string + displayName: string + badgeColor: string + addedAt: number + issueSourcePreference?: 'origin' | 'upstream' + } + let repos: FixtureRepo[] = [] const store = { - getRepos: () => [ - { - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0 - } - ] + getRepos: () => repos } const stats = { hasCountedPR: () => false, @@ -49,11 +58,26 @@ describe('registerGitHubHandlers', () => { getPRForBranchMock.mockReset() getIssueMock.mockReset() listIssuesMock.mockReset() + listWorkItemsMock.mockReset() getAuthenticatedViewerMock.mockReset() for (const key of Object.keys(handlers)) { delete handlers[key] } + // Reset fixture repos to the default single-repo fixture each test, so + // individual tests can mutate the list without leaking preferences across + // tests (e.g. a preference-threading test could otherwise shadow the + // default-undefined assertions in sibling tests). + repos = [ + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + ] + handleMock.mockImplementation((channel, handler) => { handlers[channel] = handler }) @@ -69,7 +93,7 @@ describe('registerGitHubHandlers', () => { branch: 'feature/test' }) - expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/test') + expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/test', null) }) it('rejects unknown repository paths', async () => { @@ -85,8 +109,55 @@ describe('registerGitHubHandlers', () => { expect(getIssueMock).not.toHaveBeenCalled() }) - it('forwards listIssues for registered repositories', async () => { - listIssuesMock.mockResolvedValue([]) + it('forwards listIssues for registered repositories and unwraps items', async () => { + listIssuesMock.mockResolvedValue({ items: [] }) + + registerGitHubHandlers(store as never, stats as never) + + const result = await handlers['gh:listIssues'](null, { + repoPath: '/workspace/repo', + limit: 5 + }) + + expect(listIssuesMock).toHaveBeenCalledWith('/workspace/repo', 5, undefined) + expect(result).toEqual([]) + }) + + it('drops the error field from listIssues envelope at the IPC boundary', async () => { + // Why: src/main/ipc/github.ts intentionally unwraps the { items, error? } + // envelope to just `items` to preserve the pre-feature-1 + // `Promise` contract for `gh:listIssues`. Feature 1's UI + // consumes the richer envelope through `gh:listWorkItems` instead. This + // test locks in that intentional drop so a future change that starts + // propagating the error through this channel (or that throws when an + // error is present) is caught. + listIssuesMock.mockResolvedValue({ + items: [], + error: { + type: 'permission_denied', + message: + "You don't have permission to read issues for this repository. Check your GitHub token scopes." + } + }) + + registerGitHubHandlers(store as never, stats as never) + + const result = await handlers['gh:listIssues'](null, { + repoPath: '/workspace/repo', + limit: 5 + }) + + expect(listIssuesMock).toHaveBeenCalledWith('/workspace/repo', 5, undefined) + expect(result).toEqual([]) + }) + + it('threads issueSourcePreference through gh:listIssues', async () => { + // Why: repo.issueSourcePreference must reach listIssues so the upstream + // repo is queried when configured. A regression that drops the arg would + // pass the default-fixture tests (which assert `undefined`) silently, so + // this test pins the non-undefined preference-threading contract. + repos[0].issueSourcePreference = 'upstream' + listIssuesMock.mockResolvedValue({ items: [] }) registerGitHubHandlers(store as never, stats as never) @@ -95,7 +166,31 @@ describe('registerGitHubHandlers', () => { limit: 5 }) - expect(listIssuesMock).toHaveBeenCalledWith('/workspace/repo', 5) + expect(listIssuesMock).toHaveBeenCalledWith('/workspace/repo', 5, 'upstream') + }) + + it('threads issueSourcePreference through gh:listWorkItems', async () => { + // Why: gh:listWorkItems must also forward repo.issueSourcePreference + // (5th arg) so the work-items view honors the per-repo source selector. + repos[0].issueSourcePreference = 'origin' + listWorkItemsMock.mockResolvedValue({ items: [] }) + + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:listWorkItems'](null, { + repoPath: '/workspace/repo', + limit: 10, + query: 'is:open', + before: 'cursor-1' + }) + + expect(listWorkItemsMock).toHaveBeenCalledWith( + '/workspace/repo', + 10, + 'is:open', + 'cursor-1', + 'origin' + ) }) it('forwards the authenticated viewer lookup', async () => { diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 6046aa7211f..8fa182e31c1 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -1,3 +1,7 @@ +/* eslint-disable max-lines -- Why: all GitHub IPC handlers stay co-located so +the repo-path validation, preference-threading, and stats wiring patterns are +reviewable as one surface. Splitting by feature area would risk drifting +validation/gate conventions across handler files. */ import { ipcMain } from 'electron' import { resolve } from 'path' import type { Repo, GitHubIssueUpdate } from '../../shared/types' @@ -11,6 +15,7 @@ import { listWorkItems, countWorkItems, getWorkItem, + getWorkItemByOwnerRepo, createIssue, updateIssue, addIssueComment, @@ -28,7 +33,45 @@ import { starOrca } from '../github/client' import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' +import { getRateLimit } from '../github/rate-limit' +import { diagnoseGhAuth } from '../github/auth-diagnose' import type { GitHubPRFile } from '../../shared/types' +import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' +import { + getProjectViewTable, + listAccessibleProjects, + resolveProjectRef, + listProjectViews, + getWorkItemDetailsBySlug, + updateProjectItemFieldValue, + clearProjectItemFieldValue, + updateIssueBySlug, + updatePullRequestBySlug, + addIssueCommentBySlug, + updateIssueCommentBySlug, + deleteIssueCommentBySlug, + listLabelsBySlug, + listAssignableUsersBySlug, + listIssueTypesBySlug, + updateIssueTypeBySlug +} from '../github/project-view' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + ListAssignableUsersBySlugArgs, + ListIssueTypesBySlugArgs, + ListLabelsBySlugArgs, + ListProjectViewsArgs, + ProjectWorkItemDetailsBySlugArgs, + ResolveProjectRefArgs, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdateProjectItemFieldArgs, + UpdatePullRequestBySlugArgs +} from '../../shared/github-project-types' // Why: returns the full Repo object instead of just the path string so that // callers have access to repo.id for stat tracking and other context. @@ -42,22 +85,25 @@ function assertRegisteredRepo(repoPath: string, store: Store): Repo { } export function registerGitHubHandlers(store: Store, stats: StatsCollector): void { - ipcMain.handle('gh:prForBranch', async (_event, args: { repoPath: string; branch: string }) => { - const repo = assertRegisteredRepo(args.repoPath, store) - const pr = await getPRForBranch(repo.path, args.branch) - // Emit pr_created when a PR is first detected for a branch. - // Why here: the renderer polls gh:prForBranch to check PR status per worktree. - // This captures PRs opened from any workflow (Orca UI, gh CLI, github.com). - if (pr && !stats.hasCountedPR(pr.url)) { - stats.record({ - type: 'pr_created', - at: Date.now(), - repoId: repo.id, - meta: { prNumber: pr.number, prUrl: pr.url } - }) + ipcMain.handle( + 'gh:prForBranch', + async (_event, args: { repoPath: string; branch: string; linkedPRNumber?: number | null }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + const pr = await getPRForBranch(repo.path, args.branch, args.linkedPRNumber ?? null) + // Emit pr_created when a PR is first detected for a branch. + // Why here: the renderer polls gh:prForBranch to check PR status per worktree. + // This captures PRs opened from any workflow (Orca UI, gh CLI, github.com). + if (pr && !stats.hasCountedPR(pr.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: pr.number, prUrl: pr.url } + }) + } + return pr } - return pr - }) + ) ipcMain.handle('gh:issue', (_event, args: { repoPath: string; number: number }) => { const repo = assertRegisteredRepo(args.repoPath, store) @@ -66,14 +112,17 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return listIssues(repo.path, args.limit) + // Why: listIssues now returns { items, error? }. The IPC handler unwraps to + // the items array for the existing contract; feature 1's UI consumes the + // richer envelope through `gh:listWorkItems` instead. + return listIssues(repo.path, args.limit, repo.issueSourcePreference).then((r) => r.items) }) ipcMain.handle( 'gh:createIssue', (_event, args: { repoPath: string; title: string; body: string }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return createIssue(repo.path, args.title, args.body) + return createIssue(repo.path, args.title, args.body, repo.issueSourcePreference) } ) @@ -81,24 +130,46 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi 'gh:listWorkItems', (_event, args: { repoPath: string; limit?: number; query?: string; before?: string }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return listWorkItems(repo.path, args.limit, args.query, args.before) + return listWorkItems( + repo.path, + args.limit, + args.query, + args.before, + repo.issueSourcePreference + ) } ) ipcMain.handle('gh:countWorkItems', (_event, args: { repoPath: string; query?: string }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return countWorkItems(repo.path, args.query) + return countWorkItems(repo.path, args.query, repo.issueSourcePreference) }) - ipcMain.handle('gh:workItem', (_event, args: { repoPath: string; number: number }) => { - const repo = assertRegisteredRepo(args.repoPath, store) - return getWorkItem(repo.path, args.number) - }) - - ipcMain.handle('gh:workItemDetails', (_event, args: { repoPath: string; number: number }) => { - const repo = assertRegisteredRepo(args.repoPath, store) - return getWorkItemDetails(repo.path, args.number) - }) + ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) => + dispatchWorkItem(args, assertRegisteredRepo(args.repoPath, store).path, getWorkItem) + ) + ipcMain.handle( + 'gh:workItemByOwnerRepo', + ( + _event, + args: { + repoPath: string + owner: string + repo: string + number: number + type: 'issue' | 'pr' + } + ) => + getWorkItemByOwnerRepo( + assertRegisteredRepo(args.repoPath, store).path, + { owner: args.owner, repo: args.repo }, + args.number, + args.type + ) + ) + ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => + dispatchWorkItem(args, assertRegisteredRepo(args.repoPath, store).path, getWorkItemDetails) + ) ipcMain.handle( 'gh:prFileContents', @@ -314,16 +385,103 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle('gh:listLabels', (_event, args: { repoPath: string }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return listLabels(repo.path) + return listLabels(repo.path, repo.issueSourcePreference) }) ipcMain.handle('gh:listAssignableUsers', (_event, args: { repoPath: string }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return listAssignableUsers(repo.path) + return listAssignableUsers(repo.path, repo.issueSourcePreference) }) // Star operations target the Orca repo itself — no repoPath validation needed ipcMain.handle('gh:viewer', () => getAuthenticatedViewer()) ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred()) ipcMain.handle('gh:starOrca', () => starOrca()) + + // Why: `rate_limit` is exempt from GitHub's rate-limit accounting, so + // polling is cheap. A 30s in-process cache still avoids the gh subprocess + // cost on every render — see getRateLimit for the ttl rationale. Force + // parameter lets the renderer bust the cache after a known-expensive op + // (e.g. post-ProjectPicker discovery) without waiting out the ttl. + ipcMain.handle('gh:rateLimit', (_event, args?: { force?: boolean }) => + getRateLimit(args?.force ? { force: true } : undefined) + ) + + ipcMain.handle('gh:diagnoseAuth', () => diagnoseGhAuth()) + + // ── GitHub ProjectV2 view handlers ───────────────────────────────── + // Why: registered unconditionally so enabling the experimental flag at + // runtime takes effect without a restart. The renderer gates entry points. + // Handlers never throw across IPC — every failure mode resolves through the + // GitHubProjectViewError envelope. + + ipcMain.handle('gh:listAccessibleProjects', () => listAccessibleProjects()) + + ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) => + resolveProjectRef(args) + ) + + ipcMain.handle('gh:listProjectViews', (_event, args: ListProjectViewsArgs) => + listProjectViews(args) + ) + + ipcMain.handle('gh:getProjectViewTable', (_event, args: GetProjectViewTableArgs) => + getProjectViewTable(args) + ) + + ipcMain.handle( + 'gh:projectWorkItemDetailsBySlug', + (_event, args: ProjectWorkItemDetailsBySlugArgs) => getWorkItemDetailsBySlug(args) + ) + + ipcMain.handle('gh:updateProjectItemField', (_event, args: UpdateProjectItemFieldArgs) => + updateProjectItemFieldValue(args) + ) + + ipcMain.handle('gh:clearProjectItemField', (_event, args: ClearProjectItemFieldArgs) => + clearProjectItemFieldValue(args) + ) + + ipcMain.handle('gh:updateIssueBySlug', (_event, args: UpdateIssueBySlugArgs) => + updateIssueBySlug(args) + ) + + ipcMain.handle('gh:updatePullRequestBySlug', (_event, args: UpdatePullRequestBySlugArgs) => + updatePullRequestBySlug(args) + ) + + ipcMain.handle('gh:addIssueCommentBySlug', (_event, args: AddIssueCommentBySlugArgs) => + addIssueCommentBySlug(args) + ) + + ipcMain.handle('gh:updateIssueCommentBySlug', (_event, args: UpdateIssueCommentBySlugArgs) => + updateIssueCommentBySlug(args) + ) + + ipcMain.handle('gh:deleteIssueCommentBySlug', (_event, args: DeleteIssueCommentBySlugArgs) => + deleteIssueCommentBySlug(args) + ) + + ipcMain.handle('gh:listLabelsBySlug', (_event, args: ListLabelsBySlugArgs) => + listLabelsBySlug(args) + ) + + ipcMain.handle('gh:listAssignableUsersBySlug', (_event, args: ListAssignableUsersBySlugArgs) => + listAssignableUsersBySlug(args) + ) + + ipcMain.handle('gh:listIssueTypesBySlug', (_event, args: ListIssueTypesBySlugArgs) => + listIssueTypesBySlug(args) + ) + + ipcMain.handle('gh:updateIssueTypeBySlug', (_event, args: UpdateIssueTypeBySlugArgs) => + updateIssueTypeBySlug(args) + ) + + // Why: issue-source preference writes go through the generic `repos:update` + // IPC (extended in this PR to accept `issueSourcePreference`). Routing + // through the same channel keeps a single write path, guarantees the + // `repos:changed` broadcast is emitted, and avoids two channels racing to + // persist the same field with different validation and eviction semantics. + // Reads piggyback on the `Repo` record already delivered by `repos:list`. } diff --git a/src/main/ipc/markdown-documents.ts b/src/main/ipc/markdown-documents.ts new file mode 100644 index 00000000000..cedf854be0b --- /dev/null +++ b/src/main/ipc/markdown-documents.ts @@ -0,0 +1,98 @@ +import { readdir } from 'fs/promises' +import { basename as pathBasename, extname, join, relative } from 'path' +import type { MarkdownDocument } from '../../shared/types' + +function normalizeRelativePath(path: string): string { + return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') +} + +function isMarkdownDocumentName(name: string): boolean { + const extension = extname(name).toLowerCase() + return extension === '.md' || extension === '.mdx' || extension === '.markdown' +} + +function basenameFromRelativePath(relativePath: string): string { + const normalizedPath = relativePath.replaceAll('\\', '/') + return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1) +} + +function isSafeRelativePath(relativePath: string): boolean { + return !relativePath.split('/').includes('..') +} + +function toMarkdownDocument(rootPath: string, filePath: string): MarkdownDocument { + const basename = pathBasename(filePath) + const extension = extname(basename) + return { + filePath, + relativePath: normalizeRelativePath(relative(rootPath, filePath)), + basename, + name: extension ? basename.slice(0, -extension.length) : basename + } +} + +export function markdownDocumentFromRelativePath( + rootPath: string, + relativePath: string +): MarkdownDocument | null { + const normalizedRelativePath = normalizeRelativePath(relativePath) + // Why: SSH providers should return root-relative paths; reject escape + // segments before building a synthetic absolute path for renderer use. + if (!isSafeRelativePath(normalizedRelativePath)) { + return null + } + const basename = basenameFromRelativePath(normalizedRelativePath) + if (!isMarkdownDocumentName(basename)) { + return null + } + const extension = extname(basename) + const normalizedRoot = rootPath.replace(/[\\/]+$/, '') + return { + filePath: `${normalizedRoot}/${normalizedRelativePath}`, + relativePath: normalizedRelativePath, + basename, + name: extension ? basename.slice(0, -extension.length) : basename + } +} + +export function markdownDocumentsFromRelativePaths( + rootPath: string, + relativePaths: string[] +): MarkdownDocument[] { + return relativePaths + .map((relativePath) => markdownDocumentFromRelativePath(rootPath, relativePath)) + .filter((document): document is MarkdownDocument => document !== null) + .sort((a, b) => a.relativePath.localeCompare(b.relativePath)) +} + +export async function listMarkdownDocuments(rootPath: string): Promise { + const documents: MarkdownDocument[] = [] + + async function visitDirectory(dirPath: string): Promise { + const entries = await readdir(dirPath, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isSymbolicLink()) { + continue + } + + const entryPath = join(dirPath, entry.name) + if (entry.isDirectory()) { + if (entry.name === '.git' || entry.name === 'node_modules') { + continue + } + if (entry.name.startsWith('.') && entry.name !== '.github') { + continue + } + await visitDirectory(entryPath) + continue + } + + if (entry.isFile() && isMarkdownDocumentName(entry.name)) { + documents.push(toMarkdownDocument(rootPath, entryPath)) + } + } + } + + await visitDirectory(rootPath) + return documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath)) +} diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts new file mode 100644 index 00000000000..528749c29b5 --- /dev/null +++ b/src/main/ipc/mobile.ts @@ -0,0 +1,126 @@ +import { ipcMain } from 'electron' +import { networkInterfaces } from 'os' +import QRCode from 'qrcode' +import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' + +export type NetworkInterface = { + name: string + address: string +} + +// Why: the WebSocket transport advertises 0.0.0.0 as its endpoint, which isn't +// connectable from a mobile device. We enumerate all non-internal IPv4 +// addresses so the user can choose which one to advertise in the QR code +// (e.g. LAN vs Tailscale). +function getNetworkInterfaces(): NetworkInterface[] { + const result: NetworkInterface[] = [] + const interfaces = networkInterfaces() + for (const [name, addrs] of Object.entries(interfaces)) { + if (!addrs) { + continue + } + for (const addr of addrs) { + if (addr.family === 'IPv4' && !addr.internal) { + result.push({ name, address: addr.address }) + } + } + } + return result +} + +function getLanAddress(): string | null { + const ifaces = getNetworkInterfaces() + return ifaces.length > 0 ? ifaces[0]!.address : null +} + +// Why: the mobile IPC handlers provide the renderer with QR code pairing data, +// device management, and WebSocket readiness status. They depend on the +// OrcaRuntimeRpcServer because it owns the device registry and TLS state. + +export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { + ipcMain.handle('mobile:listNetworkInterfaces', (): { interfaces: NetworkInterface[] } => ({ + interfaces: getNetworkInterfaces() + })) + + ipcMain.handle('mobile:getPairingQR', async (_event, args?: { address?: string }) => { + const rawEndpoint = rpcServer.getWebSocketEndpoint() + const registry = rpcServer.getDeviceRegistry() + if (!rawEndpoint || !registry) { + return { available: false as const } + } + + // Why: allow the caller to specify which network interface address to + // embed in the QR code. This supports overlay networks (Tailscale, + // ZeroTier) where the default LAN IP isn't reachable from the phone. + const ip = args?.address ?? getLanAddress() + if (!ip) { + return { available: false as const } + } + const endpoint = rawEndpoint.replace('0.0.0.0', ip) + + const device = registry.addDevice(`Mobile ${new Date().toLocaleDateString()}`) + + const publicKeyB64 = rpcServer.getE2EEPublicKey() + if (!publicKeyB64) { + return { available: false as const } + } + + const url = encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint, + deviceToken: device.token, + publicKeyB64 + }) + + const qrDataUrl = await QRCode.toDataURL(url, { + errorCorrectionLevel: 'M', + margin: 2, + width: 256 + }) + + return { + available: true as const, + qrDataUrl, + pairingUrl: url, + endpoint, + deviceId: device.deviceId + } + }) + + ipcMain.handle('mobile:listDevices', () => { + const registry = rpcServer.getDeviceRegistry() + if (!registry) { + return { devices: [] } + } + // Why: devices with lastSeenAt === 0 were created during QR generation + // but never actually scanned/connected. Showing them as "paired" is + // misleading, so we filter them out. + return { + devices: registry + .listDevices() + .filter((d) => d.lastSeenAt > 0) + .map((d) => ({ + deviceId: d.deviceId, + name: d.name, + pairedAt: d.pairedAt, + lastSeenAt: d.lastSeenAt + })) + } + }) + + ipcMain.handle('mobile:revokeDevice', (_event, args: { deviceId: string }) => { + const registry = rpcServer.getDeviceRegistry() + if (!registry) { + return { revoked: false } + } + return { revoked: registry.removeDevice(args.deviceId) } + }) + + ipcMain.handle('mobile:isWebSocketReady', () => { + return { + ready: rpcServer.getWebSocketEndpoint() !== null, + endpoint: rpcServer.getWebSocketEndpoint() + } + }) +} diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index fbe6c508348..2d1e6c39188 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -1,5 +1,8 @@ /* eslint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' const { removeHandlerMock, @@ -62,9 +65,12 @@ import { } from './notifications' describe('registerNotificationHandlers', () => { + let tempDir: string + beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-03-28T16:00:00Z')) + tempDir = mkdtempSync(join(tmpdir(), 'orca-notification-test-')) removeHandlerMock.mockReset() handleMock.mockReset() notificationCtorMock.mockClear() @@ -77,6 +83,10 @@ describe('registerNotificationHandlers', () => { getAllWindowsMock.mockReturnValue([]) }) + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + function getDispatchHandler(): (event: unknown, args: unknown) => unknown { const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === 'notifications:dispatch') if (!call) { @@ -85,6 +95,24 @@ describe('registerNotificationHandlers', () => { return call[1] as (event: unknown, args: unknown) => unknown } + function getLoadSoundHandler(): (event: unknown) => Promise { + const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === 'notifications:loadSound') + if (!call) { + throw new Error('notifications:loadSound handler not registered') + } + return call[1] as (event: unknown) => Promise + } + + function getResolveSoundPathHandler(): (event: unknown) => unknown { + const call = handleMock.mock.calls.find( + (c: unknown[]) => c[0] === 'notifications:resolveSoundPath' + ) + if (!call) { + throw new Error('notifications:resolveSoundPath handler not registered') + } + return call[1] as (event: unknown) => unknown + } + it('registers the IPC handler', () => { registerNotificationHandlers({ getSettings: () => ({ @@ -171,6 +199,28 @@ describe('registerNotificationHandlers', () => { expect(notificationShowMock).toHaveBeenCalledTimes(1) }) + it('silences the native notification when a custom sound is configured', () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: true, + customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg' + } + }) + } as never) + + const handler = getDispatchHandler() + expect(handler({}, { source: 'test' })).toEqual({ delivered: true }) + expect(notificationCtorMock).toHaveBeenCalledWith({ + title: 'Orca notifications are on', + body: 'This is a test notification from Orca.', + silent: true + }) + }) + it('returns source-disabled when the specific source toggle is off', () => { registerNotificationHandlers({ getSettings: () => ({ @@ -242,6 +292,87 @@ describe('registerNotificationHandlers', () => { }) expect(notificationShowMock).toHaveBeenCalledTimes(1) }) + + it('loads allowed custom sound files for preload playback', async () => { + const soundPath = join(tempDir, 'sound.ogg') + writeFileSync(soundPath, Buffer.from([1, 2, 3])) + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: soundPath + } + }) + } as never) + + const handler = getLoadSoundHandler() + await expect(handler({})).resolves.toMatchObject({ + ok: true, + data: new Uint8Array([1, 2, 3]), + mimeType: 'audio/ogg' + }) + }) + + it('rejects unsupported custom sound file types', async () => { + const soundPath = join(tempDir, 'sound.txt') + writeFileSync(soundPath, 'not audio') + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: soundPath + } + }) + } as never) + + const handler = getLoadSoundHandler() + await expect(handler({})).resolves.toEqual({ + ok: false, + reason: 'unsupported-type' + }) + }) + + it('resolves the sound path without reading the file', () => { + const soundPath = join(tempDir, 'sound.ogg') + writeFileSync(soundPath, Buffer.from([1, 2, 3])) + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: soundPath + } + }) + } as never) + + const handler = getResolveSoundPathHandler() + expect(handler({})).toEqual({ ok: true, path: soundPath }) + }) + + it('rejects unsupported types from resolveSoundPath without touching the disk', () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: '/some/where/sound.txt' + } + }) + } as never) + + const handler = getResolveSoundPathHandler() + expect(handler({})).toEqual({ ok: false, reason: 'unsupported-type' }) + }) }) describe('triggerStartupNotificationRegistration', () => { diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index eb69159bba8..8ec5752eec7 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -1,8 +1,24 @@ import { app, BrowserWindow, Notification, ipcMain, shell } from 'electron' +import { readFile, stat } from 'node:fs/promises' +import { extname, isAbsolute, normalize } from 'node:path' import type { Store } from '../persistence' -import type { NotificationDispatchRequest, NotificationDispatchResult } from '../../shared/types' +import type { + NotificationDispatchRequest, + NotificationDispatchResult, + NotificationSoundDataResult +} from '../../shared/types' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' const NOTIFICATION_COOLDOWN_MS = 5000 +const MAX_NOTIFICATION_SOUND_BYTES = 10 * 1024 * 1024 +const NOTIFICATION_SOUND_MIME_BY_EXTENSION: ReadonlyMap = new Map([ + ['.ogg', 'audio/ogg'], + ['.mp3', 'audio/mpeg'], + ['.wav', 'audio/wav'], + ['.m4a', 'audio/mp4'], + ['.aac', 'audio/aac'], + ['.flac', 'audio/flac'] +]) // Why: Electron Notification objects are normal JS objects — if the only // reference is a local variable inside the ipcMain handler, the GC can @@ -11,7 +27,7 @@ const NOTIFICATION_COOLDOWN_MS = 5000 // strong reference until the notification is clicked or closed. const activeNotifications = new Set() -export function registerNotificationHandlers(store: Store): void { +export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void { const recentNotifications = new Map() ipcMain.removeHandler('notifications:openSystemSettings') @@ -28,6 +44,21 @@ export function registerNotificationHandlers(store: Store): void { ipcMain.handle( 'notifications:dispatch', (_event, args: NotificationDispatchRequest): NotificationDispatchResult => { + // Why: mobile push is independent of desktop notification guards. + // The user's phone should receive the notification even when the desktop + // window is focused (suppressWhenFocused), Electron notifications aren't + // supported, or the desktop is in cooldown. The mobile client decides + // independently whether to show based on its own app state. + if (runtime) { + const opts = buildNotificationOptions(args) + runtime.dispatchMobileNotification({ + source: args.source, + title: opts.title, + body: opts.body, + worktreeId: args.worktreeId + }) + } + if (!Notification.isSupported()) { return { delivered: false, reason: 'not-supported' } } @@ -74,7 +105,11 @@ export function registerNotificationHandlers(store: Store): void { } } - const notification = new Notification(buildNotificationOptions(args)) + const notificationOptions = buildNotificationOptions(args) + if (settings.customSoundPath) { + notificationOptions.silent = true + } + const notification = new Notification(notificationOptions) // Why: prevent GC from collecting the notification (and its click // handler) while it's still visible in macOS Notification Center. @@ -119,9 +154,68 @@ export function registerNotificationHandlers(store: Store): void { } notification.show() + return { delivered: true } } ) + + // Why: the preload caches the decoded blob keyed by path. Returning just + // the validated path lets it skip the 10MB IPC round-trip on every dispatch + // when the user's selection hasn't changed — terminal-bell bursts can fire + // many notifications in seconds. + ipcMain.removeHandler('notifications:resolveSoundPath') + ipcMain.handle( + 'notifications:resolveSoundPath', + (): + | { ok: true; path: string } + | { ok: false; reason: 'missing-path' | 'invalid-path' | 'unsupported-type' } => { + const pathValue = store.getSettings().notifications.customSoundPath + if (!pathValue) { + return { ok: false, reason: 'missing-path' } + } + const normalizedPath = normalize(pathValue) + if (!isAbsolute(normalizedPath)) { + return { ok: false, reason: 'invalid-path' } + } + if (!NOTIFICATION_SOUND_MIME_BY_EXTENSION.has(extname(normalizedPath).toLowerCase())) { + return { ok: false, reason: 'unsupported-type' } + } + return { ok: true, path: normalizedPath } + } + ) + + ipcMain.removeHandler('notifications:loadSound') + ipcMain.handle('notifications:loadSound', async (): Promise => { + const pathValue = store.getSettings().notifications.customSoundPath + if (!pathValue) { + return { ok: false, reason: 'missing-path' } + } + + const normalizedPath = normalize(pathValue) + if (!isAbsolute(normalizedPath)) { + return { ok: false, reason: 'invalid-path' } + } + + const mimeType = NOTIFICATION_SOUND_MIME_BY_EXTENSION.get(extname(normalizedPath).toLowerCase()) + if (!mimeType) { + return { ok: false, reason: 'unsupported-type' } + } + + try { + const fileStat = await stat(normalizedPath) + if (!fileStat.isFile()) { + return { ok: false, reason: 'invalid-path' } + } + if (fileStat.size > MAX_NOTIFICATION_SOUND_BYTES) { + return { ok: false, reason: 'too-large' } + } + + const data = await readFile(normalizedPath) + return { ok: true, data: new Uint8Array(data), mimeType, path: normalizedPath } + } catch { + return { ok: false, reason: 'read-failed' } + } + }) } /** diff --git a/src/main/ipc/pty-management.test.ts b/src/main/ipc/pty-management.test.ts new file mode 100644 index 00000000000..c4d5d1c19db --- /dev/null +++ b/src/main/ipc/pty-management.test.ts @@ -0,0 +1,414 @@ +/* eslint-disable max-lines -- Why: covers every pty:management IPC channel +against shared mocks (electron, fs, daemon-init, DaemonPtyRouter). Splitting +across files would duplicate the vi.hoisted setup and the shared helpers, +with no meaningful ownership seam. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DaemonSessionInfo } from '../daemon/types' + +const { handleMock, removeHandlerMock, getDaemonProviderMock, restartDaemonMock } = vi.hoisted( + () => ({ + handleMock: vi.fn(), + removeHandlerMock: vi.fn(), + getDaemonProviderMock: vi.fn(), + restartDaemonMock: vi.fn() + }) +) + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock, removeHandler: removeHandlerMock } +})) + +vi.mock('../daemon/daemon-init', () => ({ + getDaemonProvider: getDaemonProviderMock, + restartDaemon: restartDaemonMock +})) + +// Why: the handler uses `provider instanceof DaemonPtyRouter` to branch +// between "plain adapter" and "router with current + legacy adapters". +// Mock the class here so tests can construct real instances via `new +// DaemonPtyRouter(...)` and the instanceof check returns true. The real +// router's constructor is side-effect heavy (subscribes to adapter events), +// so we only keep the accessors that pty-management touches — enough to +// satisfy the runtime type check without pulling in all the wiring. +vi.mock('../daemon/daemon-pty-router', () => { + class DaemonPtyRouter { + private allAdapters: unknown[] + constructor(opts: { current: unknown; legacy: unknown[] }) { + this.allAdapters = [opts.current, ...opts.legacy] + } + getAllAdapters() { + return this.allAdapters + } + } + return { DaemonPtyRouter } +}) + +type HandlerMap = Record unknown> + +function buildHandlerMap(): HandlerMap { + const map: HandlerMap = {} + for (const call of handleMock.mock.calls) { + const [channel, handler] = call as [string, (event: unknown, args?: unknown) => unknown] + map[channel] = handler + } + return map +} + +function makeSession( + sessionId: string, + overrides: Partial = {} +): DaemonSessionInfo { + return { + sessionId, + state: 'running', + shellState: 'ready', + isAlive: true, + pid: 1234, + cwd: '/home/user', + cols: 80, + rows: 24, + createdAt: 0, + protocolVersion: 4, + ...overrides + } +} + +type MockAdapter = { + protocolVersion: number + listSessions: ReturnType + shutdown: ReturnType +} + +function makeAdapter( + protocolVersion: number, + sessions: DaemonSessionInfo[], + shutdownImpl?: (id: string, immediate: boolean) => Promise +): MockAdapter { + // Why: collectSessions calls adapter.listSessions() (the daemon-side RPC) + // and then annotates with adapter.protocolVersion. The mock returns the + // *internal* SessionInfo shape (no protocolVersion) since the adapter adds + // it. Stripping it here mirrors production behavior. + return { + protocolVersion, + listSessions: vi.fn(async () => sessions.map(({ protocolVersion: _pv, ...rest }) => rest)), + shutdown: vi.fn(shutdownImpl ?? (async () => {})) + } +} + +async function importFresh() { + vi.resetModules() + handleMock.mockClear() + removeHandlerMock.mockClear() + return import('./pty-management') +} + +async function makeRouter(current: MockAdapter, legacy: MockAdapter[] = []) { + const { DaemonPtyRouter } = await import('../daemon/daemon-pty-router') + return new DaemonPtyRouter({ current: current as never, legacy: legacy as never }) +} + +describe('pty:management IPC handlers', () => { + beforeEach(() => { + getDaemonProviderMock.mockReset() + restartDaemonMock.mockReset() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('listSessions', () => { + it('merges sessions across current + legacy adapters with protocolVersion', async () => { + const current = makeAdapter(4, [makeSession('new-1'), makeSession('new-2')]) + const legacy = makeAdapter(3, [makeSession('old-1', { protocolVersion: 3 })]) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current, [legacy])) + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = (await handlers['pty:management:listSessions']({})) as { + sessions: DaemonSessionInfo[] + } + + expect(result.sessions).toHaveLength(3) + const byId = new Map(result.sessions.map((s) => [s.sessionId, s])) + expect(byId.get('new-1')?.protocolVersion).toBe(4) + expect(byId.get('new-2')?.protocolVersion).toBe(4) + expect(byId.get('old-1')?.protocolVersion).toBe(3) + }) + + it('returns empty list when no daemon provider is installed', async () => { + getDaemonProviderMock.mockReturnValue(null) + + const { registerDaemonManagementHandlers } = await importFresh() + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = (await handlers['pty:management:listSessions']({})) as { + sessions: DaemonSessionInfo[] + } + + expect(result.sessions).toEqual([]) + }) + + it('tolerates a failing adapter by skipping its sessions', async () => { + const current = makeAdapter(4, [makeSession('new-1')]) + const legacy = makeAdapter(3, []) + legacy.listSessions = vi.fn(async () => { + throw new Error('legacy socket dead') + }) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current, [legacy])) + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = (await handlers['pty:management:listSessions']({})) as { + sessions: DaemonSessionInfo[] + } + + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0].sessionId).toBe('new-1') + }) + }) + + describe('killAll', () => { + // Why: the handler sleeps POLL_INTERVAL_MS between listSessions polls. + // Fake timers let the tests drive that loop deterministically — without + // them, a happy-path test that converges in ≥1 poll would take 100ms+ + // of real wall time and the "refuses to die" test would take ~1s. + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + async function runKillAllWithPolls( + handler: (event: unknown, args?: unknown) => unknown, + pollCount: number = 65 + ): Promise<{ killedCount: number; remainingCount: number }> { + const resultPromise = handler({}) as Promise<{ + killedCount: number + remainingCount: number + }> + // Why: advance the loop's sleeps one at a time. Between each sleep the + // handler awaits collectSessions (a microtask), so we need to flush + // pending microtasks before advancing the next timer. + for (let i = 0; i < pollCount; i += 1) { + await Promise.resolve() + await Promise.resolve() + await vi.advanceTimersByTimeAsync(100) + } + return resultPromise + } + + it('fires one shutdown per initial session and polls until empty', async () => { + const currentSessions = [makeSession('new-1'), makeSession('new-2')] + const legacySessions = [makeSession('old-1', { protocolVersion: 3 })] + const current = makeAdapter(4, []) + const legacy = makeAdapter(3, []) + // Why: shutdown removes the session from the adapter's backing list so + // the next poll observes the shrinking set — mirrors a daemon that + // actually reaped the processes. + const removeFrom = (list: DaemonSessionInfo[], id: string): void => { + const idx = list.findIndex((s) => s.sessionId === id) + if (idx !== -1) { + list.splice(idx, 1) + } + } + current.listSessions = vi.fn(async () => + currentSessions.map(({ protocolVersion: _pv, ...rest }) => rest) + ) + legacy.listSessions = vi.fn(async () => + legacySessions.map(({ protocolVersion: _pv, ...rest }) => rest) + ) + current.shutdown = vi.fn(async (id: string) => { + removeFrom(currentSessions, id) + }) + legacy.shutdown = vi.fn(async (id: string) => { + removeFrom(legacySessions, id) + }) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current, [legacy])) + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = await runKillAllWithPolls(handlers['pty:management:killAll']) + + expect(result).toEqual({ killedCount: 3, remainingCount: 0 }) + // Each initial session receives exactly one shutdown — no retries. + expect(current.shutdown).toHaveBeenCalledTimes(2) + expect(current.shutdown).toHaveBeenCalledWith('new-1', { immediate: true }) + expect(current.shutdown).toHaveBeenCalledWith('new-2', { immediate: true }) + expect(legacy.shutdown).toHaveBeenCalledTimes(1) + expect(legacy.shutdown).toHaveBeenCalledWith('old-1', { immediate: true }) + }) + + it('reports remainingCount when sessions refuse to die after the poll window', async () => { + const sessions = [makeSession('stuck')] + const current = makeAdapter(4, []) + current.listSessions = vi.fn(async () => + sessions.map(({ protocolVersion: _pv, ...rest }) => rest) + ) + // Why: shutdown resolves but the session never leaves listSessions — + // simulates a process wedged in uninterruptible sleep. After the poll + // window (≈6.5s, past the daemon's 5s SIGTERM→SIGKILL ladder) the + // handler must return remainingCount=1 rather than loop forever. + current.shutdown = vi.fn(async () => {}) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current)) + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = await runKillAllWithPolls(handlers['pty:management:killAll']) + + expect(result).toEqual({ killedCount: 0, remainingCount: 1 }) + // One shutdown fired — no per-session retry. Initial-snapshot + // accounting means the stuck session is counted once. + expect(current.shutdown).toHaveBeenCalledTimes(1) + }) + + it('does not count respawned sessions with fresh IDs against remainingCount', async () => { + // Why: mounted panes may re-call pty:spawn with brand-new session IDs + // while killAll is polling (tab remount, navigate-back). Those fresh + // IDs are not part of the initial snapshot and must not inflate the + // "refused to exit" count — the user asked to kill what was alive + // when the button was pressed, not to chase new spawns. + const liveSessions = [makeSession('a'), makeSession('b')] + const current = makeAdapter(4, []) + let pollCalls = 0 + current.listSessions = vi.fn(async () => { + pollCalls += 1 + if (pollCalls === 1) { + // Initial snapshot: a and b are alive. + return liveSessions.map(({ protocolVersion: _pv, ...rest }) => rest) + } + // First poll onward: a and b have been reaped, but the renderer + // respawned a fresh pane with id 'c'. 'c' was never in the initial + // snapshot, so it must not count as remaining. + return [makeSession('c')].map(({ protocolVersion: _pv, ...rest }) => rest) + }) + current.shutdown = vi.fn(async () => {}) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current)) + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = await runKillAllWithPolls(handlers['pty:management:killAll']) + + expect(result).toEqual({ killedCount: 2, remainingCount: 0 }) + }) + + it('swallows per-session shutdown rejections without stopping the batch', async () => { + const sessionsList = [makeSession('a'), makeSession('b')] + const current = makeAdapter(4, []) + current.listSessions = vi.fn(async () => + sessionsList.map(({ protocolVersion: _pv, ...rest }) => rest) + ) + // Why: a rejecting shutdown for 'a' must not block the shutdown of 'b'. + // Since shutdowns fire in parallel (Promise.allSettled), both must be + // invoked regardless of 'a' throwing. + const removeFrom = (id: string): void => { + const idx = sessionsList.findIndex((s) => s.sessionId === id) + if (idx !== -1) { + sessionsList.splice(idx, 1) + } + } + current.shutdown = vi.fn(async (id: string) => { + if (id === 'a') { + throw new Error('a is stuck') + } + removeFrom(id) + }) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current)) + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = await runKillAllWithPolls(handlers['pty:management:killAll']) + + expect(current.shutdown).toHaveBeenCalledWith('a', { immediate: true }) + expect(current.shutdown).toHaveBeenCalledWith('b', { immediate: true }) + // 'a' rejected and is still alive → counts as remaining; 'b' reaped. + expect(result).toEqual({ killedCount: 1, remainingCount: 1 }) + }) + }) + + describe('killOne', () => { + it('routes to the adapter whose protocolVersion owns the session', async () => { + const current = makeAdapter(4, [makeSession('new-1')]) + const legacy = makeAdapter(3, [makeSession('old-1', { protocolVersion: 3 })]) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current, [legacy])) + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = (await handlers['pty:management:killOne']({}, { sessionId: 'old-1' })) as { + success: boolean + } + + expect(result.success).toBe(true) + expect(legacy.shutdown).toHaveBeenCalledWith('old-1', { immediate: true }) + expect(current.shutdown).not.toHaveBeenCalled() + }) + + it('returns success=false for unknown sessionId', async () => { + const current = makeAdapter(4, [makeSession('new-1')]) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current)) + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = (await handlers['pty:management:killOne']({}, { sessionId: 'ghost' })) as { + success: boolean + } + + expect(result.success).toBe(false) + expect(current.shutdown).not.toHaveBeenCalled() + }) + + it('rejects empty/missing sessionId without hitting the adapter', async () => { + const current = makeAdapter(4, [makeSession('new-1')]) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current)) + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = (await handlers['pty:management:killOne']({}, { sessionId: '' })) as { + success: boolean + } + + expect(result.success).toBe(false) + expect(current.listSessions).not.toHaveBeenCalled() + }) + }) + + describe('restart', () => { + it('delegates to restartDaemon and reports success', async () => { + restartDaemonMock.mockResolvedValue({ killedCount: 2 }) + + const { registerDaemonManagementHandlers } = await importFresh() + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = (await handlers['pty:management:restart']({})) as { success: boolean } + + expect(result.success).toBe(true) + expect(restartDaemonMock).toHaveBeenCalledTimes(1) + }) + + it('returns success=false when restartDaemon throws', async () => { + restartDaemonMock.mockRejectedValue(new Error('spawn failed')) + + const { registerDaemonManagementHandlers } = await importFresh() + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const result = (await handlers['pty:management:restart']({})) as { success: boolean } + consoleErrorSpy.mockRestore() + + expect(result.success).toBe(false) + }) + }) +}) diff --git a/src/main/ipc/pty-management.ts b/src/main/ipc/pty-management.ts new file mode 100644 index 00000000000..014aef35d36 --- /dev/null +++ b/src/main/ipc/pty-management.ts @@ -0,0 +1,171 @@ +import { ipcMain } from 'electron' +import { DaemonPtyRouter } from '../daemon/daemon-pty-router' +import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' +import { getDaemonProvider, restartDaemon } from '../daemon/daemon-init' +import type { DaemonSessionInfo } from '../daemon/types' + +// Why: the daemon's session.kill() sends SIGTERM first and escalates to +// SIGKILL after a 5s grace window (KILL_TIMEOUT_MS in session.ts). We have +// to poll past that ladder, or well-behaved-but-slow shells (zsh hosting a +// long-running agent) look like they "refused to exit" when they're actually +// still inside their SIGTERM handler waiting for SIGKILL. 65 polls at 100ms +// each (≈6.5s) covers the 5s ladder plus ~1.5s of slack for the final +// SIGKILL reap and the adapter's listSessions IPC roundtrip. The user waits +// during this window with a spinner; the alternative — reporting fake +// "refused" numbers — is worse. +const MAX_POLL_ATTEMPTS = 65 +const POLL_INTERVAL_MS = 100 + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function getDaemonAdapters(): DaemonPtyAdapter[] { + const provider = getDaemonProvider() + if (!provider) { + return [] + } + if (provider instanceof DaemonPtyRouter) { + return [...provider.getAllAdapters()] + } + return [provider] +} + +async function collectSessions(adapters: DaemonPtyAdapter[]): Promise { + const results = await Promise.allSettled( + adapters.map(async (adapter) => { + const sessions = await adapter.listSessions() + return sessions.map((s) => ({ + ...s, + protocolVersion: adapter.protocolVersion + })) + }) + ) + return results.flatMap((r) => (r.status === 'fulfilled' ? r.value : [])) +} + +export function registerDaemonManagementHandlers(): void { + ipcMain.removeHandler('pty:management:listSessions') + ipcMain.removeHandler('pty:management:killAll') + ipcMain.removeHandler('pty:management:killOne') + ipcMain.removeHandler('pty:management:restart') + + ipcMain.handle( + 'pty:management:listSessions', + async (): Promise<{ sessions: DaemonSessionInfo[] }> => { + const sessions = await collectSessions(getDaemonAdapters()) + return { sessions } + } + ) + + // Why: killAll operates on *sessions* (user-facing concept), not daemons, so + // it fans across every adapter — current + legacy — to match the user's + // "kill everything I might be attached to" mental model. The daemon + // processes themselves survive; only sessions are torn down. See + // docs/daemon-staleness-ux.md §Phase 1 "Scope rationale" for why legacy + // daemons aren't killed here. + ipcMain.handle( + 'pty:management:killAll', + async (): Promise<{ killedCount: number; remainingCount: number }> => { + const adapters = getDaemonAdapters() + // Why: snapshot the initial session set once, up front. All subsequent + // accounting is relative to these IDs. If the renderer respawns panes + // with *fresh* session IDs while we're polling (e.g. a remount fires + // pty:spawn mid-kill), those new sessions must not count as + // "remaining" — the user asked to kill what was alive at the moment + // they clicked the button, not to chase new spawns. + const initial = await collectSessions(adapters) + const initialIds = new Set(initial.map((s) => s.sessionId)) + const initialCount = initial.length + + if (initialCount === 0) { + return { killedCount: 0, remainingCount: 0 } + } + + // Why: fire one shutdown per initial session, in parallel, once — no + // per-session retry. The daemon's session.kill() is idempotent and + // schedules its own SIGTERM→SIGKILL ladder; firing the RPC repeatedly + // in a tight retry loop before the grace window expires just races our + // own polling. Promise.allSettled ensures a single adapter failure (or + // rejected RPC — e.g. session already exiting) does not short-circuit + // the remaining shutdowns. + await Promise.allSettled( + initial.map(async (session) => { + // Why: protocolVersion is unique across adapters by construction — + // PROTOCOL_VERSION is always distinct from every entry in + // PREVIOUS_DAEMON_PROTOCOL_VERSIONS (see types.ts). If a future + // bump forgets to rotate the retired version into the previous + // list, this find() would silently route legacy sessions to the + // current adapter. Keep the two constants in lockstep. + const owner = adapters.find((a) => a.protocolVersion === session.protocolVersion) + if (!owner) { + return + } + // Why: immediate=true is the adapter's "kill it now" signal. The + // current adapter ignores the flag (the daemon's SIGTERM→SIGKILL + // ladder handles escalation) but preserve it for legacy adapters + // and so a future adapter could honor it. Rejections are swallowed + // per-session — remainingCount surfaces truly-stuck sessions in + // the toast. + await owner.shutdown(session.sessionId, { immediate: true }).catch(() => {}) + }) + ) + + // Why: poll listSessions every POLL_INTERVAL_MS until none of the + // *initial* IDs are still alive, or we've exhausted MAX_POLL_ATTEMPTS. + // Counting only the initial-snapshot intersection (not the total + // session count) is what keeps the math honest when the renderer + // respawns panes with fresh IDs mid-kill. + let remainingOriginalCount = initialCount + for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) { + await sleep(POLL_INTERVAL_MS) + const current = await collectSessions(adapters) + remainingOriginalCount = current.reduce( + (count, s) => (initialIds.has(s.sessionId) ? count + 1 : count), + 0 + ) + if (remainingOriginalCount === 0) { + break + } + } + + const killedCount = initialCount - remainingOriginalCount + return { killedCount, remainingCount: remainingOriginalCount } + } + ) + + ipcMain.handle( + 'pty:management:killOne', + async (_event, args: { sessionId: string }): Promise<{ success: boolean }> => { + if (typeof args?.sessionId !== 'string' || args.sessionId.length === 0) { + return { success: false } + } + const adapters = getDaemonAdapters() + const sessions = await collectSessions(adapters) + const match = sessions.find((s) => s.sessionId === args.sessionId) + if (!match) { + return { success: false } + } + const owner = adapters.find((a) => a.protocolVersion === match.protocolVersion) + if (!owner) { + return { success: false } + } + try { + await owner.shutdown(args.sessionId, { immediate: true }) + return { success: true } + } catch { + return { success: false } + } + } + ) + + ipcMain.handle('pty:management:restart', async (): Promise<{ success: boolean }> => { + try { + await restartDaemon() + return { success: true } + } catch (err) { + console.error('[pty:management] restart failed', err) + return { success: false } + } + }) +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 6c78e375389..32baad1cd3a 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -20,7 +20,10 @@ const { openCodeClearPtyMock, buildAgentHookEnvMock, piBuildPtyEnvMock, - piClearPtyMock + piClearPtyMock, + isPwshAvailableMock, + trackMock, + classifyErrorMock } = vi.hoisted(() => ({ handleMock: vi.fn(), onMock: vi.fn(), @@ -35,10 +38,13 @@ const { getPathMock: vi.fn(), spawnMock: vi.fn(), openCodeBuildPtyEnvMock: vi.fn(), + isPwshAvailableMock: vi.fn(), openCodeClearPtyMock: vi.fn(), buildAgentHookEnvMock: vi.fn(), piBuildPtyEnvMock: vi.fn(), - piClearPtyMock: vi.fn() + piClearPtyMock: vi.fn(), + trackMock: vi.fn(), + classifyErrorMock: vi.fn() })) vi.mock('electron', () => ({ @@ -89,6 +95,18 @@ vi.mock('../pi/titlebar-extension-service', () => ({ clearPty: piClearPtyMock } })) + +vi.mock('../pwsh', () => ({ + isPwshAvailable: isPwshAvailableMock +})) + +vi.mock('../telemetry/client', () => ({ + track: trackMock +})) + +vi.mock('../telemetry/classify-error', () => ({ + classifyError: classifyErrorMock +})) import { LocalPtyProvider } from '../providers/local-pty-provider' import { registerPtyHandlers, @@ -112,8 +130,11 @@ describe('registerPtyHandlers', () => { } } + const savedPiAgentDir = process.env.PI_CODING_AGENT_DIR + beforeEach(() => { delete process.env.OPENCODE_CONFIG_DIR + delete process.env.PI_CODING_AGENT_DIR handlers.clear() handleMock.mockReset() onMock.mockReset() @@ -132,6 +153,9 @@ describe('registerPtyHandlers', () => { buildAgentHookEnvMock.mockReset() piBuildPtyEnvMock.mockReset() piClearPtyMock.mockReset() + isPwshAvailableMock.mockReset() + trackMock.mockReset() + classifyErrorMock.mockReset() mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() @@ -156,6 +180,7 @@ describe('registerPtyHandlers', () => { ? '/tmp/orca-pi-agent-overlay' : '/tmp/orca-pi-agent-overlay' })) + isPwshAvailableMock.mockReturnValue(false) spawnMock.mockReturnValue({ onData: vi.fn(() => makeDisposable()), onExit: vi.fn(() => makeDisposable()), @@ -170,6 +195,9 @@ describe('registerPtyHandlers', () => { afterEach(() => { unregisterSshPtyProvider('ssh-1') setLocalPtyProvider(new LocalPtyProvider()) + if (savedPiAgentDir !== undefined) { + process.env.PI_CODING_AGENT_DIR = savedPiAgentDir + } }) function createMockProc() { @@ -798,7 +826,61 @@ describe('registerPtyHandlers', () => { ) await handlers.get('pty:kill')!(null, { id: 'remote-pty' }) - expect(sshShutdown).toHaveBeenCalledWith('remote-pty', true) + expect(sshShutdown).toHaveBeenCalledWith('remote-pty', { + immediate: true, + keepHistory: false + }) + }) + + it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => { + const spawn = vi.fn(async () => ({ id: 'remote-pty' })) + registerSshPtyProvider('ssh-1', { + spawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn(), + acknowledgeDataEvent: vi.fn() + } as never) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), + registerPreAllocatedHandleForPty: vi.fn() + } + + registerPtyHandlers(mainWindow as never, runtime as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + connectionId: 'ssh-1', + env: { EXISTING: '1' } + }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + EXISTING: '1', + ORCA_TERMINAL_HANDLE: 'term_remote' + }) + }) + ) + expect(runtime.registerPreAllocatedHandleForPty).toHaveBeenCalledWith( + 'remote-pty', + 'term_remote' + ) }) describe('Windows UTF-8 code page', () => { @@ -937,6 +1019,178 @@ describe('registerPtyHandlers', () => { expect.any(Object) ) }) + + it('spawns powershell.exe when PowerShell family keeps the inbox implementation', () => { + process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe' + + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + () => + ({ + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'powershell.exe' + }) as never + ) + handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + + expect(spawnMock).toHaveBeenCalledWith( + 'powershell.exe', + [ + '-NoExit', + '-Command', + 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' + ], + expect.any(Object) + ) + }) + + it('spawns pwsh.exe when PowerShell 7 is selected and available', () => { + process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe' + isPwshAvailableMock.mockReturnValue(true) + + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + () => + ({ + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'pwsh.exe' + }) as never + ) + handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + + expect(spawnMock).toHaveBeenCalledWith( + 'pwsh.exe', + [ + '-NoExit', + '-Command', + 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' + ], + expect.any(Object) + ) + }) + + it('falls back to powershell.exe when PowerShell 7 is selected but unavailable', () => { + process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe' + isPwshAvailableMock.mockReturnValue(false) + + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + () => + ({ + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'pwsh.exe' + }) as never + ) + handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + + expect(spawnMock).toHaveBeenCalledWith( + 'powershell.exe', + [ + '-NoExit', + '-Command', + 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' + ], + expect.any(Object) + ) + }) + + it('falls back to powershell.exe when shellOverride requests pwsh.exe but pwsh is unavailable', () => { + process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe' + isPwshAvailableMock.mockReturnValue(false) + + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + () => + ({ + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'pwsh.exe' + }) as never + ) + handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, shellOverride: 'pwsh.exe' }) + + expect(spawnMock).toHaveBeenCalledWith( + 'powershell.exe', + [ + '-NoExit', + '-Command', + 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' + ], + expect.any(Object) + ) + }) + + it('ignores the PowerShell implementation setting for cmd.exe', () => { + process.env.COMSPEC = 'C:\\Windows\\system32\\powershell.exe' + isPwshAvailableMock.mockReturnValue(true) + + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + () => + ({ + terminalWindowsShell: 'cmd.exe', + terminalWindowsPowerShellImplementation: 'pwsh.exe' + }) as never + ) + handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + + expect(spawnMock).toHaveBeenCalledWith( + 'cmd.exe', + ['/K', 'chcp 65001 > nul'], + expect.any(Object) + ) + }) + + it('ignores the PowerShell implementation setting for wsl.exe', () => { + process.env.COMSPEC = 'C:\\Windows\\system32\\powershell.exe' + isPwshAvailableMock.mockReturnValue(true) + + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + () => + ({ + terminalWindowsShell: 'wsl.exe', + terminalWindowsPowerShellImplementation: 'pwsh.exe' + }) as never + ) + handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + + expect(spawnMock).toHaveBeenCalledWith('wsl.exe', expect.any(Array), expect.any(Object)) + }) + + it('keeps shellOverride priority for one-off tabs', () => { + process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe' + isPwshAvailableMock.mockReturnValue(false) + + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + () => + ({ + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'pwsh.exe' + }) as never + ) + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + shellOverride: 'wsl.exe' + }) + + expect(spawnMock).toHaveBeenCalledWith('wsl.exe', expect.any(Array), expect.any(Object)) + }) }) it('rejects missing WSL worktree cwd instead of validating only the fallback Windows cwd', async () => { @@ -1252,12 +1506,17 @@ describe('registerPtyHandlers', () => { it('disposes PTY listeners before manual kill IPC', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() + // Why: hold a stable reference to the kill spy. On POSIX, destroyPtyProcess + // in local-pty-provider reassigns proc.kill to a no-op to defuse the + // SIGHUP-to-recycled-pid hazard (see docs/fix-pty-fd-leak.md). Reading + // proc.kill.mock after that runs would yield a non-mock and crash. + const killSpy = vi.fn() const proc = { onData: vi.fn(() => onDataDisposable), onExit: vi.fn(() => onExitDisposable), write: vi.fn(), resize: vi.fn(), - kill: vi.fn(), + kill: killSpy, process: 'zsh', pid: 12345 } @@ -1272,22 +1531,23 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:kill')!(null, { id: spawnResult.id }) expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - proc.kill.mock.invocationCallOrder[0] + killSpy.mock.invocationCallOrder[0] ) expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - proc.kill.mock.invocationCallOrder[0] + killSpy.mock.invocationCallOrder[0] ) }) it('disposes PTY listeners before runtime controller kill', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() + const killSpy = vi.fn() const proc = { onData: vi.fn(() => onDataDisposable), onExit: vi.fn(() => onExitDisposable), write: vi.fn(), resize: vi.fn(), - kill: vi.fn(), + kill: killSpy, process: 'zsh', pid: 12345 } @@ -1295,7 +1555,8 @@ describe('registerPtyHandlers', () => { setPtyController: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), - onPtyExit: vi.fn() + onPtyExit: vi.fn(), + preAllocateHandleForPty: vi.fn() } spawnMock.mockReturnValue(proc) @@ -1310,22 +1571,23 @@ describe('registerPtyHandlers', () => { expect(runtimeController.kill(spawnResult.id)).toBe(true) expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - proc.kill.mock.invocationCallOrder[0] + killSpy.mock.invocationCallOrder[0] ) expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - proc.kill.mock.invocationCallOrder[0] + killSpy.mock.invocationCallOrder[0] ) }) it('disposes PTY listeners before did-finish-load orphan cleanup', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() + const killSpy = vi.fn() const proc = { onData: vi.fn(() => onDataDisposable), onExit: vi.fn(() => onExitDisposable), write: vi.fn(), resize: vi.fn(), - kill: vi.fn(), + kill: killSpy, process: 'zsh', pid: 12345 } @@ -1333,7 +1595,8 @@ describe('registerPtyHandlers', () => { setPtyController: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), - onPtyExit: vi.fn() + onPtyExit: vi.fn(), + preAllocateHandleForPty: vi.fn() } spawnMock.mockReturnValue(proc) @@ -1350,10 +1613,10 @@ describe('registerPtyHandlers', () => { didFinishLoad?.() expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - proc.kill.mock.invocationCallOrder[0] + killSpy.mock.invocationCallOrder[0] ) expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - proc.kill.mock.invocationCallOrder[0] + killSpy.mock.invocationCallOrder[0] ) }) @@ -1383,4 +1646,226 @@ describe('registerPtyHandlers', () => { expect(openCodeClearPtyMock).toHaveBeenCalledWith(spawnResult.id) expect(piClearPtyMock).toHaveBeenCalledWith(spawnResult.id) }) + + describe('agent_started telemetry', () => { + // Why: telemetry-plan.md§Agent launch semantics — agent_started must + // fire only after provider.spawn resolves. The renderer threads + // launch metadata through `pty:spawn`; a missing or malformed + // payload must not produce a silently-malformed event. + it('emits agent_started after a successful spawn when telemetry is supplied', async () => { + handlers.clear() + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + telemetry: { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + }) + expect(trackMock).toHaveBeenCalledWith('agent_started', { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + }) + }) + + it('does not emit agent_started when telemetry is omitted (bare-shell tab)', async () => { + handlers.clear() + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + expect(trackMock).not.toHaveBeenCalled() + }) + + it('drops the event when any telemetry field is outside its closed enum', async () => { + handlers.clear() + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + telemetry: { + agent_kind: 'claude-code', + launch_source: 'not_a_real_surface', + request_kind: 'new' + } + }) + expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything()) + }) + + it('does not emit agent_started when provider.spawn throws', async () => { + // Why: telemetry-plan contract is that agent_started fires only on + // confirmed launch. Inject a provider whose spawn throws so we hit + // the catch path with no race against the real LocalPtyProvider. + setLocalPtyProvider({ + spawn: vi.fn(async () => { + throw new Error('spawn boom') + }), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + classifyErrorMock.mockReturnValue({ error_class: 'unknown' }) + handlers.clear() + registerPtyHandlers(mainWindow as never) + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'claude', + telemetry: { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + }) + ).rejects.toThrow(/spawn boom/) + expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything()) + }) + }) + + describe('serializeBuffer dispatch', () => { + type SerializeListener = ( + _event: unknown, + args: { + requestId?: string + snapshot?: { data?: unknown; cols?: unknown; rows?: unknown; lastTitle?: unknown } | null + } + ) => void + type SerializeController = { + serializeBuffer: ( + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + ) => Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null> + } + + function setup(): { listener: SerializeListener; controller: SerializeController } { + const runtime = { + setPtyController: vi.fn(), + onPtySpawned: vi.fn(), + onPtyData: vi.fn(), + onPtyExit: vi.fn(), + preAllocateHandleForPty: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const onCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:serializeBuffer:response' + ) + if (!onCall) { + throw new Error('expected pty:serializeBuffer:response listener registration') + } + const listener = onCall[1] as SerializeListener + const controller = runtime.setPtyController.mock.calls[0]?.[0] as SerializeController + return { listener, controller } + } + + function getSentRequestIds(): string[] { + return mainWindow.webContents.send.mock.calls + .filter((call: unknown[]) => call[0] === 'pty:serializeBuffer:request') + .map((call: unknown[]) => (call[1] as { requestId: string }).requestId) + } + + it('registers exactly one persistent listener regardless of concurrent in-flight requests', async () => { + const { listener, controller } = setup() + const inflight = [ + controller.serializeBuffer('pty-1'), + controller.serializeBuffer('pty-2'), + controller.serializeBuffer('pty-3'), + controller.serializeBuffer('pty-4'), + controller.serializeBuffer('pty-5'), + controller.serializeBuffer('pty-6'), + controller.serializeBuffer('pty-7'), + controller.serializeBuffer('pty-8'), + controller.serializeBuffer('pty-9'), + controller.serializeBuffer('pty-10'), + controller.serializeBuffer('pty-11'), + controller.serializeBuffer('pty-12') + ] + // Why: the bug being fixed registered one listener per request, so 12 + // concurrent calls would register 12 listeners and trip Node's MaxListeners. + const responseChannelRegistrations = onMock.mock.calls.filter( + (call: unknown[]) => call[0] === 'pty:serializeBuffer:response' + ) + expect(responseChannelRegistrations.length).toBe(1) + // Drain the in-flight requests so the test doesn't leak timers. + for (const requestId of getSentRequestIds()) { + listener(null, { requestId, snapshot: null }) + } + await Promise.all(inflight) + }) + + it('routes each response to the originating request via requestId', async () => { + const { listener, controller } = setup() + const a = controller.serializeBuffer('pty-a') + const b = controller.serializeBuffer('pty-b') + const ids = getSentRequestIds() + const requestIdA = ids[0] + const requestIdB = ids[1] + + listener(null, { + requestId: requestIdB, + snapshot: { data: 'B-data', cols: 80, rows: 24 } + }) + listener(null, { + requestId: requestIdA, + snapshot: { data: 'A-data', cols: 100, rows: 30, lastTitle: 'A-title' } + }) + + await expect(b).resolves.toEqual({ data: 'B-data', cols: 80, rows: 24 }) + await expect(a).resolves.toEqual({ + data: 'A-data', + cols: 100, + rows: 30, + lastTitle: 'A-title' + }) + }) + + it('ignores responses with unknown requestId without affecting pending requests', async () => { + const { listener, controller } = setup() + const pending = controller.serializeBuffer('pty-1') + const realRequestId = getSentRequestIds()[0] + + listener(null, { + requestId: 'not-a-real-id', + snapshot: { data: 'irrelevant', cols: 1, rows: 1 } + }) + listener(null, { requestId: undefined, snapshot: null }) + + let resolved = false + void pending.then(() => { + resolved = true + }) + await new Promise((r) => setTimeout(r, 0)) + expect(resolved).toBe(false) + + listener(null, { requestId: realRequestId, snapshot: { data: 'ok', cols: 80, rows: 24 } }) + await expect(pending).resolves.toEqual({ data: 'ok', cols: 80, rows: 24 }) + }) + + it('resolves to null and removes the entry when the 750ms timeout fires', async () => { + vi.useFakeTimers() + try { + const { controller } = setup() + const pending = controller.serializeBuffer('pty-stuck') + vi.advanceTimersByTime(750) + await expect(pending).resolves.toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('resolves to null when the response snapshot is malformed', async () => { + const { listener, controller } = setup() + const pending = controller.serializeBuffer('pty-bad') + const requestId = getSentRequestIds()[0] + listener(null, { requestId, snapshot: { data: 'ok', cols: 'not-a-number' } }) + await expect(pending).resolves.toBeNull() + }) + }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index ee334f25c74..f0cb0b83947 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -4,13 +4,16 @@ foreground-process inspection, and renderer IPC stay behind a single audited boundary. Splitting it by line count would scatter tightly coupled terminal process behavior across files without a cleaner ownership seam. */ import { join, delimiter } from 'path' +import { randomUUID } from 'crypto' import { type BrowserWindow, ipcMain, app } from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { Store } from '../persistence' import type { GlobalSettings } from '../../shared/types' import { openCodeHookService } from '../opencode/hook-service' import { agentHookServer } from '../agent-hooks/server' import { piTitlebarExtensionService } from '../pi/titlebar-extension-service' +import { isPwshAvailable } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id' @@ -23,6 +26,13 @@ import { } from '../claude-accounts/live-pty-gate' import { applyTerminalAttributionEnv } from '../attribution/terminal-attribution' import { registerPty, unregisterPty } from '../memory/pty-registry' +import { track } from '../telemetry/client' +import { classifyError } from '../telemetry/classify-error' +import { + agentKindSchema, + launchSourceSchema, + requestKindSchema +} from '../../shared/telemetry-events' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId. null = local provider. @@ -35,6 +45,9 @@ const sshProviders = new Map() // post-spawn operations to the correct provider without the renderer needing // to track connectionId per-PTY. const ptyOwnership = new Map() +// Why: mobile clients must mirror desktop PTY geometry even when the renderer +// cannot provide an xterm snapshot yet, such as immediately after tab creation. +const ptySizes = new Map() // Why: the agent-hooks server caches per-paneKey state (last prompt, last // tool) that otherwise grows unbounded as panes come and go. Track the // spawn-time paneKey so clearProviderPtyState can clear that cache on PTY @@ -65,6 +78,45 @@ export function registerPaneKeyTeardownListener(listener: PaneKeyTeardownListene return () => paneKeyTeardownListeners.delete(listener) } +// Why: pre-signal handshake — the renderer declares it will own the serializer +// for a paneKey BEFORE issuing pty:spawn. The cooperation gate at provider.spawn +// return consults this map to suppress the daemon-snapshot seed when a renderer +// is taking over. Generation tokens prevent paneKey-reuse races during teardown: +// a paneKeyTeardownListener cleanup only fires settle when the captured gen +// still matches, so a remount that pre-signals before the old PTY's teardown +// runs is preserved. See docs/mobile-prefer-renderer-scrollback.md. +let pendingSerializerGenSeq = 0 +const pendingByPaneKey = new Map() +// Why: at PTY spawn time we capture the gen that was pending for the spawn's +// paneKey, so teardown can settle ONLY that gen. Without this, a paneKey +// remount that replaces the pending entry with a new gen would still get +// stomped by the old PTY's teardown firing settle on the wrong gen. +const ptyPendingGenByPtyId = new Map() +// Why: the runtime's hasRendererSerializer probe needs a ptyId-keyed signal. +// Populated on settlePaneSerializer (renderer has registered for this ptyId) +// and cleared on PTY teardown. +const rendererSerializerByPtyId = new Set() + +function isValidPaneKey(paneKey: unknown): paneKey is string { + return typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256 +} + +function declarePendingPaneSerializer(paneKey: string): number { + const gen = ++pendingSerializerGenSeq + pendingByPaneKey.set(paneKey, gen) + return gen +} + +function settlePendingPaneSerializer(paneKey: string, gen: number): void { + if (pendingByPaneKey.get(paneKey) === gen) { + pendingByPaneKey.delete(paneKey) + } +} + +export function hasPendingRendererSerializerForPaneKey(paneKey: string): boolean { + return isValidPaneKey(paneKey) && pendingByPaneKey.has(paneKey) +} + function getProvider(connectionId: string | null | undefined): IPtyProvider { if (!connectionId) { return localProvider @@ -188,6 +240,13 @@ export function buildPtyHostEnv( // Orca's own PTYs. Injecting lightweight PATH shims at spawn-time keeps // the behavior local to Orca instead of rewriting user git config or // touching external shells. + if (!opts.githubAttributionEnabled) { + delete baseEnv.ORCA_ENABLE_GIT_ATTRIBUTION + delete baseEnv.ORCA_GIT_COMMIT_TRAILER + delete baseEnv.ORCA_GH_PR_FOOTER + delete baseEnv.ORCA_GH_ISSUE_FOOTER + delete baseEnv.ORCA_ATTRIBUTION_SHIM_DIR + } applyTerminalAttributionEnv(baseEnv, { enabled: opts.githubAttributionEnabled, userDataPath: opts.userDataPath @@ -220,13 +279,16 @@ export function getSshPtyProvider(connectionId: string): IPtyProvider | undefine return sshProviders.get(connectionId) } -/** Get the local PTY provider (for direct access in tests/runtime). */ -export function getLocalPtyProvider(): LocalPtyProvider { - // Why: callers that need LocalPtyProvider-specific methods (killOrphanedPtys, - // advanceGeneration, getPtyProcess) can only work with the local provider. - // Daemon mode replaces it with an adapter, so callers must use this only when - // they know the concrete local provider is installed. - return localProvider as LocalPtyProvider +/** Get the installed PTY provider (for direct access in tests/runtime). + * + * Returns the installed PTY provider — after `setLocalPtyProvider()` runs + * during daemon init this may be the routed adapter (specifically either + * `DaemonPtyAdapter` or its `DaemonPtyRouter` wrapper). Callers needing + * `LocalPtyProvider`-specific methods (`killOrphanedPtys`, + * `advanceGeneration`, `getPtyProcess`) must type-narrow or import the + * concrete class directly. */ +export function getLocalPtyProvider(): IPtyProvider { + return localProvider } /** Replace the local PTY provider with a daemon-backed one. @@ -273,10 +335,12 @@ export function clearProviderPtyState(id: string): void { // new teardown path forgets to remove one provider's overlay/hook state. openCodeHookService.clearPty(id) piTitlebarExtensionService.clearPty(id) + ptySizes.delete(id) // Why: drop the memory-collector registration so a dead PTY does not keep // trying to resolve its (now-dead) pid on every snapshot. Safe no-op for // PTYs that were never registered (SSH-owned). unregisterPty(id) + rendererSerializerByPtyId.delete(id) // Why: the hook server's per-paneKey caches (lastPrompt / lastTool) would // otherwise accumulate entries for dead panes over the process lifetime. // Use the spawn-time paneKey mapping since the server has no other way to @@ -286,6 +350,17 @@ export function clearProviderPtyState(id: string): void { agentHookServer.clearPaneState(paneKey) ptyPaneKey.delete(id) paneKeyPtyId.delete(paneKey) + // Why: drop the pre-signal pending entry only if it still belongs to THIS + // PTY's spawn generation. If a remount for the same paneKey has already + // pre-signaled a new gen, this teardown must NOT touch it — otherwise + // the second mount's hydration loses to the daemon-snapshot seed. See + // the generation-token rationale in + // docs/mobile-prefer-renderer-scrollback.md. + const ownedGen = ptyPendingGenByPtyId.get(id) + if (ownedGen !== undefined) { + settlePendingPaneSerializer(paneKey, ownedGen) + } + ptyPendingGenByPtyId.delete(id) // Why: notify registered consumers AFTER we've dropped the paneKey↔ptyId // entries so a listener that re-reads the map sees the post-teardown // state. Wrap each call so one throwing listener cannot block the rest. @@ -311,6 +386,30 @@ let localDataUnsub: (() => void) | null = null let localExitUnsub: (() => void) | null = null let didFinishLoadHandler: (() => void) | null = null +// Why: the "Restart daemon" path needs to re-bind provider→renderer listeners +// against the freshly-created adapter after replaceDaemonProvider swaps the +// module-level `localProvider` pointer. Without this, old subscribers stay +// bound to the disposed adapter and new PTY data silently drops. Saved at +// module scope so the restart flow (src/main/daemon/daemon-init.ts) can +// trigger a rebind without re-running the full registerPtyHandlers setup. +let rebindProviderListeners: (() => void) | null = null + +export function rebindLocalProviderListeners(): void { + rebindProviderListeners?.() +} + +// Why: the "Restart daemon" flow needs to detach listeners from the current +// adapter *after* synthetic pty:exit events fan out (so the renderer receives +// them) but *before* replaceDaemonProvider swaps in the new adapter (so the +// new provider isn't missing bindings). This export narrows that window to +// the caller. +export function unbindLocalProviderListeners(): void { + localDataUnsub?.() + localExitUnsub?.() + localDataUnsub = null + localExitUnsub = null +} + // ─── IPC Registration ─────────────────────────────────────────────── export function registerPtyHandlers( @@ -318,7 +417,8 @@ export function registerPtyHandlers( runtime?: OrcaRuntimeService, getSelectedCodexHomePath?: () => string | null, getSettings?: () => GlobalSettings, - prepareClaudeAuth?: () => Promise + prepareClaudeAuth?: () => Promise, + store?: Store ): void { // Remove any previously registered handlers so we can re-register them // (e.g. when macOS re-activates the app and creates a new window). @@ -327,8 +427,13 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:listSessions') ipcMain.removeHandler('pty:hasChildProcesses') ipcMain.removeHandler('pty:getForegroundProcess') + ipcMain.removeHandler('pty:getCwd') + ipcMain.removeHandler('pty:declarePendingPaneSerializer') + ipcMain.removeHandler('pty:settlePaneSerializer') + ipcMain.removeHandler('pty:clearPendingPaneSerializer') ipcMain.removeAllListeners('pty:write') ipcMain.removeAllListeners('pty:ackColdRestore') + ipcMain.removeAllListeners('pty:serializeBuffer:response') // Configure the local provider with app-specific hooks. // Why: only LocalPtyProvider has the configure() method — daemon-backed @@ -339,13 +444,26 @@ export function registerPtyHandlers( localProvider.configure({ isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true, getWindowsShell: () => getSettings?.()?.terminalWindowsShell, - buildSpawnEnv: (id, baseEnv) => - buildPtyHostEnv(id, baseEnv, { + getWindowsPowerShellImplementation: () => + getSettings + ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') + : undefined, + pwshAvailable: () => isPwshAvailable(), + buildSpawnEnv: (id, baseEnv) => { + const env = buildPtyHostEnv(id, baseEnv, { isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false - }), + }) + // Why: agents need their own terminal handle at process start so they + // can self-identify in orchestration messages without an extra RPC. + const preAllocatedHandle = runtime?.preAllocateHandleForPty(id) + if (preAllocatedHandle) { + env.ORCA_TERMINAL_HANDLE = preAllocatedHandle + } + return env + }, onSpawned: (id) => runtime?.onPtySpawned(id), onExit: (id, code) => { clearProviderPtyState(id) @@ -357,10 +475,6 @@ export function registerPtyHandlers( }) } - // Wire up provider events → renderer IPC - localDataUnsub?.() - localExitUnsub?.() - // Why: batching PTY data into short flush windows (8ms ≈ half a frame) // reduces IPC round-trips from hundreds/sec to ~120/sec under high // throughput, with no perceptible latency increase for interactive use. @@ -380,37 +494,152 @@ export function registerPtyHandlers( pendingData.clear() } - localDataUnsub = localProvider.onData((payload) => { - if (mainWindow.isDestroyed()) { - // Why: clear the pending flush timer so it doesn't fire after the window - // is gone. Without this, macOS app re-activation leaks orphaned timers - // from the previous window's registration. - if (flushTimer) { - clearTimeout(flushTimer) - flushTimer = null + // Why: extracted so the "Restart daemon" flow can rebind against the fresh + // adapter after replaceDaemonProvider runs. Both the startup registration + // and the post-restart rebind go through the same code path — no risk of + // drift between the two entry points. + const bindProviderListeners = (): void => { + localDataUnsub?.() + localExitUnsub?.() + + // Why: LocalPtyProvider routes data to the runtime via configure().onData, + // but daemon-backed providers don't have configure(). Without this, daemon + // PTY data never reaches the runtime's tail buffer, so terminal.read returns + // empty and agent-detection from raw data never fires. Runtime tails also + // power mobile read/stream, so they must be notified regardless of window + // state. + const isLocalProvider = localProvider instanceof LocalPtyProvider + + localDataUnsub = localProvider.onData((payload) => { + if (!isLocalProvider) { + runtime?.onPtyData(payload.id, payload.data, Date.now()) } - pendingData.clear() + if (mainWindow.isDestroyed()) { + // Why: clear the pending flush timer so it doesn't fire after the window + // is gone. Without this, macOS app re-activation leaks orphaned timers + // from the previous window's registration. + if (flushTimer) { + clearTimeout(flushTimer) + flushTimer = null + } + pendingData.clear() + return + } + const existing = pendingData.get(payload.id) + pendingData.set(payload.id, existing ? existing + payload.data : payload.data) + if (!flushTimer) { + flushTimer = setTimeout(flushPendingData, PTY_BATCH_INTERVAL_MS) + } + }) + localExitUnsub = localProvider.onExit((payload) => { + if (!isLocalProvider) { + clearProviderPtyState(payload.id) + ptyOwnership.delete(payload.id) + markClaudePtyExited(payload.id) + runtime?.onPtyExit(payload.id, payload.code) + } + if (!mainWindow.isDestroyed()) { + // Why: flush any batched data for this PTY before sending the exit event, + // otherwise the last ≤8ms of output is silently lost because the renderer + // tears down the terminal on pty:exit before the batch timer fires. + const remaining = pendingData.get(payload.id) + if (remaining) { + mainWindow.webContents.send('pty:data', { id: payload.id, data: remaining }) + pendingData.delete(payload.id) + } + mainWindow.webContents.send('pty:exit', payload) + } + }) + } + + bindProviderListeners() + rebindProviderListeners = bindProviderListeners + + // Why: a persistent ipcMain listener with a request-ID dispatch table + // (instead of one listener per call) so concurrent serialize requests do + // not stack listeners and trip Node's MaxListeners=10 warning. Many + // sleeping PTYs waking at once (e.g. on relaunch) routinely fan out 10+ + // concurrent calls. + type SerializeResult = { data: string; cols: number; rows: number; lastTitle?: string } | null + const pendingSerializeRequests = new Map< + string, + { resolve: (result: SerializeResult) => void; timeout: NodeJS.Timeout } + >() + + function settleSerializeRequest(requestId: string, result: SerializeResult): void { + const pending = pendingSerializeRequests.get(requestId) + if (!pending) { return } - const existing = pendingData.get(payload.id) - pendingData.set(payload.id, existing ? existing + payload.data : payload.data) - if (!flushTimer) { - flushTimer = setTimeout(flushPendingData, PTY_BATCH_INTERVAL_MS) - } - }) - localExitUnsub = localProvider.onExit((payload) => { - if (!mainWindow.isDestroyed()) { - // Why: flush any batched data for this PTY before sending the exit event, - // otherwise the last ≤8ms of output is silently lost because the renderer - // tears down the terminal on pty:exit before the batch timer fires. - const remaining = pendingData.get(payload.id) - if (remaining) { - mainWindow.webContents.send('pty:data', { id: payload.id, data: remaining }) - pendingData.delete(payload.id) + clearTimeout(pending.timeout) + pendingSerializeRequests.delete(requestId) + pending.resolve(result) + } + + ipcMain.on( + 'pty:serializeBuffer:response', + ( + _event, + args: { + requestId?: string + snapshot?: { + data?: unknown + cols?: unknown + rows?: unknown + lastTitle?: unknown + } | null + } + ) => { + if (typeof args?.requestId !== 'string') { + return + } + const snapshot = args.snapshot + if ( + snapshot && + typeof snapshot.data === 'string' && + typeof snapshot.cols === 'number' && + typeof snapshot.rows === 'number' + ) { + const result: { data: string; cols: number; rows: number; lastTitle?: string } = { + data: snapshot.data, + cols: snapshot.cols, + rows: snapshot.rows + } + if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) { + result.lastTitle = snapshot.lastTitle + } + settleSerializeRequest(args.requestId, result) + } else { + settleSerializeRequest(args.requestId, null) } - mainWindow.webContents.send('pty:exit', payload) } - }) + ) + + function requestSerializedBuffer( + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + ): Promise { + if (mainWindow.isDestroyed()) { + return Promise.resolve(null) + } + + const requestId = randomUUID() + return new Promise((resolve) => { + const timeout = setTimeout(() => { + settleSerializeRequest(requestId, null) + }, 750) + pendingSerializeRequests.set(requestId, { resolve, timeout }) + const payload: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + } = { requestId, ptyId } + if (opts) { + payload.opts = opts + } + mainWindow.webContents.send('pty:serializeBuffer:request', payload) + }) + } // Kill orphaned PTY processes from previous page loads when the renderer reloads. // Why: only applies to LocalPtyProvider where PTYs live in the Electron main @@ -451,11 +680,48 @@ export function registerPtyHandlers( // Why: shutdown() is async but the PtyController interface is sync. // Swallowing the rejection prevents an unhandled promise rejection crash // if the remote SSH session is already gone. - void provider.shutdown(ptyId, false).catch(() => {}) + void provider.shutdown(ptyId, { immediate: false }).catch(() => {}) clearProviderPtyState(ptyId) markClaudePtyExited(ptyId) runtime?.onPtyExit(ptyId, -1) return true + }, + getForegroundProcess: async (ptyId) => { + try { + return await getProviderForPty(ptyId).getForegroundProcess(ptyId) + } catch { + return null + } + }, + listProcesses: async () => { + const providerSessions = await Promise.all([ + localProvider.listProcesses(), + ...Array.from(sshProviders.values(), (provider) => provider.listProcesses().catch(() => [])) + ]) + return providerSessions.flat() + }, + serializeBuffer: (ptyId, opts) => { + // Why: mobile xterm must start from the desktop xterm's exact screen + // state and dimensions before live TUI chunks can render correctly. + return requestSerializedBuffer(ptyId, opts) + }, + hasRendererSerializer: (ptyId) => { + // Why: the runtime needs a synchronous probe so it can decide whether to + // skip the daemon-snapshot seed (the renderer will hydrate it) or run the + // seed (no renderer authoritative for this PTY). A registry write happens + // when the renderer calls registerPtySerializer; we check via the same + // pendingByPaneKey + ptyId pairing that the cooperation gate uses. + return rendererSerializerByPtyId.has(ptyId) + }, + getSize: (ptyId) => ptySizes.get(ptyId) ?? null, + resize: (ptyId, cols, rows) => { + try { + ptySizes.set(ptyId, { cols, rows }) + getProviderForPty(ptyId).resize(ptyId, cols, rows) + return true + } catch { + return false + } } }) @@ -475,6 +741,25 @@ export function registerPtyHandlers( worktreeId?: string sessionId?: string shellOverride?: string + // Why: closes the SIGKILL race documented in INVESTIGATION.md by + // letting main patch + sync-flush the (worktreeId, tabId, leafId → + // ptyId) binding before pty:spawn returns. Only the renderer's + // user-typing-Ctrl+T daemon-host path threads these; mobile/runtime + // CLI/SSH spawns leave them undefined and the main-side guard + // short-circuits. + tabId?: string + leafId?: string + // Why: telemetry-plan.md§Agent launch semantics. The renderer + // threads what Orca was *asked* to launch through this field; main + // fires `agent_started` only after `provider.spawn` resolves. Loose + // typing on the IPC boundary because the main-side schema + // validator is the single enforcement point — `track()` will drop + // the event if any field is outside its closed enum. + telemetry?: { + agent_kind?: unknown + launch_source?: unknown + request_kind?: unknown + } } ) => { const provider = getProvider(args.connectionId) @@ -528,6 +813,10 @@ export function registerPtyHandlers( args.sessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) const baseEnv = claudeAuth ? { ...args.env, ...claudeAuth.envPatch } : args.env let env: Record | undefined = baseEnv + const preAllocatedHandle = + runtime && !(provider instanceof LocalPtyProvider) + ? runtime.createPreAllocatedTerminalHandle() + : null if (isDaemonHostSpawn) { if (effectiveSessionId === undefined) { // Should be unreachable: the expression above returns a string when @@ -569,6 +858,9 @@ export function registerPtyHandlers( throw err } } + const spawnEnv = preAllocatedHandle + ? { ...env, ORCA_TERMINAL_HANDLE: preAllocatedHandle } + : env const envToDelete = claudeAuth?.stripAuthEnv ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] : undefined @@ -576,7 +868,7 @@ export function registerPtyHandlers( cols: args.cols, rows: args.rows, cwd: args.cwd, - env + env: spawnEnv } if (envToDelete) { spawnOptions.envToDelete = envToDelete @@ -605,26 +897,139 @@ export function registerPtyHandlers( if (effectiveShellOverride !== undefined) { spawnOptions.shellOverride = effectiveShellOverride } + if (effectiveSessionId !== undefined) { + // Why: daemon PTYs can emit prompt/startup bytes before spawn() + // resolves. Runtime headless snapshots need the real pane geometry + // for those early bytes; otherwise they default to 80x24 and wrap TUIs. + ptySizes.set(effectiveSessionId, { cols: args.cols, rows: args.rows }) + } + if (process.platform === 'win32' && !args.connectionId) { + // Why: the renderer only models PowerShell as one shell family. Thread + // the persisted implementation choice through spawnOptions so both the + // in-process and daemon-backed PTY paths can resolve the same effective + // executable without inventing a fourth top-level shell. + spawnOptions.terminalWindowsPowerShellImplementation = getSettings + ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') + : undefined + } let result: PtySpawnResult try { result = await provider.spawn(spawnOptions) } catch (err) { + if (effectiveSessionId !== undefined) { + ptySizes.delete(effectiveSessionId) + } // Why: when buildPtyHostEnv materialized a Pi overlay for this id - // but provider.spawn failed, the overlay would leak. Sweep per-PTY - // state for the minted id so it isn't orphaned. Safe to call even - // when no overlay was created (clearProviderPtyState is a no-op in - // that case). - // - // Only clean up when we MINTED the id in this request. Caller-supplied - // ids may correspond to existing PTYs whose state (OpenCode hooks, Pi - // overlay, agent-hook pane caches) we MUST NOT clear on a retry/attach - // failure. + // but provider.spawn failed, the overlay would leak. if (isMintedSessionId && effectiveSessionId !== undefined) { clearProviderPtyState(effectiveSessionId) } + // Why: telemetry-plan.md§agent_error — when the renderer threaded + // agent_kind through args.telemetry, attribute the error to that agent. + // Otherwise fall back to sniffing the command for `claude` (the one + // agent the main process can identify on its own via the existing + // `isClaudeLaunchCommand` regex used for auth gating). Bare-shell + // catches and unknown-agent catches without renderer telemetry remain + // unattributed. The event still emits with a classified `error_class`; + // raw error messages are dropped at the telemetry validator boundary. + const rendererAgentKindParse = + args.telemetry?.agent_kind !== undefined + ? agentKindSchema.safeParse(args.telemetry.agent_kind) + : null + const errorAgentKind = rendererAgentKindParse?.success + ? rendererAgentKindParse.data + : isClaudeLaunch + ? ('claude-code' as const) + : null + if (errorAgentKind) { + const classified = classifyError(err) + track('agent_error', { + agent_kind: errorAgentKind, + error_class: classified.error_class + }) + } throw err } ptyOwnership.set(result.id, args.connectionId ?? null) + if (preAllocatedHandle) { + runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) + } + ptySizes.set(result.id, { cols: args.cols, rows: args.rows }) + // Why: closes the SIGKILL-between-spawn-and-persist race (Issue #217). + // The renderer's debounced session writer runs in parallel for every + // other field; we patch the load-bearing (tab.ptyId, ptyIdsByLeafId) + // binding synchronously so a force-quit in the ~450 ms debounce window + // can no longer orphan the daemon's history dir. Other spawn callers + // (mobile, runtime CLI, SSH) leave tabId/leafId undefined and this + // short-circuits — preserves their existing behavior. + if ( + isDaemonHostSpawn && + store && + args.worktreeId !== undefined && + args.tabId !== undefined && + args.leafId !== undefined + ) { + store.persistPtyBinding({ + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: args.leafId, + ptyId: result.id + }) + } + // Why: pre-signal cooperation gate — when the renderer has declared it + // will own the serializer for this paneKey, suppress the daemon-snapshot + // seed so the renderer's hydration path (maybeHydrateHeadlessFromRenderer) + // is the sole authority. The pre-signal is keyed on paneKey because at + // spawn time the renderer doesn't yet know the new ptyId. See + // docs/mobile-prefer-renderer-scrollback.md. + const spawnPaneKey = args.env?.ORCA_PANE_KEY + const rendererPreSignaled = isValidPaneKey(spawnPaneKey) + ? pendingByPaneKey.has(spawnPaneKey) + : false + const rendererAlreadyRegistered = rendererSerializerByPtyId.has(result.id) + // Why: capture the pending gen at spawn time so teardown for THIS PTY + // only settles its own generation. A remount that replaces the entry + // with a new gen must not be stomped by the old PTY's teardown. + if (isValidPaneKey(spawnPaneKey) && rendererPreSignaled) { + const gen = pendingByPaneKey.get(spawnPaneKey) + if (gen !== undefined) { + ptyPendingGenByPtyId.set(result.id, gen) + } + } + + // Why: hydrate the runtime's headless emulator with the adapter's + // restore data BEFORE registerPty so any live PTY data that arrives + // concurrently lands on top of the seed instead of replacing it. Mobile + // subscribers then see the same scrollback the desktop xterm received + // via coldRestore/snapshot. Without this, mobile snapshots after a + // daemon-restored attach contain only bytes emitted since the relaunch + // and the prior agent output silently disappears. + // + // Skip when the renderer is or will be authoritative for this PTY: + // its hydration path will seed the emulator from xterm's live buffer, + // which is richer than the daemon snapshot. + if (runtime && !rendererPreSignaled && !rendererAlreadyRegistered) { + const seedSize = + typeof result.snapshotCols === 'number' && typeof result.snapshotRows === 'number' + ? { cols: result.snapshotCols, rows: result.snapshotRows } + : undefined + if (typeof result.snapshot === 'string' && result.snapshot.length > 0) { + runtime.seedHeadlessTerminal(result.id, result.snapshot, seedSize) + } else if ( + result.coldRestore && + typeof result.coldRestore.scrollback === 'string' && + result.coldRestore.scrollback.length > 0 + ) { + runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, seedSize) + } + } + if ( + typeof args.worktreeId === 'string' && + args.worktreeId.length > 0 && + args.worktreeId.length <= 512 + ) { + runtime?.registerPty(result.id, args.worktreeId) + } if (isClaudeLaunch) { markClaudePtySpawned(result.id) } @@ -678,11 +1083,42 @@ export function registerPtyHandlers( : null }) } + // Why: telemetry-plan.md§Agent launch semantics — fire `agent_started` + // only after `provider.spawn` resolved. The renderer threads + // `args.telemetry` through the spawn IPC for every launch we want to + // attribute; bare-shell tabs (no agent) leave the field undefined and + // do not produce an event. Each field is parsed against its closed + // enum here so a malformed renderer payload (or a spoofed IPC) does + // not poison the event — `safeParse` failure drops that field, and + // if any required field is missing we skip the event entirely. The + // main-side `track()` validator re-runs the schema on the full + // payload as a second defense-in-depth check. + if (args.telemetry) { + const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) + const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) + const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) + if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { + track('agent_started', { + agent_kind: agentKindParse.data, + launch_source: launchSourceParse.data, + request_kind: requestKindParse.data + }) + } + } return result } ) ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => { + // Why: defense-in-depth for the mobile-presence lock. The renderer's + // xterm.onData guard already drops desktop keystrokes when mobile is + // driving, but a stale view between the main-side state flip and the + // IPC arriving in the renderer can let one keystroke slip through. + // This server-side check catches it. See + // docs/mobile-presence-lock.md. + if (runtime?.getDriver(args.id).kind === 'mobile') { + return + } getProviderForPty(args.id).write(args.id, args.data) }) @@ -691,7 +1127,27 @@ export function registerPtyHandlers( // empty acknowledgement message back to the renderer. ipcMain.removeAllListeners('pty:resize') ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { + // Why: after a desktop-fit override change, the desktop renderer's + // re-render cascade runs safeFit on ALL panes (not just the affected + // one). Background-tab panes get measured at full-width (214) instead + // of their correct split width. Suppressing ALL pty:resize during + // this window prevents the cascade from corrupting PTY dimensions. + if (runtime?.isResizeSuppressed()) { + return + } + // Why: presence-lock defense-in-depth. While mobile is driving, + // desktop-side resizes (auto-fit on window resize, split drag) must + // not reach the PTY. The renderer guard checks the driver state too, + // but this is the load-bearing layer because the renderer mirror lags + // by one IPC hop. Note: BOTH guards apply — isResizeSuppressed handles + // the safeFit cascade after take-back; this driver check handles the + // ongoing locked state. See docs/mobile-presence-lock.md. + if (runtime?.getDriver(args.id).kind === 'mobile') { + return + } + ptySizes.set(args.id, { cols: args.cols, rows: args.rows }) getProviderForPty(args.id).resize(args.id, args.cols, args.rows) + runtime?.onExternalPtyResize(args.id, args.cols, args.rows) }) // Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold restore @@ -710,13 +1166,16 @@ export function registerPtyHandlers( .catch(() => {}) }) - ipcMain.handle('pty:kill', async (_event, args: { id: string }) => { + ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { // Why: try/finally ensures ptyOwnership is cleaned up even if shutdown // throws (e.g. SSH connection already gone or daemon session already // reaped). Swallowing the error prevents noisy renderer-side rejections // when killing orphaned sessions that the daemon has already discarded. try { - await getProviderForPty(args.id).shutdown(args.id, true) + await getProviderForPty(args.id).shutdown(args.id, { + immediate: true, + keepHistory: args.keepHistory ?? false + }) } catch { /* session already dead — cleanup below handles the rest */ } finally { @@ -772,6 +1231,65 @@ export function registerPtyHandlers( return getProviderForPty(args.id).getForegroundProcess(args.id) } ) + + // Why: renderer needs the live shell cwd when the user presses Cmd+D so + // the new split pane inherits the source pane's cwd instead of the + // worktree root. Routed through getProviderForPty so local and SSH PTYs + // use the same code path. Providers return '' when the id is unknown or + // the platform cannot resolve a cwd (Windows); the renderer treats '' + // as "fall through to the next fallback layer". + ipcMain.handle('pty:getCwd', async (_event, args: { id: string }): Promise => { + try { + return await getProviderForPty(args.id).getCwd(args.id) + } catch { + return '' + } + }) + + // Why: pre-signal handshake handlers. See + // docs/mobile-prefer-renderer-scrollback.md and the rationale on + // `pendingByPaneKey` above. The IPC contract is: renderer awaits declare + // (capturing the returned gen), awaits pty:spawn, then registers its + // serializer locally and calls settle (echoing the gen). On spawn rejection + // or pane unmount before settle, renderer calls clear with the same gen. + ipcMain.handle( + 'pty:declarePendingPaneSerializer', + async (_event, args: { paneKey?: unknown }): Promise => { + if (!isValidPaneKey(args.paneKey)) { + throw new Error('Invalid paneKey') + } + return declarePendingPaneSerializer(args.paneKey) + } + ) + + ipcMain.handle( + 'pty:settlePaneSerializer', + async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { + if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { + return + } + settlePendingPaneSerializer(args.paneKey, args.gen) + // Why: settle means the renderer has registered its serializer locally + // for whatever ptyId came back from spawn. The renderer doesn't carry + // the ptyId back through this IPC because the cooperation gate ran + // pre-spawn; instead we mark the pane as authoritative by paneKey → + // ptyId via the existing paneKeyPtyId mapping populated at spawn. + const ptyId = paneKeyPtyId.get(args.paneKey) + if (ptyId) { + rendererSerializerByPtyId.add(ptyId) + } + } + ) + + ipcMain.handle( + 'pty:clearPendingPaneSerializer', + async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { + if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { + return + } + settlePendingPaneSerializer(args.paneKey, args.gen) + } + ) } /** diff --git a/src/main/ipc/rate-limits.ts b/src/main/ipc/rate-limits.ts index 1a4634e8feb..703e9761721 100644 --- a/src/main/ipc/rate-limits.ts +++ b/src/main/ipc/rate-limits.ts @@ -7,4 +7,10 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void { ipcMain.handle('rateLimits:setPollingInterval', (_event, ms: number) => rateLimits.setPollingInterval(ms) ) + ipcMain.handle('rateLimits:fetchInactiveClaudeAccounts', () => + rateLimits.fetchInactiveClaudeAccountsOnOpen() + ) + ipcMain.handle('rateLimits:fetchInactiveCodexAccounts', () => + rateLimits.fetchInactiveCodexAccountsOnOpen() + ) } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 3f8a128025a..e43f3de60c9 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -10,14 +10,18 @@ const { registerStatsHandlersMock, registerMemoryHandlersMock, registerNotificationHandlersMock, + registerDeveloperPermissionHandlersMock, registerSettingsHandlersMock, + registerTelemetryHandlersMock, registerShellHandlersMock, + registerSidekickHandlersMock, registerSessionHandlersMock, registerUIHandlersMock, registerFilesystemHandlersMock, registerRuntimeHandlersMock, registerCodexAccountHandlersMock, registerAgentHookHandlersMock, + registerAgentTrustHandlersMock, registerClaudeAccountHandlersMock, registerClipboardHandlersMock, registerUpdaterHandlersMock, @@ -39,14 +43,18 @@ const { registerStatsHandlersMock: vi.fn(), registerMemoryHandlersMock: vi.fn(), registerNotificationHandlersMock: vi.fn(), + registerDeveloperPermissionHandlersMock: vi.fn(), registerSettingsHandlersMock: vi.fn(), + registerTelemetryHandlersMock: vi.fn(), registerShellHandlersMock: vi.fn(), + registerSidekickHandlersMock: vi.fn(), registerSessionHandlersMock: vi.fn(), registerUIHandlersMock: vi.fn(), registerFilesystemHandlersMock: vi.fn(), registerRuntimeHandlersMock: vi.fn(), registerCodexAccountHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), + registerAgentTrustHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), registerUpdaterHandlersMock: vi.fn(), @@ -100,14 +108,26 @@ vi.mock('./notifications', () => ({ registerNotificationHandlers: registerNotificationHandlersMock })) +vi.mock('./developer-permissions', () => ({ + registerDeveloperPermissionHandlers: registerDeveloperPermissionHandlersMock +})) + vi.mock('./settings', () => ({ registerSettingsHandlers: registerSettingsHandlersMock })) +vi.mock('./telemetry', () => ({ + registerTelemetryHandlers: registerTelemetryHandlersMock +})) + vi.mock('./shell', () => ({ registerShellHandlers: registerShellHandlersMock })) +vi.mock('./sidekick', () => ({ + registerSidekickHandlers: registerSidekickHandlersMock +})) + vi.mock('./session', () => ({ registerSessionHandlers: registerSessionHandlersMock })) @@ -140,6 +160,10 @@ vi.mock('./agent-hooks', () => ({ registerAgentHookHandlers: registerAgentHookHandlersMock })) +vi.mock('./agent-trust', () => ({ + registerAgentTrustHandlers: registerAgentTrustHandlersMock +})) + vi.mock('./claude-accounts', () => ({ registerClaudeAccountHandlers: registerClaudeAccountHandlersMock })) @@ -176,14 +200,18 @@ describe('registerCoreHandlers', () => { registerStatsHandlersMock.mockReset() registerMemoryHandlersMock.mockReset() registerNotificationHandlersMock.mockReset() + registerDeveloperPermissionHandlersMock.mockReset() registerSettingsHandlersMock.mockReset() + registerTelemetryHandlersMock.mockReset() registerShellHandlersMock.mockReset() + registerSidekickHandlersMock.mockReset() registerSessionHandlersMock.mockReset() registerUIHandlersMock.mockReset() registerFilesystemHandlersMock.mockReset() registerRuntimeHandlersMock.mockReset() registerCodexAccountHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() + registerAgentTrustHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() registerUpdaterHandlersMock.mockReset() @@ -222,6 +250,7 @@ describe('registerCoreHandlers', () => { expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts) expect(registerAgentHookHandlersMock).toHaveBeenCalled() + expect(registerSidekickHandlersMock).toHaveBeenCalled() expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) @@ -229,8 +258,10 @@ describe('registerCoreHandlers', () => { expect(registerFeedbackHandlersMock).toHaveBeenCalled() expect(registerStatsHandlersMock).toHaveBeenCalledWith(stats) expect(registerMemoryHandlersMock).toHaveBeenCalledWith(store) - expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store) + expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store, runtime) + expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled() expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store) + expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store) expect(registerSessionHandlersMock).toHaveBeenCalledWith(store) expect(registerUIHandlersMock).toHaveBeenCalledWith(store) expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index fbd4893eb4c..2213476ca7f 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -17,15 +17,19 @@ import { registerMemoryHandlers } from './memory' import { registerRateLimitHandlers } from './rate-limits' import { registerRuntimeHandlers } from './runtime' import { registerNotificationHandlers } from './notifications' +import { registerDeveloperPermissionHandlers } from './developer-permissions' import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser' import { registerSessionHandlers } from './session' import { registerSettingsHandlers } from './settings' +import { registerTelemetryHandlers } from './telemetry' import { registerBrowserHandlers } from './browser' import { browserSessionRegistry } from '../browser/browser-session-registry' import { registerShellHandlers } from './shell' +import { registerSidekickHandlers } from './sidekick' import { registerUIHandlers } from './ui' import { registerCodexAccountHandlers } from './codex-accounts' import { registerAgentHookHandlers } from './agent-hooks' +import { registerAgentTrustHandlers } from './agent-trust' import { registerClaudeAccountHandlers } from './claude-accounts' import { warmSystemFontFamilies } from '../system-fonts' import { @@ -69,6 +73,7 @@ export function registerCoreHandlers( registerCodexUsageHandlers(codexUsage) registerCodexAccountHandlers(codexAccounts) registerAgentHookHandlers() + registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) registerRateLimitHandlers(rateLimits) registerGitHubHandlers(store, stats) @@ -77,8 +82,10 @@ export function registerCoreHandlers( registerExportHandlers() registerStatsHandlers(stats) registerMemoryHandlers(store) - registerNotificationHandlers(store) + registerNotificationHandlers(store, runtime) + registerDeveloperPermissionHandlers() registerSettingsHandlers(store) + registerTelemetryHandlers(store) registerBrowserHandlers() // Why: applyPendingCookieImport MUST run before restorePersistedUserAgent // because the latter calls session.fromPartition() which initializes @@ -87,6 +94,7 @@ export function registerCoreHandlers( browserSessionRegistry.applyPendingCookieImport() browserSessionRegistry.restorePersistedUserAgent() registerShellHandlers() + registerSidekickHandlers() registerSessionHandlers(store) registerUIHandlers(store) registerFilesystemHandlers(store) diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts new file mode 100644 index 00000000000..460f4b7d6c5 --- /dev/null +++ b/src/main/ipc/repos-create.test.ts @@ -0,0 +1,379 @@ +/** + * Unit tests for repos:create (orca#763). + * + * Pins the invariants that matter here: + * - Name validation catches empty/slash/./.. before any fs I/O. + * - Empty pre-existing directories are accepted; non-empty ones are not. + * - Only directories we create ourselves are removed on rollback — a folder + * the user picked must survive a failure so they can retry. + * - Git repos get an empty initial commit; without it, HEAD has no branch. + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const { + handleMock, + removeHandlerMock, + mockStore, + mkdirMock, + accessMock, + readdirMock, + rmMock, + gitExecFileAsyncMock, + rebuildAuthorizedRootsCacheMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + removeHandlerMock: vi.fn(), + mockStore: { + getRepos: vi.fn().mockReturnValue([]), + addRepo: vi.fn(), + removeRepo: vi.fn(), + getRepo: vi.fn(), + updateRepo: vi.fn() + }, + mkdirMock: vi.fn(), + accessMock: vi.fn(), + readdirMock: vi.fn(), + rmMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + rebuildAuthorizedRootsCacheMock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('electron', () => ({ + dialog: { showOpenDialog: vi.fn() }, + ipcMain: { + handle: handleMock, + removeHandler: removeHandlerMock + } +})) + +vi.mock('fs/promises', () => ({ + mkdir: mkdirMock, + access: accessMock, + readdir: readdirMock, + rm: rmMock +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitSpawn: vi.fn() +})) + +vi.mock('../git/repo', () => ({ + isGitRepo: vi.fn().mockReturnValue(true), + getGitUsername: vi.fn().mockReturnValue(''), + getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), + getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), + searchBaseRefs: vi.fn().mockResolvedValue([]) +})) + +vi.mock('./filesystem-auth', () => ({ + rebuildAuthorizedRootsCache: rebuildAuthorizedRootsCacheMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: vi.fn() +})) + +vi.mock('./ssh', () => ({ + getActiveMultiplexer: vi.fn() +})) + +import { registerRepoHandlers } from './repos' + +type CreateArgs = { parentPath: string; name: string; kind: 'git' | 'folder' } +type CreateResult = + | { repo: { id: string; path: string; kind: 'git' | 'folder' } } + | { error: string } + +describe('repos:create', () => { + const handlers = new Map Promise>() + const mockWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn() } + } + + const callCreate = (args: CreateArgs): Promise => { + const handler = handlers.get('repos:create') + if (!handler) { + throw new Error('repos:create handler was never registered') + } + return handler(null, args) + } + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler as (event: unknown, args: CreateArgs) => Promise) + }) + removeHandlerMock.mockReset() + mockStore.getRepos.mockReset().mockReturnValue([]) + mockStore.addRepo.mockReset() + mockWindow.webContents.send.mockReset() + rebuildAuthorizedRootsCacheMock.mockReset().mockResolvedValue(undefined) + + // Default baseline: target does NOT exist yet, mkdir succeeds, git OK. + accessMock.mockReset().mockRejectedValue(new Error('ENOENT')) + readdirMock.mockReset().mockResolvedValue([]) + mkdirMock.mockReset().mockResolvedValue(undefined) + rmMock.mockReset().mockResolvedValue(undefined) + gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) + + registerRepoHandlers(mockWindow as never, mockStore as never) + }) + + it('registers the repos:create handler', () => { + expect(handlers.has('repos:create')).toBe(true) + }) + + it('unregisters any previously-registered repos:create handler', () => { + // registerRepoHandlers must call removeHandler('repos:create') before + // ipcMain.handle to avoid the "second handler for same channel" throw + // when this module is re-registered (e.g., after a reload). + expect(removeHandlerMock).toHaveBeenCalledWith('repos:create') + }) + + // ── input validation ────────────────────────────────────────────── + + it('rejects empty names', async () => { + const result = await callCreate({ parentPath: '/tmp', name: ' ', kind: 'git' }) + expect(result).toEqual({ error: 'Name cannot be empty' }) + expect(mkdirMock).not.toHaveBeenCalled() + }) + + it('rejects names containing a forward slash', async () => { + const result = await callCreate({ parentPath: '/tmp', name: 'foo/bar', kind: 'git' }) + expect(result).toMatchObject({ error: expect.stringContaining('slash') }) + expect(mkdirMock).not.toHaveBeenCalled() + }) + + it('rejects names containing a backslash', async () => { + const result = await callCreate({ parentPath: '/tmp', name: 'foo\\bar', kind: 'git' }) + expect(result).toMatchObject({ error: expect.stringContaining('slash') }) + expect(mkdirMock).not.toHaveBeenCalled() + }) + + it('rejects "." and ".." as names', async () => { + for (const name of ['.', '..']) { + mkdirMock.mockClear() + const result = await callCreate({ parentPath: '/tmp', name, kind: 'git' }) + expect(result).toMatchObject({ error: expect.stringContaining('slash') }) + expect(mkdirMock).not.toHaveBeenCalled() + } + }) + + it('rejects empty parent path', async () => { + const result = await callCreate({ parentPath: ' ', name: 'project', kind: 'git' }) + expect(result).toEqual({ error: 'Parent directory is required' }) + expect(mkdirMock).not.toHaveBeenCalled() + }) + + // ── existing-directory handling ─────────────────────────────────── + + it('rejects a non-empty existing directory without calling mkdir', async () => { + accessMock.mockResolvedValueOnce(undefined) // exists + readdirMock.mockResolvedValueOnce(['README.md', '.DS_Store']) + + const result = await callCreate({ parentPath: '/tmp', name: 'busy', kind: 'git' }) + + expect(result).toMatchObject({ error: expect.stringContaining('not empty') }) + expect(mkdirMock).not.toHaveBeenCalled() + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('accepts an empty existing directory and does not call mkdir', async () => { + accessMock.mockResolvedValueOnce(undefined) // exists + readdirMock.mockResolvedValueOnce([]) + + const result = await callCreate({ parentPath: '/tmp', name: 'empty', kind: 'folder' }) + + expect(mkdirMock).not.toHaveBeenCalled() + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ path: '/tmp/empty', kind: 'folder' }) + ) + expect(result).toHaveProperty('repo.kind', 'folder') + }) + + it('creates a missing directory with mkdir', async () => { + // accessMock rejects by default → path does not exist + await callCreate({ parentPath: '/tmp', name: 'brand-new', kind: 'folder' }) + + expect(mkdirMock).toHaveBeenCalledWith('/tmp/brand-new', { recursive: false }) + }) + + // ── plain folder happy path ─────────────────────────────────────── + + it('creates a plain folder without running any git commands', async () => { + const result = await callCreate({ parentPath: '/tmp', name: 'plain', kind: 'folder' }) + + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/tmp/plain', + displayName: 'plain', + kind: 'folder' + }) + ) + expect(result).toHaveProperty('repo.kind', 'folder') + }) + + // ── git repo happy path ─────────────────────────────────────────── + + it('creates a git repo with an empty initial commit (in order)', async () => { + const result = await callCreate({ parentPath: '/tmp', name: 'gitproj', kind: 'git' }) + + expect(mkdirMock).toHaveBeenCalledWith('/tmp/gitproj', { recursive: false }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['init'], { cwd: '/tmp/gitproj' }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['commit', '--allow-empty', '-m', 'Initial commit'], + { cwd: '/tmp/gitproj' } + ) + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/tmp/gitproj', + displayName: 'gitproj', + kind: 'git' + }) + ) + expect(result).toHaveProperty('repo.kind', 'git') + }) + + // ── rollback semantics ──────────────────────────────────────────── + + it('rolls back the directory it just created when git init fails', async () => { + gitExecFileAsyncMock.mockReset().mockRejectedValueOnce(new Error('git init blew up')) + + const result = await callCreate({ parentPath: '/tmp', name: 'broken', kind: 'git' }) + + expect(rmMock).toHaveBeenCalledWith('/tmp/broken', { recursive: true, force: true }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(result).toMatchObject({ error: expect.stringContaining('Failed to initialize') }) + }) + + it('does NOT rm a pre-existing empty directory when git init fails', async () => { + // Pretend the directory already existed (and is empty) — user pre-created it. + accessMock.mockResolvedValueOnce(undefined) + readdirMock.mockResolvedValueOnce([]) + gitExecFileAsyncMock.mockReset().mockRejectedValueOnce(new Error('git init blew up')) + + const result = await callCreate({ parentPath: '/tmp', name: 'preexisting', kind: 'git' }) + + expect(rmMock).not.toHaveBeenCalled() + expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(result).toMatchObject({ error: expect.stringContaining('Failed to initialize') }) + }) + + it('surfaces an "initialize"-flavored error when git init itself fails', async () => { + // First git call (init) rejects — the commit step never runs. + gitExecFileAsyncMock.mockReset().mockRejectedValueOnce(new Error('init broke')) + + const result = await callCreate({ parentPath: '/tmp', name: 'initfail', kind: 'git' }) + + expect(rmMock).toHaveBeenCalledWith('/tmp/initfail', { recursive: true, force: true }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + // Loose match — handler distinguishes init vs commit failures, and we want + // to tolerate small wording tweaks as long as it still mentions "initialize". + expect(result).toMatchObject({ error: expect.stringContaining('initialize') }) + }) + + it('rolls back directory when git commit fails (not just init)', async () => { + // init resolves, commit rejects — the failure must still trigger rollback + // and surface a commit-flavored error (distinct from the init-failure path). + gitExecFileAsyncMock + .mockReset() + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockRejectedValueOnce(new Error('commit broke')) + + const result = await callCreate({ parentPath: '/tmp', name: 'commitfail', kind: 'git' }) + + expect(rmMock).toHaveBeenCalledWith('/tmp/commitfail', { recursive: true, force: true }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(result).toMatchObject({ error: expect.stringContaining('commit') }) + }) + + it('strips only .git/ when commit fails in a pre-existing empty folder', async () => { + // User pre-created an empty folder; git init succeeded, commit failed. + // The folder itself must survive (user owns it) but the half-init'd + // .git/ should be removed so the folder looks untouched. + accessMock.mockResolvedValueOnce(undefined) + readdirMock.mockResolvedValueOnce([]) + gitExecFileAsyncMock + .mockReset() + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockRejectedValueOnce(new Error('commit broke')) + + const result = await callCreate({ parentPath: '/tmp', name: 'pre-existing', kind: 'git' }) + + expect(rmMock).toHaveBeenCalledWith('/tmp/pre-existing/.git', { recursive: true, force: true }) + expect(rmMock).not.toHaveBeenCalledWith('/tmp/pre-existing', { recursive: true, force: true }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(result).toMatchObject({ error: expect.stringContaining('commit') }) + }) + + // ── friendly messaging ──────────────────────────────────────────── + + it('surfaces a friendly message when git author identity is missing', async () => { + gitExecFileAsyncMock + .mockReset() + .mockResolvedValueOnce({ stdout: '', stderr: '' }) // git init + .mockRejectedValueOnce( + new Error('Please tell me who you are. Run git config --global user.email ...') + ) + + const result = await callCreate({ parentPath: '/tmp', name: 'authorless', kind: 'git' }) + + expect(rmMock).toHaveBeenCalledWith('/tmp/authorless', { recursive: true, force: true }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(result).toMatchObject({ + error: expect.stringContaining('Git author identity is not configured') + }) + }) + + // ── renderer notification ───────────────────────────────────────── + + it('notifies the renderer via repos:changed after a successful create', async () => { + await callCreate({ parentPath: '/tmp', name: 'notified', kind: 'folder' }) + expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:changed') + }) + + // ── authorized-roots cache refresh ──────────────────────────────── + + it('rebuilds the authorized-roots cache after a successful folder create', async () => { + // Roots cache must be refreshed so the renderer can read the new path + // without waiting for the next full reconciliation. + await callCreate({ parentPath: '/tmp', name: 'rooted', kind: 'folder' }) + expect(rebuildAuthorizedRootsCacheMock).toHaveBeenCalledTimes(1) + }) + + it('does NOT rebuild the authorized-roots cache on a validation failure', async () => { + const result = await callCreate({ parentPath: '/tmp', name: ' ', kind: 'git' }) + expect(result).toEqual({ error: 'Name cannot be empty' }) + expect(rebuildAuthorizedRootsCacheMock).not.toHaveBeenCalled() + }) + + it('does NOT rebuild the authorized-roots cache when dedup short-circuits', async () => { + const existing = { id: 'abc', path: '/tmp/dupe2', displayName: 'dupe2', kind: 'git' } + mockStore.getRepos.mockReturnValue([existing]) + + await callCreate({ parentPath: '/tmp', name: 'dupe2', kind: 'git' }) + + expect(rebuildAuthorizedRootsCacheMock).not.toHaveBeenCalled() + }) + + // ── dedup-by-path ───────────────────────────────────────────────── + + it('returns the existing repo when one already lives at the target path', async () => { + const existing = { id: 'abc', path: '/tmp/dupe', displayName: 'dupe', kind: 'git' } + mockStore.getRepos.mockReturnValue([existing]) + + const result = await callCreate({ parentPath: '/tmp', name: 'dupe', kind: 'git' }) + + expect(result).toEqual({ repo: existing }) + // Short-circuit before any fs or git work. + expect(mkdirMock).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts new file mode 100644 index 00000000000..71ab88b6a4d --- /dev/null +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -0,0 +1,223 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as CryptoModule from 'crypto' +import type { SparsePreset } from '../../shared/types' + +const { handleMock, randomUUIDMock, mockStore } = vi.hoisted(() => ({ + handleMock: vi.fn(), + randomUUIDMock: vi.fn(() => 'preset-new'), + mockStore: { + getRepos: vi.fn().mockReturnValue([]), + addRepo: vi.fn(), + removeRepo: vi.fn(), + getRepo: vi.fn(), + updateRepo: vi.fn(), + getSparsePresets: vi.fn(), + saveSparsePreset: vi.fn(), + removeSparsePreset: vi.fn() + } +})) + +vi.mock('crypto', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + randomUUID: randomUUIDMock + } +}) + +vi.mock('electron', () => ({ + dialog: { showOpenDialog: vi.fn() }, + ipcMain: { + handle: handleMock, + removeHandler: vi.fn() + } +})) + +vi.mock('../git/repo', () => ({ + isGitRepo: vi.fn().mockReturnValue(true), + getGitUsername: vi.fn().mockReturnValue(''), + getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), + getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), + searchBaseRefs: vi.fn().mockResolvedValue([]), + BASE_REF_SEARCH_ARGS: ['for-each-ref'], + filterBaseRefSearchOutput: vi.fn().mockReturnValue([]) +})) + +vi.mock('./filesystem-auth', () => ({ + rebuildAuthorizedRootsCache: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: vi.fn() +})) + +vi.mock('./ssh', () => ({ + getActiveMultiplexer: vi.fn() +})) + +import { registerRepoHandlers } from './repos' + +type HandlerMap = Map unknown> + +function makePreset( + overrides: Partial & { id: string; repoId: string } +): SparsePreset { + return { + name: overrides.id, + directories: ['packages/web'], + createdAt: 10, + updatedAt: 20, + ...overrides + } +} + +describe('sparse preset repo IPC handlers', () => { + const handlers: HandlerMap = new Map() + const mainWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn() } + } + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + randomUUIDMock.mockReturnValue('preset-new') + mainWindow.webContents.send.mockReset() + mockStore.getRepo.mockReset().mockReturnValue({ + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + }) + mockStore.getSparsePresets.mockReset().mockReturnValue([]) + mockStore.saveSparsePreset.mockReset().mockImplementation((preset: SparsePreset) => preset) + mockStore.removeSparsePreset.mockReset() + + registerRepoHandlers(mainWindow as never, mockStore as never) + }) + + it('normalizes and de-duplicates saved sparse preset directories', () => { + const saved = handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + name: ' Web preset ', + directories: [' packages/web ', 'apps\\api\\', 'packages/web/', '.', ''] + }) + + expect(mockStore.saveSparsePreset).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'preset-new', + repoId: 'repo-1', + name: 'Web preset', + directories: ['packages/web', 'apps/api'] + }) + ) + expect(saved).toEqual( + expect.objectContaining({ + name: 'Web preset', + directories: ['packages/web', 'apps/api'] + }) + ) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('sparsePresets:changed', { + repoId: 'repo-1' + }) + }) + + it('preserves createdAt and id when editing an existing preset', () => { + mockStore.getSparsePresets.mockReturnValue([ + makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Old', createdAt: 123 }) + ]) + + const saved = handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + id: 'preset-1', + name: 'New', + directories: ['packages/new'] + }) + + expect(saved).toEqual( + expect.objectContaining({ + id: 'preset-1', + name: 'New', + createdAt: 123, + directories: ['packages/new'] + }) + ) + }) + + it('rejects invalid sparse preset saves before persistence', () => { + expect(() => + handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + name: 'Web', + directories: ['../secrets'] + }) + ).toThrow('Preset directories must be repo-relative paths.') + + expect(() => + handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + name: ' ', + directories: ['packages/web'] + }) + ).toThrow('Preset name is required.') + + expect(() => + handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + name: 'Web', + directories: ['.', ''] + }) + ).toThrow('Preset must have at least one directory.') + + expect(mockStore.saveSparsePreset).not.toHaveBeenCalled() + }) + + it.each([ + '/Users/me/repo/packages/web', + 'C:\\repo\\packages\\web', + '\\\\server\\share\\repo', + '\\repo\\packages\\web' + ])('rejects absolute sparse preset directory before normalization: %s', (directory) => { + expect(() => + handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + name: 'Web', + directories: ['packages/web', directory] + }) + ).toThrow('Preset directories must be repo-relative paths.') + + expect(mockStore.saveSparsePreset).not.toHaveBeenCalled() + }) + + it('rejects sparse preset saves for unknown repos', () => { + mockStore.getRepo.mockReturnValue(null) + + expect(() => + handlers.get('sparsePresets:save')!(null, { + repoId: 'missing', + name: 'Web', + directories: ['packages/web'] + }) + ).toThrow('Repo "missing" not found') + }) + + it('rejects sparse preset removals for unknown repos before persistence', () => { + mockStore.getRepo.mockReturnValue(null) + + expect(() => + handlers.get('sparsePresets:remove')!(null, { + repoId: 'missing', + presetId: 'preset-1' + }) + ).toThrow('Repo "missing" not found') + + expect(mockStore.removeSparsePreset).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('sparsePresets:changed', { + repoId: 'missing' + }) + }) +}) diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 66fe074ca2a..15ac89ad25c 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -5,14 +5,14 @@ import type { BrowserWindow } from 'electron' import { dialog, ipcMain } from 'electron' import { randomUUID } from 'crypto' import type { Store } from '../persistence' -import type { Repo, BaseRefDefaultResult } from '../../shared/types' +import type { Repo, BaseRefDefaultResult, SparsePreset } from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' import { REPO_COLORS } from '../../shared/constants' import { rebuildAuthorizedRootsCache } from './filesystem-auth' import type { ChildProcess } from 'child_process' -import { rm } from 'fs/promises' -import { gitSpawn } from '../git/runner' -import { join, basename } from 'path' +import { access, mkdir, readdir, rm } from 'fs/promises' +import { gitExecFileAsync, gitSpawn } from '../git/runner' +import { basename, isAbsolute, join } from 'path' import { isGitRepo, getGitUsername, @@ -28,6 +28,26 @@ import { } from '../git/repo' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { getActiveMultiplexer } from './ssh' +import { normalizeSparseDirectories } from './sparse-checkout-directories' +import { track } from '../telemetry/client' +import type { RepoMethod } from '../../shared/telemetry-events' + +// Why: `method` answers "which entry point did the user take?", not "what did +// they add?" — so the IPC the renderer invoked IS the method. We never send +// the path, URL, or display name. `repos:create` collapses into +// `folder_picker` because the user's entry was the folder picker, even +// though main also `git init`s. `drag_drop` is reserved for a future call +// site; no current renderer surface produces it. +function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean): void { + // Why: re-adding an existing repo (matched by path inside the handler) + // is not a new activation event. Suppressing the duplicate keeps the + // funnel honest and avoids inflating `repo_added` for users who + // re-pick the same folder. + if (alreadyExisted) { + return + } + track('repo_added', { method }) +} // Why: module-scoped so the abort handle survives window re-creation on macOS. // registerRepoHandlers is called again when a new BrowserWindow is created, @@ -50,6 +70,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:getBaseRefDefault') ipcMain.removeHandler('repos:searchBaseRefs') ipcMain.removeHandler('repos:addRemote') + ipcMain.removeHandler('repos:create') + ipcMain.removeHandler('sparsePresets:list') + ipcMain.removeHandler('sparsePresets:save') + ipcMain.removeHandler('sparsePresets:remove') ipcMain.handle('repos:list', () => { return store.getRepos() @@ -69,6 +93,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v // Check if already added const existing = store.getRepos().find((r) => r.path === args.path) if (existing) { + emitRepoAdded('folder_picker', true) return { repo: existing } } @@ -84,6 +109,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v store.addRepo(repo) await rebuildAuthorizedRootsCache(store) notifyReposChanged(mainWindow) + emitRepoAdded('folder_picker', false) return { repo } } ) @@ -130,6 +156,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v .getRepos() .find((r) => r.connectionId === args.connectionId && r.path === resolvedPath) if (existing) { + emitRepoAdded('folder_picker', true) return { repo: existing } } @@ -190,6 +217,196 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v mux.notify('session.registerRoot', { rootPath: resolvedPath }) } + emitRepoAdded('folder_picker', false) + return { repo } + } + ) + + // Creates a new repo or folder from scratch (orca#763). An empty initial + // commit is required for git repos so HEAD has a branch ref — Orca's + // worktree features all need one. + ipcMain.handle( + 'repos:create', + async ( + _event, + args: { parentPath: string; name: string; kind: 'git' | 'folder' } + ): Promise<{ repo: Repo } | { error: string }> => { + const name = args.name?.trim() ?? '' + const parentPath = args.parentPath?.trim() ?? '' + // Why: IPC input is untrusted — coerce to the narrow union so a bogus + // string (e.g. "x") can't skip git init yet persist as kind: "x" in the + // store. Mirrors the coercion in repos:add above. + const repoKind: 'git' | 'folder' = args.kind === 'folder' ? 'folder' : 'git' + + if (!name) { + return { error: 'Name cannot be empty' } + } + // Block slashes and ./.. so the name can't escape the chosen parent. + // The UI already disables submit in these cases; this guards direct IPC use. + if (/[\\/]/.test(name) || name === '.' || name === '..') { + return { error: 'Name cannot contain slashes or be "." / ".."' } + } + if (!parentPath) { + return { error: 'Parent directory is required' } + } + // Why: blocks CWD-relative paths from slipping through the IPC boundary; + // the UI uses pickDirectory which returns absolute paths, this guards + // direct IPC use (and keeps targetPath stable across process cwd changes). + if (!isAbsolute(parentPath)) { + return { error: 'Parent directory must be an absolute path' } + } + + const targetPath = join(parentPath, name) + + // Dedup by path (same as repos:add) so a double-click on Create doesn't + // produce two sidebar entries pointing at the same folder. This is the + // first of three dedup checks; see the pre-addRepo check below for why + // the race matters even after this one passes. + const existing = store.getRepos().find((r) => r.path === targetPath) + if (existing) { + emitRepoAdded('folder_picker', true) + return { repo: existing } + } + + // Empty pre-existing directories are allowed (e.g. one the user made in + // Finder first). Non-empty ones are rejected so we don't overwrite files. + let createdDir = false + let targetExists = false + try { + await access(targetPath) + targetExists = true + } catch (err) { + // Why: only ENOENT means "the path is free to use". Other codes + // (EACCES, ENOTDIR, EPERM, ELOOP, ...) mean something is in the way + // that mkdir can't fix — surface a precise error instead of falling + // through to mkdir and returning a misleading "Failed to create + // directory" message. + // + // Why the message fallback: fs.promises.access always attaches a + // NodeJS.ErrnoException code in production, but plain Error objects + // thrown in tests / non-Node contexts won't — treat a message that + // reads like ENOENT as one so we don't over-reject. + const code = + err && typeof err === 'object' && 'code' in err + ? (err as NodeJS.ErrnoException).code + : undefined + const looksLikeEnoent = + code === 'ENOENT' || + (code === undefined && err instanceof Error && /ENOENT/.test(err.message)) + if (!looksLikeEnoent) { + const message = err instanceof Error ? err.message : String(err) + return { error: `Cannot access target path: ${message}` } + } + } + + if (targetExists) { + try { + const entries = await readdir(targetPath) + if (entries.length > 0) { + return { + error: `"${name}" already exists at this location and is not empty.` + } + } + } catch (err) { + // Why: access succeeded but readdir failed — the path exists but we + // can't inspect it (e.g. it's a file, not a directory; or perms). + // mkdir would definitely fail here too, so return a distinct error. + const message = err instanceof Error ? err.message : String(err) + return { error: `Failed to read directory: ${message}` } + } + } else { + try { + await mkdir(targetPath, { recursive: false }) + createdDir = true + } catch (err) { + // Why: EEXIST here means another concurrent repos:create for the + // same path won the mkdir race. If they already added the repo to + // the store, return that entry instead of a confusing error. This + // is the second dedup check; see the pre-addRepo check below for + // the full race explanation. + const code = + err && typeof err === 'object' && 'code' in err + ? (err as NodeJS.ErrnoException).code + : undefined + const isEexist = code === 'EEXIST' || (err instanceof Error && /EEXIST/.test(err.message)) + if (isEexist) { + const raceWinner = store.getRepos().find((r) => r.path === targetPath) + if (raceWinner) { + return { repo: raceWinner } + } + } + const message = err instanceof Error ? err.message : String(err) + return { error: `Failed to create directory: ${message}` } + } + } + + if (repoKind === 'git') { + // Why: track which git step is running so the catch can attribute the + // failure correctly. The identity-hint regex is only meaningful during + // commit — git init itself never produces "Please tell me who you are". + let step: 'init' | 'commit' = 'init' + try { + await gitExecFileAsync(['init'], { cwd: targetPath }) + step = 'commit' + await gitExecFileAsync(['commit', '--allow-empty', '-m', 'Initial commit'], { + cwd: targetPath + }) + } catch (err) { + // Only remove the directory if we made it. A pre-existing folder the + // user picked must survive so they can retry after fixing git config. + // Why: if we didn't make the directory but `git init` created `.git/` + // inside it, strip just `.git/` so the user's folder looks the way + // they left it. Retrying works either way, but leaving a half-init'd + // repo behind is confusing if they choose to skip the retry. + if (createdDir) { + await rm(targetPath, { recursive: true, force: true }).catch(() => {}) + } else if (step === 'commit') { + await rm(join(targetPath, '.git'), { recursive: true, force: true }).catch(() => {}) + } + const message = err instanceof Error ? err.message : String(err) + if ( + step === 'commit' && + /Please tell me who you are|user\.name|user\.email/i.test(message) + ) { + return { + error: + 'Git author identity is not configured. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"`, then try again.' + } + } + const stepLabel = + step === 'init' + ? 'Failed to initialize git repository' + : 'Failed to create initial commit' + return { error: `${stepLabel}: ${message}` } + } + } + + // Why: ipcMain.handle doesn't serialize concurrent calls; re-running the + // dedup lookup here closes the window between the first check and + // addRepo. A second repos:create for the same path that raced past the + // initial dedup now returns the entry the first call persisted. + const raceWinner = store.getRepos().find((r) => r.path === targetPath) + if (raceWinner) { + // Why: do NOT rm even if this invocation created the directory — the + // other invocation is using it. Leaking a freshly-made empty folder on + // a rare race is strictly safer than deleting a directory the winning + // call (and the user) now owns. + return { repo: raceWinner } + } + + const repo: Repo = { + id: randomUUID(), + path: targetPath, + displayName: name, + badgeColor: REPO_COLORS[store.getRepos().length % REPO_COLORS.length], + addedAt: Date.now(), + kind: repoKind + } + + store.addRepo(repo) + await rebuildAuthorizedRootsCache(store) + notifyReposChanged(mainWindow) + emitRepoAdded('folder_picker', false) return { repo } } ) @@ -207,11 +424,48 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v args: { repoId: string updates: Partial< - Pick + Pick< + Repo, + | 'displayName' + | 'badgeColor' + | 'hookSettings' + | 'worktreeBaseRef' + | 'kind' + | 'symlinkPaths' + | 'issueSourcePreference' + > > } ) => { - const updated = store.updateRepo(args.repoId, args.updates) + // Why: validate the persisted preference string at the IPC boundary + // — the TypeScript signature is erased at runtime, and a preload + // version skew or renderer bug could otherwise persist a garbage + // string that silently collapses to 'auto' in `resolveIssueSource` + // (see gh-utils.ts#resolveIssueSource). Strip rather than throw so + // other valid fields in the same call still persist. + const updates = { ...args.updates } + if ( + 'issueSourcePreference' in updates && + updates.issueSourcePreference !== undefined && + updates.issueSourcePreference !== 'upstream' && + updates.issueSourcePreference !== 'origin' && + updates.issueSourcePreference !== 'auto' + ) { + delete updates.issueSourcePreference + } + // Why: `symlinkPaths` is consumed by `createWorktreeSymlinks` which + // calls `.trim()` on each entry. A renderer bug or preload-version skew + // that persists a non-`string[]` value (e.g. `[42, null]`, a bare + // string) would throw inside the worktree-create path with no UI + // signal. Strip invalid shapes at the boundary the same way + // `issueSourcePreference` is validated above. + if ('symlinkPaths' in updates && updates.symlinkPaths !== undefined) { + const v = updates.symlinkPaths as unknown + if (!Array.isArray(v) || !v.every((e) => typeof e === 'string')) { + delete updates.symlinkPaths + } + } + const updated = store.updateRepo(args.repoId, updates) if (updated) { notifyReposChanged(mainWindow) } @@ -219,6 +473,55 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } ) + // ── Sparse presets ───────────────────────────────────────────── + // Why: presets are repo-scoped reusable directory lists used by the + // new-workspace composer. Persisted via Store and broadcast back to the + // renderer so any open composer reflects new/edited/deleted presets + // immediately. + + ipcMain.handle('sparsePresets:list', (_event, args: { repoId: string }) => { + return store.getSparsePresets(args.repoId) + }) + + ipcMain.handle( + 'sparsePresets:save', + ( + _event, + args: { repoId: string; id?: string; name: string; directories: string[] } + ): SparsePreset => { + const repo = store.getRepo(args.repoId) + if (!repo) { + throw new Error(`Repo "${args.repoId}" not found`) + } + const name = normalizeSparsePresetName(args.name) + const directories = normalizeSparsePresetDirectories(args.directories) + const now = Date.now() + const existing = args.id + ? store.getSparsePresets(args.repoId).find((preset) => preset.id === args.id) + : undefined + const preset: SparsePreset = { + id: existing?.id ?? randomUUID(), + repoId: args.repoId, + name, + directories, + createdAt: existing?.createdAt ?? now, + updatedAt: now + } + const saved = store.saveSparsePreset(preset) + notifySparsePresetsChanged(mainWindow, args.repoId) + return saved + } + ) + + ipcMain.handle('sparsePresets:remove', (_event, args: { repoId: string; presetId: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo) { + throw new Error(`Repo "${args.repoId}" not found`) + } + store.removeSparsePreset(args.repoId, args.presetId) + notifySparsePresetsChanged(mainWindow, args.repoId) + }) + ipcMain.handle('repos:pickFolder', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory'] @@ -282,7 +585,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v // Why: clone destination may be a WSL path (e.g. user picks a WSL // directory). Use the parent destination as the cwd so the runner // detects WSL and routes through wsl.exe. - const proc = gitSpawn(['clone', '--progress', args.url, clonePath], { + // Why: use the '--' separator to isolate the URL argument and prevent + // malicious URLs from being interpreted as git flags (command injection). + const proc = gitSpawn(['clone', '--progress', '--', args.url, clonePath], { cwd: args.destination, stdio: ['ignore', 'ignore', 'pipe'] }) @@ -341,9 +646,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v const updated = store.updateRepo(existing.id, { kind: 'git' }) if (updated) { notifyReposChanged(mainWindow) + // Why: folder→git upgrade is a real new git repo provisioning event. + emitRepoAdded('clone_url', false) return updated } } + emitRepoAdded('clone_url', true) return existing } @@ -359,6 +667,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v store.addRepo(repo) await rebuildAuthorizedRootsCache(store) notifyReposChanged(mainWindow) + emitRepoAdded('clone_url', false) return repo } ) @@ -512,3 +821,39 @@ function notifyReposChanged(mainWindow: BrowserWindow): void { mainWindow.webContents.send('repos:changed') } } + +function notifySparsePresetsChanged(mainWindow: BrowserWindow, repoId: string): void { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('sparsePresets:changed', { repoId }) + } +} + +function normalizeSparsePresetName(name: string): string { + const trimmed = name.trim() + if (!trimmed) { + throw new Error('Preset name is required.') + } + if (trimmed.length > 80) { + throw new Error('Preset name is too long.') + } + return trimmed +} + +function normalizeSparsePresetDirectories(directories: string[]): string[] { + let normalized: string[] + try { + normalized = normalizeSparseDirectories(directories) + } catch (err) { + if ( + err instanceof Error && + err.message === 'Sparse checkout directories must be repo-relative paths.' + ) { + throw new Error('Preset directories must be repo-relative paths.') + } + throw err + } + if (normalized.length === 0) { + throw new Error('Preset must have at least one directory.') + } + return normalized +} diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 1460d6c9599..c4aef20d31e 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -20,4 +20,36 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { ipcMain.handle('runtime:getStatus', (): RuntimeStatus => { return runtime.getStatus() }) + + ipcMain.removeHandler('runtime:getTerminalFitOverrides') + ipcMain.handle( + 'runtime:getTerminalFitOverrides', + (): { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] => { + const overrides = runtime.getAllTerminalFitOverrides() + return Array.from(overrides.entries()).map(([ptyId, override]) => ({ + ptyId, + ...override + })) + } + ) + + // Why: the desktop "Restore" button sets the display mode to 'desktop' and + // applies it, which restores the PTY to its original dimensions and emits + // a 'resized' event to any active mobile subscriber. This uses the same + // code path as the mobile toggle button (terminal.setDisplayMode RPC). + ipcMain.removeHandler('runtime:restoreTerminalFit') + ipcMain.handle('runtime:restoreTerminalFit', (_event, args: { ptyId: string }) => { + // Why: this IPC powers the desktop "Take back" button. Beyond restoring + // PTY dims (the original semantic), it now also reclaims the input + // floor for the desktop via the driver state machine. The lock banner + // unmounts and desktop input/resize are unblocked until the next + // mobile interaction takes the floor again. See + // docs/mobile-presence-lock.md. + try { + const reclaimed = runtime.reclaimTerminalForDesktop(args.ptyId) + return { restored: reclaimed } + } catch { + return { restored: false } + } + }) } diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 323b652495a..30fe923b1d4 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -3,6 +3,23 @@ import type { Store } from '../persistence' import type { GlobalSettings, PersistedState } from '../../shared/types' import { listSystemFontFamilies } from '../system-fonts' import { previewGhosttyImport } from '../ghostty/index' +import { rebuildAppMenu } from '../menu/register-app-menu' +import { track } from '../telemetry/client' +import { SETTINGS_CHANGED_WHITELIST, type SettingsChangedKey } from '../../shared/telemetry-events' + +// Why: the whitelist is the source-of-truth for which keys we emit on. Casting +// to a Set once at module load lets the IPC handler's per-key membership +// check stay O(1) without re-coercing the readonly tuple on every call. +const SETTINGS_CHANGED_WHITELIST_SET = new Set(SETTINGS_CHANGED_WHITELIST) + +// Why: fields that appear in the View > Appearance submenu need the menu +// rebuilt after any update so the checkbox `checked` state stays in sync +// with the persisted value. Electron doesn't reactively re-render menu +// items when the backing state changes. +const APPEARANCE_MENU_KEYS: readonly (keyof GlobalSettings)[] = [ + 'showTasksButton', + 'showTitlebarAgentActivity' +] export function registerSettingsHandlers(store: Store): void { ipcMain.handle('settings:get', () => { @@ -13,7 +30,44 @@ export function registerSettingsHandlers(store: Store): void { if (args.theme) { nativeTheme.themeSource = args.theme } - return store.updateSettings(args) + // Why: capture the pre-update value so we only emit when the value + // actually changes. The settings UI sometimes re-saves the same value + // (e.g. blur after a no-op edit), and a `settings_changed` event for a + // no-op flip would inflate the experimental-feature-adoption signal. + const before = store.getSettings() + const result = store.updateSettings(args) + if (APPEARANCE_MENU_KEYS.some((key) => key in args)) { + rebuildAppMenu() + } + + // Why: telemetry-plan.md§Settings — fire `settings_changed` only for + // whitelisted keys, with `value_kind` distinguishing booleans from + // string-enum settings. We deliberately do NOT send the raw value for + // non-enum settings; the whitelist is currently scoped to experimental + // toggles, all of which are booleans, so `value_kind === 'bool'` is + // the path the v1 enum has a slot for. If a non-bool whitelisted + // setting is ever added, extend the discriminator here at the same + // time the schema's `value_kind` enum gains the new value. + for (const key of Object.keys(args)) { + if (!SETTINGS_CHANGED_WHITELIST_SET.has(key)) { + continue + } + const beforeValue = (before as Record)[key] + const afterValue = (result as Record)[key] + if (beforeValue === afterValue) { + continue + } + if (typeof afterValue !== 'boolean') { + // No non-bool whitelist entries today; skip rather than guess. + continue + } + track('settings_changed', { + setting_key: key as SettingsChangedKey, + value_kind: 'bool' + }) + } + + return result }) ipcMain.handle('settings:listFonts', () => { diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts new file mode 100644 index 00000000000..cd7ce56d893 --- /dev/null +++ b/src/main/ipc/shell.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, showOpenDialogMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + showOpenDialogMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + }, + shell: { + showItemInFolder: vi.fn(), + openExternal: vi.fn(), + openPath: vi.fn() + }, + dialog: { + showOpenDialog: showOpenDialogMock + } +})) + +import { registerShellHandlers } from './shell' + +describe('registerShellHandlers', () => { + beforeEach(() => { + handleMock.mockReset() + showOpenDialogMock.mockReset() + }) + + function getHandler(channel: string): (event: unknown, args?: unknown) => Promise { + registerShellHandlers() + const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === channel) + if (!call) { + throw new Error(`${channel} handler not registered`) + } + return call[1] as (event: unknown, args?: unknown) => Promise + } + + it('picks audio files with a constrained native dialog filter', async () => { + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ['/Users/kaylee/Downloads/Note_block_pling.ogg'] + }) + + const handler = getHandler('shell:pickAudio') + await expect(handler({})).resolves.toBe('/Users/kaylee/Downloads/Note_block_pling.ogg') + expect(showOpenDialogMock).toHaveBeenCalledWith({ + properties: ['openFile'], + filters: [{ name: 'Audio', extensions: ['ogg', 'mp3', 'wav', 'm4a', 'aac', 'flac'] }] + }) + }) + + it('returns null when audio picking is canceled', async () => { + showOpenDialogMock.mockResolvedValue({ + canceled: true, + filePaths: [] + }) + + const handler = getHandler('shell:pickAudio') + await expect(handler({})).resolves.toBeNull() + }) +}) diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 62fc383ca5c..51dff5ae5b6 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -123,6 +123,17 @@ export function registerShellHandlers(): void { return result.filePaths[0] }) + ipcMain.handle('shell:pickAudio', async (): Promise => { + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [{ name: 'Audio', extensions: ['ogg', 'mp3', 'wav', 'm4a', 'aac', 'flac'] }] + }) + if (result.canceled || result.filePaths.length === 0) { + return null + } + return result.filePaths[0] + }) + // Why: copying a picked image next to the markdown file lets us insert a // relative path (e.g. `![](image.png)`) instead of embedding base64, // keeping markdown files small and portable. diff --git a/src/main/ipc/sidekick.ts b/src/main/ipc/sidekick.ts new file mode 100644 index 00000000000..f7de7f10e45 --- /dev/null +++ b/src/main/ipc/sidekick.ts @@ -0,0 +1,485 @@ +/* eslint-disable max-lines */ +import { app, BrowserWindow, dialog, ipcMain, nativeImage } from 'electron' +import { copyFile, mkdir, readFile, rename, rm, stat, lstat } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { basename, dirname, extname, isAbsolute, join, normalize, resolve, sep } from 'node:path' +import { z } from 'zod' +import type { CustomSidekick } from '../../shared/types' + +// Why: image-only sidekick uploads. Static + animated variants render natively +// via , so no 3D engine is needed. Main owns the accepted-format table as +// the single source of truth for what the renderer will try to display. +const IMAGE_FORMATS: Record = { + '.png': 'image/png', + '.apng': 'image/apng', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml' +} + +function classifyFile(src: string): { mimeType: string; ext: string } | null { + const ext = extname(src).toLowerCase() + const mime = IMAGE_FORMATS[ext] + if (!mime) { + return null + } + return { mimeType: mime, ext } +} + +// Why: custom user-uploaded images live in a dedicated folder under userData +// so they persist across updates but are scoped to the Orca install. We never +// trust paths the renderer hands us — the renderer only ever knows the opaque +// CustomSidekick.id; main resolves it to an absolute path inside this folder. +function getSidekicksDir(): string { + return join(app.getPath('userData'), 'sidekicks', 'custom') +} + +const MAX_BYTES = 64 * 1024 * 1024 // 64 MB — generous but bounded so a user can't point at a multi-GB file and OOM the renderer when it builds a Blob URL. +const MAX_MANIFEST_BYTES = 64 * 1024 // pet.json is tiny by spec; cap to defend against a malicious bundle stuffing megabytes into the manifest. + +function isSafeId(id: string): boolean { + // UUIDs only; blocks path traversal and unexpected characters. Storage ids + // are always generated in main (never sourced from the bundle's manifest.id), + // so this regex is the canonical gate for any fs path that includes the id. + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) +} + +function resolveSidekickFile( + id: string, + fileName: string, + kind: 'image' | 'bundle' +): string | null { + if (!isSafeId(id)) { + return null + } + const safeName = basename(fileName) + const root = normalize(getSidekicksDir()) + let filePath: string + if (kind === 'bundle') { + // Bundle layout: custom//. fileName is just the spritesheet + // basename (e.g. "spritesheet.webp"); pet.json is read by main only and + // never served to the renderer. + filePath = normalize(join(root, id, safeName)) + const bundleDir = normalize(join(root, id)) + sep + if (!filePath.startsWith(bundleDir)) { + return null + } + return filePath + } + // Legacy image layout: custom/.. Filename must start with the id + // so the prefix check catches any edge case that slipped the regex. + if (!safeName.startsWith(`${id}.`)) { + return null + } + filePath = normalize(join(root, safeName)) + if (!filePath.startsWith(root + sep)) { + return null + } + return filePath +} + +const PetManifestSchema = z + .object({ + id: z.string().min(1).max(128).optional(), + displayName: z.string().min(1).max(120).optional(), + description: z.string().max(2000).optional(), + spritesheetPath: z + .string() + .min(1) + .max(255) + // Why: belt-and-suspenders against malicious manifests — downstream + // resolve+prefix check still runs as defense in depth. + .refine( + (p) => !p.includes('\0') && !p.startsWith('/') && !p.startsWith('\\') && !p.includes('..'), + 'invalid spritesheetPath' + ), + frame: z + .object({ + width: z.number().int().positive().max(1024), + height: z.number().int().positive().max(1024) + }) + .optional(), + fps: z.number().positive().max(60).optional(), + defaultAnimation: z.string().min(1).max(64).optional(), + animations: z + .record( + z.string().min(1).max(64), + z.object({ + row: z.number().int().min(0).max(256), + frames: z.number().int().positive().max(512) + }) + ) + .optional() + }) + // Why: tolerate unknown top-level fields. Pet-bundle generators may emit + // descriptive metadata (e.g. "kind": "person") we don't consume yet, and + // strict mode would reject those imports with a confusing "unrecognized + // key" error instead of just ignoring the extras. + .loose() + +type PetManifest = z.infer + +// Why: renderer-supplied IPC inputs are untrusted — validate shape before any +// path resolution. resolveSidekickFile still gates the actual filesystem path. +const SidekickFileRequestSchema = z.object({ + id: z.string(), + fileName: z.string(), + kind: z.enum(['image', 'bundle']).optional() +}) + +async function readSheetDimensions( + buffer: Buffer +): Promise<{ width: number; height: number } | null> { + // Why: nativeImage decodes PNG/JPEG/GIF/WebP/BMP. SVG isn't supported here + // (vector → no integer pixel grid), so pet bundles must use a raster sheet. + const image = nativeImage.createFromBuffer(buffer) + if (image.isEmpty()) { + return null + } + const size = image.getSize() + if (size.width <= 0 || size.height <= 0) { + return null + } + return { width: size.width, height: size.height } +} + +async function isSymlink(path: string): Promise { + try { + const s = await lstat(path) + return s.isSymbolicLink() + } catch { + return false + } +} + +export function registerSidekickHandlers(): void { + ipcMain.handle('sidekick:import', async (event): Promise => { + const senderWindow = + BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() + const options: Electron.OpenDialogOptions = { + title: 'Pick sidekick', + properties: ['openFile'], + // Why: single filter and no `apng` extension. macOS file dialogs map + // filter extensions to UTIs; `apng` has no registered UTI, so including + // it can drop sibling extensions (notably `webp`) from the allowed set. + // APNG files carry the `.png` extension and are detected from magic + // bytes by the browser. + filters: [ + { + name: 'Sidekick image', + extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'] + } + ] + } + const result = senderWindow + ? await dialog.showOpenDialog(senderWindow, options) + : await dialog.showOpenDialog(options) + if (result.canceled || result.filePaths.length === 0) { + return null + } + const src = result.filePaths[0] + const classified = classifyFile(src) + if (!classified) { + throw new Error('Unsupported file. Pick a PNG, APNG, JPG, GIF, WebP, or SVG.') + } + let srcStat: Awaited> + try { + srcStat = await stat(src) + } catch { + throw new Error('Could not read the selected file.') + } + if (!srcStat.isFile()) { + throw new Error('Selected path is not a file') + } + if (srcStat.size > MAX_BYTES) { + throw new Error( + `File is too large (${(srcStat.size / (1024 * 1024)).toFixed(1)} MB). Max is ${MAX_BYTES / (1024 * 1024)} MB.` + ) + } + + const dir = getSidekicksDir() + await mkdir(dir, { recursive: true }) + const id = randomUUID() + // Why: preserve original extension in the on-disk name so sidekick:read can + // rebuild the right Blob MIME via resolveSidekickFile without a separate + // lookup. The extension is only ever written by main (never the renderer). + const fileName = `${id}${classified.ext}` + const dest = join(dir, fileName) + try { + await copyFile(src, dest) + } catch { + await rm(dest, { force: true }).catch(() => {}) + throw new Error('Could not save the sidekick.') + } + + const rawLabel = basename(src, extname(src)).trim() + const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : 'Custom sidekick' + return { + id, + label, + fileName, + mimeType: classified.mimeType, + kind: 'image' + } + }) + + ipcMain.handle('sidekick:importPetBundle', async (event): Promise => { + const senderWindow = + BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() + // Why: the bundle is a folder. macOS users may also pick `pet.json` itself + // when Finder is set to show package contents — the post-pick logic walks + // up to the parent directory in that case. + const options: Electron.OpenDialogOptions = { + title: 'Pick a .codex-pet bundle', + properties: ['openFile', 'openDirectory', 'treatPackageAsDirectory'] + } + const result = senderWindow + ? await dialog.showOpenDialog(senderWindow, options) + : await dialog.showOpenDialog(options) + if (result.canceled || result.filePaths.length === 0) { + return null + } + const picked = result.filePaths[0] + let bundleDir: string + try { + const pickedStat = await stat(picked) + bundleDir = pickedStat.isDirectory() ? picked : dirname(picked) + } catch { + throw new Error('Could not read the selected path.') + } + + const manifestPath = join(bundleDir, 'pet.json') + let manifestStat: Awaited> + try { + manifestStat = await stat(manifestPath) + } catch { + throw new Error('Bundle is missing pet.json.') + } + if (!manifestStat.isFile() || manifestStat.size > MAX_MANIFEST_BYTES) { + throw new Error('pet.json is invalid.') + } + if (await isSymlink(manifestPath)) { + throw new Error('pet.json must not be a symlink.') + } + + let manifest: PetManifest + try { + const raw = await readFile(manifestPath, 'utf8') + // Why: defend against TOCTOU between stat and read — the file could have + // grown after the stat check. + if (Buffer.byteLength(raw, 'utf8') > MAX_MANIFEST_BYTES) { + throw new Error('pet.json exceeded the manifest size limit.') + } + manifest = PetManifestSchema.parse(JSON.parse(raw)) + } catch (error) { + throw new Error(`Invalid pet.json: ${error instanceof Error ? error.message : 'parse error'}`) + } + + // Why: spritesheetPath is bundle-relative. Reject absolute paths and any + // resolved path that escapes the bundle directory. Also reject symlinks so + // a malicious bundle can't reach outside via a sibling link. + if (isAbsolute(manifest.spritesheetPath)) { + throw new Error('spritesheetPath must be relative to the bundle.') + } + const sheetSrc = resolve(bundleDir, manifest.spritesheetPath) + const bundleResolved = resolve(bundleDir) + if (sheetSrc === bundleResolved) { + throw new Error('spritesheetPath must point to a file, not the bundle root.') + } + const bundleRoot = bundleResolved + sep + // Why: NTFS/macOS HFS+ default volumes are case-insensitive — a path like + // `BUNDLE\sheet.png` is still inside `bundle\`. Compare lowercased on + // Windows so the prefix check isn't bypassed by case differences. + const cmp = process.platform === 'win32' ? (s: string) => s.toLowerCase() : (s: string) => s + if (!cmp(sheetSrc + sep).startsWith(cmp(bundleRoot))) { + throw new Error('spritesheetPath escapes the bundle.') + } + if (await isSymlink(sheetSrc)) { + throw new Error('spritesheet must not be a symlink.') + } + const sheetClass = classifyFile(sheetSrc) + if (!sheetClass || sheetClass.ext === '.svg') { + // SVG can't be used as a sprite sheet (no pixel grid). + throw new Error('Spritesheet must be a PNG, APNG, JPG, GIF, or WebP.') + } + let sheetStat: Awaited> + try { + sheetStat = await stat(sheetSrc) + } catch { + throw new Error('Spritesheet file not found.') + } + if (!sheetStat.isFile()) { + throw new Error('Spritesheet path is not a file.') + } + if (sheetStat.size > MAX_BYTES) { + throw new Error( + `Spritesheet is too large (${(sheetStat.size / (1024 * 1024)).toFixed(1)} MB).` + ) + } + + let sprite: NonNullable | undefined + if (manifest.frame) { + // Why: only decode the sheet when we need to validate frame layout. + // nativeImage may fail on some WebP variants in headless contexts, and + // bundles without `frame` render as a static image where dimensions + // don't matter. + const sheetBuf = await readFile(sheetSrc) + // Why: defend against TOCTOU — file may have grown between stat and read. + if (sheetBuf.byteLength > MAX_BYTES) { + throw new Error('Spritesheet exceeded the size limit.') + } + const dims = await readSheetDimensions(sheetBuf) + if (!dims) { + throw new Error('Could not decode the spritesheet image.') + } + const { width: fw, height: fh } = manifest.frame + if (dims.width % fw !== 0 || dims.height % fh !== 0) { + throw new Error( + `Spritesheet ${dims.width}×${dims.height} is not a clean multiple of frame ${fw}×${fh}.` + ) + } + const columns = dims.width / fw + const rows = dims.height / fh + if (manifest.animations) { + for (const [name, anim] of Object.entries(manifest.animations)) { + if (anim.row >= rows) { + throw new Error(`Animation "${name}" references row ${anim.row} but sheet has ${rows}.`) + } + if (anim.frames > columns) { + throw new Error( + `Animation "${name}" has ${anim.frames} frames but sheet only has ${columns} columns.` + ) + } + } + if (manifest.defaultAnimation && !manifest.animations[manifest.defaultAnimation]) { + throw new Error(`defaultAnimation "${manifest.defaultAnimation}" not in animations.`) + } + } + sprite = { + frameWidth: fw, + frameHeight: fh, + columns, + rows, + sheetWidth: dims.width, + sheetHeight: dims.height, + fps: manifest.fps ?? 8, + defaultAnimation: manifest.defaultAnimation, + animations: manifest.animations + } + } + + // Why: storage id is always a fresh UUID — the manifest's `id` is purely + // a display hint. This guards against (a) collisions with bundled + // sidekick ids, (b) failing isSafeId, and (c) re-import clobbering an + // earlier copy of the same bundle. + const id = randomUUID() + const root = getSidekicksDir() + await mkdir(root, { recursive: true }) + const destDir = join(root, id) + const sheetExt = sheetClass.ext + const sheetFileName = `spritesheet${sheetExt}` + // Why: stage the bundle into a sibling .tmp directory and atomically rename + // into place so destDir only appears once both files are written. Avoids + // half-imported bundles if a copy fails midway. + const tmpDir = `${destDir}.tmp` + try { + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + await mkdir(tmpDir, { recursive: true }) + await copyFile(sheetSrc, join(tmpDir, sheetFileName)) + await copyFile(manifestPath, join(tmpDir, 'pet.json')) + await rename(tmpDir, destDir) + } catch { + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + throw new Error('Could not save the pet bundle.') + } + + const rawLabel = (manifest.displayName ?? manifest.id ?? basename(bundleDir)).trim() + const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : 'Pet bundle' + return { + id, + label, + fileName: sheetFileName, + mimeType: sheetClass.mimeType, + kind: 'bundle', + sprite, + // Why: carries manifest.fps for detected-frame bundles where `sprite` + // is undefined — renderer falls back to this when sprite is absent. + ...(manifest.fps !== undefined ? { spriteFps: manifest.fps } : {}) + } + }) + + ipcMain.handle( + 'sidekick:read', + async ( + _event, + id: string, + fileName: string, + kind?: 'image' | 'bundle' + ): Promise => { + // Why: validate IPC inputs before any path logic — renderer is not + // trusted to send strings of the right shape. + let parsed: z.infer + try { + parsed = SidekickFileRequestSchema.parse({ id, fileName, kind }) + } catch { + throw new Error('Invalid sidekick:read arguments') + } + // Why: missing kind defaults to 'image' for backwards compatibility with + // pre-bundle persisted state. + const filePath = resolveSidekickFile(parsed.id, parsed.fileName, parsed.kind ?? 'image') + if (!filePath) { + return null + } + try { + const buf = await readFile(filePath) + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) + } catch (error) { + console.warn('[sidekick-overlay] sidekick:read failed', error) + return null + } + } + ) + + ipcMain.handle( + 'sidekick:delete', + async (_event, id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => { + // Why: validate IPC inputs before any path logic. + let parsed: z.infer + try { + parsed = SidekickFileRequestSchema.parse({ id, fileName, kind }) + } catch { + throw new Error('Invalid sidekick:delete arguments') + } + if (!isSafeId(parsed.id)) { + return + } + if ((parsed.kind ?? 'image') === 'bundle') { + // Why: bundle imports own a whole directory. isSafeId already gates id; + // we still build the path under the sidekicks root and verify the + // prefix before recursive removal as defense in depth. + const root = normalize(getSidekicksDir()) + const target = normalize(join(root, parsed.id)) + if (!target.startsWith(root + sep)) { + return + } + try { + await rm(target, { recursive: true, force: true }) + } catch (error) { + console.warn('[sidekick-overlay] sidekick:delete (bundle) failed', error) + } + return + } + const filePath = resolveSidekickFile(parsed.id, parsed.fileName, 'image') + if (!filePath) { + return + } + try { + await rm(filePath, { force: true }) + } catch (error) { + console.warn('[sidekick-overlay] sidekick:delete failed', error) + } + } + ) +} diff --git a/src/main/ipc/sparse-checkout-directories.ts b/src/main/ipc/sparse-checkout-directories.ts new file mode 100644 index 00000000000..74f2873d062 --- /dev/null +++ b/src/main/ipc/sparse-checkout-directories.ts @@ -0,0 +1,29 @@ +const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/ + +function isAbsoluteSparseDirectoryPath(entry: string): boolean { + return entry.startsWith('/') || entry.startsWith('\\') || WINDOWS_DRIVE_PATH_PATTERN.test(entry) +} + +export function normalizeSparseDirectories(directories: string[]): string[] { + const seen = new Set() + return directories + .map((entry) => entry.trim()) + .map((entry) => { + // Why: absolute paths can look repo-relative after slash normalization. + if (isAbsoluteSparseDirectoryPath(entry)) { + throw new Error('Sparse checkout directories must be repo-relative paths.') + } + return entry.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '') + }) + .filter((entry) => entry.length > 0 && entry !== '.') + .filter((entry) => { + if (entry.split('/').includes('..')) { + throw new Error('Sparse checkout directories must be repo-relative paths.') + } + if (seen.has(entry)) { + return false + } + seen.add(entry) + return true + }) +} diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index cbabe7db0f1..874e677dbd7 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -34,12 +34,16 @@ export function registerSshBrowseHandler( // filenames containing spaces or special characters. The -1 flag outputs // one entry per line. The -p flag appends / to directories. // We resolve ~ and get the absolute path via `cd && pwd`. + // `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission + // denied after a readable `cd ... && pwd`) propagates as a non-zero exit + // code rather than being indistinguishable from an empty directory. const command = `cd ${shellEscape(args.dirPath)} && pwd && ls -1ap` const channel = await conn.exec(command) return new Promise((resolve, reject) => { let stdout = '' let stderr = '' + let exitCode: number | null = null channel.on('data', (data: Buffer) => { stdout += data.toString() @@ -47,7 +51,28 @@ export function registerSshBrowseHandler( channel.stderr.on('data', (data: Buffer) => { stderr += data.toString() }) + // `exit` fires before `close`; capture the code so we can distinguish + // a failed `ls` that still produced `pwd` output from an empty listing. + channel.on('exit', (code: number | null) => { + exitCode = code + }) channel.on('close', () => { + // A null exitCode means the server closed the channel without + // sending an exit-status message (or signalled termination). We + // can't assume success — falling back to "empty stdout = empty + // directory" is exactly the bug the exit-code branch was added to + // fix. Treat any non-zero OR null exit as a failure when stderr + // has content, and otherwise require stdout to contain at least + // the resolved `pwd` line before accepting the result. + if (exitCode !== 0) { + const msg = + stderr.trim() || + (exitCode === null + ? 'Remote listing failed (channel closed without exit status)' + : `Remote listing failed (exit ${exitCode})`) + reject(new Error(msg)) + return + } if (stderr.trim() && !stdout.trim()) { reject(new Error(stderr.trim())) return diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index afa4563054a..1308ceec379 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -109,6 +109,8 @@ vi.mock('./pty', () => ({ registerSshPtyProvider: vi.fn(), unregisterSshPtyProvider: vi.fn(), clearPtyOwnershipForConnection: vi.fn(), + clearProviderPtyState: vi.fn(), + deletePtyOwnership: vi.fn(), getSshPtyProvider: vi.fn(), getPtyIdsForConnection: vi.fn().mockReturnValue([]) })) @@ -268,6 +270,43 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) }) + it('forwards remote PTY events into the runtime', async () => { + const runtime = { + onPtyData: vi.fn(), + onPtyExit: vi.fn() + } + registerSshHandlers(mockStore, () => mockWindow as never, runtime as never) + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + const onData = mockPtyProvider.onData.mock.calls[0]?.[0] as + | ((payload: { id: string; data: string }) => void) + | undefined + const onExit = mockPtyProvider.onExit.mock.calls[0]?.[0] as + | ((payload: { id: string; code: number }) => void) + | undefined + + onData?.({ id: 'remote-pty', data: 'hello' }) + onExit?.({ id: 'remote-pty', code: 7 }) + + expect(runtime.onPtyData).toHaveBeenCalledWith('remote-pty', 'hello', expect.any(Number)) + expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7) + }) + it('ssh:disconnect calls connection manager', async () => { mockConnectionManager.disconnect.mockResolvedValue(undefined) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 4aac9e51744..edb556697b1 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -17,6 +17,7 @@ import type { import { isAuthError } from '../ssh/ssh-connection-utils' import { registerSshBrowseHandler } from './ssh-browse' import { requestCredential, registerCredentialHandler } from './ssh-passphrase' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' let sshStore: SshConnectionStore | null = null let connectionManager: SshConnectionManager | null = null @@ -146,7 +147,8 @@ async function restorePortForwards( export function registerSshHandlers( store: Store, - getMainWindow: () => BrowserWindow | null + getMainWindow: () => BrowserWindow | null, + runtime?: OrcaRuntimeService ): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } { // Why: on macOS, app re-activation creates a new BrowserWindow and re-calls // this function. ipcMain.handle() throws if a handler is already registered, @@ -160,6 +162,7 @@ export function registerSshHandlers( 'ssh:connect', 'ssh:disconnect', 'ssh:getState', + 'ssh:needsPassphrasePrompt', 'ssh:testConnection', 'ssh:addPortForward', 'ssh:updatePortForward', @@ -297,6 +300,7 @@ export function registerSshHandlers( getMainWindow, store, portForwardManager!, + runtime, (tid, ports, _platform) => { broadcastDetectedPorts(getMainWindow, tid, ports) } @@ -421,6 +425,21 @@ export function registerSshHandlers( return connectionManager!.getState(args.targetId) }) + // Why: callers that want to auto-connect (Cmd+J jump, terminal reattach) need + // to know whether doing so will pop a passphrase/password dialog. Auto-firing + // the connect is fine when no prompt is needed, but surprising otherwise — + // the user expects to enter the credential before the app starts connecting. + // Returns true if the target's last successful connect required a credential + // AND the live SshConnection (if any) does not already have one cached. + ipcMain.handle('ssh:needsPassphrasePrompt', (_event, args: { targetId: string }) => { + const target = sshStore!.getTarget(args.targetId) + if (!target?.lastRequiredPassphrase) { + return false + } + const conn = connectionManager!.getConnection(args.targetId) + return !conn?.hasCachedCredential() + }) + ipcMain.handle('ssh:testConnection', async (_event, args: { targetId: string }) => { const target = sshStore!.getTarget(args.targetId) if (!target) { diff --git a/src/main/ipc/telemetry.test.ts b/src/main/ipc/telemetry.test.ts new file mode 100644 index 00000000000..9e1b9231cc5 --- /dev/null +++ b/src/main/ipc/telemetry.test.ts @@ -0,0 +1,326 @@ +// IPC boundary behavior for the telemetry surface. Strict type narrows must +// drop obviously-malformed calls before they reach the validator (the +// renderer is in the threat model). Pins the consent-mutation rate limit: +// ≤5 consent-related IPC calls per session. Pins the main-side `via` +// derivation: both `OptInVia` branches are reachable from renderer input, +// and the one path that must NOT emit (`acknowledgeBanner`) has its own +// channel and handler rather than being a flag on `setOptIn`. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/types' +import type { Store } from '../persistence' + +const handlers = new Map unknown>() +const { + handleMock, + trackMock, + setOptInMock, + persistBannerAcknowledgeMock, + consumeConsentMutationTokenMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + trackMock: vi.fn(), + setOptInMock: vi.fn(), + persistBannerAcknowledgeMock: vi.fn(), + consumeConsentMutationTokenMock: vi.fn() +})) + +vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) +vi.mock('../telemetry/client', () => ({ + track: trackMock, + setOptIn: setOptInMock, + persistBannerAcknowledgeWithoutEmitting: persistBannerAcknowledgeMock +})) +vi.mock('../telemetry/burst-cap', () => ({ + consumeConsentMutationToken: consumeConsentMutationTokenMock +})) + +import { _resetStoreForTests, registerTelemetryHandlers } from './telemetry' + +function captureHandlers(): void { + handlers.clear() + for (const call of handleMock.mock.calls) { + const [channel, handler] = call as [ + string, + typeof handlers extends Map ? V : never + ] + handlers.set(channel, handler) + } +} + +// Build a fake Store with a settable `telemetry` block. Tests reassign +// `settings.telemetry` between handler invocations to seed the two +// derivation states. +type FakeStoreState = { settings: GlobalSettings } +function makeFakeStore(telemetry: GlobalSettings['telemetry']): { + store: Store + state: FakeStoreState +} { + const state: FakeStoreState = { settings: { telemetry } as unknown as GlobalSettings } + const store = { + getSettings: vi.fn(() => state.settings), + updateSettings: vi.fn((updates: Partial) => { + state.settings = { ...state.settings, ...updates } as GlobalSettings + return state.settings + }) + } as unknown as Store + return { store, state } +} + +function registerWith(telemetry: GlobalSettings['telemetry']): FakeStoreState { + const { store, state } = makeFakeStore(telemetry) + registerTelemetryHandlers(store) + captureHandlers() + return state +} + +describe('telemetry IPC handlers', () => { + beforeEach(() => { + handleMock.mockReset() + trackMock.mockReset() + setOptInMock.mockReset() + persistBannerAcknowledgeMock.mockReset() + consumeConsentMutationTokenMock.mockReset() + consumeConsentMutationTokenMock.mockReturnValue(true) + _resetStoreForTests() + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('registers all four channels', () => { + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: false, + optedIn: true + }) + expect(handlers.has('telemetry:track')).toBe(true) + expect(handlers.has('telemetry:setOptIn')).toBe(true) + expect(handlers.has('telemetry:acknowledgeBanner')).toBe(true) + expect(handlers.has('telemetry:getConsentState')).toBe(true) + }) + + // ── telemetry:track ────────────────────────────────────────────────── + + it('forwards a well-typed track call to track()', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + const handler = handlers.get('telemetry:track')! + handler({}, 'app_opened', {}) + expect(trackMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledWith('app_opened', {}) + }) + + it('drops track calls with a non-string name', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + const handler = handlers.get('telemetry:track')! + handler({}, 42, {}) + handler({}, null, {}) + handler({}, { event: 'app_opened' }, {}) + expect(trackMock).not.toHaveBeenCalled() + }) + + it('drops track calls with non-object props', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + const handler = handlers.get('telemetry:track')! + handler({}, 'app_opened', 'string-not-object') + handler({}, 'app_opened', 42) + expect(trackMock).not.toHaveBeenCalled() + }) + + it('treats null/undefined props as an empty object', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + const handler = handlers.get('telemetry:track')! + handler({}, 'app_opened', null) + handler({}, 'app_opened', undefined) + expect(trackMock).toHaveBeenCalledTimes(2) + expect(trackMock).toHaveBeenNthCalledWith(1, 'app_opened', {}) + expect(trackMock).toHaveBeenNthCalledWith(2, 'app_opened', {}) + }) + + // ── telemetry:setOptIn — input narrowing ───────────────────────────── + + it('drops setOptIn with non-boolean optedIn', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + const handler = handlers.get('telemetry:setOptIn')! + handler({}, 'true') + handler({}, 1) + handler({}, null) + handler({}, undefined) + expect(setOptInMock).not.toHaveBeenCalled() + // None of these should have consumed a mutation token either. + expect(consumeConsentMutationTokenMock).not.toHaveBeenCalled() + }) + + it('drops setOptIn past the consent-mutation rate limit', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + const handler = handlers.get('telemetry:setOptIn')! + consumeConsentMutationTokenMock.mockReturnValue(false) + handler({}, true) + expect(setOptInMock).not.toHaveBeenCalled() + }) + + // ── telemetry:setOptIn — `via` derivation ──────────────────────────── + + it("derives via='first_launch_banner' for an existing user with optedIn=null clicking Turn off", () => { + // Existing-user notice is the only path where an existing user (cohort + // marker true) with optedIn=null flips to false. That is the contract + // the notice's "Turn off" button routes through. + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: true, + optedIn: null + }) + const handler = handlers.get('telemetry:setOptIn')! + handler({}, false) + expect(setOptInMock).toHaveBeenCalledWith('first_launch_banner', false) + }) + + it("derives via='settings' (not 'first_launch_banner') for a defensive opt-in call from the pre-notice state", () => { + // Defensive: the notice's opt-in path is the ✕ (silent acknowledge), + // which does NOT route through setOptIn. A compromised renderer + // could try to call telemetrySetOptIn(true) in the pre-notice state + // and synthesize a spurious telemetry_opted_in { via: + // 'first_launch_banner' }. The derivation must refuse that tag for + // the true-incoming case and fall through to 'settings'. + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: true, + optedIn: null + }) + const handler = handlers.get('telemetry:setOptIn')! + handler({}, true) + expect(setOptInMock).toHaveBeenCalledWith('settings', true) + }) + + it("derives via='settings' for a new user toggling off from Settings (no first-launch surface exists)", () => { + // New users (existedBeforeTelemetryRelease=false) are initialized with + // optedIn=true at migration and see no first-launch surface. Any + // opt-out from this cohort routes through Settings → Privacy and + // must tag as `via: 'settings'`. + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: false, + optedIn: true + }) + const handler = handlers.get('telemetry:setOptIn')! + handler({}, false) + expect(setOptInMock).toHaveBeenCalledWith('settings', false) + }) + + it("derives via='settings' for an opt-in toggle flip after a prior opt-out", () => { + // User flipped off in Settings, flipping back on in Settings. Neither + // cohort marker nor notice state triggers a first-launch tag. + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: true, + optedIn: false + }) + const handler = handlers.get('telemetry:setOptIn')! + handler({}, true) + expect(setOptInMock).toHaveBeenCalledWith('settings', true) + }) + + it("derives via='settings' for a new user flipping Settings off→on (not a first-launch interaction)", () => { + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: false, + optedIn: false + }) + const handler = handlers.get('telemetry:setOptIn')! + handler({}, true) + expect(setOptInMock).toHaveBeenCalledWith('settings', true) + }) + + it("derives via='settings' when the telemetry block is missing (defensive)", () => { + // Should never happen post-migration, but if it does the handler must + // fall through to 'settings' rather than throwing or mis-tagging. + registerWith(undefined) + const handler = handlers.get('telemetry:setOptIn')! + handler({}, true) + expect(setOptInMock).toHaveBeenCalledWith('settings', true) + }) + + // ── telemetry:acknowledgeBanner — silent-persist path ──────────────── + + it('routes banner ✕ through persistBannerAcknowledgeWithoutEmitting without invoking setOptIn', () => { + // This is the whole point of the separate channel: the silent-persist + // path MUST NOT reach setOptIn, which would derive a `via` and fire + // `telemetry_opted_in`. The client primitive may unlock `app_opened`, + // but the acknowledge channel itself must not emit an opt-in event. + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: true, + optedIn: null + }) + const handler = handlers.get('telemetry:acknowledgeBanner')! + handler({}) + expect(persistBannerAcknowledgeMock).toHaveBeenCalledTimes(1) + expect(setOptInMock).not.toHaveBeenCalled() + expect(trackMock).not.toHaveBeenCalled() + }) + + it('acknowledgeBanner consumes a consent-mutation token and drops past the cap', () => { + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: true, + optedIn: null + }) + const handler = handlers.get('telemetry:acknowledgeBanner')! + consumeConsentMutationTokenMock.mockReturnValue(false) + handler({}) + expect(persistBannerAcknowledgeMock).not.toHaveBeenCalled() + }) + + // ── telemetry:acknowledgeBanner — state-precondition guard ─────────── + // These tests pin the guard, which rejects any cohort/optedIn + // combination other than (existed=true, optedIn=null). The guard is + // the defense against a compromised renderer silently flipping + // optedIn=true for a user who already resolved consent — a future + // refactor that weakens it must fail here. The guard also runs BEFORE + // consumeConsentMutationToken, so a rejected call must not burn a + // token either. + + it('acknowledgeBanner rejects an existing user who already opted in', () => { + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: true, + optedIn: true + }) + const handler = handlers.get('telemetry:acknowledgeBanner')! + handler({}) + expect(persistBannerAcknowledgeMock).not.toHaveBeenCalled() + expect(consumeConsentMutationTokenMock).not.toHaveBeenCalled() + }) + + it('acknowledgeBanner rejects an existing user who already opted out', () => { + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: true, + optedIn: false + }) + const handler = handlers.get('telemetry:acknowledgeBanner')! + handler({}) + expect(persistBannerAcknowledgeMock).not.toHaveBeenCalled() + expect(consumeConsentMutationTokenMock).not.toHaveBeenCalled() + }) + + it('acknowledgeBanner rejects the new-user cohort regardless of optedIn', () => { + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: false, + optedIn: true + }) + const handler = handlers.get('telemetry:acknowledgeBanner')! + handler({}) + expect(persistBannerAcknowledgeMock).not.toHaveBeenCalled() + expect(consumeConsentMutationTokenMock).not.toHaveBeenCalled() + }) + + it('acknowledgeBanner rejects a missing telemetry block', () => { + registerWith(undefined) + const handler = handlers.get('telemetry:acknowledgeBanner')! + handler({}) + expect(persistBannerAcknowledgeMock).not.toHaveBeenCalled() + expect(consumeConsentMutationTokenMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts new file mode 100644 index 00000000000..1bfddc07d41 --- /dev/null +++ b/src/main/ipc/telemetry.ts @@ -0,0 +1,215 @@ +// IPC surface for the telemetry transport. Four handlers, all renderer- +// facing: one pipe (`telemetry:track`), one consent-mutation +// (`telemetry:setOptIn`), one silent-persist for the banner ✕ path +// (`telemetry:acknowledgeBanner`), and one read-only getter for effective +// consent state (`telemetry:getConsentState`, used by the Privacy pane to +// render env-var blocked-state helper text). Every track call from the +// renderer lands here and funnels into the same `track()` the main- +// originated events go through — the validator is the single enforcement +// point, not this file. +// +// Threat model: the renderer renders attacker-controllable content (agent +// output, MCP responses, file contents, markdown, diff views). An +// XSS-equivalent rendering bug in any of those surfaces gives an attacker +// the ability to invoke `window.api.telemetry*` at will. The handlers +// below are designed to fail closed under that model: +// +// - Strict main-side type narrows. TypeScript types do not survive IPC +// serialization; the renderer can pass anything across the wire, so we +// narrow at the boundary. Non-string `name` or non-object `props` on +// `track` → drop silently. Non-boolean `optedIn` on `setOptIn` → drop. +// - Consent-mutation rate limit. A real user flips the Privacy pane +// toggle a handful of times at most; beyond 5 per session it is either +// a UI bug or a compromised renderer. Drop silently past the cap. +// Applies to `acknowledgeBanner` as well — that path also mutates +// persisted consent, so it lives under the same per-session ceiling. +// +// `via` derivation: the renderer does NOT pass `via` across the wire. That +// design was rejected specifically because a compromised renderer could +// misreport `via`, muddying the one signal we use to distinguish +// first-launch interactions from settings flips. Main derives `via` from +// fields main already owns (`existedBeforeTelemetryRelease`, current +// `optedIn`) before any state mutation. The two product paths map cleanly +// to the two schema values (`first_launch_banner` for the existing-user +// notice's "Turn off" button; `settings` for everything else — new users +// have no first-launch surface, so their opt-outs always come through +// Settings). + +import { ipcMain } from 'electron' +import { consumeConsentMutationToken } from '../telemetry/burst-cap' +import { persistBannerAcknowledgeWithoutEmitting, setOptIn, track } from '../telemetry/client' +import { resolveConsent, type ConsentState } from '../telemetry/consent' +import type { Store } from '../persistence' +import type { EventName, EventProps } from '../../shared/telemetry-events' +import type { OptInVia } from '../../shared/telemetry-events' + +// Module-level store reference, populated by `registerTelemetryHandlers`. +// The handlers need a synchronous read of `settings.telemetry` at call time +// to derive `via` before any mutation runs — threading the store through +// every handler closure is the least-surprising shape for that, and it +// mirrors how other core-handlers accept the store explicitly. +let storeRef: Store | null = null + +/** + * Derive the `via` discriminator for a `telemetry:setOptIn` call from + * main-owned state. Called BEFORE any state mutation so the cohort + opt-in + * snapshot reflects the pre-click world. + * + * Two cases (mirrors the product surfaces defined in telemetry-plan.md): + * - existing-user notice "Turn off" → `'first_launch_banner'` + * (existedBeforeTelemetryRelease=true, optedIn=null, incoming false) + * - any other flip → `'settings'` + * (Privacy pane, post-first-launch mutations, etc.) + * + * New users never reach a `'first_launch_banner'` tag — their cohort marker + * is false and they see no first-launch surface at all (see telemetry-plan.md + * §First-launch experience). Any consent flip they make routes through + * Settings → Privacy and tags as `'settings'`. + * + * Note: the notice's ✕ (silent acknowledge) path does NOT come through this + * function — it routes through `telemetry:acknowledgeBanner` and + * `persistBannerAcknowledgeWithoutEmitting`, which intentionally does not + * emit. Derivation here would tag it as `'first_launch_banner'` and emit + * `telemetry_opted_in`, which the ✕-as-silent-acknowledge semantics + * forbid. + */ +function deriveOptInVia(store: Store, incomingOptedIn: boolean): OptInVia { + const telemetry = store.getSettings().telemetry + const existedBefore = telemetry?.existedBeforeTelemetryRelease === true + const currentOptedIn = telemetry?.optedIn + + // Existing-user cohort, notice still pending. The only surface that calls + // `setOptIn` in this state is the FirstLaunchBanner's "Turn off" click; + // the ✕ path does not route here (it goes through + // `telemetry:acknowledgeBanner`). We additionally narrow on + // `incomingOptedIn === false` as a defensive guard: a compromised + // renderer could otherwise call `telemetrySetOptIn(true)` in this + // pre-notice state and synthesize a spurious + // `telemetry_opted_in { via: 'first_launch_banner' }`, which the + // ✕-as-silent-acknowledge contract forbids. Falling through to + // 'settings' for the true case keeps the forbidden tag unreachable + // from IPC. + if (existedBefore && currentOptedIn === null && incomingOptedIn === false) { + return 'first_launch_banner' + } + + return 'settings' +} + +export function registerTelemetryHandlers(store: Store): void { + storeRef = store + + ipcMain.handle('telemetry:track', (_event, name: unknown, props: unknown): void => { + // Strict input typing: non-string names are dropped at the boundary + // before the validator even sees them. The validator would also drop + // (unknown event name), but the main-side narrow keeps the attack + // surface minimal — a flood of bogus payloads does not exercise the + // Zod parser for no reason. + if (typeof name !== 'string') { + return + } + // `props` may legitimately be omitted; treat `undefined`/`null` as an + // empty object before the validator. Anything else non-object (e.g. + // a string, a number) is a boundary violation. + if (props !== null && props !== undefined && typeof props !== 'object') { + return + } + // The casts to `EventName` / `EventProps` here are + // pass-through only — this file does NOT pretend the renderer's + // name/props are type-safe. The validator inside `track()` is the + // single enforcement point at runtime; these casts only feed the + // typed channel that the validator will re-check. + track(name as EventName, (props ?? {}) as EventProps) + }) + + ipcMain.handle('telemetry:setOptIn', (_event, optedIn: unknown): Promise | void => { + // Strict input typing — renderer can pass anything over IPC. + if (typeof optedIn !== 'boolean') { + return + } + // Check storeRef BEFORE consuming a consent-mutation token. If the store + // isn't ready (pre-registration race, test harness misuse, a future + // refactor), consuming a token here would burn budget for a no-op and + // eventually block legitimate mutations from the same session. + if (!storeRef) { + return + } + // Consent-mutation bucket: ≤5 per session. See `burst-cap.ts`. Does not + // apply to main-originated consent mutations that bypass IPC (none + // today; this is future-proofing rather than a current code path). + if (!consumeConsentMutationToken()) { + return + } + // Read settings BEFORE any state mutation. The derivation must see the + // pre-mutation world so an existing user clicking "Turn off" on the + // notice still presents as (optedIn=null → false) at the moment `via` + // is computed, not (optedIn=false → false) after the write lands. + const via = deriveOptInVia(storeRef, optedIn) + return setOptIn(via, optedIn) + }) + + // Read-only view of the effective consent state. The Privacy pane needs + // this to render the correct helper text when an env var + // (DO_NOT_TRACK / ORCA_TELEMETRY_DISABLED / CI) blocks transmission — + // those variables are main-side process state and the renderer has no + // way to read them directly. No rate limit: this is a synchronous getter + // with no mutation, bounded in work by one `resolveConsent` call. + ipcMain.handle('telemetry:getConsentState', (): ConsentState => { + if (!storeRef) { + // Fail closed — a missing store means we cannot honor the user's + // stored preference, so surface pending_banner rather than a + // misleading 'enabled'. The renderer treats pending_banner like a + // disabled state in the UI. + return { effective: 'pending_banner' } + } + return resolveConsent(storeRef.getSettings()) + }) + + ipcMain.handle('telemetry:acknowledgeBanner', (_event): Promise | void => { + // Banner ✕ — persist `optedIn = true` without emitting a telemetry opt-in + // event. The acknowledge still unlocks `app_opened`, but this outcome + // cannot route through `telemetry:setOptIn` because the derivation above + // would tag it `first_launch_banner` and fire `telemetry_opted_in`. + // + // Check storeRef BEFORE consuming a consent-mutation token, mirroring + // the setOptIn handler's guard above — see that comment for why + // burning a token on a no-op blocks legitimate mutations later in + // the same session. + if (!storeRef) { + return + } + // State-precondition guard: this channel is ONLY valid when the notice + // is pending resolution — i.e. existing-user cohort + // (existedBeforeTelemetryRelease=true) with optedIn still null. Any + // other state is either a UI bug (the notice should not be reachable + // post-resolution) or a compromised renderer trying to silently flip + // optedIn=true after the user already opted out, which would bypass + // the audit signal entirely. `deriveOptInVia` already applies the + // symmetric guard on `telemetry:setOptIn` (it refuses to tag a true + // flip as `first_launch_banner`); this narrows the silent-persist + // attack surface on the acknowledge channel to exactly the state the + // notice contract covers. Must run BEFORE the token consume — same + // "don't burn a token on a no-op" reasoning as the !storeRef guard + // above. + const telemetry = storeRef.getSettings().telemetry + if (telemetry?.existedBeforeTelemetryRelease !== true || telemetry?.optedIn !== null) { + return + } + // This path still goes through `consumeConsentMutationToken` — a + // compromised renderer could otherwise burn through the + // acknowledgeBanner channel to force unbounded settings-file writes, + // which is a CPU/disk amplification vector even without any event + // emission. + if (!consumeConsentMutationToken()) { + return + } + return persistBannerAcknowledgeWithoutEmitting() + }) +} + +// Test-only reset for the module-level store reference. Tests can +// re-register handlers against a fresh mock store without leaking state +// between describes. +export function _resetStoreForTests(): void { + storeRef = null +} diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 70e60f2ef44..d6472413bf3 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -160,6 +160,7 @@ export function mergeWorktree( head: git.head, branch: git.branch, isBare: git.isBare, + ...(git.isSparse === true ? { isSparse: true } : {}), isMainWorktree: git.isMainWorktree, displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path), comment: meta?.comment || '', @@ -171,6 +172,14 @@ export function mergeWorktree( isPinned: meta?.isPinned ?? false, sortOrder: meta?.sortOrder ?? 0, lastActivityAt: meta?.lastActivityAt ?? 0, + ...(meta?.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), + ...(git.isSparse === true + ? { + sparseDirectories: meta?.sparseDirectories, + sparseBaseRef: meta?.sparseBaseRef, + sparsePresetId: meta?.sparsePresetId + } + : {}), // Why: diff comments are persisted on WorktreeMeta (see `WorktreeMeta` in // shared/types) and forwarded verbatim so the renderer store mirrors // on-disk state. `undefined` here means the worktree has no comments yet. diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 548dcc7cead..416206d2687 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -1,6 +1,11 @@ +/* eslint-disable max-lines */ // Why: extracted from worktrees.ts to keep the main IPC module under the // max-lines threshold. Worktree creation helpers (local and remote) live -// here so the IPC dispatch file stays focused on handler wiring. +// here so the IPC dispatch file stays focused on handler wiring. The +// sparse-checkout flow plus the post-create setup-runner wiring pushed +// this file marginally over the per-file limit; matches the +// eslint-disable pattern other files in src/renderer use when a +// cohesive flow would split awkwardly. import type { BrowserWindow } from 'electron' import { join } from 'path' @@ -13,9 +18,10 @@ import type { WorktreeMeta } from '../../shared/types' import { getPRForBranch } from '../github/client' -import { listWorktrees, addWorktree } from '../git/worktree' +import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree' import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo' import { gitExecFileAsync } from '../git/runner' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { createSetupRunnerScript, getEffectiveHooks, shouldRunSetupForCreate } from '../hooks' import { getSshGitProvider } from '../providers/ssh-git-dispatch' @@ -31,6 +37,8 @@ import { areWorktreePathsEqual } from './worktree-logic' import { invalidateAuthorizedRootsCache } from './filesystem-auth' +import { createWorktreeSymlinks } from './worktree-symlinks' +import { normalizeSparseDirectories } from './sparse-checkout-directories' export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void { if (!mainWindow.isDestroyed()) { @@ -38,12 +46,30 @@ export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string } } +// Why (§3.3): two-phase spinner. Main process fires `'fetching'` immediately +// after kicking off `git fetch` and `'creating'` after that fetch resolves +// (or is determined to be cache-fresh). Renderer swaps its spinner label in +// response; fallback is the static "Creating worktree..." label if no event +// arrives (e.g. renderer races destruction of the window). +export function emitCreateWorktreeProgress( + mainWindow: BrowserWindow, + phase: 'fetching' | 'creating' +): void { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('createWorktree:progress', { phase }) + } +} + export async function createRemoteWorktree( args: CreateWorktreeArgs, repo: Repo, store: Store, mainWindow: BrowserWindow ): Promise { + if (args.sparseCheckout) { + throw new Error('Sparse checkout is not supported for remote SSH repos yet.') + } + const provider = getSshGitProvider(repo.connectionId!) as SshGitProvider | undefined if (!provider) { throw new Error(`No git provider for connection "${repo.connectionId}"`) @@ -178,8 +204,16 @@ export async function createRemoteWorktree( } const worktreeId = `${repo.id}::${created.path}` + const now = Date.now() const metaUpdates: Partial = { - lastActivityAt: Date.now(), + lastActivityAt: now, + // Why: grants the new worktree a short grace window at the top of the + // Recent sort. During worktree creation (git fetch + add can take several + // seconds) other worktrees get ambient PTY bumps that would otherwise + // leave the newly-created one below them; the Recent comparator uses + // max(lastActivityAt, createdAt + GRACE_MS) to keep it on top until the + // window elapses. See smart-sort.ts `CREATE_GRACE_MS`. + createdAt: now, ...(shouldSetDisplayName(requestedName, branchName, sanitizedName) ? { displayName: requestedName } : {}) @@ -187,6 +221,12 @@ export async function createRemoteWorktree( const meta = store.setWorktreeMeta(worktreeId, metaUpdates) const worktree = mergeWorktree(repo.id, created, meta) + // Why: `experimentalWorktreeSymlinks` is intentionally not wired up for + // remote (SSH) worktrees. Creating symlinks on the remote host would + // require a new relay method and authorization surface; the feature is + // local-only until that protocol work is in scope. Remote repos with + // `symlinkPaths` configured have them silently ignored here. + notifyWorktreesChanged(mainWindow, repo.id) return { worktree } } @@ -195,13 +235,55 @@ export async function createLocalWorktree( args: CreateWorktreeArgs, repo: Repo, store: Store, - mainWindow: BrowserWindow + mainWindow: BrowserWindow, + runtime?: OrcaRuntimeService ): Promise { const settings = store.getSettings() const username = getGitUsername(repo.path) const requestedName = args.name const sanitizedName = sanitizeWorktreeName(args.name) + + // Why (§3.3): determine the base branch (and therefore the remote we need to + // fetch) FIRST, so the fetch can overlap all pre-create work below. Neither + // of these calls depends on the suffix loop / PR probe / branch-conflict + // resolution, so they are safe to hoist. + const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) + if (!baseBranch) { + // Why: getDefaultBaseRef may return null when none of origin/HEAD, + // origin/main, origin/master, local main, or local master exist. Don't + // fall back to a hardcoded 'origin/main' — passing a non-existent ref to + // `git worktree add` produces an opaque error. Fail here with a clear + // message so the UI can prompt the user to pick a base branch explicitly. + throw new Error( + 'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.' + ) + } + + // Why (§3.3 Lifecycle): fire fetch via the shared 30s-window cache on the + // runtime so repeat creates on the same repo reuse the in-flight promise + // and dispatch probes benefit from the freshness window. Kicked off BEFORE + // the suffix loop / PR probe / path resolution so those operations overlap + // the network round-trip — the `await` right before `addWorktree` is the + // only point that actually requires fetch completion. + // + // Why `runtime` is optional: a handful of legacy IPC test harnesses still + // call createLocalWorktree without the runtime. In that case we fall back + // to the old fire-and-forget behavior (which those tests already expect). + // Production `worktrees.ts` always passes runtime, so the happy path + // always gets the cache. + const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' + const fetchPromise: Promise = runtime + ? runtime.fetchRemoteWithCache(repo.path, remote) + : gitExecFileAsync(['fetch', remote], { cwd: repo.path }) + .then(() => undefined) + .catch(() => undefined) + + // Why: emit a progress event so the renderer dialog can switch its spinner + // label to "Checking for updates..." while the fetch is in flight, then + // "Creating worktree..." after we await it. Renderer falls back to the + // static "Creating worktree..." label if no event arrives. + emitCreateWorktreeProgress(mainWindow, 'fetching') // Why: WSL worktrees live under ~/orca/workspaces inside the WSL // filesystem. Validate against that root, not the Windows workspace dir. // If WSL home lookup fails, keep using the configured workspace root so @@ -288,44 +370,69 @@ export async function createLocalWorktree( ) } - // Determine base branch. - // - // Why: getDefaultBaseRef may return null when none of origin/HEAD, - // origin/main, origin/master, local main, or local master exist. In that - // case we must not fall back to a hardcoded 'origin/main' — passing a - // non-existent ref to `git worktree add` produces an opaque error. Fail - // here with a clear message so the UI can prompt the user to pick a base - // branch explicitly. - const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) - if (!baseBranch) { - throw new Error( - 'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.' - ) - } - const setupScript = getEffectiveHooks(repo)?.scripts.setup // Why: `ask` is a pre-create choice gate, not a post-create side effect. // Resolve it before mutating git state so missing UI input cannot strand - // a real worktree on disk while the renderer reports "create failed". - const shouldLaunchSetup = setupScript ? shouldRunSetupForCreate(repo, args.setupDecision) : false + // a real worktree on disk while the renderer reports "create failed". The + // actual run/skip decision is recomputed after the worktree exists against + // the worktree-bound setup script. + const primarySetupScript = getEffectiveHooks(repo)?.scripts.setup + if (primarySetupScript) { + shouldRunSetupForCreate(repo, args.setupDecision) + } + const sparseDirectories = args.sparseCheckout + ? normalizeSparseDirectories(args.sparseCheckout.directories) + : [] + if (args.sparseCheckout && sparseDirectories.length === 0) { + throw new Error('Sparse checkout requires at least one repo-relative directory.') + } + let sparsePresetId: string | undefined + if (args.sparseCheckout?.presetId) { + const preset = store + .getSparsePresets(repo.id) + .find((entry) => entry.id === args.sparseCheckout?.presetId) + if (preset?.repoId === repo.id) { + try { + const presetDirectories = normalizeSparseDirectories(preset.directories) + // Why: use Set-based comparison so directory order does not affect + // attribution — matches the renderer's sparseDirectoriesMatch logic. + const presetSet = new Set(presetDirectories) + const directoriesMatch = + presetDirectories.length === sparseDirectories.length && + sparseDirectories.every((entry) => presetSet.has(entry)) + sparsePresetId = directoriesMatch ? preset.id : undefined + } catch { + // Why: corrupt preset data should not block creation or falsely label the new worktree. + } + } + } - // Why: `git fetch` previously blocked worktree creation for 1–5s on every - // click, even though the fetch result isn't actually required — the - // subsequent `git worktree add` uses whatever local ref `baseBranch` points - // at. Kicking fetch off in parallel lets the worktree be created off the - // last-known tip while the fetch completes in the background; the next - // user action (pull, diff, PR create) will see the refreshed remote state. - const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' - void gitExecFileAsync(['fetch', remote], { cwd: repo.path }).catch(() => { - // Fetch is best-effort — don't block worktree creation if offline - }) + // Why (§3.3): gate on the fetch we fired at the top of this function. + // Pre-create probes (branch-conflict, PR probe, path resolution, sparse + // prep) already ran concurrently with the fetch; in the warm case this + // await is a no-op. In the cold case the spinner has already shown + // "Checking for updates..." so the user sees the wait is legible. + // + // `fetchRemoteWithCache` never rejects (log-and-proceed on offline + // failure), so the bare `await` does not need a try/catch here. + await fetchPromise + emitCreateWorktreeProgress(mainWindow, 'creating') - await addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - ) + await (sparseDirectories.length > 0 + ? addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ) + : addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + )) // Re-list to get the freshly created worktree info const gitWorktrees = await listWorktrees(repo.path) @@ -335,13 +442,24 @@ export async function createLocalWorktree( } const worktreeId = `${repo.id}::${created.path}` + const now = Date.now() const metaUpdates: Partial = { // Stamp activity so the worktree sorts into its final position // immediately — prevents scroll-to-reveal racing with a later // bumpWorktreeActivity that would re-sort the list. - lastActivityAt: Date.now(), + lastActivityAt: now, + // See createRemoteWorktree above: createdAt protects the newly-created + // worktree from ambient PTY bumps in other worktrees for CREATE_GRACE_MS. + createdAt: now, ...(shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName) ? { displayName: effectiveRequestedName } + : {}), + ...(sparseDirectories.length > 0 + ? { + sparseDirectories, + sparseBaseRef: baseBranch, + sparsePresetId + } : {}) } const meta = store.setWorktreeMeta(worktreeId, metaUpdates) @@ -353,7 +471,38 @@ export async function createLocalWorktree( // adds 100ms+ to every create. invalidateAuthorizedRootsCache() + // Why: create user-configured symlinks from the primary checkout into the + // new worktree before any setup script runs, so scripts that reuse shared + // state (e.g. `node_modules`, `.env`) see the links already in place. + // Gated on the experimental flag so disabling the feature globally skips + // the work even when a repo still has paths configured. + if (settings.experimentalWorktreeSymlinks && repo.symlinkPaths && repo.symlinkPaths.length > 0) { + await createWorktreeSymlinks(repo.path, created.path, repo.symlinkPaths) + } + + // Why: the worktree's own `orca.yaml` (at the tip of the base branch) is + // authoritative for what runs post-creation. The repo-level trust already + // granted by the user in the pre-create flow covers execution of that + // script; we intentionally do not re-gate on content equality with the + // primary checkout's preview, because benign divergence (whitespace, + // comments, or any setup-script edit that has landed on the base branch + // but not yet been pulled into the primary checkout) was silently + // disabling setup with no UI signal. See #1280 for the original gate and + // the regression this replaced. let setup: CreateWorktreeResult['setup'] + const setupScript = getEffectiveHooks(repo, worktreePath)?.scripts.setup + let shouldLaunchSetup = false + if (setupScript) { + try { + shouldLaunchSetup = shouldRunSetupForCreate(repo, args.setupDecision) + } catch (error) { + // Why: if the target branch introduces setup hooks that the primary + // checkout did not expose, the renderer may not have collected an ask + // decision. The worktree already exists, so skip setup instead of + // turning successful git creation into an IPC failure. + console.warn(`[hooks] setup hook skipped for ${worktreePath}:`, error) + } + } if (setupScript && shouldLaunchSetup) { try { // Why: setup now runs in a visible terminal owned by the renderer so users diff --git a/src/main/ipc/worktree-symlinks.test.ts b/src/main/ipc/worktree-symlinks.test.ts new file mode 100644 index 00000000000..155655c159a --- /dev/null +++ b/src/main/ipc/worktree-symlinks.test.ts @@ -0,0 +1,227 @@ +import { + mkdtempSync, + mkdirSync, + writeFileSync, + statSync, + lstatSync, + readlinkSync, + rmSync, + symlinkSync, + existsSync +} from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createWorktreeSymlinks, removeWorktreeSymlinks } from './worktree-symlinks' + +describe('createWorktreeSymlinks', () => { + let root: string + let primary: string + let worktree: string + let warn: ReturnType + let error: ReturnType + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-symlinks-')) + primary = join(root, 'primary') + worktree = join(root, 'worktree') + mkdirSync(primary, { recursive: true }) + mkdirSync(worktree, { recursive: true }) + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + error = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + warn.mockRestore() + error.mockRestore() + rmSync(root, { recursive: true, force: true }) + }) + + it('symlinks a file from primary into the worktree at the same relative path', async () => { + writeFileSync(join(primary, '.env'), 'SECRET=1\n') + await createWorktreeSymlinks(primary, worktree, ['.env']) + + const linkStat = lstatSync(join(worktree, '.env')) + expect(linkStat.isSymbolicLink()).toBe(true) + expect(readlinkSync(join(worktree, '.env'))).toBe(join(primary, '.env')) + // Following the link yields the primary's contents. + expect(statSync(join(worktree, '.env')).isFile()).toBe(true) + }) + + it('symlinks a directory from primary into the worktree', async () => { + mkdirSync(join(primary, 'node_modules')) + writeFileSync(join(primary, 'node_modules', 'marker'), 'installed') + await createWorktreeSymlinks(primary, worktree, ['node_modules']) + + expect(lstatSync(join(worktree, 'node_modules')).isSymbolicLink()).toBe(true) + expect(statSync(join(worktree, 'node_modules', 'marker')).isFile()).toBe(true) + }) + + it('creates parent directories lazily for nested paths', async () => { + mkdirSync(join(primary, 'apps', 'web'), { recursive: true }) + writeFileSync(join(primary, 'apps', 'web', '.env'), 'X=1\n') + await createWorktreeSymlinks(primary, worktree, ['apps/web/.env']) + + expect(lstatSync(join(worktree, 'apps', 'web', '.env')).isSymbolicLink()).toBe(true) + }) + + it('skips entries whose source is missing in the primary checkout', async () => { + await createWorktreeSymlinks(primary, worktree, ['node_modules']) + // No link created, no throw. + expect(() => lstatSync(join(worktree, 'node_modules'))).toThrow() + expect(error).not.toHaveBeenCalled() + }) + + it('preserves a pre-existing target in the worktree (no clobber)', async () => { + writeFileSync(join(primary, '.env'), 'FROM_PRIMARY=1\n') + writeFileSync(join(worktree, '.env'), 'FROM_WORKTREE=1\n') + + await createWorktreeSymlinks(primary, worktree, ['.env']) + + // The pre-existing regular file stays; no symlink was created. + expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(false) + expect(statSync(join(worktree, '.env')).isFile()).toBe(true) + }) + + it('does not escape the primary checkout via a leading-slash path', async () => { + // Why: the helper strips leading slashes (so `/etc/passwd` becomes the + // relative `etc/passwd`). No file is created outside the worktree, and the + // resolved source — which falls inside `primary/etc/passwd` — is missing, + // so the entry is silently skipped rather than linking to `/etc/passwd`. + await createWorktreeSymlinks(primary, worktree, ['/etc/passwd']) + + expect(() => lstatSync(join(worktree, 'etc', 'passwd'))).toThrow() + expect(error).not.toHaveBeenCalled() + }) + + it('rejects parent-directory traversal', async () => { + await createWorktreeSymlinks(primary, worktree, ['../secrets']) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('[worktree-symlinks] Skipping unsafe path "../secrets"') + ) + }) + + it('rejects nested traversal via ..', async () => { + await createWorktreeSymlinks(primary, worktree, ['safe/../../escape']) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('[worktree-symlinks] Skipping unsafe path "safe/../../escape"') + ) + }) + + it('rejects traversal using backslash separators (Windows form)', async () => { + // Why: users configuring paths on Windows (or pasting a mixed-separator + // value) could bypass a POSIX-only split. The guard normalizes across + // `/` and `\` so `..\escape` and `foo\..\..\escape` both get rejected. + await createWorktreeSymlinks(primary, worktree, ['..\\escape']) + await createWorktreeSymlinks(primary, worktree, ['foo\\..\\..\\escape']) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('[worktree-symlinks] Skipping unsafe path "..\\escape"') + ) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('[worktree-symlinks] Skipping unsafe path "foo\\..\\..\\escape"') + ) + }) + + it('strips a leading slash then treats the remainder as a relative path', async () => { + writeFileSync(join(primary, '.env'), 'X=1\n') + await createWorktreeSymlinks(primary, worktree, ['/.env']) + + // Leading slash is stripped in the helper; the remaining `.env` is a valid relative path. + expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(true) + expect(warn).not.toHaveBeenCalled() + }) + + it('skips empty and whitespace-only entries', async () => { + // The helper logs an "unsafe path" warn for these; nothing gets linked. + await createWorktreeSymlinks(primary, worktree, ['', ' ']) + expect(error).not.toHaveBeenCalled() + }) + + it('continues processing later entries after one fails', async () => { + writeFileSync(join(primary, '.env'), 'X=1\n') + writeFileSync(join(primary, 'config.json'), '{}') + + await createWorktreeSymlinks(primary, worktree, [ + '../escape', // rejected + 'missing-source', // no source, skipped + '.env', // succeeds + 'config.json' // succeeds + ]) + + expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(true) + expect(lstatSync(join(worktree, 'config.json')).isSymbolicLink()).toBe(true) + }) + + it('is a no-op for an empty paths list', async () => { + await createWorktreeSymlinks(primary, worktree, []) + expect(warn).not.toHaveBeenCalled() + expect(error).not.toHaveBeenCalled() + }) +}) + +describe('removeWorktreeSymlinks', () => { + let root: string + let primary: string + let worktree: string + let error: ReturnType + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-unlink-')) + primary = join(root, 'primary') + worktree = join(root, 'worktree') + mkdirSync(primary, { recursive: true }) + mkdirSync(worktree, { recursive: true }) + error = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + error.mockRestore() + rmSync(root, { recursive: true, force: true }) + }) + + it('unlinks configured symlinks from the worktree', async () => { + writeFileSync(join(primary, '.env'), 'SECRET=1\n') + mkdirSync(join(primary, 'node_modules')) + symlinkSync(join(primary, '.env'), join(worktree, '.env'), 'file') + symlinkSync(join(primary, 'node_modules'), join(worktree, 'node_modules'), 'dir') + + await removeWorktreeSymlinks(worktree, ['.env', 'node_modules']) + + expect(existsSync(join(worktree, '.env'))).toBe(false) + expect(existsSync(join(worktree, 'node_modules'))).toBe(false) + // Source is untouched. + expect(statSync(join(primary, '.env')).isFile()).toBe(true) + expect(statSync(join(primary, 'node_modules')).isDirectory()).toBe(true) + }) + + it('leaves a regular file at the configured path alone', async () => { + // Why: a user who created a real file at `.env` (instead of symlinking) + // must not lose it just because `.env` is in the configured list. + writeFileSync(join(worktree, '.env'), 'USER_WROTE_THIS=1\n') + + await removeWorktreeSymlinks(worktree, ['.env']) + + expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(false) + expect(statSync(join(worktree, '.env')).isFile()).toBe(true) + }) + + it('ignores missing entries', async () => { + await removeWorktreeSymlinks(worktree, ['.env', 'node_modules']) + expect(error).not.toHaveBeenCalled() + }) + + it('rejects unsafe paths without touching the filesystem', async () => { + // Parent-dir traversal is silently skipped; no unlink attempted. + writeFileSync(join(root, 'outside-file'), 'DO_NOT_DELETE') + await removeWorktreeSymlinks(worktree, ['../outside-file']) + expect(existsSync(join(root, 'outside-file'))).toBe(true) + }) + + it('is a no-op for an empty paths list', async () => { + await removeWorktreeSymlinks(worktree, []) + expect(error).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/worktree-symlinks.ts b/src/main/ipc/worktree-symlinks.ts new file mode 100644 index 00000000000..d08ee8d536f --- /dev/null +++ b/src/main/ipc/worktree-symlinks.ts @@ -0,0 +1,111 @@ +import { symlink, mkdir, stat, lstat, unlink } from 'fs/promises' +import { dirname, isAbsolute, resolve } from 'path' + +/** Create filesystem symlinks from the primary checkout into a freshly-created + * worktree for each configured path. Failures on individual paths are logged + * and skipped so a missing/stale entry never blocks worktree creation. + * + * Each entry is interpreted relative to `primaryPath` and placed at the same + * relative location inside `worktreePath`. Nested paths (e.g. + * `apps/web/.env`) are supported — parent directories are created lazily. */ +export async function createWorktreeSymlinks( + primaryPath: string, + worktreePath: string, + paths: readonly string[] +): Promise { + for (const rawPath of paths) { + // Why: strip leading separators (both `/` and `\`) before the guard so + // Windows-style input like `\foo` is normalized the same way POSIX `/foo` + // is, and the traversal check below sees the already-relative form. + const rel = rawPath.trim().replace(/^[\\/]+/, '') + // Why: split on both separators so a Windows-authored `..\escape` is + // rejected the same way POSIX `../escape` is. `path.isAbsolute` catches + // drive-letter absolutes (`C:\...`); the split catches relative + // backslash traversal that `.split('/')` would otherwise miss. + if (!rel || isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) { + // Users can only configure paths relative to the repo root; absolute + // paths and `..` traversal are not supported. + console.warn(`[worktree-symlinks] Skipping unsafe path "${rawPath}"`) + continue + } + + const source = resolve(primaryPath, rel) + const target = resolve(worktreePath, rel) + + let sourceIsDirectory = false + try { + const s = await stat(source) + sourceIsDirectory = s.isDirectory() + } catch { + // Source doesn't exist in primary checkout — nothing to link to. This is + // a common case for fresh clones where `node_modules` hasn't been + // installed yet; silently skip rather than leaving a dangling symlink. + continue + } + + try { + // Why: if a file/dir already exists at the target location (e.g. + // git-tracked sibling with the same name), leave it alone rather than + // clobber something the user didn't mean to replace. Use `lstat` so a + // pre-existing symlink (including a broken one whose source has moved) + // is detected and skipped, rather than falling through to `symlink()` + // and failing with EEXIST. + await lstat(target) + continue + } catch { + // Target does not exist — proceed with symlink creation. + } + + try { + await mkdir(dirname(target), { recursive: true }) + // Why: Windows requires an explicit `type` ('dir' vs 'file' vs + // 'junction') for `fs.symlink`. On POSIX the argument is ignored, so + // passing it unconditionally is safe and removes a Windows-only + // failure mode when Node can't auto-detect from the source. + await symlink(source, target, sourceIsDirectory ? 'dir' : 'file') + } catch (error) { + console.error( + `[worktree-symlinks] Failed to symlink "${rel}" (${source} -> ${target}):`, + error + ) + } + } +} + +/** Remove previously-created symlinks from a worktree before deletion. + * + * Why: `git worktree remove` refuses to delete a worktree that has modified + * or untracked files. A symlink pointing at the primary's `node_modules` + * looks "untracked" to git, so users would hit "It has changed files. Use + * Force Delete" on every deletion once they've configured this feature. + * Unlink the known symlinks up front so the non-force path keeps working. + * + * Safety: only removes entries that are actually symbolic links. A regular + * file or directory at the same path is left alone — we never want to clobber + * something the user created that happens to share a name with a configured + * entry. Missing entries (ENOENT) are silently ignored. */ +export async function removeWorktreeSymlinks( + worktreePath: string, + paths: readonly string[] +): Promise { + for (const rawPath of paths) { + const rel = rawPath.trim().replace(/^[\\/]+/, '') + if (!rel || isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) { + continue + } + const target = resolve(worktreePath, rel) + try { + const s = await lstat(target) + if (!s.isSymbolicLink()) { + continue + } + } catch { + continue + } + try { + await unlink(target) + } catch (error) { + console.error(`[worktree-symlinks] Failed to unlink "${rel}" (${target}):`, error) + } + } +} diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 3c8f6a79164..2ce4d4accb4 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -181,7 +181,14 @@ describe('registerWorktreeHandlers – Windows path handling', () => { ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\improve-dashboard') listWorktreesMock.mockResolvedValue([]) - registerWorktreeHandlers(mainWindow as never, store as never) + // Why: createLocalWorktree routes `git fetch` through + // `runtime.fetchRemoteWithCache` (§3.3 Lifecycle). Stub it for path tests. + const runtimeStub = { + fetchRemoteWithCache: async () => { + /* noop */ + } + } + registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) it('accepts a newly created Windows worktree when git lists the same path with different separators', async () => { diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index f02e8c75624..e2e055f0866 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -6,6 +6,7 @@ const { removeHandlerMock, listWorktreesMock, addWorktreeMock, + addSparseWorktreeMock, removeWorktreeMock, getGitUsernameMock, getDefaultBaseRefMock, @@ -26,6 +27,7 @@ const { removeHandlerMock: vi.fn(), listWorktreesMock: vi.fn(), addWorktreeMock: vi.fn(), + addSparseWorktreeMock: vi.fn(), removeWorktreeMock: vi.fn(), getGitUsernameMock: vi.fn(), getDefaultBaseRefMock: vi.fn(), @@ -53,6 +55,7 @@ vi.mock('electron', () => ({ vi.mock('../git/worktree', () => ({ listWorktrees: listWorktreesMock, addWorktree: addWorktreeMock, + addSparseWorktree: addSparseWorktreeMock, removeWorktree: removeWorktreeMock })) @@ -98,6 +101,19 @@ vi.mock('../terminal-history', () => ({ deleteWorktreeHistoryDir: deleteWorktreeHistoryDirMock })) +const { killAllProcessesForWorktreeMock, getLocalPtyProviderMock } = vi.hoisted(() => ({ + killAllProcessesForWorktreeMock: vi.fn(), + getLocalPtyProviderMock: vi.fn() +})) + +vi.mock('../runtime/worktree-teardown', () => ({ + killAllProcessesForWorktree: killAllProcessesForWorktreeMock +})) + +vi.mock('./pty', () => ({ + getLocalPtyProvider: getLocalPtyProviderMock +})) + import { registerWorktreeHandlers } from './worktrees' type HandlerMap = Record unknown> @@ -113,6 +129,7 @@ describe('registerWorktreeHandlers', () => { const store = { getRepos: vi.fn(), getRepo: vi.fn(), + getSparsePresets: vi.fn(), getSettings: vi.fn(), getWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), @@ -125,6 +142,7 @@ describe('registerWorktreeHandlers', () => { removeHandlerMock, listWorktreesMock, addWorktreeMock, + addSparseWorktreeMock, removeWorktreeMock, getGitUsernameMock, getDefaultBaseRefMock, @@ -143,13 +161,22 @@ describe('registerWorktreeHandlers', () => { mainWindow.webContents.send, store.getRepos, store.getRepo, + store.getSparsePresets, store.getSettings, store.getWorktreeMeta, store.setWorktreeMeta, - store.removeWorktreeMeta + store.removeWorktreeMeta, + killAllProcessesForWorktreeMock, + getLocalPtyProviderMock ]) { m.mockReset() } + killAllProcessesForWorktreeMock.mockResolvedValue({ + runtimeStopped: 0, + providerStopped: 0, + registryStopped: 0 + }) + getLocalPtyProviderMock.mockReturnValue({} as never) for (const key of Object.keys(handlers)) { delete handlers[key] @@ -168,6 +195,7 @@ describe('registerWorktreeHandlers', () => { } store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null }) + store.getSparsePresets.mockReturnValue([]) store.getSettings.mockReturnValue({ branchPrefix: 'none', nestWorkspaces: false, @@ -220,7 +248,16 @@ describe('registerWorktreeHandlers', () => { ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath) listWorktreesMock.mockResolvedValue([]) - registerWorktreeHandlers(mainWindow as never, store as never) + // Why: createLocalWorktree routes `git fetch` through + // `runtime.fetchRemoteWithCache` (§3.3 Lifecycle). A minimal stub + // keeps these tests focused on create-flow semantics; the full + // cache behavior is covered by fetch-remote-cache.test.ts. + const runtimeStub = { + fetchRemoteWithCache: async () => { + /* noop — fetch mocked at gitExecFileAsync level via gitExecFileAsyncMock */ + } + } + registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => { @@ -341,6 +378,136 @@ describe('registerWorktreeHandlers', () => { expect(listWorktreesMock).not.toHaveBeenCalled() }) + it('stamps lastActivityAt on first discovery so newly-added worktrees sort to the top of Recent', async () => { + // Why: a worktree that exists on disk but has no persisted WorktreeMeta + // (e.g. a folder repo just added, or a pre-existing worktree in a + // newly-added git repo) would otherwise fall back to `lastActivityAt: 0` + // and rank dead last in the Recent sort. + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/discovered-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue(undefined) + const stampedMeta = { lastActivityAt: 1_700_000_000_000 } + store.setWorktreeMeta.mockReturnValue(stampedMeta) + + const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { + id: string + lastActivityAt: number + }[] + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/discovered-wt', + expect.objectContaining({ lastActivityAt: expect.any(Number) }) + ) + expect(listed[0]).toMatchObject({ + id: 'repo-1::/workspace/discovered-wt', + lastActivityAt: 1_700_000_000_000 + }) + }) + + it('does not re-stamp lastActivityAt when a worktree already has persisted meta', async () => { + // Why: only the *first* discovery should stamp. Re-stamping on every list + // would overwrite real activity and reshuffle the sidebar on refresh. + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/existing-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue({ + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 42 + }) + + const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { + id: string + lastActivityAt: number + }[] + + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(listed[0].lastActivityAt).toBe(42) + }) + + it('stamps lastActivityAt on first discovery for folder-mode repos', async () => { + // Why: folder repos produce a synthetic worktree that flows through the + // same list path. Without the stamp, adding a folder puts its card at the + // bottom of Recent even though the user just added it. + store.getRepos.mockReturnValue([ + { + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + } + ]) + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + store.getWorktreeMeta.mockReturnValue(undefined) + store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 }) + + await handlers['worktrees:list'](null, { repoId: 'repo-1' }) + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/folder', + expect.objectContaining({ lastActivityAt: expect.any(Number) }) + ) + }) + + it('stamps lastActivityAt on first discovery via worktrees:listAll', async () => { + // Why: the stamping logic lives in both worktrees:list and worktrees:listAll. + // Without a dedicated test, a regression in the listAll loop would silently + // bury newly-discovered worktrees from the multi-repo sidebar view. + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/discovered-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue(undefined) + store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 }) + + const listed = (await handlers['worktrees:listAll'](null, undefined)) as { + id: string + lastActivityAt: number + }[] + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/discovered-wt', + expect.objectContaining({ lastActivityAt: expect.any(Number) }) + ) + expect(listed[0]).toMatchObject({ + id: 'repo-1::/workspace/discovered-wt', + lastActivityAt: 1_700_000_000_000 + }) + }) + it('skips past a suffix that already belongs to a PR after an initial branch conflict', async () => { // Why: `gh pr list` is network-bound and previously fired on every single // create, adding 1–3s to the happy path. We now only probe PR conflicts @@ -471,6 +638,206 @@ describe('registerWorktreeHandlers', () => { ) }) + it('launches setup even when primary and worktree orca.yaml scripts diverge', async () => { + // Why: regression for a silent skip introduced by the #1280 content-equality + // gate. Benign divergence (whitespace, comments, or any setup edit that + // landed on the base branch but not yet in the primary checkout) must not + // disable setup — repo-level trust already gates execution. + listWorktreesMock.mockResolvedValue(createdWorktreeList) + getEffectiveHooksMock.mockImplementation((_repo, worktreePath?: string) => ({ + scripts: { + setup: worktreePath ? 'pnpm worktree:setup # worktree' : 'pnpm worktree:setup' + } + })) + shouldRunSetupForCreateMock.mockReturnValue(true) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + setupDecision: 'run' + }) + + expect(createSetupRunnerScriptMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'repo-1' }), + '/workspace/improve-dashboard', + 'pnpm worktree:setup # worktree' + ) + expect(result).toEqual( + expect.objectContaining({ + setup: expect.objectContaining({ + runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh' + }) + }) + ) + }) + + it('creates a sparse worktree and persists its sparse metadata', async () => { + listWorktreesMock.mockResolvedValue([ + { + ...createdWorktreeList[0], + isSparse: true + } + ]) + store.setWorktreeMeta.mockReturnValue({ + sparseDirectories: ['packages/web', 'apps/api'], + sparseBaseRef: 'origin/main', + sparsePresetId: 'preset-1' + }) + store.getSparsePresets.mockReturnValue([ + { + id: 'preset-1', + repoId: 'repo-1', + name: 'Frontend and API', + directories: ['packages/web', 'apps/api'], + createdAt: 1, + updatedAt: 1 + } + ]) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + sparseCheckout: { + directories: [' packages/web ', 'apps\\api\\', 'packages/web/'], + presetId: 'preset-1' + } + }) + + expect(addWorktreeMock).not.toHaveBeenCalled() + expect(addSparseWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/improve-dashboard', + 'improve-dashboard', + ['packages/web', 'apps/api'], + 'origin/main', + false + ) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/improve-dashboard', + expect.objectContaining({ + sparseDirectories: ['packages/web', 'apps/api'], + sparseBaseRef: 'origin/main', + sparsePresetId: 'preset-1' + }) + ) + expect(result).toEqual({ + worktree: expect.objectContaining({ + repoId: 'repo-1', + path: '/workspace/improve-dashboard', + sparseDirectories: ['packages/web', 'apps/api'], + sparseBaseRef: 'origin/main', + sparsePresetId: 'preset-1' + }) + }) + }) + + it('clears sparse preset attribution when the preset id does not belong to the repo', async () => { + listWorktreesMock.mockResolvedValue([ + { + ...createdWorktreeList[0], + isSparse: true + } + ]) + store.getSparsePresets.mockReturnValue([ + { + id: 'preset-2', + repoId: 'repo-1', + name: 'Other preset', + directories: ['packages/web'], + createdAt: 1, + updatedAt: 1 + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + sparseCheckout: { + directories: ['packages/web'], + presetId: 'preset-1' + } + }) + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/improve-dashboard', + expect.objectContaining({ + sparseDirectories: ['packages/web'], + sparseBaseRef: 'origin/main', + sparsePresetId: undefined + }) + ) + }) + + it('clears sparse preset attribution when normalized directories do not match', async () => { + listWorktreesMock.mockResolvedValue([ + { + ...createdWorktreeList[0], + isSparse: true + } + ]) + store.getSparsePresets.mockReturnValue([ + { + id: 'preset-1', + repoId: 'repo-1', + name: 'Frontend and API', + directories: ['packages/web', 'apps/api'], + createdAt: 1, + updatedAt: 1 + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + sparseCheckout: { + directories: ['packages/web'], + presetId: 'preset-1' + } + }) + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/improve-dashboard', + expect.objectContaining({ + sparseDirectories: ['packages/web'], + sparseBaseRef: 'origin/main', + sparsePresetId: undefined + }) + ) + }) + + it('rejects sparse checkout directories that traverse above the repo root', async () => { + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + sparseCheckout: { + directories: ['packages/web', '../secrets'] + } + }) + ).rejects.toThrow('Sparse checkout directories must be repo-relative paths.') + + expect(addSparseWorktreeMock).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it.each(['/Users/me/repo/packages/web', 'C:\\repo\\packages\\web', '\\\\server\\share\\repo'])( + 'rejects absolute sparse checkout directory before normalization: %s', + async (directory) => { + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + sparseCheckout: { + directories: ['packages/web', directory] + } + }) + ).rejects.toThrow('Sparse checkout directories must be repo-relative paths.') + + expect(addSparseWorktreeMock).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + } + ) + it('still returns the created worktree when setup runner generation fails', async () => { listWorktreesMock.mockResolvedValue(createdWorktreeList) getEffectiveHooksMock.mockReturnValue({ @@ -524,6 +891,109 @@ describe('registerWorktreeHandlers', () => { }) }) + it('runs the archive hook on remove when skipArchive is not set', async () => { + listWorktreesMock.mockResolvedValue([]) + removeWorktreeMock.mockResolvedValue(undefined) + getEffectiveHooksMock.mockReturnValue({ + scripts: { + archive: 'echo archived' + } + }) + runHookMock.mockResolvedValue({ success: true, output: '' }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(runHookMock).toHaveBeenCalledWith( + 'archive', + '/workspace/feature-wt', + expect.objectContaining({ id: 'repo-1' }) + ) + expect(removeWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/feature-wt', + false + ) + }) + + it('skips the archive hook on remove when skipArchive is true', async () => { + listWorktreesMock.mockResolvedValue([]) + removeWorktreeMock.mockResolvedValue(undefined) + getEffectiveHooksMock.mockReturnValue({ + scripts: { + archive: 'echo archived' + } + }) + runHookMock.mockResolvedValue({ success: true, output: '' }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + skipArchive: true + }) + + expect(runHookMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/feature-wt', + false + ) + }) + + it('IPC-initiated delete kills PTYs BEFORE git-level removal (design §4.3)', async () => { + listWorktreesMock.mockResolvedValue([]) + getEffectiveHooksMock.mockReturnValue(null) + const callOrder: string[] = [] + killAllProcessesForWorktreeMock.mockImplementation(async () => { + callOrder.push('kill') + return { runtimeStopped: 1, providerStopped: 0, registryStopped: 0 } + }) + removeWorktreeMock.mockImplementation(async () => { + callOrder.push('git') + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith( + 'repo-1::/workspace/feature-wt', + expect.objectContaining({ + localProvider: expect.anything() + }) + ) + expect(removeWorktreeMock).toHaveBeenCalled() + expect(callOrder).toEqual(['kill', 'git']) + }) + + it('skips the PTY teardown for SSH-backed repos (design §6 out-of-scope)', async () => { + // Why: SSH-backed PTYs live on the remote host and are handled by the + // remote provider's own teardown. The local-host helper must not run for + // SSH repos, because it would sweep registry entries for other worktrees. + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: null + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + + // The test can't easily mock the SSH provider without more plumbing — the + // call will throw about 'no git provider for connection'. What matters + // here is that the kill helper was NOT called for the SSH branch. + await ( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-ssh::/remote/feature-wt' + }) as Promise + ).catch(() => {}) + + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + }) + it('rejects ask-policy creates before mutating git state when setup decision is missing', async () => { getEffectiveHooksMock.mockReturnValue({ scripts: { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index af17e708f9a..1d82f5e505e 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -34,8 +34,47 @@ import { notifyWorktreesChanged } from './worktree-remote' import { rebuildAuthorizedRootsCache, ensureAuthorizedRootsCache } from './filesystem-auth' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { killAllProcessesForWorktree } from '../runtime/worktree-teardown' +import { getLocalPtyProvider } from './pty' +import { removeWorktreeSymlinks } from './worktree-symlinks' +import { track } from '../telemetry/client' +import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' -export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store): void { +// Why: worktrees discovered on disk (not created via Orca's UI) have no +// persisted WorktreeMeta, so mergeWorktree falls back to `lastActivityAt: 0`. +// That makes them sort to the bottom of "Recent" even though the user just +// added the repo / folder. Stamp discovery time the first time we see a +// worktree so its very existence counts as a recency signal. Subsequent +// list calls find the persisted meta and skip the stamp. +function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string): WorktreeMeta { + const existing = store.getWorktreeMeta(worktreeId) + if (existing) { + return existing + } + return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() }) +} + +const loggedUnavailableSshGitProviders = new Set() +const loggedWorktreeListFailures = new Set() + +function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { + if (keySet.has(key)) { + return + } + keySet.add(key) + if (error) { + console.warn(message, error) + } else { + console.warn(message) + } +} + +export function registerWorktreeHandlers( + mainWindow: BrowserWindow, + store: Store, + runtime: OrcaRuntimeService +): void { // Remove any previously registered handlers so we can re-register them // (e.g. when macOS re-activates the app and creates a new window). ipcMain.removeHandler('worktrees:listAll') @@ -68,18 +107,31 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store } else if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) if (!provider) { + warnOnce( + loggedUnavailableSshGitProviders, + `${repo.connectionId}:${repo.id}`, + `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` + ) return [] } + loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) gitWorktrees = await provider.listWorktrees(repo.path) } else { gitWorktrees = await listRepoWorktrees(repo) } + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) return gitWorktrees.map((gw) => { const worktreeId = `${repo.id}::${gw.path}` - const meta = store.getWorktreeMeta(worktreeId) + const meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId) return mergeWorktree(repo.id, gw, meta, repo.displayName) }) - } catch { + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) return [] } }) @@ -98,29 +150,46 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store return [] } - let gitWorktrees - if (isFolderRepo(repo)) { - gitWorktrees = [createFolderWorktree(repo)] - } else if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - // Why: when SSH is disconnected the provider is null. Return [] so the - // renderer's fetchWorktrees guard (`worktrees.length === 0 && current.length > 0`) - // preserves its cached worktree list. This avoids a console error on every - // fetchAllWorktrees cycle while the connection is being (re-)established — - // worktrees will be properly populated when the SSH `connected` event fires - // and triggers a re-fetch. - if (!provider) { - return [] + try { + let gitWorktrees + if (isFolderRepo(repo)) { + gitWorktrees = [createFolderWorktree(repo)] + } else if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + // Why: when SSH is disconnected the provider is null. Return [] so the + // renderer's fetchWorktrees guard (`worktrees.length === 0 && current.length > 0`) + // preserves its cached worktree list. This avoids a console error on every + // fetchAllWorktrees cycle while the connection is being (re-)established — + // worktrees will be properly populated when the SSH `connected` event fires + // and triggers a re-fetch. + if (!provider) { + warnOnce( + loggedUnavailableSshGitProviders, + `${repo.connectionId}:${repo.id}`, + `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` + ) + return [] + } + loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) + gitWorktrees = await provider.listWorktrees(repo.path) + } else { + gitWorktrees = await listRepoWorktrees(repo) } - gitWorktrees = await provider.listWorktrees(repo.path) - } else { - gitWorktrees = await listRepoWorktrees(repo) + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + return gitWorktrees.map((gw) => { + const worktreeId = `${repo.id}::${gw.path}` + const meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId) + return mergeWorktree(repo.id, gw, meta, repo.displayName) + }) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + return [] } - return gitWorktrees.map((gw) => { - const worktreeId = `${repo.id}::${gw.path}` - const meta = store.getWorktreeMeta(worktreeId) - return mergeWorktree(repo.id, gw, meta, repo.displayName) - }) }) ipcMain.handle( @@ -135,11 +204,26 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store } // Remote repos route all git operations through the relay - if (repo.connectionId) { - return createRemoteWorktree(args, repo, store, mainWindow) - } + const result = repo.connectionId + ? await createRemoteWorktree(args, repo, store, mainWindow) + : await createLocalWorktree(args, repo, store, mainWindow, runtime) - return createLocalWorktree(args, repo, store, mainWindow) + // Why: emit `workspace_created` only after the underlying create has + // resolved (the helpers throw on failure, so reaching this line means + // git-add succeeded — we deliberately do not also emit a separate + // `workspace_initialized`, see telemetry-plan.md§Deferred events). + // `from_existing_branch` is true iff the caller specified a non-empty + // baseBranch; an unspecified baseBranch means "branch from default + // HEAD", which is the not-from-existing-branch case. We never send + // the branch name itself. + const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) + const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' + track('workspace_created', { + source, + from_existing_branch: typeof args.baseBranch === 'string' && args.baseBranch.length > 0 + }) + + return result } ) @@ -172,7 +256,10 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store // Skip the gh lookup when both hints are present (picker already has them). if (!headRefName) { - const item = await getWorkItem(repo.path, args.prNumber) + // Why: the caller already knows this is a PR number, so scope the + // lookup to `type: 'pr'` and skip the speculative issue-first probe + // that would hit the upstream issue tracker for fork checkouts. + const item = await getWorkItem(repo.path, args.prNumber, 'pr') if (!item || item.type !== 'pr') { return { error: `PR #${args.prNumber} not found.` } } @@ -246,7 +333,7 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store ipcMain.handle( 'worktrees:remove', - async (_event, args: { worktreeId: string; force?: boolean }) => { + async (_event, args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => { const { repoId, worktreePath } = parseWorktreeId(args.worktreeId) const repo = store.getRepo(repoId) if (!repo) { @@ -256,6 +343,31 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store throw new Error('Folder mode does not support deleting worktrees.') } + // Why: kill every PTY belonging to this worktree BEFORE git-level + // removal. The renderer pre-kills via shutdownWorktreeTerminals, but + // defensive teardown here protects against: (a) a future renderer bug, + // (b) a disconnected window, (c) an out-of-band window.api.worktrees.remove + // caller. Placement is before the SSH early-return so local-host PTYs + // are still reaped for local repos; SSH-backed PTYs are handled by the + // remote provider's own teardown (design §4.3, §6). + if (!repo.connectionId) { + await killAllProcessesForWorktree(args.worktreeId, { + runtime, + localProvider: getLocalPtyProvider() + }) + .then((r) => { + const total = r.runtimeStopped + r.providerStopped + r.registryStopped + if (total > 0) { + console.info( + `[worktree-teardown] ${args.worktreeId} killed runtime=${r.runtimeStopped} provider=${r.providerStopped} registry=${r.registryStopped}` + ) + } + }) + .catch((err) => { + console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) + }) + } + if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) if (!provider) { @@ -270,13 +382,22 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store // Run archive hook before removal const hooks = getEffectiveHooks(repo) - if (hooks?.scripts.archive) { + if (hooks?.scripts.archive && !args.skipArchive) { const result = await runHook('archive', worktreePath, repo) if (!result.success) { console.error(`[hooks] archive hook failed for ${worktreePath}:`, result.output) } } + // Why: `git worktree remove` (non-force) refuses to delete a worktree + // that has untracked files, and a symlink pointing into the primary + // checkout looks untracked to git. Unlink the user-configured symlinks + // first so the normal delete path keeps working — otherwise every + // deletion would require the Force Delete toast once the feature is on. + if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { + await removeWorktreeSymlinks(worktreePath, repo.symlinkPaths) + } + try { await removeWorktree(repo.path, worktreePath, args.force ?? false) } catch (error) { diff --git a/src/main/linear/client.ts b/src/main/linear/client.ts index 5f0da2fc443..04854e3423f 100644 --- a/src/main/linear/client.ts +++ b/src/main/linear/client.ts @@ -232,6 +232,7 @@ export async function testConnection(): Promise< if (!token) { return { ok: false, error: 'No API key stored.' } } + try { const client = new LinearClient({ apiKey: token }) const me = await client.viewer diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index 6847f243622..d28a0b51d56 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -30,7 +30,14 @@ function buildMenuOptions() { onZoomIn: vi.fn(), onZoomOut: vi.fn(), onZoomReset: vi.fn(), - onToggleStatusBar: vi.fn() + onToggleLeftSidebar: vi.fn(), + onToggleRightSidebar: vi.fn(), + onToggleAppearance: vi.fn(), + getAppearanceState: vi.fn(() => ({ + showTasksButton: true, + showTitlebarAgentActivity: true, + statusBarVisible: true + })) } } @@ -182,4 +189,74 @@ describe('registerAppMenu', () => { // No Help menu on macOS — About/Check for Updates live in the app menu. expect(template.find((item) => item.label === 'Help')).toBeUndefined() }) + + it('exposes an Appearance submenu under View with checkbox items reflecting state', () => { + const options = buildMenuOptions() + options.getAppearanceState.mockReturnValue({ + showTasksButton: false, + showTitlebarAgentActivity: true, + statusBarVisible: true + }) + registerAppMenu(options) + + const viewSubmenu = getSubmenu(getTemplate(), 'View') + const appearanceEntry = viewSubmenu.find((item) => item.label === 'Appearance') + expect(appearanceEntry).toBeDefined() + + const appearanceSubmenu = (appearanceEntry?.submenu ?? + []) as Electron.MenuItemConstructorOptions[] + const tasksItem = appearanceSubmenu.find((item) => item.label === 'Show Tasks Button') + expect(tasksItem?.type).toBe('checkbox') + expect(tasksItem?.checked).toBe(false) + + const titlebarItem = appearanceSubmenu.find( + (item) => item.label === 'Show Titlebar Agent Activity' + ) + expect(titlebarItem?.checked).toBe(true) + + const statusBarItem = appearanceSubmenu.find((item) => item.label === 'Show Status Bar') + expect(statusBarItem?.checked).toBe(true) + }) + + it('routes Appearance checkbox clicks through onToggleAppearance', () => { + const options = buildMenuOptions() + registerAppMenu(options) + + const viewSubmenu = getSubmenu(getTemplate(), 'View') + const appearanceSubmenu = (viewSubmenu.find((item) => item.label === 'Appearance')?.submenu ?? + []) as Electron.MenuItemConstructorOptions[] + + appearanceSubmenu + .find((item) => item.label === 'Show Tasks Button') + ?.click?.({} as never, {} as never, {} as never) + + expect(options.onToggleAppearance).toHaveBeenCalledWith('showTasksButton') + }) + + it('routes sidebar toggle items through their callbacks', () => { + const options = buildMenuOptions() + registerAppMenu(options) + + const viewSubmenu = getSubmenu(getTemplate(), 'View') + const appearanceSubmenu = (viewSubmenu.find((item) => item.label === 'Appearance')?.submenu ?? + []) as Electron.MenuItemConstructorOptions[] + + const leftLabel = `Toggle Left Sidebar\t${isMac ? 'Cmd+B' : 'Ctrl+B'}` + const rightLabel = `Toggle Right Sidebar\t${isMac ? 'Alt+Cmd+B' : 'Ctrl+Alt+B'}` + + appearanceSubmenu + .find((item) => item.label === leftLabel) + ?.click?.({} as never, {} as never, {} as never) + appearanceSubmenu + .find((item) => item.label === rightLabel) + ?.click?.({} as never, {} as never, {} as never) + + expect(options.onToggleLeftSidebar).toHaveBeenCalledTimes(1) + expect(options.onToggleRightSidebar).toHaveBeenCalledTimes(1) + // Why: these entries must not bind Cmd/Ctrl+B as real accelerators + // because before-input-event carries a TipTap-bold carve-out that the + // menu accelerator would bypass. + expect(appearanceSubmenu.find((item) => item.label === leftLabel)?.accelerator).toBeUndefined() + expect(appearanceSubmenu.find((item) => item.label === rightLabel)?.accelerator).toBeUndefined() + }) }) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index 776f48733b0..d78a70d8c1b 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -1,23 +1,40 @@ import { BrowserWindow, Menu, app } from 'electron' +export type AppearanceMenuState = { + showTasksButton: boolean + showTitlebarAgentActivity: boolean + statusBarVisible: boolean +} + +export type AppearanceMenuKey = keyof AppearanceMenuState + type RegisterAppMenuOptions = { onOpenSettings: () => void onCheckForUpdates: (options: { includePrerelease: boolean }) => void onZoomIn: () => void onZoomOut: () => void onZoomReset: () => void - onToggleStatusBar: () => void + onToggleLeftSidebar: () => void + onToggleRightSidebar: () => void + onToggleAppearance: (key: AppearanceMenuKey) => void + getAppearanceState: () => AppearanceMenuState } -export function registerAppMenu({ - onOpenSettings, - onCheckForUpdates, - onZoomIn, - onZoomOut, - onZoomReset, - onToggleStatusBar -}: RegisterAppMenuOptions): void { +function buildAndApplyMenu(options: RegisterAppMenuOptions): void { + const { + onOpenSettings, + onCheckForUpdates, + onZoomIn, + onZoomOut, + onZoomReset, + onToggleLeftSidebar, + onToggleRightSidebar, + onToggleAppearance, + getAppearanceState + } = options + const isMac = process.platform === 'darwin' + const appearance = getAppearanceState() const reloadFocusedWindow = (ignoreCache: boolean): void => { const webContents = BrowserWindow.getFocusedWindow()?.webContents @@ -125,6 +142,53 @@ export function registerAppMenu({ ] } + // Why: mirror VS Code's View > Appearance submenu so users can toggle + // sidebar/status-bar/tasks-button/titlebar-activity from the menu bar as + // well as from the settings pane. Electron doesn't reactively update + // menu items when the backing state changes, so rebuildAppMenu() must be + // called after every settings update — each build reads current + // appearance state through getAppearanceState() and produces a fresh + // template with accurate `checked` values. + const appearanceSubmenu: Electron.MenuItemConstructorOptions = { + label: 'Appearance', + submenu: [ + { + // Why: display-only shortcut hint — not a real accelerator. Cmd/Ctrl+B + // is intercepted in createMainWindow.ts's before-input-event handler + // with a TipTap-bold carve-out for markdown editors. Binding the + // accelerator here would steal the chord before that carve-out can + // fire. Sidebar open/closed lives in the renderer store (non-persisted), + // so we forward a toggle request rather than mirroring state in main. + label: `Toggle Left Sidebar\t${isMac ? 'Cmd+B' : 'Ctrl+B'}`, + click: () => onToggleLeftSidebar() + }, + { + // Why: display-only shortcut hint for the same reason as above. + label: `Toggle Right Sidebar\t${isMac ? 'Alt+Cmd+B' : 'Ctrl+Alt+B'}`, + click: () => onToggleRightSidebar() + }, + { + label: 'Show Status Bar', + type: 'checkbox', + checked: appearance.statusBarVisible, + click: () => onToggleAppearance('statusBarVisible') + }, + { type: 'separator' }, + { + label: 'Show Tasks Button', + type: 'checkbox', + checked: appearance.showTasksButton, + click: () => onToggleAppearance('showTasksButton') + }, + { + label: 'Show Titlebar Agent Activity', + type: 'checkbox', + checked: appearance.showTitlebarAgentActivity, + click: () => onToggleAppearance('showTitlebarAgentActivity') + } + ] + } + const viewMenu: Electron.MenuItemConstructorOptions = { label: 'View', submenu: [ @@ -178,10 +242,7 @@ export function registerAppMenu({ { type: 'separator' }, { role: 'togglefullscreen' }, { type: 'separator' }, - { - label: 'Toggle Status Bar', - click: () => onToggleStatusBar() - } + appearanceSubmenu ] } @@ -210,3 +271,20 @@ export function registerAppMenu({ Menu.setApplicationMenu(Menu.buildFromTemplate(template)) } + +let lastRegisterOptions: RegisterAppMenuOptions | null = null + +export function registerAppMenu(options: RegisterAppMenuOptions): void { + lastRegisterOptions = options + buildAndApplyMenu(options) +} + +/** Rebuild the application menu using the options from the most recent + * registerAppMenu call. Used to refresh checkbox `checked` state when + * settings that feed the Appearance submenu change, since Electron's + * menu items do not reactively re-render when the backing state updates. */ +export function rebuildAppMenu(): void { + if (lastRegisterOptions) { + buildAndApplyMenu(lastRegisterOptions) + } +} diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index b1ed29622b5..21fb9229ab5 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -80,6 +80,21 @@ describe('OpenCode hook plugin source', () => { expect(source).toContain('cachedEndpointValues = null;') }) + it('forwards question.asked as AskUserQuestion so the pane flips to waiting', () => { + // Why: OpenCode exposes two separate plugin events for human-in-the-loop + // moments — `permission.asked` (blocks on tool approval) and + // `question.asked` (the agent called an ask-the-user tool). The plugin + // must forward both so the server-side normalizer can map each to + // `waiting` and render the red indicator. Dropping `question.asked` + // leaves the pane stuck in `working` while the agent is actually idle, + // waiting on a human reply — exactly the bug three other OpenCode + // integrations (cmux, t3code, open-vibe-island) all handle. + const source = _internals.getOpenCodePluginSource() + + expect(source).toContain('if (event.type === "question.asked")') + expect(source).toContain('await post("AskUserQuestion", event.properties || {});') + }) + it('guards endpoint-file parse warnings with a process-lifetime latch', () => { // Why: ENOENT is the normal pre-install case and must stay silent, but a // malformed/unreadable file (EACCES, EIO, parse error) would otherwise diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 5990fa667ae..b789b2146e4 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -259,6 +259,18 @@ function getOpenCodePluginSource(): string { ' return;', ' }', '', + ' if (event.type === "question.asked") {', + ' // Why: question.asked fires when OpenCode uses an ask-the-user tool', + ' // (distinct from permission.asked, which blocks on tool approval).', + ' // The agent is idle-but-waiting on a human reply, not running, so we', + ' // must flip the pane to the same red "needs attention" state used for', + ' // permission requests. Like permission.asked, do not touch lastStatus', + ' // so the next SessionBusy/SessionIdle after the user answers still', + ' // fires and restores the normal working/done flow.', + ' await post("AskUserQuestion", event.properties || {});', + ' return;', + ' }', + '', ' if (event.type === "message.updated") {', ' // Why: role is already cached above the isChildSession await so the', ' // back-to-back message.part.updated for the same messageID is not', diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index a34b96c85d9..09a7b43cbf4 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -72,12 +72,14 @@ describe('Store', () => { expect(settings.branchPrefix).toBe('git-username') expect(settings.refreshLocalBaseRefOnWorktreeCreate).toBe(false) expect(settings.theme).toBe('system') + expect(settings.appFontFamily).toBe('Geist') expect(settings.editorAutoSave).toBe(false) expect(settings.editorAutoSaveDelayMs).toBe(1000) expect(settings.terminalFontSize).toBe(14) expect(settings.terminalFontWeight).toBe(500) expect(settings.rightSidebarOpenByDefault).toBe(true) - expect(settings.showTaskProviderIcons).toBe(true) + expect(settings.showTasksButton).toBe(true) + expect(settings.notifications.customSoundPath).toBeNull() }) it('returns default UI state when no data file exists', async () => { @@ -145,11 +147,37 @@ describe('Store', () => { expect(store.getSettings().editorAutoSaveDelayMs).toBe(1000) expect(store.getSettings().refreshLocalBaseRefOnWorktreeCreate).toBe(false) expect(store.getSettings().rightSidebarOpenByDefault).toBe(true) - expect(store.getSettings().showTaskProviderIcons).toBe(true) + expect(store.getSettings().showTasksButton).toBe(true) + expect(store.getSettings().notifications.customSoundPath).toBeNull() // repos should be loaded expect(store.getRepos()).toHaveLength(1) }) + it('preserves custom notification sound paths from persisted settings', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + notifications: { + customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg' + } + }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().notifications).toMatchObject({ + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: true, + customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg' + }) + }) + it('preserves editorAutoSaveDelayMs when set in persisted data', async () => { writeDataFile({ schemaVersion: 1, @@ -249,6 +277,34 @@ describe('Store', () => { expect(store.updateRepo('nope', { displayName: 'x' })).toBeNull() }) + it('updateRepo persists issueSourcePreference across reloads', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + + const updated = store.updateRepo('r1', { issueSourcePreference: 'upstream' }) + expect(updated!.issueSourcePreference).toBe('upstream') + + store.flush() + const reloaded = await createStore() + expect(reloaded.getRepo('r1')!.issueSourcePreference).toBe('upstream') + }) + + it('updateRepo with issueSourcePreference=undefined clears the preference', async () => { + const store = await createStore() + store.addRepo(makeRepo({ issueSourcePreference: 'origin' })) + expect(store.getRepo('r1')!.issueSourcePreference).toBe('origin') + + // Why: passing the key with value `undefined` must clear the preference. + // Plain `Object.assign` skips undefined values, so without the explicit + // delete branch in updateRepo, the persisted record would keep 'origin'. + store.updateRepo('r1', { issueSourcePreference: undefined }) + expect(store.getRepo('r1')!.issueSourcePreference).toBeUndefined() + + store.flush() + const reloaded = await createStore() + expect(reloaded.getRepo('r1')!.issueSourcePreference).toBeUndefined() + }) + // ── 8. setWorktreeMeta and getWorktreeMeta ───────────────────────── it('setWorktreeMeta creates meta with defaults for missing fields', async () => { @@ -282,12 +338,14 @@ describe('Store', () => { theme: 'dark', editorAutoSave: true, editorAutoSaveDelayMs: 1500, + appFontFamily: 'Inter', terminalFontSize: 16, terminalFontWeight: 600 }) expect(updated.theme).toBe('dark') expect(updated.editorAutoSave).toBe(true) expect(updated.editorAutoSaveDelayMs).toBe(1500) + expect(updated.appFontFamily).toBe('Inter') expect(updated.terminalFontSize).toBe(16) expect(updated.terminalFontWeight).toBe(600) // Other fields preserved @@ -433,6 +491,24 @@ describe('Store', () => { expect(store.getUI().sortBy).toBe('recent') }) + it('uses recent as the default sort for a fresh install (no persisted sortBy)', async () => { + // Why: the legacy-recent→smart migration must gate on the *raw* persisted + // value, not the normalized default. Otherwise, changing the default sort + // to 'recent' would cause every fresh install to be mis-migrated to 'smart'. + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().sortBy).toBe('recent') + }) + // ── terminalMacOptionAsAlt migration (issue #903) ─────────────────── it('migrates legacy "true" terminalMacOptionAsAlt to "auto" on first load', async () => { @@ -533,6 +609,98 @@ describe('Store', () => { expect(store.getSettings().terminalMacOptionAsAltMigrated).toBe(true) }) + // ── inline-agents card-property migration ────────────────────────── + // + // Why: 'inline-agents' was added to DEFAULT_WORKTREE_CARD_PROPERTIES after + // the experimentalAgentDashboard toggle. Users who had the toggle on in a + // prior rc already had worktreeCardProperties persisted without the new + // entry, so the defaults-merge in load() wouldn't reach them and the + // inline agent list stayed hidden after upgrade. The migration appends + // 'inline-agents' once and sets a flag so a later deliberate uncheck + // from the Workspaces view options menu sticks across restarts. + + it('adds inline-agents to persisted cardProps when experimental toggle is on', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { experimentalAgentDashboard: true }, + ui: { + worktreeCardProperties: ['status', 'unread', 'ci', 'issue', 'pr', 'comment'] + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getUI().worktreeCardProperties).toContain('inline-agents') + expect(store.getUI()._inlineAgentsDefaultedForExperiment).toBe(true) + }) + + it('does not add inline-agents when experimental toggle is off', async () => { + // Why: the experimental toggle gates whether inline agents render at all, + // so there's no value in checking the view-mode option for opted-out users. + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { experimentalAgentDashboard: false }, + ui: { + worktreeCardProperties: ['status', 'unread', 'ci', 'issue', 'pr', 'comment'] + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents') + expect(store.getUI()._inlineAgentsDefaultedForExperiment).toBe(true) + }) + + it('respects a deliberate uncheck after migration flag is set', async () => { + // Why: once migrated, an empty-of-inline-agents array is treated as a + // user choice — not a legacy pre-migration state — so we must not + // re-add it on every subsequent launch. + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { experimentalAgentDashboard: true }, + ui: { + worktreeCardProperties: ['status', 'unread', 'ci', 'issue', 'pr', 'comment'], + _inlineAgentsDefaultedForExperiment: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents') + }) + + it('leaves cardProps alone when inline-agents is already present', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { experimentalAgentDashboard: true }, + ui: { + worktreeCardProperties: [ + 'status', + 'unread', + 'ci', + 'issue', + 'pr', + 'comment', + 'inline-agents' + ] + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + const props = store.getUI().worktreeCardProperties + expect(props.filter((p) => p === 'inline-agents')).toHaveLength(1) + expect(store.getUI()._inlineAgentsDefaultedForExperiment).toBe(true) + }) + // ── GitHub Cache ─────────────────────────────────────────────────── it('get/set GitHub cache round-trips', async () => { @@ -582,4 +750,88 @@ describe('Store', () => { expect(store.getWorktreeMeta('a')).toBeUndefined() expect(store.getWorktreeMeta('b')).toBeDefined() }) + + // ── Telemetry cohort migration ───────────────────────────────────── + // + // The migration keys on `existsSync(dataFile)` rather than field-based + // inference because the `telemetry` field is new in this release: keying + // on its presence would misclassify every pre-telemetry install as fresh, + // silently flipping existing users to default-on and violating the social + // contract they installed Orca under. + + it('classifies a truly fresh install as new-user cohort (file absent → optedIn=true)', async () => { + // No data file written — truly fresh install of the telemetry release. + const store = await createStore() + const t = store.getSettings().telemetry + expect(t).toBeDefined() + expect(t!.existedBeforeTelemetryRelease).toBe(false) + expect(t!.optedIn).toBe(true) + expect(t!.installId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ) + }) + + it('classifies a pre-existing install as existing-user cohort (file present → optedIn=null)', async () => { + // A pre-telemetry data file exists on disk with no telemetry block. + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo()], + worktreeMeta: {}, + settings: { theme: 'dark' }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + const t = store.getSettings().telemetry + expect(t).toBeDefined() + expect(t!.existedBeforeTelemetryRelease).toBe(true) + expect(t!.optedIn).toBeNull() + expect(t!.installId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ) + // Sibling migrations still run alongside the telemetry migration. + expect(store.getSettings().theme).toBe('dark') + }) + + it('still classifies as existing-user cohort when the data file is corrupt', async () => { + // Load-bearing: `fileExistedOnLoad` stays true even when the parse + // throws, so the corrupt-file catch path must also apply the migration. + // Otherwise a user whose `orca-data.json` got corrupted would be + // silently opted in as if they were a fresh install. + mkdirSync(testState.dir, { recursive: true }) + writeFileSync(dataFile(), '{{{corrupt json', 'utf-8') + const store = await createStore() + const t = store.getSettings().telemetry + expect(t).toBeDefined() + expect(t!.existedBeforeTelemetryRelease).toBe(true) + expect(t!.optedIn).toBeNull() + expect(t!.installId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ) + }) + + it('preserves an already-migrated telemetry block on subsequent launches', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + telemetry: { + optedIn: true, + installId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + existedBeforeTelemetryRelease: false + } + }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getSettings().telemetry).toEqual({ + optedIn: true, + installId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + existedBeforeTelemetryRelease: false + }) + }) }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 02d54a10b9d..1ac53c5ee55 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1,12 +1,19 @@ /* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration, load/save, and flush logic in one file so the full storage contract is reviewable as a unit instead of being scattered across modules. */ -import { app } from 'electron' +import { app, safeStorage } from 'electron' import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from 'fs' import { writeFile, rename, mkdir, rm } from 'fs/promises' import { join, dirname } from 'path' import { homedir } from 'os' -import type { PersistedState, Repo, WorktreeMeta, GlobalSettings } from '../shared/types' +import { randomUUID } from 'node:crypto' +import type { + PersistedState, + Repo, + SparsePreset, + WorktreeMeta, + GlobalSettings +} from '../shared/types' import type { SshTarget } from '../shared/ssh-types' import { isFolderRepo } from '../shared/repo-kind' import { getGitUsername } from './git/repo' @@ -19,6 +26,35 @@ import { } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' +function encrypt(plaintext: string): string { + if (!plaintext || !safeStorage.isEncryptionAvailable()) { + return plaintext + } + try { + return safeStorage.encryptString(plaintext).toString('base64') + } catch (err) { + console.error('[persistence] Encryption failed:', err) + return plaintext + } +} + +function decrypt(ciphertext: string): string { + if (!ciphertext || !safeStorage.isEncryptionAvailable()) { + return ciphertext + } + try { + return safeStorage.decryptString(Buffer.from(ciphertext, 'base64')) + } catch { + // Why: if decryption fails, it likely means the value was stored as + // plaintext (pre-encryption build) or the OS keychain changed. Fall + // back to the raw string so users don't lose their cookie after upgrade. + console.warn( + '[persistence] safeStorage decryption failed — returning ciphertext as-is. Possible keychain reset.' + ) + return ciphertext + } +} + // Why: the data-file path must not be a module-level constant. Module-level // code runs at import time — before configureDevUserDataPath() redirects the // userData path in index.ts — so a constant would capture the default (non-dev) @@ -71,11 +107,28 @@ export class Store { } private load(): PersistedState { + // Capture once, at the top: this is the unambiguous "has the user run + // Orca before?" signal used by the telemetry cohort migration below. + // Field-based inference (e.g., `settings.telemetry` presence) does not + // work on the telemetry release itself — `telemetry` is new here, so it + // would be absent on every pre-telemetry install and misclassify existing + // users as fresh, flipping them to default-on in violation of the + // social contract we installed them under. + const dataFile = getDataFile() + const fileExistedOnLoad = existsSync(dataFile) + + let result: PersistedState | null = null try { - const dataFile = getDataFile() - if (existsSync(dataFile)) { + if (fileExistedOnLoad) { const raw = readFileSync(dataFile, 'utf-8') const parsed = JSON.parse(raw) as PersistedState + + // Why: opencodeSessionCookie is stored encrypted on disk via safeStorage. + // Decrypt at the load boundary so the rest of the app sees plaintext. + if (parsed.settings?.opencodeSessionCookie) { + parsed.settings.opencodeSessionCookie = decrypt(parsed.settings.opencodeSessionCookie) + } + // Merge with defaults in case new fields were added const defaults = getDefaultPersistedState(homedir()) // Why: before the layout-aware 'auto' mode shipped (issue #903), @@ -98,7 +151,7 @@ export class Store { : rawOptionAsAlt === undefined || rawOptionAsAlt === 'true' ? 'auto' : rawOptionAsAlt - return { + result = { ...defaults, ...parsed, settings: { @@ -114,14 +167,47 @@ export class Store { // Why: 'recent' used to mean the weighted smart sort. One-shot // migration moves it to 'smart'; the flag prevents re-firing after // a user intentionally selects the new last-activity 'recent' sort. + // Gate on the *raw* persisted value, not the normalized one: the + // default sortBy is now 'recent', so a fresh install with no + // persisted sortBy would otherwise be mis-migrated to 'smart'. ui: (() => { - const sort = normalizeSortBy(parsed.ui?.sortBy) - const migrate = !parsed.ui?._sortBySmartMigrated && sort === 'recent' + const rawSort = parsed.ui?.sortBy + const sort = normalizeSortBy(rawSort) + const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' + // Why: the 'inline-agents' card property was added after the + // experimentalAgentDashboard toggle. Users who had the toggle on + // in a prior rc already had worktreeCardProperties persisted + // without the new entry, so a simple defaults merge wouldn't + // reach them and the inline agent list stayed hidden after + // upgrade. One-shot append 'inline-agents' to their persisted + // array when the experimental toggle is true; the flag prevents + // re-firing so a deliberate uncheck from the Workspaces view + // options menu sticks across restarts. + // The flag is stamped on every successful load — including when + // the experiment is off — so that a later flip-on is handled by + // the renderer's ExperimentalPane handler rather than re-firing + // this migration. + const rawCardProps = parsed.ui?.worktreeCardProperties + const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForExperiment === true + const experimentOn = parsed.settings?.experimentalAgentDashboard === true + const needsInlineAgentsMigration = + !inlineAgentsMigrated && + experimentOn && + Array.isArray(rawCardProps) && + !rawCardProps.includes('inline-agents') + const migratedCardProps = + needsInlineAgentsMigration && Array.isArray(rawCardProps) + ? [...rawCardProps, 'inline-agents' as const] + : undefined return { ...defaults.ui, ...parsed.ui, sortBy: migrate ? ('smart' as const) : sort, - _sortBySmartMigrated: true + _sortBySmartMigrated: true, + ...(migratedCardProps !== undefined + ? { worktreeCardProperties: migratedCardProps } + : {}), + _inlineAgentsDefaultedForExperiment: true } })(), // Why: the workspace session is the most volatile persisted surface @@ -151,7 +237,76 @@ export class Store { } catch (err) { console.error('[persistence] Failed to load state, using defaults:', err) } - return getDefaultPersistedState(homedir()) + + // Corrupt-file catch path and "no file on disk" path converge here. The + // telemetry migration below runs on whichever branch produced `result`, + // because a user whose `orca-data.json` got corrupted is not a fresh + // install of the telemetry release — they still count as existing and + // must see the opt-in banner, not the default-on toast. + if (result === null) { + result = getDefaultPersistedState(homedir()) + } + + return this.migrateTelemetry(result, fileExistedOnLoad) + } + + // One-shot telemetry cohort migration. Runs on every `load()` but is a + // no-op once `existedBeforeTelemetryRelease` is set, so subsequent launches + // pay only the property lookup. Populates: + // - `existedBeforeTelemetryRelease` — cohort discriminator (drives + // whether the existing-user opt-in banner is shown in PR 3; + // new users get no first-launch surface). + // - `optedIn` — new users start opted in; existing users are `null` until + // the banner resolves (the consent resolver returns `pending_banner` + // until then, so nothing transmits). + // - `installId` — anonymous UUID v4. Stable across launches; not surfaced in the UI. + private migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { + const existing = state.settings?.telemetry + // Why: the one-shot is complete only when all three invariants hold. + // Keying on `existedBeforeTelemetryRelease` alone would let a partially- + // written telemetry block (crash mid-save, hand-edit, future bug) short- + // circuit migration and leave `installId` undefined or `optedIn` wiped. + if ( + typeof existing?.existedBeforeTelemetryRelease === 'boolean' && + typeof existing.installId === 'string' && + existing.installId.length > 0 && + (existing.optedIn === true || existing.optedIn === false || existing.optedIn === null) + ) { + return state + } + // Why: cohort is the authoritative discriminator per invariant #8, so + // resolve it once and reuse it below — the `optedIn` fallback must not + // re-infer cohort from `fileExistedOnLoad` or field presence, or a + // partially-written telemetry block could land a new user in the + // existing-user `pending_banner` state. + const resolvedExistedBefore = + typeof existing?.existedBeforeTelemetryRelease === 'boolean' + ? existing.existedBeforeTelemetryRelease + : fileExistedOnLoad + return { + ...state, + settings: { + ...state.settings, + telemetry: { + ...existing, + existedBeforeTelemetryRelease: resolvedExistedBefore, + // Why: preserve an explicit opt-in/out if the user has ever resolved + // it. Only fall back to the cohort default (new users: on; existing + // users: undecided until the first-launch banner resolves) when + // optedIn is truly unset (undefined), never when it is `false`. + optedIn: + existing?.optedIn === true || existing?.optedIn === false || existing?.optedIn === null + ? existing.optedIn + : resolvedExistedBefore + ? null + : true, + installId: + typeof existing?.installId === 'string' && existing.installId.length > 0 + ? existing.installId + : randomUUID() + } + } + } } private scheduleSave(): void { @@ -185,12 +340,23 @@ export class Store { const dir = dirname(dataFile) await mkdir(dir, { recursive: true }).catch(() => {}) const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + + // Why: opencodeSessionCookie must be encrypted on disk. Clone state so + // the in-memory this.state stays plaintext for the rest of the app. + const stateToSave = { + ...this.state, + settings: { + ...this.state.settings, + opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie) + } + } + // Why: wrap write+rename in try/finally-on-error so any failure (ENOSPC, // ENFILE, EIO, permission) removes the tmp file rather than leaving a // multi-megabyte orphan behind. Successful rename consumes the tmp file. let renamed = false try { - await writeFile(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8') + await writeFile(tmpFile, JSON.stringify(stateToSave, null, 2), 'utf-8') // Why: if flush() ran while this async write was in-flight, it bumped // writeGeneration and already wrote the latest state synchronously. // Renaming this stale tmp file would overwrite the fresh data. @@ -215,12 +381,23 @@ export class Store { mkdirSync(dir, { recursive: true }) } const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + + // Why: opencodeSessionCookie must be encrypted on disk. Clone state so + // the in-memory this.state stays plaintext for the rest of the app. + const stateToSave = { + ...this.state, + settings: { + ...this.state.settings, + opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie) + } + } + // Why: mirror the async path — on any failure between writeFileSync and // renameSync, remove the tmp file so crashes during shutdown don't leak // orphans into userData. let renamed = false try { - writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8') + writeFileSync(tmpFile, JSON.stringify(stateToSave, null, 2), 'utf-8') renameSync(tmpFile, dataFile) renamed = true } finally { @@ -252,6 +429,9 @@ export class Store { removeRepo(id: string): void { this.state.repos = this.state.repos.filter((r) => r.id !== id) + // Why: presets are repo-scoped, so removing the repo means the presets + // can never be referenced again — drop them with the parent. + delete this.state.sparsePresetsByRepo[id] // Clean up worktree meta for this repo const prefix = `${id}::` for (const key of Object.keys(this.state.worktreeMeta)) { @@ -265,14 +445,33 @@ export class Store { updateRepo( id: string, updates: Partial< - Pick + Pick< + Repo, + | 'displayName' + | 'badgeColor' + | 'hookSettings' + | 'worktreeBaseRef' + | 'kind' + | 'issueSourcePreference' + > > ): Repo | null { const repo = this.state.repos.find((r) => r.id === id) if (!repo) { return null } - Object.assign(repo, updates) + // Why: `issueSourcePreference === undefined` in the patch means "reset to + // auto" (and the persisted record should drop the key, not preserve a + // stale explicit value via Object.assign's skip-on-undefined behavior). + // Without this delete branch, toggling explicit → auto would silently + // leave the old preference in place on disk. + if ('issueSourcePreference' in updates && updates.issueSourcePreference === undefined) { + delete repo.issueSourcePreference + const { issueSourcePreference: _drop, ...rest } = updates + Object.assign(repo, rest) + } else { + Object.assign(repo, updates) + } this.scheduleSave() return this.hydrateRepo(repo) } @@ -302,6 +501,31 @@ export class Store { } } + // ── Sparse Presets ───────────────────────────────────────────────── + + getSparsePresets(repoId: string): SparsePreset[] { + return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) => + left.name.localeCompare(right.name) + ) + } + + saveSparsePreset(preset: SparsePreset): SparsePreset { + const existing = this.state.sparsePresetsByRepo[preset.repoId] ?? [] + const index = existing.findIndex((entry) => entry.id === preset.id) + this.state.sparsePresetsByRepo[preset.repoId] = + index === -1 + ? [...existing, preset] + : existing.map((entry, i) => (i === index ? preset : entry)) + this.scheduleSave() + return preset + } + + removeSparsePreset(repoId: string, presetId: string): void { + const existing = this.state.sparsePresetsByRepo[repoId] ?? [] + this.state.sparsePresetsByRepo[repoId] = existing.filter((entry) => entry.id !== presetId) + this.scheduleSave() + } + // ── Worktree Meta ────────────────────────────────────────────────── getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined { @@ -332,13 +556,24 @@ export class Store { } updateSettings(updates: Partial): GlobalSettings { + // Why: `telemetry` is deep-merged for the same reason `notifications` is — + // partial updates from the Privacy pane / consent flow (e.g., flipping + // only `optedIn`) must not clobber sibling fields like `installId` or + // `existedBeforeTelemetryRelease`. The field is optional, so we only + // synthesize a `telemetry` key on the result when at least one side has + // one. + const mergedTelemetry = + updates.telemetry !== undefined + ? { ...this.state.settings.telemetry, ...updates.telemetry } + : this.state.settings.telemetry this.state.settings = { ...this.state.settings, ...updates, notifications: { ...this.state.settings.notifications, ...updates.notifications - } + }, + ...(mergedTelemetry !== undefined ? { telemetry: mergedTelemetry } : {}) } this.scheduleSave() return this.state.settings @@ -387,6 +622,53 @@ export class Store { this.scheduleSave() } + // Why: closes the SIGKILL-between-spawn-and-persist race (Issue #217). The + // renderer's debounced session writer (~450 ms total) is normally the only + // path that writes tab.ptyId / ptyIdsByLeafId; a force-quit inside that + // window orphans the daemon's history dir. Patching + sync flushing here + // before pty:spawn returns guarantees the renderer cannot observe a + // spawn-success without the binding already being durable on disk. + persistPtyBinding(args: { + worktreeId: string + tabId: string + leafId: string + ptyId: string + }): void { + const session = this.state.workspaceSession + if (!session) { + return + } + const tabs = session.tabsByWorktree?.[args.worktreeId] + const tab = tabs?.find((t) => t.id === args.tabId) + if (tab) { + tab.ptyId = args.ptyId + } + const layout = session.terminalLayoutsByTabId?.[args.tabId] + if (layout) { + layout.ptyIdsByLeafId = { + ...layout.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } else { + // Why: first-spawn-ever for a new tab — the renderer's debounced writer + // creates the layout entry on PaneManager init, but the binding has to + // be on disk before pty:spawn returns or a SIGKILL inside the same + // window would lose ptyIdsByLeafId for split-pane cold restore. The + // renderer will overwrite this minimal layout once persistLayoutSnapshot + // fires. + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [args.leafId]: args.ptyId } + } + } + } + this.flush() + } + // ── SSH Targets ──────────────────────────────────────────────────── getSshTargets(): SshTarget[] { diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index e8458c77f9d..b0715df7cd5 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -71,7 +71,7 @@ describe('PiTitlebarExtensionService', () => { expect(env.PI_CODING_AGENT_DIR).toBe(join(userDataDir, 'pi-agent-overlays', 'pty-1')) // Orca's titlebar extension is added alongside user extensions, not replacing them. const overlayExtensions = readdirSync(join(env.PI_CODING_AGENT_DIR!, 'extensions')).sort() - expect(overlayExtensions).toEqual(['orca-titlebar-spinner.ts', 'user-ext']) + expect(overlayExtensions).toEqual(['orca-prefill.ts', 'orca-titlebar-spinner.ts', 'user-ext']) // User's top-level resources are reachable via the overlay. expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'skills', 'my-skill', 'SKILL.md'))).toBe(true) expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'auth.json'))).toBe(true) diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 407adbc18ce..05832b126e1 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -15,10 +15,44 @@ import { basename, join, relative, resolve, sep } from 'path' import { app } from 'electron' const ORCA_PI_EXTENSION_FILE = 'orca-titlebar-spinner.ts' +const ORCA_PI_PREFILL_EXTENSION_FILE = 'orca-prefill.ts' const PI_AGENT_DIR_NAME = '.pi' const PI_AGENT_SUBDIR = 'agent' const PI_OVERLAY_DIR_NAME = 'pi-agent-overlays' +// Why: env-var name read by the prefill extension. Mirrors Claude's +// `--prefill ` semantics for pi — the editor mounts with the text +// already in its input box but unsubmitted, so the user can review/edit +// before sending. Used by the new-workspace draft-launch flow to drop a +// linked GitHub/Linear issue URL into pi without racing pi's lengthy +// startup output (banner + skills + extensions) against the bracketed- +// paste readiness detector. +export const ORCA_PI_PREFILL_ENV_VAR = 'ORCA_PI_PREFILL' + +// Why: pi exposes `pi.ui.setEditorText(text)` from inside an extension's +// session_start handler — that's the equivalent of Claude's `--prefill`. +// We can't use a CLI flag (pi has none) and bracketed-paste-after-ready +// races against pi's startup output, so we ship a tiny extension that +// reads ORCA_PI_PREFILL on session_start and types it into the editor. +// The env var is consumed (deleted from process.env) so /new in the same +// session doesn't re-prefill. +function getPiPrefillExtensionSource(): string { + return [ + 'export default function (pi) {', + " pi.on('session_start', async (event, ctx) => {", + ` const prefill = process.env.${ORCA_PI_PREFILL_ENV_VAR}`, + ' if (!prefill) return', + ` delete process.env.${ORCA_PI_PREFILL_ENV_VAR}`, + " if (event.reason !== 'startup') return", + ' try {', + ' ctx.ui.setEditorText(prefill)', + ' } catch {}', + ' })', + '}', + '' + ].join('\n') +} + function getPiTitlebarExtensionSource(): string { return [ 'const BRAILLE_FRAMES = [', @@ -272,6 +306,10 @@ export class PiTitlebarExtensionService { // instead of replacing that directory, otherwise Orca terminals would // silently disable the user's Pi customization inside Orca only. writeFileSync(join(extensionsDir, ORCA_PI_EXTENSION_FILE), getPiTitlebarExtensionSource()) + writeFileSync( + join(extensionsDir, ORCA_PI_PREFILL_EXTENSION_FILE), + getPiPrefillExtensionSource() + ) } catch { // Why: overlay creation is best-effort — permission errors (EPERM/EACCES) // on Windows can occur when the userData directory is restricted or when diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 78274a4fa7c..fe660ed6dfa 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -1,3 +1,4 @@ +/* oxlint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -204,21 +205,26 @@ describe('LocalPtyProvider', () => { describe('shutdown', () => { it('kills the PTY process', async () => { + // Why: capture the spy reference before shutdown triggers onExit → + // POSIX kill neutralization. After neutralization, mockProc.kill is + // replaced with a non-spy no-op to close the UnixTerminal.destroy() → + // socket-close → SIGHUP-to-recycled-pid race (see docs/fix-pty-fd-leak.md). + const killSpy = mockProc.kill const { id } = await provider.spawn({ cols: 80, rows: 24 }) - await provider.shutdown(id, true) - expect(mockProc.kill).toHaveBeenCalled() + await provider.shutdown(id, { immediate: true }) + expect(killSpy).toHaveBeenCalled() }) it('invokes onExit callback via the node-pty exit handler', async () => { const onExit = vi.fn() provider.configure({ onExit }) const { id } = await provider.spawn({ cols: 80, rows: 24 }) - await provider.shutdown(id, true) + await provider.shutdown(id, { immediate: true }) expect(onExit).toHaveBeenCalledWith(id, -1) }) it('is a no-op for unknown PTY ids', async () => { - await provider.shutdown('nonexistent', true) + await provider.shutdown('nonexistent', { immediate: true }) expect(mockProc.kill).not.toHaveBeenCalled() }) }) @@ -319,12 +325,40 @@ describe('LocalPtyProvider', () => { describe('killAll', () => { it('kills all PTY processes', async () => { + // Why: each spawn needs its own proc so the onExit-triggered POSIX kill + // neutralization on one proc does not replace the kill function on the + // other (mockProc is shared by default in beforeEach). Each proc also + // needs its own exitCb holder — the default mockProc.onExit assigns to + // the shared `exitCb` variable, so the second spawn would overwrite the + // first's exit callback, and mock1Kill firing would trigger cleanup for + // id2 (removing it from the map before killAll iterates to it). + let exit1: ((e: { exitCode: number }) => void) | undefined + let exit2: ((e: { exitCode: number }) => void) | undefined + const mock1Kill = vi.fn(() => exit1?.({ exitCode: -1 })) + const mock2Kill = vi.fn(() => exit2?.({ exitCode: -1 })) + spawnMock + .mockReturnValueOnce({ + ...mockProc, + kill: mock1Kill, + onExit: vi.fn((cb) => { + exit1 = cb + }) + }) + .mockReturnValueOnce({ + ...mockProc, + kill: mock2Kill, + onExit: vi.fn((cb) => { + exit2 = cb + }) + }) + await provider.spawn({ cols: 80, rows: 24 }) await provider.spawn({ cols: 80, rows: 24 }) provider.killAll() - expect(mockProc.kill).toHaveBeenCalled() + expect(mock1Kill).toHaveBeenCalled() + expect(mock2Kill).toHaveBeenCalled() const list = await provider.listProcesses() expect(list).toHaveLength(0) }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 0ccd6c1e489..8529a5d3f9b 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -3,7 +3,10 @@ tightly coupled PTY lifecycle logic (scan → ready → write → exit cleanup) across files without a cleaner ownership seam. */ import { basename } from 'path' +import { win32 as pathWin32 } from 'path' import { resolveWindowsShellLaunchArgs } from './windows-shell-args' +import { resolveEffectiveWindowsPowerShell } from './windows-powershell' +import { resolveProcessCwd } from './process-cwd' import { existsSync } from 'fs' import * as pty from 'node-pty' import { parseWslPath, isWslAvailable } from '../wsl' @@ -79,6 +82,27 @@ function clearPtyState(id: string): void { ptyLoadGeneration.delete(id) } +function destroyPtyProcess(proc: pty.IPty): void { + // Why: node-pty's UnixTerminal.destroy() closes the master socket, which + // releases the ptmx fd to the OS — without this call the fd leaks until GC + // (see docs/fix-pty-fd-leak.md). destroy() also registers a close listener + // that fires `this.kill('SIGHUP')` AFTER the socket closes. On POSIX, by + // the time that listener runs the child may have exited and its pid been + // recycled to an unrelated user process — SIGHUP would land on a Chrome tab, + // editor, etc. Neutralize proc.kill on this instance before calling + // destroy() to defuse the hazard. Windows exempt: WindowsTerminal.destroy + // IS a kill() call via _deferNoArgs, so neutralizing it would leak the + // ConPTY agent. + if (process.platform !== 'win32') { + ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} + } + try { + ;(proc as unknown as { destroy?: () => void }).destroy?.() + } catch { + /* swallow — already torn down */ + } +} + function safeKillAndClean(id: string, proc: pty.IPty): void { disposePtyListeners(id) try { @@ -86,6 +110,7 @@ function safeKillAndClean(id: string, proc: pty.IPty): void { } catch { /* Process may already be dead */ } + destroyPtyProcess(proc) clearPtyState(id) } @@ -99,6 +124,8 @@ export type LocalPtyProviderOptions = { * IPC layer inject the persisted setting without coupling the provider to the * settings store. Returns undefined when no preference is set. */ getWindowsShell?: () => string | undefined + getWindowsPowerShellImplementation?: () => 'auto' | 'powershell.exe' | 'pwsh.exe' | undefined + pwshAvailable?: () => boolean onSpawned?: (id: string) => void onExit?: (id: string, code: number) => void onData?: (id: string, data: string, timestamp: number) => void @@ -138,11 +165,35 @@ export class LocalPtyProvider implements IPtyProvider { // Why: shellOverride lets a single tab open in a different shell than the // persisted default (e.g. "New WSL terminal" from the "+" submenu) without // changing the user's setting. It takes priority over the setting. - shellPath = + const shellFamily = args.shellOverride || this.opts.getWindowsShell?.() || process.env.COMSPEC || 'powershell.exe' + const normalizedShellFamily = pathWin32.basename(shellFamily).toLowerCase() + // Why: shell selection can arrive either as a canonical setting value + // ('powershell.exe') or as a concrete PowerShell executable path from a + // one-off override. Normalize both forms back to the PowerShell family so + // the shared resolver can still fall back to inbox powershell.exe when + // pwsh.exe was requested but is unavailable. + const powerShellImplementation = this.opts.getWindowsPowerShellImplementation?.() + const shouldResolvePowerShellFamily = + powerShellImplementation !== undefined || pathWin32.basename(shellFamily) === shellFamily + shellPath = shouldResolvePowerShellFamily + ? (resolveEffectiveWindowsPowerShell({ + shellFamily: + normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' + ? 'powershell.exe' + : normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe' + ? normalizedShellFamily + : undefined, + implementation: powerShellImplementation, + pwshAvailable: this.opts.pwshAvailable?.() ?? false + }) ?? shellFamily) + : shellFamily + // Why: one-off overrides and persisted shell-family selection keep the + // same priority, while the shared resolver chooses which PowerShell + // executable is safe to run right now if that family is selected. // Why: both this path and the daemon-subprocess path must derive the // same shellArgs for the same (shell, cwd) pair. The helper keeps CJK // UTF-8 setup (chcp 65001), PowerShell $PROFILE dot-sourcing, and the @@ -309,12 +360,28 @@ export class LocalPtyProvider implements IPtyProvider { } const onExitDisposable = proc.onExit(({ exitCode }) => { + // Why: neutralize proc.kill the instant the child is reaped, before any + // other work in this callback. node-pty's UnixTerminal installs a + // `_socket.once('close', () => this.kill('SIGHUP'))` handler at destroy + // time, but the master socket can also emit 'close' on natural exit + // between this onExit callback starting and destroyPtyProcess() running + // below. If 'close' wins, SIGHUP is dispatched to proc.pid — which on + // POSIX has already been reaped and may have been recycled to an + // unrelated process. Synchronous neutralization here closes that window. + // Windows is exempt: WindowsTerminal.destroy is implemented via kill(). + if (process.platform !== 'win32') { + ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} + } if (shellReadyTimeout) { clearTimeout(shellReadyTimeout) shellReadyTimeout = null } startupCommandCleanup?.() clearPtyState(id) + // Why: release the master ptmx fd on the natural-exit path — without + // this, a shell that exits cleanly (the common case) never releases its + // fd until the next GC. See docs/fix-pty-fd-leak.md. + destroyPtyProcess(proc) this.opts.onExit?.(id, exitCode) for (const cb of exitListeners) { cb({ id, code: exitCode }) @@ -348,7 +415,7 @@ export class LocalPtyProvider implements IPtyProvider { ptyProcesses.get(id)?.resize(cols, rows) } - async shutdown(id: string, _immediate: boolean): Promise { + async shutdown(id: string, _opts: { immediate?: boolean; keepHistory?: boolean }): Promise { const proc = ptyProcesses.get(id) if (!proc) { return @@ -364,6 +431,7 @@ export class LocalPtyProvider implements IPtyProvider { } catch { /* Process may already be dead */ } + destroyPtyProcess(proc) ptyProcesses.delete(id) ptyShellName.delete(id) ptyLoadGeneration.delete(id) @@ -386,11 +454,19 @@ export class LocalPtyProvider implements IPtyProvider { } async getCwd(id: string): Promise { - if (!ptyProcesses.has(id)) { - throw new Error(`PTY ${id} not found`) + const proc = ptyProcesses.get(id) + // Why: return '' (not throw) on unknown id — the renderer treats empty + // as "no result, try the next fallback layer". Throwing would surface a + // noisy rejection for a non-exceptional case (PTY just exited, pane + // still has its old id). + if (!proc) { + return '' } - // node-pty doesn't expose cwd; would need /proc on Linux or lsof on macOS - return '' + // Why: resolveProcessCwd returns '' when it can't resolve — let that + // empty surface so the renderer's fallback chain decides what to do. + // Handing back a fabricated initialCwd here would lie to the renderer + // and short-circuit that chain. + return resolveProcessCwd(proc.pid) } async getInitialCwd(_id: string): Promise { return '' diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 7ad3c09b902..b15d8f59bbc 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -210,13 +210,15 @@ __orca_restore_attribution_path() { export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH" } __orca_restore_attribution_path -# Why: emit OSC 133;A only after the user's startup hooks finish so Orca knows -# the prompt is actually ready for a long startup command paste. +# Why: zsh precmd runs before the prompt is drawn and before zle owns input, +# which can double-echo startup commands. line-init fires when zle is ready. if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then __orca_prompt_mark() { printf "\\033]133;A\\007" } - precmd_functions=(\${precmd_functions[@]} __orca_prompt_mark) + autoload -Uz add-zle-hook-widget + zle -N __orca_prompt_mark + add-zle-hook-widget line-init __orca_prompt_mark fi ` const bashRc = getBashShellReadyRcfileContent() diff --git a/src/main/providers/process-cwd.ts b/src/main/providers/process-cwd.ts new file mode 100644 index 00000000000..326a61b426d --- /dev/null +++ b/src/main/providers/process-cwd.ts @@ -0,0 +1,95 @@ +import { execFile as execFileCb } from 'child_process' +import { readlink } from 'fs/promises' +import { promisify } from 'util' + +const execFile = promisify(execFileCb) + +/** + * Resolve the current working directory of a local process by pid. + * + * Why duplicated from `src/relay/pty-shell-utils.ts`: the relay and Electron + * main process have separate build graphs, and cross-importing across them + * is not a pattern used in this repo. The function is short and pure, and + * the duplication is cheaper than reshaping both bundle graphs. + * + * Tries `/proc//cwd` on Linux, falls back to `lsof -d cwd` on macOS. + * Returns `''` when neither works (including Windows, where `/proc` is + * absent and `lsof` is not native). + * + * Results are coalesced and briefly cached per-pid: rapid repeat calls + * (e.g. chained Cmd+D on macOS) reuse a single `lsof` child rather than + * stacking concurrent subprocesses whose results the caller may discard + * after its own timeout. + */ +const CACHE_TTL_MS = 1500 +const LSOF_TIMEOUT_MS = 1500 + +type CacheEntry = { value: string; at: number } +const resultCache = new Map() +const inflight = new Map>() + +export async function resolveProcessCwd(pid: number): Promise { + // Assumes the caller holds a live reference to `pid` for the TTL window. + // If a pid is recycled within 1.5s of a prior query, the cache would + // return the previous process's cwd — acceptable because our callers + // (getCwd on an active pty/session) can't outlive their pid. + const now = Date.now() + const cached = resultCache.get(pid) + if (cached && now - cached.at < CACHE_TTL_MS) { + return cached.value + } + if (cached) { + // Evict stale entry on read so the map can't grow unbounded across a + // long-lived daemon session. + resultCache.delete(pid) + } + const existing = inflight.get(pid) + if (existing) { + return existing + } + // Populate the cache inside the shared promise chain so every awaiter + // (including any second caller that joined via `inflight`) observes the + // result through the same write, rather than racing on a post-await set. + const promise = doResolve(pid).then((value) => { + resultCache.set(pid, { value, at: Date.now() }) + inflight.delete(pid) + return value + }) + inflight.set(pid, promise) + return promise +} + +async function doResolve(pid: number): Promise { + // Why: skip an existsSync gate and just try the readlink. The check+read + // pair races a concurrent process exit the same way the lsof+existsSync + // pair did, and the catch already falls through to lsof. + try { + return await readlink(`/proc/${pid}/cwd`) + } catch { + /* fall through */ + } + + try { + // Why: `-a` ANDs the -p and -d filters. Without it, macOS lsof ORs them + // and emits cwd records for every process on the system, so the n-line + // scan below picks up the first unrelated process (often pid ~391 with + // cwd `/`) and returns `/` regardless of the target pid's real cwd. + const { stdout } = await execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { + encoding: 'utf-8', + timeout: LSOF_TIMEOUT_MS + }) + for (const line of stdout.split('\n')) { + if (line.startsWith('n') && line.includes('/')) { + // Why: lsof -d cwd is authoritative — don't second-guess it with + // existsSync. A concurrent rmdir would race the check and cause us + // to drop the correct answer; node-pty handles a missing cwd on + // spawn anyway. + return line.slice(1) + } + } + } catch { + /* fall through */ + } + + return '' +} diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 3ccfbb11746..0dc4ac02060 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -41,6 +41,19 @@ describe('SshGitProvider', () => { expect(result).toEqual(statusResult) }) + it('commit sends git.commit request', async () => { + const commitResult = { success: true } + mux.request.mockResolvedValue(commitResult) + + const result = await provider.commit('/home/user/repo', 'feat: add source control commit') + + expect(mux.request).toHaveBeenCalledWith('git.commit', { + worktreePath: '/home/user/repo', + message: 'feat: add source control commit' + }) + expect(result).toEqual(commitResult) + }) + it('getDiff sends git.diff request', async () => { const diffResult = { kind: 'text', originalContent: '', modifiedContent: 'hello' } mux.request.mockResolvedValue(diffResult) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 1d91fc561f5..a9fb8bc06a1 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -26,11 +26,27 @@ export class SshGitProvider implements IGitProvider { return (await this.mux.request('git.status', { worktreePath })) as GitStatusResult } - async getDiff(worktreePath: string, filePath: string, staged: boolean): Promise { + async commit( + worktreePath: string, + message: string + ): Promise<{ success: boolean; error?: string }> { + return (await this.mux.request('git.commit', { + worktreePath, + message + })) as { success: boolean; error?: string } + } + + async getDiff( + worktreePath: string, + filePath: string, + staged: boolean, + compareAgainstHead?: boolean + ): Promise { return (await this.mux.request('git.diff', { worktreePath, filePath, - staged + staged, + compareAgainstHead })) as GitDiffResult } diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index fc4c1ca9170..e705ef8f552 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -122,8 +122,21 @@ describe('SshPtyProvider', () => { }) it('shutdown sends pty.shutdown request', async () => { - await provider.shutdown('pty-1', true) - expect(mux.request).toHaveBeenCalledWith('pty.shutdown', { id: 'pty-1', immediate: true }) + await provider.shutdown('pty-1', { immediate: true }) + expect(mux.request).toHaveBeenCalledWith('pty.shutdown', { + id: 'pty-1', + immediate: true, + keepHistory: false + }) + }) + + it('shutdown forwards keepHistory: true over the relay', async () => { + await provider.shutdown('pty-1', { immediate: true, keepHistory: true }) + expect(mux.request).toHaveBeenCalledWith('pty.shutdown', { + id: 'pty-1', + immediate: true, + keepHistory: true + }) }) it('sendSignal sends pty.sendSignal request', async () => { diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 8d251ca3bd4..652c28922d2 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -121,8 +121,12 @@ export class SshPtyProvider implements IPtyProvider { this.mux.notify('pty.resize', { id, cols, rows }) } - async shutdown(id: string, immediate: boolean): Promise { - await this.mux.request('pty.shutdown', { id, immediate }) + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { + await this.mux.request('pty.shutdown', { + id, + immediate: opts.immediate ?? false, + keepHistory: opts.keepHistory ?? false + }) } async sendSignal(id: string, signal: string): Promise { diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 3125ba797be..6f36505ec2a 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -30,6 +30,12 @@ export type PtySpawnOptions = { * changing the user's persistent default shell setting. Only consulted on * Windows; ignored on macOS/Linux where shell selection is not exposed. */ shellOverride?: string + /** Why: PowerShell is the top-level shell family in product terms, but on + * Windows we may need to choose between inbox Windows PowerShell 5.1 and + * pwsh.exe at spawn time. Threading the persisted implementation choice + * through spawn options keeps local PTY and daemon PTY semantics aligned + * without promoting pwsh into a separate shell family. */ + terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' } export type PtySpawnResult = { @@ -73,7 +79,7 @@ export type IPtyProvider = { attach(id: string): Promise write(id: string, data: string): void resize(id: string, cols: number, rows: number): void - shutdown(id: string, immediate: boolean): Promise + shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise sendSignal(id: string, signal: string): Promise getCwd(id: string): Promise getInitialCwd(id: string): Promise @@ -126,7 +132,13 @@ export type IFilesystemProvider = { export type IGitProvider = { getStatus(worktreePath: string): Promise - getDiff(worktreePath: string, filePath: string, staged: boolean): Promise + commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }> + getDiff( + worktreePath: string, + filePath: string, + staged: boolean, + compareAgainstHead?: boolean + ): Promise stageFile(worktreePath: string, filePath: string): Promise unstageFile(worktreePath: string, filePath: string): Promise bulkStageFiles(worktreePath: string, filePaths: string[]): Promise diff --git a/src/main/providers/windows-powershell.test.ts b/src/main/providers/windows-powershell.test.ts new file mode 100644 index 00000000000..6a9a69468c5 --- /dev/null +++ b/src/main/providers/windows-powershell.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { resolveEffectiveWindowsPowerShell } from './windows-powershell' + +describe('resolveEffectiveWindowsPowerShell', () => { + it('returns null for non-PowerShell shell families', () => { + expect( + resolveEffectiveWindowsPowerShell({ + shellFamily: 'cmd.exe', + implementation: 'pwsh.exe', + pwshAvailable: true + }) + ).toBeNull() + + expect( + resolveEffectiveWindowsPowerShell({ + shellFamily: 'wsl.exe', + implementation: 'powershell.exe', + pwshAvailable: true + }) + ).toBeNull() + + expect( + resolveEffectiveWindowsPowerShell({ + shellFamily: undefined, + implementation: 'pwsh.exe', + pwshAvailable: true + }) + ).toBeNull() + }) + + it('returns powershell.exe when the saved implementation is powershell.exe', () => { + expect( + resolveEffectiveWindowsPowerShell({ + shellFamily: 'powershell.exe', + implementation: 'powershell.exe', + pwshAvailable: true + }) + ).toBe('powershell.exe') + }) + + it('returns pwsh.exe when the saved implementation is pwsh.exe and pwsh is available', () => { + expect( + resolveEffectiveWindowsPowerShell({ + shellFamily: 'powershell.exe', + implementation: 'pwsh.exe', + pwshAvailable: true + }) + ).toBe('pwsh.exe') + }) + + it('falls back to powershell.exe when pwsh is preferred but unavailable', () => { + expect( + resolveEffectiveWindowsPowerShell({ + shellFamily: 'powershell.exe', + implementation: 'pwsh.exe', + pwshAvailable: false + }) + ).toBe('powershell.exe') + }) + + it('uses pwsh.exe for Auto when pwsh is available', () => { + expect( + resolveEffectiveWindowsPowerShell({ + shellFamily: 'powershell.exe', + implementation: 'auto', + pwshAvailable: true + }) + ).toBe('pwsh.exe') + }) + + it('uses powershell.exe for Auto when pwsh is unavailable', () => { + expect( + resolveEffectiveWindowsPowerShell({ + shellFamily: 'powershell.exe', + implementation: 'auto', + pwshAvailable: false + }) + ).toBe('powershell.exe') + }) + + it('defaults to Auto when no implementation is persisted', () => { + expect( + resolveEffectiveWindowsPowerShell({ + shellFamily: 'powershell.exe', + implementation: undefined, + pwshAvailable: true + }) + ).toBe('pwsh.exe') + }) +}) diff --git a/src/main/providers/windows-powershell.ts b/src/main/providers/windows-powershell.ts new file mode 100644 index 00000000000..7b0917fc4fc --- /dev/null +++ b/src/main/providers/windows-powershell.ts @@ -0,0 +1,27 @@ +export type WindowsPowerShellImplementation = 'auto' | 'powershell.exe' | 'pwsh.exe' + +/** Resolve which PowerShell executable to spawn right now on Windows. + * + * Why: keep the saved pwsh preference intact even when pwsh is temporarily + * unavailable, so runtime falls back safely without mutating user settings. + */ +export function resolveEffectiveWindowsPowerShell(args: { + shellFamily: 'powershell.exe' | 'cmd.exe' | 'wsl.exe' | undefined + implementation: WindowsPowerShellImplementation | undefined + pwshAvailable: boolean +}): 'powershell.exe' | 'pwsh.exe' | null { + if (args.shellFamily !== 'powershell.exe') { + return null + } + + if ( + (args.implementation === undefined || + args.implementation === 'auto' || + args.implementation === 'pwsh.exe') && + args.pwshAvailable + ) { + return 'pwsh.exe' + } + + return 'powershell.exe' +} diff --git a/src/main/pwsh.test.ts b/src/main/pwsh.test.ts new file mode 100644 index 00000000000..2aeb91fa170 --- /dev/null +++ b/src/main/pwsh.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileSyncMock } = vi.hoisted(() => ({ + execFileSyncMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + execFileSync: execFileSyncMock +})) + +function setPlatform(platform: NodeJS.Platform): () => void { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) + + return () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } +} + +describe('isPwshAvailable', () => { + beforeEach(() => { + vi.resetModules() + execFileSyncMock.mockReset() + }) + + it('returns false on non-Windows platforms', async () => { + const restorePlatform = setPlatform('linux') + + try { + const { isPwshAvailable } = await import('./pwsh') + expect(isPwshAvailable()).toBe(false) + expect(execFileSyncMock).not.toHaveBeenCalled() + } finally { + restorePlatform() + } + }) + + it('returns true when pwsh.exe is available on Windows', async () => { + const restorePlatform = setPlatform('win32') + execFileSyncMock.mockReturnValue('PowerShell 7.5.0') + + try { + const { isPwshAvailable } = await import('./pwsh') + expect(isPwshAvailable()).toBe(true) + expect(execFileSyncMock).toHaveBeenCalledWith('pwsh.exe', ['-Version'], { + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 5000 + }) + } finally { + restorePlatform() + } + }) + + it('returns false when pwsh.exe probe throws on Windows', async () => { + const restorePlatform = setPlatform('win32') + execFileSyncMock.mockImplementation(() => { + throw new Error('missing pwsh') + }) + + try { + const { isPwshAvailable } = await import('./pwsh') + expect(isPwshAvailable()).toBe(false) + } finally { + restorePlatform() + } + }) + + it('reuses the cached result across repeated calls', async () => { + const restorePlatform = setPlatform('win32') + execFileSyncMock.mockReturnValue('PowerShell 7.5.0') + + try { + const { isPwshAvailable } = await import('./pwsh') + expect(isPwshAvailable()).toBe(true) + expect(isPwshAvailable()).toBe(true) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + } finally { + restorePlatform() + } + }) +}) diff --git a/src/main/pwsh.ts b/src/main/pwsh.ts new file mode 100644 index 00000000000..c29881aa70d --- /dev/null +++ b/src/main/pwsh.ts @@ -0,0 +1,31 @@ +import { execFileSync } from 'child_process' + +// Cached pwsh availability check — evaluated once per process lifetime +let pwshAvailableCache: boolean | null = null + +/** + * Check whether pwsh.exe is available on this Windows machine. + * Result is cached for the process lifetime. + */ +export function isPwshAvailable(): boolean { + if (pwshAvailableCache !== null) { + return pwshAvailableCache + } + + if (process.platform !== 'win32') { + pwshAvailableCache = false + return false + } + + try { + execFileSync('pwsh.exe', ['-Version'], { + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 5000 + }) + pwshAvailableCache = true + } catch { + pwshAvailableCache = false + } + + return pwshAvailableCache +} diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index f85518f4c40..312c65a8cdc 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -6,6 +6,7 @@ import { net, session } from 'electron' import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' import { fetchViaPty } from './claude-pty' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { readManagedClaudeKeychainCredentials } from '../claude-accounts/keychain' const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const OAUTH_BETA_HEADER = 'oauth-2025-04-20' @@ -75,6 +76,25 @@ type KeychainCredentials = { } } +// Why: factored out so both the active-account Keychain reader and the +// managed-account reader share the same JSON parsing + expiry check. +function parseOAuthTokenFromCredentialsJson(raw: string): string | null { + try { + const parsed = JSON.parse(raw) as KeychainCredentials + const token = parsed?.claudeAiOauth?.accessToken + if (!token || typeof token !== 'string') { + return null + } + const expiresAt = parsed.claudeAiOauth?.expiresAt + if (typeof expiresAt === 'number' && expiresAt < Date.now()) { + return null + } + return token + } catch { + return null + } +} + /** * Read OAuth token from macOS Keychain. * Why: Claude Code v2.x+ stores OAuth credentials in the macOS Keychain @@ -103,24 +123,7 @@ async function readFromKeychain(): Promise { resolve(null) return } - try { - const parsed = JSON.parse(stdout.trim()) as KeychainCredentials - const token = parsed?.claudeAiOauth?.accessToken - if (!token || typeof token !== 'string') { - resolve(null) - return - } - - const expiresAt = parsed.claudeAiOauth?.expiresAt - if (typeof expiresAt === 'number' && expiresAt < Date.now()) { - resolve(null) - return - } - - resolve(token) - } catch { - resolve(null) - } + resolve(parseOAuthTokenFromCredentialsJson(stdout.trim())) } ) }) @@ -314,3 +317,50 @@ export async function fetchClaudeRateLimits(options?: { status: 'unavailable' } } + +// --------------------------------------------------------------------------- +// Managed account usage (inactive accounts — fetch-on-open) +// --------------------------------------------------------------------------- + +export type InactiveClaudeAccountInfo = { + id: string + managedAuthPath: string +} + +// Why: reads an inactive account's OAuth token directly from its managed +// storage without materializing credentials into the shared runtime location. +// Using ClaudeRuntimeAuthService would overwrite the active account's auth. +async function readManagedOAuthToken(account: InactiveClaudeAccountInfo): Promise { + try { + if (process.platform === 'darwin') { + const raw = await readManagedClaudeKeychainCredentials(account.id) + if (raw) { + return parseOAuthTokenFromCredentialsJson(raw) + } + return null + } + return await readFromCredentialsFile(account.managedAuthPath) + } catch { + return null + } +} + +export async function fetchManagedAccountUsage( + account: InactiveClaudeAccountInfo +): Promise { + const token = await readManagedOAuthToken(account) + if (!token) { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'No credentials', + status: 'error' + } + } + // Why: PTY fallback is intentionally omitted for inactive accounts. The PTY + // path materializes credentials via ClaudeRuntimeAuthService, which would + // interfere with the active account's auth state. + return fetchViaOAuth(token) +} diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index 1ae686cebf9..53a4b4e5504 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -4,6 +4,7 @@ import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-au import { applyClaudeEnvPatch } from '../claude-accounts/environment' const PTY_TIMEOUT_MS = 25_000 +const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit // --------------------------------------------------------------------------- // PTY fallback — spawn interactive `claude`, send `/usage`, parse the TUI @@ -141,12 +142,11 @@ export async function fetchViaPty(options?: { const claudeCommand = resolveClaudeCommand() // Why: node-pty cannot spawn .cmd/.bat batch scripts directly on Windows — - // those need cmd.exe as an interpreter. resolveClaudeCommand() may also fall - // back to bare 'claude' when it can't locate the binary on disk, yet cmd.exe - // can still find claude.cmd via PATHEXT. Always route through cmd.exe on win32. + // those need cmd.exe as an interpreter. Always route through cmd.exe on win32 + // and ensure the command path is properly quoted if it contains spaces. const isWin32 = process.platform === 'win32' const spawnFile = isWin32 ? 'cmd.exe' : claudeCommand - const spawnArgs = isWin32 ? ['/c', claudeCommand] : [] + const spawnArgs = isWin32 ? ['/c', `"${claudeCommand}"`] : [] const spawnEnv = applyClaudeEnvPatch( { ...process.env, TERM: 'xterm-256color' } as Record, @@ -210,7 +210,7 @@ export async function fetchViaPty(options?: { return } enterInterval = setInterval(() => { - if (!resolved) { + if (!resolved && !stopDetected) { term.write('\r') } }, 800) @@ -266,6 +266,10 @@ export async function fetchViaPty(options?: { const onDataDisposable = term.onData((data) => { output += data + // Why: prevent memory exhaustion if the CLI process floods output + if (output.length > MAX_OUTPUT_LENGTH) { + output = output.slice(-MAX_OUTPUT_LENGTH) + } // eslint-disable-next-line no-control-regex const cleanChunk = data.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') diff --git a/src/main/rate-limits/gemini-bucket-formatting.test.ts b/src/main/rate-limits/gemini-bucket-formatting.test.ts new file mode 100644 index 00000000000..b806fbef902 --- /dev/null +++ b/src/main/rate-limits/gemini-bucket-formatting.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import type { RateLimitBucket } from '../../shared/rate-limit-types' +import { getBucketName, deriveSessionSummary } from './gemini-bucket-formatting' + +describe('getBucketName', () => { + it('maps known model IDs to stable names', () => { + expect(getBucketName('gemini-2.5-pro')).toBe('Pro') + expect(getBucketName('gemini-2.5-flash')).toBe('Flash') + expect(getBucketName('gemini-2.5-flash-lite')).toBe('Flash Lite') + expect(getBucketName('gemini-2.0-flash-lite')).toBe('2.0 Flash Lite') + expect(getBucketName('gemini-2.0-flash')).toBe('2.0 Flash') + }) + + it('humanizes unknown model IDs by stripping the gemini- prefix', () => { + expect(getBucketName('gemini-3.0-ultra')).toBe('3.0 Ultra') + expect(getBucketName('gemini-experimental')).toBe('Exp') + expect(getBucketName('some-random-id')).toBe('Some Random Id') + }) +}) + +describe('deriveSessionSummary', () => { + it('returns null for empty buckets', () => { + expect(deriveSessionSummary([])).toBeNull() + }) + + it('picks the most constrained bucket (highest usedPercent) as session summary', () => { + const buckets: RateLimitBucket[] = [ + { name: 'Pro', usedPercent: 30, windowMinutes: 60, resetsAt: null, resetDescription: null }, + { name: 'Flash', usedPercent: 80, windowMinutes: 60, resetsAt: null, resetDescription: null }, + { + name: 'Flash Lite', + usedPercent: 10, + windowMinutes: 60, + resetsAt: null, + resetDescription: null + } + ] + const summary = deriveSessionSummary(buckets) + expect(summary).not.toBeNull() + expect(summary!.usedPercent).toBe(80) + expect(summary!.windowMinutes).toBe(60) + }) + + it('preserves reset metadata from the most constrained bucket', () => { + const buckets: RateLimitBucket[] = [ + { + name: 'Pro', + usedPercent: 30, + windowMinutes: 60, + resetsAt: 1000, + resetDescription: '2:00 PM' + }, + { + name: 'Flash', + usedPercent: 80, + windowMinutes: 60, + resetsAt: 2000, + resetDescription: '3:00 PM' + } + ] + const summary = deriveSessionSummary(buckets) + expect(summary!.resetsAt).toBe(2000) + expect(summary!.resetDescription).toBe('3:00 PM') + }) +}) diff --git a/src/main/rate-limits/gemini-bucket-formatting.ts b/src/main/rate-limits/gemini-bucket-formatting.ts new file mode 100644 index 00000000000..86cf9cea763 --- /dev/null +++ b/src/main/rate-limits/gemini-bucket-formatting.ts @@ -0,0 +1,84 @@ +import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types' + +const MODEL_ID_TO_BUCKET_NAME: Record = { + 'gemini-3.1-pro': '3.1 Pro', + 'gemini-3.1-flash': '3.1 Flash', + 'gemini-3.1-flash-lite': '3.1 Flash Lite', + 'gemini-3.0-pro': '3.0 Pro', + 'gemini-3.0-flash': '3.0 Flash', + 'gemini-2.5-pro': 'Pro', + 'gemini-2.5-flash': 'Flash', + 'gemini-2.5-flash-lite': 'Flash Lite', + 'gemini-2.0-pro': '2.0 Pro', + 'gemini-2.0-flash': '2.0 Flash', + 'gemini-2.0-flash-lite': '2.0 Flash Lite', + 'gemini-1.5-pro': '1.5 Pro', + 'gemini-1.5-flash': '1.5 Flash', + 'gemini-exp': 'Exp', + 'gemini-experimental': 'Exp' +} + +function humanizeModelId(modelId: string): string { + const withoutPrefix = modelId.replace(/^gemini-/i, '') + return withoutPrefix + .split('-') + .map((part) => (part.length > 0 ? part[0]!.toUpperCase() + part.slice(1) : part)) + .join(' ') +} + +export function getBucketName(modelId: string): string { + return MODEL_ID_TO_BUCKET_NAME[modelId] ?? humanizeModelId(modelId) +} + +export function buildRateLimitBucket(b: { + remainingFraction: number + resetTime: string + modelId: string +}): RateLimitBucket { + const usedPercent = Math.min(100, Math.max(0, Math.round((1 - b.remainingFraction) * 100))) + const resetsAtTime = new Date(b.resetTime).getTime() + return { + name: getBucketName(b.modelId), + usedPercent, + windowMinutes: 60, + resetsAt: !isNaN(resetsAtTime) ? resetsAtTime : null, + resetDescription: null + } +} + +export function deduplicateBuckets( + buckets: (RateLimitBucket & { modelId: string })[] +): RateLimitBucket[] { + const result: (RateLimitBucket & { modelId: string })[] = [] + const seenKeys = new Map() + for (const b of buckets) { + const key = `${b.usedPercent}-${b.resetsAt}` + const existingIndex = seenKeys.get(key) + if (existingIndex === undefined) { + seenKeys.set(key, result.length) + result.push(b) + continue + } + const existing = result[existingIndex]! + const existingInMap = existing.modelId in MODEL_ID_TO_BUCKET_NAME + const currentInMap = b.modelId in MODEL_ID_TO_BUCKET_NAME + if ( + (currentInMap && !existingInMap) || + (currentInMap === existingInMap && b.name.length < existing.name.length) + ) { + result[existingIndex] = b + } + } + return result.map(({ modelId: _id, ...rest }) => rest) +} + +export function deriveSessionSummary(buckets: RateLimitBucket[]): RateLimitWindow | null { + if (buckets.length === 0) { + return null + } + const mostConstrained = buckets.reduce((worst, bucket) => { + return bucket.usedPercent > worst.usedPercent ? bucket : worst + }) + const { name: _name, ...window } = mostConstrained + return window +} diff --git a/src/main/rate-limits/gemini-cli-oauth-extractor.ts b/src/main/rate-limits/gemini-cli-oauth-extractor.ts new file mode 100644 index 00000000000..80e841e7adf --- /dev/null +++ b/src/main/rate-limits/gemini-cli-oauth-extractor.ts @@ -0,0 +1,262 @@ +import { exec } from 'node:child_process' +import { access, readdir, readFile, realpath } from 'node:fs/promises' +import { promisify } from 'node:util' +import { homedir } from 'node:os' +import path from 'node:path' + +const execAsync = promisify(exec) + +async function fileExists(filePath: string): Promise { + try { + await access(filePath) + return true + } catch { + return false + } +} + +// The oauth2.js relative path inside a @google/gemini-cli-core package. +const OAUTH2_SUBPATH = path.join('dist', 'src', 'code_assist', 'oauth2.js') + +async function resolveGeminiBinary(): Promise { + const whichCmd = process.platform === 'win32' ? 'where gemini' : 'which gemini' + try { + const { stdout } = await execAsync(whichCmd, { encoding: 'utf-8' }) + const fromPath = stdout.trim().split(/\r?\n/)[0] + if (fromPath && (await fileExists(fromPath))) { + return fromPath + } + } catch { + // ignore which/where failure + } + + // Why: on macOS/Linux GUI apps, the PATH might not include the binary. + // Checking common installation prefixes as fallbacks. + if (process.platform !== 'win32') { + const fallbacks = [ + '/usr/local/bin/gemini', + '/opt/homebrew/bin/gemini', + path.join(homedir(), '.local', 'bin', 'gemini'), + path.join(homedir(), 'bin', 'gemini') + ] + for (const candidate of fallbacks) { + if (await fileExists(candidate)) { + return candidate + } + } + } + + return null +} + +// Why: on all platforms the gemini binary may be a symlink (e.g. Homebrew's bin/ +// symlinks into Cellar). We must resolve it before deriving sibling paths — otherwise +// dirname points to the symlink directory, not the real installation root. +async function resolveSymlink(filePath: string): Promise { + try { + return await realpath(filePath) + } catch { + return filePath + } +} + +function parseOAuthCredentials(content: string): { clientId: string; clientSecret: string } | null { + const idMatch = content.match(/OAUTH_CLIENT_ID\s*=\s*['"]([^'"]+)['"]/)?.[1] + const secretMatch = content.match(/OAUTH_CLIENT_SECRET\s*=\s*['"]([^'"]+)['"]/)?.[1] + if (idMatch && secretMatch) { + return { clientId: idMatch, clientSecret: secretMatch } + } + return null +} + +async function tryReadCredentials( + filePath: string +): Promise<{ clientId: string; clientSecret: string } | null> { + try { + const content = await readFile(filePath, 'utf-8') + return parseOAuthCredentials(content) + } catch { + return null + } +} + +// Why: these are the known stable layouts for every major Gemini CLI install method. +// Checking explicit paths is fast and avoids walking the entire directory tree. +async function extractFromKnownPaths( + realGeminiPath: string +): Promise<{ clientId: string; clientSecret: string } | null> { + const binDir = path.dirname(realGeminiPath) + const baseDir = path.dirname(binDir) + + const candidates = [ + // Homebrew: bin -> Cellar//bin, real files live under libexec/lib + path.join( + baseDir, + 'libexec', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'node_modules', + '@google', + 'gemini-cli-core', + OAUTH2_SUBPATH + ), + // Homebrew alternate (some versions skip the extra nesting) + path.join( + baseDir, + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'node_modules', + '@google', + 'gemini-cli-core', + OAUTH2_SUBPATH + ), + // Nix package layout + path.join( + baseDir, + 'share', + 'gemini-cli', + 'node_modules', + '@google', + 'gemini-cli-core', + OAUTH2_SUBPATH + ), + // npm/bun global install: gemini-cli-core is a sibling of gemini-cli + path.join(baseDir, '..', 'gemini-cli-core', OAUTH2_SUBPATH), + // npm nested inside gemini-cli + path.join(baseDir, 'node_modules', '@google', 'gemini-cli-core', OAUTH2_SUBPATH) + ] + + for (const candidate of candidates) { + const creds = await tryReadCredentials(path.normalize(candidate)) + if (creds) { + return creds + } + } + + return null +} + +// Why: newer Gemini CLI versions (>=0.38) ship everything bundled into hash-named +// chunks with no oauth2.js source file. Scanning the bundle dir for the credential +// constants is the only reliable fallback for those installs. +async function extractFromBundleDir( + geminiCliPackageRoot: string +): Promise<{ clientId: string; clientSecret: string } | null> { + const bundleDir = path.join(geminiCliPackageRoot, 'bundle') + if (!(await fileExists(bundleDir))) { + return null + } + + let entries: string[] + try { + entries = (await readdir(bundleDir)).filter((f) => f.endsWith('.js')) + } catch { + return null + } + + for (const entry of entries) { + const creds = await tryReadCredentials(path.join(bundleDir, entry)) + if (creds) { + return creds + } + } + + return null +} + +// Resolves the gemini-cli package root directory by walking up the directory +// tree from the real binary path, looking for package.json with the right name, +// or the global Node layout under lib/node_modules. +async function findGeminiPackageRoot(realGeminiPath: string): Promise { + const MAX_ASCENTS = 8 + let current = path.dirname(realGeminiPath) + + for (let i = 0; i <= MAX_ASCENTS; i++) { + const pkgJson = path.join(current, 'package.json') + if (await fileExists(pkgJson)) { + try { + const raw = await readFile(pkgJson, 'utf-8') + const pkg = JSON.parse(raw) as { name?: string } + if (pkg.name === '@google/gemini-cli') { + return current + } + } catch { + // malformed package.json — keep walking + } + } + + // Global Node layout: /lib/node_modules/@google/gemini-cli + const globalPkg = path.join( + current, + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'package.json' + ) + if (await fileExists(globalPkg)) { + return path.join(current, 'lib', 'node_modules', '@google', 'gemini-cli') + } + + // Windows global install layout: /node_modules/@google/gemini-cli + const windowsGlobalPkg = path.join( + current, + 'node_modules', + '@google', + 'gemini-cli', + 'package.json' + ) + if (await fileExists(windowsGlobalPkg)) { + return path.join(current, 'node_modules', '@google', 'gemini-cli') + } + + const parent = path.dirname(current) + if (parent === current) { + break + } + current = parent + } + + return null +} + +export async function extractOAuthClientCredentials(): Promise<{ + clientId: string + clientSecret: string +} | null> { + const geminiPath = await resolveGeminiBinary() + if (!geminiPath) { + return null + } + + const realPath = await resolveSymlink(geminiPath) + + // 1. Known static paths (fast, covers most installs with source layout) + const fromKnown = await extractFromKnownPaths(realPath) + if (fromKnown) { + return fromKnown + } + + // 2. Walk up to find the package root, then try source layout + bundle dir + const packageRoot = await findGeminiPackageRoot(realPath) + if (packageRoot) { + const fromSource = + (await tryReadCredentials( + path.join(packageRoot, 'node_modules', '@google', 'gemini-cli-core', OAUTH2_SUBPATH) + )) ?? (await tryReadCredentials(path.join(packageRoot, OAUTH2_SUBPATH))) + if (fromSource) { + return fromSource + } + + const fromBundle = await extractFromBundleDir(packageRoot) + if (fromBundle) { + return fromBundle + } + } + + return null +} diff --git a/src/main/rate-limits/gemini-oauth-sources.ts b/src/main/rate-limits/gemini-oauth-sources.ts new file mode 100644 index 00000000000..fec013ce745 --- /dev/null +++ b/src/main/rate-limits/gemini-oauth-sources.ts @@ -0,0 +1,177 @@ +import { readFile, writeFile, rename } from 'node:fs/promises' +import { homedir } from 'node:os' +import path from 'node:path' +import { net } from 'electron' +import { extractOAuthClientCredentials } from './gemini-cli-oauth-extractor' + +const API_TIMEOUT_MS = 10_000 +const OAUTH_CREDS_PATH = path.join(homedir(), '.gemini', 'oauth_creds.json') +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token' +const LOAD_CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist' + +export type GeminiCredentials = { + access_token: string + refresh_token: string + expiry_date: number +} + +export type GoogleAuthEntry = { + type: 'oauth' + access: string + expires: number + refresh: string +} + +type AuthJson = { + google?: GoogleAuthEntry + 'opencode-go'?: { type: 'api'; key: string } +} + +export async function readAuthJson(): Promise { + const candidates = [ + process.env.APPDATA ? path.join(process.env.APPDATA, 'opencode', 'auth.json') : null, + process.env.XDG_DATA_HOME + ? path.join(process.env.XDG_DATA_HOME, 'opencode', 'auth.json') + : null, + path.join(homedir(), '.local', 'share', 'opencode', 'auth.json'), + path.join(homedir(), 'Library', 'Application Support', 'opencode', 'auth.json') + ].filter((candidate): candidate is string => candidate !== null) + + for (const candidate of candidates) { + try { + const raw = await readFile(candidate, 'utf-8') + return JSON.parse(raw) as AuthJson + } catch (err) { + if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { + continue + } + throw err + } + } + + return null +} + +export async function readGeminiCredentials(): Promise { + try { + const raw = await readFile(OAUTH_CREDS_PATH, 'utf-8') + const parsed = JSON.parse(raw) as unknown + if ( + parsed && + typeof parsed === 'object' && + 'access_token' in parsed && + typeof parsed.access_token === 'string' && + 'refresh_token' in parsed && + typeof parsed.refresh_token === 'string' && + 'expiry_date' in parsed && + typeof parsed.expiry_date === 'number' + ) { + return parsed as GeminiCredentials + } + return null + } catch (err) { + if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { + return null + } + throw err + } +} + +export async function saveGeminiCredentials(creds: GeminiCredentials): Promise { + const tmpPath = `${OAUTH_CREDS_PATH}.${process.pid}.tmp` + await writeFile(tmpPath, JSON.stringify(creds, null, 2), 'utf-8') + await rename(tmpPath, OAUTH_CREDS_PATH) +} + +export type RefreshTokenResult = { + accessToken: string | null + newRefreshToken: string | null + expiresIn?: number +} + +export async function refreshAccessToken( + refreshToken: string, + clientId: string, + clientSecret: string +): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS) + + try { + const res = await net.fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token' + }).toString(), + signal: controller.signal + }) + + if (!res.ok) { + return { accessToken: null, newRefreshToken: null } + } + + const data = (await res.json()) as { + access_token?: string + refresh_token?: string + expires_in?: number + } + return { + accessToken: typeof data.access_token === 'string' ? data.access_token : null, + newRefreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : null, + expiresIn: typeof data.expires_in === 'number' ? data.expires_in : undefined + } + } finally { + clearTimeout(timeout) + } +} + +export async function loadProjectId(accessToken: string): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS) + + try { + const res = await net.fetch(LOAD_CODE_ASSIST_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}` + }, + body: JSON.stringify({ metadata: { ideType: 'GEMINI_CLI', pluginType: 'GEMINI' } }), + signal: controller.signal + }) + + if (!res.ok) { + throw new Error(`Failed to load Gemini project ID (HTTP ${res.status})`) + } + + const data = (await res.json()) as { cloudaicompanionProject?: string } + if (typeof data.cloudaicompanionProject !== 'string') { + throw new Error('Gemini project ID not found in API response') + } + return data.cloudaicompanionProject + } finally { + clearTimeout(timeout) + } +} + +// Why: accepts a plain refresh token string so both the oauth_creds.json path +// (GeminiCredentials) and the auth.json path (pipe-split string) can share +// the same bundle credential extraction without coupling to either struct. +export async function tryRefreshTokenFromBundle( + refreshToken: string, + allowCliOAuth = true +): Promise { + if (!allowCliOAuth) { + return null + } + const clientCreds = await extractOAuthClientCredentials() + if (!clientCreds) { + return null + } + + return refreshAccessToken(refreshToken, clientCreds.clientId, clientCreds.clientSecret) +} diff --git a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts new file mode 100644 index 00000000000..fddd0a77d59 --- /dev/null +++ b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + expiredCreds, + makeResponse, + quotaResponse, + validCreds +} from './gemini-usage-fetcher.test-fixtures' + +const { readFileMock, extractCredsMock, netFetchMock } = vi.hoisted(() => ({ + readFileMock: vi.fn(), + extractCredsMock: vi.fn(), + netFetchMock: vi.fn() +})) + +// Why: mock the CLI-credential extractor at the module boundary. The extractor +// is a self-contained dependency with a simple async contract (returns a +// { clientId, clientSecret } record or null). Mocking it here keeps these +// tests focused on the oauth_creds.json refresh → loadCodeAssist → quota +// flow rather than on filesystem plumbing, and avoids having to keep pace +// with extractor internals when they change. +vi.mock('./gemini-cli-oauth-extractor', () => ({ + extractOAuthClientCredentials: extractCredsMock +})) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock, + // Why: saveGeminiCredentials is exercised on the refresh path. The atomic + // tmp+rename write has no observable side effect in these tests, so the + // stubs just resolve. + writeFile: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('electron', () => ({ + net: { fetch: netFetchMock } +})) + +import { fetchGeminiRateLimits } from './gemini-usage-fetcher' + +describe('fetchGeminiRateLimits fallback oauth creds', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-24T12:00:00.000Z')) + readFileMock.mockReset() + extractCredsMock.mockReset() + netFetchMock.mockReset() + extractCredsMock.mockResolvedValue({ + clientId: 'client-id-123', + clientSecret: 'client-secret-456' + }) + }) + + it('falls back to oauth_creds.json when auth.json has no google key', async () => { + readFileMock.mockImplementation(async (filePath: string) => { + if (filePath.includes('auth.json')) { + return JSON.stringify({ 'opencode-go': { type: 'api', key: 'k' } }) + } + if (filePath.includes('oauth_creds.json')) { + return JSON.stringify(validCreds) + } + throw { code: 'ENOENT' } + }) + netFetchMock + .mockResolvedValueOnce(makeResponse({ cloudaicompanionProject: 'proj-123' })) + .mockResolvedValueOnce(makeResponse(quotaResponse)) + + const result = await fetchGeminiRateLimits(true) + + expect(result.status).toBe('ok') + expect(result.error).toBeNull() + expect(result.session).not.toBeNull() + }) + + it('falls back to oauth_creds.json and resolves project via loadCodeAssist', async () => { + readFileMock.mockImplementation(async (filePath: string) => { + if (filePath.includes('auth.json')) { + return JSON.stringify({}) + } + if (filePath.includes('oauth_creds.json')) { + return JSON.stringify(validCreds) + } + throw { code: 'ENOENT' } + }) + netFetchMock + .mockResolvedValueOnce(makeResponse({ cloudaicompanionProject: 'cli-proj-456' })) + .mockResolvedValueOnce(makeResponse(quotaResponse)) + + const result = await fetchGeminiRateLimits(true) + + expect(result.status).toBe('ok') + expect(result.error).toBeNull() + expect(result.session).not.toBeNull() + + const quotaCall = netFetchMock.mock.calls.find( + (call) => typeof call[0] === 'string' && call[0].includes('retrieveUserQuota') + ) + expect(quotaCall).toBeDefined() + const quotaBody = JSON.parse((quotaCall![1] as RequestInit).body as string) + expect(quotaBody.project).toBe('cli-proj-456') + }) + + it('refreshes via bundled client credentials when expiry passed', async () => { + readFileMock.mockImplementation(async (filePath: string) => { + if (filePath.includes('auth.json')) { + return JSON.stringify({}) + } + if (filePath.includes('oauth_creds.json')) { + return JSON.stringify(expiredCreds) + } + throw { code: 'ENOENT' } + }) + netFetchMock + .mockResolvedValueOnce( + makeResponse({ access_token: 'bundle-refreshed-token', expires_in: 3600 }) + ) + .mockResolvedValueOnce(makeResponse({ cloudaicompanionProject: 'cli-proj-456' })) + .mockResolvedValueOnce(makeResponse(quotaResponse)) + + const result = await fetchGeminiRateLimits(true) + + expect(result.status).toBe('ok') + expect(result.error).toBeNull() + expect(result.session).not.toBeNull() + + const refreshCall = netFetchMock.mock.calls.find( + (call) => typeof call[0] === 'string' && call[0].includes('oauth2.googleapis.com') + ) + expect(refreshCall).toBeDefined() + const refreshBody = new URLSearchParams((refreshCall![1] as RequestInit).body as string) + expect(refreshBody.get('client_id')).toBe('client-id-123') + expect(refreshBody.get('client_secret')).toBe('client-secret-456') + }) + + it('returns error when oauth_creds.json token expired and bundle refresh fails', async () => { + readFileMock.mockImplementation(async (filePath: string) => { + if (filePath.includes('auth.json')) { + return JSON.stringify({}) + } + if (filePath.includes('oauth_creds.json')) { + return JSON.stringify(expiredCreds) + } + throw { code: 'ENOENT' } + }) + // Simulate: no Gemini CLI installed, so the extractor returns null and + // tryRefreshTokenFromBundle can't obtain client credentials to refresh. + extractCredsMock.mockResolvedValue(null) + + const result = await fetchGeminiRateLimits(true) + + expect(result.status).toBe('error') + expect(result.error).toContain('Token refresh failed') + expect(result.session).toBeNull() + expect(result.weekly).toBeNull() + }) + + it('returns error when loadCodeAssist cannot resolve a project for oauth_creds path', async () => { + // Why: when the fallback (oauth_creds.json) path has no project embedded + // and loadCodeAssist fails, we surface a clear "project ID not found" + // error rather than silently posting an empty project to the quota API — + // an empty project causes a 400 that looks like an auth failure. + readFileMock.mockImplementation(async (filePath: string) => { + if (filePath.includes('auth.json')) { + return JSON.stringify({}) + } + if (filePath.includes('oauth_creds.json')) { + return JSON.stringify(validCreds) + } + throw { code: 'ENOENT' } + }) + netFetchMock.mockResolvedValueOnce(makeResponse('Internal Server Error', 500)) + + const result = await fetchGeminiRateLimits(true) + + expect(result.status).toBe('error') + expect(result.error).toContain('Gemini project ID not found') + }) + + it('returns unavailable without reading OAuth files when geminiCliOAuthEnabled=false', async () => { + const result = await fetchGeminiRateLimits(false) + + expect(result.status).toBe('unavailable') + expect(result.error).toContain('disabled') + expect(readFileMock).not.toHaveBeenCalled() + // No network calls should have been made. + expect(netFetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts b/src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts new file mode 100644 index 00000000000..d37053b3458 --- /dev/null +++ b/src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts @@ -0,0 +1,56 @@ +export function makeResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)) + } as Response +} + +export function makeDirent(name: string, isDir: boolean) { + return { + name, + isDirectory: () => isDir, + isFile: () => !isDir + } +} + +export const authJsonGoogle = { + google: { + type: 'oauth', + access: 'auth-json-access-token', + expires: new Date('2026-04-24T13:00:00.000Z').getTime(), + refresh: 'refresh-token-abc|proj-123|managed-456' + } +} as const + +export const authJsonGoogleExpired = { + google: { + type: 'oauth', + access: 'expired-access-token', + expires: new Date('2026-04-24T11:00:00.000Z').getTime(), + refresh: 'refresh-token-abc|proj-123|managed-456' + } +} as const + +export const validCreds = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expiry_date: new Date('2026-04-24T13:00:00.000Z').getTime() +} + +export const expiredCreds = { + access_token: 'expired-token', + refresh_token: 'refresh-token', + expiry_date: new Date('2026-04-24T11:00:00.000Z').getTime() +} + +export const oauth2JsContent = ` + const OAUTH_CLIENT_ID = 'client-id-123'; + const OAUTH_CLIENT_SECRET = 'client-secret-456'; +` + +export const quotaResponse = [ + { remainingFraction: 0.75, resetTime: '2026-04-24T13:00:00.000Z', modelId: 'gemini-2.5-pro' }, + { remainingFraction: 0.9, resetTime: '2026-04-24T14:00:00.000Z', modelId: 'gemini-2.5-flash' } +] diff --git a/src/main/rate-limits/gemini-usage-fetcher.test.ts b/src/main/rate-limits/gemini-usage-fetcher.test.ts new file mode 100644 index 00000000000..5f4e7388a55 --- /dev/null +++ b/src/main/rate-limits/gemini-usage-fetcher.test.ts @@ -0,0 +1,217 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + authJsonGoogle, + authJsonGoogleExpired, + makeResponse, + quotaResponse +} from './gemini-usage-fetcher.test-fixtures' + +const { readFileMock, extractCredsMock, netFetchMock } = vi.hoisted(() => ({ + readFileMock: vi.fn(), + extractCredsMock: vi.fn(), + netFetchMock: vi.fn() +})) + +// Why: mock the extractor at the module boundary rather than re-routing every +// child_process/fs call. The extractor is a self-contained dependency with a +// simple async contract; mocking it directly keeps tests focused on the +// fetcher's refresh/quota logic rather than on filesystem plumbing that has +// already been integration-tested elsewhere. +vi.mock('./gemini-cli-oauth-extractor', () => ({ + extractOAuthClientCredentials: extractCredsMock +})) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock, + writeFile: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) + +import { fetchGeminiRateLimits } from './gemini-usage-fetcher' + +describe('fetchGeminiRateLimits', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-24T12:00:00.000Z')) + readFileMock.mockReset() + extractCredsMock.mockReset() + netFetchMock.mockReset() + netFetchMock.mockImplementation((url: string) => { + if (url.includes('loadCodeAssist')) { + return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) + } + if (url.includes('token')) { + return Promise.resolve(makeResponse({ access_token: 'new-token', expires_in: 3600 })) + } + return Promise.resolve(makeResponse({ error: `Unhandled fetch to ${url}` }, 500)) + }) + // Default: no CLI installed, refresh path cannot find client credentials. + extractCredsMock.mockResolvedValue(null) + readFileMock.mockRejectedValue({ code: 'ENOENT' }) + }) + + const setupAuthJsonValid = () => { + readFileMock.mockImplementation(async (p: string) => { + if (p.includes('auth.json')) { + return JSON.stringify(authJsonGoogle) + } + throw { code: 'ENOENT' } + }) + } + const setupAuthJsonExpired = () => { + readFileMock.mockImplementation(async (p: string) => { + if (p.includes('auth.json')) { + return JSON.stringify(authJsonGoogleExpired) + } + throw { code: 'ENOENT' } + }) + } + + it('returns unavailable when no credentials exist', async () => { + const result = await fetchGeminiRateLimits(true) + expect(result.status).toBe('unavailable') + }) + + it('returns quota via auth.json', async () => { + setupAuthJsonValid() + netFetchMock.mockImplementation((url: string) => { + if (url.includes('retrieveUserQuota')) { + return Promise.resolve(makeResponse(quotaResponse)) + } + if (url.includes('loadCodeAssist')) { + return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) + } + return Promise.resolve(makeResponse({}, 404)) + }) + const result = await fetchGeminiRateLimits(true) + expect(result.status).toBe('ok') + expect(result.buckets).toHaveLength(2) + }) + + it('deduplicates buckets', async () => { + setupAuthJsonValid() + netFetchMock.mockImplementation((url: string) => { + if (url.includes('retrieveUserQuota')) { + return Promise.resolve( + makeResponse([ + { + remainingFraction: 0.82, + resetTime: '2026-04-24T13:00:00.000Z', + modelId: 'gemini-1.5-flash' + }, + { + remainingFraction: 0.82, + resetTime: '2026-04-24T13:00:00.000Z', + modelId: 'gemini-3-flash-preview' + } + ]) + ) + } + if (url.includes('loadCodeAssist')) { + return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) + } + return Promise.resolve(makeResponse({}, 404)) + }) + const result = await fetchGeminiRateLimits(true) + expect(result.status).toBe('ok') + expect(result.buckets).toHaveLength(1) + expect(result.buckets![0].name).toBe('1.5 Flash') + }) + + it('handles empty bucket list', async () => { + setupAuthJsonValid() + netFetchMock.mockImplementation((url: string) => { + if (url.includes('retrieveUserQuota')) { + return Promise.resolve(makeResponse([])) + } + if (url.includes('loadCodeAssist')) { + return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) + } + return Promise.resolve(makeResponse({}, 404)) + }) + const result = await fetchGeminiRateLimits(true) + expect(result.status).toBe('ok') + expect(result.buckets).toEqual([]) + }) + + it('returns error when token refresh fails', async () => { + vi.useRealTimers() + setupAuthJsonExpired() + const result = await fetchGeminiRateLimits(true) + expect(result.status).toBe('error') + expect(result.error).toContain('Token refresh failed') + vi.useFakeTimers() + }) + + it('handles wrapped buckets response', async () => { + setupAuthJsonValid() + netFetchMock.mockImplementation((url: string) => { + if (url.includes('retrieveUserQuota')) { + return Promise.resolve(makeResponse({ buckets: quotaResponse })) + } + if (url.includes('loadCodeAssist')) { + return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) + } + return Promise.resolve(makeResponse({}, 404)) + }) + const result = await fetchGeminiRateLimits(true) + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(25) + }) + + it('filters out NaN buckets', async () => { + setupAuthJsonValid() + netFetchMock.mockImplementation((url: string) => { + if (url.includes('retrieveUserQuota')) { + return Promise.resolve( + makeResponse([ + { + remainingFraction: NaN, + resetTime: '2026-04-24T13:00:00.000Z', + modelId: 'gemini-1.5-pro' + }, + { + remainingFraction: 0.9, + resetTime: '2026-04-24T13:00:00.000Z', + modelId: 'gemini-1.5-flash' + } + ]) + ) + } + if (url.includes('loadCodeAssist')) { + return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) + } + return Promise.resolve(makeResponse({}, 404)) + }) + const result = await fetchGeminiRateLimits(true) + expect(result.status).toBe('ok') + expect(result.buckets).toHaveLength(1) + }) + + it('retries refresh on 401', async () => { + setupAuthJsonValid() + extractCredsMock.mockResolvedValue({ clientId: 'cid', clientSecret: 'csec' }) + let quotaCallCount = 0 + netFetchMock.mockImplementation((url: string) => { + if (url.includes('retrieveUserQuota')) { + quotaCallCount += 1 + if (quotaCallCount === 1) { + return Promise.resolve(makeResponse({ error: 'Unauthenticated' }, 401)) + } + return Promise.resolve(makeResponse(quotaResponse)) + } + if (url.includes('token')) { + return Promise.resolve(makeResponse({ access_token: 'retried-token', expires_in: 3600 })) + } + if (url.includes('loadCodeAssist')) { + return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) + } + return Promise.resolve(makeResponse({}, 404)) + }) + const result = await fetchGeminiRateLimits(true) + expect(result.status).toBe('ok') + // The second quota call should have been made with the refreshed token. + expect(quotaCallCount).toBe(2) + }) +}) diff --git a/src/main/rate-limits/gemini-usage-fetcher.ts b/src/main/rate-limits/gemini-usage-fetcher.ts new file mode 100644 index 00000000000..1f214b4b5eb --- /dev/null +++ b/src/main/rate-limits/gemini-usage-fetcher.ts @@ -0,0 +1,248 @@ +import { net } from 'electron' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import { + loadProjectId, + readAuthJson, + readGeminiCredentials, + saveGeminiCredentials, + tryRefreshTokenFromBundle, + type GeminiCredentials, + type GoogleAuthEntry +} from './gemini-oauth-sources' +import { + buildRateLimitBucket, + deduplicateBuckets, + deriveSessionSummary +} from './gemini-bucket-formatting' + +const API_TIMEOUT_MS = 10_000 +const RETRIEVE_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota' + +type QuotaBucket = { remainingFraction: number; resetTime: string; modelId: string } + +function isQuotaBucket(o: unknown): o is QuotaBucket { + return ( + typeof o === 'object' && + o !== null && + typeof (o as QuotaBucket).remainingFraction === 'number' && + Number.isFinite((o as QuotaBucket).remainingFraction) && + typeof (o as QuotaBucket).resetTime === 'string' && + typeof (o as QuotaBucket).modelId === 'string' + ) +} + +function parseQuotaResponse(data: unknown): QuotaBucket[] { + let rawBuckets: unknown[] = [] + if (Array.isArray(data)) { + rawBuckets = data + } else if (data && typeof data === 'object' && 'buckets' in data && Array.isArray(data.buckets)) { + rawBuckets = data.buckets + } + return rawBuckets.filter((b) => isQuotaBucket(b)) +} + +async function fetchQuota(accessToken: string, projectId: string): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort() + }, API_TIMEOUT_MS) + try { + const res = await net.fetch(RETRIEVE_QUOTA_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` }, + body: JSON.stringify({ project: projectId }), + signal: controller.signal + }) + if (!res.ok) { + return { + provider: 'gemini', + session: null, + weekly: null, + updatedAt: Date.now(), + error: `Quota fetch failed (${res.status})`, + status: 'error' + } + } + const data = (await res.json()) as unknown + const buckets = deduplicateBuckets( + parseQuotaResponse(data).map((b) => ({ ...buildRateLimitBucket(b), modelId: b.modelId })) + ) + return { + provider: 'gemini', + session: deriveSessionSummary(buckets), + weekly: null, + buckets, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + } finally { + clearTimeout(timeout) + } +} + +async function fetchViaAuthJson( + auth: GoogleAuthEntry, + geminiCliOAuthEnabled = false +): Promise { + let accessToken = auth.access + const refreshToken = (auth.refresh || '').split('|')[0] ?? '' + if (auth.expires < Date.now() || !accessToken) { + const refreshResult = await tryRefreshTokenFromBundle(refreshToken, geminiCliOAuthEnabled) + if (!refreshResult?.accessToken) { + return { + provider: 'gemini', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Token refresh failed', + status: 'error' + } + } + accessToken = refreshResult.accessToken + } + let effectiveProjectId = '' + try { + effectiveProjectId = await loadProjectId(accessToken) + } catch { + effectiveProjectId = + (auth.refresh || '').split('|')[1] || (auth.refresh || '').split('|')[2] || '' + } + if (!effectiveProjectId) { + return { + provider: 'gemini', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Gemini project ID not found', + status: 'error' + } + } + const result = await fetchQuota(accessToken, effectiveProjectId) + if (result.status === 'error' && result.error?.includes('401')) { + const refreshResult = await tryRefreshTokenFromBundle(refreshToken, geminiCliOAuthEnabled) + if (refreshResult?.accessToken) { + const newProjectId = await loadProjectId(refreshResult.accessToken).catch(() => { + return effectiveProjectId + }) + return fetchQuota(refreshResult.accessToken, newProjectId) + } + } + return result +} + +async function fetchViaOauthCreds( + creds: GeminiCredentials, + geminiCliOAuthEnabled = false +): Promise { + let accessToken = creds.access_token + let currentCreds = creds + if (creds.expiry_date < Date.now()) { + const refreshResult = await tryRefreshTokenFromBundle( + creds.refresh_token, + geminiCliOAuthEnabled + ) + if (!refreshResult?.accessToken) { + return { + provider: 'gemini', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Token refresh failed', + status: 'error' + } + } + accessToken = refreshResult.accessToken + currentCreds = { + ...creds, + access_token: accessToken, + expiry_date: refreshResult.expiresIn + ? Date.now() + refreshResult.expiresIn * 1000 + : creds.expiry_date + } + await saveGeminiCredentials(currentCreds) + } + const projectId = await loadProjectId(accessToken).catch(() => { + return '' + }) + if (!projectId) { + return { + provider: 'gemini', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Gemini project ID not found', + status: 'error' + } + } + const result = await fetchQuota(accessToken, projectId) + if (result.status === 'error' && result.error?.includes('401')) { + const refreshResult = await tryRefreshTokenFromBundle( + currentCreds.refresh_token, + geminiCliOAuthEnabled + ) + if (refreshResult?.accessToken) { + const newProjectId = await loadProjectId(refreshResult.accessToken).catch(() => { + return '' + }) + if (newProjectId) { + await saveGeminiCredentials({ + ...currentCreds, + access_token: refreshResult.accessToken, + expiry_date: refreshResult.expiresIn + ? Date.now() + refreshResult.expiresIn * 1000 + : currentCreds.expiry_date + }) + return fetchQuota(refreshResult.accessToken, newProjectId) + } + } + } + return result +} + +export async function fetchGeminiRateLimits( + geminiCliOAuthEnabled = false +): Promise { + if (!geminiCliOAuthEnabled) { + // Why: the OAuth sources include other apps' data folders on macOS. + // Do not touch them during background polling unless the user opts in. + return { + provider: 'gemini', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Gemini CLI OAuth is disabled in settings', + status: 'unavailable' + } + } + + try { + const authJson = await readAuthJson() + const result = + authJson?.google?.type === 'oauth' + ? await fetchViaAuthJson(authJson.google, geminiCliOAuthEnabled) + : await (async () => { + const creds = await readGeminiCredentials() + return !creds + ? ({ + provider: 'gemini', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Gemini CLI credentials not found', + status: 'unavailable' + } as ProviderRateLimits) + : await fetchViaOauthCreds(creds, geminiCliOAuthEnabled) + })() + return result + } catch (err) { + return { + provider: 'gemini', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + } + } +} diff --git a/src/main/rate-limits/opencode-go-page-scraper.ts b/src/main/rate-limits/opencode-go-page-scraper.ts new file mode 100644 index 00000000000..0f092da6db3 --- /dev/null +++ b/src/main/rate-limits/opencode-go-page-scraper.ts @@ -0,0 +1,172 @@ +// Why: the opencode.ai page is rendered with React Server Components. The +// embedded JS uses a wire format where object references look like: +// key:$R[28]={field:value,...} +// rather than plain `key:{field:value,...}`. A single key (e.g. monthlyUsage) +// can appear multiple times — once with real data and once as `null` inside a +// different component's props. We must find the occurrence that is an object +// with both usagePercent and resetInSec, not the null one. + +/** + * Finds the brace-balanced object block assigned to `key` anywhere in `text`. + * Skips React Flight assignment tokens (e.g. `$R[N]=`) between the colon and + * the opening brace. Returns the first block that contains `usagePercent` AND + * `resetInSec` as direct numeric properties (not nested), so that placeholder + * `null` occurrences and billing-context duplicates are ignored. + */ +function extractUsageBlock(text: string, key: string): string | null { + // Match every occurrence of `key:` (with optional $R[N]= assignment) + // Why: React Flight wire format embeds object references between the colon + // and the literal brace, so we skip over any `$R[N]=` tokens to reach `{`. + const keyRegex = new RegExp(`\\b${key}\\b\\s*:`, 'g') + let keyMatch: RegExpExecArray | null + + while ((keyMatch = keyRegex.exec(text)) !== null) { + // Scan forward from after the colon to find the opening `{`, + // allowing for the `$R[N]=` token or plain whitespace in between. + // We only scan a short window so we don't accidentally land on the + // next occurrence of the key. + const searchStart = keyMatch.index + keyMatch[0].length + const searchWindow = text.slice(searchStart, searchStart + 30) + const braceOffset = searchWindow.indexOf('{') + if (braceOffset === -1) { + // This occurrence has no object (e.g. `monthlyUsage:null`) — skip. + continue + } + + const openBrace = searchStart + braceOffset + // Extract the balanced block + // Why: this brace-depth parser does not skip string literals. React Flight's + // current format does not emit raw { } inside strings, but this is a scraper + // against HTML we don't control — treat as fragile. + let depth = 0 + let block: string | null = null + for (let i = openBrace; i < text.length; i++) { + if (text[i] === '{') { + depth++ + } else if (text[i] === '}') { + depth-- + if (depth === 0) { + block = text.slice(openBrace, i + 1) + break + } + } + } + + if (!block) { + continue + } + + // Verify this block has both required numeric fields as direct properties + // (depth 1 within the block). This rejects billing/plan objects that share + // the key name but lack usage data. + if ( + hasDirectNumericField(block, 'usagePercent') && + hasDirectNumericField(block, 'resetInSec') + ) { + return block + } + } + + return null +} + +/** + * Returns true if `fieldName` exists as a direct (depth-1) numeric property + * of the object string `objText`. + */ +function hasDirectNumericField(objText: string, fieldName: string): boolean { + return extractTopLevelNumber(objText, fieldName) !== null +} + +/** + * Extracts a numeric field at depth 1 of `objText` — ignores the same field + * inside nested sub-objects. + * Why: without depth tracking, a regex matches the first occurrence regardless + * of nesting, returning wrong values when a sub-object contains the same name. + */ +function extractTopLevelNumber(objText: string, fieldName: string): number | null { + const fieldRegex = new RegExp(`\\b${fieldName}\\b\\s*:\\s*(-?[0-9]+(?:\\.[0-9]+)?)`) + // Why: this brace-depth parser does not skip string literals. React Flight's + // current format does not emit raw { } inside strings, but this is a scraper + // against HTML we don't control — treat as fragile. + let depth = 0 + + for (let i = 0; i < objText.length; i++) { + const ch = objText[i] + if (ch === '{') { + depth++ + continue + } + if (ch === '}') { + depth-- + continue + } + + // Only match at depth 1 (direct property of the root object). + if (depth === 1) { + const slice = objText.slice(i, i + fieldName.length + 30) + const m = fieldRegex.exec(slice) + if (m && m.index === 0) { + const n = Number.parseFloat(m[1]) + return Number.isFinite(n) ? n : null + } + } + } + return null +} + +type ParsedSubscription = { + rollingUsagePercent: number + weeklyUsagePercent: number + monthlyUsagePercent: number | null + rollingResetInSec: number + weeklyResetInSec: number + monthlyResetInSec: number | null +} + +export function parseSubscriptionFromPageText(text: string): ParsedSubscription | null { + // Why: OpenCode usage is scraped from HTML-embedded JS (React Flight wire + // format). Defensive size check prevents runaway parsing on unexpected payloads. + if (!text || text.length > 10_000_000) { + return null + } + + // Find the first occurrence of each usage key that has both usagePercent and + // resetInSec as direct numeric fields. This skips null occurrences and + // billing-context duplicates that use the same key name without usage data. + const rollingBlock = extractUsageBlock(text, 'rollingUsage') + const weeklyBlock = extractUsageBlock(text, 'weeklyUsage') + const monthlyBlock = extractUsageBlock(text, 'monthlyUsage') + + const rollingPercent = + rollingBlock !== null ? extractTopLevelNumber(rollingBlock, 'usagePercent') : null + const rollingReset = + rollingBlock !== null ? extractTopLevelNumber(rollingBlock, 'resetInSec') : null + const weeklyPercent = + weeklyBlock !== null ? extractTopLevelNumber(weeklyBlock, 'usagePercent') : null + const weeklyReset = weeklyBlock !== null ? extractTopLevelNumber(weeklyBlock, 'resetInSec') : null + + if ( + rollingPercent === null || + rollingReset === null || + weeklyPercent === null || + weeklyReset === null + ) { + return null + } + + const monthlyPercent = + monthlyBlock !== null ? extractTopLevelNumber(monthlyBlock, 'usagePercent') : null + const monthlyReset = + monthlyBlock !== null ? extractTopLevelNumber(monthlyBlock, 'resetInSec') : null + + return { + rollingUsagePercent: Math.min(100, Math.max(0, rollingPercent)), + weeklyUsagePercent: Math.min(100, Math.max(0, weeklyPercent)), + monthlyUsagePercent: + monthlyPercent !== null ? Math.min(100, Math.max(0, monthlyPercent)) : null, + rollingResetInSec: rollingReset, + weeklyResetInSec: weeklyReset, + monthlyResetInSec: monthlyReset + } +} diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts new file mode 100644 index 00000000000..e6057c52571 --- /dev/null +++ b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts @@ -0,0 +1,350 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const netFetchMock = vi.hoisted(() => vi.fn()) + +vi.mock('electron', () => ({ + net: { fetch: netFetchMock } +})) + +import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-usage-fetcher' + +const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f' + +function makeResponse(body: string, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + text: async () => body + } as Response +} + +// Real React Flight wire format from opencode.ai — keys like `monthlyUsage` +// appear multiple times: once with actual data (as `$R[N]={...}`) and once as +// `null` inside a billing-context object. The parser must pick the data one. +const USAGE_PAGE_WITH_MONTHLY = ` + +` + +const USAGE_PAGE_NO_MONTHLY = ` + +` + +const WORKSPACES_RESPONSE = 'id: "wrk_TESTWORKSPACEID123"' + +describe('fetchOpenCodeGoRateLimits', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-24T12:00:00.000Z')) + netFetchMock.mockReset() + }) + + it('returns unavailable when cookie is empty', async () => { + const result = await fetchOpenCodeGoRateLimits('') + + expect(result.status).toBe('unavailable') + expect(result.provider).toBe('opencode-go') + expect(result.session).toBeNull() + expect(result.weekly).toBeNull() + expect(result.monthly).toBeNull() + expect(result.error).toBe('Session cookie not configured') + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('returns unavailable when cookie is only whitespace', async () => { + const result = await fetchOpenCodeGoRateLimits(' ') + + expect(result.status).toBe('unavailable') + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('returns error when cookie has no auth or __Host-auth name', async () => { + const result = await fetchOpenCodeGoRateLimits('session=abc123; other=xyz') + + expect(result.status).toBe('error') + expect(result.error).toMatch(/No auth cookie found/) + expect(netFetchMock).not.toHaveBeenCalled() + }) + + describe('normalizeCookieInput', () => { + it('returns empty string unchanged', () => { + expect(normalizeCookieInput('')).toBe('') + expect(normalizeCookieInput(' ')).toBe('') + }) + + it('wraps a bare token as auth=', () => { + expect(normalizeCookieInput('Fe26.2**abc123')).toBe('auth=Fe26.2**abc123') + }) + + it('leaves auth=... unchanged', () => { + expect(normalizeCookieInput('auth=Fe26.2**abc123')).toBe('auth=Fe26.2**abc123') + }) + + it('leaves __Host-auth=... unchanged', () => { + expect(normalizeCookieInput('__Host-auth=token')).toBe('__Host-auth=token') + }) + + it('leaves multi-pair cookie headers unchanged', () => { + expect(normalizeCookieInput('auth=tok; other=val')).toBe('auth=tok; other=val') + }) + + it('trims surrounding whitespace before wrapping', () => { + expect(normalizeCookieInput(' Fe26.2**abc ')).toBe('auth=Fe26.2**abc') + }) + + it('does not wrap unknown or malformed tokens', () => { + expect(normalizeCookieInput('invalid token format')).toBe('invalid token format') + expect(normalizeCookieInput('{}')).toBe('{}') + expect(normalizeCookieInput('{"token":"abc"}')).toBe('{"token":"abc"}') + }) + }) + + it('accepts a bare token (auto-wraps to auth=)', async () => { + netFetchMock + .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) + .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + + const result = await fetchOpenCodeGoRateLimits('Fe26.2**baretoken') + + expect(result.status).toBe('ok') + // Cookie sent to the server must be auth=, not the bare value. + expect(netFetchMock.mock.calls[0][1].headers.Cookie).toBe('auth=Fe26.2**baretoken') + }) + + it('uses GET /_server?id= with correct headers for workspaces', async () => { + netFetchMock + .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) + .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + + await fetchOpenCodeGoRateLimits('auth=mytoken') + + expect(netFetchMock).toHaveBeenNthCalledWith( + 1, + `https://opencode.ai/_server?id=${WORKSPACES_SERVER_ID}`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Cookie: 'auth=mytoken', + 'X-Server-Id': WORKSPACES_SERVER_ID + }) + }) + ) + }) + + it('fetches usage from /workspace//go after resolving workspace ID', async () => { + netFetchMock + .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) + .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + + await fetchOpenCodeGoRateLimits('auth=mytoken') + + expect(netFetchMock).toHaveBeenNthCalledWith( + 2, + 'https://opencode.ai/workspace/wrk_TESTWORKSPACEID123/go', + expect.objectContaining({ method: 'GET' }) + ) + }) + + it('returns ok with session, weekly, and monthly windows', async () => { + netFetchMock + .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) + .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + + const now = Date.now() + const result = await fetchOpenCodeGoRateLimits('auth=mytoken') + + expect(result.status).toBe('ok') + expect(result.error).toBeNull() + + expect(result.session).toEqual({ + usedPercent: 30, + windowMinutes: 300, + resetsAt: now + 7200 * 1000, + resetDescription: null + }) + expect(result.weekly).toEqual({ + usedPercent: 51, + windowMinutes: 10080, + resetsAt: now + 259200 * 1000, + resetDescription: null + }) + expect(result.monthly).toEqual({ + usedPercent: 89, + windowMinutes: 43200, + resetsAt: now + 1296000 * 1000, + resetDescription: null + }) + }) + + it('returns ok with null monthly when monthlyUsage is absent', async () => { + netFetchMock + .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) + .mockResolvedValueOnce(makeResponse(USAGE_PAGE_NO_MONTHLY)) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken') + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(10) + expect(result.weekly?.usedPercent).toBe(20) + expect(result.monthly).toBeNull() + }) + + it('caps usedPercent at 100 and floors at 0', async () => { + const page = ` + rollingUsage: { usagePercent: 150, resetInSec: 3600 } + weeklyUsage: { usagePercent: -5, resetInSec: 86400 } + ` + netFetchMock + .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) + .mockResolvedValueOnce(makeResponse(page)) + + const result = await fetchOpenCodeGoRateLimits('auth=token') + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(100) + expect(result.weekly?.usedPercent).toBe(0) + }) + + it('parses React Flight wire format with $R[N]= assignment tokens', async () => { + // Real format from opencode.ai — keys have $R[N]= between the colon and brace. + const page = ` + rollingUsage:$R[21]={status:"ok",resetInSec:1337,usagePercent:42}, + weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:68} + ` + netFetchMock + .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) + .mockResolvedValueOnce(makeResponse(page)) + + const result = await fetchOpenCodeGoRateLimits('auth=token') + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(42) + expect(result.weekly?.usedPercent).toBe(68) + }) + + it('skips null occurrences and finds the real data block for monthlyUsage', async () => { + // Regression: on refresh, monthlyUsage:null appeared BEFORE the real + // monthlyUsage:$R[N]={usagePercent:89,...} in a different component's props. + // Parser must skip the null and find the data block. + const page = ` + rollingUsage:$R[21]={status:"ok",resetInSec:18000,usagePercent:0}, + weeklyUsage:$R[22]={status:"ok",resetInSec:57781,usagePercent:51}, + monthlyUsage:null,timeMonthlyUsageUpdated:null, + monthlyUsage:$R[28]={status:"ok",resetInSec:1214779,usagePercent:89} + ` + netFetchMock + .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) + .mockResolvedValueOnce(makeResponse(page)) + + const result = await fetchOpenCodeGoRateLimits('auth=token') + + expect(result.status).toBe('ok') + expect(result.monthly?.usedPercent).toBe(89) + expect(result.monthly?.resetsAt).toBe(Date.now() + 1214779 * 1000) + }) + + it('returns null monthly when all monthlyUsage occurrences are null', async () => { + const page = ` + rollingUsage:$R[21]={status:"ok",resetInSec:3600,usagePercent:10}, + weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:20}, + monthlyUsage:null,timeMonthlyUsageUpdated:null + ` + netFetchMock + .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) + .mockResolvedValueOnce(makeResponse(page)) + + const result = await fetchOpenCodeGoRateLimits('auth=token') + + expect(result.status).toBe('ok') + expect(result.monthly).toBeNull() + }) + + it('skips workspace lookup when workspaceIdOverride is provided', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(netFetchMock).toHaveBeenCalledTimes(1) + expect(netFetchMock).toHaveBeenCalledWith( + 'https://opencode.ai/workspace/wrk_OVERRIDE123/go', + expect.anything() + ) + expect(result.status).toBe('ok') + }) + + it('filters cookie to auth name only', async () => { + netFetchMock + .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) + .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + + await fetchOpenCodeGoRateLimits('session=secret; auth=realtoken; tracking=xyz') + + const firstCall = netFetchMock.mock.calls[0] + expect(firstCall[1].headers.Cookie).toBe('auth=realtoken') + }) + + it('returns error on 404 from workspaces fetch', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse('Not Found', 404)) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken') + + expect(result.status).toBe('error') + expect(result.error).toBe('Workspaces fetch failed (404)') + expect(result.session).toBeNull() + }) + + it('returns error on 401 from workspaces fetch', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse('Unauthorized', 401)) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken') + + expect(result.status).toBe('error') + expect(result.error).toBe('Workspaces fetch failed (401)') + }) + + it('returns error when no workspace ID found in response', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse('no workspace id here')) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken') + + expect(result.status).toBe('error') + expect(result.error).toMatch(/No workspace ID found/) + }) + + it('returns error on non-ok usage page response', async () => { + netFetchMock + .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) + .mockResolvedValueOnce(makeResponse('Not Found', 404)) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken') + + expect(result.status).toBe('error') + expect(result.error).toBe('Usage page fetch failed (404)') + }) + + it('returns error when usage data cannot be parsed from page', async () => { + netFetchMock + .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) + .mockResolvedValueOnce(makeResponse('no usage data here')) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken') + + expect(result.status).toBe('error') + expect(result.error).toBe('Could not parse usage data from page') + }) + + it('never logs the cookie in error messages', async () => { + netFetchMock.mockRejectedValueOnce(new Error('network timeout')) + + const result = await fetchOpenCodeGoRateLimits('auth=secret123') + + expect(result.status).toBe('error') + expect(result.error).toBe('network timeout') + expect(result.error).not.toContain('secret123') + }) +}) diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.ts b/src/main/rate-limits/opencode-go-usage-fetcher.ts new file mode 100644 index 00000000000..26c50d8e6bd --- /dev/null +++ b/src/main/rate-limits/opencode-go-usage-fetcher.ts @@ -0,0 +1,256 @@ +import { net } from 'electron' +import { randomUUID } from 'crypto' +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { parseSubscriptionFromPageText } from './opencode-go-page-scraper' + +const OPENCODE_BASE_URL = 'https://opencode.ai' +const OPENCODE_SERVER_URL = 'https://opencode.ai/_server' +const API_TIMEOUT_MS = 15_000 + +// Server-function hash for the workspaces endpoint — stable identifier used by +// the opencode.ai SST/TanStack router server-fn protocol. +const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f' + +// Only these cookie names carry session auth on opencode.ai. Sending unrelated +// cookies pollutes the header and can expose sensitive data from other sites. +const AUTH_COOKIE_NAMES = new Set(['auth', '__Host-auth']) + +// Why: users may paste just the token value (e.g. "Fe26.2**...") instead of +// the full cookie header ("auth=Fe26.2**..."). Auto-wrapping avoids a confusing +// silent failure where the cookie looks non-empty but contains no auth name. +export function normalizeCookieInput(raw: string): string { + const trimmed = raw.trim() + if (!trimmed) { + return trimmed + } + // Already a valid cookie header: has multiple pairs or starts with known name. + if (trimmed.includes(';') || /^(?:auth|__Host-auth)=/i.test(trimmed)) { + return trimmed + } + // Only wrap if it looks like an Iron Session seal (starts with Fe26.2**) + // or a reasonably structured bare token (alphanumeric with dots/dashes). + // Otherwise, leave it alone to fail predictably instead of sending malformed auth. + if (trimmed.startsWith('Fe26.2**') || /^[a-zA-Z0-9.\-_]+$/.test(trimmed)) { + return `auth=${trimmed}` + } + return trimmed +} + +function filterAuthCookie(raw: string): string { + return raw + .split(';') + .map((p) => p.trim()) + .filter((pair) => { + const eq = pair.indexOf('=') + if (eq < 0) { + return false + } + return AUTH_COOKIE_NAMES.has(pair.slice(0, eq).trim()) + }) + .join('; ') +} + +function parseWorkspaceIds(text: string): string[] { + // Match id:"wrk_..." or id: "wrk_..." patterns in JS-serialized output. + // Why: Workspace IDs follow a 'wrk_xxx' or 'wk_xxx' pattern. Using a + // more specific regex with word boundaries avoids picking up unrelated + // object properties that might match a generic ID pattern. + const ids: string[] = [] + const workspaceIdRegex = /\bid\s*:\s*["']((?:wrk|wk)_[a-zA-Z0-9]+)["']/g + for (const match of text.matchAll(workspaceIdRegex)) { + const id = match[1] + if (id && !ids.includes(id)) { + ids.push(id) + } + } + return ids +} + +function makeWindow( + usedPercent: number, + resetInSec: number, + windowMinutes: number +): RateLimitWindow { + return { + usedPercent, + windowMinutes, + resetsAt: Date.now() + resetInSec * 1000, + resetDescription: null + } +} + +export async function fetchOpenCodeGoRateLimits( + cookie: string, + workspaceIdOverride?: string +): Promise { + // Normalize before any guard — bare tokens become auth=. + const normalizedCookie = normalizeCookieInput(cookie) + + if (!normalizedCookie) { + return { + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: 'Session cookie not configured', + status: 'unavailable' + } + } + + // Filter to only auth cookies — avoids sending unrelated session data. + const cookieHeader = filterAuthCookie(normalizedCookie) + if (!cookieHeader) { + return { + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: 'No auth cookie found — paste the full Cookie header from opencode.ai DevTools', + status: 'error' + } + } + + // Step 1: resolve workspace IDs to try. + let ids: string[] = [] + const override = workspaceIdOverride?.trim() + + if (override) { + if (!/^(wrk|wk)_[A-Za-z0-9]+$/.test(override)) { + return { + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: 'Invalid workspace ID format: must match ^(wrk|wk)_[A-Za-z0-9]+$', + status: 'error' + } + } + ids = [override] + } else { + const workspacesController = new AbortController() + const workspacesTimeout = setTimeout(() => workspacesController.abort(), API_TIMEOUT_MS) + try { + // The /_server endpoint uses SST server-function protocol: GET with ?id= + // and X-Server-Id / X-Server-Instance headers for routing. + const instanceId = `server-fn:${randomUUID()}` + const workspacesUrl = `${OPENCODE_SERVER_URL}?id=${WORKSPACES_SERVER_ID}` + const workspacesRes = await net.fetch(workspacesUrl, { + method: 'GET', + headers: { + Cookie: cookieHeader, + 'X-Server-Id': WORKSPACES_SERVER_ID, + 'X-Server-Instance': instanceId, + Accept: 'text/javascript, application/json;q=0.9, */*;q=0.8', + Origin: OPENCODE_BASE_URL, + Referer: OPENCODE_BASE_URL + }, + signal: workspacesController.signal + }) + + if (!workspacesRes.ok) { + return { + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: `Workspaces fetch failed (${workspacesRes.status})`, + status: 'error' + } + } + + const workspacesText = await workspacesRes.text() + ids = parseWorkspaceIds(workspacesText) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + return { + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: message, + status: 'error' + } + } finally { + clearTimeout(workspacesTimeout) + } + } + + if (ids.length === 0) { + return { + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: 'No workspace ID found — set a Workspace ID override in settings', + status: 'error' + } + } + + // Step 2: Robust workspace resolution. Try each candidate ID until one returns 200 OK + // and valid usage data. Each candidate gets its own AbortController so a slow or + // hung candidate cannot starve the rest. + let lastError = '' + for (const candidateId of ids) { + const candidateController = new AbortController() + const candidateTimeout = setTimeout(() => candidateController.abort(), API_TIMEOUT_MS) + try { + const usagePageUrl = `${OPENCODE_BASE_URL}/workspace/${candidateId}/go` + const pageRes = await net.fetch(usagePageUrl, { + method: 'GET', + headers: { + Cookie: cookieHeader, + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + Origin: OPENCODE_BASE_URL, + Referer: OPENCODE_BASE_URL + }, + signal: candidateController.signal + }) + + if (!pageRes.ok) { + lastError = `Usage page fetch failed (${pageRes.status})` + continue + } + + const pageText = await pageRes.text() + const parsed = parseSubscriptionFromPageText(pageText) + if (parsed) { + const monthly = + parsed.monthlyUsagePercent !== null && parsed.monthlyResetInSec !== null + ? makeWindow(parsed.monthlyUsagePercent, parsed.monthlyResetInSec, 43200) // 30d + : null + + return { + provider: 'opencode-go', + session: makeWindow(parsed.rollingUsagePercent, parsed.rollingResetInSec, 300), + weekly: makeWindow(parsed.weeklyUsagePercent, parsed.weeklyResetInSec, 10080), + monthly, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + } + lastError = 'Could not parse usage data from page' + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + lastError = message + } finally { + clearTimeout(candidateTimeout) + } + } + + return { + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: lastError || 'Could not parse usage data from any available workspace', + status: 'error' + } +} diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 346539b00d8..0146ea88b41 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -1,8 +1,14 @@ +/* eslint-disable max-lines -- Why: these tests mirror the fetch ordering, +stale-data handling, account-switch generation, and OpenCode config-change +semantics covered in service.ts, which already carries the same pragma. +Keeping them in one file makes the ordering contract reviewable as a unit. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { RateLimitService } from './service' import { fetchClaudeRateLimits } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' +import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' vi.mock('./claude-fetcher', () => ({ fetchClaudeRateLimits: vi.fn() @@ -12,6 +18,14 @@ vi.mock('./codex-fetcher', () => ({ fetchCodexRateLimits: vi.fn() })) +vi.mock('./gemini-usage-fetcher', () => ({ + fetchGeminiRateLimits: vi.fn() +})) + +vi.mock('./opencode-go-usage-fetcher', () => ({ + fetchOpenCodeGoRateLimits: vi.fn() +})) + type Deferred = { promise: Promise resolve: (value: T) => void @@ -26,7 +40,7 @@ function deferred(): Deferred { } function okProvider( - provider: 'claude' | 'codex', + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go', usedPercent: number, updatedAt = Date.now() ): ProviderRateLimits { @@ -45,7 +59,10 @@ function okProvider( } } -function errorProvider(provider: 'claude' | 'codex', message: string): ProviderRateLimits { +function errorProvider( + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go', + message: string +): ProviderRateLimits { return { provider, session: null, @@ -63,6 +80,8 @@ function serviceInternals(service: RateLimitService): { fetchAll: () => Promise< describe('RateLimitService', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now())) + vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now())) }) it('does not refetch Claude when a Codex account switch is queued during fetchAll', async () => { @@ -176,4 +195,180 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) }) + + it('fetches Gemini and OpenCode Go alongside Claude and Codex', async () => { + const service = new RateLimitService() + service.setSettingsResolver(() => ({ + opencodeSessionCookie: 'session=abc123', + opencodeWorkspaceId: '' + })) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now())) + vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( + okProvider('opencode-go', 40, Date.now()) + ) + + await service.refresh() + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) + expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledWith('session=abc123', undefined) + + const state = service.getState() + expect(state.claude?.status).toBe('ok') + expect(state.claude?.session?.usedPercent).toBe(10) + expect(state.codex?.status).toBe('ok') + expect(state.codex?.session?.usedPercent).toBe(20) + expect(state.gemini?.status).toBe('ok') + expect(state.gemini?.session?.usedPercent).toBe(30) + expect(state.opencodeGo?.status).toBe('ok') + expect(state.opencodeGo?.session?.usedPercent).toBe(40) + }) + + it('preserves Gemini buckets through getState after fetch', async () => { + const service = new RateLimitService() + + const geminiWithBuckets: ProviderRateLimits = { + provider: 'gemini', + session: { usedPercent: 80, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + buckets: [ + { + name: 'Pro', + usedPercent: 30, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + name: 'Flash', + usedPercent: 80, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + } + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(geminiWithBuckets) + vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( + okProvider('opencode-go', 0, Date.now()) + ) + + await service.refresh() + + const state = service.getState() + expect(state.gemini?.buckets).toHaveLength(2) + expect(state.gemini?.buckets![0].name).toBe('Pro') + expect(state.gemini?.buckets![1].name).toBe('Flash') + // Why: session summary is derived from bucket data and must match the most constrained bucket. + expect(state.gemini?.session?.usedPercent).toBe(80) + }) + + it('isolates provider failures so one error does not block others', async () => { + const service = new RateLimitService() + service.setSettingsResolver(() => ({ opencodeSessionCookie: '', opencodeWorkspaceId: '' })) + + vi.mocked(fetchClaudeRateLimits).mockRejectedValueOnce(new Error('claude down')) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + vi.mocked(fetchGeminiRateLimits).mockRejectedValueOnce(new Error('gemini down')) + vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( + okProvider('opencode-go', 40, Date.now()) + ) + + await service.refresh() + + const state = service.getState() + expect(state.claude?.status).toBe('error') + expect(state.claude?.error).toBe('claude down') + expect(state.codex?.status).toBe('ok') + expect(state.gemini?.status).toBe('error') + expect(state.gemini?.error).toBe('gemini down') + expect(state.opencodeGo?.status).toBe('ok') + }) + + it('discards stale data when a provider becomes unavailable', async () => { + const service = new RateLimitService() + let cookie = 'session=valid' + service.setSettingsResolver(() => ({ opencodeSessionCookie: cookie, opencodeWorkspaceId: '' })) + + // 1. Success fetch + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20, Date.now())) + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 30, Date.now())) + vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue( + okProvider('opencode-go', 40, Date.now()) + ) + + await service.refresh() + expect(service.getState().opencodeGo?.session?.usedPercent).toBe(40) + + // 2. Clear cookie -> should become unavailable and LOSE the 40% data + cookie = '' + vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue({ + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: 'Session cookie not configured', + status: 'unavailable' + }) + + await service.refresh() + const state = service.getState() + expect(state.opencodeGo?.status).toBe('unavailable') + expect(state.opencodeGo?.session).toBeNull() + expect(state.opencodeGo?.error).toBe('Session cookie not configured') + }) + + it('discards stale data when Workspace ID override is changed', async () => { + const service = new RateLimitService() + let workspaceId = 'wrk_A' + service.setSettingsResolver(() => ({ + opencodeSessionCookie: 'session=valid', + opencodeWorkspaceId: workspaceId + })) + + // 1. Success fetch for Workspace A + vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue( + okProvider('opencode-go', 40, Date.now()) + ) + await service.refresh() + expect(service.getState().opencodeGo?.session?.usedPercent).toBe(40) + + // 2. Change Workspace ID to B -> old data from A should be discarded + workspaceId = 'wrk_B' + vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue( + okProvider('opencode-go', 10, Date.now()) + ) + await service.refresh() + expect(service.getState().opencodeGo?.session?.usedPercent).toBe(10) + + // 3. Clear Workspace ID (automatic) but it fails -> should show error, NOT stale data from B + workspaceId = '' + vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue({ + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: 'No workspace ID found', + status: 'error' + }) + await service.refresh() + const state = service.getState() + expect(state.opencodeGo?.status).toBe('error') + expect(state.opencodeGo?.session).toBeNull() + expect(state.opencodeGo?.error).toBe('No workspace ID found') + }) }) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 0810c41acd1..b90568567ff 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -2,10 +2,22 @@ handling, account-switch fetch semantics, and renderer push coordination so the fetch ordering rules stay in one place. */ import type { BrowserWindow } from 'electron' -import type { RateLimitState, ProviderRateLimits } from '../../shared/rate-limit-types' -import { fetchClaudeRateLimits } from './claude-fetcher' +import type { + RateLimitState, + ProviderRateLimits, + InactiveAccountUsage +} from '../../shared/rate-limit-types' +import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' +import type { InactiveClaudeAccountInfo } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' + +export type InactiveCodexAccountInfo = { + id: string + managedHomePath: string +} // Why: quota state does not need near-real-time polling, and a less aggressive // default reduces avoidable Claude /usage pressure. We intentionally use a @@ -13,9 +25,24 @@ import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-au const DEFAULT_POLL_MS = 5 * 60 * 1000 // 5 minutes const MIN_REFETCH_MS = 30 * 1000 // 30 seconds — debounce rapid refresh requests const STALE_THRESHOLD_MS = 10 * 60 * 1000 // 10 minutes — after this, stale data is dropped +const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open + +// Why: the internal state only tracks claude and codex. The inactiveClaudeAccounts +// array is derived from the cache on demand in getState() and pushToRenderer(). +type InternalRateLimitState = { + claude: ProviderRateLimits | null + codex: ProviderRateLimits | null + gemini: ProviderRateLimits | null + opencodeGo: ProviderRateLimits | null +} export class RateLimitService { - private state: RateLimitState = { claude: null, codex: null } + private state: InternalRateLimitState = { + claude: null, + codex: null, + gemini: null, + opencodeGo: null + } private pollInterval: number = DEFAULT_POLL_MS private timer: ReturnType | null = null private lastFetchAt = 0 @@ -28,11 +55,36 @@ export class RateLimitService { private fetchIdleResolvers: (() => void)[] = [] private codexFetchGeneration = 0 private claudeFetchGeneration = 0 + private opencodeFetchGeneration = 0 + private lastOpencodeConfigHash = '' private codexHomePathResolver: (() => string | null) | null = null private claudeAuthPreparationResolver: (() => Promise) | null = null + private settingsResolver: + | (() => { + opencodeSessionCookie: string + opencodeWorkspaceId: string + geminiCliOAuthEnabled?: boolean + }) + | null = null + private inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null + private inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null + private inactiveClaudeCache = new Map() + private inactiveCodexCache = new Map() + private inactiveClaudeFetching = new Set() + private inactiveCodexFetching = new Set() + private lastInactiveClaudeFetchAt = 0 + private lastInactiveCodexFetchAt = 0 + private stateListeners = new Set<(state: RateLimitState) => void>() constructor() {} + onStateChange(listener: (state: RateLimitState) => void): () => void { + this.stateListeners.add(listener) + return () => { + this.stateListeners.delete(listener) + } + } + setCodexHomePathResolver(resolver: () => string | null): void { this.codexHomePathResolver = resolver } @@ -41,6 +93,24 @@ export class RateLimitService { this.claudeAuthPreparationResolver = resolver } + setSettingsResolver( + resolver: () => { + opencodeSessionCookie: string + opencodeWorkspaceId: string + geminiCliOAuthEnabled?: boolean + } + ): void { + this.settingsResolver = resolver + } + + setInactiveClaudeAccountsResolver(resolver: () => InactiveClaudeAccountInfo[]): void { + this.inactiveClaudeAccountsResolver = resolver + } + + setInactiveCodexAccountsResolver(resolver: () => InactiveCodexAccountInfo[]): void { + this.inactiveCodexAccountsResolver = resolver + } + attach(mainWindow: BrowserWindow): void { this.detachWindowListeners?.() this.mainWindow = mainWindow @@ -78,7 +148,17 @@ export class RateLimitService { } getState(): RateLimitState { - return this.state + return { + ...this.state, + inactiveClaudeAccounts: this.buildInactiveArray( + this.inactiveClaudeCache, + this.inactiveClaudeFetching + ), + inactiveCodexAccounts: this.buildInactiveArray( + this.inactiveCodexCache, + this.inactiveCodexFetching + ) + } } async refresh(): Promise { @@ -87,11 +167,15 @@ export class RateLimitService { // broken after wake/focus transitions because the click can no-op even // though the user is asking for a fresh read right now. await this.fetchAll({ force: true }) - return this.state + return this.getState() } - async refreshForCodexAccountChange(): Promise { + async refreshForCodexAccountChange(outgoingAccountId?: string | null): Promise { + if (outgoingAccountId && this.state.codex?.session) { + this.inactiveCodexCache.set(outgoingAccountId, this.state.codex) + } this.codexFetchGeneration += 1 + this.lastInactiveCodexFetchAt = 0 // Why: switching the selected Codex account must immediately clear the old // Codex quota view. Keeping stale values visible would show the previous // account's limits under the newly selected identity until the next poll. @@ -100,17 +184,97 @@ export class RateLimitService { codex: this.withFetchingStatus(null, 'codex') }) await this.fetchCodexOnly({ force: true }) - return this.state + return this.getState() } - async refreshForClaudeAccountChange(): Promise { + async refreshForClaudeAccountChange(outgoingAccountId?: string | null): Promise { + // Why: snapshot the outgoing account's usage before clearing it so the + // inline usage bars in the switcher can show last-known data immediately. + if (outgoingAccountId && this.state.claude?.session) { + this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude) + } this.claudeFetchGeneration += 1 + this.lastInactiveClaudeFetchAt = 0 this.updateState({ ...this.state, claude: this.withFetchingStatus(null, 'claude') }) await this.fetchClaudeOnly({ force: true }) - return this.state + return this.getState() + } + + async fetchInactiveClaudeAccountsOnOpen(): Promise { + if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { + return + } + const accounts = this.inactiveClaudeAccountsResolver?.() ?? [] + if (accounts.length === 0) { + return + } + + for (const account of accounts) { + this.inactiveClaudeFetching.add(account.id) + } + this.pushToRenderer() + + for (const account of accounts) { + try { + const fresh = await fetchManagedAccountUsage(account) + const cached = this.inactiveClaudeCache.get(account.id) ?? null + this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch prevents one Keychain rejection or + // network error from aborting the remaining accounts in the batch. + } + this.inactiveClaudeFetching.delete(account.id) + this.pushToRenderer() + } + + this.lastInactiveClaudeFetchAt = Date.now() + } + + async fetchInactiveCodexAccountsOnOpen(): Promise { + if (Date.now() - this.lastInactiveCodexFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { + return + } + const accounts = this.inactiveCodexAccountsResolver?.() ?? [] + if (accounts.length === 0) { + return + } + + for (const account of accounts) { + this.inactiveCodexFetching.add(account.id) + } + this.pushToRenderer() + + for (const account of accounts) { + try { + // Why: fetchCodexRateLimits already accepts codexHomePath, so we can + // point it at the managed account's home directory directly without + // materializing credentials into the shared runtime location. + const fresh = await fetchCodexRateLimits({ codexHomePath: account.managedHomePath }) + const cached = this.inactiveCodexCache.get(account.id) ?? null + this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch prevents one failure from aborting the batch. + } + this.inactiveCodexFetching.delete(account.id) + this.pushToRenderer() + } + + this.lastInactiveCodexFetchAt = Date.now() + } + + evictInactiveClaudeCache(accountId: string): void { + this.inactiveClaudeCache.delete(accountId) + this.inactiveClaudeFetching.delete(accountId) + this.pushToRenderer() + } + + evictInactiveCodexCache(accountId: string): void { + this.inactiveCodexCache.delete(accountId) + this.inactiveCodexFetching.delete(accountId) + this.pushToRenderer() } setPollingInterval(ms: number): void { @@ -305,7 +469,7 @@ export class RateLimitService { private withFetchingStatus( current: ProviderRateLimits | null, - provider: 'claude' | 'codex' + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' ): ProviderRateLimits { if (!current) { return { @@ -328,38 +492,96 @@ export class RateLimitService { const codexProvenance = codexHomePath ? `managed:${codexHomePath}` : 'system' const codexGeneration = this.codexFetchGeneration const previousState = this.state + const settings = this.settingsResolver?.() + const cookie = settings?.opencodeSessionCookie ?? '' + const workspaceIdOverride = settings?.opencodeWorkspaceId ?? '' + const geminiCliOAuthEnabled = settings?.geminiCliOAuthEnabled ?? false - // Mark both providers as fetching while keeping previous data visible. + // Detect if configuration changed — if it did, we must discard any stale + // data because it belongs to a different session/workspace. + const currentConfigHash = `${cookie}|${workspaceIdOverride}` + const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash + if (opencodeConfigChanged) { + this.lastOpencodeConfigHash = currentConfigHash + this.opencodeFetchGeneration += 1 + } + const opencodeGeneration = this.opencodeFetchGeneration + + // Mark all providers as fetching while keeping previous data visible. // Codex account changes clear Codex separately before this method is // called, so ordinary refreshes still preserve the current values. this.updateState({ + ...previousState, claude: this.withFetchingStatus(previousState.claude, 'claude'), - codex: this.withFetchingStatus(previousState.codex, 'codex') + codex: this.withFetchingStatus(previousState.codex, 'codex'), + gemini: this.withFetchingStatus(previousState.gemini, 'gemini'), + opencodeGo: opencodeConfigChanged + ? this.withFetchingStatus(null, 'opencode-go') + : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go') }) - const [claude, codex] = await Promise.all([ - fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation }).catch( - (err): ProviderRateLimits => ({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - }) - ), - fetchCodexRateLimits({ codexHomePath }).catch( - (err): ProviderRateLimits => ({ - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - }) - ) + const [claudeResult, codexResult, geminiResult, opencodeGoResult] = await Promise.allSettled([ + fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation }), + fetchCodexRateLimits({ codexHomePath }), + fetchGeminiRateLimits(geminiCliOAuthEnabled), + fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined) ]) + const claude = + claudeResult.status === 'fulfilled' + ? claudeResult.value + : ({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + claudeResult.reason instanceof Error ? claudeResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const codex = + codexResult.status === 'fulfilled' + ? codexResult.value + : ({ + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + codexResult.reason instanceof Error ? codexResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const gemini = + geminiResult.status === 'fulfilled' + ? geminiResult.value + : ({ + provider: 'gemini', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + geminiResult.reason instanceof Error ? geminiResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const opencodeGo = + opencodeGoResult.status === 'fulfilled' + ? opencodeGoResult.value + : ({ + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: + opencodeGoResult.reason instanceof Error + ? opencodeGoResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + const latestCodexHomePath = this.codexHomePathResolver?.() ?? null const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.() const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' @@ -368,16 +590,26 @@ export class RateLimitService { codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance const shouldApplyClaude = claudeGeneration === this.claudeFetchGeneration && claudeProvenance === latestClaudeProvenance + const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration // Why: account switches can race in-flight Codex fetches. Only apply a // Codex result if both the selected-account provenance and the request // generation still match, otherwise an old account could overwrite the // newly selected account's quota state. this.updateState({ + ...previousState, claude: shouldApplyClaude ? this.applyStalePolicy(claude, previousState.claude) : this.state.claude, - codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex + codex: shouldApplyCodex + ? this.applyStalePolicy(codex, previousState.codex) + : this.state.codex, + gemini: this.applyStalePolicy(gemini, previousState.gemini), + opencodeGo: shouldApplyOpencode + ? opencodeConfigChanged + ? opencodeGo + : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) + : this.state.opencodeGo }) this.lastFetchAt = Date.now() @@ -464,7 +696,18 @@ export class RateLimitService { return fresh } - const previousHasData = Boolean(previous?.session || previous?.weekly) + // Explicitly unavailable — user likely cleared a setting. Discard any stale + // data so the UI reflects that the provider is now disabled/unconfigured. + if (fresh.status === 'unavailable') { + return fresh + } + + const previousHasData = Boolean( + previous?.session || + previous?.weekly || + previous?.monthly || + (previous?.buckets && previous.buckets.length > 0) + ) // No previous data to fall back on if (!previous || !previousHasData) { @@ -488,15 +731,51 @@ export class RateLimitService { } } - private updateState(next: RateLimitState): void { + private buildInactiveArray( + cache: Map, + fetching: Set + ): InactiveAccountUsage[] { + const result: InactiveAccountUsage[] = [] + for (const [accountId, limits] of cache) { + result.push({ + accountId, + claude: limits, + updatedAt: limits.updatedAt, + isFetching: fetching.has(accountId) + }) + } + // Why: include accounts that are fetching but have no cache yet so the + // renderer can show a loading indicator for newly added accounts. + for (const accountId of fetching) { + if (!cache.has(accountId)) { + result.push({ + accountId, + claude: null, + updatedAt: 0, + isFetching: true + }) + } + } + return result + } + + private updateState(next: InternalRateLimitState): void { this.state = next this.pushToRenderer() } private pushToRenderer(): void { + const state = this.getState() + for (const listener of this.stateListeners) { + try { + listener(state) + } catch { + // ignore — one bad listener must not break the others + } + } if (!this.mainWindow || this.mainWindow.isDestroyed()) { return } - this.mainWindow.webContents.send('rateLimits:update', this.state) + this.mainWindow.webContents.send('rateLimits:update', state) } } diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts new file mode 100644 index 00000000000..fed25b62212 --- /dev/null +++ b/src/main/runtime/device-registry.ts @@ -0,0 +1,83 @@ +// Why: per-device tokens replace the shared runtime auth token for WebSocket +// (mobile) connections. Each paired device gets its own revocable token so +// compromising one device doesn't expose others. The registry is a simple +// JSON file with hardened permissions matching the runtime metadata pattern. +import { randomBytes, randomUUID } from 'crypto' +import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs' +import { join } from 'path' + +const DEVICE_REGISTRY_FILENAME = 'orca-devices.json' + +export type DeviceEntry = { + deviceId: string + name: string + token: string + pairedAt: number + lastSeenAt: number +} + +export class DeviceRegistry { + private readonly registryPath: string + private devices: DeviceEntry[] = [] + + constructor(userDataPath: string) { + this.registryPath = join(userDataPath, DEVICE_REGISTRY_FILENAME) + this.load() + } + + addDevice(name: string): DeviceEntry { + const entry: DeviceEntry = { + deviceId: randomUUID(), + name, + token: randomBytes(24).toString('hex'), + pairedAt: Date.now(), + lastSeenAt: 0 + } + this.devices.push(entry) + this.save() + return entry + } + + removeDevice(deviceId: string): boolean { + const before = this.devices.length + this.devices = this.devices.filter((d) => d.deviceId !== deviceId) + if (this.devices.length < before) { + this.save() + return true + } + return false + } + + listDevices(): readonly DeviceEntry[] { + return this.devices + } + + validateToken(token: string): DeviceEntry | null { + return this.devices.find((d) => d.token === token) ?? null + } + + updateLastSeen(deviceId: string): void { + const device = this.devices.find((d) => d.deviceId === deviceId) + if (device) { + device.lastSeenAt = Date.now() + this.save() + } + } + + private load(): void { + if (!existsSync(this.registryPath)) { + this.devices = [] + return + } + try { + this.devices = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[] + } catch { + this.devices = [] + } + } + + private save(): void { + writeFileSync(this.registryPath, JSON.stringify(this.devices, null, 2), { mode: 0o600 }) + chmodSync(this.registryPath, 0o600) + } +} diff --git a/src/main/runtime/e2ee-keypair.ts b/src/main/runtime/e2ee-keypair.ts new file mode 100644 index 00000000000..de0373ebafe --- /dev/null +++ b/src/main/runtime/e2ee-keypair.ts @@ -0,0 +1,50 @@ +// Why: the E2EE keypair enables application-layer encryption between mobile +// and desktop over plain ws://. The public key is embedded in the QR pairing +// offer so the mobile client can derive a shared secret via ECDH. +import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs' +import { join } from 'path' +import nacl from 'tweetnacl' + +const KEYPAIR_FILENAME = 'orca-e2ee-keypair.json' +const KEYPAIR_VERSION = 1 + +type KeypairFile = { + v: number + publicKeyB64: string + secretKeyB64: string +} + +export type E2EEKeypair = { + publicKey: Uint8Array + secretKey: Uint8Array + publicKeyB64: string +} + +export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair { + const filePath = join(userDataPath, KEYPAIR_FILENAME) + + if (existsSync(filePath)) { + try { + const raw: KeypairFile = JSON.parse(readFileSync(filePath, 'utf-8')) + if (raw.v === KEYPAIR_VERSION && raw.publicKeyB64 && raw.secretKeyB64) { + const publicKey = Uint8Array.from(Buffer.from(raw.publicKeyB64, 'base64')) + const secretKey = Uint8Array.from(Buffer.from(raw.secretKeyB64, 'base64')) + if (publicKey.length === 32 && secretKey.length === 32) { + return { publicKey, secretKey, publicKeyB64: raw.publicKeyB64 } + } + } + } catch { + // Malformed file — regenerate below. + } + } + + const keypair = nacl.box.keyPair() + const publicKeyB64 = Buffer.from(keypair.publicKey).toString('base64') + const secretKeyB64 = Buffer.from(keypair.secretKey).toString('base64') + + const data: KeypairFile = { v: KEYPAIR_VERSION, publicKeyB64, secretKeyB64 } + writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8') + chmodSync(filePath, 0o600) + + return { publicKey: keypair.publicKey, secretKey: keypair.secretKey, publicKeyB64 } +} diff --git a/src/main/runtime/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts new file mode 100644 index 00000000000..4ae3a516db4 --- /dev/null +++ b/src/main/runtime/fetch-remote-cache.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: these tests cover the §3.3 Lifecycle rules on +// `OrcaRuntimeService.fetchRemoteWithCache` — in particular that a rejected +// fetch evicts its Map entry AND does not advance the freshness timestamp, +// and that two concurrent callers serialize on a single underlying fetch. +// They live in a dedicated file so we can mock `gitExecFileAsync` cleanly +// without disturbing the large orca-runtime.test.ts mock surface. + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) + +vi.mock('../git/runner', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + gitExecFileAsync: gitExecFileAsyncMock + } +}) + +// Why: orca-runtime.ts imports heavy modules (hooks, ipc/*, etc.) at top +// level. We only exercise the fetch cache, so we let those imports load +// normally — none of them trigger IO until a runtime method is called. +import { OrcaRuntimeService } from './orca-runtime' + +describe('OrcaRuntimeService.fetchRemoteWithCache', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('evicts the in-flight Map entry on rejection so the next caller re-fetches', async () => { + // First call rejects, second call resolves. Without §3.3 Lifecycle + // `.finally()` eviction, the second caller would await the rejected + // promise forever (or throw the same error) — the regression pattern + // described in §3.3. + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + const runtime = new OrcaRuntimeService(null) + + await runtime.fetchRemoteWithCache('/repo/a', 'origin') + await runtime.fetchRemoteWithCache('/repo/a', 'origin') + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('does not advance the freshness timestamp when the fetch rejects', async () => { + // A rejected fetch that wrote the timestamp would make the 30s freshness + // cache "lie" — the next caller would skip the fetch on a repo whose + // last real sync is unknown. §3.3 mandates success-only writes. + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + const runtime = new OrcaRuntimeService(null) + + await runtime.fetchRemoteWithCache('/repo/b', 'origin') + // Immediately call again — if the freshness window were armed we would + // short-circuit and skip the fetch. It must still dispatch a real fetch. + await runtime.fetchRemoteWithCache('/repo/b', 'origin') + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('serializes two concurrent callers onto a single git fetch', async () => { + // Two callers hitting the same repo+remote at the same time must share + // one underlying fetch. Without the in-flight Map they would each + // dispatch an independent `git fetch`, tripling the network load in the + // worst case (renderer create + dispatch probe + CLI create). + let resolveFetch!: () => void + const pending = new Promise<{ stdout: string; stderr: string }>((resolve) => { + resolveFetch = () => resolve({ stdout: '', stderr: '' }) + }) + gitExecFileAsyncMock.mockReturnValueOnce(pending) + + const runtime = new OrcaRuntimeService(null) + + const first = runtime.fetchRemoteWithCache('/repo/c', 'origin') + const second = runtime.fetchRemoteWithCache('/repo/c', 'origin') + + // Allow both callers to register before we resolve. + await Promise.resolve() + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + + resolveFetch() + await Promise.all([first, second]) + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('skips the fetch inside the 30s freshness window after a successful fetch', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + const runtime = new OrcaRuntimeService(null) + + await runtime.fetchRemoteWithCache('/repo/d', 'origin') + await runtime.fetchRemoteWithCache('/repo/d', 'origin') + + // Second call must short-circuit on the freshness window (no new exec). + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/fit-override-integration.test.ts b/src/main/runtime/fit-override-integration.test.ts new file mode 100644 index 00000000000..a8e7e2ba85c --- /dev/null +++ b/src/main/runtime/fit-override-integration.test.ts @@ -0,0 +1,278 @@ +/** + * Integration test for the mobile-fit override flow. + * Tests the full lifecycle: mobile-fit → restore → verify PTY resized. + */ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + } + ]) +})) + +vi.mock('../hooks', () => ({ + createSetupRunnerScript: vi.fn(), + getEffectiveHooks: vi.fn().mockReturnValue(null), + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) +})) + +vi.mock('../ipc/worktree-logic', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() } +}) + +vi.mock('../ipc/filesystem-auth', () => ({ + invalidateAuthorizedRootsCache: vi.fn() +})) + +vi.mock('../git/repo', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + getBranchConflictKind: vi.fn().mockResolvedValue(null), + getGitUsername: vi.fn().mockReturnValue('') + } +}) + +const store = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [store.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) +} + +describe('fit override integration', () => { + it('full lifecycle: fit → getSize → restore → verify PTY dims', () => { + const runtime = new OrcaRuntimeService(store) + const currentSize = { cols: 150, rows: 40 } + const resizes: { ptyId: string; cols: number; rows: number }[] = [] + const notifications: { ptyId: string; mode: string; cols: number; rows: number }[] = [] + + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + currentSize.cols = cols + currentSize.rows = rows + resizes.push({ ptyId, cols, rows }) + return true + }, + getSize: () => ({ ...currentSize }) + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: (ptyId, mode, cols, rows) => { + notifications.push({ ptyId, mode, cols, rows }) + }, + terminalDriverChanged: vi.fn() + }) + + // Simulate a synced leaf (mounted desktop pane) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Terminal', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + console.log('=== Step 1: Initial state ===') + console.log('PTY size:', currentSize) + expect(currentSize).toEqual({ cols: 150, rows: 40 }) + + console.log('\n=== Step 2: Mobile fit to 45x20 ===') + const fitResult = runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20) + console.log('Fit result:', fitResult) + console.log('PTY size after fit:', currentSize) + console.log('Override:', runtime.getTerminalFitOverride('pty-1')) + expect(currentSize).toEqual({ cols: 45, rows: 20 }) + expect(fitResult.previousCols).toBe(150) + expect(fitResult.previousRows).toBe(40) + + console.log('\n=== Step 3: Desktop Restore (via IPC handler path) ===') + // Simulate what runtime:restoreTerminalFit IPC handler does + const override = runtime.getTerminalFitOverride('pty-1') + expect(override).not.toBeNull() + const restoreResult = runtime.resizeForClient('pty-1', 'restore', override!.clientId) + console.log('Restore result:', restoreResult) + console.log('PTY size after restore:', currentSize) + console.log('Override after restore:', runtime.getTerminalFitOverride('pty-1')) + expect(currentSize).toEqual({ cols: 150, rows: 40 }) + expect(restoreResult.mode).toBe('desktop-fit') + + console.log('\n=== Step 4: Verify all resizes ===') + console.log('All resize calls:', resizes) + expect(resizes).toEqual([ + { ptyId: 'pty-1', cols: 45, rows: 20 }, + { ptyId: 'pty-1', cols: 150, rows: 40 } + ]) + + console.log('\n=== Step 5: Verify notifications ===') + console.log('All notifications:', notifications) + expect(notifications).toEqual([ + { ptyId: 'pty-1', mode: 'mobile-fit', cols: 45, rows: 20 }, + { ptyId: 'pty-1', mode: 'desktop-fit', cols: 150, rows: 40 } + ]) + + console.log('\n=== Step 6: Mobile restore via RPC path ===') + // Re-fit, then restore via the mobile RPC handler path + runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20) + expect(currentSize).toEqual({ cols: 45, rows: 20 }) + + // This is what terminal.resizeForClient RPC handler does + const mobileRestore = runtime.resizeForClient('pty-1', 'restore', 'client-phone') + console.log('Mobile restore result:', mobileRestore) + console.log('PTY size after mobile restore:', currentSize) + expect(currentSize).toEqual({ cols: 150, rows: 40 }) + }) + + it('restore resizes PTY even with mounted leaf (the bug fix)', () => { + const runtime = new OrcaRuntimeService(store) + let ptySize = { cols: 120, rows: 35 } + const resizes: string[] = [] + + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + ptySize = { cols, rows } + resizes.push(`${ptyId}:${cols}x${rows}`) + return true + }, + getSize: () => ({ ...ptySize }) + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + // Synced leaf = mounted desktop pane + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + // Mobile fit + runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-a', 42, 18) + expect(ptySize).toEqual({ cols: 42, rows: 18 }) + + // Restore — THIS is the critical assertion. + // Before the fix, mounted leaves skipped the PTY resize. + runtime.resizeForClient('pty-1', 'restore', 'phone-a') + expect(ptySize).toEqual({ cols: 120, rows: 35 }) + expect(resizes).toEqual(['pty-1:42x18', 'pty-1:120x35']) + }) + + it('disconnect auto-restore also resizes PTY', () => { + const runtime = new OrcaRuntimeService(store) + let ptySize = { cols: 100, rows: 30 } + + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (_ptyId, cols, rows) => { + ptySize = { cols, rows } + return true + }, + getSize: () => ({ ...ptySize }) + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-disconnect', 45, 20) + expect(ptySize).toEqual({ cols: 45, rows: 20 }) + + // Simulate WS disconnect + runtime.onClientDisconnected('phone-disconnect') + expect(ptySize).toEqual({ cols: 100, rows: 30 }) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + }) +}) diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts new file mode 100644 index 00000000000..eea0cb88512 --- /dev/null +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -0,0 +1,346 @@ +/** + * Tests for the presence-based mobile driver lock. + * + * Covers (per docs/mobile-presence-lock.md): + * - State machine transitions (idle | desktop | mobile{clientId}) + * - Most-recent-actor wins for active phone-fit dims + * - Earliest-by-subscribe-time wins for desktop-restore target + * - Subscribe-in-desktop-mode is a passive watch (does NOT take floor) + * - mobileTookFloor → re-applies phone-fit when transitioning from desktop + * - reclaimTerminalForDesktop → idempotent, drops banner, restores dims + * - Multi-mobile sequencing: A subscribes / B subscribes / B unsubscribes / + * A unsubscribes leaves the runtime cleanly idle without dim regressions + * - terminalDriverChanged notifications fire at the right transitions + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([]) +})) +vi.mock('../hooks', () => ({ + createSetupRunnerScript: vi.fn(), + getEffectiveHooks: vi.fn().mockReturnValue(null), + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) +})) +vi.mock('../ipc/worktree-logic', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() } +}) +vi.mock('../ipc/filesystem-auth', () => ({ invalidateAuthorizedRootsCache: vi.fn() })) +vi.mock('../git/repo', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + getBranchConflictKind: vi.fn().mockResolvedValue(null), + getGitUsername: vi.fn().mockReturnValue('') + } +}) + +const store = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [store.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) +} + +function createRuntime() { + const runtime = new OrcaRuntimeService(store) + const ptySizes = new Map([ + ['pty-1', { cols: 150, rows: 40 }] + ]) + const resizes: { ptyId: string; cols: number; rows: number }[] = [] + const driverEvents: { ptyId: string; driver: { kind: string; clientId?: string } }[] = [] + + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + ptySizes.set(ptyId, { cols, rows }) + resizes.push({ ptyId, cols, rows }) + return true + }, + getSize: (ptyId) => ptySizes.get(ptyId) ?? null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: (ptyId, driver) => { + driverEvents.push({ ptyId, driver: { ...driver } }) + } + }) + + return { runtime, ptySizes, resizes, driverEvents } +} + +describe('mobile presence lock — driver state machine', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('starts idle for unknown PTY', () => { + const { runtime } = createRuntime() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + }) + + it('handleMobileSubscribe in auto mode transitions idle → mobile{clientId}', () => { + const { runtime, driverEvents } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(driverEvents.at(-1)?.driver).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + }) + + it('handleMobileSubscribe in desktop mode is passive — does NOT take floor', () => { + const { runtime, driverEvents } = createRuntime() + // Pretend a previous take-back put us in desktop mode. + runtime.setMobileDisplayMode('pty-1', 'desktop') + + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + + // Driver stays idle (the desktop banner was already gone). Phone is + // "passively watching" at desktop dims. + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + expect(driverEvents.find((e) => e.driver.kind === 'mobile')).toBeUndefined() + }) + + it('reclaimTerminalForDesktop transitions mobile → desktop and is idempotent', () => { + const { runtime } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(runtime.getDriver('pty-1').kind).toBe('mobile') + + expect(runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + + // Idempotent — second call is a no-op (no active mobile subscriber to + // reclaim from). + expect(runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + }) + + it('mobileTookFloor after reclaim re-applies phone-fit and flips driver back to mobile', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.reclaimTerminalForDesktop('pty-1') + expect(runtime.getDriver('pty-1').kind).toBe('desktop') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + + runtime.mobileTookFloor('pty-1', 'phone-A') + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + // PTY is back at phone dims. + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + }) + + it('handleMobileUnsubscribe last leaver flips driver to idle after soft-leave grace', () => { + const { runtime, driverEvents } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + + // Why: soft-leave grace keeps driver=mobile{phone-A} for ~250ms so a + // re-subscribe (e.g. mobile keyboard show/hide on legacy clients) + // doesn't cause a desktop banner flash. + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + vi.advanceTimersByTime(250) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + expect(driverEvents.at(-1)?.driver).toEqual({ kind: 'idle' }) + }) + + it('resubscribe within soft-leave grace cancels idle without driver flap', () => { + const { runtime, driverEvents } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + // Same client re-subscribes inside the grace window — no idle should + // ever be observed by the renderer. + vi.advanceTimersByTime(100) + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + vi.advanceTimersByTime(500) + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(driverEvents.find((e) => e.driver.kind === 'idle')).toBeUndefined() + }) + + it('onPtyExit clears driver state and emits idle', () => { + const { runtime, driverEvents } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + + runtime.onPtyExit('pty-1', 0) + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + // The last emitted event for pty-1 must be idle. + const last = [...driverEvents].reverse().find((e) => e.ptyId === 'pty-1') + expect(last?.driver).toEqual({ kind: 'idle' }) + }) +}) + +describe('mobile presence lock — multi-mobile semantics', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('most-recent actor wins active phone-fit dims (B subscribes after A)', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Advance fake clock so B's subscribedAt is strictly greater than A's. + vi.advanceTimersByTime(10) + // B's narrower viewport must win when it subscribes. + runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + + expect(ptySizes.get('pty-1')).toEqual({ cols: 38, rows: 18 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + }) + + it('B unsubscribes — A still present, driver re-elects to A', () => { + const { runtime, driverEvents } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + vi.advanceTimersByTime(10) + runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + + runtime.handleMobileUnsubscribe('pty-1', 'phone-B') + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(driverEvents.at(-1)?.driver).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + }) + + it('A then B unsubscribes — peer survives; final unsubscribe goes idle', () => { + const { runtime } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + vi.advanceTimersByTime(10) + runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + + runtime.handleMobileUnsubscribe('pty-1', 'phone-B') + // Last leaver enters soft-grace; advance past it before asserting idle. + vi.advanceTimersByTime(250) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + }) + + it('terminal.send by phone-B updates lastActedAt — applyMobileDisplayMode picks B viewport', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + // Advance the fake clock so phone-B's subscribe records a strictly + // later subscribedAt/lastActedAt — keeps tie-break deterministic. + vi.advanceTimersByTime(10) + runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + // Switch to desktop, then phone-B types — its viewport wins on re-fit. + runtime.setMobileDisplayMode('pty-1', 'desktop') + runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + + // Simulate B taking the floor by typing (advance again so lastActedAt + // is unambiguously the most recent). + vi.advanceTimersByTime(10) + runtime.mobileTookFloor('pty-1', 'phone-B') + + expect(ptySizes.get('pty-1')).toEqual({ cols: 38, rows: 18 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + }) + + it('updateMobileViewport re-fits PTY without flipping the driver', () => { + const { runtime, ptySizes, driverEvents } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 38 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + const before = driverEvents.length + + // Keyboard opens — viewport shrinks. + expect(runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 })).toBe(true) + + expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 16 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + // Why: a viewport update may re-emit driver to refresh listener + // wiring, but it must never go through `idle` (no banner flash). + expect(driverEvents.slice(before).every((e) => e.driver.kind === 'mobile')).toBe(true) + }) + + it('updateMobileViewport then disconnect restores PTY to original baseline', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 38 }) + + // Keyboard cycles a few times. + runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 }) + runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 38 }) + runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 }) + + // Phone disconnects (router.back → WS close). + runtime.onClientDisconnected('phone-A') + + // PTY must restore to the original 150x40 baseline, not the last + // phone-fit dim. This was the stuck-dim bug. + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + }) + + it('legacy unsubscribe → resubscribe within grace preserves baseline (regression)', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 38 }) + + // Simulate legacy keyboard re-subscribe cycle within grace. + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + vi.advanceTimersByTime(100) + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 16 }) + // Apply mode after re-subscribe so the new viewport drives PTY dims. + runtime.applyMobileDisplayMode('pty-1') + + // PTY at new viewport, phone-A still drives. + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + // Disconnect — must restore to original 150x40, not 49x16. + runtime.onClientDisconnected('phone-A') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('earliest-subscribe restore target is preserved when peers churn', () => { + const { runtime, ptySizes } = createRuntime() + // A captures the original 150x40 baseline. + runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + // B subscribes later at 38x18 (advance clock for unambiguous ordering). + vi.advanceTimersByTime(10) + runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + + // A leaves, B leaves — final restore must use A's earliest baseline (150x40), + // NOT B's (which captured 45x20 when it joined a phone-fitted PTY). + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + runtime.handleMobileUnsubscribe('pty-1', 'phone-B') + vi.advanceTimersByTime(300) + + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) +}) diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts new file mode 100644 index 00000000000..fd344c61511 --- /dev/null +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -0,0 +1,472 @@ +/* oxlint-disable max-lines -- Why: integration tests cover the full mobile subscribe lifecycle across many scenarios; splitting would scatter related assertions. */ +/** + * Integration tests for the server-authoritative mobile subscribe lifecycle. + * Tests handleMobileSubscribe, handleMobileUnsubscribe, applyMobileDisplayMode, + * debounced restore, inline restore on timer cancel, and cleanup paths. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + } + ]) +})) + +vi.mock('../hooks', () => ({ + createSetupRunnerScript: vi.fn(), + getEffectiveHooks: vi.fn().mockReturnValue(null), + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) +})) + +vi.mock('../ipc/worktree-logic', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() } +}) + +vi.mock('../ipc/filesystem-auth', () => ({ + invalidateAuthorizedRootsCache: vi.fn() +})) + +vi.mock('../git/repo', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + getBranchConflictKind: vi.fn().mockResolvedValue(null), + getGitUsername: vi.fn().mockReturnValue('') + } +}) + +const store = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [store.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) +} + +function createRuntime() { + const runtime = new OrcaRuntimeService(store) + const ptySizes = new Map() + ptySizes.set('pty-1', { cols: 150, rows: 40 }) + ptySizes.set('pty-2', { cols: 120, rows: 35 }) + ptySizes.set('pty-3', { cols: 100, rows: 30 }) + + const resizes: { ptyId: string; cols: number; rows: number }[] = [] + const notifications: { ptyId: string; mode: string; cols: number; rows: number }[] = [] + + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + ptySizes.set(ptyId, { cols, rows }) + resizes.push({ ptyId, cols, rows }) + return true + }, + getSize: (ptyId) => ptySizes.get(ptyId) ?? null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: (ptyId, mode, cols, rows) => { + notifications.push({ ptyId, mode, cols, rows }) + }, + terminalDriverChanged: vi.fn() + }) + + return { runtime, ptySizes, resizes, notifications } +} + +describe('mobile subscribe integration', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('handleMobileSubscribe resizes PTY to phone dims', () => { + const { runtime, ptySizes, resizes, notifications } = createRuntime() + + const result = runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + expect(result).toBe(true) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + expect(resizes).toEqual([{ ptyId: 'pty-1', cols: 45, rows: 20 }]) + expect(notifications).toEqual([{ ptyId: 'pty-1', mode: 'mobile-fit', cols: 45, rows: 20 }]) + expect(runtime.isMobileSubscriberActive('pty-1')).toBe(true) + }) + + it('handleMobileSubscribe skips resize when mode is desktop', () => { + const { runtime, ptySizes, resizes } = createRuntime() + runtime.setMobileDisplayMode('pty-1', 'desktop') + + const result = runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + expect(result).toBe(false) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(resizes).toEqual([]) + }) + + it('handleMobileSubscribe skips resize when no viewport provided', () => { + const { runtime, ptySizes, resizes } = createRuntime() + + const result = runtime.handleMobileSubscribe('pty-1', 'client-a') + + expect(result).toBe(false) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(resizes).toEqual([]) + }) + + it('handleMobileUnsubscribe restores PTY after 300ms debounce in auto mode', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + // Not yet restored + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + vi.advanceTimersByTime(300) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('handleMobileUnsubscribe does not restore in phone mode', () => { + const { runtime, ptySizes } = createRuntime() + runtime.setMobileDisplayMode('pty-1', 'phone') + // In phone mode, handleMobileSubscribe still resizes because mode is 'phone' + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + vi.advanceTimersByTime(1000) + // Still at phone dims — no restore for phone mode + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + }) + + // TODO: inline restore on re-subscribe not yet implemented + it.skip('re-subscribe within 300ms cancels debounce timer and inline-restores old PTY', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + // Re-subscribe to a different terminal before the timer fires + vi.advanceTimersByTime(100) + runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 }) + + // pty-1 was inline-restored when pty-2 subscribed (timer cancelled + immediate restore) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(ptySizes.get('pty-2')).toEqual({ cols: 45, rows: 20 }) + + // Advancing past the 300ms debounce should not cause a second restore + vi.advanceTimersByTime(300) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + // TODO: inline restore on re-subscribe not yet implemented + it.skip('rapid A→B→C tab navigation: inline restore of A when B subscribes', () => { + const { runtime, ptySizes } = createRuntime() + + // Subscribe to A + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Unsubscribe A, subscribe B — A's pending timer is cancelled, A gets inline restore + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 }) + + // pty-1 should be restored inline (not waiting for timer) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(ptySizes.get('pty-2')).toEqual({ cols: 45, rows: 20 }) + + // Unsubscribe B, subscribe C — B's pending timer cancelled, B gets inline restore + runtime.handleMobileUnsubscribe('pty-2', 'client-a') + runtime.handleMobileSubscribe('pty-3', 'client-a', { cols: 45, rows: 20 }) + + expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 }) + expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 }) + + // Verify final state + vi.advanceTimersByTime(1000) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 }) + expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 }) + }) + + it('preserves previousDims across re-subscribes to same terminal', () => { + const { runtime, ptySizes } = createRuntime() + + // First subscribe at desktop 150x40 + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Re-subscribe to the same terminal (e.g., after reconnect) + // The PTY is already at 45x20, but previousDims should still be 150x40 + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // Unsubscribe and let restore fire + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + vi.advanceTimersByTime(300) + + // Should restore to original desktop dims, not 45x20 + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('clamps viewport to valid range', () => { + const { runtime, ptySizes } = createRuntime() + + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 10, rows: 3 }) + // Should clamp to minimum 20x8 + expect(ptySizes.get('pty-1')).toEqual({ cols: 20, rows: 8 }) + }) + + describe('display mode', () => { + it('defaults to auto', () => { + const { runtime } = createRuntime() + expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto') + }) + + it('set/get round-trip', () => { + const { runtime } = createRuntime() + runtime.setMobileDisplayMode('pty-1', 'phone') + expect(runtime.getMobileDisplayMode('pty-1')).toBe('phone') + + runtime.setMobileDisplayMode('pty-1', 'desktop') + expect(runtime.getMobileDisplayMode('pty-1')).toBe('desktop') + + // Setting to 'auto' deletes the entry (same as default) + runtime.setMobileDisplayMode('pty-1', 'auto') + expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto') + }) + }) + + describe('applyMobileDisplayMode', () => { + it('desktop mode restores PTY when currently phone-fitted', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + const resizeEvents: unknown[] = [] + runtime.subscribeToTerminalResize('pty-1', (event) => resizeEvents.push(event)) + + runtime.setMobileDisplayMode('pty-1', 'desktop') + runtime.applyMobileDisplayMode('pty-1') + + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(resizeEvents).toHaveLength(1) + expect(resizeEvents[0]).toMatchObject({ + cols: 150, + rows: 40, + displayMode: 'desktop', + reason: 'mode-change' + }) + }) + + it('auto mode re-fits PTY when subscriber exists and not phone-fitted', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // Switch to desktop (restores to 150x40) + runtime.setMobileDisplayMode('pty-1', 'desktop') + runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + + const resizeEvents: unknown[] = [] + runtime.subscribeToTerminalResize('pty-1', (event) => resizeEvents.push(event)) + + // Switch back to auto (should re-fit to phone dims) + runtime.setMobileDisplayMode('pty-1', 'auto') + runtime.applyMobileDisplayMode('pty-1') + + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + expect(resizeEvents).toHaveLength(1) + expect(resizeEvents[0]).toMatchObject({ + displayMode: 'auto', + reason: 'mode-change' + }) + }) + }) + + describe('cleanup paths', () => { + it('onClientDisconnected restores all PTYs immediately (no debounce)', () => { + const { runtime, ptySizes } = createRuntime() + + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 }) + + runtime.onClientDisconnected('client-a') + + // Both PTYs restored immediately + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 }) + expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false) + expect(runtime.isMobileSubscriberActive('pty-2')).toBe(false) + }) + + it('onClientDisconnected cancels pending restore timers', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + // Timer is pending + + runtime.onClientDisconnected('client-a') + // Timer should be cancelled, PTY already restored by disconnect handler + + vi.advanceTimersByTime(1000) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('onPtyExit cleans up mobileSubscribers and pending timers', () => { + const { runtime } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + // Timer pending for pty-1 + + runtime.onPtyExit('pty-1', 0) + expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false) + expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto') + + // Timer should have been cancelled — no crash from resizing a dead PTY + vi.advanceTimersByTime(1000) + }) + + it('onPtyExit does not cancel timers for other PTYs', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + // pty-2 exits — should not affect pty-1's pending restore + runtime.onPtyExit('pty-2', 0) + + vi.advanceTimersByTime(300) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + }) + + describe('resize listener system', () => { + it('subscribe/unsubscribe lifecycle', () => { + const { runtime } = createRuntime() + const events: unknown[] = [] + const unsubscribe = runtime.subscribeToTerminalResize('pty-1', (e) => events.push(e)) + + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.setMobileDisplayMode('pty-1', 'desktop') + runtime.applyMobileDisplayMode('pty-1') + + expect(events.length).toBeGreaterThan(0) + + const countBefore = events.length + unsubscribe() + + // After unsubscribe, no more events + runtime.setMobileDisplayMode('pty-1', 'auto') + runtime.applyMobileDisplayMode('pty-1') + expect(events.length).toBe(countBefore) + }) + }) + + describe('onExternalPtyResize', () => { + it('updates previousCols when desktop renderer resizes PTY after desktop restore', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // Toggle to desktop — restores to previousCols (150x40) + runtime.setMobileDisplayMode('pty-1', 'desktop') + runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + + // Simulate desktop renderer's safeFit correcting to split-pane width + runtime.onExternalPtyResize('pty-1', 105, 40) + + // Toggle back to auto — should capture previousCols=105 (not 150) + runtime.setMobileDisplayMode('pty-1', 'auto') + runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Toggle to desktop again — should restore to 105 (the corrected value) + runtime.setMobileDisplayMode('pty-1', 'desktop') + runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 }) + }) + + it('uses lastRendererSize for previousCols on first subscribe', () => { + const { runtime, ptySizes } = createRuntime() + + // Simulate: PTY spawned at 214 (ptySizes), but renderer already fit to 105 + ptySizes.set('pty-1', { cols: 214, rows: 72 }) + runtime.onExternalPtyResize('pty-1', 105, 40) + + // First mobile subscribe — should use rendererSize (105) not ptySizes (214) + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Toggle to desktop — should restore to 105, not 214 + runtime.setMobileDisplayMode('pty-1', 'desktop') + runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 }) + }) + + it('does not update previousCols when PTY is phone-fitted', () => { + const { runtime, ptySizes } = createRuntime() + runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // PTY is phone-fitted (wasResizedToPhone=true) — external resize should not + // overwrite previousCols with phone dims + runtime.onExternalPtyResize('pty-1', 45, 20) + + // Toggle to desktop — should still restore to original 150x40 + runtime.setMobileDisplayMode('pty-1', 'desktop') + runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + }) + + describe('backward compatibility', () => { + it('old resizeForClient still works alongside new system', () => { + const { runtime, ptySizes } = createRuntime() + + // Old flow: explicit resizeForClient + const fitResult = runtime.resizeForClient('pty-1', 'mobile-fit', 'client-old', 45, 20) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + expect(fitResult.mode).toBe('mobile-fit') + + // Old flow: restore + const restoreResult = runtime.resizeForClient('pty-1', 'restore', 'client-old') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(restoreResult.mode).toBe('desktop-fit') + }) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index e4e44b932a7..2133215a265 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1,13 +1,20 @@ /* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */ import { afterEach, describe, expect, it, vi } from 'vitest' import type { WorktreeMeta } from '../../shared/types' -import { addWorktree, listWorktrees } from '../git/worktree' -import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks' +import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree' +import { + createSetupRunnerScript, + getEffectiveHooks, + runHook, + shouldRunSetupForCreate +} from '../hooks' +import { OrchestrationDb } from './orchestration/db' import { OrcaRuntimeService } from './orca-runtime' const { MOCK_GIT_WORKTREES, addWorktreeMock, + removeWorktreeMock, computeWorktreePathMock, ensurePathWithinWorkspaceMock, invalidateAuthorizedRootsCacheMock @@ -22,6 +29,7 @@ const { } ], addWorktreeMock: vi.fn(), + removeWorktreeMock: vi.fn(), computeWorktreePathMock: vi.fn(), ensurePathWithinWorkspaceMock: vi.fn(), invalidateAuthorizedRootsCacheMock: vi.fn() @@ -29,13 +37,19 @@ const { vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES), - addWorktree: addWorktreeMock + addWorktree: addWorktreeMock, + removeWorktree: removeWorktreeMock })) vi.mock('../hooks', () => ({ createSetupRunnerScript: vi.fn(), getEffectiveHooks: vi.fn().mockReturnValue(null), - runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }), + shouldRunSetupForCreate: vi + .fn() + .mockImplementation((_repo: never, decision: string) => decision === 'run'), + getEffectiveSetupRunPolicy: vi.fn().mockReturnValue('auto'), + hasHooksFile: vi.fn().mockReturnValue(false) })) vi.mock('../ipc/worktree-logic', async (importOriginal) => { @@ -69,15 +83,43 @@ vi.mock('../git/repo', async (importOriginal) => { afterEach(() => { vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES) vi.mocked(addWorktree).mockReset() + vi.mocked(removeWorktree).mockReset() vi.mocked(createSetupRunnerScript).mockReset() vi.mocked(getEffectiveHooks).mockReset() vi.mocked(runHook).mockReset() + vi.mocked(shouldRunSetupForCreate).mockReset() + vi.mocked(shouldRunSetupForCreate).mockImplementation((_repo, decision) => decision === 'run') vi.mocked(getEffectiveHooks).mockReturnValue(null) computeWorktreePathMock.mockReset() ensurePathWithinWorkspaceMock.mockReset() invalidateAuthorizedRootsCacheMock.mockReset() }) +function syncSinglePty(runtime: OrcaRuntimeService, ptyId: string | null = 'pty-1'): void { + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId, + paneTitle: null + } + ] + }) +} + const TEST_WINDOW_ID = 1 const TEST_REPO_ID = 'repo-1' const TEST_REPO_PATH = '/tmp/repo' @@ -126,6 +168,7 @@ const store = { ...meta }) as never, removeWorktreeMeta: () => {}, + getGitHubCache: () => undefined as never, getSettings: () => ({ workspaceDir: '/tmp/workspaces', nestWorkspaces: false, @@ -166,6 +209,16 @@ describe('OrcaRuntimeService', () => { expect(runtime.getRuntimeId()).toBeTruthy() }) + it('reports protocol version and minimum compatible mobile version on status', () => { + const runtime = createRuntime() + + const status = runtime.getStatus() + expect(typeof status.protocolVersion).toBe('number') + expect(typeof status.minCompatibleMobileVersion).toBe('number') + expect(status.protocolVersion).toBeGreaterThanOrEqual(1) + expect(status.minCompatibleMobileVersion).toBeGreaterThanOrEqual(0) + }) + it('claims the first window as authoritative and ignores later windows', () => { const runtime = createRuntime() @@ -300,7 +353,8 @@ describe('OrcaRuntimeService', () => { writes.push(data) return true }, - kill: () => true + kill: () => true, + getForegroundProcess: async () => null }) runtime.attachWindow(1) @@ -344,7 +398,7 @@ describe('OrcaRuntimeService', () => { handle: terminal.handle, accepted: true }) - expect(writes).toEqual(['continue\r']) + expect(writes).toEqual(['continue', '\r']) }) it('waits for terminal exit and resolves with the exit status', async () => { @@ -438,6 +492,148 @@ describe('OrcaRuntimeService', () => { expect(thirdRead.nextCursor).toBe('2') }) + it('delivers pending orchestration messages to an already-idle agent', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new OrchestrationDb(':memory:') + const write = vi.fn().mockReturnValue(true) + runtime.setOrchestrationDb(db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello' }) + + runtime.deliverPendingMessagesForHandle(terminal.handle) + + expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello')) + // Why: the split Enter write lands after the 500ms delay, so we advance + // past it before asserting on delivered_at. + await vi.advanceTimersByTimeAsync(500) + expect(write).toHaveBeenCalledWith('pty-1', '\r') + + // Why: design doc §3.2 splits delivered vs. read — push-on-idle stamps + // `delivered_at` but must *not* flip `read`, since only the check caller + // (the agent) is authorized to consume messages from its queue. The + // injected banner is a courtesy; the rows stay unread so the agent can + // still observe them via `check` and resolve the consumption race. + const unread = db.getUnreadMessages(terminal.handle) + expect(unread).toHaveLength(1) + expect(unread[0].read).toBe(0) + expect(unread[0].delivered_at).not.toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('adopts preallocated ORCA_TERMINAL_HANDLE as a valid runtime handle', async () => { + const runtime = new OrcaRuntimeService(store) + const handle = runtime.preAllocateHandleForPty('pty-1') + + syncSinglePty(runtime) + runtime.onPtyData('pty-1', 'ready\n', 100) + + const read = await runtime.readTerminal(handle) + expect(read.handle).toBe(handle) + expect(read.tail).toEqual(['ready']) + }) + + it('keeps preallocated terminal handles valid across renderer reloads', async () => { + const runtime = new OrcaRuntimeService(store) + const handle = runtime.preAllocateHandleForPty('pty-1') + + syncSinglePty(runtime) + runtime.markRendererReloading(1) + syncSinglePty(runtime, null) + runtime.onPtyData('pty-1', 'after reload\n', 100) + + const read = await runtime.readTerminal(handle) + expect(read.tail).toEqual(['after reload']) + }) + + it('keeps preallocated terminal handles valid when a reload graph omits the live leaf', async () => { + const runtime = new OrcaRuntimeService(store) + const handle = runtime.preAllocateHandleForPty('pty-1') + + syncSinglePty(runtime) + runtime.markRendererReloading(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [] + }) + runtime.onPtyData('pty-1', 'after omitted leaf\n', 100) + + const read = await runtime.readTerminal(handle) + expect(read.tail).toEqual(['after omitted leaf']) + }) + + it('keeps preallocated terminal handles valid after graph unavailable during reload', async () => { + const runtime = new OrcaRuntimeService(store) + const handle = runtime.preAllocateHandleForPty('pty-1') + + syncSinglePty(runtime) + runtime.markGraphUnavailable(1) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [] + }) + runtime.onPtyData('pty-1', 'after unavailable\n', 100) + + const read = await runtime.readTerminal(handle) + expect(read.tail).toEqual(['after unavailable']) + }) + + it('keeps already-idle status after tui-idle wait for immediate message delivery', async () => { + const runtime = new OrcaRuntimeService(store) + const db = new OrchestrationDb(':memory:') + const write = vi.fn().mockReturnValue(true) + runtime.setOrchestrationDb(db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + db.insertMessage({ from: 'sender', to: terminal.handle, subject: 'after wait' }) + + runtime.deliverPendingMessagesForHandle(terminal.handle) + + expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: after wait')) + db.close() + }) + + it('resolves message waiters when notifyMessageArrived is called', async () => { + const runtime = new OrcaRuntimeService(store) + + const waitPromise = runtime.waitForMessage('term_abc', { timeoutMs: 5000 }) + runtime.notifyMessageArrived('term_abc') + await waitPromise + }) + + it('resolves message waiters on timeout when no message arrives', async () => { + const runtime = new OrcaRuntimeService(store) + + const start = Date.now() + await runtime.waitForMessage('term_abc', { timeoutMs: 100 }) + const elapsed = Date.now() - start + expect(elapsed).toBeGreaterThanOrEqual(90) + expect(elapsed).toBeLessThan(500) + }) + it('fails terminal waits closed when the handle goes stale during reload', async () => { const runtime = new OrcaRuntimeService(store) @@ -481,7 +677,7 @@ describe('OrcaRuntimeService', () => { { tabId: 'tab-1', worktreeId: 'repo-1::/tmp/worktree-a', - title: 'Claude', + title: 'Terminal 1', activeLeafId: 'pane:1', layout: null } @@ -590,7 +786,11 @@ describe('OrcaRuntimeService', () => { repo: 'repo', path: '/tmp/worktree-a', branch: 'feature/foo', + displayName: 'foo', linkedIssue: 123, + linkedPR: null, + isPinned: false, + status: 'active', unread: false, liveTerminalCount: 1, hasAttachedPty: true, @@ -603,6 +803,46 @@ describe('OrcaRuntimeService', () => { }) }) + it('clears stale working status after the agent exits and the shell takes over the title', async () => { + // Why: regression test for issue #1437 — the mobile worktree-list spinner + // kept playing forever because lastAgentStatus was sticky on 'working' + // once an agent exited without emitting an idle/agent-shaped final OSC + // title. worktree.ps must recompute from the live OSC title each call. + const runtime = new OrcaRuntimeService(store) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Codex working', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + const working = await runtime.getWorktreePs() + expect(working.worktrees[0].status).toBe('working') + + // Agent exits, shell title takes over — desktop's getWorktreeStatus would + // immediately flip back to 'active'. Mobile must do the same. + runtime.onPtyData('pty-1', '\x1b]0;bash\x07', 200) + const afterExit = await runtime.getWorktreePs() + expect(afterExit.worktrees[0].status).toBe('active') + }) + it('fails terminal stop closed while the renderer graph is reloading', async () => { const runtime = new OrcaRuntimeService(store) let killed = false @@ -611,7 +851,8 @@ describe('OrcaRuntimeService', () => { kill: () => { killed = true return true - } + }, + getForegroundProcess: async () => null }) runtime.attachWindow(1) @@ -691,7 +932,8 @@ describe('OrcaRuntimeService', () => { kill: () => { killed = true return true - } + }, + getForegroundProcess: async () => null }) runtime.attachWindow(1) @@ -740,7 +982,7 @@ describe('OrcaRuntimeService', () => { await expect(runtime.searchRepoRefs('id:repo-1', 'main', -5)).rejects.toThrow('invalid_limit') }) - it('returns a setup launch payload for CLI-created worktrees when orca.yaml defines setup', async () => { + it('returns a setup launch payload for CLI-created worktrees when hooks are explicitly enabled', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() runtime.setNotifier({ @@ -751,7 +993,10 @@ describe('OrcaRuntimeService', () => { splitTerminal: vi.fn(), renameTerminal: vi.fn(), focusTerminal: vi.fn(), - closeTerminal: vi.fn() + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() }) runtime.attachWindow(1) @@ -781,7 +1026,8 @@ describe('OrcaRuntimeService', () => { const result = await runtime.createManagedWorktree({ repoSelector: 'id:repo-1', - name: 'runtime-hook-test' + name: 'runtime-hook-test', + runHooks: true }) expect(createSetupRunnerScript).toHaveBeenCalledWith( @@ -814,6 +1060,146 @@ describe('OrcaRuntimeService', () => { expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup) }) + it('skips setup hooks for CLI-created worktrees by default', async () => { + const runtime = new OrcaRuntimeService(store) + const activateWorktree = vi.fn() + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hook-skip') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hook-skip') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { + path: '/tmp/workspaces/runtime-hook-skip', + head: 'def', + branch: 'runtime-hook-skip', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-hook-skip' + }) + + expect(createSetupRunnerScript).not.toHaveBeenCalled() + expect(runHook).not.toHaveBeenCalled() + expect(result).toEqual({ + worktree: expect.objectContaining({ + repoId: 'repo-1', + path: '/tmp/workspaces/runtime-hook-skip', + branch: 'runtime-hook-skip' + }), + warning: + 'orca.yaml setup hook skipped for /tmp/workspaces/runtime-hook-skip; pass --run-hooks to run it.' + }) + expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), undefined) + }) + + it('stamps createdAt alongside lastActivityAt so CLI-created worktrees get the Recent-sort grace window', async () => { + // Why: parity with createLocalWorktree / createRemoteWorktree. Without + // createdAt, ambient PTY bumps in OTHER worktrees during the few seconds + // after creation can push the new worktree below them in Recent sort. + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-grace') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-grace') + vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: {} }) + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { + path: '/tmp/workspaces/runtime-grace', + head: 'def', + branch: 'runtime-grace', + isBare: false, + isMainWorktree: false + } + ]) + + const before = Date.now() + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-grace' + }) + const after = Date.now() + + expect(result.worktree.createdAt).toBeDefined() + expect(result.worktree.createdAt).toBeGreaterThanOrEqual(before) + expect(result.worktree.createdAt).toBeLessThanOrEqual(after) + // Both fields must be stamped from the same `now` so the grace-window + // math (max(lastActivityAt, createdAt + GRACE_MS)) is well-defined. + expect(result.worktree.createdAt).toBe(result.worktree.lastActivityAt) + }) + + it('skips archive hooks for CLI worktree removal by default', async () => { + const runtime = new OrcaRuntimeService(store) + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + archive: 'pnpm worktree:archive' + } + }) + vi.mocked(removeWorktree).mockResolvedValue(undefined) + + const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + + expect(runHook).not.toHaveBeenCalled() + expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, TEST_WORKTREE_PATH, false) + expect(result.warning).toBe( + `orca.yaml archive hook skipped for ${TEST_WORKTREE_PATH}; pass --run-hooks to run it.` + ) + }) + + it('runs archive hooks for CLI worktree removal when hooks are explicitly enabled', async () => { + const runtime = new OrcaRuntimeService(store) + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + archive: 'pnpm worktree:archive' + } + }) + vi.mocked(runHook).mockResolvedValue({ success: true, output: '' }) + vi.mocked(removeWorktree).mockResolvedValue(undefined) + + await runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, true) + + expect(runHook).toHaveBeenCalledWith( + 'archive', + TEST_WORKTREE_PATH, + expect.objectContaining({ id: TEST_REPO_ID, path: TEST_REPO_PATH }) + ) + expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, TEST_WORKTREE_PATH, false) + }) + it('invalidates the filesystem-auth cache after CLI worktree creation', async () => { // Reproduces: CLI-created worktrees fail with "Access denied: unknown // repository or worktree path" because the filesystem-auth cache was @@ -827,7 +1213,10 @@ describe('OrcaRuntimeService', () => { splitTerminal: vi.fn(), renameTerminal: vi.fn(), focusTerminal: vi.fn(), - closeTerminal: vi.fn() + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() }) computeWorktreePathMock.mockReturnValue('/tmp/workspaces/cli-worktree') @@ -885,6 +1274,7 @@ describe('OrcaRuntimeService', () => { return nextMeta }, removeWorktreeMeta: () => {}, + getGitHubCache: () => undefined as never, getSettings: () => ({ workspaceDir: 'C:\\workspaces', nestWorkspaces: false, @@ -1083,4 +1473,122 @@ describe('OrcaRuntimeService', () => { expect(getRegisteredTabsMock).toHaveBeenCalledWith(`${TEST_REPO_ID}::/tmp/worktree-b`) }) }) + + describe('removeManagedWorktree PTY teardown (design §4.3)', () => { + function createProviderStub( + listProcesses: () => Promise<{ id: string; cwd: string; title: string }[]> + ): { + spawn: ReturnType + attach: ReturnType + write: ReturnType + resize: ReturnType + shutdown: ReturnType + sendSignal: ReturnType + getCwd: ReturnType + getInitialCwd: ReturnType + clearBuffer: ReturnType + acknowledgeDataEvent: ReturnType + hasChildProcesses: ReturnType + getForegroundProcess: ReturnType + serialize: ReturnType + revive: ReturnType + listProcesses: ReturnType + getDefaultShell: ReturnType + getProfiles: ReturnType + onData: ReturnType + onReplay: ReturnType + onExit: ReturnType + } { + return { + spawn: vi.fn(), + attach: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + 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(), + listProcesses: vi.fn(listProcesses), + getDefaultShell: vi.fn(), + getProfiles: vi.fn(), + onData: vi.fn().mockReturnValue(() => {}), + onReplay: vi.fn().mockReturnValue(() => {}), + onExit: vi.fn().mockReturnValue(() => {}) + } + } + + it('RPC-initiated delete kills matching PTYs before git', async () => { + // Seed the runtime with a live leaf whose worktreeId matches the target. + const killSpy = vi.fn().mockReturnValue(true) + const localProvider = createProviderStub(async () => []) + const callOrder: string[] = [] + vi.mocked(removeWorktree).mockImplementation(async () => { + callOrder.push('git-removeWorktree') + }) + + const runtime = new OrcaRuntimeService(store, undefined, { + getLocalProvider: () => { + callOrder.push('getLocalProvider') + return localProvider as never + } + }) + runtime.setPtyController({ + write: () => true, + kill: (id) => { + callOrder.push(`kill:${id}`) + return killSpy(id) as boolean + }, + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, 'pty-1') + + await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + + expect(killSpy).toHaveBeenCalledWith('pty-1') + // The provider-prefix sweep and the git removal must happen AFTER the + // runtime-graph kill. Git removal must NOT happen before any kill. + const killIdx = callOrder.indexOf('kill:pty-1') + const gitIdx = callOrder.indexOf('git-removeWorktree') + expect(killIdx).toBeGreaterThanOrEqual(0) + expect(gitIdx).toBeGreaterThan(killIdx) + }) + + it('thunk resolves the installed provider lazily, not at construction time', async () => { + // Simulates the daemon adapter being installed AFTER OrcaRuntimeService + // construction (setLocalPtyProvider(routedAdapter) in daemon-init). + // A capture-at-construction refactor would break this test. + const preDaemonProvider = createProviderStub(async () => [ + { id: '1', cwd: '/tmp', title: 'shell' }, + { id: '2', cwd: '/tmp', title: 'shell' } + ]) + const postDaemonProvider = createProviderStub(async () => [ + { id: `${TEST_WORKTREE_ID}@@aaaaaaaa`, cwd: '/tmp', title: 'shell' } + ]) + let currentProvider: ReturnType = preDaemonProvider + + const runtime = new OrcaRuntimeService(store, undefined, { + getLocalProvider: () => currentProvider as never + }) + vi.mocked(removeWorktree).mockResolvedValue(undefined) + + // Simulate daemon-init swapping the provider after construction. + currentProvider = postDaemonProvider + + await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + + // The post-daemon provider's prefix-matching session must have been + // shut down, proving the thunk resolved lazily at call time. + expect(postDaemonProvider.shutdown).toHaveBeenCalledWith(`${TEST_WORKTREE_ID}@@aaaaaaaa`, { + immediate: true + }) + // The pre-daemon provider must not have been consulted for the kill. + expect(preDaemonProvider.shutdown).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a9fa0024cf0..a61d0498ef2 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,15 +1,30 @@ /* eslint-disable max-lines -- Why: the Orca runtime is the authoritative live control plane for the CLI, so handle validation, selector resolution, wait state, and summaries are kept together to avoid split-brain behavior. */ /* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */ /* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ -import { extractLastOscTitle, detectAgentStatusFromTitle } from '../../shared/agent-detection' +import { + extractLastOscTitle, + detectAgentStatusFromTitle, + isShellProcess +} from '../../shared/agent-detection' import type { AgentStatus } from '../../shared/agent-detection' -import { gitExecFileAsync, gitExecFileSync } from '../git/runner' +import { gitExecFileAsync } from '../git/runner' import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { randomUUID } from 'crypto' import { join } from 'path' import { rm } from 'fs/promises' -import type { CreateWorktreeResult, Repo } from '../../shared/types' +import { OrchestrationDb } from './orchestration/db' +import { formatMessagesForInjection } from './orchestration/formatter' +import type { + CreateWorktreeResult, + Repo, + StatsSummary, + WorktreeStartupLaunch +} from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' +import { + DESKTOP_PROTOCOL_VERSION, + MIN_COMPATIBLE_MOBILE_VERSION +} from '../../shared/protocol-version' import type { RuntimeGraphStatus, RuntimeRepoSearchRefs, @@ -26,6 +41,7 @@ import type { RuntimeTerminalWait, RuntimeTerminalWaitCondition, RuntimeWorktreePsSummary, + RuntimeWorktreeStatus, RuntimeTerminalShow, RuntimeTerminalSummary, RuntimeSyncedLeaf, @@ -41,9 +57,17 @@ import type { BrowserScrollResult, BrowserBackResult, BrowserReloadResult, + BrowserProfileCreateResult, + BrowserProfileDeleteResult, + BrowserProfileListResult, BrowserScreenshotResult, BrowserEvalResult, + BrowserTabCurrentResult, BrowserTabListResult, + BrowserTabProfileCloneResult, + BrowserTabProfileShowResult, + BrowserTabSetProfileResult, + BrowserTabShowResult, BrowserTabSwitchResult, BrowserHoverResult, BrowserDragResult, @@ -69,7 +93,9 @@ import type { } from '../../shared/runtime-types' import { BrowserWindow, ipcMain } from 'electron' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import { browserManager } from '../browser/browser-manager' import { BrowserError } from '../browser/cdp-bridge' +import { browserSessionRegistry } from '../browser/browser-session-registry' import { waitForTabRegistration } from '../ipc/browser' import { getPRForBranch } from '../github/client' import { @@ -78,10 +104,19 @@ import { getBranchConflictKind, isGitRepo, getRepoName, - searchBaseRefs + searchBaseRefs, + getRemoteDrift, + getRecentDriftSubjects } from '../git/repo' import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree' -import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks' +import { + createSetupRunnerScript, + getEffectiveHooks, + getEffectiveSetupRunPolicy, + hasHooksFile, + runHook, + shouldRunSetupForCreate +} from '../hooks' import { REPO_COLORS } from '../../shared/constants' import { listRepoWorktrees } from '../repo-worktrees' import type { Store } from '../persistence' @@ -99,6 +134,27 @@ import { areWorktreePathsEqual } from '../ipc/worktree-logic' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' +import { HeadlessEmulator } from '../daemon/headless-emulator' +import { killAllProcessesForWorktree } from './worktree-teardown' +import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' +import type { IPtyProvider } from '../providers/types' +import type { ClaudeAccountService } from '../claude-accounts/service' +import type { CodexAccountService } from '../codex-accounts/service' +import type { RateLimitService } from '../rate-limits/service' +import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' +import type { RateLimitState } from '../../shared/rate-limit-types' + +type RuntimeAccountServices = { + claudeAccounts: ClaudeAccountService + codexAccounts: CodexAccountService + rateLimits: RateLimitService +} + +export type AccountsSnapshot = { + claude: ClaudeRateLimitAccountsState + codex: CodexRateLimitAccountsState + rateLimits: RateLimitState +} type RuntimeStore = { getRepos: Store['getRepos'] @@ -109,6 +165,8 @@ type RuntimeStore = { getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] removeWorktreeMeta: Store['removeWorktreeMeta'] + getGitHubCache: Store['getGitHubCache'] + getWorkspaceSession?: Store['getWorkspaceSession'] getSettings(): { workspaceDir: string nestWorkspaces: boolean @@ -130,17 +188,57 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & { tailLinesTotal: number preview: string lastAgentStatus: AgentStatus | null + // Why: the most recent OSC title observed on this leaf's PTY data. Used by + // worktree.ps so daemon-hosted terminals (no renderer pushing pane titles) + // still recompute working/idle from the live title each call instead of + // serving a stale `lastAgentStatus` after the agent process exits and the + // shell takes over the title — the bug behind issue #1437. + lastOscTitle: string | null +} + +type RuntimePtyWorktreeRecord = { + ptyId: string + worktreeId: string + connected: boolean + lastOutputAt: number | null + tailBuffer: string[] + tailPartialLine: string + tailTruncated: boolean + tailLinesTotal: number + preview: string +} + +type RuntimeHeadlessTerminal = { + emulator: HeadlessEmulator + writeChain: Promise } type RuntimePtyController = { write(ptyId: string, data: string): boolean kill(ptyId: string): boolean + getForegroundProcess(ptyId: string): Promise + resize?(ptyId: string, cols: number, rows: number): boolean + listProcesses?(): Promise<{ id: string; cwd: string; title: string }[]> + serializeBuffer?( + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + ): Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null> + // Why: synchronous probe used by maybeHydrateHeadlessFromRenderer to skip + // hydration when no renderer is authoritative for this PTY. See + // docs/mobile-prefer-renderer-scrollback.md. + hasRendererSerializer?(ptyId: string): boolean + getSize?(ptyId: string): { cols: number; rows: number } | null } type RuntimeNotifier = { worktreesChanged(repoId: string): void reposChanged(): void - activateWorktree(repoId: string, worktreeId: string, setup?: CreateWorktreeResult['setup']): void + activateWorktree( + repoId: string, + worktreeId: string, + setup?: CreateWorktreeResult['setup'], + startup?: WorktreeStartupLaunch + ): void createTerminal(worktreeId: string, opts: { command?: string; title?: string }): void splitTerminal( tabId: string, @@ -150,6 +248,20 @@ type RuntimeNotifier = { renameTerminal(tabId: string, title: string | null): void focusTerminal(tabId: string, worktreeId: string): void closeTerminal(tabId: string, paneRuntimeId?: number): void + sleepWorktree(worktreeId: string): void + terminalFitOverrideChanged( + ptyId: string, + mode: 'mobile-fit' | 'desktop-fit', + cols: number, + rows: number + ): void + // Why: presence-based lock signal — desktop renderer mounts the lock + // banner when `driver.kind === 'mobile'` and unmounts otherwise. The + // structured payload (vs a `locked: boolean`) carries the active mobile + // actor's clientId so the renderer can disambiguate multi-phone scenarios + // and so a future write coordinator can use the same signal as scheduling + // input. See docs/mobile-presence-lock.md. + terminalDriverChanged(ptyId: string, driver: DriverState): void } type TerminalHandleRecord = { @@ -169,6 +281,14 @@ type TerminalWaiter = { resolve: (result: RuntimeTerminalWait) => void reject: (error: Error) => void timeout: NodeJS.Timeout | null + pollInterval: NodeJS.Timeout | null +} + +type MessageWaiter = { + handle: string + typeFilter: string[] | undefined + resolve: (result: void) => void + timeout: NodeJS.Timeout | null } type ResolvedWorktree = { @@ -203,6 +323,27 @@ type ResolvedWorktreeCache = { worktrees: ResolvedWorktree[] } +export type MobileNotificationEvent = { + source: 'agent-task-complete' | 'terminal-bell' | 'test' + title: string + body: string + worktreeId?: string +} + +// Why: presence-based driver state for the mobile-presence lock. Exactly one +// driver per PTY at any moment. See docs/mobile-presence-lock.md. +// - `idle`: no mobile subscribers; desktop input flows freely +// - `desktop`: at least one mobile client subscribed but desktop reclaimed +// (or all mobile clients are passive `desktop`-mode watchers); desktop +// input flows freely +// - `mobile{clientId}`: a mobile client is the active driver; desktop +// input/resize are dropped server-side and the lock banner is mounted. +// `clientId` is the most recent mobile actor for this PTY. +export type DriverState = + | { kind: 'idle' } + | { kind: 'desktop' } + | { kind: 'mobile'; clientId: string } + export class OrcaRuntimeService { private readonly runtimeId = randomUUID() private readonly startedAt = Date.now() @@ -214,6 +355,8 @@ export class OrcaRuntimeService { private leaves = new Map() private handles = new Map() private handleByLeafKey = new Map() + private handleByPtyId = new Map() + private detachedPreAllocatedLeaves = new Map() private graphSyncCallbacks: (() => void)[] = [] private waitersByHandle = new Map>() private ptyController: RuntimePtyController | null = null @@ -221,12 +364,223 @@ export class OrcaRuntimeService { private agentBrowserBridge: AgentBrowserBridge | null = null private resolvedWorktreeCache: ResolvedWorktreeCache | null = null private agentDetector: AgentDetector | null = null + private _orchestrationDb: OrchestrationDb | null = null + private messageWaitersByHandle = new Map>() + // Why: mobile clients subscribe to terminal output via terminal.subscribe. + // These listeners fire on every onPtyData call, enabling real-time streaming + // without polling. Keyed by ptyId for O(1) lookup per data event. + private dataListeners = new Map void>>() + // Why: mobile clients need to know when the desktop restores a terminal + // from mobile-fit so they can update their UI. These listeners are + // invoked from resizeForClient and onClientDisconnected/onPtyExit. + private fitOverrideListeners = new Map< + string, + Set<(event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void> + >() + private subscriptionCleanups = new Map void>() + // Why: index of subscriptionIds by per-WebSocket connectionId so the + // server can sweep all subscriptions for a closing socket without + // touching subscriptions on other live sockets that share the same + // deviceToken (multi-screen mobile). + private subscriptionsByConnection = new Map>() + private subscriptionConnectionByEntry = new Map() + // Why: mobile clients subscribe to desktop notifications via + // notifications.subscribe. This set enables fan-out — each connected + // mobile client gets its own listener, and dispatchMobileNotification + // iterates them all. Listeners are cleaned up via subscriptionCleanups. + private notificationListeners = new Set<(event: MobileNotificationEvent) => void>() + private ptysById = new Map() + private headlessTerminals = new Map() + // Why: per-PTY hydration state guards against double-hydration. Keys: + // 'pending' → maybeHydrateHeadlessFromRenderer is in flight + // 'done' → hydration completed (success or skip); never run again + // Absent → hydration has not been considered yet for this PTY. + // See docs/mobile-prefer-renderer-scrollback.md. + private headlessHydrationState = new Map() + // Why: mobile-fit overrides are keyed by ptyId (not terminal handle) because + // handles can be reissued while the PTY identity is stable. In-memory only — + // a stale phone override should not survive an app restart. + private terminalFitOverrides = new Map< + string, + { + mode: 'mobile-fit' + cols: number + rows: number + previousCols: number | null + previousRows: number | null + updatedAt: number + clientId: string + } + >() - constructor(store: RuntimeStore | null = null, stats?: StatsCollector) { + // Why: server-authoritative display mode per terminal. 'auto' (default) means + // phone-fit when mobile subscribes, desktop otherwise. 'phone'/'desktop' lock + // the mode regardless of subscriber state. In-memory only — modes reset on restart. + private mobileDisplayModes = new Map() + + // Why: tracks active mobile subscribers per PTY so the runtime can restore + // desktop dimensions on unsubscribe and prevent orphaned overrides during + // rapid tab switches. Keyed by ptyId → inner map of clientId → subscriber. + // The two-level map preserves multi-mobile soundness: phone B subscribing + // does not silently overwrite phone A's record. See + // docs/mobile-presence-lock.md "Multi-mobile subscriber model". + // subscribedAt drives "earliest-by-subscribe-time" restore-target selection + // (only among subscribers with non-null previousCols/Rows; desktop-mode + // joins carry null and are skipped). lastActedAt drives "most-recent + // actor's viewport wins" for active phone-fit dims. + private mobileSubscribers = new Map< + string, + Map< + string, + { + clientId: string + viewport: { cols: number; rows: number } | null + wasResizedToPhone: boolean + previousCols: number | null + previousRows: number | null + subscribedAt: number + lastActedAt: number + } + > + >() + + // Why: per-PTY driver state. The "driver" is whoever currently owns the + // input/resize floor. While `kind === 'mobile'` the desktop renderer drops + // xterm.onData/onResize and shows the lock banner; `terminal.send` / + // `pty:write` and `pty:resize` IPC handlers also drop desktop-side calls + // server-side as defense-in-depth. The `clientId` carried on the mobile + // variant is the most recent mobile actor — used by + // `applyMobileDisplayMode` to pick the active phone-fit viewport. See + // docs/mobile-presence-lock.md. + private currentDriver = new Map() + + // Why: resubscribe-grace window. When the last mobile subscriber for a + // PTY unsubscribes, we hold the driver=mobile{clientId} state and the + // inner-map record open for ~250ms. If the same (ptyId, clientId) + // re-subscribes inside the window — typically because the mobile app + // tore down the stream to reconfigure (rare with the new + // updateMobileViewport path, but still possible on reconnects, network + // hiccups, or older client builds) — we cancel the deferred idle and + // restore-timer so the desktop banner doesn't flash and the new + // subscriber doesn't capture an already-phone-fitted PTY size as its + // restore baseline. Keyed by ptyId; carries the timer plus the snapshot + // of the leaving subscriber so we can re-insert it on cancel. See + // docs/mobile-presence-lock.md. + private pendingSoftLeavers = new Map< + string, + { + clientId: string + timer: ReturnType + record: { + clientId: string + viewport: { cols: number; rows: number } | null + wasResizedToPhone: boolean + previousCols: number | null + previousRows: number | null + subscribedAt: number + lastActedAt: number + } + } + >() + + // Why: tracks the last PTY size set by the desktop renderer (via pty:resize + // IPC). Unlike ptySizes (which is overwritten by server-side phone-fit + // resizes), this map preserves the actual pane geometry. Used as the + // preferred source for previousCols so desktop restore uses the correct + // split-pane width instead of a stale full-width value. + private lastRendererSizes = new Map() + + // Why: when a desktop-fit override change fires, the desktop renderer's + // re-render cascade (triggered by setOverrideTick) runs safeFit on ALL + // panes — not just the affected one. Background tab panes get measured at + // full-width (214) instead of their correct split width (105). The stale + // pty:resize IPCs overwrite both the actual PTY size and lastRendererSizes. + // This global window suppresses ALL pty:resize for 200ms after any + // desktop-fit notification. The server has already set the correct PTY + // size via ptyController.resize(), so desktop renderer resizes during + // this window are redundant (for the restored pane) or wrong (collateral). + private resizeSuppressedUntil = 0 + + // Why: delays PTY restore by 300ms after mobile unsubscribe so rapid tab + // switches don't cause unnecessary resize thrashing. Keyed by clientId + // Why: keyed by ptyId so each PTY gets its own independent restore timer. + // The old clientId-keyed design lost timers when two PTYs were unsubscribed + // back-to-back (only the last timer survived). + private pendingRestoreTimers = new Map< + string, + { timer: ReturnType; clientId: string } + >() + + // Why: inline resize events replace the unsubscribe→resubscribe pattern. + // Listeners are notified when mode changes or desktop restores, allowing + // the subscribe stream to emit a 'resized' event with fresh scrollback. + private resizeListeners = new Map< + string, + Set<(event: { cols: number; rows: number; displayMode: string; reason: string }) => void> + >() + + private stats: StatsCollector | null = null + // Why (§3.3 + §7.1): the renderer-create path and coordinator + // `probeWorktreeDrift` share this cache so a create that already fetched + // `origin` within the last 30s does not re-fetch during dispatch, and + // vice-versa. Keyed by `::` so multi-remote repos (even + // though v1 only uses `origin`) don't cross-contaminate. The in-flight Map + // also provides serialization — two concurrent callers share a single + // underlying `git fetch`. Lifecycle rules are enforced in + // `fetchRemoteWithCache` and MUST NOT be duplicated elsewhere: + // - entry inserted BEFORE await, + // - `.finally()` removes the entry on BOTH success and rejection, + // - timestamp written ONLY on success (rejection must not make the + // 30s freshness cache lie). + // A literal "insert before await / read-back after await" without these + // three rules wedges all future creates on the same repo after a single + // DNS hiccup until process restart (see §3.3 Lifecycle). + private fetchInflight = new Map>() + private fetchLastCompletedAt = new Map() + private readonly getLocalProviderFn: (() => IPtyProvider) | null + private accountServices: RuntimeAccountServices | null = null + + constructor( + store: RuntimeStore | null = null, + stats?: StatsCollector, + deps?: { getLocalProvider?: () => IPtyProvider } + ) { this.store = store if (stats) { + this.stats = stats this.agentDetector = new AgentDetector(stats) } + // Why: the daemon adapter is installed via `setLocalPtyProvider()` during + // attachMainWindowServices, AFTER this service is constructed. Capturing + // `getLocalPtyProvider()` at construction time would freeze a reference to + // the pre-daemon `LocalPtyProvider` and miss the routed adapter. Resolve + // lazily via thunk so teardown always sees the currently-installed + // provider (design §4.3 wire-up). + this.getLocalProviderFn = deps?.getLocalProvider ?? null + } + + getLocalProvider(): IPtyProvider | null { + return this.getLocalProviderFn ? this.getLocalProviderFn() : null + } + + getStatsSummary(): StatsSummary | null { + return this.stats?.getSummary() ?? null + } + + // Why: lazy initialization — the DB path depends on Electron's userData + // which may not be finalized until after app.ready. Also allows unit tests + // to inject an in-memory DB without touching the filesystem. + getOrchestrationDb(): OrchestrationDb { + if (!this._orchestrationDb) { + const { app } = require('electron') + const dbPath = join(app.getPath('userData'), 'orchestration.db') + this._orchestrationDb = new OrchestrationDb(dbPath) + } + return this._orchestrationDb + } + + setOrchestrationDb(db: OrchestrationDb): void { + this._orchestrationDb = db } getRuntimeId(): string { @@ -244,7 +598,9 @@ export class OrcaRuntimeService { graphStatus: this.graphStatus, authoritativeWindowId: this.authoritativeWindowId, liveTabCount: this.tabs.size, - liveLeafCount: this.leaves.size + liveLeafCount: this.leaves.size, + protocolVersion: DESKTOP_PROTOCOL_VERSION, + minCompatibleMobileVersion: MIN_COMPATIBLE_MOBILE_VERSION } } @@ -284,43 +640,86 @@ export class OrcaRuntimeService { this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab])) const nextLeaves = new Map() + // Why: renderer reloads can briefly republish the same leaf with no ptyId; + // keep live CLI handles usable while the UI graph rebuilds. + const preserveLivePtysDuringReload = this.graphStatus === 'reloading' for (const leaf of graph.leaves) { const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) const existing = this.leaves.get(leafKey) + const ptyId = + preserveLivePtysDuringReload && leaf.ptyId === null && existing?.ptyId + ? existing.ptyId + : leaf.ptyId const ptyGeneration = - existing && existing.ptyId !== leaf.ptyId + existing && existing.ptyId !== ptyId ? existing.ptyGeneration + 1 : (existing?.ptyGeneration ?? 0) nextLeaves.set(leafKey, { ...leaf, + ptyId, ptyGeneration, - connected: leaf.ptyId !== null, - writable: this.graphStatus === 'ready' && leaf.ptyId !== null, - lastOutputAt: existing?.ptyId === leaf.ptyId ? existing.lastOutputAt : null, - lastExitCode: existing?.ptyId === leaf.ptyId ? existing.lastExitCode : null, - tailBuffer: existing?.ptyId === leaf.ptyId ? existing.tailBuffer : [], - tailPartialLine: existing?.ptyId === leaf.ptyId ? existing.tailPartialLine : '', - tailTruncated: existing?.ptyId === leaf.ptyId ? existing.tailTruncated : false, - tailLinesTotal: existing?.ptyId === leaf.ptyId ? existing.tailLinesTotal : 0, - preview: existing?.ptyId === leaf.ptyId ? existing.preview : '', - lastAgentStatus: existing?.ptyId === leaf.ptyId ? existing.lastAgentStatus : null + connected: ptyId !== null, + writable: this.graphStatus === 'ready' && ptyId !== null, + lastOutputAt: existing?.ptyId === ptyId ? existing.lastOutputAt : null, + lastExitCode: existing?.ptyId === ptyId ? existing.lastExitCode : null, + tailBuffer: existing?.ptyId === ptyId ? existing.tailBuffer : [], + tailPartialLine: existing?.ptyId === ptyId ? existing.tailPartialLine : '', + tailTruncated: existing?.ptyId === ptyId ? existing.tailTruncated : false, + tailLinesTotal: existing?.ptyId === ptyId ? existing.tailLinesTotal : 0, + preview: existing?.ptyId === ptyId ? existing.preview : '', + lastAgentStatus: existing?.ptyId === ptyId ? existing.lastAgentStatus : null, + lastOscTitle: existing?.ptyId === ptyId ? existing.lastOscTitle : null }) - if (existing && (existing.ptyId !== leaf.ptyId || existing.ptyGeneration !== ptyGeneration)) { + if (leaf.ptyId) { + this.recordPtyWorktree(leaf.ptyId, leaf.worktreeId, { + connected: true, + lastOutputAt: existing?.ptyId === leaf.ptyId ? existing.lastOutputAt : null, + preview: existing?.ptyId === leaf.ptyId ? existing.preview : '' + }) + } + + if (existing && (existing.ptyId !== ptyId || existing.ptyGeneration !== ptyGeneration)) { this.invalidateLeafHandle(leafKey) } } for (const oldLeafKey of this.leaves.keys()) { if (!nextLeaves.has(oldLeafKey)) { - this.invalidateLeafHandle(oldLeafKey) + const oldLeaf = this.leaves.get(oldLeafKey) + if ( + preserveLivePtysDuringReload && + oldLeaf?.ptyId && + this.handleByPtyId.has(oldLeaf.ptyId) + ) { + // Why: a CLI-created agent keeps using its exported handle even if + // the reloaded renderer has not rebound the pane yet. + nextLeaves.set(oldLeafKey, oldLeaf) + } else { + this.invalidateLeafHandle(oldLeafKey) + } } } + const nextPtyIds = new Set( + [...nextLeaves.values()].map((leaf) => leaf.ptyId).filter((ptyId): ptyId is string => !!ptyId) + ) + for (const [ptyId, leaf] of this.detachedPreAllocatedLeaves) { + if (nextPtyIds.has(ptyId) || !this.handleByPtyId.has(ptyId)) { + this.detachedPreAllocatedLeaves.delete(ptyId) + continue + } + nextLeaves.set(this.getLeafKey(leaf.tabId, leaf.leafId), leaf) + nextPtyIds.add(ptyId) + } + this.leaves = nextLeaves this.graphStatus = 'ready' this.refreshWritableFlags() + for (const leaf of this.leaves.values()) { + this.adoptPreAllocatedHandle(leaf) + } // Why: createTerminal waits for the renderer's graph sync to populate the // new leaf so it can return a handle. Drain callbacks after leaves update. @@ -331,19 +730,64 @@ export class OrcaRuntimeService { return this.getStatus() } + // Why: terminal handles are normally created lazily when first referenced via + // RPC, but agents need their own handle at spawn time (via ORCA_TERMINAL_HANDLE + // env var) so they can self-identify in orchestration messages without an + // extra RPC round-trip. Pre-allocating by ptyId lets issueHandle reuse it. + preAllocateHandleForPty(ptyId: string): string { + const existing = this.handleByPtyId.get(ptyId) + if (existing) { + return existing + } + const handle = this.createPreAllocatedTerminalHandle() + this.handleByPtyId.set(ptyId, handle) + return handle + } + + createPreAllocatedTerminalHandle(): string { + return `term_${randomUUID()}` + } + + registerPreAllocatedHandleForPty(ptyId: string, handle: string): void { + this.handleByPtyId.set(ptyId, handle) + for (const leaf of this.leaves.values()) { + if (leaf.ptyId === ptyId) { + this.adoptPreAllocatedHandle(leaf) + } + } + } + onPtySpawned(ptyId: string): void { + const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + if (pty) { + pty.connected = true + } for (const leaf of this.leaves.values()) { if (leaf.ptyId === ptyId) { leaf.connected = true leaf.writable = this.graphStatus === 'ready' + this.adoptPreAllocatedHandle(leaf) } } } + registerPty(ptyId: string, worktreeId: string): void { + this.recordPtyWorktree(ptyId, worktreeId, { connected: true }) + } + onPtyData(ptyId: string, data: string, at: number): void { // Agent detection runs on raw data before leaf processing, since the // tail buffer logic normalizes away the OSC sequences we need. this.agentDetector?.onData(ptyId, data, at) + // Ordering invariant (DO NOT REORDER): maybeHydrateHeadlessFromRenderer + // MUST run before trackHeadlessTerminalData so the eager-state pattern + // (set headlessTerminals + writeChain head = seedPromise) is in place + // before the live byte's chain link is queued. Without this ordering, + // trackHeadlessTerminalData would lazy-create a fresh state at PTY dims + // that the later seed-resolve would overwrite, dropping the live byte. + // See docs/mobile-prefer-renderer-scrollback.md. + this.maybeHydrateHeadlessFromRenderer(ptyId) + this.trackHeadlessTerminalData(ptyId, data) // Why: extract OSC title from raw PTY data before tail-buffer processing // strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.) @@ -352,10 +796,27 @@ export class OrcaRuntimeService { const oscTitle = extractLastOscTitle(data) const agentStatus = oscTitle ? detectAgentStatusFromTitle(oscTitle) : null + const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + if (pty) { + pty.connected = true + pty.lastOutputAt = at + const nextTail = appendToTailBuffer(pty.tailBuffer, pty.tailPartialLine, data) + pty.tailBuffer = nextTail.lines + pty.tailPartialLine = nextTail.partialLine + pty.tailTruncated = pty.tailTruncated || nextTail.truncated + pty.tailLinesTotal += nextTail.newCompleteLines + pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine) + } + for (const leaf of this.leaves.values()) { if (leaf.ptyId !== ptyId) { continue } + this.recordPtyWorktree(ptyId, leaf.worktreeId, { + connected: true, + lastOutputAt: pty?.lastOutputAt ?? at, + preview: pty?.preview ?? leaf.preview + }) leaf.connected = true leaf.writable = this.graphStatus === 'ready' leaf.lastOutputAt = at @@ -366,31 +827,1463 @@ export class OrcaRuntimeService { leaf.tailLinesTotal += nextTail.newCompleteLines leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) - if (agentStatus !== null) { + if (oscTitle !== null) { + // Why: keep the latest OSC title on the leaf so worktree.ps can + // recompute status from the live title each call. Without this, + // daemon-hosted terminals (no renderer pushing pane titles) had no + // way to clear a stale 'working' status after the agent exited and + // the shell took over the title — the stuck-spinner bug in #1437. + leaf.lastOscTitle = oscTitle const prevStatus = leaf.lastAgentStatus + // Why: when a new OSC title doesn't classify as an agent state (e.g. + // bare shell title after the agent exits), clear lastAgentStatus so + // it is no longer sticky. Tui-idle waiters that needed the previous + // 'idle' transition were already resolved at the moment of the + // transition below; only fresh waiters registered after the agent + // exits would observe the cleared value, and they correctly fall + // back to title-based detection / polling. leaf.lastAgentStatus = agentStatus - // Why: resolve tui-idle waiters only on working→idle, not working→permission. - // Permission means the agent is blocked on user approval (e.g. Gemini's - // "y/n" prompt) — it hasn't finished its task. Resolving tui-idle here - // would cause the CLI consumer to proceed while the agent is still waiting. - if (prevStatus === 'working' && agentStatus === 'idle') { + // Why: resolve tui-idle on any transition TO idle (not just working→idle). + // Claude Code may skip "working" entirely on fast tasks, going null→idle, + // and the coordinator's tui-idle waiter would hang forever waiting for a + // working→idle transition that never comes. Permission→idle is excluded: + // it means the agent was blocked on user approval and the user said no, + // which isn't a task-completion signal. + if (agentStatus === 'idle' && prevStatus !== 'idle') { this.resolveTuiIdleWaiters(leaf) + this.deliverPendingMessages(leaf) + } + } + } + + const listeners = this.dataListeners.get(ptyId) + if (listeners) { + for (const listener of listeners) { + listener(data) + } + } + } + + subscribeToTerminalData(ptyId: string, listener: (data: string) => void): () => void { + let listeners = this.dataListeners.get(ptyId) + if (!listeners) { + listeners = new Set() + this.dataListeners.set(ptyId, listeners) + } + listeners.add(listener) + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + this.dataListeners.delete(ptyId) + } + } + } + + subscribeToFitOverrideChanges( + ptyId: string, + listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void + ): () => void { + let listeners = this.fitOverrideListeners.get(ptyId) + if (!listeners) { + listeners = new Set() + this.fitOverrideListeners.set(ptyId, listeners) + } + listeners.add(listener) + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + this.fitOverrideListeners.delete(ptyId) + } + } + } + + private notifyFitOverrideListeners( + ptyId: string, + mode: 'mobile-fit' | 'desktop-fit', + cols: number, + rows: number + ): void { + const listeners = this.fitOverrideListeners.get(ptyId) + if (!listeners) { + return + } + for (const listener of listeners) { + listener({ mode, cols, rows }) + } + } + + serializeTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise<{ data: string; cols: number; rows: number } | null> { + return this.serializeTerminalBufferFromAvailableState(ptyId, opts) + } + + getTerminalSize(ptyId: string): { cols: number; rows: number } | null { + return this.ptyController?.getSize?.(ptyId) ?? null + } + + // Why: daemon-backed PTYs that the runtime adopted after an Orca relaunch + // start with a fresh headless emulator that has zero scrollback, even though + // the daemon's on-disk checkpoint and the desktop xterm both contain the + // full prior history. Without this hydration, mobile subscribers see only + // the bare current prompt because serializeHeadlessTerminalBuffer always + // wins over the renderer-path fallback. Seeding the emulator with the + // adapter's snapshot/cold-restore data makes mobile and desktop agree on + // what scrollback is available. + seedHeadlessTerminal(ptyId: string, data: string, size?: { cols: number; rows: number }): void { + if (!data) { + return + } + const existing = this.headlessTerminals.get(ptyId) + if (existing) { + // Why: emulator already has live data — re-seeding would duplicate + // every byte. The seed is only valid when the emulator is fresh. + return + } + const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } + const state: RuntimeHeadlessTerminal = { + emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }), + writeChain: Promise.resolve() + } + this.headlessTerminals.set(ptyId, state) + state.writeChain = state.writeChain + .then(() => state.emulator.write(data)) + .catch(() => { + // Seeding is best-effort; live data will continue to populate the + // emulator even if the snapshot replay fails. + }) + } + + // Why: hydrate the runtime headless emulator from the desktop renderer's + // xterm buffer on the first onPtyData byte after a PTY is taken over by a + // pane. Eager-state pattern matches seedHeadlessTerminal: headlessTerminals + // is populated synchronously so concurrent live writes from + // trackHeadlessTerminalData chain after the seed via the same writeChain. + // See docs/mobile-prefer-renderer-scrollback.md. + private maybeHydrateHeadlessFromRenderer(ptyId: string): void { + if (this.headlessHydrationState.has(ptyId)) { + return + } + if (this.headlessTerminals.has(ptyId)) { + // Daemon-snapshot seed already populated the emulator — skip hydration. + this.headlessHydrationState.set(ptyId, 'done') + return + } + const controller = this.ptyController + if (!controller?.serializeBuffer || !controller.hasRendererSerializer) { + return + } + if (!controller.hasRendererSerializer(ptyId)) { + // Renderer hasn't registered yet (or never will). Live writes lazy- + // create the state via trackHeadlessTerminalData on this same tick. + return + } + + this.headlessHydrationState.set(ptyId, 'pending') + const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } + const state: RuntimeHeadlessTerminal = { + emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }), + writeChain: Promise.resolve() + } + this.headlessTerminals.set(ptyId, state) + + // Why: append the seed work to writeChain so live writes queued by + // trackHeadlessTerminalData (after this method returns synchronously) + // execute AFTER the seed-write resolves. If we awaited inline before + // setting headlessTerminals, the live byte would lazy-create a separate + // state and the seed-resolve would overwrite it, dropping live bytes. + state.writeChain = state.writeChain.then(async () => { + try { + const rendered = await controller.serializeBuffer!(ptyId, { + scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, + altScreenForcesZeroRows: true + }) + if (!rendered || rendered.data.length === 0) { + return + } + // Resize to renderer's dims so the seed reflows correctly into the + // emulator's grid, then resize back to PTY dims (if known) so live + // writes use the correct cell layout. + if (rendered.cols !== dims.cols || rendered.rows !== dims.rows) { + state.emulator.resize(rendered.cols, rendered.rows) + } + await state.emulator.write(rendered.data) + const ptyDims = this.getTerminalSize(ptyId) + if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) { + state.emulator.resize(ptyDims.cols, ptyDims.rows) + } + if (rendered.lastTitle) { + this.applySeededAgentStatus(ptyId, rendered.lastTitle) + } + } catch { + // Hydration is best-effort. Live writes continue via the same + // writeChain that this catch-arm leaves intact. + } finally { + this.headlessHydrationState.set(ptyId, 'done') + } + }) + } + + // Why: seed-derived agent status reflects historical state. Orchestration + // waiters (resolveTuiIdleWaiters, deliverPendingMessages) must only react + // to LIVE transitions, so this helper writes leaf.lastAgentStatus only and + // never resolves waiters. detectAgentStatusFromTitle wrap mirrors the live + // path so seeded and live values are the same union member, keeping + // downstream `=== 'idle'` checks correct. + private applySeededAgentStatus(ptyId: string, title: string): void { + if (!title) { + return + } + const status = detectAgentStatusFromTitle(title) + for (const leaf of this.leaves.values()) { + if (leaf.ptyId === ptyId) { + // Why: seed lastOscTitle even when the seeded title doesn't classify + // as an agent state, so worktree.ps recomputes status from the live + // title rather than treating the leaf as agentless. + leaf.lastOscTitle = title + if (status !== null) { + leaf.lastAgentStatus = status } } } } + private trackHeadlessTerminalData(ptyId: string, data: string): void { + const state = this.getOrCreateHeadlessTerminal(ptyId) + state.writeChain = state.writeChain + .then(() => state.emulator.write(data)) + .catch(() => { + // Best-effort state tracking; live streaming must continue even if + // xterm rejects a malformed or raced write during shutdown. + }) + } + + private getOrCreateHeadlessTerminal(ptyId: string): RuntimeHeadlessTerminal { + const existing = this.headlessTerminals.get(ptyId) + if (existing) { + return existing + } + const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } + const state: RuntimeHeadlessTerminal = { + emulator: new HeadlessEmulator({ cols: size.cols, rows: size.rows }), + writeChain: Promise.resolve() + } + this.headlessTerminals.set(ptyId, state) + return state + } + + private resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void { + this.headlessTerminals.get(ptyId)?.emulator.resize(cols, rows) + } + + private async serializeTerminalBufferFromAvailableState( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise<{ data: string; cols: number; rows: number } | null> { + const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts) + if (headlessSnapshot) { + return headlessSnapshot + } + + let rendererSnapshot: { + data: string + cols: number + rows: number + lastTitle?: string + } | null = null + try { + // Why: read-fallback wants visible alt-screen content (e.g. an active + // TUI like vim) so altScreenForcesZeroRows is FALSE here. Hydration is + // the only path that suppresses alt-screen scrollback. See + // docs/mobile-prefer-renderer-scrollback.md. + rendererSnapshot = await (this.ptyController?.serializeBuffer?.(ptyId, { + scrollbackRows: opts.scrollbackRows, + altScreenForcesZeroRows: false + }) ?? Promise.resolve(null)) + } catch { + // Why: mobile scrollback should not depend on a mounted renderer pane. + // If renderer serialization races reload/unmount, the runtime snapshot + // below can still preserve colored terminal state. + } + if (rendererSnapshot && rendererSnapshot.data.length > 0) { + return rendererSnapshot + } + return rendererSnapshot + } + + private async serializeHeadlessTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise<{ data: string; cols: number; rows: number } | null> { + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return null + } + await state.writeChain + // Why: when an alternate-screen TUI (Claude Code, vim, etc.) is currently + // active, the visible content is the alt-screen snapshot — replaying any + // normal-buffer scrollback before it can duplicate shell prompts and + // flatten SGR attributes when the mobile xterm replays the data. Force + // scrollbackRows=0 in that case. When the buffer is in normal mode the + // caller can request scrollback so the user can scroll up to see prior + // agent output. + const requested = opts.scrollbackRows ?? 0 + const scrollbackRows = state.emulator.isAlternateScreen ? 0 : requested + const snapshot = state.emulator.getSnapshot({ scrollbackRows }) + const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi + return data.length > 0 ? { data, cols: snapshot.cols, rows: snapshot.rows } : null + } + + private disposeHeadlessTerminal(ptyId: string): void { + this.headlessHydrationState.delete(ptyId) + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return + } + this.headlessTerminals.delete(ptyId) + state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose()) + } + + resolveLeafForHandle(handle: string): { ptyId: string | null } | null { + const record = this.handles.get(handle) + if (!record) { + return null + } + if (record.tabId.startsWith('pty:')) { + return { ptyId: record.ptyId } + } + const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) + if (!leaf) { + return null + } + return { ptyId: leaf.ptyId } + } + + registerSubscriptionCleanup( + subscriptionId: string, + cleanup: () => void, + connectionId?: string + ): void { + // Why: mobile clients reconnect frequently (phone lock, network switch). + // The RPC client re-sends terminal.subscribe on reconnect, creating a new + // handler before the old one is cleaned up. Without this, the old data + // listener leaks in dataListeners and duplicates every PTY data event. + const existing = this.subscriptionCleanups.get(subscriptionId) + if (existing) { + existing() + // Why: existing() already evicts itself from the per-connection index + // via cleanupSubscription, so no extra bookkeeping is needed here. + } + this.subscriptionCleanups.set(subscriptionId, cleanup) + if (connectionId) { + let set = this.subscriptionsByConnection.get(connectionId) + if (!set) { + set = new Set() + this.subscriptionsByConnection.set(connectionId, set) + } + set.add(subscriptionId) + this.subscriptionConnectionByEntry.set(subscriptionId, connectionId) + } + } + + cleanupSubscription(subscriptionId: string): void { + const cleanup = this.subscriptionCleanups.get(subscriptionId) + if (cleanup) { + this.subscriptionCleanups.delete(subscriptionId) + const connectionId = this.subscriptionConnectionByEntry.get(subscriptionId) + if (connectionId) { + this.subscriptionConnectionByEntry.delete(subscriptionId) + const set = this.subscriptionsByConnection.get(connectionId) + if (set) { + set.delete(subscriptionId) + if (set.size === 0) { + this.subscriptionsByConnection.delete(connectionId) + } + } + } + cleanup() + } + } + + // Why: invoked from the WebSocket transport's on-close hook so streaming + // listeners registered for this exact socket get torn down even when other + // sockets sharing the same deviceToken are still alive (multi-screen + // mobile). Without this sweep, listeners leak across every reconnect. + cleanupSubscriptionsForConnection(connectionId: string): void { + const set = this.subscriptionsByConnection.get(connectionId) + if (!set) { + return + } + // Why: snapshot the ids before iterating because cleanupSubscription + // mutates both the set and the index map. + const ids = Array.from(set) + for (const id of ids) { + this.cleanupSubscription(id) + } + } + + // Why: mobile clients subscribe via notifications.subscribe streaming RPC. + // Each subscriber gets its own listener. Returns an unsubscribe function + // that the subscription cleanup mechanism calls on disconnect. + onNotificationDispatched(listener: (event: MobileNotificationEvent) => void): () => void { + this.notificationListeners.add(listener) + return () => { + this.notificationListeners.delete(listener) + } + } + + getMobileNotificationListenerCount(): number { + return this.notificationListeners.size + } + + dispatchMobileNotification(event: MobileNotificationEvent): void { + for (const listener of this.notificationListeners) { + listener(event) + } + } + + // ─── Account Services (mobile RPC bridge) ───────────────────── + + setAccountServices(services: RuntimeAccountServices): void { + this.accountServices = services + } + + private requireAccountServices(): RuntimeAccountServices { + if (!this.accountServices) { + throw new Error('Account services are not configured on this runtime') + } + return this.accountServices + } + + getAccountsSnapshot(): AccountsSnapshot { + const { claudeAccounts, codexAccounts, rateLimits } = this.requireAccountServices() + return { + claude: claudeAccounts.listAccounts(), + codex: codexAccounts.listAccounts(), + rateLimits: rateLimits.getState() + } + } + + // Why: RateLimitService polls only when the Electron window is visible AND + // focused, and the inactive-account caches fill lazily when the user opens + // the desktop AccountsPane. Mobile has neither trigger, so without this the + // phone shows 0% / "—" against a backgrounded desktop. Errors swallowed + // because partial usage is still useful for the rest of the snapshot. + async refreshAccountsForMobile(): Promise { + const { rateLimits } = this.requireAccountServices() + await Promise.allSettled([ + rateLimits.refresh(), + rateLimits.fetchInactiveClaudeAccountsOnOpen(), + rateLimits.fetchInactiveCodexAccountsOnOpen() + ]) + } + + selectClaudeAccount(accountId: string | null): Promise { + return this.requireAccountServices().claudeAccounts.selectAccount(accountId) + } + + selectCodexAccount(accountId: string | null): Promise { + return this.requireAccountServices().codexAccounts.selectAccount(accountId) + } + + removeClaudeAccount(accountId: string): Promise { + return this.requireAccountServices().claudeAccounts.removeAccount(accountId) + } + + removeCodexAccount(accountId: string): Promise { + return this.requireAccountServices().codexAccounts.removeAccount(accountId) + } + + // Why: rate-limit polling fires every 5 minutes and on account switch. + // Mobile clients subscribe to receive a fresh AccountsSnapshot whenever + // RateLimitService pushes new usage data, mirroring the existing + // `rateLimits:update` IPC channel desktop already uses. + onAccountsChanged(listener: (snapshot: AccountsSnapshot) => void): () => void { + const services = this.requireAccountServices() + return services.rateLimits.onStateChange(() => { + listener({ + claude: services.claudeAccounts.listAccounts(), + codex: services.codexAccounts.listAccounts(), + rateLimits: services.rateLimits.getState() + }) + }) + } + + // ─── Mobile Fit Override Management ───────────────────────── + + resizeForClient( + ptyId: string, + mode: 'mobile-fit' | 'restore', + clientId: string, + cols?: number, + rows?: number + ): { + cols: number + rows: number + previousCols: number | null + previousRows: number | null + mode: 'mobile-fit' | 'desktop-fit' + } { + if (mode === 'mobile-fit') { + if (cols == null || rows == null || !Number.isFinite(cols) || !Number.isFinite(rows)) { + throw new Error('invalid_dimensions') + } + const clampedCols = Math.max(20, Math.min(240, Math.round(cols))) + const clampedRows = Math.max(8, Math.min(120, Math.round(rows))) + + const currentSize = this.getTerminalSize(ptyId) + const existing = this.terminalFitOverrides.get(ptyId) + // Why: preserve the original desktop size from before any mobile-fit, + // so restore returns to the right dimensions even after multiple re-fits. + const previousCols = existing?.previousCols ?? currentSize?.cols ?? null + const previousRows = existing?.previousRows ?? currentSize?.rows ?? null + + this.terminalFitOverrides.set(ptyId, { + mode: 'mobile-fit', + cols: clampedCols, + rows: clampedRows, + previousCols, + previousRows, + updatedAt: Date.now(), + clientId + }) + + const resized = this.ptyController?.resize?.(ptyId, clampedCols, clampedRows) + if (!resized) { + this.terminalFitOverrides.delete(ptyId) + throw new Error('resize_failed') + } + this.resizeHeadlessTerminal(ptyId, clampedCols, clampedRows) + + this.notifier?.terminalFitOverrideChanged(ptyId, 'mobile-fit', clampedCols, clampedRows) + + // Why: mobile-fit via resizeForClient is a deliberate mobile action; + // the actor takes the floor. mobileTookFloor updates the actor's + // lastActedAt and re-applies phone-fit if previously in desktop mode. + this.mobileTookFloor(ptyId, clientId) + + return { + cols: clampedCols, + rows: clampedRows, + previousCols, + previousRows, + mode: 'mobile-fit' + } + } + + // restore mode + const override = this.terminalFitOverrides.get(ptyId) + if (!override) { + throw new Error('no_active_override') + } + // Why: only the owning client can restore, preventing one phone from + // undoing another phone's active fit. + if (override.clientId !== clientId) { + throw new Error('not_override_owner') + } + + const { previousCols: prevCols, previousRows: prevRows } = override + this.terminalFitOverrides.delete(ptyId) + + // Why: always resize the PTY back to pre-fit dimensions immediately, + // even for mounted leaves. Relying solely on the renderer chain + // (IPC notification → safeFit → fitAddon.fit → onResize → transport.resize) + // is fragile — any async gap leaves the PTY at phone dims while xterm + // looks correct, causing text to wrap at the wrong column. The renderer + // will still run safeFit and may send a second resize with the exact + // current pane geometry, which is harmless (SIGWINCH is idempotent). + if (prevCols != null && prevRows != null) { + this.ptyController?.resize?.(ptyId, prevCols, prevRows) + this.resizeHeadlessTerminal(ptyId, prevCols, prevRows) + } + + // Why: send the restored dimensions so the renderer can fall back to a + // direct terminal.resize() if fitAddon.fit() silently fails. The renderer + // normally computes desktop dims from the container, but passing them here + // provides a guaranteed fallback to avoid leaving xterm at phone dims. + this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', prevCols ?? 0, prevRows ?? 0) + // Why: mobile clients subscribed to this terminal need to know the desktop + // restored, so they can update their UI (clear fitted state, resubscribe). + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', prevCols ?? 0, prevRows ?? 0) + + return { + cols: prevCols ?? 0, + rows: prevRows ?? 0, + previousCols: null, + previousRows: null, + mode: 'desktop-fit' + } + } + + getTerminalFitOverride(ptyId: string) { + return this.terminalFitOverrides.get(ptyId) ?? null + } + + getAllTerminalFitOverrides(): Map { + const result = new Map() + for (const [ptyId, override] of this.terminalFitOverrides) { + result.set(ptyId, { mode: override.mode, cols: override.cols, rows: override.rows }) + } + return result + } + + onClientDisconnected(clientId: string): void { + // Cancel all pending restore timers for this client — the client is gone, + // so the debounce is meaningless and could fire against a stale PTY state. + for (const [ptyId, entry] of this.pendingRestoreTimers) { + if (entry.clientId === clientId) { + clearTimeout(entry.timer) + this.pendingRestoreTimers.delete(ptyId) + } + } + // Why: if the disconnecting client was in soft-leave grace, the grace + // is meaningless now (the client is gone for real). Promote each + // matching grace into immediate finalization: restore PTY dims to the + // captured baseline, drop driver to idle, and clear fit overrides. + // Without this, a phone that exited the screen (router.back → WS + // close) would leave the PTY stuck at phone dims forever — the soft + // grace held the inner-map empty so the mobileSubscribers loop below + // can't see it, and the 300ms restore timer could mis-fire after the + // grace if the PTY had already been mutated. + for (const [ptyId, soft] of this.pendingSoftLeavers) { + if (soft.clientId !== clientId) { + continue + } + clearTimeout(soft.timer) + this.pendingSoftLeavers.delete(ptyId) + + // Cancel any in-flight 300ms restore timer too — we'll do it now. + const pending = this.pendingRestoreTimers.get(ptyId) + if (pending) { + clearTimeout(pending.timer) + this.pendingRestoreTimers.delete(ptyId) + } + + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + const { previousCols, previousRows, wasResizedToPhone } = soft.record + if (mode === 'auto' && wasResizedToPhone) { + const fallback = this.lastRendererSizes.get(ptyId) + const cols = previousCols ?? fallback?.cols ?? null + const rows = previousRows ?? fallback?.rows ?? null + if (cols != null && rows != null) { + this.ptyController?.resize?.(ptyId, cols, rows) + this.resizeHeadlessTerminal(ptyId, cols, rows) + } + this.lastRendererSizes.delete(ptyId) + this.suppressResizesForMs(500) + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', cols ?? 0, rows ?? 0) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', cols ?? 0, rows ?? 0) + } + this.setDriver(ptyId, { kind: 'idle' }) + } + + // Immediately restore PTYs that this client had phone-fitted (no debounce — + // client is gone, no point waiting for a re-subscribe that won't come). + // With the multi-mobile rekey, only the disconnecting client's record is + // removed from the inner map; peer mobile clients keep the floor and the + // banner stays mounted. + const ptysWithSurvivingPeers: string[] = [] + for (const [ptyId, inner] of this.mobileSubscribers) { + const subscriber = inner.get(clientId) + if (!subscriber) { + continue + } + const wasResizedToPhone = subscriber.wasResizedToPhone + const { previousCols, previousRows } = subscriber + inner.delete(clientId) + if (inner.size > 0) { + ptysWithSurvivingPeers.push(ptyId) + continue + } + this.mobileSubscribers.delete(ptyId) + if (wasResizedToPhone) { + if (previousCols != null && previousRows != null) { + this.ptyController?.resize?.(ptyId, previousCols, previousRows) + this.resizeHeadlessTerminal(ptyId, previousCols, previousRows) + } + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged( + ptyId, + 'desktop-fit', + previousCols ?? 0, + previousRows ?? 0 + ) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', previousCols ?? 0, previousRows ?? 0) + } + this.setDriver(ptyId, { kind: 'idle' }) + } + // Why: if peers survived but the disconnecting client was the active + // driver, re-elect the most-recent surviving subscriber as the driver + // and re-fit if needed. This keeps the lock/dim-selection invariant. + for (const ptyId of ptysWithSurvivingPeers) { + const driver = this.getDriver(ptyId) + if (driver.kind === 'mobile' && driver.clientId === clientId) { + const inner = this.mobileSubscribers.get(ptyId) + const next = inner ? this.pickMostRecentActor(inner) : null + if (next) { + this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) + this.applyMobileDisplayMode(ptyId) + } + } + } + + // Legacy cleanup for any terminalFitOverrides not covered by mobileSubscribers + for (const [ptyId, override] of this.terminalFitOverrides) { + if (override.clientId !== clientId) { + continue + } + try { + this.resizeForClient(ptyId, 'restore', clientId) + } catch { + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) + } + } + } + onPtyExit(ptyId: string, exitCode: number): void { + // Clean up new mobile state for this PTY + this.mobileSubscribers.delete(ptyId) + this.mobileDisplayModes.delete(ptyId) + this.resizeListeners.delete(ptyId) + this.lastRendererSizes.delete(ptyId) + const pendingRestore = this.pendingRestoreTimers.get(ptyId) + if (pendingRestore) { + clearTimeout(pendingRestore.timer) + this.pendingRestoreTimers.delete(ptyId) + } + const pendingSoft = this.pendingSoftLeavers.get(ptyId) + if (pendingSoft) { + clearTimeout(pendingSoft.timer) + this.pendingSoftLeavers.delete(ptyId) + } + + if (this.terminalFitOverrides.has(ptyId)) { + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) + } + // Why: clear driver state and notify the renderer so any lock banner on + // this dead pane unmounts. Without this, the pane shows a stuck banner + // until tab teardown, and `getDriver(deadPtyId)` would keep returning a + // stale `mobile{X}` to any caller that hasn't yet seen the exit IPC. + if (this.currentDriver.has(ptyId)) { + this.currentDriver.delete(ptyId) + this.notifier?.terminalDriverChanged(ptyId, { kind: 'idle' }) + } + this.disposeHeadlessTerminal(ptyId) this.agentDetector?.onExit(ptyId) + const pty = this.ptysById.get(ptyId) + if (pty) { + pty.connected = false + } for (const leaf of this.leaves.values()) { if (leaf.ptyId !== ptyId) { continue } + this.detachedPreAllocatedLeaves.delete(ptyId) leaf.connected = false leaf.writable = false leaf.lastExitCode = exitCode this.resolveExitWaiters(leaf) + this.failActiveDispatchOnExit(leaf, exitCode) + } + } + + // ─── Driver state (mobile-presence lock) ────────────────────────── + // + // See docs/mobile-presence-lock.md. + + getDriver(ptyId: string): DriverState { + return this.currentDriver.get(ptyId) ?? { kind: 'idle' } + } + + private setDriver(ptyId: string, next: DriverState): void { + const prev = this.getDriver(ptyId) + if (prev.kind === next.kind) { + if (prev.kind === 'mobile' && next.kind === 'mobile' && prev.clientId === next.clientId) { + return + } + if (prev.kind !== 'mobile' && next.kind !== 'mobile') { + return + } + } + if (next.kind === 'idle') { + this.currentDriver.delete(ptyId) + } else { + this.currentDriver.set(ptyId, next) + } + this.notifier?.terminalDriverChanged(ptyId, next) + } + + // Why: invoked from mobile RPC method handlers (terminal.send / setDisplayMode / + // resizeForClient / fresh subscribe with auto/phone). Records the actor as + // the most recent mobile driver and re-applies phone-fit if we were previously + // in `desktop` mode (mobile reclaims a take-back). Mobile-to-mobile hand-offs + // are no-ops for resize. + mobileTookFloor(ptyId: string, clientId: string): void { + const inner = this.mobileSubscribers.get(ptyId) + const sub = inner?.get(clientId) + if (sub) { + sub.lastActedAt = Date.now() + } + const prev = this.getDriver(ptyId) + const currentMode = this.mobileDisplayModes.get(ptyId) + // Why: a deliberate mobile action implies mobile is resuming control. + // If the display mode is currently 'desktop' (set by an earlier + // take-back), flip it back to 'auto' and re-apply so phone-fit takes + // hold again. Without flipping the mode, applyMobileDisplayMode would + // take the desktop branch and leave the PTY at desktop dims while the + // driver says `mobile`. The same path also covers the case where the + // driver flipped to `desktop` and we're returning to mobile control. + // See docs/mobile-presence-lock.md. + if (prev.kind === 'desktop' || currentMode === 'desktop') { + if (currentMode === 'desktop' || currentMode === undefined) { + this.mobileDisplayModes.set(ptyId, 'auto') + } + this.applyMobileDisplayMode(ptyId) + } + this.setDriver(ptyId, { kind: 'mobile', clientId }) + } + + // Why: in-place viewport update on the existing mobile subscription — + // used when the mobile keyboard opens/closes and shrinks/grows the + // visible terminal area. We refresh the subscriber's viewport, re-fit + // the PTY to the new dims, and emit a 'resized' event so the mobile + // xterm reinits inline at the new dims without re-subscribing. This + // avoids the unsubscribe → resubscribe cycle which would (a) flash the + // desktop lock banner during the brief idle gap and (b) cause the new + // subscribe to capture the already-phone-fitted PTY size as its + // restore baseline (stuck-dim bug on later disconnect). + // No-op when the client isn't actually subscribed to this PTY. + updateMobileViewport( + ptyId: string, + clientId: string, + viewport: { cols: number; rows: number } + ): boolean { + const inner = this.mobileSubscribers.get(ptyId) + const sub = inner?.get(clientId) + if (!sub) { + return false + } + sub.viewport = viewport + sub.lastActedAt = Date.now() + + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + if (mode === 'desktop') { + // Watching at desktop dims — viewport is informational only. + return true + } + // Drive PTY dims by the most-recent-actor (just updated to this client). + const winner = this.pickMostRecentActor(inner!) + if (!winner) { + return false + } + const winnerSub = inner!.get(winner.clientId) + const driveViewport = winnerSub?.viewport ?? viewport + const clampedCols = Math.max(20, Math.min(240, Math.round(driveViewport.cols))) + const clampedRows = Math.max(8, Math.min(120, Math.round(driveViewport.rows))) + + const currentSize = this.getTerminalSize(ptyId) + const alreadyAtTarget = currentSize?.cols === clampedCols && currentSize?.rows === clampedRows + if (!alreadyAtTarget) { + this.ptyController?.resize?.(ptyId, clampedCols, clampedRows) + this.resizeHeadlessTerminal(ptyId, clampedCols, clampedRows) + } + + sub.wasResizedToPhone = true + this.terminalFitOverrides.set(ptyId, { + mode: 'mobile-fit', + cols: clampedCols, + rows: clampedRows, + previousCols: sub.previousCols, + previousRows: sub.previousRows, + updatedAt: Date.now(), + clientId + }) + this.notifier?.terminalFitOverrideChanged(ptyId, 'mobile-fit', clampedCols, clampedRows) + + // Why: emit a 'resized' event on the mobile subscription stream so the + // mobile xterm reinits inline at the new dims — same shape as a + // setDisplayMode-triggered resize, so the existing client-side handler + // path applies without changes. + this.notifyTerminalResize(ptyId, { + cols: clampedCols, + rows: clampedRows, + displayMode: mode, + reason: 'viewport-update' + }) + + // The driver is already mobile{this client} when we got here; refresh it + // to update lastActedAt-based ordering on later actor selection. + this.setDriver(ptyId, { kind: 'mobile', clientId }) + return true + } + + // Why: invoked from `runtime:restoreTerminalFit` IPC (the desktop "Take + // back" button). Forces the PTY back to desktop dims and flips the driver + // to `desktop`, suppressing further mobile-driven dim changes until a + // mobile actor takes the floor again. + reclaimTerminalForDesktop(ptyId: string): boolean { + if (!this.isMobileSubscriberActive(ptyId)) { + return false + } + this.setMobileDisplayMode(ptyId, 'desktop') + this.applyMobileDisplayMode(ptyId) + this.setDriver(ptyId, { kind: 'desktop' }) + return true + } + + // Why: with multiple subscribers, the active phone-fit dims follow the + // most recent mobile actor (argmax(lastActedAt)). See + // docs/mobile-presence-lock.md "Active phone-fit dim selection". + private pickMostRecentActor( + inner: Map + ): { clientId: string; lastActedAt: number } | null { + let best: { clientId: string; lastActedAt: number } | null = null + for (const sub of inner.values()) { + if (best === null || sub.lastActedAt > best.lastActedAt) { + best = sub + } + } + return best + } + + // Why: restore-target selection on last-subscriber-leaves picks the + // earliest-by-subscribe-time subscriber AMONG those with non-null + // previousCols/Rows. Desktop-mode joins carry null and are skipped — they + // never captured pre-fit dims by design. + private pickEarliestRestoreTarget( + inner: Map< + string, + { subscribedAt: number; previousCols: number | null; previousRows: number | null } + > + ): { previousCols: number; previousRows: number } | null { + let best: { subscribedAt: number; previousCols: number; previousRows: number } | null = null + for (const sub of inner.values()) { + if (sub.previousCols == null || sub.previousRows == null) { + continue + } + if (best === null || sub.subscribedAt < best.subscribedAt) { + best = { + subscribedAt: sub.subscribedAt, + previousCols: sub.previousCols, + previousRows: sub.previousRows + } + } + } + return best ? { previousCols: best.previousCols, previousRows: best.previousRows } : null + } + + // ─── Server-Authoritative Mobile Display Mode ───────────────────── + + setMobileDisplayMode(ptyId: string, mode: 'auto' | 'phone' | 'desktop'): void { + if (mode === 'auto') { + this.mobileDisplayModes.delete(ptyId) + } else { + this.mobileDisplayModes.set(ptyId, mode) + } + } + + getMobileDisplayMode(ptyId: string): 'auto' | 'phone' | 'desktop' { + return this.mobileDisplayModes.get(ptyId) ?? 'auto' + } + + isMobileSubscriberActive(ptyId: string): boolean { + const inner = this.mobileSubscribers.get(ptyId) + return inner !== undefined && inner.size > 0 + } + + // Why: server-side auto-fit on mobile subscribe. The runtime is the single + // source of truth — the mobile client just passes its viewport and the runtime + // decides whether to resize. This eliminates the measure→RPC→resubscribe + // pipeline that caused race conditions. + // + // Multi-mobile keying: each subscriber lives in `mobileSubscribers[ptyId]`'s + // inner map under its own clientId. Phone B subscribing does not overwrite + // phone A's record — both stay until each unsubscribes. + // + // Subscribe-in-desktop-mode rule: a subscribe with displayMode='desktop' is + // a passive watch; it does NOT take the floor. The driver remains + // `idle`/`desktop`. The lock banner is reserved for actual mobile + // interaction (input/resize/setDisplayMode/auto-or-phone subscribe). + handleMobileSubscribe( + ptyId: string, + clientId: string, + viewport?: { cols: number; rows: number } + ): boolean { + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + if (!viewport) { + return false + } + + // Why: cancel ALL pending restore timers for this ptyId on any new + // subscribe — the timer is keyed by ptyId+old-clientId but with the + // multi-mobile rekey, "any new subscriber" supersedes "any old client's + // restore". Without this, A unsub → B sub within 300ms could fire A's + // timer and snap PTY to desktop dims while B is meant to drive. + const pendingRestore = this.pendingRestoreTimers.get(ptyId) + if (pendingRestore) { + clearTimeout(pendingRestore.timer) + this.pendingRestoreTimers.delete(ptyId) + } + + // Why: resubscribe-grace honor. If the same client just unsubscribed + // within the soft-leave window, restore its prior record (preserving + // previousCols/Rows so we don't capture an already-phone-fitted PTY + // size as the new baseline). The driver state was kept at + // mobile{clientId} during the window, so no banner flash occurred. + const softLeaver = this.pendingSoftLeavers.get(ptyId) + if (softLeaver && softLeaver.clientId === clientId) { + clearTimeout(softLeaver.timer) + this.pendingSoftLeavers.delete(ptyId) + let inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + inner = new Map() + this.mobileSubscribers.set(ptyId, inner) + } + inner.set(clientId, { + ...softLeaver.record, + // Refresh viewport from the new subscribe payload — the keyboard + // state may have changed during the window. + viewport, + lastActedAt: Date.now() + }) + // The driver was already mobile{clientId}; refresh to update + // listener wiring (and re-emit, harmless if unchanged). + this.setDriver(ptyId, { kind: 'mobile', clientId }) + // If display-mode is auto/phone, reapply the fit at the new viewport + // so a keyboard show/hide that resubscribes (older clients) still + // updates dims correctly. updateMobileViewport is the preferred path + // and avoids the unsubscribe → subscribe cycle entirely. + if (mode !== 'desktop') { + this.applyMobileDisplayMode(ptyId) + } + return true + } + + let inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + inner = new Map() + this.mobileSubscribers.set(ptyId, inner) + } + + // Why: prefer lastRendererSizes (the actual pane geometry reported by the + // desktop renderer's safeFit via pty:resize IPC) over getTerminalSize (the + // server-side PTY size, which may be stale — e.g. 214 full-width when the + // pane is actually in a split at ~105). Fall back to existing subscriber's + // previousCols (re-subscribe case) then currentSize (first subscribe). + // + // Multi-mobile: if an existing subscriber on this PTY is already + // phone-fitted, the current PTY size is NOT a valid restore baseline for + // a *new* subscriber — it would point to a phone-fit dim, not the + // pre-mobile desktop size. Set previousCols/Rows to null so the new + // joiner is skipped from earliest-restore selection; the original + // subscriber's captured baseline remains the source of truth. See + // docs/mobile-presence-lock.md. + const existing = inner.get(clientId) + const someoneAlreadyFitted = [...inner.values()].some((s) => s.wasResizedToPhone) + const currentSize = this.getTerminalSize(ptyId) + const rendererSize = this.lastRendererSizes.get(ptyId) + const previousCols = + existing?.previousCols ?? + (someoneAlreadyFitted ? null : (rendererSize?.cols ?? currentSize?.cols ?? null)) + const previousRows = + existing?.previousRows ?? + (someoneAlreadyFitted ? null : (rendererSize?.rows ?? currentSize?.rows ?? null)) + const now = Date.now() + const subscribedAt = existing?.subscribedAt ?? now + + if (mode === 'desktop') { + // Why: set previousCols/Rows to null so we don't capture a stale PTY + // size that may not match the actual pane geometry (e.g. 214 when the + // pane is in a split at 105). When the user later toggles to auto/phone, + // handleMobileSubscribe will capture currentSize at that point, which + // will be correct because safeFit has had time to adjust the PTY. + inner.set(clientId, { + clientId, + viewport, + wasResizedToPhone: false, + previousCols: null, + previousRows: null, + subscribedAt, + lastActedAt: now + }) + // Subscribe-in-desktop-mode is passive: leave driver at idle/desktop. + // Do not transition to mobile{clientId}. + return false + } + + inner.set(clientId, { + clientId, + viewport, + wasResizedToPhone: true, + previousCols, + previousRows, + subscribedAt, + lastActedAt: now + }) + + const clampedCols = Math.max(20, Math.min(240, Math.round(viewport.cols))) + const clampedRows = Math.max(8, Math.min(120, Math.round(viewport.rows))) + + // Why: skip the PTY resize if already at the target dims. Re-subscribing + // to a terminal that was left at phone dims (no restore on tab switch) + // should not trigger another SIGWINCH → shell prompt redraw. + const alreadyAtTarget = currentSize?.cols === clampedCols && currentSize?.rows === clampedRows + if (!alreadyAtTarget) { + this.ptyController?.resize?.(ptyId, clampedCols, clampedRows) + this.resizeHeadlessTerminal(ptyId, clampedCols, clampedRows) + } + this.notifier?.terminalFitOverrideChanged(ptyId, 'mobile-fit', clampedCols, clampedRows) + + // Update terminalFitOverrides for desktop safeFit compatibility + this.terminalFitOverrides.set(ptyId, { + mode: 'mobile-fit', + cols: clampedCols, + rows: clampedRows, + previousCols, + previousRows, + updatedAt: Date.now(), + clientId + }) + + // Subscribe-fresh with auto/phone mode counts as "take the floor". + this.setDriver(ptyId, { kind: 'mobile', clientId }) + + return true + } + + // Why: delayed restore prevents resize thrashing during rapid tab switches. + // The 300ms debounce means only the final tab triggers a PTY restore; + // intermediate terminals keep their current dims harmlessly. + // + // Multi-mobile: only the last subscriber leaving for this ptyId triggers + // restore + driver=idle. Peer mobile clients still on the inner map keep + // the lock banner mounted; if the disconnecting client was the active + // driver, we re-elect the most-recent surviving subscriber. + handleMobileUnsubscribe(ptyId: string, clientId: string): void { + const inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + return + } + const subscriber = inner.get(clientId) + if (!subscriber) { + return + } + const wasResizedToPhone = subscriber.wasResizedToPhone + + // Why: snapshot the earliest-by-subscribe-time restore target BEFORE + // mutating the inner map. If the disconnecting client is the original + // baseline-holder, that information must survive into the last-leaver + // restore path even after their record is deleted. See + // docs/mobile-presence-lock.md "Restore-target selection". + const restoreTargetSnapshot = this.pickEarliestRestoreTarget(inner) + inner.delete(clientId) + + if (inner.size > 0) { + // Why: if the leaving client was the only one with a non-null restore + // baseline (typical when peer joiners subscribed against an + // already-phone-fitted PTY and got null prevCols), donate the baseline + // to the earliest surviving subscriber so a future last-leaver can + // still restore correctly. Without this, A leaves first, B leaves + // last with null prevCols → no restore fires. See + // docs/mobile-presence-lock.md. + if ( + subscriber.previousCols != null && + subscriber.previousRows != null && + !this.pickEarliestRestoreTarget(inner) + ) { + let earliestSurvivor: { clientId: string; subscribedAt: number } | null = null + for (const sub of inner.values()) { + if (earliestSurvivor === null || sub.subscribedAt < earliestSurvivor.subscribedAt) { + earliestSurvivor = { clientId: sub.clientId, subscribedAt: sub.subscribedAt } + } + } + if (earliestSurvivor) { + const heir = inner.get(earliestSurvivor.clientId) + if (heir) { + heir.previousCols = subscriber.previousCols + heir.previousRows = subscriber.previousRows + } + } + } + // Peers still on the line. If the disconnecting client was the active + // mobile driver, re-elect the most-recent surviving subscriber so the + // banner remains correct and active phone-fit dims follow them. + const driver = this.getDriver(ptyId) + if (driver.kind === 'mobile' && driver.clientId === clientId) { + const next = this.pickMostRecentActor(inner) + if (next) { + this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) + this.applyMobileDisplayMode(ptyId) + } + } + return + } + + // Last subscriber leaving — clean up. + this.mobileSubscribers.delete(ptyId) + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + + // Why: resubscribe-grace. Hold the driver=mobile{clientId} state and + // the leaving subscriber's record for ~250ms. If the same client + // re-subscribes in that window, handleMobileSubscribe cancels the + // pending-soft-leaver and re-inserts the record (preserving + // previousCols and avoiding a desktop-banner flash). Otherwise the + // grace timer fires, sets driver=idle, and lets the existing 300ms + // restore debounce (kept below) run as before. + const SOFT_LEAVE_GRACE_MS = 250 + const existingSoft = this.pendingSoftLeavers.get(ptyId) + if (existingSoft) { + clearTimeout(existingSoft.timer) + this.pendingSoftLeavers.delete(ptyId) + } + const softTimer = setTimeout(() => { + this.pendingSoftLeavers.delete(ptyId) + // Why: only flip to idle if no peer has reclaimed in the meantime. + if (!this.mobileSubscribers.has(ptyId)) { + this.setDriver(ptyId, { kind: 'idle' }) + } + }, SOFT_LEAVE_GRACE_MS) + this.pendingSoftLeavers.set(ptyId, { + clientId, + timer: softTimer, + record: { + clientId: subscriber.clientId, + viewport: subscriber.viewport, + wasResizedToPhone: subscriber.wasResizedToPhone, + previousCols: subscriber.previousCols, + previousRows: subscriber.previousRows, + subscribedAt: subscriber.subscribedAt, + lastActedAt: subscriber.lastActedAt + } + }) + + if (mode === 'auto' && wasResizedToPhone) { + const existing = this.pendingRestoreTimers.get(ptyId) + if (existing) { + clearTimeout(existing.timer) + } + + // Restore target: earliest-by-subscribe-time among non-null + // previousCols/Rows captured BEFORE deletion. Falls back to the + // disconnecting subscriber's own dims and finally lastRendererSizes + // (matches the existing first-insert capture path). + const fallback = this.lastRendererSizes.get(ptyId) + const previousCols = + restoreTargetSnapshot?.previousCols ?? subscriber.previousCols ?? fallback?.cols ?? null + const previousRows = + restoreTargetSnapshot?.previousRows ?? subscriber.previousRows ?? fallback?.rows ?? null + const timer = setTimeout(() => { + this.pendingRestoreTimers.delete(ptyId) + if (this.isMobileSubscriberActive(ptyId)) { + return + } + if (previousCols != null && previousRows != null) { + this.ptyController?.resize?.(ptyId, previousCols, previousRows) + this.resizeHeadlessTerminal(ptyId, previousCols, previousRows) + } + this.lastRendererSizes.delete(ptyId) + this.suppressResizesForMs(500) + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged( + ptyId, + 'desktop-fit', + previousCols ?? 0, + previousRows ?? 0 + ) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', previousCols ?? 0, previousRows ?? 0) + }, 300) + + this.pendingRestoreTimers.set(ptyId, { timer, clientId }) + } + // 'phone' mode: keep phone dims (no restore needed) + // 'desktop' mode: was never resized, nothing to restore + } + + // Why: called when mode changes via terminal.setDisplayMode. Applies the + // mode change immediately if there's an active subscriber, and emits a + // 'resized' event so the mobile client can reinitialize xterm inline. + // + // Multi-mobile: the most recent mobile actor's viewport drives the active + // phone-fit dims. The earliest-by-subscribe-time subscriber's + // previousCols/Rows drive the desktop-restore target. + applyMobileDisplayMode(ptyId: string): void { + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + const inner = this.mobileSubscribers.get(ptyId) + const subscriber = inner ? this.pickMostRecentActor(inner) : null + const subscriberRecord = subscriber && inner ? inner.get(subscriber.clientId) : null + + if (mode === 'desktop') { + // Find the first subscriber (any clientId) that was previously + // phone-fitted, and reset its flag. The desktop-restore target uses + // earliest-by-subscribe-time among non-null prevCols/Rows. + if (inner) { + const restore = this.pickEarliestRestoreTarget(inner) + let anyWasResized = false + for (const sub of inner.values()) { + if (sub.wasResizedToPhone) { + anyWasResized = true + sub.wasResizedToPhone = false + } + } + if (anyWasResized && restore) { + this.ptyController?.resize?.(ptyId, restore.previousCols, restore.previousRows) + this.resizeHeadlessTerminal(ptyId, restore.previousCols, restore.previousRows) + // Why: clear stale renderer size so the next mobile subscribe falls + // through to currentSize (which is correct after the server restore). + // Without this, a polluted 214 from a prior collateral safeFit cascade + // persists in lastRendererSizes and gets used as previousCols. + this.lastRendererSizes.delete(ptyId) + // Why: 500ms not 200ms — the desktop renderer's collateral safeFit + // cascade (IPC → React re-render → rAF → DOM measure → IPC back) + // takes ~360ms to propagate to background-tab terminals. + this.suppressResizesForMs(500) + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged( + ptyId, + 'desktop-fit', + restore.previousCols, + restore.previousRows + ) + } + } + const size = this.getTerminalSize(ptyId) + this.notifyTerminalResize(ptyId, { + cols: size?.cols ?? 0, + rows: size?.rows ?? 0, + displayMode: 'desktop', + reason: 'mode-change' + }) + } else if (mode === 'phone' || mode === 'auto') { + if (subscriberRecord && !subscriberRecord.wasResizedToPhone) { + const viewport = subscriberRecord.viewport + if (viewport) { + this.handleMobileSubscribe(ptyId, subscriberRecord.clientId, viewport) + } + } + // Why: always emit the mode change even when no resize occurred (e.g. + // subscriber missing, wasResizedToPhone already true, or no viewport). + // Without this the mobile client never learns the mode changed and its + // toggle button gets stuck showing the old state. + const size = this.getTerminalSize(ptyId) + this.notifyTerminalResize(ptyId, { + cols: size?.cols ?? 0, + rows: size?.rows ?? 0, + displayMode: mode, + reason: 'mode-change' + }) + } + } + + // Why: called from the pty:resize IPC handler whenever the desktop renderer + // resizes a PTY (e.g. via safeFit after window resize, split, or desktop-mode + // restore). Stores the renderer-reported size so handleMobileSubscribe can use + // the actual pane geometry instead of a stale PTY size for previousCols. + onExternalPtyResize(ptyId: string, cols: number, rows: number): void { + this.lastRendererSizes.set(ptyId, { cols, rows }) + + const inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + return + } + // Capture the renderer-reported size as the next-restore target on any + // subscriber that hasn't yet been phone-fitted. Subscribers in + // wasResizedToPhone state already have a captured pre-fit baseline. + for (const sub of inner.values()) { + if (!sub.wasResizedToPhone) { + sub.previousCols = cols + sub.previousRows = rows + } + } + } + + // Why: the pty:resize IPC handler calls this to check if the global + // suppress window is active. During this window, all desktop renderer + // pty:resize events are ignored to prevent collateral safeFit corruption. + isResizeSuppressed(): boolean { + return Date.now() < this.resizeSuppressedUntil + } + + private suppressResizesForMs(ms: number): void { + this.resizeSuppressedUntil = Date.now() + ms + } + + subscribeToTerminalResize( + ptyId: string, + listener: (event: { cols: number; rows: number; displayMode: string; reason: string }) => void + ): () => void { + let listeners = this.resizeListeners.get(ptyId) + if (!listeners) { + listeners = new Set() + this.resizeListeners.set(ptyId, listeners) + } + listeners.add(listener) + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + this.resizeListeners.delete(ptyId) + } + } + } + + private notifyTerminalResize( + ptyId: string, + event: { cols: number; rows: number; displayMode: string; reason: string } + ): void { + const listeners = this.resizeListeners.get(ptyId) + if (!listeners) { + return + } + for (const listener of listeners) { + listener(event) + } + } + + // Why: Section 7.2 — the runtime detects agent exit directly and updates + // dispatch contexts immediately, rather than waiting for the coordinator's + // next poll cycle. This catches agent crashes and unexpected exits within + // milliseconds. The task is set back to 'pending' so it can be re-dispatched. + private failActiveDispatchOnExit(leaf: RuntimeLeafRecord, exitCode: number): void { + if (!this._orchestrationDb) { + return + } + + const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (!handle) { + return + } + + const dispatch = this._orchestrationDb.getActiveDispatchForTerminal(handle) + if (!dispatch) { + return + } + + const errorContext = `Agent exited with code ${exitCode}` + this._orchestrationDb.failDispatch(dispatch.id, errorContext) + + // Why: create an escalation message so the coordinator is notified about + // the unexpected exit on its next check cycle, even if the circuit breaker + // hasn't tripped yet. + const run = this._orchestrationDb.getActiveCoordinatorRun() + if (run) { + this._orchestrationDb.insertMessage({ + from: handle, + to: run.coordinator_handle, + subject: `Agent exited unexpectedly (code ${exitCode})`, + type: 'escalation', + priority: 'high', + payload: JSON.stringify({ + taskId: dispatch.task_id, + exitCode, + handle + }) + }) } } @@ -408,13 +2301,44 @@ export class OrcaRuntimeService { const worktreesById = await this.getResolvedWorktreeMap() this.assertStableReadyGraph(graphEpoch) + const resolvedWorktrees = [...worktreesById.values()] + await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) + + const livePtyWorktreeIds = new Set() + for (const pty of this.ptysById.values()) { + if (pty.connected) { + livePtyWorktreeIds.add(pty.worktreeId) + } + } + const terminals: RuntimeTerminalSummary[] = [] + const ptyIdsFromLeaves = new Set() for (const leaf of this.leaves.values()) { if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) { continue } + if (!leaf.ptyId && livePtyWorktreeIds.has(leaf.worktreeId)) { + continue + } + if (leaf.ptyId) { + ptyIdsFromLeaves.add(leaf.ptyId) + } terminals.push(this.buildTerminalSummary(leaf, worktreesById)) } + + // Why: worktree.ps can classify active worktrees from PTY records even when + // the renderer graph is missing a leaf. terminal.list needs the same fallback + // so mobile does not show a false "No terminals" create flow. + for (const pty of this.ptysById.values()) { + if (!pty.connected || ptyIdsFromLeaves.has(pty.ptyId)) { + continue + } + if (targetWorktreeId && pty.worktreeId !== targetWorktreeId) { + continue + } + terminals.push(this.buildPtyTerminalSummary(pty, worktreesById)) + } + return { terminals: terminals.slice(0, limit), totalCount: terminals.length, @@ -461,6 +2385,15 @@ export class OrcaRuntimeService { const graphEpoch = this.captureReadyGraphEpoch() const worktreesById = await this.getResolvedWorktreeMap() this.assertStableReadyGraph(graphEpoch) + const pty = this.getLivePtyForHandle(handle) + if (pty) { + return { + ...this.buildPtyTerminalSummary(pty.pty, worktreesById), + paneRuntimeId: -1, + ptyId: pty.pty.ptyId, + rendererGraphEpoch: this.rendererGraphEpoch + } + } const { leaf } = this.getLiveLeafForHandle(handle) const summary = this.buildTerminalSummary(leaf, worktreesById) return { @@ -472,6 +2405,11 @@ export class OrcaRuntimeService { } async readTerminal(handle: string, opts: { cursor?: number } = {}): Promise { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + return this.readPtyTerminal(handle, pty.pty, opts) + } + const { leaf } = this.getLiveLeafForHandle(handle) const allLines = buildTailLines(leaf.tailBuffer, leaf.tailPartialLine) @@ -519,6 +2457,26 @@ export class OrcaRuntimeService { interrupt?: boolean } ): Promise { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected) { + throw new Error('terminal_not_writable') + } + const payload = buildSendPayload(action) + if (payload === null) { + throw new Error('invalid_terminal_send') + } + const wrote = this.ptyController?.write(pty.pty.ptyId, payload) ?? false + if (!wrote) { + throw new Error('terminal_not_writable') + } + return { + handle, + accepted: true, + bytesWritten: Buffer.byteLength(payload, 'utf8') + } + } + const { leaf } = this.getLiveLeafForHandle(handle) if (!leaf.writable || !leaf.ptyId) { throw new Error('terminal_not_writable') @@ -527,10 +2485,33 @@ export class OrcaRuntimeService { if (payload === null) { throw new Error('invalid_terminal_send') } - const wrote = this.ptyController?.write(leaf.ptyId, payload) ?? false - if (!wrote) { - throw new Error('terminal_not_writable') + + // Why: TUI apps (Claude Code, etc.) treat a single large write as a paste + // event. If \r is included in the same write as multi-line text, the TUI + // interprets it as part of the paste rather than a discrete Enter keypress. + // Splitting the text and the trailing control characters into separate + // writes with a small delay ensures the TUI processes the paste first, + // then receives Enter as a distinct input event. + const hasText = typeof action.text === 'string' && action.text.length > 0 + const hasSuffix = action.enter || action.interrupt + if (hasText && hasSuffix) { + const textWrote = this.ptyController?.write(leaf.ptyId, action.text!) ?? false + if (!textWrote) { + throw new Error('terminal_not_writable') + } + const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') + await new Promise((resolve) => setTimeout(resolve, 500)) + const suffixWrote = this.ptyController?.write(leaf.ptyId, suffix) ?? false + if (!suffixWrote) { + throw new Error('terminal_not_writable') + } + } else { + const wrote = this.ptyController?.write(leaf.ptyId, payload) ?? false + if (!wrote) { + throw new Error('terminal_not_writable') + } } + return { handle, accepted: true, @@ -558,10 +2539,6 @@ export class OrcaRuntimeService { // Why: only 'idle' satisfies tui-idle, not 'permission'. Permission means the // agent is blocked on user approval, not finished with its task. if (condition === 'tui-idle' && leaf.lastAgentStatus === 'idle') { - // Why: reset so the next `wait --for tui-idle` blocks until a fresh - // working→idle transition instead of resolving instantly from a stale - // status left over from a previous agent session. - leaf.lastAgentStatus = null return buildTerminalWaitResult(handle, condition, leaf) } @@ -581,7 +2558,8 @@ export class OrcaRuntimeService { condition, resolve, reject, - timeout: null + timeout: null, + pollInterval: null } if (effectiveTimeoutMs > 0) { @@ -606,8 +2584,21 @@ export class OrcaRuntimeService { if (getTerminalState(live.leaf) === 'exited') { this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) } else if (condition === 'tui-idle' && live.leaf.lastAgentStatus === 'idle') { - live.leaf.lastAgentStatus = null + // Why: don't clear lastAgentStatus here. It's a factual record of the + // last detected OSC state, not a one-shot signal. Clearing it causes + // subsequent tui-idle waiters to hang even though the agent is idle — + // the first waiter consumes the status and all later ones see null. this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) + } else if (condition === 'tui-idle' && live.leaf.lastAgentStatus === null) { + // Why: for daemon-hosted terminals, lastAgentStatus stays null because + // PTY data doesn't flow through onPtyData. Check the renderer-synced + // title as a fast path before falling back to polling. + const fastPathTitle = live.leaf.paneTitle ?? this.tabs.get(live.leaf.tabId)?.title + if (fastPathTitle && detectAgentStatusFromTitle(fastPathTitle) === 'idle') { + this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) + } else { + this.startTuiIdleFallbackPoll(waiter, live.leaf) + } } } catch (error) { this.removeWaiter(waiter) @@ -625,36 +2616,71 @@ export class OrcaRuntimeService { throw new Error('invalid_limit') } const resolvedWorktrees = await this.listResolvedWorktrees() + await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo])) const summaries = new Map() + // Why: the GitHub cache is keyed by `repoPath::branch` (no refs/heads/ prefix), + // matching how the renderer's fetchPRForBranch stores entries. We look up cached + // PR info so mobile clients can group worktrees by PR state without making + // expensive `gh` CLI calls. Falls back to meta.linkedPR if no cache entry exists. + const ghCache = this.store?.getGitHubCache?.() for (const worktree of resolvedWorktrees) { const meta = this.store?.getWorktreeMeta?.(worktree.id) ?? this.store?.getAllWorktreeMeta()[worktree.id] + const repo = repoById.get(worktree.repoId) + let linkedPR: { number: number; state: string } | null = null + const branch = worktree.branch.replace(/^refs\/heads\//, '') + if (repo?.path && branch && ghCache) { + const prCacheKey = `${repo.path}::${branch}` + const cached = ghCache.pr[prCacheKey] + if (cached?.data) { + linkedPR = { number: cached.data.number, state: cached.data.state } + } + } + if (!linkedPR && meta?.linkedPR != null) { + linkedPR = { number: meta.linkedPR, state: 'unknown' } + } summaries.set(worktree.id, { worktreeId: worktree.id, repoId: worktree.repoId, - repo: repoById.get(worktree.repoId)?.displayName ?? worktree.repoId, + repo: repo?.displayName ?? worktree.repoId, path: worktree.path, branch: worktree.branch, + displayName: worktree.displayName, linkedIssue: worktree.linkedIssue, + linkedPR, + isPinned: meta?.isPinned ?? false, unread: meta?.isUnread ?? false, liveTerminalCount: 0, hasAttachedPty: false, lastOutputAt: null, - preview: '' + preview: '', + status: 'inactive' }) } + const countedPtyIds = new Set() for (const leaf of this.leaves.values()) { - const summary = summaries.get(leaf.worktreeId) + const summary = this.getSummaryForRuntimeWorktreeId( + summaries, + resolvedWorktrees, + leaf.worktreeId + ) if (!summary) { continue } + if (leaf.ptyId) { + countedPtyIds.add(leaf.ptyId) + } const previousLastOutputAt = summary.lastOutputAt summary.liveTerminalCount += 1 summary.hasAttachedPty = summary.hasAttachedPty || leaf.connected summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, leaf.lastOutputAt) + summary.status = mergeWorktreeStatus( + summary.status, + getLeafWorktreeStatus(leaf, this.tabs.get(leaf.tabId)?.title ?? null) + ) if ( leaf.preview && (summary.preview.length === 0 || (leaf.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) @@ -663,6 +2689,53 @@ export class OrcaRuntimeService { } } + for (const pty of this.ptysById.values()) { + if (!pty.connected || countedPtyIds.has(pty.ptyId)) { + continue + } + const summary = this.getSummaryForRuntimeWorktreeId( + summaries, + resolvedWorktrees, + pty.worktreeId + ) + if (!summary) { + continue + } + const previousLastOutputAt = summary.lastOutputAt + summary.liveTerminalCount += 1 + summary.hasAttachedPty = true + summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt) + summary.status = mergeWorktreeStatus(summary.status, 'active') + if ( + pty.preview && + (summary.preview.length === 0 || (pty.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) + ) { + summary.preview = pty.preview + } + } + + const session = this.store?.getWorkspaceSession?.() + for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { + if (tabs.length === 0) { + continue + } + const summary = this.getSummaryForRuntimeWorktreeId(summaries, resolvedWorktrees, worktreeId) + if (!summary) { + continue + } + // Why: desktop can show terminal tabs that are not mounted as renderer + // leaves and are not currently visible in the PTY provider list. Mobile + // still needs those worktrees to show as terminal-bearing entries. + summary.liveTerminalCount = Math.max(summary.liveTerminalCount, tabs.length) + summary.hasAttachedPty = summary.hasAttachedPty || tabs.some((tab) => tab.ptyId !== null) + for (const tab of tabs) { + summary.status = mergeWorktreeStatus( + summary.status, + getSavedTabWorktreeStatus(tab.title, tab.ptyId !== null) + ) + } + } + const sorted = [...summaries.values()].sort(compareWorktreePs) return { worktrees: sorted.slice(0, limit), @@ -745,6 +2818,19 @@ export class OrcaRuntimeService { } } + async getRepoHooks(repoSelector: string) { + const repo = await this.resolveRepoSelector(repoSelector) + const hasFile = hasHooksFile(repo.path) + const hooks = getEffectiveHooks(repo) + const setupRunPolicy = getEffectiveSetupRunPolicy(repo) + return { + hasHooksFile: hasFile, + hooks, + setupRunPolicy, + source: hasFile ? 'orca.yaml' : hooks ? 'legacy' : null + } + } + async listManagedWorktrees( repoSelector?: string, limit = DEFAULT_WORKTREE_LIST_LIMIT @@ -766,12 +2852,42 @@ export class OrcaRuntimeService { return await this.resolveWorktreeSelector(worktreeSelector) } + async sleepManagedWorktree(worktreeSelector: string): Promise<{ worktreeId: string }> { + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + // Why: sleep is renderer-initiated on desktop (it tears down tab state + // before killing PTYs). The notifier tells the renderer to run its own + // sleep flow so all cleanup happens in the correct order. + this.notifier?.sleepWorktree(worktree.id) + return { worktreeId: worktree.id } + } + + async activateManagedWorktree(worktreeSelector: string): Promise<{ + repoId: string + worktreeId: string + activated: boolean + }> { + this.assertGraphReady() + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = this.store?.getRepo(worktree.repoId) + if (!repo) { + throw new Error('repo_not_found') + } + + // Why: inactive worktree terminal panes are renderer-owned and may not have + // live PTYs until the desktop activates the worktree and mounts them. + this.notifier?.activateWorktree(repo.id, worktree.id) + return { repoId: repo.id, worktreeId: worktree.id, activated: true } + } + async createManagedWorktree(args: { repoSelector: string name: string baseBranch?: string linkedIssue?: number | null comment?: string + runHooks?: boolean + setupDecision?: 'run' | 'skip' | 'inherit' + startup?: WorktreeStartupLaunch }): Promise { if (!this.store) { throw new Error('runtime_unavailable') @@ -826,10 +2942,18 @@ export class OrcaRuntimeService { } const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' + // Why (§3.3 Lifecycle): route through the shared fetch cache so back-to-back + // CLI creates on the same repo don't each pay the round-trip, and so a + // subsequent dispatch probe within the 30s window reuses this result. The + // helper swallows rejection (log-and-proceed) so a DNS hiccup never wedges + // future creates and CLI creation stays usable offline — same intent as + // the previous try/catch around gitExecFileSync. try { - gitExecFileSync(['fetch', remote], { cwd: repo.path }) + await this.fetchRemoteWithCache(repo.path, remote) } catch { - // Why: matching the editor behavior keeps CLI creation usable offline. + // Why: belt-and-suspenders. fetchRemoteWithCache already logs and does + // not throw; the outer try/catch guarantees create-path tolerance even + // if future refactors change that contract. } await addWorktree( @@ -846,8 +2970,14 @@ export class OrcaRuntimeService { } const worktreeId = `${repo.id}::${created.path}` + const now = Date.now() const meta = this.store.setWorktreeMeta(worktreeId, { - lastActivityAt: Date.now(), + lastActivityAt: now, + // See createRemoteWorktree: createdAt grants the new worktree a grace + // window in Recent sort so ambient PTY bumps in OTHER worktrees can't + // push it down before the user has had a chance to notice it. Smart-sort + // uses max(lastActivityAt, createdAt + CREATE_GRACE_MS). + createdAt: now, ...(shouldSetDisplayName(requestedName, branchName, sanitizedName) ? { displayName: requestedName } : {}), @@ -857,8 +2987,18 @@ export class OrcaRuntimeService { const worktree = mergeWorktree(repo.id, created, meta) let setup: CreateWorktreeResult['setup'] - const hooks = getEffectiveHooks(repo) - if (hooks?.scripts.setup) { + let warning: string | undefined + // Why: CLI-created worktrees do not have a renderer preview to mismatch + // against. Trust is granted by the direct CLI invocation (`--run-hooks`), + // so loading the setup hook from the created worktree is intentional here. + const hooks = getEffectiveHooks(repo, worktreePath) + // Why: setupDecision lets mobile/CLI callers control whether the setup + // script runs. 'skip' suppresses it, 'run' forces it, 'inherit' (default) + // defers to the repo's orca.yaml setupRunPolicy. runHooks === true maps + // to 'run' for backwards compatibility with the desktop create flow. + const effectiveDecision = args.runHooks ? 'run' : (args.setupDecision ?? 'inherit') + const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) + if (shouldRunSetup && hooks?.scripts.setup) { if (this.authoritativeWindowId !== null) { try { // Why: CLI-created worktrees must use the same runner-script path as the @@ -873,12 +3013,16 @@ export class OrcaRuntimeService { console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error) } } else { - void runHook('setup', worktreePath, repo).then((result) => { + void runHook('setup', worktreePath, repo, worktreePath).then((result) => { if (!result.success) { console.error(`[hooks] setup hook failed for ${worktreePath}:`, result.output) } }) } + } else if (hooks?.scripts.setup) { + // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. + warning = `orca.yaml setup hook skipped for ${worktreePath}; pass --run-hooks to run it.` + console.warn(`[hooks] ${warning}`) } this.notifier?.worktreesChanged(repo.id) @@ -886,7 +3030,11 @@ export class OrcaRuntimeService { // renderer-side consequence of activating a worktree. CLI-created // worktrees must trigger that same activation path or they will exist on // disk without becoming the active workspace in the UI. - this.notifier?.activateWorktree(repo.id, worktree.id, setup) + if (args.startup) { + this.notifier?.activateWorktree(repo.id, worktree.id, setup, args.startup) + } else { + this.notifier?.activateWorktree(repo.id, worktree.id, setup) + } this.invalidateResolvedWorktreeCache() // Why: the filesystem-auth layer maintains a separate cache of registered // worktree roots used by git IPC handlers (branchCompare, diff, status, etc.) @@ -896,16 +3044,108 @@ export class OrcaRuntimeService { invalidateAuthorizedRootsCache() return { worktree, - ...(setup ? { setup } : {}) + ...(setup ? { setup } : {}), + ...(warning ? { warning } : {}) } } + /** + * Fetch `remote` in `repoPath`, sharing the 30s freshness window + in-flight + * serialization with all other callers (renderer-create path, CLI create, + * dispatch drift probe). Never rejects — callers log-and-proceed on offline + * failures (§3.3 Lifecycle). + * + * Why a shared cache on the runtime instead of module-scoped: §7.1 relies on + * one cache for BOTH the renderer create path and `probeWorktreeDrift`. A + * dispatch tick that reuses a just-completed create-path fetch is the + * primary telemetry target; splitting the cache by call-site would double + * the fetch load on warm repos. + */ + async fetchRemoteWithCache(repoPath: string, remote: string): Promise { + const key = `${repoPath}::${remote}` + const lastAt = this.fetchLastCompletedAt.get(key) + if (lastAt !== undefined && Date.now() - lastAt < FETCH_FRESHNESS_MS) { + // Why: freshness window hit — skip the fetch entirely. Do NOT reuse any + // in-flight promise here; the timestamp is only written on success, so + // hitting this branch means a previous fetch did succeed recently. + return + } + + const existing = this.fetchInflight.get(key) + if (existing) { + // Why: genuine serialization (not check-then-set). Two callers racing + // on the same repo+remote share the single underlying `git fetch`. + return existing + } + + const promise = gitExecFileAsync(['fetch', remote], { cwd: repoPath }) + .then(() => { + // Why (§3.3 Lifecycle): timestamp on success ONLY. Writing on rejection + // would make the freshness cache lie about the last known remote state. + this.fetchLastCompletedAt.set(key, Date.now()) + }) + .catch((err) => { + // Why: swallow here so awaiters don't throw at the await site. Outer + // create/dispatch paths are already tolerant of offline fetch failure; + // this is the behavioral contract of this helper. + console.warn(`[fetchRemoteWithCache] ${remote} fetch failed for ${repoPath}:`, err) + }) + .finally(() => { + // Why (§3.3 Lifecycle): evict on BOTH success and rejection. A + // rejected entry that survived in the Map would wedge every future + // create on this repo until Orca restarted (the F2 bug §3.3 pins). + this.fetchInflight.delete(key) + }) + + this.fetchInflight.set(key, promise) + return promise + } + + /** + * Probe how far the worktree's HEAD is behind its tracking remote. Returns + * null when the probe cannot establish a signal (no default base ref, or + * git failure). Dispatch treats null as "unknown — proceed" (§3.1); only + * knowing-and-stale refuses. + */ + async probeWorktreeDrift(worktreeSelector: string): Promise<{ + base: string + behind: number + recentSubjects: string[] + } | null> { + const wt = await this.resolveWorktreeSelector(worktreeSelector) + if (!this.store) { + return null + } + const repo = this.store.getRepos().find((r) => r.id === wt.repoId) + if (!repo) { + return null + } + const base = getDefaultBaseRef(repo.path) + if (!base) { + // Why: brand-new repo with no remote primary — nothing to compare + // against, so there's no meaningful drift to report. Dispatch should + // not block on a probe that cannot form an opinion. + return null + } + const remote = base.includes('/') ? base.split('/')[0] : 'origin' + // Why: fetch failures are non-fatal; we proceed with whatever the + // last-known remote ref points at. `fetchRemoteWithCache` never throws. + await this.fetchRemoteWithCache(wt.path, remote) + const drift = getRemoteDrift(wt.path, 'HEAD', base) + if (!drift) { + return null + } + const recentSubjects = getRecentDriftSubjects(wt.path, 'HEAD', base, DRIFT_PROBE_SUBJECT_LIMIT) + return { base, behind: drift.behind, recentSubjects } + } + async updateManagedWorktreeMeta( worktreeSelector: string, updates: { displayName?: string linkedIssue?: number | null comment?: string + isPinned?: boolean } ) { if (!this.store) { @@ -915,7 +3155,8 @@ export class OrcaRuntimeService { const meta = this.store.setWorktreeMeta(worktree.id, { ...(updates.displayName !== undefined ? { displayName: updates.displayName } : {}), ...(updates.linkedIssue !== undefined ? { linkedIssue: updates.linkedIssue } : {}), - ...(updates.comment !== undefined ? { comment: updates.comment } : {}) + ...(updates.comment !== undefined ? { comment: updates.comment } : {}), + ...(updates.isPinned !== undefined ? { isPinned: updates.isPinned } : {}) }) // Why: unlike renderer-initiated optimistic updates, CLI callers need an // explicit push so the editor refreshes metadata changed outside the UI. @@ -924,7 +3165,11 @@ export class OrcaRuntimeService { return mergeWorktree(worktree.repoId, worktree.git, meta) } - async removeManagedWorktree(worktreeSelector: string, force = false): Promise { + async removeManagedWorktree( + worktreeSelector: string, + force = false, + runHooks = false + ): Promise<{ warning?: string }> { if (!this.store) { throw new Error('runtime_unavailable') } @@ -937,12 +3182,48 @@ export class OrcaRuntimeService { throw new Error('Folder mode does not support deleting worktrees.') } + // Why: kill every PTY belonging to this worktree BEFORE the git-level + // removal. Some shells keep the worktree directory busy, and `git worktree + // remove` throws a confusing error if PTYs still hold it open. This also + // closes the headless-CLI leak (design §2a/§2b): without this call, the + // CLI path runs git removal and never touches PTYs, leaving zombies + // behind. Best-effort: any failure here must not prevent git removal — + // the worst case without the call is the status quo. + const localProvider = this.getLocalProvider() + if (localProvider) { + await killAllProcessesForWorktree(worktree.id, { + runtime: this, + localProvider + }) + .then((r) => { + const total = r.runtimeStopped + r.providerStopped + r.registryStopped + if (total > 0) { + // Why (design §4.4 observability): breadcrumb lets ops + // distinguish a renderer-state-induced leak (diff-path purge + // non-empty) from a backend-induced one (nothing to kill but + // memory still pinned). Emit only when the sweep actually did + // work so steady-state logs stay quiet. + console.info( + `[worktree-teardown] ${worktree.id} killed runtime=${r.runtimeStopped} provider=${r.providerStopped} registry=${r.registryStopped}` + ) + } + }) + .catch((err) => { + console.warn(`[worktree-teardown] failed for ${worktree.id}:`, err) + }) + } + const hooks = getEffectiveHooks(repo) - if (hooks?.scripts.archive) { + let warning: string | undefined + if (hooks?.scripts.archive && runHooks) { const result = await runHook('archive', worktree.path, repo) if (!result.success) { console.error(`[hooks] archive hook failed for ${worktree.path}:`, result.output) } + } else if (hooks?.scripts.archive) { + // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. + warning = `orca.yaml archive hook skipped for ${worktree.path}; pass --run-hooks to run it.` + console.warn(`[hooks] ${warning}`) } try { @@ -959,7 +3240,9 @@ export class OrcaRuntimeService { this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifier?.worktreesChanged(repo.id) - return + return { + ...(warning ? { warning } : {}) + } } throw new Error(formatWorktreeRemovalError(error, worktree.path, force)) } @@ -968,6 +3251,9 @@ export class OrcaRuntimeService { this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifier?.worktreesChanged(repo.id) + return { + ...(warning ? { warning } : {}) + } } async renameTerminal(handle: string, title: string | null): Promise { @@ -1063,6 +3349,54 @@ export class OrcaRuntimeService { }) } + // Why: mobile clients may subscribe before the PTY spawns (the left pane + // of a new workspace). Instead of bailing with a bare scrollback+end, + // wait for the PTY to appear so the subscribe can proceed with phone-fit. + waitForLeafPtyId(handle: string, timeoutMs = 10_000): Promise { + const leaf = this.resolveLeafForHandle(handle) + if (leaf?.ptyId) { + return Promise.resolve(leaf.ptyId) + } + + // Why: when the ptyId changes from null to a real value, the old handle + // is invalidated (deleted from this.handles). Capture the tabId+leafId + // now so we can look up the leaf directly even after handle invalidation. + const record = this.handles.get(handle) + const savedTabId = record?.tabId ?? null + const savedLeafId = record?.leafId ?? null + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + reject(new Error('Timed out waiting for PTY to spawn')) + }, timeoutMs) + + const check = (): void => { + // Try the handle first (works if handle wasn't invalidated yet) + let ptyId = this.resolveLeafForHandle(handle)?.ptyId + // Why: when ptyId transitions null→real, issueHandle invalidates the + // old handle. Fall back to direct leaf lookup by the saved coordinates. + if (!ptyId && savedTabId && savedLeafId) { + const directLeaf = this.leaves.get(this.getLeafKey(savedTabId, savedLeafId)) + ptyId = directLeaf?.ptyId ?? null + } + if (ptyId) { + clearTimeout(timer) + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + resolve(ptyId) + } + } + this.graphSyncCallbacks.push(check) + check() + }) + } + // Why: a leaf appears in the graph before its PTY spawns. If we issue a // handle while ptyId is null, the next graph sync after PTY spawn will // change ptyId and invalidate the handle. Wait for a connected PTY so @@ -1088,6 +3422,10 @@ export class OrcaRuntimeService { async focusTerminal(handle: string): Promise { this.assertGraphReady() + const pty = this.getLivePtyForHandle(handle) + if (pty) { + return { handle, tabId: pty.record.tabId, worktreeId: pty.pty.worktreeId } + } const { leaf } = this.getLiveLeafForHandle(handle) this.notifier?.focusTerminal(leaf.tabId, leaf.worktreeId) return { handle, tabId: leaf.tabId, worktreeId: leaf.worktreeId } @@ -1218,8 +3556,13 @@ export class OrcaRuntimeService { // against whatever the renderer rebuilds next. this.rendererGraphEpoch += 1 this.graphStatus = 'reloading' + this.rememberDetachedPreAllocatedLeaves() this.handles.clear() this.handleByLeafKey.clear() + // Why: handleByPtyId maps ptyId → pre-allocated CLI handle (ORCA_TERMINAL_HANDLE). + // These must survive renderer reloads so CLI agents can keep controlling the + // same terminal across graph rebuilds — adoptPreAllocatedHandle re-links + // them when the new graph arrives. this.rejectAllWaiters('terminal_handle_stale') this.refreshWritableFlags() } @@ -1243,10 +3586,13 @@ export class OrcaRuntimeService { } this.graphStatus = 'unavailable' this.authoritativeWindowId = null + this.rememberDetachedPreAllocatedLeaves() this.tabs.clear() this.leaves.clear() this.handles.clear() this.handleByLeafKey.clear() + // Why: same as markRendererReloading — pre-allocated CLI handles must + // survive graph unavailability so they can be re-adopted on reconnect. this.rejectAllWaiters('terminal_handle_stale') } @@ -1387,6 +3733,108 @@ export class OrcaRuntimeService { this.resolvedWorktreeCache = null } + private recordPtyWorktree( + ptyId: string, + worktreeId: string, + state: Partial> = {} + ): RuntimePtyWorktreeRecord { + let pty = this.ptysById.get(ptyId) + if (!pty) { + pty = { + ptyId, + worktreeId, + connected: state.connected ?? true, + lastOutputAt: state.lastOutputAt ?? null, + tailBuffer: [], + tailPartialLine: '', + tailTruncated: false, + tailLinesTotal: 0, + preview: state.preview ?? '' + } + this.ptysById.set(ptyId, pty) + return pty + } + + pty.worktreeId = worktreeId + if (state.connected !== undefined) { + pty.connected = state.connected + } + if (state.lastOutputAt !== undefined) { + pty.lastOutputAt = maxTimestamp(pty.lastOutputAt, state.lastOutputAt) + } + if (state.preview !== undefined && state.preview.length > 0) { + pty.preview = state.preview + } + return pty + } + + private getOrCreatePtyWorktreeRecord(ptyId: string): RuntimePtyWorktreeRecord | null { + const existing = this.ptysById.get(ptyId) + if (existing) { + return existing + } + const inferredWorktreeId = inferWorktreeIdFromPtyId(ptyId) + if (!inferredWorktreeId) { + return null + } + // Why: daemon-backed PTY session IDs are prefixed with the worktree ID so + // mobile summaries survive renderer graph gaps and Electron reloads. + return this.recordPtyWorktree(ptyId, inferredWorktreeId) + } + + private async refreshPtyWorktreeRecordsFromController( + resolvedWorktrees: ResolvedWorktree[] + ): Promise { + if (!this.ptyController?.listProcesses) { + return + } + const sessions = await this.ptyController.listProcesses().catch(() => []) + const livePtyIds = new Set(sessions.map((session) => session.id)) + for (const session of sessions) { + const worktreeId = + inferWorktreeIdFromPtyId(session.id) ?? + findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd) + if (worktreeId) { + this.recordPtyWorktree(session.id, worktreeId, { connected: true }) + } + } + for (const pty of this.ptysById.values()) { + if (!livePtyIds.has(pty.ptyId) && !this.leafExistsForPty(pty.ptyId)) { + pty.connected = false + } + } + } + + private leafExistsForPty(ptyId: string): boolean { + for (const leaf of this.leaves.values()) { + if (leaf.ptyId === ptyId) { + return true + } + } + return false + } + + private getSummaryForRuntimeWorktreeId( + summaries: Map, + resolvedWorktrees: ResolvedWorktree[], + runtimeWorktreeId: string + ): RuntimeWorktreePsSummary | null { + const exact = summaries.get(runtimeWorktreeId) + if (exact) { + return exact + } + const parsed = parseRuntimeWorktreeId(runtimeWorktreeId) + if (!parsed) { + return null + } + const resolved = resolvedWorktrees.find( + (worktree) => + worktree.repoId === parsed.repoId && + areWorktreePathsEqual(worktree.path, parsed.worktreePath) + ) + return resolved ? (summaries.get(resolved.id) ?? null) : null + } + private buildTerminalSummary( leaf: RuntimeLeafRecord, worktreesById: Map @@ -1409,6 +3857,164 @@ export class OrcaRuntimeService { } } + // Why: group address resolution (Section 4.5) needs to query per-handle agent + // status without throwing on stale handles, so this returns null on any error. + getAgentStatusForHandle(handle: string): string | null { + try { + const { leaf } = this.getLiveLeafForHandle(handle) + return leaf.lastAgentStatus + } catch { + return null + } + } + + // Why: OSC title detection via onPtyData is the tightest signal for agent + // presence, but the runtime may not see PTY data for daemon-hosted terminals + // (the daemon adapter stubs getForegroundProcess). This checks three signals + // in order: (1) lastAgentStatus from PTY data OSC titles, (2) the renderer- + // synced tab title (which reflects OSC titles from the xterm instance), and + // (3) the PTY foreground process. Returns true if any signal indicates a + // non-shell agent is running. + async isTerminalRunningAgent(handle: string): Promise { + try { + const { leaf } = this.getLiveLeafForHandle(handle) + if (leaf.lastAgentStatus !== null) { + return true + } + // Why: check both the leaf-level pane title (synced from the renderer's + // runtimePaneTitlesByTabId) and the tab-level title. The tab title already + // includes OSC-enriched agent indicators (e.g. ✳ prefix) synced from the + // renderer's xterm instance. + const titleToCheck = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title + if (titleToCheck && detectAgentStatusFromTitle(titleToCheck) !== null) { + return true + } + if (!leaf.ptyId || !this.ptyController) { + return false + } + const fg = await this.ptyController.getForegroundProcess(leaf.ptyId) + if (!fg) { + return false + } + return !isShellProcess(fg) + } catch { + return false + } + } + + deliverPendingMessagesForHandle(handle: string): void { + try { + const { leaf } = this.getLiveLeafForHandle(handle) + if (leaf.lastAgentStatus === 'idle') { + this.deliverPendingMessages(leaf) + } + } catch { + // Unknown or stale handles cannot be pushed immediately; the persisted + // message remains available via explicit check or future idle delivery. + } + } + + // Why: after a message is inserted for a recipient, any blocking + // orchestration.check --wait calls watching that handle must be woken + // so they can return the new message immediately instead of polling. + notifyMessageArrived(handle: string): void { + const waiters = this.messageWaitersByHandle.get(handle) + if (!waiters || waiters.size === 0) { + return + } + for (const waiter of [...waiters]) { + this.resolveMessageWaiter(waiter) + } + } + + waitForMessage( + handle: string, + options?: { typeFilter?: string[]; timeoutMs?: number; signal?: AbortSignal } + ): Promise { + return new Promise((resolve) => { + const timeoutMs = options?.timeoutMs ?? MESSAGE_WAIT_DEFAULT_TIMEOUT_MS + + const waiter: MessageWaiter = { + handle, + typeFilter: options?.typeFilter, + resolve, + timeout: null + } + + // Why: if the caller aborts (socket closed on the RPC side — see design + // doc §3.1 counter-lifecycle), resolve immediately so the long-poll slot + // is released instead of counting down the full timeoutMs with a dead + // client on the other end. + const signal = options?.signal + const onAbort = (): void => { + this.removeMessageWaiter(waiter) + resolve() + } + if (signal) { + if (signal.aborted) { + resolve() + return + } + signal.addEventListener('abort', onAbort, { once: true }) + } + + waiter.timeout = setTimeout(() => { + if (signal) { + signal.removeEventListener('abort', onAbort) + } + this.removeMessageWaiter(waiter) + resolve() + }, timeoutMs) + + let waiters = this.messageWaitersByHandle.get(handle) + if (!waiters) { + waiters = new Set() + this.messageWaitersByHandle.set(handle, waiters) + } + waiters.add(waiter) + }) + } + + private resolveMessageWaiter(waiter: MessageWaiter): void { + this.removeMessageWaiter(waiter) + waiter.resolve() + } + + private removeMessageWaiter(waiter: MessageWaiter): void { + if (waiter.timeout) { + clearTimeout(waiter.timeout) + waiter.timeout = null + } + const waiters = this.messageWaitersByHandle.get(waiter.handle) + if (waiters) { + waiters.delete(waiter) + if (waiters.size === 0) { + this.messageWaitersByHandle.delete(waiter.handle) + } + } + } + + private buildPtyTerminalSummary( + pty: RuntimePtyWorktreeRecord, + worktreesById: Map + ): RuntimeTerminalSummary { + const worktree = worktreesById.get(pty.worktreeId) + + return { + handle: this.issuePtyHandle(pty), + worktreeId: pty.worktreeId, + worktreePath: worktree?.path ?? '', + branch: worktree?.branch ?? '', + tabId: `pty:${pty.ptyId}`, + leafId: `pty:${pty.ptyId}`, + title: null, + connected: pty.connected, + writable: pty.connected, + lastOutputAt: pty.lastOutputAt, + preview: pty.preview + } + } + private getLiveLeafForHandle(handle: string): { record: TerminalHandleRecord leaf: RuntimeLeafRecord @@ -1429,6 +4035,53 @@ export class OrcaRuntimeService { return { record, leaf } } + private getLivePtyForHandle(handle: string): { + record: TerminalHandleRecord + pty: RuntimePtyWorktreeRecord + } | null { + const record = this.handles.get(handle) + if (!record || record.runtimeId !== this.runtimeId || !record.tabId.startsWith('pty:')) { + return null + } + if (!record.ptyId) { + return null + } + const pty = this.ptysById.get(record.ptyId) + if (!pty || pty.ptyId !== record.ptyId) { + return null + } + return { record, pty } + } + + private readPtyTerminal( + handle: string, + pty: RuntimePtyWorktreeRecord, + opts: { cursor?: number } = {} + ): RuntimeTerminalRead { + const allLines = buildTailLines(pty.tailBuffer, pty.tailPartialLine) + + let tail: string[] + let truncated: boolean + + if (typeof opts.cursor === 'number' && opts.cursor >= 0) { + const bufferStart = pty.tailLinesTotal - pty.tailBuffer.length + const sliceFrom = Math.max(0, opts.cursor - bufferStart) + tail = pty.tailBuffer.slice(sliceFrom) + truncated = opts.cursor < bufferStart + } else { + tail = allLines + truncated = pty.tailTruncated + } + + return { + handle, + status: pty.connected ? 'running' : 'unknown', + tail, + truncated, + nextCursor: String(pty.tailLinesTotal) + } + } + private issueHandle(leaf: RuntimeLeafRecord): string { const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) const existingHandle = this.handleByLeafKey.get(leafKey) @@ -1444,7 +4097,10 @@ export class OrcaRuntimeService { } } - const handle = `term_${randomUUID()}` + const handle = this.adoptPreAllocatedHandle(leaf) ?? `term_${randomUUID()}` + if (this.handles.has(handle)) { + return handle + } this.handles.set(handle, { handle, runtimeId: this.runtimeId, @@ -1459,6 +4115,58 @@ export class OrcaRuntimeService { return handle } + private adoptPreAllocatedHandle(leaf: RuntimeLeafRecord): string | null { + if (!leaf.ptyId) { + return null + } + const preAllocated = this.handleByPtyId.get(leaf.ptyId) + if (!preAllocated) { + return null + } + const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) + this.handles.set(preAllocated, { + handle: preAllocated, + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + worktreeId: leaf.worktreeId, + tabId: leaf.tabId, + leafId: leaf.leafId, + ptyId: leaf.ptyId, + ptyGeneration: leaf.ptyGeneration + }) + this.handleByLeafKey.set(leafKey, preAllocated) + return preAllocated + } + + private issuePtyHandle(pty: RuntimePtyWorktreeRecord): string { + const existingHandle = this.handleByPtyId.get(pty.ptyId) + if (existingHandle) { + const existingRecord = this.handles.get(existingHandle) + if ( + existingRecord && + existingRecord.runtimeId === this.runtimeId && + existingRecord.ptyId === pty.ptyId + ) { + return existingHandle + } + } + + const handle = `term_${randomUUID()}` + const syntheticId = `pty:${pty.ptyId}` + this.handles.set(handle, { + handle, + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + worktreeId: pty.worktreeId, + tabId: syntheticId, + leafId: syntheticId, + ptyId: pty.ptyId, + ptyGeneration: 0 + }) + this.handleByPtyId.set(pty.ptyId, handle) + return handle + } + private refreshWritableFlags(): void { for (const leaf of this.leaves.values()) { leaf.writable = this.graphStatus === 'ready' && leaf.connected && leaf.ptyId !== null @@ -1475,8 +4183,18 @@ export class OrcaRuntimeService { this.rejectWaitersForHandle(handle, 'terminal_handle_stale') } + private rememberDetachedPreAllocatedLeaves(): void { + for (const leaf of this.leaves.values()) { + if (leaf.ptyId && this.handleByPtyId.has(leaf.ptyId)) { + // Why: ORCA_TERMINAL_HANDLE is an agent identity, so CLI control should + // survive renderer graph loss as long as the underlying PTY is alive. + this.detachedPreAllocatedLeaves.set(leaf.ptyId, leaf) + } + } + } + private resolveExitWaiters(leaf: RuntimeLeafRecord): void { - const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + const handle = this.issueHandle(leaf) if (!handle) { return } @@ -1513,6 +4231,117 @@ export class OrcaRuntimeService { } } + // Why: OSC title detection via onPtyData is the primary signal for tui-idle, + // but daemon-hosted terminals don't flow PTY data through the runtime, and + // some agents don't emit recognized titles on startup. This fallback polls + // two signals: (1) the renderer-synced tab title (reflects xterm's OSC title + // handler, works even for daemon terminals), and (2) the PTY foreground process + // + output quiescence. The poll self-cancels when the primary OSC path fires. + private startTuiIdleFallbackPoll(waiter: TerminalWaiter, leaf: RuntimeLeafRecord): void { + waiter.pollInterval = setInterval(async () => { + try { + // If OSC detection via onPtyData kicked in, stop — the primary path + // will handle (or has already handled) resolution. + if (leaf.lastAgentStatus !== null) { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + return + } + // Why: check the renderer-synced title. For daemon-hosted terminals, + // this is the only path where OSC titles are visible to the runtime. + const pollTitle = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title + if (pollTitle) { + const titleStatus = detectAgentStatusFromTitle(pollTitle) + if (titleStatus === 'idle') { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) + return + } + } + // Foreground process fallback: if the daemon/local provider can report + // the process and it's a non-shell with quiet output, treat as idle. + if (leaf.ptyId && this.ptyController) { + const fg = await this.ptyController.getForegroundProcess(leaf.ptyId) + if (fg && !isShellProcess(fg)) { + const quietMs = leaf.lastOutputAt ? Date.now() - leaf.lastOutputAt : 0 + if (quietMs >= TUI_IDLE_QUIESCENCE_MS) { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) + } + } + } + } catch { + // Swallow transient PTY inspection errors and keep polling. + } + }, TUI_IDLE_POLL_INTERVAL_MS) + } + + // Why: push-on-idle delivery — when an agent transitions working→idle, check + // for unread orchestration messages addressed to that terminal and inject them + // into the PTY. This is event-driven (no polling) because the runtime owns + // both the message store and terminal status detection. + private deliverPendingMessages(leaf: RuntimeLeafRecord): void { + if (!this._orchestrationDb) { + return + } + + const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (!handle) { + return + } + + const unread = this._orchestrationDb.getUnreadMessages(handle) + if (unread.length === 0) { + return + } + + if (!leaf.writable || !leaf.ptyId) { + return + } + + const payload = formatMessagesForInjection(unread) + const wrote = this.ptyController?.write(leaf.ptyId, payload) ?? false + if (!wrote) { + return + } + + // Why: Claude Code treats large single PTY writes as paste events and + // swallows a \r included in the same write. Send Enter separately after + // a delay so the agent processes the pasted message first. Stamp + // `delivered_at` only after \r is confirmed, so failed deliveries stay + // queued. + // + // Important (design doc §3.2, feedback #2): we stamp `delivered_at` here + // instead of flipping `read`. `read` is reserved for "a check-caller + // consumed this message." Flipping `read` on push-on-idle would hide the + // message from the coordinator's next `check --unread`, which is the + // exact bug feedback #2 reported. The two bits must stay independent. + const ptyId = leaf.ptyId + setTimeout(() => { + try { + if (!leaf.writable) { + return + } + const submitted = this.ptyController?.write(ptyId, '\r') ?? false + if (submitted) { + this._orchestrationDb?.markAsDelivered(unread.map((m) => m.id)) + } + } catch { + // Terminal may have closed during the delay — messages stay queued + // (delivered_at still NULL) and will be re-delivered on the next + // idle transition. + } + }, 500) + } + private resolveWaiter(waiter: TerminalWaiter, result: RuntimeTerminalWait): void { this.removeWaiter(waiter) waiter.resolve(result) @@ -1539,6 +4368,9 @@ export class OrcaRuntimeService { if (waiter.timeout) { clearTimeout(waiter.timeout) } + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + } const waiters = this.waitersByHandle.get(waiter.handle) if (!waiters) { return @@ -1792,7 +4624,24 @@ export class OrcaRuntimeService { async browserTabList(params: { worktree?: string }): Promise { const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) - return this.requireAgentBrowserBridge().tabList(worktreeId) + const result = this.requireAgentBrowserBridge().tabList(worktreeId) + return { + tabs: result.tabs.map((tab) => this.enrichBrowserTabInfo(tab)) + } + } + + async browserTabShow(params: { page: string; worktree?: string }): Promise { + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + return { tab: this.describeBrowserTab(params.page, worktreeId) } + } + + async browserTabCurrent(params: { worktree?: string }): Promise { + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + const browserPageId = this.requireAgentBrowserBridge().getActivePageId(worktreeId) + if (!browserPageId) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + return { tab: this.describeBrowserTab(browserPageId, worktreeId) } } async browserTabSwitch( @@ -2444,51 +5293,17 @@ export class OrcaRuntimeService { async browserTabCreate(params: { url?: string worktree?: string + profileId?: string }): Promise<{ browserPageId: string }> { - const win = this.getAuthoritativeWindow() - const requestId = randomUUID() const url = params.url ?? 'about:blank' - - // Why: the renderer's Zustand store keys browser tabs by worktreeId in - // "repoId::path" format. The CLI sends a selector (e.g. "path:/Users/..."). - // Resolve it here so the renderer receives the store-compatible ID. const worktreeId = params.worktree ? (await this.resolveWorktreeSelector(params.worktree)).id : undefined - - // Why: browser webviews only mount when their worktree is active in the UI. - // Switch to it before creating the tab so the webview attaches immediately. - if (worktreeId) { - await this.ensureBrowserWorktreeActive(worktreeId) - } - - // Why: tab creation is a renderer-side Zustand store operation. The main process - // sends a request, the renderer creates the tab and replies with the workspace ID - // (which is the browserPageId used by registerGuest and the bridge). - const browserPageId = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('browser:tabCreateReply', handler) - reject(new Error('Tab creation timed out')) - }, 10_000) - - const handler = ( - _event: Electron.IpcMainEvent, - reply: { requestId: string; browserPageId?: string; error?: string } - ): void => { - if (reply.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('browser:tabCreateReply', handler) - if (reply.error) { - reject(new Error(reply.error)) - } else { - resolve(reply.browserPageId!) - } - } - ipcMain.on('browser:tabCreateReply', handler) - win.webContents.send('browser:requestTabCreate', { requestId, url, worktreeId }) - }) + const { browserPageId } = await this.createBrowserTabInRenderer( + url, + worktreeId, + params.profileId + ) // Why: the renderer creates the Zustand tab immediately, but the webview must // mount and fire dom-ready before registerGuest runs. Waiting here ensures the @@ -2529,6 +5344,160 @@ export class OrcaRuntimeService { return { browserPageId } } + async browserTabSetProfile( + params: { + profileId: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const browserPageId = + target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) + if (!browserPageId) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + const profile = browserSessionRegistry.getProfile(params.profileId) + if (!profile) { + throw new BrowserError( + 'invalid_argument', + `Browser profile ${params.profileId} was not found` + ) + } + + // Why: short-circuit no-op switches so the renderer doesn't tear down and + // remount the webview when the tab is already on the requested profile. + const currentProfileId = browserManager.getSessionProfileIdForTab(browserPageId) ?? 'default' + if (currentProfileId === profile.id) { + return { + browserPageId, + profileId: profile.id, + profileLabel: profile.label + } + } + + const win = this.getAuthoritativeWindow() + const requestId = randomUUID() + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('browser:tabSetProfileReply', handler) + reject(new Error('Tab profile update timed out')) + }, 10_000) + + const handler = ( + _event: Electron.IpcMainEvent, + reply: { requestId: string; error?: string } + ): void => { + if (reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('browser:tabSetProfileReply', handler) + if (reply.error) { + reject(new Error(reply.error)) + } else { + resolve() + } + } + ipcMain.on('browser:tabSetProfileReply', handler) + win.webContents.send('browser:requestTabSetProfile', { + requestId, + browserPageId, + profileId: profile.id + }) + }) + + // Why: the renderer destroys the old webview and remounts on the new + // partition. Wait for the re-register so a follow-up tab list + // --show-profile reads the updated sessionProfileId from BrowserManager + // instead of stale data, and so subsequent CLI ops (snapshot, click, etc.) + // hit a guest that's already attached. + try { + await waitForTabRegistration(browserPageId) + } catch { + // Best-effort: re-register won't fire if the worktree is hidden. The + // store already reflects the new profile; downstream commands retry + // once the pane re-mounts. + } + + return { + browserPageId, + profileId: profile.id, + profileLabel: profile.label + } + } + + async browserTabProfileShow(params: { + page: string + worktree?: string + }): Promise { + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + const tab = this.describeBrowserTab(params.page, worktreeId) + return { + browserPageId: tab.browserPageId, + worktreeId: tab.worktreeId ?? null, + profileId: tab.profileId ?? null, + profileLabel: tab.profileLabel ?? null + } + } + + async browserTabProfileClone( + params: { + profileId: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const sourceBrowserPageId = + target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) + if (!sourceBrowserPageId) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + const sourceTab = this.describeBrowserTab(sourceBrowserPageId, target.worktreeId) + const profile = browserSessionRegistry.getProfile(params.profileId) + if (!profile) { + throw new BrowserError( + 'invalid_argument', + `Browser profile ${params.profileId} was not found` + ) + } + const created = await this.createBrowserTabInRenderer( + sourceTab.url, + sourceTab.worktreeId ?? target.worktreeId, + profile.id + ) + // Why: parity with browserTabCreate. Wait for the cloned tab's webview to + // register so the returned browserPageId is operable by the next CLI call. + try { + await waitForTabRegistration(created.browserPageId) + } catch { + // Best-effort: registration may not fire if the worktree is hidden. + } + return { + browserPageId: created.browserPageId, + sourceBrowserPageId, + profileId: profile.id, + profileLabel: profile.label + } + } + + async browserProfileList(): Promise { + return { profiles: browserSessionRegistry.listProfiles() } + } + + async browserProfileCreate(params: { + label: string + scope: 'isolated' | 'imported' + }): Promise { + return { + profile: browserSessionRegistry.createProfile(params.scope, params.label) + } + } + + async browserProfileDelete(params: { profileId: string }): Promise { + return { + deleted: await browserSessionRegistry.deleteProfile(params.profileId), + profileId: params.profileId + } + } + async browserTabClose(params: { index?: number page?: string @@ -2601,6 +5570,84 @@ export class OrcaRuntimeService { return { closed: true } } + private enrichBrowserTabInfo( + tab: BrowserTabListResult['tabs'][number] + ): BrowserTabListResult['tabs'][number] { + const rawProfileId = browserManager.getSessionProfileIdForTab(tab.browserPageId) + const profile = + browserSessionRegistry.getProfile(rawProfileId ?? 'default') ?? + browserSessionRegistry.getDefaultProfile() + return { + ...tab, + worktreeId: browserManager.getWorktreeIdForTab(tab.browserPageId) ?? null, + profileId: profile.id, + profileLabel: profile.label + } + } + + private describeBrowserTab( + browserPageId: string, + explicitWorktreeId?: string + ): BrowserTabListResult['tabs'][number] { + const worktreeId = explicitWorktreeId ?? browserManager.getWorktreeIdForTab(browserPageId) + const tab = this.requireAgentBrowserBridge() + .tabList(worktreeId) + .tabs.find((entry) => entry.browserPageId === browserPageId) + if (!tab) { + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} was not found${scope}` + ) + } + return this.enrichBrowserTabInfo(tab) + } + + private async createBrowserTabInRenderer( + url: string, + worktreeId?: string, + profileId?: string + ): Promise<{ browserPageId: string }> { + const win = this.getAuthoritativeWindow() + const requestId = randomUUID() + + if (worktreeId) { + await this.ensureBrowserWorktreeActive(worktreeId) + } + + const browserPageId = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('browser:tabCreateReply', handler) + reject(new Error('Tab creation timed out')) + }, 10_000) + + const handler = ( + _event: Electron.IpcMainEvent, + reply: { requestId: string; browserPageId?: string; error?: string } + ): void => { + if (reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('browser:tabCreateReply', handler) + if (reply.error) { + reject(new Error(reply.error)) + } else { + resolve(reply.browserPageId!) + } + } + ipcMain.on('browser:tabCreateReply', handler) + win.webContents.send('browser:requestTabCreate', { + requestId, + url, + worktreeId, + sessionProfileId: profileId + }) + }) + + return { browserPageId } + } + private getAuthoritativeWindow(): BrowserWindow { if (this.authoritativeWindowId === null) { throw new Error('No renderer window available') @@ -2617,11 +5664,25 @@ const MAX_TAIL_LINES = 120 const MAX_TAIL_CHARS = 4000 const MAX_PREVIEW_LINES = 6 const MAX_PREVIEW_CHARS = 300 +const WORKTREE_STATUS_PRIORITY: Record = { + inactive: 0, + active: 1, + done: 2, + working: 3, + permission: 4 +} const DEFAULT_REPO_SEARCH_REFS_LIMIT = 25 const DEFAULT_TERMINAL_LIST_LIMIT = 200 const DEFAULT_WORKTREE_LIST_LIMIT = 200 const DEFAULT_WORKTREE_PS_LIMIT = 200 const RESOLVED_WORKTREE_CACHE_TTL_MS = 1000 +// Why (§3.3): 30s freshness window. A second worktree-create or dispatch-probe +// against the same repo+remote within this window reuses the previous successful +// fetch instead of repeating the round-trip. Chosen so rapid "new worktree" +// clicks and successive coordinator dispatches feel snappy, while still being +// short enough that a genuinely-changed remote is observed on the next action. +const FETCH_FRESHNESS_MS = 30_000 +const DRIFT_PROBE_SUBJECT_LIMIT = 5 function buildPreview(lines: string[], partialLine: string): string { const previewLines = buildTailLines(lines, partialLine) .map((line) => line.trim()) @@ -2715,6 +5776,9 @@ function buildSendPayload(action: { // will ever fire. A 5-minute ceiling prevents indefinite hangs while still // giving real agent tasks plenty of time to complete. const TUI_IDLE_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000 +const TUI_IDLE_POLL_INTERVAL_MS = 2000 +const TUI_IDLE_QUIESCENCE_MS = 3000 +const MESSAGE_WAIT_DEFAULT_TIMEOUT_MS = 2 * 60 * 1000 function buildTerminalWaitResult( handle: string, @@ -2742,6 +5806,96 @@ function normalizeBranchRef(branch: string): string { return branch.startsWith('refs/heads/') ? branch.slice('refs/heads/'.length) : branch } +function inferWorktreeIdFromPtyId(ptyId: string): string | null { + const separatorIndex = ptyId.lastIndexOf('@@') + if (separatorIndex <= 0) { + return null + } + const worktreeId = ptyId.slice(0, separatorIndex) + return parseRuntimeWorktreeId(worktreeId) ? worktreeId : null +} + +function parseRuntimeWorktreeId( + worktreeId: string +): { repoId: string; worktreePath: string } | null { + const separatorIndex = worktreeId.indexOf('::') + if (separatorIndex <= 0) { + return null + } + const worktreePath = worktreeId.slice(separatorIndex + 2) + if (!worktreePath) { + return null + } + return { + repoId: worktreeId.slice(0, separatorIndex), + worktreePath + } +} + +function findResolvedWorktreeIdForPath( + resolvedWorktrees: ResolvedWorktree[], + cwd: string +): string | null { + if (!cwd) { + return null + } + const matches = resolvedWorktrees + .filter( + (worktree) => + areWorktreePathsEqual(worktree.path, cwd) || isPathInsideWorktree(cwd, worktree.path) + ) + .sort((left, right) => right.path.length - left.path.length) + return matches[0]?.id ?? null +} + +function isPathInsideWorktree(candidatePath: string, worktreePath: string): boolean { + if (candidatePath === worktreePath) { + return true + } + const normalizedCandidate = candidatePath.replace(/\\/g, '/').replace(/\/+$/, '') + const normalizedWorktree = worktreePath.replace(/\\/g, '/').replace(/\/+$/, '') + return normalizedCandidate.startsWith(`${normalizedWorktree}/`) +} + +function getLeafWorktreeStatus( + leaf: RuntimeLeafRecord, + tabTitle: string | null +): RuntimeWorktreeStatus { + // Why: recompute from the live title each call so worktree.ps mirrors what + // the desktop sidebar's getWorktreeStatus does (no sticky state). Prefer + // the runtime-tracked OSC title (covers daemon-hosted terminals) over the + // renderer-pushed leaf.title and the tab title. Falling back to + // lastAgentStatus only when no title is available preserves a sensible + // signal for very fresh leaves before any title has been observed. + const liveTitle = leaf.lastOscTitle ?? leaf.title ?? tabTitle ?? '' + const detected = liveTitle ? detectAgentStatusFromTitle(liveTitle) : leaf.lastAgentStatus + if (detected === 'permission') { + return 'permission' + } + if (detected === 'working') { + return 'working' + } + return leaf.ptyId ? 'active' : 'inactive' +} + +function getSavedTabWorktreeStatus(title: string, hasPty: boolean): RuntimeWorktreeStatus { + const detected = detectAgentStatusFromTitle(title) + if (detected === 'permission') { + return 'permission' + } + if (detected === 'working') { + return 'working' + } + return hasPty ? 'active' : 'inactive' +} + +function mergeWorktreeStatus( + current: RuntimeWorktreeStatus, + next: RuntimeWorktreeStatus +): RuntimeWorktreeStatus { + return WORKTREE_STATUS_PRIORITY[next] > WORKTREE_STATUS_PRIORITY[current] ? next : current +} + function normalizeTerminalChunk(chunk: string): string { return chunk .replace(/\r\n/g, '\n') @@ -2767,6 +5921,13 @@ function compareWorktreePs( left: RuntimeWorktreePsSummary, right: RuntimeWorktreePsSummary ): number { + // Pinned and unread worktrees sort above others so they survive truncation. + if (left.isPinned !== right.isPinned) { + return left.isPinned ? -1 : 1 + } + if (left.unread !== right.unread) { + return left.unread ? -1 : 1 + } const leftLast = left.lastOutputAt ?? -1 const rightLast = right.lastOutputAt ?? -1 if (leftLast !== rightLast) { diff --git a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap new file mode 100644 index 00000000000..8c8fd9f09f5 --- /dev/null +++ b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap @@ -0,0 +1,83 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`buildDispatchPreamble > renders a stable snapshot of the full preamble 1`] = ` +"You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your coordinator's terminal handle is: term_COORD +Your task ID is: task_SNAP + +You talk to the coordinator only through the CLI commands below. Do not use +Slack, GitHub comments, or any other channel to reach a human during the run. + +=== CLI COMMANDS === + + # Report task completion (REQUIRED when done — even on failure). + # + # RULE: --body must be a 3-sentence executive summary (what you did, + # what you found, what's left). Never send an empty body; the coordinator + # reads the body first and only opens artifacts if it needs more detail. + # If you produced a long-form artifact, include its path as + # payload.reportPath so the coordinator can find it without a file search. + # + # RULE: send worker_done exactly once. Failure is still a worker_done + # with subject like "Failed: " — never silently exit. + orca orchestration send --to term_COORD \\ + --type worker_done --subject "" \\ + --body "<3-sentence summary: what you did, what you found, what's left>" \\ + --payload '{"taskId":"task_SNAP","filesModified":["path/a","path/b"],"reportPath":""}' + + # BEHAVIOR RULE: send a heartbeat every 5 minutes + # while actively working on the task. The coordinator uses this to + # distinguish "still thinking" from "hung / crashed." Skip heartbeats only + # while blocked inside \`check --wait\` or \`ask\` — those calls are + # themselves liveness signals. + # + # Include BOTH taskId and dispatchId in the payload: the coordinator + # attributes the heartbeat to the specific dispatch context, not just + # the task, so a straggler heartbeat from a previously-failed dispatch + # cannot mask a hung retry. + orca orchestration send --to term_COORD \\ + --type heartbeat --subject "alive" \\ + --payload '{"taskId":"task_SNAP","dispatchId":"ctx_SNAP","phase":""}' + + # Ask the coordinator a question and block until it answers. + # + # BEHAVIOR RULE #1 (MUST NOT VIOLATE): + # NEVER use AskUserQuestion; use \`orca orchestration ask\` or send + # --type decision_gate. AskUserQuestion opens a local TUI prompt that the + # coordinator cannot see and cannot answer — your session will hang forever + # waiting on a human. Every interactive question goes through \`ask\` below. + # + # The \`ask\` verb is a thin wrapper: it sends a decision_gate message and + # blocks on \`check --wait\` until the coordinator replies, then prints the + # reply body. Use it anywhere you would otherwise have reached for + # AskUserQuestion. + orca orchestration ask --to term_COORD \\ + --question "" \\ + --options "" \\ + --timeout-ms 600000 + + # Escalate a blocker or failure (pre-completion, when you need the + # coordinator to do something before you can continue): + orca orchestration send --to term_COORD \\ + --type escalation --subject "Blocked: " \\ + --body "
" \\ + --payload '{"taskId":"task_SNAP"}' + + # Check for messages from the coordinator: + orca orchestration check + +=== AFTER YOU SEND worker_done === + +Keep the shell session open for a grace period (10 minutes) in case the +coordinator sends a follow-up or re-dispatches you. Poll with +\`orca orchestration check\` every 2 minutes during that window. If no +follow-up arrives, you may exit after the grace period — the coordinator +will not expect further output from you. + +If the coordinator re-dispatches you (you will receive a fresh preamble + +TASK block), reset your polling and start the new task. Do not respond +to the previous task's follow-ups after a re-dispatch. + +=== TASK === +TASK_BODY" +`; diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts new file mode 100644 index 00000000000..5c1ff4e22f7 --- /dev/null +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -0,0 +1,772 @@ +/* eslint-disable max-lines -- Why: coordinator tests cover dispatch, DAG ordering, escalation, decision gates, concurrency, and stop — splitting by category would scatter shared setup without improving clarity. */ +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' +import { + Coordinator, + DISPATCH_STALE_THRESHOLD, + parseAllowStaleBaseFromSpec, + type CoordinatorRuntime +} from './coordinator' + +type DriftResult = { + base: string + behind: number + recentSubjects: string[] +} | null + +function createMockRuntime(): CoordinatorRuntime & { + sentMessages: { handle: string; text: string }[] + terminals: { handle: string; worktreeId: string; connected: boolean; writable: boolean }[] + createdTerminals: string[] + probeDriftCalls: string[] + probeDriftResult: DriftResult + setProbeDrift(result: DriftResult): void + throwProbeDrift: Error | null +} { + const mock = { + sentMessages: [] as { handle: string; text: string }[], + terminals: [] as { + handle: string + worktreeId: string + connected: boolean + writable: boolean + }[], + createdTerminals: [] as string[], + probeDriftCalls: [] as string[], + probeDriftResult: null as DriftResult, + throwProbeDrift: null as Error | null, + setProbeDrift(result: DriftResult): void { + mock.probeDriftResult = result + }, + async sendTerminal(handle: string, action: { text?: string }) { + mock.sentMessages.push({ handle, text: action.text ?? '' }) + return { handle, accepted: true, bytesWritten: 0 } + }, + async listTerminals() { + return { terminals: mock.terminals } + }, + async createTerminal(_worktree?: string, opts?: { title?: string }) { + const handle = `term_worker_${mock.createdTerminals.length}` + mock.createdTerminals.push(handle) + mock.terminals.push({ handle, worktreeId: 'wt1', connected: true, writable: true }) + return { handle, worktreeId: 'wt1', title: opts?.title ?? '' } + }, + async waitForTerminal(handle: string) { + return { handle, condition: 'exit' } + }, + async probeWorktreeDrift(worktreeSelector: string): Promise { + mock.probeDriftCalls.push(worktreeSelector) + if (mock.throwProbeDrift) { + throw mock.throwProbeDrift + } + return mock.probeDriftResult + } + } + return mock +} + +describe('Coordinator', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + it('throws if no tasks exist', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + const coordinator = new Coordinator(db, runtime, { + spec: 'do stuff', + coordinatorHandle: 'coord' + }) + await expect(coordinator.run()).rejects.toThrow('No tasks found') + }) + + it('dispatches a ready task to an available terminal', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + + const task = db.createTask({ spec: 'implement feature' }) + + // Simulate worker_done arriving after dispatch + const coordinator = new Coordinator(db, runtime, { + spec: 'build it', + coordinatorHandle: 'coord', + pollIntervalMs: 50 + }) + + // Run coordinator in background, then simulate completion + const runPromise = coordinator.run() + + // Wait for dispatch to happen + await new Promise((r) => { + setTimeout(r, 100) + }) + + // Simulate the worker completing + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, filesModified: ['a.ts'] }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + expect(result.completedTasks).toContain(task.id) + expect(runtime.sentMessages.length).toBeGreaterThan(0) + }) + + it('creates a terminal when none are available', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + + const task = db.createTask({ spec: 'work' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50 + }) + + const runPromise = coordinator.run() + + await new Promise((r) => { + setTimeout(r, 100) + }) + + expect(runtime.createdTerminals.length).toBe(1) + + // Complete the task + db.insertMessage({ + from: runtime.createdTerminals[0], + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + }) + + it('handles escalation and circuit breaker', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [ + { handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }, + { handle: 'term_b', worktreeId: 'wt1', connected: true, writable: true } + ] + + const task = db.createTask({ spec: 'risky work' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50 + }) + + const runPromise = coordinator.run() + + // Send 3 escalations to trigger circuit breaker + for (let i = 0; i < 3; i++) { + await new Promise((r) => { + setTimeout(r, 100) + }) + db.insertMessage({ + from: `term_${i === 0 ? 'a' : 'b'}`, + to: 'coord', + subject: `Failed attempt ${i + 1}`, + type: 'escalation', + payload: JSON.stringify({ taskId: task.id }) + }) + } + + const result = await runPromise + expect(result.status).toBe('failed') + expect(result.failedTasks).toContain(task.id) + }) + + it('reports failed when dispatch send failures circuit-break in the DB', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + runtime.sendTerminal = async () => { + throw new Error('terminal_not_writable') + } + + const task = db.createTask({ spec: 'cannot dispatch' }) + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 10 + }) + + const result = await coordinator.run() + + expect(result.status).toBe('failed') + expect(result.failedTasks).toContain(task.id) + expect(db.getTask(task.id)?.status).toBe('failed') + }) + + it('handles decision gate blocking and resolution', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + + const task = db.createTask({ spec: 'needs approval' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50 + }) + + const runPromise = coordinator.run() + + // Wait for dispatch + await new Promise((r) => { + setTimeout(r, 100) + }) + + // Worker sends decision gate + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Need approval', + type: 'decision_gate', + payload: JSON.stringify({ + taskId: task.id, + question: 'Proceed with destructive migration?', + options: ['yes', 'no'] + }) + }) + + await new Promise((r) => { + setTimeout(r, 100) + }) + + // Verify task is blocked + const blocked = db.getTask(task.id) + expect(blocked?.status).toBe('blocked') + expect(db.getActiveDispatchForTerminal('term_a')).toBeUndefined() + + // Resolve the gate + const gates = db.listGates({ taskId: task.id, status: 'pending' }) + expect(gates.length).toBe(1) + db.resolveGate(gates[0].id, 'yes') + + // Wait for re-dispatch and simulate completion + await new Promise((r) => { + setTimeout(r, 200) + }) + + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + expect(result.completedTasks).toContain(task.id) + }) + + it('respects task DAG ordering', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + + const t1 = db.createTask({ spec: 'first' }) + const t2 = db.createTask({ spec: 'second', deps: [t1.id] }) + + expect(t2.status).toBe('pending') + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50 + }) + + const runPromise = coordinator.run() + + // Wait for t1 dispatch + await new Promise((r) => { + setTimeout(r, 100) + }) + + // t2 should still be pending + expect(db.getTask(t2.id)?.status).toBe('pending') + + // Complete t1 + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: t1.id }) + }) + + // Wait for t2 to be promoted and dispatched + await new Promise((r) => { + setTimeout(r, 200) + }) + + // t2 should now be dispatched + const t2Status = db.getTask(t2.id)?.status + expect(t2Status === 'dispatched' || t2Status === 'ready').toBe(true) + + // Complete t2 + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: t2.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + expect(result.completedTasks).toContain(t1.id) + expect(result.completedTasks).toContain(t2.id) + }) + + it('respects maxConcurrent limit', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [ + { handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }, + { handle: 'term_b', worktreeId: 'wt1', connected: true, writable: true }, + { handle: 'term_c', worktreeId: 'wt1', connected: true, writable: true } + ] + + const t1 = db.createTask({ spec: 'one' }) + const t2 = db.createTask({ spec: 'two' }) + const t3 = db.createTask({ spec: 'three' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50, + maxConcurrent: 2 + }) + + const runPromise = coordinator.run() + + await new Promise((r) => { + setTimeout(r, 100) + }) + + // Only 2 should be dispatched + const dispatched = db.listTasks({ status: 'dispatched' }) + expect(dispatched.length).toBe(2) + + // Complete all tasks + for (const task of [t1, t2, t3]) { + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + await new Promise((r) => { + setTimeout(r, 100) + }) + } + + const result = await runPromise + expect(result.status).toBe('completed') + }) + + it('logs a stale warning for dispatched rows past the threshold and does not auto-fail', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + // No terminals available so dispatchReadyTasks creates one and we can + // drive the stale-scan deterministically via SQL backdating. + const task = db.createTask({ spec: 'work' }) + const ctx = db.createDispatchContext(task.id, 'term_stale') + + // Backdate dispatched_at and last_heartbeat_at beyond the 10-min threshold + // so getStaleDispatches returns this row on the first tick. + const sqlite = ( + db as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => void } } } + ).db + const iso = (ms: number) => new Date(Date.now() - ms).toISOString() + sqlite + .prepare('UPDATE dispatch_contexts SET dispatched_at = ?, last_heartbeat_at = ? WHERE id = ?') + .run(iso(60 * 60 * 1000), iso(30 * 60 * 1000), ctx.id) + + const logs: string[] = [] + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 20, + onLog: (m) => logs.push(m) + }) + + // Drive one tick then stop — we only need the stale warning to have fired. + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 80) + }) + coordinator.stop() + await runPromise + + expect(logs.some((l) => /has not sent a heartbeat/.test(l) && l.includes(task.id))).toBe(true) + // Task status must NOT have been auto-failed — logging only. + expect(db.getTask(task.id)?.status).toBe('dispatched') + }) + + it('records heartbeat by dispatchId on worker heartbeat messages', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + + const task = db.createTask({ spec: 'work' }) + const ctx = db.createDispatchContext(task.id, 'term_a') + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 20 + }) + + const runPromise = coordinator.run() + + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'alive', + type: 'heartbeat', + payload: JSON.stringify({ taskId: task.id, dispatchId: ctx.id, phase: 'implementing' }) + }) + + await new Promise((r) => { + setTimeout(r, 80) + }) + + expect(db.getDispatchContext(task.id)?.last_heartbeat_at).toBeTruthy() + + // Complete the task so the coordinator run finishes cleanly. + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + }) + + it('can be stopped', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + db.createTask({ spec: 'never finishes' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50 + }) + + const runPromise = coordinator.run() + + await new Promise((r) => { + setTimeout(r, 100) + }) + coordinator.stop() + + const result = await runPromise + expect(result.status).toBe('failed') + }) + + describe('stale-base dispatch guard', () => { + it('threads drift into the preamble when behind > 0 and under threshold', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + runtime.setProbeDrift({ + base: 'origin/main', + behind: 5, + recentSubjects: ['fix A', 'fix B', 'fix C'] + }) + + const task = db.createTask({ spec: 'do the work' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50, + worktree: 'wt1' + }) + + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 100) + }) + + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + expect(runtime.probeDriftCalls).toContain('wt1') + const sent = runtime.sentMessages.find((m) => m.handle === 'term_a') + expect(sent).toBeDefined() + expect(sent!.text).toContain('--- BASE DRIFT ---') + expect(sent!.text).toContain('5 commits behind origin/main') + expect(sent!.text).toContain('fix A') + }) + + it('silently skips dispatch when drift > threshold and allow-stale-base is absent', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + runtime.setProbeDrift({ + base: 'origin/main', + behind: DISPATCH_STALE_THRESHOLD + 10, + recentSubjects: ['fix A'] + }) + + const task = db.createTask({ spec: 'do the work' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50, + worktree: 'wt1' + }) + + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 250) + }) + coordinator.stop() + const result = await runPromise + + // Why: silent-skip must NOT burn the circuit-breaker budget. Task must + // stay in `ready`; failDispatch must NOT be called; sendTerminal must + // NOT be called; no dispatch context should exist. + expect(runtime.sentMessages).toHaveLength(0) + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.getDispatchContext(task.id)).toBeUndefined() + // Coordinator was stopped externally, so overall status is 'failed' + // because tasks are not complete — but the task itself never dispatched. + expect(result.status).toBe('failed') + expect(result.failedTasks).not.toContain(task.id) + }) + + it('proceeds with stripped spec + drift section when allow-stale-base overrides', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + runtime.setProbeDrift({ + base: 'origin/main', + behind: 200, + recentSubjects: ['commit 1', 'commit 2'] + }) + + const spec = `Investigate issue #42 +allow-stale-base: true` + const task = db.createTask({ spec }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50, + worktree: 'wt1' + }) + + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 100) + }) + + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + const sent = runtime.sentMessages.find((m) => m.handle === 'term_a') + expect(sent).toBeDefined() + expect(sent!.text).toContain('--- BASE DRIFT ---') + expect(sent!.text).toContain('200 commits behind origin/main') + // Why (§3.4): stripped spec must not contain the infra flag line. + expect(sent!.text).toContain('Investigate issue #42') + expect(sent!.text).not.toContain('allow-stale-base: true') + }) + + it('proceeds without drift section when probeWorktreeDrift returns null', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + runtime.setProbeDrift(null) + + const task = db.createTask({ spec: 'do the work' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50, + worktree: 'wt1' + }) + + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 100) + }) + + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + const sent = runtime.sentMessages.find((m) => m.handle === 'term_a') + expect(sent).toBeDefined() + expect(sent!.text).not.toContain('--- BASE DRIFT ---') + }) + + it('does not call probeWorktreeDrift when coordinator has no worktree selector', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + const logs: string[] = [] + + const task = db.createTask({ spec: 'do the work' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50, + // worktree deliberately omitted + onLog: (msg) => logs.push(msg) + }) + + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 100) + }) + + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + expect(runtime.probeDriftCalls).toHaveLength(0) + expect(logs.some((m) => m.includes('stale-base guard inert'))).toBe(true) + // Dispatch still went through normally. + expect(runtime.sentMessages.length).toBeGreaterThan(0) + }) + + it('proceeds without drift when probeWorktreeDrift throws', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + runtime.throwProbeDrift = new Error('boom') + + const task = db.createTask({ spec: 'do the work' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50, + worktree: 'wt1' + }) + + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 100) + }) + + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + const sent = runtime.sentMessages.find((m) => m.handle === 'term_a') + expect(sent!.text).not.toContain('--- BASE DRIFT ---') + }) + }) +}) + +describe('parseAllowStaleBaseFromSpec', () => { + it('matches canonical form on its own line and strips it', () => { + const spec = `Do the work +allow-stale-base: true` + const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec) + expect(allowStale).toBe(true) + expect(strippedSpec).toBe('Do the work\n') + expect(strippedSpec).not.toContain('allow-stale-base') + }) + + it('matches case-insensitively', () => { + const spec = `Do the work +Allow-Stale-Base: TRUE` + const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec) + expect(allowStale).toBe(true) + expect(strippedSpec).not.toMatch(/[Aa]llow-[Ss]tale-[Bb]ase/) + }) + + it('does not match allow-stale-base: false', () => { + const spec = `Do the work +allow-stale-base: false` + const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec) + expect(allowStale).toBe(false) + expect(strippedSpec).toBe(spec) + }) + + it('does not match allow-stale-base: truthy', () => { + const spec = `Do the work +allow-stale-base: truthy` + const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec) + expect(allowStale).toBe(false) + expect(strippedSpec).toBe(spec) + }) + + it('does not match the flag embedded inside a sentence', () => { + const spec = 'we allow-stale-base: true sometimes' + const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec) + expect(allowStale).toBe(false) + expect(strippedSpec).toBe(spec) + }) + + it('handles the flag as the last line with no trailing newline', () => { + const spec = 'line 1\nallow-stale-base: true' + const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec) + expect(allowStale).toBe(true) + expect(strippedSpec).toBe('line 1\n') + expect(strippedSpec.endsWith('allow-stale-base: true')).toBe(false) + }) +}) diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts new file mode 100644 index 00000000000..2dc02867887 --- /dev/null +++ b/src/main/runtime/orchestration/coordinator.ts @@ -0,0 +1,640 @@ +/* eslint-disable max-lines -- Why: the coordinator keeps message processing, task dispatch, gate handling, escalation, and convergence checking in one class so the polling loop can make atomic decisions across all these concerns without split-brain behavior. */ +import type { OrchestrationDb } from './db' +import type { MessageRow, TaskRow, CoordinatorStatus } from './types' +import { buildDispatchPreamble } from './preamble' + +export type CoordinatorRuntime = { + sendTerminal(handle: string, action: { text?: string; enter?: boolean }): Promise + listTerminals( + worktreeSelector?: string, + limit?: number + ): Promise<{ + terminals: { handle: string; worktreeId: string; connected: boolean; writable: boolean }[] + }> + createTerminal( + worktreeSelector?: string, + opts?: { command?: string; title?: string } + ): Promise<{ handle: string; worktreeId: string }> + waitForTerminal( + handle: string, + options?: { condition?: string; timeoutMs?: number } + ): Promise<{ handle: string; condition: string }> + // Why (§3.1): dispatch pre-flight drift check lives on the runtime because + // it needs to resolve a worktree selector, load the repo, and fetch. The + // coordinator only knows about handles + specs; resolving a git worktree + // from this layer would leak transport details here. + probeWorktreeDrift(worktreeSelector: string): Promise<{ + base: string + behind: number + recentSubjects: string[] + } | null> +} + +// Why (§3.1): single threshold, no warn/refuse split. Coordinator picked 20 +// in msg_eff3a646110d — lets normal day-of-velocity on active monorepos pass +// while still tripping on the 168-commit harm observed in ORCHESTRATOR_FEEDBACK.md. +export const DISPATCH_STALE_THRESHOLD = 20 + +// Why (§3.4): the flag is stashed in the task spec text rather than a DB +// column in v1. The regex is intentionally narrow — only the canonical form +// matches, so typos fail closed (dispatch refuses). Returning the stripped +// spec alongside the boolean keeps this infra line out of the worker's +// `--- TASK ---` block (workers would otherwise read it as an instruction). +// +// Trade-off (§7.9): the regex matches any line of the spec including lines +// inside fenced code blocks. Acceptable v1 limitation — the failure mode is +// "dispatches through when the author didn't intend to," which the preamble +// drift section surfaces to the worker. Skill doc directs authors to place +// the flag as the last line and avoid the literal flag in code examples. +const ALLOW_STALE_BASE_RE = /^[ \t]*allow-stale-base:[ \t]*true[ \t]*\r?$/im +const ALLOW_STALE_BASE_STRIP_RE = /^[ \t]*allow-stale-base:[ \t]*true[ \t]*\r?\n?/im + +export function parseAllowStaleBaseFromSpec(spec: string): { + allowStale: boolean + strippedSpec: string +} { + if (!ALLOW_STALE_BASE_RE.test(spec)) { + return { allowStale: false, strippedSpec: spec } + } + const strippedSpec = spec.replace(ALLOW_STALE_BASE_STRIP_RE, '') + return { allowStale: true, strippedSpec } +} + +export type CoordinatorOptions = { + spec: string + coordinatorHandle: string + pollIntervalMs?: number + maxConcurrent?: number + worktree?: string + onLog?: (msg: string) => void +} + +type CoordinatorState = { + runId: string + phase: 'decomposing' | 'dispatching' | 'monitoring' | 'merging' | 'done' + completedTasks: string[] + failedTasks: string[] + escalations: MessageRow[] +} + +const DEFAULT_POLL_MS = 2000 +const MAX_CONCURRENT_DEFAULT = 4 + +// Why: 10 min matches the preamble's documented heartbeat cadence (5 min) × +// 2, so a single missed heartbeat is the earliest a dispatch can look stale. +// Keeping this in one place (not a per-call arg) ensures the preamble copy +// and the detector logic stay aligned; moving it to a config would multiply +// the places this constant must be kept in sync. +const HUNG_THRESHOLD_MS = 10 * 60 * 1000 + +export class Coordinator { + private db: OrchestrationDb + private runtime: CoordinatorRuntime + private state: CoordinatorState + private stopped = false + private opts: Required> & { + onLog: (msg: string) => void + worktree?: string + } + + constructor(db: OrchestrationDb, runtime: CoordinatorRuntime, options: CoordinatorOptions) { + this.db = db + this.runtime = runtime + this.opts = { + spec: options.spec, + coordinatorHandle: options.coordinatorHandle, + pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_MS, + maxConcurrent: options.maxConcurrent ?? MAX_CONCURRENT_DEFAULT, + worktree: options.worktree, + onLog: options.onLog ?? (() => {}) + } + this.state = { + runId: '', + phase: 'decomposing', + completedTasks: [], + failedTasks: [], + escalations: [] + } + } + + async run(): Promise<{ + runId: string + status: CoordinatorStatus + completedTasks: string[] + failedTasks: string[] + escalations: MessageRow[] + }> { + const run = this.db.createCoordinatorRun({ + spec: this.opts.spec, + coordinatorHandle: this.opts.coordinatorHandle, + pollIntervalMs: this.opts.pollIntervalMs + }) + return this.executeLoop(run.id) + } + + // Why: the RPC handler creates the coordinator_runs record itself so it can + // return the run ID immediately, then starts the loop in the background. + // This method skips the DB insert and uses the pre-created run ID. + async runFromExistingRun(runId: string): Promise<{ + runId: string + status: CoordinatorStatus + completedTasks: string[] + failedTasks: string[] + escalations: MessageRow[] + }> { + return this.executeLoop(runId) + } + + private async executeLoop(runId: string): Promise<{ + runId: string + status: CoordinatorStatus + completedTasks: string[] + failedTasks: string[] + escalations: MessageRow[] + }> { + this.state.runId = runId + this.opts.onLog(`Coordinator run ${runId} started`) + + try { + await this.decompose() + + while (!this.stopped) { + const converged = await this.tick() + if (converged) { + break + } + await this.sleep(this.opts.pollIntervalMs) + } + + // Why: if stopped early, treat it as failed since tasks are incomplete. + // Also failed if any task explicitly failed. + const tasks = this.db.listTasks() + const allDone = tasks.every((t) => t.status === 'completed' || t.status === 'failed') + const failedTasks = [ + ...new Set([ + ...this.state.failedTasks, + ...tasks.filter((task) => task.status === 'failed').map((task) => task.id) + ]) + ] + const finalStatus = + this.stopped || failedTasks.length > 0 || !allDone ? 'failed' : 'completed' + this.db.updateCoordinatorRun(runId, finalStatus) + this.opts.onLog(`Coordinator run ${runId} ${finalStatus}`) + + return { + runId, + status: finalStatus, + completedTasks: this.state.completedTasks, + failedTasks, + escalations: this.state.escalations + } + } catch (err) { + this.db.updateCoordinatorRun(runId, 'failed') + throw err + } + } + + stop(): void { + this.stopped = true + } + + // Why: the coordinator decomposes the top-level spec into a task DAG. + // For now, tasks must be pre-created before calling run(). The spec is + // stored for context but decomposition is the caller's responsibility — + // AI-driven decomposition belongs in a future phase where the coordinator + // itself is an LLM agent. + private async decompose(): Promise { + this.state.phase = 'decomposing' + const existing = this.db.listTasks() + if (existing.length === 0) { + throw new Error( + 'No tasks found. Create tasks with orchestration.taskCreate before running the coordinator.' + ) + } + this.opts.onLog(`Found ${existing.length} tasks in DAG`) + this.state.phase = 'dispatching' + } + + private async tick(): Promise { + this.processMessages() + this.processEscalations() + this.processDecisionGates() + this.warnStaleDispatches() + await this.dispatchReadyTasks() + return this.checkConvergence() + } + + // Why: emit a single warning per stale dispatch per tick. This intentionally + // does NOT auto-fail the dispatch — the false-positive cost (a slow worker + // producing correct output) is higher than the false-negative cost (a hung + // worker keeps its terminal slot until a human notices). Auto-fail policy + // is a separate decision documented in R6 of DESIGN_DOC_PREAMBLE_FIX.md. + private warnStaleDispatches(): void { + const thresholdIso = new Date(Date.now() - HUNG_THRESHOLD_MS).toISOString() + const stale = this.db.getStaleDispatches(thresholdIso) + for (const ctx of stale) { + const minutes = Math.round(HUNG_THRESHOLD_MS / 60000) + this.opts.onLog( + `Warning: worker ${ctx.assignee_handle ?? ''} on task ${ctx.task_id} has not sent a heartbeat in ~${minutes} min (dispatch ${ctx.id})` + ) + } + } + + private processMessages(): void { + const messages = this.db.getUnreadMessages(this.opts.coordinatorHandle) + if (messages.length === 0) { + return + } + + for (const msg of messages) { + switch (msg.type) { + case 'worker_done': + this.handleWorkerDone(msg) + break + case 'escalation': + this.handleEscalation(msg) + break + case 'decision_gate': + this.handleDecisionGateMessage(msg) + break + case 'heartbeat': + this.handleHeartbeat(msg) + break + case 'status': + this.opts.onLog(`Status from ${msg.from_handle}: ${msg.subject}`) + break + default: + break + } + } + + this.db.markAsRead(messages.map((m) => m.id)) + } + + // Why: attribute heartbeats to the specific dispatchId, not a + // (taskId, from_handle) lookup. A task that gets retried after a failed + // dispatch has multiple rows in dispatch_contexts — a late heartbeat from + // the previous (failed) assignee arriving while the new dispatch is active + // would falsely bump the new row's last_heartbeat_at if we resolved by + // "latest dispatch for this task" (§5.3.4). If the worker drops dispatchId + // from the payload, log-and-skip is the preferred failure mode: the stale + // detector will correctly flag the dispatch as hung because nothing + // refreshed last_heartbeat_at. + private handleHeartbeat(msg: MessageRow): void { + if (!msg.payload) { + this.opts.onLog(`Heartbeat from ${msg.from_handle} missing payload; ignored`) + return + } + let payload: { dispatchId?: unknown } = {} + try { + payload = JSON.parse(msg.payload) + } catch { + this.opts.onLog(`Heartbeat from ${msg.from_handle} has invalid JSON payload; ignored`) + return + } + const dispatchId = payload.dispatchId + if (typeof dispatchId !== 'string' || dispatchId.length === 0) { + this.opts.onLog(`Heartbeat from ${msg.from_handle} missing dispatchId; ignored`) + return + } + this.db.recordHeartbeat(dispatchId, msg.created_at) + } + + private handleWorkerDone(msg: MessageRow): void { + this.opts.onLog(`Worker done: ${msg.from_handle} — ${msg.subject}`) + + let payload: { taskId?: string; filesModified?: string[] } = {} + if (msg.payload) { + try { + payload = JSON.parse(msg.payload) + } catch { + this.opts.onLog(`Warning: invalid payload in worker_done from ${msg.from_handle}`) + } + } + + const taskId = payload.taskId + if (!taskId) { + this.opts.onLog(`Warning: worker_done without taskId from ${msg.from_handle}`) + return + } + + const task = this.db.getTask(taskId) + if (!task) { + this.opts.onLog(`Warning: worker_done for unknown task ${taskId}`) + return + } + + const result = JSON.stringify({ + completedBy: msg.from_handle, + filesModified: payload.filesModified ?? [], + completedAt: new Date().toISOString() + }) + this.db.updateTaskStatus(taskId, 'completed', result) + this.state.completedTasks.push(taskId) + + // Why: complete the dispatch context so the terminal is freed for + // subsequent task assignments. + const dispatch = this.db.getDispatchContext(taskId) + if (dispatch) { + this.db.completeDispatch(dispatch.id) + } + + this.opts.onLog(`Task ${taskId} completed`) + } + + private handleEscalation(msg: MessageRow): void { + this.opts.onLog(`Escalation from ${msg.from_handle}: ${msg.subject}`) + this.state.escalations.push(msg) + + let taskId: string | undefined + if (msg.payload) { + try { + const payload = JSON.parse(msg.payload) + taskId = payload.taskId + } catch { + // Escalation without structured payload — log subject as context + } + } + + if (!taskId) { + return + } + + const task = this.db.getTask(taskId) + if (!task || task.status === 'completed' || task.status === 'failed') { + return + } + + const dispatch = this.db.getDispatchContext(taskId) + if (!dispatch) { + return + } + + // Why: fail the dispatch so the circuit breaker increments. If under + // the threshold, the task returns to 'pending' and will be re-dispatched + // to a (potentially different) terminal on the next tick. + const updated = this.db.failDispatch(dispatch.id, msg.subject) + if (updated?.status === 'circuit_broken') { + this.opts.onLog(`Task ${taskId} circuit broken after repeated failures`) + this.db.updateTaskStatus(taskId, 'failed', `Circuit broken: ${msg.subject}`) + this.state.failedTasks.push(taskId) + } else { + this.opts.onLog(`Task ${taskId} will be retried (failure ${updated?.failure_count ?? 0}/3)`) + } + } + + private handleDecisionGateMessage(msg: MessageRow): void { + this.opts.onLog(`Decision gate from ${msg.from_handle}: ${msg.subject}`) + + let payload: { taskId?: string; question?: string; options?: string[] } = {} + if (msg.payload) { + try { + payload = JSON.parse(msg.payload) + } catch { + return + } + } + + if (!payload.taskId || !payload.question) { + this.opts.onLog(`Warning: decision_gate missing taskId or question`) + return + } + + this.db.createGate({ + taskId: payload.taskId, + question: payload.question, + options: payload.options + }) + + this.opts.onLog(`Task ${payload.taskId} blocked on decision gate`) + } + + private processEscalations(): void { + // Why: escalation processing is handled inline in processMessages via + // handleEscalation. This method exists as a hook for future escalation + // policies (e.g., auto-reassign after N minutes, notify external systems). + } + + private processDecisionGates(): void { + // Why: pending gates that haven't been resolved externally are surfaced + // here. In production, the coordinator UI or a human operator resolves + // gates via orchestration.gateResolve. The coordinator does not auto- + // resolve gates — that would defeat their purpose as approval checkpoints. + const pendingGates = this.db.listGates({ status: 'pending' }) + for (const gate of pendingGates) { + const task = this.db.getTask(gate.task_id) + if (task && task.status !== 'blocked') { + // Why: gate exists but task isn't blocked — inconsistent state. + // Re-block the task to maintain the invariant. + this.db.updateTaskStatus(gate.task_id, 'blocked') + } + } + } + + private async dispatchReadyTasks(): Promise { + this.state.phase = 'dispatching' + const readyTasks = this.db.listTasks({ ready: true }) + if (readyTasks.length === 0) { + return + } + + // Why: count currently dispatched tasks to enforce concurrency limit. + const dispatched = this.db.listTasks({ status: 'dispatched' }) + let slotsAvailable = this.opts.maxConcurrent - dispatched.length + if (slotsAvailable <= 0) { + return + } + + const terminals = await this.getAvailableTerminals() + if (terminals.length === 0 && slotsAvailable > 0) { + // Why: no idle terminals exist — create one for the next task. + // Only create one per tick to avoid spawning many terminals at once. + try { + const created = await this.runtime.createTerminal(this.opts.worktree, { + title: `Worker: ${readyTasks[0].spec.slice(0, 40)}` + }) + terminals.push(created.handle) + this.opts.onLog(`Created worker terminal ${created.handle}`) + } catch (err) { + this.opts.onLog(`Failed to create terminal: ${err}`) + return + } + } + + for (const task of readyTasks) { + if (slotsAvailable <= 0 || terminals.length === 0) { + break + } + + const targetHandle = terminals.shift()! + slotsAvailable-- + + try { + await this.dispatchTask(task, targetHandle) + } catch (err) { + this.opts.onLog(`Failed to dispatch task ${task.id}: ${err}`) + } + } + } + + private async dispatchTask(task: TaskRow, targetHandle: string): Promise { + // Why (§3.1): pre-flight drift check BEFORE `createDispatchContext` so a + // refusal does NOT increment failure_count. createDispatchContext carries + // `MAX(failure_count)` forward across contexts (db.ts:301-306), so burning + // the circuit-breaker budget here would convert a recoverable "fetch and + // retry" into a hard `failed` task within ~6s of polling. Silent return + // leaves the task in `ready`; the next `dispatchReadyTasks` tick retries + // naturally, and once the coordinator's worktree has been refreshed + // dispatch proceeds cleanly. + const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(task.spec) + let baseDrift: { + base: string + behind: number + recentSubjects: string[] + } | null = null + + if (!this.opts.worktree) { + // Why (§7.4): CoordinatorOptions.worktree is optional. When undefined, + // probeWorktreeDrift cannot resolve a selector; log once so operators + // can see the guard did not run for this task and proceed. v2 may + // always resolve a worktree via the coordinator-terminal handle. + this.opts.onLog(`stale-base guard inert for ${task.id}: coordinator has no worktree selector`) + } else { + baseDrift = await this.runtime.probeWorktreeDrift(this.opts.worktree).catch((err) => { + this.opts.onLog(`probeWorktreeDrift failed for ${this.opts.worktree}: ${err}`) + return null + }) + + if (baseDrift && baseDrift.behind > DISPATCH_STALE_THRESHOLD && !allowStale) { + // Why (§3.1): silent-return, NOT failDispatch (which would burn the + // circuit-breaker budget). The message lists three remediations so + // the operator can recover via any of them. + this.opts.onLog( + `Skipping dispatch of ${task.id}: worktree is ${baseDrift.behind} commits ` + + `behind ${baseDrift.base}. Pull/rebase the worktree, recreate it with ` + + `--base-branch ${baseDrift.base}, or include 'allow-stale-base: true' ` + + `in the task spec to override. Task remains in 'ready'; coordinator ` + + `will retry on the next tick.` + ) + return + } + } + + const dispatch = this.db.createDispatchContext(task.id, targetHandle) + + // Why: agents dispatched by the coordinator must use orca-dev in dev mode + // so they talk to the dev runtime's socket, not production (Section 6.4). + // Why (§3.4): `strippedSpec` drops the `allow-stale-base: true` line so + // the worker's `--- TASK ---` block does not contain the infra flag (which + // the worker would otherwise read as part of its instructions). + const preamble = buildDispatchPreamble({ + taskId: task.id, + dispatchId: dispatch.id, + // Why (§3.4, stale-base PR): use `strippedSpec` not `task.spec` so the + // `allow-stale-base: true` line isn't rendered into the worker's + // --- TASK --- block (worker would otherwise treat the infra flag as + // part of its instructions). + taskSpec: strippedSpec, + coordinatorHandle: this.opts.coordinatorHandle, + devMode: process.env.ORCA_USER_DATA_PATH?.includes('orca-dev'), + // Why (§3.2): drift section fires only when behind > 0. The preamble + // builder gates on this itself; passing the object unconditionally lets + // the coordinator stay dumb about the display rule. + ...(baseDrift ? { baseDrift } : {}) + }) + + // Why: check if the task was previously blocked by a decision gate that + // has since been resolved. Include the resolution in the preamble so the + // worker knows the decision outcome. + const gates = this.db.listGates({ taskId: task.id, status: 'resolved' }) + let gateContext = '' + if (gates.length > 0) { + const latest = gates.at(-1)! + gateContext = `\n\n--- DECISION GATE RESOLVED ---\nQuestion: ${latest.question}\nResolution: ${latest.resolution}\n---\n` + } + + try { + await this.runtime.sendTerminal(targetHandle, { + text: preamble + gateContext, + enter: true + }) + } catch (err) { + const updated = this.db.failDispatch( + dispatch.id, + err instanceof Error ? err.message : String(err) + ) + if (updated?.status === 'circuit_broken') { + this.state.failedTasks.push(task.id) + } + throw err + } + + this.opts.onLog(`Dispatched task ${task.id} to ${targetHandle}`) + this.state.phase = 'monitoring' + } + + private async getAvailableTerminals(): Promise { + try { + const result = await this.runtime.listTerminals(this.opts.worktree) + const dispatched = this.db.listTasks({ status: 'dispatched' }) + const busyHandles = new Set() + + for (const task of dispatched) { + const ctx = this.db.getDispatchContext(task.id) + if (ctx?.assignee_handle) { + busyHandles.add(ctx.assignee_handle) + } + } + + // Why: exclude the coordinator's own terminal, terminals with active + // dispatches, and disconnected terminals. The dispatch-lock in + // createDispatchContext prevents double-dispatch even if a terminal + // looks available here — this filter is an optimization, not a + // correctness constraint. + return result.terminals + .filter( + (t) => + t.handle !== this.opts.coordinatorHandle && + !busyHandles.has(t.handle) && + t.connected && + t.writable + ) + .map((t) => t.handle) + } catch { + return [] + } + } + + private checkConvergence(): boolean { + const tasks = this.db.listTasks() + if (tasks.length === 0) { + return true + } + + const allDone = tasks.every((t) => t.status === 'completed' || t.status === 'failed') + if (allDone) { + this.state.phase = 'done' + return true + } + + // Why: detect stuck state — no ready or dispatched tasks, but some are + // still pending/blocked. This means deps can never be satisfied. + const active = tasks.filter( + (t) => t.status === 'ready' || t.status === 'dispatched' || t.status === 'pending' + ) + const blocked = tasks.filter((t) => t.status === 'blocked') + if (active.length === 0 && blocked.length > 0) { + this.opts.onLog( + `Stuck: ${blocked.length} tasks blocked with no active tasks. Resolve decision gates to continue.` + ) + } + + return false + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) + } +} diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts new file mode 100644 index 00000000000..9e9d5d941b3 --- /dev/null +++ b/src/main/runtime/orchestration/db.test.ts @@ -0,0 +1,765 @@ +/* eslint-disable max-lines -- Why: DB tests cover messages, tasks, dispatch contexts, decision gates, coordinator runs, and lifecycle in one suite to share the createDb() helper and afterEach cleanup. */ +import Database from 'better-sqlite3' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' +import type { MessageType } from './db' + +describe('OrchestrationDb', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + function createDb(): OrchestrationDb { + db = new OrchestrationDb(':memory:') + return db + } + + describe('messages', () => { + it('inserts and retrieves a message', () => { + const d = createDb() + const msg = d.insertMessage({ + from: 'term_a', + to: 'term_b', + subject: 'hello', + body: 'world' + }) + expect(msg.id).toMatch(/^msg_/) + expect(msg.from_handle).toBe('term_a') + expect(msg.to_handle).toBe('term_b') + expect(msg.subject).toBe('hello') + expect(msg.body).toBe('world') + expect(msg.type).toBe('status') + expect(msg.priority).toBe('normal') + expect(msg.read).toBe(0) + expect(msg.sequence).toBeGreaterThan(0) + }) + + it('returns unread messages in sequence order', () => { + const d = createDb() + d.insertMessage({ from: 'a', to: 'b', subject: 'first' }) + d.insertMessage({ from: 'a', to: 'b', subject: 'second' }) + d.insertMessage({ from: 'a', to: 'c', subject: 'other' }) + + const unread = d.getUnreadMessages('b') + expect(unread).toHaveLength(2) + expect(unread[0].subject).toBe('first') + expect(unread[1].subject).toBe('second') + }) + + it('filters unread by type', () => { + const d = createDb() + d.insertMessage({ from: 'a', to: 'b', subject: 'status msg', type: 'status' }) + d.insertMessage({ from: 'a', to: 'b', subject: 'done msg', type: 'worker_done' }) + + const filtered = d.getUnreadMessages('b', ['worker_done']) + expect(filtered).toHaveLength(1) + expect(filtered[0].type).toBe('worker_done') + }) + + it('marks messages as read', () => { + const d = createDb() + const m1 = d.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + const m2 = d.insertMessage({ from: 'a', to: 'b', subject: 'two' }) + + d.markAsRead([m1.id]) + + const unread = d.getUnreadMessages('b') + expect(unread).toHaveLength(1) + expect(unread[0].id).toBe(m2.id) + }) + + it('stores typed payload and thread_id', () => { + const d = createDb() + const payload = JSON.stringify({ taskId: 'task_abc', filesModified: ['src/a.ts'] }) + const msg = d.insertMessage({ + from: 'a', + to: 'b', + subject: 'done', + type: 'worker_done', + priority: 'high', + threadId: 'thread_1', + payload + }) + + expect(msg.type).toBe('worker_done') + expect(msg.priority).toBe('high') + expect(msg.thread_id).toBe('thread_1') + expect(msg.payload).toBe(payload) + }) + + it('rejects invalid message type', () => { + const d = createDb() + expect(() => + d.insertMessage({ + from: 'a', + to: 'b', + subject: 'bad', + type: 'invalid' as MessageType + }) + ).toThrow() + }) + + it('getInbox returns all messages across recipients', () => { + const d = createDb() + d.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + d.insertMessage({ from: 'a', to: 'c', subject: 'two' }) + d.insertMessage({ from: 'b', to: 'a', subject: 'three' }) + + const inbox = d.getInbox(10) + expect(inbox).toHaveLength(3) + }) + + it('getMessageById returns the correct message', () => { + const d = createDb() + const msg = d.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + const found = d.getMessageById(msg.id) + expect(found?.subject).toBe('test') + expect(d.getMessageById('msg_nonexistent')).toBeUndefined() + }) + }) + + describe('tasks', () => { + it('creates a task with no deps as ready', () => { + const d = createDb() + const task = d.createTask({ spec: 'do something' }) + expect(task.id).toMatch(/^task_/) + expect(task.status).toBe('ready') + expect(task.deps).toBe('[]') + }) + + it('creates a task with deps as pending', () => { + const d = createDb() + const parent = d.createTask({ spec: 'parent' }) + const child = d.createTask({ spec: 'child', deps: [parent.id] }) + expect(child.status).toBe('pending') + expect(JSON.parse(child.deps)).toEqual([parent.id]) + }) + + it('promotes pending tasks when deps complete', () => { + const d = createDb() + const t1 = d.createTask({ spec: 'first' }) + const t2 = d.createTask({ spec: 'second', deps: [t1.id] }) + + expect(d.getTask(t2.id)?.status).toBe('pending') + + d.updateTaskStatus(t1.id, 'completed') + + expect(d.getTask(t2.id)?.status).toBe('ready') + }) + + it('does not promote task until ALL deps complete', () => { + const d = createDb() + const t1 = d.createTask({ spec: 'a' }) + const t2 = d.createTask({ spec: 'b' }) + const t3 = d.createTask({ spec: 'c', deps: [t1.id, t2.id] }) + + d.updateTaskStatus(t1.id, 'completed') + expect(d.getTask(t3.id)?.status).toBe('pending') + + d.updateTaskStatus(t2.id, 'completed') + expect(d.getTask(t3.id)?.status).toBe('ready') + }) + + it('sets completed_at on completion', () => { + const d = createDb() + const task = d.createTask({ spec: 'do it' }) + const updated = d.updateTaskStatus(task.id, 'completed', '{"result": true}') + expect(updated?.completed_at).toBeTruthy() + expect(updated?.result).toBe('{"result": true}') + }) + + it('completing a task frees its active dispatch context', () => { + const d = createDb() + const task = d.createTask({ spec: 'do it' }) + d.createDispatchContext(task.id, 'term_a') + + d.updateTaskStatus(task.id, 'completed') + + expect(d.getActiveDispatchForTerminal('term_a')).toBeUndefined() + expect(d.getDispatchContext(task.id)?.status).toBe('completed') + }) + + it('listTasks filters by status', () => { + const d = createDb() + d.createTask({ spec: 'ready task' }) + const t2 = d.createTask({ spec: 'another' }) + d.updateTaskStatus(t2.id, 'completed') + + expect(d.listTasks({ status: 'ready' })).toHaveLength(1) + expect(d.listTasks({ status: 'completed' })).toHaveLength(1) + expect(d.listTasks({ ready: true })).toHaveLength(1) + }) + + it('listTasks returns all when no filter', () => { + const d = createDb() + d.createTask({ spec: 'one' }) + d.createTask({ spec: 'two' }) + expect(d.listTasks()).toHaveLength(2) + }) + + it('listTasksWithDispatch joins active dispatch metadata', () => { + const d = createDb() + const ready = d.createTask({ spec: 'ready task' }) + const dispatched = d.createTask({ spec: 'active task' }) + const ctx = d.createDispatchContext(dispatched.id, 'term_worker') + + const rows = d.listTasksWithDispatch() + const readyRow = rows.find((r) => r.id === ready.id) + const dispatchedRow = rows.find((r) => r.id === dispatched.id) + + expect(readyRow?.assignee_handle).toBeNull() + expect(readyRow?.dispatch_id).toBeNull() + expect(dispatchedRow?.assignee_handle).toBe('term_worker') + expect(dispatchedRow?.dispatch_id).toBe(ctx.id) + }) + + it('listTasksWithDispatch does not surface completed dispatches', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + d.createDispatchContext(task.id, 'term_worker') + d.updateTaskStatus(task.id, 'completed') + + const rows = d.listTasksWithDispatch() + const row = rows.find((r) => r.id === task.id) + // Task is completed — its dispatch is terminal and should not appear as + // an "active" assignee. + expect(row?.assignee_handle).toBeNull() + expect(row?.dispatch_id).toBeNull() + }) + + it('supports parent_id for task decomposition', () => { + const d = createDb() + const parent = d.createTask({ spec: 'parent' }) + const child = d.createTask({ spec: 'child', parentId: parent.id }) + expect(child.parent_id).toBe(parent.id) + }) + }) + + describe('dispatch contexts', () => { + it('creates a dispatch context and marks task as dispatched', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_worker') + + expect(ctx.id).toMatch(/^ctx_/) + expect(ctx.task_id).toBe(task.id) + expect(ctx.assignee_handle).toBe('term_worker') + expect(ctx.status).toBe('dispatched') + expect(d.getTask(task.id)?.status).toBe('dispatched') + }) + + it('rejects dispatch for non-ready tasks', () => { + const d = createDb() + const parent = d.createTask({ spec: 'parent' }) + const child = d.createTask({ spec: 'child', deps: [parent.id] }) + + expect(() => d.createDispatchContext(child.id, 'term_worker')).toThrow( + /only ready tasks can be dispatched/ + ) + }) + + it('rejects dispatch to an occupied terminal', () => { + const d = createDb() + const t1 = d.createTask({ spec: 'first' }) + const t2 = d.createTask({ spec: 'second' }) + d.createDispatchContext(t1.id, 'term_worker') + + expect(() => d.createDispatchContext(t2.id, 'term_worker')).toThrow( + /already has an active dispatch/ + ) + }) + + it('allows dispatch to a terminal after previous dispatch completes', () => { + const d = createDb() + const t1 = d.createTask({ spec: 'first' }) + const t2 = d.createTask({ spec: 'second' }) + const ctx1 = d.createDispatchContext(t1.id, 'term_worker') + + d.completeDispatch(ctx1.id) + + expect(() => d.createDispatchContext(t2.id, 'term_worker')).not.toThrow() + }) + + it('getDispatchContext returns latest for a task', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + const found = d.getDispatchContext(task.id) + expect(found?.id).toBe(ctx.id) + }) + + it('getDispatchContext uses insertion order when timestamps tie', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx1 = d.createDispatchContext(task.id, 'term_a') + d.failDispatch(ctx1.id, 'retry') + const ctx2 = d.createDispatchContext(task.id, 'term_a') + + expect(d.getDispatchContext(task.id)?.id).toBe(ctx2.id) + }) + + it('getActiveDispatchForTerminal returns active dispatch', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + d.createDispatchContext(task.id, 'term_a') + + const active = d.getActiveDispatchForTerminal('term_a') + expect(active?.task_id).toBe(task.id) + expect(d.getActiveDispatchForTerminal('term_b')).toBeUndefined() + }) + + it('circuit breaker trips after 3 failures', () => { + const d = createDb() + const task = d.createTask({ spec: 'flaky' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + + const after1 = d.failDispatch(ctx.id, 'timeout') + expect(after1?.failure_count).toBe(1) + expect(after1?.status).toBe('failed') + expect(d.getTask(task.id)?.status).toBe('ready') + + const ctx2 = d.createDispatchContext(task.id, 'term_a') + const after2 = d.failDispatch(ctx2.id, 'timeout') + expect(after2?.failure_count).toBe(2) + expect(after2?.status).toBe('failed') + + const ctx3 = d.createDispatchContext(task.id, 'term_a') + const after3 = d.failDispatch(ctx3.id, 'timeout') + expect(after3?.failure_count).toBe(3) + expect(after3?.status).toBe('circuit_broken') + expect(d.getTask(task.id)?.status).toBe('failed') + }) + + it('completeDispatch sets completed_at', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + d.completeDispatch(ctx.id) + + const updated = d.getDispatchContext(task.id) + expect(updated?.status).toBe('completed') + expect(updated?.completed_at).toBeTruthy() + }) + }) + + describe('decision gates', () => { + it('creates a gate and blocks the task', () => { + const d = createDb() + const task = d.createTask({ spec: 'needs approval' }) + d.createDispatchContext(task.id, 'term_a') + const gate = d.createGate({ + taskId: task.id, + question: 'Proceed?', + options: ['yes', 'no'] + }) + + expect(gate.id).toMatch(/^gate_/) + expect(gate.task_id).toBe(task.id) + expect(gate.status).toBe('pending') + expect(JSON.parse(gate.options)).toEqual(['yes', 'no']) + + const updated = d.getTask(task.id) + expect(updated?.status).toBe('blocked') + expect(d.getActiveDispatchForTerminal('term_a')).toBeUndefined() + }) + + it('resolves a gate and unblocks the task', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const gate = d.createGate({ taskId: task.id, question: 'ok?' }) + + const resolved = d.resolveGate(gate.id, 'yes') + expect(resolved?.status).toBe('resolved') + expect(resolved?.resolution).toBe('yes') + + const updated = d.getTask(task.id) + expect(updated?.status).toBe('ready') + }) + + it('times out a gate', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const gate = d.createGate({ taskId: task.id, question: 'ok?' }) + + const timedOut = d.timeoutGate(gate.id) + expect(timedOut?.status).toBe('timeout') + }) + + it('lists gates with filters', () => { + const d = createDb() + const t1 = d.createTask({ spec: 'a' }) + const t2 = d.createTask({ spec: 'b' }) + d.createGate({ taskId: t1.id, question: 'q1' }) + const g2 = d.createGate({ taskId: t2.id, question: 'q2' }) + d.resolveGate(g2.id, 'done') + + expect(d.listGates()).toHaveLength(2) + expect(d.listGates({ status: 'pending' })).toHaveLength(1) + expect(d.listGates({ taskId: t1.id })).toHaveLength(1) + expect(d.listGates({ taskId: t2.id, status: 'resolved' })).toHaveLength(1) + }) + + it('returns undefined for nonexistent gate', () => { + const d = createDb() + expect(d.resolveGate('gate_fake', 'yes')).toBeUndefined() + }) + }) + + describe('coordinator runs', () => { + it('creates and retrieves a coordinator run', () => { + const d = createDb() + const run = d.createCoordinatorRun({ + spec: 'build feature', + coordinatorHandle: 'coord', + pollIntervalMs: 1000 + }) + + expect(run.id).toMatch(/^run_/) + expect(run.status).toBe('running') + expect(run.coordinator_handle).toBe('coord') + expect(run.poll_interval_ms).toBe(1000) + }) + + it('updates coordinator run status', () => { + const d = createDb() + const run = d.createCoordinatorRun({ + spec: 'work', + coordinatorHandle: 'coord' + }) + + const updated = d.updateCoordinatorRun(run.id, 'completed') + expect(updated?.status).toBe('completed') + expect(updated?.completed_at).not.toBeNull() + }) + + it('finds active coordinator run', () => { + const d = createDb() + expect(d.getActiveCoordinatorRun()).toBeUndefined() + + const run = d.createCoordinatorRun({ + spec: 'work', + coordinatorHandle: 'coord' + }) + + expect(d.getActiveCoordinatorRun()?.id).toBe(run.id) + + d.updateCoordinatorRun(run.id, 'completed') + expect(d.getActiveCoordinatorRun()).toBeUndefined() + }) + }) + + describe('lifecycle', () => { + it('resetAll clears all tables', () => { + const d = createDb() + d.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + d.createTask({ spec: 'work' }) + + d.resetAll() + + expect(d.getInbox()).toHaveLength(0) + expect(d.listTasks()).toHaveLength(0) + }) + + it('resetMessages clears only messages', () => { + const d = createDb() + d.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + d.createTask({ spec: 'work' }) + + d.resetMessages() + + expect(d.getInbox()).toHaveLength(0) + expect(d.listTasks()).toHaveLength(1) + }) + + it('resetTasks clears tasks and dispatch contexts', () => { + const d = createDb() + d.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + const task = d.createTask({ spec: 'work' }) + d.createDispatchContext(task.id, 'term_a') + + d.resetTasks() + + expect(d.getInbox()).toHaveLength(1) + expect(d.listTasks()).toHaveLength(0) + }) + }) + + describe('heartbeat + thread helpers (fresh schema)', () => { + it('insertMessage accepts type = heartbeat', () => { + const d = createDb() + const msg = d.insertMessage({ + from: 'worker', + to: 'coord', + subject: 'alive', + type: 'heartbeat', + payload: JSON.stringify({ taskId: 'task_x', dispatchId: 'ctx_x' }) + }) + expect(msg.type).toBe('heartbeat') + }) + + it('recordHeartbeat updates last_heartbeat_at on dispatched rows', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + + d.recordHeartbeat(ctx.id, '2026-05-04T00:00:00.000Z') + const after = d.getDispatchContext(task.id) + expect(after?.last_heartbeat_at).toBe('2026-05-04T00:00:00.000Z') + }) + + it('recordHeartbeat is a no-op for completed rows (straggler ignored)', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + d.completeDispatch(ctx.id) + + d.recordHeartbeat(ctx.id, '2026-05-04T00:00:00.000Z') + const after = d.getDispatchContext(task.id) + expect(after?.last_heartbeat_at).toBeNull() + }) + + it('getStaleDispatches returns only dispatched rows past the grace window', () => { + const d = createDb() + // Fixture: four rows, SQL-backdated timestamps (no fake clock): + // (a) dispatched, heartbeated 5 min ago → not stale + // (b) dispatched, heartbeated 12 min ago → STALE (expected result) + // (c) dispatched, never heartbeated, dispatched 30s ago → not stale (grace) + // (d) completed, heartbeated 30 min ago → not stale (status filter) + const taskA = d.createTask({ spec: 'a' }) + const taskB = d.createTask({ spec: 'b' }) + const taskC = d.createTask({ spec: 'c' }) + const taskD = d.createTask({ spec: 'd' }) + const ctxA = d.createDispatchContext(taskA.id, 'term_a') + const ctxB = d.createDispatchContext(taskB.id, 'term_b') + const ctxC = d.createDispatchContext(taskC.id, 'term_c') + const ctxD = d.createDispatchContext(taskD.id, 'term_d') + d.completeDispatch(ctxD.id) + + const now = Date.now() + const iso = (ms: number) => new Date(now - ms).toISOString() + + // Backdate dispatched_at for a, b, d to long ago so the grace doesn't + // shield them. c keeps its default (≈now). + const sqlite = (d as unknown as { db: Database.Database }).db + sqlite + .prepare( + 'UPDATE dispatch_contexts SET dispatched_at = ?, last_heartbeat_at = ? WHERE id = ?' + ) + .run(iso(60 * 60 * 1000), iso(5 * 60 * 1000), ctxA.id) + sqlite + .prepare( + 'UPDATE dispatch_contexts SET dispatched_at = ?, last_heartbeat_at = ? WHERE id = ?' + ) + .run(iso(60 * 60 * 1000), iso(12 * 60 * 1000), ctxB.id) + sqlite + .prepare('UPDATE dispatch_contexts SET dispatched_at = ? WHERE id = ?') + .run(iso(30_000), ctxC.id) + sqlite + .prepare( + 'UPDATE dispatch_contexts SET dispatched_at = ?, last_heartbeat_at = ? WHERE id = ?' + ) + .run(iso(60 * 60 * 1000), iso(30 * 60 * 1000), ctxD.id) + + const stale = d.getStaleDispatches(iso(10 * 60 * 1000)) + expect(stale.map((s) => s.id)).toEqual([ctxB.id]) + }) + + it('getThreadMessagesFor returns only same-thread replies to a handle', () => { + const d = createDb() + const outbound = d.insertMessage({ + from: 'worker', + to: 'coord', + subject: 'Question', + type: 'decision_gate', + body: 'yes or no?' + }) + // Reply in the same thread addressed to the worker + const reply = d.insertMessage({ + from: 'coord', + to: 'worker', + subject: 'Re: Question', + body: 'yes', + threadId: outbound.id + }) + // Distractor: different thread, same recipient + d.insertMessage({ + from: 'coord', + to: 'worker', + subject: 'other', + body: 'unrelated', + threadId: 'thread_other' + }) + // Distractor: same thread but not addressed to worker + d.insertMessage({ + from: 'coord', + to: 'someone_else', + subject: 'cc', + body: 'not yours', + threadId: outbound.id + }) + + const replies = d.getThreadMessagesFor(outbound.id, 'worker', outbound.sequence) + expect(replies).toHaveLength(1) + expect(replies[0].id).toBe(reply.id) + }) + }) + + describe('schema migration from v1 → v2', () => { + let dbPath: string + let tempDir: string + + afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + function createV1Snapshot(): string { + tempDir = mkdtempSync(join(tmpdir(), 'orca-db-migrate-')) + dbPath = join(tempDir, 'test.db') + const raw = new Database(dbPath) + // v1 schema: pre-heartbeat CHECK, no last_heartbeat_at column. + raw.exec(` + CREATE TABLE messages ( + id TEXT NOT NULL, + from_handle TEXT NOT NULL, + to_handle TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'status' + CHECK(type IN ( + 'status', 'dispatch', 'worker_done', 'merge_ready', + 'escalation', 'handoff', 'decision_gate' + )), + priority TEXT NOT NULL DEFAULT 'normal' + CHECK(priority IN ('normal', 'high', 'urgent')), + thread_id TEXT, + payload TEXT, + read INTEGER NOT NULL DEFAULT 0, + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE UNIQUE INDEX idx_messages_id ON messages(id); + CREATE INDEX idx_inbox ON messages(to_handle, read); + CREATE INDEX idx_thread ON messages(thread_id); + + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, parent_id TEXT, spec TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending','ready','dispatched','completed','failed','blocked')), + deps TEXT NOT NULL DEFAULT '[]', result TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT + ); + + CREATE TABLE dispatch_contexts ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, assignee_handle TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending','dispatched','completed','failed','circuit_broken')), + failure_count INTEGER NOT NULL DEFAULT 0, last_failure TEXT, + dispatched_at TEXT, completed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE decision_gates ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, question TEXT NOT NULL, + options TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending','resolved','timeout')), + resolution TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + resolved_at TEXT + ); + + CREATE TABLE coordinator_runs ( + id TEXT PRIMARY KEY, spec TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'idle' + CHECK(status IN ('idle','running','completed','failed')), + coordinator_handle TEXT NOT NULL, + poll_interval_ms INTEGER NOT NULL DEFAULT 2000, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT + ); + `) + // Seed a pre-existing v1 message so migration must preserve data. + raw + .prepare( + `INSERT INTO messages (id, from_handle, to_handle, subject, type) VALUES ('msg_v1', 'a', 'b', 'pre-migration', 'status')` + ) + .run() + raw.pragma('user_version = 0') + raw.close() + return dbPath + } + + it('migrates a v1 snapshot to v2, accepts heartbeat, preserves indexes', () => { + const path = createV1Snapshot() + const d = new OrchestrationDb(path) + db = d + + // (a) INSERT type='heartbeat' now succeeds + expect(() => + d.insertMessage({ + from: 'w', + to: 'c', + subject: 'alive', + type: 'heartbeat', + payload: '{"taskId":"t","dispatchId":"ctx"}' + }) + ).not.toThrow() + + // (b) last_heartbeat_at column exists on dispatch_contexts + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + d.recordHeartbeat(ctx.id, '2026-05-04T00:00:00.000Z') + expect(d.getDispatchContext(task.id)?.last_heartbeat_at).toBe('2026-05-04T00:00:00.000Z') + + // (c) Indexes still attached to messages post-rebuild. + const sqlite = (d as unknown as { db: Database.Database }).db + const indexes = sqlite + .prepare( + `SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'messages' AND name NOT LIKE 'sqlite_%'` + ) + .all() as { name: string }[] + const names = new Set(indexes.map((r) => r.name)) + expect(names.has('idx_messages_id')).toBe(true) + expect(names.has('idx_inbox')).toBe(true) + expect(names.has('idx_thread')).toBe(true) + + // v1 data preserved + expect(d.getMessageById('msg_v1')?.subject).toBe('pre-migration') + }) + + it('is idempotent: opening an already-migrated DB is a no-op', () => { + const path = createV1Snapshot() + const first = new OrchestrationDb(path) + first.insertMessage({ + from: 'w', + to: 'c', + subject: 'alive', + type: 'heartbeat', + payload: '{}' + }) + first.close() + + const second = new OrchestrationDb(path) + db = second + expect(() => + second.insertMessage({ + from: 'w', + to: 'c', + subject: 'again', + type: 'heartbeat', + payload: '{}' + }) + ).not.toThrow() + const inbox = second.getInbox(10) + expect(inbox.length).toBeGreaterThanOrEqual(2) + }) + }) +}) diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts new file mode 100644 index 00000000000..e8daa9f1708 --- /dev/null +++ b/src/main/runtime/orchestration/db.ts @@ -0,0 +1,803 @@ +/* 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 Database from 'better-sqlite3' +import { randomBytes } from 'crypto' +import type { + MessageType, + MessagePriority, + TaskStatus, + DispatchStatus, + GateStatus, + CoordinatorStatus, + MessageRow, + TaskRow, + DispatchContextRow, + DecisionGateRow, + CoordinatorRun +} from './types' + +export type { + MessageType, + MessagePriority, + TaskStatus, + DispatchStatus, + GateStatus, + CoordinatorStatus, + MessageRow, + TaskRow, + DispatchContextRow, + DecisionGateRow, + CoordinatorRun +} + +function generateId(prefix: string): string { + return `${prefix}_${randomBytes(6).toString('hex')}` +} + +// Why: v1 → v2 added `'heartbeat'` to messages.type CHECK + `last_heartbeat_at` +// column (preamble-hardening PR). v2 → v3 adds `delivered_at` column so +// push-on-idle can distinguish queued-but-undelivered from user-acknowledged +// messages without touching the `read` bit (check-wait PR). Bumping together +// since both PRs ship in the same release — existing on-disk DBs must clear +// both gaps in one atomic migration. +const SCHEMA_VERSION = 3 + +export class OrchestrationDb { + private db: Database.Database + + constructor(dbPath: string | ':memory:') { + this.db = new Database(dbPath) + this.db.pragma('journal_mode = WAL') + this.db.pragma('synchronous = NORMAL') + this.db.pragma('busy_timeout = 5000') + this.createTables() + this.migrate() + } + + private createTables(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS messages ( + id TEXT NOT NULL, + from_handle TEXT NOT NULL, + to_handle TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'status' + CHECK(type IN ( + 'status', 'dispatch', 'worker_done', 'merge_ready', + 'escalation', 'handoff', 'decision_gate', 'heartbeat' + )), + priority TEXT NOT NULL DEFAULT 'normal' + CHECK(priority IN ('normal', 'high', 'urgent')), + thread_id TEXT, + payload TEXT, + read INTEGER NOT NULL DEFAULT 0, + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + delivered_at TEXT + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_id ON messages(id); + 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 tasks ( + id TEXT PRIMARY KEY, + parent_id TEXT, + spec TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ( + 'pending', 'ready', 'dispatched', + 'completed', 'failed', 'blocked' + )), + deps TEXT NOT NULL DEFAULT '[]', + result TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); + CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id); + + CREATE TABLE IF NOT EXISTS dispatch_contexts ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + assignee_handle TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending', 'dispatched', 'completed', 'failed', 'circuit_broken')), + failure_count INTEGER NOT NULL DEFAULT 0, + last_failure TEXT, + dispatched_at TEXT, + completed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_heartbeat_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_dispatch_task ON dispatch_contexts(task_id); + CREATE INDEX IF NOT EXISTS idx_dispatch_status ON dispatch_contexts(status); + + CREATE TABLE IF NOT EXISTS decision_gates ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + question TEXT NOT NULL, + options TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending', 'resolved', 'timeout')), + resolution TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + resolved_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_gates_task ON decision_gates(task_id); + CREATE INDEX IF NOT EXISTS idx_gates_status ON decision_gates(status); + + CREATE TABLE IF NOT EXISTS coordinator_runs ( + id TEXT PRIMARY KEY, + spec TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'idle' + CHECK(status IN ('idle', 'running', 'completed', 'failed')), + coordinator_handle TEXT NOT NULL, + poll_interval_ms INTEGER NOT NULL DEFAULT 2000, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT + ); + `) + } + + // Why: `CREATE TABLE IF NOT EXISTS` is a no-op against an existing on-disk + // DB, so new schema shapes (added columns, widened CHECK constraints) do + // not reach an upgraded user unless we migrate explicitly. The transaction + // guarantees atomicity — a mid-migration crash leaves the DB at the prior + // version because `user_version` is bumped only on success. Idempotent + // re-invocation is a no-op (current >= SCHEMA_VERSION short-circuit). + private migrate(): void { + const current = this.db.pragma('user_version', { simple: true }) as number + if (current >= SCHEMA_VERSION) { + return + } + + this.db.exec('BEGIN') + try { + // v1 → v2: add last_heartbeat_at column; widen messages.type CHECK to + // include 'heartbeat'. SQLite cannot ALTER a CHECK constraint, so we + // rebuild the messages table. We also include `delivered_at` in the + // rebuilt schema so DBs migrating from v1 pick up the v3 column in a + // single table-rewrite pass (avoids a second messages-rebuild later). + if (current < 2) { + if (!this.hasColumn('dispatch_contexts', 'last_heartbeat_at')) { + this.db.exec(`ALTER TABLE dispatch_contexts ADD COLUMN last_heartbeat_at TEXT`) + } + + if (!this.messagesTypeCheckAllowsHeartbeat()) { + // Why — index list is not optional. createTables() already attached + // idx_messages_id / idx_inbox / idx_thread to the old messages table; + // DROP TABLE removes those indexes with it. CREATE INDEX IF NOT + // EXISTS in createTables() only runs on the next process startup, + // so skipping explicit recreation here would leave every + // getUnreadMessages / getMessageById call full-scanning for the + // rest of this process's lifetime — a silent O(N) perf regression. + // The three CREATE INDEX statements below mirror createTables() + // verbatim so the two definitions cannot drift. + this.db.exec(` + CREATE TABLE messages_new ( + id TEXT NOT NULL, + from_handle TEXT NOT NULL, + to_handle TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'status' + CHECK(type IN ( + 'status', 'dispatch', 'worker_done', 'merge_ready', + 'escalation', 'handoff', 'decision_gate', 'heartbeat' + )), + priority TEXT NOT NULL DEFAULT 'normal' + CHECK(priority IN ('normal', 'high', 'urgent')), + thread_id TEXT, + payload TEXT, + read INTEGER NOT NULL DEFAULT 0, + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + delivered_at TEXT + ); + INSERT INTO messages_new ( + id, from_handle, to_handle, subject, body, type, priority, + thread_id, payload, read, sequence, created_at + ) + SELECT + id, from_handle, to_handle, subject, body, type, priority, + thread_id, payload, read, sequence, created_at + FROM messages; + DROP TABLE messages; + ALTER TABLE messages_new RENAME TO messages; + + CREATE UNIQUE INDEX idx_messages_id ON messages(id); + CREATE INDEX idx_inbox ON messages(to_handle, read); + CREATE INDEX idx_thread ON messages(thread_id); + `) + } + } + + // v2 → v3: add `delivered_at` column to messages. A DB that reached v2 + // via the v1 → v2 rebuild above already has the column (we included + // it in messages_new); this handles DBs that were at v2 before this + // release shipped (preamble PR deployed standalone, then check-wait + // merged). ALTER TABLE is idempotent via the hasColumn probe — a + // duplicate-column error would abort the whole transaction. + if (current < 3) { + if (!this.hasColumn('messages', 'delivered_at')) { + this.db.exec(`ALTER TABLE messages ADD COLUMN delivered_at TEXT`) + } + } + + this.db.pragma(`user_version = ${SCHEMA_VERSION}`) + this.db.exec('COMMIT') + } catch (err) { + this.db.exec('ROLLBACK') + throw err + } + } + + private hasColumn(table: string, column: string): boolean { + const rows = this.db.pragma(`table_info(${table})`) as { name: string }[] + return rows.some((r) => r.name === column) + } + + // Why: sqlite_master stores the original CREATE TABLE SQL including the + // CHECK clause. Inspecting that text is the cheapest reliable way to tell + // whether the pre-rebuild schema already knows about 'heartbeat' without + // needing a dedicated schema_meta row. + private messagesTypeCheckAllowsHeartbeat(): boolean { + const row = this.db + .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'messages'") + .get() as { sql: string } | undefined + return !!row && row.sql.includes("'heartbeat'") + } + + // ── Messages ── + + insertMessage(msg: { + from: string + to: string + subject: string + body?: string + type?: MessageType + priority?: MessagePriority + threadId?: string + payload?: string + }): MessageRow { + const id = generateId('msg') + const stmt = this.db.prepare(` + INSERT INTO messages (id, from_handle, to_handle, subject, body, type, priority, thread_id, payload) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + stmt.run( + id, + msg.from, + msg.to, + msg.subject, + msg.body ?? '', + msg.type ?? 'status', + msg.priority ?? 'normal', + msg.threadId ?? null, + msg.payload ?? null + ) + return this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow + } + + getUnreadMessages(toHandle: string, types?: MessageType[]): MessageRow[] { + if (types && types.length > 0) { + const placeholders = types.map(() => '?').join(',') + return this.db + .prepare( + `SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND type IN (${placeholders}) ORDER BY sequence` + ) + .all(toHandle, ...types) as MessageRow[] + } + return this.db + .prepare('SELECT * FROM messages WHERE to_handle = ? AND read = 0 ORDER BY sequence') + .all(toHandle) as MessageRow[] + } + + getAllMessages(toHandle: string, limit = 20): MessageRow[] { + return this.db + .prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?') + .all(toHandle, limit) as MessageRow[] + } + + getMessageById(id: string): MessageRow | undefined { + return this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow | undefined + } + + markAsRead(ids: string[]): void { + if (ids.length === 0) { + return + } + const placeholders = ids.map(() => '?').join(',') + this.db.prepare(`UPDATE messages SET read = 1 WHERE id IN (${placeholders})`).run(...ids) + } + + // Why: `delivered_at` is stamped via SQLite's datetime('now') rather than a + // JS ISO string so it uses the same 'YYYY-MM-DD HH:MM:SS' UTC shape as the + // other SQL-default timestamps on this table. A future ORDER BY or + // comparison against created_at relies on this format consistency. + // See design doc §3.2. + markAsDelivered(ids: string[]): void { + if (ids.length === 0) { + return + } + const placeholders = ids.map(() => '?').join(',') + this.db + .prepare(`UPDATE messages SET delivered_at = datetime('now') WHERE id IN (${placeholders})`) + .run(...ids) + } + + getInbox(limit = 20): MessageRow[] { + return this.db + .prepare('SELECT * FROM messages ORDER BY sequence DESC LIMIT ?') + .all(limit) as MessageRow[] + } + + // Why: used by `check --all` and `inbox --terminal ` — returns every + // message for a handle regardless of read/delivered state; never touches the + // read bit. Stale-handle safe: if the handle no longer exists, the query + // just returns whatever historical rows remain (§3.3). + getAllMessagesForHandle(toHandle: string, limit = 100): MessageRow[] { + return this.db + .prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?') + .all(toHandle, limit) as MessageRow[] + } + + // Why: thread-scoped read for the `orchestration.ask` wait loop. Filtered + // by `to_handle` so a worker only sees replies addressed to it (not + // messages it sent), and ordered by `sequence` so the first post-ask + // reply is returned first. `afterSequence` lets the caller resume past an + // already-seen marker without re-reading the outbound ask itself. Uses + // the existing idx_thread index (see createTables) — no new index. + getThreadMessagesFor(threadId: string, toHandle: string, afterSequence?: number): MessageRow[] { + if (afterSequence !== undefined) { + return this.db + .prepare( + 'SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? AND sequence > ? ORDER BY sequence ASC' + ) + .all(threadId, toHandle, afterSequence) as MessageRow[] + } + return this.db + .prepare('SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? ORDER BY sequence ASC') + .all(threadId, toHandle) as MessageRow[] + } + + // ── Tasks ── + + createTask(task: { spec: string; deps?: string[]; parentId?: string }): TaskRow { + const id = generateId('task') + const depsJson = JSON.stringify(task.deps ?? []) + const hasDeps = (task.deps ?? []).length > 0 + const status: TaskStatus = hasDeps ? 'pending' : 'ready' + this.db + .prepare('INSERT INTO tasks (id, parent_id, spec, status, deps) VALUES (?, ?, ?, ?, ?)') + .run(id, task.parentId ?? null, task.spec, status, depsJson) + return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow + } + + getTask(id: string): TaskRow | undefined { + return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow | undefined + } + + listTasks(filter?: { status?: TaskStatus; ready?: boolean }): TaskRow[] { + if (filter?.ready) { + return this.db + .prepare("SELECT * FROM tasks WHERE status = 'ready' ORDER BY created_at") + .all() as TaskRow[] + } + if (filter?.status) { + return this.db + .prepare('SELECT * FROM tasks WHERE status = ? ORDER BY created_at') + .all(filter.status) as TaskRow[] + } + return this.db.prepare('SELECT * FROM tasks ORDER BY created_at').all() as TaskRow[] + } + + // Why: surfaces the active dispatch (assignee handle + dispatch context id) + // alongside each task so coordinators can answer "who is working on task X?" + // from a single query. The LEFT JOIN keeps non-dispatched tasks in the result + // with NULL assignee/dispatch fields so non-dispatched output stays stable. + // The inner subquery picks the most recent active dispatch per task to match + // the semantics of getDispatchContext for dispatched tasks. + listTasksWithDispatch(filter?: { status?: TaskStatus; ready?: boolean }): (TaskRow & { + assignee_handle: string | null + dispatch_id: string | null + })[] { + const whereClauses: string[] = [] + const params: unknown[] = [] + if (filter?.ready) { + whereClauses.push("t.status = 'ready'") + } else if (filter?.status) { + whereClauses.push('t.status = ?') + params.push(filter.status) + } + const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '' + const sql = ` + SELECT + t.*, + d.assignee_handle AS assignee_handle, + d.id AS dispatch_id + FROM tasks t + LEFT JOIN ( + SELECT dc.* + FROM dispatch_contexts dc + INNER JOIN ( + SELECT task_id, MAX(rowid) AS max_rowid + FROM dispatch_contexts + WHERE status IN ('pending', 'dispatched') + GROUP BY task_id + ) latest ON latest.task_id = dc.task_id AND latest.max_rowid = dc.rowid + ) d ON d.task_id = t.id + ${where} + ORDER BY t.created_at + ` + return this.db.prepare(sql).all(...params) as (TaskRow & { + assignee_handle: string | null + dispatch_id: string | null + })[] + } + + updateTaskStatus(id: string, status: TaskStatus, result?: string): TaskRow | undefined { + const completedAt = + status === 'completed' || status === 'failed' ? new Date().toISOString() : null + this.db + .prepare( + 'UPDATE tasks SET status = ?, result = COALESCE(?, result), completed_at = COALESCE(?, completed_at) WHERE id = ?' + ) + .run(status, result ?? null, completedAt, id) + + if (status === 'completed') { + this.promoteReadyTasks(id) + this.completeActiveDispatchForTask(id) + } + + return this.getTask(id) + } + + // Why: when a task completes, check if any pending tasks that depended on it + // now have all deps satisfied. If so, promote them to 'ready'. This is the + // DAG resolution step — it runs synchronously inside the same transaction as + // the status update, so there's no window where a task is completable but its + // children haven't been promoted. + private promoteReadyTasks(completedTaskId: string): void { + const candidates = this.db + .prepare("SELECT * FROM tasks WHERE status = 'pending'") + .all() as TaskRow[] + + for (const task of candidates) { + const deps: string[] = JSON.parse(task.deps) + if (!deps.includes(completedTaskId)) { + continue + } + + const allDepsCompleted = deps.every((depId) => { + const dep = this.getTask(depId) + return dep?.status === 'completed' + }) + if (allDepsCompleted) { + this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + } + } + } + + // ── Dispatch Contexts ── + + createDispatchContext(taskId: string, assigneeHandle: string): DispatchContextRow { + const task = this.getTask(taskId) + if (!task) { + throw new Error(`Task not found: ${taskId}`) + } + if (task.status !== 'ready') { + throw new Error(`Task ${taskId} is ${task.status}; only ready tasks can be dispatched`) + } + + const existing = this.db + .prepare( + "SELECT * FROM dispatch_contexts WHERE assignee_handle = ? AND status IN ('pending', 'dispatched')" + ) + .get(assigneeHandle) as DispatchContextRow | undefined + + if (existing) { + throw new Error( + `Terminal ${assigneeHandle} already has an active dispatch (${existing.id} for task ${existing.task_id})` + ) + } + + // Carry forward failure_count from prior contexts so the circuit breaker + // accumulates across retries for the same task. + const prior = this.db + .prepare('SELECT MAX(failure_count) as max_failures FROM dispatch_contexts WHERE task_id = ?') + .get(taskId) as { max_failures: number | null } | undefined + const priorFailures = prior?.max_failures ?? 0 + + const id = generateId('ctx') + this.db + .prepare( + `INSERT INTO dispatch_contexts (id, task_id, assignee_handle, status, failure_count, dispatched_at) + VALUES (?, ?, ?, 'dispatched', ?, datetime('now'))` + ) + .run(id, taskId, assigneeHandle, priorFailures) + + this.db.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ?").run(taskId) + + return this.db + .prepare('SELECT * FROM dispatch_contexts WHERE id = ?') + .get(id) as DispatchContextRow + } + + getDispatchContext(taskId: string): DispatchContextRow | undefined { + return this.db + .prepare('SELECT * FROM dispatch_contexts WHERE task_id = ? ORDER BY rowid DESC LIMIT 1') + .get(taskId) as DispatchContextRow | undefined + } + + getActiveDispatchForTerminal(handle: string): DispatchContextRow | undefined { + return this.db + .prepare( + "SELECT * FROM dispatch_contexts WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') LIMIT 1" + ) + .get(handle) as DispatchContextRow | undefined + } + + completeDispatch(ctxId: string): void { + this.db + .prepare( + "UPDATE dispatch_contexts SET status = 'completed', completed_at = datetime('now') WHERE id = ?" + ) + .run(ctxId) + } + + completeActiveDispatchForTask(taskId: string): void { + const active = this.db + .prepare( + "SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1" + ) + .get(taskId) as DispatchContextRow | undefined + if (active) { + this.completeDispatch(active.id) + } + } + + failActiveDispatchForTask(taskId: string, error: string): DispatchContextRow | undefined { + const active = this.db + .prepare( + "SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1" + ) + .get(taskId) as DispatchContextRow | undefined + return active ? this.failDispatch(active.id, error) : undefined + } + + // Why: only touch rows that are currently dispatched. A straggler heartbeat + // from a dispatch that already transitioned to `completed` / `failed` / + // `circuit_broken` MUST NOT retroactively bump `last_heartbeat_at`, because + // the stale-dispatch detector is the signal the coordinator uses to know a + // newer dispatch for the same task has hung. Silently no-op'ing keeps the + // zombie-heartbeat race from masking a hung retry (§5.3.4). + recordHeartbeat(dispatchId: string, at: string): void { + this.db + .prepare( + "UPDATE dispatch_contexts SET last_heartbeat_at = ? WHERE id = ? AND status = 'dispatched'" + ) + .run(at, dispatchId) + } + + // Why: the query restricts to currently-dispatched contexts AND respects a + // dispatched-at grace. Without `status = 'dispatched'`, every completed / + // failed / circuit_broken row with an old-or-null last_heartbeat_at would + // warn every tick (warning storm). Without `dispatched_at < :threshold`, + // a freshly-dispatched worker would trip the warning during its first + // heartbeat interval (false positive). Callers supply the threshold as an + // ISO timestamp so the SQLite string-compare ordering works correctly + // (ISO-8601 compares lexicographically in time order). + getStaleDispatches(thresholdIso: string): DispatchContextRow[] { + return this.db + .prepare( + `SELECT * FROM dispatch_contexts + WHERE status = 'dispatched' + AND dispatched_at IS NOT NULL + AND dispatched_at < ? + AND (last_heartbeat_at IS NULL OR last_heartbeat_at < ?)` + ) + .all(thresholdIso, thresholdIso) as DispatchContextRow[] + } + + failDispatch(ctxId: string, error: string): DispatchContextRow | undefined { + const ctx = this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as + | DispatchContextRow + | undefined + if (!ctx) { + return undefined + } + + const newFailureCount = ctx.failure_count + 1 + const newStatus: DispatchStatus = newFailureCount >= 3 ? 'circuit_broken' : 'failed' + + this.db + .prepare( + 'UPDATE dispatch_contexts SET status = ?, failure_count = ?, last_failure = ? WHERE id = ?' + ) + .run(newStatus, newFailureCount, error, ctxId) + + // Why: set the task back to 'ready' (not 'pending') so the coordinator can + // re-dispatch it on the next tick. The task's deps are already satisfied — + // setting it to 'pending' would strand it since promoteReadyTasks only runs + // when a dep completes. + const taskStatus: TaskStatus = newStatus === 'circuit_broken' ? 'failed' : 'ready' + this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(taskStatus, ctx.task_id) + + return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as + | DispatchContextRow + | undefined + } + + // ── Decision Gates ── + + createGate(gate: { taskId: string; question: string; options?: string[] }): DecisionGateRow { + 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) + + this.completeActiveDispatchForTask(gate.taskId) + this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(gate.taskId) + + return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as DecisionGateRow + } + + resolveGate(gateId: string, resolution: string): DecisionGateRow | undefined { + const gate = this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as + | DecisionGateRow + | undefined + if (!gate) { + return undefined + } + + this.db + .prepare( + "UPDATE decision_gates SET status = 'resolved', resolution = ?, resolved_at = datetime('now') WHERE id = ?" + ) + .run(resolution, gateId) + + // Why: unblock the task so the coordinator can re-dispatch it with the + // resolution context. Setting to 'ready' rather than the previous status + // because the worker needs to be re-engaged with the decision outcome. + this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(gate.task_id) + + return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as + | DecisionGateRow + | undefined + } + + timeoutGate(gateId: string): DecisionGateRow | undefined { + this.db + .prepare( + "UPDATE decision_gates SET status = 'timeout', resolved_at = datetime('now') WHERE id = ?" + ) + .run(gateId) + return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as + | DecisionGateRow + | undefined + } + + listGates(filter?: { taskId?: string; status?: GateStatus }): DecisionGateRow[] { + if (filter?.taskId && filter?.status) { + return this.db + .prepare( + 'SELECT * FROM decision_gates WHERE task_id = ? AND status = ? ORDER BY created_at' + ) + .all(filter.taskId, filter.status) as DecisionGateRow[] + } + if (filter?.taskId) { + return this.db + .prepare('SELECT * FROM decision_gates WHERE task_id = ? ORDER BY created_at') + .all(filter.taskId) as DecisionGateRow[] + } + if (filter?.status) { + return this.db + .prepare('SELECT * FROM decision_gates WHERE status = ? ORDER BY created_at') + .all(filter.status) as DecisionGateRow[] + } + return this.db + .prepare('SELECT * FROM decision_gates ORDER BY created_at') + .all() as DecisionGateRow[] + } + + getGate(id: string): DecisionGateRow | undefined { + return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as + | DecisionGateRow + | undefined + } + + // ── Coordinator Runs ── + + createCoordinatorRun(run: { + spec: string + coordinatorHandle: string + pollIntervalMs?: number + }): CoordinatorRun { + const id = generateId('run') + this.db + .prepare( + "INSERT INTO coordinator_runs (id, spec, status, coordinator_handle, poll_interval_ms) VALUES (?, ?, 'running', ?, ?)" + ) + .run(id, run.spec, run.coordinatorHandle, run.pollIntervalMs ?? 2000) + return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as CoordinatorRun + } + + getCoordinatorRun(id: string): CoordinatorRun | undefined { + return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as + | CoordinatorRun + | undefined + } + + updateCoordinatorRun(id: string, status: CoordinatorStatus): CoordinatorRun | undefined { + const completedAt = + status === 'completed' || status === 'failed' ? new Date().toISOString() : null + this.db + .prepare( + 'UPDATE coordinator_runs SET status = ?, completed_at = COALESCE(?, completed_at) WHERE id = ?' + ) + .run(status, completedAt, id) + return this.getCoordinatorRun(id) + } + + getActiveCoordinatorRun(): CoordinatorRun | undefined { + return this.db + .prepare( + "SELECT * FROM coordinator_runs WHERE status = 'running' ORDER BY created_at DESC LIMIT 1" + ) + .get() as CoordinatorRun | undefined + } + + // ── Queries for Coordinator ── + + getIdleTerminals(excludeHandles: string[] = []): string[] { + // Why: returns terminal handles that have no active dispatch, so the + // coordinator knows which terminals are available for new task assignments. + const active = this.db + .prepare( + "SELECT DISTINCT assignee_handle FROM dispatch_contexts WHERE status IN ('pending', 'dispatched')" + ) + .all() as { assignee_handle: string }[] + const busyHandles = new Set(active.map((r) => r.assignee_handle)) + for (const h of excludeHandles) { + busyHandles.add(h) + } + // Return handles from message history that aren't busy + const allHandles = this.db + .prepare( + 'SELECT DISTINCT to_handle FROM messages UNION SELECT DISTINCT from_handle FROM messages' + ) + .all() as { to_handle: string }[] + return [...new Set(allHandles.map((r) => r.to_handle))].filter((h) => !busyHandles.has(h)) + } + + // ── Lifecycle ── + + 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') + } + + 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') + } + + resetMessages(): void { + this.db.exec('DELETE FROM messages') + } + + close(): void { + this.db.close() + } +} diff --git a/src/main/runtime/orchestration/formatter.test.ts b/src/main/runtime/orchestration/formatter.test.ts new file mode 100644 index 00000000000..28182e3dbfb --- /dev/null +++ b/src/main/runtime/orchestration/formatter.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { formatMessageBanner, formatMessagesForInjection } from './formatter' +import type { MessageRow } from './types' + +function makeMessage(overrides: Partial = {}): MessageRow { + return { + id: 'msg_test1', + from_handle: 'term_abc123', + to_handle: 'term_coord', + subject: 'Auth API implementation complete', + body: 'All endpoints implemented. Tests passing.', + type: 'worker_done', + priority: 'normal', + thread_id: null, + payload: null, + read: 0, + sequence: 1, + created_at: '2026-01-01T00:00:00Z', + delivered_at: null, + ...overrides + } +} + +describe('formatMessageBanner', () => { + it('formats a normal priority message without a priority tag', () => { + const banner = formatMessageBanner(makeMessage()) + expect(banner).toContain('From: TERM_ABC123 (term_abc123)') + expect(banner).toContain('(worker_done)') + expect(banner).not.toContain('[URGENT]') + expect(banner).not.toContain('[HIGH]') + }) + + it('includes [HIGH] tag for high priority', () => { + const banner = formatMessageBanner(makeMessage({ priority: 'high' })) + expect(banner).toContain('[HIGH]') + }) + + it('includes [URGENT] tag for urgent priority', () => { + const banner = formatMessageBanner(makeMessage({ priority: 'urgent' })) + expect(banner).toContain('[URGENT]') + }) + + it('includes body after subject', () => { + const banner = formatMessageBanner(makeMessage({ body: 'Some details here' })) + const lines = banner.split('\n') + const subjectIdx = lines.findIndex((l) => l.startsWith('Subject:')) + const bodyIdx = lines.indexOf('Some details here') + expect(subjectIdx).toBeGreaterThanOrEqual(0) + expect(bodyIdx).toBeGreaterThan(subjectIdx) + }) + + it('omits body line when body is empty', () => { + const banner = formatMessageBanner(makeMessage({ body: '' })) + const lines = banner.split('\n') + // Subject line should be immediately followed by Reply hint (no empty body line) + const subjectIdx = lines.findIndex((l) => l.startsWith('Subject:')) + expect(lines[subjectIdx + 1]).toMatch(/^\[Reply:/) + }) + + it('includes payload when present', () => { + const payload = '{"taskId":"task_1","exitCode":0}' + const banner = formatMessageBanner(makeMessage({ payload })) + expect(banner).toContain(`[Payload: ${payload}]`) + }) + + it('omits payload line when payload is null', () => { + const banner = formatMessageBanner(makeMessage({ payload: null })) + expect(banner).not.toContain('[Payload:') + }) + + it('includes reply hint with message ID', () => { + const banner = formatMessageBanner(makeMessage({ id: 'msg_xyz789' })) + expect(banner).toContain('[Reply: orca orchestration reply --id msg_xyz789 --body "..."]') + }) + + it('ends with a separator line', () => { + const banner = formatMessageBanner(makeMessage()) + const lines = banner.split('\n') + expect(lines.at(-1)).toMatch(/^─+$/) + }) +}) + +describe('formatMessagesForInjection', () => { + it('returns empty string for empty array', () => { + expect(formatMessagesForInjection([])).toBe('') + }) + + it('wraps multiple banners with orchestration messages header', () => { + const messages = [makeMessage({ id: 'msg_1' }), makeMessage({ id: 'msg_2' })] + const result = formatMessagesForInjection(messages) + expect(result).toContain('--- Orchestration Messages (2) ---') + expect(result).toContain('msg_1') + expect(result).toContain('msg_2') + expect(result).toMatch(/\n---\n$/) + }) + + it('separates multiple banners with blank lines', () => { + const messages = [makeMessage({ id: 'msg_a' }), makeMessage({ id: 'msg_b' })] + const result = formatMessagesForInjection(messages) + // Two banners should be separated by \n\n + const bannerA = formatMessageBanner(messages[0]) + const bannerB = formatMessageBanner(messages[1]) + expect(result).toContain(`${bannerA}\n\n${bannerB}`) + }) +}) diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts new file mode 100644 index 00000000000..f5526553d3b --- /dev/null +++ b/src/main/runtime/orchestration/formatter.ts @@ -0,0 +1,43 @@ +import type { MessageRow } from './types' + +const BANNER_WIDTH = 60 +const SEPARATOR = '─'.repeat(BANNER_WIDTH) + +// Why: rich message banners help agents (and humans reading terminal output) +// quickly parse message metadata. Priority indicators surface urgent messages +// visually. The reply hint reduces friction for agent-to-agent responses +// (Section 4.8). +export function formatMessageBanner(msg: MessageRow): string { + const priorityTag = + msg.priority === 'urgent' ? ' [URGENT]' : msg.priority === 'high' ? ' [HIGH]' : '' + const senderName = msg.from_handle.toUpperCase() + + const header = `──── From: ${senderName} (${msg.from_handle})${priorityTag} (${msg.type}) ────` + + const lines: string[] = [header] + lines.push(`Subject: ${msg.subject}`) + + if (msg.body) { + lines.push(msg.body) + } + + if (msg.payload) { + lines.push(`[Payload: ${msg.payload}]`) + } + + lines.push(`[Reply: orca orchestration reply --id ${msg.id} --body "..."]`) + lines.push(SEPARATOR) + + return lines.join('\n') +} + +// Why: grouping multiple banners under a single wrapper line lets agents detect +// the message block boundary and parse each banner individually. +export function formatMessagesForInjection(messages: MessageRow[]): string { + if (messages.length === 0) { + return '' + } + + const banners = messages.map(formatMessageBanner).join('\n\n') + return `\n--- Orchestration Messages (${messages.length}) ---\n${banners}\n---\n` +} diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts new file mode 100644 index 00000000000..61794da9eb1 --- /dev/null +++ b/src/main/runtime/orchestration/groups.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { isGroupAddress, resolveGroupAddress } from './groups' +import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' + +function makeSummary( + handle: string, + opts: Partial = {} +): RuntimeTerminalSummary { + return { + handle, + worktreeId: opts.worktreeId ?? 'wt_default', + worktreePath: opts.worktreePath ?? '/tmp/wt', + branch: opts.branch ?? 'main', + tabId: opts.tabId ?? 'tab_1', + leafId: opts.leafId ?? handle, + title: opts.title ?? null, + connected: opts.connected ?? true, + writable: opts.writable ?? true, + lastOutputAt: opts.lastOutputAt ?? null, + preview: opts.preview ?? '' + } +} + +const noStatus = () => null + +describe('isGroupAddress', () => { + it('returns true for @-prefixed addresses', () => { + expect(isGroupAddress('@all')).toBe(true) + expect(isGroupAddress('@idle')).toBe(true) + expect(isGroupAddress('@claude')).toBe(true) + expect(isGroupAddress('@worktree:wt_1')).toBe(true) + }) + + it('returns false for regular handles', () => { + expect(isGroupAddress('term_abc')).toBe(false) + expect(isGroupAddress('coordinator')).toBe(false) + expect(isGroupAddress('')).toBe(false) + }) +}) + +describe('resolveGroupAddress', () => { + it('returns the address as-is for non-group addresses', () => { + const result = resolveGroupAddress('term_b', 'term_a', [], noStatus) + expect(result).toEqual(['term_b']) + }) + + describe('@all', () => { + it('returns all terminals except sender', () => { + const terminals = [makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')] + const result = resolveGroupAddress('@all', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b', 'term_c']) + }) + + it('returns empty when sender is the only terminal', () => { + const terminals = [makeSummary('term_a')] + const result = resolveGroupAddress('@all', 'term_a', terminals, noStatus) + expect(result).toEqual([]) + }) + }) + + describe('@idle', () => { + it('returns only idle terminals', () => { + const terminals = [makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')] + const getStatus = (h: string) => (h === 'term_b' ? 'idle' : 'busy') + const result = resolveGroupAddress('@idle', 'term_a', terminals, getStatus) + expect(result).toEqual(['term_b']) + }) + + it('excludes sender even if idle', () => { + const terminals = [makeSummary('term_a'), makeSummary('term_b')] + const getStatus = () => 'idle' + const result = resolveGroupAddress('@idle', 'term_a', terminals, getStatus) + expect(result).toEqual(['term_b']) + }) + }) + + describe('@worktree:', () => { + it('returns terminals in the specified worktree', () => { + const terminals = [ + makeSummary('term_a', { worktreeId: 'wt_1' }), + makeSummary('term_b', { worktreeId: 'wt_1' }), + makeSummary('term_c', { worktreeId: 'wt_2' }) + ] + const result = resolveGroupAddress('@worktree:wt_1', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b']) + }) + + it('returns empty for nonexistent worktree', () => { + const terminals = [makeSummary('term_a', { worktreeId: 'wt_1' })] + const result = resolveGroupAddress('@worktree:wt_99', 'term_a', terminals, noStatus) + expect(result).toEqual([]) + }) + }) + + describe('agent name groups', () => { + it('matches @claude by terminal title', () => { + const terminals = [ + makeSummary('term_a', { title: 'Claude Code' }), + makeSummary('term_b', { title: 'Claude Code' }), + makeSummary('term_c', { title: 'Codex CLI' }) + ] + const result = resolveGroupAddress('@claude', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b']) + }) + + it('matches @codex by terminal title', () => { + const terminals = [ + makeSummary('term_a', { title: 'Codex CLI' }), + makeSummary('term_b', { title: 'Codex CLI' }) + ] + const result = resolveGroupAddress('@codex', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b']) + }) + + it('is case-insensitive for group address', () => { + const terminals = [makeSummary('term_a'), makeSummary('term_b', { title: 'Claude Code' })] + const result = resolveGroupAddress('@Claude', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b']) + }) + }) + + describe('unknown groups', () => { + it('returns empty for unrecognized group', () => { + const terminals = [makeSummary('term_a'), makeSummary('term_b')] + const result = resolveGroupAddress('@unknown', 'term_a', terminals, noStatus) + expect(result).toEqual([]) + }) + }) +}) diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts new file mode 100644 index 00000000000..8c94759250b --- /dev/null +++ b/src/main/runtime/orchestration/groups.ts @@ -0,0 +1,71 @@ +import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' + +// Why: group addresses enable broadcast messaging to logical groups of agents. +// Resolution is done at send-time: one message record per recipient, same thread_id, +// so each recipient gets their own read-tracking (Section 4.5). + +const AGENT_NAME_GROUPS = ['claude', 'codex', 'opencode', 'gemini'] as const + +export type GroupAddress = + | '@all' + | '@idle' + | `@${(typeof AGENT_NAME_GROUPS)[number]}` + | `@worktree:${string}` + +export function isGroupAddress(to: string): boolean { + return to.startsWith('@') +} + +export function resolveGroupAddress( + to: string, + senderHandle: string, + terminals: RuntimeTerminalSummary[], + getAgentStatus: (handle: string) => string | null +): string[] { + if (!isGroupAddress(to)) { + return [to] + } + + const group = to.toLowerCase() + + if (group === '@all') { + // Why: @all broadcasts to every terminal except the sender to avoid self-delivery loops. + return terminals.map((t) => t.handle).filter((h) => h !== senderHandle) + } + + if (group === '@idle') { + // Why: @idle targets only agents whose TUI reports idle status, useful for + // dispatching work to available agents without interrupting busy ones. + return terminals + .filter((t) => t.handle !== senderHandle && getAgentStatus(t.handle) === 'idle') + .map((t) => t.handle) + } + + // @worktree: — all handles in a specific worktree + if (group.startsWith('@worktree:')) { + const worktreeId = to.slice('@worktree:'.length) + return terminals + .filter((t) => t.handle !== senderHandle && t.worktreeId === worktreeId) + .map((t) => t.handle) + } + + // Why: agent-name groups (@claude, @codex, etc.) match by terminal title so + // the sender can address all instances of a particular agent type without + // knowing their handles. + const agentName = group.slice(1) // remove @ + if ((AGENT_NAME_GROUPS as readonly string[]).includes(agentName)) { + return terminals + .filter((t) => { + if (t.handle === senderHandle) { + return false + } + const title = (t.title ?? '').toLowerCase() + return title.includes(agentName) + }) + .map((t) => t.handle) + } + + // Why: unknown groups resolve to empty rather than throwing so callers can + // distinguish "valid group, no current members" from programming errors. + return [] +} diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts new file mode 100644 index 00000000000..aeb51e4e9e4 --- /dev/null +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -0,0 +1,211 @@ +import { spawnSync } from 'child_process' +import { describe, expect, it } from 'vitest' +import { buildDispatchPreamble } from './preamble' + +function baseParams(overrides: Partial[0]> = {}) { + return { + taskId: 'task_abc123', + dispatchId: 'ctx_def456', + taskSpec: 'Implement the login form', + coordinatorHandle: 'term_coord', + ...overrides + } +} + +describe('buildDispatchPreamble', () => { + it('substitutes template variables', () => { + const result = buildDispatchPreamble(baseParams()) + + expect(result).toContain('task_abc123') + expect(result).toContain('ctx_def456') + expect(result).toContain('term_coord') + expect(result).toContain('Implement the login form') + expect(result).not.toContain('{{') + }) + + it('includes worker_done command with --body 3-sentence summary prompt and reportPath', () => { + const result = buildDispatchPreamble(baseParams()) + + expect(result).toContain('worker_done') + expect(result).toContain('orchestration send') + expect(result).toContain('orchestration check') + expect(result).toContain('--body') + expect(result).toMatch(/3-sentence summary/) + expect(result).toContain('reportPath') + }) + + it('CLI examples parse as valid shell (bash -n on the extracted block)', () => { + const result = buildDispatchPreamble(baseParams()) + // Why: feeding `bash -n` the full preamble falsely fails on apostrophes + // in the surrounding prose. Slice between the CLI markers and strip + // shell-style comment lines so we only syntax-check the commands. + const cliStart = result.indexOf('=== CLI COMMANDS ===') + const cliEnd = result.indexOf('=== AFTER YOU SEND worker_done ===') + expect(cliStart).toBeGreaterThan(-1) + expect(cliEnd).toBeGreaterThan(cliStart) + const block = result.slice(cliStart, cliEnd) + const stripped = block + .split('\n') + .filter((line) => !line.trim().startsWith('#')) + .filter((line) => !line.trim().startsWith('===')) + .join('\n') + + const check = spawnSync('bash', ['-n'], { input: stripped, encoding: 'utf8' }) + expect(check.status).toBe(0) + }) + + it('includes heartbeat CLI block with taskId and dispatchId and 5-minute cadence', () => { + const result = buildDispatchPreamble(baseParams()) + expect(result).toContain('--type heartbeat') + expect(result).toContain('--subject "alive"') + expect(result).toMatch(/5 minutes/) + // Both taskId and dispatchId are rendered inside the payload template + // (regression guard for §5.3.4 attribution — dispatchId attribution + // prevents the zombie-heartbeat-masks-hung-retry race). + const heartbeatLine = result + .split('\n') + .find((line) => line.includes('"taskId"') && line.includes('dispatchId')) + expect(heartbeatLine).toBeTruthy() + expect(heartbeatLine).toContain('task_abc123') + expect(heartbeatLine).toContain('ctx_def456') + }) + + it('includes ask block with BEHAVIOR RULE #1 forbidding AskUserQuestion', () => { + const result = buildDispatchPreamble(baseParams()) + expect(result).toContain('orchestration ask') + expect(result).toContain('--question') + expect(result).toContain('--timeout-ms 600000') + // Why: the exact phrase is asserted so the rule can't be trimmed away by + // accident. BEHAVIOR RULE #1 is the only place AskUserQuestion appears. + expect(result).toContain('BEHAVIOR RULE #1') + expect(result).toContain('NEVER use AskUserQuestion') + // AskUserQuestion must appear ONLY inside the rule text, not anywhere + // else (e.g., not in an example payload or header). Count occurrences + // of the exact token as a sanity check. + const occurrences = (result.match(/AskUserQuestion/g) ?? []).length + // Three mentions: the one-liner ban, the TUI-prompt rationale, and the + // "when tempted to reach for AskUserQuestion" closing line. + expect(occurrences).toBe(3) + }) + + it('includes AFTER YOU SEND block with 2-minute poll cadence and release signal', () => { + const result = buildDispatchPreamble(baseParams()) + expect(result).toContain('=== AFTER YOU SEND worker_done ===') + expect(result).toMatch(/2 minutes/) + expect(result).toMatch(/may exit/) + }) + + it('uses === TASK === separator with the task spec appended', () => { + const result = buildDispatchPreamble(baseParams({ taskSpec: 'refactor the auth module' })) + expect(result).toContain('=== TASK ===') + expect(result).toContain('refactor the auth module') + }) + + it('uses orca CLI by default when devMode is not set', () => { + const result = buildDispatchPreamble(baseParams()) + expect(result).toContain('orca orchestration send') + expect(result).toContain('orca orchestration check') + expect(result).toContain('orca orchestration ask') + }) + + it('uses orca-dev CLI when devMode is true', () => { + const result = buildDispatchPreamble(baseParams({ devMode: true })) + expect(result).toContain('orca-dev orchestration send') + expect(result).toContain('orca-dev orchestration check') + expect(result).toContain('orca-dev orchestration ask') + const fragments = result.split('orca-dev') + for (const fragment of fragments) { + expect(fragment).not.toMatch(/orca orchestration/) + } + }) + + it('uses orca CLI when devMode is false', () => { + const result = buildDispatchPreamble(baseParams({ devMode: false })) + expect(result).toContain('orca orchestration send') + expect(result).toContain('orca orchestration check') + }) + + it('appends a BASE DRIFT section when baseDrift.behind > 0', () => { + const result = buildDispatchPreamble({ + taskId: 'task_x', + dispatchId: 'ctx_x', + taskSpec: 'do stuff', + coordinatorHandle: 'term_c', + baseDrift: { + base: 'origin/main', + behind: 7, + recentSubjects: ['fix: A', 'feat: B', 'chore: C'] + } + }) + + expect(result).toContain('--- BASE DRIFT ---') + expect(result).toContain('7 commits behind origin/main') + expect(result).toContain(' - fix: A') + expect(result).toContain(' - feat: B') + expect(result).toContain(' - chore: C') + // drift section must appear before the task spec + expect(result.indexOf('--- BASE DRIFT ---')).toBeLessThan(result.indexOf('=== TASK ===')) + }) + + it('omits the drift section when baseDrift.behind is 0', () => { + const result = buildDispatchPreamble({ + taskId: 'task_x', + dispatchId: 'ctx_x', + taskSpec: 'do stuff', + coordinatorHandle: 'term_c', + baseDrift: { + base: 'origin/main', + behind: 0, + recentSubjects: [] + } + }) + + expect(result).not.toContain('--- BASE DRIFT ---') + expect(result).not.toContain('commits behind') + }) + + it('omits the drift section when baseDrift is undefined', () => { + const result = buildDispatchPreamble({ + taskId: 'task_x', + dispatchId: 'ctx_x', + taskSpec: 'do stuff', + coordinatorHandle: 'term_c' + }) + + expect(result).not.toContain('--- BASE DRIFT ---') + expect(result).not.toContain('commits behind') + }) + + it('lists drift subjects in the order provided, each prefixed with two spaces and dash', () => { + const result = buildDispatchPreamble({ + taskId: 'task_x', + dispatchId: 'ctx_x', + taskSpec: 'do stuff', + coordinatorHandle: 'term_c', + baseDrift: { + base: 'origin/main', + behind: 3, + recentSubjects: ['first', 'second', 'third'] + } + }) + + const firstIdx = result.indexOf(' - first') + const secondIdx = result.indexOf(' - second') + const thirdIdx = result.indexOf(' - third') + expect(firstIdx).toBeGreaterThanOrEqual(0) + expect(secondIdx).toBeGreaterThan(firstIdx) + expect(thirdIdx).toBeGreaterThan(secondIdx) + }) + + it('renders a stable snapshot of the full preamble', () => { + // Why: single strict snapshot catches any accidental regression in + // formatting or rule presence in one line. + const result = buildDispatchPreamble({ + taskId: 'task_SNAP', + dispatchId: 'ctx_SNAP', + taskSpec: 'TASK_BODY', + coordinatorHandle: 'term_COORD' + }) + expect(result).toMatchSnapshot() + }) +}) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts new file mode 100644 index 00000000000..6223dc94aa8 --- /dev/null +++ b/src/main/runtime/orchestration/preamble.ts @@ -0,0 +1,147 @@ +export type PreambleParams = { + taskId: string + // Why: the heartbeat payload attributes liveness to a specific dispatch + // context (not just a task). A retried task has multiple dispatch_contexts + // rows; keying heartbeats on dispatchId prevents a straggler heartbeat from + // a previously-failed dispatch from masking a hung retry. Both call sites + // already have this value in scope (coordinator.ts dispatch.id, rpc + // orchestration.ts ctx.id). + dispatchId: string + taskSpec: string + coordinatorHandle: string + devMode?: boolean + // Why: populated by the coordinator's dispatch pre-flight (§3.1) only + // when the target worktree is behind its tracking remote. When absent + // or when `behind === 0`, the preamble emits no drift section. Callers + // must NOT pre-populate this with empty data; the drift section is a + // loud-but-rare signal tied to the `allow-stale-base: true` override + // path, and polluting it for fresh worktrees would train workers to + // ignore it. + baseDrift?: { + base: string + behind: number + recentSubjects: string[] + } +} + +// Why: 5 minutes is frequent enough that the coordinator's stale-heartbeat +// check (threshold 10 min) catches a hung worker within one tick, and +// infrequent enough to avoid inbox spam on long tasks. One constant so +// cadence tuning is a single-line change (Q1 in DESIGN_DOC_PREAMBLE_FIX.md). +const HEARTBEAT_INTERVAL_MIN = 5 + +// Why: the dispatch preamble teaches agents about Orca's CLI commands for +// structured communication. Behavioral rules (body summary, heartbeat cadence, +// no-AskUserQuestion) live as inline comments above the relevant CLI example, +// not as a separate prose block — LLM readers anchor on examples and skim +// trailing prose, so rules must land at the point of use. +export function buildDispatchPreamble(params: PreambleParams): string { + // Why: in dev mode, agents must use orca-dev to connect to the dev runtime's + // socket. Without this, agents inside the dev Electron app would call the + // production CLI and talk to the wrong Orca instance (Section 6.4). + const cli = params.devMode ? 'orca-dev' : 'orca' + + const header = `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your coordinator's terminal handle is: ${params.coordinatorHandle} +Your task ID is: ${params.taskId} + +You talk to the coordinator only through the CLI commands below. Do not use +Slack, GitHub comments, or any other channel to reach a human during the run. + +=== CLI COMMANDS === + + # Report task completion (REQUIRED when done — even on failure). + # + # RULE: --body must be a 3-sentence executive summary (what you did, + # what you found, what's left). Never send an empty body; the coordinator + # reads the body first and only opens artifacts if it needs more detail. + # If you produced a long-form artifact, include its path as + # payload.reportPath so the coordinator can find it without a file search. + # + # RULE: send worker_done exactly once. Failure is still a worker_done + # with subject like "Failed: " — never silently exit. + ${cli} orchestration send --to ${params.coordinatorHandle} \\ + --type worker_done --subject "" \\ + --body "<3-sentence summary: what you did, what you found, what's left>" \\ + --payload '{"taskId":"${params.taskId}","filesModified":["path/a","path/b"],"reportPath":""}' + + # BEHAVIOR RULE: send a heartbeat every ${HEARTBEAT_INTERVAL_MIN} minutes + # while actively working on the task. The coordinator uses this to + # distinguish "still thinking" from "hung / crashed." Skip heartbeats only + # while blocked inside \`check --wait\` or \`ask\` — those calls are + # themselves liveness signals. + # + # Include BOTH taskId and dispatchId in the payload: the coordinator + # attributes the heartbeat to the specific dispatch context, not just + # the task, so a straggler heartbeat from a previously-failed dispatch + # cannot mask a hung retry. + ${cli} orchestration send --to ${params.coordinatorHandle} \\ + --type heartbeat --subject "alive" \\ + --payload '{"taskId":"${params.taskId}","dispatchId":"${params.dispatchId}","phase":""}' + + # Ask the coordinator a question and block until it answers. + # + # BEHAVIOR RULE #1 (MUST NOT VIOLATE): + # NEVER use AskUserQuestion; use \`${cli} orchestration ask\` or send + # --type decision_gate. AskUserQuestion opens a local TUI prompt that the + # coordinator cannot see and cannot answer — your session will hang forever + # waiting on a human. Every interactive question goes through \`ask\` below. + # + # The \`ask\` verb is a thin wrapper: it sends a decision_gate message and + # blocks on \`check --wait\` until the coordinator replies, then prints the + # reply body. Use it anywhere you would otherwise have reached for + # AskUserQuestion. + ${cli} orchestration ask --to ${params.coordinatorHandle} \\ + --question "" \\ + --options "" \\ + --timeout-ms 600000 + + # Escalate a blocker or failure (pre-completion, when you need the + # coordinator to do something before you can continue): + ${cli} orchestration send --to ${params.coordinatorHandle} \\ + --type escalation --subject "Blocked: " \\ + --body "
" \\ + --payload '{"taskId":"${params.taskId}"}' + + # Check for messages from the coordinator: + ${cli} orchestration check + +=== AFTER YOU SEND worker_done === + +Keep the shell session open for a grace period (10 minutes) in case the +coordinator sends a follow-up or re-dispatches you. Poll with +\`${cli} orchestration check\` every 2 minutes during that window. If no +follow-up arrives, you may exit after the grace period — the coordinator +will not expect further output from you. + +If the coordinator re-dispatches you (you will receive a fresh preamble + +TASK block), reset your polling and start the new task. Do not respond +to the previous task's follow-ups after a re-dispatch.` + + // Why: the drift section fires only when the coordinator allowed dispatch + // against a stale worktree (via `allow-stale-base: true` in the task spec, + // see §3.4) OR when behind>0 but under the refusal threshold. Either way + // it is defense-in-depth: the worker sees the drift from line 1 instead + // of discovering it via stale line numbers in artifacts later. + const drift = + params.baseDrift && params.baseDrift.behind > 0 ? buildDriftSection(params.baseDrift) : '' + + return `${header}${drift} + +=== TASK === +${params.taskSpec}` +} + +function buildDriftSection(drift: NonNullable): string { + const subjects = drift.recentSubjects.map((s) => ` - ${s}`).join('\n') + return ` + +--- BASE DRIFT --- +Your worktree HEAD is ${drift.behind} commits behind ${drift.base}. The 5 most recent +subjects on ${drift.base} NOT in your worktree: +${subjects} + +If any look relevant to your task, either pull them in (\`git pull --rebase +${drift.base}\` or equivalent) or escalate to the coordinator before starting. +---` +} diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts new file mode 100644 index 00000000000..03a31391cc9 --- /dev/null +++ b/src/main/runtime/orchestration/types.ts @@ -0,0 +1,80 @@ +export type MessageType = + | 'status' + | 'dispatch' + | 'worker_done' + | 'merge_ready' + | 'escalation' + | 'handoff' + | 'decision_gate' + | 'heartbeat' + +export type MessagePriority = 'normal' | 'high' | 'urgent' + +export type TaskStatus = 'pending' | 'ready' | 'dispatched' | 'completed' | 'failed' | 'blocked' + +export type DispatchStatus = 'pending' | 'dispatched' | 'completed' | 'failed' | 'circuit_broken' + +export type GateStatus = 'pending' | 'resolved' | 'timeout' + +export type CoordinatorStatus = 'idle' | 'running' | 'completed' | 'failed' + +export type MessageRow = { + id: string + from_handle: string + to_handle: string + subject: string + body: string + type: MessageType + priority: MessagePriority + thread_id: string | null + payload: string | null + read: number + sequence: number + created_at: string + delivered_at: string | null +} + +export type TaskRow = { + id: string + parent_id: string | null + spec: string + status: TaskStatus + deps: string + result: string | null + created_at: string + completed_at: string | null +} + +export type DispatchContextRow = { + id: string + task_id: string + assignee_handle: string | null + status: DispatchStatus + failure_count: number + last_failure: string | null + dispatched_at: string | null + completed_at: string | null + created_at: string + last_heartbeat_at: string | null +} + +export type DecisionGateRow = { + id: string + task_id: string + question: string + options: string + status: GateStatus + resolution: string | null + created_at: string + resolved_at: string | null +} + +export type CoordinatorRun = { + id: string + spec: string + status: CoordinatorStatus + coordinator_handle: string + poll_interval_ms: number + created_at: string + completed_at: string | null +} diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 9b4bae3d4e3..0340561938e 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -13,6 +13,7 @@ export type RpcSuccess = { id: string ok: true result: unknown + streaming?: true _meta: RpcEnvelopeMeta } @@ -38,6 +39,19 @@ export type RpcRequest = { export type RpcContext = { runtime: OrcaRuntimeService + // Why: long-poll handlers (e.g. orchestration.check with wait=true) need to + // observe the underlying socket's lifetime so they can release their slot + // and resolve their inner waiters immediately when a client disconnects + // instead of running down the configured timeoutMs. Undefined outside the + // runtime-rpc transport (direct in-process callers don't need it). + // See design doc §3.1 counter-lifecycle. + signal?: AbortSignal + // Why: streaming handlers (notifications/accounts/terminal subscribe) + // register cleanup callbacks against the runtime so reconnects don't leak + // listeners. Keying those cleanups by per-WebSocket connectionId lets the + // server reap all subscriptions for a closing socket, even when other + // sockets for the same deviceToken stay alive (multi-screen mobile). + connectionId?: string } export type RpcHandler = (params: TParams, ctx: RpcContext) => Promise | unknown @@ -69,10 +83,53 @@ export function defineMethod( } } -export type RpcRegistry = ReadonlyMap +export type RpcStreamingHandler = ( + params: TParams, + ctx: RpcContext, + emit: (result: unknown) => void +) => Promise -export function buildRegistry(methods: readonly RpcMethod[]): RpcRegistry { - const registry = new Map() +// Why: streaming methods emit multiple responses over a long-lived connection. +// The `stream` flag lets the dispatcher distinguish them from one-shot methods +// and route them to the emit-based call path instead of the Promise-based one. +export type RpcStreamingMethod = { + readonly name: string + readonly params: ZodType | null + readonly stream: true + readonly handler: ( + params: unknown, + ctx: RpcContext, + emit: (result: unknown) => void + ) => Promise +} + +type DefineStreamingMethodSpec = { + name: string + params: TSchema + handler: RpcStreamingHandler +} + +export function defineStreamingMethod( + spec: DefineStreamingMethodSpec +): RpcStreamingMethod { + return { + name: spec.name, + params: spec.params, + stream: true, + handler: spec.handler as RpcStreamingMethod['handler'] + } +} + +export type RpcAnyMethod = RpcMethod | RpcStreamingMethod + +export function isStreamingMethod(method: RpcAnyMethod): method is RpcStreamingMethod { + return 'stream' in method && method.stream === true +} + +export type RpcRegistry = ReadonlyMap + +export function buildRegistry(methods: readonly RpcAnyMethod[]): RpcRegistry { + const registry = new Map() for (const method of methods) { if (registry.has(method.name)) { throw new Error(`duplicate_rpc_method:${method.name}`) diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 105fba0bc55..bdd2f181b30 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -6,8 +6,9 @@ import { ZodError, buildRegistry, formatZodError, + isStreamingMethod, + type RpcAnyMethod, type RpcEnvelopeMeta, - type RpcMethod, type RpcRegistry, type RpcRequest, type RpcResponse @@ -18,7 +19,7 @@ import type { OrcaRuntimeService } from '../orca-runtime' export type DispatcherOptions = { runtime: OrcaRuntimeService - methods?: readonly RpcMethod[] + methods?: readonly RpcAnyMethod[] } export class RpcDispatcher { @@ -30,7 +31,7 @@ export class RpcDispatcher { this.registry = buildRegistry(methods) } - async dispatch(request: RpcRequest): Promise { + async dispatch(request: RpcRequest, options?: { signal?: AbortSignal }): Promise { const meta = this.meta() const method = this.registry.get(request.method) if (!method) { @@ -42,36 +43,121 @@ export class RpcDispatcher { ) } - let parsedParams: unknown - if (method.params === null) { - parsedParams = undefined - } else { - const rawParams = request.params ?? {} - const result = method.params.safeParse(rawParams) - if (!result.success) { - return errorResponse(request.id, meta, 'invalid_argument', formatZodError(result.error)) - } - parsedParams = result.data + const parsedParams = this.parseParams(request, method, meta) + if (parsedParams.error) { + return parsedParams.error + } + + // Why: streaming methods are not supported over one-shot transports like + // Unix sockets. They require a reply function that can be called multiple + // times, which is only available via dispatchStreaming. + if (isStreamingMethod(method)) { + return errorResponse( + request.id, + meta, + 'method_not_supported', + `Method ${request.method} requires a streaming transport` + ) } try { - const result = await method.handler(parsedParams, { runtime: this.runtime }) + const result = await method.handler(parsedParams.value, { + runtime: this.runtime, + signal: options?.signal + }) return successResponse(request.id, meta, result) } catch (error) { - // Why: browser methods throw BrowserError with a structured `code`; - // every other runtime error has a plain-message code. Routing by method - // prefix keeps the mapping a single decision rather than a per-method - // flag callers must remember to set. - if (request.method.startsWith('browser.')) { - return mapBrowserError(request.id, meta, error) - } - if (error instanceof ZodError) { - return errorResponse(request.id, meta, 'invalid_argument', formatZodError(error)) - } - return mapRuntimeError(request.id, meta, error) + return this.mapError(request, meta, error) } } + // Why: streaming dispatch sends multiple responses through the reply callback + // instead of returning a single Promise. This enables terminal.subscribe and + // other subscription-style methods that push data over time. + async dispatchStreaming( + request: RpcRequest, + reply: (response: string) => void, + options?: { connectionId?: string } + ): Promise { + const meta = this.meta() + const method = this.registry.get(request.method) + if (!method) { + reply( + JSON.stringify( + errorResponse(request.id, meta, 'method_not_found', `Unknown method: ${request.method}`) + ) + ) + return + } + + const parsedParams = this.parseParams(request, method, meta) + if (parsedParams.error) { + reply(JSON.stringify(parsedParams.error)) + return + } + + if (!isStreamingMethod(method)) { + try { + const result = await method.handler(parsedParams.value, { + runtime: this.runtime, + connectionId: options?.connectionId + }) + reply(JSON.stringify(successResponse(request.id, meta, result))) + } catch (error) { + reply(JSON.stringify(this.mapError(request, meta, error))) + } + return + } + + const emit = (result: unknown): void => { + const response = successResponse(request.id, meta, result) + response.streaming = true + reply(JSON.stringify(response)) + } + + try { + await method.handler( + parsedParams.value, + { runtime: this.runtime, connectionId: options?.connectionId }, + emit + ) + } catch (error) { + reply(JSON.stringify(this.mapError(request, meta, error))) + } + } + + private parseParams( + request: RpcRequest, + method: RpcAnyMethod, + meta: RpcEnvelopeMeta + ): { value: unknown; error?: undefined } | { value?: undefined; error: RpcResponse } { + if (method.params === null) { + return { value: undefined } + } + const rawParams = request.params ?? {} + const result = method.params.safeParse(rawParams) + if (!result.success) { + return { + error: errorResponse(request.id, meta, 'invalid_argument', formatZodError(result.error)) + } + } + return { value: result.data } + } + + private mapError(request: RpcRequest, meta: RpcEnvelopeMeta, error: unknown): RpcResponse { + // Why: browser methods throw BrowserError with a structured `code`; + // every other runtime error has a plain-message code. Routing by method + // prefix keeps the mapping a single decision rather than a per-method + // flag callers must remember to set. + if (request.method.startsWith('browser.')) { + return mapBrowserError(request.id, meta, error) + } + if (error instanceof ZodError) { + return errorResponse(request.id, meta, 'invalid_argument', formatZodError(error)) + } + return mapRuntimeError(request.id, meta, error) + } + private meta(): RpcEnvelopeMeta { return { runtimeId: this.runtime.getRuntimeId() } } diff --git a/src/main/runtime/rpc/e2ee-channel.test.ts b/src/main/runtime/rpc/e2ee-channel.test.ts new file mode 100644 index 00000000000..69b242c4ca8 --- /dev/null +++ b/src/main/runtime/rpc/e2ee-channel.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type { WebSocket } from 'ws' +import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel' +import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +function publicKeyToBase64(key: Uint8Array): string { + return Buffer.from(key).toString('base64') +} + +function createMockWs() { + const sent: string[] = [] + return { + OPEN: 1 as const, + readyState: 1, + send: vi.fn((data: string) => sent.push(data)), + close: vi.fn(), + sent + } +} + +function setup(overrides?: Partial) { + const serverKeys = generateKeyPair() + const clientKeys = generateKeyPair() + const ws = createMockWs() + const onReady = vi.fn() + const onError = vi.fn() + + const channel = new E2EEChannel(ws as unknown as WebSocket, { + serverSecretKey: serverKeys.secretKey, + validateToken: (token) => token === 'valid-token', + onReady, + onError, + ...overrides + }) + + return { channel, ws, serverKeys, clientKeys, onReady, onError } +} + +function doHandshake(ctx: ReturnType) { + const hello = JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey) + }) + ctx.channel.handleRawMessage(hello) + const sharedKey = deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey) + ctx.channel.handleRawMessage( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), sharedKey) + ) + return sharedKey +} + +describe('E2EEChannel', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('handshake', () => { + it('completes handshake with valid encrypted auth', () => { + const ctx = setup() + doHandshake(ctx) + + expect(ctx.onReady).toHaveBeenCalledWith(ctx.channel) + expect(ctx.onError).not.toHaveBeenCalled() + expect(ctx.channel.deviceToken).toBe('valid-token') + + const readyMsg = JSON.parse(ctx.ws.sent[0]!) + expect(readyMsg).toEqual({ type: 'e2ee_ready' }) + const authMsg = decrypt( + ctx.ws.sent[1]!, + deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey) + ) + expect(JSON.parse(authMsg!)).toEqual({ type: 'e2ee_authenticated' }) + }) + + it('does not authenticate from plaintext hello alone', () => { + const ctx = setup() + ctx.channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey) + }) + ) + + expect(ctx.onReady).not.toHaveBeenCalled() + expect(JSON.parse(ctx.ws.sent[0]!)).toEqual({ type: 'e2ee_ready' }) + }) + + it('rejects invalid encrypted token', () => { + const ctx = setup() + ctx.channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey) + }) + ) + const sharedKey = deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey) + ctx.channel.handleRawMessage( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'bad-token' }), sharedKey) + ) + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Unauthorized') + expect(ctx.onReady).not.toHaveBeenCalled() + }) + + it('rejects malformed JSON', () => { + const ctx = setup() + ctx.channel.handleRawMessage('not json') + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid handshake message') + }) + + it('rejects missing fields', () => { + const ctx = setup() + ctx.channel.handleRawMessage(JSON.stringify({ type: 'e2ee_hello' })) + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid e2ee_hello') + }) + + it('rejects invalid public key length', () => { + const ctx = setup() + ctx.channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from('short').toString('base64') + }) + ) + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid public key') + }) + + it('times out if no hello received', () => { + const ctx = setup() + + vi.advanceTimersByTime(10_001) + + expect(ctx.onError).toHaveBeenCalledWith(4002, 'E2EE handshake timeout') + }) + + it('clears timeout after successful handshake', () => { + const ctx = setup() + doHandshake(ctx) + + vi.advanceTimersByTime(10_001) + + expect(ctx.onError).not.toHaveBeenCalled() + }) + }) + + describe('post-handshake messaging', () => { + it('decrypts and forwards messages', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + const received: string[] = [] + + ctx.channel.onMessage((plaintext) => { + received.push(plaintext) + }) + + const request = '{"id":"rpc-1","method":"status.get"}' + ctx.channel.handleRawMessage(encrypt(request, sharedKey)) + + expect(received).toEqual([request]) + }) + + it('provides encrypted reply function', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + + ctx.channel.onMessage((_plaintext, encryptedReply) => { + encryptedReply('{"id":"rpc-1","ok":true}') + }) + + ctx.channel.handleRawMessage(encrypt('{"id":"rpc-1","method":"status.get"}', sharedKey)) + + // The reply (ws.sent[2], after ready + authenticated) should be encrypted + const replyEncrypted = ctx.ws.sent[2]! + const replyPlain = decrypt(replyEncrypted, sharedKey) + expect(replyPlain).toBe('{"id":"rpc-1","ok":true}') + }) + + it('silently drops messages with wrong key', () => { + const ctx = setup() + doHandshake(ctx) + const received: string[] = [] + + ctx.channel.onMessage((plaintext) => { + received.push(plaintext) + }) + + const attackerKey = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + ctx.channel.handleRawMessage(encrypt('attack', attackerKey)) + + expect(received).toEqual([]) + }) + + it('closes after too many consecutive decrypt failures', () => { + const ctx = setup() + doHandshake(ctx) + ctx.channel.onMessage(() => {}) + + const badKey = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + + for (let i = 0; i < 4; i++) { + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + } + expect(ctx.onError).not.toHaveBeenCalled() + + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + expect(ctx.onError).toHaveBeenCalledWith(4003, 'Too many decryption failures') + }) + + it('resets failure count on successful decrypt', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + ctx.channel.onMessage(() => {}) + + const badKey = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + + for (let i = 0; i < 4; i++) { + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + } + + // Successful decrypt resets the counter + ctx.channel.handleRawMessage(encrypt('good', sharedKey)) + + for (let i = 0; i < 4; i++) { + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + } + expect(ctx.onError).not.toHaveBeenCalled() + }) + }) + + describe('cross-compatibility', () => { + it('desktop encrypt is decryptable by desktop decrypt (sanity)', () => { + const a = generateKeyPair() + const b = generateKeyPair() + const sharedA = deriveSharedKey(a.secretKey, b.publicKey) + const sharedB = deriveSharedKey(b.secretKey, a.publicKey) + + const msg = '{"method":"terminal.subscribe","params":{"terminal":"t1"}}' + const enc = encrypt(msg, sharedA) + expect(decrypt(enc, sharedB)).toBe(msg) + }) + }) + + describe('destroy', () => { + it('clears state and stops forwarding', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + const received: string[] = [] + + ctx.channel.onMessage((plaintext) => { + received.push(plaintext) + }) + + ctx.channel.destroy() + ctx.channel.handleRawMessage(encrypt('after destroy', sharedKey)) + + expect(received).toEqual([]) + }) + + // Why: streaming RPC handlers (terminal.subscribe) retain the + // encryptedReply closure created inside handleRawMessage. If destroy() + // runs (mobile disconnect) before a late streaming emit, the closure's + // captured this.sharedKey is null. Without a guard, encrypt() would + // call nacl.box.after with a null key and throw an unhandled + // "unexpected type, use Uint8Array" rejection. + it('does not throw when streaming emit fires after destroy', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + const sentBefore = ctx.ws.sent.length + + let capturedReply: ((response: string) => void) | null = null + ctx.channel.onMessage((_plaintext, encryptedReply) => { + capturedReply = encryptedReply + }) + + ctx.channel.handleRawMessage(encrypt('subscribe-trigger', sharedKey)) + expect(capturedReply).not.toBeNull() + + ctx.channel.destroy() + + const callLateEmit = () => capturedReply?.('late streaming frame') + expect(callLateEmit).not.toThrow() + expect(ctx.ws.sent.length).toBe(sentBefore) + }) + }) +}) diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts new file mode 100644 index 00000000000..f3100584d56 --- /dev/null +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -0,0 +1,185 @@ +// Why: the E2EE channel sits between the WebSocket transport and the RPC handler. +// It owns the handshake state machine and transparent encrypt/decrypt so the RPC +// handler only sees plaintext JSON, identical to the Unix socket path. +import type { WebSocket } from 'ws' +import { deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +type ChannelState = 'awaiting_hello' | 'awaiting_auth' | 'ready' + +const HANDSHAKE_TIMEOUT_MS = 10_000 +const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5 + +type E2EEHello = { + type: 'e2ee_hello' + publicKeyB64: string +} + +type E2EEAuth = { + type: 'e2ee_auth' + deviceToken: string +} + +export type E2EEChannelOptions = { + serverSecretKey: Uint8Array + validateToken: (token: string) => boolean + onReady: (channel: E2EEChannel) => void + onError: (code: number, reason: string) => void +} + +export class E2EEChannel { + private state: ChannelState = 'awaiting_hello' + private sharedKey: Uint8Array | null = null + private consecutiveFailures = 0 + private handshakeTimer: ReturnType | null = null + private readonly ws: WebSocket + private readonly serverSecretKey: Uint8Array + private readonly validateToken: (token: string) => boolean + private readonly onReady: (channel: E2EEChannel) => void + private readonly onError: (code: number, reason: string) => void + // Why: the RPC handler is set after the channel is ready, so the channel + // can forward decrypted messages. Kept as a callback rather than constructor + // param because the handler needs the encrypt function for replies. + private messageHandler: + | ((plaintext: string, encryptedReply: (response: string) => void) => void) + | null = null + + deviceToken: string | null = null + + constructor(ws: WebSocket, options: E2EEChannelOptions) { + this.ws = ws + this.serverSecretKey = options.serverSecretKey + this.validateToken = options.validateToken + this.onReady = options.onReady + this.onError = options.onError + + this.handshakeTimer = setTimeout(() => { + this.onError(4002, 'E2EE handshake timeout') + }, HANDSHAKE_TIMEOUT_MS) + } + + onMessage( + handler: (plaintext: string, encryptedReply: (response: string) => void) => void + ): void { + this.messageHandler = handler + } + + handleRawMessage(raw: string): void { + if (this.state === 'awaiting_hello') { + this.handleHello(raw) + return + } + + if (!this.sharedKey) { + return + } + + const plaintext = decrypt(raw, this.sharedKey) + if (plaintext === null) { + this.consecutiveFailures++ + if (this.consecutiveFailures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) { + this.onError(4003, 'Too many decryption failures') + } + return + } + + this.consecutiveFailures = 0 + if (this.state === 'awaiting_auth') { + this.handleAuth(plaintext) + return + } + + // Why: streaming RPC handlers (e.g. terminal.subscribe) retain this + // closure and may fire emits long after the inbound message handled + // here. If destroy() runs in between (mobile disconnect, handshake + // failure) sharedKey becomes null and tweetnacl throws "unexpected + // type, use Uint8Array" from inside nacl.box.after. Guard both the + // socket state AND the key so late emits become silent no-ops. + const encryptedReply = (response: string) => { + if (!this.sharedKey || this.ws.readyState !== this.ws.OPEN) { + return + } + this.ws.send(encrypt(response, this.sharedKey)) + } + this.messageHandler?.(plaintext, encryptedReply) + } + + private handleHello(raw: string): void { + let hello: E2EEHello + try { + hello = JSON.parse(raw) as E2EEHello + } catch { + this.onError(4001, 'Invalid handshake message') + return + } + + if (hello.type !== 'e2ee_hello' || !hello.publicKeyB64) { + this.onError(4001, 'Invalid e2ee_hello') + return + } + + // Why: derive the shared key from our secret + client's public key. + // Both sides compute the same shared secret via ECDH. + const clientPublicKey = Uint8Array.from(Buffer.from(hello.publicKeyB64, 'base64')) + if (clientPublicKey.length !== 32) { + this.onError(4001, 'Invalid public key') + return + } + + this.sharedKey = deriveSharedKey(this.serverSecretKey, clientPublicKey) + this.state = 'awaiting_auth' + + // Why: send e2ee_ready as plaintext — the client needs it to know the + // key exchange succeeded before it can send encrypted authentication. + if (this.ws.readyState === this.ws.OPEN) { + this.ws.send(JSON.stringify({ type: 'e2ee_ready' })) + } + } + + private handleAuth(plaintext: string): void { + let auth: E2EEAuth + try { + auth = JSON.parse(plaintext) as E2EEAuth + } catch { + this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'bad_auth' } }) + this.onError(4001, 'Invalid e2ee_auth') + return + } + + if (auth.type !== 'e2ee_auth' || !auth.deviceToken) { + this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'bad_auth' } }) + this.onError(4001, 'Invalid e2ee_auth') + return + } + if (!this.validateToken(auth.deviceToken)) { + this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'unauthorized' } }) + this.onError(4001, 'Unauthorized') + return + } + + this.deviceToken = auth.deviceToken + this.state = 'ready' + + if (this.handshakeTimer) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + } + + this.sendEncryptedControl({ type: 'e2ee_authenticated' }) + this.onReady(this) + } + + private sendEncryptedControl(message: unknown): void { + if (this.ws.readyState === this.ws.OPEN && this.sharedKey) { + this.ws.send(encrypt(JSON.stringify(message), this.sharedKey)) + } + } + + destroy(): void { + if (this.handshakeTimer) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + } + this.sharedKey = null + this.messageHandler = null + } +} diff --git a/src/main/runtime/rpc/e2ee-crypto.test.ts b/src/main/runtime/rpc/e2ee-crypto.test.ts new file mode 100644 index 00000000000..09e546db63c --- /dev/null +++ b/src/main/runtime/rpc/e2ee-crypto.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +describe('e2ee-crypto', () => { + it('encrypt/decrypt round-trips with shared key', () => { + const server = generateKeyPair() + const client = generateKeyPair() + + const serverShared = deriveSharedKey(server.secretKey, client.publicKey) + const clientShared = deriveSharedKey(client.secretKey, server.publicKey) + + const message = '{"id":"rpc-1","method":"status.get"}' + const encrypted = encrypt(message, clientShared) + const decrypted = decrypt(encrypted, serverShared) + + expect(decrypted).toBe(message) + }) + + it('decrypt returns null with wrong key', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const attacker = generateKeyPair() + + const clientShared = deriveSharedKey(client.secretKey, server.publicKey) + const attackerShared = deriveSharedKey(attacker.secretKey, server.publicKey) + + const encrypted = encrypt('secret data', clientShared) + expect(decrypt(encrypted, attackerShared)).toBeNull() + }) + + it('each encryption produces unique ciphertext (random nonce)', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const message = 'same message' + const a = encrypt(message, shared) + const b = encrypt(message, shared) + + expect(a).not.toBe(b) + }) + + it('handles empty string', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const encrypted = encrypt('', shared) + expect(decrypt(encrypted, shared)).toBe('') + }) + + it('handles unicode content', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const message = '日本語テスト 🎉 émojis' + const encrypted = encrypt(message, shared) + expect(decrypt(encrypted, shared)).toBe(message) + }) + + it('decrypt returns null for truncated data', () => { + const shared = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + expect(decrypt('dG9vc2hvcnQ=', shared)).toBeNull() + }) + + it('decrypt returns null for tampered data', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const encrypted = encrypt('hello', shared) + const bytes = Buffer.from(encrypted, 'base64') + bytes[bytes.length - 1] ^= 0xff + const tampered = bytes.toString('base64') + + expect(decrypt(tampered, shared)).toBeNull() + }) +}) diff --git a/src/main/runtime/rpc/e2ee-crypto.ts b/src/main/runtime/rpc/e2ee-crypto.ts new file mode 100644 index 00000000000..39adec27160 --- /dev/null +++ b/src/main/runtime/rpc/e2ee-crypto.ts @@ -0,0 +1,41 @@ +// Why: shared E2EE primitives for the desktop side. Wraps tweetnacl to provide +// encrypt/decrypt with the NaCl box format: [24-byte nonce][ciphertext], encoded +// as base64 for transmission over WebSocket text frames. +import nacl from 'tweetnacl' + +export function generateKeyPair(): nacl.BoxKeyPair { + return nacl.box.keyPair() +} + +export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { + return nacl.box.before(peerPublicKey, ourSecretKey) +} + +export function encrypt(plaintext: string, sharedKey: Uint8Array): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const messageBytes = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(messageBytes, nonce, sharedKey) + + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + + return Buffer.from(bundle).toString('base64') +} + +export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { + const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64')) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { + return null + } + + const nonce = bundle.slice(0, nacl.box.nonceLength) + const ciphertext = bundle.slice(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + + if (!plaintext) { + return null + } + + return new TextDecoder().decode(plaintext) +} diff --git a/src/main/runtime/rpc/e2ee-integration.test.ts b/src/main/runtime/rpc/e2ee-integration.test.ts new file mode 100644 index 00000000000..f4029b82473 --- /dev/null +++ b/src/main/runtime/rpc/e2ee-integration.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type { WebSocket } from 'ws' +import { E2EEChannel } from './e2ee-channel' +import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +// Why: this test simulates the full mobile → desktop E2EE flow without a real +// WebSocket. The "mobile" side generates an ephemeral keypair, sends e2ee_hello, +// the E2EEChannel (desktop) derives the shared key, and then both sides can +// exchange encrypted RPC messages. This validates that the handshake protocol +// and crypto are end-to-end compatible. + +function publicKeyToBase64(key: Uint8Array): string { + return Buffer.from(key).toString('base64') +} + +describe('E2EE integration (simulated mobile ↔ desktop)', () => { + let serverKeys: ReturnType + let mobileEphemeralKeys: ReturnType + let wsSent: string[] + let mockWs: { + OPEN: 1 + readyState: number + send: ReturnType + close: ReturnType + } + let channel: E2EEChannel + let onReady: (channel: E2EEChannel) => void + let onError: (code: number, reason: string) => void + + beforeEach(() => { + vi.useFakeTimers() + + serverKeys = generateKeyPair() + mobileEphemeralKeys = generateKeyPair() + wsSent = [] + mockWs = { + OPEN: 1, + readyState: 1, + send: vi.fn((data: string) => wsSent.push(data)), + close: vi.fn() + } + onReady = vi.fn() as unknown as (channel: E2EEChannel) => void + onError = vi.fn() as unknown as (code: number, reason: string) => void + + channel = new E2EEChannel(mockWs as unknown as WebSocket, { + serverSecretKey: serverKeys.secretKey, + validateToken: (token) => token === 'device-abc', + onReady, + onError + }) + }) + + afterEach(() => { + channel.destroy() + vi.useRealTimers() + }) + + function completeHandshake(targetChannel = channel, keys = mobileEphemeralKeys): Uint8Array { + targetChannel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(keys.publicKey) + }) + ) + const sharedKey = deriveSharedKey(keys.secretKey, serverKeys.publicKey) + targetChannel.handleRawMessage( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'device-abc' }), sharedKey) + ) + return sharedKey + } + + it('full handshake → encrypted RPC round-trip', () => { + const mobileShared = completeHandshake() + + // Desktop should respond with plaintext ready, then encrypted auth ack. + expect(onReady).toHaveBeenCalled() + expect(wsSent).toHaveLength(2) + expect(JSON.parse(wsSent[0]!)).toEqual({ type: 'e2ee_ready' }) + expect(JSON.parse(decrypt(wsSent[1]!, mobileShared)!)).toEqual({ + type: 'e2ee_authenticated' + }) + + // Set up the desktop message handler + const desktopReceived: string[] = [] + channel.onMessage((plaintext, encryptedReply) => { + desktopReceived.push(plaintext) + encryptedReply(JSON.stringify({ id: 'rpc-1', ok: true, result: { status: 'ready' } })) + }) + + // Mobile sends encrypted RPC request + const request = JSON.stringify({ id: 'rpc-1', method: 'status.get' }) + channel.handleRawMessage(encrypt(request, mobileShared)) + + // Desktop received the plaintext + expect(desktopReceived).toEqual([request]) + + // Desktop's encrypted reply (wsSent[2]) is decryptable by mobile + expect(wsSent).toHaveLength(3) + const replyPlain = decrypt(wsSent[2]!, mobileShared) + expect(JSON.parse(replyPlain!)).toEqual({ + id: 'rpc-1', + ok: true, + result: { status: 'ready' } + }) + }) + + it('mobile reconnects with fresh ephemeral key', () => { + // First connection + completeHandshake() + expect(onReady).toHaveBeenCalledTimes(1) + + const firstShared = deriveSharedKey(mobileEphemeralKeys.secretKey, serverKeys.publicKey) + + // Simulate disconnect + reconnect with new ephemeral key + channel.destroy() + wsSent.length = 0 + + const newMobileKeys = generateKeyPair() + const newChannel = new E2EEChannel(mockWs as unknown as WebSocket, { + serverSecretKey: serverKeys.secretKey, + validateToken: (token) => token === 'device-abc', + onReady, + onError + }) + + completeHandshake(newChannel, newMobileKeys) + + expect(onReady).toHaveBeenCalledTimes(2) + + const secondShared = deriveSharedKey(newMobileKeys.secretKey, serverKeys.publicKey) + + // Keys from different sessions must not be interchangeable + expect(Buffer.from(firstShared).toString('hex')).not.toBe( + Buffer.from(secondShared).toString('hex') + ) + + // Verify new session works + const received: string[] = [] + newChannel.onMessage((plaintext) => received.push(plaintext)) + newChannel.handleRawMessage(encrypt('{"id":"2","method":"test"}', secondShared)) + expect(received).toEqual(['{"id":"2","method":"test"}']) + + newChannel.destroy() + }) + + it('streaming messages work through E2EE', () => { + const mobileShared = completeHandshake() + const received: string[] = [] + + channel.onMessage((plaintext, encryptedReply) => { + received.push(plaintext) + // Simulate streaming: send multiple encrypted responses + for (let i = 0; i < 3; i++) { + encryptedReply( + JSON.stringify({ + id: 'stream-1', + ok: true, + streaming: true, + result: { type: 'data', chunk: `line ${i}\n` } + }) + ) + } + }) + + channel.handleRawMessage( + encrypt(JSON.stringify({ id: 'stream-1', method: 'terminal.subscribe' }), mobileShared) + ) + + // 1 plaintext ready + 1 encrypted auth ack + 3 streaming responses + expect(wsSent).toHaveLength(5) + + for (let i = 2; i < 5; i++) { + const plain = decrypt(wsSent[i]!, mobileShared) + const parsed = JSON.parse(plain!) + expect(parsed.streaming).toBe(true) + expect(parsed.result.chunk).toBe(`line ${i - 2}\n`) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts new file mode 100644 index 00000000000..21612a707f0 --- /dev/null +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -0,0 +1,111 @@ +import { z } from 'zod' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' + +// Why: monotonically increasing per-process counter avoids the Date.now() +// collision that fired when two near-simultaneous accounts.subscribe calls +// collided on the same millisecond and one evicted the other through +// registerSubscriptionCleanup's existing-key eviction path. +let accountsSubscriptionSeq = 0 + +const SelectAccountParams = z.object({ + accountId: z + .union([z.string().min(1, 'Missing accountId'), z.null()]) + .transform((v) => (v === null ? null : v)) +}) + +const RemoveAccountParams = z.object({ + accountId: z.string().min(1, 'Missing accountId') +}) + +const AccountsUnsubscribeParams = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) + +// Why: bridges the desktop ClaudeAccountService / CodexAccountService / +// RateLimitService into the mobile WebSocket RPC. Read + switch + remove +// only — interactive add/re-auth flows spawn `claude login` / `codex login` +// PTYs that need a desktop browser, so they intentionally remain +// desktop-only. See plan in spec doc for issue #1438. +export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [ + defineMethod({ + name: 'accounts.list', + params: null, + handler: async (_params, { runtime }) => { + // Why: ensure the snapshot reflects the latest provider state before + // returning. Desktop polling pauses when the window is unfocused and + // inactive-account caches only fill on AccountsPane open, so without + // this the mobile UI would render stale nulls / zeroes. + await runtime.refreshAccountsForMobile() + return runtime.getAccountsSnapshot() + } + }), + defineMethod({ + name: 'accounts.selectClaude', + params: SelectAccountParams, + handler: async (params, { runtime }) => runtime.selectClaudeAccount(params.accountId) + }), + defineMethod({ + name: 'accounts.selectCodex', + params: SelectAccountParams, + handler: async (params, { runtime }) => runtime.selectCodexAccount(params.accountId) + }), + defineMethod({ + name: 'accounts.removeClaude', + params: RemoveAccountParams, + handler: async (params, { runtime }) => runtime.removeClaudeAccount(params.accountId) + }), + defineMethod({ + name: 'accounts.removeCodex', + params: RemoveAccountParams, + handler: async (params, { runtime }) => runtime.removeCodexAccount(params.accountId) + }), + // Why: streaming counterpart so mobile usage bars refresh in place when the + // desktop's 5-minute rate-limit poll completes or when the user switches + // accounts on either side. Mirrors the notifications.subscribe pattern. + defineStreamingMethod({ + name: 'accounts.subscribe', + params: null, + handler: async (_params, { runtime, connectionId }, emit) => { + await new Promise((resolve) => { + const unsubscribe = runtime.onAccountsChanged((snapshot) => { + emit({ type: 'snapshot', snapshot }) + }) + + // Why: scope the id by connectionId so two sockets from the same + // device (host + accounts screen) cannot evict each other through + // registerSubscriptionCleanup's "existing key" branch, and append a + // per-process counter so two concurrent subscribes on the same + // socket also can't collide. + const seq = ++accountsSubscriptionSeq + const subscriptionId = `accounts-${connectionId ?? 'inproc'}-${seq}` + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + unsubscribe() + emit({ type: 'end' }) + resolve() + }, + connectionId + ) + + // Why: emit the current snapshot synchronously so the phone has + // something to render immediately, then kick a forced refresh that + // will broadcast a fresh snapshot through the listener once each + // provider fetch completes. + emit({ type: 'ready', subscriptionId, snapshot: runtime.getAccountsSnapshot() }) + void runtime.refreshAccountsForMobile() + }) + } + }), + defineMethod({ + name: 'accounts.unsubscribe', + params: AccountsUnsubscribeParams, + handler: async (params, { runtime }) => { + runtime.cleanupSubscription(params.subscriptionId) + return { unsubscribed: true } + } + }) +] diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index 5d272137300..b141c0b14a3 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -16,13 +16,19 @@ import { KeyboardInsert, Keypress, LimitParam, + ProfileCreate, + ProfileDelete, Screenshot, Scroll, Select, SelectorPath, + TabCurrent, + TabSetProfile, TabClose, TabCreate, TabList, + TabProfileClone, + TabShow, TabSwitch, Type, Upload, @@ -90,6 +96,16 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [ params: TabList, handler: async (params, { runtime }) => runtime.browserTabList(params) }), + defineMethod({ + name: 'browser.tabShow', + params: TabShow, + handler: async (params, { runtime }) => runtime.browserTabShow(params) + }), + defineMethod({ + name: 'browser.tabCurrent', + params: TabCurrent, + handler: async (params, { runtime }) => runtime.browserTabCurrent(params) + }), defineMethod({ name: 'browser.tabSwitch', params: TabSwitch, @@ -100,11 +116,41 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [ params: TabCreate, handler: async (params, { runtime }) => runtime.browserTabCreate(params) }), + defineMethod({ + name: 'browser.tabSetProfile', + params: TabSetProfile, + handler: async (params, { runtime }) => runtime.browserTabSetProfile(params) + }), + defineMethod({ + name: 'browser.tabProfileShow', + params: TabShow, + handler: async (params, { runtime }) => runtime.browserTabProfileShow(params) + }), + defineMethod({ + name: 'browser.tabProfileClone', + params: TabProfileClone, + handler: async (params, { runtime }) => runtime.browserTabProfileClone(params) + }), defineMethod({ name: 'browser.tabClose', params: TabClose, handler: async (params, { runtime }) => runtime.browserTabClose(params) }), + defineMethod({ + name: 'browser.profileList', + params: null, + handler: async (_params, { runtime }) => runtime.browserProfileList() + }), + defineMethod({ + name: 'browser.profileCreate', + params: ProfileCreate, + handler: async (params, { runtime }) => runtime.browserProfileCreate(params) + }), + defineMethod({ + name: 'browser.profileDelete', + params: ProfileDelete, + handler: async (params, { runtime }) => runtime.browserProfileDelete(params) + }), defineMethod({ name: 'browser.hover', params: Element, diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 3a8e3cf0ace..13ba12aeb42 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -90,6 +90,16 @@ export const TabSwitch = BrowserTarget.extend({ export const TabCreate = z.object({ url: OptionalString, + worktree: OptionalString, + profileId: OptionalString +}) + +export const TabShow = z.object({ + page: requiredString('Missing required --page'), + worktree: OptionalString +}) + +export const TabCurrent = z.object({ worktree: OptionalString }) @@ -102,6 +112,25 @@ export const TabClose = z.object({ worktree: OptionalString }) +export const TabSetProfile = BrowserTarget.extend({ + profileId: requiredString('Missing required --profile') +}) + +export const TabProfileClone = BrowserTarget.extend({ + profileId: requiredString('Missing required --profile') +}) + +export const ProfileCreate = z.object({ + label: requiredString('Missing required --label'), + // Strict enum so unknown scope values surface validation errors instead of being + // silently coerced to 'isolated' (pr-bug-scan finding from #1397). + scope: z.enum(['isolated', 'imported']) +}) + +export const ProfileDelete = z.object({ + profileId: requiredString('Missing required --profile') +}) + export const Drag = BrowserTarget.extend({ from: requiredString('Missing required --from and --to element refs'), to: requiredString('Missing required --from and --to element refs') diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index bff8a215bc2..bce5a50c4e0 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -1,19 +1,27 @@ -import type { RpcMethod } from '../core' +import type { RpcAnyMethod } from '../core' import { STATUS_METHODS } from './status' import { REPO_METHODS } from './repo' import { WORKTREE_METHODS } from './worktree' import { TERMINAL_METHODS } from './terminal' import { BROWSER_CORE_METHODS } from './browser-core' import { BROWSER_EXTRA_METHODS } from './browser-extras' +import { ORCHESTRATION_METHODS } from './orchestration' +import { NOTIFICATION_METHODS } from './notifications' +import { STATS_METHODS } from './stats' +import { ACCOUNT_METHODS } from './accounts' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when // auditing the security boundary or wiring new CLI commands. -export const ALL_RPC_METHODS: readonly RpcMethod[] = [ +export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...STATUS_METHODS, ...REPO_METHODS, ...WORKTREE_METHODS, ...TERMINAL_METHODS, ...BROWSER_CORE_METHODS, - ...BROWSER_EXTRA_METHODS + ...BROWSER_EXTRA_METHODS, + ...ORCHESTRATION_METHODS, + ...NOTIFICATION_METHODS, + ...STATS_METHODS, + ...ACCOUNT_METHODS ] diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts new file mode 100644 index 00000000000..1d63f26d785 --- /dev/null +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' +import { defineStreamingMethod, defineMethod, type RpcAnyMethod } from '../core' + +// Why: monotonically increasing per-process counter eliminates the +// Date.now() collision that could fire when two near-simultaneous +// notifications.subscribe calls landed on the same millisecond. +let notificationsSubscriptionSeq = 0 + +const NotificationUnsubscribeParams = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) + +// Why: notifications.subscribe streams desktop notification events to mobile +// clients over WebSocket. The mobile client shows a local push notification +// for each event. This avoids requiring Firebase/APNs — the existing +// persistent WebSocket connection doubles as the push channel. +export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [ + defineStreamingMethod({ + name: 'notifications.subscribe', + params: null, + handler: async (_params, { runtime, connectionId }, emit) => { + await new Promise((resolve) => { + const unsubscribe = runtime.onNotificationDispatched((event) => { + emit({ type: 'notification', ...event }) + }) + + // Why: scope by per-ws connectionId + per-process counter so + // concurrent subscribes never collide on the cleanup map. + const seq = ++notificationsSubscriptionSeq + const subscriptionId = `notifications-${connectionId ?? 'inproc'}-${seq}` + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + unsubscribe() + emit({ type: 'end' }) + resolve() + }, + connectionId + ) + + emit({ type: 'ready', subscriptionId }) + }) + } + }), + defineMethod({ + name: 'notifications.unsubscribe', + params: NotificationUnsubscribeParams, + handler: async (params, { runtime }) => { + runtime.cleanupSubscription(params.subscriptionId) + return { unsubscribed: true } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-gates.ts b/src/main/runtime/rpc/methods/orchestration-gates.ts new file mode 100644 index 00000000000..ca16b9719f0 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-gates.ts @@ -0,0 +1,154 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import type { GateStatus } from '../../orchestration/db' +import { Coordinator } from '../../orchestration/coordinator' + +// Why: the coordinator instance is stored at module scope so orchestration.runStop +// can signal it to halt. Only one coordinator can run at a time (enforced by +// the DB's active-run check), so a single reference suffices. +let activeCoordinator: Coordinator | null = null + +const RunParams = z.object({ + spec: requiredString('Missing --spec'), + from: OptionalString, + pollIntervalMs: OptionalFiniteNumber, + maxConcurrent: OptionalFiniteNumber, + worktree: OptionalString +}) + +const RunStopParams = z.object({}) + +const GateCreateParams = z.object({ + task: requiredString('Missing --task'), + question: requiredString('Missing --question'), + options: OptionalString +}) + +const GateResolveParams = z.object({ + id: requiredString('Missing --id'), + resolution: requiredString('Missing --resolution') +}) + +const GateListParams = z.object({ + task: OptionalString, + status: z.enum(['pending', 'resolved', 'timeout']).optional() +}) + +export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [ + // Why: Section 4.12 — orchestration.run returns immediately with a run ID. + // The coordinator loop runs in the background; progress is queried via + // orchestration.taskList. This prevents the RPC call from blocking the + // CLI (or any caller) for the entire duration of the pipeline. + defineMethod({ + name: 'orchestration.run', + params: RunParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + + const existing = db.getActiveCoordinatorRun() + if (existing) { + throw new Error(`Coordinator already running: ${existing.id}`) + } + + const coordinatorHandle = params.from ?? 'coordinator' + const coordinator = new Coordinator(db, runtime, { + spec: params.spec, + coordinatorHandle, + pollIntervalMs: params.pollIntervalMs, + maxConcurrent: params.maxConcurrent, + worktree: params.worktree + }) + + activeCoordinator = coordinator + + const run = db.createCoordinatorRun({ + spec: params.spec, + coordinatorHandle, + pollIntervalMs: params.pollIntervalMs + }) + + // Why: fire-and-forget — the coordinator loop runs in the event loop + // background. Results are persisted to the DB; callers query via + // orchestration.taskList or orchestration.runStatus. + coordinator.runFromExistingRun(run.id).finally(() => { + if (activeCoordinator === coordinator) { + activeCoordinator = null + } + }) + + return { runId: run.id, status: 'running' } + } + }), + + defineMethod({ + name: 'orchestration.runStop', + params: RunStopParams, + handler: (_params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const run = db.getActiveCoordinatorRun() + if (!run) { + throw new Error('No active coordinator run') + } + + if (activeCoordinator) { + activeCoordinator.stop() + activeCoordinator = null + } + + return { runId: run.id, stopped: true } + } + }), + + defineMethod({ + name: 'orchestration.gateCreate', + params: GateCreateParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + let options: string[] | undefined + if (params.options) { + try { + const parsed = JSON.parse(params.options) + if (!Array.isArray(parsed) || !parsed.every((option) => typeof option === 'string')) { + throw new Error('not an array of strings') + } + options = parsed + } catch { + throw new Error('Invalid --options: must be a JSON array of strings') + } + } + const gate = db.createGate({ + taskId: params.task, + question: params.question, + options + }) + return { gate } + } + }), + + defineMethod({ + name: 'orchestration.gateResolve', + params: GateResolveParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const gate = db.resolveGate(params.id, params.resolution) + if (!gate) { + throw new Error(`Gate not found: ${params.id}`) + } + return { gate } + } + }), + + defineMethod({ + name: 'orchestration.gateList', + params: GateListParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const gates = db.listGates({ + taskId: params.task, + status: params.status as GateStatus + }) + return { gates, count: gates.length } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts new file mode 100644 index 00000000000..3ae1d213586 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -0,0 +1,1010 @@ +/* eslint-disable max-lines -- Why: orchestration tests share a mock runtime factory; splitting by method would duplicate 40 lines of setup per file without improving clarity. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_METHODS } from './orchestration' +import { buildRegistry, type RpcContext } from '../core' +import { OrchestrationDb } from '../../orchestration/db' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RuntimeTerminalSummary } from '../../../../shared/runtime-types' + +describe('orchestration RPC methods', () => { + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + + function setup(): void { + db = new OrchestrationDb(':memory:') + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + ctx = { runtime } + } + + afterEach(() => { + db?.close() + }) + + function findMethod(name: string) { + const method = ORCHESTRATION_METHODS.find((m) => m.name === name) + if (!method) { + throw new Error(`Method not found: ${name}`) + } + return method + } + + async function call(name: string, params: Record) { + const method = findMethod(name) + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, ctx) + } + + it('registers all expected methods', () => { + const registry = buildRegistry(ORCHESTRATION_METHODS) + expect(registry.size).toBe(16) + expect(registry.has('orchestration.send')).toBe(true) + expect(registry.has('orchestration.check')).toBe(true) + expect(registry.has('orchestration.reply')).toBe(true) + expect(registry.has('orchestration.inbox')).toBe(true) + expect(registry.has('orchestration.taskCreate')).toBe(true) + expect(registry.has('orchestration.taskList')).toBe(true) + expect(registry.has('orchestration.taskUpdate')).toBe(true) + expect(registry.has('orchestration.dispatch')).toBe(true) + expect(registry.has('orchestration.dispatchShow')).toBe(true) + expect(registry.has('orchestration.ask')).toBe(true) + expect(registry.has('orchestration.run')).toBe(true) + expect(registry.has('orchestration.runStop')).toBe(true) + expect(registry.has('orchestration.gateCreate')).toBe(true) + expect(registry.has('orchestration.gateResolve')).toBe(true) + expect(registry.has('orchestration.gateList')).toBe(true) + expect(registry.has('orchestration.reset')).toBe(true) + }) + + 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', + subject: 'hello' + })) as { message: { id: string; from_handle: string } } + + expect(result.message.id).toMatch(/^msg_/) + expect(result.message.from_handle).toBe('term_a') + expect(runtime.deliverPendingMessagesForHandle).toHaveBeenCalledWith('term_b') + }) + + it('rejects missing --to', () => { + const method = findMethod('orchestration.send') + expect(() => method.params!.parse({ subject: 'hi' })).toThrow() + }) + + it('rejects missing --subject', () => { + const method = findMethod('orchestration.send') + expect(() => method.params!.parse({ to: 'b' })).toThrow() + }) + + it('rejects invalid enum values', () => { + const method = findMethod('orchestration.send') + expect(() => method.params!.parse({ to: 'b', subject: 'hi', type: 'typo' })).toThrow() + expect(() => method.params!.parse({ to: 'b', subject: 'hi', priority: 'medium' })).toThrow() + }) + + function makeSummary( + handle: string, + opts: Partial = {} + ): RuntimeTerminalSummary { + return { + handle, + worktreeId: opts.worktreeId ?? 'wt_default', + worktreePath: opts.worktreePath ?? '/tmp/wt', + branch: opts.branch ?? 'main', + tabId: opts.tabId ?? 'tab_1', + leafId: opts.leafId ?? handle, + title: opts.title ?? null, + connected: opts.connected ?? true, + writable: opts.writable ?? true, + lastOutputAt: opts.lastOutputAt ?? null, + preview: opts.preview ?? '' + } + } + + function setupWithTerminals( + terminals: RuntimeTerminalSummary[], + agentStatuses?: Record + ): void { + setup() + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals, + totalCount: terminals.length, + truncated: false + }) + vi.spyOn(runtime, 'getAgentStatusForHandle').mockImplementation( + (handle: string) => agentStatuses?.[handle] ?? null + ) + } + + it('fans out @all to all terminals except sender', async () => { + setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')]) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@all', + subject: 'broadcast' + })) as { messages: { to_handle: string }[]; recipients: number } + + expect(result.recipients).toBe(2) + expect(result.messages).toHaveLength(2) + const recipients = result.messages.map((m) => m.to_handle).sort() + expect(recipients).toEqual(['term_b', 'term_c']) + }) + + it('fans out @idle to only idle agents', async () => { + setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')], { + term_b: 'idle', + term_c: 'busy' + }) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@idle', + subject: 'idle check' + })) as { messages: { to_handle: string }[]; recipients: number } + + expect(result.recipients).toBe(1) + expect(result.messages[0].to_handle).toBe('term_b') + }) + + it('fans out agent name group (@claude) by title match', async () => { + setupWithTerminals([ + makeSummary('term_a', { title: 'Claude Code' }), + makeSummary('term_b', { title: 'Claude Code' }), + makeSummary('term_c', { title: 'Codex' }) + ]) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@claude', + subject: 'claude only' + })) as { messages: { to_handle: string }[]; recipients: number } + + expect(result.recipients).toBe(1) + expect(result.messages[0].to_handle).toBe('term_b') + }) + + it('fans out @worktree: to matching worktree', async () => { + setupWithTerminals([ + makeSummary('term_a', { worktreeId: 'wt_1' }), + makeSummary('term_b', { worktreeId: 'wt_1' }), + makeSummary('term_c', { worktreeId: 'wt_2' }) + ]) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@worktree:wt_1', + subject: 'worktree msg' + })) as { messages: { to_handle: string }[]; recipients: number } + + expect(result.recipients).toBe(1) + expect(result.messages[0].to_handle).toBe('term_b') + }) + + it('shares thread_id across fan-out messages', async () => { + setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')]) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@all', + subject: 'threaded', + threadId: 'my_thread' + })) as { messages: { thread_id: string }[] } + + expect(result.messages[0].thread_id).toBe('my_thread') + expect(result.messages[1].thread_id).toBe('my_thread') + }) + + it('generates a shared thread_id when none provided', async () => { + setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')]) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@all', + subject: 'auto thread' + })) as { messages: { thread_id: string }[] } + + expect(result.messages[0].thread_id).toMatch(/^thread_/) + expect(result.messages[0].thread_id).toBe(result.messages[1].thread_id) + }) + + it('throws when group resolves to no recipients', async () => { + setupWithTerminals([makeSummary('term_a')]) + + await expect( + call('orchestration.send', { + from: 'term_a', + to: '@all', + subject: 'nobody home' + }) + ).rejects.toThrow('No recipients resolved for group address') + }) + }) + + describe('orchestration.check', () => { + it('returns unread messages for a terminal', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + db.insertMessage({ from: 'a', to: 'b', subject: 'two' }) + db.insertMessage({ from: 'a', to: 'c', subject: 'other' }) + + const result = (await call('orchestration.check', { + terminal: 'b' + })) as { messages: unknown[]; count: number } + + expect(result.count).toBe(2) + }) + + it('returns formatted output with --inject', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + + const result = (await call('orchestration.check', { + terminal: 'b', + inject: true + })) as { formatted: string; count: number } + + expect(result.formatted).toContain('Subject: test') + expect(result.count).toBe(1) + }) + + it('filters by type', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'status', type: 'status' }) + db.insertMessage({ from: 'a', to: 'b', subject: 'done', type: 'worker_done' }) + + const result = (await call('orchestration.check', { + terminal: 'b', + types: 'worker_done' + })) as { count: number } + + expect(result.count).toBe(1) + }) + + it('rejects invalid type filters', async () => { + setup() + await expect( + call('orchestration.check', { + terminal: 'b', + types: 'worker_done,typo' + }) + ).rejects.toThrow('Invalid --types') + }) + + it('default (unread only) marks returned rows as read', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + db.insertMessage({ from: 'a', to: 'b', subject: 'two' }) + + const first = (await call('orchestration.check', { terminal: 'b' })) as { + count: number + } + expect(first.count).toBe(2) + + const second = (await call('orchestration.check', { terminal: 'b' })) as { + count: number + } + expect(second.count).toBe(0) + }) + + it('--all returns every message for the handle without marking read', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + const second = db.insertMessage({ from: 'a', to: 'b', subject: 'two' }) + db.markAsRead([second.id]) + + const result = (await call('orchestration.check', { + terminal: 'b', + all: true + })) as { messages: { read: number }[]; count: number } + + expect(result.count).toBe(2) + // Must not have flipped the remaining unread row + const stillUnread = db.getUnreadMessages('b') + expect(stillUnread).toHaveLength(1) + }) + + it('--all returns rows with delivered_at set after push-on-idle stamped them', async () => { + setup() + const msg = db.insertMessage({ from: 'a', to: 'b', subject: 'hi' }) + // Why: simulate push-on-idle stamping delivered_at without the runtime loop. + db.markAsDelivered([msg.id]) + + const result = (await call('orchestration.check', { + terminal: 'b', + all: true + })) as { messages: { id: string; delivered_at: string | null }[]; count: number } + + expect(result.count).toBe(1) + expect(result.messages[0].delivered_at).not.toBeNull() + }) + + it('--all --terminal returns empty list', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + + const result = (await call('orchestration.check', { + terminal: 'does_not_exist', + all: true + })) as { count: number } + expect(result.count).toBe(0) + }) + + it('unread:false compat shim behaves like --all (one-release bridge)', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + + const result = (await call('orchestration.check', { + terminal: 'b', + unread: false + })) as { count: number } + expect(result.count).toBe(1) + + // Must not have marked read + expect(db.getUnreadMessages('b')).toHaveLength(1) + }) + }) + + describe('orchestration.reply', () => { + it('replies to a message', async () => { + setup() + const original = db.insertMessage({ from: 'a', to: 'b', subject: 'question' }) + + const result = (await call('orchestration.reply', { + id: original.id, + body: 'answer', + from: 'b' + })) as { message: { to_handle: string; subject: string; thread_id: string } } + + expect(result.message.to_handle).toBe('a') + expect(result.message.subject).toBe('Re: question') + expect(result.message.thread_id).toBe(original.id) + }) + + it('throws on nonexistent message', async () => { + setup() + await expect(call('orchestration.reply', { id: 'msg_fake', body: 'nope' })).rejects.toThrow( + 'Message not found' + ) + }) + }) + + describe('orchestration.inbox', () => { + it('returns all messages', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + db.insertMessage({ from: 'c', to: 'd', subject: 'two' }) + + const result = (await call('orchestration.inbox', {})) as { count: number } + expect(result.count).toBe(2) + }) + + it('--terminal matches check --all output for the same handle', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + db.insertMessage({ from: 'a', to: 'b', subject: 'two' }) + db.insertMessage({ from: 'a', to: 'c', subject: 'other' }) + + const inbox = (await call('orchestration.inbox', { terminal: 'b' })) as { + messages: { id: string; to_handle: string }[] + count: number + } + const check = (await call('orchestration.check', { + terminal: 'b', + all: true + })) as { messages: { id: string; to_handle: string }[]; count: number } + + expect(inbox.count).toBe(2) + expect(check.count).toBe(2) + // Same rows in the same order — both use sequence DESC + expect(inbox.messages.map((m) => m.id)).toEqual(check.messages.map((m) => m.id)) + expect(inbox.messages.every((m) => m.to_handle === 'b')).toBe(true) + }) + + it('--terminal returns empty list without erroring', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + + const result = (await call('orchestration.inbox', { + terminal: 'does_not_exist' + })) as { count: number } + expect(result.count).toBe(0) + }) + }) + + describe('orchestration.taskCreate', () => { + it('creates a task', async () => { + setup() + const result = (await call('orchestration.taskCreate', { + spec: 'implement feature X' + })) as { task: { id: string; status: string } } + + expect(result.task.id).toMatch(/^task_/) + expect(result.task.status).toBe('ready') + }) + + it('creates a task with deps', async () => { + setup() + const t1 = db.createTask({ spec: 'first' }) + + const result = (await call('orchestration.taskCreate', { + spec: 'second', + deps: JSON.stringify([t1.id]) + })) as { task: { status: string } } + + expect(result.task.status).toBe('pending') + }) + + it('rejects invalid deps JSON', async () => { + setup() + await expect( + call('orchestration.taskCreate', { spec: 'bad', deps: 'not-json' }) + ).rejects.toThrow('Invalid --deps') + }) + }) + + describe('orchestration.taskList', () => { + it('lists all tasks', async () => { + setup() + db.createTask({ spec: 'a' }) + db.createTask({ spec: 'b' }) + + const result = (await call('orchestration.taskList', {})) as { count: number } + expect(result.count).toBe(2) + }) + + it('filters by status', async () => { + setup() + db.createTask({ spec: 'a' }) + const t2 = db.createTask({ spec: 'b' }) + db.updateTaskStatus(t2.id, 'completed') + + const result = (await call('orchestration.taskList', { + status: 'ready' + })) as { count: number } + expect(result.count).toBe(1) + }) + + it('rejects invalid status filters', () => { + const method = findMethod('orchestration.taskList') + expect(() => method.params!.parse({ status: 'done-ish' })).toThrow() + }) + + it('includes assignee_handle and dispatch_id for dispatched tasks', async () => { + setup() + const t1 = db.createTask({ spec: 'ready work' }) + const t2 = db.createTask({ spec: 'active work' }) + const ctx = db.createDispatchContext(t2.id, 'term_worker') + + const result = (await call('orchestration.taskList', {})) as { + tasks: { + id: string + status: string + assignee_handle?: string | null + dispatch_id?: string | null + }[] + } + + const ready = result.tasks.find((t) => t.id === t1.id) + const dispatched = result.tasks.find((t) => t.id === t2.id) + expect(ready).toBeDefined() + expect(dispatched).toBeDefined() + // Non-dispatched tasks keep the legacy shape — no assignee/dispatch fields. + expect(ready).not.toHaveProperty('assignee_handle') + expect(ready).not.toHaveProperty('dispatch_id') + // Dispatched tasks surface the active dispatch. + expect(dispatched?.assignee_handle).toBe('term_worker') + expect(dispatched?.dispatch_id).toBe(ctx.id) + }) + }) + + describe('orchestration.taskUpdate', () => { + it('updates task status', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + + const result = (await call('orchestration.taskUpdate', { + id: task.id, + status: 'completed', + result: '{"ok": true}' + })) as { task: { status: string; result: string } } + + expect(result.task.status).toBe('completed') + expect(result.task.result).toBe('{"ok": true}') + }) + + it('completion frees the active dispatch context', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + db.createDispatchContext(task.id, 'term_a') + + await call('orchestration.taskUpdate', { + id: task.id, + status: 'completed' + }) + + expect(db.getActiveDispatchForTerminal('term_a')).toBeUndefined() + }) + + it('throws on nonexistent task', async () => { + setup() + await expect( + call('orchestration.taskUpdate', { id: 'task_fake', status: 'completed' }) + ).rejects.toThrow('Task not found') + }) + }) + + describe('orchestration.dispatch', () => { + it('dispatches a task to a terminal', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + + const result = (await call('orchestration.dispatch', { + task: task.id, + to: 'term_a' + })) as { dispatch: { task_id: string; status: string } } + + expect(result.dispatch.task_id).toBe(task.id) + expect(result.dispatch.status).toBe('dispatched') + }) + + it('rejects dispatch for a pending task', async () => { + setup() + const parent = db.createTask({ spec: 'parent' }) + const child = db.createTask({ spec: 'child', deps: [parent.id] }) + + await expect( + call('orchestration.dispatch', { + task: child.id, + to: 'term_a' + }) + ).rejects.toThrow('only ready tasks can be dispatched') + }) + + it('rolls back active dispatch when injection fails', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + vi.spyOn(runtime, 'sendTerminal').mockRejectedValue(new Error('terminal_not_writable')) + + await expect( + call('orchestration.dispatch', { + task: task.id, + to: 'term_a', + inject: true + }) + ).rejects.toThrow('terminal_not_writable') + + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.getActiveDispatchForTerminal('term_a')).toBeUndefined() + }) + + it('uses caller-provided dev mode for injected preamble', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + const send = vi.spyOn(runtime, 'sendTerminal').mockResolvedValue({ + handle: 'term_a', + accepted: true, + bytesWritten: 1 + }) + + await call('orchestration.dispatch', { + task: task.id, + to: 'term_a', + inject: true, + devMode: true + }) + + expect(send.mock.calls[0]?.[1].text).toContain('orca-dev orchestration send') + }) + + it('rejects inject to terminal without recognized agent', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(false) + + await expect( + call('orchestration.dispatch', { + task: task.id, + to: 'term_a', + inject: true + }) + ).rejects.toThrow('no recognized agent detected') + }) + + it('rejects dispatch to occupied terminal', async () => { + setup() + const t1 = db.createTask({ spec: 'first' }) + const t2 = db.createTask({ spec: 'second' }) + db.createDispatchContext(t1.id, 'term_a') + + await expect(call('orchestration.dispatch', { task: t2.id, to: 'term_a' })).rejects.toThrow( + /already has an active dispatch/ + ) + }) + + it('dry-run returns the preamble without mutating state', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + + const result = (await call('orchestration.dispatch', { + task: task.id, + to: 'term_a', + inject: true, + dryRun: true, + from: 'term_coord' + })) as { + dispatch: null + dryRun: boolean + preamble: string + injected: boolean + } + + expect(result.dryRun).toBe(true) + expect(result.dispatch).toBeNull() + expect(result.injected).toBe(false) + expect(result.preamble).toContain('work') + expect(result.preamble).toContain(task.id) + expect(result.preamble).toContain('term_coord') + // Task state must not change on dry-run. + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.getDispatchContext(task.id)).toBeUndefined() + }) + + it('returnPreamble includes preamble in the response', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + + const result = (await call('orchestration.dispatch', { + task: task.id, + to: 'term_a', + returnPreamble: true, + from: 'term_coord' + })) as { dispatch: { id: string }; preamble: string } + + expect(result.dispatch.id).toMatch(/^ctx_/) + expect(result.preamble).toContain(task.id) + expect(result.preamble).toContain('term_coord') + }) + }) + + describe('orchestration.dispatchShow', () => { + it('shows dispatch context for a task', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + db.createDispatchContext(task.id, 'term_a') + + const result = (await call('orchestration.dispatchShow', { + task: task.id + })) as { dispatch: { task_id: string } | null } + + expect(result.dispatch?.task_id).toBe(task.id) + }) + + it('returns null for unknown task', async () => { + setup() + const result = (await call('orchestration.dispatchShow', { + task: 'task_fake' + })) as { dispatch: null } + + expect(result.dispatch).toBeNull() + }) + + it('--preamble returns the preamble text', async () => { + setup() + const task = db.createTask({ spec: 'refactor auth' }) + db.createDispatchContext(task.id, 'term_a') + + const result = (await call('orchestration.dispatchShow', { + task: task.id, + preamble: true, + from: 'term_coord' + })) as { dispatch: { task_id: string } | null; preamble: string } + + expect(result.preamble).toContain('refactor auth') + expect(result.preamble).toContain(task.id) + expect(result.preamble).toContain('term_coord') + expect(result.dispatch?.task_id).toBe(task.id) + }) + + it('--preamble works when no dispatch exists yet', async () => { + setup() + const task = db.createTask({ spec: 'build feature' }) + + const result = (await call('orchestration.dispatchShow', { + task: task.id, + preamble: true, + from: 'term_coord' + })) as { dispatch: null; preamble: string } + + expect(result.dispatch).toBeNull() + expect(result.preamble).toContain('build feature') + }) + + it('--preamble throws for unknown task', async () => { + setup() + await expect( + call('orchestration.dispatchShow', { task: 'task_fake', preamble: true }) + ).rejects.toThrow('Task not found') + }) + }) + + describe('orchestration.gateCreate', () => { + it('creates a decision gate and blocks the task', async () => { + setup() + const task = db.createTask({ spec: 'needs approval' }) + + const result = (await call('orchestration.gateCreate', { + task: task.id, + question: 'Proceed with migration?', + options: JSON.stringify(['yes', 'no', 'defer']) + })) as { gate: { id: string; task_id: string; status: string } } + + expect(result.gate.id).toMatch(/^gate_/) + expect(result.gate.task_id).toBe(task.id) + expect(result.gate.status).toBe('pending') + + const updated = db.getTask(task.id) + expect(updated?.status).toBe('blocked') + }) + + it('rejects invalid options JSON', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + await expect( + call('orchestration.gateCreate', { + task: task.id, + question: 'ok?', + options: 'not-json' + }) + ).rejects.toThrow('Invalid --options') + }) + + it('rejects options that are not string arrays', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + await expect( + call('orchestration.gateCreate', { + task: task.id, + question: 'ok?', + options: JSON.stringify(['yes', 1]) + }) + ).rejects.toThrow('Invalid --options') + }) + }) + + describe('orchestration.gateResolve', () => { + it('resolves a gate and unblocks the task', async () => { + setup() + const task = db.createTask({ spec: 'needs approval' }) + const gate = db.createGate({ taskId: task.id, question: 'Proceed?' }) + + const result = (await call('orchestration.gateResolve', { + id: gate.id, + resolution: 'yes' + })) as { gate: { id: string; status: string; resolution: string } } + + expect(result.gate.status).toBe('resolved') + expect(result.gate.resolution).toBe('yes') + + const updated = db.getTask(task.id) + expect(updated?.status).toBe('ready') + }) + + it('throws on nonexistent gate', async () => { + setup() + await expect( + call('orchestration.gateResolve', { id: 'gate_fake', resolution: 'yes' }) + ).rejects.toThrow('Gate not found') + }) + }) + + describe('orchestration.gateList', () => { + it('lists all gates', async () => { + setup() + const t1 = db.createTask({ spec: 'a' }) + const t2 = db.createTask({ spec: 'b' }) + db.createGate({ taskId: t1.id, question: 'q1' }) + db.createGate({ taskId: t2.id, question: 'q2' }) + + const result = (await call('orchestration.gateList', {})) as { count: number } + expect(result.count).toBe(2) + }) + + it('filters by status', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + const gate = db.createGate({ taskId: task.id, question: 'q' }) + db.resolveGate(gate.id, 'yes') + + const result = (await call('orchestration.gateList', { + status: 'resolved' + })) as { count: number } + expect(result.count).toBe(1) + }) + + it('rejects invalid status filters', () => { + const method = findMethod('orchestration.gateList') + expect(() => method.params!.parse({ status: 'closed' })).toThrow() + }) + }) + + describe('orchestration.ask', () => { + it('sends a decision_gate and returns the first thread reply', async () => { + setup() + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + vi.spyOn(runtime, 'waitForMessage').mockImplementation(async () => { + // Simulate coordinator replying in the thread during the wait + const outbound = db.getInbox(10).find((m) => m.type === 'decision_gate') + if (outbound) { + db.insertMessage({ + from: 'term_coord', + to: 'term_worker', + subject: 'Re: Question', + body: 'go ahead', + threadId: outbound.id + }) + } + }) + + const result = (await call('orchestration.ask', { + from: 'term_worker', + to: 'term_coord', + question: 'proceed?', + options: 'yes, no', + timeoutMs: 500 + })) as { + answer: string + messageId: string + threadId: string + timedOut: boolean + } + + expect(result.timedOut).toBe(false) + expect(result.answer).toBe('go ahead') + expect(result.messageId).toMatch(/^msg_/) + + // Outbound decision_gate message was persisted with parsed options. + const outbound = db.getInbox(10).find((m) => m.type === 'decision_gate') + expect(outbound).toBeTruthy() + expect(outbound?.subject).toBe('Question') + expect(outbound?.body).toBe('proceed?') + const payload = JSON.parse(outbound!.payload ?? '{}') + expect(payload.question).toBe('proceed?') + expect(payload.options).toEqual(['yes', 'no']) + }) + + it('returns timedOut when no reply arrives in the window', async () => { + setup() + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + vi.spyOn(runtime, 'waitForMessage').mockResolvedValue() + + const result = (await call('orchestration.ask', { + from: 'term_worker', + to: 'term_coord', + question: 'still there?', + timeoutMs: 1 + })) as { answer: string | null; timedOut: boolean; messageId: string | null } + + expect(result.timedOut).toBe(true) + expect(result.answer).toBeNull() + expect(result.messageId).toBeNull() + // Outbound message still persisted (coordinator can still see it). + const outbound = db.getInbox(10).find((m) => m.type === 'decision_gate') + expect(outbound).toBeTruthy() + }) + + it('rejects group addresses with a dedicated error (no message persisted)', async () => { + setup() + await expect( + call('orchestration.ask', { + from: 'term_worker', + to: '@reviewers', + question: 'ok?' + }) + ).rejects.toThrow(/does not support group addresses/) + expect(db.getInbox(10)).toHaveLength(0) + }) + + it('does not return distractor messages on a different thread', async () => { + setup() + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + let wakeCount = 0 + vi.spyOn(runtime, 'waitForMessage').mockImplementation(async () => { + wakeCount++ + const outbound = db.getInbox(20).find((m) => m.type === 'decision_gate') + if (wakeCount === 1 && outbound) { + // First wake: distractor in a DIFFERENT thread — must be ignored. + db.insertMessage({ + from: 'term_coord', + to: 'term_worker', + subject: 'unrelated', + body: 'other', + threadId: 'thread_other' + }) + } else if (wakeCount === 2 && outbound) { + // Second wake: correct thread reply. + db.insertMessage({ + from: 'term_coord', + to: 'term_worker', + subject: 'Re: Question', + body: 'correct answer', + threadId: outbound.id + }) + } + }) + + const result = (await call('orchestration.ask', { + from: 'term_worker', + to: 'term_coord', + question: 'filter?', + timeoutMs: 2_000 + })) as { answer: string; timedOut: boolean } + + expect(result.timedOut).toBe(false) + expect(result.answer).toBe('correct answer') + }) + + it('parses options CSV with whitespace and empty entries', async () => { + setup() + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + vi.spyOn(runtime, 'waitForMessage').mockResolvedValue() + + await call('orchestration.ask', { + from: 'w', + to: 'c', + question: 'q', + options: 'a, b ,,c', + timeoutMs: 1 + }) + + const outbound = db.getInbox(10).find((m) => m.type === 'decision_gate') + const payload = JSON.parse(outbound!.payload ?? '{}') + expect(payload.options).toEqual(['a', 'b', 'c']) + }) + }) + + describe('orchestration.reset', () => { + it('resets all state', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + db.createTask({ spec: 'work' }) + + const result = (await call('orchestration.reset', { all: true })) as { reset: string } + expect(result.reset).toBe('all') + expect(db.getInbox()).toHaveLength(0) + expect(db.listTasks()).toHaveLength(0) + }) + + it('resets tasks only', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + db.createTask({ spec: 'work' }) + + await call('orchestration.reset', { tasks: true }) + expect(db.getInbox()).toHaveLength(1) + expect(db.listTasks()).toHaveLength(0) + }) + + it('resets messages only', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + db.createTask({ spec: 'work' }) + + await call('orchestration.reset', { messages: true }) + expect(db.getInbox()).toHaveLength(0) + expect(db.listTasks()).toHaveLength(1) + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts new file mode 100644 index 00000000000..e1a2d68b943 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -0,0 +1,581 @@ +/* eslint-disable max-lines -- Why: RPC method definitions co-locate param schemas with handlers; splitting by method would scatter the shared enums and Zod transforms without reducing complexity. */ +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 { buildDispatchPreamble } from '../../orchestration/preamble' +import { formatMessageBanner } from '../../orchestration/formatter' +import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups' +import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' + +const MESSAGE_TYPES: MessageType[] = [ + 'status', + 'dispatch', + 'worker_done', + 'merge_ready', + 'escalation', + 'handoff', + 'decision_gate', + 'heartbeat' +] + +const TASK_STATUSES: TaskStatus[] = [ + 'pending', + 'ready', + 'dispatched', + 'completed', + 'failed', + 'blocked' +] + +const SendParams = z.object({ + to: requiredString('Missing --to'), + subject: requiredString('Missing --subject'), + from: OptionalString, + body: OptionalString, + type: z + .enum([ + 'status', + 'dispatch', + 'worker_done', + 'merge_ready', + 'escalation', + 'handoff', + 'decision_gate', + 'heartbeat' + ]) + .optional(), + priority: z.enum(['normal', 'high', 'urgent']).optional(), + threadId: OptionalString, + payload: OptionalString, + devMode: OptionalBoolean +}) + +const CheckParams = z.object({ + terminal: OptionalString, + unread: OptionalBoolean, + // Why: `all` surfaces every message for the handle and skips mark-read. + // Previously the only way to ask for "all" was the hidden RPC trick + // `{unread: false}`. See design doc §3.2 / §3.3. + all: OptionalBoolean, + types: OptionalString, + inject: OptionalBoolean, + wait: OptionalBoolean, + timeoutMs: OptionalFiniteNumber +}) + +const ReplyParams = z.object({ + id: requiredString('Missing --id'), + body: requiredString('Missing --body'), + from: OptionalString +}) + +const InboxParams = z.object({ + limit: OptionalFiniteNumber, + // Why: filters the inbox listing to a specific handle so coordinators can + // ask "everything for this handle" with either `inbox` or `check --all` + // and get agreeing results. See design doc §3.3. + terminal: OptionalString +}) + +const TaskCreateParams = z.object({ + spec: requiredString('Missing --spec'), + deps: OptionalString, + parent: OptionalString +}) + +const TaskListParams = z.object({ + status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), + ready: OptionalBoolean +}) + +const TaskUpdateParams = z.object({ + id: requiredString('Missing --id'), + status: z + .unknown() + .transform((v) => { + if (typeof v === 'string' && TASK_STATUSES.includes(v as TaskStatus)) { + return v as TaskStatus + } + return '' + }) + .pipe( + z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked'], { + message: 'Missing --status' + }) + ), + result: OptionalString +}) + +const DispatchParams = z.object({ + task: requiredString('Missing --task'), + // Why: --to is only required for real dispatches. When --dry-run is set the + // caller is previewing the preamble and no terminal is targeted, so allow it + // to be absent. The handler enforces presence before any side-effecting work. + to: OptionalString, + from: OptionalString, + inject: OptionalBoolean, + dryRun: OptionalBoolean, + returnPreamble: OptionalBoolean, + devMode: OptionalBoolean +}) + +const DispatchShowParams = z.object({ + task: OptionalString, + preamble: OptionalBoolean, + from: OptionalString, + devMode: OptionalBoolean +}) + +const AskParams = z.object({ + to: requiredString('Missing --to'), + question: requiredString('Missing --question'), + options: OptionalString, + timeoutMs: OptionalFiniteNumber, + from: OptionalString +}) + +const ResetParams = z.object({ + all: OptionalBoolean, + tasks: OptionalBoolean, + messages: OptionalBoolean +}) + +export const ORCHESTRATION_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.send', + params: SendParams, + handler: async (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const from = params.from ?? 'unknown' + + if (!isGroupAddress(params.to)) { + // Point-to-point — existing single-recipient behavior + const msg = db.insertMessage({ + from, + to: params.to, + subject: params.subject, + body: params.body, + type: params.type as MessageType, + priority: params.priority as MessagePriority, + threadId: params.threadId, + payload: params.payload + }) + runtime.deliverPendingMessagesForHandle(params.to) + runtime.notifyMessageArrived(params.to) + return { message: msg } + } + + // Why: group addresses fan out to one message per recipient so each gets + // independent read-tracking, but they share a thread_id so the conversation + // can be correlated (Section 4.5). + const { terminals } = await runtime.listTerminals() + const handles = resolveGroupAddress(params.to, from, terminals, (handle: string) => + runtime.getAgentStatusForHandle(handle) + ) + + if (handles.length === 0) { + throw new Error(`No recipients resolved for group address: ${params.to}`) + } + + const threadId = params.threadId ?? `thread_${Date.now()}` + const messages = handles.map((handle) => + db.insertMessage({ + from, + to: handle, + subject: params.subject, + body: params.body, + type: params.type as MessageType, + priority: params.priority as MessagePriority, + threadId, + payload: params.payload + }) + ) + for (const handle of handles) { + runtime.deliverPendingMessagesForHandle(handle) + runtime.notifyMessageArrived(handle) + } + + return { messages, recipients: handles.length } + } + }), + + defineMethod({ + name: 'orchestration.check', + params: CheckParams, + 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(',')}`) + } + + // Why: `all` short-circuits to "everything for the handle, no marking." + // Explicit `unread: false` is also honored for one release as a compat + // shim so in-flight callers don't break (see design doc §5). Otherwise + // today's behavior is preserved: default is unread-only + mark-read. + const showAll = params.all === true || params.unread === false + const showUnread = !showAll + + const readAndReturn = () => { + const messages = showUnread + ? db.getUnreadMessages(handle, typeFilter) + : db.getAllMessagesForHandle(handle) + + if (showUnread && messages.length > 0) { + db.markAsRead(messages.map((m) => m.id)) + } + + if (params.inject) { + const formatted = messages.map(formatMessageBanner).join('\n\n') + return { messages, formatted, count: messages.length } + } + + return { messages, count: messages.length } + } + + const result = readAndReturn() + if (result.count > 0 || !params.wait) { + return result + } + + // Why: blocking wait lets coordinators replace sleep+poll loops with a + // single call that resolves when a message arrives or the timeout + // expires. The `signal` plumbed from the RPC transport aborts this + // waiter the moment the client socket closes, so a killed client + // releases its long-poll slot immediately rather than after the full + // timeoutMs. See design doc §3.1 counter-lifecycle. + await runtime.waitForMessage(handle, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal + }) + return readAndReturn() + } + }), + + defineMethod({ + name: 'orchestration.reply', + params: ReplyParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const original = db.getMessageById(params.id) + if (!original) { + throw new Error(`Message not found: ${params.id}`) + } + + db.markAsRead([original.id]) + + const reply = db.insertMessage({ + from: params.from ?? original.to_handle, + to: original.from_handle, + subject: `Re: ${original.subject}`, + body: params.body, + threadId: original.thread_id ?? original.id + }) + + runtime.notifyMessageArrived(original.from_handle) + return { message: reply } + } + }), + + defineMethod({ + name: 'orchestration.inbox', + params: InboxParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + // Why: when `terminal` is provided, mirror `check --all` output for that + // handle (same rows in the same sequence order). Stale/unknown handles + // return an empty list instead of erroring, matching the "historical + // rows survive handle deletion" rule in design doc §3.3. + const messages = params.terminal + ? db.getAllMessagesForHandle(params.terminal, params.limit) + : db.getInbox(params.limit) + return { messages, count: messages.length } + } + }), + + defineMethod({ + name: 'orchestration.taskCreate', + params: TaskCreateParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + let deps: string[] | undefined + if (params.deps) { + try { + const parsed = JSON.parse(params.deps) + if (!Array.isArray(parsed) || !parsed.every((d) => typeof d === 'string')) { + throw new Error('not an array of strings') + } + deps = parsed + } catch { + throw new Error('Invalid --deps: must be a JSON array of task IDs') + } + } + const task = db.createTask({ + spec: params.spec, + deps, + parentId: params.parent + }) + return { task } + } + }), + + defineMethod({ + name: 'orchestration.taskList', + params: TaskListParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + // Why: listTasksWithDispatch returns the same rows as listTasks plus + // assignee_handle + dispatch_id joined in for tasks that currently have an + // active dispatch. Non-dispatched tasks get NULL for those fields, so + // consumers reading the legacy shape are unaffected. + const joined = db.listTasksWithDispatch({ + status: params.status as TaskStatus, + ready: params.ready + }) + const tasks = joined.map((row) => { + const { assignee_handle, dispatch_id, ...base } = row + if (base.status === 'dispatched') { + return { ...base, assignee_handle, dispatch_id } + } + return base + }) + return { tasks, count: tasks.length } + } + }), + + defineMethod({ + name: 'orchestration.taskUpdate', + params: TaskUpdateParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const task = db.updateTaskStatus(params.id, params.status, params.result) + if (!task) { + throw new Error(`Task not found: ${params.id}`) + } + return { task } + } + }), + + defineMethod({ + name: 'orchestration.dispatch', + params: DispatchParams, + handler: async (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const task = db.getTask(params.task) + if (!task) { + throw new Error(`Task not found: ${params.task}`) + } + + // Why: --inject --dry-run lets a coordinator preview the exact preamble + // text that would be injected without mutating task state or touching the + // target terminal. Skips the ready-status check so coordinators can inspect + // the preamble for already-dispatched or blocked tasks too. No dispatch + // context exists yet (that happens after the ready-status check), so + // dispatchId is a placeholder — the real injected preamble gets a real + // ctx.id below. + if (params.dryRun) { + const preamble = buildDispatchPreamble({ + taskId: task.id, + dispatchId: 'ctx_dryrun', + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + devMode: params.devMode + }) + return { dispatch: null, injected: false, dryRun: true, preamble } + } + + if (!params.to) { + throw new Error('Missing --to') + } + const to = params.to + + if (task.status !== 'ready') { + throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`) + } + + // Why: dispatching with --inject to a bare shell (zsh/bash) dumps the + // preamble as shell commands, producing gibberish. Check both OSC title + // status and foreground process — Claude Code doesn't emit recognized OSC + // titles on startup, so title-only detection misses freshly spawned agents. + if (params.inject) { + const hasAgent = await runtime.isTerminalRunningAgent(to) + if (!hasAgent) { + throw new Error( + `Cannot dispatch --inject to terminal ${to}: no recognized agent detected. ` + + 'Start an agent CLI (e.g. claude, codex, gemini) in the terminal first, ' + + 'or dispatch without --inject and send the prompt manually.' + ) + } + } + + const ctx = db.createDispatchContext(params.task, to) + + // Why: preamble is built here (not before ctx) so `dispatchId` can be + // the real ctx.id — the preamble-hardening PR made dispatchId required + // so heartbeats can attribute liveness to a specific dispatch context, + // not just a task. + const preamble = buildDispatchPreamble({ + taskId: task.id, + dispatchId: ctx.id, + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + devMode: params.devMode + }) + + let injected = false + if (params.inject) { + try { + await runtime.sendTerminal(to, { text: preamble, enter: true }) + injected = true + } catch (err) { + db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) + throw err + } + } + + // Why: returnPreamble is opt-in because the preamble is several hundred + // bytes and most callers don't need it in the response. Exposing it + // supports coordinators that want to log what was injected for auditing. + if (params.returnPreamble) { + return { dispatch: ctx, injected, preamble } + } + return { dispatch: ctx, injected } + } + }), + + defineMethod({ + name: 'orchestration.dispatchShow', + params: DispatchShowParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + if (!params.task) { + throw new Error('Missing --task') + } + const ctx = db.getDispatchContext(params.task) + + // Why: --preamble lets callers inspect the exact preamble text that was + // (or would be) injected for this task. The preamble is derived from the + // current task spec, so even after dispatch completes the text can be + // regenerated deterministically. + if (params.preamble) { + const task = db.getTask(params.task) + if (!task) { + throw new Error(`Task not found: ${params.task}`) + } + const preamble = buildDispatchPreamble({ + taskId: task.id, + // Why: prefer the existing dispatch context's id if we have one + // (so the preview matches what was actually injected); fall back + // to a placeholder when no dispatch has occurred yet. + dispatchId: ctx?.id ?? 'ctx_preview', + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + devMode: params.devMode + }) + return { dispatch: ctx ?? null, preamble } + } + + return { dispatch: ctx ?? null } + } + }), + + defineMethod({ + name: 'orchestration.ask', + params: AskParams, + handler: async (params, { runtime }) => { + // Why: group addresses have no unambiguous answer semantics (whose + // reply wins? first? consensus?) and the ~60-LOC scope is not the + // place to design that. Rejecting here closes the silent-timeout + // footgun where a worker passing `--to @reviewers` would have the + // decision_gate inserted against a literal string no one subscribes + // to. Workers that need fan-out fall back to `send --type decision_gate`. + if (isGroupAddress(params.to)) { + throw new Error( + 'ask does not support group addresses; use send --type decision_gate for fan-out questions' + ) + } + + const db = runtime.getOrchestrationDb() + const from = params.from ?? 'unknown' + const timeoutMs = params.timeoutMs ?? 600_000 + const options = + params.options + ?.split(',') + .map((s) => s.trim()) + .filter(Boolean) ?? [] + + const payload = JSON.stringify({ question: params.question, options }) + const outbound = db.insertMessage({ + from, + to: params.to, + subject: 'Question', + body: params.question, + type: 'decision_gate', + payload + }) + runtime.deliverPendingMessagesForHandle(params.to) + runtime.notifyMessageArrived(params.to) + + const threadId = outbound.id + const deadline = Date.now() + timeoutMs + const afterSequence = outbound.sequence + + // Why: loop with a remaining-budget guard so an unrelated distractor + // message that wakes waitForMessage does not cause indefinite iteration. + // waitForMessage is handle-scoped, so we re-query by thread on every + // wake-up to separate "reply in my thread arrived" from "something + // else was delivered to this handle." + while (true) { + const replies = db.getThreadMessagesFor(threadId, from, afterSequence) + if (replies.length > 0) { + const reply = replies[0] + db.markAsRead([reply.id]) + return { + answer: reply.body, + messageId: reply.id, + threadId, + timedOut: false + } + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + return { answer: null, messageId: null, threadId, timedOut: true } + } + await runtime.waitForMessage(from, { timeoutMs: remainingMs }) + } + } + }), + + ...ORCHESTRATION_GATE_METHODS, + + defineMethod({ + name: 'orchestration.reset', + params: ResetParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + if (params.all) { + db.resetAll() + return { reset: 'all' } + } + if (params.tasks) { + db.resetTasks() + return { reset: 'tasks' } + } + if (params.messages) { + db.resetMessages() + return { reset: 'messages' } + } + db.resetAll() + return { reset: 'all' } + } + }) +] diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index b3a851dac0d..3af1133f836 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -52,5 +52,10 @@ export const REPO_METHODS: RpcMethod[] = [ params: RepoSearchRefs, handler: async (params, { runtime }) => runtime.searchRepoRefs(params.repo, params.query, params.limit) + }), + defineMethod({ + name: 'repo.hooks', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.getRepoHooks(params.repo) }) ] diff --git a/src/main/runtime/rpc/methods/stats.ts b/src/main/runtime/rpc/methods/stats.ts new file mode 100644 index 00000000000..59f71701c3a --- /dev/null +++ b/src/main/runtime/rpc/methods/stats.ts @@ -0,0 +1,11 @@ +import { defineMethod, type RpcMethod } from '../core' + +export const STATS_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'stats.summary', + params: null, + handler: async (_params, { runtime }) => { + return runtime.getStatsSummary() ?? {} + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index cc3db888eb9..97c5224cc90 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1,7 +1,16 @@ +/* oxlint-disable max-lines -- Why: terminal RPC methods are co-located for discoverability; splitting would scatter related handlers across files. */ import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +// Why: when a mobile client subscribes the server resizes the PTY to phone +// dims and serializes the buffer. Sending only the visible screen meant +// users coming back to the app or switching terminals could no longer scroll +// up to see prior agent output. Include enough scrollback to keep typical +// agent runs (Claude Code chats, command output) reachable. The mobile +// WebView's xterm has a 5000-row buffer so this fits comfortably. +const MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000 + const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) @@ -49,7 +58,18 @@ const TerminalRename = TerminalHandle.extend({ const TerminalSend = TerminalHandle.extend({ text: OptionalString, enter: z.unknown().optional(), - interrupt: z.unknown().optional() + interrupt: z.unknown().optional(), + // Why: identifies the caller for the driver state machine. Optional for + // backward compatibility with older mobile clients (server falls back to + // the most recent mobile actor when absent). New mobile builds populate + // this so multi-mobile semantics resolve correctly. See + // docs/mobile-presence-lock.md. + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop').optional() + }) + .optional() }) const TerminalWait = TerminalHandle.extend({ @@ -77,7 +97,81 @@ const TerminalStop = z.object({ worktree: requiredString('Missing worktree selector') }) -export const TERMINAL_METHODS: RpcMethod[] = [ +const TerminalResizeForClient = z.discriminatedUnion('mode', [ + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('mobile-fit'), + cols: z.number().finite().positive(), + rows: z.number().finite().positive(), + clientId: requiredString('Missing client ID') + }), + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('restore'), + clientId: requiredString('Missing client ID') + }) +]) + +const TerminalSubscribe = TerminalHandle.extend({ + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop') + }) + .optional(), + viewport: z + .object({ + cols: z.number().int().min(20).max(240), + rows: z.number().int().min(8).max(120) + }) + .optional() +}) + +const TerminalSetDisplayMode = TerminalHandle.extend({ + mode: z.enum(['auto', 'phone', 'desktop']), + // Why: identifies the caller for the driver state machine. Optional for + // backward compatibility with older mobile clients. + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop').optional() + }) + .optional() +}) + +const TerminalUnsubscribe = z.object({ + subscriptionId: requiredString('Missing subscription ID'), + // Why: required when subscribe registered the cleanup under the composite + // key `${terminal}:${clientId}`. If the caller passes a bare-handle + // subscriptionId (older clients), the server reconstructs the composite + // key from `client.id`. See docs/mobile-presence-lock.md. + client: z + .object({ + id: requiredString('Missing client ID') + }) + .optional() +}) + +// Why: in-place viewport update for an existing mobile subscription. Used +// when the keyboard opens/closes on the mobile client and the visible +// terminal area changes — without this, the mobile app had to +// unsubscribe → resubscribe, which (a) flashed the desktop lock banner +// during the brief idle gap and (b) caused the new subscribe to capture +// the already-phone-fitted PTY size as its restore baseline, leaving the +// PTY stuck at phone dims after the phone disconnected. See +// docs/mobile-presence-lock.md. +const TerminalUpdateViewport = TerminalHandle.extend({ + client: z.object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('mobile').optional() + }), + viewport: z.object({ + cols: z.number().int().min(20).max(240), + rows: z.number().int().min(8).max(120) + }) +}) + +export const TERMINAL_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.list', params: TerminalListParams, @@ -114,13 +208,27 @@ export const TERMINAL_METHODS: RpcMethod[] = [ defineMethod({ name: 'terminal.send', params: TerminalSend, - handler: async (params, { runtime }) => ({ - send: await runtime.sendTerminal(params.terminal, { + handler: async (params, { runtime }) => { + const result = await runtime.sendTerminal(params.terminal, { text: params.text, enter: params.enter === true, interrupt: params.interrupt === true }) - }) + // Why: deliberate mobile input is a take-floor action. Drives the + // `* → mobile{clientId}` driver transition so the desktop banner + // remounts (if previously reclaimed) and active phone-fit dims follow + // the most recent actor. Only mobile-typed callers take the floor; + // desktop callers (CLI / agents) do not. Older mobile builds without + // a `client` field continue to work — the runtime then keeps the + // current driver state. + if (params.client && params.client.type === 'mobile') { + const leaf = runtime.resolveLeafForHandle(params.terminal) + if (leaf?.ptyId) { + runtime.mobileTookFloor(leaf.ptyId, params.client.id) + } + } + return { send: result } + } }), defineMethod({ name: 'terminal.wait', @@ -157,6 +265,29 @@ export const TERMINAL_METHODS: RpcMethod[] = [ params: TerminalStop, handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree) }), + defineMethod({ + name: 'terminal.resizeForClient', + params: TerminalResizeForClient, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + const result = runtime.resizeForClient( + leaf.ptyId, + params.mode, + params.clientId, + params.mode === 'mobile-fit' ? params.cols : undefined, + params.mode === 'mobile-fit' ? params.rows : undefined + ) + return { + terminal: { + handle: params.terminal, + ...result + } + } + } + }), defineMethod({ name: 'terminal.focus', params: TerminalHandle, @@ -170,5 +301,180 @@ export const TERMINAL_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => ({ close: await runtime.closeTerminal(params.terminal) }) + }), + defineMethod({ + name: 'terminal.setDisplayMode', + params: TerminalSetDisplayMode, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + runtime.setMobileDisplayMode(leaf.ptyId, params.mode) + runtime.applyMobileDisplayMode(leaf.ptyId) + // Why: a deliberate mobile mode change is a take-floor action when + // moving to auto/phone (the user explicitly chose to drive at phone + // dims). Setting mode to desktop is intentionally NOT a take-floor + // action — that's a "watch from desktop dims" gesture. + if (params.client && params.client.type === 'mobile' && params.mode !== 'desktop') { + runtime.mobileTookFloor(leaf.ptyId, params.client.id) + } + return { mode: params.mode } + } + }), + defineMethod({ + name: 'terminal.getDisplayMode', + params: TerminalHandle, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + const mode = leaf?.ptyId ? runtime.getMobileDisplayMode(leaf.ptyId) : 'auto' + const isPhoneFitted = leaf?.ptyId ? runtime.isMobileSubscriberActive(leaf.ptyId) : false + return { mode, isPhoneFitted } + } + }), + defineMethod({ + name: 'terminal.updateViewport', + params: TerminalUpdateViewport, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + const updated = runtime.updateMobileViewport(leaf.ptyId, params.client.id, params.viewport) + return { updated } + } + }), + // Why: terminal.subscribe streams live terminal output over WebSocket. + // It sends initial scrollback, then live data chunks as they arrive. + // Mobile clients pass client+viewport params for server-side auto-fit. + defineStreamingMethod({ + name: 'terminal.subscribe', + params: TerminalSubscribe, + handler: async (params, { runtime }, emit) => { + let leaf = runtime.resolveLeafForHandle(params.terminal) + const isMobile = params.client?.type === 'mobile' + + // Why: the left pane's PTY spawns asynchronously after the tab is created. + // Mobile clients that subscribe before the PTY is ready would get a bare + // scrollback+end with no live stream or phone-fit. Wait for the PTY so + // the subscribe can proceed normally. + if (!leaf?.ptyId && isMobile) { + try { + const ptyId = await runtime.waitForLeafPtyId(params.terminal) + leaf = { ptyId } + } catch { + // PTY wait timed out — fall through to scrollback-only path below + } + } + + if (!leaf?.ptyId) { + const read = await runtime.readTerminal(params.terminal) + emit({ + type: 'scrollback', + lines: read.tail, + truncated: read.truncated, + serialized: undefined, + cols: undefined, + rows: undefined + }) + emit({ type: 'end' }) + return + } + + const ptyId = leaf.ptyId + const clientId = params.client?.id + + // Server-side auto-fit: resize PTY to phone dims before serializing scrollback + if (isMobile && clientId) { + runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + } + + const read = await runtime.readTerminal(params.terminal) + const serialized = await runtime.serializeTerminalBuffer(ptyId, { + scrollbackRows: isMobile ? MOBILE_SUBSCRIBE_SCROLLBACK_ROWS : 0 + }) + const size = runtime.getTerminalSize(ptyId) + const displayMode = runtime.getMobileDisplayMode(ptyId) + emit({ + type: 'scrollback', + lines: read.tail, + truncated: read.truncated, + serialized: serialized?.data, + cols: serialized?.cols ?? size?.cols, + rows: serialized?.rows ?? size?.rows, + displayMode + }) + + await new Promise((resolve) => { + const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { + emit({ type: 'data', chunk: data }) + }) + + // Inline resize events replace the old fit-override-changed event for + // mobile clients. They include fresh serialized scrollback so the client + // can reinitialize xterm without resubscribing. + const unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, async (event) => { + // Why: mobile subscriptions need the same scrollback on inline + // resize as on initial subscribe — without it, toggling phone/desktop + // mode or the keyboard-driven refit would silently wipe history. + const fresh = await runtime.serializeTerminalBuffer(ptyId, { + scrollbackRows: isMobile ? MOBILE_SUBSCRIBE_SCROLLBACK_ROWS : 0 + }) + emit({ + type: 'resized', + cols: event.cols, + rows: event.rows, + serialized: fresh?.data, + displayMode: event.displayMode, + reason: event.reason + }) + }) + + // Legacy fit-override-changed for non-mobile (desktop) subscribers + const unsubscribeFit = !isMobile + ? runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + emit({ + type: 'fit-override-changed', + mode: event.mode, + cols: event.cols, + rows: event.rows + }) + }) + : () => {} + + // Why: composite subscriptionId per (terminal, clientId) so two + // mobile clients subscribing to the same terminal handle do not + // evict each other via registerSubscriptionCleanup's + // duplicate-key cleanup. See docs/mobile-presence-lock.md. + const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal + runtime.registerSubscriptionCleanup(subscriptionId, () => { + unsubscribeData() + unsubscribeResize() + unsubscribeFit() + if (isMobile && clientId) { + runtime.handleMobileUnsubscribe(ptyId, clientId) + } + emit({ type: 'end' }) + resolve() + }) + }) + } + }), + defineMethod({ + name: 'terminal.unsubscribe', + params: TerminalUnsubscribe, + handler: async (params, { runtime }) => { + // Why: the subscribe handler now registers cleanup under a composite + // key `${terminal}:${clientId}`. New mobile builds emit the composite + // key directly. Older builds emit a bare-handle subscriptionId; if + // they additionally provide `client.id`, reconstruct the composite + // key server-side. We always try the as-sent value first, then fall + // back to the reconstructed composite, so both wire formats work. + runtime.cleanupSubscription(params.subscriptionId) + if (params.client && !params.subscriptionId.includes(':')) { + runtime.cleanupSubscription(`${params.subscriptionId}:${params.client.id}`) + } + return { unsubscribed: true } + } }) ] diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index acc588d049c..248d81f9c2b 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -28,23 +28,32 @@ const WorktreeCreate = z.object({ .unknown() .transform((v) => (typeof v === 'string' ? v : '')) .pipe(z.string().min(1, 'Missing repo selector')), - name: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree name')), + name: OptionalString, baseBranch: OptionalString, linkedIssue: TriStateLinkedIssue, - comment: OptionalString + comment: OptionalString, + runHooks: OptionalBoolean, + setupDecision: z + .unknown() + .transform((v) => + typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined + ) + .pipe(z.enum(['run', 'skip', 'inherit']).optional()), + // Why: mobile clients pass a startup command (e.g. 'claude') so the first + // terminal pane launches the selected agent instead of an idle shell. + startupCommand: OptionalString }) const WorktreeSet = WorktreeSelector.extend({ displayName: OptionalString, linkedIssue: TriStateLinkedIssue, - comment: OptionalString + comment: OptionalString, + isPinned: OptionalBoolean }) const WorktreeRemove = WorktreeSelector.extend({ - force: OptionalBoolean + force: OptionalBoolean, + runHooks: OptionalBoolean }) export const WORKTREE_METHODS: RpcMethod[] = [ @@ -65,16 +74,29 @@ export const WORKTREE_METHODS: RpcMethod[] = [ worktree: await runtime.showManagedWorktree(params.worktree) }) }), + defineMethod({ + name: 'worktree.sleep', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.sleepManagedWorktree(params.worktree) + }), + defineMethod({ + name: 'worktree.activate', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.activateManagedWorktree(params.worktree) + }), defineMethod({ name: 'worktree.create', params: WorktreeCreate, handler: async (params, { runtime }) => runtime.createManagedWorktree({ repoSelector: params.repo, - name: params.name, + name: params.name ?? '', baseBranch: params.baseBranch, linkedIssue: params.linkedIssue, - comment: params.comment + comment: params.comment, + runHooks: params.runHooks === true, + setupDecision: params.setupDecision, + startup: params.startupCommand ? { command: params.startupCommand } : undefined }) }), defineMethod({ @@ -84,7 +106,8 @@ export const WORKTREE_METHODS: RpcMethod[] = [ worktree: await runtime.updateManagedWorktreeMeta(params.worktree, { displayName: params.displayName, linkedIssue: params.linkedIssue, - comment: params.comment + comment: params.comment, + isPinned: params.isPinned }) }) }), @@ -92,8 +115,12 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: 'worktree.rm', params: WorktreeRemove, handler: async (params, { runtime }) => { - await runtime.removeManagedWorktree(params.worktree, params.force === true) - return { removed: true } + const result = await runtime.removeManagedWorktree( + params.worktree, + params.force === true, + params.runHooks === true + ) + return { removed: true, ...result } } }) ] diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts new file mode 100644 index 00000000000..7afa5ae7553 --- /dev/null +++ b/src/main/runtime/rpc/streaming.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { RpcDispatcher } from './dispatcher' +import { defineMethod, defineStreamingMethod, type RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + ...overrides + } as OrcaRuntimeService +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('RpcDispatcher streaming', () => { + it('sends initial scrollback via emit', async () => { + const messages: string[] = [] + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime({ + readTerminal: vi.fn().mockResolvedValue({ tail: 'hello\nworld\n', truncated: false }), + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }) + }), + methods: [ + defineStreamingMethod({ + name: 'terminal.subscribe', + params: z.object({ terminal: z.string() }), + handler: async (params, { runtime }, emit) => { + const read = await (runtime as OrcaRuntimeService).readTerminal(params.terminal) + emit({ type: 'scrollback', lines: read.tail, truncated: read.truncated }) + + const leaf = (runtime as OrcaRuntimeService).resolveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + emit({ type: 'end' }) + } + } + }) + ] + }) + + await dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { terminal: 'h-1' }), + (msg) => messages.push(msg) + ) + + expect(messages).toHaveLength(2) + const scrollback = JSON.parse(messages[0]!) + expect(scrollback).toMatchObject({ + ok: true, + streaming: true, + result: { type: 'scrollback', lines: 'hello\nworld\n', truncated: false } + }) + const end = JSON.parse(messages[1]!) + expect(end).toMatchObject({ + ok: true, + streaming: true, + result: { type: 'end' } + }) + }) + + it('streams live data chunks via emit', async () => { + const messages: string[] = [] + let emitFn: ((result: unknown) => void) | null = null + let resolveHandler: (() => void) | null = null + + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [ + defineStreamingMethod({ + name: 'test.stream', + params: null, + handler: async (_params, _ctx, emit) => { + emitFn = emit + await new Promise((resolve) => { + resolveHandler = resolve + }) + } + }) + ] + }) + + const dispatchPromise = dispatcher.dispatchStreaming(makeRequest('test.stream'), (msg) => + messages.push(msg) + ) + + // Wait for handler to capture emit + await vi.waitFor(() => expect(emitFn).not.toBeNull()) + + emitFn!({ type: 'data', chunk: 'line 1\n' }) + emitFn!({ type: 'data', chunk: 'line 2\n' }) + emitFn!({ type: 'end' }) + resolveHandler!() + + await dispatchPromise + + expect(messages).toHaveLength(3) + expect(JSON.parse(messages[0]!)).toMatchObject({ + streaming: true, + result: { type: 'data', chunk: 'line 1\n' } + }) + expect(JSON.parse(messages[1]!)).toMatchObject({ + streaming: true, + result: { type: 'data', chunk: 'line 2\n' } + }) + expect(JSON.parse(messages[2]!)).toMatchObject({ + streaming: true, + result: { type: 'end' } + }) + }) + + it('unsubscribe stops further streaming', async () => { + const messages: string[] = [] + let cleanup: (() => void) | null = null + + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime({ + registerSubscriptionCleanup: vi.fn().mockImplementation((_id: string, fn: () => void) => { + cleanup = fn + }), + cleanupSubscription: vi.fn().mockImplementation(() => { + cleanup?.() + }) + }), + methods: [ + defineStreamingMethod({ + name: 'test.subscribe', + params: null, + handler: async (_params, { runtime }, emit) => { + emit({ type: 'scrollback', lines: '' }) + + await new Promise((resolve) => { + ;(runtime as OrcaRuntimeService).registerSubscriptionCleanup('sub-1', () => { + emit({ type: 'end' }) + resolve() + }) + }) + } + }), + defineMethod({ + name: 'test.unsubscribe', + params: z.object({ subscriptionId: z.string() }), + handler: async (params, { runtime }) => { + ;(runtime as OrcaRuntimeService).cleanupSubscription(params.subscriptionId) + return { unsubscribed: true } + } + }) + ] + }) + + const subPromise = dispatcher.dispatchStreaming(makeRequest('test.subscribe'), (msg) => + messages.push(msg) + ) + + await vi.waitFor(() => expect(cleanup).not.toBeNull()) + + const unsubMessages: string[] = [] + await dispatcher.dispatchStreaming( + { + id: 'req-unsub', + authToken: 'tok', + method: 'test.unsubscribe', + params: { subscriptionId: 'sub-1' } + }, + (msg) => unsubMessages.push(msg) + ) + + await subPromise + + expect(unsubMessages).toHaveLength(1) + expect(JSON.parse(unsubMessages[0]!)).toMatchObject({ + ok: true, + result: { unsubscribed: true } + }) + + const streamMessages = messages.map((m) => JSON.parse(m)) + expect(streamMessages).toContainEqual( + expect.objectContaining({ result: { type: 'scrollback', lines: '' } }) + ) + expect(streamMessages).toContainEqual(expect.objectContaining({ result: { type: 'end' } })) + }) + + it('falls back to one-shot dispatch for non-streaming methods via dispatchStreaming', async () => { + const messages: string[] = [] + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [ + defineMethod({ + name: 'status.get', + params: null, + handler: async () => ({ status: 'ok' }) + }) + ] + }) + + await dispatcher.dispatchStreaming(makeRequest('status.get'), (msg) => messages.push(msg)) + + expect(messages).toHaveLength(1) + const response = JSON.parse(messages[0]!) + expect(response).toMatchObject({ ok: true, result: { status: 'ok' } }) + expect(response.streaming).toBeUndefined() + }) + + it('returns error for unknown method via dispatchStreaming', async () => { + const messages: string[] = [] + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [] + }) + + await dispatcher.dispatchStreaming(makeRequest('nonexistent.method'), (msg) => + messages.push(msg) + ) + + expect(messages).toHaveLength(1) + expect(JSON.parse(messages[0]!)).toMatchObject({ + ok: false, + error: { code: 'method_not_found' } + }) + }) + + it('returns error when streaming method is called via one-shot dispatch', async () => { + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [ + defineStreamingMethod({ + name: 'test.stream', + params: null, + handler: async () => {} + }) + ] + }) + + const response = await dispatcher.dispatch(makeRequest('test.stream')) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'method_not_supported' } + }) + }) + + it('captures handler errors in streaming dispatch', async () => { + const messages: string[] = [] + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [ + defineStreamingMethod({ + name: 'test.explode', + params: null, + handler: async () => { + throw new Error('boom') + } + }) + ] + }) + + await dispatcher.dispatchStreaming(makeRequest('test.explode'), (msg) => messages.push(msg)) + + expect(messages).toHaveLength(1) + expect(JSON.parse(messages[0]!)).toMatchObject({ + ok: false, + error: { code: 'runtime_error' } + }) + }) +}) diff --git a/src/main/runtime/rpc/transport.ts b/src/main/runtime/rpc/transport.ts new file mode 100644 index 00000000000..8eedd20c0fb --- /dev/null +++ b/src/main/runtime/rpc/transport.ts @@ -0,0 +1,22 @@ +// Why: the transport interface decouples the RPC server from a specific +// transport mechanism (Unix socket, WebSocket, named pipe). Each transport +// owns its own connection lifecycle — the RPC server just binds message +// handling to whatever transports are registered. Individual transports +// override `onMessage` with their own richer signatures (e.g. Unix adds a +// `RpcMessageContext` with an abort signal; WebSocket adds the `ws` handle +// for auth association). Consumers hold a concrete transport type, not +// `RpcTransport`, when they need those extensions. + +// Why: per-message hook bag owned by the Unix transport. `signal` aborts +// when the underlying connection terminates so long-poll handlers can bail +// out. `startKeepalive` is opt-in per request — only long-poll dispatches +// call it, so short RPCs pay no timer overhead. See design doc §3.1. +export type RpcMessageContext = { + signal: AbortSignal + startKeepalive: () => void +} + +export type RpcTransport = { + start(): Promise + stop(): Promise +} diff --git a/src/main/runtime/rpc/unix-socket-transport.ts b/src/main/runtime/rpc/unix-socket-transport.ts new file mode 100644 index 00000000000..50c7d9822ca --- /dev/null +++ b/src/main/runtime/rpc/unix-socket-transport.ts @@ -0,0 +1,203 @@ +// Why: this is the original Unix socket / named pipe transport extracted from +// runtime-rpc.ts. It preserves the exact same behavior: newline-delimited JSON, +// 30s idle timeout, 1MB max message, 32 max connections, chmod 0o600 on Unix. +// It also owns the keepalive timer and per-connection abort signal so the +// server-side handler can cancel long-poll dispatches when the client goes +// away. See design doc §3.1. +import { createServer, type Server, type Socket } from 'net' +import { chmodSync, existsSync, rmSync } from 'fs' +import type { RpcMessageContext, RpcTransport } from './transport' + +const MAX_RUNTIME_RPC_MESSAGE_BYTES = 1024 * 1024 +const RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS = 30_000 +const MAX_RUNTIME_RPC_CONNECTIONS = 32 +const DEFAULT_KEEPALIVE_INTERVAL_MS = 10_000 + +export type UnixSocketTransportOptions = { + endpoint: string + kind: 'unix' | 'named-pipe' + // Why: how often to write `{"_keepalive":true}\n` frames while a dispatch + // is pending. Each write resets both the server-side idle timer and, once + // the client honours them, the client-side idle timer. Tests override this + // to avoid waiting 10 s for a frame. + keepaliveIntervalMs?: number +} + +type MessageHandler = ( + msg: string, + reply: (response: string) => void, + context?: RpcMessageContext +) => void + +export class UnixSocketTransport implements RpcTransport { + private readonly endpoint: string + private readonly kind: 'unix' | 'named-pipe' + private readonly keepaliveIntervalMs: number + private server: Server | null = null + private messageHandler: MessageHandler | null = null + + constructor({ endpoint, kind, keepaliveIntervalMs }: UnixSocketTransportOptions) { + this.endpoint = endpoint + this.kind = kind + this.keepaliveIntervalMs = keepaliveIntervalMs ?? DEFAULT_KEEPALIVE_INTERVAL_MS + } + + onMessage(handler: MessageHandler): void { + this.messageHandler = handler + } + + async start(): Promise { + if (this.server) { + return + } + + if (this.kind === 'unix' && existsSync(this.endpoint)) { + rmSync(this.endpoint, { force: true }) + } + + const server = createServer((socket) => { + this.handleConnection(socket) + }) + server.maxConnections = MAX_RUNTIME_RPC_CONNECTIONS + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(this.endpoint, () => { + server.off('error', reject) + resolve() + }) + }) + + if (this.kind === 'unix') { + chmodSync(this.endpoint, 0o600) + } + + this.server = server + } + + async stop(): Promise { + const server = this.server + this.server = null + if (!server) { + return + } + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + if (this.kind === 'unix' && existsSync(this.endpoint)) { + rmSync(this.endpoint, { force: true }) + } + } + + private handleConnection(socket: Socket): void { + let buffer = '' + let oversized = false + // Why: each in-flight dispatch registers its own AbortController here so + // `socket.on('close')` can abort them all at once. Keeping the set scoped + // to the connection (rather than a single shared controller) means + // completing one dispatch does not abort any other dispatch still running + // on the same socket — future-proofing for a persistent CLI socket that + // multiplexes sequential requests. + const inflight = new Set() + + socket.setEncoding('utf8') + socket.setNoDelay(true) + socket.setTimeout(RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS, () => { + socket.destroy() + }) + socket.on('error', () => { + socket.destroy() + }) + socket.on('close', () => { + for (const ctrl of inflight) { + ctrl.abort() + } + inflight.clear() + }) + socket.on('data', (chunk: string) => { + if (oversized) { + return + } + buffer += chunk + // Why: the Orca runtime lives in Electron main, so it must reject + // oversized local RPC frames instead of letting a local client grow an + // unbounded buffer and stall the app. + if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) { + oversized = true + this.messageHandler?.('', (response) => { + socket.write(`${response}\n`) + socket.end() + }) + return + } + let newlineIndex = buffer.indexOf('\n') + while (newlineIndex !== -1) { + const rawMessage = buffer.slice(0, newlineIndex).trim() + buffer = buffer.slice(newlineIndex + 1) + if (rawMessage) { + this.dispatchMessage(socket, rawMessage, inflight) + } + newlineIndex = buffer.indexOf('\n') + } + }) + } + + // Why: the keepalive timer is opt-in per request via `startKeepalive()`. + // Short RPCs never call it and pay no timer overhead; only long-poll + // handlers (e.g. orchestration.check --wait) arm it. See §3.1. + private dispatchMessage( + socket: Socket, + rawMessage: string, + inflight: Set + ): void { + let replied = false + let keepaliveTimer: NodeJS.Timeout | null = null + // Why: per-dispatch AbortController so completing one request does not + // abort any sibling request running on the same connection. The + // connection-level `socket.on('close')` iterates `inflight` to cancel all + // outstanding dispatches at once. + const abortController = new AbortController() + inflight.add(abortController) + + const reply = (response: string): void => { + if (replied) { + return + } + replied = true + inflight.delete(abortController) + if (keepaliveTimer) { + clearInterval(keepaliveTimer) + } + if (!socket.destroyed && socket.writable) { + socket.write(`${response}\n`) + } + } + + const startKeepalive = (): void => { + if (keepaliveTimer || replied) { + return + } + keepaliveTimer = setInterval(() => { + if (replied || socket.destroyed || !socket.writable) { + return + } + socket.write('{"_keepalive":true}\n') + }, this.keepaliveIntervalMs) + // Why: don't hold the process open solely on the keepalive interval. + if (typeof keepaliveTimer.unref === 'function') { + keepaliveTimer.unref() + } + } + + this.messageHandler?.(rawMessage, reply, { + signal: abortController.signal, + startKeepalive + }) + } +} diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts new file mode 100644 index 00000000000..8bd80ca53d8 --- /dev/null +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -0,0 +1,312 @@ +import { mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it, afterEach } from 'vitest' +import WebSocket from 'ws' +import { WebSocketTransport } from './ws-transport' +import { loadOrCreateTlsCertificate } from '../tls-certificate' + +// Why: disable TLS verification for self-signed certs in tests. +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + +function makeTls() { + const userDataPath = mkdtempSync(join(tmpdir(), 'ws-transport-test-')) + return loadOrCreateTlsCertificate(userDataPath) +} + +function findFreePort(): number { + // Why: use port 0 to let the OS assign an available port, but the transport + // needs an explicit port. Use a high random port to minimize collisions. + return 30000 + Math.floor(Math.random() * 20000) +} + +describe('WebSocketTransport', () => { + const transports: WebSocketTransport[] = [] + + afterEach(async () => { + await Promise.all(transports.map((t) => t.stop().catch(() => {}))) + transports.length = 0 + }) + + async function createTransport( + handler?: (msg: string, reply: (response: string) => void) => void + ) { + const tls = makeTls() + const port = findFreePort() + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port, + tlsCert: tls.cert, + tlsKey: tls.key + }) + if (handler) { + transport.onMessage(handler) + } + transports.push(transport) + return { transport, port, tls } + } + + function connectWs(port: number): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`wss://127.0.0.1:${port}`, { + rejectUnauthorized: false + }) + ws.once('open', () => resolve(ws)) + ws.once('error', reject) + }) + } + + function sendAndReceive(ws: WebSocket, message: string): Promise { + return new Promise((resolve) => { + ws.once('message', (data) => { + resolve(typeof data === 'string' ? data : data.toString('utf-8')) + }) + ws.send(message) + }) + } + + it('starts and stops cleanly', async () => { + const { transport } = await createTransport() + + await transport.start() + await transport.stop() + }) + + it('handles request/response round-trip', async () => { + const { transport, port } = await createTransport((msg, reply) => { + const request = JSON.parse(msg) + reply(JSON.stringify({ id: request.id, ok: true, result: { echo: true } })) + }) + + await transport.start() + + const ws = await connectWs(port) + const response = await sendAndReceive( + ws, + JSON.stringify({ id: 'req-1', method: 'test', deviceToken: 'tok' }) + ) + + expect(JSON.parse(response)).toMatchObject({ + id: 'req-1', + ok: true, + result: { echo: true } + }) + + ws.close() + }) + + it('supports multiple concurrent connections', async () => { + const { transport, port } = await createTransport((msg, reply) => { + const request = JSON.parse(msg) + reply(JSON.stringify({ id: request.id, ok: true })) + }) + + await transport.start() + + const clients = await Promise.all([connectWs(port), connectWs(port), connectWs(port)]) + + const responses = await Promise.all( + clients.map((ws, i) => sendAndReceive(ws, JSON.stringify({ id: `req-${i}`, method: 'test' }))) + ) + + for (let i = 0; i < 3; i++) { + expect(JSON.parse(responses[i]!)).toMatchObject({ id: `req-${i}`, ok: true }) + } + + for (const ws of clients) { + ws.close() + } + }) + + it('multiplexes multiple requests on a single connection', async () => { + const { transport, port } = await createTransport((msg, reply) => { + const request = JSON.parse(msg) + reply(JSON.stringify({ id: request.id, ok: true, result: { method: request.method } })) + }) + + await transport.start() + + const ws = await connectWs(port) + + const r1 = sendAndReceive(ws, JSON.stringify({ id: 'a', method: 'first' })) + const resp1 = JSON.parse(await r1) + expect(resp1).toMatchObject({ id: 'a', result: { method: 'first' } }) + + const r2 = sendAndReceive(ws, JSON.stringify({ id: 'b', method: 'second' })) + const resp2 = JSON.parse(await r2) + expect(resp2).toMatchObject({ id: 'b', result: { method: 'second' } }) + + ws.close() + }) + + it('sends multiple streaming responses via reply callback', async () => { + const { transport, port } = await createTransport((msg, reply) => { + const request = JSON.parse(msg) + reply(JSON.stringify({ id: request.id, ok: true, streaming: true, result: { chunk: 1 } })) + reply(JSON.stringify({ id: request.id, ok: true, streaming: true, result: { chunk: 2 } })) + reply(JSON.stringify({ id: request.id, ok: true, result: { type: 'end' } })) + }) + + await transport.start() + + const ws = await connectWs(port) + const messages: string[] = [] + + await new Promise((resolve) => { + ws.on('message', (data) => { + messages.push(typeof data === 'string' ? data : data.toString('utf-8')) + if (messages.length === 3) { + resolve() + } + }) + ws.send(JSON.stringify({ id: 'stream-1', method: 'terminal.subscribe' })) + }) + + expect(JSON.parse(messages[0]!)).toMatchObject({ streaming: true, result: { chunk: 1 } }) + expect(JSON.parse(messages[1]!)).toMatchObject({ streaming: true, result: { chunk: 2 } }) + expect(JSON.parse(messages[2]!)).toMatchObject({ result: { type: 'end' } }) + + ws.close() + }) + + it('rejects oversized messages by closing the connection', async () => { + const { transport, port } = await createTransport() + + await transport.start() + + const ws = await connectWs(port) + + // Why: ws maxPayload is 1MB — sending >1MB should trigger close. + const oversized = 'x'.repeat(1024 * 1024 + 100) + + await new Promise((resolve) => { + ws.once('close', () => resolve()) + ws.send(oversized) + }) + }) + + it('does not crash when replying to a closed connection', async () => { + let capturedReply: ((response: string) => void) | null = null + + const { transport, port } = await createTransport((_msg, reply) => { + capturedReply = reply + }) + + await transport.start() + + const ws = await connectWs(port) + ws.send(JSON.stringify({ id: 'req-1', method: 'test' })) + + // Why: wait for the handler to capture the reply function. + await new Promise((resolve) => { + const interval = setInterval(() => { + if (capturedReply) { + clearInterval(interval) + resolve() + } + }, 10) + }) + + ws.close() + + // Why: wait for the WebSocket to fully close before trying to reply. + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Should not throw — guards with readyState check. + expect(() => capturedReply!(JSON.stringify({ id: 'req-1', ok: true }))).not.toThrow() + }) + + it('is idempotent on double start', async () => { + const { transport } = await createTransport() + + await transport.start() + await transport.start() + + await transport.stop() + }) + + it('is safe to stop without starting', async () => { + const { transport } = await createTransport() + await transport.stop() + }) + + it('falls back to OS-assigned port when preferred port is in use', async () => { + const { transport: first, port } = await createTransport() + await first.start() + + // Why: second transport requests the same port, which is now occupied. + // It should silently fall back to an OS-assigned port instead of throwing. + const tls = makeTls() + const second = new WebSocketTransport({ + host: '127.0.0.1', + port, + tlsCert: tls.cert, + tlsKey: tls.key + }) + transports.push(second) + + await second.start() + + expect(second.resolvedPort).not.toBe(port) + expect(second.resolvedPort).toBeGreaterThan(0) + + const ws = await connectWs(second.resolvedPort) + ws.close() + }) + + it('reaps a half-open client that stops responding to pings', async () => { + // Why: regression cover for the half-open-socket leak that would + // strand mobile clients in the connection pool until OS TCP keepalive + // (~2 hours) reaped them. With the heartbeat, two consecutive ping + // ticks without a pong should cause terminate() to fire and free the + // slot. Verifying via the server's connection-close handler, which + // is what frees up the MAX_WS_CONNECTIONS budget in production. + const tls = makeTls() + const port = findFreePort() + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port, + tlsCert: tls.cert, + tlsKey: tls.key, + heartbeatIntervalMs: 50 + }) + transport.onMessage(() => {}) + transports.push(transport) + + let serverClosed = false + transport.onConnectionClose(() => { + serverClosed = true + }) + + // Why: setClientId is what registers the ws → clientId mapping that + // onConnectionClose fires off. Hook the connection event before + // start so we can stamp every accepted ws with a token. + await transport.start() + + const ws = await connectWs(port) + // Why: pausing the underlying TCP socket halts both read (ping in) + // and write (pong out) at the kernel level, so the `ws` library's + // auto-pong can't actually be flushed back. From the server's + // perspective the client looks half-open — exactly the production + // failure mode iOS produces when it suspends a backgrounded socket. + const underlying = (ws as unknown as { _socket: { pause: () => void } })._socket + underlying.pause() + + // Why: we need a clientId on the ws so onConnectionClose actually + // fires. The transport sets it lazily via setClientId in production + // (after auth); in this test we don't run auth, so reach in. + const wss = (transport as unknown as { wss: { clients: Set<{ readyState: number }> } }).wss + for (const c of wss.clients) { + transport.setClientId(c as never, 'test-client') + } + + // Wait long enough for two heartbeat ticks (50ms each) plus slack. + const start = Date.now() + while (!serverClosed && Date.now() - start < 2_000) { + await new Promise((r) => setTimeout(r, 25)) + } + + expect(serverClosed).toBe(true) + expect(wss.clients.size).toBe(0) + }, 5_000) +}) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts new file mode 100644 index 00000000000..5a97642a30c --- /dev/null +++ b/src/main/runtime/rpc/ws-transport.ts @@ -0,0 +1,288 @@ +// Why: the WebSocket transport enables mobile clients to connect to the Orca +// runtime over the local network. When TLS cert/key are provided it uses wss:// +// to prevent passive sniffing; otherwise it falls back to plain ws://. Per-device +// tokens (validated by the message handler in OrcaRuntimeRpcServer) provide auth +// regardless of transport encryption. +import { createServer as createHttpsServer, type Server as HttpsServer } from 'https' +import { createServer as createHttpServer, type Server as HttpServer } from 'http' +import { WebSocketServer, type WebSocket } from 'ws' +import type { RpcTransport } from './transport' + +const MAX_WS_MESSAGE_BYTES = 1024 * 1024 +const MAX_WS_CONNECTIONS = 32 + +// Why: mobile clients (iOS/Android) regularly background-suspend their +// sockets without the OS sending a TCP FIN/RST, leaving the server with +// half-open WebSockets that count toward MAX_WS_CONNECTIONS. Without this +// heartbeat the only thing that ever reaps them is the OS's TCP keepalive +// (default macOS: ~2 hours idle + 11 min of probes), which is the +// "connection randomly turns green again after a long delay" symptom. +// Pinging every 15s and terminating any client that hasn't pong'd by the +// next tick collapses that worst case to ~30s. RN/browser WebSocket +// runtimes auto-respond to server pings with pongs at the protocol layer, +// so this works for any client that speaks RFC 6455. +const HEARTBEAT_INTERVAL_MS = 15_000 + +export type WebSocketTransportOptions = { + host: string + port: number + tlsCert?: string + tlsKey?: string + // Why: test-only override. Production uses HEARTBEAT_INTERVAL_MS. + heartbeatIntervalMs?: number +} + +export class WebSocketTransport implements RpcTransport { + private readonly host: string + private readonly port: number + private readonly tlsCert: string | undefined + private readonly tlsKey: string | undefined + private readonly heartbeatIntervalMs: number + private httpServer: HttpsServer | HttpServer | null = null + private wss: WebSocketServer | null = null + private heartbeatTimer: ReturnType | null = null + // Why: tracks whether each socket has pong'd since the last heartbeat + // sweep. A socket missing from the set when the next sweep fires is + // assumed dead and terminated. + private wsAlive = new WeakSet() + private messageHandler: + | ((msg: string, reply: (response: string) => void, ws: WebSocket) => void) + | null = null + private connectionCloseHandler: + | ((clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void) + | null = null + // Why: maps each WebSocket to the clientId (deviceToken) that authenticated it, + // so ws.on('close') can notify the runtime which mobile client disconnected. + private wsClientIds = new Map() + + constructor({ host, port, tlsCert, tlsKey, heartbeatIntervalMs }: WebSocketTransportOptions) { + this.host = host + this.port = port + this.tlsCert = tlsCert + this.tlsKey = tlsKey + this.heartbeatIntervalMs = heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS + } + + onMessage( + handler: (msg: string, reply: (response: string) => void, ws: WebSocket) => void + ): void { + this.messageHandler = handler + } + + // Why: handlers receive the closing `ws` so per-connection state can be + // targeted exactly (one paired device may hold multiple concurrent sockets, + // e.g. host screen + accounts screen). `hasOtherConnections` tells the + // runtime whether other sockets for the same deviceToken are still alive, + // so client-scoped teardown (mobile-fit overrides, etc.) only fires on the + // last disconnect. + onConnectionClose( + handler: (clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void + ): void { + this.connectionCloseHandler = handler + } + + setClientId(ws: WebSocket, clientId: string): void { + this.wsClientIds.set(ws, clientId) + } + + // Why: when port 0 is passed the OS assigns a random available port. The + // runtime metadata and mobile QR code need the real port, so callers read + // it here after start() resolves. + get resolvedPort(): number { + const addr = this.httpServer?.address() + if (addr && typeof addr === 'object') { + return addr.port + } + return this.port + } + + async start(): Promise { + if (this.wss) { + return + } + + // Why: when the preferred port is occupied (e.g. another Orca instance is + // already running), fall back to an OS-assigned port so mobile pairing + // still works. The QR code reads resolvedPort after start, so it will + // advertise the correct port regardless. + let port = this.port + try { + await this.tryListen(port) + } catch (error: unknown) { + if (isEAddressInUse(error) && port !== 0) { + console.warn(`[ws-transport] Port ${port} is in use, falling back to OS-assigned port`) + port = 0 + await this.tryListen(port) + } else { + throw error + } + } + } + + private createHttpServer(): HttpServer | HttpsServer { + return this.tlsCert && this.tlsKey + ? createHttpsServer({ cert: this.tlsCert, key: this.tlsKey }) + : createHttpServer() + } + + // Why: the WebSocketServer is attached only after listen succeeds. If we + // attached it before, the WSS would re-emit the EADDRINUSE error from the + // httpServer as an uncatchable exception, preventing the fallback from working. + private async tryListen(port: number): Promise { + const httpServer = this.createHttpServer() + + await new Promise((resolve, reject) => { + httpServer.once('error', reject) + httpServer.listen(port, this.host, () => { + httpServer.off('error', reject) + resolve() + }) + }) + + const wss = new WebSocketServer({ + server: httpServer, + maxPayload: MAX_WS_MESSAGE_BYTES + }) + + wss.on('connection', (ws) => { + if (wss.clients.size > MAX_WS_CONNECTIONS) { + ws.close(1013, 'Maximum connections reached') + return + } + this.handleConnection(ws) + }) + + this.httpServer = httpServer + this.wss = wss + this.startHeartbeat() + } + + // Why: ping every live socket on a fixed cadence and terminate any that + // didn't pong since the previous tick. This is the only thing that + // reliably reaps half-open mobile sockets stranded by background + // suspension without a TCP FIN. See HEARTBEAT_INTERVAL_MS comment. + private startHeartbeat(): void { + if (this.heartbeatTimer) { + return + } + this.heartbeatTimer = setInterval(() => { + const wss = this.wss + if (!wss) { + return + } + for (const ws of wss.clients) { + if (!this.wsAlive.has(ws)) { + // Why: terminate() (vs close()) skips the close handshake and + // immediately fires the 'close' event, freeing the slot. close() + // on an already-dead socket can hang for the OS-level TCP timeout. + ws.terminate() + continue + } + this.wsAlive.delete(ws) + try { + ws.ping() + } catch { + // Why: ping() can throw on a socket that's mid-tear-down; the + // close handler will run regardless, so swallow the throw. + } + } + }, this.heartbeatIntervalMs) + if (typeof this.heartbeatTimer.unref === 'function') { + this.heartbeatTimer.unref() + } + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + } + } + + async stop(): Promise { + const wss = this.wss + const httpServer = this.httpServer + this.wss = null + this.httpServer = null + this.stopHeartbeat() + + if (wss) { + for (const client of wss.clients) { + client.close(1001, 'Server shutting down') + } + wss.close() + } + + if (httpServer) { + await new Promise((resolve, reject) => { + httpServer.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + } + } + + // Why: WebSocket connections are long-lived (unlike Unix socket which is + // one-per-request). Multiple requests can be multiplexed on the same + // connection via the RPC `id` field. The transport delegates all auth + // and dispatch logic to the message handler set by OrcaRuntimeRpcServer. + private handleConnection(ws: WebSocket): void { + // Why: seed alive=true so the first heartbeat tick after connect doesn't + // treat a fresh socket as dead. Subsequent pongs (or any inbound traffic) + // re-arm it. + this.wsAlive.add(ws) + + ws.on('pong', () => { + this.wsAlive.add(ws) + }) + + ws.on('message', (data) => { + // Why: any inbound traffic counts as proof of life, not just pongs. + // RN's WebSocket runtime auto-pongs server pings transparently, but + // app-level frames also count toward liveness so an actively-talking + // client doesn't get terminated mid-request. + this.wsAlive.add(ws) + const msg = typeof data === 'string' ? data : data.toString('utf-8') + this.messageHandler?.( + msg, + (response) => { + // Why: mobile clients disconnect frequently (backgrounding, network + // switch, phone locked). Guard writes to avoid errors on dead sockets. + if (ws.readyState === ws.OPEN) { + ws.send(response) + } + }, + ws + ) + }) + + // Why: mobile clients disconnect when the phone locks, loses wifi, or + // backgrounds the app. The runtime must clean up connection-scoped state + // (e.g., mobile-fit overrides) to prevent orphaned phone-fit on desktop. + ws.on('close', () => { + const clientId = this.wsClientIds.get(ws) + this.wsClientIds.delete(ws) + if (clientId) { + // Why: a paired device may have multiple concurrent sockets open + // (e.g. one per app screen). Per-client teardown must only fire when + // the last socket for this token closes — otherwise closing the + // accounts-screen socket would clobber the host-screen socket's + // state and strand it in a non-functional state until re-paired. + const hasOtherConnections = Array.from(this.wsClientIds.values()).includes(clientId) + this.connectionCloseHandler?.(clientId, ws, hasOtherConnections) + } + }) + + ws.on('error', () => { + ws.close() + }) + } +} + +function isEAddressInUse(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'EADDRINUSE' +} diff --git a/src/main/runtime/runtime-metadata.test.ts b/src/main/runtime/runtime-metadata.test.ts index 5af5f493279..4e93e909dd6 100644 --- a/src/main/runtime/runtime-metadata.test.ts +++ b/src/main/runtime/runtime-metadata.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from 'os' import { join } from 'path' import { afterEach, describe, expect, it } from 'vitest' import { getRuntimeMetadataPath } from '../../shared/runtime-bootstrap' -import { clearRuntimeMetadata, readRuntimeMetadata, writeRuntimeMetadata } from './runtime-metadata' +import { + clearRuntimeMetadata, + clearRuntimeMetadataIfOwned, + readRuntimeMetadata, + writeRuntimeMetadata +} from './runtime-metadata' const tempDirs: string[] = [] @@ -21,10 +26,12 @@ describe('runtime metadata', () => { writeRuntimeMetadata(userDataPath, { runtimeId: 'rt_123', pid: 42, - transport: { - kind: 'unix', - endpoint: '/tmp/orca.sock' - }, + transports: [ + { + kind: 'unix', + endpoint: '/tmp/orca.sock' + } + ], authToken: 'secret', startedAt: 100 }) @@ -32,10 +39,12 @@ describe('runtime metadata', () => { expect(readRuntimeMetadata(userDataPath)).toEqual({ runtimeId: 'rt_123', pid: 42, - transport: { - kind: 'unix', - endpoint: '/tmp/orca.sock' - }, + transports: [ + { + kind: 'unix', + endpoint: '/tmp/orca.sock' + } + ], authToken: 'secret', startedAt: 100 }) @@ -48,7 +57,7 @@ describe('runtime metadata', () => { writeRuntimeMetadata(userDataPath, { runtimeId: 'rt_123', pid: 42, - transport: null, + transports: [], authToken: null, startedAt: 100 }) @@ -59,6 +68,73 @@ describe('runtime metadata', () => { expect(getRuntimeMetadataPath(userDataPath)).toContain('orca-runtime.json') }) + describe('clearRuntimeMetadataIfOwned', () => { + it('clears metadata when pid and runtimeId both match', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-metadata-')) + tempDirs.push(userDataPath) + writeRuntimeMetadata(userDataPath, { + runtimeId: 'rt_owner', + pid: 42, + transports: [], + authToken: null, + startedAt: 100 + }) + + clearRuntimeMetadataIfOwned(userDataPath, 42, 'rt_owner') + + expect(readRuntimeMetadata(userDataPath)).toBeNull() + }) + + it('retains metadata when the pid does not match (simulates auto-update handoff)', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-metadata-')) + tempDirs.push(userDataPath) + writeRuntimeMetadata(userDataPath, { + runtimeId: 'rt_replacement', + pid: 999, + transports: [], + authToken: null, + startedAt: 200 + }) + + clearRuntimeMetadataIfOwned(userDataPath, 42, 'rt_owner') + + expect(readRuntimeMetadata(userDataPath)).toMatchObject({ + pid: 999, + runtimeId: 'rt_replacement' + }) + }) + + it('retains metadata when only the runtimeId differs', () => { + // Why: pid reuse is possible across an auto-update (fork+exec keeps the + // old pid if the OS reassigns it quickly). The runtimeId check is the + // second-level guard that catches this even when pid collides. + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-metadata-')) + tempDirs.push(userDataPath) + writeRuntimeMetadata(userDataPath, { + runtimeId: 'rt_replacement', + pid: 42, + transports: [], + authToken: null, + startedAt: 200 + }) + + clearRuntimeMetadataIfOwned(userDataPath, 42, 'rt_owner') + + expect(readRuntimeMetadata(userDataPath)).toMatchObject({ + pid: 42, + runtimeId: 'rt_replacement' + }) + }) + + it('is a no-op when no metadata exists', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-metadata-')) + tempDirs.push(userDataPath) + + expect(() => clearRuntimeMetadataIfOwned(userDataPath, 42, 'rt_owner')).not.toThrow() + expect(readRuntimeMetadata(userDataPath)).toBeNull() + }) + }) + it.runIf(process.platform !== 'win32')( 'restricts runtime metadata permissions to the current user on Unix', () => { @@ -68,10 +144,12 @@ describe('runtime metadata', () => { writeRuntimeMetadata(userDataPath, { runtimeId: 'rt_123', pid: 42, - transport: { - kind: 'unix', - endpoint: '/tmp/orca.sock' - }, + transports: [ + { + kind: 'unix', + endpoint: '/tmp/orca.sock' + } + ], authToken: 'secret', startedAt: 100 }) diff --git a/src/main/runtime/runtime-metadata.ts b/src/main/runtime/runtime-metadata.ts index 3818dc2530e..95a7f707c0f 100644 --- a/src/main/runtime/runtime-metadata.ts +++ b/src/main/runtime/runtime-metadata.ts @@ -30,6 +30,38 @@ export function clearRuntimeMetadata(userDataPath: string): void { rmSync(getRuntimeMetadataPath(userDataPath), { force: true }) } +/** + * Why: clearing metadata unconditionally on quit would race with a sibling + * Orca process during auto-updater handoff (the new process may already + * have written its own metadata before the old process finishes tearing + * down). The ownership guard — pid + runtimeId must both match the values + * the caller recorded at its own startup — keeps the clean-exit case honest + * ('not_running' instead of 'stale_bootstrap') while refusing to erase the + * replacement process's fresh bootstrap. + * + * Callers MUST capture `ownedPid` and `ownedRuntimeId` synchronously at + * startup (or at least before any shutdown await) so the comparison below + * reflects the process that actually wrote the file, not whatever state + * globals happen to hold mid-teardown. + */ +export function clearRuntimeMetadataIfOwned( + userDataPath: string, + ownedPid: number, + ownedRuntimeId: string +): void { + const current = readRuntimeMetadata(userDataPath) + if (!current) { + return + } + if (current.pid !== ownedPid) { + return + } + if (current.runtimeId !== ownedRuntimeId) { + return + } + clearRuntimeMetadata(userDataPath) +} + function writeMetadataFile(path: string, metadata: RuntimeMetadata): void { const dir = dirname(path) if (!existsSync(dir)) { diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 594a0161bb2..d07fc93b01b 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -2,9 +2,11 @@ import { existsSync, mkdtempSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { createConnection } from 'net' +import { createConnection, type Socket } from 'net' import { describe, expect, it, vi } from 'vitest' +import Database from 'better-sqlite3' import { OrcaRuntimeService } from './orca-runtime' +import { OrchestrationDb } from './orchestration/db' import * as runtimeMetadataModule from './runtime-metadata' import { readRuntimeMetadata } from './runtime-metadata' import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc' @@ -46,6 +48,62 @@ async function sendRequest( }) } +// Why: long-poll keepalive tests need every frame, not just the first, because +// we need to count `_keepalive` frames before the terminal success/failure. +// Also exposes the socket so tests can close it mid-wait to exercise the +// long-poll counter decrement path. +type FramedSession = { + socket: Socket + frames: Record[] + done: Promise +} + +function openFramedSession(endpoint: string, request: Record): FramedSession { + const frames: Record[] = [] + const socket = createConnection(endpoint) + let buffer = '' + socket.setEncoding('utf8') + const done = new Promise((resolve, reject) => { + socket.once('error', (err) => { + // Why: ECONNRESET is expected when we deliberately destroy the socket + // mid-wait to probe the counter decrement; surface other errors. + if ((err as NodeJS.ErrnoException).code === 'ECONNRESET') { + resolve() + return + } + reject(err) + }) + socket.on('close', () => resolve()) + socket.on('data', (chunk: string) => { + buffer += chunk + let newlineIndex = buffer.indexOf('\n') + while (newlineIndex !== -1) { + const raw = buffer.slice(0, newlineIndex).trim() + buffer = buffer.slice(newlineIndex + 1) + if (raw) { + const frame = JSON.parse(raw) as Record + frames.push(frame) + // Why: the server leaves the socket open after writing the terminal + // frame (short RPCs expect the client to close); close the client + // side so `done` resolves once we've captured the response. + if (frame._keepalive !== true) { + socket.end() + } + } + newlineIndex = buffer.indexOf('\n') + } + }) + socket.on('connect', () => { + socket.write(`${JSON.stringify(request)}\n`) + }) + }) + return { socket, frames, done } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + describe('OrcaRuntimeRpcServer', () => { const makeStore = (overrides?: { isUnread?: boolean }) => ({ getRepo: (id: string) => @@ -109,8 +167,8 @@ describe('OrcaRuntimeRpcServer', () => { const metadata = readRuntimeMetadata(userDataPath) expect(metadata?.runtimeId).toBe(runtime.getRuntimeId()) expect(metadata?.authToken).toBeTruthy() - expect(metadata?.transport?.endpoint).toBeTruthy() - expect(metadata?.transport).toEqual(server['transport']) + expect(metadata?.transports?.[0]?.endpoint).toBeTruthy() + expect(metadata?.transports).toEqual(server['transports']) await server.stop() expect(readRuntimeMetadata(userDataPath)).toMatchObject({ @@ -157,8 +215,8 @@ describe('OrcaRuntimeRpcServer', () => { await expect(server.start()).rejects.toThrow('write failed') expect(readRuntimeMetadata(userDataPath)).toBeNull() expect(existsSync(endpoint)).toBe(false) - expect(server['transport']).toBeNull() - expect(server['server']).toBeNull() + expect(server['transports']).toEqual([]) + expect(server['activeTransports']).toEqual([]) writeMetadataSpy.mockRestore() }) @@ -171,7 +229,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const response = await sendRequest(metadata!.transport!.endpoint, { + const response = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_1', authToken: metadata!.authToken, method: 'status.get' @@ -197,7 +255,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const response = await sendRequest(metadata!.transport!.endpoint, { + const response = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_1', authToken: 'wrong', method: 'status.get' @@ -222,7 +280,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const response = await sendRequest(metadata!.transport!.endpoint, { + const response = await sendRequest(metadata!.transports[0]!.endpoint, { authToken: metadata!.authToken, method: 'status.get' }) @@ -247,7 +305,8 @@ describe('OrcaRuntimeRpcServer', () => { writes.push(data) return true }, - kill: () => true + kill: () => true, + getForegroundProcess: async () => null }) const server = new OrcaRuntimeRpcServer({ runtime, userDataPath }) @@ -277,7 +336,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const listResponse = await sendRequest(metadata!.transport!.endpoint, { + const listResponse = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_list', authToken: metadata!.authToken, method: 'terminal.list', @@ -301,7 +360,7 @@ describe('OrcaRuntimeRpcServer', () => { ).handle expect(handle).toBeTruthy() - const showResponse = await sendRequest(metadata!.transport!.endpoint, { + const showResponse = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_show', authToken: metadata!.authToken, method: 'terminal.show', @@ -314,7 +373,7 @@ describe('OrcaRuntimeRpcServer', () => { ok: true }) - const readResponse = await sendRequest(metadata!.transport!.endpoint, { + const readResponse = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_read', authToken: metadata!.authToken, method: 'terminal.read', @@ -327,7 +386,7 @@ describe('OrcaRuntimeRpcServer', () => { ok: true }) - const sendResponse = await sendRequest(metadata!.transport!.endpoint, { + const sendResponse = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_send', authToken: metadata!.authToken, method: 'terminal.send', @@ -341,9 +400,9 @@ describe('OrcaRuntimeRpcServer', () => { id: 'req_send', ok: true }) - expect(writes).toEqual(['continue\r']) + expect(writes).toEqual(['continue', '\r']) - const waitPromise = sendRequest(metadata!.transport!.endpoint, { + const waitPromise = sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_wait', authToken: metadata!.authToken, method: 'terminal.wait', @@ -403,7 +462,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const response = await sendRequest(metadata!.transport!.endpoint, { + const response = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_ps', authToken: metadata!.authToken, method: 'worktree.ps' @@ -444,7 +503,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const response = await sendRequest(metadata!.transport!.endpoint, { + const response = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_worktrees', authToken: metadata!.authToken, method: 'worktree.list', @@ -474,7 +533,7 @@ describe('OrcaRuntimeRpcServer', () => { const metadata = readRuntimeMetadata(userDataPath) const response = await new Promise>((resolve, reject) => { - const socket = createConnection(metadata!.transport!.endpoint) + const socket = createConnection(metadata!.transports[0]!.endpoint) let buffer = '' socket.setEncoding('utf8') socket.once('error', reject) @@ -501,4 +560,308 @@ describe('OrcaRuntimeRpcServer', () => { await server.stop() }) + + // Why: §6 tests for the transport keepalive + long-poll counter path in §3.1. + // Exercise the real socket (not a mock) so we catch buffer/flush regressions + // that a unit-level test would miss. + describe('long-poll transport (§3.1)', () => { + it('emits keepalive frames while a check --wait handler blocks', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + // Why: 50ms keepalive lets us collect ≥3 frames within a 300ms wait + // window without slowing the suite. + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 50 + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const session = openFramedSession(metadata!.transports[0]!.endpoint, { + id: 'req_wait', + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { + terminal: 'term_nobody', + wait: true, + timeoutMs: 300 + } + }) + await session.done + + const keepalives = session.frames.filter((f) => f._keepalive === true) + const terminals = session.frames.filter((f) => f.ok !== undefined) + expect(terminals).toHaveLength(1) + expect(terminals[0]).toMatchObject({ id: 'req_wait', ok: true }) + // Why: 300ms wait with 50ms keepalive → expect roughly 5 keepalives; + // assert ≥3 to tolerate scheduler jitter without flaking. + expect(keepalives.length).toBeGreaterThanOrEqual(3) + } finally { + db.close() + await server.stop() + } + }) + + it('releases long-poll slot when client closes mid-wait', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 1000, + longPollCap: 2 + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const endpoint = metadata!.transports[0]!.endpoint + + // Fill the cap with two long waits (10s each — we'll kill them). + const a = openFramedSession(endpoint, { + id: 'req_a', + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { terminal: 'term_a', wait: true, timeoutMs: 10_000 } + }) + const b = openFramedSession(endpoint, { + id: 'req_b', + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { terminal: 'term_b', wait: true, timeoutMs: 10_000 } + }) + // Let the two waits land in the handler and increment the counter. + await sleep(100) + expect(server['activeLongPolls']).toBe(2) + + // Kill one client mid-wait; counter must drop to 1. + a.socket.destroy() + await a.done + // Give Node one tick to fire the close event on the server socket. + await sleep(50) + expect(server['activeLongPolls']).toBe(1) + + // The freed slot must admit a new long-poll immediately. + const c = openFramedSession(endpoint, { + id: 'req_c', + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { terminal: 'term_c', wait: true, timeoutMs: 100 } + }) + await c.done + const cTerminal = c.frames.find((f) => f.ok !== undefined) + expect(cTerminal).toMatchObject({ ok: true, id: 'req_c' }) + + b.socket.destroy() + await b.done + } finally { + db.close() + await server.stop() + } + }) + + it('responds runtime_busy once the long-poll cap is saturated', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 1000, + longPollCap: 1 + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const endpoint = metadata!.transports[0]!.endpoint + + const a = openFramedSession(endpoint, { + id: 'req_a', + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { terminal: 'term_a', wait: true, timeoutMs: 5_000 } + }) + await sleep(100) + expect(server['activeLongPolls']).toBe(1) + + // Second long-poll overflows the cap → runtime_busy. + const overflow = await sendRequest(endpoint, { + id: 'req_overflow', + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { terminal: 'term_b', wait: true, timeoutMs: 5_000 } + }) + expect(overflow).toMatchObject({ + id: 'req_overflow', + ok: false, + error: { code: 'runtime_busy' } + }) + // The failing request must not have counted against the cap. + expect(server['activeLongPolls']).toBe(1) + + // Short RPCs still succeed even when the long-poll cap is full. + const short = await sendRequest(endpoint, { + id: 'req_short', + authToken: metadata!.authToken, + method: 'status.get' + }) + expect(short).toMatchObject({ id: 'req_short', ok: true }) + + a.socket.destroy() + await a.done + } finally { + db.close() + await server.stop() + } + }) + + it('does not emit keepalive frames for short RPCs', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + // Why: a 10ms interval means any frame in the first ~100ms of a short + // RPC would show up; `status.get` returns in <10ms so no keepalive + // should ever fire. Locks in the "keepalive is long-poll-only" invariant + // so a future refactor can't silently re-broaden the timer. + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 10 + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const session = openFramedSession(metadata!.transports[0]!.endpoint, { + id: 'req_short', + authToken: metadata!.authToken, + method: 'status.get' + }) + await session.done + + const keepalives = session.frames.filter((f) => f._keepalive === true) + const terminals = session.frames.filter((f) => f.ok !== undefined) + expect(terminals).toHaveLength(1) + expect(terminals[0]).toMatchObject({ id: 'req_short', ok: true }) + expect(keepalives).toHaveLength(0) + } finally { + await server.stop() + } + }) + + it('returns an internal_error envelope when the dispatcher throws', async () => { + // Why: handlers are designed to return error envelopes, never to throw, + // but a bug somewhere in the RPC stack (e.g. JSON.stringify choking on + // a response with circular refs) must still produce a terminal frame. + // Without the `.catch` on handleMessage's promise, a throw would leave + // the client hanging until the 30s idle timer and leak the dispatch's + // AbortController in the transport's in-flight set. + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath }) + await server.start() + + // Force the dispatcher to throw a non-envelope error. + const originalDispatch = server['dispatcher'].dispatch.bind(server['dispatcher']) + server['dispatcher'].dispatch = vi.fn().mockRejectedValue(new Error('boom')) + + try { + const metadata = readRuntimeMetadata(userDataPath) + const response = await sendRequest(metadata!.transports[0]!.endpoint, { + id: 'req_throw', + authToken: metadata!.authToken, + method: 'status.get' + }) + expect(response).toMatchObject({ + id: 'req_throw', + ok: false, + error: { code: 'internal_error', message: 'boom' } + }) + } finally { + server['dispatcher'].dispatch = originalDispatch + await server.stop() + } + }) + }) + + // Why: §6 test for the idempotent + hard-fail schema migration. A broken + // migration must crash startup loudly rather than serve traffic against a + // schema missing the delivered_at column. + describe('orchestration DB migration (§3.2)', () => { + it('is idempotent when delivered_at already exists', () => { + // First open creates the column; second open should be a no-op. + const db1 = new OrchestrationDb(':memory:') + db1.close() + // File path reuse is meaningless with :memory:, so use a tmp file. + const tmpPath = join(mkdtempSync(join(tmpdir(), 'orca-orch-mig-')), 'orch.sqlite') + const a = new OrchestrationDb(tmpPath) + a.close() + // Second construction must not throw "duplicate column name". + expect(() => { + const b = new OrchestrationDb(tmpPath) + b.close() + }).not.toThrow() + }) + + it('hard-fails startup when the migration cannot be applied', () => { + // Simulate a migration error by monkey-patching better-sqlite3's exec. + // If ALTER TABLE throws for any reason (e.g. disk full, permissions), + // the constructor must propagate — not swallow and serve half-broken. + // + // Why the pre-seeded v2 DB: after the schema bundle, fresh DBs are + // initialized directly at v3 via createTables() (which already includes + // `delivered_at`), so the v2 → v3 ALTER is a no-op for new installs. + // To exercise the hard-fail path we need a DB that actually has work + // to migrate — a v2-shape file without the delivered_at column — so + // the guarded ALTER runs and the stub can fire. + const tmpPath = join(mkdtempSync(join(tmpdir(), 'orca-orch-mig-')), 'orch.sqlite') + const seed = new Database(tmpPath) + seed.exec(` + CREATE TABLE messages ( + id TEXT NOT NULL, + from_handle TEXT NOT NULL, + to_handle TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'status' + CHECK(type IN ( + 'status', 'dispatch', 'worker_done', 'merge_ready', + 'escalation', 'handoff', 'decision_gate', 'heartbeat' + )), + priority TEXT NOT NULL DEFAULT 'normal' + CHECK(priority IN ('normal', 'high', 'urgent')), + thread_id TEXT, + payload TEXT, + read INTEGER NOT NULL DEFAULT 0, + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `) + seed.pragma('user_version = 2') + seed.close() + + const realPrototype = Database.prototype as unknown as { + exec: (sql: string) => unknown + } + const originalExec = realPrototype.exec + realPrototype.exec = function (sql: string) { + if (sql.includes('ALTER TABLE messages ADD COLUMN delivered_at')) { + throw new Error('simulated migration failure') + } + return originalExec.call(this, sql) + } + try { + expect(() => new OrchestrationDb(tmpPath)).toThrow('simulated migration failure') + } finally { + realPrototype.exec = originalExec + } + }) + }) }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index e1275c37dcb..c1f946e6990 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -1,11 +1,11 @@ +/* eslint-disable max-lines -- Why: this file is the single security boundary for the bundled CLI — transport setup, auth-token enforcement, admission control, keepalive framing, and orphan-socket sweeping all co-locate deliberately so a reviewer can audit the boundary in one sitting. Splitting this across files would scatter the invariants without reducing complexity. */ // Why: this is the single security boundary for the bundled CLI. It owns -// transport setup (unix socket / named pipe), auth-token enforcement, and -// bootstrap-metadata publication so a running runtime is always discoverable -// via exactly one on-disk file. Method handling lives in `rpc/` so this file -// stays easy to audit in one sitting. +// auth-token enforcement, bootstrap-metadata publication, and transport +// orchestration so a running runtime is always discoverable via exactly +// one on-disk file. Method handling lives in `rpc/` and transport specifics +// live in `rpc/unix-socket-transport.ts` and `rpc/ws-transport.ts`. import { randomBytes } from 'crypto' -import { createServer, type Server, type Socket } from 'net' -import { chmodSync, existsSync, rmSync } from 'fs' +import { readdirSync, rmSync } from 'fs' import { join } from 'path' import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap' import type { OrcaRuntimeService } from './orca-runtime' @@ -13,17 +13,61 @@ import { writeRuntimeMetadata } from './runtime-metadata' import { RpcDispatcher } from './rpc/dispatcher' import type { RpcRequest, RpcResponse } from './rpc/core' import { errorResponse } from './rpc/errors' +import type { RpcMessageContext, RpcTransport } from './rpc/transport' +import { UnixSocketTransport } from './rpc/unix-socket-transport' +import { WebSocketTransport } from './rpc/ws-transport' +import type { WebSocket } from 'ws' +import { DeviceRegistry } from './device-registry' +import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair' +import { E2EEChannel } from './rpc/e2ee-channel' + +const DEFAULT_WS_PORT = 6768 type OrcaRuntimeRpcServerOptions = { runtime: OrcaRuntimeService userDataPath: string pid?: number platform?: NodeJS.Platform + enableWebSocket?: boolean + wsPort?: number + // Why: test-only overrides for the two time-bound constants below. + // Production callers must not pass these — defaults are set by the design + // doc (§3.1) and changing them in production would weaken the admission + // fence or flood the socket with keepalive frames. + keepaliveIntervalMs?: number + longPollCap?: number } -const MAX_RUNTIME_RPC_MESSAGE_BYTES = 1024 * 1024 -const RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS = 30_000 -const MAX_RUNTIME_RPC_CONNECTIONS = 32 +// Why: after 10 s of a pending dispatch we emit a tiny `{"_keepalive":true}` +// frame every 10 s until the handler resolves. Each write resets both the +// server's own socket idle timer (30 s) and — once §3.1 ships on the client — +// the client's idle timer, because any byte counts as socket activity. This +// is the transport-layer fix for feedback #1: long-poll RPCs (i.e. +// orchestration.check --wait) can now run past the 30 s/60 s idle caps +// without either end tearing the socket down. See design doc §3.1. +const KEEPALIVE_INTERVAL_MS = 10_000 + +// Why: long-poll slot cap. With keepalives a `check --wait --timeout-ms +// 600000` can hold a connection for up to 10 minutes; unbounded that would +// saturate MAX_RUNTIME_RPC_CONNECTIONS (32) with 32 waiting coordinators +// and lock out normal short RPCs. Capping at half the connection budget +// leaves the other half for short traffic. On overflow the server responds +// immediately with `runtime_busy` (CLI exit 75) — fail fast, not silent +// queuing. See design doc §3.1 + §7 risk #2. +const LONG_POLL_CAP = 16 + +// Why: a long-poll request is one whose handler blocks for an unbounded +// amount of time waiting for an external event (today, only +// `orchestration.check` with `wait === true`). This function is the single +// place that classifies it — the long-poll counter, abort wiring, and +// runtime_busy admission check all share this decision. See §3.1. +function isLongPollRequest(request: RpcRequest): boolean { + if (request.method !== 'orchestration.check') { + return false + } + const params = request.params as { wait?: unknown } | undefined + return params?.wait === true +} export class OrcaRuntimeRpcServer { private readonly runtime: OrcaRuntimeService @@ -31,104 +75,256 @@ export class OrcaRuntimeRpcServer { private readonly userDataPath: string private readonly pid: number private readonly platform: NodeJS.Platform + private readonly enableWebSocket: boolean + private readonly wsPort: number private readonly authToken = randomBytes(24).toString('hex') - private server: Server | null = null - private transport: RuntimeTransportMetadata | null = null + private readonly keepaliveIntervalMs: number + private readonly longPollCap: number + private deviceRegistry: DeviceRegistry | null = null + private e2eeKeypair: E2EEKeypair | null = null + private tlsFingerprint: string | null = null + private activeTransports: RpcTransport[] = [] + private transports: RuntimeTransportMetadata[] = [] + // Why: each WebSocket connection has its own E2EE channel that manages the + // handshake and encrypt/decrypt lifecycle. Keyed by WebSocket instance. + private e2eeChannels = new Map() + // Why: stable per-WebSocket id used as the cleanup key for streaming + // subscriptions, so the server can reap a closing socket's subscriptions + // without affecting other live sockets that share the same deviceToken. + private wsConnectionIds = new Map() + // Why: separate from Node's server.maxConnections because we need to count + // only long-running dispatches, not every in-flight short RPC. See §3.1 + + // §7 risk #2. + private activeLongPolls = 0 constructor({ runtime, userDataPath, pid = process.pid, - platform = process.platform + platform = process.platform, + enableWebSocket = false, + wsPort = DEFAULT_WS_PORT, + keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, + longPollCap = LONG_POLL_CAP }: OrcaRuntimeRpcServerOptions) { this.runtime = runtime this.dispatcher = new RpcDispatcher({ runtime }) this.userDataPath = userDataPath this.pid = pid this.platform = platform + this.enableWebSocket = enableWebSocket + this.wsPort = wsPort + this.keepaliveIntervalMs = keepaliveIntervalMs + this.longPollCap = longPollCap + } + + getDeviceRegistry(): DeviceRegistry | null { + return this.deviceRegistry + } + + getTlsFingerprint(): string | null { + return this.tlsFingerprint + } + + getE2EEPublicKey(): string | null { + return this.e2eeKeypair?.publicKeyB64 ?? null + } + + getE2EEKeypair(): E2EEKeypair | null { + return this.e2eeKeypair + } + + getWebSocketEndpoint(): string | null { + const ws = this.transports.find((t) => t.kind === 'websocket') + return ws?.endpoint ?? null } async start(): Promise { - if (this.server) { + if (this.activeTransports.length > 0) { return } - const transport = createRuntimeTransportMetadata( + // Why: processes killed by SIGKILL / OOM-kill / forced-shutdown skip + // stop() and leave behind `o--*.sock` files in userData. Sweeping + // dead-pid sockets at startup keeps the directory from accumulating + // orphans over the app's lifetime. Named-pipe transports on Windows do + // not leave filesystem entries in userData, so the sweep is a no-op + // there. + if (this.platform !== 'win32') { + sweepOrphanedRuntimeSockets(this.userDataPath, this.pid) + } + + const transportMeta = createRuntimeTransportMetadata( this.userDataPath, this.pid, this.platform, this.runtime.getRuntimeId() ) - if (transport.kind === 'unix' && existsSync(transport.endpoint)) { - rmSync(transport.endpoint, { force: true }) - } - const server = createServer((socket) => { - this.handleConnection(socket) + const socketTransport = new UnixSocketTransport({ + endpoint: transportMeta.endpoint, + kind: transportMeta.kind as 'unix' | 'named-pipe', + keepaliveIntervalMs: this.keepaliveIntervalMs }) - server.maxConnections = MAX_RUNTIME_RPC_CONNECTIONS - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(transport.endpoint, () => { - server.off('error', reject) - resolve() - }) + // Why: Unix socket transport uses the shared runtime auth token. This is + // the existing security model for CLI connections — the token lives in a + // 0o600-permissioned file on disk. + // Why: the `.catch` guarantees `reply()` always fires even if + // `handleMessage` (or `JSON.stringify` on a pathological response) throws. + // Without it, a throw would leave the client waiting for a terminal frame + // that never arrives AND leak the dispatch's AbortController in the + // transport's in-flight set until the 30 s socket idle timer closes the + // connection. + socketTransport.onMessage((msg, reply, context) => { + void this.handleMessage(msg, context) + .then((response) => { + reply(JSON.stringify(response)) + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + // Why: best-effort id recovery so the client can correlate the + // error frame to its pending request. A malformed message would + // have been caught by handleMessage and returned an envelope + // instead of throwing, so in practice the id is always present. + let id = 'unknown' + try { + const parsed = JSON.parse(msg) as { id?: unknown } + if (typeof parsed.id === 'string' && parsed.id.length > 0) { + id = parsed.id + } + } catch { + // ignore — fall through with id='unknown' + } + reply(JSON.stringify(this.buildError(id, 'internal_error', message))) + }) }) - if (transport.kind === 'unix') { - chmodSync(transport.endpoint, 0o600) + + await socketTransport.start() + + const activeTransports: RpcTransport[] = [socketTransport] + const transportsMeta: RuntimeTransportMetadata[] = [transportMeta] + + // Why: WebSocket transport is opt-in and starts alongside the Unix socket. + // It uses per-device tokens and E2EE (application-layer encryption via + // tweetnacl) rather than TLS, since React Native can't pin self-signed certs. + if (this.enableWebSocket) { + try { + this.deviceRegistry = new DeviceRegistry(this.userDataPath) + this.e2eeKeypair = loadOrCreateE2EEKeypair(this.userDataPath) + + const wsTransport = new WebSocketTransport({ + host: '0.0.0.0', + port: this.wsPort + }) + + // Why: each WebSocket connection gets an E2EE channel that handles the + // handshake before any RPC messages are processed. The channel decrypts + // inbound messages and encrypts outbound replies transparently. + wsTransport.onMessage((msg, _reply, ws) => { + let channel = this.e2eeChannels.get(ws) + if (!channel) { + // Why: stable per-ws id used as the cleanup-index key for + // streaming subscriptions, so the server can reap them exactly + // when this socket closes (without affecting other live sockets + // that share the same deviceToken). + this.wsConnectionIds.set(ws, randomBytes(8).toString('hex')) + channel = new E2EEChannel(ws, { + serverSecretKey: this.e2eeKeypair!.secretKey, + validateToken: (token) => this.deviceRegistry?.validateToken(token) != null, + onReady: (ch) => { + if (ch.deviceToken) { + wsTransport.setClientId(ws, ch.deviceToken) + // Why: mark the device as actually connected so it appears + // in the "Paired Devices" list. Devices that were only + // generated as QR codes but never scanned stay hidden. + const device = this.deviceRegistry?.validateToken(ch.deviceToken) + if (device) { + this.deviceRegistry?.updateLastSeen(device.deviceId) + } + } + }, + onError: (code, reason) => { + this.e2eeChannels.get(ws)?.destroy() + this.e2eeChannels.delete(ws) + ws.close(code, reason) + } + }) + channel.onMessage((plaintext, encryptedReply) => { + void this.handleWebSocketMessage(plaintext, encryptedReply, wsTransport, ws) + }) + this.e2eeChannels.set(ws, channel) + } + channel.handleRawMessage(msg) + }) + + // Why: when a mobile client disconnects, the runtime must clean up + // connection-scoped state like mobile-fit overrides and the E2EE + // channel to prevent orphaned state. A single paired device can hold + // multiple concurrent sockets (host screen + accounts screen, etc.), + // so destroy the channel for THIS exact ws and skip the per-client + // teardown when other sockets for the same token are still alive. + wsTransport.onConnectionClose((clientId, ws, hasOtherConnections) => { + // Why: sweep streaming subscriptions for THIS ws regardless of + // hasOtherConnections, so per-ws listeners (notifications, + // accounts, terminal) don't leak across reconnects. This is + // independent of the deviceToken-scoped onClientDisconnected. + const connectionId = this.wsConnectionIds.get(ws) + if (connectionId) { + this.runtime.cleanupSubscriptionsForConnection(connectionId) + this.wsConnectionIds.delete(ws) + } + const channel = this.e2eeChannels.get(ws) + if (channel) { + channel.destroy() + this.e2eeChannels.delete(ws) + } + if (!hasOtherConnections) { + this.runtime.onClientDisconnected(clientId) + } + }) + + await wsTransport.start() + activeTransports.push(wsTransport) + transportsMeta.push({ + kind: 'websocket', + endpoint: `ws://0.0.0.0:${wsTransport.resolvedPort}` + }) + } catch (error) { + // Why: WebSocket transport is supplementary — the runtime must still + // function if it fails to start (e.g., port in use). Log and continue + // with Unix socket only. + console.error('[runtime] Failed to start WebSocket transport:', error) + } } // Why: publish the transport into in-memory state before writing metadata // so the bootstrap file always contains the real endpoint/token pair. The // CLI only discovers the runtime through that file. - this.server = server - this.transport = transport + this.activeTransports = activeTransports + this.transports = transportsMeta try { this.writeMetadata() } catch (error) { // Why: a runtime that cannot publish bootstrap metadata is invisible to - // the `orca` CLI. Close the socket immediately instead of leaving behind - // a live but undiscoverable control plane. - this.server = null - this.transport = null - await new Promise((resolve, reject) => { - server.close((closeError) => { - if (closeError) { - reject(closeError) - return - } - resolve() - }) - }).catch(() => {}) - if (transport.kind === 'unix' && existsSync(transport.endpoint)) { - rmSync(transport.endpoint, { force: true }) - } + // the `orca` CLI. Close all transports immediately instead of leaving + // behind a live but undiscoverable control plane. + this.activeTransports = [] + this.transports = [] + await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {}) throw error } } async stop(): Promise { - const server = this.server - const transport = this.transport - this.server = null - this.transport = null - if (!server) { + const transports = this.activeTransports + this.activeTransports = [] + this.transports = [] + if (transports.length === 0) { return } - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error) - return - } - resolve() - }) - }) - if (transport?.kind === 'unix' && existsSync(transport.endpoint)) { - rmSync(transport.endpoint, { force: true }) - } + await Promise.all(transports.map((t) => t.stop())) // Why: we intentionally leave the last metadata file behind instead of // deleting it on shutdown. Shared userData paths can briefly host multiple // Orca processes during restarts, updates, or development, and stale @@ -136,65 +332,126 @@ export class OrcaRuntimeRpcServer { // bootstrap file. } - private handleConnection(socket: Socket): void { - let buffer = '' + // Why: Unix socket messages use one-shot dispatch (single response per + // request) and the shared runtime auth token from the 0o600 metadata file. + // The transport layer owns socket lifecycle, keepalive writes, and the + // per-connection abort signal — this method just parses, auths, and + // dispatches. See design doc §3.1. + private async handleMessage( + rawMessage: string, + context?: RpcMessageContext + ): Promise { + // Why: empty messages are sent by the Unix socket transport layer when a + // client exceeds the max message size. The transport closes the connection + // after this response. + if (!rawMessage) { + return this.buildError('unknown', 'request_too_large', 'RPC request exceeds the maximum size') + } - socket.setEncoding('utf8') - socket.setNoDelay(true) - socket.setTimeout(RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS, () => { - socket.destroy() - }) - socket.on('error', () => { - socket.destroy() - }) - socket.on('data', (chunk: string) => { - buffer += chunk - // Why: the Orca runtime lives in Electron main, so it must reject - // oversized local RPC frames instead of letting a local client grow an - // unbounded buffer and stall the app. - if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) { - socket.write( - `${JSON.stringify(this.buildError('unknown', 'request_too_large', 'RPC request exceeds the maximum size'))}\n` - ) - socket.end() - return + const parsed = this.parseAndAuth(rawMessage) + if ('error' in parsed) { + return parsed.error + } + const request = parsed.request + + // Why: long-poll admission fence. Short RPCs bypass the counter entirely + // — it only guards handlers that can block for minutes. See §7 risk #2. + const longPoll = isLongPollRequest(request) + if (longPoll && this.activeLongPolls >= this.longPollCap) { + return this.buildError( + request.id, + 'runtime_busy', + 'long-poll capacity reached; retry with backoff' + ) + } + if (longPoll) { + this.activeLongPolls += 1 + // Why: arm the keepalive timer only for long-polls. Short RPCs never + // touch it so the `setInterval` is never created. See §3.1. + context?.startKeepalive() + } + + try { + return await this.dispatcher.dispatch(request, { + signal: longPoll ? context?.signal : undefined + }) + } finally { + if (longPoll) { + this.activeLongPolls = Math.max(0, this.activeLongPolls - 1) } - let newlineIndex = buffer.indexOf('\n') - while (newlineIndex !== -1) { - const rawMessage = buffer.slice(0, newlineIndex).trim() - buffer = buffer.slice(newlineIndex + 1) - if (rawMessage) { - void this.handleMessage(rawMessage).then((response) => { - socket.write(`${JSON.stringify(response)}\n`) - }) - } - newlineIndex = buffer.indexOf('\n') - } - }) + } } - private async handleMessage(rawMessage: string): Promise { + private parseAndAuth(rawMessage: string): { request: RpcRequest } | { error: RpcResponse } { let request: RpcRequest try { request = JSON.parse(rawMessage) as RpcRequest } catch { - return this.buildError('unknown', 'bad_request', 'Invalid JSON request') + return { error: this.buildError('unknown', 'bad_request', 'Invalid JSON request') } } if (typeof request.id !== 'string' || request.id.length === 0) { - return this.buildError('unknown', 'bad_request', 'Missing request id') + return { error: this.buildError('unknown', 'bad_request', 'Missing request id') } } if (typeof request.method !== 'string' || request.method.length === 0) { - return this.buildError(request.id, 'bad_request', 'Missing RPC method') + return { error: this.buildError(request.id, 'bad_request', 'Missing RPC method') } } if (typeof request.authToken !== 'string' || request.authToken.length === 0) { - return this.buildError(request.id, 'unauthorized', 'Missing auth token') + return { error: this.buildError(request.id, 'unauthorized', 'Missing auth token') } } if (request.authToken !== this.authToken) { - return this.buildError(request.id, 'unauthorized', 'Invalid auth token') + return { error: this.buildError(request.id, 'unauthorized', 'Invalid auth token') } } - return this.dispatcher.dispatch(request) + return { request } + } + + // Why: WebSocket messages go through streaming dispatch which can emit + // multiple responses. Auth uses per-device tokens from the device registry. + private async handleWebSocketMessage( + rawMessage: string, + reply: (response: string) => void, + wsTransport?: WebSocketTransport, + ws?: WebSocket + ): Promise { + let request: RpcRequest + try { + request = JSON.parse(rawMessage) as RpcRequest + } catch { + reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Invalid JSON request'))) + return + } + + if (typeof request.id !== 'string' || request.id.length === 0) { + reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Missing request id'))) + return + } + if (typeof request.method !== 'string' || request.method.length === 0) { + reply(JSON.stringify(this.buildError(request.id, 'bad_request', 'Missing RPC method'))) + return + } + + const token = + typeof (request as Record).deviceToken === 'string' + ? ((request as Record).deviceToken as string) + : null + if (!token) { + reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Missing device token'))) + return + } + if (!this.deviceRegistry?.validateToken(token)) { + reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Invalid device token'))) + return + } + + // Why: associate the deviceToken with this WebSocket so ws.on('close') + // can notify the runtime which mobile client disconnected. + if (wsTransport && ws) { + wsTransport.setClientId(ws, token) + } + + const connectionId = ws ? this.wsConnectionIds.get(ws) : undefined + await this.dispatcher.dispatchStreaming(request, reply, { connectionId }) } private buildError(id: string, code: string, message: string): RpcResponse { @@ -205,7 +462,7 @@ export class OrcaRuntimeRpcServer { const metadata: RuntimeMetadata = { runtimeId: this.runtime.getRuntimeId(), pid: this.pid, - transport: this.transport, + transports: this.transports, authToken: this.authToken, startedAt: this.runtime.getStartedAt() } @@ -213,6 +470,61 @@ export class OrcaRuntimeRpcServer { } } +/** + * Why: the regex MUST stay in lockstep with createRuntimeTransportMetadata() + * below, which emits `o-${pid}-${endpointSuffix}.sock` where endpointSuffix + * is `[A-Za-z0-9_-]{1,4}` (derived from a sanitised runtimeId prefix, or + * `'rt'` as the fallback). The invariant is covered by a unit test so any + * future change to the transport-name shape trips CI. + */ +export const RUNTIME_SOCKET_NAME_REGEX = /^o-(\d+)-[A-Za-z0-9_-]+\.sock$/ + +export function sweepOrphanedRuntimeSockets(userDataPath: string, ownPid: number): void { + let entries: string[] + try { + entries = readdirSync(userDataPath) + } catch { + // Why: first-launch userData may not exist yet; the cold-start path + // below will create it. Nothing to sweep in that case. + return + } + for (const entry of entries) { + const match = RUNTIME_SOCKET_NAME_REGEX.exec(entry) + if (!match) { + continue + } + const pid = Number(match[1]) + if (!Number.isFinite(pid)) { + continue + } + // Why: never touch the current process's socket. start() already + // rmSync's it if it exists, but belt-and-braces — a bug in the own-pid + // path here would rmSync a socket we're about to bind to. + if (pid === ownPid) { + continue + } + try { + // Why: signal 0 is the POSIX liveness probe — it delivers no signal + // but returns success iff the pid resolves AND the caller has + // permission to signal it. ESRCH = no such process; EPERM = pid + // exists but owned by another user, which is extremely unusual on a + // desktop app's userData dir but we conservatively leave those + // sockets alone. + process.kill(pid, 0) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + try { + rmSync(join(userDataPath, entry), { force: true }) + } catch { + // Why: best-effort sweep — a permission error on unlink is fine + // to ignore; the socket will be cleaned by a later start() or + // by the OS on reboot. + } + } + } + } +} + export function createRuntimeTransportMetadata( userDataPath: string, pid: number, diff --git a/src/main/runtime/runtime-socket-sweep.test.ts b/src/main/runtime/runtime-socket-sweep.test.ts new file mode 100644 index 00000000000..d2165039369 --- /dev/null +++ b/src/main/runtime/runtime-socket-sweep.test.ts @@ -0,0 +1,83 @@ +import { existsSync, mkdtempSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it } from 'vitest' +import { + createRuntimeTransportMetadata, + RUNTIME_SOCKET_NAME_REGEX, + sweepOrphanedRuntimeSockets +} from './runtime-rpc' + +describe('sweepOrphanedRuntimeSockets', () => { + // Why: a pid we know is always alive and is never the test runner's own + // pid — init on POSIX, so process.kill(1, 0) resolves without ESRCH. Using + // this synthetic pid for ownPid cleanly separates three retention branches + // (own-pid-skipped / alive-non-own-retained / dead-swept) into distinct + // observations. + const SYNTHETIC_OWN_PID = 1 + const KNOWN_DEAD_PID = 99999999 + + it.runIf(process.platform !== 'win32')( + 'sweeps dead-pid sockets while retaining own, alive, and non-matching entries', + () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-sweep-')) + + const ownPidSocket = join(userDataPath, `o-${SYNTHETIC_OWN_PID}-aaaa.sock`) + const aliveSocket = join(userDataPath, `o-${process.pid}-bbbb.sock`) + const deadSocket = join(userDataPath, `o-${KNOWN_DEAD_PID}-cccc.sock`) + const unrelatedFile = join(userDataPath, 'foo.sock') + + writeFileSync(ownPidSocket, '') + writeFileSync(aliveSocket, '') + writeFileSync(deadSocket, '') + writeFileSync(unrelatedFile, '') + + sweepOrphanedRuntimeSockets(userDataPath, SYNTHETIC_OWN_PID) + + // Why: own pid (1) is skipped by the ownPid === pid early-exit. + expect(existsSync(ownPidSocket)).toBe(true) + // Why: alive non-own pid — process.kill(pid, 0) succeeds without + // throwing, so the sweep leaves it alone. + expect(existsSync(aliveSocket)).toBe(true) + // Why: dead pid — process.kill throws ESRCH, sweep removes it. + expect(existsSync(deadSocket)).toBe(false) + // Why: regex miss — not `o--.sock` shape, so the + // sweep never touches it. + expect(existsSync(unrelatedFile)).toBe(true) + } + ) + + it('tolerates a non-existent userData directory', () => { + const userDataPath = join(tmpdir(), `orca-sweep-missing-${Date.now()}`) + + expect(() => sweepOrphanedRuntimeSockets(userDataPath, SYNTHETIC_OWN_PID)).not.toThrow() + }) + + it('regex invariant: matches sockets produced by createRuntimeTransportMetadata', () => { + // Why: if the socket-name factory ever changes shape (e.g. adds a new + // separator or allows different characters), the sweep will silently + // stop matching real sockets. Assert the two stay in lockstep. + const userDataPath = '/tmp' + const transport = createRuntimeTransportMetadata(userDataPath, 12345, 'linux', 'rt_abcdef') + expect(transport.kind).toBe('unix') + if (transport.kind !== 'unix') { + throw new Error('expected unix transport') + } + const basename = transport.endpoint.slice(transport.endpoint.lastIndexOf('/') + 1) + expect(RUNTIME_SOCKET_NAME_REGEX.test(basename)).toBe(true) + }) + + it('regex invariant: also matches the fallback runtimeId suffix ("rt")', () => { + // Why: createRuntimeTransportMetadata falls back to the literal 'rt' + // suffix when the runtimeId contains no allowed characters; the sweep + // regex must still match that shape. + const userDataPath = '/tmp' + const transport = createRuntimeTransportMetadata(userDataPath, 99, 'darwin', '!!!!') + if (transport.kind !== 'unix') { + throw new Error('expected unix transport') + } + const basename = transport.endpoint.slice(transport.endpoint.lastIndexOf('/') + 1) + expect(RUNTIME_SOCKET_NAME_REGEX.test(basename)).toBe(true) + expect(basename).toBe('o-99-rt.sock') + }) +}) diff --git a/src/main/runtime/scrollback-limits.ts b/src/main/runtime/scrollback-limits.ts new file mode 100644 index 00000000000..95308afcff8 --- /dev/null +++ b/src/main/runtime/scrollback-limits.ts @@ -0,0 +1,7 @@ +// Why: mobile subscribers hydrate the runtime headless emulator from the +// desktop renderer's xterm buffer. The renderer holds a 50k-row scrollback; +// shipping all of it across IPC + replaying through HeadlessEmulator is +// expensive and unnecessary for a phone screen. Cap at 1000 rows so the seed +// covers a meaningful amount of agent context while keeping the round-trip +// bounded. See docs/mobile-prefer-renderer-scrollback.md. +export const MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000 diff --git a/src/main/runtime/tls-certificate.ts b/src/main/runtime/tls-certificate.ts new file mode 100644 index 00000000000..1972d13d16e --- /dev/null +++ b/src/main/runtime/tls-certificate.ts @@ -0,0 +1,63 @@ +// Why: the WebSocket transport uses wss:// with a self-signed TLS certificate +// to prevent passive sniffing of device tokens on shared WiFi networks. The +// cert is generated once on first run and reused across restarts. The mobile +// app pins the certificate fingerprint received during QR pairing. +import { createHash } from 'crypto' +import { execSync } from 'child_process' +import { existsSync, readFileSync, chmodSync } from 'fs' +import { join } from 'path' + +const TLS_CERT_FILENAME = 'orca-tls-cert.pem' +const TLS_KEY_FILENAME = 'orca-tls-key.pem' + +export type TlsCertificate = { + cert: string + key: string + fingerprint: string +} + +export function loadOrCreateTlsCertificate(userDataPath: string): TlsCertificate { + const certPath = join(userDataPath, TLS_CERT_FILENAME) + const keyPath = join(userDataPath, TLS_KEY_FILENAME) + + if (existsSync(certPath) && existsSync(keyPath)) { + const cert = readFileSync(certPath, 'utf-8') + const key = readFileSync(keyPath, 'utf-8') + const fingerprint = computeFingerprint(cert) + if (fingerprint) { + return { cert, key, fingerprint } + } + // Why: if the existing cert is malformed (e.g., from a buggy earlier + // generation), regenerate rather than failing the WebSocket transport. + } + + const keyPath_ = join(userDataPath, TLS_KEY_FILENAME) + const certPath_ = join(userDataPath, TLS_CERT_FILENAME) + + // Why: openssl is available on macOS, Linux, and Windows (via Git Bash). + // Using it avoids hand-rolling ASN.1 DER encoding which is error-prone. + execSync( + `openssl req -new -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 ` + + `-nodes -days 3650 -subj "/CN=Orca Runtime" ` + + `-keyout "${keyPath_}" -out "${certPath_}" 2>/dev/null` + ) + + chmodSync(keyPath_, 0o600) + chmodSync(certPath_, 0o600) + + const cert = readFileSync(certPath_, 'utf-8') + const key = readFileSync(keyPath_, 'utf-8') + return { cert, key, fingerprint: computeFingerprint(cert)! } +} + +function computeFingerprint(certPem: string): string | null { + const derMatch = certPem.match( + /-----BEGIN CERTIFICATE-----\n([\s\S]+?)\n-----END CERTIFICATE-----/ + ) + if (!derMatch?.[1]) { + return null + } + const der = Buffer.from(derMatch[1].replace(/\n/g, ''), 'base64') + const hash = createHash('sha256').update(der).digest('hex') + return `sha256:${hash}` +} diff --git a/src/main/runtime/worktree-teardown.test.ts b/src/main/runtime/worktree-teardown.test.ts new file mode 100644 index 00000000000..c66749a7aac --- /dev/null +++ b/src/main/runtime/worktree-teardown.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { listRegisteredPtysMock } = vi.hoisted(() => ({ + listRegisteredPtysMock: vi.fn() +})) + +vi.mock('../memory/pty-registry', () => ({ + listRegisteredPtys: listRegisteredPtysMock +})) + +import { killAllProcessesForWorktree } from './worktree-teardown' +import type { IPtyProvider } from '../providers/types' + +function createProviderStub( + listProcesses: () => Promise<{ id: string; cwd: string; title: string }[]> +): IPtyProvider { + return { + spawn: vi.fn(), + attach: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + 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(), + listProcesses: vi.fn(listProcesses), + getDefaultShell: vi.fn(), + getProfiles: vi.fn(), + onData: vi.fn().mockReturnValue(() => {}), + onReplay: vi.fn().mockReturnValue(() => {}), + onExit: vi.fn().mockReturnValue(() => {}) + } as unknown as IPtyProvider +} + +describe('killAllProcessesForWorktree', () => { + beforeEach(() => { + listRegisteredPtysMock.mockReset() + }) + + it('reaches daemon sessions and registry entries without a runtime', async () => { + // Simulate headless-CLI: no renderer, so `runtime` is undefined. + const localProvider = createProviderStub(async () => [ + { id: 'w1@@abcd1234', cwd: '/tmp/w1', title: 'shell' }, + { id: 'w2@@efef5678', cwd: '/tmp/w2', title: 'shell' } + ]) + listRegisteredPtysMock.mockReturnValue([ + { ptyId: 'w1-registry-1', worktreeId: 'w1', sessionId: null, paneKey: null, pid: 100 }, + { ptyId: 'w2-registry-2', worktreeId: 'w2', sessionId: null, paneKey: null, pid: 101 } + ]) + + const result = await killAllProcessesForWorktree('w1', { localProvider }) + + expect(result.runtimeStopped).toBe(0) + expect(result.providerStopped).toBe(1) + expect(result.registryStopped).toBe(1) + + expect(localProvider.shutdown).toHaveBeenCalledWith('w1@@abcd1234', { immediate: true }) + expect(localProvider.shutdown).toHaveBeenCalledWith('w1-registry-1', { immediate: true }) + expect(localProvider.shutdown).not.toHaveBeenCalledWith('w2@@efef5678', { immediate: true }) + expect(localProvider.shutdown).not.toHaveBeenCalledWith('w2-registry-2', { immediate: true }) + }) + + it('skips the daemon prefix sweep safely when the provider uses numeric ids', async () => { + // LocalPtyProvider shape: numeric ids that cannot match `${worktreeId}@@`. + const localProvider = createProviderStub(async () => [ + { id: '1', cwd: '/tmp/w1', title: 'shell' }, + { id: '2', cwd: '/tmp/w2', title: 'shell' } + ]) + listRegisteredPtysMock.mockReturnValue([ + { ptyId: '1', worktreeId: 'w1', sessionId: null, paneKey: null, pid: 200 } + ]) + + const result = await killAllProcessesForWorktree('w1', { localProvider }) + + // Prefix sweep must kill nothing; registry sweep must still fire. + expect(result.providerStopped).toBe(0) + expect(result.registryStopped).toBe(1) + expect(localProvider.shutdown).toHaveBeenCalledWith('1', { immediate: true }) + expect(localProvider.shutdown).toHaveBeenCalledTimes(1) + }) + + it('best-effort: swallows errors from listProcesses and shutdown', async () => { + const localProvider = createProviderStub(() => Promise.reject(new Error('boom'))) + listRegisteredPtysMock.mockReturnValue([ + { ptyId: 'x', worktreeId: 'w1', sessionId: null, paneKey: null, pid: 10 } + ]) + ;(localProvider.shutdown as unknown as ReturnType).mockRejectedValue( + new Error('already dead') + ) + + const result = await killAllProcessesForWorktree('w1', { localProvider }) + + // listProcesses rejected → provider sweep returns 0; registry shutdown + // rejected → counted as not-killed (registry sweep currently swallows). + expect(result.providerStopped).toBe(0) + expect(result.registryStopped).toBe(0) + }) + + it('does not carry state between successive calls with distinct providers', async () => { + // Guards against a future refactor that memoises provider or registry + // reads inside the helper. + const providerA = createProviderStub(async () => [ + { id: 'w1@@aaaa', cwd: '/tmp', title: 'shell' } + ]) + const providerB = createProviderStub(async () => [ + { id: 'w1@@bbbb', cwd: '/tmp', title: 'shell' } + ]) + listRegisteredPtysMock.mockReturnValue([]) + + const r1 = await killAllProcessesForWorktree('w1', { localProvider: providerA }) + expect(providerA.shutdown).toHaveBeenCalledWith('w1@@aaaa', { immediate: true }) + expect(providerB.shutdown).not.toHaveBeenCalled() + expect(r1.providerStopped).toBe(1) + + const r2 = await killAllProcessesForWorktree('w1', { localProvider: providerB }) + expect(providerB.shutdown).toHaveBeenCalledWith('w1@@bbbb', { immediate: true }) + expect(providerB.shutdown).toHaveBeenCalledTimes(1) + expect(r2.providerStopped).toBe(1) + }) + + it('invokes runtime.stopTerminalsForWorktree when runtime is provided', async () => { + const stopTerminalsForWorktree = vi.fn().mockResolvedValue({ stopped: 3 }) + const runtime = { + stopTerminalsForWorktree + } as unknown as Parameters[1]['runtime'] + + const localProvider = createProviderStub(async () => []) + listRegisteredPtysMock.mockReturnValue([]) + + const result = await killAllProcessesForWorktree('w1', { runtime, localProvider }) + + expect(stopTerminalsForWorktree).toHaveBeenCalledWith('w1') + expect(result.runtimeStopped).toBe(3) + }) + + it('tolerates runtime.stopTerminalsForWorktree throwing (headless assertGraphReady reject)', async () => { + const stopTerminalsForWorktree = vi.fn().mockRejectedValue(new Error('graph not ready')) + const runtime = { + stopTerminalsForWorktree + } as unknown as Parameters[1]['runtime'] + + const localProvider = createProviderStub(async () => []) + listRegisteredPtysMock.mockReturnValue([]) + + const result = await killAllProcessesForWorktree('w1', { runtime, localProvider }) + + expect(result.runtimeStopped).toBe(0) + }) +}) diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts new file mode 100644 index 00000000000..3488d5033af --- /dev/null +++ b/src/main/runtime/worktree-teardown.ts @@ -0,0 +1,93 @@ +import type { IPtyProvider } from '../providers/types' +import type { OrcaRuntimeService } from './orca-runtime' +import { listRegisteredPtys } from '../memory/pty-registry' + +export type WorktreeTeardownDeps = { + runtime?: OrcaRuntimeService + localProvider: IPtyProvider +} + +export type WorktreeTeardownResult = { + runtimeStopped: number + providerStopped: number + registryStopped: number +} + +/** + * Kills every PTY we can prove belongs to `worktreeId`, across all three + * registration surfaces (renderer graph, installed PTY provider session list, + * local pty-registry). + * + * Why all three: + * - runtime.leaves is authoritative when the renderer is attached, but is + * empty in the headless-CLI case (see design §2b). + * - The installed provider's listProcesses() surfaces daemon sessions by + * the `${worktreeId}@@` session-id contract (§3.1). Because daemon-init + * installs the daemon adapter AS the localProvider via + * setLocalPtyProvider(), a single call reaches the right backend in both + * daemon-on and daemon-off configurations. LocalPtyProvider uses numeric + * ids, so the prefix filter is a safe no-op when the daemon is absent. + * - pty-registry covers the fallback local provider case and is the + * canonical source for memory attribution; it also redundantly backstops + * daemon spawns. + * + * Best-effort throughout: each sweep catches its own errors. The caller + * (removeManagedWorktree, worktrees:remove IPC) must run the git-level + * removal regardless of what this returns. + */ +export async function killAllProcessesForWorktree( + worktreeId: string, + deps: WorktreeTeardownDeps +): Promise { + const result: WorktreeTeardownResult = { + runtimeStopped: 0, + providerStopped: 0, + registryStopped: 0 + } + + if (deps.runtime) { + const r = await deps.runtime.stopTerminalsForWorktree(worktreeId).catch(() => ({ stopped: 0 })) + result.runtimeStopped = r.stopped + } + + result.providerStopped = await sweepProviderByPrefix(worktreeId, deps.localProvider) + result.registryStopped = await sweepRegistryForWorktree(worktreeId, deps.localProvider) + + return result +} + +async function sweepProviderByPrefix(worktreeId: string, provider: IPtyProvider): Promise { + const prefix = `${worktreeId}@@` + const sessions = await provider.listProcesses().catch(() => []) + let killed = 0 + for (const s of sessions) { + if (!s.id.startsWith(prefix)) { + continue + } + try { + await provider.shutdown(s.id, { immediate: true }) + killed += 1 + } catch { + // Already dead, or the backend dropped the session — treat as success. + killed += 1 + } + } + return killed +} + +async function sweepRegistryForWorktree( + worktreeId: string, + localProvider: IPtyProvider +): Promise { + const entries = listRegisteredPtys().filter((r) => r.worktreeId === worktreeId) + let killed = 0 + for (const entry of entries) { + try { + await localProvider.shutdown(entry.ptyId, { immediate: true }) + killed += 1 + } catch { + /* ignore — best-effort */ + } + } + return killed +} diff --git a/src/main/ssh/sftp-upload.ts b/src/main/ssh/sftp-upload.ts new file mode 100644 index 00000000000..b7491542b64 --- /dev/null +++ b/src/main/ssh/sftp-upload.ts @@ -0,0 +1,97 @@ +import { createReadStream } from 'fs' +import { readdir } from 'fs/promises' +import { join as pathJoin } from 'path' +import type { SFTPWrapper } from 'ssh2' + +export function mkdirSftp(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.mkdir(path, (err) => { + // Why: SFTP status code 4 (SSH_FX_FAILURE) is a generic code that + // OpenSSH returns for "already exists," but could also cover other + // failures (e.g. permission denied on parent). We accept this ambiguity + // because the next operation (write/recurse) will surface the real error. + if (err && (err as { code?: number }).code !== 4) { + reject(err) + } else { + resolve() + } + }) + }) +} + +export function uploadFile( + sftp: SFTPWrapper, + localPath: string, + remotePath: string +): Promise { + return new Promise((resolve, reject) => { + let settled = false + const readStream = createReadStream(localPath) + const writeStream = sftp.createWriteStream(remotePath) + + const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => { + if (settled) { + return + } + settled = true + readStream.destroy() + writeStream.destroy() + fn(val as never) + } + + writeStream.on('close', () => settle(resolve)) + writeStream.on('error', (err) => settle(reject, err)) + readStream.on('error', (err) => settle(reject, err)) + + readStream.pipe(writeStream) + }) +} + +export async function uploadDirectory( + sftp: SFTPWrapper, + localDir: string, + remoteDir: string +): Promise { + const entries = await readdir(localDir, { withFileTypes: true }) + for (const entry of entries) { + const localPath = pathJoin(localDir, entry.name) + const remotePath = `${remoteDir}/${entry.name}` + + // Why: skip symlinks and special files (sockets, FIFOs, devices) to + // prevent following symlinks that could exfiltrate local files to the + // remote. The caller's pre-scan catches symlinks up-front, but this + // guard closes the TOCTOU gap if one is created between scan and upload. + if (entry.isSymbolicLink() || (!entry.isFile() && !entry.isDirectory())) { + continue + } + + if (entry.isDirectory()) { + await mkdirSftp(sftp, remotePath) + await uploadDirectory(sftp, localPath, remotePath) + } else { + await uploadFile(sftp, localPath, remotePath) + } + } +} + +/** + * Check whether a path exists on the remote via SFTP lstat. + * Returns true if the path exists (file, directory, or symlink). + */ +export function sftpPathExists(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.lstat(remotePath, (err) => { + if (!err) { + resolve(true) + return + } + // Why: SFTP status code 2 = SSH_FX_NO_SUCH_FILE — the path does not + // exist, which is the expected "no collision" signal for deconfliction. + if ((err as { code?: number }).code === 2) { + resolve(false) + return + } + reject(err) + }) + }) +} diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 6c986fbcb48..fc19c622b1c 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -55,6 +55,16 @@ export class SshConnection { return { ...this.target } } + // Why: exposes whether a passphrase/password is already cached in-memory for + // this connection. Used by ssh:needsPassphrasePrompt so callers can decide + // whether a manual-reconnect will prompt or go through silently. Without this, + // lastRequiredPassphrase stays true across the session even after the user + // has entered the credential once, causing redundant "enter passphrase" + // prompts on disconnect→reconnect cycles within a single app session. + hasCachedCredential(): boolean { + return this.cachedPassphrase != null || this.cachedPassword != null + } + async exec(cmd: string): Promise { if (!this.client) { throw new Error('Not connected') @@ -110,7 +120,9 @@ export class SshConnection { this.proxyProcess = null const connectGeneration = ++this.connectGeneration - const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch(() => null) + const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch( + () => null + ) const config = buildConnectConfig(this.target, resolved) // Why: ssh2 doesn't support ProxyCommand/ProxyJump natively. Spawn the diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index 37307269151..f2511c9d948 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -1,63 +1,9 @@ -import { createReadStream } from 'fs' -import type { SFTPWrapper, ClientChannel } from 'ssh2' +import type { ClientChannel } from 'ssh2' import type { SshConnection } from './ssh-connection' import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' -// ── SFTP upload helpers ─────────────────────────────────────────────── - -export async function uploadDirectory( - sftp: SFTPWrapper, - localDir: string, - remoteDir: string -): Promise { - const { readdirSync, statSync } = await import('fs') - const { join: pathJoin } = await import('path') - - const entries = readdirSync(localDir) - for (const entry of entries) { - const localPath = pathJoin(localDir, entry) - const remotePath = `${remoteDir}/${entry}` - const stat = statSync(localPath) - - if (stat.isDirectory()) { - await mkdirSftp(sftp, remotePath) - await uploadDirectory(sftp, localPath, remotePath) - } else { - await uploadFile(sftp, localPath, remotePath) - } - } -} - -export function mkdirSftp(sftp: SFTPWrapper, path: string): Promise { - return new Promise((resolve, reject) => { - sftp.mkdir(path, (err) => { - // Ignore "already exists" errors (SFTP status code 4 = SSH_FX_FAILURE) - if (err && (err as { code?: number }).code !== 4) { - reject(err) - } else { - resolve() - } - }) - }) -} - -export function uploadFile( - sftp: SFTPWrapper, - localPath: string, - remotePath: string -): Promise { - return new Promise((resolve, reject) => { - const readStream = createReadStream(localPath) - const writeStream = sftp.createWriteStream(remotePath) - - writeStream.on('close', resolve) - writeStream.on('error', reject) - readStream.on('error', reject) - - readStream.pipe(writeStream) - }) -} +export { uploadFile, uploadDirectory, mkdirSftp } from './sftp-upload' // ── Sentinel detection ──────────────────────────────────────────────── diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index c5cec06581b..2a972db6af1 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -34,6 +34,7 @@ import type { SshPortForwardManager } from './ssh-port-forward' import type { SshConnection } from './ssh-connection' import type { DetectedPort } from '../../shared/ssh-types' import type { Store } from '../persistence' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' @@ -54,6 +55,7 @@ export class SshRelaySession { private getMainWindow: () => BrowserWindow | null, private store: Store, private portForwardManager: SshPortForwardManager, + private runtime?: OrcaRuntimeService, private onDetectedPortsChanged?: ( targetId: string, ports: DetectedPort[], @@ -462,6 +464,7 @@ export class SshRelaySession { private wireUpPtyEvents(ptyProvider: SshPtyProvider): void { const getWin = this.getMainWindow ptyProvider.onData((payload) => { + this.runtime?.onPtyData(payload.id, payload.data, Date.now()) const win = getWin() if (win && !win.isDestroyed()) { win.webContents.send('pty:data', payload) @@ -476,6 +479,7 @@ export class SshRelaySession { ptyProvider.onExit((payload) => { clearProviderPtyState(payload.id) deletePtyOwnership(payload.id) + this.runtime?.onPtyExit(payload.id, payload.code) const win = getWin() if (win && !win.isDestroyed()) { win.webContents.send('pty:exit', payload) diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index a20dd7c6eda..a58e85dab7a 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -13,7 +13,8 @@ vi.mock('electron', () => { exit: vi.fn(), isPackaged: false, commandLine: { - appendSwitch: vi.fn() + appendSwitch: vi.fn(), + getSwitchValue: vi.fn(() => '') } } } @@ -246,97 +247,31 @@ describe('installDevParentWatchdog', () => { }) describe('enableMainProcessGpuFeatures', () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - const originalWaylandDisplay = process.env.WAYLAND_DISPLAY - const originalXdgSessionType = process.env.XDG_SESSION_TYPE - - function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) - } - - afterEach(() => { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - if (originalWaylandDisplay === undefined) { - delete process.env.WAYLAND_DISPLAY - } else { - process.env.WAYLAND_DISPLAY = originalWaylandDisplay - } - if (originalXdgSessionType === undefined) { - delete process.env.XDG_SESSION_TYPE - } else { - process.env.XDG_SESSION_TYPE = originalXdgSessionType - } - }) - - it('appends Orca GPU flags on darwin', async () => { + it('appends VS Code-style GPU channel flags without unsafe WebGPU/Vulkan opt-ins', async () => { const { app } = await import('electron') const { enableMainProcessGpuFeatures } = await import('./configure-process') vi.mocked(app.commandLine.appendSwitch).mockClear() - setPlatform('darwin') enableMainProcessGpuFeatures() expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( 'enable-features', - 'Vulkan,UseSkiaGraphite' + 'EarlyEstablishGpuChannel,EstablishGpuChannelAsync' ) - expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('enable-unsafe-webgpu') + expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('enable-unsafe-webgpu') }) - it('appends Orca GPU flags on linux X11 sessions', async () => { - // Why: Chromium's X11 Vulkan path works, so we keep Skia Graphite / WebGPU - // acceleration on X11. Only Wayland hits the wayland_surface_factory.cc - // incompatibility and needs the gate. + it('preserves existing enable-features switches', async () => { const { app } = await import('electron') const { enableMainProcessGpuFeatures } = await import('./configure-process') vi.mocked(app.commandLine.appendSwitch).mockClear() - setPlatform('linux') - delete process.env.WAYLAND_DISPLAY - process.env.XDG_SESSION_TYPE = 'x11' + vi.mocked(app.commandLine.getSwitchValue).mockReturnValue('ExistingFeature') enableMainProcessGpuFeatures() expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( 'enable-features', - 'Vulkan,UseSkiaGraphite' + 'EarlyEstablishGpuChannel,EstablishGpuChannelAsync,ExistingFeature' ) - expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('enable-unsafe-webgpu') - }) - - it('skips Vulkan/SkiaGraphite/WebGPU switches when WAYLAND_DISPLAY is set', async () => { - // Why: Chromium's Ozone/Wayland surface factory hard-aborts with Vulkan - // enabled (wayland_surface_factory.cc:251), producing a blank/transparent - // window on Wayland-default distros (Arch/Omarchy/Hyprland, GNOME-Wayland). - const { app } = await import('electron') - const { enableMainProcessGpuFeatures } = await import('./configure-process') - - vi.mocked(app.commandLine.appendSwitch).mockClear() - setPlatform('linux') - process.env.WAYLAND_DISPLAY = 'wayland-0' - delete process.env.XDG_SESSION_TYPE - enableMainProcessGpuFeatures() - - expect(app.commandLine.appendSwitch).not.toHaveBeenCalled() - }) - - it('skips switches when XDG_SESSION_TYPE=wayland without WAYLAND_DISPLAY', async () => { - // Why: belt-and-suspenders — some nested/manually-started Wayland sessions - // advertise XDG_SESSION_TYPE=wayland without yet having WAYLAND_DISPLAY - // set in the process's environment at launch time. - const { app } = await import('electron') - const { enableMainProcessGpuFeatures } = await import('./configure-process') - - vi.mocked(app.commandLine.appendSwitch).mockClear() - setPlatform('linux') - delete process.env.WAYLAND_DISPLAY - process.env.XDG_SESSION_TYPE = 'wayland' - enableMainProcessGpuFeatures() - - expect(app.commandLine.appendSwitch).not.toHaveBeenCalled() }) }) diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index fe1c309e5dc..bf64bc4a3cd 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -169,33 +169,17 @@ export function installDevParentWatchdog(isDev: boolean): void { timer.unref() } -function isLinuxWaylandSession(): boolean { - // Why: WAYLAND_DISPLAY is set directly by the Wayland compositor and is the - // same signal Electron's own ELECTRON_OZONE_PLATFORM_HINT=auto logic uses. - // XDG_SESSION_TYPE is the login-manager/PAM signal and is the belt-and- - // suspenders check for sessions where WAYLAND_DISPLAY isn't set at process - // start (nested Wayland, manual session startup). Both are inherited from - // the parent process, so they're available before app.whenReady where the - // GPU command-line switches must be appended. - return ( - process.platform === 'linux' && - (Boolean(process.env.WAYLAND_DISPLAY) || process.env.XDG_SESSION_TYPE === 'wayland') - ) -} - export function enableMainProcessGpuFeatures(): void { - // Why: Chromium's Ozone/Wayland surface factory hard-aborts when Vulkan is - // enabled (see wayland_surface_factory.cc:251 — "--ozone-platform=wayland is - // not compatible with Vulkan"), leaving the renderer unable to compose and - // showing a blank/transparent window on Wayland-default distros like - // Arch/Omarchy/Hyprland and GNOME-Wayland. Skia Graphite is Vulkan-only on - // Linux (no OpenGL backend), so enabling it with Vulkan off would silently - // fall back to software rendering — gate it on the same Wayland signal. - // The X11 Vulkan path works, so we keep the acceleration there and on - // macOS/Windows. - if (isLinuxWaylandSession()) { - return - } - app.commandLine.appendSwitch('enable-features', 'Vulkan,UseSkiaGraphite') - app.commandLine.appendSwitch('enable-unsafe-webgpu') + const existingFeatures = app.commandLine.getSwitchValue('enable-features') + const features = [ + // Why: mirror VS Code's conservative Electron GPU-channel startup flags + // instead of opting into Vulkan/SkiaGraphite/unsafe WebGPU globally. + // Terminal acceleration is controlled by xterm WebGL in the renderer. + 'EarlyEstablishGpuChannel', + 'EstablishGpuChannelAsync', + existingFeatures + ] + .filter(Boolean) + .join(',') + app.commandLine.appendSwitch('enable-features', features) } diff --git a/src/main/startup/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts index c1fea6eab25..e56e8c79c18 100644 --- a/src/main/startup/run-electron-vite-dev.test.ts +++ b/src/main/startup/run-electron-vite-dev.test.ts @@ -63,6 +63,7 @@ describe('run-electron-vite-dev', () => { env: { ...process.env, ORCA_ELECTRON_VITE_CLI: fakeCliPath, + ORCA_SKIP_DEV_CLI_PREPARE: '1', ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile }, stdio: 'ignore' diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts new file mode 100644 index 00000000000..4b1fe7a1c12 --- /dev/null +++ b/src/main/startup/single-instance-lock.test.ts @@ -0,0 +1,67 @@ +import type { App } from 'electron' +import { describe, expect, it, vi } from 'vitest' +import { acquireSingleInstanceLock } from './single-instance-lock' + +type Listener = (...args: unknown[]) => void + +function makeFakeApp(lockResult: boolean): { + app: App + requestSingleInstanceLock: ReturnType + on: ReturnType + listeners: Record +} { + const listeners: Record = {} + const requestSingleInstanceLock = vi.fn(() => lockResult) + const on = vi.fn((event: string, cb: Listener) => { + listeners[event] = listeners[event] ?? [] + listeners[event].push(cb) + }) + const app = { + requestSingleInstanceLock, + on + } as unknown as App + return { app, requestSingleInstanceLock, on, listeners } +} + +describe('acquireSingleInstanceLock', () => { + it('returns false and does NOT register second-instance when the lock is held', () => { + const onSecondInstance = vi.fn() + const fake = makeFakeApp(false) + + const acquired = acquireSingleInstanceLock(fake.app, onSecondInstance) + + expect(acquired).toBe(false) + expect(fake.requestSingleInstanceLock).toHaveBeenCalledTimes(1) + // Why: if we registered the listener on a losing process, focusing the + // existing window would become our job even though the primary owns + // that UX surface. Verify no listener was added. + expect(fake.on).not.toHaveBeenCalled() + expect(fake.listeners['second-instance']).toBeUndefined() + }) + + it('returns true and registers exactly one second-instance listener when the lock is acquired', () => { + const onSecondInstance = vi.fn() + const fake = makeFakeApp(true) + + const acquired = acquireSingleInstanceLock(fake.app, onSecondInstance) + + expect(acquired).toBe(true) + expect(fake.requestSingleInstanceLock).toHaveBeenCalledTimes(1) + expect(fake.on).toHaveBeenCalledTimes(1) + expect(fake.on).toHaveBeenCalledWith('second-instance', onSecondInstance) + expect(fake.listeners['second-instance']).toHaveLength(1) + }) + + it('fires the registered callback when second-instance dispatches', () => { + const onSecondInstance = vi.fn() + const fake = makeFakeApp(true) + + acquireSingleInstanceLock(fake.app, onSecondInstance) + + const [registered] = fake.listeners['second-instance'] ?? [] + expect(registered).toBeDefined() + registered?.() + + expect(onSecondInstance).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts new file mode 100644 index 00000000000..0227d454cc0 --- /dev/null +++ b/src/main/startup/single-instance-lock.ts @@ -0,0 +1,27 @@ +import type { App } from 'electron' + +/** + * Why: Orca writes two canonical discovery files into `/`: + * `orca-runtime.json` (RPC endpoint + authToken for the bundled CLI) and + * `agent-hooks/endpoint.env` (hook port + token for cursor-agent/claude/codex + * scripts). Without a single-instance lock, every AppImage/.app double-click + * boots a fresh Electron main that clobbers both files. When the most recent + * instance quits, metadata points at a dead pid and `orca status` reports + * `stale_bootstrap` even though the original process is still running. + * + * This helper centralises the lock gate so it is testable in isolation and + * so `src/main/index.ts` has one clean call site rather than two spread-out + * Electron calls. + * + * Electron derives the lock identity from the current `userData` path, so + * callers MUST invoke this AFTER `configureDevUserDataPath(is.dev)` — that + * way dev (`orca-dev` userData) and packaged (`orca` userData) runs lock in + * separate namespaces instead of serialising against each other. + */ +export function acquireSingleInstanceLock(app: App, onSecondInstance: () => void): boolean { + if (!app.requestSingleInstanceLock()) { + return false + } + app.on('second-instance', onSecondInstance) + return true +} diff --git a/src/main/telemetry/burst-cap.test.ts b/src/main/telemetry/burst-cap.test.ts new file mode 100644 index 00000000000..a7b3ae456c0 --- /dev/null +++ b/src/main/telemetry/burst-cap.test.ts @@ -0,0 +1,163 @@ +// Burst-cap behavior. These tests pin the three independent buckets (per- +// event token bucket, per-session global ceiling, consent-mutation bucket), +// the refill math, and the "exactly one warn per cap crossing per session" +// rule. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + _getBurstCapStateForTests, + consumeBurstToken, + consumeConsentMutationToken, + resetBurstCapsForSession +} from './burst-cap' + +describe('burst-cap', () => { + beforeEach(() => { + resetBurstCapsForSession() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-03T00:00:00Z')) + }) + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + // ── Per-event token bucket ─────────────────────────────────────────── + + it('allows up to 30 events/min for default-bucket event names', () => { + // 30 tokens; 30 consumes should all succeed, 31st fails in the same + // instant (no time has elapsed for refill). + for (let i = 0; i < 30; i++) { + expect(consumeBurstToken('app_opened')).toBe(true) + } + expect(consumeBurstToken('app_opened')).toBe(false) + }) + + it('allows up to 20 events/min for agent_error (tighter cap)', () => { + for (let i = 0; i < 20; i++) { + expect(consumeBurstToken('agent_error')).toBe(true) + } + expect(consumeBurstToken('agent_error')).toBe(false) + }) + + it('refills tokens continuously over the window', () => { + // Drain the bucket… + for (let i = 0; i < 30; i++) { + consumeBurstToken('app_opened') + } + expect(consumeBurstToken('app_opened')).toBe(false) + // …then advance half the refill window. Half of 30 = 15 tokens back. + vi.advanceTimersByTime(30_000) + let allowed = 0 + for (let i = 0; i < 20; i++) { + if (consumeBurstToken('app_opened')) { + allowed++ + } + } + expect(allowed).toBeGreaterThanOrEqual(14) + expect(allowed).toBeLessThanOrEqual(15) + }) + + it('emits exactly one warn the first time the per-event cap is crossed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + for (let i = 0; i < 30; i++) { + consumeBurstToken('app_opened') + } + // Four overflow attempts — only one warn across all of them. + consumeBurstToken('app_opened') + consumeBurstToken('app_opened') + consumeBurstToken('app_opened') + consumeBurstToken('app_opened') + expect(warn).toHaveBeenCalledTimes(1) + }) + + // ── Unknown event names ────────────────────────────────────────────── + + it('rejects unknown event names without creating a bucket (prevents unbounded Map growth)', () => { + // The IPC handler casts any renderer string to `EventName`, so a + // compromised renderer could flood unique bogus names. `consumeBurstToken` + // must short-circuit before `getOrCreateBucket` to keep `perEventBuckets` + // bounded by the compile-time `eventSchemas` size. + const sizeBefore = _getBurstCapStateForTests().perEventBuckets.size + // Cast through unknown — this is the renderer-controlled-string scenario. + expect(consumeBurstToken('totally_bogus_event_name' as unknown as 'app_opened')).toBe(false) + expect(consumeBurstToken('another_fake_name' as unknown as 'app_opened')).toBe(false) + expect(consumeBurstToken('yet_another' as unknown as 'app_opened')).toBe(false) + const sizeAfter = _getBurstCapStateForTests().perEventBuckets.size + expect(sizeAfter).toBe(sizeBefore) + }) + + it('rejects Object.prototype key names without creating a bucket', () => { + // Regression: the guard originally used `name in eventSchemas`, which + // walks the prototype chain — so `'toString'`, `'__proto__'`, + // `'constructor'`, etc. would all pass the check and seed buckets. + // `Object.hasOwn` is an own-property check and rejects them. + const sizeBefore = _getBurstCapStateForTests().perEventBuckets.size + expect(consumeBurstToken('toString' as unknown as 'app_opened')).toBe(false) + expect(consumeBurstToken('__proto__' as unknown as 'app_opened')).toBe(false) + expect(consumeBurstToken('constructor' as unknown as 'app_opened')).toBe(false) + expect(consumeBurstToken('hasOwnProperty' as unknown as 'app_opened')).toBe(false) + expect(consumeBurstToken('valueOf' as unknown as 'app_opened')).toBe(false) + const sizeAfter = _getBurstCapStateForTests().perEventBuckets.size + expect(sizeAfter).toBe(sizeBefore) + }) + + // ── Per-session ceiling ────────────────────────────────────────────── + + it('enforces the 1000-event per-session ceiling even if per-event caps refill', () => { + // Cycle through enum event names to keep per-event buckets alive, and + // advance time so the per-event bucket always has a token. After + // ceiling is hit, all further attempts must fail regardless of which + // event name. + let accepted = 0 + for (let i = 0; i < 2000; i++) { + vi.advanceTimersByTime(10_000) // generous refill + if (consumeBurstToken('app_opened')) { + accepted++ + } + } + expect(accepted).toBe(1000) + }) + + it('resets the per-session ceiling on resetBurstCapsForSession()', () => { + for (let i = 0; i < 1500; i++) { + vi.advanceTimersByTime(10_000) + consumeBurstToken('app_opened') + } + resetBurstCapsForSession() + expect(consumeBurstToken('app_opened')).toBe(true) + expect(_getBurstCapStateForTests().perSessionCount).toBe(1) + }) + + // ── Consent-mutation bucket ────────────────────────────────────────── + + it('allows up to 5 consent mutations per session and then drops', () => { + for (let i = 0; i < 5; i++) { + expect(consumeConsentMutationToken()).toBe(true) + } + expect(consumeConsentMutationToken()).toBe(false) + expect(consumeConsentMutationToken()).toBe(false) + }) + + it('emits exactly one warn when the consent-mutation cap is first crossed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + for (let i = 0; i < 5; i++) { + consumeConsentMutationToken() + } + // Multiple overflow attempts — one warn only. + consumeConsentMutationToken() + consumeConsentMutationToken() + consumeConsentMutationToken() + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('consent-mutation bucket resets across sessions, not within one', () => { + for (let i = 0; i < 5; i++) { + consumeConsentMutationToken() + } + expect(consumeConsentMutationToken()).toBe(false) + resetBurstCapsForSession() + expect(consumeConsentMutationToken()).toBe(true) + }) +}) diff --git a/src/main/telemetry/burst-cap.ts b/src/main/telemetry/burst-cap.ts new file mode 100644 index 00000000000..382e6600dfa --- /dev/null +++ b/src/main/telemetry/burst-cap.ts @@ -0,0 +1,171 @@ +// Burst caps for the telemetry transport. Three independent buckets — all +// must be satisfied for an event to transmit or a consent mutation to apply. +// +// (1) Per-event-name token bucket — defends against runaway-`useEffect` +// bugs and repeated error serializations. `agent_error` is rate-limited +// slightly tighter (20/min) than the default (30/min). +// +// (2) Per-session global ceiling (1,000 events) — defends against a +// compromised renderer. A renderer rendering attacker-controllable +// content can invoke `window.api.telemetryTrack` at any rate the +// per-event-name bucket allows; without a global ceiling, 24h of +// abuse at the per-event cap could emit ~650K events and blow the +// PostHog billing cap in one session. +// +// (3) Consent-mutation bucket (≤5 per session, shared across `setOptIn` +// and `acknowledgeBanner`) — a real user flips the Privacy pane +// toggle a handful of times at most; beyond that it is either a bug +// or a compromised renderer. +// +// All buckets reset when `resetBurstCapsForSession()` is called at the start +// of a fresh telemetry session (on `initTelemetry`). Within a session, the +// per-event token bucket refills continuously at its `capacity / 60_000` ms +// rate; the global ceiling and the consent-mutation bucket do not refill +// within a session by design — the whole point of the global ceiling is to +// cap aggregate per-session volume. +// +// Overflow is silent past the first crossing: each bucket logs exactly one +// `console.warn` the first time it rejects an attempt in a given session, +// then drops quietly until the next session reset. Rate-limiting the logs +// themselves is what keeps a pathological caller from DoSing stderr. + +import { eventSchemas, type EventName } from '../../shared/telemetry-events' + +const PER_EVENT_DEFAULT_CAPACITY = 30 +const PER_EVENT_AGENT_ERROR_CAPACITY = 20 +const WINDOW_MS = 60_000 + +const PER_SESSION_CEILING = 1_000 +const CONSENT_MUTATION_CEILING = 5 + +type TokenBucket = { + tokens: number + capacity: number + lastRefill: number + warned: boolean +} + +// Module-level state. One Store instance / one telemetry session per main +// process; there is no multi-tenant reuse to worry about. `initTelemetry` +// calls `resetBurstCapsForSession()` to start fresh on each session. +const perEventBuckets = new Map() +let perSessionCount = 0 +let perSessionWarned = false +let consentMutationCount = 0 +let consentMutationWarned = false + +function capacityFor(name: string): number { + return name === 'agent_error' ? PER_EVENT_AGENT_ERROR_CAPACITY : PER_EVENT_DEFAULT_CAPACITY +} + +function getOrCreateBucket(name: string, now: number): TokenBucket { + let bucket = perEventBuckets.get(name) + if (!bucket) { + const capacity = capacityFor(name) + bucket = { tokens: capacity, capacity, lastRefill: now, warned: false } + perEventBuckets.set(name, bucket) + return bucket + } + // Continuous refill — `capacity` tokens per `WINDOW_MS` ms. Computed + // lazily on each access so we do not need a timer; equivalent to the + // standard token-bucket formula used by most rate limiters. + const elapsed = now - bucket.lastRefill + if (elapsed > 0) { + const refill = (elapsed / WINDOW_MS) * bucket.capacity + bucket.tokens = Math.min(bucket.capacity, bucket.tokens + refill) + bucket.lastRefill = now + } + return bucket +} + +/** + * Consume one token for the given event name. Returns `true` if the event is + * allowed to proceed, `false` if any of the buckets rejected it. + * + * Ordering rationale: per-event bucket first, per-session ceiling second. + * The per-event bucket is the attention-conserving check (drops runaway + * useEffects early, before counting against the session ceiling); the global + * ceiling is the correctness backstop against a compromised renderer that + * cycles through event names to evade the per-event caps. + */ +export function consumeBurstToken(name: EventName): boolean { + // Reject unknown event names here so renderer-controlled strings cannot + // grow `perEventBuckets` past the fixed `eventSchemas` size. The IPC + // `telemetry:track` handler casts any string to `EventName`, so a + // compromised renderer could otherwise flood unique bogus names and + // unboundedly grow the Map before the validator rejects them. Downstream + // validator still rejects with the proper "unknown event" reason. + // + // Use `Object.hasOwn` rather than `in` — the latter walks the prototype + // chain, so a compromised renderer could pass `'toString'`, `'__proto__'`, + // `'constructor'`, etc. to bypass the guard and seed buckets for every + // `Object.prototype` key. Growth would be bounded (~12 keys) but the whole + // point of this check is to keep the Map size pinned to the compile-time + // `eventSchemas` surface. + if (!Object.hasOwn(eventSchemas, name)) { + return false + } + const now = Date.now() + const bucket = getOrCreateBucket(name, now) + if (bucket.tokens < 1) { + if (!bucket.warned) { + bucket.warned = true + console.warn(`[telemetry] per-event burst cap hit for '${name}'; dropping further events`) + } + return false + } + if (perSessionCount >= PER_SESSION_CEILING) { + if (!perSessionWarned) { + perSessionWarned = true + console.warn( + `[telemetry] per-session event ceiling (${PER_SESSION_CEILING}) hit; dropping further events` + ) + } + return false + } + bucket.tokens -= 1 + perSessionCount += 1 + return true +} + +/** + * Consume one token from the consent-mutation bucket. Returns `true` if the + * caller is allowed to apply a consent mutation, `false` if the per-session + * ceiling has been reached. Renderer-triggered IPC calls are the only + * callers of this bucket — main-originated consent mutations bypass IPC and + * are not rate-limited here. + */ +export function consumeConsentMutationToken(): boolean { + if (consentMutationCount >= CONSENT_MUTATION_CEILING) { + if (!consentMutationWarned) { + consentMutationWarned = true + console.warn( + `[telemetry] consent-mutation rate limit (${CONSENT_MUTATION_CEILING}/session) hit; dropping further mutations` + ) + } + return false + } + consentMutationCount += 1 + return true +} + +/** + * Reset every bucket. Called at the start of each telemetry session from + * `initTelemetry`. Tests also call it to get a clean slate between cases. + */ +export function resetBurstCapsForSession(): void { + perEventBuckets.clear() + perSessionCount = 0 + perSessionWarned = false + consentMutationCount = 0 + consentMutationWarned = false +} + +/** Test-only introspection. Not part of the runtime API. */ +export function _getBurstCapStateForTests(): { + perEventBuckets: Map + perSessionCount: number + consentMutationCount: number +} { + return { perEventBuckets, perSessionCount, consentMutationCount } +} diff --git a/src/main/telemetry/classify-error.test.ts b/src/main/telemetry/classify-error.test.ts new file mode 100644 index 00000000000..bad63bb06aa --- /dev/null +++ b/src/main/telemetry/classify-error.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { classifyError } from './classify-error' + +describe('classifyError', () => { + it('classifies ENOENT as binary_not_found', () => { + expect(classifyError(new Error('spawn zsh ENOENT'))).toEqual({ + error_class: 'binary_not_found' + }) + expect(classifyError(Object.assign(new Error('missing'), { code: 'ENOENT' }))).toEqual({ + error_class: 'binary_not_found' + }) + }) + + it('keeps shell-output-shaped not-found messages unknown', () => { + expect(classifyError(new Error('command not found: claude'))).toEqual({ + error_class: 'unknown' + }) + expect(classifyError(new Error('workspace not found'))).toEqual({ + error_class: 'unknown' + }) + expect( + classifyError( + Object.assign(new Error('socket missing'), { code: 'ENOENT', syscall: 'connect' }) + ) + ).toEqual({ error_class: 'unknown' }) + }) + + it('returns unknown for null/undefined input', () => { + expect(classifyError(null)).toEqual({ error_class: 'unknown' }) + expect(classifyError(undefined)).toEqual({ error_class: 'unknown' }) + }) + + it('returns unknown for an unrecognized message', () => { + expect(classifyError(new Error('some unique failure mode we do not know'))).toEqual({ + error_class: 'unknown' + }) + }) + + it('returns unknown for non-Error throws', () => { + expect(classifyError('a bare string')).toEqual({ error_class: 'unknown' }) + expect(classifyError({ random: 'object' })).toEqual({ error_class: 'unknown' }) + }) +}) diff --git a/src/main/telemetry/classify-error.ts b/src/main/telemetry/classify-error.ts new file mode 100644 index 00000000000..06b5700dbe4 --- /dev/null +++ b/src/main/telemetry/classify-error.ts @@ -0,0 +1,44 @@ +// Best-effort classifier from a thrown value to the closed `error_class` +// enum on `agent_error`. The wire never carries a raw error message or stack +// — every transmitted error is bucketed into one of the two enum members +// (`binary_not_found` for ENOENT-shaped failures, `unknown` for everything +// else). A non-zero `unknown` slice on the dashboard is the trigger to add a +// new enum value alongside the call site that would emit it. + +import type { ErrorClass } from '../../shared/telemetry-events' + +export type ClassifiedError = { + error_class: ErrorClass +} + +export function classifyError(err: unknown): ClassifiedError { + if (err === null || err === undefined) { + return { error_class: 'unknown' } + } + + const code = + typeof err === 'object' && err !== null && 'code' in err + ? (err as { code?: unknown }).code + : undefined + const syscall = + typeof err === 'object' && err !== null && 'syscall' in err + ? (err as { syscall?: unknown }).syscall + : undefined + const message = + typeof err === 'object' && err !== null && 'message' in err + ? (err as { message?: unknown }).message + : undefined + + if ( + code === 'ENOENT' && + (typeof syscall !== 'string' || syscall.toLowerCase().startsWith('spawn')) + ) { + return { error_class: 'binary_not_found' } + } + + if (typeof message === 'string' && /\bspawn\b/i.test(message) && /\benoent\b/i.test(message)) { + return { error_class: 'binary_not_found' } + } + + return { error_class: 'unknown' } +} diff --git a/src/main/telemetry/client.test.ts b/src/main/telemetry/client.test.ts new file mode 100644 index 00000000000..2b76dd44c36 --- /dev/null +++ b/src/main/telemetry/client.test.ts @@ -0,0 +1,488 @@ +/* eslint-disable max-lines */ +// End-to-end behavior of the track() wrapper against a mock PostHog. These +// tests pin the ordering contracts: +// +// - shutdown gate fires before anything else +// - burst cap runs BEFORE consent resolve (opted-out flood does not hit +// resolveConsent) +// - per-session 1000-event ceiling enforced +// - opt-out event reaches the SDK queue BEFORE posthog.optOut() — the one +// event that transmits against the user's new preference +// - serialized capture payload is exactly CommonProps ∪ EventProps ∪ the +// allowed auto-properties ({ $process_person_profile }); an unexpected +// auto-property from a future SDK upgrade fails the drift check +// +// Tests inject a fake PostHog and a fake Store. No network, no real SDK. + +import type { PostHog } from 'posthog-node' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CommonProps } from '../../shared/telemetry-events' +import type { GlobalSettings } from '../../shared/types' +import type { Store } from '../persistence' +import { resetBurstCapsForSession } from './burst-cap' +import { + _enableTransportForTests, + _setCommonPropsForTests, + _setPostHogClientForTests, + _setShuttingDownForTests, + _setStoreForTests, + _resetFirstAppOpenedFiredForTests, + persistBannerAcknowledgeWithoutEmitting, + setOptIn, + shouldOptOutSdkAtInit, + shutdownTelemetry, + track, + trackAppOpenedOnce +} from './client' + +// Minimal mock of the PostHog client surface the wrapper actually calls. +// Deliberately narrow — if the client wrapper ever starts calling something +// else (identify, alias, groupIdentify, etc.) we want the type mismatch to +// force review against the no-identify invariant. +type MockPostHog = { + capture: ReturnType + optIn: ReturnType + optOut: ReturnType + shutdown: ReturnType + on: ReturnType + emitForTests: (event: string, payload: unknown) => void +} + +function makeMockPostHog(): MockPostHog { + const listeners = new Map void>>() + const emitForTests = (event: string, payload: unknown): void => { + for (const listener of listeners.get(event) ?? []) { + listener(payload) + } + } + + return { + capture: vi.fn((message: { event?: string; uuid?: string }) => { + queueMicrotask(() => { + emitForTests('capture', { + event: message.event, + uuid: message.uuid + }) + }) + }), + optIn: vi.fn(async () => {}), + optOut: vi.fn(async () => {}), + shutdown: vi.fn(async () => {}), + on: vi.fn((event: string, listener: (payload: unknown) => void) => { + let eventListeners = listeners.get(event) + if (!eventListeners) { + eventListeners = new Set() + listeners.set(event, eventListeners) + } + eventListeners.add(listener) + return () => { + eventListeners?.delete(listener) + } + }), + emitForTests + } +} + +function makeFakeSettings(telemetry: GlobalSettings['telemetry']): GlobalSettings { + return { telemetry } as unknown as GlobalSettings +} + +function makeFakeStore(settings: GlobalSettings): Store { + return { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + if (updates.telemetry) { + settings.telemetry = { + ...settings.telemetry, + ...updates.telemetry + } as typeof settings.telemetry + } + return settings + }) + } as unknown as Store +} + +// Env vars read by `resolveConsent` (see `consent.ts`). Any of these set at +// test time — most commonly `CI=true` on GitHub Actions — would make every +// `track()` call drop at the consent gate and every capture assertion fail. +// We clear them per-test and restore in afterEach so tests behave identically +// on a dev laptop (where none are set) and in CI (where `CI` always is). +const CONSENT_ENV_VARS = [ + 'DO_NOT_TRACK', + 'ORCA_TELEMETRY_DISABLED', + 'CI', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'CIRCLECI', + 'TRAVIS', + 'BUILDKITE', + 'JENKINS_URL', + 'TEAMCITY_VERSION' +] as const + +function stashAndClearConsentEnv(): Record { + const stash: Record = {} + for (const name of CONSENT_ENV_VARS) { + stash[name] = process.env[name] + delete process.env[name] + } + return stash +} + +function restoreConsentEnv(stash: Record): void { + for (const name of CONSENT_ENV_VARS) { + const prior = stash[name] + if (prior === undefined) { + delete process.env[name] + } else { + process.env[name] = prior + } + } +} + +const BASE_COMMON: CommonProps = { + app_version: '1.3.33', + platform: 'darwin', + arch: 'arm64', + os_release: '25.3.0', + install_id: '00000000-0000-4000-8000-000000000000', + session_id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + orca_channel: 'stable' +} + +describe('track()', () => { + let mock: MockPostHog + let store: Store + let envStash: Record + + beforeEach(() => { + envStash = stashAndClearConsentEnv() + vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + resetBurstCapsForSession() + mock = makeMockPostHog() + store = makeFakeStore( + makeFakeSettings({ + optedIn: true, + installId: BASE_COMMON.install_id, + existedBeforeTelemetryRelease: false + }) + ) + _setPostHogClientForTests(mock as unknown as PostHog) + _setCommonPropsForTests(BASE_COMMON) + _setStoreForTests(store) + _setShuttingDownForTests(false) + _enableTransportForTests(true) + _resetFirstAppOpenedFiredForTests() + }) + afterEach(() => { + _enableTransportForTests(false) + _setPostHogClientForTests(null) + _setCommonPropsForTests(null) + _setStoreForTests(null) + _resetFirstAppOpenedFiredForTests() + vi.restoreAllMocks() + restoreConsentEnv(envStash) + }) + + it('captures a valid event with merged common + event props and $process_person_profile false', () => { + track('app_opened', {}) + expect(mock.capture).toHaveBeenCalledTimes(1) + const call = mock.capture.mock.calls[0]![0] + expect(call.event).toBe('app_opened') + expect(call.distinctId).toBe(BASE_COMMON.install_id) + expect(call.properties.$process_person_profile).toBe(false) + for (const key of Object.keys(BASE_COMMON) as (keyof CommonProps)[]) { + expect(call.properties[key]).toBe(BASE_COMMON[key]) + } + }) + + // Drift-check: the full set of keys on the capture payload is bounded by + // CommonProps ∪ EventProps ∪ {$process_person_profile}. A future + // posthog-node upgrade that adds a new auto-property (e.g. $session_id) + // via our `properties` would widen this — we'd want to review before + // shipping, so this test fails loudly. + it('serialized property set is exactly CommonProps ∪ EventProps ∪ {$process_person_profile}', () => { + track('workspace_created', { source: 'command_palette', from_existing_branch: true }) + const call = mock.capture.mock.calls[0]![0] + const allowed = new Set([ + ...Object.keys(BASE_COMMON), + 'source', + 'from_existing_branch', + '$process_person_profile' + ]) + for (const key of Object.keys(call.properties)) { + expect(allowed.has(key)).toBe(true) + } + }) + + it('respects the shutdown gate', () => { + _setShuttingDownForTests(true) + track('app_opened', {}) + expect(mock.capture).not.toHaveBeenCalled() + }) + + // Core security-ordering invariant: a compromised renderer of an + // opted-out user should not be able to burn consent-resolve CPU. The + // observable signal is `store.getSettings()` — `resolveConsent` calls it + // exactly once per evaluation, so the call count on the spy is a proxy + // for "how many events reached the consent gate." + it('burst cap runs BEFORE consent resolve', () => { + // Flip the fake store to opted-out. Any track that reaches the consent + // gate also reads settings via `store.getSettings()` — so the + // getSettings call count is our observable. + ;(store.getSettings as ReturnType).mockReturnValue( + makeFakeSettings({ + optedIn: false, + installId: BASE_COMMON.install_id, + existedBeforeTelemetryRelease: false + }) + ) + // Exhaust the per-event bucket (30 default). + for (let i = 0; i < 30; i++) { + track('app_opened', {}) + } + const callsAtBoundary = (store.getSettings as ReturnType).mock.calls.length + // Further calls must short-circuit in the burst cap — consent is never + // reached for the post-cap flood, so getSettings is not called again. + for (let i = 0; i < 20; i++) { + track('app_opened', {}) + } + expect((store.getSettings as ReturnType).mock.calls.length).toBe(callsAtBoundary) + expect(mock.capture).not.toHaveBeenCalled() + }) + + it('enforces per-event burst cap (30 per minute default)', () => { + for (let i = 0; i < 50; i++) { + track('app_opened', {}) + } + expect(mock.capture).toHaveBeenCalledTimes(30) + }) + + it('enforces the per-session 1000-event global ceiling across event names', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-03T00:00:00Z')) + // Advance time between calls so the per-event token buckets refill — + // the only remaining cap is the session ceiling. + for (let i = 0; i < 1500; i++) { + vi.advanceTimersByTime(10_000) + track('app_opened', {}) + } + expect(mock.capture).toHaveBeenCalledTimes(1000) + vi.useRealTimers() + }) + + it('drops invalid events before calling capture', () => { + // Raw error strings on agent_error are rejected by `.strict()`. + track('agent_error', { + error_class: 'unknown', + agent_kind: 'claude-code', + error_message: 'leaked message' // rejected by .strict() + } as never) + expect(mock.capture).not.toHaveBeenCalled() + }) + + it('trackAppOpenedOnce emits app_opened at most once per session', () => { + trackAppOpenedOnce() + trackAppOpenedOnce() + expect(mock.capture).toHaveBeenCalledTimes(1) + expect(mock.capture.mock.calls[0]![0].event).toBe('app_opened') + }) +}) + +describe('setOptIn()', () => { + let mock: MockPostHog + let store: Store + let settings: GlobalSettings + let envStash: Record + + beforeEach(() => { + envStash = stashAndClearConsentEnv() + vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + resetBurstCapsForSession() + mock = makeMockPostHog() + settings = makeFakeSettings({ + optedIn: true, + installId: BASE_COMMON.install_id, + existedBeforeTelemetryRelease: false + }) + store = makeFakeStore(settings) + _setPostHogClientForTests(mock as unknown as PostHog) + _setCommonPropsForTests(BASE_COMMON) + _setStoreForTests(store) + _setShuttingDownForTests(false) + _enableTransportForTests(true) + _resetFirstAppOpenedFiredForTests() + }) + afterEach(() => { + _enableTransportForTests(false) + _setPostHogClientForTests(null) + _setCommonPropsForTests(null) + _setStoreForTests(null) + _resetFirstAppOpenedFiredForTests() + vi.restoreAllMocks() + restoreConsentEnv(envStash) + }) + + // Ordering invariant: the opt-out event is the one signal that transmits + // against the user's new preference. It MUST reach the SDK queue before + // we disable the SDK — otherwise posthog-node drops it at enqueue time. + it('waits for telemetry_opted_out to enqueue before posthog.optOut()', async () => { + const order: string[] = [] + mock.capture.mockImplementation((message: { event?: string; uuid?: string }) => { + order.push('capture called') + queueMicrotask(() => { + order.push('sdk enqueue') + mock.emitForTests('capture', { + event: message.event, + uuid: message.uuid + }) + }) + }) + mock.optOut.mockImplementation(async () => { + order.push('optOut') + }) + await setOptIn('settings', false) + expect(order).toEqual(['capture called', 'sdk enqueue', 'optOut']) + }) + + it('fires telemetry_opted_in after posthog.optIn without app_opened for settings opt-in', async () => { + // Flip settings to currently-opted-out so the flip to true exercises + // the opt-in branch cleanly. This is not the pending-banner path, so + // it must not replay the once-per-session app_opened event. + settings.telemetry!.optedIn = false + const order: string[] = [] + mock.optIn.mockImplementation(async () => order.push('optIn')) + mock.capture.mockImplementation((message: { event?: string }) => { + order.push(`capture:${message.event}`) + }) + await setOptIn('settings', true) + expect(order).toEqual(['optIn', 'capture:telemetry_opted_in']) + }) + + it('drops telemetry_opted_in silently in non-official builds', async () => { + settings.telemetry!.optedIn = false + _setPostHogClientForTests(null) + _enableTransportForTests(false) + + await setOptIn('settings', true) + + expect(mock.capture).not.toHaveBeenCalled() + expect(console.debug).not.toHaveBeenCalled() + }) + + it('fires app_opened once after pending-banner opt-in enables the SDK', async () => { + settings.telemetry = { + optedIn: null, + installId: BASE_COMMON.install_id, + existedBeforeTelemetryRelease: true + } + const order: string[] = [] + mock.optIn.mockImplementation(async () => order.push('optIn')) + mock.capture.mockImplementation((message: { event?: string }) => { + order.push(`capture:${message.event}`) + }) + + await setOptIn('settings', true) + + expect(order).toEqual(['optIn', 'capture:app_opened', 'capture:telemetry_opted_in']) + }) +}) + +describe('persistBannerAcknowledgeWithoutEmitting()', () => { + let mock: MockPostHog + let store: Store + let settings: GlobalSettings + let envStash: Record + + beforeEach(() => { + envStash = stashAndClearConsentEnv() + vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + resetBurstCapsForSession() + mock = makeMockPostHog() + settings = makeFakeSettings({ + optedIn: null, + installId: BASE_COMMON.install_id, + existedBeforeTelemetryRelease: true + }) + store = makeFakeStore(settings) + _setPostHogClientForTests(mock as unknown as PostHog) + _setCommonPropsForTests(BASE_COMMON) + _setStoreForTests(store) + _setShuttingDownForTests(false) + _enableTransportForTests(true) + _resetFirstAppOpenedFiredForTests() + }) + + afterEach(() => { + _enableTransportForTests(false) + _setPostHogClientForTests(null) + _setCommonPropsForTests(null) + _setStoreForTests(null) + _resetFirstAppOpenedFiredForTests() + vi.restoreAllMocks() + restoreConsentEnv(envStash) + }) + + it('fires app_opened after re-enabling the SDK and does not emit telemetry_opted_in', async () => { + const order: string[] = [] + mock.optIn.mockImplementation(async () => order.push('optIn')) + mock.capture.mockImplementation((message: { event?: string }) => { + order.push(`capture:${message.event}`) + }) + + await persistBannerAcknowledgeWithoutEmitting() + + expect(order).toEqual(['optIn', 'capture:app_opened']) + expect(settings.telemetry?.optedIn).toBe(true) + }) +}) + +// Pin the init-time SDK opt-out decision. The bug this test prevents: if +// `initTelemetry` flipped the SDK's `optedOut` flag for `pending_banner` +// (existing users who have not yet resolved the notice), the direct +// `posthog.capture(telemetry_opted_out)` on the Turn-off path would be +// dropped by posthog-core's enqueue — silently losing the one signal that +// tells us the existing-user opt-out flow works. +describe('shouldOptOutSdkAtInit()', () => { + it('opts out the SDK for every disabled-reason', () => { + for (const reason of ['user_opt_out', 'ci', 'do_not_track', 'orca_disabled'] as const) { + expect(shouldOptOutSdkAtInit({ effective: 'disabled', reason })).toBe(true) + } + }) + + it('does NOT opt out the SDK for pending_banner', () => { + expect(shouldOptOutSdkAtInit({ effective: 'pending_banner' })).toBe(false) + }) + + it('does NOT opt out the SDK for enabled', () => { + expect(shouldOptOutSdkAtInit({ effective: 'enabled' })).toBe(false) + }) +}) + +describe('shutdownTelemetry()', () => { + // Reset module-level state that persists across tests: shutdownTelemetry + // leaves shuttingDown=true, which would silently drop events in any + // later-added test. Also null out the client so a stale mock from one + // test cannot leak into the next. + afterEach(() => { + _setShuttingDownForTests(false) + _setPostHogClientForTests(null) + }) + + it('sets the shutdown gate and calls posthog.shutdown(2000)', async () => { + const mock = makeMockPostHog() + _setPostHogClientForTests(mock as unknown as PostHog) + _setShuttingDownForTests(false) + await shutdownTelemetry() + expect(mock.shutdown).toHaveBeenCalledWith(2_000) + _setPostHogClientForTests(null) + }) + + it('is a no-op when no client is initialized', async () => { + _setPostHogClientForTests(null) + await expect(shutdownTelemetry()).resolves.toBeUndefined() + }) +}) diff --git a/src/main/telemetry/client.ts b/src/main/telemetry/client.ts new file mode 100644 index 00000000000..03b8effc7f9 --- /dev/null +++ b/src/main/telemetry/client.ts @@ -0,0 +1,492 @@ +// Main-process telemetry transport. One `posthog-node` client per process, +// one source of truth for common props, one `track()` entry that every event +// (main-originated AND IPC-arrived) funnels through. The validator in +// `validator.ts` is the single gate that protects the wire from malformed +// or over-sized payloads; the burst cap in `burst-cap.ts` protects against +// runaway useEffects and a compromised renderer. +// +// Ordering inside `track()` — MUST be preserved: +// 1. shutdown gate — will-quit already set `shuttingDown = true`; +// late IPC arrivals drop, never crash. +// 2. burst cap — O(1). Runs BEFORE consent resolve so an +// opted-out user whose renderer is compromised +// cannot burn handler CPU by forcing a +// settings read + consent evaluation on every +// attempt. +// 3. consent resolve — reads the live settings, never a cached +// boolean. Env-var / CI / opt-out all funnel +// through here. +// 4. validator — schema-level safeParse. Fail-closed. +// 5. posthog.capture — the only place this module calls into the +// vendor SDK. +// +// `$process_person_profile: false` is attached on every capture because +// posthog-node has no init-time equivalent of posthog-js's +// `person_profiles: 'identified_only'` — without the per-capture flag, the +// server SDK would materialize a PostHog person per install_id, which we +// explicitly do not want for anonymous-only events. + +import { randomUUID } from 'node:crypto' +import { arch as osArch, platform as osPlatform, release as osRelease } from 'node:os' +import { app } from 'electron' +import { PostHog } from 'posthog-node' +import type { CommonProps, EventName, EventProps, OptInVia } from '../../shared/telemetry-events' +import type { Store } from '../persistence' +import { consumeBurstToken, resetBurstCapsForSession } from './burst-cap' +import { resolveConsent, type ConsentState } from './consent' +import { commonPropsSchema, validate } from './validator' + +// Compile-time feature flag. PR 2 shipped with this `false` so the SDK was +// wired but no event transmitted. PR 3 flips it to `true`. Independent of +// the build-identity gate below: both must be satisfied to transmit, so +// flipping the flag alone still leaves contributor builds silent. +// +// NOTE: config/scripts/verify-telemetry-constants.mjs greps this declaration +// shape (`const TELEMETRY_ENABLED = true|false`) to gate release verification. +// If you refactor this (e.g. let, export, computed-from-env, moved into a +// config object), update the regex in that script too. +const TELEMETRY_ENABLED = true + +// Eligible-to-transmit only if the CI release pipeline injected BOTH the +// build-identity constant and a write key. One without the other is treated +// as a pipeline misconfiguration and fails closed. Contributor / `pnpm dev` +// / third-party rebuilds get literal `null` from electron-vite's `define`, +// so `IS_OFFICIAL_BUILD` evaluates `false` at module load. There is no +// runtime env-var fallback. +// +// The `globalThis` dance exists for the vitest harness. `declare const` +// lets TypeScript type-check against the substituted symbols, but vitest +// does not run electron-vite's `define` pass, so the identifiers are +// undefined at test-runtime. Routing the read through `globalThis` gives +// us the compile-time substitution in production and a safe `undefined` +// in tests — both of which resolve to `IS_OFFICIAL_BUILD === false`, which +// is the fail-closed default we want anywhere outside an official CI build. +const BUILD_IDENTITY: 'stable' | 'rc' | null = + typeof ORCA_BUILD_IDENTITY !== 'undefined' + ? ORCA_BUILD_IDENTITY + : ((globalThis as { ORCA_BUILD_IDENTITY?: 'stable' | 'rc' | null }).ORCA_BUILD_IDENTITY ?? null) +const WRITE_KEY: string | null = + typeof ORCA_POSTHOG_WRITE_KEY !== 'undefined' + ? ORCA_POSTHOG_WRITE_KEY + : ((globalThis as { ORCA_POSTHOG_WRITE_KEY?: string | null }).ORCA_POSTHOG_WRITE_KEY ?? null) +const IS_OFFICIAL_BUILD: boolean = + (BUILD_IDENTITY === 'stable' || BUILD_IDENTITY === 'rc') && + typeof WRITE_KEY === 'string' && + WRITE_KEY.length > 0 + +// Module-level singletons. There is exactly one Store / one main process / +// one telemetry session at a time; threading `store` through every export +// is verbose without buying anything. +let posthog: PostHog | null = null +let sessionId: string | null = null +let commonProps: CommonProps | null = null +let shuttingDown = false +let storeRef: Store | null = null + +const OPT_OUT_CAPTURE_ENQUEUE_TIMEOUT_MS = 1_000 + +// Test-only override for the transport gate. Set by `_enableTransportForTests` +// so the client.test.ts suite can exercise the full pipeline (burst cap, +// consent, validator, capture) without waiting on a real CI build. Left +// `false` in production; an accidental call from non-test code would still +// be bounded by `resolveConsent` + the validator. +let testTransportEnabled = false + +// First-launch `app_opened` session gate. The existing-user banner contract is: +// no events transmit until the notice resolves. Keep "mark" and "emit" +// atomic so no path can accidentally suppress the event without firing it. +let appOpenedTrackedThisSession = false + +function buildCommonProps(installId: string, sid: string, channel: 'stable' | 'rc'): CommonProps { + // `.max(64)` on every free-form string field in `commonPropsSchema` is the + // upper bound; node's platform / arch / release strings are always well + // under that in practice. We do not truncate here because the validator's + // schema cap is the authoritative check — truncating pre-validator would + // silently mask an unexpected-long-string case we want to see as a drop. + return { + app_version: app.getVersion(), + platform: osPlatform(), + arch: osArch(), + os_release: osRelease(), + install_id: installId, + session_id: sid, + orca_channel: channel + } +} + +export function initTelemetry(store: Store): void { + // Set `storeRef` unconditionally so `setOptIn` can persist consent + // changes even in console-mirror builds — opt-out must still write to + // disk on a contributor laptop, not just on official builds. + storeRef = store + resetBurstCapsForSession() + shuttingDown = false + // Gate reset per session: the "no app_opened until banner resolution" + // invariant is per-launch, not across the lifetime of the install. + appOpenedTrackedThisSession = false + + if (!TELEMETRY_ENABLED || !IS_OFFICIAL_BUILD) { + return + } + + const settings = store.getSettings() + const installId = settings.telemetry?.installId + if (!installId) { + // Migration guarantees this is set; if it isn't, we're in an invariant- + // violation state and must not transmit with a missing distinct_id. + console.warn('[telemetry] installId missing after migration; skipping transport init') + return + } + + sessionId = randomUUID() + commonProps = buildCommonProps( + installId, + sessionId, + // Non-null at this point: `IS_OFFICIAL_BUILD` gated this branch and + // narrows the identity constant to the `'stable' | 'rc'` arm. + BUILD_IDENTITY as 'stable' | 'rc' + ) + + // Fail-closed on bad common props — the validator is the single enforcement + // point for wire shape, including common props. A bad `install_id` (e.g. + // empty string from a migration bug) would collapse all events into one + // distinct_id, so we must refuse to initialize transport rather than ship + // malformed identity on every capture. + // + // Validated once here at init — NOT on every `track()` call — because + // `commonProps` is a module-level singleton built exactly once from inputs + // that do not change across the session (app version, OS, install_id, + // session_id, channel). Re-validating per event would be wasted work on + // a value that cannot drift. If a future refactor makes `commonProps` + // mutable mid-session, move this check accordingly. + const parsedCommon = commonPropsSchema.safeParse(commonProps) + if (!parsedCommon.success) { + console.warn('[telemetry] common props failed schema validation; skipping transport init') + commonProps = null + return + } + + posthog = new PostHog(WRITE_KEY as string, { + host: 'https://us.i.posthog.com', + flushAt: 20, + flushInterval: 10_000, + // Strip every auto-attached property we do not want on our wire: no + // GeoIP, no client IP enrichment. Our wire is exactly + // `CommonProps ∪ EventProps ∪ a small allow-list of SDK auto-props`. + disableGeoip: true, + // Default is 1000; past that, the SDK drops oldest-first. Bumped to + // 5000 to tolerate long-offline sessions (flights, VPN-down, tunnels). + // The per-session 1,000-event ceiling in `track()` caps normal + // operation well below this; the 5000 slots are the absolute ceiling + // across any conceivable offline duration. + maxQueueSize: 5000 + }) + + if (shouldOptOutSdkAtInit(resolveConsent(settings))) { + posthog.optOut() + } +} + +/** + * Decide whether to flip the PostHog SDK's in-memory `optedOut` flag at boot. + * + * Applied to DISABLED cohorts only (`user_opt_out` / CI / DO_NOT_TRACK / + * ORCA_TELEMETRY_DISABLED). The SDK flag does not persist across process + * restarts, so we re-apply on every boot as defense-in-depth: any direct + * `posthog.capture()` that bypasses `track()` (and therefore bypasses the + * consent gate in this module) must still drop at the SDK boundary for a + * user who has opted out. + * + * Intentionally NOT applied to `pending_banner`: the existing-user Turn-off + * path in `setOptIn(_, false)` does a direct `posthog.capture()` for the + * `telemetry_opted_out { via: 'first_launch_banner' }` signal, bypassing + * `track()` (see the long comment in the opt-out branch below explaining + * why). If the SDK were already opted-out at that point, the capture would + * silently drop inside posthog-core's `enqueue()` — losing the one signal + * that tells us the opt-out flow works. `track()`'s own consent gate + * (`resolveConsent() !== 'enabled'`) still drops every other event while + * the cohort is `pending_banner`, so there is no risk of stray transmission + * during the pre-banner window. + * + * Exported for tests; production has exactly one call site above. + */ +export function shouldOptOutSdkAtInit(consent: ConsentState): boolean { + return consent.effective === 'disabled' +} + +function waitForCaptureEnqueue(client: PostHog, event: EventName, uuid: string): Promise { + return new Promise((resolve) => { + let settled = false + let stopListening: (() => void) | null = null + let timeout: ReturnType | null = null + + const settle = (enqueued: boolean): void => { + if (settled) { + return + } + settled = true + if (timeout) { + clearTimeout(timeout) + } + stopListening?.() + resolve(enqueued) + } + + // Why: posthog-node's capture() prepares/enqueues asynchronously; this + // public SDK event is the durable boundary we need before calling optOut(). + stopListening = client.on('capture', (payload: unknown) => { + if (!payload || typeof payload !== 'object') { + return + } + const message = payload as { event?: unknown; uuid?: unknown } + if (message.event === event && message.uuid === uuid) { + settle(true) + } + }) + + timeout = setTimeout(() => settle(false), OPT_OUT_CAPTURE_ENQUEUE_TIMEOUT_MS) + }) +} + +// In `pnpm dev` and any contributor / non-official build, `track()` is a +// no-op: it returns immediately without transmitting, logging, or running +// the burst-cap / consent / validator pipeline. Telemetry only flows in +// official stable/rc builds where CI injects `ORCA_BUILD_IDENTITY` and +// `ORCA_POSTHOG_WRITE_KEY`. +export function track(name: N, props: EventProps): void { + if (!testTransportEnabled && (!IS_OFFICIAL_BUILD || !TELEMETRY_ENABLED)) { + return + } + + // (1) Shutdown gate. Late IPC arrivals should not attempt to enqueue + // against a client that is actively flushing. + if (shuttingDown) { + return + } + if (!posthog || !commonProps || !storeRef) { + return + } + + // (2) Burst cap BEFORE consent. A compromised renderer of an opted-out + // user should not be able to burn CPU by forcing a settings read and a + // `resolveConsent` evaluation on every attempt — the cap is O(1), the + // consent resolve reads the live settings object. This ordering is the + // difference between "opt-out is a free drop" and "opt-out is a cheap + // drop at the cost of a settings read per event." + if (!consumeBurstToken(name)) { + return + } + + // (3) Consent resolve — reads live settings every call; never a cached + // module-level boolean that could drift from the persisted state or the + // env-var precedence. + const consent = resolveConsent(storeRef.getSettings()) + if (consent.effective !== 'enabled') { + return + } + + // (4) Validator — single enforcement point for schema, enum, strict key + // set, and per-string length caps. + const result = validate(name, props) + if (!result.ok) { + return + } + + // (5) Capture. `$process_person_profile: false` is the server-SDK + // equivalent of posthog-js's `person_profiles: 'identified_only'` — + // attached per-event because posthog-node has no init-time option. + // Without this, posthog-node materializes a PostHog person per + // `install_id`, which we explicitly do not want for anonymous-only + // events. + posthog.capture({ + distinctId: commonProps.install_id, + event: name, + properties: { + ...commonProps, + ...result.props, + $process_person_profile: false + } + }) +} + +export async function setOptIn(via: OptInVia, optedIn: boolean): Promise { + if (!storeRef) { + return + } + const settings = storeRef.getSettings() + const telemetryBeforeUpdate = settings.telemetry + const wasPendingBanner = + telemetryBeforeUpdate?.existedBeforeTelemetryRelease === true && + telemetryBeforeUpdate.optedIn === null + // `updateSettings` is a partial-merge (see persistence.ts:552). The Store's + // `telemetry` field is deep-merged there specifically so an `optedIn` flip + // from the Privacy pane / consent flow does not clobber `installId` or + // `existedBeforeTelemetryRelease`. + storeRef.updateSettings({ + telemetry: { + ...(settings.telemetry ?? { installId: '', existedBeforeTelemetryRelease: true }), + optedIn + } + }) + + const client = posthog + if (optedIn) { + if (client) { + await client.optIn() + } + if (wasPendingBanner) { + trackAppOpenedOnce() + } + track('telemetry_opted_in', { via }) + } else { + if (!client) { + return + } + // Fire opt-out event BEFORE disabling the SDK. This is the one event + // that transmits against the user's new preference — the user chose to + // tell us they are opting out, and that single signal is what tells us + // the opt-out flow is working. + // + // Capture directly (not via `track()`) because `updateSettings` above + // just flipped `optedIn` to `false`; `track()` would re-read settings, + // call `resolveConsent`, and drop on `user_opt_out` — at which point the + // one signal that tells us the opt-out flow works would be silent. + // Burst cap + validator still run; consent is the only gate bypassed, + // and it is bypassed exactly once per user per session at most (IPC + // consent-mutation cap is 5/session). + // + // posthog-node prepares capture() asynchronously, so "call capture + // before optOut" is not enough; wait until the SDK confirms enqueue. + // We do not wait for network flush here — the SDK queue and shutdown + // flush own delivery, while the enqueue boundary owns the optOut race. + try { + if (!shuttingDown && commonProps && consumeBurstToken('telemetry_opted_out')) { + const validated = validate('telemetry_opted_out', { via }) + if (validated.ok) { + const uuid = randomUUID() + const enqueued = waitForCaptureEnqueue(client, 'telemetry_opted_out', uuid) + client.capture({ + distinctId: commonProps.install_id, + event: 'telemetry_opted_out', + uuid, + properties: { + ...commonProps, + ...validated.props, + $process_person_profile: false + } + }) + if (!(await enqueued)) { + console.warn('[telemetry] telemetry_opted_out did not enqueue before SDK opt-out') + } + } + } + } catch (err) { + console.warn('[telemetry] telemetry_opted_out capture failed before SDK opt-out:', err) + } finally { + await client.optOut() + } + } +} + +// Banner ✕ path. Writes `optedIn = true` permanently without emitting a +// telemetry opt-in event. `app_opened` still fires because resolving the +// banner is the first point where this session is eligible to transmit. +// That outcome cannot route through `setOptIn()` — `setOptIn()` always +// fires a `telemetry_opted_in/out` event and the IPC handler always +// derives a non-`null` `via` value, which would tag a ✕ click as +// `first_launch_banner` + `telemetry_opted_in`. The ✕-as-silent- +// acknowledge contract is explicit: the user did not explicitly opt in, +// they declined to intervene, so no opt-in event transmits. +// +// So this primitive exists as a named, non-overloaded code path: persist +// the opt-in, unlock the SDK, and fire the once-per-session app-opened event. +// The corresponding `telemetry:acknowledgeBanner` IPC channel +// routes renderer ✕ clicks here instead of through `telemetry:setOptIn`. +// +// Do NOT extend this with a `via` parameter or emission flag. If a future +// surface also needs a silent persisted opt-in, give it its own named +// function rather than overloading this one — the grep'ability of +// `persistBannerAcknowledgeWithoutEmitting` is the whole point. +export async function persistBannerAcknowledgeWithoutEmitting(): Promise { + if (!storeRef) { + return + } + const settings = storeRef.getSettings() + // Defensive merge mirrors `setOptIn`: updateSettings deep-merges the + // telemetry block (persistence.ts:560), so the fallback object here only + // matters if the migration invariant has been violated and `telemetry` + // is somehow absent — in which case we still want to persist an opt-in + // rather than no-op. + storeRef.updateSettings({ + telemetry: { + ...(settings.telemetry ?? { installId: '', existedBeforeTelemetryRelease: true }), + optedIn: true + } + }) + if (posthog) { + await posthog.optIn() + } + // Why: resolving the banner is the first eligible moment for app_opened. + // Re-enable the SDK first so capture sees the new consent state. + trackAppOpenedOnce() +} + +export function trackAppOpenedOnce(): void { + if (appOpenedTrackedThisSession) { + return + } + appOpenedTrackedThisSession = true + track('app_opened', {}) +} + +export async function shutdownTelemetry(): Promise { + // Setting the shutdown gate is synchronous and cheap — it matters that + // late IPC-arrived tracks hit it before the bounded flush starts. + shuttingDown = true + const instance = posthog + if (!instance) { + return + } + try { + // PostHog's bounded flush caps at 2s. Observed quit delay goes up by at + // most that on top of the current daemon-teardown budget. + await instance.shutdown(2_000) + } catch (err) { + // Telemetry must never crash the app on quit. Swallow. + console.warn('[telemetry] shutdown error (ignored):', err) + } +} + +// ── Test-only introspection ───────────────────────────────────────────── +// +// The test suite needs to inject a fake PostHog and observe capture calls +// without touching the network. Kept under a `_`-prefixed name so it is +// obvious in code review that this is not a runtime API. + +export function _setPostHogClientForTests(client: PostHog | null): void { + posthog = client +} + +export function _setCommonPropsForTests(props: CommonProps | null): void { + commonProps = props +} + +export function _setStoreForTests(store: Store | null): void { + storeRef = store +} + +export function _setShuttingDownForTests(value: boolean): void { + shuttingDown = value +} + +export function _getSessionIdForTests(): string | null { + return sessionId +} + +export function _enableTransportForTests(enabled: boolean): void { + testTransportEnabled = enabled +} + +export function _resetFirstAppOpenedFiredForTests(): void { + appOpenedTrackedThisSession = false +} diff --git a/src/main/telemetry/consent.test.ts b/src/main/telemetry/consent.test.ts new file mode 100644 index 00000000000..12ee8ad0058 --- /dev/null +++ b/src/main/telemetry/consent.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/types' +import { resolveConsent, _resetMisconfigWarnCacheForTests } from './consent' + +// A minimal GlobalSettings stub — the resolver only reads `settings.telemetry`, +// so we cast through `unknown` rather than enumerating every unrelated field. +function settingsWithTelemetry(telemetry: GlobalSettings['telemetry']): GlobalSettings { + return { telemetry } as unknown as GlobalSettings +} + +// Keys cleared after each test so one case's env setup cannot leak into the +// next. `process.env` writes persist across vi.mock boundaries, so we track +// and restore explicitly. +const ENV_KEYS_UNDER_TEST = [ + 'DO_NOT_TRACK', + 'ORCA_TELEMETRY_DISABLED', + 'CI', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'CIRCLECI', + 'TRAVIS', + 'BUILDKITE', + 'JENKINS_URL', + 'TEAMCITY_VERSION' +] + +describe('resolveConsent', () => { + let savedEnv: Record + + beforeEach(() => { + savedEnv = {} + for (const k of ENV_KEYS_UNDER_TEST) { + savedEnv[k] = process.env[k] + delete process.env[k] + } + _resetMisconfigWarnCacheForTests() + }) + + afterEach(() => { + for (const k of ENV_KEYS_UNDER_TEST) { + if (savedEnv[k] === undefined) { + delete process.env[k] + } else { + process.env[k] = savedEnv[k] + } + } + }) + + // ── Env-var overrides (non-persistent, highest precedence) ────────── + + it('returns do_not_track when DO_NOT_TRACK=1', () => { + process.env.DO_NOT_TRACK = '1' + expect( + resolveConsent( + settingsWithTelemetry({ + optedIn: true, + installId: 'x', + existedBeforeTelemetryRelease: false + }) + ) + ).toEqual({ + effective: 'disabled', + reason: 'do_not_track' + }) + }) + + it('returns do_not_track when DO_NOT_TRACK=true (case/whitespace insensitive)', () => { + process.env.DO_NOT_TRACK = ' TRUE ' + expect( + resolveConsent( + settingsWithTelemetry({ + optedIn: true, + installId: 'x', + existedBeforeTelemetryRelease: false + }) + ) + ).toEqual({ + effective: 'disabled', + reason: 'do_not_track' + }) + }) + + it('returns orca_disabled when ORCA_TELEMETRY_DISABLED=1', () => { + process.env.ORCA_TELEMETRY_DISABLED = '1' + expect( + resolveConsent( + settingsWithTelemetry({ + optedIn: true, + installId: 'x', + existedBeforeTelemetryRelease: false + }) + ) + ).toEqual({ + effective: 'disabled', + reason: 'orca_disabled' + }) + }) + + it('prefers do_not_track over orca_disabled when both are set', () => { + process.env.DO_NOT_TRACK = '1' + process.env.ORCA_TELEMETRY_DISABLED = '1' + expect( + resolveConsent( + settingsWithTelemetry({ + optedIn: true, + installId: 'x', + existedBeforeTelemetryRelease: false + }) + ) + ).toEqual({ + effective: 'disabled', + reason: 'do_not_track' + }) + }) + + it.each([ + ['CI', 'true'], + ['GITHUB_ACTIONS', 'true'], + ['GITLAB_CI', 'true'], + ['CIRCLECI', 'true'], + ['TRAVIS', 'true'], + ['BUILDKITE', 'true'], + ['JENKINS_URL', 'http://ci.example.com/'], + ['TEAMCITY_VERSION', '2023.11'] + ])('returns ci when %s is set', (name, value) => { + process.env[name] = value + expect( + resolveConsent( + settingsWithTelemetry({ + optedIn: true, + installId: 'x', + existedBeforeTelemetryRelease: false + }) + ) + ).toEqual({ + effective: 'disabled', + reason: 'ci' + }) + }) + + it('does not treat CI="" (empty string) as a CI environment', () => { + process.env.CI = '' + expect( + resolveConsent( + settingsWithTelemetry({ + optedIn: true, + installId: 'x', + existedBeforeTelemetryRelease: false + }) + ) + ).toEqual({ effective: 'enabled' }) + }) + + it('warns to stderr once for misconfigured env var values like "yes" / "on" / "FALSE"', () => { + const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + process.env.DO_NOT_TRACK = 'yes' + // Two resolves, only one warning written. + resolveConsent( + settingsWithTelemetry({ + optedIn: true, + installId: 'x', + existedBeforeTelemetryRelease: false + }) + ) + resolveConsent( + settingsWithTelemetry({ + optedIn: true, + installId: 'x', + existedBeforeTelemetryRelease: false + }) + ) + expect(spy).toHaveBeenCalledTimes(1) + expect(spy.mock.calls[0][0]).toContain('DO_NOT_TRACK') + } finally { + spy.mockRestore() + } + }) + + it('treats a misconfigured DO_NOT_TRACK=0 as unset (not truthy)', () => { + // This is the skills.sh regression called out in the plan doc: `=0` must + // not count as truthy. Without the parse guard, a stringly-truthy check + // would disable telemetry for anyone who types `DO_NOT_TRACK=0` expecting + // it to mean "tracking allowed." + process.env.DO_NOT_TRACK = '0' + expect( + resolveConsent( + settingsWithTelemetry({ + optedIn: true, + installId: 'x', + existedBeforeTelemetryRelease: false + }) + ) + ).toEqual({ effective: 'enabled' }) + }) + + // ── Persisted preference ──────────────────────────────────────────── + + it('returns enabled when optedIn is true (new-user cohort after migration)', () => { + expect( + resolveConsent( + settingsWithTelemetry({ + optedIn: true, + installId: 'x', + existedBeforeTelemetryRelease: false + }) + ) + ).toEqual({ effective: 'enabled' }) + }) + + it('returns user_opt_out when optedIn is false', () => { + expect( + resolveConsent( + settingsWithTelemetry({ + optedIn: false, + installId: 'x', + existedBeforeTelemetryRelease: true + }) + ) + ).toEqual({ effective: 'disabled', reason: 'user_opt_out' }) + }) + + it('returns pending_banner when optedIn is null (existing-user cohort awaiting banner)', () => { + expect( + resolveConsent( + settingsWithTelemetry({ + optedIn: null, + installId: 'x', + existedBeforeTelemetryRelease: true + }) + ) + ).toEqual({ effective: 'pending_banner' }) + }) + + it('returns pending_banner when telemetry settings are absent (pre-migration defensive path)', () => { + expect(resolveConsent(settingsWithTelemetry(undefined))).toEqual({ + effective: 'pending_banner' + }) + }) + + // ── Env-var precedence beats persisted preference ─────────────────── + + it('env-var override wins over a stored optedIn=true', () => { + process.env.DO_NOT_TRACK = '1' + expect( + resolveConsent( + settingsWithTelemetry({ + optedIn: true, + installId: 'x', + existedBeforeTelemetryRelease: false + }) + ) + ).toEqual({ effective: 'disabled', reason: 'do_not_track' }) + }) + + it('env-var override wins over a stored pending_banner state', () => { + process.env.ORCA_TELEMETRY_DISABLED = '1' + expect( + resolveConsent( + settingsWithTelemetry({ + optedIn: null, + installId: 'x', + existedBeforeTelemetryRelease: true + }) + ) + ).toEqual({ effective: 'disabled', reason: 'orca_disabled' }) + }) +}) diff --git a/src/main/telemetry/consent.ts b/src/main/telemetry/consent.ts new file mode 100644 index 00000000000..85ed0dfd481 --- /dev/null +++ b/src/main/telemetry/consent.ts @@ -0,0 +1,110 @@ +// Pure consent resolver. Every call site (PR 2+) goes through `resolveConsent` +// so env-var / CI detection is centralized — no scattered `process.env.CI` +// checks can drift out of sync with the documented precedence list. +// +// Env-var and CI paths are non-persistent: they set effective consent at +// runtime only and never mutate `GlobalSettings.telemetry.optedIn`. Unsetting +// the variable on the next launch restores the user's stored preference. + +import type { GlobalSettings } from '../../shared/types' +import type { TelemetryConsentState } from '../../shared/telemetry-consent-types' + +// Discriminated union instead of a boolean: the Privacy pane (PR 3) needs the +// `reason` to render the correct "disabled because X" helper text, and the +// first-launch banner must distinguish "existing user awaiting decision" +// (`pending_banner`) from "user explicitly opted out" (`disabled`). A boolean +// would force the UI to re-derive that, re-introducing the scattered env +// checks this module exists to eliminate. +// +// The type itself lives in `shared/telemetry-consent-types.ts` so the +// renderer can import it across the IPC boundary; this re-export keeps +// existing call sites in main working without a rename. +export type ConsentState = TelemetryConsentState + +// Precedence for the `disabled` branches is documented alongside +// `resolveConsent` below. +const CI_ENV_VARS = [ + 'CI', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'CIRCLECI', + 'TRAVIS', + 'BUILDKITE', + 'JENKINS_URL', + 'TEAMCITY_VERSION' +] as const + +// Track which env-var names have already produced a misconfiguration warning +// so a noisy shell profile that sets e.g. `DO_NOT_TRACK=yes` does not spam +// stderr on every resolve call. +const warnedMisconfigured = new Set() + +function warnOnceMisconfigured(name: string, raw: string): void { + if (warnedMisconfigured.has(name)) { + return + } + warnedMisconfigured.add(name) + // Stderr, not stdout — consent misconfiguration is an operator signal, not + // user-facing output. Mirrors the skills.sh-style bug note in the plan doc: + // a value like `0` / `yes` / `on` / `FALSE` silently no-oping is exactly + // the class of bug we want surfaced. + process.stderr.write( + `[telemetry] ${name}=${JSON.stringify(raw)} is not a recognized truthy value ` + + `(expected "1" or "true"); treating as unset.\n` + ) +} + +function isEnvVarTruthy(name: string): boolean { + const v = process.env[name] + if (!v) { + return false + } + const normalized = v.trim().toLowerCase() + if (normalized === '1' || normalized === 'true') { + return true + } + warnOnceMisconfigured(name, v) + return false +} + +// Exposed for tests only — resets the one-shot warning dedupe so separate +// tests can each exercise the misconfiguration path independently. +export function _resetMisconfigWarnCacheForTests(): void { + warnedMisconfigured.clear() +} + +export function resolveConsent(settings: GlobalSettings): ConsentState { + // Precedence 1: community standard kill switch. Always wins. + if (isEnvVarTruthy('DO_NOT_TRACK')) { + return { effective: 'disabled', reason: 'do_not_track' } + } + // Precedence 2: product-specific kill switch. + if (isEnvVarTruthy('ORCA_TELEMETRY_DISABLED')) { + return { effective: 'disabled', reason: 'orca_disabled' } + } + // Precedence 3: CI detection. Any presence (not just truthy) counts — many + // CI systems set `CI=true` but some legacy ones just set it to an empty + // string or a build ID, and none of those are human intent to opt in. + if (CI_ENV_VARS.some((v) => process.env[v] !== undefined && process.env[v] !== '')) { + return { effective: 'disabled', reason: 'ci' } + } + + const t = settings.telemetry + // Defensive: after the PR 1 migration in `Store.load()`, every settings + // object has `telemetry` populated. If we somehow read a settings object + // that predates migration, fail closed to `pending_banner` (no transmit) + // rather than defaulting on. + if (!t) { + return { effective: 'pending_banner' } + } + + if (t.optedIn === true) { + return { effective: 'enabled' } + } + if (t.optedIn === false) { + return { effective: 'disabled', reason: 'user_opt_out' } + } + + // `optedIn === null` — existing-user cohort awaiting banner resolution. + return { effective: 'pending_banner' } +} diff --git a/src/main/telemetry/install-id.test.ts b/src/main/telemetry/install-id.test.ts new file mode 100644 index 00000000000..69591a72663 --- /dev/null +++ b/src/main/telemetry/install-id.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from 'vitest' +import type { Store } from '../persistence' +import type { GlobalSettings } from '../../shared/types' +import { generateInstallId, readInstallId } from './install-id' + +// Minimal in-memory store stand-in. The real `Store` pulls in Electron + fs, +// which is overkill for testing the install-id read/write contract. We only +// need the two methods install-id.ts consumes. +function makeFakeStore(initial: Partial): Store { + let settings = { ...initial } as GlobalSettings + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } as GlobalSettings + return settings + }) + } + return store as unknown as Store +} + +describe('install-id', () => { + describe('generateInstallId', () => { + it('produces a UUID v4 string', () => { + const id = generateInstallId() + // RFC 4122 v4: 8-4-4-4-12 hex with version nibble `4` and variant nibble 8/9/a/b. + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + }) + + it('produces a different id on each call', () => { + expect(generateInstallId()).not.toBe(generateInstallId()) + }) + }) + + describe('readInstallId', () => { + it('returns the persisted install id', () => { + const store = makeFakeStore({ + telemetry: { + optedIn: true, + installId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + existedBeforeTelemetryRelease: false + } + }) + expect(readInstallId(store)).toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') + }) + + it('returns undefined when telemetry is not initialized', () => { + const store = makeFakeStore({}) + expect(readInstallId(store)).toBeUndefined() + }) + }) +}) diff --git a/src/main/telemetry/install-id.ts b/src/main/telemetry/install-id.ts new file mode 100644 index 00000000000..04c6d89a61f --- /dev/null +++ b/src/main/telemetry/install-id.ts @@ -0,0 +1,21 @@ +import { randomUUID } from 'node:crypto' +import type { Store } from '../persistence' + +// Anonymous UUID v4 that keys the user as a telemetry subject. Stability +// across launches is the single contract this module preserves: the +// migration in `persistence.ts` populates `GlobalSettings.telemetry.installId` +// once, and `readInstallId` is the sole read path so call sites cannot +// accidentally regenerate it by reaching into the store themselves. + +export function generateInstallId(): string { + return randomUUID() +} + +// Lookup-only. Returns undefined if `telemetry` is missing — this only +// happens before `Store.load()` has run the migration, which is an invariant +// violation everywhere else. Callers can treat undefined as "telemetry not +// initialized yet" rather than silently regenerating here (regenerating +// behind a caller's back would mask a startup-ordering bug). +export function readInstallId(store: Store): string | undefined { + return store.getSettings().telemetry?.installId +} diff --git a/src/main/telemetry/validator.test.ts b/src/main/telemetry/validator.test.ts new file mode 100644 index 00000000000..d6619448f34 --- /dev/null +++ b/src/main/telemetry/validator.test.ts @@ -0,0 +1,120 @@ +// Fail-closed validator behavior. These tests exist to catch the classes of +// input the validator is designed to reject at the IPC boundary: unknown +// event names, extra properties (via `.strict()`), missing required keys, +// wrong enum values, and overlength free-form strings. Every rejected case +// returns `{ ok: false, reason }` — the client.ts wrapper then drops the +// event instead of calling posthog.capture. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { _resetValidatorWarnCacheForTests, validate } from './validator' + +describe('validate', () => { + beforeEach(() => { + _resetValidatorWarnCacheForTests() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('accepts a well-formed app_opened payload', () => { + const result = validate('app_opened', {}) + expect(result.ok).toBe(true) + }) + + it('accepts a well-formed agent_started payload', () => { + const result = validate('agent_started', { + agent_kind: 'claude-code', + launch_source: 'command_palette', + request_kind: 'new' + }) + expect(result.ok).toBe(true) + }) + + it('drops unknown event names', () => { + const result = validate('not_a_real_event' as never, {}) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toMatch(/unknown event/) + } + }) + + it('rejects extra keys via .strict()', () => { + const result = validate('app_opened', { unexpected: 'value' }) + expect(result.ok).toBe(false) + }) + + // Core invariant: agent_error is enum-only. If a call site ever tries to + // attach raw error strings to the event, the validator drops it and nothing + // transmits. + it('rejects error_message on agent_error', () => { + const result = validate('agent_error', { + error_class: 'unknown', + agent_kind: 'claude-code', + error_message: 'at /Users/alice/secret/path/index.ts:42' + } as never) + expect(result.ok).toBe(false) + }) + + it('rejects error_stack on agent_error', () => { + const result = validate('agent_error', { + error_class: 'unknown', + agent_kind: 'claude-code', + error_stack: 'Error: boom\n at /Users/alice/...' + } as never) + expect(result.ok).toBe(false) + }) + + it('drops on missing required key', () => { + const result = validate('agent_started', { + agent_kind: 'claude-code', + launch_source: 'sidebar' + // missing request_kind + } as never) + expect(result.ok).toBe(false) + }) + + it('drops on wrong enum value', () => { + const result = validate('agent_started', { + agent_kind: 'claude-code', + launch_source: 'command_palette', + request_kind: 'restart' // not in ['new', 'resume', 'followup'] + } as never) + expect(result.ok).toBe(false) + }) + + it('accepts a well-formed agent_error payload', () => { + const result = validate('agent_error', { + error_class: 'binary_not_found', + agent_kind: 'claude-code' + }) + expect(result.ok).toBe(true) + }) + + it('rejects agent_error with a deferred enum value', () => { + // `auth_expired` was in an earlier draft; the trimmed enum is + // ['binary_not_found', 'unknown']. Pin that contract so re-introducing + // a deferred value silently is impossible. + const result = validate('agent_error', { + error_class: 'auth_expired', + agent_kind: 'claude-code' + } as never) + expect(result.ok).toBe(false) + }) + + // Rate-limit: at most one warn per event name per 60s. We cannot easily + // control Date.now() without mocking time, so the coarse assertion is + // that repeat-dropping the same event name does not emit a warn on every + // call. The first rejection should warn; the second within the window + // should not. + it('rate-limits warns to 1/min per event name', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + _resetValidatorWarnCacheForTests() + validate('app_opened', { bogus: true } as never) + const afterFirst = warn.mock.calls.length + validate('app_opened', { bogus2: true } as never) + const afterSecond = warn.mock.calls.length + expect(afterFirst).toBe(1) + expect(afterSecond).toBe(1) + }) +}) diff --git a/src/main/telemetry/validator.ts b/src/main/telemetry/validator.ts new file mode 100644 index 00000000000..253af32e9bf --- /dev/null +++ b/src/main/telemetry/validator.ts @@ -0,0 +1,82 @@ +// Fail-closed runtime validator. Thin wrapper around +// `eventSchemas[name].safeParse(props)` — the schema defined in +// `src/shared/telemetry-events.ts` IS the validator. There is no parallel +// `EVENT_SPEC` declaration to keep in sync with `EventMap`. +// +// The validator is the single enforcement point for both main-originated +// and IPC-arrived events. TypeScript types do not survive IPC serialization, +// so the renderer cannot be trusted to send well-typed payloads — the +// renderer is explicitly in the threat model. Every shape-level promise in +// `EventMap` is also a runtime check here. +// +// Contract (all drops go through the same fail-closed path — no event is +// ever emitted when any of these fire): +// - Unknown event name → drop + rate-limited `console.warn`. +// - Extra property key → drop + warn. (Enforced by `.strict()` on every +// per-event object schema — no separate check.) +// - Missing required key → drop + warn. +// - Wrong type / value not in declared enum → drop + warn. +// - Any string longer than its `.max(N)` cap → drop + warn. (Enforced by +// `.max()` in the schema; the cap and the schema are the same thing.) +// +// Warnings are rate-limited: ≤ 1 log per `event_name` per 60 s. Silent +// otherwise — a misbehaving caller should not be able to DoS stderr. + +import { + commonPropsSchema, + eventSchemas, + type EventName, + type EventProps +} from '../../shared/telemetry-events' + +export type ValidationResult = + | { ok: true; props: EventProps } + | { ok: false; reason: string } + +const WARN_WINDOW_MS = 60_000 +const lastWarnAt = new Map() + +function warnRateLimited(key: string, message: string): void { + const now = Date.now() + const prev = lastWarnAt.get(key) ?? 0 + if (now - prev < WARN_WINDOW_MS) { + return + } + lastWarnAt.set(key, now) + console.warn(`[telemetry] ${message}`) +} + +export function validate(name: N, props: unknown): ValidationResult { + // Event name must be a known key. `eventSchemas` is the source of truth for + // what names exist; a cast-bypass at a call site (`track('foo' as never, {})`) + // fails here at runtime. + const schema = eventSchemas[name] as (typeof eventSchemas)[EventName] | undefined + if (!schema) { + const reason = `unknown event: ${String(name)}` + warnRateLimited(`unknown:${String(name)}`, reason) + return { ok: false, reason } + } + + // `.safeParse()` is the single call that enforces exact key set (via + // `.strict()`), types, enum membership, and per-string `.max()` caps. + const parsed = schema.safeParse(props) + if (!parsed.success) { + const issue = parsed.error.issues[0] + const path = issue?.path.length ? issue.path.join('.') : '' + const reason = `${String(name)}: ${path}: ${issue?.message ?? 'invalid'}` + warnRateLimited(String(name), reason) + return { ok: false, reason } + } + + return { ok: true, props: parsed.data as EventProps } +} + +/** Test-only reset of the warn-rate-limit cache. */ +export function _resetValidatorWarnCacheForTests(): void { + lastWarnAt.clear() +} + +// Re-exported so `client.ts` can re-validate the merged outgoing payload +// without reaching into `src/shared/telemetry-events.ts` directly. Keeps the +// validator as the single surface the client depends on. +export { commonPropsSchema } diff --git a/src/main/updater.ts b/src/main/updater.ts index 80b7d1b548f..d99f6521222 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import path from 'node:path' import { app, BrowserWindow, powerMonitor } from 'electron' import { autoUpdater } from 'electron-updater' import type { NsisUpdater } from 'electron-updater' @@ -283,17 +284,20 @@ async function pinPrereleaseFeed(): Promise { // back to the default /releases/latest/download/ URL. In the "no newer" // case that feed will report the latest stable and compareVersions in the // 'update-available' handler will correctly mark it as not-available. - const newerTag = await fetchNewerReleaseTag(app.getVersion()) + const currentVersion = app.getVersion() + const newerTag = await fetchNewerReleaseTag(currentVersion) + // Why: console.info goes to stdout and is captured by Console.app on macOS + // and by --enable-logging elsewhere. This is the only window we have into + // the updater on a user's machine when something goes wrong (issue: RC user + // not offered newer stable). Cheap to keep, invaluable when triaging. if (newerTag) { - autoUpdater.setFeedURL({ - provider: 'generic', - url: getReleaseDownloadUrl(newerTag) - }) + const url = getReleaseDownloadUrl(newerTag) + console.info(`[updater] prerelease feed pinned: current=${currentVersion} → ${url}`) + autoUpdater.setFeedURL({ provider: 'generic', url }) } else { - autoUpdater.setFeedURL({ - provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' - }) + const url = 'https://github.com/stablyai/orca/releases/latest/download' + console.info(`[updater] prerelease feed fallback: current=${currentVersion} → ${url}`) + autoUpdater.setFeedURL({ provider: 'generic', url }) } } @@ -497,12 +501,30 @@ export function setupAutoUpdater( return } if (is.dev) { + // Why: dev-app-update.yml lives at config/dev-app-update.yml (not repo root) + // so the root directory stays short. electron-updater only reads it when + // the dev-mode early-return below is temporarily disabled to exercise the + // update flow locally — point it at the new path up-front so that works. + autoUpdater.updateConfigPath = path.join(app.getAppPath(), 'config', 'dev-app-update.yml') return } autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true + // Why: the only on-machine window we have into electron-updater. Without + // this, an unexpected `update-not-available` (e.g. RC user not offered + // newer stable) is invisible — we can't tell whether the manifest fetch + // got the wrong version, the request failed, or a stale in-flight check + // was deduped. Logs go to main-process stdout, captured on macOS by + // Console.app under the app bundle, and on Win/Linux by --enable-logging. + autoUpdater.logger = { + info: (m: unknown) => console.info('[autoUpdater]', m), + warn: (m: unknown) => console.warn('[autoUpdater]', m), + error: (m: unknown) => console.error('[autoUpdater]', m), + debug: (m: unknown) => console.debug('[autoUpdater]', m) + } as never + // Why: no Windows Authenticode certificate exists for this project. // electron-builder embeds the code-signing publisherName into the app's // bundled app-update.yml at build time. Versions that were incorrectly diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 8b9cc429daf..1a11ddd2f69 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -6,6 +6,8 @@ const { setPermissionRequestHandlerMock, setPermissionCheckHandlerMock, setDisplayMediaRequestHandlerMock, + systemPreferencesAskForMediaAccessMock, + systemPreferencesGetMediaAccessStatusMock, registerRepoHandlersMock, registerWorktreeHandlersMock, registerPtyHandlersMock, @@ -20,6 +22,8 @@ const { setPermissionRequestHandlerMock: vi.fn(), setPermissionCheckHandlerMock: vi.fn(), setDisplayMediaRequestHandlerMock: vi.fn(), + systemPreferencesAskForMediaAccessMock: vi.fn(), + systemPreferencesGetMediaAccessStatusMock: vi.fn(), registerRepoHandlersMock: vi.fn(), registerWorktreeHandlersMock: vi.fn(), registerPtyHandlersMock: vi.fn(), @@ -36,6 +40,10 @@ vi.mock('electron', () => ({ session: { fromPartition: sessionFromPartitionMock }, + systemPreferences: { + askForMediaAccess: systemPreferencesAskForMediaAccessMock, + getMediaAccessStatus: systemPreferencesGetMediaAccessStatusMock + }, ipcMain: { on: onMock, removeAllListeners: removeAllListenersMock, @@ -74,6 +82,55 @@ vi.mock('../updater', () => ({ import { attachMainWindowServices } from './attach-main-window-services' +type MockFn = ReturnType + +type MainWindowStub = { + isDestroyed?: MockFn + on: MockFn + webContents: { + on: MockFn + send?: MockFn + session: { + setPermissionRequestHandler: MockFn + setPermissionCheckHandler: MockFn + } + } +} + +type RuntimeStub = { + attachWindow: MockFn + setNotifier: MockFn + markRendererReloading: MockFn + markGraphUnavailable: MockFn +} + +function createMainWindow(extraWebContents: { on?: MockFn; send?: MockFn } = {}): MainWindowStub { + return { + on: vi.fn(), + webContents: { + on: vi.fn(), + session: { + setPermissionRequestHandler: setPermissionRequestHandlerMock, + setPermissionCheckHandler: setPermissionCheckHandlerMock + }, + ...extraWebContents + } + } +} + +function createStore(): never { + return { flush: vi.fn() } as never +} + +function createRuntime(): RuntimeStub { + return { + attachWindow: vi.fn(), + setNotifier: vi.fn(), + markRendererReloading: vi.fn(), + markGraphUnavailable: vi.fn() + } +} + describe('attachMainWindowServices', () => { beforeEach(() => { onMock.mockReset() @@ -81,6 +138,8 @@ describe('attachMainWindowServices', () => { setPermissionRequestHandlerMock.mockReset() setPermissionCheckHandlerMock.mockReset() setDisplayMediaRequestHandlerMock.mockReset() + systemPreferencesAskForMediaAccessMock.mockReset() + systemPreferencesGetMediaAccessStatusMock.mockReset() registerRepoHandlersMock.mockReset() registerWorktreeHandlersMock.mockReset() registerPtyHandlersMock.mockReset() @@ -95,33 +154,19 @@ describe('attachMainWindowServices', () => { setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock, on: vi.fn() }) + systemPreferencesAskForMediaAccessMock.mockResolvedValue(true) + systemPreferencesGetMediaAccessStatusMock.mockReturnValue('granted') }) - it('only allows the explicit permission allowlist', () => { - const mainWindow = { - on: vi.fn(), - webContents: { - on: vi.fn(), - session: { - setPermissionRequestHandler: setPermissionRequestHandlerMock - } - } - } - const store = { flush: vi.fn() } - const runtime = { - attachWindow: vi.fn(), - setNotifier: vi.fn(), - markRendererReloading: vi.fn(), - markGraphUnavailable: vi.fn() - } - - attachMainWindowServices(mainWindow as never, store as never, runtime as never) + it('only allows the explicit permission allowlist', async () => { + attachMainWindowServices(createMainWindow() as never, createStore(), createRuntime() as never) expect(setPermissionRequestHandlerMock).toHaveBeenCalledTimes(2) const permissionHandler = setPermissionRequestHandlerMock.mock.calls[0][0] const callback = vi.fn() - permissionHandler(null, 'media', callback) + permissionHandler(null, 'media', callback, { mediaTypes: ['audio'] }) + await vi.waitFor(() => expect(callback).toHaveBeenCalledWith(true)) permissionHandler(null, 'fullscreen', callback) permissionHandler(null, 'pointerLock', callback) permissionHandler(null, 'clipboard-read', callback) @@ -129,7 +174,29 @@ describe('attachMainWindowServices', () => { expect(callback.mock.calls).toEqual([[true], [true], [true], [false]]) }) - it('denies browser-session permissions, display capture, and downloads by default', () => { + it('requests macOS media access only when the renderer asks for media', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'darwin' }) + try { + attachMainWindowServices(createMainWindow() as never, createStore(), createRuntime() as never) + + expect(systemPreferencesAskForMediaAccessMock).not.toHaveBeenCalled() + + const permissionHandler = setPermissionRequestHandlerMock.mock.calls[0][0] + const callback = vi.fn() + permissionHandler(null, 'media', callback, { mediaTypes: ['audio', 'video'] }) + + await vi.waitFor(() => expect(callback).toHaveBeenCalledWith(true)) + expect(systemPreferencesAskForMediaAccessMock.mock.calls).toEqual([ + ['microphone'], + ['camera'] + ]) + } finally { + Object.defineProperty(process, 'platform', platform ?? { value: process.platform }) + } + }) + + it('denies browser-session permissions, display capture, and downloads by default', async () => { const browserSessionOnMock = vi.fn() sessionFromPartitionMock.mockReturnValue({ setPermissionRequestHandler: setPermissionRequestHandlerMock, @@ -139,57 +206,47 @@ describe('attachMainWindowServices', () => { }) const mainWindowOnMock = vi.fn() - const mainWindow = { - on: mainWindowOnMock, - webContents: { - on: vi.fn(), - session: { - setPermissionRequestHandler: setPermissionRequestHandlerMock - } - } - } - const store = { flush: vi.fn() } - const runtime = { - attachWindow: vi.fn(), - setNotifier: vi.fn(), - markRendererReloading: vi.fn(), - markGraphUnavailable: vi.fn() - } + const mainWindow = createMainWindow() + mainWindow.on = mainWindowOnMock - attachMainWindowServices(mainWindow as never, store as never, runtime as never) + attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) const browserPermissionHandler = setPermissionRequestHandlerMock.mock.calls[1][0] as ( wc: unknown, permission: string, - callback: (allowed: boolean) => void + callback: (allowed: boolean) => void, + details?: unknown ) => void - const permissionCallback = vi.fn() - const guestWebContents = { id: 401, getURL: vi.fn(() => 'https://example.com/account') } - browserPermissionHandler(guestWebContents, 'fullscreen', permissionCallback) - browserPermissionHandler(guestWebContents, 'media', permissionCallback) - - expect(permissionCallback.mock.calls).toEqual([[true], [false]]) + const cb = vi.fn() + const guestWc = { id: 401, getURL: vi.fn(() => 'https://example.com/account') } + browserPermissionHandler(guestWc, 'fullscreen', cb) + browserPermissionHandler(guestWc, 'notifications', cb) + // Why: `media` routes through macOS TCC instead of being denied outright, + // so pages inside the in-app browser can use camera/mic once Orca has been + // granted Camera/Microphone at the OS level. + browserPermissionHandler(guestWc, 'media', cb, { mediaTypes: ['video'] }) + await vi.waitFor(() => expect(cb.mock.calls).toEqual([[true], [false], [true]])) expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledTimes(1) expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ guestWebContentsId: 401, - permission: 'media', + permission: 'notifications', rawUrl: 'https://example.com/account' }) - const browserPermissionCheckHandler = setPermissionCheckHandlerMock.mock.calls[0][0] as ( + const browserCheckHandler = setPermissionCheckHandlerMock.mock.calls[1][0] as ( wc: unknown, - permission: string + permission: string, + origin: string, + details?: { mediaType?: 'video' | 'audio' | 'unknown' } ) => boolean - expect(browserPermissionCheckHandler(null, 'fullscreen')).toBe(true) - expect(browserPermissionCheckHandler(null, 'notifications')).toBe(false) + expect(browserCheckHandler(null, 'fullscreen', '')).toBe(true) + expect(browserCheckHandler(null, 'notifications', '')).toBe(false) + expect(browserCheckHandler(null, 'media', '', { mediaType: 'video' })).toBe(true) - const displayMediaHandler = setDisplayMediaRequestHandlerMock.mock.calls[0][0] as ( - request: unknown, - callback: (streams: { video: null; audio: null }) => void - ) => void - const displayCallback = vi.fn() - displayMediaHandler(null, displayCallback) - expect(displayCallback).toHaveBeenCalledWith({ video: undefined, audio: undefined }) + const displayMediaHandler = setDisplayMediaRequestHandlerMock.mock.calls[0][0] + const displayCb = vi.fn() + displayMediaHandler(null, displayCb) + expect(displayCb).toHaveBeenCalledWith({ video: undefined, audio: undefined }) const willDownloadHandler = browserSessionOnMock.mock.calls.find( ([eventName]) => eventName === 'will-download' @@ -215,24 +272,10 @@ describe('attachMainWindowServices', () => { on: vi.fn() }) const mainWindowOnMock = vi.fn() - const mainWindow = { - on: mainWindowOnMock, - webContents: { - on: vi.fn(), - session: { - setPermissionRequestHandler: setPermissionRequestHandlerMock - } - } - } - const store = { flush: vi.fn() } - const runtime = { - attachWindow: vi.fn(), - setNotifier: vi.fn(), - markRendererReloading: vi.fn(), - markGraphUnavailable: vi.fn() - } + const mainWindow = createMainWindow() + mainWindow.on = mainWindowOnMock - attachMainWindowServices(mainWindow as never, store as never, runtime as never) + attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) const closedHandler = mainWindowOnMock.mock.calls .filter(([event]) => event === 'closed') @@ -246,26 +289,12 @@ describe('attachMainWindowServices', () => { const sendMock = vi.fn() const webContentsOnMock = vi.fn() const mainWindowOnMock = vi.fn() - const mainWindow = { - isDestroyed: vi.fn(() => false), - on: mainWindowOnMock, - webContents: { - on: webContentsOnMock, - send: sendMock, - session: { - setPermissionRequestHandler: setPermissionRequestHandlerMock - } - } - } - const store = { flush: vi.fn() } - const runtime = { - attachWindow: vi.fn(), - setNotifier: vi.fn(), - markRendererReloading: vi.fn(), - markGraphUnavailable: vi.fn() - } + const mainWindow = createMainWindow({ on: webContentsOnMock, send: sendMock }) + mainWindow.isDestroyed = vi.fn(() => false) + mainWindow.on = mainWindowOnMock + const runtime = createRuntime() - attachMainWindowServices(mainWindow as never, store as never, runtime as never) + attachMainWindowServices(mainWindow as never, createStore(), runtime as never) expect(runtime.setNotifier).toHaveBeenCalledTimes(1) const notifier = runtime.setNotifier.mock.calls[0][0] as { diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index b1e71587ec2..0d9b54a7998 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -4,13 +4,15 @@ import path from 'node:path' import { app, clipboard, ipcMain, nativeImage, session } from 'electron' import type { BrowserWindow } from 'electron' import type { Store } from '../persistence' -import type { CreateWorktreeResult } from '../../shared/types' +import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/types' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' import { registerPtyHandlers } from '../ipc/pty' +import { registerDaemonManagementHandlers } from '../ipc/pty-management' import { registerSshHandlers } from '../ipc/ssh' import { browserManager } from '../browser/browser-manager' +import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/browser-media-access' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { checkForUpdatesFromMenu, @@ -32,14 +34,22 @@ export function attachMainWindowServices( prepareClaudeAuth?: () => Promise ): void { registerRepoHandlers(mainWindow, store) - registerWorktreeHandlers(mainWindow, store) + registerWorktreeHandlers(mainWindow, store, runtime) registerPtyHandlers( mainWindow, runtime, getSelectedCodexHomePath, () => store.getSettings(), - prepareClaudeAuth + prepareClaudeAuth, + store ) + // Why: the Manage Sessions settings panel (docs/daemon-staleness-ux.md §Phase 1) + // uses a narrow `pty:management:*` IPC surface that reads the live + // DaemonPtyRouter via getDaemonProvider(). Registering here — after + // registerPtyHandlers — keeps this wiring alongside the rest of the PTY IPC + // and ensures the handlers are re-installed on macOS app re-activation when + // the main window is recreated. + registerDaemonManagementHandlers() // Why: GC runs on a 10s delay so live worktree enumeration completes first. // Uses git worktree list (not store.getWorktreeMeta) because untouched // worktrees have no metadata entries — see design doc §7.6. @@ -54,7 +64,7 @@ export function attachMainWindowServices( } return ids }) - registerSshHandlers(store, () => mainWindow) + registerSshHandlers(store, () => mainWindow, runtime) registerFileDropRelay(mainWindow) setupAutoUpdater(mainWindow, { getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt, @@ -85,16 +95,63 @@ export function attachMainWindowServices( const allowedPermissions = new Set(['media', 'fullscreen', 'pointerLock']) mainWindow.webContents.session.setPermissionRequestHandler( - (_webContents, permission, callback) => { + (_webContents, permission, callback, details) => { + if (permission === 'media') { + void requestSystemMediaAccess(details).then(callback, (error: unknown) => { + console.error('[permissions] Failed to request media access:', error) + callback(false) + }) + return + } callback(allowedPermissions.has(permission)) } ) + mainWindow.webContents.session.setPermissionCheckHandler( + (_webContents, permission, _origin, details) => { + if (permission !== 'media') { + return allowedPermissions.has(permission) + } + return hasSystemMediaAccess(details?.mediaType) + } + ) const browserSession = session.fromPartition(ORCA_BROWSER_PARTITION) - browserSession.setPermissionRequestHandler((webContents, permission, callback) => { + browserSession.setPermissionRequestHandler((webContents, permission, callback, details) => { // Why: the in-app browser is for dev previews and lightweight browsing, not // trusted desktop-app privileges. Denying by default keeps arbitrary sites // from silently escalating into camera/mic/notification prompts inside Orca. + // Why `media` is allowed through: camera/mic are still gated by macOS TCC + // at the app-process level, so granting here only *permits* Chromium to + // use whatever the OS has already authorized for Orca. Denying at this + // layer would make pages inside the in-app browser throw NotAllowedError + // even after the user granted Camera/Microphone via Settings → Permissions + // or System Settings — the bug #1273 partially addressed. + if (permission === 'media') { + void requestSystemMediaAccess( + details as Electron.MediaAccessPermissionRequest | undefined + ).then( + (granted) => { + if (!granted) { + browserManager.notifyPermissionDenied({ + guestWebContentsId: webContents.id, + permission, + rawUrl: webContents.getURL() + }) + } + callback(granted) + }, + (error: unknown) => { + console.error('[permissions] Browser media access failed:', error) + browserManager.notifyPermissionDenied({ + guestWebContentsId: webContents.id, + permission, + rawUrl: webContents.getURL() + }) + callback(false) + } + ) + return + } const allowed = permission === 'fullscreen' if (!allowed) { browserManager.notifyPermissionDenied({ @@ -105,8 +162,14 @@ export function attachMainWindowServices( } callback(allowed) }) - browserSession.setPermissionCheckHandler((_webContents, permission) => { - return permission === 'fullscreen' + browserSession.setPermissionCheckHandler((_webContents, permission, _origin, details) => { + if (permission === 'fullscreen') { + return true + } + if (permission === 'media') { + return hasSystemMediaAccess(details?.mediaType) + } + return false }) browserSession.setDisplayMediaRequestHandler((_request, callback) => { // Why: arbitrary sites inside Orca should never be able to capture the @@ -137,56 +200,45 @@ function registerRuntimeWindowLifecycle( runtime: OrcaRuntimeService ): void { runtime.attachWindow(mainWindow.id) - runtime.setNotifier({ - worktreesChanged: (repoId) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('worktrees:changed', { repoId }) - } - }, - reposChanged: () => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('repos:changed') - } - }, - activateWorktree: (repoId, worktreeId, setup?: CreateWorktreeResult['setup']) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:activateWorktree', { repoId, worktreeId, setup }) - } - }, - createTerminal: (worktreeId, opts) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:createTerminal', { - worktreeId, - command: opts.command, - title: opts.title - }) - } - }, - splitTerminal: (tabId, paneRuntimeId, opts) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:splitTerminal', { - tabId, - paneRuntimeId, - direction: opts.direction, - command: opts.command - }) - } - }, - renameTerminal: (tabId, title) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:renameTerminal', { tabId, title }) - } - }, - focusTerminal: (tabId, worktreeId) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:focusTerminal', { tabId, worktreeId }) - } - }, - closeTerminal: (tabId, paneRuntimeId) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:closeTerminal', { tabId, paneRuntimeId }) - } + const send = (channel: string, ...args: unknown[]): void => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send(channel, ...args) } + } + runtime.setNotifier({ + worktreesChanged: (repoId) => send('worktrees:changed', { repoId }), + reposChanged: () => send('repos:changed'), + activateWorktree: ( + repoId, + worktreeId, + setup?: CreateWorktreeResult['setup'], + startup?: WorktreeStartupLaunch + ) => { + send('ui:activateWorktree', { + repoId, + worktreeId, + ...(setup ? { setup } : {}), + ...(startup ? { startup } : {}) + }) + }, + createTerminal: (worktreeId, opts) => + send('ui:createTerminal', { worktreeId, command: opts.command, title: opts.title }), + splitTerminal: (tabId, paneRuntimeId, opts) => { + send('ui:splitTerminal', { + tabId, + paneRuntimeId, + direction: opts.direction, + command: opts.command + }) + }, + renameTerminal: (tabId, title) => send('ui:renameTerminal', { tabId, title }), + focusTerminal: (tabId, worktreeId) => send('ui:focusTerminal', { tabId, worktreeId }), + closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }), + sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }), + terminalFitOverrideChanged: (ptyId, mode, cols, rows) => + send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }), + terminalDriverChanged: (ptyId, driver) => + send('runtime:terminalDriverChanged', { ptyId, driver }) }) // Why: the runtime must fail closed while the renderer graph is being torn // down or rebuilt, otherwise future CLI calls could act on stale terminal diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 34863909242..acd93f539c5 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -9,6 +9,7 @@ const { browserWindowMock, openExternalMock, attachGuestPoliciesMock, isMock } = })) vi.mock('electron', () => ({ + app: { on: vi.fn(), removeListener: vi.fn() }, BrowserWindow: browserWindowMock, ipcMain: { on: vi.fn(), removeListener: vi.fn() }, nativeTheme: { shouldUseDarkColors: false }, @@ -493,7 +494,7 @@ describe('createMainWindow', () => { syncListener?.({} as never, 1.2) if (process.platform === 'darwin') { - expect(browserWindowInstance.setWindowButtonPosition).toHaveBeenCalledWith({ x: 16, y: 18 }) + expect(browserWindowInstance.setWindowButtonPosition).toHaveBeenCalledWith({ x: 16, y: 16 }) return } diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 09a29e7d3f0..ec92d3a93ab 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -1,5 +1,5 @@ /* oxlint-disable max-lines */ -import { BrowserWindow, ipcMain, nativeTheme, screen, shell } from 'electron' +import { app, BrowserWindow, ipcMain, nativeTheme, screen, shell } from 'electron' import { join } from 'path' import { is } from '@electron-toolkit/utils' import icon from '../../../resources/icon.png?asset' @@ -31,13 +31,15 @@ function forceRepaint(window: BrowserWindow): void { }, 32) } -// Why: the titlebar is 42px (border-box, 1px border-bottom). The visual -// center of the CSS-centered content sits at ~20 CSS px from the top. -// At zoom factor z that becomes 20·z window px. Traffic lights are +// Why: the titlebar is 36px (border-box, 1px border-bottom). The visual +// center of the CSS-centered content sits at ~18 CSS px from the top. +// At zoom factor z that becomes 18·z window px. Traffic lights are // ~12px tall, so we position their top edge at (center − 6). -const TITLEBAR_CSS_CENTER = 20 +const TITLEBAR_CSS_CENTER = 18 const TRAFFIC_LIGHT_RADIUS = 6 const TRAFFIC_LIGHT_X = 16 +const MIN_WIDTH = 600 +const MIN_HEIGHT = 400 function syncTrafficLightPosition(win: BrowserWindow, zoomFactor: number): void { if (process.platform !== 'darwin' || win.isDestroyed()) { @@ -63,7 +65,54 @@ export function createMainWindow( store: Store | null, opts?: CreateMainWindowOptions ): BrowserWindow { - const savedBounds = store?.getUI().windowBounds + const rawSavedBounds = store?.getUI().windowBounds + // Why: defense in depth — if a previous quit/update path persisted + // shrink-to-min bounds (see freezeBoundsOnQuit), discard them on restore + // rather than resurrecting a tiny window. Anything at or below the min + // dimensions is treated as corrupt and falls back to defaultBounds. The + // position must also land on a currently-attached display with a + // *meaningful* visible area — not just any >0 overlap, since a 1-pixel + // sliver (or a sub-pixel shaving after DPI scaling) would still leave + // the titlebar unreachable. Require at least MIN_WIDTH/2 of horizontal + // and MIN_HEIGHT/2 of vertical overlap with some display's workArea + // (workArea excludes menu bar / dock, so a rect entirely hidden under + // the dock is also correctly discarded). A rect saved while an external + // monitor was connected would otherwise be restored off-screen and + // macOS would silently shrink/reposition the window. + const rectHasVisibleAreaOnAnyDisplay = (b: { + x: number + y: number + width: number + height: number + }): boolean => { + try { + return screen.getAllDisplays().some((d) => { + const wa = d.workArea + const overlapX = Math.max(0, Math.min(b.x + b.width, wa.x + wa.width) - Math.max(b.x, wa.x)) + const overlapY = Math.max( + 0, + Math.min(b.y + b.height, wa.y + wa.height) - Math.max(b.y, wa.y) + ) + return overlapX >= MIN_WIDTH / 2 && overlapY >= MIN_HEIGHT / 2 + }) + } catch (err) { + console.warn('[window] screen.getAllDisplays() threw; treating bounds as off-screen', err) + return false + } + } + const savedBounds = + rawSavedBounds && + rawSavedBounds.width > MIN_WIDTH && + rawSavedBounds.height > MIN_HEIGHT && + rectHasVisibleAreaOnAnyDisplay(rawSavedBounds) + ? rawSavedBounds + : undefined + if (rawSavedBounds && !savedBounds) { + console.warn( + '[window] Discarding persisted windowBounds and falling back to defaultBounds:', + rawSavedBounds + ) + } const savedMaximized = store?.getUI().windowMaximized ?? false // Why: on first launch (no saved bounds), fill the primary display work area // so the window feels spacious without calling maximize(). Saved bounds still @@ -95,8 +144,8 @@ export function createMainWindow( width: savedBounds?.width ?? defaultBounds.width, height: savedBounds?.height ?? defaultBounds.height, ...(savedBounds ? { x: savedBounds.x, y: savedBounds.y } : {}), - minWidth: 600, - minHeight: 400, + minWidth: MIN_WIDTH, + minHeight: MIN_HEIGHT, show: false, // Why: on macOS the menu lives in the system menu bar, so the in-window // menu bar is irrelevant. On Windows/Linux we auto-hide so the menu bar @@ -180,30 +229,88 @@ export function createMainWindow( // position/size instead of maximizing on every launch. Debounce to avoid // hammering the persistence layer during continuous resize drags. let boundsTimer: ReturnType | null = null + // Why: once close has been initiated (user Cmd+Q, auto-updater relaunch, + // app.quit during quitAndInstall), Electron can still emit resize/move/ + // unmaximize events while the OS tears the window down — persisting those + // intermediate, often near-minimum bounds would clobber the user's real + // last-used size and cause the next launch (especially post-update + // relaunch) to come up at minWidth × minHeight. Freeze persistence as soon + // as 'close' is observed. + let windowClosing = false const saveBounds = (): void => { if (boundsTimer) { clearTimeout(boundsTimer) } boundsTimer = setTimeout(() => { boundsTimer = null - if (mainWindow.isDestroyed() || mainWindow.isFullScreen()) { + if (windowClosing || mainWindow.isDestroyed() || mainWindow.isFullScreen()) { return } + // Why: windowMaximized and windowBounds must be sampled and persisted + // atomically — writing windowMaximized first and then deciding whether + // to write bounds can leave the store with `windowMaximized: false` + // paired with stale/absent windowBounds if the near-min guard trips, + // which violates the pairing invariant subsequent launches rely on. const isMaximized = mainWindow.isMaximized() - store?.updateUI({ windowMaximized: isMaximized }) - if (!isMaximized) { - store?.updateUI({ windowBounds: mainWindow.getBounds() }) + if (isMaximized) { + store?.updateUI({ windowMaximized: true }) + return } + const bounds = mainWindow.getBounds() + // Why: never persist shrink-to-min bounds. The user cannot want these + // saved — the window hit the enforced minimum, so either the teardown + // race from PR #1269 slipped past the freeze (e.g. dev-mode Ctrl+C + // where will-prevent-unload re-opens the freeze), or a transient + // OS resize fired. Dropping the bounds write here makes the next + // launch fall back to defaultBounds instead of resurrecting a tiny + // window. We still record windowMaximized: false so subsequent + // launches don't incorrectly restore maximized state. + if (bounds.width <= MIN_WIDTH || bounds.height <= MIN_HEIGHT) { + console.warn('[window] Skipping persist of near-minimum windowBounds:', bounds) + store?.updateUI({ windowMaximized: false }) + return + } + store?.updateUI({ windowMaximized: false, windowBounds: bounds }) }, 500) } mainWindow.on('resize', saveBounds) mainWindow.on('move', saveBounds) + // Why: the auto-updater install path calls + // `win.removeAllListeners('close')` before quitting, so the per-window + // 'close' handler below never runs for update-triggered relaunches. + // Listen to app-level 'before-quit' as a second latch so resize/move + // events emitted during window teardown don't persist shrink-to-min + // bounds that would be restored on next launch. + const freezeBoundsOnQuit = (): void => { + windowClosing = true + if (boundsTimer) { + clearTimeout(boundsTimer) + boundsTimer = null + } + } + app.on('before-quit', freezeBoundsOnQuit) + mainWindow.on('maximize', () => { + if (windowClosing) { + return + } store?.updateUI({ windowMaximized: true }) }) mainWindow.on('unmaximize', () => { - store?.updateUI({ windowMaximized: false, windowBounds: mainWindow.getBounds() }) + if (windowClosing) { + return + } + const bounds = mainWindow.getBounds() + // Why: mirror the saveBounds guard — unmaximize during teardown can land + // at MIN_WIDTH × MIN_HEIGHT and we must not persist those as the user's + // remembered size. + if (bounds.width <= MIN_WIDTH || bounds.height <= MIN_HEIGHT) { + console.warn('[window] Skipping unmaximize-time persist of near-min bounds:', bounds) + store?.updateUI({ windowMaximized: false }) + return + } + store?.updateUI({ windowMaximized: false, windowBounds: bounds }) }) mainWindow.on('enter-full-screen', () => { @@ -407,9 +514,7 @@ export function createMainWindow( // Why: routed through the main process so focus contexts that bypass // the renderer's window-level keydown (contentEditable markdown editor, // browser-guest webContents) still reach the new-workspace composer. - // Forward the target tab so Cmd/Ctrl+Shift+N lands on the - // "Create from…" tab instead of the default quick-create form. - mainWindow.webContents.send('ui:openNewWorkspace', action.tab) + mainWindow.webContents.send('ui:openNewWorkspace') return } @@ -448,6 +553,16 @@ export function createMainWindow( mainWindow.on('close', (e) => { if (windowCloseConfirmed) { windowCloseConfirmed = false + // Why: past this point Electron/OS may emit resize/move/unmaximize as + // the window is destroyed. Freeze bounds persistence so those + // teardown events can't clobber the user's saved window size — which + // would otherwise make the post-update relaunch come up at minWidth × + // minHeight (issue surfaced in v1.3.26-rc2). + windowClosing = true + if (boundsTimer) { + clearTimeout(boundsTimer) + boundsTimer = null + } return } e.preventDefault() @@ -456,6 +571,10 @@ export function createMainWindow( }) }) mainWindow.webContents.on('will-prevent-unload', () => { + // Why: a prevented beforeunload cancels the in-flight quit. Release the + // bounds-persistence freeze so a user who keeps using the window after + // aborting Cmd+Q still gets their size saved. + windowClosing = false opts?.onQuitAborted?.() }) @@ -480,6 +599,7 @@ export function createMainWindow( ipcMain.removeListener(trafficLightChannel, onSyncTrafficLights) ipcMain.removeListener(confirmCloseChannel, onConfirmClose) ipcMain.removeListener(markdownFocusChannel, onMarkdownEditorFocused) + app.removeListener('before-quit', freezeBoundsOnQuit) }) if (is.dev && process.env.ELECTRON_RENDERER_URL) { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index daedae535ad..71a83947045 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -6,9 +6,12 @@ import type { BrowserSessionProfile, BrowserSessionProfileScope, BrowserSessionProfileSource, + BrowserViewportOverride, ClaudeRateLimitAccountsState, CodexRateLimitAccountsState, + CreateWorktreeArgs, CreateWorktreeResult, + CustomSidekick, DirEntry, FsChangedPayload, GhosttyImportPreview, @@ -16,7 +19,7 @@ import type { GitBranchCompareResult, GitConflictOperation, GitDiffResult, - GitStatusEntry, + GitStatusResult, GitHubAssignableUser, GitHubPRFile, GitHubPRFileContents, @@ -25,6 +28,7 @@ import type { GitHubWorkItem, GitHubWorkItemDetails, GitHubViewer, + ListWorkItemsResult, IssueInfo, LinearViewer, LinearConnectionStatus, @@ -35,15 +39,19 @@ import type { LinearLabel, LinearMember, LinearTeam, + MarkdownDocument, GitHubIssueUpdate, + GetRateLimitResult, NotificationDispatchRequest, NotificationDispatchResult, + NotificationSoundResult, OrcaHooks, PersistedUIState, PRCheckDetail, PRComment, PRInfo, Repo, + SparsePreset, SearchOptions, SearchResult, StatsSummary, @@ -52,8 +60,36 @@ import type { Worktree, WorktreeMeta, WorktreeSetupLaunch, + WorktreeStartupLaunch, WorkspaceSessionState } from '../shared/types' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + GetProjectViewTableResult, + GitHubProjectCommentMutationResult, + GitHubProjectMutationResult, + ListAccessibleProjectsResult, + ListAssignableUsersBySlugArgs, + ListAssignableUsersBySlugResult, + ListIssueTypesBySlugArgs, + ListIssueTypesBySlugResult, + ListLabelsBySlugArgs, + ListLabelsBySlugResult, + ListProjectViewsArgs, + ListProjectViewsResult, + ProjectWorkItemDetailsBySlugArgs, + ProjectWorkItemDetailsBySlugResult, + ResolveProjectRefArgs, + ResolveProjectRefResult, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdatePullRequestBySlugArgs, + UpdateProjectItemFieldArgs +} from '../shared/github-project-types' import type { BrowserSetGrabModeArgs, BrowserSetGrabModeResult, @@ -80,6 +116,11 @@ import type { E2EConfig } from '../shared/e2e-config' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { AgentStatusState } from '../shared/agent-status-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' +import type { + DeveloperPermissionId, + DeveloperPermissionRequestResult, + DeveloperPermissionState +} from '../shared/developer-permissions-types' import type { ClaudeUsageBreakdownKind, ClaudeUsageBreakdownRow, @@ -91,6 +132,7 @@ import type { ClaudeUsageSummary } from '../shared/claude-usage-types' import type { RateLimitState } from '../shared/rate-limit-types' +import type { GhAuthDiagnostic } from '../shared/github-auth-types' import type { SshConnectionState, SshTarget, @@ -107,16 +149,23 @@ import type { CodexUsageSessionRow, CodexUsageSummary } from '../shared/codex-usage-types' +import type { TelemetryConsentState } from '../shared/telemetry-consent-types' +import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' export type BrowserApi = { registerGuest: (args: { browserPageId: string workspaceId: string worktreeId: string + sessionProfileId?: string | null webContentsId: number }) => Promise unregisterGuest: (args: { browserPageId: string }) => Promise openDevTools: (args: { browserPageId: string }) => Promise + setViewportOverride: (args: { + browserPageId: string + override: BrowserViewportOverride | null + }) => Promise onGuestLoadFailed: ( callback: (args: { browserPageId: string; loadError: BrowserLoadError }) => void ) => () => void @@ -201,6 +250,32 @@ export type PreflightApi = { detectRemoteAgents: (args: { connectionId: string }) => Promise } +// Why: renderer-facing mirror of the daemon's `SessionInfo` + protocolVersion +// annotation (src/main/daemon/types.ts `DaemonSessionInfo`). Kept here instead +// of imported from main because the preload boundary must not depend on +// main-only protocol types — those are subprocess-facing. Keep the two shapes +// in sync when adding fields on either side; the Manage Sessions panel reads +// these directly. +export type PtyManagementSession = { + sessionId: string + state: 'created' | 'spawning' | 'running' | 'exiting' | 'exited' + shellState: 'pending' | 'ready' | 'timed_out' | 'unsupported' + isAlive: boolean + pid: number | null + cwd: string | null + cols: number + rows: number + createdAt: number + protocolVersion: number +} + +export type PtyManagementApi = { + listSessions: () => Promise<{ sessions: PtyManagementSession[] }> + killAll: () => Promise<{ killedCount: number; remainingCount: number }> + killOne: (args: { sessionId: string }) => Promise<{ success: boolean }> + restart: () => Promise<{ success: boolean }> +} + export type ExportApi = { htmlToPdf: (args: { html: string @@ -267,24 +342,14 @@ export type CodexUsageApi = { } export type AppRuntimeFlags = { - daemonEnabledAtStartup: boolean -} - -export type DaemonTransitionNotice = { - killedCount: number + agentDashboardEnabledAtStartup: boolean } export type AppApi = { - /** Returns flags about the main-process state that was set at startup - * (e.g. whether the persistent terminal daemon actually started). The - * renderer uses this to show a "restart required" banner when the user + /** Returns flags about the main-process state that was set at startup. + * The renderer uses this to show a "restart required" banner when the user * toggles a setting that only applies across a full relaunch. */ getRuntimeFlags: () => Promise - /** Reads and clears any pending one-shot notice about a daemon cleanup - * that ran during startup (e.g. when upgrading from v1.3.0 where the - * daemon was on by default to a build where it's opt-in). Returns null - * when there is nothing to show. */ - consumeDaemonTransitionNotice: () => Promise /** Relaunches the app via Electron's app.relaunch() + app.exit(0). Used * by the "Restart now" button on the Experimental settings pane. */ relaunch: () => Promise @@ -312,7 +377,15 @@ export type PreloadApi = { update: (args: { repoId: string updates: Partial< - Pick + Pick< + Repo, + | 'displayName' + | 'badgeColor' + | 'hookSettings' + | 'worktreeBaseRef' + | 'kind' + | 'issueSourcePreference' + > > }) => Promise pickFolder: () => Promise @@ -326,28 +399,40 @@ export type PreloadApi = { displayName?: string kind?: 'git' | 'folder' }) => Promise<{ repo: Repo } | { error: string }> + // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. + create: (args: { + parentPath: string + name: string + kind: 'git' | 'folder' + }) => Promise<{ repo: Repo } | { error: string }> onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void getGitUsername: (args: { repoId: string }) => Promise getBaseRefDefault: (args: { repoId: string }) => Promise searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise onChanged: (callback: () => void) => () => void } + sparsePresets: { + list: (args: { repoId: string }) => Promise + save: (args: { + repoId: string + id?: string + name: string + directories: string[] + }) => Promise + remove: (args: { repoId: string; presetId: string }) => Promise + onChanged: (callback: (data: { repoId: string }) => void) => () => void + } worktrees: { list: (args: { repoId: string }) => Promise listAll: () => Promise - create: (args: { - repoId: string - name: string - baseBranch?: string - setupDecision?: 'inherit' | 'run' | 'skip' - }) => Promise + create: (args: CreateWorktreeArgs) => Promise resolvePrBase: (args: { repoId: string prNumber: number headRefName?: string isCrossRepository?: boolean }) => Promise<{ baseBranch: string } | { error: string }> - remove: (args: { worktreeId: string; force?: boolean }) => Promise + remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => Promise updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise persistSortOrder: (args: { orderedIds: string[] }) => Promise onChanged: (callback: (data: { repoId: string }) => void) => () => void @@ -366,6 +451,16 @@ export type PreloadApi = { // Preserved from the deleted index.d.ts PtyApi duplicate during the // single-source-of-truth collapse (see docs/preload-typecheck-hole.md §1). shellOverride?: string + // Why: closes the SIGKILL race documented in INVESTIGATION.md — main + // sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before + // pty:spawn returns. Only the renderer's daemon-host path threads these. + tabId?: string + leafId?: string + // Why: telemetry-plan.md§Agent launch semantics — main emits + // `agent_started` only after the PTY/session is created successfully, + // so the renderer threads the launch metadata through this field and + // the IPC handler fires the event from the spawn-success branch. + telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }) => Promise<{ id: string snapshot?: string @@ -380,14 +475,30 @@ export type PreloadApi = { write: (id: string, data: string) => void resize: (id: string, cols: number, rows: number) => void signal: (id: string, signal: string) => void - kill: (id: string) => Promise + kill: (id: string, opts?: { keepHistory?: boolean }) => Promise ackColdRestore: (id: string) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise + getCwd: (id: string) => Promise listSessions: () => Promise<{ id: string; cwd: string; title: string }[]> onData: (callback: (data: { id: string; data: string }) => void) => () => void onReplay: (callback: (data: { id: string; data: string }) => void) => () => void onExit: (callback: (data: { id: string; code: number }) => void) => () => void + onSerializeBufferRequest: ( + callback: (data: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + }) => void + ) => () => void + sendSerializedBuffer: ( + requestId: string, + snapshot: { data: string; cols: number; rows: number; lastTitle?: string } | null + ) => void + declarePendingPaneSerializer: (paneKey: string) => Promise + settlePaneSerializer: (paneKey: string, gen: number) => Promise + clearPendingPaneSerializer: (paneKey: string, gen: number) => Promise + management: PtyManagementApi } feedback: { submit: (args: { @@ -400,15 +511,28 @@ export type PreloadApi = { gh: { viewer: () => Promise repoSlug: (args: { repoPath: string }) => Promise<{ owner: string; repo: string } | null> - prForBranch: (args: { repoPath: string; branch: string }) => Promise + prForBranch: (args: { + repoPath: string + branch: string + linkedPRNumber?: number | null + }) => Promise issue: (args: { repoPath: string; number: number }) => Promise workItem: (args: { repoPath: string number: number + type?: 'issue' | 'pr' + }) => Promise | null> + workItemByOwnerRepo: (args: { + repoPath: string + owner: string + repo: string + number: number + type: 'issue' | 'pr' }) => Promise | null> workItemDetails: (args: { repoPath: string number: number + type?: 'issue' | 'pr' }) => Promise prFileContents: (args: { repoPath: string @@ -431,7 +555,7 @@ export type PreloadApi = { limit?: number query?: string before?: string - }) => Promise[]> + }) => Promise>> prChecks: (args: { repoPath: string prNumber: number @@ -478,6 +602,51 @@ export type PreloadApi = { listAssignableUsers: (args: { repoPath: string }) => Promise checkOrcaStarred: () => Promise starOrca: () => Promise + /** + * GitHub API rate-limit snapshot. Does NOT consume quota (the + * `rate_limit` endpoint is exempt). Cached 30s server-side — pass + * `force: true` to bust after a known-expensive op. + */ + rateLimit: (args?: { force?: boolean }) => Promise + /** + * Probe `gh auth status` and the Electron process env to explain + * why ProjectV2 calls are failing with scope_missing. Surfaces the + * common gotcha where `GITHUB_TOKEN` is exported in the user's + * shell and silently shadows the keyring credential — in that case + * `gh auth refresh` is a no-op and the UI must say so. + */ + diagnoseAuth: () => Promise + // ── ProjectV2 (GitHub Projects) ───────────────────────────────── + listAccessibleProjects: () => Promise + resolveProjectRef: (args: ResolveProjectRefArgs) => Promise + listProjectViews: (args: ListProjectViewsArgs) => Promise + getProjectViewTable: (args: GetProjectViewTableArgs) => Promise + projectWorkItemDetailsBySlug: ( + args: ProjectWorkItemDetailsBySlugArgs + ) => Promise + updateProjectItemField: ( + args: UpdateProjectItemFieldArgs + ) => Promise + clearProjectItemField: (args: ClearProjectItemFieldArgs) => Promise + updateIssueBySlug: (args: UpdateIssueBySlugArgs) => Promise + updatePullRequestBySlug: ( + args: UpdatePullRequestBySlugArgs + ) => Promise + addIssueCommentBySlug: ( + args: AddIssueCommentBySlugArgs + ) => Promise + updateIssueCommentBySlug: ( + args: UpdateIssueCommentBySlugArgs + ) => Promise + deleteIssueCommentBySlug: ( + args: DeleteIssueCommentBySlugArgs + ) => Promise + listLabelsBySlug: (args: ListLabelsBySlugArgs) => Promise + listAssignableUsersBySlug: ( + args: ListAssignableUsersBySlugArgs + ) => Promise + listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs) => Promise + updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs) => Promise } linear: { connect: (args: { @@ -519,11 +688,36 @@ export type PreloadApi = { complete: () => Promise forceShow: () => Promise } + /** Fire-and-forget track. Loose typing at the IPC boundary on purpose — + * the main-side validator is the single enforcement point. Renderer call + * sites should import `track()` from `src/renderer/src/lib/telemetry.ts` + * for the `EventMap`-based type safety, not reach for this directly. */ + telemetryTrack: (name: string, props: Record) => Promise + /** Flip the persisted opt-in preference. Subject to a per-session + * consent-mutation rate limit on the main side (≤5/session). */ + telemetrySetOptIn: (optedIn: boolean) => Promise + /** Read-only view of effective consent state, including the reason if + * disabled (env var / user opt-out / CI / pending banner). Used by the + * Privacy pane to render the correct "blocked by X" helper text — env + * vars are main-side state the renderer cannot read directly. */ + telemetryGetConsentState: () => Promise + /** Banner ✕ — persist `optedIn = true` silently, emit nothing. Deliberately + * a separate channel from `telemetrySetOptIn` because main's `via` + * derivation on that channel would tag this path as `first_launch_banner` + * and fire `telemetry_opted_in`, which the ✕-as-silent-acknowledge + * semantics forbid (the user did not explicitly opt in, they declined to + * intervene). Subject to the same per-session consent-mutation rate + * limit as `telemetrySetOptIn`. */ + telemetryAcknowledgeBanner: () => Promise settings: { get: () => Promise set: (args: Partial) => Promise listFonts: () => Promise previewGhosttyImport: () => Promise + /** Subscribe to out-of-band settings updates (e.g. the View > Appearance + * menu toggles) so the renderer can stay in sync with main's persisted + * state without round-tripping through settings:get. */ + onChanged: (callback: (updates: Partial) => void) => () => void } codexAccounts: { list: () => Promise @@ -550,10 +744,19 @@ export type PreloadApi = { geminiStatus: () => Promise cursorStatus: () => Promise } + agentTrust: { + markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }) => Promise + } preflight: PreflightApi notifications: { dispatch: (args: NotificationDispatchRequest) => Promise openSystemSettings: () => Promise + playSound: (options?: { force?: boolean }) => Promise + } + developerPermissions: { + getStatus: () => Promise + request: (args: { id: DeveloperPermissionId }) => Promise + openSettings: (args: { id: DeveloperPermissionId }) => Promise } shell: { openPath: (path: string) => Promise @@ -563,9 +766,16 @@ export type PreloadApi = { pathExists: (path: string) => Promise pickAttachment: () => Promise pickImage: () => Promise + pickAudio: () => Promise pickDirectory: (args: { defaultPath?: string }) => Promise copyFile: (args: { srcPath: string; destPath: string }) => Promise } + sidekick: { + import: () => Promise + importPetBundle: () => Promise + read: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise + delete: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise + } browser: BrowserApi hooks: { check: (args: { @@ -622,6 +832,10 @@ export type PreloadApi = { filePath: string connectionId?: string }) => Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> + listMarkdownDocuments: (args: { + rootPath: string + connectionId?: string + }) => Promise writeFile: (args: { filePath: string; content: string; connectionId?: string }) => Promise createFile: (args: { filePath: string; connectionId?: string }) => Promise createDir: (args: { dirPath: string; connectionId?: string }) => Promise @@ -642,7 +856,11 @@ export type PreloadApi = { excludePaths?: string[] }) => Promise search: (args: SearchOptions & { connectionId?: string }) => Promise - importExternalPaths: (args: { sourcePaths: string[]; destDir: string }) => Promise<{ + importExternalPaths: (args: { + sourcePaths: string[] + destDir: string + connectionId?: string + }) => Promise<{ results: ( | { sourcePath: string @@ -663,15 +881,24 @@ export type PreloadApi = { } )[] }> + resolveDroppedPathsForAgent: (args: { + paths: string[] + worktreePath: string + connectionId?: string + }) => Promise<{ + resolvedPaths: string[] + skipped: { + sourcePath: string + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + }[] + failed: { sourcePath: string; reason: string }[] + }> watchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise unwatchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void } git: { - status: (args: { - worktreePath: string - connectionId?: string - }) => Promise<{ entries: GitStatusEntry[] }> + status: (args: { worktreePath: string; connectionId?: string }) => Promise conflictOperation: (args: { worktreePath: string connectionId?: string @@ -680,6 +907,7 @@ export type PreloadApi = { worktreePath: string filePath: string staged: boolean + compareAgainstHead?: boolean connectionId?: string }) => Promise branchCompare: (args: { @@ -699,6 +927,11 @@ export type PreloadApi = { oldPath?: string connectionId?: string }) => Promise + commit: (args: { + worktreePath: string + message: string + connectionId?: string + }) => Promise<{ success: boolean; error?: string }> stage: (args: { worktreePath: string filePath: string @@ -739,14 +972,23 @@ export type PreloadApi = { onToggleRightSidebar: (callback: () => void) => () => void onToggleWorktreePalette: (callback: () => void) => () => void onOpenQuickOpen: (callback: () => void) => () => void - onOpenNewWorkspace: (callback: (tab: 'quick' | 'create-from') => void) => () => void + onOpenNewWorkspace: (callback: () => void) => () => void onJumpToWorktreeIndex: (callback: (index: number) => void) => () => void onWorktreeHistoryNavigate: (callback: (direction: 'back' | 'forward') => void) => () => void onNewBrowserTab: (callback: () => void) => () => void onRequestTabCreate: ( - callback: (data: { requestId: string; url: string; worktreeId?: string }) => void + callback: (data: { + requestId: string + url: string + worktreeId?: string + sessionProfileId?: string + }) => void ) => () => void replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }) => void + onRequestTabSetProfile: ( + callback: (data: { requestId: string; browserPageId: string; profileId: string }) => void + ) => () => void + replyTabSetProfile: (reply: { requestId: string; error?: string }) => void onRequestTabClose: ( callback: (data: { requestId: string; tabId: string | null; worktreeId?: string }) => void ) => () => void @@ -758,11 +1000,17 @@ export type PreloadApi = { onHardReloadBrowserPage: (callback: () => void) => () => void onCloseActiveTab: (callback: () => void) => () => void onSwitchTab: (callback: (direction: 1 | -1) => void) => () => void + onSwitchTabAcrossAllTypes: (callback: (direction: 1 | -1) => void) => () => void onSwitchTerminalTab: (callback: (direction: 1 | -1) => void) => () => void onToggleStatusBar: (callback: () => void) => () => void onExportPdfRequested: (callback: () => void) => () => void onActivateWorktree: ( - callback: (data: { repoId: string; worktreeId: string; setup?: WorktreeSetupLaunch }) => void + callback: (data: { + repoId: string + worktreeId: string + setup?: WorktreeSetupLaunch + startup?: WorktreeStartupLaunch + }) => void ) => () => void onCreateTerminal: ( callback: (data: { worktreeId: string; command?: string; title?: string }) => void @@ -796,6 +1044,7 @@ export type PreloadApi = { onCloseTerminal: ( callback: (data: { tabId: string; paneRuntimeId?: number }) => void ) => () => void + onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void readClipboardText: () => Promise saveClipboardImageAsTempFile: () => Promise @@ -821,11 +1070,31 @@ export type PreloadApi = { runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise getStatus: () => Promise + getTerminalFitOverrides: () => Promise< + { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] + > + restoreTerminalFit: (ptyId: string) => Promise<{ restored: boolean }> + onTerminalFitOverrideChanged: ( + callback: (event: { + ptyId: string + mode: 'mobile-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + ) => () => void + onTerminalDriverChanged: ( + callback: (event: { + ptyId: string + driver: { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string } + }) => void + ) => () => void } rateLimits: { get: () => Promise refresh: () => Promise setPollingInterval: (ms: number) => Promise + fetchInactiveClaudeAccounts: () => Promise + fetchInactiveCodexAccounts: () => Promise onUpdate: (callback: (state: RateLimitState) => void) => () => void } ssh: { @@ -840,6 +1109,7 @@ export type PreloadApi = { connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise getState: (args: { targetId: string }) => Promise + needsPassphrasePrompt: (args: { targetId: string }) => Promise testConnection: (args: { targetId: string }) => Promise<{ success: boolean; error?: string; state?: SshConnectionState }> @@ -888,6 +1158,9 @@ export type PreloadApi = { wsl: { isAvailable: () => Promise } + pwsh: { + isAvailable: () => Promise + } agentStatus: { /** Listen for agent status updates forwarded from native hook receivers. */ onSet: ( @@ -905,6 +1178,26 @@ export type PreloadApi = { }) => void ) => () => void } + mobile: { + listNetworkInterfaces: () => Promise<{ + interfaces: { name: string; address: string }[] + }> + getPairingQR: (args?: { address?: string }) => Promise< + | { available: false } + | { + available: true + qrDataUrl: string + pairingUrl: string + endpoint: string + deviceId: string + } + > + listDevices: () => Promise<{ + devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] + }> + revokeDevice: (args: { deviceId: string }) => Promise<{ revoked: boolean }> + isWebSocketReady: () => Promise<{ ready: boolean; endpoint: string | null }> + } } declare global { diff --git a/src/preload/index.ts b/src/preload/index.ts index 07f90b263cf..3043c5715ea 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,20 +4,58 @@ review and type drift checks easier than scattering these bindings across module import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' +import type { AppRuntimeFlags } from './api-types' import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { BaseRefDefaultResult, + BrowserViewportOverride, + CreateWorktreeArgs, + CustomSidekick, FsChangedPayload, + GetRateLimitResult, GitHubAssignableUser, GitHubCommentResult, + GitHubWorkItem, GhosttyImportPreview, + ListWorkItemsResult, MemorySnapshot, NotificationDispatchResult, + NotificationSoundDataResult, + NotificationSoundPathResult, + NotificationSoundResult, SearchResult } from '../shared/types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' import type { RateLimitState } from '../shared/rate-limit-types' +import type { GhAuthDiagnostic } from '../shared/github-auth-types' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + GetProjectViewTableResult, + GitHubProjectCommentMutationResult, + GitHubProjectMutationResult, + ListAccessibleProjectsResult, + ListAssignableUsersBySlugArgs, + ListAssignableUsersBySlugResult, + ListIssueTypesBySlugArgs, + ListIssueTypesBySlugResult, + ListLabelsBySlugArgs, + ListLabelsBySlugResult, + ListProjectViewsArgs, + ListProjectViewsResult, + ProjectWorkItemDetailsBySlugArgs, + ProjectWorkItemDetailsBySlugResult, + ResolveProjectRefArgs, + ResolveProjectRefResult, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdatePullRequestBySlugArgs, + UpdateProjectItemFieldArgs +} from '../shared/github-project-types' import type { SshConnectionState, SshTarget, @@ -25,6 +63,8 @@ import type { DetectedPort } from '../shared/ssh-types' import type { AgentStatusState } from '../shared/agent-status-types' +import type { TelemetryConsentState } from '../shared/telemetry-consent-types' +import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import { ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, type EditorSaveDirtyFilesDetail @@ -44,6 +84,29 @@ type NativeDropResolution = // falling back to 'editor' — fail-closed behavior per design §7.1. | { target: 'rejected' } +// Why: one shared HTMLAudioElement per sound file, restarted from t=0 on each +// play, with an in-flight guard that drops new plays while the sound is still +// ringing. This mirrors VS Code's AccessibilitySignalService and GNOME's +// libcanberra: a burst of triggers self-dedupes by the sound's own duration +// (no magic time constant), while distinct sounds are still allowed to overlap. +// We also cache the decoded blob URL by path so we don't re-read 10MB from +// disk and re-transfer it over IPC on every notification. +let cachedNotificationSound: { + path: string + blobUrl: string + audio: HTMLAudioElement +} | null = null +let isNotificationSoundPlaying = false + +function disposeCachedNotificationSound(): void { + if (cachedNotificationSound) { + cachedNotificationSound.audio.pause() + cachedNotificationSound.audio.src = '' + URL.revokeObjectURL(cachedNotificationSound.blobUrl) + cachedNotificationSound = null + } +} + /** * Walk the composed event path to classify which UI surface the native OS drop * landed on, and — for file-explorer drops — extract the nearest destination @@ -174,10 +237,7 @@ document.addEventListener( // Custom APIs for renderer const api = { app: { - getRuntimeFlags: (): Promise<{ daemonEnabledAtStartup: boolean }> => - ipcRenderer.invoke('app:getRuntimeFlags'), - consumeDaemonTransitionNotice: (): Promise<{ killedCount: number } | null> => - ipcRenderer.invoke('app:consumeDaemonTransitionNotice'), + getRuntimeFlags: (): Promise => ipcRenderer.invoke('app:getRuntimeFlags'), relaunch: (): Promise => ipcRenderer.invoke('app:relaunch'), // Why: on macOS this returns AppleCurrentKeyboardLayoutInputSourceID so // the renderer's keyboard-layout probe can distinguish Polish Pro / US @@ -192,6 +252,10 @@ const api = { isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable') }, + pwsh: { + isAvailable: (): Promise => ipcRenderer.invoke('pwsh:isAvailable') + }, + repos: { list: (): Promise => ipcRenderer.invoke('repos:list'), @@ -205,6 +269,12 @@ const api = { kind?: 'git' | 'folder' }): Promise => ipcRenderer.invoke('repos:addRemote', args), + create: (args: { + parentPath: string + name: string + kind: 'git' | 'folder' + }): Promise => ipcRenderer.invoke('repos:create', args), + remove: (args: { repoId: string }): Promise => ipcRenderer.invoke('repos:remove', args), update: (args: { repoId: string; updates: Record }): Promise => @@ -246,18 +316,36 @@ const api = { } }, + sparsePresets: { + list: (args: { repoId: string }): Promise => + ipcRenderer.invoke('sparsePresets:list', args), + + save: (args: { + repoId: string + id?: string + name: string + directories: string[] + }): Promise => ipcRenderer.invoke('sparsePresets:save', args), + + remove: (args: { repoId: string; presetId: string }): Promise => + ipcRenderer.invoke('sparsePresets:remove', args), + + onChanged: (callback: (data: { repoId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => + callback(data) + ipcRenderer.on('sparsePresets:changed', listener) + return () => ipcRenderer.removeListener('sparsePresets:changed', listener) + } + }, + worktrees: { list: (args: { repoId: string }): Promise => ipcRenderer.invoke('worktrees:list', args), listAll: (): Promise => ipcRenderer.invoke('worktrees:listAll'), - create: (args: { - repoId: string - name: string - baseBranch?: string - setupDecision?: 'inherit' | 'run' | 'skip' - }): Promise => ipcRenderer.invoke('worktrees:create', args), + create: (args: CreateWorktreeArgs): Promise => + ipcRenderer.invoke('worktrees:create', args), resolvePrBase: (args: { repoId: string @@ -267,7 +355,7 @@ const api = { }): Promise<{ baseBranch: string } | { error: string }> => ipcRenderer.invoke('worktrees:resolvePrBase', args), - remove: (args: { worktreeId: string; force?: boolean }): Promise => + remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }): Promise => ipcRenderer.invoke('worktrees:remove', args), updateMeta: (args: { @@ -297,6 +385,18 @@ const api = { worktreeId?: string sessionId?: string shellOverride?: string + // Why: closes the SIGKILL race documented in INVESTIGATION.md by + // letting main patch + sync-flush the (worktreeId, tabId, leafId → + // ptyId) binding before pty:spawn returns. Only the renderer's + // user-typing-Ctrl+T daemon-host path threads these. + tabId?: string + leafId?: string + // Why: telemetry-plan.md§Agent launch semantics — main fires + // `agent_started` only after the spawn succeeds. The renderer is the + // source of truth for the launch metadata; main is the source of + // truth for whether the launch happened. Loose typing here on + // purpose: validation lives at the main-side schema validator. + telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }): Promise<{ id: string snapshot?: string @@ -325,7 +425,8 @@ const api = { ipcRenderer.send('pty:ackColdRestore', { id }) }, - kill: (id: string): Promise => ipcRenderer.invoke('pty:kill', { id }), + kill: (id: string, opts?: { keepHistory?: boolean }): Promise => + ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), listSessions: (): Promise<{ id: string; cwd: string; title: string }[]> => ipcRenderer.invoke('pty:listSessions'), @@ -339,6 +440,10 @@ const api = { getForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:getForegroundProcess', { id }), + /** Resolve the live cwd of a PTY via `/proc` (Linux) or `lsof` (macOS). + * Returns `''` when the id is unknown or the platform cannot resolve one. */ + getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), + onData: (callback: (data: { id: string; data: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { id: string; data: string }) => callback(data) @@ -358,6 +463,53 @@ const api = { callback(data) ipcRenderer.on('pty:exit', listener) return () => ipcRenderer.removeListener('pty:exit', listener) + }, + + onSerializeBufferRequest: ( + callback: (data: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + } + ) => callback(data) + ipcRenderer.on('pty:serializeBuffer:request', listener) + return () => ipcRenderer.removeListener('pty:serializeBuffer:request', listener) + }, + + sendSerializedBuffer: ( + requestId: string, + snapshot: { data: string; cols: number; rows: number; lastTitle?: string } | null + ): void => { + ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) + }, + + // Why: pre-signal handshake — renderer declares it will own the serializer + // for `paneKey` BEFORE issuing pty:spawn so the cooperation gate in main + // can suppress the daemon-snapshot seed. Returns a generation token that + // the renderer must echo on settle/clear so paneKey-reuse during teardown + // cannot defeat the pre-signal. See docs/mobile-prefer-renderer-scrollback.md. + declarePendingPaneSerializer: (paneKey: string): Promise => + ipcRenderer.invoke('pty:declarePendingPaneSerializer', { paneKey }), + + settlePaneSerializer: (paneKey: string, gen: number): Promise => + ipcRenderer.invoke('pty:settlePaneSerializer', { paneKey, gen }), + + clearPendingPaneSerializer: (paneKey: string, gen: number): Promise => + ipcRenderer.invoke('pty:clearPendingPaneSerializer', { paneKey, gen }), + + management: { + listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), + killAll: () => ipcRenderer.invoke('pty:management:killAll'), + killOne: (args: { sessionId: string }) => ipcRenderer.invoke('pty:management:killOne', args), + restart: () => ipcRenderer.invoke('pty:management:restart') } }, @@ -385,17 +537,34 @@ const api = { repoSlug: (args: { repoPath: string }): Promise => ipcRenderer.invoke('gh:repoSlug', args), - prForBranch: (args: { repoPath: string; branch: string }): Promise => - ipcRenderer.invoke('gh:prForBranch', args), + prForBranch: (args: { + repoPath: string + branch: string + linkedPRNumber?: number | null + }): Promise => ipcRenderer.invoke('gh:prForBranch', args), issue: (args: { repoPath: string; number: number }): Promise => ipcRenderer.invoke('gh:issue', args), - workItem: (args: { repoPath: string; number: number }): Promise => - ipcRenderer.invoke('gh:workItem', args), + workItem: (args: { + repoPath: string + number: number + type?: 'issue' | 'pr' + }): Promise => ipcRenderer.invoke('gh:workItem', args), - workItemDetails: (args: { repoPath: string; number: number }): Promise => - ipcRenderer.invoke('gh:workItemDetails', args), + workItemByOwnerRepo: (args: { + repoPath: string + owner: string + repo: string + number: number + type: 'issue' | 'pr' + }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), + + workItemDetails: (args: { + repoPath: string + number: number + type?: 'issue' | 'pr' + }): Promise => ipcRenderer.invoke('gh:workItemDetails', args), prFileContents: (args: { repoPath: string @@ -425,7 +594,8 @@ const api = { limit?: number query?: string before?: string - }): Promise => ipcRenderer.invoke('gh:listWorkItems', args), + }): Promise>> => + ipcRenderer.invoke('gh:listWorkItems', args), prChecks: (args: { repoPath: string @@ -499,7 +669,65 @@ const api = { ipcRenderer.invoke('gh:listAssignableUsers', args), checkOrcaStarred: (): Promise => ipcRenderer.invoke('gh:checkOrcaStarred'), - starOrca: (): Promise => ipcRenderer.invoke('gh:starOrca') + starOrca: (): Promise => ipcRenderer.invoke('gh:starOrca'), + + // Why: rate_limit is exempt from rate-limit accounting, but we still pass + // `force` through so callers can bust the 30s in-process cache after a + // known-expensive op (e.g. after ProjectPicker discovery). + rateLimit: (args?: { force?: boolean }): Promise => + ipcRenderer.invoke('gh:rateLimit', args), + + diagnoseAuth: (): Promise => ipcRenderer.invoke('gh:diagnoseAuth'), + + // ── ProjectV2 (GitHub Projects) ─────────────────────────────────── + listAccessibleProjects: (): Promise => + ipcRenderer.invoke('gh:listAccessibleProjects'), + resolveProjectRef: (args: ResolveProjectRefArgs): Promise => + ipcRenderer.invoke('gh:resolveProjectRef', args), + listProjectViews: (args: ListProjectViewsArgs): Promise => + ipcRenderer.invoke('gh:listProjectViews', args), + getProjectViewTable: (args: GetProjectViewTableArgs): Promise => + ipcRenderer.invoke('gh:getProjectViewTable', args), + projectWorkItemDetailsBySlug: ( + args: ProjectWorkItemDetailsBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:projectWorkItemDetailsBySlug', args), + updateProjectItemField: ( + args: UpdateProjectItemFieldArgs + ): Promise => + ipcRenderer.invoke('gh:updateProjectItemField', args), + clearProjectItemField: ( + args: ClearProjectItemFieldArgs + ): Promise => ipcRenderer.invoke('gh:clearProjectItemField', args), + updateIssueBySlug: (args: UpdateIssueBySlugArgs): Promise => + ipcRenderer.invoke('gh:updateIssueBySlug', args), + updatePullRequestBySlug: ( + args: UpdatePullRequestBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:updatePullRequestBySlug', args), + addIssueCommentBySlug: ( + args: AddIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:addIssueCommentBySlug', args), + updateIssueCommentBySlug: ( + args: UpdateIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:updateIssueCommentBySlug', args), + deleteIssueCommentBySlug: ( + args: DeleteIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:deleteIssueCommentBySlug', args), + listLabelsBySlug: (args: ListLabelsBySlugArgs): Promise => + ipcRenderer.invoke('gh:listLabelsBySlug', args), + listAssignableUsersBySlug: ( + args: ListAssignableUsersBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:listAssignableUsersBySlug', args), + listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs): Promise => + ipcRenderer.invoke('gh:listIssueTypesBySlug', args), + updateIssueTypeBySlug: ( + args: UpdateIssueTypeBySlugArgs + ): Promise => ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) }, linear: { @@ -572,6 +800,20 @@ const api = { forceShow: (): Promise => ipcRenderer.invoke('star-nag:forceShow') }, + // Why: telemetry uses a loose untyped surface at the preload boundary on + // purpose — the main-side validator (src/main/telemetry/validator.ts) is + // the single enforcement point, not the preload types. The renderer gets + // typed `track()` / `setOptIn()` wrappers via + // src/renderer/src/lib/telemetry.ts, which is what call sites import. + telemetryTrack: (name: string, props: Record): Promise => + ipcRenderer.invoke('telemetry:track', name, props), + telemetrySetOptIn: (optedIn: boolean): Promise => + ipcRenderer.invoke('telemetry:setOptIn', optedIn), + telemetryAcknowledgeBanner: (): Promise => + ipcRenderer.invoke('telemetry:acknowledgeBanner'), + telemetryGetConsentState: (): Promise => + ipcRenderer.invoke('telemetry:getConsentState'), + settings: { get: (): Promise => ipcRenderer.invoke('settings:get'), @@ -581,7 +823,16 @@ const api = { listFonts: (): Promise => ipcRenderer.invoke('settings:listFonts'), previewGhosttyImport: (): Promise => - ipcRenderer.invoke('settings:previewGhosttyImport') + ipcRenderer.invoke('settings:previewGhosttyImport'), + + onChanged: (callback: (updates: Record) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + updates: Record + ): void => callback(updates) + ipcRenderer.on('settings:changed', listener) + return () => ipcRenderer.removeListener('settings:changed', listener) + } }, codexAccounts: { @@ -623,6 +874,11 @@ const api = { ipcRenderer.invoke('agentHooks:cursorStatus') }, + agentTrust: { + markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }): Promise => + ipcRenderer.invoke('agentTrust:markTrusted', args) + }, + preflight: { check: (args?: { force?: boolean @@ -644,7 +900,74 @@ const api = { notifications: { dispatch: (args: Record): Promise => ipcRenderer.invoke('notifications:dispatch', args), - openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings') + openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings'), + playSound: async (options?: { force?: boolean }): Promise => { + try { + // Why: drop replays while the sound is still ringing. The "test" + // button bypasses with force so the user always hears a confirmation. + if (!options?.force && isNotificationSoundPlaying) { + return { played: false, reason: 'deduped' } + } + + const resolved = (await ipcRenderer.invoke( + 'notifications:resolveSoundPath' + )) as NotificationSoundPathResult + if (!resolved.ok) { + if (cachedNotificationSound) { + disposeCachedNotificationSound() + } + return { played: false, reason: resolved.reason } + } + + let entry = cachedNotificationSound + if (!entry || entry.path !== resolved.path) { + const sound = (await ipcRenderer.invoke( + 'notifications:loadSound' + )) as NotificationSoundDataResult + if (!sound.ok) { + disposeCachedNotificationSound() + return { played: false, reason: sound.reason } + } + const arrayBuffer = new ArrayBuffer(sound.data.byteLength) + new Uint8Array(arrayBuffer).set(sound.data) + const blob = new Blob([arrayBuffer], { type: sound.mimeType }) + disposeCachedNotificationSound() + const blobUrl = URL.createObjectURL(blob) + entry = { path: sound.path, blobUrl, audio: new Audio(blobUrl) } + cachedNotificationSound = entry + } + + const audio = entry.audio + // Why: restart-from-zero on every play so a burst of triggers replays + // the sound from the start instead of stacking overlapping copies. + // Matches GNOME canberra and VS Code AccessibilitySignalService. + audio.currentTime = 0 + isNotificationSoundPlaying = true + const release = (): void => { + isNotificationSoundPlaying = false + } + audio.addEventListener('ended', release, { once: true }) + audio.addEventListener('error', release, { once: true }) + try { + await audio.play() + } catch { + release() + return { played: false, reason: 'playback-failed' } + } + return { played: true } + } catch { + isNotificationSoundPlaying = false + return { played: false, reason: 'playback-failed' } + } + } + }, + + developerPermissions: { + getStatus: (): Promise => ipcRenderer.invoke('developerPermissions:getStatus'), + request: (args: { id: string }): Promise => + ipcRenderer.invoke('developerPermissions:request', args), + openSettings: (args: { id: string }): Promise => + ipcRenderer.invoke('developerPermissions:openSettings', args) }, shell: { @@ -662,6 +985,8 @@ const api = { pickImage: (): Promise => ipcRenderer.invoke('shell:pickImage'), + pickAudio: (): Promise => ipcRenderer.invoke('shell:pickAudio'), + pickDirectory: (args: { defaultPath?: string }): Promise => ipcRenderer.invoke('shell:pickDirectory', args), @@ -669,11 +994,22 @@ const api = { ipcRenderer.invoke('shell:copyFile', args) }, + sidekick: { + import: (): Promise => ipcRenderer.invoke('sidekick:import'), + importPetBundle: (): Promise => + ipcRenderer.invoke('sidekick:importPetBundle'), + read: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => + ipcRenderer.invoke('sidekick:read', id, fileName, kind), + delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => + ipcRenderer.invoke('sidekick:delete', id, fileName, kind) + }, + browser: { registerGuest: (args: { browserPageId: string workspaceId: string worktreeId: string + sessionProfileId?: string | null webContentsId: number }): Promise => ipcRenderer.invoke('browser:registerGuest', args), @@ -683,6 +1019,11 @@ const api = { openDevTools: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:openDevTools', args), + setViewportOverride: (args: { + browserPageId: string + override: BrowserViewportOverride | null + }): Promise => ipcRenderer.invoke('browser:setViewportOverride', args), + onGuestLoadFailed: ( callback: (args: { browserPageId: string @@ -1072,6 +1413,11 @@ const api = { connectionId?: string }): Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> => ipcRenderer.invoke('fs:readFile', args), + listMarkdownDocuments: (args: { + rootPath: string + connectionId?: string + }): Promise<{ filePath: string; relativePath: string; basename: string; name: string }[]> => + ipcRenderer.invoke('fs:listMarkdownDocuments', args), writeFile: (args: { filePath: string content: string @@ -1114,6 +1460,7 @@ const api = { importExternalPaths: (args: { sourcePaths: string[] destDir: string + connectionId?: string }): Promise<{ results: ( | { @@ -1135,6 +1482,18 @@ const api = { } )[] }> => ipcRenderer.invoke('fs:importExternalPaths', args), + resolveDroppedPathsForAgent: (args: { + paths: string[] + worktreePath: string + connectionId?: string + }): Promise<{ + resolvedPaths: string[] + skipped: { + sourcePath: string + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + }[] + failed: { sourcePath: string; reason: string }[] + }> => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:watchWorktree', args), unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => @@ -1156,6 +1515,7 @@ const api = { worktreePath: string filePath: string staged: boolean + compareAgainstHead?: boolean connectionId?: string }): Promise => ipcRenderer.invoke('git:diff', args), branchCompare: (args: { @@ -1170,6 +1530,11 @@ const api = { oldPath?: string connectionId?: string }): Promise => ipcRenderer.invoke('git:branchDiff', args), + commit: (args: { + worktreePath: string + message: string + connectionId?: string + }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:commit', args), stage: (args: { worktreePath: string filePath: string @@ -1231,13 +1596,8 @@ const api = { ipcRenderer.on('ui:openQuickOpen', listener) return () => ipcRenderer.removeListener('ui:openQuickOpen', listener) }, - onOpenNewWorkspace: (callback: (tab: 'quick' | 'create-from') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, tab: 'quick' | 'create-from') => { - // Why: older main-process builds may send this event without a payload - // — default to 'quick' so the preload contract stays forward-compatible - // during a partial rollout where only one side has shipped the tab arg. - callback(tab ?? 'quick') - } + onOpenNewWorkspace: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openNewWorkspace', listener) return () => ipcRenderer.removeListener('ui:openNewWorkspace', listener) }, @@ -1260,11 +1620,16 @@ const api = { return () => ipcRenderer.removeListener('ui:newBrowserTab', listener) }, onRequestTabCreate: ( - callback: (data: { requestId: string; url: string; worktreeId?: string }) => void + callback: (data: { + requestId: string + url: string + worktreeId?: string + sessionProfileId?: string + }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { requestId: string; url: string; worktreeId?: string } + data: { requestId: string; url: string; worktreeId?: string; sessionProfileId?: string } ) => callback(data) ipcRenderer.on('browser:requestTabCreate', listener) return () => ipcRenderer.removeListener('browser:requestTabCreate', listener) @@ -1276,6 +1641,19 @@ const api = { }): void => { ipcRenderer.send('browser:tabCreateReply', reply) }, + onRequestTabSetProfile: ( + callback: (data: { requestId: string; browserPageId: string; profileId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { requestId: string; browserPageId: string; profileId: string } + ) => callback(data) + ipcRenderer.on('browser:requestTabSetProfile', listener) + return () => ipcRenderer.removeListener('browser:requestTabSetProfile', listener) + }, + replyTabSetProfile: (reply: { requestId: string; error?: string }): void => { + ipcRenderer.send('browser:tabSetProfileReply', reply) + }, onRequestTabClose: ( callback: (data: { requestId: string; tabId: string | null; worktreeId?: string }) => void ): (() => void) => { @@ -1324,6 +1702,11 @@ const api = { ipcRenderer.on('ui:switchTab', listener) return () => ipcRenderer.removeListener('ui:switchTab', listener) }, + onSwitchTabAcrossAllTypes: (callback: (direction: 1 | -1) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) + ipcRenderer.on('ui:switchTabAcrossAllTypes', listener) + return () => ipcRenderer.removeListener('ui:switchTabAcrossAllTypes', listener) + }, onSwitchTerminalTab: (callback: (direction: 1 | -1) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) ipcRenderer.on('ui:switchTerminalTab', listener) @@ -1344,6 +1727,7 @@ const api = { repoId: string worktreeId: string setup?: { runnerScriptPath: string; envVars: Record } + startup?: { command: string; env?: Record } }) => void ): (() => void) => { const listener = ( @@ -1352,6 +1736,7 @@ const api = { repoId: string worktreeId: string setup?: { runnerScriptPath: string; envVars: Record } + startup?: { command: string; env?: Record } } ) => callback(data) ipcRenderer.on('ui:activateWorktree', listener) @@ -1440,6 +1825,12 @@ const api = { ipcRenderer.on('ui:closeTerminal', listener) return () => ipcRenderer.removeListener('ui:closeTerminal', listener) }, + onSleepWorktree: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('ui:sleepWorktree', listener) + return () => ipcRenderer.removeListener('ui:sleepWorktree', listener) + }, onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => callback(direction) @@ -1555,7 +1946,43 @@ const api = { runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise => ipcRenderer.invoke('runtime:syncWindowGraph', graph), - getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus') + getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus'), + getTerminalFitOverrides: (): Promise< + { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] + > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), + restoreTerminalFit: (ptyId: string): Promise<{ restored: boolean }> => + ipcRenderer.invoke('runtime:restoreTerminalFit', { ptyId }), + onTerminalFitOverrideChanged: ( + callback: (event: { + ptyId: string + mode: 'mobile-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { ptyId: string; mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number } + ) => callback(data) + ipcRenderer.on('runtime:terminalFitOverrideChanged', listener) + return () => ipcRenderer.removeListener('runtime:terminalFitOverrideChanged', listener) + }, + onTerminalDriverChanged: ( + callback: (event: { + ptyId: string + driver: { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + ptyId: string + driver: { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string } + } + ) => callback(data) + ipcRenderer.on('runtime:terminalDriverChanged', listener) + return () => ipcRenderer.removeListener('runtime:terminalDriverChanged', listener) + } }, rateLimits: { @@ -1563,6 +1990,10 @@ const api = { refresh: (): Promise => ipcRenderer.invoke('rateLimits:refresh'), setPollingInterval: (ms: number): Promise => ipcRenderer.invoke('rateLimits:setPollingInterval', ms), + fetchInactiveClaudeAccounts: (): Promise => + ipcRenderer.invoke('rateLimits:fetchInactiveClaudeAccounts'), + fetchInactiveCodexAccounts: (): Promise => + ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), onUpdate: (callback: (state: RateLimitState) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state) ipcRenderer.on('rateLimits:update', listener) @@ -1595,6 +2026,9 @@ const api = { getState: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:getState', args), + needsPassphrasePrompt: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:needsPassphrasePrompt', args), + testConnection: (args: { targetId: string }): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> => @@ -1702,6 +2136,35 @@ const api = { getConfig: () => preloadE2EConfig }, + mobile: { + listNetworkInterfaces: (): Promise<{ + interfaces: { name: string; address: string }[] + }> => ipcRenderer.invoke('mobile:listNetworkInterfaces'), + + getPairingQR: (args?: { + address?: string + }): Promise< + | { available: false } + | { + available: true + qrDataUrl: string + pairingUrl: string + endpoint: string + deviceId: string + } + > => ipcRenderer.invoke('mobile:getPairingQR', args), + + listDevices: (): Promise<{ + devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] + }> => ipcRenderer.invoke('mobile:listDevices'), + + revokeDevice: (args: { deviceId: string }): Promise<{ revoked: boolean }> => + ipcRenderer.invoke('mobile:revokeDevice', args), + + isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> => + ipcRenderer.invoke('mobile:isWebSocketReady') + }, + agentStatus: { /** Listen for agent status updates forwarded from native hook receivers. */ onSet: ( diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts new file mode 100644 index 00000000000..d114f5837a0 --- /dev/null +++ b/src/relay/fs-handler-file-read.ts @@ -0,0 +1,38 @@ +import { readFile, stat } from 'fs/promises' +import { extname } from 'path' +import { + BINARY_PROBE_BYTES, + IMAGE_MIME_TYPES, + MAX_PREVIEWABLE_BINARY_SIZE, + MAX_TEXT_FILE_SIZE, + isBinaryBuffer, + isBinaryFilePrefix +} from './fs-handler-utils' + +export async function readRelayFileContent(filePath: string) { + const stats = await stat(filePath) + const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] + const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE + if (stats.size > sizeLimit) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` + ) + } + + if (mimeType) { + const buffer = await readFile(filePath) + return { content: buffer.toString('base64'), isBinary: true, isImage: true, mimeType } + } + + // Why: SSH reads serialize through bounded relay frames; probing large + // unknown files prevents binary archives from consuming frame budget. + if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { + return { content: '', isBinary: true } + } + + const buffer = await readFile(filePath) + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { content: buffer.toString('utf-8'), isBinary: false } +} diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index 4d0baffdc91..d49d082b0ea 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -6,6 +6,7 @@ * so they are straightforward to test independently. */ import { spawn, execFile } from 'child_process' +import { open } from 'fs/promises' import { buildRgArgs, createAccumulator, @@ -17,9 +18,13 @@ import type { SearchResult as SharedSearchResult } from '../shared/types' // ─── Constants ─────────────────────────────────────────────────────── -export const MAX_FILE_SIZE = 5 * 1024 * 1024 +// Why: remote reads still travel through bounded JSON-RPC frames, but matching +// the old 5MB search cap would block common JSON/log files before Monaco's +// large-file optimizations can handle them. +export const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 // 10MB for relayed binaries (base64 → ~13.3MB frame payload at 16MB relay cap) export const MAX_PREVIEWABLE_BINARY_SIZE = 10 * 1024 * 1024 +export const BINARY_PROBE_BYTES = 8192 export const SEARCH_TIMEOUT_MS = SHARED_SEARCH_TIMEOUT_MS export const DEFAULT_MAX_RESULTS = 2000 @@ -47,6 +52,17 @@ export function isBinaryBuffer(buffer: Buffer): boolean { return false } +export async function isBinaryFilePrefix(filePath: string): Promise { + const handle = await open(filePath, 'r') + try { + const probe = Buffer.alloc(BINARY_PROBE_BYTES) + const { bytesRead } = await handle.read(probe, 0, probe.length, 0) + return isBinaryBuffer(probe.subarray(0, bytesRead)) + } finally { + await handle.close() + } +} + // ─── Search types ──────────────────────────────────────────────────── export type SearchOptions = { diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index 6923dd8c922..aef0fea7ff8 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -132,6 +132,33 @@ describe('FsHandler', () => { expect(result.isBinary).toBe(false) }) + it('readFile returns text files larger than the old 5MB guard', async () => { + const filePath = path.join(tmpDir, 'large.json') + const content = 'a'.repeat(6 * 1024 * 1024) + writeFileSync(filePath, content) + + const result = (await dispatcher.callRequest('fs.readFile', { filePath })) as { + content: string + isBinary: boolean + } + expect(result.content).toBe(content) + expect(result.isBinary).toBe(false) + }) + + it('readFile returns binary marker for large unknown binary files', async () => { + const filePath = path.join(tmpDir, 'archive.bin') + const content = Buffer.alloc(6 * 1024 * 1024, 0x61) + content[0] = 0x00 + writeFileSync(filePath, content) + + const result = (await dispatcher.callRequest('fs.readFile', { filePath })) as { + content: string + isBinary: boolean + } + expect(result.content).toBe('') + expect(result.isBinary).toBe(true) + }) + it('readFile returns base64 for image files', async () => { const filePath = path.join(tmpDir, 'test.png') writeFileSync(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])) @@ -150,8 +177,7 @@ describe('FsHandler', () => { it('readFile throws for files exceeding size limit', async () => { const filePath = path.join(tmpDir, 'huge.txt') - // Write 6MB file - writeFileSync(filePath, Buffer.alloc(6 * 1024 * 1024)) + writeFileSync(filePath, Buffer.alloc(11 * 1024 * 1024, 'a')) await expect(dispatcher.callRequest('fs.readFile', { filePath })).rejects.toThrow( 'File too large' diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 4a9b42148c8..413fb53f7fc 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -1,26 +1,10 @@ -import { - readdir, - readFile, - writeFile, - stat, - lstat, - mkdir, - rename, - cp, - rm, - realpath -} from 'fs/promises' -import { extname } from 'path' +import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'fs/promises' import { execFile } from 'child_process' import type { RelayDispatcher, RequestContext } from './dispatcher' import type { RelayContext } from './context' import { expandTilde } from './context' import { - MAX_FILE_SIZE, - MAX_PREVIEWABLE_BINARY_SIZE, DEFAULT_MAX_RESULTS, - IMAGE_MIME_TYPES, - isBinaryBuffer, searchWithRg, listFilesWithRg, checkRgAvailable @@ -29,6 +13,7 @@ import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback' import { listFilesWithReaddir } from './fs-handler-readdir-fallback' import { buildExcludePathPrefixes } from '../shared/quick-open-filter' import { buildInstallRgMessage } from './fs-handler-install-rg' +import { readRelayFileContent } from './fs-handler-file-read' type WatchState = { rootPath: string @@ -86,23 +71,7 @@ export class FsHandler { private async readFile(params: Record) { const filePath = expandTilde(params.filePath as string) await this.context.validatePathResolved(filePath) - const stats = await stat(filePath) - const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_FILE_SIZE - if (stats.size > sizeLimit) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` - ) - } - - const buffer = await readFile(filePath) - if (mimeType) { - return { content: buffer.toString('base64'), isBinary: true, isImage: true, mimeType } - } - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - return { content: buffer.toString('utf-8'), isBinary: false } + return readRelayFileContent(filePath) } private async writeFile(params: Record) { diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index b437ff446ae..8d89a85d356 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -73,7 +73,8 @@ export async function computeDiff( git: GitBufferExec, worktreePath: string, filePath: string, - staged: boolean + staged: boolean, + compareAgainstHead = false ) { let originalContent = '' let modifiedContent = '' @@ -90,7 +91,9 @@ export async function computeDiff( modifiedContent = right.content modifiedIsBinary = right.isBinary } else { - const left = await readUnstagedLeft(git, worktreePath, filePath) + const left = compareAgainstHead + ? await readBlobAtOid(git, worktreePath, 'HEAD', filePath) + : await readUnstagedLeft(git, worktreePath, filePath) originalContent = left.content originalIsBinary = left.isBinary diff --git a/src/relay/git-handler-staging.test.ts b/src/relay/git-handler-staging.test.ts new file mode 100644 index 00000000000..9deae4e1971 --- /dev/null +++ b/src/relay/git-handler-staging.test.ts @@ -0,0 +1,126 @@ +/** + * Tests for GitHandler commit and bulk-staging operations. + * + * Why: split from git-handler.test.ts to stay under the oxlint max-lines (300) limit. + */ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import * as path from 'path' +import * as fs from 'fs/promises' +import { mkdtempSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { execFileSync } from 'child_process' +import { GitHandler } from './git-handler' +import { RelayContext } from './context' +import { + createMockDispatcher, + gitInit, + gitCommit, + type MockDispatcher, + type RelayDispatcher +} from './git-handler-test-setup' + +describe('GitHandler — commit & staging', () => { + let dispatcher: MockDispatcher + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-staging-')) + dispatcher = createMockDispatcher() + const ctx = new RelayContext() + ctx.registerRoot(tmpDir) + // eslint-disable-next-line no-new + new GitHandler(dispatcher as unknown as RelayDispatcher, ctx) + }) + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + describe('commit', () => { + it('commits staged changes and returns success', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'content') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') + execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) + + const result = (await dispatcher.callRequest('git.commit', { + worktreePath: tmpDir, + message: 'feat: relay commit' + })) as { success: boolean; error?: string } + + expect(result).toEqual({ success: true }) + const latestMessage = execFileSync('git', ['log', '-1', '--format=%s'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + expect(latestMessage).toBe('feat: relay commit') + }) + + // Why: covers the error-extraction path in commitChangesRelay + // (git-handler-worktree-ops.ts). Running `git commit` with nothing staged + // exits non-zero and writes a "nothing to commit" message; we assert the + // relay surfaces a non-empty error string so the UI can display it. + it('returns a non-empty error when the commit fails', async () => { + gitInit(tmpDir) + + const result = (await dispatcher.callRequest('git.commit', { + worktreePath: tmpDir, + message: 'no changes' + })) as { success: boolean; error?: string } + + expect(result.success).toBe(false) + expect(typeof result.error).toBe('string') + expect((result.error ?? '').length).toBeGreaterThan(0) + // Why: exact phrasing can vary across git versions, so match the + // stable substring "nothing" rather than the full "nothing to commit". + expect((result.error ?? '').toLowerCase()).toContain('nothing') + }) + }) + + describe('bulkStage and bulkUnstage', () => { + it('stages multiple files', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'a.txt'), 'a') + writeFileSync(path.join(tmpDir, 'b.txt'), 'b') + gitCommit(tmpDir, 'initial') + + writeFileSync(path.join(tmpDir, 'a.txt'), 'a-modified') + writeFileSync(path.join(tmpDir, 'b.txt'), 'b-modified') + + await dispatcher.callRequest('git.bulkStage', { + worktreePath: tmpDir, + filePaths: ['a.txt', 'b.txt'] + }) + + const output = execFileSync('git', ['diff', '--cached', '--name-only'], { + cwd: tmpDir, + encoding: 'utf-8' + }) + expect(output).toContain('a.txt') + expect(output).toContain('b.txt') + }) + + it('unstages multiple files', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'a.txt'), 'a') + writeFileSync(path.join(tmpDir, 'b.txt'), 'b') + gitCommit(tmpDir, 'initial') + + writeFileSync(path.join(tmpDir, 'a.txt'), 'changed') + writeFileSync(path.join(tmpDir, 'b.txt'), 'changed') + execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' }) + + await dispatcher.callRequest('git.bulkUnstage', { + worktreePath: tmpDir, + filePaths: ['a.txt', 'b.txt'] + }) + + const output = execFileSync('git', ['diff', '--cached', '--name-only'], { + cwd: tmpDir, + encoding: 'utf-8' + }) + expect(output.trim()).toBe('') + }) + }) +}) diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts new file mode 100644 index 00000000000..5f55d861449 --- /dev/null +++ b/src/relay/git-handler-status-ops.ts @@ -0,0 +1,77 @@ +/** + * Status and conflict-detection operations extracted from git-handler.ts. + * + * Why: oxlint max-lines (300) requires splitting large files. + * These functions are pure data operations on git state — no class coupling. + */ +import * as path from 'path' +import { existsSync } from 'fs' +import { readFile } from 'fs/promises' +import { parseStatusOutput, parseUnmergedEntry } from './git-handler-utils' +import type { GitExec } from './git-handler-ops' + +export async function resolveGitDir(worktreePath: string): Promise { + const dotGitPath = path.join(worktreePath, '.git') + try { + const contents = await readFile(dotGitPath, 'utf-8') + const match = contents.match(/^gitdir:\s*(.+)\s*$/m) + if (match) { + return path.resolve(worktreePath, match[1]) + } + } catch { + // .git is a directory, not a file + } + return dotGitPath +} + +export async function detectConflictOperation(worktreePath: string): Promise { + const gitDir = await resolveGitDir(worktreePath) + try { + if (existsSync(path.join(gitDir, 'MERGE_HEAD'))) { + return 'merge' + } + if ( + existsSync(path.join(gitDir, 'rebase-merge')) || + existsSync(path.join(gitDir, 'rebase-apply')) + ) { + return 'rebase' + } + if (existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'))) { + return 'cherry-pick' + } + } catch { + // fs error — treat as no conflict operation + } + return 'unknown' +} + +export async function getStatusOp( + git: GitExec, + validatePath: (p: string) => void, + params: Record +): Promise<{ entries: Record[]; conflictOperation: string }> { + const worktreePath = params.worktreePath as string + validatePath(worktreePath) + const conflictOperation = await detectConflictOperation(worktreePath) + const entries: Record[] = [] + + try { + const { stdout } = await git( + ['status', '--porcelain=v2', '--untracked-files=all'], + worktreePath + ) + const parsed = parseStatusOutput(stdout) + entries.push(...parsed.entries) + + for (const uLine of parsed.unmergedLines) { + const entry = parseUnmergedEntry(worktreePath, uLine) + if (entry) { + entries.push(entry) + } + } + } catch { + // not a git repo or git not available + } + + return { entries, conflictOperation } +} diff --git a/src/relay/git-handler-test-setup.ts b/src/relay/git-handler-test-setup.ts new file mode 100644 index 00000000000..8e5dc7ef0c3 --- /dev/null +++ b/src/relay/git-handler-test-setup.ts @@ -0,0 +1,76 @@ +/** + * Shared test utilities for git-handler tests. + * + * Why: oxlint max-lines (300) requires splitting large test suites. + * This module exports the mock dispatcher factory and git helpers + * so multiple test files can reuse them without duplication. + */ +import { vi } from 'vitest' +import { execFileSync } from 'child_process' +import type { RelayDispatcher } from './dispatcher' + +// Why: declare an explicit type so the inferred return type of +// createMockDispatcher doesn't transitively reference `@vitest/spy`'s +// internal `Procedure` type (from `vi.fn(...)`). Without this annotation, +// TS2883 fires under `pnpm run tc:node` because the generated .d.ts would +// need to name a type that isn't portably resolvable from this module. +export type MockDispatcher = { + onRequest: ( + method: string, + handler: ( + params: Record, + context: { isStale: () => boolean } + ) => Promise + ) => void + onNotification: (method: string, handler: (params: Record) => void) => void + notify: (method: string, params?: Record) => void + _requestHandlers: Map< + string, + (params: Record, context: { isStale: () => boolean }) => Promise + > + callRequest(method: string, params?: Record): Promise +} + +export function createMockDispatcher(): MockDispatcher { + const requestHandlers = new Map< + string, + (params: Record, context: { isStale: () => boolean }) => Promise + >() + + return { + onRequest: vi.fn( + ( + method: string, + handler: ( + params: Record, + context: { isStale: () => boolean } + ) => Promise + ) => { + requestHandlers.set(method, handler) + } + ), + onNotification: vi.fn(), + notify: vi.fn(), + _requestHandlers: requestHandlers, + async callRequest(method: string, params: Record = {}) { + const handler = requestHandlers.get(method) + if (!handler) { + throw new Error(`No handler for ${method}`) + } + return handler(params, { isStale: () => false }) + } + } +} + +export function gitInit(dir: string): void { + execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'pipe' }) +} + +export function gitCommit(dir: string, message: string): void { + execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' }) +} + +export type { RelayDispatcher } diff --git a/src/relay/git-handler-worktree-ops.ts b/src/relay/git-handler-worktree-ops.ts new file mode 100644 index 00000000000..66ea18398d5 --- /dev/null +++ b/src/relay/git-handler-worktree-ops.ts @@ -0,0 +1,110 @@ +/** + * Worktree management and commit operations for the relay git handler. + * + * Why: extracted from git-handler-ops.ts to keep all relay files under + * the oxlint max-lines (300) limit. + */ +import * as path from 'path' +import type { GitExec } from './git-handler-ops' + +// ─── Worktree management ───────────────────────────────────────────── + +export async function addWorktreeOp( + git: GitExec, + validatePath: (p: string) => void, + params: Record +): Promise { + const repoPath = params.repoPath as string + validatePath(repoPath) + const branchName = params.branchName as string + const targetDir = params.targetDir as string + validatePath(targetDir) + const base = params.base as string | undefined + const track = params.track as boolean | undefined + + // Why: a branchName starting with '-' would be interpreted as a git flag, + // potentially changing the command's semantics (e.g. "--detach"). + if (branchName.startsWith('-') || (base && base.startsWith('-'))) { + throw new Error('Branch name and base ref must not start with "-"') + } + + const args = ['worktree', 'add'] + if (track) { + args.push('--track') + } + args.push('-b', branchName, targetDir) + if (base) { + args.push(base) + } + + await git(args, repoPath) +} + +export async function removeWorktreeOp( + git: GitExec, + validatePath: (p: string) => void, + params: Record +): Promise { + const worktreePath = params.worktreePath as string + validatePath(worktreePath) + const force = params.force as boolean | undefined + + let repoPath = worktreePath + try { + const { stdout } = await git(['rev-parse', '--git-common-dir'], worktreePath) + const commonDir = stdout.trim() + if (commonDir && commonDir !== '.git') { + repoPath = path.resolve(worktreePath, commonDir, '..') + } + } catch { + // fall through with worktreePath as repo + } + + const args = ['worktree', 'remove'] + if (force) { + args.push('--force') + } + args.push(worktreePath) + await git(args, repoPath) + await git(['worktree', 'prune'], repoPath) +} + +// ─── Commit ────────────────────────────────────────────────────────── + +export async function commitChangesRelay( + git: GitExec, + worktreePath: string, + message: string +): Promise<{ success: boolean; error?: string }> { + // Why: defense-in-depth. The IPC handler at src/main/ipc/filesystem.ts validates + // the message, but a relay caller (future automation, or an SSH client connecting + // to the relay directly) could bypass that path. Reject empty/whitespace messages + // here so we surface a clear error instead of git's opaque failure. + if (typeof message !== 'string' || message.trim().length === 0) { + return { success: false, error: 'Commit message is required' } + } + + try { + await git(['commit', '-m', message], worktreePath) + return { success: true } + } catch (error) { + // Why: surface whichever channel carries the useful message. Pre-commit/GPG + // hook failures write to stderr; "nothing to commit, working tree clean" + // writes to stdout. Try stderr first, fall back to stdout, then error.message. + // Mirrors commitChanges in src/main/git/status.ts — keep the two paths in sync. + const readStringField = (field: string): string | null => { + if (typeof error === 'object' && error && field in error) { + const v = (error as Record)[field] + if (typeof v === 'string' && v.length > 0) { + return v + } + } + return null + } + const errorMessage = + readStringField('stderr') ?? + readStringField('stdout') ?? + (error instanceof Error ? error.message : 'Commit failed') + return { success: false, error: errorMessage } + } +} diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 1cf6ff160fe..9541ffcca0a 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -1,51 +1,21 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { describe, expect, it, beforeEach, afterEach } from 'vitest' import { GitHandler } from './git-handler' import { RelayContext } from './context' -import type { RelayDispatcher } from './dispatcher' import * as fs from 'fs/promises' import * as path from 'path' import { mkdtempSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { execFileSync } from 'child_process' - -function createMockDispatcher() { - const requestHandlers = new Map) => Promise>() - const notificationHandlers = new Map) => void>() - - return { - onRequest: vi.fn( - (method: string, handler: (params: Record) => Promise) => { - requestHandlers.set(method, handler) - } - ), - onNotification: vi.fn((method: string, handler: (params: Record) => void) => { - notificationHandlers.set(method, handler) - }), - notify: vi.fn(), - _requestHandlers: requestHandlers, - async callRequest(method: string, params: Record = {}) { - const handler = requestHandlers.get(method) - if (!handler) { - throw new Error(`No handler for ${method}`) - } - return handler(params) - } - } -} - -function gitInit(dir: string): void { - execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }) - execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'pipe' }) - execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'pipe' }) -} - -function gitCommit(dir: string, message: string): void { - execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' }) - execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' }) -} +import { + createMockDispatcher, + gitInit, + gitCommit, + type MockDispatcher, + type RelayDispatcher +} from './git-handler-test-setup' describe('GitHandler', () => { - let dispatcher: ReturnType + let dispatcher: MockDispatcher let tmpDir: string beforeEach(() => { @@ -53,6 +23,7 @@ describe('GitHandler', () => { dispatcher = createMockDispatcher() const ctx = new RelayContext() ctx.registerRoot(tmpDir) + // eslint-disable-next-line no-new new GitHandler(dispatcher as unknown as RelayDispatcher, ctx) }) @@ -63,6 +34,7 @@ describe('GitHandler', () => { it('registers all expected handlers', () => { const methods = Array.from(dispatcher._requestHandlers.keys()) expect(methods).toContain('git.status') + expect(methods).toContain('git.commit') expect(methods).toContain('git.diff') expect(methods).toContain('git.stage') expect(methods).toContain('git.unstage') @@ -75,6 +47,8 @@ describe('GitHandler', () => { expect(methods).toContain('git.listWorktrees') expect(methods).toContain('git.addWorktree') expect(methods).toContain('git.removeWorktree') + expect(methods).toContain('git.exec') + expect(methods).toContain('git.isGitRepo') }) describe('status', () => { @@ -284,50 +258,4 @@ describe('GitHandler', () => { expect(result[0].isMainWorktree).toBe(true) }) }) - - describe('bulkStage and bulkUnstage', () => { - it('stages multiple files', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'a.txt'), 'a') - writeFileSync(path.join(tmpDir, 'b.txt'), 'b') - gitCommit(tmpDir, 'initial') - - writeFileSync(path.join(tmpDir, 'a.txt'), 'a-modified') - writeFileSync(path.join(tmpDir, 'b.txt'), 'b-modified') - - await dispatcher.callRequest('git.bulkStage', { - worktreePath: tmpDir, - filePaths: ['a.txt', 'b.txt'] - }) - - const output = execFileSync('git', ['diff', '--cached', '--name-only'], { - cwd: tmpDir, - encoding: 'utf-8' - }) - expect(output).toContain('a.txt') - expect(output).toContain('b.txt') - }) - - it('unstages multiple files', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'a.txt'), 'a') - writeFileSync(path.join(tmpDir, 'b.txt'), 'b') - gitCommit(tmpDir, 'initial') - - writeFileSync(path.join(tmpDir, 'a.txt'), 'changed') - writeFileSync(path.join(tmpDir, 'b.txt'), 'changed') - execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' }) - - await dispatcher.callRequest('git.bulkUnstage', { - worktreePath: tmpDir, - filePaths: ['a.txt', 'b.txt'] - }) - - const output = execFileSync('git', ['diff', '--cached', '--name-only'], { - cwd: tmpDir, - encoding: 'utf-8' - }) - expect(output.trim()).toBe('') - }) - }) }) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 0875fb2d382..7f34fcfec83 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -1,23 +1,19 @@ import { execFile } from 'child_process' import { promisify } from 'util' -import { existsSync } from 'fs' -import { readFile, rm } from 'fs/promises' +import { rm } from 'fs/promises' import * as path from 'path' import type { RelayDispatcher } from './dispatcher' import type { RelayContext } from './context' import { expandTilde } from './context' -import { - parseStatusOutput, - parseUnmergedEntry, - parseBranchDiff, - parseWorktreeList -} from './git-handler-utils' +import { parseBranchDiff, parseWorktreeList } from './git-handler-utils' import { computeDiff, branchCompare as branchCompareOp, branchDiffEntries, validateGitExecArgs } from './git-handler-ops' +import { commitChangesRelay, addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops' +import { detectConflictOperation, getStatusOp } from './git-handler-status-ops' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 @@ -35,6 +31,7 @@ export class GitHandler { private registerHandlers(): void { this.dispatcher.onRequest('git.status', (p) => this.getStatus(p)) + this.dispatcher.onRequest('git.commit', (p) => this.commit(p)) this.dispatcher.onRequest('git.diff', (p) => this.getDiff(p)) this.dispatcher.onRequest('git.stage', (p) => this.stage(p)) this.dispatcher.onRequest('git.unstage', (p) => this.unstage(p)) @@ -73,66 +70,7 @@ export class GitHandler { } private async getStatus(params: Record) { - const worktreePath = params.worktreePath as string - this.context.validatePath(worktreePath) - const conflictOperation = await this.detectConflictOperation(worktreePath) - const entries: Record[] = [] - - try { - const { stdout } = await this.git( - ['status', '--porcelain=v2', '--untracked-files=all'], - worktreePath - ) - - const parsed = parseStatusOutput(stdout) - entries.push(...parsed.entries) - - for (const uLine of parsed.unmergedLines) { - const entry = parseUnmergedEntry(worktreePath, uLine) - if (entry) { - entries.push(entry) - } - } - } catch { - // Not a git repo or git not available - } - - return { entries, conflictOperation } - } - - private async detectConflictOperation(worktreePath: string): Promise { - const gitDir = await this.resolveGitDir(worktreePath) - try { - if (existsSync(path.join(gitDir, 'MERGE_HEAD'))) { - return 'merge' - } - if ( - existsSync(path.join(gitDir, 'rebase-merge')) || - existsSync(path.join(gitDir, 'rebase-apply')) - ) { - return 'rebase' - } - if (existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'))) { - return 'cherry-pick' - } - } catch { - // fs error - } - return 'unknown' - } - - private async resolveGitDir(worktreePath: string): Promise { - const dotGitPath = path.join(worktreePath, '.git') - try { - const contents = await readFile(dotGitPath, 'utf-8') - const match = contents.match(/^gitdir:\s*(.+)\s*$/m) - if (match) { - return path.resolve(worktreePath, match[1]) - } - } catch { - // .git is a directory - } - return dotGitPath + return getStatusOp(this.git.bind(this), this.context.validatePath.bind(this.context), params) } private async getDiff(params: Record) { @@ -146,8 +84,13 @@ export class GitHandler { if (rel.startsWith('..') || path.isAbsolute(rel)) { throw new Error(`Path "${filePath}" resolves outside the worktree`) } - const staged = params.staged as boolean - return computeDiff(this.gitBuffer.bind(this), worktreePath, filePath, staged) + return computeDiff( + this.gitBuffer.bind(this), + worktreePath, + filePath, + params.staged as boolean, + params.compareAgainstHead as boolean | undefined + ) } private async stage(params: Record) { @@ -157,6 +100,15 @@ export class GitHandler { await this.git(['add', '--', filePath], worktreePath) } + private async commit( + params: Record + ): Promise<{ success: boolean; error?: string }> { + const worktreePath = params.worktreePath as string + this.context.validatePath(worktreePath) + const message = params.message as string + return commitChangesRelay(this.git.bind(this), worktreePath, message) + } + private async unstage(params: Record) { const worktreePath = params.worktreePath as string this.context.validatePath(worktreePath) @@ -219,7 +171,7 @@ export class GitHandler { private async conflictOperation(params: Record) { const worktreePath = params.worktreePath as string this.context.validatePath(worktreePath) - return this.detectConflictOperation(worktreePath) + return detectConflictOperation(worktreePath) } private async branchCompare(params: Record) { @@ -296,54 +248,14 @@ export class GitHandler { } private async addWorktree(params: Record) { - const repoPath = params.repoPath as string - this.context.validatePath(repoPath) - const branchName = params.branchName as string - const targetDir = params.targetDir as string - this.context.validatePath(targetDir) - const base = params.base as string | undefined - const track = params.track as boolean | undefined - - // Why: a branchName starting with '-' would be interpreted as a git flag, - // potentially changing the command's semantics (e.g. "--detach"). - if (branchName.startsWith('-') || (base && base.startsWith('-'))) { - throw new Error('Branch name and base ref must not start with "-"') - } - - const args = ['worktree', 'add'] - if (track) { - args.push('--track') - } - args.push('-b', branchName, targetDir) - if (base) { - args.push(base) - } - - await this.git(args, repoPath) + return addWorktreeOp(this.git.bind(this), this.context.validatePath.bind(this.context), params) } private async removeWorktree(params: Record) { - const worktreePath = params.worktreePath as string - this.context.validatePath(worktreePath) - const force = params.force as boolean | undefined - - let repoPath = worktreePath - try { - const { stdout } = await this.git(['rev-parse', '--git-common-dir'], worktreePath) - const commonDir = stdout.trim() - if (commonDir && commonDir !== '.git') { - repoPath = path.resolve(worktreePath, commonDir, '..') - } - } catch { - // Fall through with worktreePath as repo - } - - const args = ['worktree', 'remove'] - if (force) { - args.push('--force') - } - args.push(worktreePath) - await this.git(args, repoPath) - await this.git(['worktree', 'prune'], repoPath) + return removeWorktreeOp( + this.git.bind(this), + this.context.validatePath.bind(this.context), + params + ) } } diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 486cc362f98..c204ac2958b 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -127,14 +127,20 @@ describe('PtyHandler', () => { }) it('terminates spawned PTY when request becomes stale before response', async () => { - const term = { ...mockPtyInstance, kill: vi.fn(), onData: vi.fn(), onExit: vi.fn() } + const killSpy = vi.fn() + const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() } mockPtySpawn.mockReturnValue(term) await dispatcher.callRequest('pty.spawn', {}, { isStale: () => true }) - expect(term.kill).toHaveBeenCalledWith('SIGTERM') + // Why: assert via the captured spy reference rather than term.kill because + // disposeManagedPty() neutralizes managed.pty.kill (replaces it with a + // no-op) on POSIX to close the UnixTerminal.destroy() → socket-close → + // SIGHUP-to-recycled-pid race. After the 5s timer fires, term.kill is the + // neutralized function, not the original spy. killSpy retains call history. + expect(killSpy).toHaveBeenCalledWith('SIGTERM') vi.advanceTimersByTime(5000) - expect(term.kill).toHaveBeenCalledWith('SIGKILL') + expect(killSpy).toHaveBeenCalledWith('SIGKILL') }) it('increments PTY ids on each spawn', async () => { @@ -403,7 +409,7 @@ describe('PtyHandler', () => { }) }) - it('dispose kills all PTYs', async () => { + it('dispose kills all PTYs with SIGKILL', async () => { const mockKill = vi.fn() mockPtySpawn.mockReturnValue({ ...mockPtyInstance, @@ -417,7 +423,11 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(2) handler.dispose() - expect(mockKill).toHaveBeenCalledWith('SIGTERM') + // Why: dispose uses SIGKILL (not SIGTERM) because the relay process is + // exiting. A SIGTERM-ignoring remote shell (editor with unsaved buffers, + // wedged process, uninterruptible sleep) would survive SIGTERM + immediate + // destroy() as an orphan on the remote host. SIGKILL is not ignorable. + expect(mockKill).toHaveBeenCalledWith('SIGKILL') expect(handler.activePtyCount).toBe(0) }) }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 14fe49484f3..1949db301c8 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -34,6 +34,41 @@ type ManagedPty = { buffered: string /** Timer for SIGKILL fallback after a graceful SIGTERM shutdown. */ killTimer?: ReturnType + /** True once disposeManagedPty has run. Prevents double-dispose (onExit + an + * explicit shutdown can both fire for the same PTY) and converts post-dispose + * entry-point calls into a clean "not found" error instead of a silent no-op + * (POSIX proc.kill is neutralized inside disposeManagedPty). */ + disposed?: boolean +} + +function disposeManagedPty(managed: ManagedPty): void { + if (managed.disposed) { + return + } + managed.disposed = true + // Why: clear any pending 5s SIGKILL fallback timer. If graceful-shutdown + // armed a killTimer and the child then exited cleanly (firing onExit → + // disposeManagedPty), the timer would otherwise fire later and attempt + // pty.kill('SIGKILL') on an already-disposed instance. The ptys.has(id) + // guard inside the timer short-circuits today, but symmetry is clearer. + if (managed.killTimer) { + clearTimeout(managed.killTimer) + managed.killTimer = undefined + } + // Why: UnixTerminal.destroy() registers `_socket.once('close', () => this.kill('SIGHUP'))`. + // The close event fires asynchronously; by then the child may have exited and + // its pid been recycled. On the Linux remote hosts the relay typically runs on, + // pid recycling is fast — SIGHUP to a stranger is a real hazard. Neutralize + // managed.pty.kill before destroy() runs. Windows exempt: WindowsTerminal.destroy + // IS a kill() call via _deferNoArgs — neutralizing it leaks the ConPTY agent. + if (process.platform !== 'win32') { + ;(managed.pty as unknown as { kill: (sig?: string) => void }).kill = () => {} + } + try { + ;(managed.pty as unknown as { destroy?: () => void }).destroy?.() + } catch { + /* swallow */ + } } const DEFAULT_GRACE_TIME_MS = 5 * 60 * 1000 export const REPLAY_BUFFER_MAX = 100 * 1024 @@ -75,14 +110,29 @@ export class PtyHandler { this.dispatcher.notify('pty.data', { id: managed.id, data }) }) managed.pty.onExit(({ exitCode }: { exitCode: number }) => { + // Why: neutralize managed.pty.kill synchronously BEFORE anything else + // in this callback. node-pty's UnixTerminal has + // `_socket.once('close', () => this.kill('SIGHUP'))` wired at destroy + // time, and the master socket can emit 'close' concurrently with this + // onExit on natural exit. If 'close' wins, SIGHUP targets the reaped + // pid — recycled to an unrelated process on Linux (the typical relay + // host). Synchronous neutralization closes that window. Windows is + // exempt (WindowsTerminal.destroy uses kill() to close ConPTY). + if (process.platform !== 'win32') { + ;(managed.pty as unknown as { kill: (sig?: string) => void }).kill = () => {} + } // Why: If the PTY exits normally (or via SIGTERM), we must clear the - // SIGKILL fallback timer to avoid sending SIGKILL to a recycled PID. + // SIGKILL fallback timer to avoid firing SIGKILL later. if (managed.killTimer) { clearTimeout(managed.killTimer) managed.killTimer = undefined } this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode }) this.ptys.delete(managed.id) + // Why: release the ptmx fd on the natural-exit path. Without this the + // node-pty wrapper's _socket stays alive until GC and the master fd + // leaks (see docs/fix-pty-fd-leak.md). + disposeManagedPty(managed) }) } @@ -147,8 +197,15 @@ export class PtyHandler { // immediately so it does not linger as an unreachable remote shell. term.kill('SIGTERM') managed.killTimer = setTimeout(() => { - if (this.ptys.has(id)) { - term.kill('SIGKILL') + const still = this.ptys.get(id) + if (still && !still.disposed) { + still.pty.kill('SIGKILL') + // Why: stale-spawn cleanup has no client who will ever attach. If + // SIGKILL's onExit is missed (kernel edge case, uninterruptible + // sleep), the managed entry + ptmx fd would leak forever. Dispose + // synchronously so the entry is gone regardless of onExit timing. + disposeManagedPty(still) + this.ptys.delete(id) } }, 5000) } @@ -158,7 +215,11 @@ export class PtyHandler { private async attach(params: Record): Promise<{ replay?: string }> { const id = params.id as string const managed = this.ptys.get(id) - if (!managed) { + // Why: treat a disposed managed entry the same as "not found" — after + // disposeManagedPty has run, managed.pty is torn down and any write/kill + // would hit a neutralized no-op on POSIX. The explicit check converts a + // silent failure into the existing error callers already handle. + if (!managed || managed.disposed) { throw new Error(`PTY "${id}" not found`) } @@ -187,7 +248,7 @@ export class PtyHandler { return } const managed = this.ptys.get(id) - if (managed) { + if (managed && !managed.disposed) { managed.pty.write(data) } } @@ -197,7 +258,7 @@ export class PtyHandler { const cols = Math.max(1, Math.min(500, Math.floor(Number(params.cols) || 80))) const rows = Math.max(1, Math.min(500, Math.floor(Number(params.rows) || 24))) const managed = this.ptys.get(id) - if (managed) { + if (managed && !managed.disposed) { managed.pty.resize(cols, rows) } } @@ -212,6 +273,20 @@ export class PtyHandler { if (immediate) { managed.pty.kill('SIGKILL') + // Why: SIGKILL has already reaped the child; release the ptmx fd on the + // same tick. Deferring to onExit leaves a window where the fd is live + // with a dead child. Idempotent via the disposed guard — if onExit fires + // later and also calls disposeManagedPty, the second call is a no-op. + disposeManagedPty(managed) + // Why: mirror the graceful-shutdown killTimer cleanup. If SIGKILL's + // onExit never fires (kernel edge case: uninterruptible sleep, + // D-state child on a bad NFS mount), the disposed managed entry would + // linger in this.ptys forever. Each stranded entry consumes a slot in + // the 50-PTY cap and is returned by listProcesses/serialize. Deleting + // here makes the map hygiene a hard guarantee, not "hopefully onExit + // runs". If onExit DOES fire later, its own `this.ptys.delete(id)` is + // a no-op. + this.ptys.delete(id) } else { managed.pty.kill('SIGTERM') @@ -220,9 +295,22 @@ export class PtyHandler { // managed entry would never be cleaned up. The 5-second window gives // well-behaved processes time to flush and exit gracefully. The timer is // cleared in the onExit handler if the process terminates on its own. + // Do NOT call disposeManagedPty here: destroy()-right-after-SIGTERM + // collapses the graceful-shutdown window and risks interrupting shell + // EXIT traps. Fd release happens via onExit (natural exit) or via the + // killTimer → SIGKILL → disposeManagedPty chain below. managed.killTimer = setTimeout(() => { - if (this.ptys.has(id)) { - managed.pty.kill('SIGKILL') + const still = this.ptys.get(id) + if (still && !still.disposed) { + still.pty.kill('SIGKILL') + // Why: if SIGKILL's onExit never fires (kernel edge case, + // uninterruptible sleep, child wedged on a bad NFS mount), the + // fd and map entry would leak forever. Dispose synchronously so + // graceful-shutdown's SIGKILL fallback is a hard guarantee, not + // "hopefully onExit will run". The disposed guard inside + // disposeManagedPty makes a later onExit's dispose a no-op. + disposeManagedPty(still) + this.ptys.delete(id) } }, 5000) } @@ -235,7 +323,10 @@ export class PtyHandler { throw new Error(`Signal not allowed: ${signal}`) } const managed = this.ptys.get(id) - if (!managed) { + // Why: POSIX disposeManagedPty neutralizes managed.pty.kill. Without the + // disposed check, a post-dispose sendSignal would silently succeed (no + // error, no action). Convert to the existing "not found" error. + if (!managed || managed.disposed) { throw new Error(`PTY "${id}" not found`) } managed.pty.kill(signal) @@ -244,7 +335,7 @@ export class PtyHandler { private async getCwd(params: Record): Promise { const id = params.id as string const managed = this.ptys.get(id) - if (!managed) { + if (!managed || managed.disposed) { throw new Error(`PTY "${id}" not found`) } return resolveProcessCwd(managed.pty.pid, managed.initialCwd) @@ -253,7 +344,7 @@ export class PtyHandler { private async getInitialCwd(params: Record): Promise { const id = params.id as string const managed = this.ptys.get(id) - if (!managed) { + if (!managed || managed.disposed) { throw new Error(`PTY "${id}" not found`) } return managed.initialCwd @@ -262,7 +353,7 @@ export class PtyHandler { private async clearBuffer(params: Record): Promise { const id = params.id as string const managed = this.ptys.get(id) - if (managed) { + if (managed && !managed.disposed) { managed.pty.clear() } } @@ -270,7 +361,7 @@ export class PtyHandler { private async hasChildProcesses(params: Record): Promise { const id = params.id as string const managed = this.ptys.get(id) - if (!managed) { + if (!managed || managed.disposed) { return false } return await processHasChildren(managed.pty.pid) @@ -279,7 +370,7 @@ export class PtyHandler { private async getForegroundProcess(params: Record): Promise { const id = params.id as string const managed = this.ptys.get(id) - if (!managed) { + if (!managed || managed.disposed) { return null } return await getForegroundProcessName(managed.pty.pid) @@ -371,8 +462,21 @@ export class PtyHandler { for (const [, managed] of this.ptys) { if (managed.killTimer) { clearTimeout(managed.killTimer) + managed.killTimer = undefined } - managed.pty.kill('SIGTERM') + // Why: SIGKILL (not SIGTERM) before destroy. The relay process is + // exiting; any SIGTERM-ignoring remote shell (editor with unsaved + // buffers, a hung child with a bad handler, a process in + // uninterruptible sleep) would survive SIGTERM + immediate destroy() + // as an orphan on the remote host. SIGKILL is not ignorable and the + // ptmx fd release via disposeManagedPty is synchronous, so there is + // no graceful-shutdown window to preserve at this point. + try { + managed.pty.kill('SIGKILL') + } catch { + /* child may already be dead */ + } + disposeManagedPty(managed) } this.ptys.clear() } diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts index 1ce8e20bc9d..72e26e1ec11 100644 --- a/src/relay/pty-shell-utils.ts +++ b/src/relay/pty-shell-utils.ts @@ -27,15 +27,14 @@ export function resolveDefaultShell(): string { * Tries /proc on Linux and lsof on macOS before falling back to `fallbackCwd`. */ export async function resolveProcessCwd(pid: number, fallbackCwd: string): Promise { - // Try to read /proc/{pid}/cwd on Linux - const procCwd = `/proc/${pid}/cwd` - if (existsSync(procCwd)) { - try { - const { readlinkSync } = await import('fs') - return readlinkSync(procCwd) - } catch { - // Fall through - } + // Try to read /proc/{pid}/cwd on Linux. Skip an existsSync gate — the + // check+read pair races a concurrent exit anyway, and the catch already + // falls through to lsof. + try { + const { readlinkSync } = await import('fs') + return readlinkSync(`/proc/${pid}/cwd`) + } catch { + // Fall through } // Fallback: use lsof on macOS @@ -43,17 +42,26 @@ export async function resolveProcessCwd(pid: number, fallbackCwd: string): Promi // lsof returns ALL open files (sockets, log files, TTYs) and the first `n`-line // could be any of them — not the actual working directory. try { - const { stdout: output } = await execFile('lsof', ['-p', String(pid), '-d', 'cwd', '-Fn'], { - encoding: 'utf-8', - timeout: 3000 - }) + // Why: `-a` ANDs the -p and -d filters. Without it, macOS lsof ORs them + // and emits cwd records for every process on the system, so the n-line + // scan below picks up the first unrelated process (often pid ~391 with + // cwd `/`) and returns `/` regardless of the target pid's real cwd. + const { stdout: output } = await execFile( + 'lsof', + ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], + { + encoding: 'utf-8', + timeout: 3000 + } + ) const lines = output.split('\n') for (const line of lines) { if (line.startsWith('n') && line.includes('/')) { - const candidate = line.slice(1) - if (existsSync(candidate)) { - return candidate - } + // Why: lsof -d cwd is authoritative — don't second-guess it with + // existsSync. A concurrent rmdir would race the check and cause us + // to drop the correct answer; node-pty handles a missing cwd on + // spawn anyway. + return line.slice(1) } } } catch { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 19ed5954fb0..f9ad204936f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,18 +1,12 @@ /* eslint-disable max-lines */ import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { - AGENT_DASHBOARD_ENABLED, - DEFAULT_STATUS_BAR_ITEMS, - DEFAULT_WORKTREE_CARD_PROPERTIES -} from '../../shared/constants' +import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants' import { ArrowLeft, ArrowRight, Minimize2, PanelLeft, PanelRight } from 'lucide-react' -import { - FOCUS_TERMINAL_PANE_EVENT, - SYNC_FIT_PANES_EVENT, - TOGGLE_TERMINAL_PANE_EXPAND_EVENT -} from '@/constants/terminal' +import { SYNC_FIT_PANES_EVENT, TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal' import { syncZoomCSSVar } from '@/lib/ui-zoom' +import { buildAppFontFamily } from '@/lib/app-font-family' +import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import { toast } from 'sonner' import { Toaster } from '@/components/ui/sonner' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' @@ -22,15 +16,17 @@ import { useIpcEvents } from './hooks/useIpcEvents' import RetainedAgentsSyncGate from './components/dashboard/RetainedAgentsSyncGate' import Sidebar from './components/Sidebar' import Terminal from './components/Terminal' -import { shutdownBufferCaptures } from './components/terminal-pane/TerminalPane' +import { shutdownBufferCaptures } from './components/terminal-pane/shutdown-buffer-captures' import RightSidebar from './components/right-sidebar' import { StatusBar } from './components/status-bar/StatusBar' import { UpdateCard } from './components/UpdateCard' import { StarNagCard } from './components/StarNagCard' +import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from './components/ZoomOverlay' import { SshPassphraseDialog } from './components/settings/SshPassphraseDialog' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' import { useEditorExternalWatch } from './hooks/useEditorExternalWatch' +import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent' import { setRuntimeGraphStoreStateGetter, setRuntimeGraphSyncEnabled @@ -40,6 +36,7 @@ import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload' import { buildWorkspaceSessionPayload } from './lib/workspace-session' import { countWorkingAgents, getWorkingAgentsPerWorktree } from './lib/agent-status' import { activateAndRevealWorktree } from './lib/worktree-activation' +import { applyDocumentTheme } from './lib/document-theme' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import { findWorktreeById, getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' import { @@ -55,6 +52,9 @@ const Settings = lazy(() => import('./components/settings/Settings')) const QuickOpen = lazy(() => import('./components/QuickOpen')) const WorktreeJumpPalette = lazy(() => import('./components/WorktreeJumpPalette')) const NewWorkspaceComposerModal = lazy(() => import('./components/NewWorkspaceComposerModal')) +// Why: lazy-loaded so the WebP asset + overlay module aren't fetched unless +// the user opts into the experimental flag. +const SidekickOverlay = lazy(() => import('./components/sidekick/SidekickOverlay')) function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { @@ -96,7 +96,6 @@ function App(): React.JSX.Element { hydrateEditorSession: s.hydrateEditorSession, hydrateBrowserSession: s.hydrateBrowserSession, fetchBrowserSessionProfiles: s.fetchBrowserSessionProfiles, - fetchDetectedBrowsers: s.fetchDetectedBrowsers, reconnectPersistedTerminals: s.reconnectPersistedTerminals, setDeferredSshReconnectTargets: s.setDeferredSshReconnectTargets, setSshConnectionState: s.setSshConnectionState, @@ -106,7 +105,9 @@ function App(): React.JSX.Element { toggleRightSidebar: s.toggleRightSidebar, setRightSidebarOpen: s.setRightSidebarOpen, setRightSidebarTab: s.setRightSidebarTab, - updateSettings: s.updateSettings + updateSettings: s.updateSettings, + pruneLastVisitedTimestamps: s.pruneLastVisitedTimestamps, + seedActiveWorktreeLastVisitedIfMissing: s.seedActiveWorktreeLastVisitedIfMissing })) ) @@ -136,12 +137,22 @@ function App(): React.JSX.Element { const groupBy = useAppStore((s) => s.groupBy) const sortBy = useAppStore((s) => s.sortBy) const showActiveOnly = useAppStore((s) => s.showActiveOnly) + const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const persistedUIReady = useAppStore((s) => s.persistedUIReady) const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth) const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) const isFullScreen = useAppStore((s) => s.isFullScreen) const settings = useAppStore((s) => s.settings) + // Why: render-level gate for the experimental agent dashboard retention + // sync. Reading the flag here (rather than only inside useDashboardData / + // useRetainedAgentsSync) lets us skip mounting RetainedAgentsSyncGate + // entirely for non-toggled users, which drops all feature-tied + // subscriptions (agentStatusByPaneKey, agentStatusEpoch, etc.) instead of + // keeping them alive behind an early-return inside the hook bodies. + const agentDashboardEnabled = useAppStore((s) => s.settings?.experimentalAgentDashboard === true) + const sidekickEnabled = useAppStore((s) => s.settings?.experimentalSidekick === true) + const sidekickVisible = useAppStore((s) => s.sidekickVisible) const canGoBackWorktree = useAppStore(canGoBackWorktreeHistory) const canGoForwardWorktree = useAppStore(canGoForwardWorktreeHistory) const titlebarLeftControlsRef = useRef(null) @@ -150,10 +161,10 @@ function App(): React.JSX.Element { // Subscribe to IPC push events useIpcEvents() - // Why: retention must run at App level (not inside AgentDashboard) because - // the sidebar hovercard also reads retained entries. If retention only ran - // when the dashboard is mounted, "done" agents would vanish from the hover - // any time the user collapses the dashboard panel. + // Why: retention must run at App level so the inline per-card agents list + // always sees retained entries. If retention ran inside the sidebar-card + // subtree, "done" agents would vanish any time the user collapsed a card's + // inline agents section. // // The retention hooks are hosted inside (a leaf // component that renders null) rather than being called inline here. @@ -162,9 +173,17 @@ function App(): React.JSX.Element { // event frequency), re-rendering the entire app tree on every agent status // update. Hosting the subscriptions in a leaf isolates that churn. // - // The AGENT_DASHBOARD_ENABLED gate still lives inside the hooks themselves - // (useDashboardData early-returns [] from its memo; useRetainedAgentsSync - // early-returns from its effect), so the gate component is cheap when off. + // The render-level gate on (see + // agentDashboardEnabled above) keeps the experimental feature fully dark + // for non-toggled users: without the gate mounted, none of its feature-tied + // zustand selectors (agentStatusByPaneKey / agentStatusEpoch / etc.) are + // ever subscribed, so PTY agent-status events cause zero work for them. + // + // The inner hook guards (useDashboardData early-returns [] from its memo; + // useRetainedAgentsSync early-returns from its effect) remain as + // defense-in-depth: they keep both hooks safe to call from any future + // callsite, and they handle the in-session off→on toggle transition + // cleanly without relying on a remount race when the setting flips. // Why: git conflict-operation state also drives the worktree cards. Polling // cannot live under RightSidebar because App unmounts that subtree when the // sidebar is closed, which leaves stale "Rebasing"/"Merging" badges behind @@ -178,6 +197,7 @@ function App(): React.JSX.Element { // of tying reloads to the Explorer UI lifecycle. useEditorExternalWatch() useGlobalFileDrop() + useAutoAckViewedAgent() // Why: sidebar open/close flips width instantaneously. useLayoutEffect // runs synchronously after React commits the DOM but before paint, so @@ -205,10 +225,8 @@ function App(): React.JSX.Element { const persistedUI = await window.api.ui.get() const session = await window.api.session.get() // Why: settings must be loaded before hydrateWorkspaceSession so that - // it can read experimentalTerminalDaemon to decide whether to stage - // pendingReconnectPtyIdByTabId. Without this, opted-in daemon users - // would silently lose session reattach on every launch because - // s.settings would still be null at hydration time. + // hydration has access to user preferences. Without this, settings + // would still be null at hydration time. await actions.fetchSettings() if (!cancelled) { actions.hydratePersistedUI(persistedUI) @@ -216,8 +234,17 @@ function App(): React.JSX.Element { actions.hydrateTabsSession(session) actions.hydrateEditorSession(session) actions.hydrateBrowserSession(session) + // Why: prune lastVisitedAtByWorktreeId entries whose worktrees + // no longer exist. Must run AFTER hydration — before this point, + // async repo loads may not have populated worktreesByRepo yet and + // pruning would delete timestamps for worktrees that are about to + // appear. Seed the restored active worktree's timestamp if missing + // so users upgrading from a pre-feature build don't see the active + // worktree sink in the empty-query list. + // See docs/cmd-j-empty-query-ordering.md. + actions.pruneLastVisitedTimestamps() + actions.seedActiveWorktreeLastVisitedIfMissing() await actions.fetchBrowserSessionProfiles() - await actions.fetchDetectedBrowsers() // Why: SSH connections must be re-established BEFORE terminal // reconnect so that reconnectPersistedTerminals can route SSH-backed @@ -314,8 +341,9 @@ function App(): React.JSX.Element { sidebarWidth: 280, rightSidebarWidth: 350, groupBy: 'none', - sortBy: 'name', + sortBy: 'recent', showActiveOnly: false, + hideDefaultBranchWorkspace: false, filterRepoIds: [], collapsedGroups: [], uiZoomLevel: 0, @@ -407,7 +435,7 @@ function App(): React.JSX.Element { if (!useAppStore.getState().workspaceSessionReady) { return } - for (const capture of shutdownBufferCaptures) { + for (const capture of shutdownBufferCaptures.values()) { try { capture() } catch { @@ -446,6 +474,7 @@ function App(): React.JSX.Element { groupBy, sortBy, showActiveOnly, + hideDefaultBranchWorkspace, filterRepoIds }) }, 150) @@ -458,6 +487,7 @@ function App(): React.JSX.Element { groupBy, sortBy, showActiveOnly, + hideDefaultBranchWorkspace, filterRepoIds ]) @@ -467,26 +497,29 @@ function App(): React.JSX.Element { return } - const applyTheme = (dark: boolean): void => { - document.documentElement.classList.toggle('dark', dark) - } - if (settings.theme === 'dark') { - applyTheme(true) + applyDocumentTheme('dark') return undefined } else if (settings.theme === 'light') { - applyTheme(false) + applyDocumentTheme('light') return undefined } else { // system const mq = window.matchMedia('(prefers-color-scheme: dark)') - applyTheme(mq.matches) - const handler = (e: MediaQueryListEvent): void => applyTheme(e.matches) + applyDocumentTheme('system') + const handler = (): void => applyDocumentTheme('system') mq.addEventListener('change', handler) return () => mq.removeEventListener('change', handler) } }, [settings]) + useEffect(() => { + document.documentElement.style.setProperty( + '--app-font-family', + buildAppFontFamily(settings?.appFontFamily) + ) + }, [settings?.appFontFamily]) + // Refresh GitHub data (PR/issue status) when window regains focus useEffect(() => { const handler = (): void => { @@ -498,65 +531,6 @@ function App(): React.JSX.Element { return () => document.removeEventListener('visibilitychange', handler) }, [actions]) - // Why: v1.3.0 shipped the persistent-terminal daemon ON by default. v1.3.1+ - // defaults it OFF and gates it behind an Experimental toggle. On the first - // launch after that upgrade, main detects a still-running daemon, shuts it - // down (killing any surviving `sleep 9999`-style sessions), and stashes a - // one-shot notice. We consume that notice here and inform the user so their - // vanished sessions don't look like a bug. The renderer-side - // `experimentalTerminalDaemonNoticeShown` flag guarantees the toast fires at - // most once per install, even if main stashes a notice again on a later - // launch. - const transitionNoticeHandledRef = useRef(false) - useEffect(() => { - if (!settings || transitionNoticeHandledRef.current) { - return - } - if (settings.experimentalTerminalDaemonNoticeShown) { - transitionNoticeHandledRef.current = true - return - } - transitionNoticeHandledRef.current = true - void (async () => { - let notice: { killedCount: number } | null = null - try { - notice = await window.api.app.consumeDaemonTransitionNotice() - } catch { - // Informational only — if the IPC fails, don't fire the toast and - // don't flip the "shown" flag so we can retry on next launch. - return - } - if (!notice) { - return - } - const killedCount = notice.killedCount - const killedClause = - killedCount > 0 - ? ` Cleaned up ${killedCount} background session${killedCount === 1 ? '' : 's'} from the previous version.` - : '' - toast.info('Persistent terminal sessions are now opt-in.', { - description: `${killedClause} You can re-enable them in Settings → Experimental.`.trim(), - duration: 15000, - action: { - label: 'Open settings', - onClick: () => { - useAppStore.getState().openSettingsTarget({ - pane: 'experimental', - repoId: null - }) - useAppStore.getState().openSettingsPage() - } - } - }) - try { - await actions.updateSettings({ experimentalTerminalDaemonNoticeShown: true }) - } catch { - // If persistence fails, the toast may re-fire on a later launch — - // acceptable tradeoff vs. silently dropping the notification. - } - })() - }, [actions, settings]) - const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : [] const hasTabBar = tabs.length >= 2 const effectiveActiveTabId = activeTabId ?? tabs[0]?.id ?? null @@ -713,26 +687,6 @@ function App(): React.JSX.Element { return } - // Cmd/Ctrl+Shift+D — open right sidebar (agent dashboard is now - // docked at the sidebar bottom, so only toggle visibility). - // Why: skip when a terminal is focused — that combo is the terminal - // split-pane shortcut (see terminal-shortcut-policy.ts). Both listeners - // share the window capture phase and registration order can vary with - // React effect re-runs, so a DOM check is the reliable coordination - // mechanism (same pattern as Cmd+Shift+G above). xterm-helper-textarea - // is the focus target the terminal handler itself uses to detect - // terminal focus (see keyboard-handlers.ts isEditableTarget). - if (AGENT_DASHBOARD_ENABLED && e.shiftKey && !e.altKey && e.key.toLowerCase() === 'd') { - const active = document.activeElement as HTMLElement | null - if (active?.classList.contains('xterm-helper-textarea')) { - return - } - dispatchClearModifierHints() - e.preventDefault() - actions.setRightSidebarOpen(true) - return - } - // Cmd+Shift+I — toggle right sidebar / ports tab (macOS only). // Why: Ctrl+Shift+I is the built-in DevTools accelerator on Windows/Linux; // intercepting it would break an essential developer tool. @@ -887,22 +841,7 @@ function App(): React.JSX.Element { className="titlebar-agent-hovercard-agent" onClick={() => { activateAndRevealWorktree(worktreeId) - useAppStore.getState().setActiveTab(agent.tabId) - if (agent.paneId !== null) { - // Why: a split-terminal tab can host multiple - // agents. After selecting the tab, wait one - // frame so the active TerminalPane can mount - // and then focus the specific pane the user - // clicked instead of leaving whichever pane - // was previously active highlighted. - requestAnimationFrame(() => { - window.dispatchEvent( - new CustomEvent(FOCUS_TERMINAL_PANE_EVENT, { - detail: { tabId: agent.tabId, paneId: agent.paneId } - }) - ) - }) - } + activateTabAndFocusPane(agent.tabId, agent.paneId) }} > @@ -1008,122 +947,140 @@ function App(): React.JSX.Element { } > - {/* Why: leaf-mounted retention sync. Hosts useDashboardData() + - useRetainedAgentsSync() so their high-churn store subscriptions - re-render a null component rather than the entire App tree. */} - - {/* Why: in workspace view (split groups always enabled), the full-width - titlebar is removed so tab groups + terminal extend to the top of - the window. Left titlebar controls move to a header above the sidebar. - Settings, landing, and the tasks page keep the full-width titlebar. */} - {!workspaceActive ? ( -
-
- {titlebarLeftControls} -
-
- {showTitlebarExpandButton && ( - - - - - - Collapse pane - - - )} - {rightSidebarToggle} -
- ) : null} + {/* Why: leaf-mounted retention sync, gated at the render level by + agentDashboardEnabled. Hosting useDashboardData() + + useRetainedAgentsSync() inside a null-rendering leaf keeps their + high-churn store subscriptions from re-rendering the App tree; + the outer conditional drops those subscriptions entirely for + users who have not toggled the experimental agent dashboard on, + so PTY agent-status events do no feature-tied work for them. + The hooks' internal early-returns remain as defense-in-depth + (see the comment above useIpcEvents()). */} + {agentDashboardEnabled ? : null}
- {showSidebar ? ( - workspaceActive ? ( - /* Why: left column wraps the sidebar with a titlebar-height - header above it. The header holds the same controls - (traffic lights, sidebar toggle, "Orca" title, agent badge) - that the full-width titlebar held while the center and right - columns keep their own top strips at the same 42px height. - When the sidebar is collapsed, take this header out of flex - layout so the terminal/editor reclaim the left edge instead of - leaving behind a content-width blank strip. */ -
+ {/* Why: the non-workspace titlebar lives inside this left+center + wrapper so it does not span over the right-sidebar column — + when the right sidebar is open, its own header anchors at the + top alongside the titlebar instead of being pushed below it. */} +
+ {/* Why: in workspace view (split groups always enabled), the + full-width titlebar is removed so tab groups + terminal extend + to the top of the window. Left titlebar controls move to a + header above the sidebar. Settings, landing, and the tasks + page keep the titlebar. */} + {!workspaceActive ? ( +
{titlebarLeftControls}
-
- {/* Why: the workspace-view wrapper adds a fixed 42px header - above the sidebar. Without a flex-1/min-h-0 slot here, - the sidebar falls back to its content height, so the - worktree list loses its scroll viewport and the fixed - bottom toolbar (including Add Project) gets pushed offscreen. */} +
+ {showTitlebarExpandButton && ( + + + + + + Collapse pane + + + )} + {/* Why: when the right sidebar is open, its own header renders + an identical close button — hide this copy so only one is + visible at a time. */} + {!rightSidebarOpen && rightSidebarToggle} +
+ ) : null} +
+ {showSidebar ? ( + workspaceActive ? ( + /* Why: left column wraps the sidebar with a titlebar-height + header above it. The header holds the same controls + (traffic lights, sidebar toggle, "Orca" title, agent badge) + that the full-width titlebar held while the center and right + columns keep their own top strips at the same 36px height. + When the sidebar is collapsed, take this header out of flex + layout so the terminal/editor reclaim the left edge instead of + leaving behind a content-width blank strip. */ +
+
+ {titlebarLeftControls} +
+
+ {/* Why: the workspace-view wrapper adds a fixed 36px header + above the sidebar. Without a flex-1/min-h-0 slot here, + the sidebar falls back to its content height, so the + worktree list loses its scroll viewport and the fixed + bottom toolbar (including Add Project) gets pushed offscreen. */} + +
+
+ ) : ( + ) + ) : null} +
+ {/* Why: right sidebar toggle floats at the top-right of the center + column so it's always accessible whether the right sidebar is + open or closed. Match the RightSidebar header's 36px height and + top-0 anchor so the icon's vertical center is identical between + open and closed states — otherwise toggling makes the icon jump + a few pixels, which reads as layout jitter. */} + {workspaceActive && !rightSidebarOpen && ( +
+ {rightSidebarToggle} +
+ )} +
+
+ +
+ + {activeView === 'settings' ? : null} + {activeView === 'tasks' ? : null} + {activeView === 'terminal' && !activeWorktreeId ? : null} +
- ) : ( - - ) - ) : null} -
- {/* Why: right sidebar toggle floats at the top-right of the center - column so it's always accessible whether the right sidebar is - open or closed. Match the RightSidebar header's 42px height and - top-0 anchor so the icon's vertical center is identical between - open and closed states — otherwise toggling makes the icon jump - a few pixels, which reads as layout jitter. */} - {workspaceActive && !rightSidebarOpen && ( -
- {rightSidebarToggle} -
- )} -
-
- -
- - {activeView === 'settings' ? : null} - {activeView === 'tasks' ? : null} - {activeView === 'terminal' && !activeWorktreeId ? : null} -
{/* Why: keep RightSidebar mounted even when closed so that its @@ -1146,8 +1103,25 @@ function App(): React.JSX.Element { {mountedLazyModalIds.has('quick-open') ? : null} {mountedLazyModalIds.has('worktree-palette') ? : null} + {/* Why: mount SidekickOverlay only when the experimental flag is on AND + the user hasn't hit "Hide sidekick" in the status-bar menu. Both + conditions must be true — see design doc (sidekick-overlay.md) on why + the two toggles are kept independent. */} + {sidekickEnabled && sidekickVisible ? ( + + + + ) : null} + {/* Why: the existing-user opt-in banner mounts at App root so it + renders once per renderer session, not per view. It gates + internally on the cohort markers populated by the migration, + so it only shows for users who installed before the telemetry + release and have not yet resolved consent. New users get no + first-launch surface — see telemetry-plan.md §First-launch + experience. */} + diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 69c2b8c4820..5ddadaebf4f 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -16,6 +16,14 @@ @custom-variant dark (&:is(.dark *)); +.theme-transition-disabled *, +.theme-transition-disabled *::before, +.theme-transition-disabled *::after { + /* Why: theme swaps flip many CSS variables at once; transitions make parts of + the UI fade at different speeds. Animations stay enabled for spinners. */ + transition: none !important; +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -61,6 +69,8 @@ /* ── Light Mode ──────────────────────────────────────── */ :root { + --app-font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-sans: var(--app-font-family); --font-mono: 'SF Mono', SFMono-Regular, ui-monospace, 'Cascadia Code', Menlo, Consolas, 'Liberation Mono', monospace; @@ -165,12 +175,14 @@ padding: 0; overflow: hidden; height: 100vh; - font-family: + font-family: var( + --app-font-family, 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', - sans-serif; + sans-serif + ); letter-spacing: 0.01em; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -226,6 +238,36 @@ scrollbar-color: color-mix(in srgb, var(--muted-foreground, #737373) 48%, transparent) transparent; } +/* Keep the sidebar gutter reserved; only the thumb fades in on hover. */ +.worktree-sidebar-scrollbar { + scrollbar-color: transparent transparent; +} + +.worktree-sidebar-scrollbar::-webkit-scrollbar-thumb { + background-color: transparent; + border-width: 3px 3px 3px 0; +} + +.scrollbar-sleek-parent:hover .worktree-sidebar-scrollbar, +.worktree-sidebar-scrollbar:hover { + scrollbar-color: color-mix(in srgb, var(--muted-foreground, #737373) 48%, transparent) transparent; +} + +.scrollbar-sleek-parent:hover .worktree-sidebar-scrollbar::-webkit-scrollbar-thumb, +.worktree-sidebar-scrollbar:hover::-webkit-scrollbar-thumb { + background-color: color-mix(in srgb, var(--muted-foreground, #737373) 28%, transparent); +} + +.scrollbar-sleek-parent:hover .worktree-sidebar-scrollbar::-webkit-scrollbar-thumb:hover, +.worktree-sidebar-scrollbar:hover::-webkit-scrollbar-thumb:hover { + background-color: color-mix(in srgb, var(--muted-foreground, #737373) 48%, transparent); +} + +.scrollbar-sleek-parent:hover .worktree-sidebar-scrollbar::-webkit-scrollbar-thumb:active, +.worktree-sidebar-scrollbar:hover::-webkit-scrollbar-thumb:active { + background-color: color-mix(in srgb, var(--foreground, #171717) 36%, transparent); +} + [data-slot='popover-content'] { max-height: var(--radix-popover-content-available-height); } @@ -317,8 +359,8 @@ } .titlebar { - height: 42px; - min-height: 42px; + height: 36px; + min-height: 36px; display: flex; flex-direction: row; align-items: center; @@ -331,17 +373,26 @@ /* Why: workspace view still needs a full titlebar-height strip above the sidebar so the native macOS traffic lights and sidebar chrome sit in the same vertical band they use in settings/landing. The center and right - workspace columns are offset separately to align with this 42px baseline. */ + workspace columns are offset separately to align with this 36px baseline + (4px drag strip + 32px tab row). */ .titlebar-left { - height: 42px; - min-height: 42px; + height: 36px; + min-height: 36px; display: flex; flex-direction: row; align-items: center; overflow: hidden; flex-shrink: 0; background: var(--bg-titlebar, var(--card)); - border-bottom: 1px solid var(--border); + /* Why: draw the divider via inset shadow instead of border-bottom so the + flex content area spans the full 36px. With border-bottom + border-box, + the content area shrinks to 35px and flex-centered controls land at + y=17.5, a half-pixel below the native traffic-light center at y=18 + (set via setWindowButtonPosition in createMainWindow.ts). The shadow + paints the same 1px seam without stealing layout space, so the sidebar + toggle, agent badge, and back/forward arrows align pixel-perfectly + with the traffic lights. */ + box-shadow: inset 0 -1px 0 var(--border); -webkit-app-region: drag; user-select: none; } @@ -375,6 +426,11 @@ .sidebar-toggle spacing stays unchanged for other titlebar icons. */ .sidebar-toggle-compact { padding: 4px 5px; + color: var(--foreground); +} + +.sidebar-toggle-compact:disabled { + color: var(--muted-foreground); } .sidebar-toggle:disabled { @@ -897,6 +953,13 @@ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--warning, #f59e0b) 52%, transparent); } +.monaco-editor .monaco-markdown-doc-link { + color: #58a6ff !important; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 45%, transparent); + text-underline-offset: 2px; +} + /* Why: Monaco's find-widget button tooltips (Close, Find in Selection, etc.) render inside a `.context-view` wrapper → `.workbench-hover-container` → `.workbench-hover.compact`, positioned above the target button. The wrapper's diff --git a/src/renderer/src/assets/markdown-preview.css b/src/renderer/src/assets/markdown-preview.css index fb37e366e75..625696c41f1 100644 --- a/src/renderer/src/assets/markdown-preview.css +++ b/src/renderer/src/assets/markdown-preview.css @@ -142,6 +142,16 @@ text-decoration-color: currentColor; } +.markdown-body .markdown-doc-link { + text-decoration-style: solid; +} + +.markdown-body .markdown-doc-link-broken { + color: var(--muted-foreground); + cursor: default !important; + text-decoration-style: dotted; +} + .markdown-light .markdown-body a { color: #0969da; } @@ -150,6 +160,11 @@ color: #58a6ff; } +.markdown-light .markdown-body .markdown-doc-link-broken, +.markdown-dark .markdown-body .markdown-doc-link-broken { + color: var(--muted-foreground); +} + .markdown-body code { padding: 0.2em 0.4em; border-radius: 5px; diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 59c0231bd14..25bcaacd260 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -592,6 +592,110 @@ color: var(--muted-foreground); } +.rich-markdown-doc-link-menu { + position: absolute; + z-index: 30; + display: flex; + width: min(360px, calc(100% - 24px)); + max-height: 320px; + flex-direction: column; + overflow-y: auto; + border: 1px solid color-mix(in srgb, var(--border) 76%, transparent); + border-radius: 12px; + background: color-mix(in srgb, var(--background) 92%, transparent); + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(16px); +} + +.rich-markdown-doc-link-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 12px; + border: none; + background: transparent; + color: inherit; + text-align: left; +} + +.rich-markdown-doc-link-item:hover, +.rich-markdown-doc-link-item.is-active { + background: var(--accent); +} + +.rich-markdown-doc-link-item.is-empty { + color: var(--muted-foreground); + font-size: 13px; + font-style: italic; + cursor: default; +} + +.rich-markdown-doc-link-item.is-empty:hover { + background: transparent; +} + +.rich-markdown-doc-link-footer { + padding: 6px 12px; + border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + color: var(--muted-foreground); + font-size: 11px; +} + +.rich-markdown-doc-link-hint { + padding: 6px 12px; + border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + color: var(--muted-foreground); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +/* ── Doc Links ───────────────────────────────────────── */ + +.rich-markdown-editor .rich-markdown-doc-link { + color: #0969da; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 45%, transparent); + text-underline-offset: 2px; +} + +.dark .rich-markdown-editor .rich-markdown-doc-link { + color: #58a6ff; +} + +.rich-markdown-editor .rich-markdown-doc-link.rich-markdown-doc-link--missing { + color: var(--muted-foreground); + text-decoration-style: dashed; +} + +.rich-markdown-mod-held .rich-markdown-editor .rich-markdown-doc-link { + cursor: pointer; +} + +.rich-markdown-mod-held + .rich-markdown-editor + .rich-markdown-doc-link.rich-markdown-doc-link--missing { + cursor: default; +} + +/* In-progress [[target]] text while cursor is inside. Matches the committed + doc-link styling so the visual doesn't jump when the atom node takes over. */ +.rich-markdown-editor .rich-markdown-doc-link-preview { + color: #0969da; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 45%, transparent); + text-underline-offset: 2px; +} + +.dark .rich-markdown-editor .rich-markdown-doc-link-preview { + color: #58a6ff; +} + +.rich-markdown-editor .rich-markdown-doc-link-preview.rich-markdown-doc-link-preview--missing { + color: var(--muted-foreground); + text-decoration-style: dashed; +} + /* ── Link Bubble ──────────────────────────────────────── */ .rich-markdown-link-bubble { diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index ef76088075e..d0b32989339 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -26,7 +26,9 @@ } .pane-manager-root .xterm-viewport { - overflow-y: auto; + /* Why: xterm/VS Code keep the scrollbar gutter stable. `auto` lets Linux + DOM scrollbars appear/disappear, which can feed resize/reflow jitter. */ + overflow-y: scroll; } /* xterm.css sets position:absolute;top:0 on .xterm-helpers but omits left, diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts new file mode 100644 index 00000000000..e5617c81427 --- /dev/null +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -0,0 +1,23 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { AgentStateDot, type AgentDotState } from './AgentStateDot' + +function renderMarkup(state: AgentDotState): string { + return renderToStaticMarkup(React.createElement(AgentStateDot, { state })) +} + +describe('AgentStateDot', () => { + it('renders done as an emerald check icon', () => { + const markup = renderMarkup('done') + + // Why: 'done' renders a CircleCheck icon rather than a dot so it is + // visually distinct from other emerald-adjacent states across surfaces. + // Note: the sidebar's StatusIndicator intentionally diverges and uses an + // emerald dot for 'done'. Assertion targets the lucide 'circle-check' + // class hook + emerald text color, identifying the check icon without + // coupling to the exact SVG path markup lucide emits. + expect(markup).toContain('lucide-circle-check') + expect(markup).toContain('text-emerald-500') + }) +}) diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 9cbf203351f..cd7d20ff534 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -1,11 +1,17 @@ import React from 'react' +import { CircleCheck } from 'lucide-react' import { cn } from '@/lib/utils' -// Why: shared state-dot primitive so the dashboard and the sidebar's agent -// hover render the same state vocabulary identically. The dot sits next to the -// agent icon (Claude/Codex/etc.) — they are two distinct glyphs: one for *who* -// (icon) and one for *what state* (dot). Keeping them separate keeps each -// glyph scannable at a glance instead of fused into a single decorated icon. +// Why: shared state-indicator primitive so the dashboard and the sidebar's +// agent hover share a single state vocabulary. Most states render as a dot; +// 'working' renders a spinner. 'done' intentionally diverges from the +// sidebar's StatusIndicator: the dashboard uses a check icon so completion +// is visually distinct from 'idle' (grey dot) and the sidebar's 'active' +// (emerald dot), while the sidebar collapses 'done'/'active' to the same +// emerald dot and relies on a tooltip. It sits next to the agent icon +// (Claude/Codex/etc.) — two distinct glyphs: one for *who* (agent icon) and +// one for *what state* (this indicator). Keeping them separate keeps each +// scannable instead of fused into one decorated icon. export type AgentDotState = | 'working' @@ -49,6 +55,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({ }: Props): React.JSX.Element { const box = size === 'md' ? 'h-3 w-3' : 'h-2.5 w-2.5' const inner = size === 'md' ? 'size-2' : 'size-1.5' + const icon = size === 'md' ? 'size-3' : 'size-2.5' if (state === 'working') { return ( @@ -58,7 +65,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({ > @@ -66,6 +73,21 @@ export const AgentStateDot = React.memo(function AgentStateDot({ ) } + if (state === 'done') { + // Why: the dashboard lists many agents, so a check glyph scans well for + // agent-reported completion and keeps 'done' visually distinct from + // 'idle' and other dot states at a glance. The sidebar's StatusIndicator + // intentionally diverges (emerald dot + tooltip) — see file header. + return ( + + + ) + } + return ( diff --git a/src/renderer/src/components/FirstLaunchBanner.tsx b/src/renderer/src/components/FirstLaunchBanner.tsx new file mode 100644 index 00000000000..8a03379786e --- /dev/null +++ b/src/renderer/src/components/FirstLaunchBanner.tsx @@ -0,0 +1,174 @@ +// Existing-user first-launch notice. Shown to users whose cohort marker is +// `existedBeforeTelemetryRelease === true` and whose `optedIn` is still +// `null`, i.e. users who installed Orca before the telemetry release and +// have not yet resolved the notice. +// +// Why existing users see a notice at all (and new users do not): pre- +// telemetry users installed Orca under a "no telemetry" social contract, +// so default-on for them would be a silent policy flip. New users are +// covered by the install-time disclosure and receive no first-launch UI — +// see telemetry-plan.md §First-launch experience. +// +// Three actions, two semantics: +// - "Got it" and the ✕ in the corner → silent acknowledge. Both persist +// `optedIn: true`, fire no opt-in event, route through +// `window.api.telemetryAcknowledgeBanner()` to a dedicated main-side +// channel so no `via` derivation can tag this path. Two surfaces for +// the same action because the ✕ alone is easy to miss; "Got it" is +// the discoverable primary, ✕ is the keyboard/notification-style +// escape. Either way the user sees the notice, chooses not to +// intervene, and is opted in silently. +// - "Turn off" → explicit opt-out. Routes through +// `window.api.telemetrySetOptIn(false)`; main derives +// `via = 'first_launch_banner'` from the pre-mutation state +// (existedBeforeTelemetryRelease=true, optedIn=null, incoming=false) +// and fires `telemetry_opted_out { via: 'first_launch_banner' }` +// BEFORE disabling the SDK — the one signal that tells us the +// opt-out flow is working must not be silenced by the opt-out it +// announces. +// - "Privacy policy" link → opens the privacy doc URL, no state change, +// no dismiss. +// +// No auto-dismiss, no delayed re-ask: once resolved (Got it / ✕ / Turn +// off), the notice never returns, because the cohort condition +// (`optedIn === null`) clears in all three resolving paths. + +import { useState } from 'react' +import { X } from 'lucide-react' + +import { Button } from './ui/button' +import { acknowledgeBanner, PRIVACY_URL, setOptIn as telemetrySetOptIn } from '../lib/telemetry' + +type FirstLaunchBannerProps = { + onResolve: () => void + fetchSettings: () => Promise +} + +export function FirstLaunchBanner({ + onResolve, + fetchSettings +}: FirstLaunchBannerProps): React.JSX.Element { + // Double-click guard. Without this, a fast second click on "Turn off" + // would re-enter telemetrySetOptIn(false); on the second call, main's + // deriveOptInVia sees currentOptedIn=false (just persisted by click 1) + // and falls through to the 'settings' branch, producing one opt-out + // intent tagged as two different `via` values. The acknowledge paths + // are guarded symmetrically — a second Got-it/✕ click would be a + // wasted IPC round-trip, but the guard also blocks a Turn-off click + // arriving mid-flight after an acknowledge (or vice versa). + const [inFlight, setInFlight] = useState(false) + + const handleAcknowledge = async (): Promise => { + if (inFlight) { + return + } + setInFlight(true) + // Main's `telemetry:acknowledgeBanner` handler persists `optedIn: true` + // without an opt-in event and intentionally does NOT broadcast + // `settings:changed` (see src/main/ipc/telemetry.ts). Without an + // explicit `fetchSettings()` refresh, the renderer store would retain + // `optedIn: null` and PrivacyPane would keep rendering its pending- + // banner helper text until the next full relaunch. Mirror + // PrivacyPane's handleToggle pattern which refetches for the same + // reason before surfacing UI changes. + try { + await acknowledgeBanner() + await fetchSettings() + onResolve() + } finally { + // Why: if `fetchSettings` rejects (IPC error during shutdown, + // settings file lock, etc.), `onResolve` never runs and the banner + // stays mounted. Without resetting `inFlight`, every button stays + // permanently disabled for the rest of the session. + setInFlight(false) + } + } + + const handleTurnOff = async (): Promise => { + if (inFlight) { + return + } + setInFlight(true) + // Opt-out fires `telemetry_opted_out { via: 'first_launch_banner' }` + // BEFORE the SDK disable — main enforces that ordering inside + // `setOptIn` (client.ts). The renderer just needs to route through + // `telemetrySetOptIn(false)` so the IPC handler derives the correct + // `via` and fires the event. + try { + await telemetrySetOptIn(false) + await fetchSettings() + onResolve() + } finally { + setInFlight(false) + } + } + + return ( + // Fixed-top strip so the notice overlays whatever is beneath without + // shifting the rest of the layout. `top-2` hugs the top edge of the + // window; on macOS the titlebar is drawn over the same region but the + // banner stays below the traffic lights via centering + narrow width. + // The notice is non-modal and intentionally does not occlude the main + // content — clicks pass through to below-notice regions outside this + // box. + // + // `relative` is load-bearing: the absolutely-positioned ✕ anchors to + // this container. +
+ {/* Text column — title + body stack on the left, takes remaining + width so the action column never pushes copy into a wrap. */} +
+

Help us decide what to build next

+

+ Anonymous counts of which features you use help us prioritize what to build. No file + contents, prompts, terminal output, or anything that identifies you. Change anytime in + Settings → Privacy & Telemetry.{' '} + + . +

+
+ {/* Action column — vertically centered against the text block. + "Got it" is the affirmative primary so it reads as the easy + path; "Turn off" is the secondary outline so the destructive + (from telemetry's perspective) action requires an explicit + choice. ✕ stays in the corner as a familiar escape and to + satisfy keyboard/notification-style dismiss expectations. */} +
+ + +
+ {/* aria-label says "Dismiss" — the action persists silent opt-in, + not just hides the UI. */} + +
+ ) +} diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index d2134e00a3c..6eef848bdcd 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: the GH item dialog keeps its header, conversation, files, and checks tabs co-located so the read-only PR/Issue surface stays in one place while this view evolves. */ import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { + AlignJustify, ArrowDown, ArrowRight, ArrowUp, @@ -11,7 +12,10 @@ import { CircleDot, ExternalLink, FileText, + Folder, + FolderOpen, GitPullRequest, + LayoutList, LoaderCircle, MessageSquare, MessageSquarePlus, @@ -36,6 +40,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { detectLanguage } from '@/lib/language-detect' import { cn } from '@/lib/utils' +import { buildDiffTree, type DiffTreeNode } from '@/components/pr-diff-tree' import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-helpers' import { filterPRCommentsByAudience, @@ -57,8 +62,10 @@ import { } from '@/lib/pr-comment-groups' import { useAppStore } from '@/store' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' - +import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' +import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' import type { + GitHubOwnerRepo, GitHubPRFile, GitHubPRFileContents, GitHubWorkItem, @@ -67,6 +74,29 @@ import type { GitHubReaction, PRComment } from '../../../shared/types' +import { PER_REPO_FETCH_LIMIT } from '../../../shared/work-items' + +// Why: the GH item dialog can be opened from any work-item list surface and +// doesn't have the full owner/repo context the list's cache entry carries. +// Parsing the canonical `https://github.com/{owner}/{repo}/...` URL is the +// simplest reliable source — the URL is already present on every work item +// and survives the main-process → IPC boundary. Non-GitHub hosts return null, +// which matches the indicator's suppression rule. +function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { + try { + const parsed = new URL(url) + if (parsed.hostname !== 'github.com') { + return null + } + const segments = parsed.pathname.split('/').filter(Boolean) + if (segments.length < 2) { + return null + } + return { owner: segments[0], repo: segments[1] } + } catch { + return null + } +} // Why: the editor's DiffViewer loads Monaco, which is heavy and should not be // pulled into the dialog's bundle until the user actually opens the Files tab. @@ -102,12 +132,35 @@ const REACTION_EMOJI: Record = { eyes: '👀' } +/** Why: Project-origin rows don't always belong to the active local repo. + * When set, GHEditSection routes label/assignee/state mutations through + * slug-addressed IPCs against `owner`/`repo` instead of through `repoPath`, + * preventing edits from silently landing on the workspace's repo when the + * Project view is showing rows from a different repo. See + * docs/design/github-project-view-tasks.md §Dialog editing from Project rows. + */ +export type GitHubItemDialogProjectOrigin = { + owner: string + repo: string + number: number + type: 'issue' | 'pr' + projectId: string + projectItemId: string + cacheKey: string +} + type GitHubItemDialogProps = { workItem: GitHubWorkItem | null repoPath: string | null /** Called when the user clicks the primary CTA to start work from this item. */ onUse: (item: GitHubWorkItem) => void onClose: () => void + /** Optional Project-origin context. When set, edits in the dialog are + * routed via slug-addressed mutation IPCs against the row's actual repo + * instead of the active workspace's `repoPath`. Both can be set + * simultaneously (Project mode where the row also lives in the active + * workspace) — slug routing wins for writes. */ + projectOrigin?: GitHubItemDialogProjectOrigin } function formatRelativeTime(input: string): string { @@ -315,6 +368,130 @@ type FileRowProps = { baseSha: string | undefined } +type DiffViewMode = 'flat' | 'tree' + +// ─── Tree view components ──────────────────────────────────────────── + +type DiffTreeNodeProps = { + node: DiffTreeNode + depth: number + repoPath: string + prNumber: number + headSha: string | undefined + baseSha: string | undefined + onCommentAdded: (comment: PRComment) => void +} + +function PRDiffTreeNode({ + node, + depth, + repoPath, + prNumber, + headSha, + baseSha, + onCommentAdded +}: DiffTreeNodeProps): React.JSX.Element { + const [open, setOpen] = useState(true) + + if (node.kind === 'file') { + return ( + + ) + } + + // Directory node + return ( +
+ + {open && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ) +} + +type PRDiffTreeViewProps = { + files: GitHubPRFile[] + repoPath: string + prNumber: number + headSha: string | undefined + baseSha: string | undefined + onCommentAdded: (comment: PRComment) => void +} + +function PRDiffTreeView({ + files, + repoPath, + prNumber, + headSha, + baseSha, + onCommentAdded +}: PRDiffTreeViewProps): React.JSX.Element { + const tree = useMemo(() => buildDiffTree(files), [files]) + return ( +
+ {tree.map((node) => ( + + ))} +
+ ) +} + // Why: bounded LRU — opening many PRs with many files during a session // would otherwise grow this module-level map without bound until reload. const PR_FILE_CONTENT_CACHE_MAX = 64 @@ -396,8 +573,14 @@ function PRFileRow({ prNumber, headSha, baseSha, - onCommentAdded -}: FileRowProps & { onCommentAdded: (comment: PRComment) => void }): React.JSX.Element { + onCommentAdded, + indentDepth = 0, + label +}: FileRowProps & { + onCommentAdded: (comment: PRComment) => void + indentDepth?: number + label?: string +}): React.JSX.Element { const [expanded, setExpanded] = useState(false) const [contents, setContents] = useState(null) const [loading, setLoading] = useState(false) @@ -469,11 +652,12 @@ function PRFileRow({ ) return ( -
+
+
+ ) : null}
{rightPanel} @@ -1443,9 +1669,44 @@ function MentionTextarea({ ) } +// Why: when the dialog opens for a Project row whose repo differs from the +// active workspace, mutations must target the row's actual repo via +// slug-addressed IPCs. Otherwise edits silently apply to the workspace's +// repo. The edit IPCs return a structured `{ ok, error }` shape; we adapt +// to a thrown rejection so the existing `useImmediateMutation` flow +// (which expects throws on failure) continues to work unchanged. +async function runIssueUpdate(args: { + repoPath: string | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined + number: number + updates: Parameters[0]['updates'] +}): Promise { + if (args.projectOrigin) { + const res = await window.api.gh.updateIssueBySlug({ + owner: args.projectOrigin.owner, + repo: args.projectOrigin.repo, + number: args.number, + updates: args.updates + }) + if (!res.ok) { + throw new Error(res.error.message) + } + return + } + if (!args.repoPath) { + throw new Error('No repo context available for this edit.') + } + await window.api.gh.updateIssue({ + repoPath: args.repoPath, + number: args.number, + updates: args.updates + }) +} + function GHEditSection({ item, repoPath, + projectOrigin, localState, localLabels, onStateChange, @@ -1453,7 +1714,8 @@ function GHEditSection({ assignees }: { item: GitHubWorkItem - repoPath: string + repoPath: string | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined localState: GitHubWorkItem['state'] localLabels: string[] onStateChange: (state: GitHubWorkItem['state']) => void @@ -1465,10 +1727,35 @@ function GHEditSection({ const [localAssignees, setLocalAssignees] = useState(assignees) const hasEditedAssigneesRef = useRef(false) const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) const { isPending, run } = useImmediateMutation() - const repoLabels = useRepoLabels(repoPath) - const repoAssignees = useRepoAssignees(repoPath) + // Why: when the dialog opens from a Project view, mutations route through + // *BySlug IPCs and we must keep `projectViewCache` in sync alongside + // `workItemsCache` — `patchWorkItem` only walks the latter, so without this + // helper the Project table would render stale data until manual refresh. + // See docs/design/github-project-view-tasks.md §Dialog editing from Project rows. + const patchProjectRowIfNeeded = useCallback( + (patch: Parameters[2]) => { + if (!projectOrigin) { + return + } + patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, patch) + }, + [projectOrigin, patchProjectRowContent] + ) + + // Why: when projectOrigin is set we MUST read labels/assignees from the + // row's repo, not from the workspace path — otherwise the popovers list + // values from a different repo than the writes target. + const slugOwner = projectOrigin?.owner ?? null + const slugRepo = projectOrigin?.repo ?? null + const repoLabelsByPath = useRepoLabels(projectOrigin ? null : repoPath) + const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo) + const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath + const repoAssigneesByPath = useRepoAssignees(projectOrigin ? null : repoPath) + const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees) + const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath // Why: sync local assignees when item changes or when the detail fetch // resolves with real data — but skip if the user already made an @@ -1493,26 +1780,40 @@ function GHEditSection({ const prevState = localState run('state', { mutate: () => - window.api.gh.updateIssue({ + runIssueUpdate({ repoPath, + projectOrigin, number: item.number, updates: { state: newState } }), onOptimistic: () => { onStateChange(newState) patchWorkItem(item.id, { state: newState }) + patchProjectRowIfNeeded({ state: newState }) }, onRevert: () => { onStateChange(prevState) patchWorkItem(item.id, { state: prevState }) + patchProjectRowIfNeeded({ state: prevState }) }, onSuccess: () => { patchWorkItem(item.id, { state: newState }) + patchProjectRowIfNeeded({ state: newState }) }, onError: (err) => toast.error(err) }) }, - [item.id, item.number, localState, repoPath, patchWorkItem, run, onStateChange] + [ + item.id, + item.number, + localState, + repoPath, + projectOrigin, + patchWorkItem, + patchProjectRowIfNeeded, + run, + onStateChange + ] ) const handleLabelToggle = useCallback( @@ -1524,44 +1825,60 @@ function GHEditSection({ if (isAdding) { run('labels', { mutate: () => - window.api.gh.updateIssue({ + runIssueUpdate({ repoPath, + projectOrigin, number: item.number, updates: { addLabels: [label] } }), onOptimistic: () => { onLabelsChange(newLabels) patchWorkItem(item.id, { labels: newLabels }) + patchProjectRowIfNeeded({ labels: newLabels }) }, onSuccess: () => {}, onRevert: () => { onLabelsChange(prevLabels) patchWorkItem(item.id, { labels: prevLabels }) + patchProjectRowIfNeeded({ labels: prevLabels }) }, onError: (err) => toast.error(err) }) } else { run('labels', { mutate: () => - window.api.gh.updateIssue({ + runIssueUpdate({ repoPath, + projectOrigin, number: item.number, updates: { removeLabels: [label] } }), onOptimistic: () => { onLabelsChange(newLabels) patchWorkItem(item.id, { labels: newLabels }) + patchProjectRowIfNeeded({ labels: newLabels }) }, onRevert: () => { onLabelsChange(prevLabels) patchWorkItem(item.id, { labels: prevLabels }) + patchProjectRowIfNeeded({ labels: prevLabels }) }, onSuccess: () => {}, onError: (err) => toast.error(err) }) } }, - [item.id, item.number, localLabels, repoPath, patchWorkItem, run, onLabelsChange] + [ + item.id, + item.number, + localLabels, + repoPath, + projectOrigin, + patchWorkItem, + patchProjectRowIfNeeded, + run, + onLabelsChange + ] ) const handleAssigneeToggle = useCallback( @@ -1576,16 +1893,19 @@ function GHEditSection({ if (isAssigned) { run('assignees', { mutate: () => - window.api.gh.updateIssue({ + runIssueUpdate({ repoPath, + projectOrigin, number: item.number, updates: { removeAssignees: [login] } }), onOptimistic: () => { setLocalAssignees(newAssignees) + patchProjectRowIfNeeded({ assignees: newAssignees }) }, onRevert: () => { setLocalAssignees(prevAssignees) + patchProjectRowIfNeeded({ assignees: prevAssignees }) }, onSuccess: () => {}, onError: (err) => toast.error(err) @@ -1593,23 +1913,26 @@ function GHEditSection({ } else { run('assignees', { mutate: () => - window.api.gh.updateIssue({ + runIssueUpdate({ repoPath, + projectOrigin, number: item.number, updates: { addAssignees: [login] } }), onOptimistic: () => { setLocalAssignees(newAssignees) + patchProjectRowIfNeeded({ assignees: newAssignees }) }, onSuccess: () => {}, onRevert: () => { setLocalAssignees(prevAssignees) + patchProjectRowIfNeeded({ assignees: prevAssignees }) }, onError: (err) => toast.error(err) }) } }, - [item.number, repoPath, localAssignees, run] + [item.number, repoPath, projectOrigin, localAssignees, patchProjectRowIfNeeded, run] ) if (item.type === 'pr') { @@ -1815,7 +2138,7 @@ function GHCommentComposer({ return } el.style.height = 'auto' - el.style.height = `${Math.max(36, Math.min(el.scrollHeight, 96))}px` + el.style.height = `${Math.max(80, Math.min(el.scrollHeight, 240))}px` }, []) const handleSubmit = useCallback(async () => { @@ -1857,12 +2180,7 @@ function GHCommentComposer({ ) return ( -
+
+ + + Flat view + + + + + + + + Tree view + + +
+ {diffViewMode === 'flat' ? ( + files.map((file) => ( + + )) + ) : ( + - ))} + )}
)} diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 8d1ca8072b1..3e97240c3a1 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -252,7 +252,7 @@ export default function Landing(): React.JSX.Element { className="inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-sm px-4 py-2 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed enabled:cursor-pointer enabled:hover:bg-accent" disabled={!canCreateWorktree} title={!canCreateWorktree ? 'Add a Git project first' : undefined} - onClick={() => openModal('new-workspace-composer')} + onClick={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })} > Create Worktree diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 3b27aab1996..7cacd39f437 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -11,14 +11,17 @@ import { Settings2 } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import RepoCombobox from '@/components/repo/RepoCombobox' import AgentCombobox from '@/components/agent/AgentCombobox' import { AGENT_CATALOG } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' -import type { TuiAgent } from '../../../shared/types' +import type { GitHubWorkItem, LinearIssue, SparsePreset, TuiAgent } from '../../../shared/types' +import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPresetSelect' +import SmartWorkspaceNameField, { + type SmartWorkspaceNameSelection +} from '@/components/new-workspace/SmartWorkspaceNameField' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -34,7 +37,12 @@ type NewWorkspaceComposerCardProps = { repoId: string onRepoChange: (value: string) => void name: string - onNameChange: (event: React.ChangeEvent) => void + onNameValueChange: (value: string) => void + onSmartGitHubItemSelect: (item: GitHubWorkItem) => void + onSmartBranchSelect: (refName: string) => void + onSmartLinearIssueSelect: (issue: LinearIssue) => void + smartNameSelection: SmartWorkspaceNameSelection | null + onClearSmartNameSelection: () => void detectedAgentIds: Set | null onOpenAgentSettings: () => void advancedOpen: boolean @@ -51,6 +59,10 @@ type NewWorkspaceComposerCardProps = { shouldWaitForSetupCheck: boolean resolvedSetupDecision: 'run' | 'skip' | null createError: string | null + canUseSparseCheckout: boolean + sparsePresets: SparsePreset[] + sparseSelectedPresetId: string | null + onSparseSelectPreset: (preset: SparsePreset | null) => void } function SetupCommandPreview({ @@ -170,7 +182,12 @@ export default function NewWorkspaceComposerCard({ repoId, onRepoChange, name, - onNameChange, + onNameValueChange, + onSmartGitHubItemSelect, + onSmartBranchSelect, + onSmartLinearIssueSelect, + smartNameSelection, + onClearSmartNameSelection, detectedAgentIds, onOpenAgentSettings, advancedOpen, @@ -186,7 +203,11 @@ export default function NewWorkspaceComposerCard({ onSetupDecisionChange, shouldWaitForSetupCheck, resolvedSetupDecision, - createError + createError, + canUseSparseCheckout, + sparsePresets, + sparseSelectedPresetId, + onSparseSelectPreset }: NewWorkspaceComposerCardProps): React.JSX.Element { const { isFileDragOver, dragHandlers } = useComposerFileDragOver() const openModal = useAppStore((s) => s.openModal) @@ -231,12 +252,12 @@ export default function NewWorkspaceComposerCard({ onDragEnter={dragHandlers.onDragEnter} onDragLeave={dragHandlers.onDragLeave} className={cn( - 'grid gap-1 rounded-md transition', + 'grid min-w-0 gap-1 rounded-md transition', isFileDragOver && 'ring-2 ring-ring/30', containerClassName )} > -
+
@@ -275,30 +296,33 @@ export default function NewWorkspaceComposerCard({ />
-
+
- { + onValueChange={onNameValueChange} + onGitHubItemSelect={onSmartGitHubItemSelect} + onBranchSelect={onSmartBranchSelect} + onLinearIssueSelect={onSmartLinearIssueSelect} + selectedSource={smartNameSelection} + onClearSelectedSource={onClearSmartNameSelection} + onPlainEnter={() => { // Why: Enter on the workspace name advances focus to the next // field (Agent combobox) rather than submitting, letting the user // progress through the form with just the keyboard. - if (event.key !== 'Enter' || event.shiftKey || event.metaKey || event.ctrlKey) { - return - } - event.preventDefault() const root = composerRef?.current const agentTrigger = root?.querySelector( '[data-agent-combobox-root="true"][role="combobox"]' ) agentTrigger?.focus() }} - placeholder="Workspace name" - className="h-9 text-sm" />
@@ -312,6 +336,10 @@ export default function NewWorkspaceComposerCard({ variant="ghost" size="icon-xs" onClick={onOpenAgentSettings} + // Why: keep Tab flow Name → Agent combobox. This settings + // shortcut is a detour; making it tabbable forces a keystroke + // on every workspace creation. + tabIndex={-1} className="size-5 shrink-0 rounded-sm text-muted-foreground hover:text-foreground" aria-label="Open agent settings" > @@ -335,10 +363,25 @@ export default function NewWorkspaceComposerCard({ />
+
+ +
+
@@ -347,7 +390,33 @@ export default function NewWorkspaceComposerCard({ textarea's 3px outset focus ring has horizontal breathing room inside the overflow-hidden drawer above. Without it the ring gets clipped on the right edge when the field is focused. */} -
+
+ {smartNameSelection ? ( + // Why: when a source (PR/issue/Linear/branch) is picked the + // smart field shows a pill instead of an editable name, so + // surface the auto-derived workspace name here under Advanced + // where it can be reviewed/overridden. When the user typed an + // explicit name there's no source pill — the smart input is + // already the name field, so we don't duplicate it here. +
+ + onNameValueChange(event.target.value)} + placeholder="Workspace name" + className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" + /> +
+ ) : null} +