diff --git a/.ade/cto/identity.yaml b/.ade/cto/identity.yaml index a6ca64553..265223cd2 100644 --- a/.ade/cto/identity.yaml +++ b/.ade/cto/identity.yaml @@ -11,12 +11,6 @@ memoryPolicy: compactionThreshold: 0.7 preCompactionFlush: true temporalDecayHalfLifeDays: 30 -openclawContextPolicy: - shareMode: filtered - blockedCategories: - - secret - - token - - system_prompt onboardingState: completedSteps: - identity diff --git a/.claude/commands/automate.md b/.claude/commands/automate.md index 17d5e9a8f..57320dd16 100644 --- a/.claude/commands/automate.md +++ b/.claude/commands/automate.md @@ -158,9 +158,316 @@ Fix until passing before moving to the next. --- +## Parity Passes (4–7) + +After the test-suite work above, run four parity reviewers that keep docs, iOS, the CLI, and the TUI in lockstep with the desktop changes on this branch. They are independent of one another and of Passes 1–3. + +**Preferred: TeamCreate** for these four passes so progress is tracked and a single completion event surfaces the batch. Per the global git-worktrees policy, do not pass worktree isolation. Fallback: parallel `Agent` calls in a single tool-call round if TeamCreate is unavailable. + +--- + +## Pass 4: DOCS + +The internal docs live under `docs/` with this structure (rebuilt; do NOT confuse with the public Mintlify site at repo root `docs.json` + `*.mdx`): + +``` +docs/ +├── README.md # navigation map +├── PRD.md # product entry point — links to every feature +├── ARCHITECTURE.md # consolidated system architecture +├── OPTIMIZATION_OPPORTUNITIES.md # backlog (append-only) +└── features/ + ├── agents/ ├── memory/ + ├── automations/ ├── missions/ + ├── chat/ ├── onboarding-and-settings/ + ├── computer-use/ ├── project-home/ + ├── conflicts/ ├── pull-requests/ + ├── context-packs/ ├── sync-and-multi-device/ + ├── cto/ ├── terminals-and-sessions/ + ├── files-and-editor/ └── workspace-graph/ + ├── history/ + ├── lanes/ + └── linear-integration/ +``` + +Each `features//` contains a `README.md` (overview + source file map at top) plus 1–4 detail `*.md` files. + +Spawn a general-purpose agent with this prompt: + +``` +You are the documentation updater for the ADE project. + +Analyze all changes on the current branch vs main and update relevant internal +docs under `docs/`. The public Mintlify site (docs.json + root-level .mdx files) +is out of scope — do NOT touch it. + +Step 1: Get changed files + git diff main --name-only + git diff main --stat | tail -30 + +Step 2: Map changed source to internal docs + +| Source Directory | Doc Location | +|----------------------------------------------------|----------------------------------------------------| +| apps/desktop/src/main/services/orchestrator/ | docs/features/missions/ | +| apps/desktop/src/main/services/projects/ | docs/features/project-home/ | +| apps/desktop/src/main/services/proof/ | docs/features/proof.md | +| apps/desktop/src/main/services/review/ | docs/features/pull-requests/ | +| apps/desktop/src/main/services/prs/ | docs/features/pull-requests/ | +| apps/desktop/src/main/services/lanes/ | docs/features/lanes/ | +| apps/desktop/src/main/services/memory/ | docs/features/memory/ | +| apps/desktop/src/main/services/cto/ | docs/features/cto/ (+ linear-integration/) | +| apps/desktop/src/main/services/ai/ | docs/features/chat/ + features/agents/ | +| apps/desktop/src/main/services/chat/ | docs/features/chat/ | +| apps/desktop/src/main/services/automations/ | docs/features/automations/ | +| apps/desktop/src/main/services/computerUse/ | docs/features/computer-use/ | +| apps/desktop/src/main/services/context/ | docs/features/context-packs/ | +| apps/desktop/src/main/services/conflicts/ | docs/features/conflicts/ | +| apps/desktop/src/main/services/files/ | docs/features/files-and-editor/ | +| apps/desktop/src/main/services/history/ | docs/features/history/ | +| apps/desktop/src/main/services/onboarding/ | docs/features/onboarding-and-settings/ | +| apps/desktop/src/main/services/pty/ | docs/features/terminals-and-sessions/ | +| apps/desktop/src/main/services/sessions/ | docs/features/terminals-and-sessions/ | +| apps/desktop/src/main/services/processes/ | docs/features/terminals-and-sessions/ | +| apps/desktop/src/main/services/sync/ | docs/features/sync-and-multi-device/ | +| apps/desktop/src/main/services/config/ | docs/features/onboarding-and-settings/ | +| apps/desktop/src/main/services/ipc/ | docs/ARCHITECTURE.md (IPC section) | +| apps/desktop/src/main/services/git/ | docs/ARCHITECTURE.md (Git engine section) + lanes/ | +| apps/desktop/src/preload/ | docs/ARCHITECTURE.md (IPC contract) | +| apps/desktop/src/shared/ | docs/ARCHITECTURE.md + touching feature's doc | +| apps/desktop/src/renderer/components// | docs/features// | +| apps/desktop/src/renderer/state/ | docs/ARCHITECTURE.md (UI framework) | +| apps/ade-cli/src/tuiClient/ | docs/features/ade-code/README.md + docs/ARCHITECTURE.md (ADE CLI / Build/Test/Deploy) | +| apps/ade-cli/ | docs/ARCHITECTURE.md (ADE CLI / Build/Test/Deploy) + docs/features/agents/ | +| .github/workflows/ | docs/ARCHITECTURE.md (Build/Test/Deploy) | +| apps/ios/ | docs/features/sync-and-multi-device/ios-companion.md | +| apps/web/ | docs/ARCHITECTURE.md (Apps & Processes) | + +Step 3: Update docs in place +- Prefer editing existing docs over creating new ones. +- If a feature gets a genuinely new sub-concept worth its own page, add a new detail doc inside the existing features// folder. +- Keep each README.md's "Source file map" section current — it is the primary way an agent orients itself. +- Rewrite prose to reflect current reality (not a changelog of what changed). +- Remove outdated information. +- Do NOT add changelog sections, "Updated on X" notes, or dated markers. +- Do NOT modify docs/OPTIMIZATION_OPPORTUNITIES.md via this agent — it is append-only and human-curated. + +Step 4: Run doc validation + node scripts/validate-docs.mjs + +This validator only covers the Mintlify site. For internal docs, self-check: + - Every features//README.md still has a "Source file map" section. + - PRD.md links resolve (grep for broken relative links). + +Report what docs were updated and what was changed. +``` + +--- + +## Pass 5: MOBILE parity + +Spawn a general-purpose agent with this prompt: + +``` +You are the mobile parity reviewer for the ADE project. + +Analyze all work on the current branch vs main, including changes that are +already under review and any simplifications made during `/finalize`. Determine +whether the iOS companion app under `apps/ios/` needs matching updates. + +Step 1: Get branch context + git diff main --name-only + git diff main --stat | tail -30 + git log main..HEAD --oneline + +Step 2: Identify cross-platform changes +- Shared contracts: apps/desktop/src/shared/**, preload IPC types, sync payloads, + PR mobile snapshots, chat/session models, lane summaries, config schemas. +- Desktop behavior with a mobile surface: PR workflows, lanes, Work chat, + files, sync/multi-device, settings exposed on iOS, model/session controls. +- Renderer-only desktop preferences are only mobile-applicable when the iOS app + has the same user-facing concept and a native implementation path. + +Step 3: Inspect iOS equivalents +- Search `apps/ios/ADE` and `apps/ios/ADETests` for the affected model, view, + service, or workflow names. +- If the branch adds or changes a host/mobile contract, update Swift Codable + models and iOS tests as needed. +- If the branch changes user-facing behavior that iOS already exposes, update + the SwiftUI view using native iOS controls and existing ADE design patterns. +- If the change is not applicable to iOS, explain why in the report. + +Step 4: Apply required iOS updates +- Keep edits scoped to `apps/ios/` unless a shared contract fix is required. +- Prefer existing SwiftUI patterns and native controls. +- Preserve Dynamic Type, VoiceOver labels, and 44x44 tap targets. +- Add or update targeted tests in `apps/ios/ADETests` for contract changes. + +Step 5: Validate what you touched +- At minimum: `xcrun swiftc -parse ` when a full Xcode + build/test run is unavailable. +- Prefer an iOS build/test when the local simulator/runtime environment supports it. + +Report: +- iOS files changed, or "No iOS changes required" +- Why each desktop/shared change was applicable or not applicable to mobile +- Validation run and any environment limitations +``` + +--- + +## Pass 6: CLI parity + +The `apps/ade-cli/` package is the agent-facing surface for ADE. Every desktop +action should be reachable either through a typed subcommand (`ade lanes …`, +`ade prs …`, `ade chat …`, `ade tests …`, `ade run …`, `ade proof …`) or +through the generic `ade actions run ` registry exposed by +`adeRpcServer.ts`. When a feature branch adds, renames, or removes a desktop +feature, the CLI silently drifts unless someone updates it in the same PR. +This agent closes that gap. + +Spawn a general-purpose agent with this prompt: + +``` +You are the ADE CLI parity reviewer. + +The ADE CLI (apps/ade-cli) is the primary agent-facing interface to the ADE +desktop app. Its goal is to surface every meaningful action inside ADE +desktop — either as a typed subcommand or via the generic +`ade actions run ` registry. When desktop changes, the CLI +must change with it. Your job is to detect drift on this branch and patch +apps/ade-cli/ so the CLI stays in lockstep with desktop. + +Step 1: Get branch context + git diff main --name-only + git diff main --stat | tail -30 + git log main..HEAD --oneline + +Step 2: Identify CLI-relevant desktop changes +Treat anything under these paths as a candidate for new / changed / removed +CLI surface: +- apps/desktop/src/main/services/** (each service is a candidate action + domain — lanes, prs, chat, tests, proof, run, git, files, missions, + automations, computerUse, context, conflicts, history, memory, onboarding, + pty, sessions, processes, sync, config, cto, ai) +- apps/desktop/src/preload/** and apps/desktop/src/shared/** (IPC and + shared contracts the CLI ultimately calls through) +- New domains/actions registered with the action registry on either side + +Step 3: Map each candidate to the CLI +- Typed subcommands live in apps/ade-cli/src/cli.ts (~3300 lines), a + case-based dispatcher. Existing cases include lanes, git-status, prs-list, + chat-list, tests-runs, proof-list, actions-list, action-result, etc. + Locate the closest existing case block and either extend it or add a + sibling case alongside it. +- The RPC + actions-registry surface lives in + apps/ade-cli/src/adeRpcServer.ts (~6500 lines), with a no-desktop fallback + in apps/ade-cli/src/headlessLinearServices.ts. New service actions usually + need wiring in one or both so `ade actions run ` resolves + them whether or not the desktop socket is up. +- The user-facing inventory lives in apps/ade-cli/README.md under + "CLI surface". Keep it accurate whenever a typed command is added, + renamed, or removed. + +Step 4: Apply auto-fix edits — scoped to apps/ade-cli/ only +- New feature: add a typed subcommand if the desktop feature is a distinct + user-facing workflow (lane / PR / chat / test / run / proof / mission / + automation / etc.). If it is just a new low-level service action, ensure + it is reachable via the actions registry and skip a typed wrapper. +- Renamed or behavior-changed feature: update the existing case to match + new parameters, IPC names, or output shape. Keep flag names stable when + possible — flag any breaking renames in the report. +- Removed feature: delete the dead case and any registry wiring. Do NOT + leave a stub. Drop the corresponding README line. +- Reuse existing patterns: match surrounding cases for argv parsing, + --text / --json output mode, error formatting, and --lane / --project-root + argument handling. Do not invent new dispatch styles. + +Step 5: Validate locally before reporting + cd apps/ade-cli && npm run typecheck + cd apps/ade-cli && npm test + +If tests fail in files you did not touch, leave them — Phase 3 handles +test-suite drift. Do not rewrite unrelated tests. + +Out of scope: +- Do NOT edit anything under apps/desktop/. +- Do NOT touch docs/ — the docs agent owns that. +- Do NOT refactor unrelated CLI code. + +Report: +- apps/ade-cli/ files changed (or "no CLI changes required") +- For each branch change: desktop change → CLI change, or why not applicable +- Any breaking flag / command renames +- typecheck and test results +``` + +--- + +## Pass 7: TUI parity + +`apps/ade-cli/src/tuiClient/` is the terminal client for `ade code`. It surfaces lanes, chats, git state, and slash commands in a 28-col Drawer + 38-col RightPane Ink UI. When desktop or ade-cli changes, the TUI must stay in lockstep — most commonly because a new git/lane/PR action becomes available, a slash command is renamed, or a lane summary field is added. + +Spawn a general-purpose agent with this prompt: + +``` +You are the ADE TUI parity reviewer. + +`apps/ade-cli/src/tuiClient/` is the terminal client for `ade code`. It surfaces lanes, chats, git +state, and slash commands in a 28-col Drawer + 38-col RightPane Ink UI. +When desktop or ade-cli changes, the TUI must stay in lockstep — most +commonly because a new git/lane/PR action becomes available, a slash +command is renamed, or a lane summary field is added. + +Step 1: Get branch context + git diff main --name-only + git diff main --stat | tail -30 + +Step 2: Identify TUI-relevant changes. Treat as candidates: +- apps/desktop/src/shared/types/lanes.ts, /chat, /sync — TUI imports these directly. +- apps/ade-cli/src/adeRpcServer.ts new actions — should appear in BUILTIN_COMMANDS or via /ade. +- New IPC handlers in window.ade.git/.lanes/.app/.prs — TUI may want a slash command + right-pane action wrapper. + +Step 3: Map to the TUI surface +- Slash commands: apps/ade-cli/src/tuiClient/commands.ts BUILTIN_COMMANDS. +- Slash dispatch: apps/ade-cli/src/tuiClient/app.tsx (search by name pattern, e.g. `if (name === "/push")`). +- Sidebar rendering: apps/ade-cli/src/tuiClient/components/Drawer.tsx. +- Right pane content kinds: apps/ade-cli/src/tuiClient/components/RightPane.tsx + types.ts (RightPaneContent union). +- ADE API calls: apps/ade-cli/src/tuiClient/adeApi.ts. + +Step 4: Apply auto-fix edits — scoped to apps/ade-cli/src/tuiClient/ only. +- New action: add a BUILTIN_COMMANDS entry + dispatch case. Mirror existing + shape (placement, argumentHint). +- Renamed action: rename in commands.ts and the dispatch handler. Keep the + user-facing slash name stable when possible — flag breaking renames. +- Removed action: delete the BUILTIN_COMMANDS entry and its dispatch case. +- New LaneSummary or AgentChatSessionSummary fields: surface in Drawer.tsx + if relevant to lane/chat list rendering, or in lane-details RightPane + block if relevant to status. + +Step 5: Validate + cd apps/ade-cli && npm run typecheck + cd apps/ade-cli && npx vitest run src/tuiClient + +Out of scope: +- Do NOT edit apps/desktop/ or apps/ios/. +- Do NOT edit unrelated apps/ade-cli code unless the `ade code` launcher in apps/ade-cli/src/cli.ts must change with the TUI. +- Do NOT touch docs/ — the docs agent owns that. + +Report: +- apps/ade-cli/src/tuiClient/ files changed (or "no TUI changes required") +- For each branch change: source change → TUI change, or why not applicable +- Any breaking slash-command renames +- typecheck and test results +``` + +Wait for all four parity agents to complete before moving to Verification. + +--- + ## Verification -After all three passes: +After all seven passes: 1. **Run the affected shards**, not the full suite (`/finalize` runs everything): ```bash @@ -241,6 +548,13 @@ Added: - - Or "none — feature was visual / fully covered by consolidation" +Parity: +- Docs: — validation PASS / blocked +- Mobile: — validation PASS / blocked +- CLI: — typecheck + tests PASS / blocked +- TUI: — typecheck + tests PASS / blocked +- Breaking flag/command/slash renames: + Verification: - Affected files: PASS ( tests) - Shard re-run: PASS diff --git a/.claude/commands/finalize.md b/.claude/commands/finalize.md index d49150e80..53e9f4cdd 100644 --- a/.claude/commands/finalize.md +++ b/.claude/commands/finalize.md @@ -1,6 +1,6 @@ --- name: finalize -description: 'Final gate: simplify code, update docs, and run local CI checks before pushing' +description: 'Final gate: simplify code, validate docs, and run local CI checks before pushing' --- # Finalize Command @@ -9,7 +9,7 @@ This command is the final gate before pushing and opening a PR. It guarantees three outcomes: 1. Code quality cleanup is complete -2. Docs are current +2. Docs changed by `/automate` are still valid 3. Local CI checks pass It does **not** guarantee that remote PR review is complete after a push. GitHub's @@ -45,10 +45,10 @@ Outputs are exactly two things: the Phase 4 summary, and fatal-error messages (t ## Pipeline Overview ``` -Phase 1: Analyze code changes and batch simplification work (lead) -Phase 2: Parallel execution (simplify + docs + mobile + cli) (agents) -Phase 3: CI sync + local verification (lead) -Phase 4: Summary (lead) +Phase 1: Analyze & Prepare Code Simplification (lead) +Phase 2: Code Simplification (agents) +Phase 3: CI sync + local verification (lead) +Phase 4: Summary (lead) ``` --- @@ -96,13 +96,9 @@ git diff main --name-only | sort > /tmp/finalize-branch-files.txt --- -## Phase 2: Parallel Execution +## Phase 2: Code Simplification -**Preferred orchestration: `TeamCreate`.** Spawn the four agents below as one team so progress is tracked, inboxes catch cross-agent messages, and a single completion event surfaces the whole batch. Per the global git-worktrees policy, do **not** pass worktree isolation — all agents work in the main directory. - -Fallback: if `TeamCreate` is unavailable in the current harness (or if running outside Claude entirely), spawn them as parallel `Agent` calls in a single tool-call round and aggregate their reports manually before Phase 3. - -### Simplifier agents (1-3 based on batch size) +Spawn 1–3 simplifier agents based on batch size from Phase 1c. Use TeamCreate when available; parallel Agent calls otherwise. Per the global git-worktrees policy, do **not** pass worktree isolation — all agents work in the main directory. Use `subagent_type: "code-simplifier:code-simplifier"` for each batch (note the full namespaced form — plain `"code-simplifier"` is not a valid agent type). @@ -114,238 +110,9 @@ Prompt each with: - **Diff-only scope**: `git diff main -- ` first; if zero diff, do not edit (a previous run tried to simplify files it thought were modified, and wasted time on unchanged code). - **Typecheck after every file**: `cd apps/desktop && npx tsc --noEmit -p . 2>&1 | head -20`. -### Doc updater agent - -The internal docs live under `docs/` with this structure (rebuilt; do NOT confuse with the public Mintlify site at repo root `docs.json` + `*.mdx`): - -``` -docs/ -├── README.md # navigation map -├── PRD.md # product entry point — links to every feature -├── ARCHITECTURE.md # consolidated system architecture -├── OPTIMIZATION_OPPORTUNITIES.md # backlog (append-only) -└── features/ - ├── agents/ ├── memory/ - ├── automations/ ├── missions/ - ├── chat/ ├── onboarding-and-settings/ - ├── computer-use/ ├── project-home/ - ├── conflicts/ ├── pull-requests/ - ├── context-packs/ ├── sync-and-multi-device/ - ├── cto/ ├── terminals-and-sessions/ - ├── files-and-editor/ └── workspace-graph/ - ├── history/ - ├── lanes/ - └── linear-integration/ -``` - -Each `features//` contains a `README.md` (overview + source file map at top) plus 1–4 detail `*.md` files. - -Spawn a general-purpose agent with this prompt: - -``` -You are the documentation updater for the ADE project. - -Analyze all changes on the current branch vs main and update relevant internal -docs under `docs/`. The public Mintlify site (docs.json + root-level .mdx files) -is out of scope — do NOT touch it. - -Step 1: Get changed files - git diff main --name-only - git diff main --stat | tail -30 - -Step 2: Map changed source to internal docs - -| Source Directory | Doc Location | -|----------------------------------------------------|----------------------------------------------------| -| apps/desktop/src/main/services/orchestrator/ | docs/features/missions/ | -| apps/desktop/src/main/services/projects/ | docs/features/project-home/ | -| apps/desktop/src/main/services/proof/ | docs/features/proof.md | -| apps/desktop/src/main/services/review/ | docs/features/pull-requests/ | -| apps/desktop/src/main/services/prs/ | docs/features/pull-requests/ | -| apps/desktop/src/main/services/lanes/ | docs/features/lanes/ | -| apps/desktop/src/main/services/memory/ | docs/features/memory/ | -| apps/desktop/src/main/services/cto/ | docs/features/cto/ (+ linear-integration/) | -| apps/desktop/src/main/services/ai/ | docs/features/chat/ + features/agents/ | -| apps/desktop/src/main/services/chat/ | docs/features/chat/ | -| apps/desktop/src/main/services/automations/ | docs/features/automations/ | -| apps/desktop/src/main/services/computerUse/ | docs/features/computer-use/ | -| apps/desktop/src/main/services/context/ | docs/features/context-packs/ | -| apps/desktop/src/main/services/conflicts/ | docs/features/conflicts/ | -| apps/desktop/src/main/services/files/ | docs/features/files-and-editor/ | -| apps/desktop/src/main/services/history/ | docs/features/history/ | -| apps/desktop/src/main/services/onboarding/ | docs/features/onboarding-and-settings/ | -| apps/desktop/src/main/services/pty/ | docs/features/terminals-and-sessions/ | -| apps/desktop/src/main/services/sessions/ | docs/features/terminals-and-sessions/ | -| apps/desktop/src/main/services/processes/ | docs/features/terminals-and-sessions/ | -| apps/desktop/src/main/services/sync/ | docs/features/sync-and-multi-device/ | -| apps/desktop/src/main/services/config/ | docs/features/onboarding-and-settings/ | -| apps/desktop/src/main/services/ipc/ | docs/ARCHITECTURE.md (IPC section) | -| apps/desktop/src/main/services/git/ | docs/ARCHITECTURE.md (Git engine section) + lanes/ | -| apps/desktop/src/preload/ | docs/ARCHITECTURE.md (IPC contract) | -| apps/desktop/src/shared/ | docs/ARCHITECTURE.md + touching feature's doc | -| apps/desktop/src/renderer/components// | docs/features// | -| apps/desktop/src/renderer/state/ | docs/ARCHITECTURE.md (UI framework) | -| apps/ade-cli/ | docs/ARCHITECTURE.md (ADE CLI / Build/Test/Deploy) + docs/features/agents/ | -| .github/workflows/ | docs/ARCHITECTURE.md (Build/Test/Deploy) | -| apps/ios/ | docs/features/sync-and-multi-device/ios-companion.md | -| apps/web/ | docs/ARCHITECTURE.md (Apps & Processes) | - -Step 3: Update docs in place -- Prefer editing existing docs over creating new ones. -- If a feature gets a genuinely new sub-concept worth its own page, add a new detail doc inside the existing features// folder. -- Keep each README.md's "Source file map" section current — it is the primary way an agent orients itself. -- Rewrite prose to reflect current reality (not a changelog of what changed). -- Remove outdated information. -- Do NOT add changelog sections, "Updated on X" notes, or dated markers. -- Do NOT modify docs/OPTIMIZATION_OPPORTUNITIES.md via this agent — it is append-only and human-curated. - -Step 4: Run doc validation - node scripts/validate-docs.mjs - -This validator only covers the Mintlify site. For internal docs, self-check: - - Every features//README.md still has a "Source file map" section. - - PRD.md links resolve (grep for broken relative links). - -Report what docs were updated and what was changed. -``` - -### Mobile parity agent - -Spawn a general-purpose agent with this prompt: +Wait for all simplifier agents to complete before moving to Phase 3. -``` -You are the mobile parity reviewer for the ADE project. - -Analyze all work on the current branch vs main, including changes that are -already under review and any simplifications made during `/finalize`. Determine -whether the iOS companion app under `apps/ios/` needs matching updates. - -Step 1: Get branch context - git diff main --name-only - git diff main --stat | tail -30 - git log main..HEAD --oneline - -Step 2: Identify cross-platform changes -- Shared contracts: apps/desktop/src/shared/**, preload IPC types, sync payloads, - PR mobile snapshots, chat/session models, lane summaries, config schemas. -- Desktop behavior with a mobile surface: PR workflows, lanes, Work chat, - files, sync/multi-device, settings exposed on iOS, model/session controls. -- Renderer-only desktop preferences are only mobile-applicable when the iOS app - has the same user-facing concept and a native implementation path. - -Step 3: Inspect iOS equivalents -- Search `apps/ios/ADE` and `apps/ios/ADETests` for the affected model, view, - service, or workflow names. -- If the branch adds or changes a host/mobile contract, update Swift Codable - models and iOS tests as needed. -- If the branch changes user-facing behavior that iOS already exposes, update - the SwiftUI view using native iOS controls and existing ADE design patterns. -- If the change is not applicable to iOS, explain why in the report. - -Step 4: Apply required iOS updates -- Keep edits scoped to `apps/ios/` unless a shared contract fix is required. -- Prefer existing SwiftUI patterns and native controls. -- Preserve Dynamic Type, VoiceOver labels, and 44x44 tap targets. -- Add or update targeted tests in `apps/ios/ADETests` for contract changes. - -Step 5: Validate what you touched -- At minimum: `xcrun swiftc -parse ` when a full Xcode - build/test run is unavailable. -- Prefer an iOS build/test when the local simulator/runtime environment supports it. - -Report: -- iOS files changed, or "No iOS changes required" -- Why each desktop/shared change was applicable or not applicable to mobile -- Validation run and any environment limitations -``` - -### CLI parity agent - -The `apps/ade-cli/` package is the agent-facing surface for ADE. Every desktop -action should be reachable either through a typed subcommand (`ade lanes …`, -`ade prs …`, `ade chat …`, `ade tests …`, `ade run …`, `ade proof …`) or -through the generic `ade actions run ` registry exposed by -`adeRpcServer.ts`. When a feature branch adds, renames, or removes a desktop -feature, the CLI silently drifts unless someone updates it in the same PR. -This agent closes that gap. - -Spawn a general-purpose agent with this prompt: - -``` -You are the ADE CLI parity reviewer. - -The ADE CLI (apps/ade-cli) is the primary agent-facing interface to the ADE -desktop app. Its goal is to surface every meaningful action inside ADE -desktop — either as a typed subcommand or via the generic -`ade actions run ` registry. When desktop changes, the CLI -must change with it. Your job is to detect drift on this branch and patch -apps/ade-cli/ so the CLI stays in lockstep with desktop. - -Step 1: Get branch context - git diff main --name-only - git diff main --stat | tail -30 - git log main..HEAD --oneline - -Step 2: Identify CLI-relevant desktop changes -Treat anything under these paths as a candidate for new / changed / removed -CLI surface: -- apps/desktop/src/main/services/** (each service is a candidate action - domain — lanes, prs, chat, tests, proof, run, git, files, missions, - automations, computerUse, context, conflicts, history, memory, onboarding, - pty, sessions, processes, sync, config, cto, ai) -- apps/desktop/src/preload/** and apps/desktop/src/shared/** (IPC and - shared contracts the CLI ultimately calls through) -- New domains/actions registered with the action registry on either side - -Step 3: Map each candidate to the CLI -- Typed subcommands live in apps/ade-cli/src/cli.ts (~3300 lines), a - case-based dispatcher. Existing cases include lanes, git-status, prs-list, - chat-list, tests-runs, proof-list, actions-list, action-result, etc. - Locate the closest existing case block and either extend it or add a - sibling case alongside it. -- The RPC + actions-registry surface lives in - apps/ade-cli/src/adeRpcServer.ts (~6500 lines), with a no-desktop fallback - in apps/ade-cli/src/headlessLinearServices.ts. New service actions usually - need wiring in one or both so `ade actions run ` resolves - them whether or not the desktop socket is up. -- The user-facing inventory lives in apps/ade-cli/README.md under - "CLI surface". Keep it accurate whenever a typed command is added, - renamed, or removed. - -Step 4: Apply auto-fix edits — scoped to apps/ade-cli/ only -- New feature: add a typed subcommand if the desktop feature is a distinct - user-facing workflow (lane / PR / chat / test / run / proof / mission / - automation / etc.). If it is just a new low-level service action, ensure - it is reachable via the actions registry and skip a typed wrapper. -- Renamed or behavior-changed feature: update the existing case to match - new parameters, IPC names, or output shape. Keep flag names stable when - possible — flag any breaking renames in the report. -- Removed feature: delete the dead case and any registry wiring. Do NOT - leave a stub. Drop the corresponding README line. -- Reuse existing patterns: match surrounding cases for argv parsing, - --text / --json output mode, error formatting, and --lane / --project-root - argument handling. Do not invent new dispatch styles. - -Step 5: Validate locally before reporting - cd apps/ade-cli && npm run typecheck - cd apps/ade-cli && npm test - -If tests fail in files you did not touch, leave them — Phase 3 handles -test-suite drift. Do not rewrite unrelated tests. - -Out of scope: -- Do NOT edit anything under apps/desktop/. -- Do NOT touch docs/ — the docs agent owns that. -- Do NOT refactor unrelated CLI code. - -Report: -- apps/ade-cli/ files changed (or "no CLI changes required") -- For each branch change: desktop change → CLI change, or why not applicable -- Any breaking flag / command renames -- typecheck and test results -``` - -Wait for all agents to complete. +Docs, mobile, CLI, and TUI parity reviewers have moved to `/automate` — they should run before `/finalize`. Do not re-spawn them here. --- @@ -539,22 +306,6 @@ Do not report "PR clean" from `/finalize` alone. - Files simplified: X - Key changes: [brief list] -### Documentation: -- Docs updated: [list] -- Docs checked but unchanged: [list] -- Doc validation: PASS - -### Mobile Parity: -- iOS changes: [list or "none required"] -- Applicability notes: [brief list] -- Validation: PASS / blocked with reason - -### CLI Parity: -- apps/ade-cli files changed: [list or "none required"] -- Desktop change → CLI change mapping: [brief list] -- Breaking flag/command renames: [list or "none"] -- Validation (typecheck + tests): PASS / blocked with reason - ### CI Verification: - Lock files in sync: PASS - Typecheck (desktop): PASS @@ -581,4 +332,4 @@ Do not report "PR clean" from `/finalize` alone. ## Completion Checklist -Before marking complete: every Phase 3 step (3a–3j) must report PASS in the Phase 4 summary, and all four Phase 2 agents (simplify, docs, mobile, cli) must have reported back. Remote PR review is **not** declared clean by `/finalize` — handoff to `/shipLane` (Phase 3j) is mandatory after push. +Before marking complete: every Phase 3 step (3a–3j) must report PASS in the Phase 4 summary, and Phase 2 simplifier agents must have reported back. Remote PR review is **not** declared clean by `/finalize` — handoff to `/shipLane` (Phase 3j) is mandatory after push. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c79ab7f51..19292f3d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -174,6 +174,53 @@ jobs: - run: cd apps/ade-cli && npm run build - run: cd apps/web && npm run build + build-runtime-binaries: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + os: macos-15 + - target: darwin-x64 + os: macos-15-intel + - target: linux-x64 + os: ubuntu-latest + - target: linux-arm64 + os: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + apps/desktop/package-lock.json + apps/ade-cli/package-lock.json + + - name: Install desktop dependencies + run: cd apps/desktop && npm ci + + - name: Install ADE CLI dependencies + run: cd apps/ade-cli && npm ci + + - name: Build ADE runtime binary + run: cd apps/ade-cli && npm run build:static -- --target ${{ matrix.target }} + + - name: Smoke test ADE runtime binary + run: | + apps/ade-cli/dist-static/ade-${{ matrix.target }} --version + tar -tzf apps/ade-cli/dist-static/ade-${{ matrix.target }}.native.tar.gz | grep -q '^\./node_modules/' + + - name: Upload ADE runtime binary + uses: actions/upload-artifact@v4 + with: + name: ade-runtime-${{ matrix.target }} + path: | + apps/ade-cli/dist-static/ade-${{ matrix.target }} + apps/ade-cli/dist-static/ade-${{ matrix.target }}.native.tar.gz + if-no-files-found: error + validate-docs: needs: install runs-on: ubuntu-latest @@ -197,6 +244,7 @@ jobs: # time. Self-contained because windows-latest node_modules contain # platform-specific native binaries that can't share a Linux cache. build-win: + needs: build-runtime-binaries runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -214,6 +262,18 @@ jobs: - name: Install ADE CLI dependencies run: cd apps/ade-cli && npm ci + - name: Download ADE runtime binaries + uses: actions/download-artifact@v4 + with: + pattern: ade-runtime-* + path: apps/desktop/resources/runtime + merge-multiple: true + + - name: Materialize ADE runtime resources + env: + ADE_RUNTIME_ARTIFACTS_DIR: ${{ github.workspace }}\apps\desktop\resources\runtime + run: cd apps/desktop && npm run materialize:runtime-resources + - name: Reset release output shell: pwsh run: | @@ -239,6 +299,7 @@ jobs: - test-desktop - test-ade-cli - build + - build-runtime-binaries - validate-docs - build-win runs-on: ubuntu-latest diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index a963a2375..6ebe518fb 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -35,7 +35,9 @@ jobs: git merge-base --is-ancestor HEAD refs/remotes/origin/main build-mac-release: - needs: verify + needs: + - verify + - build-runtime-binaries runs-on: macos-15 concurrency: group: release-${{ inputs.release_tag }}-mac @@ -60,6 +62,18 @@ jobs: - name: Install ADE CLI dependencies run: cd apps/ade-cli && npm ci + - name: Download ADE runtime binaries + uses: actions/download-artifact@v4 + with: + pattern: ade-runtime-* + path: apps/desktop/resources/runtime + merge-multiple: true + + - name: Materialize ADE runtime resources + env: + ADE_RUNTIME_ARTIFACTS_DIR: ${{ github.workspace }}/apps/desktop/resources/runtime + run: cd apps/desktop && npm run materialize:runtime-resources + - name: Stamp release version env: ADE_RELEASE_TAG: ${{ inputs.release_tag }} @@ -90,6 +104,15 @@ jobs: cd apps/desktop npm run prepare:mac:universal + - name: Reject insecure macOS signing certificate URL + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + run: | + if [[ "$CSC_LINK" == http://* ]]; then + echo "::error::CSC_LINK must use HTTPS, a local path, or an encoded certificate payload." + exit 1 + fi + - name: Build signed universal macOS release env: CSC_LINK: ${{ secrets.CSC_LINK }} @@ -121,7 +144,9 @@ jobs: if-no-files-found: error build-win-release: - needs: verify + needs: + - verify + - build-runtime-binaries runs-on: windows-latest concurrency: group: release-${{ inputs.release_tag }}-win @@ -146,6 +171,18 @@ jobs: - name: Install ADE CLI dependencies run: cd apps/ade-cli && npm ci + - name: Download ADE runtime binaries + uses: actions/download-artifact@v4 + with: + pattern: ade-runtime-* + path: apps/desktop/resources/runtime + merge-multiple: true + + - name: Materialize ADE runtime resources + env: + ADE_RUNTIME_ARTIFACTS_DIR: ${{ github.workspace }}\apps\desktop\resources\runtime + run: cd apps/desktop && npm run materialize:runtime-resources + - name: Stamp release version env: ADE_RELEASE_TAG: ${{ inputs.release_tag }} @@ -175,13 +212,138 @@ jobs: apps/desktop/release/latest.yml if-no-files-found: error + build-runtime-binaries: + needs: verify + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + os: macos-15 + - target: darwin-x64 + os: macos-15-intel + - target: linux-x64 + os: ubuntu-latest + - target: linux-arm64 + os: ubuntu-24.04-arm + runs-on: ${{ matrix.os }} + concurrency: + group: release-${{ inputs.release_tag }}-runtime-${{ matrix.target }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_ref }} + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + apps/desktop/package-lock.json + apps/ade-cli/package-lock.json + + - name: Install desktop dependencies + run: cd apps/desktop && npm ci + + - name: Install ADE CLI dependencies + run: cd apps/ade-cli && npm ci + + - name: Stamp runtime release version + env: + ADE_RELEASE_TAG: ${{ inputs.release_tag }} + run: cd apps/desktop && npm run version:release + + - name: Build ADE runtime binary + run: cd apps/ade-cli && npm run build:static -- --target ${{ matrix.target }} + + - name: Materialize runtime notarization API key + if: ${{ startsWith(matrix.target, 'darwin-') }} + env: + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + run: | + if [ -z "$APPLE_API_KEY_P8" ] || [ -z "$APPLE_API_KEY_ID" ]; then + echo "::error::Missing APPLE_API_KEY_P8 or APPLE_API_KEY_ID GitHub secret." + exit 1 + fi + + KEY_PATH="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY_P8" > "$KEY_PATH" + chmod 600 "$KEY_PATH" + echo "APPLE_API_KEY=$KEY_PATH" >> "$GITHUB_ENV" + + - name: Import runtime Developer ID certificate + if: ${{ startsWith(matrix.target, 'darwin-') }} + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + run: | + if [ -z "$CSC_LINK" ] || [ -z "$CSC_KEY_PASSWORD" ]; then + echo "::error::Missing CSC_LINK or CSC_KEY_PASSWORD GitHub secret." + exit 1 + fi + + CERT_PATH="$RUNNER_TEMP/runtime-signing.p12" + if [ -f "$CSC_LINK" ]; then + cp "$CSC_LINK" "$CERT_PATH" + elif [[ "$CSC_LINK" == file://* ]]; then + cp "${CSC_LINK#file://}" "$CERT_PATH" + elif [[ "$CSC_LINK" == https://* ]]; then + curl -fsSL "$CSC_LINK" -o "$CERT_PATH" + elif [[ "$CSC_LINK" == http://* ]]; then + echo "::error::CSC_LINK must use HTTPS, a local path, or an encoded certificate payload." + exit 1 + else + printf '%s' "$CSC_LINK" | base64 --decode > "$CERT_PATH" + fi + + KEYCHAIN="$RUNNER_TEMP/runtime-signing.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -hex 24)" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + EXISTING_KEYCHAINS="$(security list-keychains -d user | tr -d '\"' | xargs)" + security list-keychains -d user -s "$KEYCHAIN" $EXISTING_KEYCHAINS + security default-keychain -s "$KEYCHAIN" + security import "$CERT_PATH" -k "$KEYCHAIN" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + + - name: Sign and notarize ADE runtime binary + if: ${{ startsWith(matrix.target, 'darwin-') }} + env: + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: cd apps/ade-cli && npm run notarize:static -- --binary=dist-static/ade-${{ matrix.target }} + + - name: Smoke test ADE runtime binary + run: | + apps/ade-cli/dist-static/ade-${{ matrix.target }} --version + tar -tzf apps/ade-cli/dist-static/ade-${{ matrix.target }}.native.tar.gz | grep -q '^\./node_modules/' + + - name: Upload ADE runtime binary + uses: actions/upload-artifact@v4 + with: + name: ade-runtime-${{ matrix.target }} + path: | + apps/ade-cli/dist-static/ade-${{ matrix.target }} + apps/ade-cli/dist-static/ade-${{ matrix.target }}.native.tar.gz + if-no-files-found: error + publish-release: if: ${{ inputs.publish }} needs: + - build-runtime-binaries - build-mac-release - build-win-release runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_ref }} + fetch-depth: 1 + - name: Download macOS release artifacts uses: actions/download-artifact@v4 with: @@ -194,6 +356,18 @@ jobs: name: ade-win-release-${{ inputs.release_tag }} path: release-assets/win + - name: Download ADE runtime binaries + uses: actions/download-artifact@v4 + with: + pattern: ade-runtime-* + path: release-assets/runtime + merge-multiple: true + + - name: Add standalone runtime installer + run: | + cp apps/ade-cli/scripts/install-runtime.sh release-assets/runtime/install.sh + chmod 755 release-assets/runtime/install.sh + - name: Create or update draft GitHub release env: GH_TOKEN: ${{ github.token }} @@ -210,6 +384,8 @@ jobs: release-assets/win/*.exe release-assets/win/*.exe.blockmap release-assets/win/latest.yml + release-assets/runtime/install.sh + release-assets/runtime/ade-* ) if [ "${#files[@]}" -eq 0 ]; then diff --git a/.gitignore b/.gitignore index 3886252af..855ee2909 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ __pycache__/ # Build outputs /apps/ade-cli/dist/ +/apps/ade-code/dist/ /apps/desktop/release/ /apps/desktop/dist/ /apps/desktop/vendor/crsqlite/darwin-x64/ @@ -63,3 +64,6 @@ ios-signing/ /.playwright-mcp /.codex-derived-data package-lock.json +!/apps/ade-code/package-lock.json +/apps/desktop/release-alpha +apps/desktop/resources/runtime/ade-* diff --git a/README.md b/README.md index b7acf51ca..807d64bb1 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,12 @@ Requirements: macOS 13+, git on `PATH`, Node 22+ for headless CLI workflows. ## CLI ```bash +ade desktop +ade runtime status --text +ade runtime start +ade runtime stop ade doctor --json +ade code ade lanes create --name fix-checkout-flow ade prs checks 168 --text ade tests run --suite unit --wait @@ -123,12 +128,12 @@ ade actions list --text # discover every service action ## Architecture -Local-first, on purpose. Runtime state lives under `.ade/` inside each project — SQLite db, worktree checkouts, proof artifacts, encrypted secrets. +Local-first, on purpose. The center of ADE is the **runtime daemon** — a single per-machine `ade` service that owns projects, lanes, chats, processes, sync, and proof artifacts. Desktop, the terminal client, the iOS app, and SSH-attached desktop windows all attach to it as clients. Runtime state lives under `.ade/` inside each project (SQLite db, worktree checkouts, proof artifacts, encrypted secrets) and the machine-wide socket lives under `~/.ade/sock/ade.sock`. ```text -apps/desktop Electron host — SQLite, git, processes, AI runtimes, sync host -apps/ade-cli Node CLI over the desktop socket (or headless) -apps/ios SwiftUI companion that syncs with a desktop host +apps/ade-cli ADE runtime daemon (`ade serve`) + `ade` CLI + `ade code` terminal client +apps/desktop Electron client — multi-window, attaches to a local or SSH-bound runtime +apps/ios SwiftUI controller that attaches to a runtime over WebSocket apps/web Public website and download surface docs/ Product and engineering docs ``` @@ -137,11 +142,67 @@ Deep reference: [ARCHITECTURE.md](docs/ARCHITECTURE.md). ## Develop +First-time setup: + +```bash +npm run setup +``` + +Daily desktop dev: + +```bash +npm run dev +``` + +That aliases to `npm run dev:desktop`: it rebuilds `apps/ade-cli`, launches the Electron desktop app, and points it at the dev runtime socket `/tmp/ade-runtime-dev.sock`. If no dev runtime is listening, desktop is allowed to create it. This is the normal desktop-dev flow. + +Dev command matrix: + ```bash -cd apps/desktop && npm install && npm run dev # live Electron app -cd apps/ade-cli && npm install && npm run build # build the CLI +npm run dev:desktop # desktop only; dev socket; desktop may auto-create runtime +npm run dev:desktop:attach # desktop only; fail if dev runtime is not already running +npm run dev:desktop:clean # desktop only; clear Vite cache before launch +npm run dev:code # terminal TUI only; starts dev runtime if missing +npm run dev:code:attach # terminal TUI only; fail if dev runtime is not already running +npm run dev:runtime # runtime only in the foreground +npm run dev:all # start shared dev runtime, then run desktop/code attach commands in separate terminals +npm run dev:stop # stop the dev runtime +npm stop dev # same as dev:stop +``` + +The dev commands intentionally use a temp socket so they do not collide with the installed ADE app: + +```text +/tmp/ade-runtime-dev.sock ``` +Override it when needed: + +```bash +npm run dev:desktop -- --socket /tmp/my-ade-dev.sock +npm run dev:code -- --socket /tmp/my-ade-dev.sock +ADE_DEV_RUNTIME_SOCKET_PATH=/tmp/my-ade-dev.sock npm run dev:runtime +``` + +To test auto-runtime creation, use the `:auto`/default commands after stopping the dev runtime: + +```bash +npm run dev:stop +npm run dev:desktop # tests desktop creating the dev runtime +npm run dev:stop +npm run dev:code # tests TUI wrapper creating the dev runtime +``` + +Local packaged builds: + +```bash +npm run package:alpha # current checkout -> ADE Alpha.app, ade-alpha, ~/.ade-alpha +npm run package:beta # origin/main -> ADE Beta.app, ade-beta, ~/.ade-beta +``` + +These are unsigned local macOS app builds under `apps/desktop/release-alpha` and `apps/desktop/release-beta`. They do not replace the production `ADE.app`, production `ade`, or `~/.ade` runtime/state. +Local channel packages include the host runtime binary for this Mac. Release builds still require the full cross-platform runtime artifact set used by remote runtime bootstrap. + Validate with `npm --prefix apps/desktop run typecheck` and `run test`. The desktop test suite is large — run the smallest relevant subset first. ## Links diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 118d58046..85c2fc4f9 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -1,58 +1,229 @@ # ADE CLI -`apps/ade-cli` owns the `ade` command-line entry point for agents and local automation. +`apps/ade-cli` owns the `ade` command, the per-machine ADE runtime daemon, and the terminal `ade code` client. The runtime daemon is the source of truth for lanes, chats, PR state, process state, sync, and proof artifacts on a machine. Desktop ADE, `ade code`, the iOS app, and SSH-attached desktops all attach to it. -The CLI is the primary agent interface. It prefers the live ADE desktop socket at `.ade/ade.sock` so commands operate against the same lanes, chats, PR state, process runtime, and proof artifacts as the UI. If the desktop app is not running, it falls back to a short-lived headless runtime for actions that can safely run without Electron. +## Modes -## Scripts +The `ade` binary has three operating modes: + +- **Socket** — the runtime daemon (`ade serve`) listens on `~/.ade/sock/ade.sock` (POSIX) or `\\.\pipe\ade-runtime` (Windows). All other CLI commands and clients open that socket and speak ADE JSON-RPC. +- **Headless** (`--headless` or `ade code --embedded`) — the CLI builds an in-process `AdeRuntime` for one project and answers the same JSON-RPC surface directly. Used for one-shot commands and as a fallback when no socket is available. +- **`ade rpc --stdio`** — attaches to the local runtime daemon and bridges its JSON-RPC over stdio. This is the transport the desktop's remote runtime feature spawns over SSH. + +Default routing for typed commands: prefer the socket if reachable; auto-spawn `ade serve` in the background if the socket does not exist; fall back to headless for commands that don't need shared live state. Add `--socket` to require the daemon, or `--headless` to force in-process execution. + +## Machine layout + +`resolveMachineAdeLayout()` (in `src/services/projects/machineLayout.ts`) is the single source for per-machine paths. Override the root with `ADE_HOME`. + +| Path | Purpose | +| --- | --- | +| `~/.ade/` | Per-machine ADE state root. | +| `~/.ade/sock/ade.sock` | Runtime daemon socket (POSIX). | +| `\\.\pipe\ade-runtime` | Runtime daemon named pipe (Windows). | +| `~/.ade/projects.json` | Project registry. | +| `~/.ade/secrets/` | Encrypted credential store (`credentials.json.enc` + `.machine-key`). | +| `~/.ade/bin/ade` | Bundled static runtime binary (release installs / remote uploads). | +| `~/.ade/runtime//` | Native node modules for that runtime binary. | +| `~/.ade/runtime/launchd.{out,err}.log` | Daemon stdout/stderr when running as a login service on macOS. | + +Per-project state stays under `/.ade/` and is governed by `projectConfigService` (see `docs/features/onboarding-and-settings/configuration-schema.md`). + +Channel builds use parallel state roots and binary names so Stable, Beta, and Alpha can coexist: + +```text +ADE.app -> ade -> ~/.ade +ADE Beta.app -> ade-beta -> ~/.ade-beta +ADE Alpha.app -> ade-alpha -> ~/.ade-alpha +``` + +## Install paths + +Three ways to put `ade` on a machine: + +1. **Standalone runtime install** — single static binary plus its native dependency archive, fetched from a GitHub release. Suitable for headless macOS/Linux servers. + + ```bash + curl -fsSL https://github.com/arul28/ADE/releases/latest/download/install.sh | sh + ``` + + Environment overrides accepted by `install.sh`: + + - `ADE_VERSION=vX.Y.Z` — install a specific release tag (default `latest`). + - `ADE_INSTALL_DIR=/usr/local/bin` — destination directory for the binary. + - `ADE_RELEASE_REPO=owner/repo` — fetch from a fork. + - `ADE_HOME=/custom/.ade` — change the per-machine state root. + + The script downloads `ade-` to `$ADE_INSTALL_DIR/ade`, extracts `ade-.native.tar.gz` to `~/.ade/runtime//`, runs `ade --version` to verify, and best-effort registers the per-user login service on macOS / systemd. + +2. **Desktop bundle** — every packaged ADE.app ships the CLI. macOS path: + + ```bash + /Applications/ADE.app/Contents/Resources/ade-cli/bin/ade + ``` + + Add it to `PATH` once with the channel-specific helper: + + ```bash + /Applications/ADE.app/Contents/Resources/ade-cli/install-path.sh + ``` + + The `install-path.sh` wrapper exposes `ade` (or `ade-beta` / `ade-alpha` from the matching `.app`). The wrapper runs the CLI under the packaged Electron runtime, so users do not need a separate Node install. The desktop General settings tab also exposes Install / Repair via `AdeCliSection` (`window.ade.adeCli.installForUser()`). + +3. **Source build** — for repository development: + + ```bash + cd apps/ade-cli + npm run build + npm link # or: npm pack && npm install -g ./ade-cli-*.tgz + ``` + + Requires Node.js 22 or newer (the headless runtime depends on `node:sqlite`). + +## Service manager + +The runtime daemon runs as a per-user login service. The implementations live in `src/serviceManager/`. + +| Platform | Backend | Service path | +| --- | --- | --- | +| macOS | launchd `LaunchAgent` | `~/Library/LaunchAgents/com.ade.runtime.plist` | +| Linux | `systemctl --user` | `~/.config/systemd/user/ade-runtime.service` | +| Windows | `schtasks.exe ONLOGON` | scheduled task `ADE Runtime` | + +The default service label is `com.ade.runtime`; channel builds override it via `ADE_PACKAGE_CHANNEL=alpha|beta` (`com.ade.runtime.alpha`, `com.ade.runtime.beta`). `ADE_RUNTIME_SERVICE_NAME` overrides the label outright. macOS also writes `~/.ade/runtime/launchd.{out,err}.log`. + +Manage the service from the CLI: ```bash -npm run cli:dev -- help -npm run cli:dev -- doctor --project-root /absolute/path/to/repo -npm run dev -- --project-root /absolute/path/to/repo -npm run build -npm run typecheck -npm run test +ade serve --install-service # write the plist/unit/task and start it +ade serve --uninstall-service # stop and remove it +ade serve --service-status # JSON: { ok, installed, running, path, message } + +# Aliases on the runtime command (same backend): +ade runtime install-service +ade runtime uninstall-service +ade runtime service-status --text ``` -## Install and PATH +`resolveAdeServeCommand()` builds the service command from the current `ade` binary path so the installed service launches the same ADE channel that ran the install. + +## Foreground daemon -For local development, build the package and link its `ade` binary: +`ade serve` runs the runtime in the foreground. Use it for development or when the system service is disabled. ```bash -cd apps/ade-cli -npm run build -npm link -ade doctor --project-root /absolute/path/to/repo +ade serve +ade serve --socket ~/.ade/sock/ade.sock +ade serve --port 8787 # also accept JSON-RPC on 127.0.0.1:8787 +ade serve --no-sync # disable phone-sync host for this run ``` -The package is also packable as a normal Node CLI. It requires Node.js 22 or newer because ADE uses `node:sqlite` in the headless runtime. +## Runtime control + +`ade runtime` is the typed wrapper for daemon lifecycle commands: ```bash -cd apps/ade-cli -npm pack -npm install -g ./ade-cli-*.tgz +ade runtime status --text # is the daemon up, which socket +ade runtime start # spawn it in the background if missing +ade runtime stop # graceful shutdown via JSON-RPC +ade runtime install-service # delegates to ade serve --install-service ``` -The desktop macOS build also bundles the CLI at: +`ade runtime start` is idempotent: it spawns `ade serve` detached and returns once the runtime answers `ade/initialize`. `ade runtime stop` calls the daemon's `shutdown` method. + +## Project registry + +The runtime daemon owns a per-machine project registry at `~/.ade/projects.json` (`ProjectRegistry` in `src/services/projects/projectRegistry.ts`). A project record carries a stable `projectId` (`project_`), root path, display name, `addedAt`, `lastOpenedAt`, and the resolved git origin URL. + +Manage the registry through typed CLI commands: ```bash -/Applications/ADE.app/Contents/Resources/ade-cli/bin/ade +ade projects list --text +ade projects add /path/to/project +ade projects remove project_abc123… +ade projects touch project_abc123… +ade init # adds the cwd as a project +ade init /path/to/project # adds an explicit path +``` + +…or call the same JSON-RPC methods directly: + +```text +projects.list { } -> ProjectRecord[] +projects.add { rootPath } -> ProjectRecord +projects.remove { projectId } -> { removed } +projects.touch { projectId } -> ProjectRecord +``` + +Adding a project creates `/.ade/` if needed but does not run any heavy onboarding. The first project-scoped JSON-RPC call lazily builds an `AdeRuntime` for that root via `ProjectScopeRegistry`. + +## RPC surface + +The runtime exposes two layers of JSON-RPC methods (`src/multiProjectRpcServer.ts`): + +**Runtime-scoped** — no `projectId` required: + +```text +ade/initialize ade/initialized ping shutdown exit +runtime/info machineInfo.get +projects.list projects.add projects.remove projects.touch +runtimeEvents.subscribe runtimeEvents.unsubscribe +sync.getStatus sync.refreshDiscovery +sync.listDevices sync.updateLocalDevice +sync.connectToBrain sync.disconnectFromBrain +sync.forgetDevice +sync.getTransferReadiness sync.transferBrainToLocal +sync.getPin sync.setPin sync.clearPin +sync.setActiveLanePresence ``` -To make the desktop-bundled command available as `ade`, add a symlink from a directory on `PATH`: +**Project-scoped** — every other request must carry `params.projectId`. `ade/actions/call` (and the legacy ADE action / tool catalog underneath it) is dispatched into the per-project `ProjectScope` returned by `ProjectScopeRegistry.get(projectId)`. + +`ade/initialize` advertises `runtimeInfo.multiProject: true` and `capabilities.projects: true`. Clients use that to switch between sending `projectId` per request (multi-project runtime) and the legacy per-process binding (embedded runtime). Sync is hosted by the daemon for the most-recently-opened registered project; `ProjectScopeRegistry.ensureSyncHost` re-elects a host when projects are added or removed. + +## Credentials + +`src/services/credentials/credentialStore.ts` owns the machine-scoped credential store under `~/.ade/secrets/`: + +- `KeytarCredentialStore` (default when `keytar` is loadable) keys against the OS keychain under service `com.ade.runtime.credentials.v1`. +- `EncryptedFileCredentialStore` falls back to AES-256-GCM at `~/.ade/secrets/credentials.json.enc`, with the AES key in `~/.ade/secrets/.machine-key` (mode 600). +- `ElectronSafeStorageCredentialStore` is used when the desktop process talks to the same files but wants to encrypt with `safeStorage` instead. + +Disable keytar with `ADE_CREDENTIAL_STORE_DISABLE_KEYTAR=1` to force the encrypted-file store. + +## `ade code` + +`ade code` launches the terminal-native ADE Work chat (Ink + React, in `src/tuiClient/`). Default behavior: ```bash -/Applications/ADE.app/Contents/Resources/ade-cli/install-path.sh +ade code # attach to the machine daemon, auto-spawn it if missing +ade code --embedded # force the in-process embedded runtime +ade code --print-state # smoke-test the connection and exit +ade --socket /path/to/ade.sock code # attach to a specific socket +ade --project-root /repo code # bind to a specific project root ``` -That wrapper runs the CLI with the packaged ADE Electron runtime, so users do not need a separate Node install for the desktop-bundled path. +See `docs/features/ade-code/README.md` for the full attach/embedded handshake, slash command catalog, and right-pane drawers. -## CLI surface +## `ade rpc --stdio` + +`ade rpc --stdio` attaches to the local runtime daemon (auto-spawning it if needed) and bridges its JSON-RPC over stdio. The remote-runtime path on the desktop runs `ade rpc --stdio` over an SSH `exec` channel; see `docs/features/remote-runtime/internal-architecture.md` for the protocol shape and bootstrap sequence. + +## `ade desktop` + +`ade desktop` opens the installed ADE app from the terminal. On macOS it runs `open -a "ADE"` (or `ADE Beta` / `ADE Alpha` based on `ADE_PACKAGE_CHANNEL` / `ADE_DESKTOP_APP_NAME`). The desktop attaches to the same machine runtime; if the daemon is not running, the desktop spawns and waits for it via `LocalRuntimeConnectionPool`. + +## CLI surface (selected) ```bash +ade desktop +ade runtime status --text +ade runtime start +ade runtime stop ade auth status -ade doctor +ade doctor --json +ade projects list --text +ade init ade lanes list --text ade lanes create "fix-checkout-flow" --parent main ade lanes create "lin-123" --linear-issue-json '{"id":"...","identifier":"LIN-123","title":"...","projectId":"...","projectSlug":"...","teamId":"...","teamKey":"...","stateId":"...","stateName":"Todo","stateType":"unstarted","priority":2,"priorityLabel":"high","labels":[],"assigneeId":null,"assigneeName":null,"createdAt":"...","updatedAt":"..."}' @@ -75,9 +246,10 @@ ade shell start --lane lane-id -- npm test ade shell start-cli codex --lane lane-id --permission-mode edit --message "fix failing tests" ade shell start-cli --provider claude --lane lane-id --permission-mode default ade chat create --lane lane-id --model gpt-5.5 +ade code +ade code --embedded ade tests run --lane lane-id --suite unit --wait ade proof list --arg ownerKind=chat --arg ownerId=session-id -ade help ios-sim preview-render ade ios-sim devices --text ade --socket ios-sim apps --text ade --socket ios-sim launch --target target-id --text @@ -86,8 +258,6 @@ ade --socket app-control launch --command "npm run dev" --text ade --socket browser open http://localhost:5173 --new-tab --text ade --socket macos-vm status --lane lane-id --text ade --socket macos-vm start --lane lane-id --create --no-display --text -ade --socket macos-vm screenshot --lane lane-id --text -ade --socket macos-vm click --lane lane-id --x 120 --y 420 --text ade --socket update status --text ade --socket update check --text ade --socket update install --text @@ -101,73 +271,100 @@ ade actions list ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts ade cursor cloud agents list --text ade cursor cloud agents create --repo https://github.com/owner/repo --prompt "fix flaky test" --auto-pr -ade cursor cloud me ``` Use typed commands first. They validate common arguments and provide stable JSON fields or readable text summaries. Use `ade help ` for exact flags, `ade actions list --text` to discover the full service-backed action catalog, and `ade actions run ` only when there is no typed command for the workflow yet. -The `prs path-to-merge` and `prs pipeline save` commands persist a partial `PipelineSettings` patch via `issue_inventory.savePipelineSettings` before launching the resolver. The Path to Merge orchestrator reads these from saved settings, so the same flags work either way: +Output modes are explicit: `--text` for human-readable summaries, `--json` (default for piped output) for stable JSON, and `--pretty` for pretty-printed JSON. -| Flag | PipelineSettings field | Values | -| --- | --- | --- | -| `--max-rounds ` (alias `--rounds`) | `maxRounds` | positive integer | -| `--auto-merge` / `--no-auto-merge` | `autoMerge` | boolean | -| `--merge-method ` | `mergeMethod` | `repo_default` \| `merge` \| `squash` \| `rebase` | -| `--conflict-strategy ` | `conflictStrategy` | `pause` \| `rebase` \| `merge` \| `auto` | -| `--force-finalize ` | `forceFinalizeMode` | `off` \| `conditional` \| `unconditional` | -| `--force-finalize-require-no-ci` / `--force-finalize-allow-ci` | `forceFinalizeRequireNoCiFailures` | boolean | -| `--early-merge-on-green` / `--no-early-merge-on-green` | `earlyMergeOnGreen` | boolean | +`--socket` requires the daemon and fails fast when it is missing. Without `--socket`, the CLI auto-attaches when reachable and falls back to headless for commands that can run that way. -To set fields without a dedicated flag (for example `autoAgentSettings`), call the action directly: +## `ade auth` and `ade doctor` + +ADE CLI auth is local project access, not a separate cloud login. `ade auth status` verifies that the current terminal can initialize an ADE runtime for the project. Provider credentials, GitHub tokens, Linear tokens, and computer-use policy are read from ADE project settings and the existing secure stores. + +`ade doctor` reports local-only readiness metadata by default: + +- CLI version, Node/runtime version, project root, workspace root, `.ade` initialization, and config file presence. +- Machine socket path, whether the socket exists, and whether this invocation is using `runtime-socket`, `desktop-socket`, or `headless` mode. +- RPC tool count, ADE action count, and action counts by domain. +- Git repository readiness and GitHub readiness signals from local remotes, `gh` availability, and token environment presence. +- Linear readiness from local encrypted token presence or headless environment variables. +- Provider/model readiness from local ADE config, API-key provider references, and provider CLI availability. +- Computer-use readiness from local platform capabilities. +- Packaged/PATH status for the `ade` binary and concrete next actions. + +Default doctor / auth checks do not call provider, GitHub, or Linear networks. They report presence and local readiness only, without printing secret values. + +Agents starting an unfamiliar ADE session should begin with: ```bash -ade actions run issue_inventory.savePipelineSettings --args-list-json \ - '["pr-1",{"autoAgentSettings":{"provider":"claude","model":"sonnet","reasoningEffort":"high","permissionMode":"guarded_edit","confidenceThreshold":0.7}}]' +ade doctor --json +ade actions list --text ``` -Output modes are explicit: +…then prefer typed commands such as `ade lanes list --text`, `ade files read --text`, `ade prs checks --text`, or `ade tests runs --json`. Use `ade actions run …` as the broad escape hatch. + +## Repo development + +The installed `ade` command is the production CLI. Repository development uses root npm scripts so the command always runs the CLI and desktop code from this checkout, not whichever `ade` happens to be first on `PATH`. ```bash -ade lanes list --text -ade git status --lane lane-id --json -ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts --json +npm run setup +npm run dev:desktop +npm run dev:code +npm run dev:runtime +npm run dev:stop ``` -Commands that need UI-owned state, long-running Work chat state, live Run tab process state, or desktop proof state should use the live ADE socket: +The dev scripts are the same runtime daemon, just running from source against a temporary socket so a packaged ADE on the same machine is not affected: -```bash -ade doctor --project-root /absolute/path/to/repo --socket --json -ade lanes list --project-root /absolute/path/to/repo --socket --text +```text +/tmp/ade-runtime-dev.sock ``` -Without `--socket`, the CLI auto-connects to the desktop socket when it is available and falls back to headless mode when it is not. +Full matrix: -## Auth and readiness +```bash +npm run dev:desktop # desktop only; dev socket; desktop may auto-create runtime +npm run dev:desktop:attach # desktop only; fail unless dev runtime is already running +npm run dev:desktop:clean # desktop only; clear Vite cache before launch +npm run dev:code # terminal TUI only; starts dev runtime if missing +npm run dev:code:attach # terminal TUI only; fail unless dev runtime is already running +npm run dev:runtime # runtime only in the foreground +npm run dev:all # start shared dev runtime, then use attach commands in separate terminals +npm run dev:stop # stop the dev runtime +npm stop dev # same as dev:stop +``` -ADE CLI auth is local project access, not a separate cloud login. `ade auth status` verifies that the current terminal can initialize an ADE runtime for the project. Provider credentials, GitHub tokens, and computer-use policy are read from ADE project settings and the existing secure stores. +Local packaged builds are separate from dev-mode scripts: -`ade doctor` reports local-only readiness metadata by default: +```bash +npm run package:alpha # current checkout -> ADE Alpha.app, ade-alpha, ~/.ade-alpha +npm run package:beta # origin/main -> ADE Beta.app, ade-beta, ~/.ade-beta +``` -- CLI version, Node/runtime version, project root, workspace root, `.ade` initialization, and config file presence. -- Desktop socket path, whether the socket exists, and whether this invocation is actually using `desktop-socket` or `headless` mode. -- RPC tool count, ADE service action count, and action counts by domain. -- Git repository readiness and GitHub readiness signals from local remotes, `gh` availability, and token environment presence. -- Linear readiness from local encrypted token presence or headless environment variables. -- Provider/model readiness from local ADE config, API-key provider references, and provider CLI availability. -- Computer-use readiness from local platform capabilities. -- Packaged/PATH status for the `ade` binary and concrete next actions. +Use these when you want a production-shaped local app without going through the GitHub release workflow. Use the dev scripts when you want Vite/Electron live reload and the temp dev socket. Local channel packages include the host runtime binary for the build machine. GitHub release builds use and validate the full cross-platform runtime artifact set. -Default doctor/auth checks do not call provider, GitHub, or Linear networks. They report presence and local readiness only, without printing secret values. +The `prs path-to-merge` and `prs pipeline save` commands persist a partial `PipelineSettings` patch via `issue_inventory.savePipelineSettings` before launching the resolver. The Path to Merge orchestrator reads these from saved settings, so the same flags work either way: + +| Flag | PipelineSettings field | Values | +| --- | --- | --- | +| `--max-rounds ` (alias `--rounds`) | `maxRounds` | positive integer | +| `--auto-merge` / `--no-auto-merge` | `autoMerge` | boolean | +| `--merge-method ` | `mergeMethod` | `repo_default` \| `merge` \| `squash` \| `rebase` | +| `--conflict-strategy ` | `conflictStrategy` | `pause` \| `rebase` \| `merge` \| `auto` | +| `--force-finalize ` | `forceFinalizeMode` | `off` \| `conditional` \| `unconditional` | +| `--force-finalize-require-no-ci` / `--force-finalize-allow-ci` | `forceFinalizeRequireNoCiFailures` | boolean | +| `--early-merge-on-green` / `--no-early-merge-on-green` | `earlyMergeOnGreen` | boolean | -Agents should start unfamiliar ADE sessions with: +To set fields without a dedicated flag (for example `autoAgentSettings`), call the action directly: ```bash -ade doctor --json -ade actions list --text +ade actions run issue_inventory.savePipelineSettings --args-list-json \ + '["pr-1",{"autoAgentSettings":{"provider":"claude","model":"sonnet","reasoningEffort":"high","permissionMode":"guarded_edit","confidenceThreshold":0.7}}]' ``` -Then prefer typed commands such as `ade lanes list --text`, `ade files read --text`, `ade prs checks --text`, or `ade tests runs --json`. Use `ade actions run ...` as the broad escape hatch for internal ADE actions that do not yet have a typed command. - ## Automations Automation rules are managed with `ade automations `. Run `ade help automations` for the full flag reference. The lane-mode flags layer on top of `--from-file` / `--stdin` / `--text` for `create` and `update`: diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index f08dd37f6..08626f2c8 100644 --- a/apps/ade-cli/package-lock.json +++ b/apps/ade-cli/package-lock.json @@ -8,17 +8,33 @@ "name": "ade-cli", "version": "0.0.0", "dependencies": { + "@agentclientprotocol/sdk": "^0.20.0", + "@anthropic-ai/claude-agent-sdk": "^0.2.119", "@cursor/sdk": "^1.0.9", + "@linear/sdk": "^84.0.0", + "@opencode-ai/sdk": "^1.4.2", + "@wize-logic/nodejs-rfb": "^4.2.0", + "bonjour-service": "^1.3.0", + "chokidar": "^4.0.3", + "ink": "^5.2.1", + "ink-text-input": "^6.0.0", "node-cron": "^3.0.3", "node-pty": "^1.1.0", + "react": "^18.3.1", "sql.js": "^1.13.0", - "yaml": "^2.8.2" + "ws": "^8.20.0", + "yaml": "^2.8.2", + "zod": "^4.3.6" }, "bin": { "ade": "dist/cli.cjs" }, "devDependencies": { "@types/node": "^20.11.30", + "@types/react": "^18.3.28", + "@types/ws": "^8.18.1", + "ink-testing-library": "^4.0.0", + "postject": "^1.0.0-alpha.6", "tsup": "^8.3.5", "tsx": "^4.20.6", "typescript": "^5.7.3", @@ -28,6 +44,211 @@ "node": ">=22.0.0" } }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.20.0.tgz", + "integrity": "sha512-BxEHyE4MvwyOsdyVPub1vEtyrq8E0JSdjC+ckXWimY1VabFCTXdPyXv2y2Omz1j+iod7Z8oBJDXFCJptM0GBqQ==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", + "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.138.tgz", + "integrity": "sha512-rH6dFI3DBBsPBPcHTBdTZCHA14OCt2t4+6XYi2MJB/GlFrnZvlWmMIk2z9uxAiZ05Txg8YbftgSuE5A1qpAXwg==", + "license": "SEE LICENSE IN README.md", + "dependencies": { + "@anthropic-ai/sdk": "^0.81.0", + "@modelcontextprotocol/sdk": "^1.29.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.138", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.138", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.138", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.138", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.138", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.138", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.138", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.138" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.2.138.tgz", + "integrity": "sha512-aObxJ/GeJ5UxT9N8XypUHPYQKpwYsRT5THiJl5E2pKEUk/Xt42gT55N5GV0TOjtgxVAnDMWjxTAgGCGoDzjgpg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.2.138.tgz", + "integrity": "sha512-ou3i1/gAf2PEgVl2WYJb7ZdE+KGwoB1I46JRhWHSC3uD6lb9HMZam233T/rlKCVX9e5dzfkujUOnmCkmXjgVGQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.2.138.tgz", + "integrity": "sha512-jp8lmAVe9uI9X5o+IYWFajLbN+Z80XogVX7NeyaenLHdpHkxg29Yf8pb6Os4OvHMjJOAdwDhPpXajf6RtBeEDA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.2.138.tgz", + "integrity": "sha512-uZaEFND1pl7KD9tdYqj2hd6ktjlYizVmkHRgU2Aj/P1CC6WMDsKG+rqPP7dsVXO77gMXhL4xjjwwqMjxx83HkA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.2.138.tgz", + "integrity": "sha512-SLuUmu/nH1Wh0wnoXj/Bwh0nbDfEn9PgXqMsZHEUk3x1zxeR+6aRqFLjKZ8TawBey7xod7nfYUIjPnQx6IWDzg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.2.138.tgz", + "integrity": "sha512-T16F8Vkikb98E781ZM6Cx84yEBk+loSCqAObjaZ1hzQ1eKcpnxzSTF4rH2bz6N91dhFuCfIjFaBfNYg+oQA+yQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.2.138.tgz", + "integrity": "sha512-H/sD25fmMyEeJWamYmBKRS3E7jaIrg2S8KWxyR37P+xTZgkLe19sDTp7gYYywMXf1X9CJZJ8jJZ93qxINZoCeA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.2.138.tgz", + "integrity": "sha512-cSOdTH1OfIamVdJit9laWZiXne81ewgdP8MGh5HzLLLci0NGHkME7YxCWd0lYkCNkfiOEcToKU9axaZ+84jGiw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.81.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.81.0.tgz", + "integrity": "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@bufbuild/protobuf": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", @@ -148,6 +369,15 @@ "win32" ] }, + "node_modules/@cursor/sdk/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -580,6 +810,27 @@ "license": "MIT", "optional": true }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -627,6 +878,64 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, + "node_modules/@linear/sdk": { + "version": "84.0.0", + "resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-84.0.0.tgz", + "integrity": "sha512-jPtGlY06zG86ba6cL78d4JB9m61YMHo3L0luMrtVFgeOounI/GK3S3c6Ncjw7cxWzcsepoNZD10mQYoT81uKKA==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0" + }, + "engines": { + "node": ">=18.x" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -653,6 +962,15 @@ "node": ">=10" } }, + "node_modules/@opencode-ai/sdk": { + "version": "1.14.48", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.48.tgz", + "integrity": "sha512-wKM86jCzV/ZApyWrdm3uP8XdWcS0LMbu3FV+OWz1ChiGGg1wiIWNGMJs5CY8/QX2/rUuZrd1Q1DqvdamZ0zLeg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -1039,6 +1357,34 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "0.34.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz", @@ -1119,6 +1465,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@wize-logic/nodejs-rfb": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@wize-logic/nodejs-rfb/-/nodejs-rfb-4.2.0.tgz", + "integrity": "sha512-H524S2VTk9FWCSlczp9TpHZumltRdr/jU13g1dwARJOYtTJxlOoqO4ILSXc9u1Fr97wqz5vxpTV8JK+WAp44PA==", + "license": "MIT", + "dependencies": { + "des.js": "^1.1.0", + "js-md4": "^0.3.2" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -1126,6 +1482,28 @@ "license": "ISC", "optional": true }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1190,6 +1568,54 @@ "node": ">=8" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1249,6 +1675,18 @@ "node": "*" } }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1296,23 +1734,73 @@ "readable-stream": "^3.4.0" } }, - "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", - "optional": true, "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { "type": "github", "url": "https://github.com/sponsors/feross" }, @@ -1346,6 +1834,15 @@ "esbuild": ">=0.18" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1385,6 +1882,35 @@ "node": ">= 10" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -1403,6 +1929,18 @@ "node": ">=4" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -1419,7 +1957,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -1449,6 +1986,151 @@ "node": ">=6" } }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -1497,11 +2179,97 @@ "license": "ISC", "optional": true }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "devOptional": true, "dependencies": { "ms": "^2.1.3" }, @@ -1557,6 +2325,25 @@ "license": "MIT", "optional": true }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1575,6 +2362,38 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1582,6 +2401,15 @@ "license": "MIT", "optional": true }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", @@ -1611,6 +2439,18 @@ "node": ">=6" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -1618,6 +2458,46 @@ "license": "MIT", "optional": true }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -1659,6 +2539,51 @@ "@esbuild/win32-x64": "0.27.3" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1668,6 +2593,89 @@ "node": ">=6" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1691,6 +2699,27 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -1702,6 +2731,24 @@ "rollup": "^4.34.8" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -1741,6 +2788,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gauge": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", @@ -1762,6 +2818,18 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -1771,6 +2839,43 @@ "node": "*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "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.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -1811,6 +2916,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1818,6 +2935,28 @@ "license": "ISC", "optional": true }, + "node_modules/graphql": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", + "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -1825,6 +2964,27 @@ "license": "ISC", "optional": true }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -1832,6 +2992,26 @@ "license": "BSD-2-Clause", "optional": true }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", @@ -1943,28 +3123,193 @@ "wrappy": "1" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ink": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", + "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.3", + "ansi-escapes": "^7.0.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.22.0", + "indent-string": "^5.0.0", + "is-in-ci": "^1.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^7.2.0", + "type-fest": "^4.27.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0", + "react-devtools-core": "^4.19.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-testing-library": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", + "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/ink-text-input": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", + "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5", + "react": ">=18" + } + }, + "node_modules/ink/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ink/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ink/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/ink/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } }, "node_modules/ip-address": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.1.tgz", - "integrity": "sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", - "optional": true, "engines": { "node": ">= 12" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1975,6 +3320,21 @@ "node": ">=8" } }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-lambda": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", @@ -1982,12 +3342,26 @@ "license": "MIT", "optional": true }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC", - "optional": true + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } }, "node_modules/joycon": { "version": "3.1.1", @@ -1998,6 +3372,43 @@ "node": ">=10" } }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2037,6 +3448,18 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -2096,6 +3519,70 @@ "node": ">= 10" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -2108,6 +3595,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2258,8 +3751,20 @@ "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } }, "node_modules/mz": { "version": "2.7.0", @@ -2405,11 +3910,34 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2419,6 +3947,21 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-limit": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", @@ -2450,6 +3993,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2460,6 +4021,25 @@ "node": ">=0.10.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2502,6 +4082,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -2583,6 +4172,32 @@ } } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -2645,6 +4260,19 @@ "node": ">=10" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -2655,6 +4283,61 @@ "once": "^1.3.1" } }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -2670,12 +4353,40 @@ "rc": "cli.js" } }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -2694,7 +4405,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, "engines": { "node": ">= 14.18.0" }, @@ -2703,6 +4413,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -2718,7 +4437,23 @@ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/retry": { @@ -2792,6 +4527,22 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2816,8 +4567,16 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", - "optional": true + "dependencies": { + "loose-envify": "^1.1.0" + } }, "node_modules/semver": { "version": "7.7.4", @@ -2831,6 +4590,51 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -2838,6 +4642,105 @@ "license": "ISC", "optional": true }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2848,8 +4751,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/simple-concat": { "version": "1.0.1", @@ -2896,6 +4798,49 @@ "simple-concat": "^1.0.0" } }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -2997,12 +4942,33 @@ "node": ">= 8" } }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -3171,6 +5137,12 @@ "node": ">=0.8" } }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3217,6 +5189,15 @@ "node": ">=14.0.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -3226,6 +5207,12 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -3324,6 +5311,32 @@ "node": ">=4" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3381,6 +5394,15 @@ "imurmurhash": "^0.1.4" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3395,6 +5417,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -3977,7 +6008,6 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", - "optional": true, "dependencies": { "isexe": "^2.0.0" }, @@ -3994,24 +6024,168 @@ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/widest-line/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "license": "ISC", - "optional": true, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/wrappy": { @@ -4020,6 +6194,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "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 + } + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -4052,17 +6247,137 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } }, "dependencies": { + "@agentclientprotocol/sdk": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.20.0.tgz", + "integrity": "sha512-BxEHyE4MvwyOsdyVPub1vEtyrq8E0JSdjC+ckXWimY1VabFCTXdPyXv2y2Omz1j+iod7Z8oBJDXFCJptM0GBqQ==", + "requires": {} + }, + "@alcalzone/ansi-tokenize": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", + "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", + "requires": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==" + }, + "is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==" + } + } + }, + "@anthropic-ai/claude-agent-sdk": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.138.tgz", + "integrity": "sha512-rH6dFI3DBBsPBPcHTBdTZCHA14OCt2t4+6XYi2MJB/GlFrnZvlWmMIk2z9uxAiZ05Txg8YbftgSuE5A1qpAXwg==", + "requires": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.138", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.138", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.138", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.138", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.138", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.138", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.138", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.138", + "@anthropic-ai/sdk": "^0.81.0", + "@modelcontextprotocol/sdk": "^1.29.0" + } + }, + "@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.2.138.tgz", + "integrity": "sha512-aObxJ/GeJ5UxT9N8XypUHPYQKpwYsRT5THiJl5E2pKEUk/Xt42gT55N5GV0TOjtgxVAnDMWjxTAgGCGoDzjgpg==", + "optional": true + }, + "@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.2.138.tgz", + "integrity": "sha512-ou3i1/gAf2PEgVl2WYJb7ZdE+KGwoB1I46JRhWHSC3uD6lb9HMZam233T/rlKCVX9e5dzfkujUOnmCkmXjgVGQ==", + "optional": true + }, + "@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.2.138.tgz", + "integrity": "sha512-jp8lmAVe9uI9X5o+IYWFajLbN+Z80XogVX7NeyaenLHdpHkxg29Yf8pb6Os4OvHMjJOAdwDhPpXajf6RtBeEDA==", + "optional": true + }, + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.2.138.tgz", + "integrity": "sha512-uZaEFND1pl7KD9tdYqj2hd6ktjlYizVmkHRgU2Aj/P1CC6WMDsKG+rqPP7dsVXO77gMXhL4xjjwwqMjxx83HkA==", + "optional": true + }, + "@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.2.138.tgz", + "integrity": "sha512-SLuUmu/nH1Wh0wnoXj/Bwh0nbDfEn9PgXqMsZHEUk3x1zxeR+6aRqFLjKZ8TawBey7xod7nfYUIjPnQx6IWDzg==", + "optional": true + }, + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.2.138.tgz", + "integrity": "sha512-T16F8Vkikb98E781ZM6Cx84yEBk+loSCqAObjaZ1hzQ1eKcpnxzSTF4rH2bz6N91dhFuCfIjFaBfNYg+oQA+yQ==", + "optional": true + }, + "@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.2.138.tgz", + "integrity": "sha512-H/sD25fmMyEeJWamYmBKRS3E7jaIrg2S8KWxyR37P+xTZgkLe19sDTp7gYYywMXf1X9CJZJ8jJZ93qxINZoCeA==", + "optional": true + }, + "@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.2.138", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.2.138.tgz", + "integrity": "sha512-cSOdTH1OfIamVdJit9laWZiXne81ewgdP8MGh5HzLLLci0NGHkME7YxCWd0lYkCNkfiOEcToKU9axaZ+84jGiw==", + "optional": true + }, + "@anthropic-ai/sdk": { + "version": "0.81.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.81.0.tgz", + "integrity": "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==", + "requires": { + "json-schema-to-ts": "^3.1.1" + } + }, + "@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==" + }, "@bufbuild/protobuf": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", @@ -4098,6 +6413,13 @@ "@statsig/js-client": "3.31.0", "sqlite3": "^5.1.7", "zod": "^3.25.0" + }, + "dependencies": { + "zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" + } } }, "@cursor/sdk-darwin-arm64": { @@ -4323,6 +6645,18 @@ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "optional": true }, + "@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "requires": {} + }, + "@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "requires": {} + }, "@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -4364,6 +6698,43 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==" + }, + "@linear/sdk": { + "version": "84.0.0", + "resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-84.0.0.tgz", + "integrity": "sha512-jPtGlY06zG86ba6cL78d4JB9m61YMHo3L0luMrtVFgeOounI/GK3S3c6Ncjw7cxWzcsepoNZD10mQYoT81uKKA==", + "requires": { + "@graphql-typed-document-node/core": "^3.2.0" + } + }, + "@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "requires": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + } + }, "@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -4384,6 +6755,14 @@ "rimraf": "^3.0.2" } }, + "@opencode-ai/sdk": { + "version": "1.14.48", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.48.tgz", + "integrity": "sha512-wKM86jCzV/ZApyWrdm3uP8XdWcS0LMbu3FV+OWz1ChiGGg1wiIWNGMJs5CY8/QX2/rUuZrd1Q1DqvdamZ0zLeg==", + "requires": { + "cross-spawn": "7.0.6" + } + }, "@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -4612,6 +6991,31 @@ "undici-types": "~6.21.0" } }, + "@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true + }, + "@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "requires": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@vitest/expect": { "version": "0.34.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz", @@ -4681,12 +7085,37 @@ "pretty-format": "^29.5.0" } }, + "@wize-logic/nodejs-rfb": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@wize-logic/nodejs-rfb/-/nodejs-rfb-4.2.0.tgz", + "integrity": "sha512-H524S2VTk9FWCSlczp9TpHZumltRdr/jU13g1dwARJOYtTJxlOoqO4ILSXc9u1Fr97wqz5vxpTV8JK+WAp44PA==", + "requires": { + "des.js": "^1.1.0", + "js-md4": "^0.3.2" + } + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "optional": true }, + "accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "requires": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "dependencies": { + "negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" + } + } + }, "acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -4730,6 +7159,33 @@ "indent-string": "^4.0.0" } }, + "ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + } + }, + "ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "requires": { + "ajv": "^8.0.0" + } + }, + "ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "requires": { + "environment": "^1.0.0" + } + }, "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -4770,6 +7226,11 @@ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", "dev": true }, + "auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==" + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4799,6 +7260,41 @@ "readable-stream": "^3.4.0" } }, + "body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "requires": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "dependencies": { + "iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, + "bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "requires": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -4827,6 +7323,11 @@ "load-tsconfig": "^0.2.3" } }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, "cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4859,6 +7360,24 @@ "unique-filename": "^1.1.1" } }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, + "call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + } + }, "chai": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -4874,6 +7393,11 @@ "type-detect": "^4.1.0" } }, + "chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==" + }, "check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -4887,7 +7411,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, "requires": { "readdirp": "^4.0.1" } @@ -4903,6 +7426,85 @@ "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "optional": true }, + "cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==" + }, + "cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "requires": { + "restore-cursor": "^4.0.0" + } + }, + "cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "requires": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" + }, + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==" + }, + "emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==" + }, + "is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==" + }, + "slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "requires": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + } + }, + "string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "requires": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + } + }, + "strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "requires": { + "ansi-regex": "^6.2.2" + } + } + } + }, + "code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "requires": { + "convert-to-spaces": "^2.0.1" + } + }, "color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -4939,11 +7541,60 @@ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "optional": true }, + "content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==" + }, + "content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" + }, + "convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==" + }, + "cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" + }, + "cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" + }, + "cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, + "cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true + }, "debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "devOptional": true, "requires": { "ms": "^2.1.3" } @@ -4976,6 +7627,20 @@ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "optional": true }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "requires": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4987,12 +7652,40 @@ "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "dev": true }, + "dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "requires": { + "@leichtgewicht/ip-codec": "^2.0.1" + } + }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "optional": true }, + "encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" + }, "encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", @@ -5016,12 +7709,40 @@ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "optional": true }, + "environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==" + }, "err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "optional": true }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "requires": { + "es-errors": "^1.3.0" + } + }, + "es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==" + }, "esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -5056,11 +7777,92 @@ "@esbuild/win32-x64": "0.27.3" } }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, + "eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "requires": { + "eventsource-parser": "^3.0.1" + } + }, + "eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==" + }, "expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==" }, + "express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "requires": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + } + }, + "express-rate-limit": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "requires": { + "ip-address": "^10.2.0" + } + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==" + }, "fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -5073,6 +7875,19 @@ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" }, + "finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "requires": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + } + }, "fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -5084,6 +7899,16 @@ "rollup": "^4.34.8" } }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" + }, "fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -5110,6 +7935,11 @@ "dev": true, "optional": true }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, "gauge": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", @@ -5126,12 +7956,43 @@ "wide-align": "^1.1.5" } }, + "get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==" + }, "get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", "dev": true }, + "get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "requires": { + "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.2", + "math-intrinsics": "^1.1.0" + } + }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, "get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -5160,24 +8021,65 @@ "path-is-absolute": "^1.0.0" } }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, "graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "optional": true }, + "graphql": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", + "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==", + "peer": true + }, + "has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, "has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", "optional": true }, + "hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "requires": { + "function-bind": "^1.1.2" + } + }, + "hono": { + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==" + }, "http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "optional": true }, + "http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "requires": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + } + }, "http-proxy-agent": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", @@ -5260,11 +8162,102 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, + "ink": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", + "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", + "requires": { + "@alcalzone/ansi-tokenize": "^0.1.3", + "ansi-escapes": "^7.0.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.22.0", + "indent-string": "^5.0.0", + "is-in-ci": "^1.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^7.2.0", + "type-fest": "^4.27.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "dependencies": { + "ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" + }, + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==" + }, + "emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==" + }, + "indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==" + }, + "string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "requires": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + } + }, + "strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "requires": { + "ansi-regex": "^6.2.2" + } + } + } + }, + "ink-testing-library": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", + "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", + "dev": true, + "requires": {} + }, + "ink-text-input": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", + "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "requires": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + } + }, "ip-address": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.1.tgz", - "integrity": "sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw==", - "optional": true + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==" + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" }, "is-fullwidth-code-point": { "version": "3.0.0", @@ -5272,17 +8265,31 @@ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "optional": true }, + "is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==" + }, "is-lambda": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", "optional": true }, + "is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "optional": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==" }, "joycon": { "version": "3.1.1", @@ -5290,6 +8297,35 @@ "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", "dev": true }, + "js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==" + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "requires": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==" + }, "lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -5314,6 +8350,14 @@ "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", "dev": true }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, "loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -5365,11 +8409,49 @@ "ssri": "^8.0.0" } }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" + }, + "merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" + }, + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "requires": { + "mime-db": "^1.54.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" + }, "mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, "minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -5474,8 +8556,16 @@ "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "requires": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + } }, "mz": { "version": "2.7.0", @@ -5576,8 +8666,20 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + }, + "object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "requires": { + "ee-first": "1.1.1" + } }, "once": { "version": "1.4.0", @@ -5587,6 +8689,14 @@ "wrappy": "1" } }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "requires": { + "mimic-fn": "^2.1.0" + } + }, "p-limit": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", @@ -5605,12 +8715,32 @@ "aggregate-error": "^3.0.0" } }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==" + }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "optional": true }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, + "path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==" + }, "pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -5641,6 +8771,11 @@ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", "dev": true }, + "pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==" + }, "pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -5672,6 +8807,23 @@ "lilconfig": "^3.1.1" } }, + "postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "requires": { + "commander": "^9.4.0" + }, + "dependencies": { + "commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true + } + } + }, "prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -5718,6 +8870,15 @@ "retry": "^0.12.0" } }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, "pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -5727,6 +8888,40 @@ "once": "^1.3.1" } }, + "qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "requires": { + "side-channel": "^1.1.0" + } + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "requires": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, "rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -5738,12 +8933,29 @@ "strip-json-comments": "~2.0.1" } }, + "react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "requires": { + "loose-envify": "^1.1.0" + } + }, "react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "requires": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + } + }, "readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -5757,8 +8969,12 @@ "readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==" + }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" }, "resolve-from": { "version": "5.0.0", @@ -5772,6 +8988,15 @@ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true }, + "restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, "retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -5822,6 +9047,18 @@ "fsevents": "~2.3.2" } }, + "router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "requires": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + } + }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -5830,20 +9067,118 @@ "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "optional": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "requires": { + "loose-envify": "^1.1.0" + } }, "semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==" }, + "send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "requires": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + } + }, + "serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "requires": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + } + }, "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "optional": true }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + "side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + } + }, + "side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + } + }, + "side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + } + }, + "side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + } + }, "siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5853,8 +9188,7 @@ "signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "optional": true + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, "simple-concat": { "version": "1.0.1", @@ -5871,6 +9205,30 @@ "simple-concat": "^1.0.0" } }, + "slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "requires": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==" + }, + "is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "requires": { + "get-east-asian-width": "^1.3.1" + } + } + } + }, "smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -5936,12 +9294,25 @@ "minipass": "^3.1.1" } }, + "stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "requires": { + "escape-string-regexp": "^2.0.0" + } + }, "stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, + "statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" + }, "std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -6073,6 +9444,11 @@ "thenify": ">= 3.1.0 < 4" } }, + "thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" + }, "tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -6107,12 +9483,22 @@ "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", "dev": true }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, "tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", "dev": true }, + "ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==" + }, "ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -6169,6 +9555,21 @@ "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", "dev": true }, + "type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==" + }, + "type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "requires": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + } + }, "typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -6213,6 +9614,11 @@ "imurmurhash": "^0.1.4" } }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6223,6 +9629,11 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + }, "vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -6495,7 +9906,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "optional": true, "requires": { "isexe": "^2.0.0" } @@ -6519,11 +9929,100 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "requires": { + "string-width": "^7.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" + }, + "emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==" + }, + "string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "requires": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + } + }, + "strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "requires": { + "ansi-regex": "^6.2.2" + } + } + } + }, + "wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "requires": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" + }, + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==" + }, + "emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==" + }, + "string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "requires": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + } + }, + "strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "requires": { + "ansi-regex": "^6.2.2" + } + } + } + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "requires": {} + }, "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -6540,10 +10039,21 @@ "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", "dev": true }, + "yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==" + }, "zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==" + }, + "zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "requires": {} } } } diff --git a/apps/ade-cli/package.json b/apps/ade-cli/package.json index 5077eb3f1..410e3b1f1 100644 --- a/apps/ade-cli/package.json +++ b/apps/ade-cli/package.json @@ -17,18 +17,37 @@ "cli:dev": "npm run build --silent && node dist/cli.cjs", "dev": "tsx src/cli.ts", "build": "tsup && node ./scripts/verify-built-cli.mjs", + "build:static": "node ./scripts/build-static.mjs", + "notarize:static": "node ./scripts/notarize-static-runtime.mjs", + "package:native-deps": "node ./scripts/package-native-deps.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.20.0", + "@anthropic-ai/claude-agent-sdk": "^0.2.119", "@cursor/sdk": "^1.0.9", + "@linear/sdk": "^84.0.0", + "@opencode-ai/sdk": "^1.4.2", + "@wize-logic/nodejs-rfb": "^4.2.0", + "bonjour-service": "^1.3.0", + "chokidar": "^4.0.3", + "ink": "^5.2.1", + "ink-text-input": "^6.0.0", "node-cron": "^3.0.3", "node-pty": "^1.1.0", + "react": "^18.3.1", "sql.js": "^1.13.0", - "yaml": "^2.8.2" + "ws": "^8.20.0", + "yaml": "^2.8.2", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^20.11.30", + "@types/react": "^18.3.28", + "@types/ws": "^8.18.1", + "ink-testing-library": "^4.0.0", + "postject": "^1.0.0-alpha.6", "tsup": "^8.3.5", "tsx": "^4.20.6", "typescript": "^5.7.3", diff --git a/apps/ade-cli/scripts/build-static.mjs b/apps/ade-cli/scripts/build-static.mjs new file mode 100644 index 000000000..ea9139b9d --- /dev/null +++ b/apps/ade-cli/scripts/build-static.mjs @@ -0,0 +1,289 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const defaultOutDir = path.join(packageRoot, "dist-static"); +const fuse = "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2"; + +function parseArgs(argv) { + const args = { + target: currentTarget(), + outDir: defaultOutDir, + skipBuild: false, + skipNativeDeps: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--") { + continue; + } else if (token === "--target") { + args.target = argv[++i] ?? ""; + } else if (token === "--out-dir") { + args.outDir = path.resolve(argv[++i] ?? ""); + } else if (token === "--skip-build") { + args.skipBuild = true; + } else if (token === "--skip-native-deps") { + args.skipNativeDeps = true; + } else if (token === "--help" || token === "-h") { + printHelp(); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${token}`); + } + } + validateTarget(args.target); + return args; +} + +function printHelp() { + process.stdout.write([ + "Usage: node scripts/build-static.mjs [--target darwin-arm64] [--out-dir dist-static]", + "", + "Builds an ADE runtime executable with Node SEA. Cross-target builds require", + "ADE_STATIC_NODE_BINARY to point at a matching Node executable.", + "", + ].join("\n")); +} + +function currentTarget() { + const platform = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform; + const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch; + return `${platform}-${arch}`; +} + +function validateTarget(target) { + if (!/^(darwin|linux)-(arm64|x64)$/.test(target)) { + throw new Error(`Unsupported runtime target '${target}'. Expected darwin-arm64, darwin-x64, linux-arm64, or linux-x64.`); + } +} + +async function assertHostOrExplicitBinary(target) { + if (target === currentTarget() || process.env.ADE_STATIC_NODE_BINARY) return; + throw new Error(`Cannot build ${target} from ${currentTarget()} without ADE_STATIC_NODE_BINARY.`); +} + +async function run(command, args, options = {}) { + let stdout = ""; + let stderr = ""; + try { + const result = await execFileAsync(command, args, { + cwd: packageRoot, + env: process.env, + maxBuffer: 50 * 1024 * 1024, + ...options, + }); + stdout = result.stdout; + stderr = result.stderr; + } catch (error) { + stdout = typeof error?.stdout === "string" ? error.stdout : ""; + stderr = typeof error?.stderr === "string" ? error.stderr : ""; + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + throw error; + } + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); +} + +async function assertSeaCapableNodeBinary(binaryPath) { + const contents = await fs.readFile(binaryPath); + if (contents.includes(Buffer.from(fuse))) return; + throw new Error([ + `Node binary '${binaryPath}' is not SEA-capable; it does not contain ${fuse}.`, + "Use an official Node.js release binary for this target, or set ADE_STATIC_NODE_BINARY to one before running build:static.", + ].join(" ")); +} + +async function removeSignatureIfNeeded(binaryPath) { + if (process.platform !== "darwin") return; + try { + await run("codesign", ["--remove-signature", binaryPath]); + } catch { + // Some Node builds are unsigned. postject can proceed in that case. + } +} + +async function adHocSignIfNeeded(binaryPath) { + if (process.platform !== "darwin") return; + await run("codesign", ["--sign", "-", binaryPath]); +} + +async function writeSeaEntry(workDir) { + const cliPath = path.join(packageRoot, "dist", "cli.cjs"); + const seaEntryPath = path.join(workDir, "cli-sea.cjs"); + const cliSource = await fs.readFile(cliPath, "utf8"); + const banner = `\ +var __adeSeaOriginalRequire = require; +var __adeSeaModule = __adeSeaOriginalRequire("module"); +var __adeSeaPath = __adeSeaOriginalRequire("path"); +var __adeSeaOs = __adeSeaOriginalRequire("os"); +var __adeSeaFs = __adeSeaOriginalRequire("fs"); +function __adeSeaTargetLabel() { + var platform = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform; + var arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch; + return platform + "-" + arch; +} +function __adeSeaRuntimeRootFromNodeModules(value) { + if (!value) return null; + return __adeSeaPath.basename(value) === "node_modules" ? __adeSeaPath.dirname(value) : value; +} +function __adeSeaDirectoryExists(value) { + try { + return __adeSeaFs.statSync(value).isDirectory(); + } catch { + return false; + } +} +function __adeSeaCandidateRuntimeRoots() { + var target = __adeSeaTargetLabel(); + var roots = []; + var explicitRoot = process.env.ADE_RUNTIME_ROOT; + var explicitNodeModules = process.env.ADE_RUNTIME_NODE_MODULES; + if (explicitRoot) roots.push(explicitRoot); + if (explicitNodeModules) roots.push(__adeSeaRuntimeRootFromNodeModules(explicitNodeModules)); + if (process.env.NODE_PATH) { + process.env.NODE_PATH.split(__adeSeaPath.delimiter).forEach(function (entry) { + roots.push(__adeSeaRuntimeRootFromNodeModules(entry)); + }); + } + roots.push(__adeSeaPath.join(__adeSeaPath.dirname(process.execPath), "ade-" + target + ".native")); + roots.push(__adeSeaPath.dirname(process.execPath)); + roots.push(__adeSeaPath.join(__adeSeaPath.dirname(process.execPath), "..", "runtime", target)); + roots.push(__adeSeaPath.join(__adeSeaOs.homedir(), ".ade", "runtime", target)); + return roots.filter(function (entry, index) { + return Boolean(entry) && roots.indexOf(entry) === index; + }); +} +function __adeSeaResolveRuntimeRoot() { + var roots = __adeSeaCandidateRuntimeRoots(); + for (var index = 0; index < roots.length; index += 1) { + var root = roots[index]; + if (__adeSeaDirectoryExists(__adeSeaPath.join(root, "node_modules"))) return root; + } + return null; +} +var __adeSeaRuntimeRoot = __adeSeaResolveRuntimeRoot(); +if (__adeSeaRuntimeRoot) { + var __adeSeaRuntimeNodeModules = __adeSeaPath.join(__adeSeaRuntimeRoot, "node_modules"); + var __adeSeaNodePath = process.env.NODE_PATH || ""; + var __adeSeaNodePathParts = __adeSeaNodePath.split(__adeSeaPath.delimiter).filter(Boolean); + if (!__adeSeaNodePathParts.includes(__adeSeaRuntimeNodeModules)) { + process.env.NODE_PATH = [__adeSeaRuntimeNodeModules].concat(__adeSeaNodePathParts).join(__adeSeaPath.delimiter); + if (typeof __adeSeaModule._initPaths === "function") __adeSeaModule._initPaths(); + } +} +var __adeSeaFilesystemRequire = __adeSeaModule.createRequire( + __adeSeaRuntimeRoot ? __adeSeaPath.join(__adeSeaRuntimeRoot, ".ade-runtime.cjs") : process.execPath +); +function __adeSeaRequire(id) { + try { + return __adeSeaOriginalRequire(id); + } catch (error) { + if (error && (error.code === "ERR_UNKNOWN_BUILTIN_MODULE" || error.code === "MODULE_NOT_FOUND")) { + return __adeSeaFilesystemRequire(id); + } + throw error; + } +} +Object.assign(__adeSeaRequire, __adeSeaOriginalRequire); +__adeSeaRequire.resolve = function __adeSeaRequireResolve(id, options) { + try { + return __adeSeaOriginalRequire.resolve(id, options); + } catch (error) { + if (error && (error.code === "ERR_UNKNOWN_BUILTIN_MODULE" || error.code === "MODULE_NOT_FOUND")) { + return __adeSeaFilesystemRequire.resolve(id, options); + } + throw error; + } +}; +require = __adeSeaRequire; +var __adeSeaArgv1 = process.argv[1] || ""; +if (!/(^|[/\\\\])cli\\.(?:ts|js|cjs)$/.test(__adeSeaArgv1)) { + if (__adeSeaArgv1 === process.execPath || /(^|[/\\\\])ade(?:[-.]|$)/.test(__adeSeaArgv1)) { + process.argv[1] = "cli.cjs"; + } else { + process.argv.splice(1, 0, "cli.cjs"); + } +} +`; + const source = cliSource.startsWith("#!") + ? cliSource.replace(/^#!.*\n/u, "") + : cliSource; + await fs.writeFile(seaEntryPath, `${banner}\n${source}`, "utf8"); + return seaEntryPath; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + await assertHostOrExplicitBinary(args.target); + await fs.mkdir(args.outDir, { recursive: true }); + + if (!args.skipBuild) { + await run(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "build"]); + } + + const workDir = path.join(args.outDir, ".sea", args.target); + await fs.rm(workDir, { recursive: true, force: true }); + await fs.mkdir(workDir, { recursive: true }); + const seaEntryPath = await writeSeaEntry(workDir); + + const seaConfigPath = path.join(workDir, "sea-config.json"); + const blobPath = path.join(workDir, "ade.blob"); + const seaConfig = { + main: seaEntryPath, + output: blobPath, + disableExperimentalSEAWarning: true, + useCodeCache: false, + useSnapshot: false, + }; + await fs.writeFile(seaConfigPath, `${JSON.stringify(seaConfig, null, 2)}\n`, "utf8"); + await run(process.execPath, ["--experimental-sea-config", seaConfigPath]); + + const sourceNodeBinary = process.env.ADE_STATIC_NODE_BINARY || process.execPath; + await assertSeaCapableNodeBinary(sourceNodeBinary); + const binaryName = `ade-${args.target}${process.platform === "win32" ? ".exe" : ""}`; + const binaryPath = path.join(args.outDir, binaryName); + await fs.copyFile(sourceNodeBinary, binaryPath); + await fs.chmod(binaryPath, 0o755); + await removeSignatureIfNeeded(binaryPath); + + const postjectArgs = [ + binaryPath, + "NODE_SEA_BLOB", + blobPath, + "--sentinel-fuse", + fuse, + ]; + if (args.target.startsWith("darwin-")) { + postjectArgs.push("--macho-segment-name", "NODE_SEA"); + } + await run(path.join(packageRoot, "node_modules", ".bin", process.platform === "win32" ? "postject.cmd" : "postject"), postjectArgs); + await adHocSignIfNeeded(binaryPath); + + let nativeArchivePath = null; + if (!args.skipNativeDeps) { + await run(process.execPath, [ + path.join(packageRoot, "scripts", "package-native-deps.mjs"), + "--target", + args.target, + "--out-dir", + args.outDir, + ]); + nativeArchivePath = path.join(args.outDir, `ade-${args.target}.native.tar.gz`); + } + + process.stdout.write(`${JSON.stringify({ + target: args.target, + binaryPath, + nativeArchivePath, + }, null, 2)}\n`); +} + +main().catch((error) => { + process.stderr.write(`[build-static] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/apps/ade-cli/scripts/install-runtime.sh b/apps/ade-cli/scripts/install-runtime.sh new file mode 100644 index 000000000..6ea64c870 --- /dev/null +++ b/apps/ade-cli/scripts/install-runtime.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env sh +set -eu + +repo="${ADE_RELEASE_REPO:-arul28/ADE}" +version="${ADE_VERSION:-latest}" +install_dir="${ADE_INSTALL_DIR:-}" +ade_home="${ADE_HOME:-$HOME/.ade}" + +die() { + printf '%s\n' "ade install: $*" >&2 + exit 1 +} + +need() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +detect_target() { + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m | tr '[:upper:]' '[:lower:]')" + + case "$os" in + darwin) platform="darwin" ;; + linux) platform="linux" ;; + *) die "unsupported OS: $os" ;; + esac + + case "$arch" in + arm64|aarch64) cpu="arm64" ;; + x86_64|amd64) cpu="x64" ;; + *) die "unsupported architecture: $arch" ;; + esac + + printf '%s-%s\n' "$platform" "$cpu" +} + +download() { + url="$1" + out="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$out" + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$out" + else + die "missing curl or wget" + fi +} + +try_install_service() { + service_log="$tmp_dir/install-service.log" + if "$dest_dir/ade" serve --install-service >"$service_log" 2>&1; then + return 0 + fi + + status="$?" + printf 'ade install: warning: runtime service install failed with exit status %s; ADE was installed but the login service was not registered.\n' "$status" >&2 + if [ -s "$service_log" ]; then + while IFS= read -r line; do + printf 'ade install: service: %s\n' "$line" >&2 + done < "$service_log" + fi + return 0 +} + +asset_url() { + name="$1" + if [ "$version" = "latest" ]; then + printf 'https://github.com/%s/releases/latest/download/%s\n' "$repo" "$name" + else + printf 'https://github.com/%s/releases/download/%s/%s\n' "$repo" "$version" "$name" + fi +} + +choose_install_dir() { + if [ -n "$install_dir" ]; then + printf '%s\n' "$install_dir" + return + fi + + if [ -w /usr/local/bin ]; then + printf '%s\n' "/usr/local/bin" + return + fi + + printf '%s\n' "$HOME/.local/bin" +} + +need uname +need tar +need chmod +target="$(detect_target)" +binary_name="ade-$target" +archive_name="$binary_name.native.tar.gz" +dest_dir="$(choose_install_dir)" +runtime_dir="$ade_home/runtime/$target" +tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/ade-install.XXXXXX")" +trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM + +mkdir -p "$dest_dir" "$runtime_dir" "$ade_home/bin" + +download "$(asset_url "$binary_name")" "$tmp_dir/ade" +download "$(asset_url "$archive_name")" "$tmp_dir/native.tar.gz" + +chmod 755 "$tmp_dir/ade" +cp "$tmp_dir/ade" "$dest_dir/ade" +chmod 755 "$dest_dir/ade" + +rm -rf "$runtime_dir/node_modules" +tar -xzf "$tmp_dir/native.tar.gz" -C "$runtime_dir" +export NODE_PATH="$runtime_dir/node_modules${NODE_PATH:+:$NODE_PATH}" + +"$dest_dir/ade" --version >/dev/null || die "installed ade binary failed to run" + +if command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then + try_install_service +elif [ "$(uname -s)" = "Darwin" ]; then + try_install_service +fi + +printf 'ADE runtime installed: %s\n' "$dest_dir/ade" +case ":$PATH:" in + *":$dest_dir:"*) ;; + *) printf 'Add %s to PATH to run ade from new shells.\n' "$dest_dir" ;; +esac diff --git a/apps/ade-cli/scripts/notarize-static-runtime.mjs b/apps/ade-cli/scripts/notarize-static-runtime.mjs new file mode 100644 index 000000000..d90cebdbb --- /dev/null +++ b/apps/ade-cli/scripts/notarize-static-runtime.mjs @@ -0,0 +1,133 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +function readFlag(name) { + const prefix = `${name}=`; + for (const arg of process.argv.slice(2)) { + if (arg.startsWith(prefix)) return arg.slice(prefix.length).trim(); + } + return null; +} + +function hasEnv(name) { + return Boolean(process.env[name] && String(process.env[name]).trim().length > 0); +} + +async function assertExists(filePath, label) { + try { + await fs.access(filePath); + } catch { + throw new Error(`Missing ${label}: ${filePath}`); + } +} + +async function run(command, args, options = {}) { + const result = await execFileAsync(command, args, { + maxBuffer: 10 * 1024 * 1024, + ...options, + }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + return result; +} + +async function findDeveloperIdIdentity() { + const { stdout } = await run("security", ["find-identity", "-v", "-p", "codesigning"]); + const explicit = process.env.ADE_RUNTIME_CODESIGN_IDENTITY || process.env.CSC_NAME; + if (explicit?.trim()) return explicit.trim(); + + for (const line of stdout.split(/\r?\n/)) { + const match = /"([^"]*Developer ID Application[^"]*)"/.exec(line); + if (match?.[1]) return match[1]; + } + throw new Error("Unable to find a Developer ID Application signing identity."); +} + +function buildNotarytoolArgs(zipPath) { + if (hasEnv("APPLE_API_KEY") && hasEnv("APPLE_API_KEY_ID") && hasEnv("APPLE_API_ISSUER")) { + return [ + "notarytool", + "submit", + zipPath, + "--key", + process.env.APPLE_API_KEY, + "--key-id", + process.env.APPLE_API_KEY_ID, + "--issuer", + process.env.APPLE_API_ISSUER, + "--wait", + ]; + } + + if (hasEnv("APPLE_ID") && hasEnv("APPLE_APP_SPECIFIC_PASSWORD") && hasEnv("APPLE_TEAM_ID")) { + return [ + "notarytool", + "submit", + zipPath, + "--apple-id", + process.env.APPLE_ID, + "--password", + process.env.APPLE_APP_SPECIFIC_PASSWORD, + "--team-id", + process.env.APPLE_TEAM_ID, + "--wait", + ]; + } + + if (hasEnv("APPLE_KEYCHAIN_PROFILE")) { + const args = ["notarytool", "submit", zipPath, "--keychain-profile", process.env.APPLE_KEYCHAIN_PROFILE, "--wait"]; + if (hasEnv("APPLE_KEYCHAIN")) args.push("--keychain", process.env.APPLE_KEYCHAIN); + return args; + } + + throw new Error( + "Missing notarization credentials. Provide APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER, " + + "or APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID, or APPLE_KEYCHAIN_PROFILE.", + ); +} + +const binary = readFlag("--binary"); +if (!binary) { + throw new Error("Usage: node scripts/notarize-static-runtime.mjs --binary=/path/to/ade-darwin-arm64"); +} + +const binaryPath = path.resolve(binary); +await assertExists(binaryPath, "ADE runtime binary"); + +if (process.platform !== "darwin") { + throw new Error("Static runtime notarization must run on macOS."); +} + +const identity = await findDeveloperIdIdentity(); +console.log(`[runtime:notarize] Signing ${binaryPath} with ${identity}`); +await run("codesign", [ + "--force", + "--options", + "runtime", + "--timestamp", + "--sign", + identity, + binaryPath, +]); +await run("codesign", ["--verify", "--strict", "--verbose=4", binaryPath]); + +const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "ade-runtime-notary-")); +const zipPath = path.join(workDir, `${path.basename(binaryPath)}.zip`); +try { + console.log(`[runtime:notarize] Creating notarization archive ${zipPath}`); + await run("ditto", ["-c", "-k", "--keepParent", binaryPath, zipPath]); + + console.log(`[runtime:notarize] Submitting ${path.basename(binaryPath)} to notarytool`); + await run("xcrun", buildNotarytoolArgs(zipPath)); + + console.log(`[runtime:notarize] Stapling ${binaryPath}`); + await run("xcrun", ["stapler", "staple", binaryPath]); + await run("spctl", ["--assess", "--type", "execute", "--verbose=4", binaryPath]); +} finally { + await fs.rm(workDir, { recursive: true, force: true }); +} diff --git a/apps/ade-cli/scripts/package-native-deps.mjs b/apps/ade-cli/scripts/package-native-deps.mjs new file mode 100644 index 000000000..57c45ae39 --- /dev/null +++ b/apps/ade-cli/scripts/package-native-deps.mjs @@ -0,0 +1,195 @@ +import { createWriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { pipeline } from "node:stream/promises"; +import { createGzip } from "node:zlib"; +import { spawn } from "node:child_process"; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const nodeModulesRoot = path.join(packageRoot, "node_modules"); +const defaultOutDir = path.join(packageRoot, "dist-static"); + +function parseArgs(argv) { + const args = { target: null, outDir: defaultOutDir }; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--target") { + args.target = argv[++i] ?? null; + } else if (token === "--out-dir") { + args.outDir = path.resolve(argv[++i] ?? ""); + } else if (token === "--help" || token === "-h") { + printHelp(); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${token}`); + } + } + args.target ??= currentTarget(); + validateTarget(args.target); + return args; +} + +function printHelp() { + process.stdout.write(`Usage: node scripts/package-native-deps.mjs [--target darwin-arm64] [--out-dir dist-static]\n`); +} + +function currentTarget() { + const platform = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform; + const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch; + return `${platform}-${arch}`; +} + +function validateTarget(target) { + if (!/^(darwin|linux)-(arm64|x64)$/.test(target)) { + throw new Error(`Unsupported runtime target '${target}'. Expected darwin-arm64, darwin-x64, linux-arm64, or linux-x64.`); + } +} + +async function exists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function readJson(filePath) { + return JSON.parse(await fs.readFile(filePath, "utf8")); +} + +function packagePath(packageName) { + return path.join(nodeModulesRoot, ...packageName.split("/")); +} + +async function readPackageManifest(packageName) { + const manifestPath = path.join(packagePath(packageName), "package.json"); + if (!(await exists(manifestPath))) return null; + return await readJson(manifestPath); +} + +async function collectRuntimePackages(target) { + const rootManifest = await readJson(path.join(packageRoot, "package.json")); + const platformCursorPackage = `@cursor/sdk-${target}`; + const queue = [ + ...Object.keys(rootManifest.dependencies ?? {}), + platformCursorPackage, + ]; + const visited = new Set(); + const packages = []; + + while (queue.length > 0) { + const packageName = queue.shift(); + if (!packageName || visited.has(packageName)) continue; + visited.add(packageName); + const manifest = await readPackageManifest(packageName); + if (!manifest) continue; + packages.push(packageName); + + const deps = { + ...(manifest.dependencies ?? {}), + ...(manifest.optionalDependencies ?? {}), + }; + for (const dependencyName of Object.keys(deps)) { + if (dependencyName.startsWith("@cursor/sdk-") && dependencyName !== platformCursorPackage) { + continue; + } + if (!visited.has(dependencyName)) queue.push(dependencyName); + } + } + + return packages.sort((a, b) => a.localeCompare(b)); +} + +async function copyPackage(packageName, destinationRoot) { + const source = packagePath(packageName); + if (!(await exists(source))) return false; + const destination = path.join(destinationRoot, "node_modules", ...packageName.split("/")); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.rm(destination, { recursive: true, force: true }); + await fs.cp(source, destination, { + recursive: true, + filter: (entry) => { + const normalized = entry.split(path.sep).join("/"); + return !normalized.includes("/.cache/") + && !normalized.includes("/test/") + && !normalized.includes("/tests/") + && !normalized.endsWith(".map"); + }, + }); + return true; +} + +async function writeManifest(bundleRoot, target, packages) { + const manifest = { + target, + createdAt: new Date().toISOString(), + packages, + }; + await fs.writeFile(path.join(bundleRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); +} + +async function chmodRuntimeExecutables(bundleRoot, target) { + if (!target.startsWith("darwin-")) return; + const helperPath = path.join(bundleRoot, "node_modules", "node-pty", "prebuilds", target, "spawn-helper"); + if (!(await exists(helperPath))) return; + const stat = await fs.stat(helperPath); + await fs.chmod(helperPath, stat.mode | 0o111); +} + +async function makeTarGz(sourceDir, outputPath) { + await fs.rm(outputPath, { force: true }); + const tar = spawn("tar", ["-cf", "-", "-C", sourceDir, "."], { + stdio: ["ignore", "pipe", "inherit"], + }); + let spawnError = null; + tar.once("error", (error) => { + spawnError = error; + tar.stdout.destroy(error); + }); + const out = createWriteStream(outputPath, { mode: 0o644 }); + try { + await pipeline(tar.stdout, createGzip({ level: 9 }), out); + } catch (error) { + if (spawnError?.code === "ENOENT") { + throw new Error("The 'tar' command is required to package native runtime dependencies."); + } + throw error; + } + const exitCode = await new Promise((resolve) => tar.once("close", resolve)); + if (exitCode !== 0) { + throw new Error(`tar exited with status ${exitCode}`); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const bundleRoot = path.join(args.outDir, `ade-${args.target}.native`); + await fs.rm(bundleRoot, { recursive: true, force: true }); + await fs.mkdir(bundleRoot, { recursive: true }); + + const packageNames = await collectRuntimePackages(args.target); + const copied = []; + for (const packageName of packageNames) { + if (await copyPackage(packageName, bundleRoot)) { + copied.push(packageName); + } + } + await chmodRuntimeExecutables(bundleRoot, args.target); + await writeManifest(bundleRoot, args.target, copied); + + const archivePath = path.join(args.outDir, `ade-${args.target}.native.tar.gz`); + await makeTarGz(bundleRoot, archivePath); + process.stdout.write(`${JSON.stringify({ + target: args.target, + archivePath, + bundleRoot, + packages: copied, + }, null, 2)}\n`); +} + +main().catch((error) => { + process.stderr.write(`[package-native-deps] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/apps/ade-cli/scripts/verify-built-cli.mjs b/apps/ade-cli/scripts/verify-built-cli.mjs index d4d0d075e..fc95d1009 100644 --- a/apps/ade-cli/scripts/verify-built-cli.mjs +++ b/apps/ade-cli/scripts/verify-built-cli.mjs @@ -7,6 +7,7 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "dist", "cli.cjs"); +const packageJsonPath = path.join(packageRoot, "package.json"); async function runHelp(command, args) { const { stdout } = await execFileAsync(command, args, { @@ -18,7 +19,24 @@ async function runHelp(command, args) { } } +async function assertVersion(command, args, expectedVersion) { + const { stdout } = await execFileAsync(command, args, { + cwd: packageRoot, + env: process.env, + }); + const actual = stdout.trim().replace(/^ade\s+/i, ""); + if (actual !== expectedVersion) { + throw new Error(`[ade-cli:build] CLI version mismatch: expected ${expectedVersion}, got ${actual || ""}`); + } +} + const contents = await fs.readFile(cliPath, "utf8"); +const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")); +const expectedVersion = process.env.ADE_CLI_VERSION?.trim() || packageJson.version; +if (!expectedVersion) { + throw new Error("[ade-cli:build] Unable to resolve expected CLI version from ADE_CLI_VERSION or package.json"); +} + if (!contents.startsWith("#!/usr/bin/env node")) { throw new Error("[ade-cli:build] dist/cli.cjs is missing the node shebang"); } @@ -34,9 +52,11 @@ if (process.platform !== "win32" && (stat.mode & 0o111) === 0) { } await runHelp(process.execPath, [cliPath, "--help"]); +await assertVersion(process.execPath, [cliPath, "--version"], expectedVersion); if (process.platform !== "win32") { await runHelp(cliPath, ["--help"]); + await assertVersion(cliPath, ["--version"], expectedVersion); } console.log("[ade-cli:build] verified dist/cli.cjs binary"); diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index b9ec45e09..3bbd9d1af 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -216,12 +216,50 @@ function createRuntime() { get: vi.fn(), readTranscriptTail: vi.fn(() => "") }, + sessionDeltaService: { + getSessionDelta: vi.fn((sessionId: string) => ({ sessionId, filesChanged: 2 })), + }, operationService: { start: operationStart, finish: operationFinish, list: vi.fn(() => [{ id: "op-1", kind: "git_push", status: "running" }]), }, projectConfigService: {} as any, + aiIntegrationService: { + getStatus: vi.fn(async () => ({ + mode: "subscription", + availableProviders: { + claude: true, + codex: true, + cursor: false, + droid: false, + }, + models: { + claude: [], + codex: [], + cursor: [], + droid: [], + }, + detectedAuth: [ + { type: "cli-subscription", cli: "codex", authenticated: true }, + ], + providerConnections: {}, + runtimeConnections: {}, + availableModelIds: ["openai/gpt-5.5"], + opencodeBinaryInstalled: true, + opencodeBinarySource: "bundled", + opencodeInventoryError: null, + opencodeProviders: [], + apiKeyStore: { + secureStorageAvailable: true, + legacyPlaintextDetected: false, + decryptionFailed: false, + }, + })), + getDailyUsageBatch: vi.fn(() => new Map()), + getFeatureFlag: vi.fn(() => true), + getDailyBudgetLimit: vi.fn(() => null), + } as any, conflictService: { runPrediction: vi.fn(async () => ({ lanes: [], matrix: [], overlaps: [] })), getLaneStatus: vi.fn(async ({ laneId }: { laneId: string }) => ({ laneId, status: "merge-ready" })), @@ -784,6 +822,7 @@ function createRuntime() { getBackendStatus: vi.fn(() => ({ backends: [] })), listArtifacts: vi.fn(() => []), ingest: vi.fn(() => ({ artifacts: [] })), + readArtifactPreview: vi.fn(async () => "data:image/png;base64,AAAA"), } as any, macosVmService: { getStatus: vi.fn(async ({ laneId }: { laneId?: string | null } = {}) => ({ @@ -1152,6 +1191,75 @@ function createFakePathExecutable(dir: string, name: string): string { } describe("adeRpcServer", () => { + it("routes app/navigate through the runtime navigation service", async () => { + const { runtime } = createRuntime(); + const navigate = vi.fn(async () => ({ ok: true, mode: "desktop", windowId: 7 })); + runtime.appNavigationService = { navigate }; + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + await initialize(handler, { role: "cto" }); + + const result = await handler({ + jsonrpc: "2.0", + id: 2, + method: "app/navigate", + params: { + source: "ade-code", + target: { kind: "lane", sessionId: "chat-1", laneId: "lane-1" }, + }, + }); + + expect(result).toEqual({ ok: true, mode: "desktop", windowId: 7 }); + expect(navigate).toHaveBeenCalledWith({ + source: "ade-code", + target: { kind: "lane", sessionId: "chat-1", laneId: "lane-1" }, + }); + }); + + it("reports app/navigate unavailable in headless runtime", async () => { + const { runtime } = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + await initialize(handler, { role: "cto" }); + + const result = await handler({ + jsonrpc: "2.0", + id: 2, + method: "app/navigate", + params: { + source: "ade-code", + target: { kind: "work" }, + }, + }); + + expect(result).toEqual({ + ok: false, + mode: "unavailable", + message: "Desktop navigation is unavailable in this runtime.", + }); + }); + + it("rejects malformed app/navigate targets before calling the runtime service", async () => { + const { runtime } = createRuntime(); + const navigate = vi.fn(async () => ({ ok: true, mode: "desktop", windowId: 7 })); + runtime.appNavigationService = { navigate }; + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + await initialize(handler, { role: "cto" }); + + await expect(handler({ + jsonrpc: "2.0", + id: 2, + method: "app/navigate", + params: { + source: "ade-code", + target: { kind: "lane" }, + }, + })).rejects.toMatchObject({ + code: JsonRpcErrorCode.invalidParams, + message: "app/navigate target 'lane' requires laneId.", + }); + + expect(navigate).not.toHaveBeenCalled(); + }); + it("treats requested privileged roles as external without trusted env identity", async () => { const { runtime } = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); @@ -3882,7 +3990,7 @@ describe("adeRpcServer", () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(handler, { callerId: "agent-1", role: "agent" }); + await initialize(handler, { callerId: "cto-1", role: "cto" }); const response = await callTool(handler, "commit_changes", { laneId: "lane-1", @@ -4040,6 +4148,7 @@ describe("adeRpcServer", () => { const allDomains = await callTool(handler, "list_ade_actions", { domain: "all" }); expect(allDomains?.isError).toBeUndefined(); expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "memory")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "ai")).toBe(true); expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "mission")).toBe(true); expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "orchestrator")).toBe(true); expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "orchestrator_core")).toBe(true); @@ -4058,6 +4167,31 @@ describe("adeRpcServer", () => { expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "graph_state")).toBe(true); }); + it("hides memory tools and actions when the runtime disables memory", async () => { + const fixture = createRuntime(); + fixture.runtime.capabilities = { memory: false }; + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "agent-1", role: "agent" }); + + const listed = await handler({ + jsonrpc: "2.0", + id: 2, + method: "ade/actions/list", + params: {}, + }) as { actions: Array<{ name: string }> }; + expect(listed.actions.some((entry) => entry.name.startsWith("memory_"))).toBe(false); + + const memoryCall = await callTool(handler, "memory_add", { + content: "Remember this", + category: "fact", + }); + expect(memoryCall.isError).toBe(true); + expect(String(memoryCall.error?.message ?? "")).toContain("Tool not available"); + + const actionList = await callTool(handler, "list_ade_actions", { domain: "all" }); + expect(actionList.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "memory")).toBe(false); + }); + it("invokes ADE actions dynamically and returns status hints", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); @@ -4089,6 +4223,18 @@ describe("adeRpcServer", () => { expect(keybindings?.isError).toBeUndefined(); expect(fixture.runtime.keybindingsService.get).toHaveBeenCalled(); + const aiStatus = await callTool(handler, "run_ade_action", { + domain: "ai", + action: "getStatus", + args: { refreshOpenCodeInventory: true }, + }); + expect(aiStatus?.isError).toBeUndefined(); + expect(fixture.runtime.aiIntegrationService.getStatus).toHaveBeenCalledWith({ + force: false, + refreshOpenCodeInventory: true, + }); + expect(aiStatus.structuredContent.result.availableModelIds).toContain("openai/gpt-5.5"); + const layoutSet = await callTool(handler, "run_ade_action", { domain: "layout", action: "set", @@ -4104,6 +4250,27 @@ describe("adeRpcServer", () => { }); expect(layoutGet?.isError).toBeUndefined(); expect(layoutGet.structuredContent.result).toEqual({ left: 100, right: 0 }); + + const delta = await callTool(handler, "run_ade_action", { + domain: "session", + action: "getDelta", + args: { sessionId: "session-1" }, + }); + expect(delta?.isError).toBeUndefined(); + expect(fixture.runtime.sessionDeltaService.getSessionDelta).toHaveBeenCalledWith("session-1"); + expect(delta.structuredContent.result).toEqual({ sessionId: "session-1", filesChanged: 2 }); + + const preview = await callTool(handler, "run_ade_action", { + domain: "computer_use_artifacts", + action: "readArtifactPreview", + args: { uri: ".ade/artifacts/proof.png" }, + }); + expect(preview?.isError).toBeUndefined(); + expect(fixture.runtime.computerUseArtifactBrokerService.readArtifactPreview).toHaveBeenCalledWith({ + uri: ".ade/artifacts/proof.png", + }); + expect(preview.structuredContent.result).toBe("data:image/png;base64,AAAA"); + }); it("binds service method context when invoking dynamic ADE actions", async () => { @@ -4128,6 +4295,40 @@ describe("adeRpcServer", () => { expect(response.structuredContent.statusHints.missionId).toBe("mission-new"); }); + it("compacts orchestrator ADE action results for runtime transport", async () => { + const fixture = createRuntime(); + const docs = Array.from({ length: 16 }, (_, index) => ({ + path: index === 0 ? ".ade/internal.md" : `docs/${index}.md`, + bytes: index + 1, + sha256: `sha-${index}`, + })); + fixture.runtime.orchestratorService.listRuns.mockReturnValueOnce([ + { + id: "run-compact", + missionId: "mission-1", + status: "running", + metadata: { runtimeCursor: { docs } }, + }, + ]); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "agent-1", role: "agent" }); + + const response = await callTool(handler, "run_ade_action", { + domain: "orchestrator_core", + action: "listRuns", + args: { limit: 10 }, + }); + + expect(response?.isError).toBeUndefined(); + expect(fixture.runtime.orchestratorService.listRuns).toHaveBeenCalledWith({ limit: 10 }); + const runs = response.structuredContent.result; + expect(runs).toHaveLength(1); + const cursor = runs[0].metadata.runtimeCursor; + expect(cursor.docs).toHaveLength(12); + expect(cursor.docs.map((entry: { path: string }) => entry.path)).not.toContain(".ade/internal.md"); + expect(cursor.docsOmittedCount).toBe(4); + }); + it("does not expose unlisted service methods through dynamic ADE actions", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 8f097488c..2378e45e3 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -40,6 +40,7 @@ import { type GraphPersistedState, type LaneLinearIssue, type MergeMethod, + type AppNavigationRequest, } from "../../desktop/src/shared/types"; import type { PrActionRun, PrCheck, PrComment, PrReviewThread } from "../../desktop/src/shared/types/prs"; import type { CtoLinearQuickView } from "../../desktop/src/shared/types/cto"; @@ -80,16 +81,27 @@ const LINEAR_ISSUE_TOOL_SCHEMA: Record = { "id", "identifier", "title", + "description", + "url", "projectId", "projectSlug", + "projectName", "teamId", "teamKey", + "teamName", "stateId", "stateName", "stateType", "priority", "priorityLabel", "labels", + "assigneeId", + "assigneeName", + "creatorId", + "creatorName", + "dueDate", + "estimate", + "branchName", "createdAt", "updatedAt", ], @@ -2130,6 +2142,12 @@ const ALL_TOOL_SPECS: ToolSpec[] = [ ...COORDINATOR_TOOL_SPECS, ]; const COORDINATOR_TOOL_NAMES = new Set(COORDINATOR_TOOL_SPECS.map((tool) => tool.name)); +const MEMORY_TOOL_NAMES = new Set([ + "memory_add", + "memory_update_core", + "memory_search", + "memory_pin", +]); const READ_ONLY_TOOLS = new Set([ "check_conflicts", @@ -3443,6 +3461,7 @@ function isLocalComputerUseAllowed(callerCtx: CallerContext): boolean { async function listToolSpecsForSession(runtime: AdeRuntime, session: SessionState): Promise { const callerCtx = await resolveEffectiveCallerContext(runtime, session); + const memoryAllowed = runtime.capabilities?.memory !== false; const externalComputerUseAvailable = runtime.computerUseArtifactBrokerService ?.getBackendStatus() ?.backends.some((backend) => backend.available) ?? false; @@ -3452,6 +3471,7 @@ async function listToolSpecsForSession(runtime: AdeRuntime, session: SessionStat const keepVisibleTool = (tool: ToolSpec): boolean => ( (!shouldHideLocalComputerUse || !LOCAL_COMPUTER_USE_TOOL_NAMES.has(tool.name)) && (macosVmAllowed || !MACOS_VM_TOOL_NAMES.has(tool.name)) + && (memoryAllowed || !MEMORY_TOOL_NAMES.has(tool.name)) ); const visibleBaseTools = TOOL_SPECS.filter(keepVisibleTool); const visibleCoordinatorTools = COORDINATOR_TOOL_SPECS.filter(keepVisibleTool); @@ -4611,6 +4631,9 @@ async function runTool(args: { }): Promise { const { runtime, session, name, toolArgs } = args; const callerCtx = await resolveEffectiveCallerContext(runtime, session); + if (runtime.capabilities?.memory === false && MEMORY_TOOL_NAMES.has(name)) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Tool not available in this runtime: ${name}`); + } if (isToolHiddenForStandaloneChat(name, callerCtx)) { throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported tool: ${name}`); } @@ -6859,8 +6882,20 @@ async function runTool(args: { commandPreviewParts.push("--sandbox", "workspace-write", "--ask-for-approval", "untrusted"); } } else { - const claudePermission = - permissionMode === "plan" ? "plan" : permissionMode === "full-auto" ? "bypassPermissions" : permissionMode === "edit" ? "acceptEdits" : "default"; + let claudePermission: string; + switch (permissionMode) { + case "plan": + claudePermission = "plan"; + break; + case "full-auto": + claudePermission = "bypassPermissions"; + break; + case "edit": + claudePermission = "acceptEdits"; + break; + default: + claudePermission = "default"; + } commandArgs.push("--permission-mode", claudePermission); commandPreviewParts.push("--permission-mode", previewShellEscapeArg(claudePermission)); @@ -7628,6 +7663,69 @@ export function createAdeRpcRequestHandler(args: { return { pong: true, at: nowIso() }; } + if (method.startsWith("sync.")) { + const syncService = runtime.syncService; + if (!syncService) { + throw new JsonRpcError(JsonRpcErrorCode.invalidRequest, "Sync service is not available."); + } + if (method === "sync.getStatus") { + return await syncService.getStatus({ + includeTransferReadiness: params.includeTransferReadiness === true, + forceTransferReadiness: params.forceTransferReadiness === true, + }); + } + if (method === "sync.refreshDiscovery") { + return await syncService.refreshDiscovery(); + } + if (method === "sync.listDevices") { + return await syncService.listDevices(); + } + if (method === "sync.updateLocalDevice") { + const name = typeof params.name === "string" ? params.name : undefined; + const deviceType = typeof params.deviceType === "string" ? params.deviceType : undefined; + return await syncService.updateLocalDevice({ + ...(name !== undefined ? { name } : {}), + ...(deviceType !== undefined ? { deviceType: deviceType as never } : {}), + }); + } + if (method === "sync.connectToBrain") { + return await syncService.connectToBrain(params as Parameters[0]); + } + if (method === "sync.disconnectFromBrain") { + return await syncService.disconnectFromBrain(); + } + if (method === "sync.forgetDevice") { + const deviceId = typeof params.deviceId === "string" ? params.deviceId : ""; + return await syncService.forgetDevice(deviceId); + } + if (method === "sync.getTransferReadiness") { + return await syncService.getTransferReadiness(); + } + if (method === "sync.transferBrainToLocal") { + return await syncService.transferBrainToLocal(); + } + if (method === "sync.getPin") { + return { pin: syncService.getPin() }; + } + if (method === "sync.setPin") { + const pin = typeof params.pin === "string" ? params.pin : ""; + return await syncService.setPin(pin); + } + if (method === "sync.generatePin") { + return await syncService.generatePin(); + } + if (method === "sync.clearPin") { + return await syncService.clearPin(); + } + if (method === "sync.setActiveLanePresence") { + const laneIds = Array.isArray(params.laneIds) + ? params.laneIds.filter((laneId): laneId is string => typeof laneId === "string") + : []; + await syncService.setActiveLanePresence(laneIds); + return null; + } + } + if (method === "ade/actions/list") { return await listActions(); } @@ -7664,6 +7762,48 @@ export function createAdeRpcRequestHandler(args: { return await readResource(runtime, uri); } + if (method === "app/navigate") { + const target = safeObject(params.target); + const kind = asOptionalTrimmedString(target.kind); + if (!kind) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate requires target.kind."); + } + if (kind !== "work" && kind !== "chat" && kind !== "lane" && kind !== "pr" && kind !== "route") { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Unsupported app navigation target kind: ${kind}.`); + } + if (kind === "lane" && !asOptionalTrimmedString(target.laneId)) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate target 'lane' requires laneId."); + } + if (kind === "route" && !asOptionalTrimmedString(target.route)) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate target 'route' requires route."); + } + const normalizedTarget: Record = { kind }; + const sessionId = asOptionalTrimmedString(target.sessionId); + const laneId = asOptionalTrimmedString(target.laneId); + if ((kind === "work" || kind === "chat" || kind === "lane") && sessionId) normalizedTarget.sessionId = sessionId; + if ((kind === "work" || kind === "chat" || kind === "lane" || kind === "pr") && laneId) normalizedTarget.laneId = laneId; + if (kind === "pr") { + const prId = asOptionalTrimmedString(target.prId); + if (prId) normalizedTarget.prId = prId; + if (typeof target.prNumber === "number") normalizedTarget.prNumber = target.prNumber; + } + if (kind === "route") { + normalizedTarget.route = asOptionalTrimmedString(target.route); + } + const request = { + target: normalizedTarget, + source: asOptionalTrimmedString(params.source) ?? "ade-rpc", + } as AppNavigationRequest; + if (!runtime.appNavigationService) { + return { + ok: false, + mode: "unavailable", + message: "Desktop navigation is unavailable in this runtime.", + }; + } + return await runtime.appNavigationService.navigate(request); + } + if (method === "shutdown") { return {}; } diff --git a/apps/ade-cli/src/bootstrap.test.ts b/apps/ade-cli/src/bootstrap.test.ts index 58d7fadc0..0354607c5 100644 --- a/apps/ade-cli/src/bootstrap.test.ts +++ b/apps/ade-cli/src/bootstrap.test.ts @@ -144,4 +144,43 @@ describe("createEventBuffer", () => { expect(result.events[i]!.payload).toEqual({ kind: categories[i] }); } }); + + it("notifies subscribers for newly pushed events until unsubscribed", () => { + const buffer = createEventBuffer(); + const seen: BufferedEvent[] = []; + + const unsubscribe = buffer.subscribe((event) => seen.push(event)); + buffer.push({ timestamp: "t1", category: "runtime", payload: { n: 1 } }); + unsubscribe(); + buffer.push({ timestamp: "t2", category: "runtime", payload: { n: 2 } }); + + expect(seen).toEqual([ + expect.objectContaining({ + id: 1, + category: "runtime", + payload: { n: 1 }, + }), + ]); + }); + + it("keeps notifying subscribers when one listener throws", () => { + const buffer = createEventBuffer(); + const seen: BufferedEvent[] = []; + + buffer.subscribe(() => { + throw new Error("listener failed"); + }); + buffer.subscribe((event) => seen.push(event)); + + expect(() => { + buffer.push({ timestamp: "t1", category: "runtime", payload: { n: 1 } }); + }).not.toThrow(); + expect(seen).toEqual([ + expect.objectContaining({ + id: 1, + category: "runtime", + payload: { n: 1 }, + }), + ]); + }); }); diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index da24b8514..9b2dd6f51 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -6,7 +6,11 @@ import * as nodePty from "node-pty"; import { createFileLogger, type Logger } from "../../desktop/src/main/services/logging/logger"; import { openKvDb, type AdeDb } from "../../desktop/src/main/services/state/kvDb"; import { detectDefaultBaseRef, toProjectInfo, upsertProjectRow } from "../../desktop/src/main/services/projects/projectService"; -import { initializeOrRepairAdeProject } from "../../desktop/src/main/services/projects/adeProjectService"; +import { + createAdeProjectService, + initializeOrRepairAdeProject, +} from "../../desktop/src/main/services/projects/adeProjectService"; +import { createConfigReloadService } from "../../desktop/src/main/services/projects/configReloadService"; import { createOperationService } from "../../desktop/src/main/services/history/operationService"; import { createLaneService } from "../../desktop/src/main/services/lanes/laneService"; import { createSessionService } from "../../desktop/src/main/services/sessions/sessionService"; @@ -18,22 +22,22 @@ import { createMissionService } from "../../desktop/src/main/services/missions/m import type { createMissionPreflightService } from "../../desktop/src/main/services/missions/missionPreflightService"; import { createPtyService } from "../../desktop/src/main/services/pty/ptyService"; import { createTestService } from "../../desktop/src/main/services/tests/testService"; -import type { createKeybindingsService } from "../../desktop/src/main/services/keybindings/keybindingsService"; +import { createKeybindingsService } from "../../desktop/src/main/services/keybindings/keybindingsService"; import type { createAgentToolsService } from "../../desktop/src/main/services/agentTools/agentToolsService"; import type { createAdeCliService } from "../../desktop/src/main/services/cli/adeCliService"; import type { createDevToolsService } from "../../desktop/src/main/services/devTools/devToolsService"; -import type { createOnboardingService } from "../../desktop/src/main/services/onboarding/onboardingService"; -import type { createLaneEnvironmentService } from "../../desktop/src/main/services/lanes/laneEnvironmentService"; -import type { createLaneTemplateService } from "../../desktop/src/main/services/lanes/laneTemplateService"; -import type { createPortAllocationService } from "../../desktop/src/main/services/lanes/portAllocationService"; -import type { createLaneProxyService } from "../../desktop/src/main/services/lanes/laneProxyService"; -import type { createOAuthRedirectService } from "../../desktop/src/main/services/lanes/oauthRedirectService"; -import type { createRuntimeDiagnosticsService } from "../../desktop/src/main/services/lanes/runtimeDiagnosticsService"; -import type { createRebaseSuggestionService } from "../../desktop/src/main/services/lanes/rebaseSuggestionService"; -import type { createAutoRebaseService } from "../../desktop/src/main/services/lanes/autoRebaseService"; +import { createOnboardingService } from "../../desktop/src/main/services/onboarding/onboardingService"; +import { createLaneEnvironmentService } from "../../desktop/src/main/services/lanes/laneEnvironmentService"; +import { createLaneTemplateService } from "../../desktop/src/main/services/lanes/laneTemplateService"; +import { createPortAllocationService } from "../../desktop/src/main/services/lanes/portAllocationService"; +import { createLaneProxyService } from "../../desktop/src/main/services/lanes/laneProxyService"; +import { createOAuthRedirectService } from "../../desktop/src/main/services/lanes/oauthRedirectService"; +import { createRuntimeDiagnosticsService } from "../../desktop/src/main/services/lanes/runtimeDiagnosticsService"; +import { createRebaseSuggestionService } from "../../desktop/src/main/services/lanes/rebaseSuggestionService"; +import { createAutoRebaseService } from "../../desktop/src/main/services/lanes/autoRebaseService"; import { createProcessService } from "../../desktop/src/main/services/processes/processService"; import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "../../desktop/src/main/services/ai/cliExecutableResolver"; -import type { createAgentChatService } from "../../desktop/src/main/services/chat/agentChatService"; +import { createAgentChatService } from "../../desktop/src/main/services/chat/agentChatService"; import type { createPrService } from "../../desktop/src/main/services/prs/prService"; import type { createPrSummaryService } from "../../desktop/src/main/services/prs/prSummaryService"; import type { createQueueLandingService } from "../../desktop/src/main/services/prs/queueLandingService"; @@ -47,7 +51,7 @@ import type { createWorkerRevisionService } from "../../desktop/src/main/service import type { createWorkerHeartbeatService } from "../../desktop/src/main/services/cto/workerHeartbeatService"; import type { createWorkerTaskSessionService } from "../../desktop/src/main/services/cto/workerTaskSessionService"; import type { createLinearCredentialService } from "../../desktop/src/main/services/cto/linearCredentialService"; -import type { createOpenclawBridgeService } from "../../desktop/src/main/services/cto/openclawBridgeService"; +import { createLinearOAuthService } from "../../desktop/src/main/services/cto/linearOAuthService"; import type { createFlowPolicyService } from "../../desktop/src/main/services/cto/flowPolicyService"; import type { createLinearDispatcherService } from "../../desktop/src/main/services/cto/linearDispatcherService"; import type { createLinearIssueTracker } from "../../desktop/src/main/services/cto/linearIssueTracker"; @@ -57,15 +61,17 @@ import type { createLinearSyncService } from "../../desktop/src/main/services/ct import { createOrchestratorService } from "../../desktop/src/main/services/orchestrator/orchestratorService"; import { createAiOrchestratorService } from "../../desktop/src/main/services/orchestrator/aiOrchestratorService"; import { createAiIntegrationService } from "../../desktop/src/main/services/ai/aiIntegrationService"; +import { initApiKeyStore } from "../../desktop/src/main/services/ai/apiKeyStore"; import { createMissionBudgetService } from "../../desktop/src/main/services/orchestrator/missionBudgetService"; -import type { createSyncService } from "../../desktop/src/main/services/sync/syncService"; -import type { createSyncHostService } from "../../desktop/src/main/services/sync/syncHostService"; +import type { createSyncService } from "./services/sync/syncService"; +import type { createSyncHostService, SyncRuntimeKind } from "./services/sync/syncHostService"; import type { createAutomationIngressService } from "../../desktop/src/main/services/automations/automationIngressService"; import type { createGithubService } from "../../desktop/src/main/services/github/githubService"; -import type { createFeedbackReporterService } from "../../desktop/src/main/services/feedback/feedbackReporterService"; +import { createFeedbackReporterService } from "../../desktop/src/main/services/feedback/feedbackReporterService"; import type { createUsageTrackingService } from "../../desktop/src/main/services/usage/usageTrackingService"; import type { createBudgetCapService } from "../../desktop/src/main/services/usage/budgetCapService"; -import type { createSessionDeltaService } from "../../desktop/src/main/services/sessions/sessionDeltaService"; +import { createSessionDeltaService } from "../../desktop/src/main/services/sessions/sessionDeltaService"; +import { createReviewService } from "../../desktop/src/main/services/review/reviewService"; import type { createAutoUpdateService } from "../../desktop/src/main/services/updates/autoUpdateService"; import { createComputerUseArtifactBrokerService, @@ -82,6 +88,7 @@ import { import { createMacosVmService } from "../../desktop/src/main/services/macosVm/macosVmService"; import type { BuiltInBrowserService } from "../../desktop/src/main/services/builtInBrowser/builtInBrowserService"; import type { createFileService } from "../../desktop/src/main/services/files/fileService"; +import type { AppNavigationRequest, AppNavigationResult, PortLease } from "../../desktop/src/shared/types"; import { createAutomationService, type AutomationAdeActionRegistry, @@ -95,6 +102,7 @@ import { } from "../../desktop/src/main/services/adeActions/registry"; import { createLaneWorktreeLockService, type LaneWorktreeLockService } from "../../desktop/src/main/services/lanes/laneWorktreeLockService"; import { createHeadlessLinearServices } from "./headlessLinearServices"; +import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; import { createEventBuffer, type BufferedEvent, type EventBuffer } from "./eventBuffer"; export { createEventBuffer, type BufferedEvent, type EventBuffer }; @@ -117,10 +125,27 @@ export type AdeRuntimePaths = { missionStateDir: string; }; +export type AdeRuntimeSyncOptions = { + enabled?: boolean; + hostStartupEnabled?: boolean; + hostDiscoveryEnabled?: boolean; + forceHostRole?: boolean; + runtimeKind?: SyncRuntimeKind; + appVersion?: string; + registryProjectId?: string; + localDeviceIdPath?: string; + phonePairingStateDir?: string; + projectCatalogProvider?: Parameters[0]["projectCatalogProvider"]; + remoteCommandExecutor?: Parameters[0]["remoteCommandExecutor"]; +}; + export type AdeRuntime = { projectRoot: string; workspaceRoot: string; projectId: string; + capabilities?: { + memory?: boolean; + }; project: { rootPath: string; displayName: string; baseRef: string }; paths: AdeRuntimePaths; logger: Logger; @@ -130,6 +155,7 @@ export type AdeRuntime = { adeCliService?: ReturnType | null; devToolsService?: ReturnType | null; onboardingService?: ReturnType | null; + adeProjectService?: ReturnType | null; laneService: ReturnType; laneWorktreeLockService?: LaneWorktreeLockService | null; laneEnvironmentService?: ReturnType | null; @@ -166,7 +192,7 @@ export type AdeRuntime = { workerHeartbeatService?: ReturnType | null; workerTaskSessionService?: ReturnType | null; linearCredentialService?: ReturnType | null; - openclawBridgeService?: ReturnType | null; + linearOAuthService?: ReturnType | null; flowPolicyService?: ReturnType | null; linearDispatcherService?: ReturnType | null; linearIssueTracker?: ReturnType | null; @@ -192,7 +218,11 @@ export type AdeRuntime = { usageTrackingService?: ReturnType | null; budgetCapService?: ReturnType | null; sessionDeltaService?: ReturnType | null; + reviewService?: ReturnType | null; autoUpdateService?: ReturnType | null; + appNavigationService?: { + navigate(args: AppNavigationRequest): Promise; + } | null; eventBuffer: EventBuffer; dispose: () => void; }; @@ -302,12 +332,22 @@ function createHeadlessAdeCliAgentEnv(baseEnv: NodeJS.ProcessEnv = process.env): return next; } -export async function createAdeRuntime(args: { projectRoot: string; workspaceRoot?: string } | string): Promise { +export async function createAdeRuntime(args: { + projectRoot: string; + workspaceRoot?: string; + chatRuntime?: "headless-stub" | "agent"; + runtimeProfile?: "full" | "chat"; + syncRuntime?: AdeRuntimeSyncOptions; + capabilities?: { + memory?: boolean; + }; +} | string): Promise { const resolvedArgs = typeof args === "string" ? { projectRoot: args, workspaceRoot: args } : args; const projectRoot = path.resolve(resolvedArgs.projectRoot); const workspaceRoot = path.resolve(resolvedArgs.workspaceRoot ?? resolvedArgs.projectRoot); + const chatOnlyRuntime = resolvedArgs.runtimeProfile === "chat"; if (!fs.existsSync(projectRoot) || !fs.statSync(projectRoot).isDirectory()) { throw new Error(`Project root does not exist: ${projectRoot}`); } @@ -315,8 +355,10 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo throw new Error(`Workspace root does not exist: ${workspaceRoot}`); } + const hadAdeDb = fs.existsSync(path.join(projectRoot, ".ade", "ade.db")); const baseRef = await detectDefaultBaseRef(projectRoot); const paths = ensureAdePaths(projectRoot); + initApiKeyStore(projectRoot, { credentialStore: new EncryptedFileCredentialStore() }); const logger = createFileLogger(path.join(paths.logsDir, "ade-cli.jsonl")); const db = await openKvDb(paths.dbPath, logger); @@ -329,6 +371,16 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo }); const operationService = createOperationService({ db, projectId }); + const keybindingsService = createKeybindingsService({ db }); + const eventBuffer = createEventBuffer(); + + function pushEvent(category: BufferedEvent["category"], payload: Record): void { + eventBuffer.push({ timestamp: new Date().toISOString(), category, payload }); + } + + let conflictServiceRef: ReturnType | null = null; + let rebaseSuggestionServiceRef: ReturnType | null = null; + let autoRebaseServiceRef: ReturnType | null = null; const laneService = createLaneService({ db, @@ -336,15 +388,37 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo projectId, defaultBaseRef: baseRef, worktreesDir: paths.worktreesDir, - operationService + operationService, + onHeadChanged: (event) => { + pushEvent("runtime", { type: "lane_head_changed", ...event }); + void rebaseSuggestionServiceRef?.onParentHeadChanged(event).catch(() => {}); + void autoRebaseServiceRef?.onHeadChanged(event).catch(() => {}); + }, + onRebaseEvent: (event) => { + pushEvent("runtime", { type: "lane_rebase_event", event }); + if (event.type === "rebase-run-updated" && event.run.state !== "running") { + void conflictServiceRef?.scanRebaseNeeds().catch(() => {}); + } + }, + onDeleteEvent: (event) => pushEvent("runtime", { type: "lane_delete_event", event }), + logger, }); await laneService.ensurePrimaryLane(); const sessionService = createSessionService({ db }); + sessionService.onChanged((event) => { + pushEvent("runtime", { type: "terminal_session_changed", event }); + }); sessionService.reconcileStaleRunningSessions({ status: "disposed", excludeToolTypes: ["claude-chat", "codex-chat", "opencode-chat", "cursor", "droid-chat"], }); + const sessionDeltaService = createSessionDeltaService({ + db, + projectId, + laneService, + sessionService, + }); const projectConfigService = createProjectConfigService({ projectRoot, @@ -353,6 +427,85 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo db, logger }); + const onboardingService = createOnboardingService({ + db, + logger, + projectRoot, + projectId, + baseRef, + freshProject: !hadAdeDb, + laneService, + projectConfigService, + }); + + const laneEnvironmentService = createLaneEnvironmentService({ + projectRoot, + adeDir: paths.adeDir, + logger, + broadcastEvent: (event) => pushEvent("runtime", { type: "lane_env_event", event }), + }); + + const laneTemplateService = createLaneTemplateService({ + projectConfigService, + logger, + }); + + const portAllocationService = createPortAllocationService({ + logger, + broadcastEvent: (event) => pushEvent("runtime", { type: "lane_port_event", event }), + persistLeases: (leases) => db.setJson("port_leases", leases), + loadLeases: () => db.getJson("port_leases") ?? [], + }); + portAllocationService.restore(); + + const recoverPortAllocations = async () => { + const lanes = await laneService.list({ includeArchived: false, includeStatus: false }); + const validIds = new Set(lanes.map((lane) => lane.id)); + portAllocationService.recoverOrphans(validIds); + for (const lane of lanes) { + const lease = portAllocationService.getLease(lane.id); + if (lease?.status === "active") continue; + try { + portAllocationService.acquire(lane.id); + } catch (error) { + logger.warn("port_allocation.headless_startup_acquire_failed", { + laneId: lane.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + portAllocationService.detectConflicts(); + }; + await recoverPortAllocations().catch((error) => { + logger.warn("port_allocation.headless_startup_recovery_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + + const laneProxyService = createLaneProxyService({ + logger, + broadcastEvent: (event) => pushEvent("runtime", { type: "lane_proxy_event", event }), + }); + + const oauthRedirectService = createOAuthRedirectService({ + logger, + broadcastEvent: (event) => pushEvent("runtime", { type: "lane_oauth_event", event }), + getRoutes: () => laneProxyService.listRoutes(), + getProxyPort: () => laneProxyService.getConfig().proxyPort, + getHostnameSuffix: () => laneProxyService.getConfig().hostnameSuffix, + forwardToPort: (req, res, port) => laneProxyService.forwardToPort(req, res, port), + }); + laneProxyService.registerInterceptor((req, res) => oauthRedirectService.handleRequest(req, res)); + + const runtimeDiagnosticsService = createRuntimeDiagnosticsService({ + logger, + broadcastEvent: (event) => pushEvent("runtime", { type: "lane_diagnostics_event", event }), + getPortLease: (laneId) => portAllocationService.getLease(laneId), + getPortConflicts: () => portAllocationService.listConflicts(), + detectPortConflicts: () => portAllocationService.detectConflicts(), + getProxyStatus: () => laneProxyService.getStatus(), + getProxyRoute: (laneId) => laneProxyService.getRoute(laneId), + }); const aiIntegrationService = createAiIntegrationService({ db, @@ -371,8 +524,30 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo projectConfigService, operationService, conflictPacksDir: path.join(paths.packsDir, "conflicts"), - onEvent: () => {} + onEvent: (event) => pushEvent("runtime", { type: "conflict_event", event }) + }); + conflictServiceRef = conflictService; + + const rebaseSuggestionService = createRebaseSuggestionService({ + db, + logger, + projectId, + projectRoot, + laneService, + onEvent: (event) => pushEvent("runtime", { type: "lane_rebase_suggestions_event", event }), }); + rebaseSuggestionServiceRef = rebaseSuggestionService; + + const autoRebaseService = createAutoRebaseService({ + db, + logger, + laneService, + conflictService, + projectConfigService, + onEvent: (event) => pushEvent("runtime", { type: "lane_auto_rebase_event", event }), + }); + autoRebaseServiceRef = autoRebaseService; + void autoRebaseService.emit().catch(() => {}); const gitService = createGitOperationsService({ laneService, @@ -387,7 +562,7 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo const missionService = createMissionService({ db, projectId, - onEvent: () => {} + onEvent: (event) => pushEvent("mission", event as unknown as Record) }); const ptyService = createPtyService({ @@ -396,8 +571,8 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo laneService, sessionService, logger, - broadcastData: () => {}, - broadcastExit: () => {}, + broadcastData: (event) => pushEvent("runtime", { type: "pty_data", event }), + broadcastExit: (event) => pushEvent("runtime", { type: "pty_exit", event }), onSessionEnded: () => {}, getAdeCliAgentEnv: createHeadlessAdeCliAgentEnv, loadPty: () => nodePty @@ -410,47 +585,23 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo logger, laneService, projectConfigService, - broadcastEvent: () => {} + broadcastEvent: (event) => pushEvent("runtime", event as unknown as Record) }); const issueInventoryService = createIssueInventoryService({ db }); const laneWorktreeLockService = createLaneWorktreeLockService({ db, logger }); - const eventBuffer = createEventBuffer(); - function pushEvent(category: BufferedEvent["category"], payload: Record): void { - eventBuffer.push({ timestamp: new Date().toISOString(), category, payload }); - } - - // Headless lane runtime env. Unlike the desktop path (which leases ports via - // portAllocationService and builds collision-safe hostnames via - // laneProxyService), headless has no persistent allocator wired in — so we - // derive ports and hostname suffix from a stable hash of the laneId. This is - // (a) independent of the lane's current list position (archival/reordering - // no longer shifts a lane's PORT) and (b) resistant to slug collisions - // between lanes whose display names slugify to the same string. - // Range matches desktop: basePort=3000, portsPerLane=100, maxPort=9999 → 70 slots. - const HEADLESS_BASE_PORT = 3000; - const HEADLESS_PORTS_PER_LANE = 100; - const HEADLESS_MAX_SLOTS = 70; + // Headless lane runtime env uses the same persistent allocator/proxy hostname + // services as desktop so a remote runtime presents the same PORT and preview + // surface to process definitions. const getHeadlessLaneRuntimeEnv = async (laneId: string): Promise> => { const lanes = await laneService.list({ includeArchived: false, includeStatus: false }); const lane = lanes.find((entry) => entry.id === laneId); - const laneHash = createHash("sha256").update(laneId).digest(); - const slotIndex = laneHash.readUInt32BE(0) % HEADLESS_MAX_SLOTS; - const portStart = HEADLESS_BASE_PORT + slotIndex * HEADLESS_PORTS_PER_LANE; - const portEnd = portStart + HEADLESS_PORTS_PER_LANE - 1; - const baseSlug = (lane?.name ?? lane?.branchRef ?? laneId) - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "lane"; - // 6-char suffix from the laneId hash keeps hostnames readable while making - // two lanes with identical slugs resolve to distinct hostnames. - const idSuffix = laneHash.toString("hex").slice(0, 6); - const hostname = `${baseSlug}-${idSuffix}.localhost`; + const lease = portAllocationService.getLease(laneId) ?? portAllocationService.acquire(laneId); + const hostname = laneProxyService.generateHostname(laneId, lane?.name ?? lane?.branchRef); return { - PORT: String(portStart), - PORT_RANGE_START: String(portStart), - PORT_RANGE_END: String(portEnd), + PORT: String(lease.rangeStart), + PORT_RANGE_START: String(lease.rangeStart), + PORT_RANGE_END: String(lease.rangeEnd), HOSTNAME: hostname, PROXY_HOSTNAME: hostname, }; @@ -504,6 +655,15 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo projectId, adeDir: paths.adeDir, }); + const adeProjectService = createAdeProjectService({ + projectRoot, + db, + projectId, + logger, + projectConfigService, + ctoStateService, + workerAgentService, + }); const workerBudgetService = createWorkerBudgetService({ db, projectId, @@ -573,53 +733,59 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo orchestratorService, logger, }); - const iosSimulatorService = createIosSimulatorService({ - projectRoot, - logger, - }); + const iosSimulatorService = chatOnlyRuntime + ? null + : createIosSimulatorService({ + projectRoot, + logger, + }); // Late-bound chat session lookup. agentChatService is created after // appControlService below, so we capture a holder that the resolveLaneId // closure reads at call time. The chat session store lives in agentChatService // (getSessionSummary), not in sessionService (which holds terminal sessions). const agentChatServiceHolder: { current: ReturnType | null } = { current: null }; - const appControlService = createAppControlService({ - projectRoot, - logger, - ptyService, - resolveLaneId: async ({ cwd, projectRoot: requestedProjectRoot, laneId, chatSessionId }) => { - const explicitLaneId = laneId?.trim(); - if (explicitLaneId) return explicitLaneId; - const chatId = chatSessionId?.trim(); - if (chatId && agentChatServiceHolder.current) { - const chatSession = await agentChatServiceHolder.current.getSessionSummary(chatId).catch(() => null); - if (chatSession?.laneId) return chatSession.laneId; - } - const targetRoot = path.resolve(cwd || requestedProjectRoot || projectRoot); - const lanes = await laneService.list({ includeArchived: false }); - const matchingLane = lanes.find((lane) => { - const worktreePath = path.resolve(lane.worktreePath); - const attachedRootPath = lane.attachedRootPath ? path.resolve(lane.attachedRootPath) : null; - return ( - targetRoot === worktreePath - || targetRoot.startsWith(`${worktreePath}${path.sep}`) - || (attachedRootPath !== null - && (targetRoot === attachedRootPath - || targetRoot.startsWith(`${attachedRootPath}${path.sep}`))) - ); + const appControlService = chatOnlyRuntime + ? null + : createAppControlService({ + projectRoot, + logger, + ptyService, + resolveLaneId: async ({ cwd, projectRoot: requestedProjectRoot, laneId, chatSessionId }) => { + const explicitLaneId = laneId?.trim(); + if (explicitLaneId) return explicitLaneId; + const chatId = chatSessionId?.trim(); + if (chatId && agentChatServiceHolder.current) { + const chatSession = await agentChatServiceHolder.current.getSessionSummary(chatId).catch(() => null); + if (chatSession?.laneId) return chatSession.laneId; + } + const targetRoot = path.resolve(cwd || requestedProjectRoot || projectRoot); + const lanes = await laneService.list({ includeArchived: false }); + const matchingLane = lanes.find((lane) => { + const worktreePath = path.resolve(lane.worktreePath); + const attachedRootPath = lane.attachedRootPath ? path.resolve(lane.attachedRootPath) : null; + return ( + targetRoot === worktreePath + || targetRoot.startsWith(`${worktreePath}${path.sep}`) + || (attachedRootPath !== null + && (targetRoot === attachedRootPath + || targetRoot.startsWith(`${attachedRootPath}${path.sep}`))) + ); + }); + return matchingLane?.id ?? lanes[0]?.id ?? null; + }, + }); + const macosVmService = chatOnlyRuntime + ? null + : createMacosVmService({ + projectRoot, + logger, + resolveLanes: async () => laneService.list({ includeArchived: false }), + onEvent: (event) => pushEvent("runtime", { + ...(event as unknown as Record), + type: "macos_vm", + eventType: event.type, + }), }); - return matchingLane?.id ?? lanes[0]?.id ?? null; - }, - }); - const macosVmService = createMacosVmService({ - projectRoot, - logger, - resolveLanes: async () => laneService.list({ includeArchived: false }), - onEvent: (event) => pushEvent("runtime", { - ...(event as unknown as Record), - type: "macos_vm", - eventType: event.type, - }), - }); const aiOrchestratorService = createAiOrchestratorService({ db, @@ -656,14 +822,114 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo orchestratorService, openExternal: async () => {}, }); + const linearOAuthService = createLinearOAuthService({ + credentials: headlessLinearServices.linearCredentialService as never, + logger, + }); + + const feedbackReporterService = createFeedbackReporterService({ + db, + logger, + projectRoot, + aiIntegrationService, + githubService: headlessLinearServices.githubService as never, + onSubmissionUpdated: (event) => pushEvent("runtime", { type: "feedback_submission_event", event }), + }); - const agentChatService = headlessLinearServices.agentChatService as unknown as ReturnType | null; + let automationServiceRef: ReturnType | null = null; + let agentChatService = headlessLinearServices.agentChatService as unknown as ReturnType | null; + if (resolvedArgs.chatRuntime === "agent") { + agentChatService = createAgentChatService({ + projectRoot, + adeDir: paths.adeDir, + transcriptsDir: paths.transcriptsDir, + projectId, + memoryService, + fileService: headlessLinearServices.fileService, + workerAgentService, + workerHeartbeatService: headlessLinearServices.workerHeartbeatService, + linearIssueTracker: headlessLinearServices.linearIssueTracker, + flowPolicyService: headlessLinearServices.flowPolicyService, + getMissionService: () => missionService, + getAiOrchestratorService: () => aiOrchestratorService, + getLinearDispatcherService: () => headlessLinearServices.linearDispatcherService, + linearClient: headlessLinearServices.linearClient, + linearCredentials: headlessLinearServices.linearCredentialService as never, + prService: headlessLinearServices.prService, + issueInventoryService, + processService, + getTestService: () => testService, + ptyService, + getAutomationService: () => automationServiceRef, + getGitService: () => gitService, + conflictService, + getWorkerBudgetService: () => workerBudgetService, + getMissionBudgetService: () => missionBudgetService, + computerUseArtifactBrokerService, + laneService, + sessionService, + projectConfigService, + aiIntegrationService, + ctoStateService, + logger, + appVersion: "ade-cli", + getAdeCliAgentEnv: createHeadlessAdeCliAgentEnv, + onEvent: (event) => { + aiOrchestratorService.onAgentChatEvent(event); + pushEvent("runtime", event as unknown as Record); + }, + onSessionEnded: (event) => { + pushEvent("runtime", { type: "agent_chat_session_ended", ...event }); + }, + getDirtyFileTextForPath: () => undefined, + }); + if (typeof (headlessLinearServices.prService as { setAgentChatService?: (svc: unknown) => void }).setAgentChatService === "function") { + (headlessLinearServices.prService as { setAgentChatService: (svc: unknown) => void }).setAgentChatService(agentChatService); + } + } agentChatServiceHolder.current = agentChatService; + if (typeof (aiOrchestratorService as { setAgentChatService?: (svc: typeof agentChatService) => void }).setAgentChatService === "function") { + (aiOrchestratorService as { setAgentChatService: (svc: typeof agentChatService) => void }).setAgentChatService(agentChatService); + } + if (resolvedArgs.chatRuntime === "agent" && !agentChatService) { + throw new Error("Agent chat runtime was requested but the agent chat service was not initialized."); + } + if (resolvedArgs.chatRuntime === "agent" && agentChatService) { + setImmediate(() => { + try { + aiOrchestratorService.resumeActiveTeamRuntimes(); + } catch (error) { + logger.warn("bootstrap.resume_active_team_runtimes_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }); + } + const reviewService = agentChatService + ? createReviewService({ + db, + logger, + projectId, + projectRoot, + projectDefaultBranch: baseRef, + laneService, + gitService, + agentChatService, + sessionService, + sessionDeltaService, + testService, + issueInventoryService, + prService: headlessLinearServices.prService, + embeddingService: null, + onEvent: (event) => pushEvent("runtime", { type: "review_event", event }), + }) + : null; + type PathToMergeAgentChatService = Parameters[0]["agentChatService"]; const pathToMergeOrchestrator = createPathToMergeOrchestrator({ logger, prService: headlessLinearServices.prService, laneService, - agentChatService: headlessLinearServices.agentChatService as never, + agentChatService: agentChatService as unknown as PathToMergeAgentChatService, sessionService, issueInventoryService, conflictService, @@ -685,6 +951,24 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo aiOrchestratorService, onEvent: (event) => pushEvent("runtime", { ...event, source: "automations" }), }); + automationServiceRef = automationService; + const configReloadService = createConfigReloadService({ + paths: { + sharedPath: adeProjectService.paths.sharedConfigPath, + localPath: adeProjectService.paths.localConfigPath, + secretPath: adeProjectService.paths.secretConfigPath, + }, + projectConfigService, + adeProjectService, + automationService, + logger, + onEvent: (event) => pushEvent("runtime", { type: "project_state_event", event }), + }); + void configReloadService.start().catch((error) => { + logger.warn("project.config_reload_start_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); const automationPlannerService = createAutomationPlannerService({ logger, projectRoot, @@ -693,16 +977,89 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo automationService, }); + let syncService: ReturnType | null = null; + if (resolvedArgs.syncRuntime?.enabled && agentChatService) { + const { createSyncService } = await import("./services/sync/syncService"); + syncService = createSyncService({ + db, + logger, + projectId: resolvedArgs.syncRuntime.registryProjectId ?? projectId, + projectRoot, + appVersion: resolvedArgs.syncRuntime.appVersion ?? "ade-cli", + runtimeKind: resolvedArgs.syncRuntime.runtimeKind ?? "headless", + localDeviceIdPath: resolvedArgs.syncRuntime.localDeviceIdPath, + phonePairingStateDir: resolvedArgs.syncRuntime.phonePairingStateDir, + fileService: headlessLinearServices.fileService, + laneService, + gitService, + diffService, + conflictService, + prService: headlessLinearServices.prService, + issueInventoryService, + pathToMergeOrchestrator, + sessionService, + ptyService, + projectConfigService, + portAllocationService, + laneEnvironmentService, + laneTemplateService, + rebaseSuggestionService, + autoRebaseService, + computerUseArtifactBrokerService, + missionService, + agentChatService, + workerAgentService, + workerBudgetService, + workerHeartbeatService: headlessLinearServices.workerHeartbeatService, + ctoStateService, + flowPolicyService: headlessLinearServices.flowPolicyService, + getLinearIngressService: () => headlessLinearServices.linearIngressService, + getLinearIssueTracker: () => headlessLinearServices.linearIssueTracker, + getLinearSyncService: () => headlessLinearServices.linearSyncService, + processService, + hostStartupEnabled: resolvedArgs.syncRuntime.hostStartupEnabled ?? true, + hostDiscoveryEnabled: resolvedArgs.syncRuntime.hostDiscoveryEnabled ?? true, + forceHostRole: resolvedArgs.syncRuntime.forceHostRole ?? true, + projectCatalogProvider: resolvedArgs.syncRuntime.projectCatalogProvider, + remoteCommandExecutor: resolvedArgs.syncRuntime.remoteCommandExecutor, + onStatusChanged: (snapshot) => pushEvent("runtime", { type: "sync-status", snapshot }), + }); + } + + if (syncService) { + try { + await syncService.initialize(); + } catch (error) { + logger.warn("sync.runtime_initialize_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + const runtime: AdeRuntime = { projectRoot, workspaceRoot, projectId, + capabilities: { + memory: resolvedArgs.capabilities?.memory ?? true, + }, project, paths, logger, db, + keybindingsService, laneService, + laneEnvironmentService, + laneTemplateService, + portAllocationService, + laneProxyService, + oauthRedirectService, + runtimeDiagnosticsService, + rebaseSuggestionService, + autoRebaseService, sessionService, + sessionDeltaService, + onboardingService, operationService, projectConfigService, conflictService, @@ -710,9 +1067,12 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo diffService, missionService, missionBudgetService, + syncService, + syncHostService: syncService?.getHostService() ?? null, laneWorktreeLockService, ptyService, testService, + reviewService, aiIntegrationService, agentChatService, issueInventoryService, @@ -720,11 +1080,13 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo memoryService, ctoStateService, workerAgentService, + adeProjectService, workerBudgetService, githubService: headlessLinearServices.githubService as never, workerTaskSessionService: headlessLinearServices.workerTaskSessionService, workerHeartbeatService: headlessLinearServices.workerHeartbeatService, linearCredentialService: headlessLinearServices.linearCredentialService as never, + linearOAuthService, prService: headlessLinearServices.prService, fileService: headlessLinearServices.fileService, flowPolicyService: headlessLinearServices.flowPolicyService, @@ -734,6 +1096,7 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo linearIngressService: headlessLinearServices.linearIngressService, linearRoutingService: headlessLinearServices.linearRoutingService, processService, + feedbackReporterService, automationService, automationPlannerService, computerUseArtifactBrokerService, @@ -745,14 +1108,22 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo eventBuffer, dispose: () => { const swallow = (fn: () => void) => { try { fn(); } catch { /* ignore */ } }; + void configReloadService.dispose().catch(() => {}); swallow(() => automationService.dispose()); + swallow(() => syncService?.dispose()); swallow(() => pathToMergeOrchestrator.dispose()); swallow(() => processService.disposeAll()); - swallow(() => iosSimulatorService.dispose()); - swallow(() => appControlService.dispose()); - swallow(() => macosVmService.dispose()); + swallow(() => runtimeDiagnosticsService.dispose()); + swallow(() => oauthRedirectService.dispose()); + void laneProxyService.dispose().catch(() => {}); + swallow(() => portAllocationService.dispose()); + swallow(() => iosSimulatorService?.dispose()); + swallow(() => appControlService?.dispose()); + swallow(() => macosVmService?.dispose()); + swallow(() => linearOAuthService.dispose()); swallow(() => headlessLinearServices.dispose()); swallow(() => aiOrchestratorService.dispose()); + swallow(() => agentChatService?.forceDisposeAll?.()); swallow(() => testService.disposeAll()); swallow(() => ptyService.disposeAll()); swallow(() => db.flushNow()); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 780af0d37..633bf5633 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -3,13 +3,16 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { + buildAdeCodeArgs, buildCliPlan, findProjectRoots, formatOutput, graphWaitState, + isFailedServiceManagerResult, parseCliArgs, renderLaneGraph, resolveRoots, + shouldAutoRegisterProjectForPlan, shouldAttemptDesktopSocketConnection, summarizeExecution, unwrapToolResult, @@ -17,7 +20,10 @@ import { type ResolveRootsOptions = Parameters[0]; -function baseResolveOpts(): Omit { +function baseResolveOpts(): Omit< + ResolveRootsOptions, + "projectRoot" | "workspaceRoot" +> { return { role: "external", headless: true, @@ -44,13 +50,270 @@ describe("ADE CLI", () => { expect(parsed.options.projectRoot).toBe("/tmp/project"); expect(parsed.options.role).toBe("cto"); - expect(parsed.command).toEqual(["actions", "run", "git.stageFile", "--arg", "laneId=lane-1"]); + expect(parsed.command).toEqual([ + "actions", + "run", + "git.stageFile", + "--arg", + "laneId=lane-1", + ]); + }); + + it("maps ade code to the terminal Work chat launcher", () => { + const parsed = parseCliArgs([ + "--project-root", + "/tmp/project", + "code", + "--print-state", + ]); + expect(parsed.options.projectRoot).toBe("/tmp/project"); + expect(parsed.command).toEqual(["code", "--print-state"]); + + const plan = buildCliPlan(parsed.command); + expect(plan).toEqual({ kind: "ade-code", rest: ["--print-state"] }); + }); + + it("shows socket-aware TUI help for ade code --help", () => { + const plan = buildCliPlan(["code", "--help"]); + expect(plan.kind).toBe("help"); + if (plan.kind !== "help") return; + expect(plan.text).toContain("ade code --socket /tmp/ade.sock"); + expect(plan.text).toContain("ade code --require-socket"); + expect(plan.text).toContain("Command palette"); + }); + + it("shows help for bare ade invocations", () => { + expect(buildCliPlan([])).toEqual({ + kind: "help", + text: expect.stringContaining( + "Agent-focused command-line interface for ADE", + ), + }); + }); + + it("keeps global help on the help surface", () => { + const plan = buildCliPlan(["--help"]); + expect(plan.kind).toBe("help"); + }); + + it("keeps global version on the version surface", () => { + expect(buildCliPlan(["--version"])).toEqual({ + kind: "help", + text: "ade 0.0.0\n", + }); + expect(buildCliPlan(["-v"])).toEqual({ kind: "help", text: "ade 0.0.0\n" }); + }); + + it("builds runtime daemon and stdio RPC commands", () => { + expect(buildCliPlan(["runtime", "status"])).toEqual({ + kind: "runtime", + rest: ["status"], + }); + expect( + buildCliPlan(["runtime", "start", "--socket", "/tmp/ade.sock"]), + ).toEqual({ + kind: "runtime", + rest: ["start", "--socket", "/tmp/ade.sock"], + }); + expect(buildCliPlan(["desktop"])).toEqual({ + kind: "desktop", + rest: [], + }); + expect( + buildCliPlan(["serve", "--socket", "/tmp/ade.sock", "--port", "7777"]), + ).toEqual({ + kind: "serve", + rest: ["--socket", "/tmp/ade.sock", "--port", "7777"], + }); + expect(buildCliPlan(["serve", "--service-status"])).toEqual({ + kind: "serve", + rest: ["--service-status"], + }); + expect(buildCliPlan(["rpc", "--stdio"])).toEqual({ + kind: "rpc-stdio", + rest: [], + }); + expect(buildCliPlan(["rpc", "stdio", "--trace"])).toEqual({ + kind: "rpc-stdio", + rest: ["--trace"], + }); + }); + + it("marks failed service manager results as CLI failures", () => { + expect( + isFailedServiceManagerResult({ + ok: false, + serviceName: "com.ade.runtime", + action: "install", + path: "/tmp/com.ade.runtime.plist", + message: "launchctl failed", + }), + ).toBe(true); + expect( + isFailedServiceManagerResult({ + ok: true, + serviceName: "com.ade.runtime", + action: "install", + path: "/tmp/com.ade.runtime.plist", + message: "installed", + }), + ).toBe(false); + }); + + it("builds project init command", () => { + expect(buildCliPlan(["init", "/tmp/project"])).toEqual({ + kind: "init", + targetPath: "/tmp/project", + }); + expect(buildCliPlan(["init"])).toEqual({ + kind: "init", + targetPath: null, + }); + }); + + it("builds machine project registry commands", () => { + expect(buildCliPlan(["projects", "list"])).toEqual({ + kind: "execute", + label: "projects list", + formatter: "projects-list", + steps: [{ key: "result", method: "projects.list" }], + }); + expect(buildCliPlan(["project", "add", "/tmp/project"])).toEqual({ + kind: "execute", + label: "projects add", + formatter: "projects-list", + steps: [ + { + key: "result", + method: "projects.add", + params: { rootPath: "/tmp/project" }, + }, + ], + }); + expect(buildCliPlan(["projects", "remove", "project_abc"])).toEqual({ + kind: "execute", + label: "projects remove", + steps: [ + { + key: "result", + method: "projects.remove", + params: { projectId: "project_abc" }, + }, + ], + }); + expect( + buildCliPlan(["projects", "touch", "--project-id", "project_abc"]), + ).toEqual({ + kind: "execute", + label: "projects touch", + formatter: "projects-list", + steps: [ + { + key: "result", + method: "projects.touch", + params: { projectId: "project_abc" }, + }, + ], + }); + }); + + it("does not auto-register cwd for machine-scoped registry commands", () => { + const projects = buildCliPlan(["projects", "list"]); + expect(projects.kind).toBe("execute"); + if (projects.kind !== "execute") return; + expect(shouldAutoRegisterProjectForPlan(projects)).toBe(false); + + const lanes = buildCliPlan(["lanes", "list"]); + expect(lanes.kind).toBe("execute"); + if (lanes.kind !== "execute") return; + expect(shouldAutoRegisterProjectForPlan(lanes)).toBe(true); + }); + + it("builds sync status and pairing PIN commands", () => { + const status = buildCliPlan([ + "sync", + "status", + "--include-transfer-readiness", + ]); + expect(status.kind).toBe("execute"); + if (status.kind !== "execute") return; + expect(status.steps).toEqual([ + { + key: "result", + method: "sync.getStatus", + params: { + includeTransferReadiness: true, + forceTransferReadiness: false, + }, + }, + ]); + + const setPin = buildCliPlan(["sync", "pin", "set", "123456"]); + expect(setPin.kind).toBe("execute"); + if (setPin.kind !== "execute") return; + expect(setPin.steps).toEqual([ + { + key: "result", + method: "sync.setPin", + params: { pin: "123456" }, + }, + ]); + + const generatePin = buildCliPlan(["sync", "pin", "generate"]); + expect(generatePin.kind).toBe("execute"); + if (generatePin.kind !== "execute") return; + expect(generatePin.steps).toEqual([ + { + key: "result", + method: "sync.generatePin", + }, + ]); + }); + + it("forwards resolved roots and socket intent to ade code", () => { + const previous = process.env.ADE_RUNTIME_SOCKET_PATH; + process.env.ADE_RUNTIME_SOCKET_PATH = "/tmp/ade-runtime.sock"; + try { + const args = buildAdeCodeArgs(["--print-state"], { + ...baseResolveOpts(), + projectRoot: "/tmp/project", + workspaceRoot: null, + headless: false, + requireSocket: true, + }); + + expect(args).toEqual([ + "--project-root", + "/tmp/project", + "--workspace-root", + "/tmp/project", + "--socket", + "/tmp/ade-runtime.sock", + "--require-socket", + "--print-state", + ]); + } finally { + if (previous === undefined) delete process.env.ADE_RUNTIME_SOCKET_PATH; + else process.env.ADE_RUNTIME_SOCKET_PATH = previous; + } }); it("preserves command-local value flags that overlap global flags", () => { - const parsed = parseCliArgs(["files", "write", "src/index.ts", "--text", "hello"]); + const parsed = parseCliArgs([ + "files", + "write", + "src/index.ts", + "--text", + "hello", + ]); expect(parsed.options.text).toBe(false); - expect(parsed.command).toEqual(["files", "write", "src/index.ts", "--text", "hello"]); + expect(parsed.command).toEqual([ + "files", + "write", + "src/index.ts", + "--text", + "hello", + ]); const plan = buildCliPlan(parsed.command); expect(plan.kind).toBe("execute"); @@ -68,13 +331,27 @@ describe("ADE CLI", () => { }, }); - const typed = parseCliArgs(["ios-sim", "type", "--value", "hello", "--text"]); + const typed = parseCliArgs([ + "ios-sim", + "type", + "--value", + "hello", + "--text", + ]); expect(typed.options.text).toBe(true); expect(typed.command).toEqual(["ios-sim", "type", "--value", "hello"]); }); it("builds a generic ADE action invocation", () => { - const plan = buildCliPlan(["actions", "run", "git.stageFile", "--arg", "laneId=lane-1", "--arg", "path=src/index.ts"]); + const plan = buildCliPlan([ + "actions", + "run", + "git.stageFile", + "--arg", + "laneId=lane-1", + "--arg", + "path=src/index.ts", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; @@ -99,7 +376,15 @@ describe("ADE CLI", () => { }); it("builds a diff patch invocation with an explicit path flag", () => { - const parsed = parseCliArgs(["diff", "patch", "--lane", "main", "--path", "file.txt", "--text"]); + const parsed = parseCliArgs([ + "diff", + "patch", + "--lane", + "main", + "--path", + "file.txt", + "--text", + ]); expect(parsed.options.text).toBe(true); const plan = buildCliPlan(parsed.command); @@ -146,7 +431,7 @@ describe("ADE CLI", () => { "--arg", "filters.clean=false", "--arg-json", - "metadata.tags=[\"review\"]", + 'metadata.tags=["review"]', ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; @@ -174,7 +459,7 @@ describe("ADE CLI", () => { "run", "git.push", "--input-json", - "{\"laneId\":\"lane-1\",\"setUpstream\":true}", + '{"laneId":"lane-1","setUpstream":true}', ]); expect(objectCall.kind).toBe("execute"); if (objectCall.kind !== "execute") return; @@ -195,7 +480,7 @@ describe("ADE CLI", () => { "run", "issue_inventory.savePipelineSettings", "--args-list-json", - "[\"pr-1\",{\"maxRounds\":3}]", + '["pr-1",{"maxRounds":3}]', ]); expect(argsListCall.kind).toBe("execute"); if (argsListCall.kind !== "execute") return; @@ -208,7 +493,13 @@ describe("ADE CLI", () => { }, }); - const scalarCall = buildCliPlan(["actions", "run", "mission.get", "--scalar", "mission-1"]); + const scalarCall = buildCliPlan([ + "actions", + "run", + "mission.get", + "--scalar", + "mission-1", + ]); expect(scalarCall.kind).toBe("execute"); if (scalarCall.kind !== "execute") return; expect(scalarCall.steps[0]?.params).toEqual({ @@ -222,11 +513,27 @@ describe("ADE CLI", () => { }); it("builds typed mission create with custom phase and planned-step payload files", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-mission-plan-")); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "ade-cli-mission-plan-"), + ); const phasesPath = path.join(root, "phases.json"); const stepsPath = path.join(root, "steps.json"); - fs.writeFileSync(phasesPath, JSON.stringify([{ phaseKey: "planning", name: "Planning", position: 0 }])); - fs.writeFileSync(stepsPath, JSON.stringify([{ index: 0, title: "Plan", detail: "Plan it", kind: "planning", metadata: {} }])); + fs.writeFileSync( + phasesPath, + JSON.stringify([{ phaseKey: "planning", name: "Planning", position: 0 }]), + ); + fs.writeFileSync( + stepsPath, + JSON.stringify([ + { + index: 0, + title: "Plan", + detail: "Plan it", + kind: "planning", + metadata: {}, + }, + ]), + ); const plan = buildCliPlan([ "missions", @@ -251,8 +558,18 @@ describe("ADE CLI", () => { args: expect.objectContaining({ prompt: "Try the mission backend", launchMode: "manual", - phaseOverride: [{ phaseKey: "planning", name: "Planning", position: 0 }], - plannedSteps: [{ index: 0, title: "Plan", detail: "Plan it", kind: "planning", metadata: {} }], + phaseOverride: [ + { phaseKey: "planning", name: "Planning", position: 0 }, + ], + plannedSteps: [ + { + index: 0, + title: "Plan", + detail: "Plan it", + kind: "planning", + metadata: {}, + }, + ], }), }, }); @@ -261,14 +578,16 @@ describe("ADE CLI", () => { it("reports unreadable JSON payload files as CLI usage errors", () => { const missingPath = path.join(os.tmpdir(), "ade-cli-missing-phases.json"); - expect(() => buildCliPlan([ - "missions", - "create", - "--prompt", - "Try the mission backend", - "--phase-override-file", - missingPath, - ])).toThrow(/Could not read --phase-override-file file/); + expect(() => + buildCliPlan([ + "missions", + "create", + "--prompt", + "Try the mission backend", + "--phase-override-file", + missingPath, + ]), + ).toThrow(/Could not read --phase-override-file file/); }); it("builds typed mission launch with a dependent start step", () => { @@ -299,15 +618,16 @@ describe("ADE CLI", () => { }, }); expect(typeof plan.steps[1]?.params).toBe("function"); - const params = typeof plan.steps[1]?.params === "function" - ? plan.steps[1].params({ - created: { - domain: "mission", - action: "create", - result: { id: "mission-1" }, - }, - }) - : null; + const params = + typeof plan.steps[1]?.params === "function" + ? plan.steps[1].params({ + created: { + domain: "mission", + action: "create", + result: { id: "mission-1" }, + }, + }) + : null; expect(params).toEqual({ name: "run_ade_action", arguments: { @@ -337,15 +657,16 @@ describe("ADE CLI", () => { if (plan.kind !== "execute") return; expect(plan.steps).toHaveLength(4); expect(typeof plan.steps[2]?.params).toBe("function"); - const missionParams = typeof plan.steps[2]?.params === "function" - ? plan.steps[2].params({ - created: { - domain: "mission", - action: "create", - result: { id: "mission-1" }, - }, - }) - : null; + const missionParams = + typeof plan.steps[2]?.params === "function" + ? plan.steps[2].params({ + created: { + domain: "mission", + action: "create", + result: { id: "mission-1" }, + }, + }) + : null; expect(missionParams).toEqual({ name: "run_ade_action", arguments: { @@ -359,18 +680,19 @@ describe("ADE CLI", () => { method: "ade-cli/wait-run-graph", }); expect(typeof plan.steps[3]?.params).toBe("function"); - const graphParams = typeof plan.steps[3]?.params === "function" - ? plan.steps[3].params({ - started: { - domain: "orchestrator", - action: "startMissionRun", - result: { - started: { run: { id: "run-1" } }, - mission: { id: "mission-1" }, + const graphParams = + typeof plan.steps[3]?.params === "function" + ? plan.steps[3].params({ + started: { + domain: "orchestrator", + action: "startMissionRun", + result: { + started: { run: { id: "run-1" } }, + mission: { id: "mission-1" }, + }, }, - }, - }) - : null; + }) + : null; expect(graphParams).toEqual({ runId: "run-1", waitMs: 5000, @@ -427,9 +749,10 @@ describe("ADE CLI", () => { method: "ade-cli/wait-run-graph", }); expect(typeof plan.steps[1]?.params).toBe("function"); - const graphParams = typeof plan.steps[1]?.params === "function" - ? plan.steps[1].params({}) - : null; + const graphParams = + typeof plan.steps[1]?.params === "function" + ? plan.steps[1].params({}) + : null; expect(graphParams).toEqual({ runId: "run-1", waitMs: 5000, @@ -456,7 +779,13 @@ describe("ADE CLI", () => { }); it("builds mission cancel with a run id for graceful cancellation", () => { - const plan = buildCliPlan(["missions", "cancel", "run-1", "--reason", "superseded"]); + const plan = buildCliPlan([ + "missions", + "cancel", + "run-1", + "--reason", + "superseded", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; @@ -508,12 +837,18 @@ describe("ADE CLI", () => { }); it("rejects invalid JSON action shapes before execution", () => { - expect(() => buildCliPlan(["actions", "run", "git.push", "--input-json", "[1,2]"])).toThrow( - /--input-json must be a JSON object/, - ); - expect(() => buildCliPlan(["actions", "run", "git.push", "--args-list-json", "{\"laneId\":\"lane-1\"}"])).toThrow( - /--args-list-json must be a JSON array/, - ); + expect(() => + buildCliPlan(["actions", "run", "git.push", "--input-json", "[1,2]"]), + ).toThrow(/--input-json must be a JSON object/); + expect(() => + buildCliPlan([ + "actions", + "run", + "git.push", + "--args-list-json", + '{"laneId":"lane-1"}', + ]), + ).toThrow(/--args-list-json must be a JSON array/); }); it("builds chat create with both model and modelId plus generic args", () => { @@ -585,17 +920,33 @@ describe("ADE CLI", () => { }); it("requires a chat session id for chat show", () => { - expect(() => buildCliPlan(["chat", "show"])).toThrow(/sessionId is required/); + expect(() => buildCliPlan(["chat", "show"])).toThrow( + /sessionId is required/, + ); }); it("rejects prototype-sensitive generic ADE action arg paths", () => { expect(({} as Record).polluted).toBeUndefined(); - for (const arg of ["__proto__.polluted=true", "safe.__proto__.polluted=true", "constructor.prototype.polluted=true"]) { - expect(() => buildCliPlan(["actions", "run", "git.status", "--arg", arg])).toThrow(/not allowed/); + for (const arg of [ + "__proto__.polluted=true", + "safe.__proto__.polluted=true", + "constructor.prototype.polluted=true", + ]) { + expect(() => + buildCliPlan(["actions", "run", "git.status", "--arg", arg]), + ).toThrow(/not allowed/); } - expect(() => buildCliPlan(["actions", "run", "git.status", "--arg-json", "prototype.polluted=true"])).toThrow(/not allowed/); + expect(() => + buildCliPlan([ + "actions", + "run", + "git.status", + "--arg-json", + "prototype.polluted=true", + ]), + ).toThrow(/not allowed/); expect(({} as Record).polluted).toBeUndefined(); }); @@ -741,13 +1092,27 @@ describe("ADE CLI", () => { it("validates required arguments before service execution", () => { expect(() => buildCliPlan(["lanes", "create"])).toThrow(/name is required/); - expect(() => buildCliPlan(["lanes", "child", "--name", "child"])).toThrow(/parent lane is required/); - expect(() => buildCliPlan(["diff", "file", "--lane", "main"])).toThrow(/path is required/); - expect(() => buildCliPlan(["diff", "patch", "--lane", "main"])).toThrow(/path is required/); - expect(() => buildCliPlan(["files", "write", "src/index.ts"])).toThrow(/--text, --from-file, or --stdin/); - expect(() => buildCliPlan(["chat", "send", "hello"])).toThrow(/message text is required/); - expect(() => buildCliPlan(["agent", "spawn", "--prompt", "fix it"])).toThrow(/laneId is required/); - expect(() => buildCliPlan(["tests", "run", "--lane", "main"])).toThrow(/--suite or --command/); + expect(() => buildCliPlan(["lanes", "child", "--name", "child"])).toThrow( + /parent lane is required/, + ); + expect(() => buildCliPlan(["diff", "file", "--lane", "main"])).toThrow( + /path is required/, + ); + expect(() => buildCliPlan(["diff", "patch", "--lane", "main"])).toThrow( + /path is required/, + ); + expect(() => buildCliPlan(["files", "write", "src/index.ts"])).toThrow( + /--text, --from-file, or --stdin/, + ); + expect(() => buildCliPlan(["chat", "send", "hello"])).toThrow( + /message text is required/, + ); + expect(() => + buildCliPlan(["agent", "spawn", "--prompt", "fix it"]), + ).toThrow(/laneId is required/); + expect(() => buildCliPlan(["tests", "run", "--lane", "main"])).toThrow( + /--suite or --command/, + ); }); it("unwraps typed ADE action results while preserving actions run envelopes", () => { @@ -786,7 +1151,11 @@ describe("ADE CLI", () => { }, }, } as any); - expect(escapeHatch).toMatchObject({ domain: "git", action: "getStatus", result: { clean: true } }); + expect(escapeHatch).toMatchObject({ + domain: "git", + action: "getStatus", + result: { clean: true }, + }); }); it("summarizes mission launch from post-wait mission and graph snapshots", () => { @@ -819,14 +1188,22 @@ describe("ADE CLI", () => { mission: { domain: "mission", action: "get", - result: { id: "mission-1", status: "intervention_required", lastError: "Model not found" }, + result: { + id: "mission-1", + status: "intervention_required", + lastError: "Model not found", + }, }, graph: { domain: "orchestrator_core", action: "getRunGraph", result: { graph: { - run: { id: "run-1", status: "paused", lastError: "Model not found" }, + run: { + id: "run-1", + status: "paused", + lastError: "Model not found", + }, steps: [], }, }, @@ -835,49 +1212,70 @@ describe("ADE CLI", () => { } as any); expect(summarized).toMatchObject({ - mission: { id: "mission-1", status: "intervention_required", lastError: "Model not found" }, + mission: { + id: "mission-1", + status: "intervention_required", + lastError: "Model not found", + }, run: { id: "run-1", status: "paused", lastError: "Model not found" }, }); }); it("turns ADE action failure envelopes into CLI tool errors", () => { - expect(() => unwrapToolResult({ - ok: false, - error: { - code: -32011, - message: "Action 'git.nonexistent_action' is not callable.", - }, - })).toThrow(/not callable/); + expect(() => + unwrapToolResult({ + ok: false, + error: { + code: -32011, + message: "Action 'git.nonexistent_action' is not callable.", + }, + }), + ).toThrow(/not callable/); }); it("renders richer doctor text", () => { - const output = formatOutput({ - ok: true, - cliVersion: "0.0.0", - mode: "headless", - projectRoot: "/tmp/project", - workspaceRoot: "/tmp/project", - project: { projectInitialized: true }, - desktop: { socketAvailable: false, socketPath: "/tmp/project/.ade/ade.sock" }, - actions: { rpcActionCount: 10, actionCount: 42 }, - git: { message: "Git repository detected on main." }, - github: { message: "GitHub remote detected and a local auth mechanism is available." }, - linear: { message: "Linear credentials are present locally." }, - providers: { message: "AI provider configuration or provider CLI availability was detected locally." }, - computerUse: { message: "Local macOS computer-use fallback commands are available." }, - path: { message: "ade is available on PATH." }, - recommendation: "Using live ADE desktop state.", - recommendations: [], - }, { - projectRoot: null, - workspaceRoot: null, - role: "agent", - headless: false, - requireSocket: false, - pretty: true, - text: true, - timeoutMs: 1000, - }, "doctor"); + const output = formatOutput( + { + ok: true, + cliVersion: "0.0.0", + mode: "headless", + projectRoot: "/tmp/project", + workspaceRoot: "/tmp/project", + project: { projectInitialized: true }, + desktop: { + socketAvailable: false, + socketPath: "/tmp/project/.ade/ade.sock", + }, + actions: { rpcActionCount: 10, actionCount: 42 }, + git: { message: "Git repository detected on main." }, + github: { + message: + "GitHub remote detected and a local auth mechanism is available.", + }, + linear: { message: "Linear credentials are present locally." }, + providers: { + message: + "AI provider configuration or provider CLI availability was detected locally.", + }, + computerUse: { + message: "Local macOS computer-use fallback commands are available.", + }, + path: { message: "ade is available on PATH." }, + recommendation: "Using live ADE desktop state.", + recommendations: [], + }, + { + projectRoot: null, + workspaceRoot: null, + role: "agent", + headless: false, + requireSocket: false, + pretty: true, + text: true, + timeoutMs: 1000, + }, + "doctor", + ); expect(output).toContain("ADE doctor"); expect(output).toContain("cli version"); @@ -886,7 +1284,9 @@ describe("ADE CLI", () => { }); it("attempts Windows named-pipe desktop sockets without filesystem existence checks", () => { - expect(shouldAttemptDesktopSocketConnection("\\\\.\\pipe\\ade-123")).toBe(true); + expect(shouldAttemptDesktopSocketConnection("\\\\.\\pipe\\ade-123")).toBe( + true, + ); expect(shouldAttemptDesktopSocketConnection("//./pipe/ade-123")).toBe(true); }); @@ -894,8 +1294,18 @@ describe("ADE CLI", () => { const graph = renderLaneGraph({ lanes: [ { id: "main", name: "main", branchRef: "main" }, - { id: "child", name: "child", branchRef: "feature", parentLaneId: "main" }, - { id: "sibling", name: "sibling", branchRef: "feature-2", parentLaneId: "main" }, + { + id: "child", + name: "child", + branchRef: "feature", + parentLaneId: "main", + }, + { + id: "sibling", + name: "sibling", + branchRef: "feature-2", + parentLaneId: "main", + }, ], }); @@ -906,8 +1316,20 @@ describe("ADE CLI", () => { }); it("accepts --option=value syntax equivalently to --option value", () => { - const spaced = parseCliArgs(["--project-root", "/tmp/project", "--role", "cto", "lanes", "list"]); - const joined = parseCliArgs(["--project-root=/tmp/project", "--role=cto", "lanes", "list"]); + const spaced = parseCliArgs([ + "--project-root", + "/tmp/project", + "--role", + "cto", + "lanes", + "list", + ]); + const joined = parseCliArgs([ + "--project-root=/tmp/project", + "--role=cto", + "lanes", + "list", + ]); expect(joined.options.projectRoot).toBe(spaced.options.projectRoot); expect(joined.options.role).toBe("cto"); expect(joined.command).toEqual(["lanes", "list"]); @@ -915,7 +1337,16 @@ describe("ADE CLI", () => { it("prefers headless mode for local proof capture commands", () => { const screenshot = buildCliPlan(["proof", "screenshot"]); - const capture = buildCliPlan(["proof", "capture", "--caption", "Done", "--owner-kind", "chat", "--owner-id", "chat-1"]); + const capture = buildCliPlan([ + "proof", + "capture", + "--caption", + "Done", + "--owner-kind", + "chat", + "--owner-id", + "chat-1", + ]); const record = buildCliPlan(["proof", "record", "--seconds", "3"]); const list = buildCliPlan(["proof", "list"]); @@ -923,7 +1354,13 @@ describe("ADE CLI", () => { expect(capture.kind).toBe("execute"); expect(record.kind).toBe("execute"); expect(list.kind).toBe("execute"); - if (screenshot.kind !== "execute" || capture.kind !== "execute" || record.kind !== "execute" || list.kind !== "execute") return; + if ( + screenshot.kind !== "execute" || + capture.kind !== "execute" || + record.kind !== "execute" || + list.kind !== "execute" + ) + return; expect(screenshot.preferHeadless).toBe(true); expect(capture.preferHeadless).toBe(true); @@ -940,7 +1377,17 @@ describe("ADE CLI", () => { }); it("maps proof attach to visual artifact ingestion", () => { - const plan = buildCliPlan(["proof", "attach", "/tmp/done.png", "--caption", "Checkout complete", "--owner-kind", "chat", "--owner-id", "chat-1"]); + const plan = buildCliPlan([ + "proof", + "attach", + "/tmp/done.png", + "--caption", + "Checkout complete", + "--owner-kind", + "chat", + "--owner-id", + "chat-1", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; @@ -952,12 +1399,14 @@ describe("ADE CLI", () => { toolName: "proof attach", ownerKind: "chat", ownerId: "chat-1", - inputs: [{ - kind: "screenshot", - title: "Checkout complete", - description: "Checkout complete", - path: "/tmp/done.png", - }], + inputs: [ + { + kind: "screenshot", + title: "Checkout complete", + description: "Checkout complete", + path: "/tmp/done.png", + }, + ], }, }); }); @@ -1013,6 +1462,145 @@ describe("ADE CLI", () => { }); }); + it("maps discoverable git status, sync, and conflict helpers to existing actions", () => { + const fullStatus = buildCliPlan([ + "git", + "status", + "--full", + "--lane", + "lane-1", + ]); + expect(fullStatus.kind).toBe("execute"); + if (fullStatus.kind !== "execute") return; + expect(fullStatus.label).toBe("lane status"); + expect(fullStatus.steps[0]?.params).toEqual({ + name: "get_lane_status", + arguments: { laneId: "lane-1" }, + }); + + const sync = buildCliPlan([ + "git", + "sync", + "--lane", + "lane-1", + "--rebase", + "--base", + "main", + ]); + expect(sync.kind).toBe("execute"); + if (sync.kind !== "execute") return; + expect(sync.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "git", + action: "sync", + args: { laneId: "lane-1", mode: "rebase", baseRef: "main" }, + }, + }); + + const conflictShow = buildCliPlan([ + "git", + "conflict", + "show", + "--lane", + "lane-1", + ]); + expect(conflictShow.kind).toBe("execute"); + if (conflictShow.kind !== "execute") return; + expect(conflictShow.steps[0]?.params).toEqual({ + name: "get_lane_conflict_state", + arguments: { laneId: "lane-1" }, + }); + + const conflictResolve = buildCliPlan([ + "git", + "conflict", + "resolve", + "--lane", + "lane-1", + "--kind", + "rebase", + ]); + expect(conflictResolve.kind).toBe("execute"); + if (conflictResolve.kind !== "execute") return; + expect(conflictResolve.steps[0]?.params).toEqual({ + name: "rebase_continue", + arguments: { laneId: "lane-1" }, + }); + + const push = buildCliPlan([ + "git", + "push", + "--lane", + "lane-1", + "--set-upstream", + "--force-with-lease", + ]); + expect(push.kind).toBe("execute"); + if (push.kind !== "execute") return; + expect(push.steps[0]?.params).toEqual({ + name: "git_push", + arguments: { laneId: "lane-1", forceWithLease: true, setUpstream: true }, + }); + }); + + it("preserves the public git push --set-upstream flag", () => { + const plan = buildCliPlan([ + "git", + "push", + "--lane", + "lane-1", + "--set-upstream", + "--force-with-lease", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toEqual({ + name: "git_push", + arguments: { + laneId: "lane-1", + forceWithLease: true, + setUpstream: true, + }, + }); + }); + + it("maps action and operation wait aliases to the ADE status poller", () => { + const actionWait = buildCliPlan([ + "actions", + "wait", + "--operation", + "op-1", + "--previous-hash", + "abc", + ]); + expect(actionWait.kind).toBe("execute"); + if (actionWait.kind !== "execute") return; + expect(actionWait.steps[0]?.params).toEqual({ + name: "get_ade_action_status", + arguments: { + operationId: "op-1", + previousHash: "abc", + waitForMs: 30_000, + }, + }); + + const operationStatus = buildCliPlan([ + "operations", + "status", + "--test-run", + "test-1", + "--wait-ms", + "5000", + ]); + expect(operationStatus.kind).toBe("execute"); + if (operationStatus.kind !== "execute") return; + expect(operationStatus.steps[0]?.params).toEqual({ + name: "get_ade_action_status", + arguments: { testRunId: "test-1", waitForMs: 5000 }, + }); + }); + it("uses the parent ADE project when invoked inside an ADE-managed lane worktree", () => { const rawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-roots-")); // findProjectRoots canonicalizes symlinks (e.g. /var -> /private/var on macOS). @@ -1093,7 +1681,14 @@ describe("ADE CLI", () => { }); it("maps PR link arguments to the service contract", () => { - const plan = buildCliPlan(["prs", "link", "--lane", "lane-1", "--url", "https://github.com/acme/ade/pull/123"]); + const plan = buildCliPlan([ + "prs", + "link", + "--lane", + "lane-1", + "--url", + "https://github.com/acme/ade/pull/123", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; @@ -1111,7 +1706,13 @@ describe("ADE CLI", () => { }); it("maps `git checkout ` to git_checkout_branch with mode=existing by default", () => { - const plan = buildCliPlan(["git", "checkout", "feature/foo", "--lane", "lane-1"]); + const plan = buildCliPlan([ + "git", + "checkout", + "feature/foo", + "--lane", + "lane-1", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; @@ -1128,12 +1729,16 @@ describe("ADE CLI", () => { it("maps `git checkout --create` to mode=create with optional --from/--base", () => { const plan = buildCliPlan([ - "git", "checkout", + "git", + "checkout", "feature/new", - "--lane", "lane-1", + "--lane", + "lane-1", "--create", - "--from", "main", - "--base", "main", + "--from", + "main", + "--base", + "main", "--ack-active-work", ]); expect(plan.kind).toBe("execute"); @@ -1153,26 +1758,44 @@ describe("ADE CLI", () => { }); it("accepts the `-b` short flag as an alias for --create", () => { - const plan = buildCliPlan(["git", "checkout", "topic-1", "--lane", "lane-1", "-b"]); + const plan = buildCliPlan([ + "git", + "checkout", + "topic-1", + "--lane", + "lane-1", + "-b", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; - const args = (plan.steps[0]?.params as { arguments: Record }).arguments; + const args = ( + plan.steps[0]?.params as { arguments: Record } + ).arguments; expect(args.mode).toBe("create"); expect(args.branchName).toBe("topic-1"); }); it("omits startPoint and baseRef from the call when not supplied", () => { - const plan = buildCliPlan(["git", "checkout", "feature/x", "--lane", "lane-1"]); + const plan = buildCliPlan([ + "git", + "checkout", + "feature/x", + "--lane", + "lane-1", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; - const args = (plan.steps[0]?.params as { arguments: Record }).arguments; + const args = ( + plan.steps[0]?.params as { arguments: Record } + ).arguments; expect(args).not.toHaveProperty("startPoint"); expect(args).not.toHaveProperty("baseRef"); }); it("rejects `git checkout` without a branch name", () => { - expect(() => buildCliPlan(["git", "checkout", "--lane", "lane-1"])) - .toThrow(/branchName/); + expect(() => buildCliPlan(["git", "checkout", "--lane", "lane-1"])).toThrow( + /branchName/, + ); }); it("shows command help from subcommand help flags", () => { @@ -1231,7 +1854,7 @@ describe("ADE CLI", () => { "--branch-name", "ade-123-linked-lane", "--linear-issue-json", - "{\"id\":\"issue-1\",\"identifier\":\"ADE-123\",\"title\":\"Linked lane\"}", + '{"id":"issue-1","identifier":"ADE-123","title":"Linked lane"}', ]); expect(plan.kind).toBe("execute"); @@ -1321,7 +1944,13 @@ describe("ADE CLI", () => { expect(aliasHelp.text).toContain("iOS Simulator: snapshot"); expect(aliasHelp.text).toContain("ADEInspector/accessibility"); - const targetHelp = buildCliPlan(["ios-sim", "launch", "--target", "preview-target", "--help"]); + const targetHelp = buildCliPlan([ + "ios-sim", + "launch", + "--target", + "preview-target", + "--help", + ]); expect(targetHelp.kind).toBe("help"); if (targetHelp.kind !== "help") return; expect(targetHelp.text).toContain("iOS Simulator: launch"); @@ -1341,7 +1970,16 @@ describe("ADE CLI", () => { }); it("shell-escapes argv tokens after -- when building shell start commands", () => { - const plan = buildCliPlan(["shell", "start", "--lane", "lane-1", "--", "cat", "file with spaces.txt", "literal&name"]); + const plan = buildCliPlan([ + "shell", + "start", + "--lane", + "lane-1", + "--", + "cat", + "file with spaces.txt", + "literal&name", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toEqual({ @@ -1391,14 +2029,16 @@ describe("ADE CLI", () => { }); it("does not treat option values as start-cli providers", () => { - expect(() => buildCliPlan([ - "shell", - "start-cli", - "--lane", - "lane-1", - "--permission-mode", - "edit", - ])).toThrow("provider is required"); + expect(() => + buildCliPlan([ + "shell", + "start-cli", + "--lane", + "lane-1", + "--permission-mode", + "edit", + ]), + ).toThrow("provider is required"); }); it("finds a start-cli provider after value-taking options", () => { @@ -1500,7 +2140,11 @@ describe("ADE CLI", () => { if (byPositional.kind !== "execute") return; expect(byPositional.steps[0]?.params).toEqual({ name: "run_ade_action", - arguments: { domain: "automations", action: "get", args: { id: "rule-42" } }, + arguments: { + domain: "automations", + action: "get", + args: { id: "rule-42" }, + }, }); const byFlag = buildCliPlan(["automations", "show", "--id", "rule-42"]); @@ -1508,7 +2152,11 @@ describe("ADE CLI", () => { if (byFlag.kind !== "execute") return; expect(byFlag.steps[0]?.params).toEqual({ name: "run_ade_action", - arguments: { domain: "automations", action: "get", args: { id: "rule-42" } }, + arguments: { + domain: "automations", + action: "get", + args: { id: "rule-42" }, + }, }); }); @@ -1601,25 +2249,49 @@ describe("ADE CLI", () => { if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toEqual({ name: "run_ade_action", - arguments: { domain: "automations", action: "deleteRule", args: { id: "rule-42" } }, + arguments: { + domain: "automations", + action: "deleteRule", + args: { id: "rule-42" }, + }, }); }); it("automations toggle requires --enabled true|false and coerces to boolean", () => { - const enabled = buildCliPlan(["automations", "toggle", "rule-42", "--enabled", "true"]); + const enabled = buildCliPlan([ + "automations", + "toggle", + "rule-42", + "--enabled", + "true", + ]); expect(enabled.kind).toBe("execute"); if (enabled.kind !== "execute") return; expect(enabled.steps[0]?.params).toEqual({ name: "run_ade_action", - arguments: { domain: "automations", action: "toggleRule", args: { id: "rule-42", enabled: true } }, + arguments: { + domain: "automations", + action: "toggleRule", + args: { id: "rule-42", enabled: true }, + }, }); - const disabled = buildCliPlan(["automations", "toggle", "rule-42", "--enabled", "false"]); + const disabled = buildCliPlan([ + "automations", + "toggle", + "rule-42", + "--enabled", + "false", + ]); expect(disabled.kind).toBe("execute"); if (disabled.kind !== "execute") return; expect(disabled.steps[0]?.params).toEqual({ name: "run_ade_action", - arguments: { domain: "automations", action: "toggleRule", args: { id: "rule-42", enabled: false } }, + arguments: { + domain: "automations", + action: "toggleRule", + args: { id: "rule-42", enabled: false }, + }, }); }); @@ -1753,7 +2425,8 @@ describe("ADE CLI", () => { execution: { laneMode: "create", laneNamePreset: "custom", - laneNameTemplate: "{{trigger.issue.author}}/{{trigger.issue.title}}", + laneNameTemplate: + "{{trigger.issue.author}}/{{trigger.issue.title}}", }, }, }, @@ -1805,7 +2478,9 @@ describe("ADE CLI", () => { "--lane-name-template", "{{trigger.issue.title}}", ]), - ).toThrow(/--lane-name-template is only valid with --lane-name-preset custom/); + ).toThrow( + /--lane-name-template is only valid with --lane-name-preset custom/, + ); }); it("automations create rejects unknown --lane-mode value", () => { @@ -1822,7 +2497,14 @@ describe("ADE CLI", () => { }); it("automations runs accepts a --status filter", () => { - const plan = buildCliPlan(["automations", "runs", "--rule", "r1", "--status", "failed"]); + const plan = buildCliPlan([ + "automations", + "runs", + "--rule", + "r1", + "--status", + "failed", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toEqual({ @@ -1883,7 +2565,13 @@ describe("ADE CLI", () => { id: "legacy-rule", actions: [{ type: "create-lane", laneNameTemplate: "x" }], }); - const plan = buildCliPlan(["automations", "create", "--text", draft, "--allow-legacy"]); + const plan = buildCliPlan([ + "automations", + "create", + "--text", + draft, + "--allow-legacy", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -1911,9 +2599,9 @@ describe("ADE CLI", () => { }); it("automations toggle rejects invalid --enabled values", () => { - expect(() => buildCliPlan(["automations", "toggle", "rule-42", "--enabled", "maybe"])).toThrow( - /must be true or false/, - ); + expect(() => + buildCliPlan(["automations", "toggle", "rule-42", "--enabled", "maybe"]), + ).toThrow(/must be true or false/); }); it("automations run passes dryRun only when --dry-run is set", () => { @@ -1922,7 +2610,11 @@ describe("ADE CLI", () => { if (plain.kind !== "execute") return; expect(plain.steps[0]?.params).toEqual({ name: "run_ade_action", - arguments: { domain: "automations", action: "triggerManually", args: { id: "rule-42" } }, + arguments: { + domain: "automations", + action: "triggerManually", + args: { id: "rule-42" }, + }, }); const dry = buildCliPlan(["automations", "run", "rule-42", "--dry-run"]); @@ -1939,7 +2631,13 @@ describe("ADE CLI", () => { }); it("automations run forwards --lane as laneId", () => { - const plan = buildCliPlan(["automations", "run", "rule-42", "--lane", "lane-7"]); + const plan = buildCliPlan([ + "automations", + "run", + "rule-42", + "--lane", + "lane-7", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -1948,7 +2646,13 @@ describe("ADE CLI", () => { }); it("automations trigger aliases run and forwards --lane as laneId", () => { - const plan = buildCliPlan(["automations", "trigger", "rule-42", "--lane", "lane-7"]); + const plan = buildCliPlan([ + "automations", + "trigger", + "rule-42", + "--lane", + "lane-7", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -2067,7 +2771,14 @@ describe("ADE CLI", () => { it("ios-sim inspect requires both coordinates and forwards them", () => { expect(() => buildCliPlan(["ios-sim", "inspect"])).toThrow(/--x|--y/); - const plan = buildCliPlan(["ios-sim", "inspect", "--x", "120", "--y", "420"]); + const plan = buildCliPlan([ + "ios-sim", + "inspect", + "--x", + "120", + "--y", + "420", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -2080,7 +2791,14 @@ describe("ADE CLI", () => { }); it("ios-sim preview commands map to Xcode preview actions", () => { - const status = buildCliPlan(["ios-sim", "preview-status", "--source", "Views/HomeView.swift", "--line", "42"]); + const status = buildCliPlan([ + "ios-sim", + "preview-status", + "--source", + "Views/HomeView.swift", + "--line", + "42", + ]); expect(status.kind).toBe("execute"); if (status.kind !== "execute") return; expect(status.steps[0]?.params).toMatchObject({ @@ -2091,7 +2809,12 @@ describe("ADE CLI", () => { }, }); - const list = buildCliPlan(["ios-sim", "previews", "--source", "Views/HomeView.swift"]); + const list = buildCliPlan([ + "ios-sim", + "previews", + "--source", + "Views/HomeView.swift", + ]); expect(list.kind).toBe("execute"); if (list.kind !== "execute") return; expect(list.steps[0]?.params).toMatchObject({ @@ -2102,7 +2825,12 @@ describe("ADE CLI", () => { }, }); - const open = buildCliPlan(["ios-sim", "preview-open", "--project-root", "/tmp/app"]); + const open = buildCliPlan([ + "ios-sim", + "preview-open", + "--project-root", + "/tmp/app", + ]); expect(open.kind).toBe("execute"); if (open.kind !== "execute") return; expect(open.steps[0]?.params).toMatchObject({ @@ -2115,7 +2843,9 @@ describe("ADE CLI", () => { }); it("ios-sim preview-render requires a source file and forwards render options", () => { - expect(() => buildCliPlan(["ios-sim", "preview-render"])).toThrow(/sourceFilePath/); + expect(() => buildCliPlan(["ios-sim", "preview-render"])).toThrow( + /sourceFilePath/, + ); const plan = buildCliPlan([ "ios-sim", @@ -2152,7 +2882,9 @@ describe("ADE CLI", () => { expect(plain.steps[0]?.params).toMatchObject({ arguments: { domain: "ios_simulator", action: "shutdown" }, }); - expect((plain.steps[0]?.params as any).arguments.args.force ?? false).toBe(false); + expect((plain.steps[0]?.params as any).arguments.args.force ?? false).toBe( + false, + ); const forced = buildCliPlan(["ios-sim", "shutdown", "--force"]); expect(forced.kind).toBe("execute"); @@ -2167,7 +2899,15 @@ describe("ADE CLI", () => { }); it("keeps shell --command when an argument terminator has no trailing tokens", () => { - const plan = buildCliPlan(["shell", "start", "--lane", "lane-1", "--command", "npm test", "--"]); + const plan = buildCliPlan([ + "shell", + "start", + "--lane", + "lane-1", + "--command", + "npm test", + "--", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -2182,7 +2922,16 @@ describe("ADE CLI", () => { }); it("keeps start-cli --message when an argument terminator has no trailing tokens", () => { - const plan = buildCliPlan(["shell", "start-cli", "codex", "--lane", "lane-1", "--message", "hello", "--"]); + const plan = buildCliPlan([ + "shell", + "start-cli", + "codex", + "--lane", + "lane-1", + "--message", + "hello", + "--", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -2195,7 +2944,13 @@ describe("ADE CLI", () => { }); it("ios-sim type accepts clear text payload aliases without shadowing output --text", () => { - const withValue = buildCliPlan(["ios-sim", "type", "--value", "hello", "--text"]); + const withValue = buildCliPlan([ + "ios-sim", + "type", + "--value", + "hello", + "--text", + ]); expect(withValue.kind).toBe("execute"); if (withValue.kind !== "execute") return; expect(withValue.steps[0]?.params).toMatchObject({ @@ -2206,7 +2961,12 @@ describe("ADE CLI", () => { }, }); - const withPositional = buildCliPlan(["ios-sim", "type", "hello world", "--text"]); + const withPositional = buildCliPlan([ + "ios-sim", + "type", + "hello world", + "--text", + ]); expect(withPositional.kind).toBe("execute"); if (withPositional.kind !== "execute") return; expect(withPositional.steps[0]?.params).toMatchObject({ @@ -2222,7 +2982,14 @@ describe("ADE CLI", () => { const previous = process.env.ADE_CHAT_SESSION_ID; try { process.env.ADE_CHAT_SESSION_ID = "chat-env-1"; - const plan = buildCliPlan(["shell", "start", "--lane", "lane-1", "--command", "npm test"]); + const plan = buildCliPlan([ + "shell", + "start", + "--lane", + "lane-1", + "--command", + "npm test", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -2277,7 +3044,14 @@ describe("ADE CLI", () => { const previous = process.env.ADE_CHAT_SESSION_ID; try { process.env.ADE_CHAT_SESSION_ID = " "; - const plan = buildCliPlan(["shell", "start", "--lane", "lane-1", "--command", "npm test"]); + const plan = buildCliPlan([ + "shell", + "start", + "--lane", + "lane-1", + "--command", + "npm test", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -2317,7 +3091,15 @@ describe("ADE CLI", () => { }); it("app-control launch requires a command and supports aliases", () => { - const launch = buildCliPlan(["app-control", "launch", "--command", "npm run dev", "--debug-port", "9333", "--force"]); + const launch = buildCliPlan([ + "app-control", + "launch", + "--command", + "npm run dev", + "--debug-port", + "9333", + "--force", + ]); expect(launch.kind).toBe("execute"); if (launch.kind !== "execute") return; expect(launch.steps[0]?.params).toMatchObject({ @@ -2389,7 +3171,13 @@ describe("ADE CLI", () => { }, }); - const start = buildCliPlan(["mac-vm", "start", "lane-1", "--create", "--no-display"]); + const start = buildCliPlan([ + "mac-vm", + "start", + "lane-1", + "--create", + "--no-display", + ]); expect(start.kind).toBe("execute"); if (start.kind !== "execute") return; expect(start.steps[0]?.params).toMatchObject({ @@ -2427,7 +3215,14 @@ describe("ADE CLI", () => { }); it("macos-vm window control commands map to VM computer-use actions", () => { - const screenshot = buildCliPlan(["macos-vm", "screenshot", "--lane", "lane-1", "--output", "/tmp/vm.png"]); + const screenshot = buildCliPlan([ + "macos-vm", + "screenshot", + "--lane", + "lane-1", + "--output", + "/tmp/vm.png", + ]); expect(screenshot.kind).toBe("execute"); if (screenshot.kind !== "execute") return; expect(screenshot.steps[0]?.params).toMatchObject({ @@ -2441,7 +3236,14 @@ describe("ADE CLI", () => { }, }); - const click = buildCliPlan(["macos-vm", "click", "--lane", "lane-1", "120", "420"]); + const click = buildCliPlan([ + "macos-vm", + "click", + "--lane", + "lane-1", + "120", + "420", + ]); expect(click.kind).toBe("execute"); if (click.kind !== "execute") return; expect(click.steps[0]?.params).toMatchObject({ @@ -2456,7 +3258,16 @@ describe("ADE CLI", () => { }, }); - const select = buildCliPlan(["macos-vm", "select", "--lane", "lane-1", "--x", "120", "--y", "420"]); + const select = buildCliPlan([ + "macos-vm", + "select", + "--lane", + "lane-1", + "--x", + "120", + "--y", + "420", + ]); expect(select.kind).toBe("execute"); if (select.kind !== "execute") return; expect(select.steps[0]?.params).toMatchObject({ @@ -2471,7 +3282,14 @@ describe("ADE CLI", () => { }, }); - const type = buildCliPlan(["macos-vm", "type", "--lane", "lane-1", "--value", "hello"]); + const type = buildCliPlan([ + "macos-vm", + "type", + "--lane", + "lane-1", + "--value", + "hello", + ]); expect(type.kind).toBe("execute"); if (type.kind !== "execute") return; expect(type.steps[0]?.params).toMatchObject({ @@ -2487,7 +3305,14 @@ describe("ADE CLI", () => { }); it("terminal read and write map to terminal actions", () => { - const read = buildCliPlan(["terminal", "read", "--chat-session", "chat-1", "--max-bytes", "500"]); + const read = buildCliPlan([ + "terminal", + "read", + "--chat-session", + "chat-1", + "--max-bytes", + "500", + ]); expect(read.kind).toBe("execute"); if (read.kind !== "execute") return; expect(read.steps[0]?.params).toMatchObject({ @@ -2498,7 +3323,14 @@ describe("ADE CLI", () => { }, }); - const write = buildCliPlan(["terminal", "write", "--terminal", "term-1", "--data", "y\n"]); + const write = buildCliPlan([ + "terminal", + "write", + "--terminal", + "term-1", + "--data", + "y\n", + ]); expect(write.kind).toBe("execute"); if (write.kind !== "execute") return; expect(write.steps[0]?.params).toMatchObject({ @@ -2522,7 +3354,13 @@ describe("ADE CLI", () => { }, }); - const write = buildCliPlan(["app-control", "terminal", "write", "--data", "y\n"]); + const write = buildCliPlan([ + "app-control", + "terminal", + "write", + "--data", + "y\n", + ]); expect(write.kind).toBe("execute"); if (write.kind !== "execute") return; expect(write.steps[0]?.params).toMatchObject({ @@ -2535,7 +3373,13 @@ describe("ADE CLI", () => { }); it("app-control connect, select, click, and type map to App Control actions", () => { - const connect = buildCliPlan(["app-control", "connect", "--cdp-port", "9222", "--force"]); + const connect = buildCliPlan([ + "app-control", + "connect", + "--cdp-port", + "9222", + "--force", + ]); expect(connect.kind).toBe("execute"); if (connect.kind !== "execute") return; expect(connect.steps[0]?.params).toMatchObject({ @@ -2550,47 +3394,103 @@ describe("ADE CLI", () => { expect(positionalConnect.kind).toBe("execute"); if (positionalConnect.kind !== "execute") return; expect(positionalConnect.steps[0]?.params).toMatchObject({ - arguments: { domain: "app_control", action: "connect", args: { cdpPort: 9333 } }, + arguments: { + domain: "app_control", + action: "connect", + args: { cdpPort: 9333 }, + }, }); - const select = buildCliPlan(["app-control", "select", "--x", "120", "--y", "420"]); + const select = buildCliPlan([ + "app-control", + "select", + "--x", + "120", + "--y", + "420", + ]); expect(select.kind).toBe("execute"); if (select.kind !== "execute") return; expect(select.steps[0]?.params).toMatchObject({ - arguments: { domain: "app_control", action: "selectPoint", args: { x: 120, y: 420 } }, + arguments: { + domain: "app_control", + action: "selectPoint", + args: { x: 120, y: 420 }, + }, }); const click = buildCliPlan(["app", "click", "120", "420"]); expect(click.kind).toBe("execute"); if (click.kind !== "execute") return; expect(click.steps[0]?.params).toMatchObject({ - arguments: { domain: "app_control", action: "click", args: { x: 120, y: 420 } }, + arguments: { + domain: "app_control", + action: "click", + args: { x: 120, y: 420 }, + }, }); - const type = buildCliPlan(["app-control", "type", "--value", "hello", "--text"]); + const type = buildCliPlan([ + "app-control", + "type", + "--value", + "hello", + "--text", + ]); expect(type.kind).toBe("execute"); if (type.kind !== "execute") return; expect(type.steps[0]?.params).toMatchObject({ - arguments: { domain: "app_control", action: "typeText", args: { text: "hello" } }, + arguments: { + domain: "app_control", + action: "typeText", + args: { text: "hello" }, + }, }); - const scroll = buildCliPlan(["app-control", "scroll", "--x", "120", "--y", "420", "--delta-y", "600"]); + const scroll = buildCliPlan([ + "app-control", + "scroll", + "--x", + "120", + "--y", + "420", + "--delta-y", + "600", + ]); expect(scroll.kind).toBe("execute"); if (scroll.kind !== "execute") return; expect(scroll.steps[0]?.params).toMatchObject({ - arguments: { domain: "app_control", action: "scroll", args: { x: 120, y: 420, deltaY: 600 } }, + arguments: { + domain: "app_control", + action: "scroll", + args: { x: 120, y: 420, deltaY: 600 }, + }, }); - const attachTarget = buildCliPlan(["app-control", "attach-target", "--target", "target-1"]); + const attachTarget = buildCliPlan([ + "app-control", + "attach-target", + "--target", + "target-1", + ]); expect(attachTarget.kind).toBe("execute"); if (attachTarget.kind !== "execute") return; expect(attachTarget.steps[0]?.params).toMatchObject({ - arguments: { domain: "app_control", action: "attachToTarget", argsList: ["target-1"] }, + arguments: { + domain: "app_control", + action: "attachToTarget", + argsList: ["target-1"], + }, }); }); it("browser commands map to built-in browser actions", () => { - const open = buildCliPlan(["browser", "open", "localhost:5173", "--new-tab"]); + const open = buildCliPlan([ + "browser", + "open", + "localhost:5173", + "--new-tab", + ]); expect(open.kind).toBe("execute"); if (open.kind !== "execute") return; expect(open.steps[0]?.params).toMatchObject({ @@ -2608,14 +3508,29 @@ describe("ADE CLI", () => { arguments: { domain: "built_in_browser", action: "showPanel", args: {} }, }); - const panelWithUrl = buildCliPlan(["browser", "panel", "--url", "localhost:5173"]); + const panelWithUrl = buildCliPlan([ + "browser", + "panel", + "--url", + "localhost:5173", + ]); expect(panelWithUrl.kind).toBe("execute"); if (panelWithUrl.kind !== "execute") return; expect(panelWithUrl.steps[0]?.params).toMatchObject({ - arguments: { domain: "built_in_browser", action: "showPanel", args: { url: "localhost:5173" } }, + arguments: { + domain: "built_in_browser", + action: "showPanel", + args: { url: "localhost:5173" }, + }, }); - const targetedOpen = buildCliPlan(["browser", "open", "https://example.com", "--tab", "tab-1"]); + const targetedOpen = buildCliPlan([ + "browser", + "open", + "https://example.com", + "--tab", + "tab-1", + ]); expect(targetedOpen.kind).toBe("execute"); if (targetedOpen.kind !== "execute") return; expect(targetedOpen.steps[0]?.params).toMatchObject({ @@ -2626,7 +3541,12 @@ describe("ADE CLI", () => { }, }); - const hiddenOpen = buildCliPlan(["browser", "open", "https://example.com", "--no-panel"]); + const hiddenOpen = buildCliPlan([ + "browser", + "open", + "https://example.com", + "--no-panel", + ]); expect(hiddenOpen.kind).toBe("execute"); if (hiddenOpen.kind !== "execute") return; expect(hiddenOpen.steps[0]?.params).toMatchObject({ @@ -2637,7 +3557,13 @@ describe("ADE CLI", () => { }, }); - const openWithGenericArg = buildCliPlan(["browser", "open", "https://example.com", "--arg", "openPanel=false"]); + const openWithGenericArg = buildCliPlan([ + "browser", + "open", + "https://example.com", + "--arg", + "openPanel=false", + ]); expect(openWithGenericArg.kind).toBe("execute"); if (openWithGenericArg.kind !== "execute") return; expect(openWithGenericArg.steps[0]?.params).toMatchObject({ @@ -2648,7 +3574,12 @@ describe("ADE CLI", () => { }, }); - const openFromGenericUrl = buildCliPlan(["browser", "open", "--arg", "url=https://example.com"]); + const openFromGenericUrl = buildCliPlan([ + "browser", + "open", + "--arg", + "url=https://example.com", + ]); expect(openFromGenericUrl.kind).toBe("execute"); if (openFromGenericUrl.kind !== "execute") return; expect(openFromGenericUrl.steps[0]?.params).toMatchObject({ @@ -2659,7 +3590,12 @@ describe("ADE CLI", () => { }, }); - const backgroundTab = buildCliPlan(["browser", "new-tab", "https://example.com", "--background"]); + const backgroundTab = buildCliPlan([ + "browser", + "new-tab", + "https://example.com", + "--background", + ]); expect(backgroundTab.kind).toBe("execute"); if (backgroundTab.kind !== "execute") return; expect(backgroundTab.steps[0]?.params).toMatchObject({ @@ -2674,10 +3610,22 @@ describe("ADE CLI", () => { expect(switchTab.kind).toBe("execute"); if (switchTab.kind !== "execute") return; expect(switchTab.steps[0]?.params).toMatchObject({ - arguments: { domain: "built_in_browser", action: "switchTab", args: { tabId: "tab-1", openPanel: true } }, + arguments: { + domain: "built_in_browser", + action: "switchTab", + args: { tabId: "tab-1", openPanel: true }, + }, }); - const selectPoint = buildCliPlan(["browser", "select", "--x", "120", "--y", "420", "--no-screenshot"]); + const selectPoint = buildCliPlan([ + "browser", + "select", + "--x", + "120", + "--y", + "420", + "--no-screenshot", + ]); expect(selectPoint.kind).toBe("execute"); if (selectPoint.kind !== "execute") return; expect(selectPoint.steps[0]?.params).toMatchObject({ @@ -2715,7 +3663,11 @@ describe("ADE CLI", () => { expect(dismiss.kind).toBe("execute"); if (dismiss.kind !== "execute") return; expect(dismiss.steps[0]?.params).toMatchObject({ - arguments: { domain: "update", action: "dismissInstalledNotice", args: {} }, + arguments: { + domain: "update", + action: "dismissInstalledNotice", + args: {}, + }, }); const actions = buildCliPlan(["update", "actions"]); @@ -2727,34 +3679,6 @@ describe("ADE CLI", () => { }); }); - it("attaches a rendered lane graph when the plan has the lanes visualizer", () => { - const connection = { - mode: "headless" as const, - projectRoot: "/tmp/project", - workspaceRoot: "/tmp/project", - socketPath: "/tmp/project/.ade/ade.sock", - request: async () => null, - close: () => {}, - }; - const summarized = summarizeExecution({ - plan: { kind: "execute", label: "lanes list", steps: [], visualizer: "lanes" }, - connection, - values: { - result: { - lanes: [ - { id: "main", name: "main", branchRef: "main" }, - { id: "child", name: "child", branchRef: "feature", parentLaneId: "main" }, - ], - }, - }, - } as any); - expect(summarized).toMatchObject({ - lanes: expect.any(Array), - }); - expect((summarized as any).visual).toContain("\\- main (id: main) [main]"); - expect((summarized as any).visual).toContain("\\- child (id: child) [feature]"); - }); - it("usage snapshot routes to the usage.getUsageSnapshot action with no args", () => { const plan = buildCliPlan(["usage", "snapshot"]); expect(plan.kind).toBe("execute"); @@ -2766,7 +3690,6 @@ describe("ADE CLI", () => { arguments: { domain: "usage", action: "getUsageSnapshot", args: {} }, }); - // The `quota`/`quotas` aliases must dispatch to the same plan. const aliased = buildCliPlan(["quota", "snapshot"]); expect(aliased.kind).toBe("execute"); if (aliased.kind !== "execute") return; @@ -2783,7 +3706,7 @@ describe("ADE CLI", () => { name: "run_ade_action", arguments: { domain: "usage", action: "forceRefresh", args: {} }, }); - // `poll` is the documented alias. + const polled = buildCliPlan(["usage", "poll"]); expect(polled.kind).toBe("execute"); if (polled.kind !== "execute") return; @@ -2817,15 +3740,18 @@ describe("ADE CLI", () => { arguments: { domain: "budget", action: "updateConfig", args: config }, }); - // Empty body must surface as a CLI usage error, not silently send `{}`. - expect(() => buildCliPlan(["usage", "budget", "set", "--text", "[1,2,3]"])) - .toThrow(/must be a JSON object/i); - expect(() => buildCliPlan(["usage", "budget", "set", "--text", " \n "])) - .toThrow(/non-empty JSON object/i); - expect(() => buildCliPlan(["usage", "budget", "set"])) - .toThrow(/at least one field/i); - expect(() => buildCliPlan(["usage", "budget", "set", "--text", "{}"])) - .toThrow(/at least one field/i); + expect(() => + buildCliPlan(["usage", "budget", "set", "--text", "[1,2,3]"]), + ).toThrow(/must be a JSON object/i); + expect(() => + buildCliPlan(["usage", "budget", "set", "--text", " \n "]), + ).toThrow(/non-empty JSON object/i); + expect(() => buildCliPlan(["usage", "budget", "set"])).toThrow( + /at least one field/i, + ); + expect(() => + buildCliPlan(["usage", "budget", "set", "--text", "{}"]), + ).toThrow(/at least one field/i); }); it("usage budget check defaults scope to global and forwards --provider", () => { @@ -2842,8 +3768,9 @@ describe("ADE CLI", () => { }, }); - expect(() => buildCliPlan(["usage", "budget", "bogus"])) - .toThrow(/usage budget supports get, set, check, or cumulative/); + expect(() => buildCliPlan(["usage", "budget", "bogus"])).toThrow( + /usage budget supports get, set, check, or cumulative/, + ); }); it("usage budget cumulative routes with scope parameters", () => { @@ -2860,7 +3787,13 @@ describe("ADE CLI", () => { }, }); - const aliased = buildCliPlan(["quota", "budget", "totals", "--provider", "cursor"]); + const aliased = buildCliPlan([ + "quota", + "budget", + "totals", + "--provider", + "cursor", + ]); expect(aliased.kind).toBe("execute"); if (aliased.kind !== "execute") return; expect(aliased.steps[0]?.params).toEqual({ @@ -2884,4 +3817,44 @@ describe("ADE CLI", () => { expect(quota.text).toBe(direct.text); expect(helpQuota.text).toBe(direct.text); }); + + it("attaches a rendered lane graph when the plan has the lanes visualizer", () => { + const connection = { + mode: "headless" as const, + projectRoot: "/tmp/project", + workspaceRoot: "/tmp/project", + socketPath: "/tmp/project/.ade/ade.sock", + request: async () => null, + close: () => {}, + }; + const summarized = summarizeExecution({ + plan: { + kind: "execute", + label: "lanes list", + steps: [], + visualizer: "lanes", + }, + connection, + values: { + result: { + lanes: [ + { id: "main", name: "main", branchRef: "main" }, + { + id: "child", + name: "child", + branchRef: "feature", + parentLaneId: "main", + }, + ], + }, + }, + } as any); + expect(summarized).toMatchObject({ + lanes: expect.any(Array), + }); + expect((summarized as any).visual).toContain("\\- main (id: main) [main]"); + expect((summarized as any).visual).toContain( + "\\- child (id: child) [feature]", + ); + }); }); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 5e19334c0..bca89e4c9 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1,15 +1,21 @@ #!/usr/bin/env node import { Buffer } from "node:buffer"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import net from "node:net"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import YAML from "yaml"; import { CURSOR_CLOUD_HELP, CursorCloudUsageError, runCursorCloud, } from "./cursorCloud"; +import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; +import { + findAdeManagedWorktreeRoot, + realpathIfExists, +} from "./services/projects/projectRoots"; import { JsonRpcError, JsonRpcErrorCode, @@ -27,6 +33,11 @@ import { validateLaunchProfilePermissionMode, type LaunchProfile, } from "../../desktop/src/shared/cliLaunch"; +import type { + SyncMobileProjectSummary, + SyncProjectSwitchRequestPayload, + SyncProjectSwitchResultPayload, +} from "../../desktop/src/shared/types/sync"; type JsonObject = Record; @@ -58,6 +69,7 @@ type FormatterId = | "status" | "doctor" | "auth" + | "projects-list" | "linear-quick-view" | "lanes" | "lane-detail" @@ -104,12 +116,26 @@ type FormatterId = type CliPlan = | { kind: "help"; text: string } - | { kind: "execute"; label: string; steps: InvocationStep[]; visualizer?: "lanes"; summary?: "status" | "doctor" | "auth"; formatter?: FormatterId; preferHeadless?: boolean } + | { + kind: "execute"; + label: string; + steps: InvocationStep[]; + visualizer?: "lanes"; + summary?: "status" | "doctor" | "auth"; + formatter?: FormatterId; + preferHeadless?: boolean; + } + | { kind: "ade-code"; rest: string[] } + | { kind: "desktop"; rest: string[] } + | { kind: "runtime"; rest: string[] } + | { kind: "serve"; rest: string[] } + | { kind: "rpc-stdio"; rest: string[] } + | { kind: "init"; targetPath: string | null } | { kind: "cursor-cloud"; rest: string[] } | { kind: "mcp" }; type CliConnection = { - mode: "desktop-socket" | "headless"; + mode: "desktop-socket" | "runtime-socket" | "headless"; projectRoot: string; workspaceRoot: string; socketPath: string; @@ -145,10 +171,16 @@ type ReadinessCheck = { details?: JsonObject; }; -const VERSION = "0.0.0"; +declare const __ADE_VERSION__: string | undefined; + +const VERSION = + typeof __ADE_VERSION__ === "string" && __ADE_VERSION__.trim() + ? __ADE_VERSION__ + : process.env.ADE_CLI_VERSION?.trim() || "0.0.0"; const PROTOCOL_VERSION = "2025-06-18"; const SOURCE_FALLBACK_ENV = "ADE_CLI_SOURCE_FALLBACK_ACTIVE"; -const CLI_ENTRY_PATH = typeof process.argv[1] === "string" ? path.resolve(process.argv[1]) : ""; +const CLI_ENTRY_PATH = + typeof process.argv[1] === "string" ? path.resolve(process.argv[1]) : ""; const CLI_PACKAGE_ROOT = resolveCliPackageRoot(CLI_ENTRY_PATH); const CLI_DIST_PATH = path.join(CLI_PACKAGE_ROOT, "dist", "cli.cjs"); const COORDINATOR_MCP_TOOL_NAMES = new Set([ @@ -204,10 +236,7 @@ const WORKER_MISSION_TOOL_CLI_NAMES = new Set([ function resolveCliPackageRoot(entryPath: string): string { const seen = new Set(); - const starts = [ - entryPath ? path.dirname(entryPath) : null, - process.cwd(), - ]; + const starts = [entryPath ? path.dirname(entryPath) : null, process.cwd()]; for (const start of starts) { if (!start) continue; let cursor = path.resolve(start); @@ -231,19 +260,25 @@ function isSourceCliEntryPath(modulePath: string): boolean { } function isSourceRuntimeInteropError(value: unknown): boolean { - const message = typeof value === "string" - ? value - : value instanceof Error - ? value.message - : ""; + const message = + typeof value === "string" + ? value + : value instanceof Error + ? value.message + : ""; if (!message.length) return false; const lower = message.toLowerCase(); - return lower.includes("__filename is not defined in es module scope") - || lower.includes("__filename is not defined") - || lower.includes("__dirname is not defined"); + return ( + lower.includes("__filename is not defined in es module scope") || + lower.includes("__filename is not defined") || + lower.includes("__dirname is not defined") + ); } -function formatSpawnFailure(result: ReturnType, fallbackCommand: string): string { +function formatSpawnFailure( + result: ReturnType, + fallbackCommand: string, +): string { if (result.error) { return result.error.message; } @@ -288,11 +323,17 @@ function isBuiltCliFresh(): boolean { } } -function maybeRunBuiltCliFallback(error: unknown, argv: string[]): { stdout: string; stderr: string; exitCode: number } | null { +function maybeRunBuiltCliFallback( + error: unknown, + argv: string[], +): { stdout: string; stderr: string; exitCode: number } | null { if (!(error instanceof CliExecutionError)) return null; if (process.env[SOURCE_FALLBACK_ENV] === "1") return null; if (!isSourceCliEntryPath(CLI_ENTRY_PATH)) return null; - if (!isSourceRuntimeInteropError(asString(error.details.cause) ?? error.message)) return null; + if ( + !isSourceRuntimeInteropError(asString(error.details.cause) ?? error.message) + ) + return null; if (!isBuiltCliFresh()) { const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; @@ -302,8 +343,12 @@ function maybeRunBuiltCliFallback(error: unknown, argv: string[]): { stdout: str encoding: "utf8", }); if (buildResult.error || buildResult.status !== 0 || !isBuiltCliFresh()) { - error.details.nextAction = "Run `npm --prefix apps/ade-cli run build` and retry the command."; - error.details.fallback = formatSpawnFailure(buildResult, "npm run build --silent"); + error.details.nextAction = + "Run `npm --prefix apps/ade-cli run build` and retry the command."; + error.details.fallback = formatSpawnFailure( + buildResult, + "npm run build --silent", + ); return null; } } @@ -317,7 +362,8 @@ function maybeRunBuiltCliFallback(error: unknown, argv: string[]): { stdout: str encoding: "utf8", }); if (rerun.error) { - error.details.nextAction = "Run `node apps/ade-cli/dist/cli.cjs ...` directly to inspect the runtime failure."; + error.details.nextAction = + "Run `node apps/ade-cli/dist/cli.cjs ...` directly to inspect the runtime failure."; error.details.fallback = rerun.error.message; return null; } @@ -340,15 +386,24 @@ const ADE_BANNER = String.raw` const TOP_LEVEL_HELP = `${ADE_BANNER} Agent-focused command-line interface for ADE. - ADE CLI commands operate on the same project database and live desktop socket - used by the ADE app. By default the CLI connects to the app socket when it is - running; otherwise it falls back to a headless runtime for local-safe actions. + ADE CLI commands operate through the machine ADE runtime daemon by default. + If the daemon is not running, the CLI starts it, registers the selected + project, and routes project actions through that runtime. $ ade help Display help for a command $ ade auth status Check local ADE CLI readiness + $ ade code Open ADE Work chat in the terminal + $ ade desktop Launch the installed desktop app + $ ade runtime start | stop | status Manage the machine runtime daemon + $ ade serve Run the ADE runtime daemon in foreground + $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout + $ ade init [path] Register a project with this machine runtime + $ ade projects list List projects registered on this machine + $ ade sync status | pin generate Manage machine sync and phone pairing $ ade doctor Inspect project, socket, runtime, and tool availability $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations + $ ade operations status | wait Poll operation/test/chat/run/mission status $ ade diff changes | file | patch Inspect lane diffs (including raw git patch text) $ ade files tree | read | write | search Read and edit lane workspaces $ ade missions launch | watch | graph Create, start, and inspect mission runs @@ -372,7 +427,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade usage snapshot | refresh | budget Read provider quota usage and edit automation guardrails $ ade settings action Call project config actions $ ade update status | check | install | dismiss Read auto-update state and drive install - $ ade actions list | run | status Escape hatch for every ADE service action + $ ade actions list | run | status | wait Escape hatch for every ADE service action $ ade mcp Expose ADE actions over stdio MCP $ ade cursor cloud agents | runs | artifacts | repos | models | me Drive Cursor Cloud agents via @cursor/sdk @@ -380,8 +435,8 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} Global options: --project-root ADE project root. Inside .ade/worktrees/, this resolves to the parent project. --workspace-root Lane/worktree to treat as the active workspace. - --headless Skip the desktop socket and run an in-process ADE runtime. - --socket Require the desktop socket; fail instead of falling back to headless. + --headless Skip the runtime daemon and run an in-process ADE runtime. + --socket Require a live ADE socket; fail instead of falling back to headless. --json Print machine-readable JSON. This is the default output mode. --text Print a compact human-readable summary when a formatter exists. --timeout-ms Per-request timeout. Long agent/PR workflows may need several minutes. @@ -391,6 +446,8 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade lanes list --text $ ade lanes create --name fix-login --description "Repair login redirect" $ ade git status --lane --text + $ ade git status --full --lane --text + $ ade git sync --lane --rebase --base main $ ade git stage --lane src/index.ts $ ade git commit --lane -m "Fix login redirect" $ ade missions launch --prompt "Fix onboarding" --manual --text @@ -754,6 +811,102 @@ const IOS_SIMULATOR_HELP_ALIASES: Record = { }; const HELP_BY_COMMAND: Record = { + desktop: `${ADE_BANNER} + ADE Desktop + + Launch the installed ADE desktop app. The desktop app attaches to the normal + machine runtime and starts it if needed. + + $ ade desktop + $ ade desktop open + + Flags: + --app-name macOS app name to open. Defaults to ADE, ADE Beta, + or ADE Alpha based on the installed CLI wrapper. +`, + runtime: `${ADE_BANNER} + ADE Runtime + + Manage the normal machine ADE runtime daemon used by desktop, ade code, and + socket-backed CLI commands. + + $ ade runtime status --text + $ ade runtime start + $ ade runtime stop + + Notes: + "start" launches the daemon in the background if it is missing. + "stop" shuts down the daemon on the selected socket. + Use "ade serve" when you want to run the runtime in the foreground. +`, + serve: `${ADE_BANNER} + ADE Runtime Daemon + + Runs the machine-scoped ADE runtime in the foreground. The daemon listens on + a local socket and can lazily serve any project registered with "ade init". + + $ ade serve + $ ade serve --socket ~/.ade/sock/ade.sock + $ ade serve --port 8787 + + Flags: + --socket Unix socket or Windows named pipe to listen on. + --port Also listen for local TCP JSON-RPC on 127.0.0.1:n. + --no-sync Disable machine sync discovery for this daemon run. + --install-service Register the per-user login service and exit. + --uninstall-service Remove the per-user login service and exit. + --service-status Print per-user login service status and exit. +`, + rpc: `${ADE_BANNER} + ADE JSON-RPC + + Attaches to the machine runtime daemon and speaks ADE JSON-RPC over stdio. + If the daemon is not running, ADE starts it before accepting requests. This + mode is used by SSH transports. + + $ ade rpc --stdio +`, + init: `${ADE_BANNER} + ADE Project Init + + Registers a project with this machine runtime and creates its .ade directory + if needed. + + $ ade init + $ ade init /path/to/project +`, + projects: `${ADE_BANNER} + ADE projects + + Manage the machine-scoped ADE project registry used by the runtime daemon. + + $ ade projects list --text + $ ade projects add /path/to/project + $ ade projects remove + $ ade projects touch +`, + code: `${ADE_BANNER} + ADE Code + + Launch the terminal-native ADE Work chat. It uses the same project lanes, + chat sessions, transcript state, and slash commands as desktop ADE, but it + does not require the desktop app to be running. + + $ ade code Start the TUI for the current project + $ ade code --print-state Smoke-test attach/embed state + $ ade code --embedded Force the embedded runtime fallback + $ ade code --require-socket Fail instead of embedding when no socket exists + $ ade code --socket /tmp/ade.sock Attach to a specific runtime socket + $ ade --project-root code Launch against a specific ADE project + + Keys: + ctrl-o Open or focus lanes and chats + ctrl-p Open or focus details + shift-tab Cycle pane focus + esc Return or cancel the active pane + ? Help when it is the first prompt character + / Command palette + `, lanes: `${ADE_BANNER} Lanes @@ -779,16 +932,39 @@ const HELP_BY_COMMAND: Record = { refresh lane state. Use --lane for anything other than the active workspace. $ ade git status --lane --text Show ADE-aware sync status + $ ade git status --full --lane --text Show full lane status, diff, and conflict state + $ ade git fetch --lane Fetch remote refs + $ ade git pull --lane Pull with ADE's ff-only lane operation + $ ade git sync --lane --rebase --base main + Sync the lane with its base branch $ ade git stage --lane src/file.ts Stage one file $ ade git stage-all --lane Stage all current changes $ ade git unstage --lane src/file.ts Unstage one file $ ade git commit --lane [-m ] Commit, adding Refs on linked Linear lanes $ ade git push --lane --set-upstream Push through ADE + $ ade git push --lane --force-with-lease Force-push through ADE with lease $ ade git branches --lane --text List branches with last-commit metadata $ ade git user-identity --lane --text Read lane checkout's git user.name/email $ ade git stash push|list|apply|pop Use ADE lane stash actions $ ade git rebase --lane --ai Rebase with ADE conflict support + $ ade git rebase continue --lane Continue an in-progress rebase + $ ade git conflict show --lane --text Inspect merge/rebase conflict state + $ ade git conflict resolve --kind rebase Continue after manual conflict resolution $ ade diff changes --lane --text Inspect changed files +`, + operations: `${ADE_BANNER} + Operations + + Poll status for long-running ADE operations that returned an operation, + test run, chat session, run graph, mission, or PR id. + + $ ade operations status --operation --text + $ ade operations wait --operation --wait-ms 30000 --text + $ ade actions wait --test-run --wait-ms 30000 --text + + Generic operation logs are not persisted by the operation table. Use + "ade tests logs", "ade run logs", or terminal/app-control log commands for + surfaces that own logs. `, diff: `${ADE_BANNER} Diffs @@ -854,8 +1030,8 @@ const HELP_BY_COMMAND: Record = { run: `${ADE_BANNER} Run tab - Run tab commands mirror ADE desktop process definitions and runtime state. - They require the desktop socket when live process state is needed. + Run tab commands mirror ADE process definitions and runtime state. They use + the machine runtime daemon when live process state is needed. $ ade run defs --text List configured run commands $ ade run ps --lane --text List process runtime state @@ -884,7 +1060,7 @@ const HELP_BY_COMMAND: Record = { Chat terminal Terminal commands control the active in-chat terminal for an ADE chat. Use - desktop socket mode when you want the same terminal the user sees in the app. + attached runtime mode when you want the same terminal the app is viewing. $ ade terminal list --chat-session --text List terminals for a chat $ ade terminal active --chat-session --text Show the active chat terminal @@ -912,7 +1088,7 @@ const HELP_BY_COMMAND: Record = { Work chats Chat commands use ADE agent chat sessions. Live provider-backed chat normally - requires the desktop socket because the app owns provider/session state. + requires an attached runtime because the daemon owns provider/session state. $ ade chat list --text List chat sessions $ ade chat create --lane --provider codex --model [--fast] @@ -936,8 +1112,8 @@ const HELP_BY_COMMAND: Record = { Prefer screenshots/images, screen recordings, and browser captures/traces. Console logs are supporting diagnostics, not a replacement for visual proof. Local screenshot/video fallback is macOS-only and runs headless by default - unless --socket is explicitly requested. Desktop socket mode has the best - parity for UI-owned proof state. + unless --socket is explicitly requested. Runtime socket mode has the best + parity for shared proof state. $ ade proof status --text Show proof backend capabilities $ ade proof list --text List captured artifacts @@ -952,7 +1128,7 @@ const HELP_BY_COMMAND: Record = { iOS simulator commands build, launch, mirror, inspect, and control the ADE drawer simulator. Aliases: \`ade ios\` and \`ade simulator\` route to the same - surface. For drawer/shared session state, prefer desktop socket mode + surface. For drawer/shared session state, prefer runtime socket mode (--socket) so launch/select/tap operate on the same long-lived ADE service. Launch is headless by default; use --foreground only when you need the native Simulator window in front. idb is optional for direct @@ -1005,7 +1181,7 @@ const HELP_BY_COMMAND: Record = { macOS VM commands provision and control lane-tied Apple silicon macOS guests through Lume. ADE mounts the lane worktree into the guest with a - shared directory so host and guest edits stay in sync. Use desktop socket + shared directory so host and guest edits stay in sync. Use runtime socket mode when the Work sidebar and agents should observe the same live VM state. Discovery and lifecycle: @@ -1283,7 +1459,9 @@ function isRecord(value: unknown): value is JsonObject { } function asString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; } function parseBooleanEnv(value: string | undefined): boolean { @@ -1307,7 +1485,9 @@ function parseJson(value: string, label: string): unknown { try { return JSON.parse(value); } catch (error) { - throw new CliUsageError(`${label} must be valid JSON: ${error instanceof Error ? error.message : String(error)}`); + throw new CliUsageError( + `${label} must be valid JSON: ${error instanceof Error ? error.message : String(error)}`, + ); } } @@ -1319,7 +1499,10 @@ function parseObjectJson(value: string, label: string): JsonObject { return parsed; } -function parseAssignment(value: string, label: string): { key: string; value: string } { +function parseAssignment( + value: string, + label: string, +): { key: string; value: string } { const index = value.indexOf("="); if (index <= 0) { throw new CliUsageError(`${label} must use key=value syntax.`); @@ -1331,16 +1514,25 @@ function parseAssignment(value: string, label: string): { key: string; value: st return { key, value: value.slice(index + 1) }; } -const UNSAFE_ARG_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]); +const UNSAFE_ARG_PATH_SEGMENTS = new Set([ + "__proto__", + "constructor", + "prototype", +]); function setPath(target: JsonObject, key: string, value: unknown): void { - const parts = key.split(".").map((part) => part.trim()).filter(Boolean); + const parts = key + .split(".") + .map((part) => part.trim()) + .filter(Boolean); if (parts.length === 0) { throw new CliUsageError("Argument key cannot be empty."); } const unsafePart = parts.find((part) => UNSAFE_ARG_PATH_SEGMENTS.has(part)); if (unsafePart) { - throw new CliUsageError(`Argument key segment "${unsafePart}" is not allowed.`); + throw new CliUsageError( + `Argument key segment "${unsafePart}" is not allowed.`, + ); } let cursor: JsonObject = target; for (const part of parts.slice(0, -1)) { @@ -1360,7 +1552,9 @@ function readValue(args: string[], names: string[]): string | null { for (let index = 0; index < args.length; index += 1) { const token = args[index]; if (!token) continue; - const matchedName = names.find((name) => token === name || token.startsWith(`${name}=`)); + const matchedName = names.find( + (name) => token === name || token.startsWith(`${name}=`), + ); if (!matchedName) continue; if (token.includes("=")) { args.splice(index, 1); @@ -1389,7 +1583,9 @@ function readCommandTextValue(args: string[], names: string[]): string | null { for (let index = 0; index < args.length; index += 1) { const token = args[index]; if (!token) continue; - const matchedName = names.find((name) => token === name || token.startsWith(`${name}=`)); + const matchedName = names.find( + (name) => token === name || token.startsWith(`${name}=`), + ); if (!matchedName) continue; if (token.includes("=")) { args.splice(index, 1); @@ -1422,8 +1618,11 @@ function firstStandalonePositional(args: string[]): string | null { continue; } if (token.startsWith("-")) { - const flagName = token.includes("=") ? token.slice(0, token.indexOf("=")) : token; - previousTokenWasValueCarrier = !token.includes("=") && VALUE_CARRIER_FLAGS.has(flagName); + const flagName = token.includes("=") + ? token.slice(0, token.indexOf("=")) + : token; + previousTokenWasValueCarrier = + !token.includes("=") && VALUE_CARRIER_FLAGS.has(flagName); continue; } const [value] = args.splice(index, 1); @@ -1456,17 +1655,28 @@ function buildCursorHelp(args: string[]): string { positionals.push(token.toLowerCase()); } // Drop a leading "cursor" / "cloud" if present so we land on the group token. - while (positionals.length && (positionals[0] === "cursor" || positionals[0] === "cloud")) { + while ( + positionals.length && + (positionals[0] === "cursor" || positionals[0] === "cloud") + ) { positionals.shift(); } const group = positionals[0]; const aliasMap: Record = { - agents: "agents", agent: "agents", - runs: "runs", run: "runs", - artifacts: "artifacts", artifact: "artifacts", - repos: "repos", repo: "repos", repositories: "repos", - models: "models", model: "models", - me: "me", whoami: "me", user: "me", + agents: "agents", + agent: "agents", + runs: "runs", + run: "runs", + artifacts: "artifacts", + artifact: "artifacts", + repos: "repos", + repo: "repos", + repositories: "repos", + models: "models", + model: "models", + me: "me", + whoami: "me", + user: "me", }; if (group && aliasMap[group] && CURSOR_CLOUD_HELP[aliasMap[group]]) { return `${ADE_BANNER}${CURSOR_CLOUD_HELP[aliasMap[group]]}`; @@ -1477,7 +1687,7 @@ function buildCursorHelp(args: string[]): string { function buildIosSimulatorHelp(args: string[]): string { const rawSubcommand = peekFirstPositional(args)?.toLowerCase() ?? ""; const canonical = rawSubcommand - ? IOS_SIMULATOR_HELP_ALIASES[rawSubcommand] ?? rawSubcommand + ? (IOS_SIMULATOR_HELP_ALIASES[rawSubcommand] ?? rawSubcommand) : ""; if (canonical && IOS_SIMULATOR_SUBCOMMAND_HELP[canonical]) { return IOS_SIMULATOR_SUBCOMMAND_HELP[canonical]; @@ -1500,10 +1710,17 @@ function buildAppControlHelp(args: string[]): string { return focused; } -function collectGenericObjectArgs(args: string[], base: JsonObject = {}): JsonObject { +function collectGenericObjectArgs( + args: string[], + base: JsonObject = {}, +): JsonObject { const input: JsonObject = { ...base }; while (true) { - const inputJson = readValue(args, ["--input-json", "--json-input", "--input"]); + const inputJson = readValue(args, [ + "--input-json", + "--json-input", + "--input", + ]); if (inputJson != null) { Object.assign(input, parseObjectJson(inputJson, "--input-json")); continue; @@ -1536,7 +1753,11 @@ function readPrId(args: string[]): string | null { return readValue(args, ["--pr", "--pr-id"]) ?? null; } -function readIntOption(args: string[], names: string[], fallback?: number): number | undefined { +function readIntOption( + args: string[], + names: string[], + fallback?: number, +): number | undefined { const value = readValue(args, names); if (value == null) return fallback; const parsed = Number.parseInt(value, 10); @@ -1546,7 +1767,11 @@ function readIntOption(args: string[], names: string[], fallback?: number): numb return parsed; } -function readNumberOption(args: string[], names: string[], fallback?: number): number | undefined { +function readNumberOption( + args: string[], + names: string[], + fallback?: number, +): number | undefined { const value = readValue(args, names); if (value == null) return fallback; const parsed = Number(value); @@ -1556,12 +1781,20 @@ function readNumberOption(args: string[], names: string[], fallback?: number): n return parsed; } -function readJsonOption(args: string[], names: string[], label: string): unknown | undefined { +function readJsonOption( + args: string[], + names: string[], + label: string, +): unknown | undefined { const value = readValue(args, names); return value == null ? undefined : parseJson(value, label); } -function readJsonFileOption(args: string[], names: string[], label: string): unknown | undefined { +function readJsonFileOption( + args: string[], + names: string[], + label: string, +): unknown | undefined { const filePath = readValue(args, names); if (filePath == null) return undefined; const resolvedPath = path.resolve(filePath); @@ -1570,16 +1803,25 @@ function readJsonFileOption(args: string[], names: string[], label: string): unk text = fs.readFileSync(resolvedPath, "utf8"); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new CliUsageError(`Could not read ${names[0]} file '${filePath}': ${message}`); + throw new CliUsageError( + `Could not read ${names[0]} file '${filePath}': ${message}`, + ); } return parseJson(text, label); } -function readJsonPayloadOption(args: string[], jsonNames: string[], fileNames: string[], label: string): unknown | undefined { +function readJsonPayloadOption( + args: string[], + jsonNames: string[], + fileNames: string[], + label: string, +): unknown | undefined { const inline = readJsonOption(args, jsonNames, label); const fromFile = readJsonFileOption(args, fileNames, label); if (inline !== undefined && fromFile !== undefined) { - throw new CliUsageError(`Use either ${jsonNames[0]} or ${fileNames[0]}, not both.`); + throw new CliUsageError( + `Use either ${jsonNames[0]} or ${fileNames[0]}, not both.`, + ); } return inline ?? fromFile; } @@ -1593,7 +1835,11 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function isCommandTextValue(argv: string[], index: number, command: string[]): boolean { +function isCommandTextValue( + argv: string[], + index: number, + command: string[], +): boolean { if (command.length === 0) return false; const token = argv[index]; if (token?.startsWith("--text=")) return true; @@ -1633,10 +1879,10 @@ function readPipelineSettingsPatch(args: string[]): JsonObject { const conflictStrategy = readValue(args, ["--conflict-strategy"]); if (conflictStrategy) { if ( - conflictStrategy !== "pause" - && conflictStrategy !== "rebase" - && conflictStrategy !== "merge" - && conflictStrategy !== "auto" + conflictStrategy !== "pause" && + conflictStrategy !== "rebase" && + conflictStrategy !== "merge" && + conflictStrategy !== "auto" ) { throw new CliUsageError( "--conflict-strategy must be one of pause, rebase, merge, or auto.", @@ -1648,20 +1894,21 @@ function readPipelineSettingsPatch(args: string[]): JsonObject { const forceFinalize = readValue(args, ["--force-finalize"]); if (forceFinalize) { if ( - forceFinalize !== "off" - && forceFinalize !== "conditional" - && forceFinalize !== "unconditional" + forceFinalize !== "off" && + forceFinalize !== "conditional" && + forceFinalize !== "unconditional" ) { throw new CliUsageError( "--force-finalize must be one of off, conditional, or unconditional.", ); } patch.forceFinalizeMode = forceFinalize; - patch.atCapPolicy = forceFinalize === "off" - ? "stop" - : forceFinalize === "unconditional" - ? "force_merge" - : "ci_retry_once"; + patch.atCapPolicy = + forceFinalize === "off" + ? "stop" + : forceFinalize === "unconditional" + ? "force_merge" + : "ci_retry_once"; } const requireNoCi = readFlag(args, ["--force-finalize-require-no-ci"]); @@ -1679,40 +1926,48 @@ function readPipelineSettingsPatch(args: string[]): JsonObject { const atCapPolicy = readValue(args, ["--at-cap-policy"]); if (atCapPolicy) { if ( - atCapPolicy !== "stop" - && atCapPolicy !== "wait_for_ci" - && atCapPolicy !== "ci_retry_once" - && atCapPolicy !== "ci_retry_loop" - && atCapPolicy !== "force_merge" + atCapPolicy !== "stop" && + atCapPolicy !== "wait_for_ci" && + atCapPolicy !== "ci_retry_once" && + atCapPolicy !== "ci_retry_loop" && + atCapPolicy !== "force_merge" ) { throw new CliUsageError( "--at-cap-policy must be one of stop, wait_for_ci, ci_retry_once, ci_retry_loop, or force_merge.", ); } patch.atCapPolicy = atCapPolicy; - patch.forceFinalizeMode = atCapPolicy === "stop" - ? "off" - : atCapPolicy === "force_merge" - ? "unconditional" - : "conditional"; + patch.forceFinalizeMode = + atCapPolicy === "stop" + ? "off" + : atCapPolicy === "force_merge" + ? "unconditional" + : "conditional"; } const atCapWaitMinutes = readIntOption(args, ["--at-cap-wait-minutes"]); if (atCapWaitMinutes != null) { - if (atCapWaitMinutes < 1) throw new CliUsageError("--at-cap-wait-minutes must be at least 1."); + if (atCapWaitMinutes < 1) + throw new CliUsageError("--at-cap-wait-minutes must be at least 1."); patch.atCapWaitMinutes = atCapWaitMinutes; } const atCapCiRetryMax = readIntOption(args, ["--at-cap-ci-retry-max"]); if (atCapCiRetryMax != null) { - if (atCapCiRetryMax < 1) throw new CliUsageError("--at-cap-ci-retry-max must be at least 1."); + if (atCapCiRetryMax < 1) + throw new CliUsageError("--at-cap-ci-retry-max must be at least 1."); patch.atCapCiRetryMax = atCapCiRetryMax; } - const forceMergeConfirm = readFlag(args, ["--force-merge-requires-confirmation"]); - const noForceMergeConfirm = readFlag(args, ["--no-force-merge-requires-confirmation"]); + const forceMergeConfirm = readFlag(args, [ + "--force-merge-requires-confirmation", + ]); + const noForceMergeConfirm = readFlag(args, [ + "--no-force-merge-requires-confirmation", + ]); if (forceMergeConfirm || noForceMergeConfirm) { - patch.forceMergeRequiresConfirmation = forceMergeConfirm && !noForceMergeConfirm; + patch.forceMergeRequiresConfirmation = + forceMergeConfirm && !noForceMergeConfirm; } return patch; @@ -1723,7 +1978,10 @@ function parseCliArgs(argv: string[]): ParsedCli { const options: GlobalOptions = { projectRoot: null, workspaceRoot: null, - role: (asString(process.env.ADE_DEFAULT_ROLE) as GlobalOptions["role"] | null) ?? "agent", + role: + (asString(process.env.ADE_DEFAULT_ROLE) as + | GlobalOptions["role"] + | null) ?? "agent", headless: parseBooleanEnv(process.env.ADE_CLI_HEADLESS), requireSocket: false, pretty: true, @@ -1739,21 +1997,32 @@ function parseCliArgs(argv: string[]): ParsedCli { break; } if (inGlobalPrefix && token === "--project-root") { - options.projectRoot = path.resolve(requireValue(argv[index + 1] ?? null, "--project-root")); + options.projectRoot = path.resolve( + requireValue(argv[index + 1] ?? null, "--project-root"), + ); index += 1; continue; } if (inGlobalPrefix && token.startsWith("--project-root=")) { - options.projectRoot = path.resolve(requireValue(token.slice("--project-root=".length), "--project-root")); + options.projectRoot = path.resolve( + requireValue(token.slice("--project-root=".length), "--project-root"), + ); continue; } if (inGlobalPrefix && token === "--workspace-root") { - options.workspaceRoot = path.resolve(requireValue(argv[index + 1] ?? null, "--workspace-root")); + options.workspaceRoot = path.resolve( + requireValue(argv[index + 1] ?? null, "--workspace-root"), + ); index += 1; continue; } if (inGlobalPrefix && token.startsWith("--workspace-root=")) { - options.workspaceRoot = path.resolve(requireValue(token.slice("--workspace-root=".length), "--workspace-root")); + options.workspaceRoot = path.resolve( + requireValue( + token.slice("--workspace-root=".length), + "--workspace-root", + ), + ); continue; } if (inGlobalPrefix && token === "--role") { @@ -1762,7 +2031,9 @@ function parseCliArgs(argv: string[]): ParsedCli { continue; } if (inGlobalPrefix && token.startsWith("--role=")) { - options.role = parseRole(requireValue(token.slice("--role=".length), "--role")); + options.role = parseRole( + requireValue(token.slice("--role=".length), "--role"), + ); continue; } if (inGlobalPrefix && (token === "--headless" || token === "--no-socket")) { @@ -1795,7 +2066,10 @@ function parseCliArgs(argv: string[]): ParsedCli { continue; } if (inGlobalPrefix && token === "--timeout-ms") { - const parsed = Number.parseInt(requireValue(argv[index + 1] ?? null, "--timeout-ms"), 10); + const parsed = Number.parseInt( + requireValue(argv[index + 1] ?? null, "--timeout-ms"), + 10, + ); if (!Number.isFinite(parsed) || parsed <= 0) { throw new CliUsageError("--timeout-ms must be a positive integer."); } @@ -1804,7 +2078,10 @@ function parseCliArgs(argv: string[]): ParsedCli { continue; } if (inGlobalPrefix && token.startsWith("--timeout-ms=")) { - const parsed = Number.parseInt(requireValue(token.slice("--timeout-ms=".length), "--timeout-ms"), 10); + const parsed = Number.parseInt( + requireValue(token.slice("--timeout-ms=".length), "--timeout-ms"), + 10, + ); if (!Number.isFinite(parsed) || parsed <= 0) { throw new CliUsageError("--timeout-ms must be a positive integer."); } @@ -1818,10 +2095,18 @@ function parseCliArgs(argv: string[]): ParsedCli { } function parseRole(value: string): GlobalOptions["role"] { - if (value === "cto" || value === "orchestrator" || value === "agent" || value === "external" || value === "evaluator") { + if ( + value === "cto" || + value === "orchestrator" || + value === "agent" || + value === "external" || + value === "evaluator" + ) { return value; } - throw new CliUsageError("--role must be one of cto, orchestrator, agent, external, or evaluator."); + throw new CliUsageError( + "--role must be one of cto, orchestrator, agent, external, or evaluator.", + ); } function shellEscapeToken(value: string): string { @@ -1830,7 +2115,11 @@ function shellEscapeToken(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } -function actionCallStep(key: string, name: string, args: JsonObject = {}): InvocationStep { +function actionCallStep( + key: string, + name: string, + args: JsonObject = {}, +): InvocationStep { return { key, method: "ade/actions/call", @@ -1839,15 +2128,30 @@ function actionCallStep(key: string, name: string, args: JsonObject = {}): Invoc }; } -function actionStep(key: string, domain: string, action: string, args: JsonObject = {}): InvocationStep { +function actionStep( + key: string, + domain: string, + action: string, + args: JsonObject = {}, +): InvocationStep { return actionCallStep(key, "run_ade_action", { domain, action, args }); } -function actionArgsListStep(key: string, domain: string, action: string, argsList: unknown[]): InvocationStep { +function actionArgsListStep( + key: string, + domain: string, + action: string, + argsList: unknown[], +): InvocationStep { return actionCallStep(key, "run_ade_action", { domain, action, argsList }); } -function actionScalarStep(key: string, domain: string, action: string, arg: unknown): InvocationStep { +function actionScalarStep( + key: string, + domain: string, + action: string, + arg: unknown, +): InvocationStep { return actionCallStep(key, "run_ade_action", { domain, action, arg }); } @@ -1858,8 +2162,12 @@ function waitRunGraphStep(args: { timelineLimit: number; untilTerminal: boolean; }): InvocationStep | null { - if ((args.waitMs == null || args.waitMs <= 0) && !args.untilTerminal) return null; - const waitMs = Math.min(30 * 60 * 1000, Math.max(0, Math.floor(args.waitMs ?? 30 * 60 * 1000))); + if ((args.waitMs == null || args.waitMs <= 0) && !args.untilTerminal) + return null; + const waitMs = Math.min( + 30 * 60 * 1000, + Math.max(0, Math.floor(args.waitMs ?? 30 * 60 * 1000)), + ); return { key: args.key, method: "ade-cli/wait-run-graph", @@ -1878,7 +2186,10 @@ function listActionsStep(key: string, domain?: string): InvocationStep { function buildActionRunStep(args: string[]): InvocationStep { const target = firstPositional(args); - if (!target) throw new CliUsageError("actions run requires or ."); + if (!target) + throw new CliUsageError( + "actions run requires or .", + ); let domain: string; let action: string; @@ -1894,18 +2205,31 @@ function buildActionRunStep(args: string[]): InvocationStep { const argsListJson = readValue(args, ["--args-list-json", "--params-json"]); if (argsListJson != null) { const argsList = parseJson(argsListJson, "--args-list-json"); - if (!Array.isArray(argsList)) throw new CliUsageError("--args-list-json must be a JSON array."); - return actionCallStep("result", "run_ade_action", { domain, action, argsList }); + if (!Array.isArray(argsList)) + throw new CliUsageError("--args-list-json must be a JSON array."); + return actionCallStep("result", "run_ade_action", { + domain, + action, + argsList, + }); } const scalarJson = readValue(args, ["--scalar-json", "--arg-value-json"]); if (scalarJson != null) { - return actionCallStep("result", "run_ade_action", { domain, action, arg: parseJson(scalarJson, "--scalar-json") }); + return actionCallStep("result", "run_ade_action", { + domain, + action, + arg: parseJson(scalarJson, "--scalar-json"), + }); } const scalar = readValue(args, ["--scalar", "--arg-value"]); if (scalar != null) { - return actionCallStep("result", "run_ade_action", { domain, action, arg: parsePrimitive(scalar) }); + return actionCallStep("result", "run_ade_action", { + domain, + action, + arg: parsePrimitive(scalar), + }); } return actionStep("result", domain, action, collectGenericObjectArgs(args)); @@ -1950,12 +2274,26 @@ function buildWorkerMissionToolPlan(name: string, args: string[]): CliPlan { }); } if (name === "message_worker") { - const toWorkerId = readValue(args, ["--to-worker", "--to-worker-id", "--worker", "--worker-id", "--to"]) - ?? firstPositional(args); - const content = readValue(args, ["--content", "--message", "--body"]) - ?? args.filter((entry) => entry !== "--" && !entry.startsWith("-")).join(" ").trim(); + const toWorkerId = + readValue(args, [ + "--to-worker", + "--to-worker-id", + "--worker", + "--worker-id", + "--to", + ]) ?? firstPositional(args); + const content = + readValue(args, ["--content", "--message", "--body"]) ?? + args + .filter((entry) => entry !== "--" && !entry.startsWith("-")) + .join(" ") + .trim(); return collectGenericObjectArgs(args, { - fromWorkerId: readValue(args, ["--from-worker", "--from-worker-id", "--from"]), + fromWorkerId: readValue(args, [ + "--from-worker", + "--from-worker-id", + "--from", + ]), toWorkerId, content, priority: readValue(args, ["--priority"]) ?? "normal", @@ -1974,10 +2312,18 @@ function buildWorkerMissionToolPlan(name: string, args: string[]): CliPlan { function buildLanePlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; if (sub === "actions") { - return { kind: "execute", label: "lane actions", steps: [listActionsStep("actions", "lane")] }; + return { + kind: "execute", + label: "lane actions", + steps: [listActionsStep("actions", "lane")], + }; } if (sub === "action") { - return { kind: "execute", label: "lane action", steps: [buildActionRunStep(["lane", ...args])] }; + return { + kind: "execute", + label: "lane action", + steps: [buildActionRunStep(["lane", ...args])], + }; } if (sub === "list" || sub === "ls") { const input = collectGenericObjectArgs(args, { @@ -1993,129 +2339,502 @@ function buildLanePlan(args: string[]): CliPlan { }; } if (sub === "show" || sub === "status") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane status", steps: [actionCallStep("result", "get_lane_status", { laneId })] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane status", + steps: [actionCallStep("result", "get_lane_status", { laneId })], + }; } if (sub === "merge") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane merge", steps: [actionCallStep("result", "merge_lane", collectGenericObjectArgs(args, { laneId, message: readValue(args, ["--message", "-m"]), deleteSourceLane: readFlag(args, ["--delete-source-lane", "--delete-source"]) }))] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane merge", + steps: [ + actionCallStep( + "result", + "merge_lane", + collectGenericObjectArgs(args, { + laneId, + message: readValue(args, ["--message", "-m"]), + deleteSourceLane: readFlag(args, [ + "--delete-source-lane", + "--delete-source", + ]), + }), + ), + ], + }; } if (sub === "conflicts") { const mode = firstPositional(args) ?? "check"; - if (mode !== "check") return { kind: "execute", label: `lane conflicts ${mode}`, steps: [actionStep("result", "conflicts", mode, collectGenericObjectArgs(args, { laneId: readLaneId(args) }))] }; + if (mode !== "check") + return { + kind: "execute", + label: `lane conflicts ${mode}`, + steps: [ + actionStep( + "result", + "conflicts", + mode, + collectGenericObjectArgs(args, { laneId: readLaneId(args) }), + ), + ], + }; const ids = args.filter((entry) => !entry.startsWith("-")); - return { kind: "execute", label: "lane conflicts check", steps: [actionCallStep("result", "check_conflicts", collectGenericObjectArgs(args, { laneId: readLaneId(args), ...(ids.length ? { laneIds: ids } : {}), force: readFlag(args, ["--force"]) }))] }; + return { + kind: "execute", + label: "lane conflicts check", + steps: [ + actionCallStep( + "result", + "check_conflicts", + collectGenericObjectArgs(args, { + laneId: readLaneId(args), + ...(ids.length ? { laneIds: ids } : {}), + force: readFlag(args, ["--force"]), + }), + ), + ], + }; } if (sub === "create" || sub === "child") { const name = readValue(args, ["--name"]) ?? firstPositional(args); const input: JsonObject = {}; input.name = requireValue(name, "name"); - maybePut(input, "description", readValue(args, ["--description", "--desc"])); - maybePut(input, "parentLaneId", readValue(args, ["--parent", "--parent-lane", "--parent-lane-id"]) ?? (sub === "child" ? readLaneId(args) : null)); + maybePut( + input, + "description", + readValue(args, ["--description", "--desc"]), + ); + maybePut( + input, + "parentLaneId", + readValue(args, ["--parent", "--parent-lane", "--parent-lane-id"]) ?? + (sub === "child" ? readLaneId(args) : null), + ); maybePut(input, "baseBranch", readValue(args, ["--base", "--base-branch"])); maybePut(input, "branchName", readValue(args, ["--branch-name"])); const linearIssueJson = readValue(args, ["--linear-issue-json"]); if (linearIssueJson) { const parsed = parseJson(linearIssueJson, "--linear-issue-json"); - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new CliUsageError("--linear-issue-json must decode to a non-null JSON object"); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + throw new CliUsageError( + "--linear-issue-json must decode to a non-null JSON object", + ); } input.linearIssue = parsed as JsonObject; } - if (sub === "child" && !input.parentLaneId) throw new CliUsageError("parent lane is required. Use --lane or --parent ."); - return { kind: "execute", label: "lane create", steps: [actionCallStep("result", "create_lane", collectGenericObjectArgs(args, input))] }; + if (sub === "child" && !input.parentLaneId) + throw new CliUsageError( + "parent lane is required. Use --lane or --parent .", + ); + return { + kind: "execute", + label: "lane create", + steps: [ + actionCallStep( + "result", + "create_lane", + collectGenericObjectArgs(args, input), + ), + ], + }; } if (sub === "children") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane children", steps: [actionArgsListStep("result", "lane", "getChildren", [laneId])] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane children", + steps: [actionArgsListStep("result", "lane", "getChildren", [laneId])], + }; } if (sub === "stack") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane stack", steps: [actionArgsListStep("result", "lane", "getStackChain", [laneId])] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane stack", + steps: [actionArgsListStep("result", "lane", "getStackChain", [laneId])], + }; } if (sub === "refresh") { - return { kind: "execute", label: "lane refresh", steps: [actionStep("result", "lane", "refreshSnapshots", collectGenericObjectArgs(args, { includeArchived: readFlag(args, ["--archived", "--include-archived"]) }))] }; + return { + kind: "execute", + label: "lane refresh", + steps: [ + actionStep( + "result", + "lane", + "refreshSnapshots", + collectGenericObjectArgs(args, { + includeArchived: readFlag(args, [ + "--archived", + "--include-archived", + ]), + }), + ), + ], + }; } if (sub === "rename") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane rename", steps: [actionStep("result", "lane", "rename", collectGenericObjectArgs(args, { laneId, name: readValue(args, ["--name"]) ?? firstPositional(args) }))] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane rename", + steps: [ + actionStep( + "result", + "lane", + "rename", + collectGenericObjectArgs(args, { + laneId, + name: readValue(args, ["--name"]) ?? firstPositional(args), + }), + ), + ], + }; } if (sub === "reparent") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane reparent", steps: [actionStep("result", "lane", "reparent", collectGenericObjectArgs(args, { laneId, newParentLaneId: readValue(args, ["--parent", "--parent-lane", "--parent-lane-id"]) ?? firstPositional(args) }))] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane reparent", + steps: [ + actionStep( + "result", + "lane", + "reparent", + collectGenericObjectArgs(args, { + laneId, + newParentLaneId: + readValue(args, [ + "--parent", + "--parent-lane", + "--parent-lane-id", + ]) ?? firstPositional(args), + }), + ), + ], + }; } if (sub === "appearance") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane appearance", steps: [actionStep("result", "lane", "updateAppearance", collectGenericObjectArgs(args, { laneId, color: readValue(args, ["--color"]), icon: readValue(args, ["--icon"]) }))] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane appearance", + steps: [ + actionStep( + "result", + "lane", + "updateAppearance", + collectGenericObjectArgs(args, { + laneId, + color: readValue(args, ["--color"]), + icon: readValue(args, ["--icon"]), + }), + ), + ], + }; } if (sub === "archive" || sub === "unarchive") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: `lane ${sub}`, steps: [actionStep("result", "lane", sub, collectGenericObjectArgs(args, { laneId }))] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: `lane ${sub}`, + steps: [ + actionStep( + "result", + "lane", + sub, + collectGenericObjectArgs(args, { laneId }), + ), + ], + }; } if (sub === "delete" || sub === "rm") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane delete", steps: [actionStep("result", "lane", "delete", collectGenericObjectArgs(args, { laneId, force: readFlag(args, ["--force"]), deleteBranch: readFlag(args, ["--delete-branch"]), deleteRemoteBranch: readFlag(args, ["--delete-remote-branch"]) }))] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane delete", + steps: [ + actionStep( + "result", + "lane", + "delete", + collectGenericObjectArgs(args, { + laneId, + force: readFlag(args, ["--force"]), + deleteBranch: readFlag(args, ["--delete-branch"]), + deleteRemoteBranch: readFlag(args, ["--delete-remote-branch"]), + }), + ), + ], + }; } if (sub === "attach") { - return { kind: "execute", label: "lane attach", steps: [actionStep("result", "lane", "attach", collectGenericObjectArgs(args, { worktreePath: readValue(args, ["--path"]) ?? firstPositional(args), name: readValue(args, ["--name"]) }))] }; + return { + kind: "execute", + label: "lane attach", + steps: [ + actionStep( + "result", + "lane", + "attach", + collectGenericObjectArgs(args, { + worktreePath: readValue(args, ["--path"]) ?? firstPositional(args), + name: readValue(args, ["--name"]), + }), + ), + ], + }; } if (sub === "adopt-attached") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane adopt attached", steps: [actionStep("result", "lane", "adoptAttached", collectGenericObjectArgs(args, { laneId }))] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane adopt attached", + steps: [ + actionStep( + "result", + "lane", + "adoptAttached", + collectGenericObjectArgs(args, { laneId }), + ), + ], + }; } if (sub === "split-unstaged") { - return { kind: "execute", label: "lane split unstaged", steps: [actionStep("result", "lane", "createFromUnstaged", collectGenericObjectArgs(args, { sourceLaneId: readValue(args, ["--source", "--source-lane"]) ?? readLaneId(args), name: readValue(args, ["--name"]) ?? firstPositional(args) }))] }; + return { + kind: "execute", + label: "lane split unstaged", + steps: [ + actionStep( + "result", + "lane", + "createFromUnstaged", + collectGenericObjectArgs(args, { + sourceLaneId: + readValue(args, ["--source", "--source-lane"]) ?? + readLaneId(args), + name: readValue(args, ["--name"]) ?? firstPositional(args), + }), + ), + ], + }; } if (sub === "import" || sub === "import-branch") { const input: JsonObject = {}; - input.branchRef = requireValue(readValue(args, ["--branch", "--branch-ref"]) ?? firstPositional(args), "branchRef"); + input.branchRef = requireValue( + readValue(args, ["--branch", "--branch-ref"]) ?? firstPositional(args), + "branchRef", + ); maybePut(input, "name", readValue(args, ["--name"])); - maybePut(input, "description", readValue(args, ["--description", "--desc"])); + maybePut( + input, + "description", + readValue(args, ["--description", "--desc"]), + ); maybePut(input, "baseBranch", readValue(args, ["--base", "--base-branch"])); - return { kind: "execute", label: "lane import", steps: [actionCallStep("result", "import_lane", collectGenericObjectArgs(args, input))] }; + return { + kind: "execute", + label: "lane import", + steps: [ + actionCallStep( + "result", + "import_lane", + collectGenericObjectArgs(args, input), + ), + ], + }; } if (sub === "unregistered" || sub === "list-unregistered") { - return { kind: "execute", label: "unregistered lanes", steps: [actionCallStep("result", "list_unregistered_lanes", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "unregistered lanes", + steps: [ + actionCallStep( + "result", + "list_unregistered_lanes", + collectGenericObjectArgs(args), + ), + ], + }; } - return { kind: "execute", label: `lane ${sub}`, steps: [actionStep("result", "lane", sub, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `lane ${sub}`, + steps: [actionStep("result", "lane", sub, collectGenericObjectArgs(args))], + }; } function buildGitPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "status"; if (sub === "actions") { - return { kind: "execute", label: "git actions", steps: [listActionsStep("actions", "git")] }; + return { + kind: "execute", + label: "git actions", + steps: [listActionsStep("actions", "git")], + }; } if (sub === "action") { - return { kind: "execute", label: "git action", steps: [buildActionRunStep(["git", ...args])] }; + return { + kind: "execute", + label: "git action", + steps: [buildActionRunStep(["git", ...args])], + }; } const laneId = readLaneId(args); - const withLane = (base: JsonObject = {}) => collectGenericObjectArgs(args, { ...base, ...(laneId ? { laneId } : {}) }); - - if (sub === "status" || sub === "sync-status") return { kind: "execute", label: "git status", steps: [actionCallStep("result", "git_get_sync_status", withLane())] }; - if (sub === "fetch") return { kind: "execute", label: "git fetch", steps: [actionCallStep("result", "git_fetch", withLane())] }; - if (sub === "pull") return { kind: "execute", label: "git pull", steps: [actionCallStep("result", "git_pull", withLane())] }; + const withLane = (base: JsonObject = {}) => + collectGenericObjectArgs(args, { ...base, ...(laneId ? { laneId } : {}) }); + + if (sub === "status" || sub === "sync-status") { + const full = + readFlag(args, ["--full"]) || peekFirstPositional(args) === "full"; + if (full && peekFirstPositional(args) === "full") firstPositional(args); + if (full) + return { + kind: "execute", + label: "lane status", + steps: [actionCallStep("result", "get_lane_status", withLane())], + }; + return { + kind: "execute", + label: "git status", + steps: [actionCallStep("result", "git_get_sync_status", withLane())], + }; + } + if (sub === "fetch") + return { + kind: "execute", + label: "git fetch", + steps: [actionCallStep("result", "git_fetch", withLane())], + }; + if (sub === "pull") + return { + kind: "execute", + label: "git pull", + steps: [actionCallStep("result", "git_pull", withLane())], + }; + if (sub === "sync") { + const explicitMode = readValue(args, ["--mode"]); + const mode = readFlag(args, ["--rebase"]) + ? "rebase" + : readFlag(args, ["--merge"]) + ? "merge" + : explicitMode; + if (mode && mode !== "merge" && mode !== "rebase") { + throw new CliUsageError("--mode must be either merge or rebase."); + } + const baseRef = readValue(args, ["--base", "--base-ref"]); + return { + kind: "execute", + label: "git sync", + steps: [ + actionStep( + "result", + "git", + "sync", + withLane({ + ...(mode ? { mode } : {}), + ...(baseRef ? { baseRef } : {}), + }), + ), + ], + }; + } if (sub === "push") { const forceWithLease = readFlag(args, ["--force", "--force-with-lease"]); const setUpstream = readFlag(args, ["--set-upstream", "-u"]); - return { kind: "execute", label: "git push", steps: [actionCallStep("result", "git_push", withLane({ forceWithLease, setUpstream }))] }; + return { + kind: "execute", + label: "git push", + steps: [ + actionCallStep( + "result", + "git_push", + withLane({ forceWithLease, setUpstream }), + ), + ], + }; } if (sub === "commit") { const input: JsonObject = {}; maybePut(input, "message", readValue(args, ["--message", "-m"])); maybePut(input, "amend", readFlag(args, ["--amend"])); input.stageAll = !readFlag(args, ["--no-stage-all"]); - return { kind: "execute", label: "git commit", steps: [actionCallStep("result", "commit_changes", withLane(input))] }; + return { + kind: "execute", + label: "git commit", + steps: [actionCallStep("result", "commit_changes", withLane(input))], + }; } if (sub === "generate-message") { - return { kind: "execute", label: "git commit message", steps: [actionCallStep("result", "generate_commit_message", withLane({ amend: readFlag(args, ["--amend"]) }))] }; + return { + kind: "execute", + label: "git commit message", + steps: [ + actionCallStep( + "result", + "generate_commit_message", + withLane({ amend: readFlag(args, ["--amend"]) }), + ), + ], + }; } - if (sub === "branches" || sub === "branch") return { kind: "execute", label: "git branches", steps: [actionCallStep("result", "git_list_branches", withLane())] }; + if (sub === "branches" || sub === "branch") + return { + kind: "execute", + label: "git branches", + steps: [actionCallStep("result", "git_list_branches", withLane())], + }; if (sub === "user-identity" || sub === "user" || sub === "identity") { - return { kind: "execute", label: "git user identity", steps: [actionCallStep("result", "git_get_user_identity", withLane())] }; + return { + kind: "execute", + label: "git user identity", + steps: [actionCallStep("result", "git_get_user_identity", withLane())], + }; } if (sub === "checkout") { - const branchName = requireValue(readValue(args, ["--branch", "--branch-name"]) ?? firstPositional(args), "branchName"); + const branchName = requireValue( + readValue(args, ["--branch", "--branch-name"]) ?? firstPositional(args), + "branchName", + ); const create = readFlag(args, ["--create", "-b"]); const startPoint = readValue(args, ["--start-point", "--from"]); const baseRef = readValue(args, ["--base", "--base-ref"]); @@ -2123,30 +2842,131 @@ function buildGitPlan(args: string[]): CliPlan { return { kind: "execute", label: "git checkout", - steps: [actionCallStep("result", "git_checkout_branch", withLane({ - branchName, - mode: create ? "create" : "existing", - ...(startPoint ? { startPoint } : {}), - ...(baseRef ? { baseRef } : {}), - acknowledgeActiveWork, - }))] + steps: [ + actionCallStep( + "result", + "git_checkout_branch", + withLane({ + branchName, + mode: create ? "create" : "existing", + ...(startPoint ? { startPoint } : {}), + ...(baseRef ? { baseRef } : {}), + acknowledgeActiveWork, + }), + ), + ], }; } - if (sub === "conflicts") return { kind: "execute", label: "git conflicts", steps: [actionCallStep("result", "get_lane_conflict_state", withLane())] }; + if (sub === "conflict" || sub === "conflicts") { + const action = firstPositional(args) ?? "show"; + if (action === "show" || action === "status") { + return { + kind: "execute", + label: "git conflicts", + steps: [ + actionCallStep("result", "get_lane_conflict_state", withLane()), + ], + }; + } + if (action === "resolve" || action === "continue") { + const kind = + readValue(args, ["--kind"]) ?? + (readFlag(args, ["--merge"]) + ? "merge" + : readFlag(args, ["--rebase"]) + ? "rebase" + : null); + if (kind === "rebase") + return { + kind: "execute", + label: "rebase continue", + steps: [actionCallStep("result", "rebase_continue", withLane())], + }; + if (kind === "merge") + return { + kind: "execute", + label: "merge continue", + steps: [actionStep("result", "git", "mergeContinue", withLane())], + }; + throw new CliUsageError( + "git conflict resolve requires --kind rebase or --kind merge.", + ); + } + if (action === "abort") { + const kind = + readValue(args, ["--kind"]) ?? + (readFlag(args, ["--merge"]) + ? "merge" + : readFlag(args, ["--rebase"]) + ? "rebase" + : null); + if (kind === "rebase") + return { + kind: "execute", + label: "rebase abort", + steps: [actionCallStep("result", "rebase_abort", withLane())], + }; + if (kind === "merge") + return { + kind: "execute", + label: "merge abort", + steps: [actionStep("result", "git", "mergeAbort", withLane())], + }; + throw new CliUsageError( + "git conflict abort requires --kind rebase or --kind merge.", + ); + } + throw new CliUsageError( + "git conflict supports show, resolve, continue, or abort.", + ); + } if (sub === "rebase") { const mode = firstPositional(args); - if (mode === "continue") return { kind: "execute", label: "rebase continue", steps: [actionCallStep("result", "rebase_continue", withLane())] }; - if (mode === "abort") return { kind: "execute", label: "rebase abort", steps: [actionCallStep("result", "rebase_abort", withLane())] }; - return { kind: "execute", label: "rebase lane", steps: [actionCallStep("result", "rebase_lane", withLane({ aiAssisted: readFlag(args, ["--ai", "--ai-assisted"]) }))] }; - } - if (sub === "merge") { - const mode = requireValue(firstPositional(args), "merge action"); - if (mode !== "continue" && mode !== "abort") throw new CliUsageError("git merge supports continue or abort."); - return { kind: "execute", label: `merge ${mode}`, steps: [actionStep("result", "git", mode === "continue" ? "mergeContinue" : "mergeAbort", withLane())] }; - } - if (sub === "stash") { + if (mode === "continue") + return { + kind: "execute", + label: "rebase continue", + steps: [actionCallStep("result", "rebase_continue", withLane())], + }; + if (mode === "abort") + return { + kind: "execute", + label: "rebase abort", + steps: [actionCallStep("result", "rebase_abort", withLane())], + }; + return { + kind: "execute", + label: "rebase lane", + steps: [ + actionCallStep( + "result", + "rebase_lane", + withLane({ aiAssisted: readFlag(args, ["--ai", "--ai-assisted"]) }), + ), + ], + }; + } + if (sub === "merge") { + const mode = requireValue(firstPositional(args), "merge action"); + if (mode !== "continue" && mode !== "abort") + throw new CliUsageError("git merge supports continue or abort."); + return { + kind: "execute", + label: `merge ${mode}`, + steps: [ + actionStep( + "result", + "git", + mode === "continue" ? "mergeContinue" : "mergeAbort", + withLane(), + ), + ], + }; + } + if (sub === "stash") { const action = firstPositional(args) ?? "list"; - const stashRef = readValue(args, ["--ref", "--stash-ref"]) ?? firstPositional(args); + const stashRef = + readValue(args, ["--ref", "--stash-ref"]) ?? firstPositional(args); const message = readValue(args, ["--message", "-m"]); const common = withLane({ ...(stashRef ? { stashRef } : {}), @@ -2165,58 +2985,158 @@ function buildGitPlan(args: string[]): CliPlan { }; const toolName = toolNameByAction[action]; if (!toolName) throw new CliUsageError(`Unknown stash action '${action}'.`); - return { kind: "execute", label: `git stash ${action}`, steps: [actionCallStep("result", toolName, common)] }; + return { + kind: "execute", + label: `git stash ${action}`, + steps: [actionCallStep("result", toolName, common)], + }; } if (sub === "diff") { return buildDiffPlan([...(laneId ? ["--lane", laneId] : []), ...args]); } - if (sub === "stage" || sub === "unstage" || sub === "discard" || sub === "restore") { - const pathArg = requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path"); + if ( + sub === "stage" || + sub === "unstage" || + sub === "discard" || + sub === "restore" + ) { + const pathArg = requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ); const actionBySub: Record = { stage: "stageFile", unstage: "unstageFile", discard: "discardFile", restore: "restoreStagedFile", }; - return { kind: "execute", label: `git ${sub}`, steps: [actionStep("result", "git", actionBySub[sub]!, withLane({ path: pathArg }))] }; + return { + kind: "execute", + label: `git ${sub}`, + steps: [ + actionStep( + "result", + "git", + actionBySub[sub]!, + withLane({ path: pathArg }), + ), + ], + }; } if (sub === "stage-all" || sub === "unstage-all") { const paths = args.filter((entry) => !entry.startsWith("-")); const action = sub === "stage-all" ? "stageAll" : "unstageAll"; - return { kind: "execute", label: `git ${sub}`, steps: [actionStep("result", "git", action, withLane({ paths }))] }; + return { + kind: "execute", + label: `git ${sub}`, + steps: [actionStep("result", "git", action, withLane({ paths }))], + }; } if (sub === "files" || sub === "commit-files") { - const commitSha = requireValue(readValue(args, ["--commit", "--sha"]) ?? firstPositional(args), "commitSha"); - return { kind: "execute", label: "git commit files", steps: [actionStep("result", "git", "listCommitFiles", withLane({ commitSha }))] }; + const commitSha = requireValue( + readValue(args, ["--commit", "--sha"]) ?? firstPositional(args), + "commitSha", + ); + return { + kind: "execute", + label: "git commit files", + steps: [ + actionStep("result", "git", "listCommitFiles", withLane({ commitSha })), + ], + }; } if (sub === "message" || sub === "commit-message" || sub === "show-message") { - const commitSha = readValue(args, ["--commit", "--sha"]) ?? firstPositional(args); - if (commitSha) return { kind: "execute", label: "git commit message", steps: [actionStep("result", "git", "getCommitMessage", withLane({ commitSha }))] }; - return { kind: "execute", label: "git commit message", steps: [actionCallStep("result", "generate_commit_message", withLane({ amend: readFlag(args, ["--amend"]) }))] }; + const commitSha = + readValue(args, ["--commit", "--sha"]) ?? firstPositional(args); + if (commitSha) + return { + kind: "execute", + label: "git commit message", + steps: [ + actionStep( + "result", + "git", + "getCommitMessage", + withLane({ commitSha }), + ), + ], + }; + return { + kind: "execute", + label: "git commit message", + steps: [ + actionCallStep( + "result", + "generate_commit_message", + withLane({ amend: readFlag(args, ["--amend"]) }), + ), + ], + }; } if (sub === "history" || sub === "file-history") { - const filePath = requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path"); - return { kind: "execute", label: "git file history", steps: [actionStep("result", "git", "getFileHistory", withLane({ path: filePath, limit: readIntOption(args, ["--limit"]) }))] }; + const filePath = requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ); + return { + kind: "execute", + label: "git file history", + steps: [ + actionStep( + "result", + "git", + "getFileHistory", + withLane({ path: filePath, limit: readIntOption(args, ["--limit"]) }), + ), + ], + }; } if (sub === "revert" || sub === "cherry-pick") { - const commitSha = requireValue(readValue(args, ["--commit", "--sha"]) ?? firstPositional(args), "commitSha"); - return { kind: "execute", label: `git ${sub}`, steps: [actionStep("result", "git", sub === "revert" ? "revertCommit" : "cherryPickCommit", withLane({ commitSha }))] }; + const commitSha = requireValue( + readValue(args, ["--commit", "--sha"]) ?? firstPositional(args), + "commitSha", + ); + return { + kind: "execute", + label: `git ${sub}`, + steps: [ + actionStep( + "result", + "git", + sub === "revert" ? "revertCommit" : "cherryPickCommit", + withLane({ commitSha }), + ), + ], + }; } const actionAliases: Record = { commits: "listRecentCommits", sync: "sync", }; - return { kind: "execute", label: `git ${sub}`, steps: [actionStep("result", "git", actionAliases[sub] ?? sub, withLane())] }; + return { + kind: "execute", + label: `git ${sub}`, + steps: [actionStep("result", "git", actionAliases[sub] ?? sub, withLane())], + }; } function buildDiffPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "changes"; - if (sub === "actions") return { kind: "execute", label: "diff actions", steps: [listActionsStep("actions", "diff")] }; + if (sub === "actions") + return { + kind: "execute", + label: "diff actions", + steps: [listActionsStep("actions", "diff")], + }; const laneId = readLaneId(args); - const withLane = (base: JsonObject = {}) => collectGenericObjectArgs(args, { ...base, ...(laneId ? { laneId } : {}) }); + const withLane = (base: JsonObject = {}) => + collectGenericObjectArgs(args, { ...base, ...(laneId ? { laneId } : {}) }); if (sub === "changes" || sub === "summary") { - const id = requireValue(laneId ?? readValue(args, ["--lane", "--lane-id"]), "laneId"); + const id = requireValue( + laneId ?? readValue(args, ["--lane", "--lane-id"]), + "laneId", + ); return { kind: "execute", label: "diff changes", @@ -2224,51 +3144,113 @@ function buildDiffPlan(args: string[]): CliPlan { }; } if (sub === "file") { - const filePath = requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path"); + const filePath = requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ); return { kind: "execute", label: "diff file", - steps: [actionStep("result", "diff", "getFileDiff", withLane({ - filePath, - mode: readValue(args, ["--mode"]) ?? "unstaged", - compareRef: readValue(args, ["--compare-ref", "--base"]), - compareTo: readValue(args, ["--compare-to", "--head"]), - }))], + steps: [ + actionStep( + "result", + "diff", + "getFileDiff", + withLane({ + filePath, + mode: readValue(args, ["--mode"]) ?? "unstaged", + compareRef: readValue(args, ["--compare-ref", "--base"]), + compareTo: readValue(args, ["--compare-to", "--head"]), + }), + ), + ], }; } if (sub === "patch") { - const filePath = requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path"); + const filePath = requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ); return { kind: "execute", label: "diff patch", - steps: [actionStep("result", "diff", "getFilePatch", withLane({ - filePath, - mode: readValue(args, ["--mode"]) ?? "unstaged", - compareRef: readValue(args, ["--compare-ref", "--base"]), - compareTo: readValue(args, ["--compare-to", "--head"]), - }))], + steps: [ + actionStep( + "result", + "diff", + "getFilePatch", + withLane({ + filePath, + mode: readValue(args, ["--mode"]) ?? "unstaged", + compareRef: readValue(args, ["--compare-ref", "--base"]), + compareTo: readValue(args, ["--compare-to", "--head"]), + }), + ), + ], }; } - return { kind: "execute", label: `diff ${sub}`, steps: [actionStep("result", "diff", sub, withLane())] }; + return { + kind: "execute", + label: `diff ${sub}`, + steps: [actionStep("result", "diff", sub, withLane())], + }; } function buildPrPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; - if (sub === "actions") return { kind: "execute", label: "PR actions", steps: [listActionsStep("actions", "pr")] }; - if (sub === "action") return { kind: "execute", label: "PR action", steps: [buildActionRunStep(["pr", ...args])] }; + if (sub === "actions") + return { + kind: "execute", + label: "PR actions", + steps: [listActionsStep("actions", "pr")], + }; + if (sub === "action") + return { + kind: "execute", + label: "PR action", + steps: [buildActionRunStep(["pr", ...args])], + }; const prId = readPrId(args); - const withPr = (base: JsonObject = {}) => collectGenericObjectArgs(args, { ...base, ...(prId ? { prId } : {}) }); + const withPr = (base: JsonObject = {}) => + collectGenericObjectArgs(args, { ...base, ...(prId ? { prId } : {}) }); - if (sub === "list" || sub === "ls") return { kind: "execute", label: "PR list", steps: [actionStep("result", "pr", "listAll", collectGenericObjectArgs(args))] }; + if (sub === "list" || sub === "ls") + return { + kind: "execute", + label: "PR list", + steps: [ + actionStep("result", "pr", "listAll", collectGenericObjectArgs(args)), + ], + }; if (sub === "list-open" || sub === "open" || sub === "list-repo-open") { - return { kind: "execute", label: "PR list open", steps: [actionCallStep("result", "prs_list_open", {})] }; + return { + kind: "execute", + label: "PR list open", + steps: [actionCallStep("result", "prs_list_open", {})], + }; } if (sub === "show" || sub === "detail" || sub === "view") { const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: "PR detail", steps: [actionArgsListStep("result", "pr", "getDetail", [id])] }; + return { + kind: "execute", + label: "PR detail", + steps: [actionArgsListStep("result", "pr", "getDetail", [id])], + }; } - if (sub === "refresh") return { kind: "execute", label: "PR refresh", steps: [actionStep("result", "pr", "refresh", withPr({ prId: prId ?? firstPositional(args) }))] }; + if (sub === "refresh") + return { + kind: "execute", + label: "PR refresh", + steps: [ + actionStep( + "result", + "pr", + "refresh", + withPr({ prId: prId ?? firstPositional(args) }), + ), + ], + }; if (sub === "create") { const laneId = readLaneId(args) ?? readValue(args, ["--lane-id"]); const input: JsonObject = {}; @@ -2282,30 +3264,163 @@ function buildPrPlan(args: string[]): CliPlan { "--close-linear", "--fixes-linear-issue", ]); - return { kind: "execute", label: "PR create", steps: [actionCallStep("result", "create_pr_from_lane", collectGenericObjectArgs(args, input))] }; - } - if (sub === "health") return { kind: "execute", label: "PR health", steps: [actionCallStep("result", "get_pr_health", withPr({ prId: prId ?? firstPositional(args) }))] }; - if (sub === "checks") return { kind: "execute", label: "PR checks", steps: [actionCallStep("result", "pr_get_checks", withPr({ prId: requireValue(prId ?? firstPositional(args), "prId") }))] }; - if (sub === "comments" || sub === "review-comments") return { kind: "execute", label: "PR comments", steps: [actionCallStep("result", "pr_get_review_comments", withPr({ prId: requireValue(prId ?? firstPositional(args), "prId") }))] }; - if (sub === "rerun" || sub === "rerun-failed-checks") return { kind: "execute", label: "PR rerun failed checks", steps: [actionCallStep("result", "pr_rerun_failed_checks", withPr({ prId: prId ?? firstPositional(args) }))] }; - if (sub === "comment") return { kind: "execute", label: "PR comment", steps: [actionCallStep("result", "pr_add_comment", withPr({ prId: prId ?? firstPositional(args), body: readValue(args, ["--body"]) }))] }; - if (sub === "reply") return { kind: "execute", label: "PR thread reply", steps: [actionCallStep("result", "pr_reply_to_review_thread", withPr({ prId: prId ?? firstPositional(args), threadId: readValue(args, ["--thread", "--thread-id"]), body: readValue(args, ["--body"]) }))] }; - if (sub === "resolve-thread") return { kind: "execute", label: "PR resolve thread", steps: [actionCallStep("result", "pr_resolve_review_thread", withPr({ prId: requireValue(prId ?? firstPositional(args), "prId"), threadId: requireValue(readValue(args, ["--thread", "--thread-id"]), "threadId") }))] }; - if (sub === "title" || sub === "update-title") return { kind: "execute", label: "PR update title", steps: [actionCallStep("result", "pr_update_title", withPr({ prId: prId ?? firstPositional(args), title: readValue(args, ["--title"]) }))] }; - if (sub === "body" || sub === "update-body") return { kind: "execute", label: "PR update body", steps: [actionCallStep("result", "pr_update_body", withPr({ prId: prId ?? firstPositional(args), body: readValue(args, ["--body"]) ?? "" }))] }; + return { + kind: "execute", + label: "PR create", + steps: [ + actionCallStep( + "result", + "create_pr_from_lane", + collectGenericObjectArgs(args, input), + ), + ], + }; + } + if (sub === "health") + return { + kind: "execute", + label: "PR health", + steps: [ + actionCallStep( + "result", + "get_pr_health", + withPr({ prId: prId ?? firstPositional(args) }), + ), + ], + }; + if (sub === "checks") + return { + kind: "execute", + label: "PR checks", + steps: [ + actionCallStep( + "result", + "pr_get_checks", + withPr({ prId: requireValue(prId ?? firstPositional(args), "prId") }), + ), + ], + }; + if (sub === "comments" || sub === "review-comments") + return { + kind: "execute", + label: "PR comments", + steps: [ + actionCallStep( + "result", + "pr_get_review_comments", + withPr({ prId: requireValue(prId ?? firstPositional(args), "prId") }), + ), + ], + }; + if (sub === "rerun" || sub === "rerun-failed-checks") + return { + kind: "execute", + label: "PR rerun failed checks", + steps: [ + actionCallStep( + "result", + "pr_rerun_failed_checks", + withPr({ prId: prId ?? firstPositional(args) }), + ), + ], + }; + if (sub === "comment") + return { + kind: "execute", + label: "PR comment", + steps: [ + actionCallStep( + "result", + "pr_add_comment", + withPr({ + prId: prId ?? firstPositional(args), + body: readValue(args, ["--body"]), + }), + ), + ], + }; + if (sub === "reply") + return { + kind: "execute", + label: "PR thread reply", + steps: [ + actionCallStep( + "result", + "pr_reply_to_review_thread", + withPr({ + prId: prId ?? firstPositional(args), + threadId: readValue(args, ["--thread", "--thread-id"]), + body: readValue(args, ["--body"]), + }), + ), + ], + }; + if (sub === "resolve-thread") + return { + kind: "execute", + label: "PR resolve thread", + steps: [ + actionCallStep( + "result", + "pr_resolve_review_thread", + withPr({ + prId: requireValue(prId ?? firstPositional(args), "prId"), + threadId: requireValue( + readValue(args, ["--thread", "--thread-id"]), + "threadId", + ), + }), + ), + ], + }; + if (sub === "title" || sub === "update-title") + return { + kind: "execute", + label: "PR update title", + steps: [ + actionCallStep( + "result", + "pr_update_title", + withPr({ + prId: prId ?? firstPositional(args), + title: readValue(args, ["--title"]), + }), + ), + ], + }; + if (sub === "body" || sub === "update-body") + return { + kind: "execute", + label: "PR update body", + steps: [ + actionCallStep( + "result", + "pr_update_body", + withPr({ + prId: prId ?? firstPositional(args), + body: readValue(args, ["--body"]) ?? "", + }), + ), + ], + }; if (sub === "link") { const laneId = readLaneId(args) ?? firstPositional(args); const prUrlOrNumber = - readValue(args, ["--url", "--pr-url", "--number", "--pr-number"]) - ?? firstPositional(args); + readValue(args, ["--url", "--pr-url", "--number", "--pr-number"]) ?? + firstPositional(args); return { kind: "execute", label: "PR link", steps: [ - actionStep("result", "pr", "linkToLane", collectGenericObjectArgs(args, { - laneId: requireValue(laneId, "laneId"), - prUrlOrNumber: requireValue(prUrlOrNumber, "prUrlOrNumber"), - })), + actionStep( + "result", + "pr", + "linkToLane", + collectGenericObjectArgs(args, { + laneId: requireValue(laneId, "laneId"), + prUrlOrNumber: requireValue(prUrlOrNumber, "prUrlOrNumber"), + }), + ), ], }; } @@ -2324,69 +3439,290 @@ function buildPrPlan(args: string[]): CliPlan { }; if (scalarPrActions[sub]) { const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: `PR ${sub}`, steps: [actionArgsListStep("result", "pr", scalarPrActions[sub]!, [id])] }; + return { + kind: "execute", + label: `PR ${sub}`, + steps: [actionArgsListStep("result", "pr", scalarPrActions[sub]!, [id])], + }; } - if (sub === "draft-description") return { kind: "execute", label: "PR draft description", steps: [actionStep("result", "pr", "draftDescription", collectGenericObjectArgs(args, { laneId: readLaneId(args) ?? firstPositional(args) }))] }; - if (sub === "update-description") return { kind: "execute", label: "PR update description", steps: [actionStep("result", "pr", "updateDescription", withPr({ prId: prId ?? firstPositional(args), title: readValue(args, ["--title"]), body: readValue(args, ["--body"]) }))] }; - if (sub === "delete" || sub === "land" || sub === "close" || sub === "reopen") { + if (sub === "draft-description") + return { + kind: "execute", + label: "PR draft description", + steps: [ + actionStep( + "result", + "pr", + "draftDescription", + collectGenericObjectArgs(args, { + laneId: readLaneId(args) ?? firstPositional(args), + }), + ), + ], + }; + if (sub === "update-description") + return { + kind: "execute", + label: "PR update description", + steps: [ + actionStep( + "result", + "pr", + "updateDescription", + withPr({ + prId: prId ?? firstPositional(args), + title: readValue(args, ["--title"]), + body: readValue(args, ["--body"]), + }), + ), + ], + }; + if ( + sub === "delete" || + sub === "land" || + sub === "close" || + sub === "reopen" + ) { const id = requireValue(prId ?? firstPositional(args), "prId"); - const actionBySub: Record = { delete: "delete", land: "land", close: "closePr", reopen: "reopenPr" }; - return { kind: "execute", label: `PR ${sub}`, steps: [actionStep("result", "pr", actionBySub[sub]!, collectGenericObjectArgs(args, { prId: id, method: readValue(args, ["--method"]) }))] }; + const actionBySub: Record = { + delete: "delete", + land: "land", + close: "closePr", + reopen: "reopenPr", + }; + return { + kind: "execute", + label: `PR ${sub}`, + steps: [ + actionStep( + "result", + "pr", + actionBySub[sub]!, + collectGenericObjectArgs(args, { + prId: id, + method: readValue(args, ["--method"]), + }), + ), + ], + }; } if (sub === "land-stack" || sub === "land-stack-enhanced") { - return { kind: "execute", label: `PR ${sub}`, steps: [actionStep("result", "pr", sub === "land-stack" ? "landStack" : "landStackEnhanced", collectGenericObjectArgs(args, { rootLaneId: readValue(args, ["--root", "--root-lane"]) ?? firstPositional(args) }))] }; + return { + kind: "execute", + label: `PR ${sub}`, + steps: [ + actionStep( + "result", + "pr", + sub === "land-stack" ? "landStack" : "landStackEnhanced", + collectGenericObjectArgs(args, { + rootLaneId: + readValue(args, ["--root", "--root-lane"]) ?? + firstPositional(args), + }), + ), + ], + }; } if (sub === "labels") { const mode = firstPositional(args) ?? "set"; if (mode !== "set") throw new CliUsageError("prs labels supports set."); const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: "PR labels set", steps: [actionStep("result", "pr", "setLabels", collectGenericObjectArgs(args, { prId: id, labels: args.filter((entry) => !entry.startsWith("-")) }))] }; + return { + kind: "execute", + label: "PR labels set", + steps: [ + actionStep( + "result", + "pr", + "setLabels", + collectGenericObjectArgs(args, { + prId: id, + labels: args.filter((entry) => !entry.startsWith("-")), + }), + ), + ], + }; } if (sub === "reviewers") { const mode = firstPositional(args) ?? "request"; - if (mode !== "request") throw new CliUsageError("prs reviewers supports request."); + if (mode !== "request") + throw new CliUsageError("prs reviewers supports request."); const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: "PR reviewers request", steps: [actionStep("result", "pr", "requestReviewers", collectGenericObjectArgs(args, { prId: id, reviewers: args.filter((entry) => !entry.startsWith("-")) }))] }; + return { + kind: "execute", + label: "PR reviewers request", + steps: [ + actionStep( + "result", + "pr", + "requestReviewers", + collectGenericObjectArgs(args, { + prId: id, + reviewers: args.filter((entry) => !entry.startsWith("-")), + }), + ), + ], + }; } if (sub === "review") { const mode = firstPositional(args) ?? "submit"; - if (mode !== "submit") throw new CliUsageError("prs review supports submit."); + if (mode !== "submit") + throw new CliUsageError("prs review supports submit."); const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: "PR review submit", steps: [actionStep("result", "pr", "submitReview", collectGenericObjectArgs(args, { prId: id, event: readValue(args, ["--event"]) ?? "comment", body: readValue(args, ["--body"]) ?? "" }))] }; + return { + kind: "execute", + label: "PR review submit", + steps: [ + actionStep( + "result", + "pr", + "submitReview", + collectGenericObjectArgs(args, { + prId: id, + event: readValue(args, ["--event"]) ?? "comment", + body: readValue(args, ["--body"]) ?? "", + }), + ), + ], + }; } if (sub === "comment-react") { const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: "PR comment react", steps: [actionStep("result", "pr", "reactToComment", collectGenericObjectArgs(args, { prId: id, commentId: readValue(args, ["--comment", "--comment-id"]), content: readValue(args, ["--content"]) }))] }; + return { + kind: "execute", + label: "PR comment react", + steps: [ + actionStep( + "result", + "pr", + "reactToComment", + collectGenericObjectArgs(args, { + prId: id, + commentId: readValue(args, ["--comment", "--comment-id"]), + content: readValue(args, ["--content"]), + }), + ), + ], + }; } if (sub === "review-comment") { const mode = firstPositional(args) ?? "post"; - if (mode !== "post") throw new CliUsageError("prs review-comment supports post."); + if (mode !== "post") + throw new CliUsageError("prs review-comment supports post."); const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: "PR review comment post", steps: [actionStep("result", "pr", "postReviewComment", collectGenericObjectArgs(args, { prId: id, threadId: readValue(args, ["--thread", "--thread-id"]), body: readValue(args, ["--body"]) }))] }; + return { + kind: "execute", + label: "PR review comment post", + steps: [ + actionStep( + "result", + "pr", + "postReviewComment", + collectGenericObjectArgs(args, { + prId: id, + threadId: readValue(args, ["--thread", "--thread-id"]), + body: readValue(args, ["--body"]), + }), + ), + ], + }; } if (sub === "thread") { const mode = firstPositional(args) ?? "set-resolved"; - if (mode !== "set-resolved") throw new CliUsageError("prs thread supports set-resolved."); + if (mode !== "set-resolved") + throw new CliUsageError("prs thread supports set-resolved."); const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: "PR thread set resolved", steps: [actionStep("result", "pr", "setReviewThreadResolved", collectGenericObjectArgs(args, { prId: id, threadId: readValue(args, ["--thread", "--thread-id"]), resolved: !readFlag(args, ["--unresolved"]) }))] }; + return { + kind: "execute", + label: "PR thread set resolved", + steps: [ + actionStep( + "result", + "pr", + "setReviewThreadResolved", + collectGenericObjectArgs(args, { + prId: id, + threadId: readValue(args, ["--thread", "--thread-id"]), + resolved: !readFlag(args, ["--unresolved"]), + }), + ), + ], + }; } - if (sub === "ai-review-summary") return { kind: "execute", label: "PR AI review summary", steps: [actionStep("result", "pr", "aiReviewSummary", withPr({ prId: prId ?? firstPositional(args) }))] }; - if (sub === "mobile-snapshot") return { kind: "execute", label: "PR mobile snapshot", steps: [actionArgsListStep("result", "pr", "getMobileSnapshot", [])] }; + if (sub === "ai-review-summary") + return { + kind: "execute", + label: "PR AI review summary", + steps: [ + actionStep( + "result", + "pr", + "aiReviewSummary", + withPr({ prId: prId ?? firstPositional(args) }), + ), + ], + }; + if (sub === "mobile-snapshot") + return { + kind: "execute", + label: "PR mobile snapshot", + steps: [actionArgsListStep("result", "pr", "getMobileSnapshot", [])], + }; if (sub === "snapshots") { const mode = firstPositional(args) ?? "list"; const action = mode === "refresh" ? "refreshSnapshots" : "listSnapshots"; - return { kind: "execute", label: `PR snapshots ${mode}`, steps: [actionStep("result", "pr", action, withPr({ prId: prId ?? firstPositional(args) }))] }; + return { + kind: "execute", + label: `PR snapshots ${mode}`, + steps: [ + actionStep( + "result", + "pr", + action, + withPr({ prId: prId ?? firstPositional(args) }), + ), + ], + }; } - if (sub === "github-snapshot") return { kind: "execute", label: "PR GitHub snapshot", steps: [actionStep("result", "pr", "getGithubSnapshot", collectGenericObjectArgs(args, { force: readFlag(args, ["--force"]) }))] }; + if (sub === "github-snapshot") + return { + kind: "execute", + label: "PR GitHub snapshot", + steps: [ + actionStep( + "result", + "pr", + "getGithubSnapshot", + collectGenericObjectArgs(args, { + force: readFlag(args, ["--force"]), + }), + ), + ], + }; if (sub === "conflicts") { const mode = firstPositional(args) ?? "list"; - if (mode === "list") return { kind: "execute", label: "PR conflicts list", steps: [actionArgsListStep("result", "pr", "listWithConflicts", [])] }; + if (mode === "list") + return { + kind: "execute", + label: "PR conflicts list", + steps: [actionArgsListStep("result", "pr", "listWithConflicts", [])], + }; const id = requireValue(prId ?? firstPositional(args), "prId"); - const action = mode === "analysis" ? "getConflictAnalysis" : "getMergeContext"; - return { kind: "execute", label: `PR conflicts ${mode}`, steps: [actionArgsListStep("result", "pr", action, [id])] }; + const action = + mode === "analysis" ? "getConflictAnalysis" : "getMergeContext"; + return { + kind: "execute", + label: `PR conflicts ${mode}`, + steps: [actionArgsListStep("result", "pr", action, [id])], + }; } - if (sub === "path-to-merge" || sub === "resolve" || sub === "issue-resolution") { + if ( + sub === "path-to-merge" || + sub === "resolve" || + sub === "issue-resolution" + ) { let mode = "start"; let positionalPrId = firstPositional(args); if (positionalPrId === "start" || positionalPrId === "preview") { @@ -2395,15 +3731,26 @@ function buildPrPlan(args: string[]): CliPlan { } const id = requireValue(prId ?? positionalPrId, "prId"); const scope = readValue(args, ["--scope"]) ?? "both"; - const modelId = requireValue(readValue(args, ["--model", "--model-id"]), "--model"); + const modelId = requireValue( + readValue(args, ["--model", "--model-id"]), + "--model", + ); const input: JsonObject = { prId: id, scope, modelId, }; maybePut(input, "reasoning", readValue(args, ["--reasoning"])); - maybePut(input, "permissionMode", readValue(args, ["--permission-mode", "--permissions"])); - maybePut(input, "additionalInstructions", readValue(args, ["--instructions", "--additional-instructions"])); + maybePut( + input, + "permissionMode", + readValue(args, ["--permission-mode", "--permissions"]), + ); + maybePut( + input, + "additionalInstructions", + readValue(args, ["--instructions", "--additional-instructions"]), + ); // Path to Merge orchestrator reads conflictStrategy / forceFinalizeMode / // earlyMergeOnGreen / autoMerge / maxRounds / mergeMethod from saved // PipelineSettings, not from the launch args. Persist any user-supplied @@ -2411,15 +3758,32 @@ function buildPrPlan(args: string[]): CliPlan { const pipelinePatch = readPipelineSettingsPatch(args); const steps: InvocationStep[] = []; if (Object.keys(pipelinePatch).length > 0) { - steps.push(actionArgsListStep("pipelineSettings", "issue_inventory", "savePipelineSettings", [ - id, - pipelinePatch, - ])); + steps.push( + actionArgsListStep( + "pipelineSettings", + "issue_inventory", + "savePipelineSettings", + [id, pipelinePatch], + ), + ); } if (mode === "preview") { - steps.push(actionCallStep("result", "pr_preview_issue_resolution_prompt", collectGenericObjectArgs(args, input))); + steps.push( + actionCallStep( + "result", + "pr_preview_issue_resolution_prompt", + collectGenericObjectArgs(args, input), + ), + ); } else { - steps.push(actionStep("result", "path_to_merge", "startPathToMerge", collectGenericObjectArgs(args, input))); + steps.push( + actionStep( + "result", + "path_to_merge", + "startPathToMerge", + collectGenericObjectArgs(args, input), + ), + ); } return { kind: "execute", label: `PR path-to-merge ${mode}`, steps }; } @@ -2427,25 +3791,117 @@ function buildPrPlan(args: string[]): CliPlan { if (sub === "pipeline") { const mode = firstPositional(args) ?? "get"; const id = requireValue(prId ?? firstPositional(args), "prId"); - if (mode === "get") return { kind: "execute", label: "PR pipeline", steps: [actionArgsListStep("result", "issue_inventory", "getPipelineSettings", [id])] }; - if (mode === "delete") return { kind: "execute", label: "PR pipeline delete", steps: [actionArgsListStep("result", "issue_inventory", "deletePipelineSettings", [id])] }; - const settings = collectGenericObjectArgs(args, readPipelineSettingsPatch(args)); - return { kind: "execute", label: "PR pipeline save", steps: [actionArgsListStep("result", "issue_inventory", "savePipelineSettings", [id, settings])] }; + if (mode === "get") + return { + kind: "execute", + label: "PR pipeline", + steps: [ + actionArgsListStep( + "result", + "issue_inventory", + "getPipelineSettings", + [id], + ), + ], + }; + if (mode === "delete") + return { + kind: "execute", + label: "PR pipeline delete", + steps: [ + actionArgsListStep( + "result", + "issue_inventory", + "deletePipelineSettings", + [id], + ), + ], + }; + const settings = collectGenericObjectArgs( + args, + readPipelineSettingsPatch(args), + ); + return { + kind: "execute", + label: "PR pipeline save", + steps: [ + actionArgsListStep( + "result", + "issue_inventory", + "savePipelineSettings", + [id, settings], + ), + ], + }; } if (sub === "queue") { const mode = firstPositional(args) ?? "create"; if (mode === "state" || mode === "list") { - const groupId = requireValue(readValue(args, ["--group", "--group-id"]) ?? firstPositional(args), "groupId"); - return { kind: "execute", label: `queue ${mode}`, steps: [actionArgsListStep("result", "pr", mode === "state" ? "getQueueState" : "listGroupPrs", [groupId])] }; + const groupId = requireValue( + readValue(args, ["--group", "--group-id"]) ?? firstPositional(args), + "groupId", + ); + return { + kind: "execute", + label: `queue ${mode}`, + steps: [ + actionArgsListStep( + "result", + "pr", + mode === "state" ? "getQueueState" : "listGroupPrs", + [groupId], + ), + ], + }; } if (mode === "reorder") { - return { kind: "execute", label: "queue reorder", steps: [actionStep("result", "pr", "reorderQueuePrs", collectGenericObjectArgs(args, { groupId: readValue(args, ["--group", "--group-id"]) ?? firstPositional(args) }))] }; + return { + kind: "execute", + label: "queue reorder", + steps: [ + actionStep( + "result", + "pr", + "reorderQueuePrs", + collectGenericObjectArgs(args, { + groupId: + readValue(args, ["--group", "--group-id"]) ?? + firstPositional(args), + }), + ), + ], + }; } if (mode === "land-next") { - return { kind: "execute", label: "queue land next", steps: [actionCallStep("result", "land_queue_next", collectGenericObjectArgs(args, { groupId: readValue(args, ["--group", "--group-id"]) ?? firstPositional(args), method: readValue(args, ["--method"]) ?? "squash" }))] }; + return { + kind: "execute", + label: "queue land next", + steps: [ + actionCallStep( + "result", + "land_queue_next", + collectGenericObjectArgs(args, { + groupId: + readValue(args, ["--group", "--group-id"]) ?? + firstPositional(args), + method: readValue(args, ["--method"]) ?? "squash", + }), + ), + ], + }; } - return { kind: "execute", label: "queue create", steps: [actionCallStep("result", "create_queue", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "queue create", + steps: [ + actionCallStep( + "result", + "create_queue", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "integration") { @@ -2461,28 +3917,88 @@ function buildPrPlan(args: string[]): CliPlan { "recheck-step": "recheckIntegrationStep", }; if (integrationMap[mode]) { - return { kind: "execute", label: `integration ${mode}`, steps: [actionStep("result", "pr", integrationMap[mode]!, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `integration ${mode}`, + steps: [ + actionStep( + "result", + "pr", + integrationMap[mode]!, + collectGenericObjectArgs(args), + ), + ], + }; } if (mode === "lane") { const laneMode = firstPositional(args) ?? "create"; - if (laneMode !== "create") throw new CliUsageError("prs integration lane supports create."); - return { kind: "execute", label: "integration lane create", steps: [actionStep("result", "pr", "createIntegrationLane", collectGenericObjectArgs(args))] }; + if (laneMode !== "create") + throw new CliUsageError("prs integration lane supports create."); + return { + kind: "execute", + label: "integration lane create", + steps: [ + actionStep( + "result", + "pr", + "createIntegrationLane", + collectGenericObjectArgs(args), + ), + ], + }; } if (mode === "cleanup") { const cleanupMode = firstPositional(args) ?? "run"; - return { kind: "execute", label: `integration cleanup ${cleanupMode}`, steps: [actionStep("result", "pr", cleanupMode === "dismiss" ? "dismissIntegrationCleanup" : "cleanupIntegrationWorkflow", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `integration cleanup ${cleanupMode}`, + steps: [ + actionStep( + "result", + "pr", + cleanupMode === "dismiss" + ? "dismissIntegrationCleanup" + : "cleanupIntegrationWorkflow", + collectGenericObjectArgs(args), + ), + ], + }; } - const tool = mode === "create" ? "create_integration" : "simulate_integration"; - return { kind: "execute", label: `integration ${mode}`, steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))] }; + const tool = + mode === "create" ? "create_integration" : "simulate_integration"; + return { + kind: "execute", + label: `integration ${mode}`, + steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))], + }; } if (sub === "inventory") { const first = firstPositional(args); - const knownModes = new Set(["refresh", "get", "new", "mark-sent", "mark-fixed", "dismiss", "escalate", "reset"]); + const knownModes = new Set([ + "refresh", + "get", + "new", + "mark-sent", + "mark-fixed", + "dismiss", + "escalate", + "reset", + ]); const mode = first && knownModes.has(first) ? first : "refresh"; const positionalPrId = mode === "refresh" ? first : firstPositional(args); if (mode === "refresh") { - return { kind: "execute", label: "PR inventory", steps: [actionCallStep("result", "pr_refresh_issue_inventory", withPr({ prId: requireValue(prId ?? positionalPrId, "prId") }))] }; + return { + kind: "execute", + label: "PR inventory", + steps: [ + actionCallStep( + "result", + "pr_refresh_issue_inventory", + withPr({ prId: requireValue(prId ?? positionalPrId, "prId") }), + ), + ], + }; } const actionByMode: Record = { get: "getInventory", @@ -2494,19 +4010,38 @@ function buildPrPlan(args: string[]): CliPlan { reset: "resetInventory", }; const action = actionByMode[mode]; - if (!action) throw new CliUsageError("prs inventory supports get, new, mark-sent, mark-fixed, dismiss, escalate, or reset."); + if (!action) + throw new CliUsageError( + "prs inventory supports get, new, mark-sent, mark-fixed, dismiss, escalate, or reset.", + ); const id = requireValue(prId ?? positionalPrId, "prId"); const itemIds = args.filter((entry) => !entry.startsWith("-")); const argsListByMode: Record = { get: [id], new: [id], - "mark-sent": [id, itemIds, readValue(args, ["--session", "--session-id"]) ?? "", readIntOption(args, ["--round"], 0) ?? 0], + "mark-sent": [ + id, + itemIds, + readValue(args, ["--session", "--session-id"]) ?? "", + readIntOption(args, ["--round"], 0) ?? 0, + ], "mark-fixed": [id, itemIds], dismiss: [id, itemIds, readValue(args, ["--reason"]) ?? ""], escalate: [id, itemIds], reset: [id], }; - return { kind: "execute", label: `PR inventory ${mode}`, steps: [actionArgsListStep("result", "issue_inventory", action, argsListByMode[mode] ?? [id])] }; + return { + kind: "execute", + label: `PR inventory ${mode}`, + steps: [ + actionArgsListStep( + "result", + "issue_inventory", + action, + argsListByMode[mode] ?? [id], + ), + ], + }; } if (sub === "convergence") { @@ -2520,21 +4055,55 @@ function buildPrPlan(args: string[]): CliPlan { reconcile: "reconcileConvergenceSessionExit", }; const action = actionByMode[mode]; - if (!action) throw new CliUsageError("prs convergence supports status, runtime, save, reset, or reconcile."); + if (!action) + throw new CliUsageError( + "prs convergence supports status, runtime, save, reset, or reconcile.", + ); const id = requireValue(prId ?? firstPositional(args), "prId"); if (mode === "save") { - return { kind: "execute", label: "PR convergence save", steps: [actionArgsListStep("result", "issue_inventory", action, [id, collectGenericObjectArgs(args)])] }; + return { + kind: "execute", + label: "PR convergence save", + steps: [ + actionArgsListStep("result", "issue_inventory", action, [ + id, + collectGenericObjectArgs(args), + ]), + ], + }; } if (mode === "reconcile") { - return { kind: "execute", label: "PR convergence reconcile", steps: [actionStep("result", "issue_inventory", action, collectGenericObjectArgs(args, { prId: id }))] }; + return { + kind: "execute", + label: "PR convergence reconcile", + steps: [ + actionStep( + "result", + "issue_inventory", + action, + collectGenericObjectArgs(args, { prId: id }), + ), + ], + }; } - return { kind: "execute", label: `PR convergence ${mode}`, steps: [actionArgsListStep("result", "issue_inventory", action, [id])] }; + return { + kind: "execute", + label: `PR convergence ${mode}`, + steps: [actionArgsListStep("result", "issue_inventory", action, [id])], + }; } - return { kind: "execute", label: `PR ${sub}`, steps: [actionStep("result", "pr", sub, withPr())] }; + return { + kind: "execute", + label: `PR ${sub}`, + steps: [actionStep("result", "pr", sub, withPr())], + }; } -function collectMissionCreateArgs(args: string[], base: JsonObject = {}): JsonObject { +function collectMissionCreateArgs( + args: string[], + base: JsonObject = {}, +): JsonObject { const noAutostart = readFlag(args, ["--no-autostart", "--no-start"]); const autostartFlag = readFlag(args, ["--autostart"]); const manual = readFlag(args, ["--manual"]); @@ -2550,79 +4119,163 @@ function collectMissionCreateArgs(args: string[], base: JsonObject = {}): JsonOb laneId: readLaneId(args), priority: readValue(args, ["--priority"]), executionMode: readValue(args, ["--execution-mode"]), - targetMachineId: readValue(args, ["--target-machine", "--target-machine-id"]), + targetMachineId: readValue(args, [ + "--target-machine", + "--target-machine-id", + ]), plannerEngine: readValue(args, ["--planner", "--planner-engine"]), planningTimeoutMs: readIntOption(args, ["--planning-timeout-ms"]), - launchMode: readValue(args, ["--launch-mode", "--run-mode"]) ?? createBase.launchMode, - autopilotExecutor: readValue(args, ["--executor", "--autopilot-executor", "--default-executor"]), + launchMode: + readValue(args, ["--launch-mode", "--run-mode"]) ?? createBase.launchMode, + autopilotExecutor: readValue(args, [ + "--executor", + "--autopilot-executor", + "--default-executor", + ]), autostart: createBase.autostart, phaseProfileId: readValue(args, ["--phase-profile", "--phase-profile-id"]), - employeeAgentId: readValue(args, ["--employee-agent", "--employee-agent-id"]), + employeeAgentId: readValue(args, [ + "--employee-agent", + "--employee-agent-id", + ]), }); - const phaseOverride = readJsonPayloadOption(args, ["--phase-override-json"], ["--phase-override-file"], "--phase-override-json"); + const phaseOverride = readJsonPayloadOption( + args, + ["--phase-override-json"], + ["--phase-override-file"], + "--phase-override-json", + ); if (phaseOverride !== undefined) { - if (!Array.isArray(phaseOverride)) throw new CliUsageError("--phase-override-json must be a JSON array."); + if (!Array.isArray(phaseOverride)) + throw new CliUsageError("--phase-override-json must be a JSON array."); input.phaseOverride = phaseOverride; } - const plannedSteps = readJsonPayloadOption(args, ["--planned-steps-json"], ["--planned-steps-file"], "--planned-steps-json"); + const plannedSteps = readJsonPayloadOption( + args, + ["--planned-steps-json"], + ["--planned-steps-file"], + "--planned-steps-json", + ); if (plannedSteps !== undefined) { - if (!Array.isArray(plannedSteps)) throw new CliUsageError("--planned-steps-json must be a JSON array."); + if (!Array.isArray(plannedSteps)) + throw new CliUsageError("--planned-steps-json must be a JSON array."); input.plannedSteps = plannedSteps; } const jsonObjects: Array<[string, string[], string[], string]> = [ - ["modelConfig", ["--model-config-json"], ["--model-config-file"], "--model-config-json"], - ["executionPolicy", ["--execution-policy-json"], ["--execution-policy-file"], "--execution-policy-json"], - ["recoveryLoop", ["--recovery-loop-json"], ["--recovery-loop-file"], "--recovery-loop-json"], - ["teamRuntime", ["--team-runtime-json"], ["--team-runtime-file"], "--team-runtime-json"], - ["agentRuntime", ["--agent-runtime-json"], ["--agent-runtime-file"], "--agent-runtime-json"], - ["permissionConfig", ["--permission-config-json"], ["--permission-config-file"], "--permission-config-json"], + [ + "modelConfig", + ["--model-config-json"], + ["--model-config-file"], + "--model-config-json", + ], + [ + "executionPolicy", + ["--execution-policy-json"], + ["--execution-policy-file"], + "--execution-policy-json", + ], + [ + "recoveryLoop", + ["--recovery-loop-json"], + ["--recovery-loop-file"], + "--recovery-loop-json", + ], + [ + "teamRuntime", + ["--team-runtime-json"], + ["--team-runtime-file"], + "--team-runtime-json", + ], + [ + "agentRuntime", + ["--agent-runtime-json"], + ["--agent-runtime-file"], + "--agent-runtime-json", + ], + [ + "permissionConfig", + ["--permission-config-json"], + ["--permission-config-file"], + "--permission-config-json", + ], ]; for (const [key, inlineNames, fileNames, label] of jsonObjects) { const value = readJsonPayloadOption(args, inlineNames, fileNames, label); if (value === undefined) continue; - if (!isRecord(value)) throw new CliUsageError(`${label} must be a JSON object.`); + if (!isRecord(value)) + throw new CliUsageError(`${label} must be a JSON object.`); input[key] = value; } if (!asString(input.prompt)) { - const positionalPrompt = args.filter((entry) => entry !== "--" && !entry.startsWith("-")).join(" ").trim(); + const positionalPrompt = args + .filter((entry) => entry !== "--" && !entry.startsWith("-")) + .join(" ") + .trim(); if (positionalPrompt.length > 0) input.prompt = positionalPrompt; } input.prompt = requireValue(asString(input.prompt) ?? null, "prompt"); return input; } -function collectMissionStartArgs(args: string[], base: JsonObject = {}): JsonObject { +function collectMissionStartArgs( + args: string[], + base: JsonObject = {}, +): JsonObject { const manual = readFlag(args, ["--manual"]); - const runMode = manual ? "manual" : readValue(args, ["--run-mode", "--launch-mode"]); - const executor = readValue(args, ["--executor", "--default-executor", "--executor-kind"]); + const runMode = manual + ? "manual" + : readValue(args, ["--run-mode", "--launch-mode"]); + const executor = readValue(args, [ + "--executor", + "--default-executor", + "--executor-kind", + ]); const owner = readValue(args, ["--owner", "--owner-id", "--autopilot-owner"]); const input: JsonObject = { ...base }; if (runMode) input.runMode = runMode; - if (executor ?? base.defaultExecutorKind) input.defaultExecutorKind = executor ?? base.defaultExecutorKind; + if (executor ?? base.defaultExecutorKind) + input.defaultExecutorKind = executor ?? base.defaultExecutorKind; if (owner) input.autopilotOwnerId = owner; return collectGenericObjectArgs(args, input); } function buildMissionsPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; - if (sub === "actions") return { kind: "execute", label: "mission actions", steps: [listActionsStep("actions", "mission")] }; - if (sub === "action") return { kind: "execute", label: "mission action", steps: [buildActionRunStep(["mission", ...args])] }; + if (sub === "actions") + return { + kind: "execute", + label: "mission actions", + steps: [listActionsStep("actions", "mission")], + }; + if (sub === "action") + return { + kind: "execute", + label: "mission action", + steps: [buildActionRunStep(["mission", ...args])], + }; if (sub === "list" || sub === "ls") { return { kind: "execute", label: "mission list", formatter: "mission-list", - steps: [actionStep("result", "mission", "list", collectGenericObjectArgs(args, { - status: readValue(args, ["--status"]), - laneId: readLaneId(args), - limit: readIntOption(args, ["--limit"]), - includeArchived: readFlag(args, ["--include-archived"]), - }))], + steps: [ + actionStep( + "result", + "mission", + "list", + collectGenericObjectArgs(args, { + status: readValue(args, ["--status"]), + laneId: readLaneId(args), + limit: readIntOption(args, ["--limit"]), + includeArchived: readFlag(args, ["--include-archived"]), + }), + ), + ], }; } @@ -2631,13 +4284,28 @@ function buildMissionsPlan(args: string[]): CliPlan { kind: "execute", label: "mission create", formatter: "mission-detail", - steps: [actionStep("result", "mission", "create", collectMissionCreateArgs(args))], + steps: [ + actionStep( + "result", + "mission", + "create", + collectMissionCreateArgs(args), + ), + ], }; } if (sub === "launch") { - const waitUntilTerminal = readFlag(args, ["--wait", "--until-terminal", "--wait-until-terminal"]); - const waitMs = readIntOption(args, ["--wait-ms", "--hold-ms", "--wait-for-ms"], waitUntilTerminal ? 30 * 60 * 1000 : undefined); + const waitUntilTerminal = readFlag(args, [ + "--wait", + "--until-terminal", + "--wait-until-terminal", + ]); + const waitMs = readIntOption( + args, + ["--wait-ms", "--hold-ms", "--wait-for-ms"], + waitUntilTerminal ? 30 * 60 * 1000 : undefined, + ); const timelineLimit = readIntOption(args, ["--timeline-limit"], 120) ?? 120; const createArgs = collectMissionCreateArgs(args, { autostart: false }); const startArgs = collectMissionStartArgs(args, { @@ -2646,7 +4314,11 @@ function buildMissionsPlan(args: string[]): CliPlan { }); const waitGraphStep = waitRunGraphStep({ key: "graph", - runId: (values) => requireValue(asString(runFromStartResult(values.started)?.id) ?? null, "run id"), + runId: (values) => + requireValue( + asString(runFromStartResult(values.started)?.id) ?? null, + "run id", + ), waitMs, untilTerminal: waitUntilTerminal, timelineLimit, @@ -2692,17 +4364,30 @@ function buildMissionsPlan(args: string[]): CliPlan { } if (sub === "start" || sub === "run") { - const missionId = requireValue(readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args), "missionId"); + const missionId = requireValue( + readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args), + "missionId", + ); return { kind: "execute", label: "mission start", formatter: "mission-watch", - steps: [actionStep("result", "orchestrator", "startMissionRun", collectMissionStartArgs(args, { missionId }))], + steps: [ + actionStep( + "result", + "orchestrator", + "startMissionRun", + collectMissionStartArgs(args, { missionId }), + ), + ], }; } if (sub === "show" || sub === "get" || sub === "view") { - const missionId = requireValue(readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args), "missionId"); + const missionId = requireValue( + readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args), + "missionId", + ); return { kind: "execute", label: "mission show", @@ -2712,42 +4397,75 @@ function buildMissionsPlan(args: string[]): CliPlan { } if (sub === "runs" || sub === "attempts") { - const missionId = readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args); + const missionId = + readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args); return { kind: "execute", label: "mission runs", formatter: "mission-runs", - steps: [actionStep("result", "orchestrator_core", "listRuns", collectGenericObjectArgs(args, { - missionId: missionId ?? undefined, - status: readValue(args, ["--status"]), - limit: readIntOption(args, ["--limit"], 20), - }))], + steps: [ + actionStep( + "result", + "orchestrator_core", + "listRuns", + collectGenericObjectArgs(args, { + missionId: missionId ?? undefined, + status: readValue(args, ["--status"]), + limit: readIntOption(args, ["--limit"], 20), + }), + ), + ], }; } if (sub === "graph" || sub === "run-graph") { - const runId = requireValue(readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), "runId"); + const runId = requireValue( + readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), + "runId", + ); return { kind: "execute", label: "mission graph", formatter: "mission-graph", - steps: [actionStep("result", "orchestrator_core", "getRunGraph", collectGenericObjectArgs(args, { - runId, - timelineLimit: readIntOption(args, ["--timeline-limit"], 80), - }))], + steps: [ + actionStep( + "result", + "orchestrator_core", + "getRunGraph", + collectGenericObjectArgs(args, { + runId, + timelineLimit: readIntOption(args, ["--timeline-limit"], 80), + }), + ), + ], }; } if (sub === "watch" || sub === "monitor") { - const waitUntilTerminal = readFlag(args, ["--wait", "--until-terminal", "--wait-until-terminal"]); - const waitMs = readIntOption(args, ["--wait-ms", "--hold-ms", "--wait-for-ms"], waitUntilTerminal ? 30 * 60 * 1000 : undefined); + const waitUntilTerminal = readFlag(args, [ + "--wait", + "--until-terminal", + "--wait-until-terminal", + ]); + const waitMs = readIntOption( + args, + ["--wait-ms", "--hold-ms", "--wait-for-ms"], + waitUntilTerminal ? 30 * 60 * 1000 : undefined, + ); const runId = readValue(args, ["--run", "--run-id"]); - const missionId = readValue(args, ["--mission", "--mission-id"]) ?? (runId ? null : firstPositional(args)); + const missionId = + readValue(args, ["--mission", "--mission-id"]) ?? + (runId ? null : firstPositional(args)); const timelineLimit = readIntOption(args, ["--timeline-limit"], 80) ?? 80; const steps: InvocationStep[] = []; if (missionId) { steps.push(actionScalarStep("mission", "mission", "get", missionId)); - steps.push(actionStep("runs", "orchestrator_core", "listRuns", { missionId, limit: readIntOption(args, ["--limit"], 20) })); + steps.push( + actionStep("runs", "orchestrator_core", "listRuns", { + missionId, + limit: readIntOption(args, ["--limit"], 20), + }), + ); } const waitGraphStep = waitRunGraphStep({ key: "graph", @@ -2784,16 +4502,50 @@ function buildMissionsPlan(args: string[]): CliPlan { } if (sub === "pause") { - const runId = requireValue(readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), "runId"); - return { kind: "execute", label: "mission pause", formatter: "mission-graph", steps: [actionStep("result", "orchestrator_core", "pauseRun", collectGenericObjectArgs(args, { runId, reason: readValue(args, ["--reason"]) }))] }; + const runId = requireValue( + readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), + "runId", + ); + return { + kind: "execute", + label: "mission pause", + formatter: "mission-graph", + steps: [ + actionStep( + "result", + "orchestrator_core", + "pauseRun", + collectGenericObjectArgs(args, { + runId, + reason: readValue(args, ["--reason"]), + }), + ), + ], + }; } if (sub === "resume") { - const runId = requireValue(readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), "runId"); - const waitUntilTerminal = readFlag(args, ["--wait", "--until-terminal", "--wait-until-terminal"]); - const waitMs = readIntOption(args, ["--wait-ms", "--hold-ms", "--wait-for-ms"], waitUntilTerminal ? 30 * 60 * 1000 : undefined); + const runId = requireValue( + readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), + "runId", + ); + const waitUntilTerminal = readFlag(args, [ + "--wait", + "--until-terminal", + "--wait-until-terminal", + ]); + const waitMs = readIntOption( + args, + ["--wait-ms", "--hold-ms", "--wait-for-ms"], + waitUntilTerminal ? 30 * 60 * 1000 : undefined, + ); const steps: InvocationStep[] = [ - actionStep("result", "orchestrator", "resumeRun", collectGenericObjectArgs(args, { runId })), + actionStep( + "result", + "orchestrator", + "resumeRun", + collectGenericObjectArgs(args, { runId }), + ), ]; const waitGraphStep = waitRunGraphStep({ key: "graph", @@ -2812,52 +4564,187 @@ function buildMissionsPlan(args: string[]): CliPlan { } if (sub === "cancel") { - const runId = requireValue(readValue(args, ["--run", "--run-id"]) ?? readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args), "runId"); - return { kind: "execute", label: "mission cancel", formatter: "mission-detail", steps: [actionStep("result", "orchestrator", "cancelRunGracefully", collectGenericObjectArgs(args, { runId, reason: readValue(args, ["--reason"]) }))] }; + const runId = requireValue( + readValue(args, ["--run", "--run-id"]) ?? + readValue(args, ["--mission", "--mission-id"]) ?? + firstPositional(args), + "runId", + ); + return { + kind: "execute", + label: "mission cancel", + formatter: "mission-detail", + steps: [ + actionStep( + "result", + "orchestrator", + "cancelRunGracefully", + collectGenericObjectArgs(args, { + runId, + reason: readValue(args, ["--reason"]), + }), + ), + ], + }; } - return { kind: "execute", label: `mission ${sub}`, steps: [actionStep("result", "mission", sub, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `mission ${sub}`, + steps: [ + actionStep("result", "mission", sub, collectGenericObjectArgs(args)), + ], + }; } function buildRunPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "ps"; - if (sub === "actions") return { kind: "execute", label: "run actions", steps: [listActionsStep("actions", "process")] }; - if (sub === "action") return { kind: "execute", label: "run action", steps: [buildActionRunStep(["process", ...args])] }; - if (sub === "defs" || sub === "definitions") return { kind: "execute", label: "process definitions", steps: [actionStep("result", "process", "listDefinitions", collectGenericObjectArgs(args))] }; + if (sub === "actions") + return { + kind: "execute", + label: "run actions", + steps: [listActionsStep("actions", "process")], + }; + if (sub === "action") + return { + kind: "execute", + label: "run action", + steps: [buildActionRunStep(["process", ...args])], + }; + if (sub === "defs" || sub === "definitions") + return { + kind: "execute", + label: "process definitions", + steps: [ + actionStep( + "result", + "process", + "listDefinitions", + collectGenericObjectArgs(args), + ), + ], + }; const laneId = readLaneId(args); - const processId = readValue(args, ["--process", "--process-id"]) ?? firstPositional(args); + const processId = + readValue(args, ["--process", "--process-id"]) ?? firstPositional(args); const runId = readValue(args, ["--run", "--run-id"]); - const withProcess = (base: JsonObject = {}) => collectGenericObjectArgs(args, { - ...base, - ...(laneId ? { laneId } : {}), - ...(processId ? { processId } : {}), - ...(runId ? { runId } : {}), - }); + const withProcess = (base: JsonObject = {}) => + collectGenericObjectArgs(args, { + ...base, + ...(laneId ? { laneId } : {}), + ...(processId ? { processId } : {}), + ...(runId ? { runId } : {}), + }); if (sub === "ps" || sub === "list" || sub === "runtime") { const id = requireValue(laneId, "laneId"); - return { kind: "execute", label: "process runtime", steps: [actionArgsListStep("result", "process", "listRuntime", [id])] }; + return { + kind: "execute", + label: "process runtime", + steps: [actionArgsListStep("result", "process", "listRuntime", [id])], + }; } - if (sub === "start" || sub === "stop" || sub === "restart" || sub === "kill") { - return { kind: "execute", label: `process ${sub}`, steps: [actionStep("result", "process", sub, withProcess({ laneId: requireValue(laneId, "laneId"), processId: requireValue(processId, "processId") }))] }; + if ( + sub === "start" || + sub === "stop" || + sub === "restart" || + sub === "kill" + ) { + return { + kind: "execute", + label: `process ${sub}`, + steps: [ + actionStep( + "result", + "process", + sub, + withProcess({ + laneId: requireValue(laneId, "laneId"), + processId: requireValue(processId, "processId"), + }), + ), + ], + }; } if (sub === "logs" || sub === "log") { - return { kind: "execute", label: "process logs", steps: [actionStep("result", "process", "getLogTail", withProcess({ laneId: requireValue(laneId, "laneId"), processId: requireValue(processId, "processId"), maxBytes: readIntOption(args, ["--max-bytes", "--tail-bytes"], 80_000) }))] }; + return { + kind: "execute", + label: "process logs", + steps: [ + actionStep( + "result", + "process", + "getLogTail", + withProcess({ + laneId: requireValue(laneId, "laneId"), + processId: requireValue(processId, "processId"), + maxBytes: readIntOption( + args, + ["--max-bytes", "--tail-bytes"], + 80_000, + ), + }), + ), + ], + }; } if (sub === "stack") { const mode = requireValue(firstPositional(args), "stack action"); - const stackId = requireValue(readValue(args, ["--stack", "--stack-id"]) ?? firstPositional(args), "stackId"); - const methodByMode: Record = { start: "startStack", stop: "stopStack", restart: "restartStack" }; + const stackId = requireValue( + readValue(args, ["--stack", "--stack-id"]) ?? firstPositional(args), + "stackId", + ); + const methodByMode: Record = { + start: "startStack", + stop: "stopStack", + restart: "restartStack", + }; const method = methodByMode[mode]; - if (!method) throw new CliUsageError("run stack supports start, stop, or restart."); - return { kind: "execute", label: `stack ${mode}`, steps: [actionStep("result", "process", method, collectGenericObjectArgs(args, { laneId: requireValue(laneId, "laneId"), stackId }))] }; + if (!method) + throw new CliUsageError("run stack supports start, stop, or restart."); + return { + kind: "execute", + label: `stack ${mode}`, + steps: [ + actionStep( + "result", + "process", + method, + collectGenericObjectArgs(args, { + laneId: requireValue(laneId, "laneId"), + stackId, + }), + ), + ], + }; } - if (sub === "start-all" || sub === "stop-all") return { kind: "execute", label: `process ${sub}`, steps: [actionStep("result", "process", sub === "start-all" ? "startAll" : "stopAll", collectGenericObjectArgs(args, { ...(laneId ? { laneId } : {}) }))] }; - return { kind: "execute", label: `process ${sub}`, steps: [actionStep("result", "process", sub, withProcess())] }; + if (sub === "start-all" || sub === "stop-all") + return { + kind: "execute", + label: `process ${sub}`, + steps: [ + actionStep( + "result", + "process", + sub === "start-all" ? "startAll" : "stopAll", + collectGenericObjectArgs(args, { ...(laneId ? { laneId } : {}) }), + ), + ], + }; + return { + kind: "execute", + label: `process ${sub}`, + steps: [actionStep("result", "process", sub, withProcess())], + }; } function buildShellPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "start"; - if (sub === "actions") return { kind: "execute", label: "shell actions", steps: [listActionsStep("actions", "pty")] }; + if (sub === "actions") + return { + kind: "execute", + label: "shell actions", + steps: [listActionsStep("actions", "pty")], + }; if (sub === "start-cli" || sub === "cli" || sub === "agent-cli") { return buildCliSessionStartPlan(args); } @@ -2868,8 +4755,12 @@ function buildShellPlan(args: string[]): CliPlan { } const laneId = readLaneId(args); const chatSessionId = asString( - readValue(args, ["--chat-session", "--chat-session-id", "--session", "--session-id"]) - ?? process.env.ADE_CHAT_SESSION_ID, + readValue(args, [ + "--chat-session", + "--chat-session-id", + "--session", + "--session-id", + ]) ?? process.env.ADE_CHAT_SESSION_ID, ); const startupCommandArgs = takeArgsAfterTerminator(args); const startupCommand = startupCommandArgs @@ -2886,31 +4777,104 @@ function buildShellPlan(args: string[]): CliPlan { rows: readIntOption(args, ["--rows"], 36), tracked: !readFlag(args, ["--untracked"]), }); - return { kind: "execute", label: "shell start", steps: [actionStep("result", "pty", "create", input)] }; + return { + kind: "execute", + label: "shell start", + steps: [actionStep("result", "pty", "create", input)], + }; } - if (sub === "write") return { kind: "execute", label: "shell write", steps: [actionStep("result", "pty", "write", collectGenericObjectArgs(args, { ptyId: requireValue(readValue(args, ["--pty", "--pty-id"]) ?? firstPositional(args), "ptyId"), data: readValue(args, ["--data"]) ?? "" }))] }; - if (sub === "resize") return { kind: "execute", label: "shell resize", steps: [actionStep("result", "pty", "resize", collectGenericObjectArgs(args, { ptyId: requireValue(readValue(args, ["--pty", "--pty-id"]) ?? firstPositional(args), "ptyId"), cols: readIntOption(args, ["--cols"], 120), rows: readIntOption(args, ["--rows"], 36) }))] }; - if (sub === "close" || sub === "dispose") return { kind: "execute", label: "shell close", steps: [actionStep("result", "pty", "dispose", collectGenericObjectArgs(args, { ptyId: requireValue(readValue(args, ["--pty", "--pty-id"]) ?? firstPositional(args), "ptyId"), sessionId: readValue(args, ["--session", "--session-id"]) }))] }; - return { kind: "execute", label: `shell ${sub}`, steps: [actionStep("result", "pty", sub, collectGenericObjectArgs(args))] }; + if (sub === "write") + return { + kind: "execute", + label: "shell write", + steps: [ + actionStep( + "result", + "pty", + "write", + collectGenericObjectArgs(args, { + ptyId: requireValue( + readValue(args, ["--pty", "--pty-id"]) ?? firstPositional(args), + "ptyId", + ), + data: readValue(args, ["--data"]) ?? "", + }), + ), + ], + }; + if (sub === "resize") + return { + kind: "execute", + label: "shell resize", + steps: [ + actionStep( + "result", + "pty", + "resize", + collectGenericObjectArgs(args, { + ptyId: requireValue( + readValue(args, ["--pty", "--pty-id"]) ?? firstPositional(args), + "ptyId", + ), + cols: readIntOption(args, ["--cols"], 120), + rows: readIntOption(args, ["--rows"], 36), + }), + ), + ], + }; + if (sub === "close" || sub === "dispose") + return { + kind: "execute", + label: "shell close", + steps: [ + actionStep( + "result", + "pty", + "dispose", + collectGenericObjectArgs(args, { + ptyId: requireValue( + readValue(args, ["--pty", "--pty-id"]) ?? firstPositional(args), + "ptyId", + ), + sessionId: readValue(args, ["--session", "--session-id"]), + }), + ), + ], + }; + return { + kind: "execute", + label: `shell ${sub}`, + steps: [actionStep("result", "pty", sub, collectGenericObjectArgs(args))], + }; } -function buildCliSessionStartPlan(args: string[], providerArg?: string): CliPlan { +function buildCliSessionStartPlan( + args: string[], + providerArg?: string, +): CliPlan { const laneId = requireValue(readLaneId(args), "laneId"); const rawProvider = requireValue( - providerArg ?? readValue(args, ["--provider", "--profile"]) ?? firstStandalonePositional(args), + providerArg ?? + readValue(args, ["--provider", "--profile"]) ?? + firstStandalonePositional(args), "provider", ); if (!isLaunchProfile(rawProvider)) { - throw new CliUsageError("provider must be one of claude, codex, cursor, droid, opencode, or shell."); + throw new CliUsageError( + "provider must be one of claude, codex, cursor, droid, opencode, or shell.", + ); } const provider: LaunchProfile = rawProvider; const promptArgs = takeArgsAfterTerminator(args); const initialInput = promptArgs ? promptArgs.join(" ").trim() : readValue(args, ["--message", "--prompt", "--initial-input"]); - const permissionMode = readValue(args, ["--permission-mode", "--permissions"]) ?? "default"; + const permissionMode = + readValue(args, ["--permission-mode", "--permissions"]) ?? "default"; if (!isTrackedCliPermissionMode(permissionMode)) { - throw new CliUsageError("permissionMode must be one of default, plan, edit, full-auto, or config-toml."); + throw new CliUsageError( + "permissionMode must be one of default, plan, edit, full-auto, or config-toml.", + ); } validateLaunchProfilePermissionMode(provider, permissionMode); @@ -2918,177 +4882,656 @@ function buildCliSessionStartPlan(args: string[], providerArg?: string): CliPlan laneId, provider, permissionMode, - title: readValue(args, ["--title"]) ?? LAUNCH_PROFILE_TITLE[provider] ?? undefined, + title: + readValue(args, ["--title"]) ?? + LAUNCH_PROFILE_TITLE[provider] ?? + undefined, initialInput, cols: readIntOption(args, ["--cols"], 120), rows: readIntOption(args, ["--rows"], 36), cwd: readValue(args, ["--cwd"]), chatSessionId: readValue(args, ["--chat-session", "--chat-session-id"]), - resumeSessionId: readValue(args, ["--resume-session", "--resume-session-id"]), - resumeTargetId: readValue(args, ["--resume-target", "--resume-target-id", "--target"]), + resumeSessionId: readValue(args, [ + "--resume-session", + "--resume-session-id", + ]), + resumeTargetId: readValue(args, [ + "--resume-target", + "--resume-target-id", + "--target", + ]), tracked: !readFlag(args, ["--untracked"]), }); - return { kind: "execute", label: "shell start cli", steps: [actionCallStep("result", "start_cli_session", input)] }; + return { + kind: "execute", + label: "shell start cli", + steps: [actionCallStep("result", "start_cli_session", input)], + }; } function buildTerminalPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "active"; - if (sub === "actions") return { kind: "execute", label: "terminal actions", steps: [listActionsStep("actions", "terminal")] }; - const chatSessionId = () => readValue(args, ["--chat-session", "--chat-session-id", "--session", "--session-id"]) ?? process.env.ADE_CHAT_SESSION_ID ?? null; + if (sub === "actions") + return { + kind: "execute", + label: "terminal actions", + steps: [listActionsStep("actions", "terminal")], + }; + const chatSessionId = () => + readValue(args, [ + "--chat-session", + "--chat-session-id", + "--session", + "--session-id", + ]) ?? + process.env.ADE_CHAT_SESSION_ID ?? + null; if (sub === "list" || sub === "ls") { - return { kind: "execute", label: "terminal list", steps: [actionStep("result", "terminal", "list", collectGenericObjectArgs(args, { - chatSessionId: chatSessionId(), - laneId: readValue(args, ["--lane", "--lane-id"]), - limit: readIntOption(args, ["--limit"], undefined), - }))] }; + return { + kind: "execute", + label: "terminal list", + steps: [ + actionStep( + "result", + "terminal", + "list", + collectGenericObjectArgs(args, { + chatSessionId: chatSessionId(), + laneId: readValue(args, ["--lane", "--lane-id"]), + limit: readIntOption(args, ["--limit"], undefined), + }), + ), + ], + }; } if (sub === "active" || sub === "current") { - return { kind: "execute", label: "terminal active", steps: [actionStep("result", "terminal", "activeForChat", collectGenericObjectArgs(args, { - chatSessionId: requireValue(chatSessionId(), "chatSessionId"), - }))] }; + return { + kind: "execute", + label: "terminal active", + steps: [ + actionStep( + "result", + "terminal", + "activeForChat", + collectGenericObjectArgs(args, { + chatSessionId: requireValue(chatSessionId(), "chatSessionId"), + }), + ), + ], + }; } if (sub === "read" || sub === "tail" || sub === "scrollback") { const terminal = readValue(args, ["--terminal", "--terminal-id"]); const chat = chatSessionId(); const maxBytes = readIntOption(args, ["--max-bytes"], undefined); const since = readIntOption(args, ["--since"], undefined); - return { kind: "execute", label: "terminal read", steps: [actionStep("result", "terminal", "read", collectGenericObjectArgs(args, { - terminalId: terminal ?? firstPositional(args), - chatSessionId: chat, - maxBytes, - since, - }))] }; + return { + kind: "execute", + label: "terminal read", + steps: [ + actionStep( + "result", + "terminal", + "read", + collectGenericObjectArgs(args, { + terminalId: terminal ?? firstPositional(args), + chatSessionId: chat, + maxBytes, + since, + }), + ), + ], + }; } if (sub === "write" || sub === "send" || sub === "input") { const terminal = readValue(args, ["--terminal", "--terminal-id"]); const ptyId = readValue(args, ["--pty", "--pty-id"]); const chat = chatSessionId(); - const data = readValue(args, ["--data", "--value", "--text"]) ?? args.join(" "); + const data = + readValue(args, ["--data", "--value", "--text"]) ?? args.join(" "); if (!data.length) throw new CliUsageError("data is required."); - return { kind: "execute", label: "terminal write", steps: [actionStep("result", "terminal", "write", collectGenericObjectArgs(args, { - terminalId: terminal ?? firstPositional(args), - ptyId, - chatSessionId: chat, - data, - }))] }; + return { + kind: "execute", + label: "terminal write", + steps: [ + actionStep( + "result", + "terminal", + "write", + collectGenericObjectArgs(args, { + terminalId: terminal ?? firstPositional(args), + ptyId, + chatSessionId: chat, + data, + }), + ), + ], + }; } if (sub === "signal" || sub === "interrupt" || sub === "stop") { const terminal = readValue(args, ["--terminal", "--terminal-id"]); const ptyId = readValue(args, ["--pty", "--pty-id"]); const chat = chatSessionId(); - const signal = readValue(args, ["--signal"]) ?? (sub === "stop" ? "SIGTERM" : "SIGINT"); - return { kind: "execute", label: "terminal signal", steps: [actionStep("result", "terminal", "signal", collectGenericObjectArgs(args, { - terminalId: terminal ?? firstPositional(args), - ptyId, - chatSessionId: chat, - signal, - }))] }; + const signal = + readValue(args, ["--signal"]) ?? (sub === "stop" ? "SIGTERM" : "SIGINT"); + return { + kind: "execute", + label: "terminal signal", + steps: [ + actionStep( + "result", + "terminal", + "signal", + collectGenericObjectArgs(args, { + terminalId: terminal ?? firstPositional(args), + ptyId, + chatSessionId: chat, + signal, + }), + ), + ], + }; } - return { kind: "execute", label: `terminal ${sub}`, steps: [actionStep("result", "terminal", sub, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `terminal ${sub}`, + steps: [ + actionStep("result", "terminal", sub, collectGenericObjectArgs(args)), + ], + }; } function buildChatPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; - if (sub === "actions") return { kind: "execute", label: "chat actions", steps: [listActionsStep("actions", "chat")] }; - const sessionId = readValue(args, ["--session", "--session-id"]) ?? (sub !== "create" && sub !== "list" ? firstPositional(args) : null); - const withSession = (base: JsonObject = {}) => collectGenericObjectArgs(args, { ...base, ...(sessionId ? { sessionId } : {}) }); - if (sub === "list" || sub === "ls") return { kind: "execute", label: "chat list", steps: [actionStep("result", "chat", "listSessions", collectGenericObjectArgs(args))] }; - if (sub === "show" || sub === "status") return { kind: "execute", label: "chat status", steps: [actionArgsListStep("result", "chat", "getSessionSummary", [requireValue(sessionId, "sessionId")])] }; + if (sub === "actions") + return { + kind: "execute", + label: "chat actions", + steps: [listActionsStep("actions", "chat")], + }; + const sessionId = + readValue(args, ["--session", "--session-id"]) ?? + (sub !== "create" && sub !== "list" ? firstPositional(args) : null); + const withSession = (base: JsonObject = {}) => + collectGenericObjectArgs(args, { + ...base, + ...(sessionId ? { sessionId } : {}), + }); + if (sub === "list" || sub === "ls") + return { + kind: "execute", + label: "chat list", + steps: [ + actionStep( + "result", + "chat", + "listSessions", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "show" || sub === "status") + return { + kind: "execute", + label: "chat status", + steps: [ + actionArgsListStep("result", "chat", "getSessionSummary", [ + requireValue(sessionId, "sessionId"), + ]), + ], + }; if (sub === "create" || sub === "spawn") { const modelArg = readValue(args, ["--model", "--model-id"]); const fastRequested = readFlag(args, ["--fast", "--codex-fast"]); - const standardRequested = readFlag(args, ["--standard", "--no-fast", "--no-codex-fast"]); + const standardRequested = readFlag(args, [ + "--standard", + "--no-fast", + "--no-codex-fast", + ]); if (fastRequested && standardRequested) { throw new CliUsageError( "Use either --fast/--codex-fast or --standard/--no-fast/--no-codex-fast, not both.", ); } - const codexFastMode: boolean | undefined = fastRequested ? true : standardRequested ? false : undefined; - return { kind: "execute", label: "chat create", steps: [actionStep("result", "chat", "createSession", collectGenericObjectArgs(args, { laneId: readLaneId(args), provider: readValue(args, ["--provider"]), model: modelArg, modelId: modelArg, permissionMode: readValue(args, ["--permission-mode", "--permissions"]), droidPermissionMode: readValue(args, ["--droid-permission-mode", "--droid-autonomy", "--autonomy"]), title: readValue(args, ["--title"]), surface: readValue(args, ["--surface"]) ?? "work", ...(codexFastMode !== undefined ? { codexFastMode } : {}) }))] }; - } - if (sub === "send") return { kind: "execute", label: "chat send", steps: [actionStep("result", "chat", "sendMessage", withSession({ sessionId: requireValue(sessionId, "sessionId"), text: requireValue(readValue(args, ["--text", "--message"]) ?? args.join(" "), "message text") }))] }; - if (sub === "interrupt") return { kind: "execute", label: "chat interrupt", steps: [actionStep("result", "chat", "interrupt", withSession({ sessionId: requireValue(sessionId, "sessionId") }))] }; - if (sub === "resume") return { kind: "execute", label: "chat resume", steps: [actionStep("result", "chat", "resumeSession", withSession())] }; - if (sub === "delete" || sub === "rm") return { kind: "execute", label: "chat delete", steps: [actionStep("result", "chat", "deleteSession", withSession())] }; - if (sub === "models") return { kind: "execute", label: "chat models", steps: [actionStep("result", "chat", "getAvailableModels", collectGenericObjectArgs(args))] }; - if (sub === "slash") return { kind: "execute", label: "chat slash commands", steps: [actionStep("result", "chat", "getSlashCommands", collectGenericObjectArgs(args))] }; - return { kind: "execute", label: `chat ${sub}`, steps: [actionStep("result", "chat", sub, withSession())] }; -} - -function buildTestsPlan(args: string[]): CliPlan { - const sub = firstPositional(args) ?? "list"; - if (sub === "actions") return { kind: "execute", label: "test actions", steps: [listActionsStep("actions", "tests")] }; - if (sub === "list" || sub === "suites") return { kind: "execute", label: "test suites", steps: [actionStep("result", "tests", "listSuites", collectGenericObjectArgs(args))] }; - if (sub === "run") { - const laneId = requireValue(readLaneId(args), "laneId"); - const suiteId = readValue(args, ["--suite", "--suite-id"]) ?? firstPositional(args); - const command = readValue(args, ["--command", "-c"]); - if (!suiteId && !command) throw new CliUsageError("tests run requires --suite or --command ."); - const input = collectGenericObjectArgs(args, { - laneId, - suiteId, - command, - waitForCompletion: readFlag(args, ["--wait"]), - timeoutMs: readIntOption(args, ["--timeout-ms"]), - maxLogBytes: readIntOption(args, ["--max-log-bytes"]), - }); - return { kind: "execute", label: "test run", steps: [actionCallStep("result", "run_tests", input)] }; + const codexFastMode: boolean | undefined = fastRequested + ? true + : standardRequested + ? false + : undefined; + return { + kind: "execute", + label: "chat create", + steps: [ + actionStep( + "result", + "chat", + "createSession", + collectGenericObjectArgs(args, { + laneId: readLaneId(args), + provider: readValue(args, ["--provider"]), + model: modelArg, + modelId: modelArg, + permissionMode: readValue(args, [ + "--permission-mode", + "--permissions", + ]), + droidPermissionMode: readValue(args, [ + "--droid-permission-mode", + "--droid-autonomy", + "--autonomy", + ]), + title: readValue(args, ["--title"]), + surface: readValue(args, ["--surface"]) ?? "work", + ...(codexFastMode !== undefined ? { codexFastMode } : {}), + }), + ), + ], + }; } - if (sub === "stop") return { kind: "execute", label: "test stop", steps: [actionStep("result", "tests", "stop", collectGenericObjectArgs(args, { runId: requireValue(readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), "runId") }))] }; - if (sub === "runs") return { kind: "execute", label: "test runs", steps: [actionStep("result", "tests", "listRuns", collectGenericObjectArgs(args, { laneId: readLaneId(args), suiteId: readValue(args, ["--suite", "--suite-id"]), limit: readIntOption(args, ["--limit"]) }))] }; - if (sub === "logs" || sub === "log") return { kind: "execute", label: "test logs", steps: [actionStep("result", "tests", "getLogTail", collectGenericObjectArgs(args, { runId: requireValue(readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), "runId"), maxBytes: readIntOption(args, ["--max-bytes"], 220_000) }))] }; - return { kind: "execute", label: `tests ${sub}`, steps: [actionStep("result", "tests", sub, collectGenericObjectArgs(args))] }; -} - -function readFileTextInput(args: string[]): string | undefined { - const text = readValue(args, ["--text"]); - if (text != null) return text; - const filePath = readValue(args, ["--from-file"]); - if (filePath != null) return fs.readFileSync(path.resolve(filePath), "utf8"); + if (sub === "send") + return { + kind: "execute", + label: "chat send", + steps: [ + actionStep( + "result", + "chat", + "sendMessage", + withSession({ + sessionId: requireValue(sessionId, "sessionId"), + text: requireValue( + readValue(args, ["--text", "--message"]) ?? args.join(" "), + "message text", + ), + }), + ), + ], + }; + if (sub === "interrupt") + return { + kind: "execute", + label: "chat interrupt", + steps: [ + actionStep( + "result", + "chat", + "interrupt", + withSession({ sessionId: requireValue(sessionId, "sessionId") }), + ), + ], + }; + if (sub === "resume") + return { + kind: "execute", + label: "chat resume", + steps: [actionStep("result", "chat", "resumeSession", withSession())], + }; + if (sub === "delete" || sub === "rm") + return { + kind: "execute", + label: "chat delete", + steps: [actionStep("result", "chat", "deleteSession", withSession())], + }; + if (sub === "models") + return { + kind: "execute", + label: "chat models", + steps: [ + actionStep( + "result", + "chat", + "getAvailableModels", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "slash") + return { + kind: "execute", + label: "chat slash commands", + steps: [ + actionStep( + "result", + "chat", + "getSlashCommands", + collectGenericObjectArgs(args), + ), + ], + }; + return { + kind: "execute", + label: `chat ${sub}`, + steps: [actionStep("result", "chat", sub, withSession())], + }; +} + +function buildTestsPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "list"; + if (sub === "actions") + return { + kind: "execute", + label: "test actions", + steps: [listActionsStep("actions", "tests")], + }; + if (sub === "list" || sub === "suites") + return { + kind: "execute", + label: "test suites", + steps: [ + actionStep( + "result", + "tests", + "listSuites", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "run") { + const laneId = requireValue(readLaneId(args), "laneId"); + const suiteId = + readValue(args, ["--suite", "--suite-id"]) ?? firstPositional(args); + const command = readValue(args, ["--command", "-c"]); + if (!suiteId && !command) + throw new CliUsageError( + "tests run requires --suite or --command .", + ); + const input = collectGenericObjectArgs(args, { + laneId, + suiteId, + command, + waitForCompletion: readFlag(args, ["--wait"]), + timeoutMs: readIntOption(args, ["--timeout-ms"]), + maxLogBytes: readIntOption(args, ["--max-log-bytes"]), + }); + return { + kind: "execute", + label: "test run", + steps: [actionCallStep("result", "run_tests", input)], + }; + } + if (sub === "stop") + return { + kind: "execute", + label: "test stop", + steps: [ + actionStep( + "result", + "tests", + "stop", + collectGenericObjectArgs(args, { + runId: requireValue( + readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), + "runId", + ), + }), + ), + ], + }; + if (sub === "runs") + return { + kind: "execute", + label: "test runs", + steps: [ + actionStep( + "result", + "tests", + "listRuns", + collectGenericObjectArgs(args, { + laneId: readLaneId(args), + suiteId: readValue(args, ["--suite", "--suite-id"]), + limit: readIntOption(args, ["--limit"]), + }), + ), + ], + }; + if (sub === "logs" || sub === "log") + return { + kind: "execute", + label: "test logs", + steps: [ + actionStep( + "result", + "tests", + "getLogTail", + collectGenericObjectArgs(args, { + runId: requireValue( + readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), + "runId", + ), + maxBytes: readIntOption(args, ["--max-bytes"], 220_000), + }), + ), + ], + }; + return { + kind: "execute", + label: `tests ${sub}`, + steps: [actionStep("result", "tests", sub, collectGenericObjectArgs(args))], + }; +} + +function readFileTextInput(args: string[]): string | undefined { + const text = readValue(args, ["--text"]); + if (text != null) return text; + const filePath = readValue(args, ["--from-file"]); + if (filePath != null) return fs.readFileSync(path.resolve(filePath), "utf8"); if (readFlag(args, ["--stdin"])) return fs.readFileSync(0, "utf8"); return undefined; } function buildFilesPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "workspaces"; - if (sub === "actions") return { kind: "execute", label: "file actions", steps: [listActionsStep("actions", "file")] }; + if (sub === "actions") + return { + kind: "execute", + label: "file actions", + steps: [listActionsStep("actions", "file")], + }; const workspaceId = readValue(args, ["--workspace", "--workspace-id"]); - const withWorkspace = (base: JsonObject = {}) => collectGenericObjectArgs(args, { ...base, ...(workspaceId ? { workspaceId } : {}) }); + const withWorkspace = (base: JsonObject = {}) => + collectGenericObjectArgs(args, { + ...base, + ...(workspaceId ? { workspaceId } : {}), + }); if (sub === "workspaces" || sub === "workspace" || sub === "roots") { - return { kind: "execute", label: "file workspaces", steps: [actionStep("result", "file", "listWorkspaces", collectGenericObjectArgs(args, { laneId: readLaneId(args) }))] }; + return { + kind: "execute", + label: "file workspaces", + steps: [ + actionStep( + "result", + "file", + "listWorkspaces", + collectGenericObjectArgs(args, { laneId: readLaneId(args) }), + ), + ], + }; } if (sub === "tree" || sub === "ls") { - return { kind: "execute", label: "file tree", steps: [actionStep("result", "file", "listTree", withWorkspace({ parentPath: readValue(args, ["--path"]) ?? firstPositional(args), depth: readIntOption(args, ["--depth"]), includeIgnored: readFlag(args, ["--include-ignored"]) }))] }; + return { + kind: "execute", + label: "file tree", + steps: [ + actionStep( + "result", + "file", + "listTree", + withWorkspace({ + parentPath: readValue(args, ["--path"]) ?? firstPositional(args), + depth: readIntOption(args, ["--depth"]), + includeIgnored: readFlag(args, ["--include-ignored"]), + }), + ), + ], + }; } if (sub === "read" || sub === "cat") { - return { kind: "execute", label: "file read", steps: [actionStep("result", "file", "readFile", withWorkspace({ path: requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path") }))] }; + return { + kind: "execute", + label: "file read", + steps: [ + actionStep( + "result", + "file", + "readFile", + withWorkspace({ + path: requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ), + }), + ), + ], + }; } if (sub === "write") { const text = readFileTextInput(args); - if (text == null) throw new CliUsageError("files write requires --text, --from-file, or --stdin."); - return { kind: "execute", label: "file write", steps: [actionStep("result", "file", "writeWorkspaceText", withWorkspace({ path: requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path"), text }))] }; + if (text == null) + throw new CliUsageError( + "files write requires --text, --from-file, or --stdin.", + ); + return { + kind: "execute", + label: "file write", + steps: [ + actionStep( + "result", + "file", + "writeWorkspaceText", + withWorkspace({ + path: requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ), + text, + }), + ), + ], + }; } if (sub === "create") { - return { kind: "execute", label: "file create", steps: [actionStep("result", "file", "createFile", withWorkspace({ path: requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path"), content: readFileTextInput(args) ?? "" }))] }; + return { + kind: "execute", + label: "file create", + steps: [ + actionStep( + "result", + "file", + "createFile", + withWorkspace({ + path: requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ), + content: readFileTextInput(args) ?? "", + }), + ), + ], + }; } if (sub === "mkdir") { - return { kind: "execute", label: "file mkdir", steps: [actionStep("result", "file", "createDirectory", withWorkspace({ path: requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path") }))] }; + return { + kind: "execute", + label: "file mkdir", + steps: [ + actionStep( + "result", + "file", + "createDirectory", + withWorkspace({ + path: requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ), + }), + ), + ], + }; } if (sub === "rename" || sub === "mv") { - return { kind: "execute", label: "file rename", steps: [actionStep("result", "file", "rename", withWorkspace({ oldPath: readValue(args, ["--old", "--old-path"]) ?? firstPositional(args), newPath: readValue(args, ["--new", "--new-path"]) ?? firstPositional(args) }))] }; + return { + kind: "execute", + label: "file rename", + steps: [ + actionStep( + "result", + "file", + "rename", + withWorkspace({ + oldPath: + readValue(args, ["--old", "--old-path"]) ?? firstPositional(args), + newPath: + readValue(args, ["--new", "--new-path"]) ?? firstPositional(args), + }), + ), + ], + }; } if (sub === "delete" || sub === "rm") { - return { kind: "execute", label: "file delete", steps: [actionStep("result", "file", "deletePath", withWorkspace({ path: requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path") }))] }; + return { + kind: "execute", + label: "file delete", + steps: [ + actionStep( + "result", + "file", + "deletePath", + withWorkspace({ + path: requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ), + }), + ), + ], + }; } if (sub === "quick-open") { - return { kind: "execute", label: "file quick-open", steps: [actionStep("result", "file", "quickOpen", withWorkspace({ query: readValue(args, ["--query", "-q"]) ?? args.join(" "), limit: readIntOption(args, ["--limit"]), includeIgnored: readFlag(args, ["--include-ignored"]) }))] }; + return { + kind: "execute", + label: "file quick-open", + steps: [ + actionStep( + "result", + "file", + "quickOpen", + withWorkspace({ + query: readValue(args, ["--query", "-q"]) ?? args.join(" "), + limit: readIntOption(args, ["--limit"]), + includeIgnored: readFlag(args, ["--include-ignored"]), + }), + ), + ], + }; } if (sub === "search") { - return { kind: "execute", label: "file search", steps: [actionStep("result", "file", "searchText", withWorkspace({ query: requireValue(readValue(args, ["--query", "-q"]) ?? args.join(" "), "query"), limit: readIntOption(args, ["--limit"]), includeIgnored: readFlag(args, ["--include-ignored"]) }))] }; + return { + kind: "execute", + label: "file search", + steps: [ + actionStep( + "result", + "file", + "searchText", + withWorkspace({ + query: requireValue( + readValue(args, ["--query", "-q"]) ?? args.join(" "), + "query", + ), + limit: readIntOption(args, ["--limit"]), + includeIgnored: readFlag(args, ["--include-ignored"]), + }), + ), + ], + }; } - return { kind: "execute", label: `files ${sub}`, steps: [actionStep("result", "file", sub, withWorkspace())] }; + return { + kind: "execute", + label: `files ${sub}`, + steps: [actionStep("result", "file", sub, withWorkspace())], + }; } function buildProofPlan(args: string[]): CliPlan { @@ -3103,193 +5546,664 @@ function buildProofPlan(args: string[]): CliPlan { }; const inferAttachedProofKind = (filePath: string): string => { const ext = path.extname(filePath).replace(/^\./, "").toLowerCase(); - if (["png", "jpg", "jpeg", "webp", "gif", "heic", "heif", "tif", "tiff"].includes(ext)) return "screenshot"; + if ( + [ + "png", + "jpg", + "jpeg", + "webp", + "gif", + "heic", + "heif", + "tif", + "tiff", + ].includes(ext) + ) + return "screenshot"; if (["mov", "mp4", "m4v", "webm"].includes(ext)) return "video_recording"; if (["zip", "har"].includes(ext)) return "browser_trace"; return "browser_verification"; }; - if (sub === "actions") return { kind: "execute", label: "proof actions", steps: [listActionsStep("actions", "computer_use_artifacts")] }; - if (sub === "status" || sub === "backends") return { kind: "execute", label: "proof backend status", steps: [actionCallStep("result", "get_computer_use_backend_status", collectGenericObjectArgs(args))] }; - if (sub === "environment") return { kind: "execute", label: "computer-use environment", steps: [actionCallStep("result", "get_environment_info", collectGenericObjectArgs(args, proofOwnerBase()))], preferHeadless: true }; - if (sub === "list" || sub === "ls") return { kind: "execute", label: "proof list", steps: [actionCallStep("result", "list_computer_use_artifacts", collectGenericObjectArgs(args))] }; - if (sub === "ingest") return { kind: "execute", label: "proof ingest", steps: [actionCallStep("result", "ingest_computer_use_artifacts", collectGenericObjectArgs(args))] }; + if (sub === "actions") + return { + kind: "execute", + label: "proof actions", + steps: [listActionsStep("actions", "computer_use_artifacts")], + }; + if (sub === "status" || sub === "backends") + return { + kind: "execute", + label: "proof backend status", + steps: [ + actionCallStep( + "result", + "get_computer_use_backend_status", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "environment") + return { + kind: "execute", + label: "computer-use environment", + steps: [ + actionCallStep( + "result", + "get_environment_info", + collectGenericObjectArgs(args, proofOwnerBase()), + ), + ], + preferHeadless: true, + }; + if (sub === "list" || sub === "ls") + return { + kind: "execute", + label: "proof list", + steps: [ + actionCallStep( + "result", + "list_computer_use_artifacts", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "ingest") + return { + kind: "execute", + label: "proof ingest", + steps: [ + actionCallStep( + "result", + "ingest_computer_use_artifacts", + collectGenericObjectArgs(args), + ), + ], + }; if (sub === "attach") { const caption = readValue(args, ["--caption", "--description", "--desc"]); - const attachedPath = requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path"); - const title = readValue(args, ["--title", "--name"]) ?? caption ?? path.basename(attachedPath); + const attachedPath = requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ); + const title = + readValue(args, ["--title", "--name"]) ?? + caption ?? + path.basename(attachedPath); return { kind: "execute", label: "proof attach", - steps: [actionCallStep("result", "ingest_computer_use_artifacts", collectGenericObjectArgs(args, { - backendStyle: "manual", - backendName: "ade-cli", - toolName: "proof attach", - ...proofOwnerBase(), - inputs: [{ - kind: inferAttachedProofKind(attachedPath), - title, - ...(caption ? { description: caption } : {}), - path: attachedPath, - }], - }))], + steps: [ + actionCallStep( + "result", + "ingest_computer_use_artifacts", + collectGenericObjectArgs(args, { + backendStyle: "manual", + backendName: "ade-cli", + toolName: "proof attach", + ...proofOwnerBase(), + inputs: [ + { + kind: inferAttachedProofKind(attachedPath), + title, + ...(caption ? { description: caption } : {}), + path: attachedPath, + }, + ], + }), + ), + ], }; } if (sub === "screenshot" || sub === "capture") { const caption = readValue(args, ["--caption", "--description", "--desc"]); - return { kind: "execute", label: "computer-use screenshot", steps: [actionCallStep("result", "screenshot_environment", collectGenericObjectArgs(args, { ...proofOwnerBase(), name: readValue(args, ["--name", "--title"]) ?? caption }))], preferHeadless: true }; + return { + kind: "execute", + label: "computer-use screenshot", + steps: [ + actionCallStep( + "result", + "screenshot_environment", + collectGenericObjectArgs(args, { + ...proofOwnerBase(), + name: readValue(args, ["--name", "--title"]) ?? caption, + }), + ), + ], + preferHeadless: true, + }; } - if (sub === "record") return { kind: "execute", label: "computer-use record", steps: [actionCallStep("result", "record_environment", collectGenericObjectArgs(args, { ...proofOwnerBase(), name: readValue(args, ["--name", "--title"]) ?? readValue(args, ["--caption", "--description", "--desc"]), durationSec: readNumberOption(args, ["--seconds", "--duration-sec"]) }))], preferHeadless: true }; - if (sub === "launch") return { kind: "execute", label: "computer-use launch", steps: [actionCallStep("result", "launch_app", collectGenericObjectArgs(args, { app: readValue(args, ["--app"]) ?? firstPositional(args) }))], preferHeadless: true }; - if (sub === "interact") return { kind: "execute", label: "computer-use interact", steps: [actionCallStep("result", "interact_gui", collectGenericObjectArgs(args, proofOwnerBase()))], preferHeadless: true }; - return { kind: "execute", label: `proof ${sub}`, steps: [actionStep("result", "computer_use_artifacts", sub, collectGenericObjectArgs(args))] }; + if (sub === "record") + return { + kind: "execute", + label: "computer-use record", + steps: [ + actionCallStep( + "result", + "record_environment", + collectGenericObjectArgs(args, { + ...proofOwnerBase(), + name: + readValue(args, ["--name", "--title"]) ?? + readValue(args, ["--caption", "--description", "--desc"]), + durationSec: readNumberOption(args, [ + "--seconds", + "--duration-sec", + ]), + }), + ), + ], + preferHeadless: true, + }; + if (sub === "launch") + return { + kind: "execute", + label: "computer-use launch", + steps: [ + actionCallStep( + "result", + "launch_app", + collectGenericObjectArgs(args, { + app: readValue(args, ["--app"]) ?? firstPositional(args), + }), + ), + ], + preferHeadless: true, + }; + if (sub === "interact") + return { + kind: "execute", + label: "computer-use interact", + steps: [ + actionCallStep( + "result", + "interact_gui", + collectGenericObjectArgs(args, proofOwnerBase()), + ), + ], + preferHeadless: true, + }; + return { + kind: "execute", + label: `proof ${sub}`, + steps: [ + actionStep( + "result", + "computer_use_artifacts", + sub, + collectGenericObjectArgs(args), + ), + ], + }; } function buildIosSimulatorPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "status"; - if (sub === "help") return { kind: "help", text: buildIosSimulatorHelp(args) }; - const numericPositionals = () => args.filter((value) => /^\d+(\.\d+)?$/.test(value)); + if (sub === "help") + return { kind: "help", text: buildIosSimulatorHelp(args) }; + const numericPositionals = () => + args.filter((value) => /^\d+(\.\d+)?$/.test(value)); const readCoordinate = (flag: string, index: number): number => { - const value = readNumberOption(args, [flag]) ?? Number(numericPositionals()[index]); - if (!Number.isFinite(value)) throw new CliUsageError(`${flag} is required and must be a number.`); + const value = + readNumberOption(args, [flag]) ?? Number(numericPositionals()[index]); + if (!Number.isFinite(value)) + throw new CliUsageError(`${flag} is required and must be a number.`); return value; }; - if (sub === "actions") return { kind: "execute", label: "iOS simulator actions", steps: [listActionsStep("actions", "ios_simulator")] }; - if (sub === "status") return { kind: "execute", label: "iOS simulator status", steps: [actionStep("result", "ios_simulator", "getStatus", collectGenericObjectArgs(args))] }; - if (sub === "devices" || sub === "list" || sub === "ls") return { kind: "execute", label: "iOS simulator devices", steps: [actionStep("result", "ios_simulator", "listDevices", collectGenericObjectArgs(args))] }; - if (sub === "apps" || sub === "targets" || sub === "launchable" || sub === "launchables") { - return { kind: "execute", label: "iOS simulator launchable apps", steps: [actionStep("result", "ios_simulator", "listLaunchTargets", collectGenericObjectArgs(args, { deviceUdid: readValue(args, ["--device", "--udid"]), projectRoot: readValue(args, ["--project-root", "--root"]) }))] }; + if (sub === "actions") + return { + kind: "execute", + label: "iOS simulator actions", + steps: [listActionsStep("actions", "ios_simulator")], + }; + if (sub === "status") + return { + kind: "execute", + label: "iOS simulator status", + steps: [ + actionStep( + "result", + "ios_simulator", + "getStatus", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "devices" || sub === "list" || sub === "ls") + return { + kind: "execute", + label: "iOS simulator devices", + steps: [ + actionStep( + "result", + "ios_simulator", + "listDevices", + collectGenericObjectArgs(args), + ), + ], + }; + if ( + sub === "apps" || + sub === "targets" || + sub === "launchable" || + sub === "launchables" + ) { + return { + kind: "execute", + label: "iOS simulator launchable apps", + steps: [ + actionStep( + "result", + "ios_simulator", + "listLaunchTargets", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + }), + ), + ], + }; } if (sub === "launch" || sub === "open") { return { kind: "execute", label: "iOS simulator launch", - steps: [actionStep("result", "ios_simulator", "launch", collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - projectRoot: readValue(args, ["--project-root", "--root"]), - laneId: readValue(args, ["--lane", "--lane-id"]), - targetId: readValue(args, ["--target", "--target-id"]), - bundleId: readValue(args, ["--bundle-id", "--bundle"]), - appBundlePath: readValue(args, ["--app-bundle", "--app"]), - projectPath: readValue(args, ["--project", "--xcodeproj"]), - scheme: readValue(args, ["--scheme"]), - chatSessionId: readValue(args, ["--chat-session", "--session"]) ?? process.env.ADE_CHAT_SESSION_ID, - build: !readFlag(args, ["--no-build"]), - mode: readValue(args, ["--mode"]) ?? "live", - keepSimulatorInBackground: !readFlag(args, ["--foreground"]), - }))], + steps: [ + actionStep( + "result", + "ios_simulator", + "launch", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + laneId: readValue(args, ["--lane", "--lane-id"]), + targetId: readValue(args, ["--target", "--target-id"]), + bundleId: readValue(args, ["--bundle-id", "--bundle"]), + appBundlePath: readValue(args, ["--app-bundle", "--app"]), + projectPath: readValue(args, ["--project", "--xcodeproj"]), + scheme: readValue(args, ["--scheme"]), + chatSessionId: + readValue(args, ["--chat-session", "--session"]) ?? + process.env.ADE_CHAT_SESSION_ID, + build: !readFlag(args, ["--no-build"]), + mode: readValue(args, ["--mode"]) ?? "live", + keepSimulatorInBackground: !readFlag(args, ["--foreground"]), + }), + ), + ], }; } if (sub === "screenshot" || sub === "capture") { - return { kind: "execute", label: "iOS simulator screenshot", steps: [actionStep("result", "ios_simulator", "screenshot", collectGenericObjectArgs(args, { deviceUdid: readValue(args, ["--device", "--udid"]) }))] }; + return { + kind: "execute", + label: "iOS simulator screenshot", + steps: [ + actionStep( + "result", + "ios_simulator", + "screenshot", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + }), + ), + ], + }; } if (sub === "inspector") { - return { kind: "execute", label: "iOS simulator inspector snapshot", steps: [actionStep("result", "ios_simulator", "getInspectorSnapshot", collectGenericObjectArgs(args, { deviceUdid: readValue(args, ["--device", "--udid"]) }))] }; + return { + kind: "execute", + label: "iOS simulator inspector snapshot", + steps: [ + actionStep( + "result", + "ios_simulator", + "getInspectorSnapshot", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + }), + ), + ], + }; } if (sub === "preview-status" || sub === "preview-doctor") { - return { kind: "execute", label: "iOS simulator preview status", steps: [actionStep("result", "ios_simulator", "getPreviewCapability", collectGenericObjectArgs(args, { projectRoot: readValue(args, ["--project-root", "--root"]), sourceFile: readValue(args, ["--source", "--file"]), sourceLine: readNumberOption(args, ["--line"]) }))] }; + return { + kind: "execute", + label: "iOS simulator preview status", + steps: [ + actionStep( + "result", + "ios_simulator", + "getPreviewCapability", + collectGenericObjectArgs(args, { + projectRoot: readValue(args, ["--project-root", "--root"]), + sourceFile: readValue(args, ["--source", "--file"]), + sourceLine: readNumberOption(args, ["--line"]), + }), + ), + ], + }; } if (sub === "previews" || sub === "preview-list" || sub === "list-previews") { - return { kind: "execute", label: "iOS simulator previews", steps: [actionStep("result", "ios_simulator", "listPreviewTargets", collectGenericObjectArgs(args, { projectRoot: readValue(args, ["--project-root", "--root"]), sourceFile: readValue(args, ["--source", "--file"]), sourceLine: readNumberOption(args, ["--line"]) }))] }; + return { + kind: "execute", + label: "iOS simulator previews", + steps: [ + actionStep( + "result", + "ios_simulator", + "listPreviewTargets", + collectGenericObjectArgs(args, { + projectRoot: readValue(args, ["--project-root", "--root"]), + sourceFile: readValue(args, ["--source", "--file"]), + sourceLine: readNumberOption(args, ["--line"]), + }), + ), + ], + }; } - if (sub === "preview-render" || sub === "render-preview" || sub === "preview") { - return { kind: "execute", label: "iOS simulator preview render", steps: [actionStep("result", "ios_simulator", "renderPreview", collectGenericObjectArgs(args, { - projectRoot: readValue(args, ["--project-root", "--root"]), - sourceFilePath: requireValue(readValue(args, ["--source", "--file"]), "sourceFilePath"), - previewDefinitionIndexInFile: readNumberOption(args, ["--index"], 0), - tabIdentifier: readValue(args, ["--tab", "--tab-identifier"]), - timeoutSec: readNumberOption(args, ["--timeout"], 120), - }))] }; + if ( + sub === "preview-render" || + sub === "render-preview" || + sub === "preview" + ) { + return { + kind: "execute", + label: "iOS simulator preview render", + steps: [ + actionStep( + "result", + "ios_simulator", + "renderPreview", + collectGenericObjectArgs(args, { + projectRoot: readValue(args, ["--project-root", "--root"]), + sourceFilePath: requireValue( + readValue(args, ["--source", "--file"]), + "sourceFilePath", + ), + previewDefinitionIndexInFile: readNumberOption( + args, + ["--index"], + 0, + ), + tabIdentifier: readValue(args, ["--tab", "--tab-identifier"]), + timeoutSec: readNumberOption(args, ["--timeout"], 120), + }), + ), + ], + }; } - if (sub === "preview-open" || sub === "open-preview-workspace" || sub === "open-xcode") { - return { kind: "execute", label: "iOS simulator preview open", steps: [actionStep("result", "ios_simulator", "openPreviewWorkspace", collectGenericObjectArgs(args, { projectRoot: readValue(args, ["--project-root", "--root"]) }))] }; + if ( + sub === "preview-open" || + sub === "open-preview-workspace" || + sub === "open-xcode" + ) { + return { + kind: "execute", + label: "iOS simulator preview open", + steps: [ + actionStep( + "result", + "ios_simulator", + "openPreviewWorkspace", + collectGenericObjectArgs(args, { + projectRoot: readValue(args, ["--project-root", "--root"]), + }), + ), + ], + }; } if (sub === "snapshot" || sub === "screen" || sub === "elements") { - return { kind: "execute", label: "iOS simulator screen snapshot", steps: [actionStep("result", "ios_simulator", "getScreenSnapshot", collectGenericObjectArgs(args, { deviceUdid: readValue(args, ["--device", "--udid"]), projectRoot: readValue(args, ["--project-root", "--root"]) }))] }; + return { + kind: "execute", + label: "iOS simulator screen snapshot", + steps: [ + actionStep( + "result", + "ios_simulator", + "getScreenSnapshot", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + }), + ), + ], + }; } if (sub === "inspect" || sub === "hit-test" || sub === "hover") { - return { kind: "execute", label: "iOS simulator inspect point", steps: [actionStep("result", "ios_simulator", "inspectPoint", collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - projectRoot: readValue(args, ["--project-root", "--root"]), - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - includeScreenshot: readFlag(args, ["--screenshot", "--include-screenshot"]), - }))] }; - } - if (sub === "stream-start" || sub === "start-stream" || sub === "stream" || sub === "preview-start" || sub === "start-preview" || sub === "live-start" || sub === "start-live" || sub === "window-start" || sub === "start-window" || sub === "mirror-start" || sub === "start-mirror") { - const forcedBackend = sub === "preview-start" || sub === "start-preview" - ? "simctl-screenshot-poll" - : sub === "window-start" || sub === "start-window" || sub === "mirror-start" || sub === "start-mirror" - ? "simulator-window-capture" - : sub === "live-start" || sub === "start-live" - ? "auto" - : undefined; - const requestedBackend = forcedBackend - ?? (readFlag(args, ["--window", "--mirror"]) ? "simulator-window-capture" : readFlag(args, ["--idb", "--live"]) ? "auto" : readFlag(args, ["--simctl", "--preview"]) ? "simctl-screenshot-poll" : readValue(args, ["--backend"]) ?? "auto"); - const defaultFps = requestedBackend === "simulator-window-capture" - ? 60 - : requestedBackend === "iosurface-indigo" || requestedBackend === "idb-mjpeg" || requestedBackend === "idb-h264-ffmpeg-mjpeg" - ? 30 - : requestedBackend === "simctl-screenshot-poll" - ? 8 - : undefined; - return { kind: "execute", label: "iOS simulator stream start", steps: [actionStep("result", "ios_simulator", "startStream", collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - fps: readNumberOption(args, ["--fps"], defaultFps), - backend: requestedBackend, - }))] }; - } - if (sub === "stream-stop" || sub === "stop-stream" || sub === "preview-stop" || sub === "stop-preview" || sub === "live-stop" || sub === "stop-live") { - return { kind: "execute", label: "iOS simulator stream stop", steps: [actionStep("result", "ios_simulator", "stopStream", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "iOS simulator inspect point", + steps: [ + actionStep( + "result", + "ios_simulator", + "inspectPoint", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + includeScreenshot: readFlag(args, [ + "--screenshot", + "--include-screenshot", + ]), + }), + ), + ], + }; + } + if ( + sub === "stream-start" || + sub === "start-stream" || + sub === "stream" || + sub === "preview-start" || + sub === "start-preview" || + sub === "live-start" || + sub === "start-live" || + sub === "window-start" || + sub === "start-window" || + sub === "mirror-start" || + sub === "start-mirror" + ) { + const forcedBackend = + sub === "preview-start" || sub === "start-preview" + ? "simctl-screenshot-poll" + : sub === "window-start" || + sub === "start-window" || + sub === "mirror-start" || + sub === "start-mirror" + ? "simulator-window-capture" + : sub === "live-start" || sub === "start-live" + ? "auto" + : undefined; + const requestedBackend = + forcedBackend ?? + (readFlag(args, ["--window", "--mirror"]) + ? "simulator-window-capture" + : readFlag(args, ["--idb", "--live"]) + ? "auto" + : readFlag(args, ["--simctl", "--preview"]) + ? "simctl-screenshot-poll" + : (readValue(args, ["--backend"]) ?? "auto")); + const defaultFps = + requestedBackend === "simulator-window-capture" + ? 60 + : requestedBackend === "iosurface-indigo" || + requestedBackend === "idb-mjpeg" || + requestedBackend === "idb-h264-ffmpeg-mjpeg" + ? 30 + : requestedBackend === "simctl-screenshot-poll" + ? 8 + : undefined; + return { + kind: "execute", + label: "iOS simulator stream start", + steps: [ + actionStep( + "result", + "ios_simulator", + "startStream", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + fps: readNumberOption(args, ["--fps"], defaultFps), + backend: requestedBackend, + }), + ), + ], + }; + } + if ( + sub === "stream-stop" || + sub === "stop-stream" || + sub === "preview-stop" || + sub === "stop-preview" || + sub === "live-stop" || + sub === "stop-live" + ) { + return { + kind: "execute", + label: "iOS simulator stream stop", + steps: [ + actionStep( + "result", + "ios_simulator", + "stopStream", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "stream-status") { - return { kind: "execute", label: "iOS simulator stream status", steps: [actionStep("result", "ios_simulator", "getStreamStatus", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "iOS simulator stream status", + steps: [ + actionStep( + "result", + "ios_simulator", + "getStreamStatus", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "tap") { - return { kind: "execute", label: "iOS simulator tap", steps: [actionStep("result", "ios_simulator", "tap", collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - projectRoot: readValue(args, ["--project-root", "--root"]), - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - }))] }; + return { + kind: "execute", + label: "iOS simulator tap", + steps: [ + actionStep( + "result", + "ios_simulator", + "tap", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + }), + ), + ], + }; } if (sub === "drag" || sub === "swipe") { - return { kind: "execute", label: `iOS simulator ${sub}`, steps: [actionStep("result", "ios_simulator", sub, collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - projectRoot: readValue(args, ["--project-root", "--root"]), - startX: readCoordinate("--start-x", 0), - startY: readCoordinate("--start-y", 1), - endX: readCoordinate("--end-x", 2), - endY: readCoordinate("--end-y", 3), - durationMs: readNumberOption(args, ["--duration-ms", "--duration"]), - }))] }; + return { + kind: "execute", + label: `iOS simulator ${sub}`, + steps: [ + actionStep( + "result", + "ios_simulator", + sub, + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + startX: readCoordinate("--start-x", 0), + startY: readCoordinate("--start-y", 1), + endX: readCoordinate("--end-x", 2), + endY: readCoordinate("--end-y", 3), + durationMs: readNumberOption(args, ["--duration-ms", "--duration"]), + }), + ), + ], + }; } if (sub === "select") { - return { kind: "execute", label: "iOS simulator select", steps: [actionStep("result", "ios_simulator", "selectPoint", collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - projectRoot: readValue(args, ["--project-root", "--root"]), - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - }))] }; + return { + kind: "execute", + label: "iOS simulator select", + steps: [ + actionStep( + "result", + "ios_simulator", + "selectPoint", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + }), + ), + ], + }; } if (sub === "type" || sub === "text") { - return { kind: "execute", label: "iOS simulator type", steps: [actionStep("result", "ios_simulator", "typeText", collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - projectRoot: readValue(args, ["--project-root", "--root"]), - text: requireValue( - readValue(args, ["--value", "--message", "--input-text"]) - ?? readCommandTextValue(args, ["--text"]) - ?? args.filter((arg) => arg !== "--text").join(" "), - "text", - ), - }))] }; + return { + kind: "execute", + label: "iOS simulator type", + steps: [ + actionStep( + "result", + "ios_simulator", + "typeText", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + text: requireValue( + readValue(args, ["--value", "--message", "--input-text"]) ?? + readCommandTextValue(args, ["--text"]) ?? + args.filter((arg) => arg !== "--text").join(" "), + "text", + ), + }), + ), + ], + }; } - if (sub === "shutdown" || sub === "stop" || sub === "teardown" || sub === "end" || sub === "end-session") { - return { kind: "execute", label: "iOS simulator shutdown", steps: [actionStep("result", "ios_simulator", "shutdown", collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - force: readFlag(args, ["--force", "-f"]) ? true : undefined, - }))] }; + if ( + sub === "shutdown" || + sub === "stop" || + sub === "teardown" || + sub === "end" || + sub === "end-session" + ) { + return { + kind: "execute", + label: "iOS simulator shutdown", + steps: [ + actionStep( + "result", + "ios_simulator", + "shutdown", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + force: readFlag(args, ["--force", "-f"]) ? true : undefined, + }), + ), + ], + }; } - return { kind: "execute", label: `ios-sim ${sub}`, steps: [actionStep("result", "ios_simulator", sub, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `ios-sim ${sub}`, + steps: [ + actionStep( + "result", + "ios_simulator", + sub, + collectGenericObjectArgs(args), + ), + ], + }; } function readTrailingCommand(args: string[]): string | null { @@ -3309,39 +6223,108 @@ function readTrailingCommand(args: string[]): string | null { function buildAppControlPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "status"; if (sub === "help") return { kind: "help", text: buildAppControlHelp(args) }; - const numericPositionals = () => args.filter((value) => /^\d+(\.\d+)?$/.test(value)); + const numericPositionals = () => + args.filter((value) => /^\d+(\.\d+)?$/.test(value)); const readCoordinate = (flag: string, index: number): number => { - const value = readNumberOption(args, [flag]) ?? Number(numericPositionals()[index]); - if (!Number.isFinite(value)) throw new CliUsageError(`${flag} is required and must be a number.`); + const value = + readNumberOption(args, [flag]) ?? Number(numericPositionals()[index]); + if (!Number.isFinite(value)) + throw new CliUsageError(`${flag} is required and must be a number.`); return value; }; - if (sub === "actions") return { kind: "execute", label: "App Control actions", steps: [listActionsStep("actions", "app_control")] }; - if (sub === "status") return { kind: "execute", label: "App Control status", steps: [actionStep("result", "app_control", "getStatus", collectGenericObjectArgs(args))] }; + if (sub === "actions") + return { + kind: "execute", + label: "App Control actions", + steps: [listActionsStep("actions", "app_control")], + }; + if (sub === "status") + return { + kind: "execute", + label: "App Control status", + steps: [ + actionStep( + "result", + "app_control", + "getStatus", + collectGenericObjectArgs(args), + ), + ], + }; if (sub === "logs" || sub === "log" || sub === "read" || sub === "tail") { - return { kind: "execute", label: "terminal read", steps: [actionStep("result", "app_control", "readTerminal", collectGenericObjectArgs(args, { - maxBytes: readIntOption(args, ["--max-bytes"], undefined), - since: readIntOption(args, ["--since"], undefined), - }))] }; + return { + kind: "execute", + label: "terminal read", + steps: [ + actionStep( + "result", + "app_control", + "readTerminal", + collectGenericObjectArgs(args, { + maxBytes: readIntOption(args, ["--max-bytes"], undefined), + since: readIntOption(args, ["--since"], undefined), + }), + ), + ], + }; } if (sub === "terminal") { const mode = firstPositional(args) ?? "read"; if (mode === "read" || mode === "logs" || mode === "tail") { - return { kind: "execute", label: "terminal read", steps: [actionStep("result", "app_control", "readTerminal", collectGenericObjectArgs(args, { - maxBytes: readIntOption(args, ["--max-bytes"], undefined), - since: readIntOption(args, ["--since"], undefined), - }))] }; + return { + kind: "execute", + label: "terminal read", + steps: [ + actionStep( + "result", + "app_control", + "readTerminal", + collectGenericObjectArgs(args, { + maxBytes: readIntOption(args, ["--max-bytes"], undefined), + since: readIntOption(args, ["--since"], undefined), + }), + ), + ], + }; } if (mode === "write" || mode === "send" || mode === "input") { - const data = readValue(args, ["--data", "--value", "--text"]) ?? args.join(" "); + const data = + readValue(args, ["--data", "--value", "--text"]) ?? args.join(" "); if (!data.length) throw new CliUsageError("data is required."); - return { kind: "execute", label: "terminal write", steps: [actionStep("result", "app_control", "writeTerminal", collectGenericObjectArgs(args, { data }))] }; + return { + kind: "execute", + label: "terminal write", + steps: [ + actionStep( + "result", + "app_control", + "writeTerminal", + collectGenericObjectArgs(args, { data }), + ), + ], + }; } if (mode === "signal" || mode === "interrupt" || mode === "stop") { - return { kind: "execute", label: "terminal signal", steps: [actionStep("result", "app_control", "signalTerminal", collectGenericObjectArgs(args, { - signal: readValue(args, ["--signal"]) ?? (mode === "stop" ? "SIGTERM" : "SIGINT"), - }))] }; + return { + kind: "execute", + label: "terminal signal", + steps: [ + actionStep( + "result", + "app_control", + "signalTerminal", + collectGenericObjectArgs(args, { + signal: + readValue(args, ["--signal"]) ?? + (mode === "stop" ? "SIGTERM" : "SIGINT"), + }), + ), + ], + }; } - throw new CliUsageError("app-control terminal supports read, write, or signal."); + throw new CliUsageError( + "app-control terminal supports read, write, or signal.", + ); } if (sub === "launch" || sub === "open" || sub === "start") { const trailingCommand = readTrailingCommand(args); @@ -3353,120 +6336,299 @@ function buildAppControlPlan(args: string[]): CliPlan { const debugPort = readNumberOption(args, ["--debug-port", "--port"]); const cdpPort = readNumberOption(args, ["--cdp-port"]); const label = readValue(args, ["--label", "--name"]); - const chatSessionId = readValue(args, ["--chat-session", "--chat-session-id", "--session", "--session-id"]) ?? process.env.ADE_CHAT_SESSION_ID; + const chatSessionId = + readValue(args, [ + "--chat-session", + "--chat-session-id", + "--session", + "--session-id", + ]) ?? process.env.ADE_CHAT_SESSION_ID; const force = readFlag(args, ["--force", "-f"]) ? true : undefined; - const positionalCommand = args.filter((arg) => arg !== "--" && !arg.startsWith("-")).join(" ").trim(); - const launchCommand = command ?? (positionalCommand.length ? positionalCommand : null); - if (!launchCommand) throw new CliUsageError("app-control launch requires a command, for example: ade app-control launch --command \"pnpm dev\"."); + const positionalCommand = args + .filter((arg) => arg !== "--" && !arg.startsWith("-")) + .join(" ") + .trim(); + const launchCommand = + command ?? (positionalCommand.length ? positionalCommand : null); + if (!launchCommand) + throw new CliUsageError( + 'app-control launch requires a command, for example: ade app-control launch --command "pnpm dev".', + ); return { kind: "execute", label: "App Control launch", - steps: [actionStep("result", "app_control", "launch", collectGenericObjectArgs(args, { - appKind, - projectRoot, - laneId, - command: launchCommand, - cwd, - debugPort, - cdpPort, - label, - chatSessionId, - force, - }))], + steps: [ + actionStep( + "result", + "app_control", + "launch", + collectGenericObjectArgs(args, { + appKind, + projectRoot, + laneId, + command: launchCommand, + cwd, + debugPort, + cdpPort, + label, + chatSessionId, + force, + }), + ), + ], }; } if (sub === "connect" || sub === "attach") { - return { kind: "execute", label: "App Control connect", steps: [actionStep("result", "app_control", "connect", collectGenericObjectArgs(args, { - appKind: readValue(args, ["--kind", "--app-kind"]) ?? "electron", - projectRoot: readValue(args, ["--project-root", "--root"]), - laneId: readValue(args, ["--lane", "--lane-id"]), - cdpPort: readNumberOption(args, ["--cdp-port", "--port"]) ?? Number(numericPositionals()[0]), - label: readValue(args, ["--label", "--name"]), - chatSessionId: readValue(args, ["--chat-session", "--session"]) ?? process.env.ADE_CHAT_SESSION_ID, - force: readFlag(args, ["--force", "-f"]) ? true : undefined, - }))] }; + return { + kind: "execute", + label: "App Control connect", + steps: [ + actionStep( + "result", + "app_control", + "connect", + collectGenericObjectArgs(args, { + appKind: readValue(args, ["--kind", "--app-kind"]) ?? "electron", + projectRoot: readValue(args, ["--project-root", "--root"]), + laneId: readValue(args, ["--lane", "--lane-id"]), + cdpPort: + readNumberOption(args, ["--cdp-port", "--port"]) ?? + Number(numericPositionals()[0]), + label: readValue(args, ["--label", "--name"]), + chatSessionId: + readValue(args, ["--chat-session", "--session"]) ?? + process.env.ADE_CHAT_SESSION_ID, + force: readFlag(args, ["--force", "-f"]) ? true : undefined, + }), + ), + ], + }; } if (sub === "targets" || sub === "list-targets") { - return { kind: "execute", label: "App Control targets", steps: [actionStep("result", "app_control", "listTargets", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "App Control targets", + steps: [ + actionStep( + "result", + "app_control", + "listTargets", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "attach-target" || sub === "target") { - const targetId = requireValue(readValue(args, ["--target", "--target-id"]) ?? firstPositional(args), "targetId"); - return { kind: "execute", label: "App Control attach target", steps: [actionArgsListStep("result", "app_control", "attachToTarget", [targetId])] }; + const targetId = requireValue( + readValue(args, ["--target", "--target-id"]) ?? firstPositional(args), + "targetId", + ); + return { + kind: "execute", + label: "App Control attach target", + steps: [ + actionArgsListStep("result", "app_control", "attachToTarget", [ + targetId, + ]), + ], + }; } - if (sub === "stop" || sub === "shutdown" || sub === "teardown" || sub === "close") { - return { kind: "execute", label: "App Control stop", steps: [actionStep("result", "app_control", "stop", collectGenericObjectArgs(args, { force: readFlag(args, ["--force", "-f"]) ? true : undefined }))] }; + if ( + sub === "stop" || + sub === "shutdown" || + sub === "teardown" || + sub === "close" + ) { + return { + kind: "execute", + label: "App Control stop", + steps: [ + actionStep( + "result", + "app_control", + "stop", + collectGenericObjectArgs(args, { + force: readFlag(args, ["--force", "-f"]) ? true : undefined, + }), + ), + ], + }; } if (sub === "screenshot" || sub === "capture") { - return { kind: "execute", label: "App Control screenshot", steps: [actionStep("result", "app_control", "screenshot", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "App Control screenshot", + steps: [ + actionStep( + "result", + "app_control", + "screenshot", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "snapshot" || sub === "screen" || sub === "elements") { - return { kind: "execute", label: "App Control snapshot", steps: [actionStep("result", "app_control", "getSnapshot", collectGenericObjectArgs(args, { projectRoot: readValue(args, ["--project-root", "--root"]) }))] }; + return { + kind: "execute", + label: "App Control snapshot", + steps: [ + actionStep( + "result", + "app_control", + "getSnapshot", + collectGenericObjectArgs(args, { + projectRoot: readValue(args, ["--project-root", "--root"]), + }), + ), + ], + }; } if (sub === "inspect" || sub === "hit-test" || sub === "hover") { - return { kind: "execute", label: "App Control inspect point", steps: [actionStep("result", "app_control", "inspectPoint", collectGenericObjectArgs(args, { - projectRoot: readValue(args, ["--project-root", "--root"]), - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - includeScreenshot: readFlag(args, ["--screenshot", "--include-screenshot"]), - }))] }; + return { + kind: "execute", + label: "App Control inspect point", + steps: [ + actionStep( + "result", + "app_control", + "inspectPoint", + collectGenericObjectArgs(args, { + projectRoot: readValue(args, ["--project-root", "--root"]), + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + includeScreenshot: readFlag(args, [ + "--screenshot", + "--include-screenshot", + ]), + }), + ), + ], + }; } if (sub === "select") { - return { kind: "execute", label: "App Control select", steps: [actionStep("result", "app_control", "selectPoint", collectGenericObjectArgs(args, { - projectRoot: readValue(args, ["--project-root", "--root"]), - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - }))] }; + return { + kind: "execute", + label: "App Control select", + steps: [ + actionStep( + "result", + "app_control", + "selectPoint", + collectGenericObjectArgs(args, { + projectRoot: readValue(args, ["--project-root", "--root"]), + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + }), + ), + ], + }; } if (sub === "click" || sub === "tap") { - return { kind: "execute", label: "App Control click", steps: [actionStep("result", "app_control", "click", collectGenericObjectArgs(args, { - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - }))] }; + return { + kind: "execute", + label: "App Control click", + steps: [ + actionStep( + "result", + "app_control", + "click", + collectGenericObjectArgs(args, { + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + }), + ), + ], + }; } if (sub === "scroll" || sub === "wheel") { - return { kind: "execute", label: "App Control scroll", steps: [actionStep("result", "app_control", "scroll", collectGenericObjectArgs(args, { - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - deltaX: readNumberOption(args, ["--delta-x", "--dx"]) ?? 0, - deltaY: readNumberOption(args, ["--delta-y", "--dy"]) ?? 0, - scale: readNumberOption(args, ["--scale"]), - }))] }; + return { + kind: "execute", + label: "App Control scroll", + steps: [ + actionStep( + "result", + "app_control", + "scroll", + collectGenericObjectArgs(args, { + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + deltaX: readNumberOption(args, ["--delta-x", "--dx"]) ?? 0, + deltaY: readNumberOption(args, ["--delta-y", "--dy"]) ?? 0, + scale: readNumberOption(args, ["--scale"]), + }), + ), + ], + }; } if (sub === "key" || sub === "dispatch-key") { const key = readValue(args, ["--key"]) ?? firstPositional(args); - return { kind: "execute", label: "App Control key", steps: [actionStep("result", "app_control", "dispatchKey", collectGenericObjectArgs(args, { - type: readValue(args, ["--event-type", "--type"]) ?? "keyDown", - key: requireValue(key, "key"), - code: readValue(args, ["--code"]), - text: readValue(args, ["--text"]), - modifiers: readNumberOption(args, ["--modifiers"]), - }))] }; + return { + kind: "execute", + label: "App Control key", + steps: [ + actionStep( + "result", + "app_control", + "dispatchKey", + collectGenericObjectArgs(args, { + type: readValue(args, ["--event-type", "--type"]) ?? "keyDown", + key: requireValue(key, "key"), + code: readValue(args, ["--code"]), + text: readValue(args, ["--text"]), + modifiers: readNumberOption(args, ["--modifiers"]), + }), + ), + ], + }; } if (sub === "type" || sub === "text") { - return { kind: "execute", label: "App Control type", steps: [actionStep("result", "app_control", "typeText", collectGenericObjectArgs(args, { - text: requireValue( - readValue(args, ["--value", "--message", "--input-text"]) - ?? readCommandTextValue(args, ["--text"]) - ?? args.filter((arg) => arg !== "--text").join(" "), - "text", - ), - }))] }; + return { + kind: "execute", + label: "App Control type", + steps: [ + actionStep( + "result", + "app_control", + "typeText", + collectGenericObjectArgs(args, { + text: requireValue( + readValue(args, ["--value", "--message", "--input-text"]) ?? + readCommandTextValue(args, ["--text"]) ?? + args.filter((arg) => arg !== "--text").join(" "), + "text", + ), + }), + ), + ], + }; } - return { kind: "execute", label: `app-control ${sub}`, steps: [actionStep("result", "app_control", sub, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `app-control ${sub}`, + steps: [ + actionStep("result", "app_control", sub, collectGenericObjectArgs(args)), + ], + }; } function buildMacosVmPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "status"; - if (sub === "help") return { kind: "help", text: HELP_BY_COMMAND["macos-vm"] }; - const numericPositionals = () => args.filter((value) => /^\d+(\.\d+)?$/.test(value)); + if (sub === "help") + return { kind: "help", text: HELP_BY_COMMAND["macos-vm"] }; + const numericPositionals = () => + args.filter((value) => /^\d+(\.\d+)?$/.test(value)); const readCoordinate = (flag: string, index: number): number => { - const value = readNumberOption(args, [flag]) ?? Number(numericPositionals()[index]); - if (!Number.isFinite(value)) throw new CliUsageError(`${flag} is required and must be a number.`); + const value = + readNumberOption(args, [flag]) ?? Number(numericPositionals()[index]); + if (!Number.isFinite(value)) + throw new CliUsageError(`${flag} is required and must be a number.`); return value; }; const readVmLaneId = (required: boolean): string | null => { - const laneId = readValue(args, ["--lane", "--lane-id"]) ?? firstPositional(args); + const laneId = + readValue(args, ["--lane", "--lane-id"]) ?? firstPositional(args); if (required) return requireValue(laneId, "laneId"); return laneId; }; @@ -3481,218 +6643,805 @@ function buildMacosVmPlan(args: string[]): CliPlan { mode: readValue(args, ["--mode"]), ipsw: readValue(args, ["--ipsw"]), sourceImage: readValue(args, ["--image", "--source-image"]), - unattendedPreset: readValue(args, ["--unattended", "--unattended-preset"]), + unattendedPreset: readValue(args, [ + "--unattended", + "--unattended-preset", + ]), }; - return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined && value !== null && value !== "")); + return Object.fromEntries( + Object.entries(options).filter( + ([, value]) => value !== undefined && value !== null && value !== "", + ), + ); }; - if (sub === "actions") return { kind: "execute", label: "macOS VM actions", steps: [listActionsStep("actions", "macos_vm")] }; + if (sub === "actions") + return { + kind: "execute", + label: "macOS VM actions", + steps: [listActionsStep("actions", "macos_vm")], + }; if (sub === "status" || sub === "list" || sub === "ls") { - return { kind: "execute", label: "macOS VM status", steps: [actionStep("result", "macos_vm", "getStatus", collectGenericObjectArgs(args, { laneId: readVmLaneId(false) }))] }; + return { + kind: "execute", + label: "macOS VM status", + steps: [ + actionStep( + "result", + "macos_vm", + "getStatus", + collectGenericObjectArgs(args, { laneId: readVmLaneId(false) }), + ), + ], + }; } if (sub === "share" || sub === "share-policy") { - return { kind: "execute", label: "macOS VM share policy", steps: [actionStep("result", "macos_vm", "getSharePolicy", collectGenericObjectArgs(args, { laneId: readVmLaneId(true) }))] }; + return { + kind: "execute", + label: "macOS VM share policy", + steps: [ + actionStep( + "result", + "macos_vm", + "getSharePolicy", + collectGenericObjectArgs(args, { laneId: readVmLaneId(true) }), + ), + ], + }; } if (sub === "provision" || sub === "create" || sub === "pull") { const provisionOptions = readProvisionOptions(); - const mode = sub === "create" ? "create" : sub === "pull" ? "pull-image" : provisionOptions.mode; - return { kind: "execute", label: "macOS VM provision", steps: [actionStep("result", "macos_vm", "provision", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - ...provisionOptions, - mode, - force: readFlag(args, ["--force", "-f"]) ? true : undefined, - }))] }; + const mode = + sub === "create" + ? "create" + : sub === "pull" + ? "pull-image" + : provisionOptions.mode; + return { + kind: "execute", + label: "macOS VM provision", + steps: [ + actionStep( + "result", + "macos_vm", + "provision", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + ...provisionOptions, + mode, + force: readFlag(args, ["--force", "-f"]) ? true : undefined, + }), + ), + ], + }; } if (sub === "start" || sub === "run" || sub === "open") { const noDisplay = readFlag(args, ["--no-display", "--headless"]); - const openDisplay = noDisplay ? false : readFlag(args, ["--open-display", "--display-window"]) ? true : undefined; - return { kind: "execute", label: "macOS VM start", steps: [actionStep("result", "macos_vm", "start", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - ...readProvisionOptions(), - openDisplay, - createIfMissing: readFlag(args, ["--create", "--create-if-missing"]) ? true : undefined, - }))] }; + const openDisplay = noDisplay + ? false + : readFlag(args, ["--open-display", "--display-window"]) + ? true + : undefined; + return { + kind: "execute", + label: "macOS VM start", + steps: [ + actionStep( + "result", + "macos_vm", + "start", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + ...readProvisionOptions(), + openDisplay, + createIfMissing: readFlag(args, ["--create", "--create-if-missing"]) + ? true + : undefined, + }), + ), + ], + }; } if (sub === "stop" || sub === "shutdown") { - return { kind: "execute", label: "macOS VM stop", steps: [actionStep("result", "macos_vm", "stop", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - force: readFlag(args, ["--force", "-f"]) ? true : undefined, - }))] }; + return { + kind: "execute", + label: "macOS VM stop", + steps: [ + actionStep( + "result", + "macos_vm", + "stop", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + force: readFlag(args, ["--force", "-f"]) ? true : undefined, + }), + ), + ], + }; } - if (sub === "delete" || sub === "rm" || sub === "remove" || sub === "destroy") { - return { kind: "execute", label: "macOS VM delete", steps: [actionStep("result", "macos_vm", "delete", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - force: readFlag(args, ["--force", "-f"]) ? true : undefined, - }))] }; + if ( + sub === "delete" || + sub === "rm" || + sub === "remove" || + sub === "destroy" + ) { + return { + kind: "execute", + label: "macOS VM delete", + steps: [ + actionStep( + "result", + "macos_vm", + "delete", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + force: readFlag(args, ["--force", "-f"]) ? true : undefined, + }), + ), + ], + }; } - if (sub === "guide" || sub === "agent-guide" || sub === "handoff" || sub === "target") { - return { kind: "execute", label: "macOS VM guide", steps: [actionStep("result", "macos_vm", "getAgentGuide", collectGenericObjectArgs(args, { laneId: readVmLaneId(true) }))] }; + if ( + sub === "guide" || + sub === "agent-guide" || + sub === "handoff" || + sub === "target" + ) { + return { + kind: "execute", + label: "macOS VM guide", + steps: [ + actionStep( + "result", + "macos_vm", + "getAgentGuide", + collectGenericObjectArgs(args, { laneId: readVmLaneId(true) }), + ), + ], + }; } if (sub === "focus" || sub === "focus-window") { - return { kind: "execute", label: "macOS VM focus", steps: [actionStep("result", "macos_vm", "focusWindow", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - windowTitleQuery: readValue(args, ["--window-title", "--title-query"]), - }))] }; + return { + kind: "execute", + label: "macOS VM focus", + steps: [ + actionStep( + "result", + "macos_vm", + "focusWindow", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + windowTitleQuery: readValue(args, [ + "--window-title", + "--title-query", + ]), + }), + ), + ], + }; } if (sub === "screenshot" || sub === "capture") { - return { kind: "execute", label: "macOS VM screenshot", steps: [actionStep("result", "macos_vm", "captureScreenshot", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - windowTitleQuery: readValue(args, ["--window-title", "--title-query"]), - outputPath: readValue(args, ["--output", "--path"]), - }))] }; + return { + kind: "execute", + label: "macOS VM screenshot", + steps: [ + actionStep( + "result", + "macos_vm", + "captureScreenshot", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + windowTitleQuery: readValue(args, [ + "--window-title", + "--title-query", + ]), + outputPath: readValue(args, ["--output", "--path"]), + }), + ), + ], + }; } if (sub === "select" || sub === "select-point" || sub === "inspect") { - return { kind: "execute", label: "macOS VM select", steps: [actionStep("result", "macos_vm", "selectPoint", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - coordinateSpace: readValue(args, ["--coordinate-space", "--coords"]), - windowTitleQuery: readValue(args, ["--window-title", "--title-query"]), - includeScreenshot: readFlag(args, ["--no-screenshot"]) ? false : undefined, - }))] }; + return { + kind: "execute", + label: "macOS VM select", + steps: [ + actionStep( + "result", + "macos_vm", + "selectPoint", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + coordinateSpace: readValue(args, [ + "--coordinate-space", + "--coords", + ]), + windowTitleQuery: readValue(args, [ + "--window-title", + "--title-query", + ]), + includeScreenshot: readFlag(args, ["--no-screenshot"]) + ? false + : undefined, + }), + ), + ], + }; } if (sub === "click" || sub === "tap") { - return { kind: "execute", label: "macOS VM click", steps: [actionStep("result", "macos_vm", "click", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - coordinateSpace: readValue(args, ["--coordinate-space", "--coords"]), - windowTitleQuery: readValue(args, ["--window-title", "--title-query"]), - }))] }; + return { + kind: "execute", + label: "macOS VM click", + steps: [ + actionStep( + "result", + "macos_vm", + "click", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + coordinateSpace: readValue(args, [ + "--coordinate-space", + "--coords", + ]), + windowTitleQuery: readValue(args, [ + "--window-title", + "--title-query", + ]), + }), + ), + ], + }; } if (sub === "type" || sub === "text") { - return { kind: "execute", label: "macOS VM type", steps: [actionStep("result", "macos_vm", "typeText", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - text: requireValue( - readValue(args, ["--value", "--message", "--input-text"]) - ?? readCommandTextValue(args, ["--text"]) - ?? args.filter((arg) => arg !== "--text").join(" "), - "text", - ), - windowTitleQuery: readValue(args, ["--window-title", "--title-query"]), - }))] }; + return { + kind: "execute", + label: "macOS VM type", + steps: [ + actionStep( + "result", + "macos_vm", + "typeText", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + text: requireValue( + readValue(args, ["--value", "--message", "--input-text"]) ?? + readCommandTextValue(args, ["--text"]) ?? + args.filter((arg) => arg !== "--text").join(" "), + "text", + ), + windowTitleQuery: readValue(args, [ + "--window-title", + "--title-query", + ]), + }), + ), + ], + }; + } + return { + kind: "execute", + label: `macos-vm ${sub}`, + steps: [ + actionStep("result", "macos_vm", sub, collectGenericObjectArgs(args)), + ], + }; +} + +function buildBrowserPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "status"; + if (sub === "help") return { kind: "help", text: HELP_BY_COMMAND.browser }; + if (sub === "actions") + return { + kind: "execute", + label: "browser actions", + steps: [listActionsStep("actions", "built_in_browser")], + }; + if (sub === "status" || sub === "tabs" || sub === "list") { + return { + kind: "execute", + label: "browser status", + steps: [ + actionStep( + "result", + "built_in_browser", + "getStatus", + collectGenericObjectArgs(args), + ), + ], + }; + } + if ( + sub === "panel" || + sub === "show" || + sub === "open-panel" || + sub === "reveal" + ) { + const panelArgs: JsonObject = {}; + maybePut(panelArgs, "url", readValue(args, ["--url"])); + maybePut(panelArgs, "tabId", readValue(args, ["--tab", "--tab-id"])); + return { + kind: "execute", + label: "browser panel", + steps: [ + actionStep( + "result", + "built_in_browser", + "showPanel", + collectGenericObjectArgs(args, panelArgs), + ), + ], + }; + } + if (sub === "open" || sub === "navigate" || sub === "go") { + const explicitUrl = readValue(args, ["--url"]); + const tabId = readValue(args, ["--tab", "--tab-id"]); + const activeTab = readFlag(args, [ + "--active-tab", + "--current-tab", + "--same-tab", + ]); + const newTab = readFlag(args, ["--new-tab"]); + const noPanel = readFlag(args, ["--no-panel", "--hidden"]); + const genericArgs = collectGenericObjectArgs(args); + const genericUrl = + typeof genericArgs.url === "string" ? genericArgs.url : null; + const url = explicitUrl ?? genericUrl ?? args.join(" "); + if (!url.trim()) throw new CliUsageError("browser open requires a URL."); + return { + kind: "execute", + label: "browser open", + steps: [ + actionStep("result", "built_in_browser", "navigate", { + url, + tabId, + newTab: newTab && !activeTab ? true : undefined, + openPanel: !noPanel, + ...genericArgs, + }), + ], + }; + } + if (sub === "new-tab" || sub === "tab" || sub === "new") { + const background = readFlag(args, ["--background"]); + const noPanel = readFlag(args, ["--no-panel", "--hidden"]); + const explicitUrl = readValue(args, ["--url"]); + const genericArgs = collectGenericObjectArgs(args); + const genericUrl = + typeof genericArgs.url === "string" ? genericArgs.url : null; + const url = + explicitUrl ?? genericUrl ?? (args.length ? args.join(" ") : undefined); + return { + kind: "execute", + label: "browser new tab", + steps: [ + actionStep("result", "built_in_browser", "createTab", { + url, + activate: background ? false : undefined, + openPanel: !noPanel, + ...genericArgs, + }), + ], + }; + } + if (sub === "switch" || sub === "activate") { + const noPanel = readFlag(args, ["--no-panel", "--hidden"]); + const explicitTabId = readValue(args, ["--tab", "--tab-id"]); + const genericArgs = collectGenericObjectArgs(args); + const genericTabId = + typeof genericArgs.tabId === "string" ? genericArgs.tabId : null; + return { + kind: "execute", + label: "browser switch", + steps: [ + actionStep("result", "built_in_browser", "switchTab", { + tabId: requireValue( + explicitTabId ?? genericTabId ?? firstPositional(args), + "tabId", + ), + openPanel: !noPanel, + ...genericArgs, + }), + ], + }; + } + if (sub === "close" || sub === "close-tab") { + const explicitTabId = readValue(args, ["--tab", "--tab-id"]); + const genericArgs = collectGenericObjectArgs(args); + const genericTabId = + typeof genericArgs.tabId === "string" ? genericArgs.tabId : null; + return { + kind: "execute", + label: "browser close", + steps: [ + actionStep("result", "built_in_browser", "closeTab", { + tabId: requireValue( + explicitTabId ?? genericTabId ?? firstPositional(args), + "tabId", + ), + ...genericArgs, + }), + ], + }; + } + if (sub === "reload" || sub === "refresh") + return { + kind: "execute", + label: "browser reload", + steps: [ + actionStep( + "result", + "built_in_browser", + "reload", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "back") + return { + kind: "execute", + label: "browser back", + steps: [ + actionStep( + "result", + "built_in_browser", + "goBack", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "forward") + return { + kind: "execute", + label: "browser forward", + steps: [ + actionStep( + "result", + "built_in_browser", + "goForward", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "stop") + return { + kind: "execute", + label: "browser stop", + steps: [ + actionStep( + "result", + "built_in_browser", + "stop", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "screenshot" || sub === "capture") + return { + kind: "execute", + label: "browser screenshot", + steps: [ + actionStep( + "result", + "built_in_browser", + "captureScreenshot", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "select" || sub === "select-point" || sub === "point") { + const x = readNumberOption(args, ["--x"]); + const y = readNumberOption(args, ["--y"]); + if (x == null || y == null) + throw new CliUsageError("browser select requires --x and --y."); + return { + kind: "execute", + label: "browser selection", + steps: [ + actionStep( + "result", + "built_in_browser", + "selectPoint", + collectGenericObjectArgs(args, { + x, + y, + includeScreenshot: readFlag(args, ["--no-screenshot"]) + ? false + : undefined, + }), + ), + ], + }; } - return { kind: "execute", label: `macos-vm ${sub}`, steps: [actionStep("result", "macos_vm", sub, collectGenericObjectArgs(args))] }; + if (sub === "inspect-start" || sub === "start-inspect" || sub === "inspect") + return { + kind: "execute", + label: "browser inspect start", + steps: [ + actionStep( + "result", + "built_in_browser", + "startInspect", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "inspect-stop" || sub === "stop-inspect") + return { + kind: "execute", + label: "browser inspect stop", + steps: [ + actionStep( + "result", + "built_in_browser", + "stopInspect", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "select-current" || sub === "selection" || sub === "selected") + return { + kind: "execute", + label: "browser selection", + steps: [ + actionStep( + "result", + "built_in_browser", + "selectCurrent", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "clear-selection" || sub === "clear") + return { + kind: "execute", + label: "browser clear selection", + steps: [ + actionStep( + "result", + "built_in_browser", + "clearSelection", + collectGenericObjectArgs(args), + ), + ], + }; + return { + kind: "execute", + label: `browser ${sub}`, + steps: [ + actionStep( + "result", + "built_in_browser", + sub, + collectGenericObjectArgs(args), + ), + ], + }; +} + +function buildMemoryPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "search"; + if (sub === "actions") + return { + kind: "execute", + label: "memory actions", + steps: [listActionsStep("actions", "memory")], + }; + if (sub === "add") + return { + kind: "execute", + label: "memory add", + steps: [ + actionCallStep( + "result", + "memory_add", + collectGenericObjectArgs(args, { + content: requireValue( + readValue(args, ["--content"]) ?? args.join(" "), + "content", + ), + category: requireValue(readValue(args, ["--category"]), "category"), + scope: readValue(args, ["--scope"]), + }), + ), + ], + }; + if (sub === "search") + return { + kind: "execute", + label: "memory search", + steps: [ + actionCallStep( + "result", + "memory_search", + collectGenericObjectArgs(args, { + query: requireValue( + readValue(args, ["--query", "-q"]) ?? args.join(" "), + "query", + ), + }), + ), + ], + }; + if (sub === "pin") + return { + kind: "execute", + label: "memory pin", + steps: [ + actionCallStep( + "result", + "memory_pin", + collectGenericObjectArgs(args, { + id: requireValue( + readValue(args, ["--memory", "--memory-id", "--id"]) ?? + firstPositional(args), + "memory id", + ), + }), + ), + ], + }; + if (sub === "core") + return { + kind: "execute", + label: "memory core", + steps: [ + actionCallStep( + "result", + "memory_update_core", + collectGenericObjectArgs(args), + ), + ], + }; + return { + kind: "execute", + label: `memory ${sub}`, + steps: [ + actionStep("result", "memory", sub, collectGenericObjectArgs(args)), + ], + }; +} + +function buildSettingsPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "get"; + if (sub === "actions") + return { + kind: "execute", + label: "settings actions", + steps: [listActionsStep("actions", "project_config")], + }; + if (sub === "action") + return { + kind: "execute", + label: "settings action", + steps: [buildActionRunStep(["project_config", ...args])], + }; + return { + kind: "execute", + label: `settings ${sub}`, + steps: [ + actionStep( + "result", + "project_config", + sub, + collectGenericObjectArgs(args), + ), + ], + }; +} + +function buildActionStatusArgs( + args: string[], + defaults: { waitForMs?: number } = {}, +): JsonObject { + const input: JsonObject = {}; + maybePut( + input, + "operationId", + readValue(args, ["--operation", "--operation-id"]), + ); + maybePut( + input, + "testRunId", + readValue(args, ["--test-run", "--test-run-id"]), + ); + maybePut( + input, + "chatSessionId", + readValue(args, ["--chat-session", "--chat-session-id"]), + ); + maybePut(input, "runId", readValue(args, ["--run", "--run-id"])); + maybePut(input, "missionId", readValue(args, ["--mission", "--mission-id"])); + maybePut(input, "prId", readValue(args, ["--pr", "--pr-id"])); + maybePut(input, "previousHash", readValue(args, ["--previous-hash"])); + maybePut( + input, + "waitForMs", + readIntOption(args, ["--wait-ms"], defaults.waitForMs), + ); + maybePut( + input, + "pollIntervalMs", + readIntOption(args, ["--poll-interval-ms"]), + ); + return collectGenericObjectArgs(args, input); } -function buildBrowserPlan(args: string[]): CliPlan { +function buildOperationsPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "status"; - if (sub === "help") return { kind: "help", text: HELP_BY_COMMAND.browser }; - if (sub === "actions") return { kind: "execute", label: "browser actions", steps: [listActionsStep("actions", "built_in_browser")] }; - if (sub === "status" || sub === "tabs" || sub === "list") { - return { kind: "execute", label: "browser status", steps: [actionStep("result", "built_in_browser", "getStatus", collectGenericObjectArgs(args))] }; - } - if (sub === "panel" || sub === "show" || sub === "open-panel" || sub === "reveal") { - const panelArgs: JsonObject = {}; - maybePut(panelArgs, "url", readValue(args, ["--url"])); - maybePut(panelArgs, "tabId", readValue(args, ["--tab", "--tab-id"])); - return { kind: "execute", label: "browser panel", steps: [actionStep("result", "built_in_browser", "showPanel", collectGenericObjectArgs(args, panelArgs))] }; - } - if (sub === "open" || sub === "navigate" || sub === "go") { - const explicitUrl = readValue(args, ["--url"]); - const tabId = readValue(args, ["--tab", "--tab-id"]); - const activeTab = readFlag(args, ["--active-tab", "--current-tab", "--same-tab"]); - const newTab = readFlag(args, ["--new-tab"]); - const noPanel = readFlag(args, ["--no-panel", "--hidden"]); - const genericArgs = collectGenericObjectArgs(args); - const genericUrl = typeof genericArgs.url === "string" ? genericArgs.url : null; - const url = explicitUrl ?? genericUrl ?? args.join(" "); - if (!url.trim()) throw new CliUsageError("browser open requires a URL."); - return { kind: "execute", label: "browser open", steps: [actionStep("result", "built_in_browser", "navigate", { - url, - tabId, - newTab: newTab && !activeTab ? true : undefined, - openPanel: !noPanel, - ...genericArgs, - })] }; - } - if (sub === "new-tab" || sub === "tab" || sub === "new") { - const background = readFlag(args, ["--background"]); - const noPanel = readFlag(args, ["--no-panel", "--hidden"]); - const explicitUrl = readValue(args, ["--url"]); - const genericArgs = collectGenericObjectArgs(args); - const genericUrl = typeof genericArgs.url === "string" ? genericArgs.url : null; - const url = explicitUrl ?? genericUrl ?? (args.length ? args.join(" ") : undefined); - return { kind: "execute", label: "browser new tab", steps: [actionStep("result", "built_in_browser", "createTab", { - url, - activate: background ? false : undefined, - openPanel: !noPanel, - ...genericArgs, - })] }; + if (sub === "status" || sub === "show") { + return { + kind: "execute", + label: "action status", + steps: [ + actionCallStep( + "result", + "get_ade_action_status", + buildActionStatusArgs(args), + ), + ], + }; } - if (sub === "switch" || sub === "activate") { - const noPanel = readFlag(args, ["--no-panel", "--hidden"]); - const explicitTabId = readValue(args, ["--tab", "--tab-id"]); - const genericArgs = collectGenericObjectArgs(args); - const genericTabId = typeof genericArgs.tabId === "string" ? genericArgs.tabId : null; - return { kind: "execute", label: "browser switch", steps: [actionStep("result", "built_in_browser", "switchTab", { - tabId: requireValue(explicitTabId ?? genericTabId ?? firstPositional(args), "tabId"), - openPanel: !noPanel, - ...genericArgs, - })] }; + if (sub === "wait" || sub === "watch") { + return { + kind: "execute", + label: "action status", + steps: [ + actionCallStep( + "result", + "get_ade_action_status", + buildActionStatusArgs(args, { waitForMs: 30_000 }), + ), + ], + }; } - if (sub === "close" || sub === "close-tab") { - const explicitTabId = readValue(args, ["--tab", "--tab-id"]); - const genericArgs = collectGenericObjectArgs(args); - const genericTabId = typeof genericArgs.tabId === "string" ? genericArgs.tabId : null; - return { kind: "execute", label: "browser close", steps: [actionStep("result", "built_in_browser", "closeTab", { - tabId: requireValue(explicitTabId ?? genericTabId ?? firstPositional(args), "tabId"), - ...genericArgs, - })] }; - } - if (sub === "reload" || sub === "refresh") return { kind: "execute", label: "browser reload", steps: [actionStep("result", "built_in_browser", "reload", collectGenericObjectArgs(args))] }; - if (sub === "back") return { kind: "execute", label: "browser back", steps: [actionStep("result", "built_in_browser", "goBack", collectGenericObjectArgs(args))] }; - if (sub === "forward") return { kind: "execute", label: "browser forward", steps: [actionStep("result", "built_in_browser", "goForward", collectGenericObjectArgs(args))] }; - if (sub === "stop") return { kind: "execute", label: "browser stop", steps: [actionStep("result", "built_in_browser", "stop", collectGenericObjectArgs(args))] }; - if (sub === "screenshot" || sub === "capture") return { kind: "execute", label: "browser screenshot", steps: [actionStep("result", "built_in_browser", "captureScreenshot", collectGenericObjectArgs(args))] }; - if (sub === "select" || sub === "select-point" || sub === "point") { - const x = readNumberOption(args, ["--x"]); - const y = readNumberOption(args, ["--y"]); - if (x == null || y == null) throw new CliUsageError("browser select requires --x and --y."); - return { kind: "execute", label: "browser selection", steps: [actionStep("result", "built_in_browser", "selectPoint", collectGenericObjectArgs(args, { - x, - y, - includeScreenshot: readFlag(args, ["--no-screenshot"]) ? false : undefined, - }))] }; + if (sub === "logs" || sub === "log") { + throw new CliUsageError( + "Generic operation logs are not available; use tests logs, run logs, terminal read, or app-control logs for log-owning surfaces.", + ); } - if (sub === "inspect-start" || sub === "start-inspect" || sub === "inspect") return { kind: "execute", label: "browser inspect start", steps: [actionStep("result", "built_in_browser", "startInspect", collectGenericObjectArgs(args))] }; - if (sub === "inspect-stop" || sub === "stop-inspect") return { kind: "execute", label: "browser inspect stop", steps: [actionStep("result", "built_in_browser", "stopInspect", collectGenericObjectArgs(args))] }; - if (sub === "select-current" || sub === "selection" || sub === "selected") return { kind: "execute", label: "browser selection", steps: [actionStep("result", "built_in_browser", "selectCurrent", collectGenericObjectArgs(args))] }; - if (sub === "clear-selection" || sub === "clear") return { kind: "execute", label: "browser clear selection", steps: [actionStep("result", "built_in_browser", "clearSelection", collectGenericObjectArgs(args))] }; - return { kind: "execute", label: `browser ${sub}`, steps: [actionStep("result", "built_in_browser", sub, collectGenericObjectArgs(args))] }; -} - -function buildMemoryPlan(args: string[]): CliPlan { - const sub = firstPositional(args) ?? "search"; - if (sub === "actions") return { kind: "execute", label: "memory actions", steps: [listActionsStep("actions", "memory")] }; - if (sub === "add") return { kind: "execute", label: "memory add", steps: [actionCallStep("result", "memory_add", collectGenericObjectArgs(args, { content: requireValue(readValue(args, ["--content"]) ?? args.join(" "), "content"), category: requireValue(readValue(args, ["--category"]), "category"), scope: readValue(args, ["--scope"]) }))] }; - if (sub === "search") return { kind: "execute", label: "memory search", steps: [actionCallStep("result", "memory_search", collectGenericObjectArgs(args, { query: requireValue(readValue(args, ["--query", "-q"]) ?? args.join(" "), "query") }))] }; - if (sub === "pin") return { kind: "execute", label: "memory pin", steps: [actionCallStep("result", "memory_pin", collectGenericObjectArgs(args, { id: requireValue(readValue(args, ["--memory", "--memory-id", "--id"]) ?? firstPositional(args), "memory id") }))] }; - if (sub === "core") return { kind: "execute", label: "memory core", steps: [actionCallStep("result", "memory_update_core", collectGenericObjectArgs(args))] }; - return { kind: "execute", label: `memory ${sub}`, steps: [actionStep("result", "memory", sub, collectGenericObjectArgs(args))] }; -} - -function buildSettingsPlan(args: string[]): CliPlan { - const sub = firstPositional(args) ?? "get"; - if (sub === "actions") return { kind: "execute", label: "settings actions", steps: [listActionsStep("actions", "project_config")] }; - if (sub === "action") return { kind: "execute", label: "settings action", steps: [buildActionRunStep(["project_config", ...args])] }; - return { kind: "execute", label: `settings ${sub}`, steps: [actionStep("result", "project_config", sub, collectGenericObjectArgs(args))] }; + throw new CliUsageError("operations supports status or wait."); } function buildUsagePlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "snapshot"; - if (sub === "actions") return { kind: "execute", label: "usage actions", steps: [listActionsStep("actions", "usage")] }; - if (sub === "action") return { kind: "execute", label: "usage action", steps: [buildActionRunStep(["usage", ...args])] }; + if (sub === "actions") + return { + kind: "execute", + label: "usage actions", + steps: [listActionsStep("actions", "usage")], + }; + if (sub === "action") + return { + kind: "execute", + label: "usage action", + steps: [buildActionRunStep(["usage", ...args])], + }; if (sub === "snapshot" || sub === "get" || sub === "status") { - return { kind: "execute", label: "usage snapshot", steps: [actionStep("result", "usage", "getUsageSnapshot", {})] }; + return { + kind: "execute", + label: "usage snapshot", + steps: [actionStep("result", "usage", "getUsageSnapshot", {})], + }; } if (sub === "refresh" || sub === "poll") { - return { kind: "execute", label: "usage refresh", steps: [actionStep("result", "usage", "forceRefresh", {})] }; + return { + kind: "execute", + label: "usage refresh", + steps: [actionStep("result", "usage", "forceRefresh", {})], + }; } if (sub === "budget") { const mode = firstPositional(args) ?? "get"; if (mode === "get") { - return { kind: "execute", label: "usage budget get", steps: [actionStep("result", "budget", "getConfig", {})] }; + return { + kind: "execute", + label: "usage budget get", + steps: [actionStep("result", "budget", "getConfig", {})], + }; } if (mode === "set" || mode === "update") { const text = readFileTextInput(args); @@ -3712,73 +7461,175 @@ function buildUsagePlan(args: string[]): CliPlan { } if (!isRecord(parsed)) throw new CliUsageError("Budget config must be a JSON object."); if (Object.keys(parsed).length === 0) throw new CliUsageError("Budget config must contain at least one field."); - return { kind: "execute", label: "usage budget update", steps: [actionStep("result", "budget", "updateConfig", parsed as JsonObject)] }; + return { + kind: "execute", + label: "usage budget update", + steps: [actionStep("result", "budget", "updateConfig", parsed as JsonObject)], + }; } if (mode === "check") { - return { kind: "execute", label: "usage budget check", steps: [actionStep("result", "budget", "checkBudget", collectGenericObjectArgs(args, { - scope: readValue(args, ["--scope"]) ?? "global", - scopeId: readValue(args, ["--scope-id"]), - provider: readValue(args, ["--provider"]) ?? "any", - }))] }; + return { + kind: "execute", + label: "usage budget check", + steps: [ + actionStep("result", "budget", "checkBudget", collectGenericObjectArgs(args, { + scope: readValue(args, ["--scope"]) ?? "global", + scopeId: readValue(args, ["--scope-id"]), + provider: readValue(args, ["--provider"]) ?? "any", + })), + ], + }; } if (mode === "cumulative" || mode === "totals") { - return { kind: "execute", label: "usage budget cumulative", steps: [actionStep("result", "budget", "getCumulativeUsage", collectGenericObjectArgs(args, { - scope: readValue(args, ["--scope"]) ?? "global", - scopeId: readValue(args, ["--scope-id"]), - provider: readValue(args, ["--provider"]), - }))] }; + return { + kind: "execute", + label: "usage budget cumulative", + steps: [ + actionStep("result", "budget", "getCumulativeUsage", collectGenericObjectArgs(args, { + scope: readValue(args, ["--scope"]) ?? "global", + scopeId: readValue(args, ["--scope-id"]), + provider: readValue(args, ["--provider"]), + })), + ], + }; } throw new CliUsageError("usage budget supports get, set, check, or cumulative."); } - return { kind: "execute", label: `usage ${sub}`, steps: [actionStep("result", "usage", sub, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `usage ${sub}`, + steps: [actionStep("result", "usage", sub, collectGenericObjectArgs(args))], + }; } function buildActionsPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; - if (sub === "list" || sub === "ls") return { kind: "execute", label: "actions list", steps: [listActionsStep("result", readValue(args, ["--domain"]) ?? firstPositional(args) ?? undefined)] }; + if (sub === "list" || sub === "ls") + return { + kind: "execute", + label: "actions list", + steps: [ + listActionsStep( + "result", + readValue(args, ["--domain"]) ?? firstPositional(args) ?? undefined, + ), + ], + }; if (sub === "call" || sub === "direct" || sub === "tool") { const toolName = requireValue(firstPositional(args), "toolName"); - return { kind: "execute", label: "action call", steps: [actionCallStep("result", toolName, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "action call", + steps: [ + actionCallStep("result", toolName, collectGenericObjectArgs(args)), + ], + }; } - if (sub === "run") return { kind: "execute", label: "action run", steps: [buildActionRunStep(args)] }; - if (sub === "status") return { kind: "execute", label: "action status", steps: [actionCallStep("result", "get_ade_action_status", collectGenericObjectArgs(args))] }; - throw new CliUsageError("actions supports list, run, call, or status."); + if (sub === "run") + return { + kind: "execute", + label: "action run", + steps: [buildActionRunStep(args)], + }; + if (sub === "status") + return { + kind: "execute", + label: "action status", + steps: [ + actionCallStep( + "result", + "get_ade_action_status", + buildActionStatusArgs(args), + ), + ], + }; + if (sub === "wait" || sub === "watch") + return { + kind: "execute", + label: "action status", + steps: [ + actionCallStep( + "result", + "get_ade_action_status", + buildActionStatusArgs(args, { waitForMs: 30_000 }), + ), + ], + }; + throw new CliUsageError("actions supports list, run, call, status, or wait."); } function buildAgentPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "spawn"; if (sub === "spawn" || sub === "start") { const toolWhitelist = args - .filter((entry) => entry.startsWith("--tool=") || entry.startsWith("--allow-tool=")) + .filter( + (entry) => + entry.startsWith("--tool=") || entry.startsWith("--allow-tool="), + ) .map((entry) => entry.slice(entry.indexOf("=") + 1).trim()) .filter(Boolean); const laneId = requireValue(readLaneId(args), "laneId"); - const prompt = requireValue(readValue(args, ["--prompt"]) ?? args.join(" "), "prompt"); + const prompt = requireValue( + readValue(args, ["--prompt"]) ?? args.join(" "), + "prompt", + ); return { kind: "execute", label: "agent spawn", - steps: [actionCallStep("result", "spawn_agent", collectGenericObjectArgs(args, { - laneId, - provider: readValue(args, ["--provider"]) ?? "codex", - model: readValue(args, ["--model"]), - title: readValue(args, ["--title"]), - prompt, - permissionMode: readValue(args, ["--permission-mode", "--permissions"]), - contextFilePath: readValue(args, ["--context-file"]), - runId: readValue(args, ["--run", "--run-id"]), - stepId: readValue(args, ["--step", "--step-id"]), - attemptId: readValue(args, ["--attempt", "--attempt-id"]), - maxPromptChars: readIntOption(args, ["--max-prompt-chars"]), - ...(toolWhitelist.length ? { toolWhitelist } : {}), - }))], + steps: [ + actionCallStep( + "result", + "spawn_agent", + collectGenericObjectArgs(args, { + laneId, + provider: readValue(args, ["--provider"]) ?? "codex", + model: readValue(args, ["--model"]), + title: readValue(args, ["--title"]), + prompt, + permissionMode: readValue(args, [ + "--permission-mode", + "--permissions", + ]), + contextFilePath: readValue(args, ["--context-file"]), + runId: readValue(args, ["--run", "--run-id"]), + stepId: readValue(args, ["--step", "--step-id"]), + attemptId: readValue(args, ["--attempt", "--attempt-id"]), + maxPromptChars: readIntOption(args, ["--max-prompt-chars"]), + ...(toolWhitelist.length ? { toolWhitelist } : {}), + }), + ), + ], }; } - return { kind: "execute", label: `agent ${sub}`, steps: [actionCallStep("result", sub.replace(/-/g, "_"), collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `agent ${sub}`, + steps: [ + actionCallStep( + "result", + sub.replace(/-/g, "_"), + collectGenericObjectArgs(args), + ), + ], + }; } function buildCtoPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "state"; - if (sub === "state") return { kind: "execute", label: "CTO state", steps: [actionCallStep("result", "get_cto_state", collectGenericObjectArgs(args, { recentLimit: readIntOption(args, ["--recent-limit", "--limit"]) }))] }; + if (sub === "state") + return { + kind: "execute", + label: "CTO state", + steps: [ + actionCallStep( + "result", + "get_cto_state", + collectGenericObjectArgs(args, { + recentLimit: readIntOption(args, ["--recent-limit", "--limit"]), + }), + ), + ], + }; if (sub === "chats" || sub === "chat") { const mode = firstPositional(args) ?? "list"; const toolByMode: Record = { @@ -3792,16 +7643,49 @@ function buildCtoPlan(args: string[]): CliPlan { end: "endChat", }; const tool = toolByMode[mode]; - if (!tool) throw new CliUsageError("cto chats supports list, spawn, status, transcript, send, interrupt, resume, or end."); - return { kind: "execute", label: `CTO chats ${mode}`, steps: [actionCallStep("result", tool, collectGenericObjectArgs(args, { sessionId: readValue(args, ["--session", "--session-id"]) ?? firstPositional(args), text: readValue(args, ["--text", "--message"]) ?? args.join(" "), laneId: readLaneId(args), modelId: readValue(args, ["--model", "--model-id"]), initialPrompt: readValue(args, ["--prompt"]) }))] }; + if (!tool) + throw new CliUsageError( + "cto chats supports list, spawn, status, transcript, send, interrupt, resume, or end.", + ); + return { + kind: "execute", + label: `CTO chats ${mode}`, + steps: [ + actionCallStep( + "result", + tool, + collectGenericObjectArgs(args, { + sessionId: + readValue(args, ["--session", "--session-id"]) ?? + firstPositional(args), + text: readValue(args, ["--text", "--message"]) ?? args.join(" "), + laneId: readLaneId(args), + modelId: readValue(args, ["--model", "--model-id"]), + initialPrompt: readValue(args, ["--prompt"]), + }), + ), + ], + }; } - return { kind: "execute", label: `CTO ${sub}`, steps: [actionCallStep("result", sub.replace(/-/g, "_"), collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `CTO ${sub}`, + steps: [ + actionCallStep( + "result", + sub.replace(/-/g, "_"), + collectGenericObjectArgs(args), + ), + ], + }; } function parseDraftInput(args: string[]): JsonObject { const text = readFileTextInput(args); if (text == null) { - throw new CliUsageError("Provide a rule body via --from-file, --stdin, or --text."); + throw new CliUsageError( + "Provide a rule body via --from-file, --stdin, or --text.", + ); } const trimmed = text.trim(); if (!trimmed.length) { @@ -3809,11 +7693,14 @@ function parseDraftInput(args: string[]): JsonObject { } let parsed: unknown; try { - parsed = trimmed.startsWith("{") || trimmed.startsWith("[") - ? JSON.parse(trimmed) - : YAML.parse(trimmed); + parsed = + trimmed.startsWith("{") || trimmed.startsWith("[") + ? JSON.parse(trimmed) + : YAML.parse(trimmed); } catch (error) { - throw new CliUsageError(`Failed to parse rule body: ${error instanceof Error ? error.message : String(error)}`); + throw new CliUsageError( + `Failed to parse rule body: ${error instanceof Error ? error.message : String(error)}`, + ); } if (!isRecord(parsed)) { throw new CliUsageError("Rule body must be an object."); @@ -3821,12 +7708,30 @@ function parseDraftInput(args: string[]): JsonObject { return parsed; } -const AUTOMATION_LANE_MODES = ["create", "reuse", "require-on-trigger"] as const; -const AUTOMATION_LANE_NAME_PRESETS = ["issue-title", "issue-num-title", "pr-title-author", "custom"] as const; -const AUTOMATION_RUN_STATUSES = ["queued", "running", "succeeded", "failed", "cancelled", "paused", "all"] as const; +const AUTOMATION_LANE_MODES = [ + "create", + "reuse", + "require-on-trigger", +] as const; +const AUTOMATION_LANE_NAME_PRESETS = [ + "issue-title", + "issue-num-title", + "pr-title-author", + "custom", +] as const; +const AUTOMATION_RUN_STATUSES = [ + "queued", + "running", + "succeeded", + "failed", + "cancelled", + "paused", + "all", +] as const; type AutomationLaneModeFlag = (typeof AUTOMATION_LANE_MODES)[number]; -type AutomationLaneNamePresetFlag = (typeof AUTOMATION_LANE_NAME_PRESETS)[number]; +type AutomationLaneNamePresetFlag = + (typeof AUTOMATION_LANE_NAME_PRESETS)[number]; function readEnumOption( args: string[], @@ -3843,31 +7748,56 @@ function readEnumOption( } function applyLaneFlagsToDraft(draft: JsonObject, args: string[]): JsonObject { - const laneMode = readEnumOption(args, ["--lane-mode"], AUTOMATION_LANE_MODES, "--lane-mode"); + const laneMode = readEnumOption( + args, + ["--lane-mode"], + AUTOMATION_LANE_MODES, + "--lane-mode", + ); const laneId = readLaneId(args); - const preset = readEnumOption(args, ["--lane-name-preset"], AUTOMATION_LANE_NAME_PRESETS, "--lane-name-preset"); + const preset = readEnumOption( + args, + ["--lane-name-preset"], + AUTOMATION_LANE_NAME_PRESETS, + "--lane-name-preset", + ); const template = readValue(args, ["--lane-name-template"]); - if (laneMode == null && laneId == null && preset == null && template == null) { + if ( + laneMode == null && + laneId == null && + preset == null && + template == null + ) { return draft; } const existingExecution = isRecord(draft.execution) ? draft.execution : {}; const effectiveLaneMode = - laneMode - ?? (asString(existingExecution.laneMode) as AutomationLaneModeFlag | null); + laneMode ?? + (asString(existingExecution.laneMode) as AutomationLaneModeFlag | null); - if (laneId != null && effectiveLaneMode != null && effectiveLaneMode !== "reuse") { + if ( + laneId != null && + effectiveLaneMode != null && + effectiveLaneMode !== "reuse" + ) { throw new CliUsageError("--lane is only valid with --lane-mode reuse."); } if (preset != null && effectiveLaneMode !== "create") { - throw new CliUsageError("--lane-name-preset is only valid with --lane-mode create."); + throw new CliUsageError( + "--lane-name-preset is only valid with --lane-mode create.", + ); } if (template != null && preset != null && preset !== "custom") { - throw new CliUsageError("--lane-name-template is only valid with --lane-name-preset custom."); + throw new CliUsageError( + "--lane-name-template is only valid with --lane-name-preset custom.", + ); } if (template != null && preset == null && effectiveLaneMode !== "create") { - throw new CliUsageError("--lane-name-template requires --lane-mode create (with --lane-name-preset custom)."); + throw new CliUsageError( + "--lane-name-template requires --lane-mode create (with --lane-name-preset custom).", + ); } const execution: JsonObject = { ...existingExecution }; @@ -3879,18 +7809,26 @@ function applyLaneFlagsToDraft(draft: JsonObject, args: string[]): JsonObject { return { ...draft, execution }; } -function migrateLegacyCreateLane(draft: JsonObject, opts: { allowLegacy: boolean }): JsonObject { +function migrateLegacyCreateLane( + draft: JsonObject, + opts: { allowLegacy: boolean }, +): JsonObject { const actions = Array.isArray(draft.actions) ? draft.actions : null; if (!actions || actions.length === 0) return draft; const first = actions[0]; if (!isRecord(first) || first.type !== "create-lane") return draft; if (opts.allowLegacy) return draft; const execution = isRecord(draft.execution) ? draft.execution : {}; - const template = typeof first.laneNameTemplate === "string" ? first.laneNameTemplate : undefined; + const template = + typeof first.laneNameTemplate === "string" + ? first.laneNameTemplate + : undefined; const migratedExecution: JsonObject = { ...execution, laneMode: "create", - ...(template ? { laneNamePreset: "custom", laneNameTemplate: template } : {}), + ...(template + ? { laneNamePreset: "custom", laneNameTemplate: template } + : {}), }; return { ...draft, execution: migratedExecution, actions: actions.slice(1) }; } @@ -3929,12 +7867,23 @@ function buildAutomationsPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; if (sub === "list") { - return { kind: "execute", label: "automations list", steps: [actionStep("result", "automations", "list")] }; + return { + kind: "execute", + label: "automations list", + steps: [actionStep("result", "automations", "list")], + }; } if (sub === "show" || sub === "get") { - const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); - return { kind: "execute", label: `automations show ${id}`, steps: [actionStep("result", "automations", "get", { id })] }; + const id = requireValue( + readValue(args, ["--id"]) ?? firstPositional(args), + "rule id", + ); + return { + kind: "execute", + label: `automations show ${id}`, + steps: [actionStep("result", "automations", "get", { id })], + }; } if (sub === "example") { @@ -3944,7 +7893,10 @@ function buildAutomationsPlan(args: string[]): CliPlan { if (sub === "create") { const allowLegacy = readFlag(args, ["--allow-legacy"]); const raw = parseDraftInput(args); - const draft = applyLaneFlagsToDraft(migrateLegacyCreateLane(raw, { allowLegacy }), args); + const draft = applyLaneFlagsToDraft( + migrateLegacyCreateLane(raw, { allowLegacy }), + args, + ); return { kind: "execute", label: "automations create", @@ -3953,71 +7905,112 @@ function buildAutomationsPlan(args: string[]): CliPlan { } if (sub === "update") { - const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); + const id = requireValue( + readValue(args, ["--id"]) ?? firstPositional(args), + "rule id", + ); const allowLegacy = readFlag(args, ["--allow-legacy"]); const raw = parseDraftInput(args); - const draft = applyLaneFlagsToDraft(migrateLegacyCreateLane(raw, { allowLegacy }), args); + const draft = applyLaneFlagsToDraft( + migrateLegacyCreateLane(raw, { allowLegacy }), + args, + ); return { kind: "execute", label: `automations update ${id}`, - steps: [actionStep("result", "automations", "saveRule", { draft: { ...draft, id } })], + steps: [ + actionStep("result", "automations", "saveRule", { + draft: { ...draft, id }, + }), + ], }; } if (sub === "delete") { - const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); - return { kind: "execute", label: `automations delete ${id}`, steps: [actionStep("result", "automations", "deleteRule", { id })] }; + const id = requireValue( + readValue(args, ["--id"]) ?? firstPositional(args), + "rule id", + ); + return { + kind: "execute", + label: `automations delete ${id}`, + steps: [actionStep("result", "automations", "deleteRule", { id })], + }; } if (sub === "toggle") { - const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); + const id = requireValue( + readValue(args, ["--id"]) ?? firstPositional(args), + "rule id", + ); const enabledRaw = readValue(args, ["--enabled"]); if (enabledRaw == null) { - throw new CliUsageError("automations toggle requires --enabled ."); + throw new CliUsageError( + "automations toggle requires --enabled .", + ); } if (enabledRaw !== "true" && enabledRaw !== "false") { - throw new CliUsageError("automations toggle --enabled must be true or false."); + throw new CliUsageError( + "automations toggle --enabled must be true or false.", + ); } const enabled = enabledRaw === "true"; return { kind: "execute", label: `automations toggle ${id}`, - steps: [actionStep("result", "automations", "toggleRule", { id, enabled })], + steps: [ + actionStep("result", "automations", "toggleRule", { id, enabled }), + ], }; } if (sub === "run" || sub === "trigger") { - const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); + const id = requireValue( + readValue(args, ["--id"]) ?? firstPositional(args), + "rule id", + ); const dryRun = readFlag(args, ["--dry-run"]); const laneId = readLaneId(args); return { kind: "execute", label: `automations run ${id}`, - steps: [actionStep("result", "automations", "triggerManually", { - id, - ...(dryRun ? { dryRun: true } : {}), - ...(laneId ? { laneId } : {}), - })], + steps: [ + actionStep("result", "automations", "triggerManually", { + id, + ...(dryRun ? { dryRun: true } : {}), + ...(laneId ? { laneId } : {}), + }), + ], }; } if (sub === "runs") { const automationId = readValue(args, ["--rule", "--automation", "--id"]); const limit = readIntOption(args, ["--limit"]); - const status = readEnumOption(args, ["--status"], AUTOMATION_RUN_STATUSES, "--status"); + const status = readEnumOption( + args, + ["--status"], + AUTOMATION_RUN_STATUSES, + "--status", + ); return { kind: "execute", label: "automations runs", - steps: [actionStep("result", "automations", "listRuns", { - ...(automationId ? { automationId } : {}), - ...(typeof limit === "number" ? { limit } : {}), - ...(status ? { status } : {}), - })], + steps: [ + actionStep("result", "automations", "listRuns", { + ...(automationId ? { automationId } : {}), + ...(typeof limit === "number" ? { limit } : {}), + ...(status ? { status } : {}), + }), + ], }; } if (sub === "run-show" || sub === "run-detail") { - const runId = requireValue(readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), "run id"); + const runId = requireValue( + readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), + "run id", + ); return { kind: "execute", label: `automations run-show ${runId}`, @@ -4034,22 +8027,58 @@ function buildAutomationsPlan(args: string[]): CliPlan { function buildLinearPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "workflows"; if (sub === "quick-view" || sub === "quick" || sub === "overview") { - return { kind: "execute", label: "Linear quick view", formatter: "linear-quick-view", steps: [actionCallStep("result", "getLinearQuickView", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "Linear quick view", + formatter: "linear-quick-view", + steps: [ + actionCallStep( + "result", + "getLinearQuickView", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "picker-data" || sub === "picker") { - return { kind: "execute", label: "Linear picker data", steps: [actionCallStep("result", "getLinearIssuePickerData", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "Linear picker data", + steps: [ + actionCallStep( + "result", + "getLinearIssuePickerData", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "search-issues" || sub === "search") { - const stateTypesValue = readValue(args, ["--state-type", "--state-types", "--state"]); + const stateTypesValue = readValue(args, [ + "--state-type", + "--state-types", + "--state", + ]); const stateTypes = stateTypesValue - ? stateTypesValue.split(",").map((entry) => entry.trim()).filter(Boolean) + ? stateTypesValue + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) : []; const input: JsonObject = {}; maybePut(input, "projectId", readValue(args, ["--project-id"])); - maybePut(input, "projectSlug", readValue(args, ["--project-slug", "--project"])); + maybePut( + input, + "projectSlug", + readValue(args, ["--project-slug", "--project"]), + ); maybePut(input, "teamKey", readValue(args, ["--team-key", "--team"])); if (stateTypes.length) input.stateTypes = stateTypes; - maybePut(input, "assigneeId", readValue(args, ["--assignee", "--assignee-id"])); + maybePut( + input, + "assigneeId", + readValue(args, ["--assignee", "--assignee-id"]), + ); const priority = readNumberOption(args, ["--priority"]); if (priority !== undefined) input.priority = priority; maybePut(input, "query", readValue(args, ["--query", "-q"])); @@ -4057,9 +8086,30 @@ function buildLinearPlan(args: string[]): CliPlan { if (first !== undefined) input.first = first; maybePut(input, "after", readValue(args, ["--after", "--cursor"])); if (readFlag(args, ["--include-archived"])) input.includeArchived = true; - return { kind: "execute", label: "Linear search issues", steps: [actionCallStep("result", "searchLinearIssues", collectGenericObjectArgs(args, input))] }; + return { + kind: "execute", + label: "Linear search issues", + steps: [ + actionCallStep( + "result", + "searchLinearIssues", + collectGenericObjectArgs(args, input), + ), + ], + }; } - if (sub === "workflows") return { kind: "execute", label: "Linear workflows", steps: [actionCallStep("result", "listLinearWorkflows", collectGenericObjectArgs(args))] }; + if (sub === "workflows") + return { + kind: "execute", + label: "Linear workflows", + steps: [ + actionCallStep( + "result", + "listLinearWorkflows", + collectGenericObjectArgs(args), + ), + ], + }; if (sub === "run") { const mode = firstPositional(args) ?? "status"; const toolByMode: Record = { @@ -4069,8 +8119,24 @@ function buildLinearPlan(args: string[]): CliPlan { reroute: "rerouteLinearRun", }; const tool = toolByMode[mode]; - if (!tool) throw new CliUsageError("linear run supports status, resolve, cancel, or reroute."); - return { kind: "execute", label: `Linear run ${mode}`, steps: [actionCallStep("result", tool, collectGenericObjectArgs(args, { runId: readValue(args, ["--run", "--run-id"]) ?? firstPositional(args) }))] }; + if (!tool) + throw new CliUsageError( + "linear run supports status, resolve, cancel, or reroute.", + ); + return { + kind: "execute", + label: `Linear run ${mode}`, + steps: [ + actionCallStep( + "result", + tool, + collectGenericObjectArgs(args, { + runId: + readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), + }), + ), + ], + }; } if (sub === "route") { const mode = firstPositional(args) ?? "cto"; @@ -4080,8 +8146,13 @@ function buildLinearPlan(args: string[]): CliPlan { worker: "routeLinearIssueToWorker", }; const tool = toolByMode[mode]; - if (!tool) throw new CliUsageError("linear route supports cto, mission, or worker."); - return { kind: "execute", label: `Linear route ${mode}`, steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))] }; + if (!tool) + throw new CliUsageError("linear route supports cto, mission, or worker."); + return { + kind: "execute", + label: `Linear route ${mode}`, + steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))], + }; } if (sub === "sync") { const mode = firstPositional(args) ?? "dashboard"; @@ -4093,8 +8164,15 @@ function buildLinearPlan(args: string[]): CliPlan { detail: "getLinearWorkflowRunDetail", }; const tool = toolByMode[mode]; - if (!tool) throw new CliUsageError("linear sync supports dashboard, run, queue, resolve, or detail."); - return { kind: "execute", label: `Linear sync ${mode}`, steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))] }; + if (!tool) + throw new CliUsageError( + "linear sync supports dashboard, run, queue, resolve, or detail.", + ); + return { + kind: "execute", + label: `Linear sync ${mode}`, + steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))], + }; } if (sub === "ingress") { const mode = firstPositional(args) ?? "status"; @@ -4104,15 +8182,45 @@ function buildLinearPlan(args: string[]): CliPlan { webhook: "ensureLinearWebhook", }; const tool = toolByMode[mode]; - if (!tool) throw new CliUsageError("linear ingress supports status, events, or webhook."); - return { kind: "execute", label: `Linear ingress ${mode}`, steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))] }; + if (!tool) + throw new CliUsageError( + "linear ingress supports status, events, or webhook.", + ); + return { + kind: "execute", + label: `Linear ingress ${mode}`, + steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))], + }; } - return { kind: "execute", label: `Linear ${sub}`, steps: [actionStep("result", "linear_dispatcher", sub, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `Linear ${sub}`, + steps: [ + actionStep( + "result", + "linear_dispatcher", + sub, + collectGenericObjectArgs(args), + ), + ], + }; } function buildFlowPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "policy"; - if (sub !== "policy") return { kind: "execute", label: `flow ${sub}`, steps: [actionStep("result", "flow_policy", sub, collectGenericObjectArgs(args))] }; + if (sub !== "policy") + return { + kind: "execute", + label: `flow ${sub}`, + steps: [ + actionStep( + "result", + "flow_policy", + sub, + collectGenericObjectArgs(args), + ), + ], + }; const mode = firstPositional(args) ?? "get"; const actionByMode: Record = { get: "getPolicy", @@ -4124,79 +8232,327 @@ function buildFlowPlan(args: string[]): CliPlan { diff: "diffPolicyPaths", }; const action = actionByMode[mode]; - if (!action) throw new CliUsageError("flow policy supports get, save, validate, normalize, revisions, rollback, or diff."); - return { kind: "execute", label: `flow policy ${mode}`, steps: [actionStep("result", "flow_policy", action, collectGenericObjectArgs(args))] }; + if (!action) + throw new CliUsageError( + "flow policy supports get, save, validate, normalize, revisions, rollback, or diff.", + ); + return { + kind: "execute", + label: `flow policy ${mode}`, + steps: [ + actionStep( + "result", + "flow_policy", + action, + collectGenericObjectArgs(args), + ), + ], + }; } function buildCoordinatorPlan(args: string[]): CliPlan { - const toolName = requireValue(firstPositional(args), "coordinator tool").replace(/-/g, "_"); - return { kind: "execute", label: `coordinator ${toolName}`, steps: [actionCallStep("result", toolName, collectGenericObjectArgs(args))] }; + const toolName = requireValue( + firstPositional(args), + "coordinator tool", + ).replace(/-/g, "_"); + return { + kind: "execute", + label: `coordinator ${toolName}`, + steps: [actionCallStep("result", toolName, collectGenericObjectArgs(args))], + }; } function buildUpdatePlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "status"; - if (sub === "actions") return { kind: "execute", label: "update actions", steps: [listActionsStep("actions", "update")] }; - if (sub === "status" || sub === "state" || sub === "snapshot" || sub === "show") { - return { kind: "execute", label: "update status", steps: [actionStep("result", "update", "getSnapshot", collectGenericObjectArgs(args))] }; + if (sub === "actions") + return { + kind: "execute", + label: "update actions", + steps: [listActionsStep("actions", "update")], + }; + if ( + sub === "status" || + sub === "state" || + sub === "snapshot" || + sub === "show" + ) { + return { + kind: "execute", + label: "update status", + steps: [ + actionStep( + "result", + "update", + "getSnapshot", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "check" || sub === "check-for-updates" || sub === "check-now") { - return { kind: "execute", label: "update check", steps: [actionStep("result", "update", "checkForUpdates", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "update check", + steps: [ + actionStep( + "result", + "update", + "checkForUpdates", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "install" || sub === "quit-and-install" || sub === "apply") { - return { kind: "execute", label: "update install", steps: [actionStep("result", "update", "quitAndInstall", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "update install", + steps: [ + actionStep( + "result", + "update", + "quitAndInstall", + collectGenericObjectArgs(args), + ), + ], + }; } - if (sub === "dismiss" || sub === "dismiss-installed" || sub === "dismiss-installed-notice") { - return { kind: "execute", label: "update dismiss", steps: [actionStep("result", "update", "dismissInstalledNotice", collectGenericObjectArgs(args))] }; + if ( + sub === "dismiss" || + sub === "dismiss-installed" || + sub === "dismiss-installed-notice" + ) { + return { + kind: "execute", + label: "update dismiss", + steps: [ + actionStep( + "result", + "update", + "dismissInstalledNotice", + collectGenericObjectArgs(args), + ), + ], + }; } - return { kind: "execute", label: `update ${sub}`, steps: [actionStep("result", "update", sub, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `update ${sub}`, + steps: [ + actionStep("result", "update", sub, collectGenericObjectArgs(args)), + ], + }; } const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ // Only flags that actually take a following value (readValue / readIntOption // callers) belong here. Boolean-only flags consumed via readFlag must be // excluded, otherwise the next positional would be swallowed as their value. - "-b", "-m", "-q", "-t", - "--additional-instructions", "--app", "--app-bundle", "--arg", "--arg-json", "--arg-value", - "--arg-value-json", "--args-list-json", "--attempt", "--attempt-id", - "--automation", "--autonomy", "--backend", "--base", "--base-branch", "--base-ref", "--body", "--branch", - "--branch-name", "--branch-ref", "--bundle", "--bundle-id", "--category", "--color", "--cols", - "--command", "--comment", "--comment-id", "--commit", "--compare-ref", - "--caption", "--cdp-port", "--chat-session", "--chat-session-id", "--compare-to", "--content", "--context-file", "--cwd", "--data", - "--cpu", "--cpu-cores", + "-b", + "-m", + "-q", + "-t", + "--additional-instructions", + "--app", + "--app-bundle", + "--arg", + "--arg-json", + "--arg-value", + "--arg-value-json", + "--args-list-json", + "--attempt", + "--attempt-id", + "--automation", + "--autonomy", + "--backend", + "--base", + "--base-branch", + "--base-ref", + "--body", + "--branch", + "--branch-name", + "--branch-ref", + "--bundle", + "--bundle-id", + "--category", + "--color", + "--cols", + "--command", + "--comment", + "--comment-id", + "--commit", + "--compare-ref", + "--caption", + "--cdp-port", + "--chat-session", + "--chat-session-id", + "--compare-to", + "--content", + "--context-file", + "--cwd", + "--data", + "--cpu", + "--cpu-cores", "--debug-port", - "--depth", "--desc", "--device", "--disk", "--disk-size", "--display", "--duration", "--duration-ms", - "--description", "--domain", "--droid-autonomy", "--droid-permission-mode", - "--duration-sec", "--enabled", "--event", - "--end-x", "--end-y", "--file", "--fps", "--from", "--from-file", "--group", "--group-id", "--head", "--icon", "--id", - "--image", "--index", "--initial-input", "--input", "--input-json", "--input-text", "--instructions", - "--ipsw", "--kind", - "--json-input", "--lane", "--lane-id", "--limit", "--max-bytes", + "--depth", + "--desc", + "--device", + "--disk", + "--disk-size", + "--display", + "--duration", + "--duration-ms", + "--description", + "--domain", + "--droid-autonomy", + "--droid-permission-mode", + "--duration-sec", + "--enabled", + "--event", + "--end-x", + "--end-y", + "--file", + "--fps", + "--from", + "--from-file", + "--group", + "--group-id", + "--head", + "--icon", + "--id", + "--image", + "--index", + "--initial-input", + "--input", + "--input-json", + "--input-text", + "--instructions", + "--ipsw", + "--kind", + "--json-input", + "--lane", + "--lane-id", + "--limit", + "--max-bytes", "--line", - "--max-log-bytes", "--max-prompt-chars", "--max-rounds", "--memory", - "--memory-id", "--merge-method", "--message", "--method", "--mode", "--model", - "--model-id", "--name", "--new", "--new-path", "--number", "--old", - "--old-path", "--owner", "--owner-id", "--owner-kind", + "--max-log-bytes", + "--max-prompt-chars", + "--max-rounds", + "--memory", + "--memory-id", + "--merge-method", + "--message", + "--method", + "--mode", + "--model", + "--model-id", + "--name", + "--new", + "--new-path", + "--number", + "--old", + "--old-path", + "--owner", + "--owner-id", + "--owner-kind", "--output", - "--params-json", "--parent", "--parent-lane", "--parent-lane-id", - "--path", "--permission-mode", "--permissions", "--port", "--pr", "--pr-id", - "--pr-number", "--pr-url", "--process", "--process-id", "--project-root", - "--prompt", "--provider", "--pty", "--pty-id", "--query", "--question", - "--reason", "--reasoning", "--recent-limit", "--ref", "--resume-session", "--resume-session-id", - "--resume-target", "--resume-target-id", "--role", "--root", - "--root-lane", "--round", "--rounds", "--rows", "--rule", "--run", "--run-id", "--scalar", - "--scalar-json", "--scope", "--seconds", "--session", "--session-id", "--set", - "--set-json", "--sha", "--signal", "--since", "--source", "--source-lane", "--stack", "--stack-id", - "--scheme", "--start-point", "--start-x", "--start-y", "--stash-ref", "--step", "--step-id", "--suite", "--suite-id", "--surface", - "--tab", "--tab-identifier", "--target", "--target-id", "--terminal", "--terminal-id", "--thread", "--thread-id", "--timeout", "--timeout-ms", "--title", "--tool-type", + "--params-json", + "--parent", + "--parent-lane", + "--parent-lane-id", + "--path", + "--permission-mode", + "--permissions", + "--port", + "--pr", + "--pr-id", + "--pr-number", + "--pr-url", + "--process", + "--process-id", + "--project-root", + "--prompt", + "--provider", + "--pty", + "--pty-id", + "--query", + "--question", + "--reason", + "--reasoning", + "--recent-limit", + "--ref", + "--resume-session", + "--resume-session-id", + "--resume-target", + "--resume-target-id", + "--role", + "--root", + "--root-lane", + "--round", + "--rounds", + "--rows", + "--rule", + "--run", + "--run-id", + "--scalar", + "--scalar-json", + "--scope", + "--seconds", + "--session", + "--session-id", + "--set", + "--set-json", + "--sha", + "--signal", + "--since", + "--source", + "--source-lane", + "--stack", + "--stack-id", + "--scheme", + "--start-point", + "--start-x", + "--start-y", + "--stash-ref", + "--step", + "--step-id", + "--suite", + "--suite-id", + "--surface", + "--tab", + "--tab-identifier", + "--target", + "--target-id", + "--terminal", + "--terminal-id", + "--thread", + "--thread-id", + "--timeout", + "--timeout-ms", + "--title", + "--tool-type", "--title-query", - "--udid", "--unattended", "--unattended-preset", "--url", "--value", "--vm-name", "--window-title", "--workspace", "--workspace-id", "--workspace-root", - "--coordinate-space", "--coords", - "--x", "--xcodeproj", "--y", + "--udid", + "--unattended", + "--unattended-preset", + "--url", + "--value", + "--vm-name", + "--window-title", + "--workspace", + "--workspace-id", + "--workspace-root", + "--coordinate-space", + "--coords", + "--x", + "--xcodeproj", + "--y", ]); function hasHelpFlag(args: string[]): boolean { const terminatorIndex = args.indexOf("--"); - const searchable = terminatorIndex >= 0 ? args.slice(0, terminatorIndex) : args; + const searchable = + terminatorIndex >= 0 ? args.slice(0, terminatorIndex) : args; const valueCarrierFlags = VALUE_CARRIER_FLAGS; for (let i = 0; i < searchable.length; i++) { const token = searchable[i]!; @@ -4214,8 +8570,14 @@ function hasHelpFlag(args: string[]): boolean { function buildCliPlan(command: string[]): CliPlan { const args = [...command]; + if (args[0] === "--version" || args[0] === "-v") { + return { kind: "help", text: `ade ${VERSION}\n` }; + } const primary = firstPositional(args); - if (!primary || primary === "-h" || primary === "--help") { + if (!primary) { + return { kind: "help", text: TOP_LEVEL_HELP }; + } + if (primary === "-h" || primary === "--help") { return { kind: "help", text: TOP_LEVEL_HELP }; } const aliases: Record = { @@ -4255,6 +8617,8 @@ function buildCliPlan(command: string[]): CliPlan { automation: "automations", "auto-update": "update", updates: "update", + operation: "operations", + project: "projects", quota: "usage", quotas: "usage", }; @@ -4269,7 +8633,10 @@ function buildCliPlan(command: string[]): CliPlan { if (primaryHelpKey === "app-control") { return { kind: "help", text: buildAppControlHelp(args) }; } - return { kind: "help", text: HELP_BY_COMMAND[primaryHelpKey] ?? TOP_LEVEL_HELP }; + return { + kind: "help", + text: HELP_BY_COMMAND[primaryHelpKey] ?? TOP_LEVEL_HELP, + }; } if (primary === "help") { const topic = (firstPositional(args) ?? "").toLowerCase(); @@ -4283,13 +8650,50 @@ function buildCliPlan(command: string[]): CliPlan { if (key === "app-control") { return { kind: "help", text: buildAppControlHelp(args) }; } - return { kind: "help", text: key && HELP_BY_COMMAND[key] ? HELP_BY_COMMAND[key] : TOP_LEVEL_HELP }; + return { + kind: "help", + text: key && HELP_BY_COMMAND[key] ? HELP_BY_COMMAND[key] : TOP_LEVEL_HELP, + }; } if (primary === "version" || primary === "--version" || primary === "-v") { return { kind: "help", text: `ade ${VERSION}\n` }; } + if (primary === "code") { + const rest = args; + return { kind: "ade-code", rest }; + } + if (primary === "desktop") { + return { kind: "desktop", rest: args }; + } + if (primary === "runtime") { + return { kind: "runtime", rest: args }; + } + if (primary === "serve") { + return { kind: "serve", rest: args }; + } + if (primary === "rpc") { + const sub = firstPositional(args); + if (sub === "stdio" || readFlag(args, ["--stdio"])) { + return { kind: "rpc-stdio", rest: args }; + } + throw new CliUsageError("rpc currently supports only --stdio."); + } + if (primary === "init") { + return { kind: "init", targetPath: firstPositional(args) }; + } + if (primary === "projects" || primary === "project") { + return buildProjectsPlan(args); + } + if (primary === "sync") { + return buildSyncPlan(args); + } if (primary === "status") { - return { kind: "execute", label: "status", summary: "status", steps: [{ key: "ping", method: "ping" }] }; + return { + kind: "execute", + label: "status", + summary: "status", + steps: [{ key: "ping", method: "ping" }], + }; } if (primary === "doctor") { return { @@ -4300,20 +8704,27 @@ function buildCliPlan(command: string[]): CliPlan { { key: "ping", method: "ping" }, { key: "rpcActions", method: "ade/actions/list" }, listActionsStep("actions"), - { ...actionStep("projectConfig", "project_config", "get"), optional: true }, + { + ...actionStep("projectConfig", "project_config", "get"), + optional: true, + }, ], }; } if (primary === "auth") { const sub = firstPositional(args) ?? "status"; - if (sub !== "status") throw new CliUsageError("auth currently supports status."); + if (sub !== "status") + throw new CliUsageError("auth currently supports status."); return { kind: "execute", label: "auth status", summary: "auth", steps: [ { key: "actions", method: "ade/actions/list" }, - { ...actionStep("projectConfig", "project_config", "get"), optional: true }, + { + ...actionStep("projectConfig", "project_config", "get"), + optional: true, + }, ], }; } @@ -4324,32 +8735,87 @@ function buildCliPlan(command: string[]): CliPlan { if (primary === "git") return buildGitPlan(args); if (primary === "diff" || primary === "diffs") return buildDiffPlan(args); if (primary === "files" || primary === "file") return buildFilesPlan(args); - if (primary === "missions" || primary === "mission") return buildMissionsPlan(args); + if (primary === "missions" || primary === "mission") + return buildMissionsPlan(args); if (primary === "prs" || primary === "pr") return buildPrPlan(args); - if (primary === "run" || primary === "process" || primary === "processes") return buildRunPlan(args); + if (primary === "run" || primary === "process" || primary === "processes") + return buildRunPlan(args); if (primary === "shell" || primary === "pty") return buildShellPlan(args); - if (primary === "terminal" || primary === "term") return buildTerminalPlan(args); - if (primary === "chat" || primary === "chats" || primary === "work") return buildChatPlan(args); + if (primary === "terminal" || primary === "term") + return buildTerminalPlan(args); + if (primary === "chat" || primary === "chats" || primary === "work") + return buildChatPlan(args); if (primary === "agent" || primary === "agents") return buildAgentPlan(args); if (primary === "cto") return buildCtoPlan(args); if (primary === "linear") return buildLinearPlan(args); - if (primary === "automations" || primary === "automation") return buildAutomationsPlan(args); + if (primary === "automations" || primary === "automation") + return buildAutomationsPlan(args); if (primary === "flow") return buildFlowPlan(args); - if (primary === "coordinator" || primary === "coord") return buildCoordinatorPlan(args); - if (primary === "ask") return { kind: "execute", label: "ask user", steps: [actionCallStep("result", "ask_user", collectGenericObjectArgs(args, { title: readValue(args, ["--title"]) ?? "ADE question", body: readValue(args, ["--body", "--question"]) ?? args.join(" ") }))] }; + if (primary === "coordinator" || primary === "coord") + return buildCoordinatorPlan(args); + if (primary === "ask") + return { + kind: "execute", + label: "ask user", + steps: [ + actionCallStep( + "result", + "ask_user", + collectGenericObjectArgs(args, { + title: readValue(args, ["--title"]) ?? "ADE question", + body: readValue(args, ["--body", "--question"]) ?? args.join(" "), + }), + ), + ], + }; if (primary === "tests" || primary === "test") return buildTestsPlan(args); - if (primary === "proof" || primary === "computer-use" || primary === "artifacts" || primary === "computer" || primary === "artifact") { + if ( + primary === "proof" || + primary === "computer-use" || + primary === "artifacts" || + primary === "computer" || + primary === "artifact" + ) { return buildProofPlan(args); } - if (primary === "ios-sim" || primary === "ios" || primary === "simulator") return buildIosSimulatorPlan(args); - if (primary === "app-control" || primary === "app" || primary === "apps" || primary === "electron") return buildAppControlPlan(args); - if (primary === "macos-vm" || primary === "macos" || primary === "mac-vm" || primary === "macvm") return buildMacosVmPlan(args); - if (primary === "browser" || primary === "ade-browser" || primary === "built-in-browser" || primary === "builtin-browser") return buildBrowserPlan(args); + if (primary === "ios-sim" || primary === "ios" || primary === "simulator") + return buildIosSimulatorPlan(args); + if ( + primary === "app-control" || + primary === "app" || + primary === "apps" || + primary === "electron" + ) + return buildAppControlPlan(args); + if ( + primary === "macos-vm" || + primary === "macos" || + primary === "mac-vm" || + primary === "macvm" + ) + return buildMacosVmPlan(args); + if ( + primary === "browser" || + primary === "ade-browser" || + primary === "built-in-browser" || + primary === "builtin-browser" + ) + return buildBrowserPlan(args); if (primary === "memory") return buildMemoryPlan(args); - if (primary === "usage" || primary === "quota" || primary === "quotas") return buildUsagePlan(args); - if (primary === "settings" || primary === "config" || primary === "setting") return buildSettingsPlan(args); - if (primary === "actions" || primary === "action") return buildActionsPlan(args); - if (primary === "update" || primary === "auto-update" || primary === "updates") return buildUpdatePlan(args); + if (primary === "usage" || primary === "quota" || primary === "quotas") + return buildUsagePlan(args); + if (primary === "settings" || primary === "config" || primary === "setting") + return buildSettingsPlan(args); + if (primary === "operation" || primary === "operations") + return buildOperationsPlan(args); + if (primary === "actions" || primary === "action") + return buildActionsPlan(args); + if ( + primary === "update" || + primary === "auto-update" || + primary === "updates" + ) + return buildUpdatePlan(args); if (primary === "mcp" || primary === "mcp-server") return { kind: "mcp" }; if (primary === "cursor") return buildCursorPlan(args); throw new CliUsageError(`Unknown command '${primary}'. Run 'ade help'.`); @@ -4362,7 +8828,9 @@ function buildCursorPlan(args: string[]): CliPlan { return { kind: "help", text: HELP_BY_COMMAND.cursor ?? TOP_LEVEL_HELP }; } if (surface !== "cloud") { - throw new CliUsageError(`Unknown 'ade cursor' surface '${surface}'. The only supported surface is 'cloud'.`); + throw new CliUsageError( + `Unknown 'ade cursor' surface '${surface}'. The only supported surface is 'cloud'.`, + ); } if (hasHelpFlag(args)) { const group = peekFirstPositional(args)?.toLowerCase(); @@ -4374,33 +8842,11 @@ function buildCursorPlan(args: string[]): CliPlan { return { kind: "cursor-cloud", rest: args }; } -function findAdeManagedWorktreeRoot(startDir: string): { projectRoot: string; workspaceRoot: string } | null { - let resolved = path.resolve(startDir); - try { - resolved = fs.realpathSync.native(resolved); - } catch { - // path may not yet exist on disk; use the lexical resolution. - } - const segments = resolved.split(path.sep); - for (let index = segments.length - 2; index >= 0; index -= 1) { - if (segments[index] !== ".ade" || segments[index + 1] !== "worktrees") continue; - const projectRoot = segments.slice(0, index).join(path.sep) || path.sep; - const worktreeName = segments[index + 2]; - if (!worktreeName) continue; - const workspaceRoot = segments.slice(0, index + 3).join(path.sep) || path.sep; - if (!fs.existsSync(path.join(projectRoot, ".ade"))) continue; - return { projectRoot: path.resolve(projectRoot), workspaceRoot: path.resolve(workspaceRoot) }; - } - return null; -} - -function findProjectRoots(startDir: string): { projectRoot: string; workspaceRoot: string } { - let canonicalStart = path.resolve(startDir); - try { - canonicalStart = fs.realpathSync.native(canonicalStart); - } catch { - // path may not yet exist on disk; use the lexical resolution. - } +function findProjectRoots(startDir: string): { + projectRoot: string; + workspaceRoot: string; +} { + const canonicalStart = realpathIfExists(startDir); const managedWorktree = findAdeManagedWorktreeRoot(canonicalStart); if (managedWorktree) return managedWorktree; @@ -4424,7 +8870,10 @@ function findProjectRoots(startDir: string): { projectRoot: string; workspaceRoo return { projectRoot: fallback, workspaceRoot: fallback }; } -function resolveRoots(options: GlobalOptions): { projectRoot: string; workspaceRoot: string } { +function resolveRoots(options: GlobalOptions): { + projectRoot: string; + workspaceRoot: string; +} { const discovered = findProjectRoots(process.cwd()); const projectFromEnv = process.env.ADE_PROJECT_ROOT?.trim() ? path.resolve(process.env.ADE_PROJECT_ROOT.trim()) @@ -4433,13 +8882,15 @@ function resolveRoots(options: GlobalOptions): { projectRoot: string; workspaceR ? path.resolve(process.env.ADE_WORKSPACE_ROOT.trim()) : null; - const projectRoot = options.projectRoot ?? projectFromEnv ?? discovered.projectRoot; - const projectExplicitlyOverridden = options.projectRoot != null || projectFromEnv != null; + const projectRoot = + options.projectRoot ?? projectFromEnv ?? discovered.projectRoot; + const projectExplicitlyOverridden = + options.projectRoot != null || projectFromEnv != null; const workspaceRoot = - options.workspaceRoot - ?? workspaceFromEnv - ?? (projectExplicitlyOverridden ? projectRoot : discovered.workspaceRoot); + options.workspaceRoot ?? + workspaceFromEnv ?? + (projectExplicitlyOverridden ? projectRoot : discovered.workspaceRoot); return { projectRoot, workspaceRoot }; } @@ -4453,7 +8904,59 @@ function commandExists(command: string): boolean { return result.status === 0 && result.stdout.trim().length > 0; } -function runLocalCommand(command: string, args: string[], cwd: string): { ok: boolean; stdout: string; stderr: string } { +function resolveAdeCodeSocketPath(projectRoot: string): string { + return ( + process.env.ADE_RPC_URL?.trim() || + process.env.ADE_RPC_SOCKET_PATH?.trim() || + process.env.ADE_RUNTIME_SOCKET_PATH?.trim() || + resolveMachineAdeLayout().socketPath || + path.join(projectRoot, ".ade", "ade.sock") + ); +} + +function buildAdeCodeArgs(rest: string[], options: GlobalOptions): string[] { + const roots = resolveRoots(options); + return [ + "--project-root", + roots.projectRoot, + "--workspace-root", + roots.workspaceRoot, + ...(options.headless ? ["--embedded"] : []), + ...(options.requireSocket + ? [ + "--socket", + resolveAdeCodeSocketPath(roots.projectRoot), + "--require-socket", + ] + : []), + ...rest, + ]; +} + +async function runAdeCode( + rest: string[], + options: GlobalOptions, +): Promise<{ output: string; exitCode: number }> { + const sourceModule = path.join( + CLI_PACKAGE_ROOT, + "src", + "tuiClient", + "cli.tsx", + ); + const builtModule = CLI_ENTRY_PATH + ? path.join(path.dirname(CLI_ENTRY_PATH), "tuiClient", "cli.mjs") + : path.join(CLI_PACKAGE_ROOT, "dist", "tuiClient", "cli.mjs"); + const modulePath = fs.existsSync(builtModule) ? builtModule : sourceModule; + const { runAdeCodeCli } = await import(pathToFileURL(modulePath).href); + const exitCode = await runAdeCodeCli(buildAdeCodeArgs(rest, options)); + return { output: "", exitCode }; +} + +function runLocalCommand( + command: string, + args: string[], + cwd: string, +): { ok: boolean; stdout: string; stderr: string } { const result = spawnSync(command, args, { cwd, encoding: "utf8", @@ -4476,7 +8979,11 @@ function checkGitReadiness(projectRoot: string): ReadinessCheck { nextAction: "Install git and rerun ade doctor.", }; } - const inside = runLocalCommand("git", ["rev-parse", "--is-inside-work-tree"], projectRoot); + const inside = runLocalCommand( + "git", + ["rev-parse", "--is-inside-work-tree"], + projectRoot, + ); if (!inside.ok || inside.stdout !== "true") { return { ready: false, @@ -4485,8 +8992,16 @@ function checkGitReadiness(projectRoot: string): ReadinessCheck { nextAction: "Run ade with --project-root pointing at a git repository.", }; } - const root = runLocalCommand("git", ["rev-parse", "--show-toplevel"], projectRoot); - const branch = runLocalCommand("git", ["branch", "--show-current"], projectRoot); + const root = runLocalCommand( + "git", + ["rev-parse", "--show-toplevel"], + projectRoot, + ); + const branch = runLocalCommand( + "git", + ["branch", "--show-current"], + projectRoot, + ); return { ready: true, status: "ready", @@ -4499,7 +9014,11 @@ function checkGitReadiness(projectRoot: string): ReadinessCheck { } function getGitRemote(projectRoot: string): string | null { - const remote = runLocalCommand("git", ["config", "--get", "remote.origin.url"], projectRoot); + const remote = runLocalCommand( + "git", + ["config", "--get", "remote.origin.url"], + projectRoot, + ); return remote.ok && remote.stdout ? remote.stdout : null; } @@ -4507,7 +9026,9 @@ function checkGitHubReadiness(projectRoot: string): ReadinessCheck { const remote = getGitRemote(projectRoot); const hasGitHubRemote = Boolean(remote && /github\.com[:/]/i.test(remote)); const ghInstalled = commandExists("gh"); - const envTokenPresent = Boolean(process.env.ADE_GITHUB_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim()); + const envTokenPresent = Boolean( + process.env.ADE_GITHUB_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim(), + ); const ready = hasGitHubRemote && (ghInstalled || envTokenPresent); return { ready, @@ -4533,12 +9054,14 @@ function checkGitHubReadiness(projectRoot: string): ReadinessCheck { function checkLinearReadiness(projectRoot: string): ReadinessCheck { const { resolveAdeLayout } = requireAdeLayout(); const layout = resolveAdeLayout(projectRoot); - const encryptedTokenPresent = fs.existsSync(path.join(layout.secretsDir, "linear-token.v1.bin")); + const encryptedTokenPresent = fs.existsSync( + path.join(layout.secretsDir, "linear-token.v1.bin"), + ); const envTokenPresent = Boolean( - process.env.ADE_LINEAR_API?.trim() - || process.env.LINEAR_API_KEY?.trim() - || process.env.ADE_LINEAR_TOKEN?.trim() - || process.env.LINEAR_TOKEN?.trim() + process.env.ADE_LINEAR_API?.trim() || + process.env.LINEAR_API_KEY?.trim() || + process.env.ADE_LINEAR_TOKEN?.trim() || + process.env.LINEAR_TOKEN?.trim(), ); const ready = encryptedTokenPresent || envTokenPresent; return { @@ -4558,8 +9081,12 @@ function checkLinearReadiness(projectRoot: string): ReadinessCheck { } function checkProviderReadiness(value: unknown): ReadinessCheck { - const configResult = isRecord(value) && isRecord(value.result) ? value.result : value; - const effective = isRecord(configResult) && isRecord(configResult.effective) ? configResult.effective : {}; + const configResult = + isRecord(value) && isRecord(value.result) ? value.result : value; + const effective = + isRecord(configResult) && isRecord(configResult.effective) + ? configResult.effective + : {}; const ai = isRecord(effective.ai) ? effective.ai : {}; const defaultProvider = asString(ai.defaultProvider) ?? asString(ai.mode); const defaultModel = asString(ai.defaultModel); @@ -4571,8 +9098,15 @@ function checkProviderReadiness(value: unknown): ReadinessCheck { cursor: commandExists("agent") || commandExists("cursor-agent"), droid: commandExists("droid"), }; - const apiKeyProviders = Object.keys(apiKeys).filter((key) => Boolean(asString(apiKeys[key]))); - const ready = Boolean(defaultProvider || defaultModel || apiKeyProviders.length || Object.values(cliProviders).some(Boolean)); + const apiKeyProviders = Object.keys(apiKeys).filter((key) => + Boolean(asString(apiKeys[key])), + ); + const ready = Boolean( + defaultProvider || + defaultModel || + apiKeyProviders.length || + Object.values(cliProviders).some(Boolean), + ); return { ready, status: ready ? "ready" : "warning", @@ -4595,7 +9129,8 @@ function checkComputerUseReadiness(): ReadinessCheck { const isDarwin = process.platform === "darwin"; const screenshotReady = isDarwin && commandExists("screencapture"); const appLaunchReady = isDarwin && commandExists("open"); - const guiReady = isDarwin && (commandExists("swift") || commandExists("osascript")); + const guiReady = + isDarwin && (commandExists("swift") || commandExists("osascript")); const ready = isDarwin && screenshotReady && appLaunchReady && guiReady; return { ready, @@ -4620,16 +9155,22 @@ function checkComputerUseReadiness(): ReadinessCheck { } function checkPathReadiness(): ReadinessCheck { - const lookup = process.platform === "win32" - ? runLocalCommand("where", ["ade"], process.cwd()) - : runLocalCommand("which", ["ade"], process.cwd()); + const lookup = + process.platform === "win32" + ? runLocalCommand("where", ["ade"], process.cwd()) + : runLocalCommand("which", ["ade"], process.cwd()); const current = path.resolve(process.argv[1] ?? ""); - const whichPath = lookup.ok && lookup.stdout ? path.resolve(lookup.stdout.split(/\r?\n/)[0]!) : null; + const whichPath = + lookup.ok && lookup.stdout + ? path.resolve(lookup.stdout.split(/\r?\n/)[0]!) + : null; const onPath = Boolean(whichPath); return { ready: onPath, status: onPath ? "ready" : "warning", - message: onPath ? "ade is available on PATH." : "ade is not available on PATH.", + message: onPath + ? "ade is available on PATH." + : "ade is not available on PATH.", nextAction: onPath ? undefined : process.platform === "win32" @@ -4646,14 +9187,23 @@ function checkPathReadiness(): ReadinessCheck { }; } -function requireAdeLayout(): { resolveAdeLayout: (projectRoot: string) => { secretsDir: string } } { +function requireAdeLayout(): { + resolveAdeLayout: (projectRoot: string) => { secretsDir: string }; +} { // The CLI loads the shared layout dynamically elsewhere; this CommonJS fallback // keeps readiness checks synchronous and local-only. - return { resolveAdeLayout: (projectRoot: string) => ({ secretsDir: path.join(projectRoot, ".ade", "secrets") }) }; + return { + resolveAdeLayout: (projectRoot: string) => ({ + secretsDir: path.join(projectRoot, ".ade", "secrets"), + }), + }; } function actionDomainCounts(value: unknown): Record { - const actions = isRecord(value) && Array.isArray(value.actions) ? value.actions.filter(isRecord) : []; + const actions = + isRecord(value) && Array.isArray(value.actions) + ? value.actions.filter(isRecord) + : []; return actions.reduce>((acc, action) => { const domain = asString(action.domain) ?? "core"; acc[domain] = (acc[domain] ?? 0) + 1; @@ -4667,15 +9217,24 @@ function buildReadinessSnapshot(args: { summary: "doctor" | "auth"; }): JsonObject { const { connection, values, summary } = args; - const rpcActions = isRecord(values.rpcActions) && Array.isArray(values.rpcActions.actions) ? values.rpcActions.actions : []; - const actions = isRecord(values.actions) && Array.isArray(values.actions.actions) ? values.actions.actions : []; + const rpcActions = + isRecord(values.rpcActions) && Array.isArray(values.rpcActions.actions) + ? values.rpcActions.actions + : []; + const actions = + isRecord(values.actions) && Array.isArray(values.actions.actions) + ? values.actions.actions + : []; const projectConfig = values.projectConfig; const adeDir = path.join(connection.projectRoot, ".ade"); const sharedConfigPath = path.join(adeDir, "ade.yaml"); const localConfigPath = path.join(adeDir, "local.yaml"); + const attachedSocketAvailable = + connection.mode === "runtime-socket" || + connection.mode === "desktop-socket"; const desktopSocketAvailable = connection.mode === "desktop-socket"; const socketExists = isAdeMcpNamedPipePath(connection.socketPath) - ? desktopSocketAvailable + ? attachedSocketAvailable : fs.existsSync(connection.socketPath); const checks = { git: checkGitReadiness(connection.projectRoot), @@ -4688,12 +9247,16 @@ function buildReadinessSnapshot(args: { const recommendations = Object.entries(checks) .filter(([, check]) => check.nextAction) .map(([key, check]) => `${key}: ${check.nextAction}`); - if (!desktopSocketAvailable) { - recommendations.unshift("desktop: Start ADE desktop or pass --socket when Work chat, Path to Merge, Run tab state, or UI-owned proof state is required."); + if (!attachedSocketAvailable) { + recommendations.unshift( + "runtime: Start ADE runtime or remove --headless when Work chat, Path to Merge, Run tab state, or shared proof state is required.", + ); } const projectInitialized = fs.existsSync(adeDir); if (!projectInitialized) { - recommendations.unshift("project: Run ade doctor from an ADE project or pass --project-root ."); + recommendations.unshift( + "project: Run ade doctor from an ADE project or pass --project-root .", + ); } const actionCountsByDomain = actionDomainCounts(values.actions); const ready = projectInitialized && checks.git.ready && actions.length > 0; @@ -4704,7 +9267,12 @@ function buildReadinessSnapshot(args: { protocolVersion: PROTOCOL_VERSION, mode: connection.mode, selectedMode: connection.mode, - requestedMode: desktopSocketAvailable ? "desktop-socket" : "headless", + requestedMode: + connection.mode === "runtime-socket" + ? "runtime-socket" + : desktopSocketAvailable + ? "desktop-socket" + : "headless", runtime: { node: process.version, execPath: process.execPath, @@ -4727,12 +9295,16 @@ function buildReadinessSnapshot(args: { desktop: { socketPath: connection.socketPath, socketExists, - socketAvailable: desktopSocketAvailable, - message: desktopSocketAvailable - ? "Connected to live ADE desktop socket." - : socketExists - ? "Socket path exists but CLI is running in headless mode; the socket may be stale or unavailable." - : "No live ADE desktop socket was detected.", + socketAvailable: attachedSocketAvailable, + socketMode: connection.mode, + message: + connection.mode === "runtime-socket" + ? "Connected to ADE runtime daemon socket." + : desktopSocketAvailable + ? "Connected to legacy ADE desktop socket." + : socketExists + ? "Socket path exists but CLI is running in headless mode; the socket may be stale or unavailable." + : "No live ADE socket was detected.", }, actions: { rpcActionCount: rpcActions.length, @@ -4752,81 +9324,133 @@ function buildReadinessSnapshot(args: { }, networkChecks: { performed: false, - message: "Default doctor/auth checks do not call provider, GitHub, or Linear networks.", + message: + "Default doctor/auth checks do not call provider, GitHub, or Linear networks.", }, recommendations, - recommendation: recommendations[0] ?? (connection.mode === "desktop-socket" - ? "Using live ADE desktop state." - : "Headless mode is ready for local ADE actions; start ADE desktop for UI-owned runtime state."), + recommendation: + recommendations[0] ?? + (attachedSocketAvailable + ? "Using live ADE runtime state." + : "Headless mode is ready for local ADE actions; start ADE runtime for shared runtime state."), summary, }; } -class SocketJsonRpcClient { - private buffer: Buffer = Buffer.alloc(0); - private nextId = 1; - private pending = new Map void; - reject: (error: Error) => void; - timer: ReturnType; - }>(); - - private constructor(private readonly socket: net.Socket, private readonly timeoutMs: number) { - socket.on("data", (chunk) => this.onData(Buffer.from(chunk))); - socket.on("error", (error) => this.rejectAll(error instanceof Error ? error : new Error(String(error)))); - socket.on("close", () => this.rejectAll(new Error("ADE desktop socket closed."))); +function createSocketConnection(socketPath: string): net.Socket { + if (socketPath.startsWith("tcp://")) { + const parsed = new URL(socketPath); + return net.createConnection({ + host: parsed.hostname, + port: Number(parsed.port), + }); } + return net.createConnection(socketPath); +} - static connect(socketPath: string, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - const connectTimeoutMs = Math.min(timeoutMs, 5000); - const deadline = Date.now() + connectTimeoutMs; - const retryable = (error: NodeJS.ErrnoException) => - error.code === "ENOENT" || error.code === "ECONNREFUSED" || error.code === "EACCES" || error.code === "EPERM"; - const attempt = () => { - const socket = (() => { - if (socketPath.startsWith("tcp://")) { - const parsed = new URL(socketPath); - return net.createConnection({ - host: parsed.hostname, - port: Number(parsed.port), - }); - } - return net.createConnection(socketPath); - })(); - let settled = false; - let connectTimer: ReturnType | null = null; - const finish = (fn: () => void) => { - if (settled) return; - settled = true; - if (connectTimer) clearTimeout(connectTimer); - fn(); - }; - connectTimer = setTimeout(() => { - finish(() => { - socket.destroy(); - reject(new Error(`Timed out connecting to ADE desktop socket after ${connectTimeoutMs}ms.`)); - }); - }, Math.max(1, deadline - Date.now())); - socket.once("connect", () => { - finish(() => resolve(new SocketJsonRpcClient(socket, timeoutMs))); - }); - socket.once("error", (error: NodeJS.ErrnoException) => { +function isRetryableSocketConnectError(error: NodeJS.ErrnoException): boolean { + return ( + error.code === "ENOENT" || + error.code === "ECONNREFUSED" || + error.code === "EACCES" || + error.code === "EPERM" + ); +} + +function connectSocket( + socketPath: string, + timeoutMs: number, + label: string, +): Promise { + return new Promise((resolve, reject) => { + const connectTimeoutMs = Math.min(timeoutMs, 5000); + const deadline = Date.now() + connectTimeoutMs; + const attempt = () => { + const socket = createSocketConnection(socketPath); + let settled = false; + let connectTimer: ReturnType | null = null; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + if (connectTimer) clearTimeout(connectTimer); + fn(); + }; + connectTimer = setTimeout( + () => { finish(() => { socket.destroy(); - if (retryable(error) && Date.now() < deadline) { - setTimeout(attempt, 100); - return; - } - reject(error); + reject( + new Error( + `Timed out connecting to ${label} after ${connectTimeoutMs}ms.`, + ), + ); }); + }, + Math.max(1, deadline - Date.now()), + ); + socket.once("connect", () => { + finish(() => resolve(socket)); + }); + socket.once("error", (error: NodeJS.ErrnoException) => { + finish(() => { + socket.destroy(); + if (isRetryableSocketConnectError(error) && Date.now() < deadline) { + setTimeout(attempt, 100); + return; + } + reject(error); }); - }; - attempt(); - }); + }); + }; + attempt(); + }); +} + +class SocketJsonRpcClient { + private buffer: Buffer = Buffer.alloc(0); + private nextId = 1; + private closedError: Error | null = null; + private pending = new Map< + number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; + } + >(); + private notificationHandlers = new Map< + string, + Set<(params: unknown) => void> + >(); + private anyNotificationHandlers = new Set< + (method: string, params: unknown) => void + >(); + private closeHandlers = new Set<(error: Error) => void>(); + + private constructor( + private readonly socket: net.Socket, + private readonly timeoutMs: number, + ) { + socket.on("data", (chunk) => this.onData(Buffer.from(chunk))); + socket.on("error", (error) => + this.rejectAll(error instanceof Error ? error : new Error(String(error))), + ); + socket.on("close", () => + this.failConnection(new Error("ADE socket closed.")), + ); + } + + static async connect( + socketPath: string, + timeoutMs: number, + label = "ADE socket", + ): Promise { + const socket = await connectSocket(socketPath, timeoutMs, label); + return new SocketJsonRpcClient(socket, timeoutMs); } - request(method: string, params?: JsonObject): Promise { + request(method: string, params?: unknown): Promise { + if (this.closedError) return Promise.reject(this.closedError); const id = this.nextId; this.nextId += 1; const payload: JsonRpcRequest = { @@ -4851,12 +9475,60 @@ class SocketJsonRpcClient { }); } + notify(method: string, params?: unknown): void { + if (this.closedError) return; + const payload: JsonRpcRequest = { + jsonrpc: "2.0", + method, + ...(params !== undefined ? { params } : {}), + }; + this.socket.write(`${JSON.stringify(payload)}\n`, "utf8"); + } + + onClose(handler: (error: Error) => void): () => void { + if (this.closedError) { + const error = this.closedError; + queueMicrotask(() => handler(error)); + return () => {}; + } + this.closeHandlers.add(handler); + return () => { + this.closeHandlers.delete(handler); + }; + } + + onNotification( + method: string, + handler: (params: unknown) => void, + ): () => void { + const handlers = + this.notificationHandlers.get(method) ?? + new Set<(params: unknown) => void>(); + handlers.add(handler); + this.notificationHandlers.set(method, handlers); + return () => { + handlers.delete(handler); + if (handlers.size === 0) this.notificationHandlers.delete(method); + }; + } + + onAnyNotification( + handler: (method: string, params: unknown) => void, + ): () => void { + this.anyNotificationHandlers.add(handler); + return () => { + this.anyNotificationHandlers.delete(handler); + }; + } + close(): void { this.socket.end(); } private onData(chunk: Buffer): void { - this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk; + this.buffer = this.buffer.length + ? Buffer.concat([this.buffer, chunk]) + : chunk; while (true) { const newline = this.buffer.indexOf(0x0a); if (newline < 0) break; @@ -4872,18 +9544,36 @@ class SocketJsonRpcClient { try { parsed = JSON.parse(line); } catch (error) { - this.rejectAll(new Error(`Failed to parse ADE socket response: ${error instanceof Error ? error.message : String(error)}`)); + this.rejectAll( + new Error( + `Failed to parse ADE socket response: ${error instanceof Error ? error.message : String(error)}`, + ), + ); return; } if (!isRecord(parsed)) return; const id = typeof parsed.id === "number" ? parsed.id : null; - if (id == null) return; + if (id == null) { + const method = asString(parsed.method); + if (!method) return; + for (const handler of this.notificationHandlers.get(method) ?? []) { + handler(parsed.params); + } + for (const handler of this.anyNotificationHandlers) { + handler(method, parsed.params); + } + return; + } const pending = this.pending.get(id); if (!pending) return; this.pending.delete(id); clearTimeout(pending.timer); if (isRecord(parsed.error)) { - pending.reject(new Error(asString(parsed.error.message) ?? "ADE JSON-RPC request failed.")); + pending.reject( + new Error( + asString(parsed.error.message) ?? "ADE JSON-RPC request failed.", + ), + ); return; } pending.resolve(parsed.result); @@ -4896,6 +9586,16 @@ class SocketJsonRpcClient { pending.reject(error); } } + + private failConnection(error: Error): void { + if (this.closedError) return; + this.closedError = error; + this.rejectAll(error); + for (const handler of this.closeHandlers) { + handler(error); + } + this.closeHandlers.clear(); + } } class InProcessJsonRpcClient { @@ -4919,8 +9619,12 @@ class InProcessJsonRpcClient { } close(): void { - try { this.handler.dispose?.(); } catch {} - try { this.runtime.dispose(); } catch {} + try { + this.handler.dispose?.(); + } catch {} + try { + this.runtime.dispose(); + } catch {} if (this.previousRole == null) delete process.env.ADE_DEFAULT_ROLE; else process.env.ADE_DEFAULT_ROLE = this.previousRole; } @@ -4930,7 +9634,10 @@ async function startHeadlessRpcSocketServer(args: { socketPath: string; createHandler: () => JsonRpcHandler & { dispose?: () => void }; }): Promise<(() => void) | null> { - if (isAdeMcpNamedPipePath(args.socketPath) || fs.existsSync(args.socketPath)) { + if ( + isAdeMcpNamedPipePath(args.socketPath) || + fs.existsSync(args.socketPath) + ) { return null; } fs.mkdirSync(path.dirname(args.socketPath), { recursive: true }); @@ -4953,7 +9660,9 @@ async function startHeadlessRpcSocketServer(args: { return () => { stopHeadlessRpcServer(serverState); - try { fs.unlinkSync(args.socketPath); } catch {} + try { + fs.unlinkSync(args.socketPath); + } catch {} }; } @@ -4967,7 +9676,11 @@ async function startHeadlessRpcTcpServer(args: { const handleListening = () => { server.off("error", handleError); const address = server.address(); - if (typeof address === "object" && address && typeof address.port === "number") { + if ( + typeof address === "object" && + address && + typeof address.port === "number" + ) { resolve(address.port); } else { reject(new Error("Headless RPC TCP server did not expose a port.")); @@ -4994,7 +9707,15 @@ type HeadlessRpcServerState = { server: net.Server; }; -function createHeadlessRpcServer(createHandler: () => JsonRpcHandler & { dispose?: () => void }): HeadlessRpcServerState { +type NotifiableJsonRpcHandler = JsonRpcHandler & { + setNotifier?: ( + notify: ((method: string, params?: unknown) => void) | null, + ) => void; +}; + +function createHeadlessRpcServer( + createHandler: () => JsonRpcHandler & { dispose?: () => void }, +): HeadlessRpcServerState { const activeConnections = new Set(); const activeStops = new Set>(); const server = net.createServer((conn) => { @@ -5002,7 +9723,9 @@ function createHeadlessRpcServer(createHandler: () => JsonRpcHandler & { dispose const handler = createHandler(); const transport: JsonRpcTransport = { onData(callback) { - conn.on("data", (chunk) => callback(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + conn.on("data", (chunk) => + callback(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)), + ); }, write(data) { conn.write(data); @@ -5012,6 +9735,9 @@ function createHeadlessRpcServer(createHandler: () => JsonRpcHandler & { dispose }, }; const stop = startJsonRpcServer(handler, transport, { nonFatal: true }); + (handler as NotifiableJsonRpcHandler).setNotifier?.((method, params) => + stop.notify(method, params), + ); activeStops.add(stop); let cleanedUp = false; const cleanup = () => { @@ -5019,8 +9745,12 @@ function createHeadlessRpcServer(createHandler: () => JsonRpcHandler & { dispose cleanedUp = true; activeConnections.delete(conn); activeStops.delete(stop); - try { stop(); } catch {} - try { handler.dispose?.(); } catch {} + try { + stop(); + } catch {} + try { + handler.dispose?.(); + } catch {} }; conn.once("close", cleanup); conn.once("end", cleanup); @@ -5032,12 +9762,18 @@ function createHeadlessRpcServer(createHandler: () => JsonRpcHandler & { dispose function stopHeadlessRpcServer(state: HeadlessRpcServerState): void { for (const conn of state.activeConnections) { - try { conn.destroy(); } catch {} + try { + conn.destroy(); + } catch {} } for (const stop of state.activeStops) { - try { stop(); } catch {} + try { + stop(); + } catch {} } - try { state.server.close(); } catch {} + try { + state.server.close(); + } catch {} } function discoverHeadlessWorktreeSocketPaths(projectRoot: string): string[] { @@ -5097,7 +9833,11 @@ async function startHeadlessRpcSocketServers(args: { const scan = async () => { await ensure(args.socketPath); - await Promise.all(discoverHeadlessWorktreeSocketPaths(args.projectRoot).map((socketPath) => ensure(socketPath))); + await Promise.all( + discoverHeadlessWorktreeSocketPaths(args.projectRoot).map((socketPath) => + ensure(socketPath), + ), + ); }; await scan(); @@ -5110,34 +9850,281 @@ async function startHeadlessRpcSocketServers(args: { stopped = true; clearInterval(interval); for (const stop of stops.values()) { - try { stop(); } catch {} + try { + stop(); + } catch {} } stops.clear(); }; } -export function shouldAttemptDesktopSocketConnection(socketPath: string): boolean { +export function shouldAttemptDesktopSocketConnection( + socketPath: string, +): boolean { return isAdeMcpNamedPipePath(socketPath) || fs.existsSync(socketPath); } -async function initializeConnection(connection: CliConnection, options: GlobalOptions): Promise { - await connection.request("ade/initialize", buildInitializeParams(options, "ade-cli")); +async function initializeConnection( + connection: CliConnection, + options: GlobalOptions, +): Promise { + await connection.request( + "ade/initialize", + buildInitializeParams(options, "ade-cli"), + ); +} + +function isMachineRuntimeScopedMethod(method: string): boolean { + return ( + method === "ade/initialize" || + method === "ade/initialized" || + method === "ping" || + method === "shutdown" || + method === "exit" || + method === "runtime/info" || + method === "machineInfo.get" || + method.startsWith("sync.") || + method.startsWith("projects.") + ); +} + +export function shouldAutoRegisterProjectForPlan( + plan: CliPlan & { kind: "execute" }, +): boolean { + return plan.steps.some((step) => !isMachineRuntimeScopedMethod(step.method)); +} + +function buildSyncPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "status"; + if (sub === "help") { + return { + kind: "help", + text: `${ADE_BANNER} +Usage: + ade sync status [--include-transfer-readiness] + ade sync refresh + ade sync devices + ade sync pin get + ade sync pin generate + ade sync pin set <6-digit-pin> + ade sync pin clear +`, + }; + } + if (sub === "status") { + return { + kind: "execute", + label: "sync status", + steps: [ + { + key: "result", + method: "sync.getStatus", + params: { + includeTransferReadiness: readFlag(args, [ + "--include-transfer-readiness", + ]), + forceTransferReadiness: readFlag(args, [ + "--force-transfer-readiness", + ]), + }, + }, + ], + }; + } + if (sub === "refresh" || sub === "refresh-discovery") { + return { + kind: "execute", + label: "sync refresh", + steps: [{ key: "result", method: "sync.refreshDiscovery" }], + }; + } + if (sub === "devices" || sub === "list-devices") { + return { + kind: "execute", + label: "sync devices", + steps: [{ key: "result", method: "sync.listDevices" }], + }; + } + if (sub === "pin") { + const action = firstPositional(args) ?? "get"; + if (action === "get" || action === "show") { + return { + kind: "execute", + label: "sync pin get", + steps: [{ key: "result", method: "sync.getPin" }], + }; + } + if (action === "set") { + const pin = requireValue( + readValue(args, ["--pin"]) ?? firstPositional(args), + "pin", + ); + return { + kind: "execute", + label: "sync pin set", + steps: [{ key: "result", method: "sync.setPin", params: { pin } }], + }; + } + if (action === "generate" || action === "new") { + return { + kind: "execute", + label: "sync pin generate", + steps: [{ key: "result", method: "sync.generatePin" }], + }; + } + if (action === "clear" || action === "remove") { + return { + kind: "execute", + label: "sync pin clear", + steps: [{ key: "result", method: "sync.clearPin" }], + }; + } + throw new CliUsageError(`Unsupported sync pin action: ${action}`); + } + throw new CliUsageError(`Unsupported sync command: ${sub}`); +} + +function buildProjectsPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "list"; + if (sub === "list" || sub === "ls") { + return { + kind: "execute", + label: "projects list", + formatter: "projects-list", + steps: [{ key: "result", method: "projects.list" }], + }; + } + if (sub === "add" || sub === "register") { + const rootPath = requireValue( + readValue(args, ["--path", "--root"]) ?? firstPositional(args), + "project path", + ); + return { + kind: "execute", + label: "projects add", + formatter: "projects-list", + steps: [{ key: "result", method: "projects.add", params: { rootPath } }], + }; + } + if (sub === "remove" || sub === "rm" || sub === "delete") { + const projectId = requireValue( + readValue(args, ["--project-id", "--id"]) ?? firstPositional(args), + "project id", + ); + return { + kind: "execute", + label: "projects remove", + steps: [ + { key: "result", method: "projects.remove", params: { projectId } }, + ], + }; + } + if (sub === "touch") { + const projectId = requireValue( + readValue(args, ["--project-id", "--id"]) ?? firstPositional(args), + "project id", + ); + return { + kind: "execute", + label: "projects touch", + formatter: "projects-list", + steps: [ + { key: "result", method: "projects.touch", params: { projectId } }, + ], + }; + } + throw new CliUsageError( + `projects supports list, add, remove, or touch; got '${sub}'.`, + ); +} + +function withProjectId( + params: JsonObject | undefined, + projectId: string, +): JsonObject { + return { + ...(params ?? {}), + projectId, + }; } -async function createConnection(options: GlobalOptions): Promise { +async function createConnection( + options: GlobalOptions, + args: { autoRegisterProject?: boolean } = {}, +): Promise { const roots = resolveRoots(options); - const { resolveAdeLayout } = await import("../../desktop/src/shared/adeLayout"); + const { resolveAdeLayout } = + await import("../../desktop/src/shared/adeLayout"); const layout = resolveAdeLayout(roots.projectRoot); - const socketPath = process.env.ADE_RPC_URL?.trim() || process.env.ADE_RPC_SOCKET_PATH?.trim() || layout.socketPath; + const legacySocketPath = + process.env.ADE_RPC_URL?.trim() || + process.env.ADE_RPC_SOCKET_PATH?.trim() || + layout.socketPath; + const autoRegisterProject = args.autoRegisterProject ?? true; + + if (!options.headless) { + let socketClient: SocketJsonRpcClient | null = null; + try { + const machineSocketPath = await resolveMachineRuntimeSocketPath(); + socketClient = await connectMachineRuntimeDaemon(options); + let activeProjectId: string | null = null; + const connection: CliConnection = { + mode: "runtime-socket", + projectRoot: roots.projectRoot, + workspaceRoot: roots.workspaceRoot, + socketPath: machineSocketPath, + request: (method, params) => + socketClient!.request( + method, + activeProjectId && !isMachineRuntimeScopedMethod(method) + ? withProjectId(params, activeProjectId) + : params, + ), + close: () => socketClient?.close(), + }; + if (autoRegisterProject) { + const registered = await connection.request("projects.add", { + rootPath: roots.projectRoot, + }); + const registeredProjectId = isRecord(registered) + ? asString(registered.projectId) + : null; + if (!registeredProjectId) { + throw new Error( + "Machine runtime did not return a projectId from projects.add.", + ); + } + activeProjectId = registeredProjectId; + } + return connection; + } catch (error) { + try { + socketClient?.close(); + } catch {} + if ( + options.requireSocket && + !shouldAttemptDesktopSocketConnection(legacySocketPath) + ) { + throw error; + } + } + } - if (!options.headless && (shouldAttemptDesktopSocketConnection(socketPath) || options.requireSocket)) { + if ( + !options.headless && + (shouldAttemptDesktopSocketConnection(legacySocketPath) || + options.requireSocket) + ) { try { - const socketClient = await SocketJsonRpcClient.connect(socketPath, options.timeoutMs); + const socketClient = await SocketJsonRpcClient.connect( + legacySocketPath, + options.timeoutMs, + ); const connection: CliConnection = { mode: "desktop-socket", projectRoot: roots.projectRoot, workspaceRoot: roots.workspaceRoot, - socketPath, + socketPath: legacySocketPath, request: (method, params) => socketClient.request(method, params), close: () => socketClient.close(), }; @@ -5149,21 +10136,23 @@ async function createConnection(options: GlobalOptions): Promise } if (options.requireSocket) { - throw new Error(`ADE desktop socket is not available at ${socketPath}.`); + throw new Error(`ADE socket is not available at ${legacySocketPath}.`); } const previousRole = process.env.ADE_DEFAULT_ROLE; process.env.ADE_DEFAULT_ROLE = options.role; - const [{ createAdeRuntime }, { createAdeRpcRequestHandler }] = await Promise.all([ - import("./bootstrap"), - import("./adeRpcServer"), - ]); - const runtime = await createAdeRuntime({ projectRoot: roots.projectRoot, workspaceRoot: roots.workspaceRoot }); - const createHandler = () => createAdeRpcRequestHandler({ - runtime, - serverVersion: VERSION, - onActionsListChanged: () => {}, + const [{ createAdeRuntime }, { createAdeRpcRequestHandler }] = + await Promise.all([import("./bootstrap"), import("./adeRpcServer")]); + const runtime = await createAdeRuntime({ + projectRoot: roots.projectRoot, + workspaceRoot: roots.workspaceRoot, }); + const createHandler = () => + createAdeRpcRequestHandler({ + runtime, + serverVersion: VERSION, + onActionsListChanged: () => {}, + }); const handler = createHandler(); const previousRpcUrl = process.env.ADE_RPC_URL; let stopHeadlessSocket: (() => void) | null = null; @@ -5178,7 +10167,7 @@ async function createConnection(options: GlobalOptions): Promise try { stopHeadlessSocket = await startHeadlessRpcSocketServers({ projectRoot: roots.projectRoot, - socketPath, + socketPath: legacySocketPath, createHandler, }); } catch { @@ -5190,11 +10179,15 @@ async function createConnection(options: GlobalOptions): Promise mode: "headless", projectRoot: roots.projectRoot, workspaceRoot: roots.workspaceRoot, - socketPath, + socketPath: legacySocketPath, request: (method, params) => inProcess.request(method, params), close: () => { - try { stopHeadlessSocket?.(); } catch {} - try { stopHeadlessTcp?.(); } catch {} + try { + stopHeadlessSocket?.(); + } catch {} + try { + stopHeadlessTcp?.(); + } catch {} if (previousRpcUrl == null) delete process.env.ADE_RPC_URL; else process.env.ADE_RPC_URL = previousRpcUrl; inProcess.close(); @@ -5204,7 +10197,10 @@ async function createConnection(options: GlobalOptions): Promise return connection; } -function buildInitializeParams(options: GlobalOptions, clientName: string): JsonObject { +function buildInitializeParams( + options: GlobalOptions, + clientName: string, +): JsonObject { const envChatSessionId = asString(process.env.ADE_CHAT_SESSION_ID); const envMissionId = asString(process.env.ADE_MISSION_ID); const envRunId = asString(process.env.ADE_RUN_ID); @@ -5215,7 +10211,8 @@ function buildInitializeParams(options: GlobalOptions, clientName: string): Json protocolVersion: PROTOCOL_VERSION, clientInfo: { name: clientName, version: VERSION }, identity: { - callerId: envChatSessionId ?? envAttemptId ?? `${clientName}:${process.pid}`, + callerId: + envChatSessionId ?? envAttemptId ?? `${clientName}:${process.pid}`, role: options.role, ...(envChatSessionId ? { chatSessionId: envChatSessionId } : {}), ...(envMissionId ? { missionId: envMissionId } : {}), @@ -5247,7 +10244,9 @@ function normalizeMcpAdeToolName(name: string): string { } function mcpToolScope(): "all" | "coordinator" { - return process.env.ADE_MCP_TOOL_SCOPE === "coordinator" ? "coordinator" : "all"; + return process.env.ADE_MCP_TOOL_SCOPE === "coordinator" + ? "coordinator" + : "all"; } function isMcpToolVisible(name: string): boolean { @@ -5265,14 +10264,19 @@ function formatMcpToolText(value: unknown): string { } async function runMcpServer(options: GlobalOptions): Promise { - const roots = resolveRoots({ ...options, headless: true, requireSocket: false }); + const roots = resolveRoots({ + ...options, + headless: true, + requireSocket: false, + }); const previousRole = process.env.ADE_DEFAULT_ROLE; process.env.ADE_DEFAULT_ROLE = options.role; - const [{ createAdeRuntime }, { createAdeRpcRequestHandler }] = await Promise.all([ - import("./bootstrap"), - import("./adeRpcServer"), - ]); - const runtime = await createAdeRuntime({ projectRoot: roots.projectRoot, workspaceRoot: roots.workspaceRoot }); + const [{ createAdeRuntime }, { createAdeRpcRequestHandler }] = + await Promise.all([import("./bootstrap"), import("./adeRpcServer")]); + const runtime = await createAdeRuntime({ + projectRoot: roots.projectRoot, + workspaceRoot: roots.workspaceRoot, + }); const adeHandler = createAdeRpcRequestHandler({ runtime, serverVersion: VERSION, @@ -5280,129 +10284,851 @@ async function runMcpServer(options: GlobalOptions): Promise { }); let initialized = false; let nextAdeRequestId = 1; - const callAde = async (method: string, params?: JsonObject): Promise => { + const callAde = async ( + method: string, + params?: JsonObject, + ): Promise => { return await adeHandler({ jsonrpc: "2.0", id: nextAdeRequestId++, method, ...(params !== undefined ? { params } : {}), }); - }; - const ensureInitialized = async (): Promise => { - if (initialized) return; - await callAde("ade/initialize", buildInitializeParams(options, "ade-mcp")); - initialized = true; - }; + }; + const ensureInitialized = async (): Promise => { + if (initialized) return; + await callAde("ade/initialize", buildInitializeParams(options, "ade-mcp")); + initialized = true; + }; + + const mcpHandler: JsonRpcHandler = async (request) => { + const method = typeof request.method === "string" ? request.method : ""; + const params = isRecord(request.params) ? request.params : {}; + if (method === "initialize") { + await ensureInitialized(); + const requestedVersion = + asString(params.protocolVersion) ?? PROTOCOL_VERSION; + return { + protocolVersion: requestedVersion, + capabilities: { + tools: { + listChanged: false, + }, + }, + serverInfo: { + name: "ade", + version: VERSION, + }, + }; + } + if (method === "notifications/initialized" || method === "initialized") { + await ensureInitialized(); + return null; + } + await ensureInitialized(); + if (method === "tools/list") { + const listed = await callAde("ade/actions/list"); + const actions = + isRecord(listed) && Array.isArray(listed.actions) + ? listed.actions.filter(isRecord) + : []; + return { + tools: actions + .map((action) => ({ + name: asString(action.name) ?? "", + description: asString(action.description) ?? "", + inputSchema: isRecord(action.inputSchema) + ? action.inputSchema + : { type: "object", properties: {} }, + })) + .filter( + (tool) => tool.name.length > 0 && isMcpToolVisible(tool.name), + ), + }; + } + if (method === "tools/call") { + const rawName = asString(params.name); + if (!rawName) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "tools/call requires a tool name.", + ); + } + if (!isMcpToolVisible(rawName)) { + throw new JsonRpcError( + JsonRpcErrorCode.methodNotFound, + `Tool not available in this MCP scope: ${rawName}`, + ); + } + const result = await callAde("ade/actions/call", { + name: normalizeMcpAdeToolName(rawName), + arguments: isRecord(params.arguments) ? params.arguments : {}, + }); + const isError = isRecord(result) && result.ok === false; + return { + content: [ + { + type: "text", + text: formatMcpToolText(result), + }, + ], + structuredContent: result ?? null, + isError, + }; + } + if (method === "shutdown") { + return {}; + } + if (method === "exit") { + process.nextTick(() => process.exit(0)); + return {}; + } + throw new JsonRpcError( + JsonRpcErrorCode.methodNotFound, + `Method not found: ${method}`, + ); + }; + + const transport: JsonRpcTransport = { + onData(callback) { + process.stdin.on("data", (chunk) => + callback(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)), + ); + }, + write(data) { + process.stdout.write(data); + }, + close() { + process.stdin.pause(); + }, + }; + const stop = startJsonRpcServer(mcpHandler, transport, { nonFatal: true }); + await new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + resolve(); + }; + process.stdin.once("end", finish); + process.stdin.once("close", finish); + }); + stop(); + try { + adeHandler.dispose?.(); + } catch {} + try { + runtime.dispose(); + } catch {} + if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; + else process.env.ADE_DEFAULT_ROLE = previousRole; +} + +function parseOptionalPort(value: string | null, label: string): number | null { + if (value == null) return null; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65_535) { + throw new CliUsageError(`${label} must be a TCP port between 1 and 65535.`); + } + return parsed; +} + +function normalizeRuntimeSocketPath(rawSocketPath: string): string { + return rawSocketPath.startsWith("tcp://") || + isAdeMcpNamedPipePath(rawSocketPath) + ? rawSocketPath + : path.resolve(rawSocketPath); +} + +async function resolveMachineRuntimeSocketPath( + rawOverride?: string | null, +): Promise { + const { resolveMachineAdeLayout } = + await import("./services/projects/machineLayout"); + const rawSocketPath = + rawOverride?.trim() || + process.env.ADE_RUNTIME_SOCKET_PATH?.trim() || + resolveMachineAdeLayout().socketPath; + return normalizeRuntimeSocketPath(rawSocketPath); +} + +function readRuntimeInfoVersion(value: unknown): string | null { + if (!isRecord(value) || !isRecord(value.runtimeInfo)) return null; + return asString(value.runtimeInfo.version); +} + +async function initializeMachineRuntimeDaemon( + client: SocketJsonRpcClient, + options: GlobalOptions, +): Promise { + const result = await client.request( + "ade/initialize", + buildInitializeParams(options, "ade-rpc-stdio-proxy"), + ); + return readRuntimeInfoVersion(result); +} + +async function shutdownMachineRuntimeDaemon( + client: SocketJsonRpcClient, +): Promise { + try { + await client.request("shutdown"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("socket closed")) throw error; + } finally { + try { + client.close(); + } catch {} + } +} + +async function spawnMachineRuntimeDaemon( + socketPath: string, + options: GlobalOptions, +): Promise { + if (socketPath.startsWith("tcp://")) return false; + + const { resolveAdeServeCommand } = await import("./serviceManager/common"); + const serviceCommand = resolveAdeServeCommand(); + const args = [...serviceCommand.args]; + if ( + serviceCommand.command === process.execPath && + args.length === 1 && + args[0] === "serve" && + fs.existsSync(CLI_DIST_PATH) + ) { + args.splice(0, 1, CLI_DIST_PATH, "serve"); + } + args.push("--socket", socketPath); + + const child = spawn(serviceCommand.command, args, { + detached: true, + stdio: "ignore", + env: { + ...process.env, + ...(serviceCommand.env ?? {}), + ADE_DEFAULT_ROLE: options.role, + ADE_RPC_SOCKET_PATH: socketPath, + ADE_RUNTIME_SOCKET_PATH: socketPath, + }, + }); + child.once("error", () => {}); + child.unref(); + return true; +} + +async function connectMachineRuntimeDaemon( + options: GlobalOptions, + socketPathOverride?: string | null, +): Promise { + const socketPath = await resolveMachineRuntimeSocketPath(socketPathOverride); + const label = "ADE runtime daemon socket"; + try { + const client = await SocketJsonRpcClient.connect( + socketPath, + options.timeoutMs, + label, + ); + const runtimeVersion = await initializeMachineRuntimeDaemon( + client, + options, + ); + if (runtimeVersion && runtimeVersion !== VERSION) { + await shutdownMachineRuntimeDaemon(client); + const spawned = await spawnMachineRuntimeDaemon(socketPath, options); + if (!spawned) { + throw new Error( + `ADE runtime daemon version ${runtimeVersion} does not match CLI version ${VERSION}.`, + ); + } + const restarted = await SocketJsonRpcClient.connect( + socketPath, + options.timeoutMs, + label, + ); + const restartedVersion = await initializeMachineRuntimeDaemon( + restarted, + options, + ); + if (restartedVersion && restartedVersion !== VERSION) { + await shutdownMachineRuntimeDaemon(restarted); + throw new Error( + `ADE runtime daemon version ${restartedVersion} does not match CLI version ${VERSION}.`, + ); + } + return restarted; + } + return client; + } catch (firstError) { + const spawned = await spawnMachineRuntimeDaemon(socketPath, options); + if (!spawned) throw firstError; + try { + const client = await SocketJsonRpcClient.connect( + socketPath, + options.timeoutMs, + label, + ); + const runtimeVersion = await initializeMachineRuntimeDaemon( + client, + options, + ); + if (runtimeVersion && runtimeVersion !== VERSION) { + await shutdownMachineRuntimeDaemon(client); + throw new Error( + `ADE runtime daemon version ${runtimeVersion} does not match CLI version ${VERSION}.`, + ); + } + return client; + } catch (secondError) { + const firstMessage = + firstError instanceof Error ? firstError.message : String(firstError); + const secondMessage = + secondError instanceof Error + ? secondError.message + : String(secondError); + throw new Error( + `Unable to attach to ADE runtime daemon at ${socketPath}: ${secondMessage} (initial attempt: ${firstMessage})`, + ); + } + } +} + +async function runRuntimeCommand( + rest: string[], + options: GlobalOptions, +): Promise { + const args = [...rest]; + const sub = firstPositional(args) ?? "status"; + const socketOverride = readValue(args, ["--socket"]); + const socketPath = await resolveMachineRuntimeSocketPath(socketOverride); + + if (sub === "status") { + try { + const client = await SocketJsonRpcClient.connect( + socketPath, + Math.min(options.timeoutMs, 3_000), + "ADE runtime daemon socket", + ); + try { + const runtimeVersion = await initializeMachineRuntimeDaemon( + client, + options, + ); + return { + ok: true, + running: true, + socketPath, + version: runtimeVersion, + message: "ADE runtime daemon is running.", + }; + } finally { + client.close(); + } + } catch (error) { + return { + ok: false, + running: false, + socketPath, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + if (sub === "start") { + const client = await connectMachineRuntimeDaemon(options, socketOverride); + try { + const runtimeVersion = await initializeMachineRuntimeDaemon( + client, + options, + ).catch(() => null); + return { + ok: true, + running: true, + socketPath, + version: runtimeVersion, + message: "ADE runtime daemon is running.", + }; + } finally { + client.close(); + } + } + + if (sub === "stop" || sub === "shutdown") { + try { + const client = await SocketJsonRpcClient.connect( + socketPath, + Math.min(options.timeoutMs, 3_000), + "ADE runtime daemon socket", + ); + try { + await initializeMachineRuntimeDaemon(client, options).catch(() => null); + await shutdownMachineRuntimeDaemon(client); + } finally { + client.close(); + } + return { + ok: true, + running: false, + socketPath, + message: "ADE runtime daemon stopped.", + }; + } catch (error) { + return { + ok: false, + running: false, + socketPath, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + if (sub === "install-service") { + const { installRuntimeService } = await import("./serviceManager"); + return installRuntimeService(); + } + if (sub === "uninstall-service") { + const { uninstallRuntimeService } = await import("./serviceManager"); + return uninstallRuntimeService(); + } + if (sub === "service-status") { + const { getRuntimeServiceStatus } = await import("./serviceManager"); + return getRuntimeServiceStatus(); + } + + throw new CliUsageError( + "runtime supports status, start, stop, install-service, uninstall-service, or service-status.", + ); +} + +async function runDesktopCommand(rest: string[]): Promise { + const args = [...rest]; + const sub = firstPositional(args) ?? "open"; + const appName = + readValue(args, ["--app-name"]) ?? resolveDefaultDesktopAppName(); + if (sub !== "open" && sub !== "launch" && sub !== "start") { + throw new CliUsageError("desktop supports open."); + } + + if (process.platform === "darwin") { + const result = spawnSync("open", ["-a", appName], { encoding: "utf8" }); + const detail = + typeof result.stderr === "string" && result.stderr.trim() + ? result.stderr.trim() + : typeof result.stdout === "string" && result.stdout.trim() + ? result.stdout.trim() + : `Unable to open ${appName}.`; + return { + ok: result.status === 0, + platform: process.platform, + appName, + message: result.status === 0 ? `Opened ${appName}.` : detail, + }; + } + + return { + ok: false, + platform: process.platform, + appName, + message: + "Launching ADE desktop from the CLI is currently supported on macOS.", + }; +} + +function resolveDefaultDesktopAppName(): string { + const explicit = process.env.ADE_DESKTOP_APP_NAME?.trim(); + if (explicit) return explicit; + const channel = process.env.ADE_PACKAGE_CHANNEL?.trim().toLowerCase(); + if (channel === "alpha") return "ADE Alpha"; + if (channel === "beta") return "ADE Beta"; + return "ADE"; +} + +async function runNativeRpcStdio(options: GlobalOptions): Promise { + const previousRole = process.env.ADE_DEFAULT_ROLE; + process.env.ADE_DEFAULT_ROLE = options.role; + const [{ createStdioTransport }] = await Promise.all([ + import("./transports/stdioTransport"), + ]); + let client: SocketJsonRpcClient | null = null; + let stop: ReturnType | null = null; + let unsubscribeNotifications: (() => void) | null = null; + try { + client = await connectMachineRuntimeDaemon(options); + const handler: JsonRpcHandler = async (request) => { + const method = typeof request.method === "string" ? request.method : ""; + if (!method) return null; + if (request.id === undefined) { + client?.notify(method, request.params); + return null; + } + if (!client) { + throw new Error("ADE runtime daemon is not connected."); + } + try { + return await client.request(method, request.params); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + (method === "shutdown" || method === "exit") && + message.includes("socket closed") + ) { + return {}; + } + throw error; + } + }; + stop = startJsonRpcServer(handler, createStdioTransport(), { + nonFatal: true, + }); + unsubscribeNotifications = client.onAnyNotification((method, params) => + stop?.notify(method, params), + ); + await new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + resolve(); + }; + client?.onClose(finish); + process.stdin.once("end", finish); + process.stdin.once("close", finish); + }); + } finally { + unsubscribeNotifications?.(); + try { + stop?.(); + } catch {} + try { + client?.close(); + } catch {} + if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; + else process.env.ADE_DEFAULT_ROLE = previousRole; + } +} + +async function runServe( + rest: string[], + options: GlobalOptions, +): Promise { + const args = [...rest]; + if (readFlag(args, ["--install-service"])) { + const { installRuntimeService } = await import("./serviceManager"); + return installRuntimeService(); + } + if (readFlag(args, ["--uninstall-service"])) { + const { uninstallRuntimeService } = await import("./serviceManager"); + return uninstallRuntimeService(); + } + if (readFlag(args, ["--service-status"])) { + const { getRuntimeServiceStatus } = await import("./serviceManager"); + return getRuntimeServiceStatus(); + } + const [ + { resolveMachineAdeLayout }, + { ProjectRegistry }, + { ProjectScopeRegistry }, + { createMultiProjectRpcRequestHandler }, + ] = await Promise.all([ + import("./services/projects/machineLayout"), + import("./services/projects/projectRegistry"), + import("./services/projects/projectScope"), + import("./multiProjectRpcServer"), + ]); - const mcpHandler: JsonRpcHandler = async (request) => { - const method = typeof request.method === "string" ? request.method : ""; - const params = isRecord(request.params) ? request.params : {}; - if (method === "initialize") { - await ensureInitialized(); - const requestedVersion = asString(params.protocolVersion) ?? PROTOCOL_VERSION; - return { - protocolVersion: requestedVersion, - capabilities: { - tools: { - listChanged: false, - }, + const layout = resolveMachineAdeLayout(); + const rawSocketPath = + readValue(args, ["--socket"]) ?? + process.env.ADE_RPC_SOCKET_PATH?.trim() ?? + layout.socketPath; + const socketPath = isAdeMcpNamedPipePath(rawSocketPath) + ? rawSocketPath + : path.resolve(rawSocketPath); + const port = parseOptionalPort(readValue(args, ["--port"]), "--port"); + const syncEnabled = !readFlag(args, ["--no-sync"]); + const projectRegistry = new ProjectRegistry(layout); + type ProjectRecord = ReturnType< + InstanceType["list"] + >[number]; + const toMobileProjectSummary = ( + record: ProjectRecord, + overrides: Partial = {}, + ): SyncMobileProjectSummary => ({ + id: record.projectId, + displayName: record.displayName, + rootPath: record.rootPath, + defaultBaseRef: null, + lastOpenedAt: + record.lastOpenedAt > 0 + ? new Date(record.lastOpenedAt).toISOString() + : null, + laneCount: 0, + isAvailable: true, + isCached: true, + isOpen: false, + ...overrides, + }); + let scopeRegistry: InstanceType; + scopeRegistry = new ProjectScopeRegistry(projectRegistry, { + syncRuntime: { + enabled: syncEnabled, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: true, + runtimeKind: "headless", + appVersion: VERSION, + localDeviceIdPath: path.join(layout.secretsDir, "sync-device-id"), + phonePairingStateDir: layout.secretsDir, + projectCatalogProvider: { + listProjects: async () => ({ + projects: projectRegistry + .list() + .map((record) => toMobileProjectSummary(record)), + }), + prepareProjectConnection: async ( + request: SyncProjectSwitchRequestPayload, + ): Promise => { + const requestedId = + typeof request.projectId === "string" + ? request.projectId.trim() + : ""; + const requestedRootPath = + typeof request.rootPath === "string" + ? path.resolve(request.rootPath) + : ""; + const record = + projectRegistry + .list() + .find( + (candidate) => + (requestedId.length > 0 && + candidate.projectId === requestedId) || + (requestedRootPath.length > 0 && + path.resolve(candidate.rootPath) === requestedRootPath), + ) ?? null; + const project = record + ? toMobileProjectSummary(record, { isOpen: true }) + : null; + if (!record) { + return { + ok: false, + message: "That project is not registered on this ADE machine.", + project, + }; + } + try { + const scope = await scopeRegistry.ensureSyncHost(record.projectId); + const syncService = scope?.runtime.syncService ?? null; + if (!scope || !syncService) { + return { + ok: false, + message: "Phone sync is not available for that project.", + project, + }; + } + syncService.setHostDiscoveryEnabled?.(true); + await syncService.setHostStartupEnabled?.(true); + await syncService.initialize(); + const lanes = await scope.runtime.laneService + .list({ includeArchived: false, includeStatus: false }) + .catch(() => []); + const laneCount = lanes.length; + const readyProject = toMobileProjectSummary(record, { + isOpen: true, + laneCount, + }); + const status = await syncService.getStatus(); + const connectInfo = status.pairingConnectInfo; + if (!connectInfo) { + return { + ok: false, + message: "Phone sync is not ready for that project yet.", + project: readyProject, + }; + } + return { + ok: true, + project: readyProject, + connection: { + authKind: "paired", + token: null, + pairedDeviceId: null, + hostIdentity: connectInfo.hostIdentity, + port: connectInfo.port, + addressCandidates: connectInfo.addressCandidates, + }, + }; + } catch (error) { + return { + ok: false, + message: + error instanceof Error + ? error.message + : "Unable to prepare phone sync for that project.", + project, + }; + } }, - serverInfo: { - name: "ade", - version: VERSION, + completeProjectConnection: async ( + request: SyncProjectSwitchRequestPayload, + result: SyncProjectSwitchResultPayload, + ): Promise => { + if (!result.ok) return; + const projectId = + typeof result.project?.id === "string" && result.project.id.trim() + ? result.project.id.trim() + : typeof request.projectId === "string" && + request.projectId.trim() + ? request.projectId.trim() + : null; + if (!projectId) return; + try { + projectRegistry.touch(projectId); + } catch { + // The mobile handoff already succeeded; a stale registry touch should + // not fail the sync protocol completion. + } }, + }, + }, + }); + const previousRole = process.env.ADE_DEFAULT_ROLE; + process.env.ADE_DEFAULT_ROLE = options.role; + + const states: HeadlessRpcServerState[] = []; + let done = false; + let resolveDone: (() => void) | null = null; + + const finish = () => { + if (done) return; + done = true; + resolveDone?.(); + }; + + const createHandler = () => + createMultiProjectRpcRequestHandler({ + serverVersion: VERSION, + projectRegistry, + scopeRegistry, + disposeScopesOnDispose: false, + onShutdown: finish, + }); + + const listen = async ( + server: net.Server, + target: string | { port: number; host: string }, + ): Promise => { + await new Promise((resolve, reject) => { + const handleListening = () => { + server.off("error", handleError); + resolve(); }; - } - if (method === "notifications/initialized" || method === "initialized") { - await ensureInitialized(); - return null; - } - await ensureInitialized(); - if (method === "tools/list") { - const listed = await callAde("ade/actions/list"); - const actions = isRecord(listed) && Array.isArray(listed.actions) - ? listed.actions.filter(isRecord) - : []; - return { - tools: actions - .map((action) => ({ - name: asString(action.name) ?? "", - description: asString(action.description) ?? "", - inputSchema: isRecord(action.inputSchema) ? action.inputSchema : { type: "object", properties: {} }, - })) - .filter((tool) => tool.name.length > 0 && isMcpToolVisible(tool.name)), + const handleError = (error: Error) => { + server.off("listening", handleListening); + reject(error); }; - } - if (method === "tools/call") { - const rawName = asString(params.name); - if (!rawName) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "tools/call requires a tool name."); - } - if (!isMcpToolVisible(rawName)) { - throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Tool not available in this MCP scope: ${rawName}`); + server.once("listening", handleListening); + server.once("error", handleError); + if (typeof target === "string") { + server.listen(target); + } else { + server.listen(target.port, target.host); } - const result = await callAde("ade/actions/call", { - name: normalizeMcpAdeToolName(rawName), - arguments: isRecord(params.arguments) ? params.arguments : {}, - }); - const isError = isRecord(result) && result.ok === false; - return { - content: [ - { - type: "text", - text: formatMcpToolText(result), - }, - ], - structuredContent: result ?? null, - isError, - }; - } - if (method === "shutdown") { - return {}; - } - if (method === "exit") { - process.nextTick(() => process.exit(0)); - return {}; - } - throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Method not found: ${method}`); + }); }; - const transport: JsonRpcTransport = { - onData(callback) { - process.stdin.on("data", (chunk) => callback(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); - }, - write(data) { - process.stdout.write(data); - }, - close() { - process.stdin.pause(); - }, - }; - const stop = startJsonRpcServer(mcpHandler, transport, { nonFatal: true }); + fs.mkdirSync(layout.adeDir, { recursive: true, mode: 0o700 }); + if (!isAdeMcpNamedPipePath(socketPath)) { + fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 }); + try { + fs.unlinkSync(socketPath); + } catch {} + } + + const socketState = createHeadlessRpcServer(createHandler); + states.push(socketState); + await listen(socketState.server, socketPath); + if (!isAdeMcpNamedPipePath(socketPath)) { + try { + fs.chmodSync(socketPath, 0o600); + } catch {} + } + + let tcpUrl: string | null = null; + if (port != null) { + const tcpState = createHeadlessRpcServer(createHandler); + states.push(tcpState); + await listen(tcpState.server, { port, host: "127.0.0.1" }); + tcpUrl = `tcp://127.0.0.1:${port}`; + } + + if (syncEnabled) { + void scopeRegistry.ensureSyncHost().catch((error: unknown) => { + process.stderr.write( + `ade serve sync host failed: ${error instanceof Error ? error.message : String(error)}\n`, + ); + }); + } + + process.stderr.write( + `ade serve listening on ${socketPath}${tcpUrl ? ` and ${tcpUrl}` : ""}\n`, + ); + await new Promise((resolve) => { - let done = false; - const finish = () => { - if (done) return; - done = true; - resolve(); - }; - process.stdin.once("end", finish); - process.stdin.once("close", finish); + resolveDone = resolve; + process.once("SIGINT", finish); + process.once("SIGTERM", finish); }); - stop(); - try { adeHandler.dispose?.(); } catch {} - try { runtime.dispose(); } catch {} + + for (const state of states) { + stopHeadlessRpcServer(state); + } + await scopeRegistry.disposeAll(); + if (!isAdeMcpNamedPipePath(socketPath)) { + try { + fs.unlinkSync(socketPath); + } catch {} + } if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; else process.env.ADE_DEFAULT_ROLE = previousRole; + return null; +} + +function isFailedServiceManagerResult(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return ( + record.ok === false && + (record.action === "install" || record.action === "uninstall") && + typeof record.serviceName === "string" + ); +} + +async function runInit( + targetPath: string | null, +): Promise<{ project: unknown; registryPath: string }> { + const [{ resolveMachineAdeLayout }, { ProjectRegistry }] = await Promise.all([ + import("./services/projects/machineLayout"), + import("./services/projects/projectRegistry"), + ]); + const layout = resolveMachineAdeLayout(); + const registry = new ProjectRegistry(layout); + const project = registry.add(path.resolve(targetPath ?? process.cwd())); + return { + project, + registryPath: registry.path, + }; } function unwrapToolResult(result: unknown): unknown { if (!isRecord(result)) return result; if (result.isError === true) { const structured = result.structuredContent; - const message = isRecord(structured) && isRecord(structured.error) - ? asString(structured.error.message) ?? "ADE tool call failed." - : "ADE tool call failed."; + const message = + isRecord(structured) && isRecord(structured.error) + ? (asString(structured.error.message) ?? "ADE tool call failed.") + : "ADE tool call failed."; throw new CliToolError(message, structured ?? result); } if (result.ok === false && isRecord(result.error)) { @@ -5418,8 +11144,10 @@ function unwrapToolResult(result: unknown): unknown { function unwrapActionEnvelope(value: unknown): unknown { if (!isRecord(value)) return value; if ( - Object.prototype.hasOwnProperty.call(value, "result") - && (asString(value.domain) || asString(value.action) || Object.prototype.hasOwnProperty.call(value, "statusHint")) + Object.prototype.hasOwnProperty.call(value, "result") && + (asString(value.domain) || + asString(value.action) || + Object.prototype.hasOwnProperty.call(value, "statusHint")) ) { return value.result; } @@ -5429,7 +11157,8 @@ function unwrapActionEnvelope(value: unknown): unknown { function missionIdFromCreateResult(value: unknown): string { const result = unwrapActionEnvelope(value); const mission = firstRecord(result, ["mission"]); - const id = asString(mission?.id) ?? (isRecord(result) ? asString(result.id) : null); + const id = + asString(mission?.id) ?? (isRecord(result) ? asString(result.id) : null); return requireValue(id ?? null, "created mission id"); } @@ -5437,11 +11166,14 @@ function newestRunFromListResult(value: unknown): JsonObject | null { const result = unwrapActionEnvelope(value); const runs = firstArray(result, ["runs", "items", "results"]); if (runs.length === 0) return null; - return [...runs].sort((left, right) => { - const leftAt = asString(left.startedAt) ?? asString(left.createdAt) ?? ""; - const rightAt = asString(right.startedAt) ?? asString(right.createdAt) ?? ""; - return rightAt.localeCompare(leftAt); - })[0] ?? null; + return ( + [...runs].sort((left, right) => { + const leftAt = asString(left.startedAt) ?? asString(left.createdAt) ?? ""; + const rightAt = + asString(right.startedAt) ?? asString(right.createdAt) ?? ""; + return rightAt.localeCompare(leftAt); + })[0] ?? null + ); } function runFromStartResult(value: unknown): JsonObject | null { @@ -5468,7 +11200,10 @@ function graphFromResult(value: unknown): JsonObject | null { if (!isRecord(result)) return null; if (hasRunGraphShape(result)) return result; const nestedGraph = isRecord(result.graph) ? result.graph : null; - const graph = nestedGraph && hasRunGraphShape(nestedGraph) ? nestedGraph : nestedGraph ?? result; + const graph = + nestedGraph && hasRunGraphShape(nestedGraph) + ? nestedGraph + : (nestedGraph ?? result); return isRecord(graph) ? graph : null; } @@ -5478,11 +11213,12 @@ function runFromGraphResult(value: unknown): JsonObject | null { } function hasRunGraphShape(value: unknown): boolean { - return isRecord(value) && ( - isRecord(value.run) - || Array.isArray(value.steps) - || Array.isArray(value.attempts) - || Array.isArray(value.timeline) + return ( + isRecord(value) && + (isRecord(value.run) || + Array.isArray(value.steps) || + Array.isArray(value.attempts) || + Array.isArray(value.timeline)) ); } @@ -5498,7 +11234,8 @@ function runIdFromWatchValues(values: JsonObject): string { } function renderLaneGraph(result: unknown): string { - const lanesRaw = isRecord(result) && Array.isArray(result.lanes) ? result.lanes : []; + const lanesRaw = + isRecord(result) && Array.isArray(result.lanes) ? result.lanes : []; const lanes = lanesRaw.filter(isRecord); if (lanes.length === 0) return "ADE lanes\n(no lanes)"; @@ -5518,10 +11255,14 @@ function renderLaneGraph(result: unknown): string { } for (const children of byParent.values()) { children.sort((left, right) => { - const leftDepth = typeof left.stackDepth === "number" ? left.stackDepth : 0; - const rightDepth = typeof right.stackDepth === "number" ? right.stackDepth : 0; + const leftDepth = + typeof left.stackDepth === "number" ? left.stackDepth : 0; + const rightDepth = + typeof right.stackDepth === "number" ? right.stackDepth : 0; if (leftDepth !== rightDepth) return leftDepth - rightDepth; - return String(left.name ?? left.id ?? "").localeCompare(String(right.name ?? right.id ?? "")); + return String(left.name ?? left.id ?? "").localeCompare( + String(right.name ?? right.id ?? ""), + ); }); } @@ -5533,9 +11274,17 @@ function renderLaneGraph(result: unknown): string { const archived = asString(lane.archivedAt) ? " archived" : ""; const id = asString(lane.id); const idSuffix = id ? ` (id: ${id})` : ""; - lines.push(`${prefix}${isLast ? "\\- " : "|- "}${name}${idSuffix}${branch ? ` [${branch}]` : ""}${status ? ` ${status}` : ""}${archived}`); - const children = id ? byParent.get(id) ?? [] : []; - children.forEach((child, index) => visit(child, `${prefix}${isLast ? " " : "| "}`, index === children.length - 1)); + lines.push( + `${prefix}${isLast ? "\\- " : "|- "}${name}${idSuffix}${branch ? ` [${branch}]` : ""}${status ? ` ${status}` : ""}${archived}`, + ); + const children = id ? (byParent.get(id) ?? []) : []; + children.forEach((child, index) => + visit( + child, + `${prefix}${isLast ? " " : "| "}`, + index === children.length - 1, + ), + ); }; const roots = byParent.get("") ?? []; roots.forEach((lane, index) => visit(lane, "", index === roots.length - 1)); @@ -5552,12 +11301,23 @@ function truncateCell(value: string, width = 42): string { function cell(value: unknown, width = 42): string { if (value == null) return ""; if (typeof value === "boolean") return value ? "yes" : "no"; - if (typeof value === "number") return Number.isFinite(value) ? String(value) : ""; + if (typeof value === "number") + return Number.isFinite(value) ? String(value) : ""; if (typeof value === "string") return truncateCell(value, width); - if (Array.isArray(value)) return truncateCell(value.map((entry) => cell(entry, 18)).filter(Boolean).join(", "), width); + if (Array.isArray(value)) + return truncateCell( + value + .map((entry) => cell(entry, 18)) + .filter(Boolean) + .join(", "), + width, + ); if (isRecord(value)) { - const id = asString(value.id) ?? asString(value.name) ?? asString(value.title); - return id ? truncateCell(id, width) : truncateCell(JSON.stringify(value), width); + const id = + asString(value.id) ?? asString(value.name) ?? asString(value.title); + return id + ? truncateCell(id, width) + : truncateCell(JSON.stringify(value), width); } return truncateCell(String(value), width); } @@ -5567,7 +11327,9 @@ function formatAutomationRunDetail(value: unknown): string { const run = isRecord(value.run) ? value.run : value; const actions = Array.isArray(value.actions) ? value.actions - : Array.isArray(run.actions) ? run.actions : []; + : Array.isArray(run.actions) + ? run.actions + : []; const header = renderKeyValues("ADE automation run", [ ["id", run.id], ["rule", run.automationId ?? run.ruleId], @@ -5580,15 +11342,21 @@ function formatAutomationRunDetail(value: unknown): string { const rows = actions .filter((action): action is JsonObject => isRecord(action)) .map((action) => { - const kind = typeof action.kind === "string" ? action.kind - : typeof action.type === "string" ? action.type - : "action"; + const kind = + typeof action.kind === "string" + ? action.kind + : typeof action.type === "string" + ? action.type + : "action"; const status = typeof action.status === "string" ? action.status : "?"; - const error = typeof action.errorMessage === "string" ? action.errorMessage : ""; + const error = + typeof action.errorMessage === "string" ? action.errorMessage : ""; const output = typeof action.output === "string" ? action.output : ""; const isLaneSetup = kind === "lane-setup"; const note = error - ? (isLaneSetup ? `FAILED: ${error}` : error) + ? isLaneSetup + ? `FAILED: ${error}` + : error : isLaneSetup && output ? `created lane: ${output}` : output; @@ -5599,22 +11367,46 @@ function formatAutomationRunDetail(value: unknown): string { return [header, "", "Actions", table].join("\n"); } -function renderKeyValues(title: string, entries: Array<[string, unknown]>): string { - const rows = entries.filter(([, value]) => value !== undefined && value !== null && value !== ""); +function renderKeyValues( + title: string, + entries: Array<[string, unknown]>, +): string { + const rows = entries.filter( + ([, value]) => value !== undefined && value !== null && value !== "", + ); const labelWidth = Math.max(0, ...rows.map(([label]) => label.length)); return [ title, - ...rows.map(([label, value]) => `${label.padEnd(labelWidth)} ${cell(value, 96)}`), + ...rows.map( + ([label, value]) => `${label.padEnd(labelWidth)} ${cell(value, 96)}`, + ), ].join("\n"); } -function renderTable(headers: string[], rows: unknown[][], emptyMessage: string): string { +function renderTable( + headers: string[], + rows: unknown[][], + emptyMessage: string, +): string { if (rows.length === 0) return emptyMessage; - const widths = headers.map((header, index) => Math.max( - header.length, - ...rows.map((row) => cell(row[index], index === headers.length - 1 ? 64 : 28).length), - )); - const renderRow = (row: unknown[]) => row.map((entry, index) => cell(entry, index === headers.length - 1 ? 64 : 28).padEnd(widths[index] ?? 0)).join(" ").trimEnd(); + const widths = headers.map((header, index) => + Math.max( + header.length, + ...rows.map( + (row) => + cell(row[index], index === headers.length - 1 ? 64 : 28).length, + ), + ), + ); + const renderRow = (row: unknown[]) => + row + .map((entry, index) => + cell(entry, index === headers.length - 1 ? 64 : 28).padEnd( + widths[index] ?? 0, + ), + ) + .join(" ") + .trimEnd(); return [ renderRow(headers), widths.map((width) => "-".repeat(width)).join(" "), @@ -5644,35 +11436,63 @@ function firstRecord(value: unknown, keys: string[]): JsonObject | null { function statusWord(value: unknown): string { const raw = cell(value, 24).toLowerCase(); if (!raw) return ""; - if (["success", "passing", "passed", "completed", "ready", "clean", "ok"].includes(raw)) return "OK"; - if (["failure", "failed", "failing", "error", "blocked", "dirty"].includes(raw)) return "FAIL"; - if (["pending", "running", "in_progress", "queued", "active"].includes(raw)) return "WAIT"; + if ( + [ + "success", + "passing", + "passed", + "completed", + "ready", + "clean", + "ok", + ].includes(raw) + ) + return "OK"; + if ( + ["failure", "failed", "failing", "error", "blocked", "dirty"].includes(raw) + ) + return "FAIL"; + if (["pending", "running", "in_progress", "queued", "active"].includes(raw)) + return "WAIT"; return raw.toUpperCase(); } function formatActionsList(value: unknown): string { - const actionResult = isRecord(value) && isRecord(value.actions) ? value.actions : value; + const actionResult = + isRecord(value) && isRecord(value.actions) ? value.actions : value; const actions = firstArray(actionResult, ["actions"]); if (actions.length === 0) return "ADE actions\n(no actions)"; const byDomain = new Map(); for (const action of actions) { const name = asString(action.name); - const domain = asString(action.domain) ?? (name?.includes(".") ? name.split(".")[0] : null) ?? "core"; + const domain = + asString(action.domain) ?? + (name?.includes(".") ? name.split(".")[0] : null) ?? + "core"; const list = byDomain.get(domain) ?? []; list.push(action); byDomain.set(domain, list); } const lines = [ "ADE actions", - "Use: ade actions run --input-json '{\"key\":\"value\"}'", - "For multi-parameter methods: --args-list-json '[\"first\",{\"second\":true}]'", + 'Use: ade actions run --input-json \'{"key":"value"}\'', + 'For multi-parameter methods: --args-list-json \'["first",{"second":true}]\'', ]; - for (const [domain, list] of [...byDomain.entries()].sort(([left], [right]) => left.localeCompare(right))) { + for (const [domain, list] of [...byDomain.entries()].sort(([left], [right]) => + left.localeCompare(right), + )) { lines.push("", `${domain}:`); - for (const action of list.sort((left, right) => cell(left.action ?? left.name).localeCompare(cell(right.action ?? right.name)))) { - const name = asString(action.action) ?? asString(action.name) ?? "(unknown)"; + for (const action of list.sort((left, right) => + cell(left.action ?? left.name).localeCompare( + cell(right.action ?? right.name), + ), + )) { + const name = + asString(action.action) ?? asString(action.name) ?? "(unknown)"; const description = asString(action.description) ?? ""; - lines.push(` ${name}${description ? ` - ${truncateCell(description, 86)}` : ""}`); + lines.push( + ` ${name}${description ? ` - ${truncateCell(description, 86)}` : ""}`, + ); } } return lines.join("\n"); @@ -5709,7 +11529,9 @@ function formatPrList(value: unknown): string { function formatPrChecks(value: unknown): string { const checks = firstArray(value, ["checks", "items"]); const summary = isRecord(value) ? value.summary : null; - const header = summary ? `ADE PR checks - ${cell(summary, 80)}` : "ADE PR checks"; + const header = summary + ? `ADE PR checks - ${cell(summary, 80)}` + : "ADE PR checks"; return `${header}\n${renderTable( ["status", "name", "details"], checks.map((check) => [ @@ -5726,46 +11548,67 @@ function formatPrComments(value: unknown): string { const comments = firstArray(value, ["comments", "issueComments"]); const lines = ["ADE PR comments"]; if (threads.length > 0) { - lines.push("", renderTable( - ["thread", "state", "file", "comment"], - threads.map((thread) => { - const threadComments = Array.isArray(thread.comments) ? thread.comments.filter(isRecord) : []; - const first = threadComments[0] ?? {}; - return [ - thread.id, - thread.isResolved ? "resolved" : "open", - `${cell(thread.path, 34)}${thread.line ? `:${thread.line}` : ""}`, - first.body ?? thread.body, - ]; - }), - "(no review threads)", - )); + lines.push( + "", + renderTable( + ["thread", "state", "file", "comment"], + threads.map((thread) => { + const threadComments = Array.isArray(thread.comments) + ? thread.comments.filter(isRecord) + : []; + const first = threadComments[0] ?? {}; + return [ + thread.id, + thread.isResolved ? "resolved" : "open", + `${cell(thread.path, 34)}${thread.line ? `:${thread.line}` : ""}`, + first.body ?? thread.body, + ]; + }), + "(no review threads)", + ), + ); } if (comments.length > 0) { - lines.push("", renderTable( - ["id", "author", "comment"], - comments.map((comment) => [comment.id, comment.author ?? comment.user, comment.body]), - "(no issue comments)", - )); + lines.push( + "", + renderTable( + ["id", "author", "comment"], + comments.map((comment) => [ + comment.id, + comment.author ?? comment.user, + comment.body, + ]), + "(no issue comments)", + ), + ); } - if (threads.length === 0 && comments.length === 0) lines.push("(no comments)"); + if (threads.length === 0 && comments.length === 0) + lines.push("(no comments)"); return lines.join("\n"); } function phaseKeysFromMission(mission: JsonObject): string { const metadata = isRecord(mission.metadata) ? mission.metadata : {}; - const phaseConfiguration = isRecord(metadata.phaseConfiguration) ? metadata.phaseConfiguration : {}; + const phaseConfiguration = isRecord(metadata.phaseConfiguration) + ? metadata.phaseConfiguration + : {}; const phaseKeys = Array.isArray(phaseConfiguration.phaseKeys) ? phaseConfiguration.phaseKeys : Array.isArray(phaseConfiguration.phases) - ? phaseConfiguration.phases.filter(isRecord).map((phase) => phase.phaseKey) + ? phaseConfiguration.phases + .filter(isRecord) + .map((phase) => phase.phaseKey) : []; - return phaseKeys.map((key) => cell(key, 24)).filter(Boolean).join(" -> "); + return phaseKeys + .map((key) => cell(key, 24)) + .filter(Boolean) + .join(" -> "); } function formatMissionDetail(value: unknown): string { const result = unwrapActionEnvelope(value); - const mission = firstRecord(result, ["mission"]) ?? (isRecord(result) ? result : {}); + const mission = + firstRecord(result, ["mission"]) ?? (isRecord(result) ? result : {}); const steps = firstArray(mission, ["steps"]); const phaseKeys = phaseKeysFromMission(mission); return [ @@ -5787,13 +11630,16 @@ function formatMissionDetail(value: unknown): string { steps.map((step) => [ step.index ?? step.stepIndex, step.status, - step.phaseKey ?? (isRecord(step.metadata) ? step.metadata.phaseKey : null), + step.phaseKey ?? + (isRecord(step.metadata) ? step.metadata.phaseKey : null), step.title, ]), "(no steps)", )}` : "", - ].filter(Boolean).join("\n"); + ] + .filter(Boolean) + .join("\n"); } function formatMissionList(value: unknown): string { @@ -5833,7 +11679,8 @@ function formatMissionRuns(value: unknown): string { function formatMissionGraph(value: unknown): string { const result = unwrapActionEnvelope(value); - const graph = isRecord(result) && isRecord(result.graph) ? result.graph : result; + const graph = + isRecord(result) && isRecord(result.graph) ? result.graph : result; const run = firstRecord(graph, ["run"]) ?? {}; const steps = firstArray(graph, ["steps"]); const attempts = firstArray(graph, ["attempts"]); @@ -5855,25 +11702,30 @@ function formatMissionGraph(value: unknown): string { steps.map((step) => [ step.id ?? step.stepKey, step.status, - step.phaseKey ?? (isRecord(step.metadata) ? step.metadata.phaseKey : null), + step.phaseKey ?? + (isRecord(step.metadata) ? step.metadata.phaseKey : null), step.title, ]), "(no steps)", )}` : "", - ].filter(Boolean).join("\n"); + ] + .filter(Boolean) + .join("\n"); } function formatMissionWatch(value: unknown): string { const result = isRecord(value) ? value : {}; const created = unwrapActionEnvelope(result.created); const started = unwrapActionEnvelope(result.started ?? result.result); - const mission = missionFromResult(result.mission) - ?? missionFromResult(created) - ?? missionFromResult(started) - ?? {}; + const mission = + missionFromResult(result.mission) ?? + missionFromResult(created) ?? + missionFromResult(started) ?? + {}; const runsResult = unwrapActionEnvelope(result.runs); - const newestRun = newestRunFromListResult(runsResult) ?? runFromStartResult(started); + const newestRun = + newestRunFromListResult(runsResult) ?? runFromStartResult(started); const graphResult = unwrapActionEnvelope(result.graph); const graph = graphFromResult(graphResult) ?? {}; const wait = firstRecord(graphResult, ["wait"]); @@ -5895,16 +11747,20 @@ function formatMissionWatch(value: unknown): string { ]), ]; if (graphSteps.length > 0) { - parts.push("", renderTable( - ["step", "status", "phase", "title"], - graphSteps.map((step) => [ - step.id ?? step.stepKey, - step.status, - step.phaseKey ?? (isRecord(step.metadata) ? step.metadata.phaseKey : null), - step.title, - ]), - "(no steps)", - )); + parts.push( + "", + renderTable( + ["step", "status", "phase", "title"], + graphSteps.map((step) => [ + step.id ?? step.stepKey, + step.status, + step.phaseKey ?? + (isRecord(step.metadata) ? step.metadata.phaseKey : null), + step.title, + ]), + "(no steps)", + ), + ); } return parts.join("\n"); } @@ -5913,7 +11769,11 @@ function formatFileTree(value: unknown): string { const entries = firstArray(value, ["entries", "nodes", "items", "children"]); return renderTable( ["type", "path", "size"], - entries.map((entry) => [entry.type ?? (entry.isDirectory ? "dir" : "file"), entry.path ?? entry.name, entry.sizeBytes ?? entry.size]), + entries.map((entry) => [ + entry.type ?? (entry.isDirectory ? "dir" : "file"), + entry.path ?? entry.name, + entry.sizeBytes ?? entry.size, + ]), "ADE files\n(no entries)", ); } @@ -5921,7 +11781,12 @@ function formatFileTree(value: unknown): string { function formatFileRead(value: unknown): string { if (typeof value === "string") return value; if (!isRecord(value)) return JSON.stringify(value, null, 2); - const text = typeof value.text === "string" ? value.text : typeof value.content === "string" ? value.content : null; + const text = + typeof value.text === "string" + ? value.text + : typeof value.content === "string" + ? value.content + : null; return text ?? JSON.stringify(value, null, 2); } @@ -5929,7 +11794,11 @@ function formatFilesSearch(value: unknown): string { const matches = firstArray(value, ["matches", "results", "items"]); return renderTable( ["file", "line", "match"], - matches.map((match) => [match.path ?? match.filePath, match.line ?? match.lineNumber, match.preview ?? match.text ?? match.match]), + matches.map((match) => [ + match.path ?? match.filePath, + match.line ?? match.lineNumber, + match.preview ?? match.text ?? match.match, + ]), "ADE file search\n(no matches)", ); } @@ -5949,7 +11818,13 @@ function formatDiffSummary(value: unknown): string { } function formatRunTable(value: unknown, title: string): string { - const rows = firstArray(value, ["processes", "definitions", "runtime", "runs", "items"]); + const rows = firstArray(value, [ + "processes", + "definitions", + "runtime", + "runs", + "items", + ]); return `${title}\n${renderTable( ["id", "status", "lane", "command"], rows.map((row) => [ @@ -5966,7 +11841,12 @@ function formatChatList(value: unknown): string { const sessions = firstArray(value, ["sessions", "chats", "items"]); return renderTable( ["session", "provider", "lane", "title"], - sessions.map((session) => [session.id ?? session.sessionId, session.provider ?? session.modelId, session.laneId, session.title]), + sessions.map((session) => [ + session.id ?? session.sessionId, + session.provider ?? session.modelId, + session.laneId, + session.title, + ]), "ADE chats\n(no sessions)", ); } @@ -5975,7 +11855,12 @@ function formatTestsRuns(value: unknown): string { const runs = firstArray(value, ["runs", "items"]); return renderTable( ["run", "status", "suite", "duration"], - runs.map((run) => [run.id ?? run.runId, statusWord(run.status), run.suiteId ?? run.suiteName, run.durationMs]), + runs.map((run) => [ + run.id ?? run.runId, + statusWord(run.status), + run.suiteId ?? run.suiteName, + run.durationMs, + ]), "ADE test runs\n(no runs)", ); } @@ -5984,21 +11869,35 @@ function formatProofList(value: unknown): string { const artifacts = firstArray(value, ["artifacts", "items"]); return renderTable( ["kind", "created", "title", "path"], - artifacts.map((artifact) => [artifact.kind ?? artifact.type, artifact.createdAt, artifact.title ?? artifact.name, artifact.path ?? artifact.uri]), + artifacts.map((artifact) => [ + artifact.kind ?? artifact.type, + artifact.createdAt, + artifact.title ?? artifact.name, + artifact.path ?? artifact.uri, + ]), "ADE proof artifacts\n(no artifacts)", ); } function formatIosSimStatus(value: unknown): string { const status = isRecord(value) ? value : {}; - const tools = Array.isArray(status.tools) ? status.tools.filter(isRecord) : []; + const tools = Array.isArray(status.tools) + ? status.tools.filter(isRecord) + : []; const activeDevice = isRecord(status.activeDevice) ? status.activeDevice : {}; - const activeSession = isRecord(status.activeSession) ? status.activeSession : {}; + const activeSession = isRecord(status.activeSession) + ? status.activeSession + : {}; return [ renderKeyValues("ADE iOS simulator", [ ["supported", status.supported], ["platform", status.platform], - ["active device", activeDevice.name ? `${activeDevice.name} (${activeDevice.state})` : null], + [ + "active device", + activeDevice.name + ? `${activeDevice.name} (${activeDevice.state})` + : null, + ], ["active app", activeSession.bundleId], ["mode", activeSession.mode], ["chat session", activeSession.chatSessionId], @@ -6006,26 +11905,44 @@ function formatIosSimStatus(value: unknown): string { "", renderTable( ["tool", "ready", "detail"], - tools.map((tool) => [tool.name, tool.available ? "yes" : "no", tool.detail]), + tools.map((tool) => [ + tool.name, + tool.available ? "yes" : "no", + tool.detail, + ]), "Tools\n(none)", ), ].join("\n"); } function formatIosSimDevices(value: unknown): string { - const devices = Array.isArray(value) ? value.filter(isRecord) : firstArray(value, ["devices", "items"]); + const devices = Array.isArray(value) + ? value.filter(isRecord) + : firstArray(value, ["devices", "items"]); return renderTable( ["udid", "device", "runtime", "state"], - devices.map((device) => [device.udid, device.name, device.runtime, device.state]), + devices.map((device) => [ + device.udid, + device.name, + device.runtime, + device.state, + ]), "ADE iOS simulators\n(no installed simulators)", ); } function formatIosSimApps(value: unknown): string { - const targets = Array.isArray(value) ? value.filter(isRecord) : firstArray(value, ["targets", "apps", "items"]); + const targets = Array.isArray(value) + ? value.filter(isRecord) + : firstArray(value, ["targets", "apps", "items"]); return renderTable( ["target", "kind", "name", "bundle"], - targets.map((target) => [target.id, target.kind, target.name, target.bundleId ?? target.detail]), + targets.map((target) => [ + target.id, + target.kind, + target.name, + target.bundleId ?? target.detail, + ]), "ADE iOS launchable apps\n(no apps)", ); } @@ -6056,17 +11973,38 @@ function formatIosSimStream(value: unknown): string { function formatIosSimSnapshot(value: unknown): string { const snapshot = isRecord(value) ? value : {}; - const screenshot = isRecord(snapshot.screenshot) ? snapshot.screenshot : snapshot; + const screenshot = isRecord(snapshot.screenshot) + ? snapshot.screenshot + : snapshot; const screen = isRecord(snapshot.screen) ? snapshot.screen : {}; - const providers = Array.isArray(snapshot.providers) ? snapshot.providers.filter(isRecord) : []; - const elements = Array.isArray(snapshot.elements) ? snapshot.elements.filter(isRecord) : []; - const providerSummary = providers.map((provider) => `${provider.source}:${provider.available ? provider.elementCount ?? "ok" : "unavailable"}`).join(", "); + const providers = Array.isArray(snapshot.providers) + ? snapshot.providers.filter(isRecord) + : []; + const elements = Array.isArray(snapshot.elements) + ? snapshot.elements.filter(isRecord) + : []; + const providerSummary = providers + .map( + (provider) => + `${provider.source}:${provider.available ? (provider.elementCount ?? "ok") : "unavailable"}`, + ) + .join(", "); return [ renderKeyValues("ADE iOS simulator snapshot", [ ["device", snapshot.deviceUdid], ["captured", snapshot.capturedAt], - ["screenshot", screenshot.width && screenshot.height ? `${screenshot.width}x${screenshot.height}` : null], - ["screen", screen.width && screen.height ? `${screen.width}x${screen.height} @${screen.scale ?? 1}x` : null], + [ + "screenshot", + screenshot.width && screenshot.height + ? `${screenshot.width}x${screenshot.height}` + : null, + ], + [ + "screen", + screen.width && screen.height + ? `${screen.width}x${screen.height} @${screen.scale ?? 1}x` + : null, + ], ["elements", elements.length], ["providers", providerSummary], ]), @@ -6074,25 +12012,42 @@ function formatIosSimSnapshot(value: unknown): string { elements.length ? renderTable( ["id", "source", "label", "source file"], - elements.slice(0, 20).map((element) => [ - element.id, - element.source, - element.label ?? element.identifier ?? element.componentId, - element.sourceFile ? `${element.sourceFile}${element.sourceLine ? `:${element.sourceLine}` : ""}` : "", - ]), + elements + .slice(0, 20) + .map((element) => [ + element.id, + element.source, + element.label ?? element.identifier ?? element.componentId, + element.sourceFile + ? `${element.sourceFile}${element.sourceLine ? `:${element.sourceLine}` : ""}` + : "", + ]), "", ) : "", - ].filter(Boolean).join("\n"); + ] + .filter(Boolean) + .join("\n"); } function formatIosSimSelection(value: unknown): string { - const item = firstRecord(value, ["item", "selection"]) ?? (isRecord(value) ? value : {}); + const item = + firstRecord(value, ["item", "selection"]) ?? (isRecord(value) ? value : {}); const metadata = isRecord(item.metadata) ? item.metadata : {}; return renderKeyValues("ADE iOS simulator selection", [ ["component", item.componentId], - ["source", isRecord(value) ? value.source ?? metadata.screenElementSource : metadata.screenElementSource], - ["file", item.sourceFile ? `${item.sourceFile}${item.sourceLine ? `:${item.sourceLine}` : ""}` : null], + [ + "source", + isRecord(value) + ? (value.source ?? metadata.screenElementSource) + : metadata.screenElementSource, + ], + [ + "file", + item.sourceFile + ? `${item.sourceFile}${item.sourceLine ? `:${item.sourceLine}` : ""}` + : null, + ], ["identifier", item.accessibilityIdentifier], ["chat session", metadata.chatSessionId], ["selected", item.selectedAt], @@ -6104,14 +12059,23 @@ function formatIosSimPreview(value: unknown): string { const targets = value.filter(isRecord); return renderTable( ["index", "title", "file", "kind"], - targets.map((target) => [target.previewDefinitionIndexInFile, target.title, target.sourceFilePath ?? target.sourceFile, target.kind]), + targets.map((target) => [ + target.previewDefinitionIndexInFile, + target.title, + target.sourceFilePath ?? target.sourceFile, + target.kind, + ]), "ADE iOS previews\n(no #Preview definitions found)", ); } const record = isRecord(value) ? value : {}; const capability = isRecord(record.capability) ? record.capability : record; - const steps = Array.isArray(capability.setupSteps) ? capability.setupSteps.join("; ") : null; - const selectedWindow = isRecord(capability.selectedWindow) ? capability.selectedWindow : {}; + const steps = Array.isArray(capability.setupSteps) + ? capability.setupSteps.join("; ") + : null; + const selectedWindow = isRecord(capability.selectedWindow) + ? capability.selectedWindow + : {}; return renderKeyValues("ADE iOS Preview Lab", [ ["supported", capability.supported ?? record.ok], ["xcode", capability.xcodeVersion], @@ -6144,7 +12108,9 @@ function formatMacosVmStatus(value: unknown): string { ]); } const provider = isRecord(status.activeProvider) ? status.activeProvider : {}; - const tools = Array.isArray(status.tools) ? status.tools.filter(isRecord) : []; + const tools = Array.isArray(status.tools) + ? status.tools.filter(isRecord) + : []; const laneVm = isRecord(status.laneVm) ? status.laneVm : null; const vms = Array.isArray(status.vms) ? status.vms.filter(isRecord) : []; const lines = [ @@ -6155,7 +12121,12 @@ function formatMacosVmStatus(value: unknown): string { ["provider", provider.kind], ["provider ready", provider.available], ["provider detail", provider.detail], - ["lane VM", laneVm ? `${laneVm.name ?? laneVm.id} (${laneVm.state ?? "unknown"})` : null], + [ + "lane VM", + laneVm + ? `${laneVm.name ?? laneVm.id} (${laneVm.state ?? "unknown"})` + : null, + ], ["guest path", laneVm?.guestSharedPath], ["host path", laneVm?.sharedDirectory ?? laneVm?.laneRoot], ["ssh", laneVm?.sshCommand], @@ -6164,13 +12135,22 @@ function formatMacosVmStatus(value: unknown): string { "", renderTable( ["lane", "vm", "state", "host path"], - vms.map((vm) => [vm.laneName ?? vm.laneId, vm.name, vm.state, vm.sharedDirectory ?? vm.laneRoot]), + vms.map((vm) => [ + vm.laneName ?? vm.laneId, + vm.name, + vm.state, + vm.sharedDirectory ?? vm.laneRoot, + ]), "Lane VMs\n(none)", ), "", renderTable( ["tool", "ready", "detail"], - tools.map((tool) => [tool.name, tool.available ? "yes" : "no", tool.detail]), + tools.map((tool) => [ + tool.name, + tool.available ? "yes" : "no", + tool.detail, + ]), "Tools\n(none)", ), ]; @@ -6179,7 +12159,9 @@ function formatMacosVmStatus(value: unknown): string { function formatMacosVmSharePolicy(value: unknown): string { const policy = isRecord(value) ? value : {}; - const excludedPaths = Array.isArray(policy.excludedPaths) ? policy.excludedPaths.filter((entry) => typeof entry === "string") : []; + const excludedPaths = Array.isArray(policy.excludedPaths) + ? policy.excludedPaths.filter((entry) => typeof entry === "string") + : []; return renderKeyValues("ADE macOS VM share policy", [ ["allowed", policy.allowed], ["mode", policy.syncMode], @@ -6196,7 +12178,10 @@ function formatMacosVmSharePolicy(value: unknown): string { function formatMacosVmGuide(value: unknown): string { if (isRecord(value) && typeof value.text === "string") return value.text; - return renderKeyValues("ADE macOS VM guide", Object.entries(isRecord(value) ? value : {}).slice(0, 24)); + return renderKeyValues( + "ADE macOS VM guide", + Object.entries(isRecord(value) ? value : {}).slice(0, 24), + ); } function formatMacosVmCapture(value: unknown): string { @@ -6211,7 +12196,10 @@ function formatMacosVmCapture(value: unknown): string { ["mode", capture.captureMode], ["window", window.windowTitle], ["process", window.processName], - ["frame", frame ? `${frame.x},${frame.y} ${frame.width}x${frame.height}` : null], + [ + "frame", + frame ? `${frame.x},${frame.y} ${frame.width}x${frame.height}` : null, + ], ["captured", capture.capturedAt], ["image data", capture.dataUrl ? "included" : null], ]); @@ -6221,13 +12209,20 @@ function formatMacosVmSelection(value: unknown): string { const result = isRecord(value) ? value : {}; const item = isRecord(result.item) ? result.item : {}; const metadata = isRecord(item.metadata) ? item.metadata : {}; - const selectedPoint = isRecord(metadata.selectedPoint) ? metadata.selectedPoint : {}; + const selectedPoint = isRecord(metadata.selectedPoint) + ? metadata.selectedPoint + : {}; const screenshot = isRecord(result.screenshot) ? result.screenshot : {}; return renderKeyValues("ADE macOS VM selection", [ ["source", result.source], ["lane", item.laneId], ["vm", item.vmName], - ["point", selectedPoint.x != null && selectedPoint.y != null ? `${selectedPoint.x},${selectedPoint.y}` : null], + [ + "point", + selectedPoint.x != null && selectedPoint.y != null + ? `${selectedPoint.x},${selectedPoint.y}` + : null, + ], ["coordinate space", selectedPoint.coordinateSpace], ["guest path", item.guestLanePath], ["host path", item.hostLanePath], @@ -6238,10 +12233,14 @@ function formatMacosVmSelection(value: unknown): string { function formatAppControlStatus(value: unknown): string { const status = isRecord(value) ? value : {}; - const providers = Array.isArray(status.providers) ? status.providers.filter(isRecord) : []; + const providers = Array.isArray(status.providers) + ? status.providers.filter(isRecord) + : []; const session = isRecord(status.activeSession) ? status.activeSession - : typeof status.status === "string" && status.label ? status : {}; + : typeof status.status === "string" && status.label + ? status + : {}; return [ renderKeyValues("ADE App Control", [ ["supported", status.supported], @@ -6260,7 +12259,11 @@ function formatAppControlStatus(value: unknown): string { "", renderTable( ["provider", "ready", "detail"], - providers.map((provider) => [provider.provider, provider.available ? "yes" : "no", provider.detail]), + providers.map((provider) => [ + provider.provider, + provider.available ? "yes" : "no", + provider.detail, + ]), "Providers\n(none)", ), ].join("\n"); @@ -6299,18 +12302,39 @@ function formatBrowserStatus(value: unknown): string { function formatAppControlSnapshot(value: unknown): string { const snapshot = isRecord(value) ? value : {}; - const screenshot = isRecord(snapshot.screenshot) ? snapshot.screenshot : snapshot; + const screenshot = isRecord(snapshot.screenshot) + ? snapshot.screenshot + : snapshot; const screen = isRecord(snapshot.screen) ? snapshot.screen : {}; - const providers = Array.isArray(snapshot.providers) ? snapshot.providers.filter(isRecord) : []; - const elements = Array.isArray(snapshot.elements) ? snapshot.elements.filter(isRecord) : []; - const providerSummary = providers.map((provider) => `${provider.provider}:${provider.available ? provider.elementCount ?? "ok" : "unavailable"}`).join(", "); + const providers = Array.isArray(snapshot.providers) + ? snapshot.providers.filter(isRecord) + : []; + const elements = Array.isArray(snapshot.elements) + ? snapshot.elements.filter(isRecord) + : []; + const providerSummary = providers + .map( + (provider) => + `${provider.provider}:${provider.available ? (provider.elementCount ?? "ok") : "unavailable"}`, + ) + .join(", "); return [ renderKeyValues("ADE App Control snapshot", [ ["title", snapshot.title], ["url", snapshot.url], ["captured", snapshot.capturedAt], - ["screenshot", screenshot.width && screenshot.height ? `${screenshot.width}x${screenshot.height}` : null], - ["screen", screen.width && screen.height ? `${screen.width}x${screen.height} @${screen.scale ?? 1}x` : null], + [ + "screenshot", + screenshot.width && screenshot.height + ? `${screenshot.width}x${screenshot.height}` + : null, + ], + [ + "screen", + screen.width && screen.height + ? `${screen.width}x${screen.height} @${screen.scale ?? 1}x` + : null, + ], ["elements", elements.length], ["providers", providerSummary], ]), @@ -6318,16 +12342,20 @@ function formatAppControlSnapshot(value: unknown): string { elements.length ? renderTable( ["ref", "role", "label", "selector"], - elements.slice(0, 24).map((element) => [ - element.ref ?? element.id, - element.role ?? element.tagName, - element.label ?? element.value ?? element.testId, - element.selector, - ]), + elements + .slice(0, 24) + .map((element) => [ + element.ref ?? element.id, + element.role ?? element.tagName, + element.label ?? element.value ?? element.testId, + element.selector, + ]), "", ) : "", - ].filter(Boolean).join("\n"); + ] + .filter(Boolean) + .join("\n"); } function formatTerminalList(value: unknown): string { @@ -6361,6 +12389,27 @@ function formatTerminalRead(value: unknown): string { return data.length ? `${header}\n\n${data}` : `${header}\n\n(no output)`; } +function formatProjectsList(value: unknown): string { + const projects = Array.isArray(value) + ? value.filter(isRecord) + : isRecord(value) && value.projectId + ? [value] + : firstArray(value, ["projects", "items"]); + return renderTable( + ["project", "name", "path", "git origin", "last opened"], + projects.map((project) => [ + project.projectId, + project.displayName, + project.rootPath, + project.gitOriginUrl, + typeof project.lastOpenedAt === "number" && project.lastOpenedAt > 0 + ? new Date(project.lastOpenedAt).toISOString() + : "", + ]), + "ADE projects\n(no projects registered)", + ); +} + function formatLinearQuickView(value: unknown): string { if (!isRecord(value)) return JSON.stringify(value, null, 2); const connection = isRecord(value.connection) ? value.connection : {}; @@ -6385,11 +12434,16 @@ function formatLinearQuickView(value: unknown): string { const projectRows = projects.map((project) => [ project.name, project.statusName ?? project.statusType, - typeof project.progress === "number" ? `${Math.round(project.progress * 100)}%` : "", + typeof project.progress === "number" + ? `${Math.round(project.progress * 100)}%` + : "", project.issueCount, ]); const issueRows = [...assignedIssues, ...recentIssues] - .filter((issue, index, all) => all.findIndex((candidate) => candidate.id === issue.id) === index) + .filter( + (issue, index, all) => + all.findIndex((candidate) => candidate.id === issue.id) === index, + ) .slice(0, 12) .map((issue) => [ issue.identifier, @@ -6401,7 +12455,11 @@ function formatLinearQuickView(value: unknown): string { header, "", "Projects", - renderTable(["project", "status", "progress", "issues"], projectRows, "(no projects)"), + renderTable( + ["project", "status", "progress", "issues"], + projectRows, + "(no projects)", + ), "", "Issues", renderTable(["id", "title", "state", "area"], issueRows, "(no issues)"), @@ -6409,22 +12467,41 @@ function formatLinearQuickView(value: unknown): string { } function formatAppControlSelection(value: unknown): string { - const item = firstRecord(value, ["item", "selection"]) ?? (isRecord(value) ? value : {}); + const item = + firstRecord(value, ["item", "selection"]) ?? (isRecord(value) ? value : {}); const metadata = isRecord(item.metadata) ? item.metadata : {}; - const selected = isRecord(metadata.selectedElement) ? metadata.selectedElement : {}; + const selected = isRecord(metadata.selectedElement) + ? metadata.selectedElement + : {}; return renderKeyValues("ADE App Control selection", [ ["component", item.componentId], - ["source", isRecord(value) ? value.source ?? item.provider : item.provider], - ["file", item.sourceFile ? `${item.sourceFile}${item.sourceLine ? `:${item.sourceLine}` : ""}` : null], + [ + "source", + isRecord(value) ? (value.source ?? item.provider) : item.provider, + ], + [ + "file", + item.sourceFile + ? `${item.sourceFile}${item.sourceLine ? `:${item.sourceLine}` : ""}` + : null, + ], ["selector", selected.selector], ["label", selected.label ?? metadata.label], ["selected", item.selectedAt], ]); } -function formatTextOutput(value: unknown, formatter: FormatterId | undefined): string { +function formatTextOutput( + value: unknown, + formatter: FormatterId | undefined, +): string { if (typeof value === "string") return value; - if (isRecord(value) && typeof value.visual === "string" && (!formatter || formatter === "lanes")) return value.visual; + if ( + isRecord(value) && + typeof value.visual === "string" && + (!formatter || formatter === "lanes") + ) + return value.visual; switch (formatter) { case "status": return renderKeyValues("ADE status", [ @@ -6434,61 +12511,79 @@ function formatTextOutput(value: unknown, formatter: FormatterId | undefined): s ["workspace", isRecord(value) ? value.workspaceRoot : null], ["socket", isRecord(value) ? value.socketPath : null], ]); - case "doctor": - { - const project = isRecord(value) && isRecord(value.project) ? value.project : {}; - const desktop = isRecord(value) && isRecord(value.desktop) ? value.desktop : {}; - const actions = isRecord(value) && isRecord(value.actions) ? value.actions : {}; - const git = isRecord(value) && isRecord(value.git) ? value.git : {}; - const github = isRecord(value) && isRecord(value.github) ? value.github : {}; - const linear = isRecord(value) && isRecord(value.linear) ? value.linear : {}; - const providers = isRecord(value) && isRecord(value.providers) ? value.providers : {}; - const computerUse = isRecord(value) && isRecord(value.computerUse) ? value.computerUse : {}; - const pathStatus = isRecord(value) && isRecord(value.path) ? value.path : {}; - const recommendations = isRecord(value) && Array.isArray(value.recommendations) ? value.recommendations : []; - return [ - renderKeyValues("ADE doctor", [ - ["ok", isRecord(value) ? value.ok : null], - ["cli version", isRecord(value) ? value.cliVersion : null], - ["mode", isRecord(value) ? value.mode : null], - ["project", isRecord(value) ? value.projectRoot : null], - ["workspace", isRecord(value) ? value.workspaceRoot : null], - ["project initialized", project.projectInitialized], - ["desktop socket", desktop.socketAvailable], - ["socket path", desktop.socketPath], - ["rpc actions", actions.rpcActionCount], - ["service actions", actions.actionCount], - ["git", git.message], - ["github", github.message], - ["linear", linear.message], - ["providers", providers.message], - ["computer use", computerUse.message], - ["path", pathStatus.message], - ["recommendation", isRecord(value) ? value.recommendation : null], - ]), - ...(recommendations.length ? ["", "Next actions", ...recommendations.map((entry) => `- ${cell(entry, 120)}`)] : []), - ].join("\n"); - } - case "auth": - { - const checks = isRecord(value) && isRecord(value.checks) ? value.checks : {}; - const git = isRecord(checks.git) ? checks.git : {}; - const github = isRecord(checks.github) ? checks.github : {}; - const linear = isRecord(checks.linear) ? checks.linear : {}; - const providers = isRecord(checks.providers) ? checks.providers : {}; - return renderKeyValues("ADE auth", [ - ["authenticated", isRecord(value) ? value.authenticated : null], - ["mode", isRecord(value) ? value.authMode : null], - ["role", isRecord(value) ? value.role : null], + case "doctor": { + const project = + isRecord(value) && isRecord(value.project) ? value.project : {}; + const desktop = + isRecord(value) && isRecord(value.desktop) ? value.desktop : {}; + const actions = + isRecord(value) && isRecord(value.actions) ? value.actions : {}; + const git = isRecord(value) && isRecord(value.git) ? value.git : {}; + const github = + isRecord(value) && isRecord(value.github) ? value.github : {}; + const linear = + isRecord(value) && isRecord(value.linear) ? value.linear : {}; + const providers = + isRecord(value) && isRecord(value.providers) ? value.providers : {}; + const computerUse = + isRecord(value) && isRecord(value.computerUse) ? value.computerUse : {}; + const pathStatus = + isRecord(value) && isRecord(value.path) ? value.path : {}; + const recommendations = + isRecord(value) && Array.isArray(value.recommendations) + ? value.recommendations + : []; + return [ + renderKeyValues("ADE doctor", [ + ["ok", isRecord(value) ? value.ok : null], + ["cli version", isRecord(value) ? value.cliVersion : null], + ["mode", isRecord(value) ? value.mode : null], ["project", isRecord(value) ? value.projectRoot : null], - ["actions", isRecord(value) ? value.availableActionCount : null], + ["workspace", isRecord(value) ? value.workspaceRoot : null], + ["project initialized", project.projectInitialized], + ["runtime socket", desktop.socketAvailable], + ["socket path", desktop.socketPath], + ["rpc actions", actions.rpcActionCount], + ["service actions", actions.actionCount], ["git", git.message], ["github", github.message], ["linear", linear.message], ["providers", providers.message], - ["note", isRecord(value) ? value.note : null], - ]); - } + ["computer use", computerUse.message], + ["path", pathStatus.message], + ["recommendation", isRecord(value) ? value.recommendation : null], + ]), + ...(recommendations.length + ? [ + "", + "Next actions", + ...recommendations.map((entry) => `- ${cell(entry, 120)}`), + ] + : []), + ].join("\n"); + } + case "auth": { + const checks = + isRecord(value) && isRecord(value.checks) ? value.checks : {}; + const git = isRecord(checks.git) ? checks.git : {}; + const github = isRecord(checks.github) ? checks.github : {}; + const linear = isRecord(checks.linear) ? checks.linear : {}; + const providers = isRecord(checks.providers) ? checks.providers : {}; + return renderKeyValues("ADE auth", [ + ["authenticated", isRecord(value) ? value.authenticated : null], + ["mode", isRecord(value) ? value.authMode : null], + ["role", isRecord(value) ? value.role : null], + ["project", isRecord(value) ? value.projectRoot : null], + ["actions", isRecord(value) ? value.availableActionCount : null], + ["git", git.message], + ["github", github.message], + ["linear", linear.message], + ["providers", providers.message], + ["note", isRecord(value) ? value.note : null], + ]); + } + case "projects-list": + return formatProjectsList(value); case "linear-quick-view": return formatLinearQuickView(value); case "lanes": @@ -6496,7 +12591,10 @@ function formatTextOutput(value: unknown, formatter: FormatterId | undefined): s case "lane-detail": return formatLaneDetail(value); case "git-status": - return renderKeyValues("ADE git status", Object.entries(isRecord(value) ? value : {})); + return renderKeyValues( + "ADE git status", + Object.entries(isRecord(value) ? value : {}), + ); case "diff-summary": return formatDiffSummary(value); case "file-read": @@ -6508,7 +12606,13 @@ function formatTextOutput(value: unknown, formatter: FormatterId | undefined): s case "prs-list": return formatPrList(value); case "pr-detail": - return renderKeyValues("ADE pull request", Object.entries(firstRecord(value, ["pr", "detail"]) ?? (isRecord(value) ? value : {})).slice(0, 16)); + return renderKeyValues( + "ADE pull request", + Object.entries( + firstRecord(value, ["pr", "detail"]) ?? + (isRecord(value) ? value : {}), + ).slice(0, 16), + ); case "pr-checks": return formatPrChecks(value); case "pr-comments": @@ -6575,22 +12679,35 @@ function formatTextOutput(value: unknown, formatter: FormatterId | undefined): s return formatAutomationRunDetail(value); case "action-result": default: - if (isRecord(value)) return renderKeyValues("ADE result", Object.entries(value).slice(0, 24)); + if (isRecord(value)) + return renderKeyValues( + "ADE result", + Object.entries(value).slice(0, 24), + ); return JSON.stringify(value, null, 2); } } -function inferFormatter(plan: CliPlan & { kind: "execute" }): FormatterId | undefined { +function inferFormatter( + plan: CliPlan & { kind: "execute" }, +): FormatterId | undefined { if (plan.formatter) return plan.formatter; if (plan.summary) return plan.summary; if (plan.visualizer === "lanes") return "lanes"; const label = plan.label.toLowerCase(); + if ( + label === "projects list" || + label === "projects add" || + label === "projects touch" + ) + return "projects-list"; if (label === "lane status") return "lane-detail"; if (label === "git status") return "git-status"; if (label === "diff changes") return "diff-summary"; if (label === "file read") return "file-read"; if (label === "file tree" || label === "file workspaces") return "files-tree"; - if (label === "file search" || label === "file quick-open") return "files-search"; + if (label === "file search" || label === "file quick-open") + return "files-search"; if (label === "pr list" || label === "pr list open") return "prs-list"; if (label === "pr detail" || label === "pr health") return "pr-detail"; if (label === "pr checks") return "pr-checks"; @@ -6603,20 +12720,64 @@ function inferFormatter(plan: CliPlan & { kind: "execute" }): FormatterId | unde if (label === "ios simulator status") return "ios-sim-status"; if (label === "ios simulator devices") return "ios-sim-devices"; if (label === "ios simulator launchable apps") return "ios-sim-apps"; - if (label === "ios simulator stream start" || label === "ios simulator stream status" || label === "ios simulator stream stop") return "ios-sim-stream"; - if (label === "ios simulator screen snapshot" || label === "ios simulator inspector snapshot" || label === "ios simulator screenshot") return "ios-sim-snapshot"; - if (label === "ios simulator select" || label === "ios simulator inspect point") return "ios-sim-selection"; - if (label === "ios simulator preview status" || label === "ios simulator previews" || label === "ios simulator preview render" || label === "ios simulator preview open") return "ios-sim-preview"; - if (label === "app control status" || label === "app control launch" || label === "app control connect" || label === "app control stop") return "app-control-status"; - if (label === "app control snapshot" || label === "app control screenshot") return "app-control-snapshot"; - if (label === "app control select" || label === "app control inspect point") return "app-control-selection"; - if (label === "browser status" || label === "browser panel" || label === "browser open" || label === "browser new tab" || label === "browser switch" || label === "browser close") return "browser-status"; - if (label === "macos vm status" || label === "macos vm start" || label === "macos vm stop" || label === "macos vm provision" || label === "macos vm delete") return "macos-vm-status"; + if ( + label === "ios simulator stream start" || + label === "ios simulator stream status" || + label === "ios simulator stream stop" + ) + return "ios-sim-stream"; + if ( + label === "ios simulator screen snapshot" || + label === "ios simulator inspector snapshot" || + label === "ios simulator screenshot" + ) + return "ios-sim-snapshot"; + if ( + label === "ios simulator select" || + label === "ios simulator inspect point" + ) + return "ios-sim-selection"; + if ( + label === "ios simulator preview status" || + label === "ios simulator previews" || + label === "ios simulator preview render" || + label === "ios simulator preview open" + ) + return "ios-sim-preview"; + if ( + label === "app control status" || + label === "app control launch" || + label === "app control connect" || + label === "app control stop" + ) + return "app-control-status"; + if (label === "app control snapshot" || label === "app control screenshot") + return "app-control-snapshot"; + if (label === "app control select" || label === "app control inspect point") + return "app-control-selection"; + if ( + label === "browser status" || + label === "browser panel" || + label === "browser open" || + label === "browser new tab" || + label === "browser switch" || + label === "browser close" + ) + return "browser-status"; + if ( + label === "macos vm status" || + label === "macos vm start" || + label === "macos vm stop" || + label === "macos vm provision" || + label === "macos vm delete" + ) + return "macos-vm-status"; if (label === "macos vm share policy") return "macos-vm-share-policy"; if (label === "macos vm guide") return "macos-vm-guide"; if (label === "macos vm screenshot") return "macos-vm-capture"; if (label === "macos vm select") return "macos-vm-selection"; - if (label === "terminal list" || label === "terminal active") return "terminal-list"; + if (label === "terminal list" || label === "terminal active") + return "terminal-list"; if (label === "terminal read") return "terminal-read"; if (label === "actions list") return "actions-list"; if (label.endsWith("actions")) return "actions-list"; @@ -6643,12 +12804,21 @@ function summarizeExecution(args: { return buildReadinessSnapshot({ connection, values, summary: "doctor" }); } if (plan.summary === "auth") { - const readiness = buildReadinessSnapshot({ connection, values, summary: "auth" }); + const readiness = buildReadinessSnapshot({ + connection, + values, + summary: "auth", + }); const actions = isRecord(readiness.actions) ? readiness.actions : {}; return { ok: readiness.ok, - authenticated: isRecord(readiness.auth) ? readiness.auth.localProjectAccess : false, - authMode: connection.mode === "desktop-socket" ? "local-desktop-socket" : "local-headless-project", + authenticated: isRecord(readiness.auth) + ? readiness.auth.localProjectAccess + : false, + authMode: + connection.mode === "desktop-socket" + ? "local-desktop-socket" + : "local-headless-project", role: process.env.ADE_DEFAULT_ROLE ?? "agent", projectRoot: connection.projectRoot, workspaceRoot: connection.workspaceRoot, @@ -6663,7 +12833,9 @@ function summarizeExecution(args: { path: readiness.path, }, recommendations: readiness.recommendations, - note: isRecord(readiness.auth) ? readiness.auth.note : "ADE CLI auth is local project access.", + note: isRecord(readiness.auth) + ? readiness.auth.note + : "ADE CLI auth is local project access.", }; } @@ -6675,7 +12847,11 @@ function summarizeExecution(args: { return { created, started, - mission: refreshedMission ?? missionFromResult(started) ?? missionFromResult(created) ?? created, + mission: + refreshedMission ?? + missionFromResult(started) ?? + missionFromResult(created) ?? + created, run: runFromGraphResult(graph) ?? runFromStartResult(started), graph, }; @@ -6708,12 +12884,12 @@ function summarizeExecution(args: { const result = values.result ?? values; if ( - isRecord(result) - && Object.prototype.hasOwnProperty.call(result, "result") - && asString(result.domain) - && asString(result.action) - && !plan.label.toLowerCase().startsWith("action ") - && !plan.label.toLowerCase().endsWith(" action") + isRecord(result) && + Object.prototype.hasOwnProperty.call(result, "result") && + asString(result.domain) && + asString(result.action) && + !plan.label.toLowerCase().startsWith("action ") && + !plan.label.toLowerCase().endsWith(" action") ) { return result.result; } @@ -6726,17 +12902,29 @@ function summarizeExecution(args: { return result; } -const TERMINAL_MISSION_RUN_STATUSES = new Set(["succeeded", "failed", "canceled", "cancelled"]); +const TERMINAL_MISSION_RUN_STATUSES = new Set([ + "succeeded", + "failed", + "canceled", + "cancelled", +]); const HEADLESS_ACTIVE_ATTEMPT_DRAIN_MS = 30 * 60 * 1000; -function graphWaitState(value: unknown): { status: string; activeCount: number } { +function graphWaitState(value: unknown): { + status: string; + activeCount: number; +} { const graph = graphFromResult(value) ?? {}; const run = firstRecord(graph, ["run"]) ?? {}; const status = (asString(run.status) ?? "").trim().toLowerCase(); const steps = firstArray(graph, ["steps"]); const attempts = firstArray(graph, ["attempts"]); - const activeStepCount = steps.filter((step) => asString(step.status)?.trim().toLowerCase() === "running").length; - const activeAttemptCount = attempts.filter((attempt) => asString(attempt.status)?.trim().toLowerCase() === "running").length; + const activeStepCount = steps.filter( + (step) => asString(step.status)?.trim().toLowerCase() === "running", + ).length; + const activeAttemptCount = attempts.filter( + (attempt) => asString(attempt.status)?.trim().toLowerCase() === "running", + ).length; return { status, activeCount: Math.max(activeStepCount, activeAttemptCount), @@ -6791,9 +12979,9 @@ async function waitForRunGraph(args: { if (pastDeadline) { timedOut = true; const shouldDrainActiveHeadlessWork = - args.connection.mode === "headless" - && waitState.activeCount > 0 - && now < headlessDrainDeadline; + args.connection.mode === "headless" && + waitState.activeCount > 0 && + now < headlessDrainDeadline; if (!shouldDrainActiveHeadlessWork) break; extendedForActiveHeadlessWork = true; } @@ -6819,52 +13007,81 @@ async function waitForRunGraph(args: { }; } -async function executePlan(plan: CliPlan & { kind: "execute" }, options: GlobalOptions): Promise { +async function executePlan( + plan: CliPlan & { kind: "execute" }, + options: GlobalOptions, +): Promise { let connection: CliConnection; const isWorkerMissionToolPlan = plan.label.startsWith("worker mission tool "); const workerRpcUrl = process.env.ADE_RPC_URL?.trim(); const workerSocketOverride = process.env.ADE_RPC_SOCKET_PATH?.trim(); - const connectionOptions = isWorkerMissionToolPlan && !options.requireSocket - ? { ...options, headless: false, requireSocket: Boolean(workerRpcUrl || workerSocketOverride) } - : plan.preferHeadless && !options.requireSocket - ? { ...options, headless: true } - : options; + const connectionOptions = + isWorkerMissionToolPlan && !options.requireSocket + ? { + ...options, + headless: false, + requireSocket: Boolean(workerRpcUrl || workerSocketOverride), + } + : plan.preferHeadless && !options.requireSocket + ? { ...options, headless: true } + : options; try { - connection = await createConnection(connectionOptions); + connection = await createConnection(connectionOptions, { + autoRegisterProject: shouldAutoRegisterProjectForPlan(plan), + }); } catch (error) { const roots = resolveRoots(options); let socketPath = path.join(roots.projectRoot, ".ade", "ade.sock"); try { - const { resolveAdeLayout } = await import("../../desktop/src/shared/adeLayout"); + const { resolveAdeLayout } = + await import("../../desktop/src/shared/adeLayout"); socketPath = resolveAdeLayout(roots.projectRoot).socketPath; } catch { // Keep the conventional Unix fallback if shared layout loading fails. } - const requestedMode = connectionOptions.requireSocket ? "desktop-socket" : connectionOptions.headless ? "headless" : "auto"; + const requestedMode = connectionOptions.requireSocket + ? "socket" + : connectionOptions.headless + ? "headless" + : "auto"; const cause = error instanceof Error ? error.message : String(error); const sourceRuntimeInterop = isSourceRuntimeInteropError(cause); - throw new CliExecutionError(`Failed to initialize ADE CLI connection for ${plan.label}.`, { - cause, - requestedMode, - projectRoot: roots.projectRoot, - workspaceRoot: roots.workspaceRoot, - socketPath, - nextAction: options.requireSocket - ? "Start ADE desktop for this project or remove --socket to allow headless mode." - : sourceRuntimeInterop - ? "Run `npm --prefix apps/ade-cli run build` and retry, or use `npm --prefix apps/ade-cli run cli:dev -- ...`." - : "Verify --project-root points at an ADE project and run ade doctor --json.", - }); + throw new CliExecutionError( + `Failed to initialize ADE CLI connection for ${plan.label}.`, + { + cause, + requestedMode, + projectRoot: roots.projectRoot, + workspaceRoot: roots.workspaceRoot, + socketPath, + nextAction: options.requireSocket + ? "Start the ADE runtime for this project or remove --socket to allow headless mode." + : sourceRuntimeInterop + ? "Run `npm --prefix apps/ade-cli run build` and retry, or use `npm --prefix apps/ade-cli run cli:dev -- ...`." + : "Verify --project-root points at an ADE project and run ade doctor --json.", + }, + ); } try { const values: JsonObject = {}; for (const step of plan.steps) { try { - const params = typeof step.params === "function" ? step.params(values) : step.params; + const params = + typeof step.params === "function" ? step.params(values) : step.params; if (step.method === "ade-cli/wait-run-graph") { const runId = requireValue(asString(params?.runId) ?? null, "run id"); - const waitMs = Math.max(0, Math.floor(typeof params?.waitMs === "number" ? params.waitMs : 0)); - const timelineLimit = Math.max(0, Math.floor(typeof params?.timelineLimit === "number" ? params.timelineLimit : 120)); + const waitMs = Math.max( + 0, + Math.floor(typeof params?.waitMs === "number" ? params.waitMs : 0), + ); + const timelineLimit = Math.max( + 0, + Math.floor( + typeof params?.timelineLimit === "number" + ? params.timelineLimit + : 120, + ), + ); values[step.key] = await waitForRunGraph({ connection, runId, @@ -6886,40 +13103,58 @@ async function executePlan(plan: CliPlan & { kind: "execute" }, options: GlobalO } return summarizeExecution({ plan, connection, values }); } catch (error) { - if (error instanceof CliToolError || error instanceof CliUsageError || error instanceof CliExecutionError) throw error; + if ( + error instanceof CliToolError || + error instanceof CliUsageError || + error instanceof CliExecutionError + ) + throw error; throw new CliExecutionError(`Failed while running ${plan.label}.`, { cause: error instanceof Error ? error.message : String(error), mode: connection.mode, projectRoot: connection.projectRoot, workspaceRoot: connection.workspaceRoot, socketPath: connection.socketPath, - nextAction: connection.mode === "desktop-socket" - ? "Check ADE desktop logs or retry with --headless if the workflow does not need UI-owned state." - : "Run ade doctor --json to inspect local project readiness, or start ADE desktop and retry with --socket.", + nextAction: + connection.mode === "desktop-socket" + ? "Check ADE desktop logs or retry with --headless if the workflow does not need UI-owned state." + : "Run ade doctor --json to inspect local project readiness, or start ADE desktop and retry with --socket.", }); } finally { await connection.close(); } } -function formatOutput(value: unknown, options: GlobalOptions, formatter?: FormatterId): string { +function formatOutput( + value: unknown, + options: GlobalOptions, + formatter?: FormatterId, +): string { if (options.text) { return `${formatTextOutput(value, formatter)}\n`; } return `${JSON.stringify(value, null, options.pretty ? 2 : 0)}\n`; } -async function runCli(argv: string[]): Promise<{ output: string; exitCode: number }> { +async function runCli( + argv: string[], +): Promise<{ output: string; exitCode: number }> { const parsed = parseCliArgs(argv); const plan = buildCliPlan(parsed.command); - if (plan.kind === "help") return { output: plan.text.endsWith("\n") ? plan.text : `${plan.text}\n`, exitCode: 0 }; + if (plan.kind === "help") + return { + output: plan.text.endsWith("\n") ? plan.text : `${plan.text}\n`, + exitCode: 0, + }; const originalConsole = { log: console.log, info: console.info, warn: console.warn, }; const writeDiagnostic = (...args: unknown[]) => { - process.stderr.write(`${args.map((arg) => typeof arg === "string" ? arg : JSON.stringify(arg)).join(" ")}\n`); + process.stderr.write( + `${args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ")}\n`, + ); }; console.log = writeDiagnostic; console.info = writeDiagnostic; @@ -6930,19 +13165,66 @@ async function runCli(argv: string[]): Promise<{ output: string; exitCode: numbe // RPC. The function handles its own --json/--text/--compact parsing on // the remaining tokens. try { - const result = await runCursorCloud(plan.rest, parsed.options.text ? "text" : "json"); + const result = await runCursorCloud( + plan.rest, + parsed.options.text ? "text" : "json", + ); return result; } catch (error) { - if (error instanceof CursorCloudUsageError) throw new CliUsageError(error.message); + if (error instanceof CursorCloudUsageError) + throw new CliUsageError(error.message); throw error; } } if (plan.kind === "mcp") { - await runMcpServer({ ...parsed.options, headless: true, requireSocket: false }); + await runMcpServer({ + ...parsed.options, + headless: true, + requireSocket: false, + }); + return { output: "", exitCode: 0 }; + } + if (plan.kind === "rpc-stdio") { + await runNativeRpcStdio(parsed.options); return { output: "", exitCode: 0 }; } + if (plan.kind === "desktop") { + const result = await runDesktopCommand(plan.rest); + return { + output: formatOutput(result, parsed.options, undefined), + exitCode: isRecord(result) && result.ok === false ? 1 : 0, + }; + } + if (plan.kind === "runtime") { + const result = await runRuntimeCommand(plan.rest, parsed.options); + return { + output: formatOutput(result, parsed.options, undefined), + exitCode: isRecord(result) && result.ok === false ? 1 : 0, + }; + } + if (plan.kind === "serve") { + const result = await runServe(plan.rest, parsed.options); + return { + output: + result == null ? "" : formatOutput(result, parsed.options, undefined), + exitCode: isFailedServiceManagerResult(result) ? 1 : 0, + }; + } + if (plan.kind === "init") { + const result = await runInit(plan.targetPath); + return { + output: formatOutput(result, parsed.options, undefined), + exitCode: 0, + }; + } + if (plan.kind === "ade-code") { + return await runAdeCode(plan.rest, parsed.options); + } const result = await executePlan(plan, parsed.options); - return { output: formatOutput(result, parsed.options, inferFormatter(plan)), exitCode: 0 }; + return { + output: formatOutput(result, parsed.options, inferFormatter(plan)), + exitCode: 0, + }; } finally { console.log = originalConsole.log; console.info = originalConsole.info; @@ -6952,7 +13234,9 @@ async function runCli(argv: string[]): Promise<{ output: string; exitCode: numbe async function main(): Promise { const writeDiagnostic = (...args: unknown[]) => { - process.stderr.write(`${args.map((arg) => typeof arg === "string" ? arg : JSON.stringify(arg)).join(" ")}\n`); + process.stderr.write( + `${args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ")}\n`, + ); }; console.log = writeDiagnostic; console.info = writeDiagnostic; @@ -6988,7 +13272,9 @@ async function main(): Promise { process.exitCode = 1; return; } - process.stderr.write(`ade: ${error instanceof Error ? error.stack || error.message : String(error)}\n`); + process.stderr.write( + `ade: ${error instanceof Error ? error.stack || error.message : String(error)}\n`, + ); process.exitCode = 1; } } @@ -6999,9 +13285,11 @@ if (/(^|[/\\])cli\.(?:ts|js|cjs)$/.test(process.argv[1] ?? "")) { export { buildCliPlan, + buildAdeCodeArgs, findProjectRoots, formatOutput, graphWaitState, + isFailedServiceManagerResult, parseCliArgs, renderLaneGraph, resolveRoots, diff --git a/apps/ade-cli/src/eventBuffer.ts b/apps/ade-cli/src/eventBuffer.ts index 07035ab77..be0475117 100644 --- a/apps/ade-cli/src/eventBuffer.ts +++ b/apps/ade-cli/src/eventBuffer.ts @@ -8,11 +8,13 @@ export type BufferedEvent = { export type EventBuffer = { push(event: Omit): void; drain(cursor: number, limit?: number): { events: BufferedEvent[]; nextCursor: number; hasMore: boolean }; + subscribe(listener: (event: BufferedEvent) => void): () => void; size(): number; }; export function createEventBuffer(capacity = 10_000): EventBuffer { const events: BufferedEvent[] = []; + const listeners = new Set<(event: BufferedEvent) => void>(); let nextId = 1; return { @@ -22,6 +24,13 @@ export function createEventBuffer(capacity = 10_000): EventBuffer { while (events.length > capacity) { events.shift(); } + for (const listener of [...listeners]) { + try { + listener(entry); + } catch { + // Event delivery is best-effort; one subscriber must not break producers. + } + } }, drain(cursor, limit = 100) { const clamped = Math.max(1, Math.min(1000, limit)); @@ -37,6 +46,12 @@ export function createEventBuffer(capacity = 10_000): EventBuffer { hasMore: startIdx + clamped < events.length, }; }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, size() { return events.length; }, diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index c35a65b51..11a9c09da 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockState = vi.hoisted(() => ({ @@ -411,6 +414,26 @@ describe("headlessLinearServices", () => { services.dispose(); }); + it("exposes bundled Linear OAuth credentials in headless runtime", () => { + const previousAdeHome = process.env.ADE_HOME; + process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-linear-oauth-")); + const services = createHeadlessLinearServices(createDeps()); + try { + expect(services.linearCredentialService.getStatus().oauthConfigured).toBe(true); + expect(services.linearCredentialService.getOAuthClientCredentials()).toEqual({ + clientId: expect.any(String), + clientSecret: null, + }); + } finally { + services.dispose(); + if (previousAdeHome == null) { + delete process.env.ADE_HOME; + } else { + process.env.ADE_HOME = previousAdeHome; + } + } + }); + it("assigns CTO default title for cto identityKey", async () => { const services = createHeadlessLinearServices(createDeps()); diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index db732c7a6..94c3d49eb 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -30,7 +30,15 @@ import type { createWorkerTaskSessionService } from "../../desktop/src/main/serv import type { createWorkerHeartbeatService } from "../../desktop/src/main/services/cto/workerHeartbeatService"; import type { createAutomationSecretService } from "../../desktop/src/main/services/automations/automationSecretService"; import type { ComputerUseArtifactBrokerService } from "../../desktop/src/main/services/computerUse/computerUseArtifactBrokerService"; -import { getModelById, getRuntimeModelRefForDescriptor, resolveModelAlias } from "../../desktop/src/shared/modelRegistry"; +import { + getModelById, + getRuntimeModelRefForDescriptor, + resolveModelAlias, +} from "../../desktop/src/shared/modelRegistry"; +import { + getGitHubTokenAccessState, + parseGitHubScopeHeaders, +} from "../../desktop/src/shared/githubScopes"; import type { AdeRuntimePaths } from "./bootstrap"; import { createLinearClient as createLinearClientImpl } from "../../desktop/src/main/services/cto/linearClient"; import { createLinearIssueTracker as createLinearIssueTrackerImpl } from "../../desktop/src/main/services/cto/linearIssueTracker"; @@ -49,6 +57,12 @@ import { createFileService as createFileServiceImpl } from "../../desktop/src/ma import { createProcessService as createProcessServiceImpl } from "../../desktop/src/main/services/processes/processService"; import { createPrService as createPrServiceImpl } from "../../desktop/src/main/services/prs/prService"; import { createAutomationSecretService as createAutomationSecretServiceImpl } from "../../desktop/src/main/services/automations/automationSecretService"; +import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; + +// Keep headless runtimes aligned with the desktop credential service so packaged +// alpha builds can offer the same PKCE-based Linear sign-in flow. +const BUNDLED_LINEAR_OAUTH_CLIENT_ID = + process.env.ADE_LINEAR_CLIENT_ID?.trim() || "432fb2ddb16f939ae5d5270e2c86571f"; type HeadlessLinearCredentialService = { getStatus: () => { @@ -60,10 +74,27 @@ type HeadlessLinearCredentialService = { scopes: string[]; checkedAt: string | null; authMode?: "manual" | "oauth" | null; + tokenExpiresAt?: string | null; + refreshTokenStored?: boolean; + oauthConfigured?: boolean; }; getTokenOrThrow: () => string; setToken: (token: string) => void; + setOAuthToken: (args: { + accessToken: string; + refreshToken?: string | null; + expiresAt?: string | null; + }) => void; clearToken: () => void; + setOAuthClientCredentials: (args: { + clientId: string; + clientSecret?: string | null; + }) => void; + clearOAuthClientCredentials: () => void; + getOAuthClientCredentials: () => { + clientId: string; + clientSecret: string | null; + } | null; }; type HeadlessGitHubStatus = { @@ -72,16 +103,23 @@ type HeadlessGitHubStatus = { storageScope: "app"; tokenType?: "classic" | "fine-grained" | "unknown"; repo: { owner: string; name: string } | null; + hasOrigin: boolean; userLogin: string | null; scopes: string[]; checkedAt: string | null; + repoAccessOk: boolean | null; + repoAccessError: string | null; + connected: boolean; }; -type HeadlessGitHubService = { - getStatus: () => Promise; +export type HeadlessGitHubService = { + getStatus: (opts?: { + forceRefresh?: boolean; + }) => Promise; detectRepo: () => Promise<{ owner: string; name: string } | null>; getRepoOrThrow: () => Promise<{ owner: string; name: string }>; getTokenOrThrow: () => string; + parseGitHubRepoFromRemoteUrl: typeof parseGitHubRepoFromRemoteUrl; apiRequest: (args: { method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; path: string; @@ -89,12 +127,50 @@ type HeadlessGitHubService = { body?: unknown; token?: string; }) => Promise<{ data: T; response: Response | null }>; - addIssueComment: (owner: string, name: string, number: number, body: string) => Promise; - setIssueLabels: (owner: string, name: string, number: number, labels: string[]) => Promise; - closeIssue: (owner: string, name: string, number: number, reason?: "completed" | "not_planned") => Promise; - reopenIssue: (owner: string, name: string, number: number) => Promise; - assignIssue: (owner: string, name: string, number: number, assignees: string[]) => Promise; - setIssueTitle: (owner: string, name: string, number: number, title: string) => Promise; + setToken: (token: string) => void; + clearToken: () => void; + listRepoLabels: (owner: string, name: string) => Promise; + listRepoCollaborators: (owner: string, name: string) => Promise; + publishCurrentProject: (args: { + name: string; + description?: string; + isPrivate: boolean; + }) => Promise<{ state: "pushed" | "remote_added"; htmlUrl: string }>; + addIssueComment: ( + owner: string, + name: string, + number: number, + body: string, + ) => Promise; + setIssueLabels: ( + owner: string, + name: string, + number: number, + labels: string[], + ) => Promise; + closeIssue: ( + owner: string, + name: string, + number: number, + reason?: "completed" | "not_planned", + ) => Promise; + reopenIssue: ( + owner: string, + name: string, + number: number, + ) => Promise; + assignIssue: ( + owner: string, + name: string, + number: number, + assignees: string[], + ) => Promise; + setIssueTitle: ( + owner: string, + name: string, + number: number, + title: string, + ) => Promise; }; type HeadlessAgentChatSession = { @@ -163,19 +239,39 @@ type HeadlessLinearServices = { prService: ReturnType; agentChatService: { listSessions: () => Promise>>; - getSessionSummary: (sessionId: string) => Promise | null>; - getChatTranscript: (args: { sessionId: string; limit?: number; maxChars?: number }) => Promise<{ + getSessionSummary: ( + sessionId: string, + ) => Promise | null>; + getChatTranscript: (args: { sessionId: string; - entries: Array<{ role: "user" | "assistant"; text: string; timestamp: string }>; + limit?: number; + maxChars?: number; + }) => Promise<{ + sessionId: string; + entries: Array<{ + role: "user" | "assistant"; + text: string; + timestamp: string; + }>; truncated: boolean; totalEntries: number; }>; - previewSessionToolNames: (args?: { sessionId?: string | null }) => Promise; - createSession: (args: { laneId: string; title?: string }) => Promise; - updateSession: (args: { sessionId: string; title?: string | null }) => Promise; + previewSessionToolNames: (args?: { + sessionId?: string | null; + }) => Promise; + createSession: (args: { + laneId: string; + title?: string; + }) => Promise; + updateSession: (args: { + sessionId: string; + title?: string | null; + }) => Promise; sendMessage: (args: { sessionId: string; text: string }) => Promise; interrupt: (args: { sessionId: string }) => Promise; - resumeSession: (args: { sessionId: string }) => Promise; + resumeSession: (args: { + sessionId: string; + }) => Promise; dispose: (args: { sessionId: string }) => Promise; ensureIdentitySession: (args: { identityKey: string; @@ -185,7 +281,9 @@ type HeadlessLinearServices = { reuseExisting?: boolean; permissionMode?: string; }) => Promise; - setComputerUseArtifactBrokerService: (svc: ComputerUseArtifactBrokerService) => void; + setComputerUseArtifactBrokerService: ( + svc: ComputerUseArtifactBrokerService, + ) => void; }; workerTaskSessionService: ReturnType; workerHeartbeatService: ReturnType; @@ -200,9 +298,16 @@ function envToken(...names: string[]): string | null { return null; } +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + function ghAuthToken(): string | null { try { - const result = spawnSync("gh", ["auth", "token"], { encoding: "utf8", timeout: 5_000 }); + const result = spawnSync("gh", ["auth", "token"], { + encoding: "utf8", + timeout: 5_000, + }); if (result.status !== 0) return null; const token = result.stdout?.trim() ?? ""; return token.length > 0 ? token : null; @@ -211,12 +316,44 @@ function ghAuthToken(): string | null { } } -function detectGitHubRepo(projectRoot: string): { owner: string; name: string } | null { +function readGitOrigin(projectRoot: string): string | null { const result = spawnSync("git", ["remote", "get-url", "origin"], { cwd: projectRoot, encoding: "utf8", }); const remote = typeof result.stdout === "string" ? result.stdout.trim() : ""; + return remote.length > 0 ? remote : null; +} + +function runGitHeadless( + projectRoot: string, + args: string[], + timeoutMs: number, +): { exitCode: number; stdout: string; stderr: string } { + try { + const result = spawnSync("git", args, { + cwd: projectRoot, + encoding: "utf8", + timeout: timeoutMs, + }); + return { + exitCode: result.status ?? 1, + stdout: typeof result.stdout === "string" ? result.stdout : "", + stderr: typeof result.stderr === "string" ? result.stderr : "", + }; + } catch (error) { + return { + exitCode: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + }; + } +} + +function parseGitHubRepoFromRemoteUrl( + remoteUrlRaw: string, +): { owner: string; name: string } | null { + const remote = remoteUrlRaw.trim(); if (!remote) return null; const ssh = remote.match(/^git@github\.com:(.+)$/i); if (ssh) { @@ -226,7 +363,10 @@ function detectGitHubRepo(projectRoot: string): { owner: string; name: string } try { const url = new URL(remote); if (!/github\.com$/i.test(url.hostname)) return null; - const parts = url.pathname.replace(/^\/+/, "").replace(/\.git$/i, "").split("/"); + const parts = url.pathname + .replace(/^\/+/, "") + .replace(/\.git$/i, "") + .split("/"); const owner = parts[0]?.trim() ?? ""; const name = parts[1]?.trim() ?? ""; return owner && name ? { owner, name } : null; @@ -235,28 +375,178 @@ function detectGitHubRepo(projectRoot: string): { owner: string; name: string } } } -function createHeadlessGitHubService(projectRoot: string, logger: Logger): HeadlessGitHubService { - let cachedStatus: Awaited> | null = null; +function detectGitHubRepo( + projectRoot: string, +): { owner: string; name: string } | null { + return parseGitHubRepoFromRemoteUrl(readGitOrigin(projectRoot) ?? ""); +} + +function parseNextGitHubLink(linkHeader: string | null): string | null { + if (!linkHeader) return null; + for (const part of linkHeader.split(",")) { + const match = part.match(/<([^>]+)>;\s*rel="([^"]+)"/); + if (match?.[2] === "next") return match[1] ?? null; + } + return null; +} + +const GITHUB_API_TIMEOUT_MS = 20_000; + +async function fetchGitHub(input: string | URL, init: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error( + "GitHub API request timed out. Check network access on this machine.", + ); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +export function createHeadlessGitHubService( + projectRoot: string, + logger: Logger, +): HeadlessGitHubService { + const credentialStore = new EncryptedFileCredentialStore(); + const tokenKey = "github.token.v1"; + let cachedStatus: Awaited< + ReturnType + > | null = null; let cachedAt = 0; + let tokenOverride: string | null = null; + let tokenDecryptionFailed = false; - const getToken = (): string => envToken("ADE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN") ?? ghAuthToken() ?? ""; + const readStoredToken = (): string | null => { + if (tokenOverride != null) return tokenOverride; + try { + const stored = credentialStore.getSync(tokenKey); + tokenDecryptionFailed = false; + if (stored?.trim()) return stored.trim(); + } catch { + tokenDecryptionFailed = true; + } + return null; + }; + const getToken = (): string => + readStoredToken() ?? + envToken("ADE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN") ?? + ghAuthToken() ?? + ""; const getTokenType = (token: string): HeadlessGitHubStatus["tokenType"] => { if (token.startsWith("github_pat_")) return "fine-grained"; if (token.startsWith("ghp_")) return "classic"; return "unknown"; }; + const readApiMessage = (payload: unknown, fallback: string): string => { + if ( + payload && + typeof payload === "object" && + "message" in payload && + typeof (payload as { message?: unknown }).message === "string" + ) { + return String((payload as { message: string }).message); + } + return fallback; + }; + const computeConnected = (args: { + tokenStored: boolean; + userLogin: string | null; + tokenType: HeadlessGitHubStatus["tokenType"]; + scopes: string[]; + repo: { owner: string; name: string } | null; + repoAccessOk: boolean | null; + }): boolean => { + if (!args.tokenStored || !args.userLogin) return false; + if (args.tokenType === "fine-grained") { + return args.repo ? args.repoAccessOk === true : true; + } + if (args.tokenType === "classic") { + return getGitHubTokenAccessState(args.scopes).hasRequiredAccess; + } + return true; + }; + const validateToken = async ( + token: string, + ): Promise<{ + userLogin: string | null; + scopes: string[]; + tokenType: HeadlessGitHubStatus["tokenType"]; + }> => { + const response = await fetchGitHub("https://api.github.com/user", { + method: "GET", + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "user-agent": "ade-cli", + }, + }); + const scopes = parseGitHubScopeHeaders(response.headers); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error( + readApiMessage( + payload, + `GitHub token validation failed (HTTP ${response.status})`, + ), + ); + } + const userLogin = + payload && + typeof payload === "object" && + typeof (payload as { login?: unknown }).login === "string" + ? (payload as { login: string }).login + : null; + return { userLogin, scopes, tokenType: getTokenType(token) }; + }; + const probeRepoAccess = async ( + token: string, + repo: { owner: string; name: string }, + ): Promise<{ ok: boolean; error: string | null }> => { + try { + const response = await fetchGitHub( + `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`, + { + method: "GET", + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "user-agent": "ade-cli", + }, + }, + ); + if (response.ok) return { ok: true, error: null }; + const payload = await response.json().catch(() => ({})); + return { + ok: false, + error: `${response.status}: ${readApiMessage(payload, `HTTP ${response.status}`)}`, + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }; const apiRequest: HeadlessGitHubService["apiRequest"] = async (args) => { const token = (args.token ?? getToken()).trim(); if (!token) { - throw new Error("GitHub token missing. Set ADE_GITHUB_TOKEN or GITHUB_TOKEN, or run `gh auth login` so `gh auth token` returns a token."); + throw new Error( + "GitHub token missing. Set ADE_GITHUB_TOKEN or GITHUB_TOKEN, or run `gh auth login` so `gh auth token` returns a token.", + ); } const url = new URL(`https://api.github.com${args.path}`); for (const [key, value] of Object.entries(args.query ?? {})) { if (value == null) continue; url.searchParams.set(key, String(value)); } - const response = await fetch(url, { + const response = await fetchGitHub(url, { method: args.method, headers: { accept: "application/vnd.github+json", @@ -275,7 +565,10 @@ function createHeadlessGitHubService(projectRoot: string, logger: Logger): Headl } if (!response.ok) { const message = - typeof data === "object" && data && "message" in data && typeof (data as { message?: unknown }).message === "string" + typeof data === "object" && + data && + "message" in data && + typeof (data as { message?: unknown }).message === "string" ? String((data as { message?: unknown }).message) : `GitHub API request failed (HTTP ${response.status})`; throw new Error(message); @@ -283,116 +576,589 @@ function createHeadlessGitHubService(projectRoot: string, logger: Logger): Headl return { data: data as never, response }; }; + const apiRequestAllPages = async (args: { + path: string; + query?: Record; + token?: string; + }): Promise => { + const first = await apiRequest({ method: "GET", ...args }); + const out = Array.isArray(first.data) ? [...first.data] : []; + let nextUrl = parseNextGitHubLink( + first.response?.headers.get("link") ?? null, + ); + while (nextUrl) { + const url = new URL(nextUrl); + const next = await apiRequest({ + method: "GET", + path: `${url.pathname}${url.search}`, + token: args.token, + }); + if (Array.isArray(next.data)) out.push(...next.data); + nextUrl = parseNextGitHubLink(next.response?.headers.get("link") ?? null); + } + return out; + }; + + const createRepository = async (args: { + name: string; + description?: string; + isPrivate: boolean; + }): Promise<{ + cloneUrl: string; + sshUrl: string; + htmlUrl: string; + defaultBranch: string; + }> => { + const body: Record = { + name: args.name, + private: args.isPrivate, + auto_init: false, + }; + if (args.description != null && args.description.trim().length > 0) { + body.description = args.description.trim(); + } + const { data } = await apiRequest>({ + method: "POST", + path: "/user/repos", + body, + }); + return { + cloneUrl: asString(data.clone_url), + sshUrl: asString(data.ssh_url), + htmlUrl: asString(data.html_url), + defaultBranch: asString(data.default_branch) || "main", + }; + }; + + const getRepository = async ( + owner: string, + name: string, + ): Promise<{ + cloneUrl: string; + sshUrl: string; + htmlUrl: string; + defaultBranch: string; + size: number; + }> => { + const { data } = await apiRequest>({ + method: "GET", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, + }); + return { + cloneUrl: asString(data.clone_url), + sshUrl: asString(data.ssh_url), + htmlUrl: asString(data.html_url), + defaultBranch: asString(data.default_branch) || "main", + size: typeof data.size === "number" ? data.size : 0, + }; + }; + return { - async getStatus() { + async getStatus(opts: { forceRefresh?: boolean } = {}) { + if (opts.forceRefresh) { + cachedStatus = null; + cachedAt = 0; + } const now = Date.now(); - if (cachedStatus && now - cachedAt < 30_000) return { ...cachedStatus, repo: detectGitHubRepo(projectRoot) }; const repo = detectGitHubRepo(projectRoot); - const tokenStored = Boolean(getToken()); - const status: HeadlessGitHubStatus = { - tokenStored, - tokenDecryptionFailed: false, - storageScope: "app", - tokenType: tokenStored ? getTokenType(getToken()) : "unknown", - repo, - userLogin: null, - scopes: [], - checkedAt: tokenStored ? new Date(now).toISOString() : null, - }; - cachedStatus = status; - cachedAt = now; - return status; + const hasOrigin = Boolean(readGitOrigin(projectRoot)); + if (cachedStatus && now - cachedAt < 30_000) { + const repoChanged = + (cachedStatus.repo?.owner ?? null) !== (repo?.owner ?? null) || + (cachedStatus.repo?.name ?? null) !== (repo?.name ?? null); + const repoAccessOk = repoChanged ? null : cachedStatus.repoAccessOk; + const repoAccessError = repoChanged + ? null + : cachedStatus.repoAccessError; + return { + ...cachedStatus, + repo, + hasOrigin, + repoAccessOk, + repoAccessError, + connected: computeConnected({ + tokenStored: cachedStatus.tokenStored, + userLogin: cachedStatus.userLogin, + tokenType: cachedStatus.tokenType, + scopes: cachedStatus.scopes, + repo, + repoAccessOk, + }), + }; + } + const token = getToken(); + if (!token) { + const status: HeadlessGitHubStatus = { + tokenStored: false, + tokenDecryptionFailed, + storageScope: "app", + tokenType: "unknown", + repo, + hasOrigin, + userLogin: null, + scopes: [], + checkedAt: null, + repoAccessOk: null, + repoAccessError: null, + connected: false, + }; + cachedStatus = status; + cachedAt = now; + return status; + } + + try { + const validated = await validateToken(token); + let repoAccessOk: boolean | null = null; + let repoAccessError: string | null = null; + if (repo) { + const probe = await probeRepoAccess(token, repo); + repoAccessOk = probe.ok; + repoAccessError = probe.error; + if (!probe.ok) { + logger.warn("github.repo_probe_failed", { + repo: `${repo.owner}/${repo.name}`, + tokenType: validated.tokenType, + error: probe.error, + }); + } + } + const status: HeadlessGitHubStatus = { + tokenStored: true, + tokenDecryptionFailed: false, + storageScope: "app", + tokenType: validated.tokenType, + repo, + hasOrigin, + userLogin: validated.userLogin, + scopes: validated.scopes, + checkedAt: new Date(now).toISOString(), + repoAccessOk, + repoAccessError, + connected: computeConnected({ + tokenStored: true, + userLogin: validated.userLogin, + tokenType: validated.tokenType, + scopes: validated.scopes, + repo, + repoAccessOk, + }), + }; + cachedStatus = status; + cachedAt = now; + return status; + } catch (error) { + logger.warn("github.token_validation_failed", { + error: error instanceof Error ? error.message : String(error), + }); + const status: HeadlessGitHubStatus = { + tokenStored: true, + tokenDecryptionFailed: false, + storageScope: "app", + tokenType: getTokenType(token), + repo, + hasOrigin, + userLogin: null, + scopes: [], + checkedAt: new Date(now).toISOString(), + repoAccessOk: null, + repoAccessError: null, + connected: false, + }; + cachedStatus = status; + cachedAt = now; + return status; + } }, async detectRepo() { return detectGitHubRepo(projectRoot); }, async getRepoOrThrow() { const repo = detectGitHubRepo(projectRoot); - if (!repo) throw new Error("Unable to detect GitHub repo from git remote 'origin'."); + if (!repo) + throw new Error( + "Unable to detect GitHub repo from git remote 'origin'.", + ); return repo; }, getTokenOrThrow() { const token = getToken(); - if (!token) throw new Error("GitHub token missing. Set ADE_GITHUB_TOKEN or GITHUB_TOKEN, or run `gh auth login`."); + if (!token) + throw new Error( + "GitHub token missing. Set ADE_GITHUB_TOKEN or GITHUB_TOKEN, or run `gh auth login`.", + ); return token; }, + parseGitHubRepoFromRemoteUrl, + setToken(nextToken: string) { + tokenOverride = nextToken.trim(); + credentialStore.setSync(tokenKey, tokenOverride); + tokenDecryptionFailed = false; + cachedStatus = null; + cachedAt = 0; + }, + clearToken() { + tokenOverride = ""; + credentialStore.deleteSync(tokenKey); + tokenDecryptionFailed = false; + cachedStatus = null; + cachedAt = 0; + }, apiRequest, + async listRepoLabels(owner, name) { + return apiRequestAllPages({ + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/labels`, + query: { per_page: 100 }, + }); + }, + async listRepoCollaborators(owner, name) { + return apiRequestAllPages({ + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/collaborators`, + query: { per_page: 100 }, + }); + }, + async publishCurrentProject(args) { + const token = getToken(); + if (!token) { + const err = new Error( + "GitHub is not connected. Add a token in Settings.", + ) as Error & { code?: string }; + err.code = "github_not_connected"; + throw err; + } + + const existingRemote = runGitHeadless( + projectRoot, + ["remote", "get-url", "origin"], + 8_000, + ); + if ( + existingRemote.exitCode === 0 && + existingRemote.stdout.trim().length > 0 + ) { + const err = new Error( + "This project already has a GitHub remote named 'origin'.", + ) as Error & { code?: string }; + err.code = "remote_already_exists"; + throw err; + } + + let created: { + cloneUrl: string; + sshUrl: string; + htmlUrl: string; + defaultBranch: string; + }; + try { + created = await createRepository(args); + } catch (createErr) { + const message = + createErr instanceof Error ? createErr.message : String(createErr); + const isNameTaken = /already exists/i.test(message); + if (!isNameTaken) throw createErr; + + const validated = await validateToken(token).catch(() => ({ + userLogin: null as string | null, + })); + const owner = validated.userLogin; + if (!owner) throw createErr; + + const existing = await getRepository(owner, args.name); + if (existing.size > 0) { + const taken = new Error( + `A GitHub repo named '${args.name}' already exists on your account and contains commits. Pick a different name.`, + ) as Error & { code?: string }; + taken.code = "repo_name_taken"; + throw taken; + } + created = { + cloneUrl: existing.cloneUrl, + sshUrl: existing.sshUrl, + htmlUrl: existing.htmlUrl, + defaultBranch: existing.defaultBranch, + }; + } + + const cleanupLocalOrigin = (): void => { + runGitHeadless(projectRoot, ["remote", "remove", "origin"], 8_000); + }; + + const remoteAddRes = runGitHeadless( + projectRoot, + ["remote", "add", "origin", created.cloneUrl], + 8_000, + ); + if (remoteAddRes.exitCode !== 0) { + cleanupLocalOrigin(); + throw new Error( + `Failed to add origin remote: ${remoteAddRes.stderr.trim() || `exit ${remoteAddRes.exitCode}`}`, + ); + } + + const headRes = runGitHeadless( + projectRoot, + ["rev-parse", "--verify", "HEAD"], + 5_000, + ); + let resultState: "pushed" | "remote_added"; + if (headRes.exitCode === 0) { + const pushRes = runGitHeadless( + projectRoot, + ["push", "-u", "origin", "HEAD"], + 5 * 60_000, + ); + if (pushRes.exitCode !== 0) { + cleanupLocalOrigin(); + throw new Error( + `Failed to push to origin: ${pushRes.stderr.trim() || `exit ${pushRes.exitCode}`}`, + ); + } + resultState = "pushed"; + } else { + resultState = "remote_added"; + } + + cachedStatus = null; + cachedAt = 0; + + return { state: resultState, htmlUrl: created.htmlUrl }; + }, async addIssueComment(owner, name, number, body) { - return (await apiRequest({ - method: "POST", - path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/comments`, - body: { body }, - })).data; + return ( + await apiRequest({ + method: "POST", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/comments`, + body: { body }, + }) + ).data; }, async setIssueLabels(owner, name, number, labels) { - return (await apiRequest({ - method: "PUT", - path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/labels`, - body: { labels }, - })).data; + return ( + await apiRequest({ + method: "PUT", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/labels`, + body: { labels }, + }) + ).data; }, async closeIssue(owner, name, number, reason) { - return (await apiRequest({ - method: "PATCH", - path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, - body: { state: "closed", ...(reason ? { state_reason: reason } : {}) }, - })).data; + return ( + await apiRequest({ + method: "PATCH", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, + body: { + state: "closed", + ...(reason ? { state_reason: reason } : {}), + }, + }) + ).data; }, async reopenIssue(owner, name, number) { - return (await apiRequest({ - method: "PATCH", - path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, - body: { state: "open" }, - })).data; + return ( + await apiRequest({ + method: "PATCH", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, + body: { state: "open" }, + }) + ).data; }, async assignIssue(owner, name, number, assignees) { - return (await apiRequest({ - method: "POST", - path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/assignees`, - body: { assignees }, - })).data; + return ( + await apiRequest({ + method: "POST", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/assignees`, + body: { assignees }, + }) + ).data; }, async setIssueTitle(owner, name, number, title) { - return (await apiRequest({ - method: "PATCH", - path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, - body: { title }, - })).data; + return ( + await apiRequest({ + method: "PATCH", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, + body: { title }, + }) + ).data; }, }; } function createHeadlessLinearCredentialService(): HeadlessLinearCredentialService { - let token = envToken("ADE_LINEAR_API", "LINEAR_API_KEY", "ADE_LINEAR_TOKEN", "LINEAR_TOKEN") ?? ""; + const credentialStore = new EncryptedFileCredentialStore(); + const tokenKey = "linear.token.v1"; + const authModeKey = "linear.authMode.v1"; + const tokenExpiresAtKey = "linear.tokenExpiresAt.v1"; + const refreshTokenKey = "linear.refreshToken.v1"; + const oauthClientKey = "linear.oauthClient.v1"; + let tokenOverride: string | null = null; + let tokenDecryptionFailed = false; + + const readCredential = (key: string): string | null => { + try { + const stored = credentialStore.getSync(key); + tokenDecryptionFailed = false; + return stored?.trim() || null; + } catch { + tokenDecryptionFailed = true; + return null; + } + }; + + const writeCredential = ( + key: string, + value: string | null | undefined, + ): void => { + if (value?.trim()) { + credentialStore.setSync(key, value.trim()); + } else { + credentialStore.deleteSync(key); + } + tokenDecryptionFailed = false; + }; + + const readToken = (): { + token: string; + source: "stored" | "env" | "override" | null; + } => { + if (tokenOverride != null) { + return { + token: tokenOverride, + source: tokenOverride.trim().length > 0 ? "override" : null, + }; + } + const stored = readCredential(tokenKey); + if (stored) return { token: stored, source: "stored" }; + const envValue = + envToken( + "ADE_LINEAR_API", + "LINEAR_API_KEY", + "ADE_LINEAR_TOKEN", + "LINEAR_TOKEN", + ) ?? ""; + return { + token: envValue, + source: envValue.trim().length > 0 ? "env" : null, + }; + }; + + const readOAuthClientCredentials = (): { + clientId: string; + clientSecret: string | null; + } | null => { + const raw = readCredential(oauthClientKey); + if (!raw) { + return BUNDLED_LINEAR_OAUTH_CLIENT_ID + ? { clientId: BUNDLED_LINEAR_OAUTH_CLIENT_ID, clientSecret: null } + : null; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + return null; + const record = parsed as Record; + const clientId = + typeof record.clientId === "string" ? record.clientId.trim() : ""; + if (!clientId) return null; + return { + clientId, + clientSecret: + typeof record.clientSecret === "string" && + record.clientSecret.trim().length > 0 + ? record.clientSecret.trim() + : null, + }; + } catch { + return null; + } + }; + return { getStatus() { + const { token, source } = readToken(); + const authMode = + source === "stored" || source === "override" + ? readCredential(authModeKey) === "oauth" + ? "oauth" + : "manual" + : token.trim().length > 0 + ? "manual" + : null; return { tokenStored: token.trim().length > 0, - tokenDecryptionFailed: false, + tokenDecryptionFailed, storageScope: "app", repo: null, userLogin: null, scopes: [], checkedAt: token.trim().length > 0 ? new Date().toISOString() : null, - authMode: token.trim().length > 0 ? "manual" : null, + authMode, + tokenExpiresAt: readCredential(tokenExpiresAtKey), + refreshTokenStored: Boolean(readCredential(refreshTokenKey)), + oauthConfigured: readOAuthClientCredentials() != null, }; }, getTokenOrThrow() { + const { token } = readToken(); if (!token.trim()) { - throw new Error("Linear token missing. Set ADE_LINEAR_API, LINEAR_API_KEY, ADE_LINEAR_TOKEN, or LINEAR_TOKEN for headless mode."); + throw new Error( + "Linear token missing. Set ADE_LINEAR_API, LINEAR_API_KEY, ADE_LINEAR_TOKEN, or LINEAR_TOKEN for headless mode.", + ); } return token.trim(); }, setToken(nextToken: string) { - token = nextToken.trim(); + tokenOverride = nextToken.trim(); + writeCredential(tokenKey, tokenOverride); + writeCredential(authModeKey, "manual"); + writeCredential(refreshTokenKey, null); + writeCredential(tokenExpiresAtKey, null); + }, + setOAuthToken(args: { + accessToken: string; + refreshToken?: string | null; + expiresAt?: string | null; + }) { + tokenOverride = args.accessToken.trim(); + writeCredential(tokenKey, tokenOverride); + writeCredential(authModeKey, "oauth"); + writeCredential(refreshTokenKey, args.refreshToken); + writeCredential(tokenExpiresAtKey, args.expiresAt); }, clearToken() { - token = ""; + tokenOverride = ""; + writeCredential(tokenKey, null); + writeCredential(authModeKey, null); + writeCredential(refreshTokenKey, null); + writeCredential(tokenExpiresAtKey, null); + }, + setOAuthClientCredentials(args: { + clientId: string; + clientSecret?: string | null; + }) { + const clientId = args.clientId.trim(); + if (!clientId.length) { + throw new Error("A Linear OAuth client ID is required."); + } + writeCredential( + oauthClientKey, + JSON.stringify({ + clientId, + clientSecret: args.clientSecret?.trim() || null, + }), + ); + }, + clearOAuthClientCredentials() { + writeCredential(oauthClientKey, null); + }, + getOAuthClientCredentials() { + return readOAuthClientCredentials(); }, }; } -function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServices["agentChatService"] { +function createHeadlessAgentChatService( + projectRoot: string, +): HeadlessLinearServices["agentChatService"] { const sessions = new Map(); const identitySessionIds = new Map(); const transcripts = new Map(); @@ -416,7 +1182,9 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ ? `Headless ADE session for ${identityKey}. Automatic agent execution is not available in this runtime.` : "Headless ADE chat session. Automatic agent execution is not available in this runtime."; - const resolveHeadlessModel = (modelId?: string | null): { modelId: string; model: string } => { + const resolveHeadlessModel = ( + modelId?: string | null, + ): { modelId: string; model: string } => { const requested = modelId?.trim() || HEADLESS_MODEL_ID; const descriptor = getModelById(requested) ?? resolveModelAlias(requested); if (descriptor) { @@ -464,9 +1232,12 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ status: args.status ?? existing.status, endedAt: args.endedAt === undefined ? existing.endedAt : args.endedAt, identityKey: args.identityKey ?? existing.identityKey, - reasoningEffort: args.reasoningEffort ?? existing.reasoningEffort ?? null, + reasoningEffort: + args.reasoningEffort ?? existing.reasoningEffort ?? null, permissionMode: args.permissionMode ?? existing.permissionMode, - summary: existing.summary ?? defaultSummary(args.identityKey ?? existing.identityKey), + summary: + existing.summary ?? + defaultSummary(args.identityKey ?? existing.identityKey), lastActivityAt: now, }; sessions.set(sessionId, updated); @@ -492,7 +1263,9 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ lastOutputPreview: null, summary: defaultSummary(args.identityKey), ...(args.identityKey ? { identityKey: args.identityKey } : {}), - ...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}), + ...(args.reasoningEffort !== undefined + ? { reasoningEffort: args.reasoningEffort } + : {}), ...(args.permissionMode ? { permissionMode: args.permissionMode } : {}), }; sessions.set(sessionId, created); @@ -505,14 +1278,28 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ return { async listSessions() { - return Array.from(sessions.values()).sort((left, right) => Date.parse(right.lastActivityAt) - Date.parse(left.lastActivityAt)); + return Array.from(sessions.values()).sort( + (left, right) => + Date.parse(right.lastActivityAt) - Date.parse(left.lastActivityAt), + ); }, async getSessionSummary(sessionId: string) { return sessions.get(sessionId.trim()) ?? null; }, - async getChatTranscript({ sessionId, limit, maxChars }: { sessionId: string; limit?: number; maxChars?: number }) { + async getChatTranscript({ + sessionId, + limit, + maxChars, + }: { + sessionId: string; + limit?: number; + maxChars?: number; + }) { const safeLimit = Math.max(1, Math.min(500, Math.floor(limit ?? 100))); - const safeMaxChars = Math.max(32, Math.min(20_000, Math.floor(maxChars ?? 4_000))); + const safeMaxChars = Math.max( + 32, + Math.min(20_000, Math.floor(maxChars ?? 4_000)), + ); const source = ensureTranscript(sessionId.trim()); const entries = source.slice(-safeLimit).map((entry) => ({ ...entry, @@ -521,7 +1308,9 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ return { sessionId, entries, - truncated: source.length > entries.length || entries.some((entry) => entry.text.length >= safeMaxChars), + truncated: + source.length > entries.length || + entries.some((entry) => entry.text.length >= safeMaxChars), totalEntries: source.length, }; }, @@ -532,8 +1321,14 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ return ensureSession({ laneId: args.laneId, title: args.title }); }, async updateSession(args: { sessionId: string; title?: string | null }) { - const existing = sessions.get(args.sessionId) ?? ensureSession({ sessionId: args.sessionId, laneId: "lane-headless" }); - return ensureSession({ sessionId: existing.id, laneId: existing.laneId, title: args.title ?? existing.title }); + const existing = + sessions.get(args.sessionId) ?? + ensureSession({ sessionId: args.sessionId, laneId: "lane-headless" }); + return ensureSession({ + sessionId: existing.id, + laneId: existing.laneId, + title: args.title ?? existing.title, + }); }, async sendMessage(args: { sessionId: string; text: string }) { const sessionId = args.sessionId.trim(); @@ -544,12 +1339,19 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ text: args.text, timestamp: new Date().toISOString(), }); - sessions.set(sessionId, { ...existing, lastActivityAt: new Date().toISOString() }); + sessions.set(sessionId, { + ...existing, + lastActivityAt: new Date().toISOString(), + }); } }, async interrupt(args: { sessionId: string }) { const existing = sessions.get(args.sessionId); - if (existing) sessions.set(args.sessionId, { ...existing, lastActivityAt: new Date().toISOString() }); + if (existing) + sessions.set(args.sessionId, { + ...existing, + lastActivityAt: new Date().toISOString(), + }); }, async resumeSession(args: { sessionId: string }) { return ensureSession({ @@ -563,7 +1365,10 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ const existing = sessions.get(args.sessionId); sessions.delete(args.sessionId); transcripts.delete(args.sessionId); - if (existing?.identityKey && identitySessionIds.get(existing.identityKey) === args.sessionId) { + if ( + existing?.identityKey && + identitySessionIds.get(existing.identityKey) === args.sessionId + ) { identitySessionIds.delete(existing.identityKey); } }, @@ -607,7 +1412,9 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ }; } -function createHeadlessWorkerHeartbeatService(): ReturnType { +function createHeadlessWorkerHeartbeatService(): ReturnType< + typeof createWorkerHeartbeatService +> { const runs: Array<{ id: string; agentId: string; @@ -633,7 +1440,13 @@ function createHeadlessWorkerHeartbeatService(): ReturnType }) { + async triggerWakeup(args: { + agentId: string; + reason?: string; + taskKey?: string | null; + issueKey?: string | null; + context?: Record; + }) { const runId = `wake-${randomUUID()}`; const now = new Date().toISOString(); runs.unshift({ @@ -644,7 +1457,8 @@ function createHeadlessWorkerHeartbeatService(): ReturnType; } -export function createHeadlessLinearServices(args: HeadlessLinearDeps): HeadlessLinearServices { +export function createHeadlessLinearServices( + args: HeadlessLinearDeps, +): HeadlessLinearServices { const automationSecretService = createAutomationSecretServiceImpl({ adeDir: args.adeDir, logger: args.logger, }); - const linearCredentialService = createHeadlessLinearCredentialService() as any; - const githubService = createHeadlessGitHubService(args.projectRoot, args.logger); + const linearCredentialService = + createHeadlessLinearCredentialService() as any; + const githubService = createHeadlessGitHubService( + args.projectRoot, + args.logger, + ); const linearClient = createLinearClientImpl({ credentials: linearCredentialService as any, logger: args.logger, }); const issueTracker = createLinearIssueTrackerImpl({ client: linearClient }); - const templateService = createLinearTemplateServiceImpl({ adeDir: args.adeDir }); - const workflowFileService = createLinearWorkflowFileServiceImpl({ projectRoot: args.projectRoot }); + const templateService = createLinearTemplateServiceImpl({ + adeDir: args.adeDir, + }); + const workflowFileService = createLinearWorkflowFileServiceImpl({ + projectRoot: args.projectRoot, + }); const flowPolicyService = createFlowPolicyServiceImpl({ db: args.db, projectId: args.projectId, @@ -709,7 +1533,9 @@ export function createHeadlessLinearServices(args: HeadlessLinearDeps): Headless } as any; const ptyService = { create: async () => { - throw new Error("PTY-backed run commands are unavailable in headless Linear services."); + throw new Error( + "PTY-backed run commands are unavailable in headless Linear services.", + ); }, dispose: () => {}, onData: () => () => {}, @@ -743,8 +1569,13 @@ export function createHeadlessLinearServices(args: HeadlessLinearDeps): Headless }); const workerHeartbeatService = createHeadlessWorkerHeartbeatService(); const agentChatService = createHeadlessAgentChatService(args.projectRoot); - if (typeof (prService as { setAgentChatService?: (svc: unknown) => void }).setAgentChatService === "function") { - (prService as { setAgentChatService: (svc: unknown) => void }).setAgentChatService(agentChatService as never); + if ( + typeof (prService as { setAgentChatService?: (svc: unknown) => void }) + .setAgentChatService === "function" + ) { + ( + prService as { setAgentChatService: (svc: unknown) => void } + ).setAgentChatService(agentChatService as never); } const closeoutService = createLinearCloseoutServiceImpl({ issueTracker, @@ -784,7 +1615,8 @@ export function createHeadlessLinearServices(args: HeadlessLinearDeps): Headless hasCredentials: () => linearCredentialService.getStatus().tokenStored, }); const handleIngressEvent = async (event: { issueId?: string | null }) => { - const issueId = typeof event.issueId === "string" ? event.issueId.trim() : ""; + const issueId = + typeof event.issueId === "string" ? event.issueId.trim() : ""; if (!issueId) return; await syncService.processIssueUpdate(issueId); }; @@ -793,7 +1625,9 @@ export function createHeadlessLinearServices(args: HeadlessLinearDeps): Headless logger: args.logger, projectId: args.projectId, linearClient, - secretService: automationSecretService as ReturnType, + secretService: automationSecretService as ReturnType< + typeof createAutomationSecretService + >, onEvent: handleIngressEvent, }); @@ -819,31 +1653,18 @@ export function createHeadlessLinearServices(args: HeadlessLinearDeps): Headless workerTaskSessionService, workerHeartbeatService, dispose: () => { - try { - syncService.dispose(); - } catch { - // ignore - } - try { - ingressService.dispose(); - } catch { - // ignore - } - try { - fileService.dispose(); - } catch { - // ignore - } - try { - processService.disposeAll(); - } catch { - // ignore - } - try { - workerHeartbeatService.dispose(); - } catch { - // ignore - } + const swallow = (fn: () => void) => { + try { + fn(); + } catch { + /* ignore */ + } + }; + swallow(() => syncService.dispose()); + swallow(() => ingressService.dispose()); + swallow(() => fileService.dispose()); + swallow(() => processService.disposeAll()); + swallow(() => workerHeartbeatService.dispose()); }, }; } diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts new file mode 100644 index 000000000..965083e09 --- /dev/null +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -0,0 +1,427 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createEventBuffer } from "./eventBuffer"; +import { createMultiProjectRpcRequestHandler } from "./multiProjectRpcServer"; +import { ProjectRegistry } from "./services/projects/projectRegistry"; +import { ProjectScopeRegistry } from "./services/projects/projectScope"; + +function createRegistry() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-multi-project-rpc-")); + const projectRoot = path.join(root, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + const registry = new ProjectRegistry({ + adeDir: path.join(root, "home"), + projectsPath: path.join(root, "home", "projects.json"), + secretsDir: path.join(root, "home", "secrets"), + sockDir: path.join(root, "home", "sock"), + socketPath: path.join(root, "home", "sock", "ade.sock"), + binDir: path.join(root, "home", "bin"), + runtimeDir: path.join(root, "home", "runtime"), + }); + return { root, projectRoot, registry }; +} + +function makeRuntime(label: string) { + return { + operationService: { + start: vi.fn(() => ({ operationId: `${label}-operation`, startedAt: "2026-05-10T00:00:00.000Z" })), + finish: vi.fn(), + }, + laneService: { + list: vi.fn(async () => [{ id: `${label}-lane`, name: label }]), + }, + syncService: { + getStatus: vi.fn(async () => ({ role: "brain", label })), + }, + eventBuffer: createEventBuffer(), + dispose: vi.fn(), + }; +} + +describe("multi-project RPC server", () => { + it("exposes runtime-scoped project registry methods", async () => { + const { projectRoot, registry } = createRegistry(); + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + }); + + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: { protocolVersion: "test" }, + }); + + const added = await handler({ + jsonrpc: "2.0", + id: 2, + method: "projects.add", + params: { rootPath: projectRoot }, + }); + expect(added).toMatchObject({ + rootPath: projectRoot, + displayName: "project", + gitOriginUrl: null, + }); + + const listed = await handler({ + jsonrpc: "2.0", + id: 3, + method: "projects.list", + params: {}, + }); + expect(listed).toEqual([added]); + + const projectId = (added as { projectId: string }).projectId; + const touched = await handler({ + jsonrpc: "2.0", + id: 4, + method: "projects.touch", + params: { projectId }, + }); + expect((touched as { projectId: string }).projectId).toBe(projectId); + + await handler({ + jsonrpc: "2.0", + id: 5, + method: "projects.remove", + params: { projectId }, + }); + expect(await handler({ jsonrpc: "2.0", id: 6, method: "projects.list", params: {} })).toEqual([]); + + handler.dispose(); + }); + + it("requires projectId for project-scoped methods", async () => { + const { registry } = createRegistry(); + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + }); + + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: {}, + }); + + await expect(handler({ + jsonrpc: "2.0", + id: 2, + method: "ade/actions/list", + params: {}, + })).rejects.toThrow("requires params.projectId"); + + handler.dispose(); + }); + + it("passes runtime capability flags into project scopes", async () => { + const { projectRoot, registry } = createRegistry(); + const added = registry.add(projectRoot); + const runtime = { + capabilities: { memory: false }, + dispose: vi.fn(), + }; + const scopeRegistry = { + get: vi.fn(async () => ({ + registryProjectId: added.projectId, + record: added, + runtime, + dispose: vi.fn(), + })), + dispose: vi.fn(), + disposeAll: vi.fn(), + } as unknown as ProjectScopeRegistry; + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + scopeRegistry, + }); + + const init = await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: {}, + }); + expect(init).toMatchObject({ + runtimeInfo: { multiProject: true }, + capabilities: { projects: true }, + }); + + const actions = await handler({ + jsonrpc: "2.0", + id: 2, + method: "ade/actions/list", + params: { projectId: added.projectId }, + }) as { actions: Array<{ name: string }> }; + expect(actions.actions.some((entry) => entry.name.startsWith("memory_"))).toBe(false); + expect(scopeRegistry.get).toHaveBeenCalledWith(added.projectId); + + handler.dispose(); + }); + + it("exposes runtime sync PIN methods through the selected sync host scope", async () => { + const { projectRoot, registry } = createRegistry(); + const added = registry.add(projectRoot); + const syncService = { + getPin: vi.fn(() => "123456"), + setPin: vi.fn(async (pin: string) => ({ role: "brain", pairingPin: pin })), + generatePin: vi.fn(async () => ({ role: "brain", pairingPin: "111222" })), + clearPin: vi.fn(async () => ({ role: "brain", pairingPin: null })), + getStatus: vi.fn(async () => ({ role: "brain" })), + refreshDiscovery: vi.fn(), + listDevices: vi.fn(), + updateLocalDevice: vi.fn(async (args: { name?: string }) => ({ deviceId: "machine-1", name: args.name })), + forgetDevice: vi.fn(async (deviceId: string) => ({ role: "brain", forgotten: deviceId })), + setActiveLanePresence: vi.fn(async (_laneIds: string[]) => {}), + }; + const scopeRegistry = { + get: vi.fn(), + ensureSyncHost: vi.fn(async () => ({ + registryProjectId: added.projectId, + record: added, + runtime: { syncService }, + dispose: vi.fn(), + })), + dispose: vi.fn(), + disposeAll: vi.fn(), + } as unknown as ProjectScopeRegistry; + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + scopeRegistry, + }); + + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: {}, + }); + + expect(await handler({ + jsonrpc: "2.0", + id: 2, + method: "sync.getPin", + params: { projectId: added.projectId }, + })).toEqual({ pin: "123456" }); + + expect(await handler({ + jsonrpc: "2.0", + id: 3, + method: "sync.setPin", + params: { projectId: added.projectId, pin: "654321" }, + })).toEqual({ role: "brain", pairingPin: "654321" }); + + expect(await handler({ + jsonrpc: "2.0", + id: 4, + method: "sync.generatePin", + params: { projectId: added.projectId }, + })).toEqual({ role: "brain", pairingPin: "111222" }); + + await handler({ + jsonrpc: "2.0", + id: 5, + method: "sync.clearPin", + params: { projectId: added.projectId }, + }); + + expect(await handler({ + jsonrpc: "2.0", + id: 6, + method: "sync.updateLocalDevice", + params: { projectId: added.projectId, name: "Mac Studio" }, + })).toEqual({ deviceId: "machine-1", name: "Mac Studio" }); + + expect(await handler({ + jsonrpc: "2.0", + id: 7, + method: "sync.forgetDevice", + params: { projectId: added.projectId, deviceId: "phone-1" }, + })).toEqual({ role: "brain", forgotten: "phone-1" }); + + expect(await handler({ + jsonrpc: "2.0", + id: 8, + method: "sync.setActiveLanePresence", + params: { projectId: added.projectId, laneIds: ["lane-1", 42, "lane-2"] }, + })).toBeNull(); + + expect(scopeRegistry.ensureSyncHost).toHaveBeenCalledWith(added.projectId); + expect(syncService.setPin).toHaveBeenCalledWith("654321"); + expect(syncService.generatePin).toHaveBeenCalledTimes(1); + expect(syncService.clearPin).toHaveBeenCalledTimes(1); + expect(syncService.updateLocalDevice).toHaveBeenCalledWith({ name: "Mac Studio" }); + expect(syncService.forgetDevice).toHaveBeenCalledWith("phone-1"); + expect(syncService.setActiveLanePresence).toHaveBeenCalledWith(["lane-1", "lane-2"]); + + handler.dispose(); + }); + + it("drops cached project handlers when the backing project scope is disposed", async () => { + const { projectRoot, registry } = createRegistry(); + const added = registry.add(projectRoot); + const firstRuntime = makeRuntime("first"); + const secondRuntime = makeRuntime("second"); + let disposeListener: ((projectId: string) => void) | null = null; + let getCount = 0; + const scopeRegistry = { + get: vi.fn(async () => ({ + registryProjectId: added.projectId, + record: added, + runtime: getCount++ === 0 ? firstRuntime : secondRuntime, + dispose: vi.fn(), + })), + ensureSyncHost: vi.fn(async () => { + disposeListener?.(added.projectId); + return { + registryProjectId: added.projectId, + record: added, + runtime: secondRuntime, + dispose: vi.fn(), + }; + }), + dispose: vi.fn(), + disposeAll: vi.fn(), + onDispose: vi.fn((listener: (projectId: string) => void) => { + disposeListener = listener; + return () => { + disposeListener = null; + }; + }), + } as unknown as ProjectScopeRegistry; + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + scopeRegistry, + }); + + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: {}, + }); + + const first = await handler({ + jsonrpc: "2.0", + id: 2, + method: "ade/actions/call", + params: { + projectId: added.projectId, + name: "run_ade_action", + arguments: { domain: "lane", action: "list" }, + }, + }) as { result: Array<{ id: string }> }; + expect(first.result[0]?.id).toBe("first-lane"); + + await handler({ + jsonrpc: "2.0", + id: 3, + method: "sync.getStatus", + params: { projectId: added.projectId }, + }); + + const second = await handler({ + jsonrpc: "2.0", + id: 4, + method: "ade/actions/call", + params: { + projectId: added.projectId, + name: "run_ade_action", + arguments: { domain: "lane", action: "list" }, + }, + }) as { result: Array<{ id: string }> }; + expect(second.result[0]?.id).toBe("second-lane"); + expect(scopeRegistry.get).toHaveBeenCalledTimes(2); + + handler.dispose(); + }); + + it("subscribes to project runtime events and emits JSON-RPC notifications", async () => { + const { projectRoot, registry } = createRegistry(); + const added = registry.add(projectRoot); + const eventBuffer = createEventBuffer(); + const scopeRegistry = { + get: vi.fn(async () => ({ + registryProjectId: added.projectId, + record: added, + runtime: { + eventBuffer, + dispose: vi.fn(), + }, + dispose: vi.fn(), + })), + ensureSyncHost: vi.fn(), + dispose: vi.fn(), + disposeAll: vi.fn(), + } as unknown as ProjectScopeRegistry; + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + scopeRegistry, + }); + const notify = vi.fn(); + handler.setNotifier(notify); + + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: {}, + }); + + const subscribed = await handler({ + jsonrpc: "2.0", + id: 2, + method: "runtimeEvents.subscribe", + params: { + projectId: added.projectId, + category: "runtime", + }, + }) as { subscriptionId: string }; + + eventBuffer.push({ + timestamp: "2026-05-10T00:00:00.000Z", + category: "runtime", + payload: { type: "file_change", event: { path: "README.md" } }, + }); + eventBuffer.push({ + timestamp: "2026-05-10T00:00:01.000Z", + category: "mission", + payload: { type: "ignored" }, + }); + + expect(notify).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalledWith("runtime/event", { + subscriptionId: subscribed.subscriptionId, + projectId: added.projectId, + event: expect.objectContaining({ + category: "runtime", + payload: { type: "file_change", event: { path: "README.md" } }, + }), + }); + + expect(await handler({ + jsonrpc: "2.0", + id: 3, + method: "runtimeEvents.unsubscribe", + params: { subscriptionId: subscribed.subscriptionId }, + })).toEqual({ removed: true }); + + eventBuffer.push({ + timestamp: "2026-05-10T00:00:02.000Z", + category: "runtime", + payload: { type: "file_change", event: { path: "package.json" } }, + }); + expect(notify).toHaveBeenCalledTimes(1); + + handler.dispose(); + }); +}); diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts new file mode 100644 index 000000000..dff4ae43f --- /dev/null +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -0,0 +1,687 @@ +import { createAdeRpcRequestHandler } from "./adeRpcServer"; +import os from "node:os"; +import path from "node:path"; +import { browseProjectDirectories } from "../../desktop/src/main/services/projects/projectBrowserService"; +import { + getProjectDetail, + getProjectWorkSummary, +} from "../../desktop/src/main/services/projects/projectDetailService"; +import { createProjectScaffoldService } from "../../desktop/src/main/services/projects/projectScaffoldService"; +import type { Logger } from "../../desktop/src/main/services/logging/logger"; +import type { + CloneProjectInput, + CreateProjectInput, + ListMyGitHubReposInput, + ProjectBrowseInput, +} from "../../desktop/src/shared/types"; +import type { BufferedEvent } from "./eventBuffer"; +import { + JsonRpcError, + JsonRpcErrorCode, + type JsonRpcHandler, + type JsonRpcRequest, +} from "./jsonrpc"; +import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; +import { + ProjectRegistry, + type ProjectId, +} from "./services/projects/projectRegistry"; +import { ProjectScopeRegistry } from "./services/projects/projectScope"; +import { createHeadlessGitHubService } from "./headlessLinearServices"; +import type { SyncPeerDeviceType } from "../../desktop/src/shared/types"; + +type HandlerEntry = { + handler: JsonRpcHandler & { dispose?: () => void }; +}; + +type RuntimeEventCategory = BufferedEvent["category"]; +type JsonRpcNotifier = (method: string, params?: unknown) => void; +type RuntimeEventSubscription = { + id: string; + projectId: ProjectId; + unsubscribe: () => void; +}; + +export type MultiProjectRpcHandlerOptions = { + serverVersion: string; + projectRegistry?: ProjectRegistry; + scopeRegistry?: ProjectScopeRegistry; + runtimeCapabilities?: { + memory?: boolean; + }; + disposeScopesOnDispose?: boolean; + onShutdown?: (() => void) | null; +}; + +const RUNTIME_METHODS = new Set([ + "ade/initialize", + "ade/initialized", + "ping", + "shutdown", + "exit", + "runtime/info", + "machineInfo.get", + "projects.list", + "projects.add", + "projects.remove", + "projects.touch", + "projects.browseDirectories", + "projects.getDetail", + "projects.getWorkSummary", + "projects.getDefaultParentDir", + "projects.create", + "projects.clone", + "projects.listMyGitHubRepos", + "runtimeEvents.subscribe", + "runtimeEvents.unsubscribe", + "sync.getStatus", + "sync.refreshDiscovery", + "sync.listDevices", + "sync.updateLocalDevice", + "sync.connectToBrain", + "sync.disconnectFromBrain", + "sync.forgetDevice", + "sync.getTransferReadiness", + "sync.transferBrainToLocal", + "sync.getPin", + "sync.setPin", + "sync.generatePin", + "sync.clearPin", + "sync.setActiveLanePresence", +]); + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function safeParams(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +const machineProjectLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +function readOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function readProjectBrowseInput( + params: Record, +): ProjectBrowseInput { + const input: ProjectBrowseInput = {}; + const partialPath = readOptionalString(params.partialPath); + if (partialPath) input.partialPath = partialPath; + if (typeof params.cwd === "string") input.cwd = params.cwd.trim() || null; + if (typeof params.limit === "number" && Number.isFinite(params.limit)) + input.limit = params.limit; + return input; +} + +function readCreateProjectInput( + params: Record, +): CreateProjectInput { + const name = readOptionalString(params.name); + const parentDir = readOptionalString(params.parentDir); + if (!name) + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.create requires name.", + ); + if (!parentDir) + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.create requires parentDir.", + ); + return { name, parentDir }; +} + +function readCloneProjectInput( + params: Record, +): CloneProjectInput { + const url = readOptionalString(params.url); + const parentDir = readOptionalString(params.parentDir); + const name = readOptionalString(params.name); + const githubAuthHeader = readOptionalString(params.githubAuthHeader); + if (!url) + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.clone requires url.", + ); + if (!parentDir) + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.clone requires parentDir.", + ); + return { + url, + parentDir, + ...(name ? { name } : {}), + ...(githubAuthHeader ? { githubAuthHeader } : {}), + }; +} + +function readListMyReposInput( + params: Record, +): ListMyGitHubReposInput { + const search = readOptionalString(params.search); + return search ? { search } : {}; +} + +function createMachineProjectScaffoldService() { + const githubService = createHeadlessGitHubService( + process.cwd(), + machineProjectLogger, + ); + return createProjectScaffoldService({ + logger: machineProjectLogger, + githubService: githubService as never, + }); +} + +function defaultParentDir(projectRegistry: ProjectRegistry): string { + const first = projectRegistry.list()[0]?.rootPath; + if (first) return path.dirname(first); + return path.join(os.homedir(), "Projects"); +} + +function readProjectId(params: Record): ProjectId | null { + const value = params.projectId; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function omitProjectId( + params: Record, +): Record { + const { projectId: _projectId, ...rest } = params; + return rest; +} + +function readEventCategory(value: unknown): RuntimeEventCategory | null { + return value === "orchestrator" || + value === "dag_mutation" || + value === "runtime" || + value === "mission" + ? value + : null; +} + +function readCursor(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(0, Math.floor(value)) + : 0; +} + +function readLimit(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(1, Math.min(1000, Math.floor(value))) + : 100; +} + +export function createMultiProjectRpcRequestHandler( + options: MultiProjectRpcHandlerOptions, +): JsonRpcHandler & { + dispose: () => void; + setNotifier: (notify: JsonRpcNotifier | null) => void; +} { + const projectRegistry = options.projectRegistry ?? new ProjectRegistry(); + const handlers = new Map>(); + const eventSubscriptions = new Map(); + const disposeProjectRuntimeCaches = (projectId: ProjectId): void => { + const cached = handlers.get(projectId); + handlers.delete(projectId); + if (cached) { + void cached.then((entry) => entry.handler.dispose?.()).catch(() => {}); + } + for (const subscription of [...eventSubscriptions.values()]) { + if (subscription.projectId !== projectId) continue; + subscription.unsubscribe(); + eventSubscriptions.delete(subscription.id); + } + }; + const scopeRegistry = + options.scopeRegistry ?? + new ProjectScopeRegistry(projectRegistry, { + runtimeCapabilities: options.runtimeCapabilities, + }); + const removeScopeDisposeListener = + typeof (scopeRegistry as Partial).onDispose === + "function" + ? scopeRegistry.onDispose(disposeProjectRuntimeCaches) + : null; + let initializedParams: Record | null = null; + let notifier: JsonRpcNotifier | null = null; + let nextSubscriptionId = 1; + + const emitRuntimeEvent = ( + subscriptionId: string, + projectId: ProjectId, + event: BufferedEvent, + ): void => { + notifier?.("runtime/event", { + subscriptionId, + projectId, + event, + }); + }; + + const getProjectHandler = async ( + projectId: ProjectId, + ): Promise => { + const cached = handlers.get(projectId); + if (cached) return await cached; + + const pending = (async () => { + const scope = await scopeRegistry.get(projectId); + const handler = createAdeRpcRequestHandler({ + runtime: scope.runtime, + serverVersion: options.serverVersion, + onActionsListChanged: () => {}, + }); + if (initializedParams) { + await handler({ + jsonrpc: "2.0", + id: "initialize-project-scope", + method: "ade/initialize", + params: initializedParams, + }); + } + return { handler }; + })(); + handlers.set(projectId, pending); + + try { + return await pending; + } catch (error) { + handlers.delete(projectId); + throw error; + } + }; + + const subscribeRuntimeEvents = async (params: Record) => { + const projectId = readProjectId(params); + if (!projectId) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "runtimeEvents.subscribe requires projectId.", + ); + } + const category = + params.category == null ? null : readEventCategory(params.category); + if (params.category != null && !category) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "runtimeEvents.subscribe category is invalid.", + ); + } + const cursor = readCursor(params.cursor); + const limit = readLimit(params.limit); + const scope = await scopeRegistry.get(projectId); + const subscriptionId = `runtime-events-${nextSubscriptionId++}`; + const shouldForward = (event: BufferedEvent): boolean => + !category || event.category === category; + const unsubscribe = scope.runtime.eventBuffer.subscribe((event) => { + if (shouldForward(event)) + emitRuntimeEvent(subscriptionId, projectId, event); + }); + eventSubscriptions.set(subscriptionId, { + id: subscriptionId, + projectId, + unsubscribe, + }); + + const replay = scope.runtime.eventBuffer.drain(cursor, limit); + for (const event of replay.events) { + if (shouldForward(event)) + emitRuntimeEvent(subscriptionId, projectId, event); + } + return { + subscriptionId, + nextCursor: replay.nextCursor, + hasMore: replay.hasMore, + }; + }; + + const unsubscribeRuntimeEvents = (params: Record) => { + const subscriptionId = + typeof params.subscriptionId === "string" + ? params.subscriptionId.trim() + : ""; + if (!subscriptionId) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "runtimeEvents.unsubscribe requires subscriptionId.", + ); + } + const subscription = eventSubscriptions.get(subscriptionId); + if (!subscription) return { removed: false }; + subscription.unsubscribe(); + eventSubscriptions.delete(subscriptionId); + return { removed: true }; + }; + + const getSyncService = async (params: Record) => { + const projectId = readProjectId(params); + const scope = await scopeRegistry.ensureSyncHost(projectId ?? undefined); + const syncService = scope?.runtime.syncService ?? null; + if (!syncService) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidRequest, + "Sync service is not available. Register a project first.", + ); + } + return syncService; + }; + + const handler = (async (request: JsonRpcRequest): Promise => { + const method = typeof request.method === "string" ? request.method : ""; + const params = safeParams(request.params); + + if (method === "ade/initialize") { + initializedParams = params; + return { + protocolVersion: + typeof params.protocolVersion === "string" + ? params.protocolVersion + : "2025-06-18", + runtimeInfo: { + name: "ade-rpc", + version: options.serverVersion, + buildHash: + typeof process.env.ADE_RUNTIME_BUILD_HASH === "string" && + process.env.ADE_RUNTIME_BUILD_HASH.trim() + ? process.env.ADE_RUNTIME_BUILD_HASH.trim() + : null, + multiProject: true, + }, + capabilities: { + actions: { + listChanged: true, + }, + projects: true, + machineProjects: { + browseDirectories: true, + getDetail: true, + getWorkSummary: true, + getDefaultParentDir: true, + create: true, + clone: true, + listMyGitHubRepos: true, + }, + }, + }; + } + + if (method === "ade/initialized") { + return null; + } + + if (!initializedParams) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidRequest, + "Server must be initialized first.", + ); + } + + if (method === "ping") { + return { pong: true, at: new Date().toISOString() }; + } + + if (method === "runtime/info" || method === "machineInfo.get") { + const layout = resolveMachineAdeLayout(); + return { + version: options.serverVersion, + runtimeKind: "headless", + adeDir: layout.adeDir, + socketPath: layout.socketPath, + projectCount: projectRegistry.list().length, + }; + } + + if (method === "projects.list") { + return projectRegistry.list(); + } + + if (method === "projects.add") { + const rootPath = + typeof params.rootPath === "string" ? params.rootPath.trim() : ""; + if (!rootPath) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.add requires rootPath.", + ); + } + return projectRegistry.add(rootPath); + } + + if (method === "projects.remove") { + const projectId = readProjectId(params); + if (!projectId) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.remove requires projectId.", + ); + } + await scopeRegistry.dispose(projectId); + handlers.delete(projectId); + return { removed: projectRegistry.remove(projectId) }; + } + + if (method === "projects.touch") { + const projectId = readProjectId(params); + if (!projectId) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.touch requires projectId.", + ); + } + return projectRegistry.touch(projectId); + } + + if (method === "projects.browseDirectories") { + return await browseProjectDirectories(readProjectBrowseInput(params)); + } + + if (method === "projects.getDetail") { + const rootPath = readOptionalString(params.rootPath); + if (!rootPath) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.getDetail requires rootPath.", + ); + } + return await getProjectDetail(rootPath); + } + + if (method === "projects.getWorkSummary") { + const rootPath = readOptionalString(params.rootPath); + if (!rootPath) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.getWorkSummary requires rootPath.", + ); + } + return await getProjectWorkSummary(rootPath); + } + + if (method === "projects.getDefaultParentDir") { + return defaultParentDir(projectRegistry); + } + + if (method === "projects.create") { + const result = + await createMachineProjectScaffoldService().createLocalProject( + readCreateProjectInput(params), + ); + return projectRegistry.add(result.rootPath); + } + + if (method === "projects.clone") { + const result = + await createMachineProjectScaffoldService().cloneRepository( + readCloneProjectInput(params), + ); + return projectRegistry.add(result.rootPath); + } + + if (method === "projects.listMyGitHubRepos") { + return await createMachineProjectScaffoldService().listMyGitHubRepos( + readListMyReposInput(params), + ); + } + + if (method === "runtimeEvents.subscribe") { + return await subscribeRuntimeEvents(params); + } + + if (method === "runtimeEvents.unsubscribe") { + return unsubscribeRuntimeEvents(params); + } + + if (method === "sync.getStatus") { + const syncService = await getSyncService(params); + return await syncService.getStatus({ + includeTransferReadiness: params.includeTransferReadiness === true, + forceTransferReadiness: params.forceTransferReadiness === true, + }); + } + + if (method === "sync.refreshDiscovery") { + return await (await getSyncService(params)).refreshDiscovery(); + } + + if (method === "sync.listDevices") { + return await (await getSyncService(params)).listDevices(); + } + + if (method === "sync.updateLocalDevice") { + const name = typeof params.name === "string" ? params.name : undefined; + const deviceType = + typeof params.deviceType === "string" + ? (params.deviceType as SyncPeerDeviceType) + : undefined; + return await ( + await getSyncService(params) + ).updateLocalDevice({ + ...(name !== undefined ? { name } : {}), + ...(deviceType !== undefined ? { deviceType } : {}), + }); + } + + if (method === "sync.connectToBrain") { + const syncService = await getSyncService(params); + return await syncService.connectToBrain( + omitProjectId(params) as Parameters< + typeof syncService.connectToBrain + >[0], + ); + } + + if (method === "sync.disconnectFromBrain") { + return await (await getSyncService(params)).disconnectFromBrain(); + } + + if (method === "sync.forgetDevice") { + const deviceId = + typeof params.deviceId === "string" ? params.deviceId : ""; + return await (await getSyncService(params)).forgetDevice(deviceId); + } + + if (method === "sync.getTransferReadiness") { + return await (await getSyncService(params)).getTransferReadiness(); + } + + if (method === "sync.transferBrainToLocal") { + return await (await getSyncService(params)).transferBrainToLocal(); + } + + if (method === "sync.getPin") { + return { pin: (await getSyncService(params)).getPin() }; + } + + if (method === "sync.setPin") { + const pin = typeof params.pin === "string" ? params.pin : ""; + return await (await getSyncService(params)).setPin(pin); + } + + if (method === "sync.generatePin") { + return await (await getSyncService(params)).generatePin(); + } + + if (method === "sync.clearPin") { + return await (await getSyncService(params)).clearPin(); + } + + if (method === "sync.setActiveLanePresence") { + const laneIds = Array.isArray(params.laneIds) + ? params.laneIds.filter( + (laneId): laneId is string => typeof laneId === "string", + ) + : []; + await (await getSyncService(params)).setActiveLanePresence(laneIds); + return null; + } + + if (method === "shutdown") { + process.nextTick(() => options.onShutdown?.()); + return {}; + } + + if (method === "exit") { + process.nextTick(() => process.exit(0)); + return {}; + } + + if (RUNTIME_METHODS.has(method)) { + throw new JsonRpcError( + JsonRpcErrorCode.methodNotFound, + `Method not found: ${method}`, + ); + } + + const projectId = readProjectId(params); + if (!projectId) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + `Method ${method} requires params.projectId.`, + ); + } + + const entry = await getProjectHandler(projectId); + return await entry.handler({ + ...request, + params: omitProjectId(params), + }); + }) as JsonRpcHandler & { + dispose: () => void; + setNotifier: (notify: JsonRpcNotifier | null) => void; + }; + + handler.dispose = () => { + for (const subscription of eventSubscriptions.values()) { + subscription.unsubscribe(); + } + eventSubscriptions.clear(); + for (const cached of handlers.values()) { + void cached.then((entry) => entry.handler.dispose?.()).catch(() => {}); + } + handlers.clear(); + removeScopeDisposeListener?.(); + if (options.disposeScopesOnDispose ?? !options.scopeRegistry) { + void scopeRegistry.disposeAll(); + } + }; + + handler.setNotifier = (notify: JsonRpcNotifier | null) => { + notifier = notify; + }; + + return handler; +} diff --git a/apps/ade-cli/src/serviceManager/common.test.ts b/apps/ade-cli/src/serviceManager/common.test.ts new file mode 100644 index 000000000..9ce64046d --- /dev/null +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -0,0 +1,421 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + ADE_RUNTIME_SERVICE_NAME, + renderCommand, + renderWindowsCommand, + resolveAdeServeCommand, + type AdeServiceCommand, + type ServiceManagerProcessResult, + type ServiceManagerSpawnSync, +} from "./common"; +import { installLaunchdService, isLaunchdPrintRunning, launchAgentPath, renderLaunchdPlist } from "./installLaunchd"; +import { installSystemdService, renderSystemdEnvironment, renderSystemdUnit, servicePath as systemdServicePath } from "./installSystemd"; +import { + buildWindowsCreateTaskArgs, + buildWindowsDeleteTaskArgs, + buildWindowsQueryTaskArgs, + buildWindowsRunTaskArgs, + installWindowsService, + isSchtasksOutputRunning, + parseSchtasksListStatus, + resolveWindowsTaskUser, + TASK_NAME, + uninstallWindowsService, +} from "./installWindows"; + +const originalArgv = [...process.argv]; +const originalNodePath = process.env.NODE_PATH; +const tempDirs: string[] = []; + +afterEach(() => { + process.argv.splice(0, process.argv.length, ...originalArgv); + if (originalNodePath === undefined) delete process.env.NODE_PATH; + else process.env.NODE_PATH = originalNodePath; + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTempHome(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +describe("resolveAdeServeCommand", () => { + it("uses node plus the CLI script when argv points at a real script", () => { + process.argv[1] = path.resolve("src/cli.ts"); + + expect(resolveAdeServeCommand()).toMatchObject({ + command: process.execPath, + args: [path.resolve("src/cli.ts"), "serve"], + }); + }); + + it("uses the executable directly when SEA argv contains the synthetic CLI script name", () => { + process.argv[1] = path.resolve("definitely-not-real-cli.cjs"); + + expect(resolveAdeServeCommand()).toMatchObject({ + command: process.execPath, + args: ["serve"], + }); + }); + + it("preserves NODE_PATH for standalone runtime sidecar dependencies", () => { + process.argv[1] = path.resolve("definitely-not-real-cli.cjs"); + process.env.NODE_PATH = "/opt/ade/runtime/node_modules"; + + expect(resolveAdeServeCommand()).toMatchObject({ + command: process.execPath, + args: ["serve"], + env: { + NODE_PATH: "/opt/ade/runtime/node_modules", + }, + }); + }); +}); + +describe("service manager status parsers", () => { + it("detects running launchd services from launchctl print output", () => { + expect(isLaunchdPrintRunning("state = running\npid = 123\n")).toBe(true); + expect(isLaunchdPrintRunning("state = waiting\n")).toBe(false); + }); + + it("detects running Windows scheduled tasks from schtasks output", () => { + expect(isSchtasksOutputRunning("TaskName: ADE Runtime\r\nStatus: Running\r\n")).toBe(true); + expect(isSchtasksOutputRunning("TaskName: ADE Runtime\r\nStatus: Ready\r\n")).toBe(false); + }); + + it("parses Windows scheduled task status from schtasks LIST output", () => { + expect(parseSchtasksListStatus("TaskName: ADE Runtime\r\nStatus: Ready\r\n")).toBe("Ready"); + expect(parseSchtasksListStatus("TaskName: ADE Runtime\r\n")).toBeNull(); + }); +}); + +describe("launchd service rendering", () => { + it("renders the launch agent path under the user home directory", () => { + expect(launchAgentPath("/Users/example")).toBe( + path.join("/Users/example", "Library", "LaunchAgents", `${ADE_RUNTIME_SERVICE_NAME}.plist`), + ); + }); + + it("renders plist content with escaped command, logs, and environment values", () => { + const plist = renderLaunchdPlist({ + command: "/Applications/ADE & Tools/ade", + args: ["serve", "--name", "A${ADE_RUNTIME_SERVICE_NAME}`); + expect(plist).toContain("ProgramArguments"); + expect(plist).toContain("/Applications/ADE & Tools/ade"); + expect(plist).toContain("A<B"); + expect(plist).toContain("EnvironmentVariables"); + expect(plist).toContain("NODE_PATH"); + expect(plist).toContain("/opt/ADE & deps"); + expect(plist).toContain("ADE_HOME"); + expect(plist).toContain("/Users/example/'ade'"); + expect(plist).toContain(`${path.join("/Users/example", ".ade", "runtime", "launchd.out.log")}`); + expect(plist).toContain(`${path.join("/Users/example", ".ade", "runtime", "launchd.err.log")}`); + }); +}); + +describe("launchd service install", () => { + const serviceCommand: AdeServiceCommand = { + command: "/Applications/ADE.app/Contents/MacOS/ade", + args: ["serve"], + env: { NODE_PATH: "/opt/ade/node_modules" }, + }; + + it("writes the plist and loads the launch agent", () => { + const homeDir = makeTempHome("ade-launchd-install-"); + const servicePath = launchAgentPath(homeDir); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + ]); + + const result = installLaunchdService({ command: serviceCommand, spawnSync, homeDir }); + + expect(result).toMatchObject({ + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: servicePath, + }); + expect(fs.readFileSync(servicePath, "utf8")).toBe(renderLaunchdPlist(serviceCommand, homeDir)); + expect(calls).toEqual([ + { command: "launchctl", args: ["unload", servicePath] }, + { command: "launchctl", args: ["load", servicePath] }, + ]); + }); + + it("surfaces launchctl load failures", () => { + const homeDir = makeTempHome("ade-launchd-fail-"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "", stderr: "" }, + { status: 5, stdout: "", stderr: "Load failed" }, + ]); + + const result = installLaunchdService({ command: serviceCommand, spawnSync, homeDir }); + + expect(result.ok).toBe(false); + expect(result.message).toBe("Load failed"); + expect(calls.map((call) => call.args[0])).toEqual(["unload", "load"]); + }); +}); + +describe("systemd service rendering", () => { + it("renders the user service path under the home directory", () => { + expect(systemdServicePath("/home/example")).toBe( + path.join("/home/example", ".config", "systemd", "user", "ade-runtime.service"), + ); + }); + + it("renders unit content with quoted ExecStart and escaped environment values", () => { + const unit = renderSystemdUnit({ + command: "/opt/ADE CLI/node", + args: ["/opt/ade/cli.cjs", "serve"], + env: { + NODE_PATH: "/tmp/100%/node modules", + ADE_HOME: "/home/example/ade path\\with\"quotes", + }, + }); + + expect(unit).toContain("Description=ADE service daemon"); + expect(unit).toContain("Type=simple"); + expect(unit).toContain("ExecStart='/opt/ADE CLI/node' '/opt/ade/cli.cjs' 'serve'"); + expect(unit).toContain("Restart=always"); + expect(unit).toContain("Environment=\"NODE_PATH=/tmp/100%%/node modules\""); + expect(unit).toContain("Environment=\"ADE_HOME=/home/example/ade path\\\\with\\\"quotes\""); + expect(unit).toContain("WantedBy=default.target"); + }); + + it("quotes systemd environment assignments for whitespace, backslashes, quotes, and percent signs", () => { + expect(renderSystemdEnvironment("NODE_PATH", "C:\\ADE deps\\100% \"runtime\"")).toBe( + "Environment=\"NODE_PATH=C:\\\\ADE deps\\\\100%% \\\"runtime\\\"\"", + ); + }); +}); + +describe("systemd service install", () => { + const serviceCommand: AdeServiceCommand = { + command: "/opt/ade/bin/ade", + args: ["serve"], + env: { NODE_PATH: "/opt/ade/node_modules" }, + }; + + it("writes the user unit and enables it immediately", () => { + const homeDir = makeTempHome("ade-systemd-install-"); + const targetPath = systemdServicePath(homeDir); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + ]); + + const result = installSystemdService({ command: serviceCommand, spawnSync, homeDir }); + + expect(result).toMatchObject({ + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: targetPath, + }); + expect(fs.readFileSync(targetPath, "utf8")).toBe(renderSystemdUnit(serviceCommand)); + expect(calls).toEqual([ + { command: "systemctl", args: ["--user", "daemon-reload"] }, + { command: "systemctl", args: ["--user", "enable", "--now", "ade-runtime.service"] }, + ]); + }); + + it("does not enable when daemon-reload fails", () => { + const homeDir = makeTempHome("ade-systemd-reload-fail-"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 1, stdout: "", stderr: "reload failed" }, + ]); + + const result = installSystemdService({ command: serviceCommand, spawnSync, homeDir }); + + expect(result.ok).toBe(false); + expect(result.message).toBe("reload failed"); + expect(calls).toEqual([ + { command: "systemctl", args: ["--user", "daemon-reload"] }, + ]); + }); + + it("surfaces enable failures after a successful reload", () => { + const homeDir = makeTempHome("ade-systemd-enable-fail-"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "enable failed" }, + ]); + + const result = installSystemdService({ command: serviceCommand, spawnSync, homeDir }); + + expect(result.ok).toBe(false); + expect(result.message).toBe("enable failed"); + expect(calls.map((call) => call.args)).toEqual([ + ["--user", "daemon-reload"], + ["--user", "enable", "--now", "ade-runtime.service"], + ]); + }); +}); + +describe("Windows scheduled task helpers", () => { + const serviceCommand: AdeServiceCommand = { + command: "C:\\Program Files\\ADE\\ade.exe", + args: ["serve"], + }; + const taskUser = "ADEBOX\\arul"; + + it("builds schtasks create, run, query, and delete arguments without invoking schtasks", () => { + const renderedCommand = renderWindowsCommand(serviceCommand); + + expect(buildWindowsCreateTaskArgs(renderedCommand, taskUser)).toEqual([ + "/Create", + "/SC", + "ONLOGON", + "/TN", + TASK_NAME, + "/TR", + renderedCommand, + "/RU", + taskUser, + "/IT", + "/F", + ]); + expect(buildWindowsRunTaskArgs()).toEqual(["/Run", "/TN", TASK_NAME]); + expect(buildWindowsQueryTaskArgs()).toEqual(["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]); + expect(buildWindowsDeleteTaskArgs()).toEqual(["/Delete", "/TN", TASK_NAME, "/F"]); + }); + + it("resolves the Windows scheduled task user from domain and username environment values", () => { + expect(resolveWindowsTaskUser({ USERDOMAIN: "ADEBOX", USERNAME: "arul" })).toBe("ADEBOX\\arul"); + expect(resolveWindowsTaskUser({ USERNAME: "LOCALUSER" })).toBe("LOCALUSER"); + expect(resolveWindowsTaskUser({ USERDOMAIN: "ADEBOX", USERNAME: "ADEBOX\\arul" })).toBe("ADEBOX\\arul"); + }); + + it("renders Windows scheduled task commands with double-quoted argv tokens", () => { + expect(renderWindowsCommand({ + command: "C:\\Program Files\\ADE\\ade.exe", + args: ["serve", "--root", "C:\\path with space\\"], + })).toBe("\"C:\\Program Files\\ADE\\ade.exe\" \"serve\" \"--root\" \"C:\\path with space\\\\\""); + expect(renderCommand(serviceCommand)).toBe("'C:\\Program Files\\ADE\\ade.exe' 'serve'"); + }); + + it("rejects embedded double quotes in Windows scheduled task command tokens", () => { + expect(() => renderWindowsCommand({ + command: "C:\\Program Files\\ADE\\ade.exe", + args: ["serve", "--name", "quoted \"value\""], + })).toThrow("Windows service command arguments cannot contain double quotes."); + }); + + it("starts the scheduled task immediately after a successful create", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "SUCCESS: created", stderr: "" }, + { status: 0, stdout: "SUCCESS: attempted to run", stderr: "" }, + ]); + + const result = installWindowsService({ command: serviceCommand, spawnSync, userName: taskUser }); + + expect(result).toMatchObject({ + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: TASK_NAME, + message: "ADE service scheduled task installed and started.", + }); + expect(calls).toEqual([ + { command: "schtasks.exe", args: buildWindowsCreateTaskArgs(renderWindowsCommand(serviceCommand), taskUser) }, + { command: "schtasks.exe", args: buildWindowsRunTaskArgs() }, + ]); + }); + + it("surfaces a clear install failure when create succeeds but immediate start fails", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "SUCCESS: created", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: access is denied" }, + ]); + + const result = installWindowsService({ command: serviceCommand, spawnSync, userName: taskUser }); + + expect(result.ok).toBe(false); + expect(result.message).toBe("ADE service scheduled task installed, but failed to start: ERROR: access is denied"); + expect(calls.map((call) => call.args)).toEqual([ + buildWindowsCreateTaskArgs(renderWindowsCommand(serviceCommand), taskUser), + buildWindowsRunTaskArgs(), + ]); + }); + + it("does not try to run the task when create fails", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 1, stdout: "", stderr: "ERROR: create failed" }, + ]); + + const result = installWindowsService({ command: serviceCommand, spawnSync, userName: taskUser }); + + expect(result.ok).toBe(false); + expect(result.message).toBe("ERROR: create failed"); + expect(calls).toHaveLength(1); + }); + + it("reports successful scheduled task removal", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, + ]); + + const result = uninstallWindowsService({ spawnSync }); + + expect(result).toMatchObject({ + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "uninstall", + path: TASK_NAME, + message: "ADE service scheduled task removed.", + }); + expect(calls).toEqual([ + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs() }, + ]); + }); + + it("surfaces scheduled task removal failures", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 1, stdout: "", stderr: "ERROR: The system cannot find the file specified." }, + ]); + + const result = uninstallWindowsService({ spawnSync }); + + expect(result.ok).toBe(false); + expect(result.message).toBe("ERROR: The system cannot find the file specified."); + expect(calls).toEqual([ + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs() }, + ]); + }); +}); + +function spawnSequence( + calls: Array<{ command: string; args: string[] }>, + results: ServiceManagerProcessResult[], +): ServiceManagerSpawnSync { + return (command, args) => { + calls.push({ command, args }); + return results.shift() ?? { status: 0, stdout: "", stderr: "" }; + }; +} diff --git a/apps/ade-cli/src/serviceManager/common.ts b/apps/ade-cli/src/serviceManager/common.ts new file mode 100644 index 000000000..465d0c7ee --- /dev/null +++ b/apps/ade-cli/src/serviceManager/common.ts @@ -0,0 +1,141 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { SpawnSyncOptions } from "node:child_process"; + +export type ServiceManagerResult = { + ok: boolean; + serviceName: string; + action: "install" | "uninstall"; + path: string | null; + message: string; +}; + +export type ServiceManagerStatusResult = { + ok: boolean; + serviceName: string; + action: "status"; + installed: boolean | null; + running: boolean | null; + path: string | null; + message: string; +}; + +export type AdeServiceCommand = { + command: string; + args: string[]; + env?: Record; +}; + +function resolveRuntimeServiceName(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.ADE_RUNTIME_SERVICE_NAME?.trim(); + if (explicit) return explicit; + const channel = env.ADE_PACKAGE_CHANNEL?.trim().toLowerCase(); + if (channel === "alpha") return "com.ade.runtime.alpha"; + if (channel === "beta") return "com.ade.runtime.beta"; + return "com.ade.runtime"; +} + +export const ADE_RUNTIME_SERVICE_NAME = resolveRuntimeServiceName(); + +export type ServiceManagerProcessResult = { + status: number | null; + stdout?: string | Buffer | null; + stderr?: string | Buffer | null; +}; + +export type ServiceManagerSpawnSync = ( + command: string, + args: string[], + options?: SpawnSyncOptions, +) => ServiceManagerProcessResult; + +const RUNTIME_ENV_PASSTHROUGH = [ + "NODE_PATH", + "ADE_HOME", + "ADE_PACKAGE_CHANNEL", + "ADE_DESKTOP_APP_NAME", + "ADE_DISABLE_RUNTIME_SERVICE_INSTALL", + "ADE_RUNTIME_SERVICE_NAME", +] as const; + +function runtimeEnvironment(): Record | undefined { + const env: Record = {}; + if (process.versions.electron) { + env.ELECTRON_RUN_AS_NODE = "1"; + } + for (const key of RUNTIME_ENV_PASSTHROUGH) { + const value = process.env[key]; + if (value?.trim()) { + env[key] = value; + } + } + return Object.keys(env).length > 0 ? env : undefined; +} + +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +export function cmdQuote(value: string): string { + if (value.includes("\"")) { + throw new Error("Windows service command arguments cannot contain double quotes."); + } + let quoted = "\""; + let backslashes = 0; + for (const char of value) { + if (char === "\\") { + backslashes += 1; + continue; + } + quoted += "\\".repeat(backslashes); + quoted += char; + backslashes = 0; + } + quoted += "\\".repeat(backslashes * 2); + quoted += "\""; + return quoted; +} + +export function resolveAdeServeCommand(): AdeServiceCommand { + const entry = typeof process.argv[1] === "string" && process.argv[1].trim() + ? path.resolve(process.argv[1]) + : ""; + const isNodeScript = /\.(?:cjs|mjs|js|ts)$/i.test(entry) && fs.existsSync(entry); + if (isNodeScript) { + return { + command: process.execPath, + args: [entry, "serve"], + env: runtimeEnvironment(), + }; + } + if (entry && fs.existsSync(entry)) { + return { + command: entry, + args: ["serve"], + env: runtimeEnvironment(), + }; + } + return { + command: process.execPath, + args: ["serve"], + env: runtimeEnvironment(), + }; +} + +export function renderCommand(command: AdeServiceCommand): string { + return [command.command, ...command.args].map(shellQuote).join(" "); +} + +export function renderWindowsCommand(command: AdeServiceCommand): string { + return [command.command, ...command.args].map(cmdQuote).join(" "); +} + +function streamToText(value: string | Buffer | null | undefined): string { + if (typeof value === "string") return value.trim(); + if (Buffer.isBuffer(value)) return value.toString("utf8").trim(); + return ""; +} + +export function serviceManagerResultText(result: ServiceManagerProcessResult): string { + return streamToText(result.stderr) || streamToText(result.stdout); +} diff --git a/apps/ade-cli/src/serviceManager/index.ts b/apps/ade-cli/src/serviceManager/index.ts new file mode 100644 index 000000000..afcd0f754 --- /dev/null +++ b/apps/ade-cli/src/serviceManager/index.ts @@ -0,0 +1,66 @@ +import type { ServiceManagerResult, ServiceManagerStatusResult } from "./common"; +import { ADE_RUNTIME_SERVICE_NAME } from "./common"; +import { getLaunchdServiceStatus, installLaunchdService, uninstallLaunchdService } from "./installLaunchd"; +import { getSystemdServiceStatus, installSystemdService, uninstallSystemdService } from "./installSystemd"; +import { getWindowsServiceStatus, installWindowsService, uninstallWindowsService } from "./installWindows"; + +export type { ServiceManagerResult, ServiceManagerStatusResult } from "./common"; + +export function installRuntimeService(): ServiceManagerResult { + switch (process.platform) { + case "darwin": + return installLaunchdService(); + case "linux": + return installSystemdService(); + case "win32": + return installWindowsService(); + default: + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: null, + message: `ADE service installation is not supported on ${process.platform}.`, + }; + } +} + +export function uninstallRuntimeService(): ServiceManagerResult { + switch (process.platform) { + case "darwin": + return uninstallLaunchdService(); + case "linux": + return uninstallSystemdService(); + case "win32": + return uninstallWindowsService(); + default: + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "uninstall", + path: null, + message: `ADE service removal is not supported on ${process.platform}.`, + }; + } +} + +export function getRuntimeServiceStatus(): ServiceManagerStatusResult { + switch (process.platform) { + case "darwin": + return getLaunchdServiceStatus(); + case "linux": + return getSystemdServiceStatus(); + case "win32": + return getWindowsServiceStatus(); + default: + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: null, + running: null, + path: null, + message: `ADE service status is not supported on ${process.platform}.`, + }; + } +} diff --git a/apps/ade-cli/src/serviceManager/installLaunchd.ts b/apps/ade-cli/src/serviceManager/installLaunchd.ts new file mode 100644 index 000000000..6f842adf3 --- /dev/null +++ b/apps/ade-cli/src/serviceManager/installLaunchd.ts @@ -0,0 +1,172 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + ADE_RUNTIME_SERVICE_NAME, + type AdeServiceCommand, + resolveAdeServeCommand, + serviceManagerResultText, + type ServiceManagerResult, + type ServiceManagerSpawnSync, + type ServiceManagerStatusResult, +} from "./common"; + +type LaunchdServiceManagerDeps = { + command?: AdeServiceCommand; + spawnSync?: ServiceManagerSpawnSync; + homeDir?: string; +}; + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function plistArray(values: string[]): string { + return [ + "", + ...values.map((value) => ` ${escapeXml(value)}`), + "", + ].join("\n"); +} + +export function launchAgentPath(homeDir = os.homedir()): string { + return path.join(homeDir, "Library", "LaunchAgents", `${ADE_RUNTIME_SERVICE_NAME}.plist`); +} + +export function isLaunchdPrintRunning(output: string): boolean { + return /\bstate\s*=\s*running\b/i.test(output); +} + +export function renderLaunchdPlist(command: AdeServiceCommand, homeDir = os.homedir()): string { + const envEntries = Object.entries(command.env ?? {}); + const envBlock = envEntries.length + ? [ + " EnvironmentVariables", + " ", + ...envEntries.flatMap(([key, value]) => [ + ` ${escapeXml(key)}`, + ` ${escapeXml(value)}`, + ]), + " ", + ].join("\n") + : ""; + const sections = [ + ` + + + + Label + ${ADE_RUNTIME_SERVICE_NAME} + ProgramArguments +${plistArray([command.command, ...command.args]).split("\n").map((line) => ` ${line}`).join("\n")} + RunAtLoad + + KeepAlive + + StandardOutPath + ${escapeXml(path.join(homeDir, ".ade", "runtime", "launchd.out.log"))} + StandardErrorPath + ${escapeXml(path.join(homeDir, ".ade", "runtime", "launchd.err.log"))}`, + envBlock, + ` + +`, + ].filter(Boolean); + return sections.join("\n"); +} + +export function installLaunchdService(deps: LaunchdServiceManagerDeps = {}): ServiceManagerResult { + const run = deps.spawnSync ?? spawnSync; + const homeDir = deps.homeDir ?? os.homedir(); + const servicePath = launchAgentPath(homeDir); + const command = deps.command ?? resolveAdeServeCommand(); + fs.mkdirSync(path.dirname(servicePath), { recursive: true }); + const plist = renderLaunchdPlist(command, homeDir); + fs.mkdirSync(path.join(homeDir, ".ade", "runtime"), { recursive: true }); + fs.writeFileSync(servicePath, plist, "utf8"); + run("launchctl", ["unload", servicePath], { stdio: "ignore" }); + const load = run("launchctl", ["load", servicePath], { encoding: "utf8" }); + if (load.status !== 0) { + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: servicePath, + message: serviceManagerResultText(load) || "launchctl load failed.", + }; + } + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: servicePath, + message: "ADE service launchd service installed.", + }; +} + +export function uninstallLaunchdService(): ServiceManagerResult { + const servicePath = launchAgentPath(); + spawnSync("launchctl", ["unload", servicePath], { stdio: "ignore" }); + try { fs.unlinkSync(servicePath); } catch {} + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "uninstall", + path: servicePath, + message: "ADE service launchd service removed.", + }; +} + +export function getLaunchdServiceStatus(): ServiceManagerStatusResult { + const servicePath = launchAgentPath(); + if (!fs.existsSync(servicePath)) { + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: false, + running: false, + path: servicePath, + message: "ADE service launchd service is not installed.", + }; + } + + const uid = typeof process.getuid === "function" ? process.getuid() : os.userInfo().uid; + let print = spawnSync("launchctl", ["print", `gui/${uid}/${ADE_RUNTIME_SERVICE_NAME}`], { encoding: "utf8" }); + if (print.status !== 0) { + const userPrint = spawnSync("launchctl", ["print", `user/${uid}/${ADE_RUNTIME_SERVICE_NAME}`], { encoding: "utf8" }); + if (userPrint.status === 0) { + print = userPrint; + } + } + if (print.status !== 0) { + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: true, + running: false, + path: servicePath, + message: serviceManagerResultText(print) || "ADE service launchd service is installed but not loaded.", + }; + } + + const running = isLaunchdPrintRunning(print.stdout); + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: true, + running, + path: servicePath, + message: running + ? "ADE service launchd service is running." + : "ADE service launchd service is loaded but not running.", + }; +} diff --git a/apps/ade-cli/src/serviceManager/installSystemd.ts b/apps/ade-cli/src/serviceManager/installSystemd.ts new file mode 100644 index 000000000..e88b0d2e0 --- /dev/null +++ b/apps/ade-cli/src/serviceManager/installSystemd.ts @@ -0,0 +1,128 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + ADE_RUNTIME_SERVICE_NAME, + type AdeServiceCommand, + renderCommand, + resolveAdeServeCommand, + serviceManagerResultText, + type ServiceManagerResult, + type ServiceManagerSpawnSync, + type ServiceManagerStatusResult, +} from "./common"; + +type SystemdServiceManagerDeps = { + command?: AdeServiceCommand; + spawnSync?: ServiceManagerSpawnSync; + homeDir?: string; +}; + +export function servicePath(homeDir = os.homedir()): string { + return path.join(homeDir, ".config", "systemd", "user", "ade-runtime.service"); +} + +function escapeSystemdQuotedValue(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/%/g, "%%"); +} + +export function renderSystemdEnvironment(key: string, value: string): string { + return `Environment="${key}=${escapeSystemdQuotedValue(value)}"`; +} + +export function renderSystemdUnit(command: AdeServiceCommand): string { + const envLines = Object.entries(command.env ?? {}) + .map(([key, value]) => renderSystemdEnvironment(key, value)) + .join("\n"); + return `[Unit] +Description=ADE service daemon + +[Service] +Type=simple +ExecStart=${renderCommand(command)} +Restart=always +RestartSec=2 +${envLines} + +[Install] +WantedBy=default.target +`; +} + +export function installSystemdService(deps: SystemdServiceManagerDeps = {}): ServiceManagerResult { + const run = deps.spawnSync ?? spawnSync; + const targetPath = servicePath(deps.homeDir); + const command = deps.command ?? resolveAdeServeCommand(); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + const unit = renderSystemdUnit(command); + fs.writeFileSync(targetPath, unit, "utf8"); + const reload = run("systemctl", ["--user", "daemon-reload"], { encoding: "utf8" }); + if (reload.status !== 0) { + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: targetPath, + message: serviceManagerResultText(reload) || "systemctl daemon-reload failed.", + }; + } + const enable = run("systemctl", ["--user", "enable", "--now", "ade-runtime.service"], { encoding: "utf8" }); + return { + ok: enable.status === 0, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: targetPath, + message: enable.status === 0 + ? "ADE service systemd user service installed." + : serviceManagerResultText(enable) || "systemctl enable --now failed.", + }; +} + +export function uninstallSystemdService(): ServiceManagerResult { + const targetPath = servicePath(); + spawnSync("systemctl", ["--user", "disable", "--now", "ade-runtime.service"], { stdio: "ignore" }); + try { fs.unlinkSync(targetPath); } catch {} + spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" }); + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "uninstall", + path: targetPath, + message: "ADE service systemd user service removed.", + }; +} + +export function getSystemdServiceStatus(): ServiceManagerStatusResult { + const targetPath = servicePath(); + const enabled = spawnSync("systemctl", ["--user", "is-enabled", "ade-runtime.service"], { encoding: "utf8" }); + const active = spawnSync("systemctl", ["--user", "is-active", "ade-runtime.service"], { encoding: "utf8" }); + const installed = fs.existsSync(targetPath) || enabled.status === 0; + if (!installed) { + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: false, + running: false, + path: targetPath, + message: "ADE service systemd user service is not installed.", + }; + } + + const running = active.status === 0; + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: true, + running, + path: targetPath, + message: running + ? "ADE service systemd user service is running." + : serviceManagerResultText(active) || "ADE service systemd user service is installed but not running.", + }; +} diff --git a/apps/ade-cli/src/serviceManager/installWindows.ts b/apps/ade-cli/src/serviceManager/installWindows.ts new file mode 100644 index 000000000..9606b1d8a --- /dev/null +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -0,0 +1,161 @@ +import { spawnSync } from "node:child_process"; +import os from "node:os"; +import { + ADE_RUNTIME_SERVICE_NAME, + type AdeServiceCommand, + renderWindowsCommand, + resolveAdeServeCommand, + serviceManagerResultText, + type ServiceManagerResult, + type ServiceManagerSpawnSync, + type ServiceManagerStatusResult, +} from "./common"; + +export const TASK_NAME = "ADE Runtime"; + +type WindowsServiceManagerDeps = { + command?: AdeServiceCommand; + spawnSync?: ServiceManagerSpawnSync; + userName?: string; +}; + +export function resolveWindowsTaskUser(env: NodeJS.ProcessEnv = process.env): string { + const username = env.USERNAME?.trim() || os.userInfo().username.trim(); + if (!username) { + throw new Error("Unable to resolve current Windows user for scheduled task registration."); + } + const domain = env.USERDOMAIN?.trim(); + if (domain && !username.includes("\\")) { + return `${domain}\\${username}`; + } + return username; +} + +export function buildWindowsCreateTaskArgs(command: string, userName = resolveWindowsTaskUser()): string[] { + return [ + "/Create", + "/SC", + "ONLOGON", + "/TN", + TASK_NAME, + "/TR", + command, + "/RU", + userName, + "/IT", + "/F", + ]; +} + +export function buildWindowsRunTaskArgs(): string[] { + return ["/Run", "/TN", TASK_NAME]; +} + +export function buildWindowsQueryTaskArgs(): string[] { + return ["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]; +} + +export function buildWindowsDeleteTaskArgs(): string[] { + return ["/Delete", "/TN", TASK_NAME, "/F"]; +} + +export function parseSchtasksListStatus(output: string): string | null { + const match = /^\s*Status:\s*(.*?)\s*$/im.exec(output); + return match?.[1] ?? null; +} + +export function isSchtasksOutputRunning(output: string): boolean { + return parseSchtasksListStatus(output)?.toLowerCase() === "running"; +} + +export function installWindowsService(deps: WindowsServiceManagerDeps = {}): ServiceManagerResult { + const run = deps.spawnSync ?? spawnSync; + const command = renderWindowsCommand(deps.command ?? resolveAdeServeCommand()); + let userName: string; + try { + userName = deps.userName ?? resolveWindowsTaskUser(); + } catch (error) { + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: TASK_NAME, + message: error instanceof Error ? error.message : "Unable to resolve current Windows user.", + }; + } + const result = run("schtasks.exe", buildWindowsCreateTaskArgs(command, userName), { encoding: "utf8" }); + if (result.status !== 0) { + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: TASK_NAME, + message: serviceManagerResultText(result) || "schtasks create failed.", + }; + } + const start = run("schtasks.exe", buildWindowsRunTaskArgs(), { encoding: "utf8" }); + if (start.status !== 0) { + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: TASK_NAME, + message: `ADE service scheduled task installed, but failed to start: ${serviceManagerResultText(start) || "schtasks run failed."}`, + }; + } + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: TASK_NAME, + message: "ADE service scheduled task installed and started.", + }; +} + +export function uninstallWindowsService(deps: Pick = {}): ServiceManagerResult { + const run = deps.spawnSync ?? spawnSync; + const result = run("schtasks.exe", buildWindowsDeleteTaskArgs(), { encoding: "utf8" }); + if (result.status !== 0) { + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "uninstall", + path: TASK_NAME, + message: serviceManagerResultText(result) || "schtasks delete failed.", + }; + } + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "uninstall", + path: TASK_NAME, + message: "ADE service scheduled task removed.", + }; +} + +export function getWindowsServiceStatus(): ServiceManagerStatusResult { + const result = spawnSync("schtasks.exe", buildWindowsQueryTaskArgs(), { encoding: "utf8" }); + if (result.status !== 0) { + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: false, + running: false, + path: TASK_NAME, + message: serviceManagerResultText(result) || "ADE service scheduled task is not installed.", + }; + } + const running = isSchtasksOutputRunning(result.stdout); + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: true, + running, + path: TASK_NAME, + message: running + ? "ADE service scheduled task is running." + : "ADE service scheduled task is installed.", + }; +} diff --git a/apps/ade-cli/src/services/agentRegistry.test.ts b/apps/ade-cli/src/services/agentRegistry.test.ts new file mode 100644 index 000000000..6b45ba884 --- /dev/null +++ b/apps/ade-cli/src/services/agentRegistry.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { classifyAgentCliError } from "./agentRegistry"; + +describe("classifyAgentCliError", () => { + it("classifies missing agent CLIs with install/auth commands", () => { + expect(classifyAgentCliError("spawn codex ENOENT")).toMatchObject({ + agent: "codex", + displayName: "Codex CLI", + category: "missing", + installCommand: 'mkdir -p "$HOME/.npm-global" "$HOME/.local/bin" && NPM_CONFIG_PREFIX="$HOME/.npm-global" npm install -g @openai/codex', + authCommand: "codex login", + }); + }); + + it("classifies unauthenticated agent CLIs with auth commands", () => { + expect(classifyAgentCliError("codex failed: login required")).toMatchObject({ + agent: "codex", + displayName: "Codex CLI", + category: "unauthenticated", + authCommand: "codex login", + }); + }); + + it("uses the preferred provider for generic auth failures", () => { + expect(classifyAgentCliError("401 unauthorized", "claude")).toMatchObject({ + agent: "claude", + displayName: "Claude Code", + category: "unauthenticated", + authCommand: "claude /login", + }); + }); +}); diff --git a/apps/ade-cli/src/services/agentRegistry.ts b/apps/ade-cli/src/services/agentRegistry.ts new file mode 100644 index 000000000..3b865b4cc --- /dev/null +++ b/apps/ade-cli/src/services/agentRegistry.ts @@ -0,0 +1,141 @@ +export type AgentCliErrorCategory = "missing" | "unauthenticated"; + +export type AgentCliDescriptor = { + agent: string; + displayName: string; + binaryNames: string[]; + installCommand: string; + authCommand: string; + missingErrorPatterns: RegExp[]; + notAuthErrorPatterns: RegExp[]; +}; + +export type AgentCliErrorMatch = { + agent: string; + displayName: string; + category: AgentCliErrorCategory; + installCommand: string; + authCommand: string; +}; + +function npmGlobalInstallCommand(packageName: string): string { + if (typeof process !== "undefined" && process.platform === "win32") { + return `npm install -g ${packageName}`; + } + return `mkdir -p "$HOME/.npm-global" "$HOME/.local/bin" && NPM_CONFIG_PREFIX="$HOME/.npm-global" npm install -g ${packageName}`; +} + +export const AGENT_CLI_REGISTRY: AgentCliDescriptor[] = [ + { + agent: "claude", + displayName: "Claude Code", + binaryNames: ["claude"], + installCommand: npmGlobalInstallCommand("@anthropic-ai/claude-code"), + authCommand: "claude /login", + missingErrorPatterns: [ + /\bclaude\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+claude\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bclaude\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i, + /\brun\s+[`'"]?claude\s+\/login[`'"]?/i, + ], + }, + { + agent: "codex", + displayName: "Codex CLI", + binaryNames: ["codex"], + installCommand: npmGlobalInstallCommand("@openai/codex"), + authCommand: "codex login", + missingErrorPatterns: [ + /\bcodex\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+codex\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bcodex\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i, + /\brun\s+[`'"]?codex\s+login[`'"]?/i, + ], + }, + { + agent: "opencode", + displayName: "OpenCode", + binaryNames: ["opencode"], + installCommand: npmGlobalInstallCommand("opencode-ai"), + authCommand: "opencode auth login", + missingErrorPatterns: [ + /\bopencode\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+opencode\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bopencode\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i, + ], + }, + { + agent: "cursor", + displayName: "Cursor Agent", + binaryNames: ["cursor-agent", "cursor"], + installCommand: 'mkdir -p "$HOME/.local/bin" && curl https://cursor.com/install -fsS | bash', + authCommand: "cursor-agent login", + missingErrorPatterns: [ + /\bcursor(?:-agent)?\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+cursor(?:-agent)?\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bcursor(?:-agent)?\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i, + ], + }, +]; + +function descriptorMatchesPreferred(descriptor: AgentCliDescriptor, preferredAgent: string | null | undefined): boolean { + if (!preferredAgent) return false; + const normalized = preferredAgent.trim().toLowerCase(); + return descriptor.agent === normalized + || descriptor.displayName.toLowerCase().includes(normalized) + || descriptor.binaryNames.some((name) => name.toLowerCase() === normalized); +} + +function descriptorMentioned(descriptor: AgentCliDescriptor, text: string): boolean { + return descriptor.binaryNames.some((name) => new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(text)) + || new RegExp(`\\b${descriptor.agent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(text); +} + +function toMatch(descriptor: AgentCliDescriptor, category: AgentCliErrorCategory): AgentCliErrorMatch { + return { + agent: descriptor.agent, + displayName: descriptor.displayName, + category, + installCommand: descriptor.installCommand, + authCommand: descriptor.authCommand, + }; +} + +export function classifyAgentCliError(message: string, preferredAgent?: string | null): AgentCliErrorMatch | null { + const text = message.trim(); + if (!text) return null; + const preferred = AGENT_CLI_REGISTRY.find((descriptor) => descriptorMatchesPreferred(descriptor, preferredAgent)); + const candidates = preferred + ? [preferred, ...AGENT_CLI_REGISTRY.filter((descriptor) => descriptor !== preferred)] + : AGENT_CLI_REGISTRY; + + for (const descriptor of candidates) { + const mentioned = descriptorMentioned(descriptor, text); + if (!mentioned && descriptor !== preferred) continue; + if (descriptor.missingErrorPatterns.some((pattern) => pattern.test(text))) { + return toMatch(descriptor, "missing"); + } + if (descriptor.notAuthErrorPatterns.some((pattern) => pattern.test(text))) { + return toMatch(descriptor, "unauthenticated"); + } + } + + if (preferred) { + if (/\b(command not found|not recognized|enoent|executable file not found|no such file or directory)\b/i.test(text)) { + return toMatch(preferred, "missing"); + } + if (/\b(not logged in|not authenticated|unauthorized|authentication failed|login required|invalid api key|401|403)\b/i.test(text)) { + return toMatch(preferred, "unauthenticated"); + } + } + + return null; +} diff --git a/apps/ade-cli/src/services/credentials/credentialStore.test.ts b/apps/ade-cli/src/services/credentials/credentialStore.test.ts new file mode 100644 index 000000000..73b1d7a53 --- /dev/null +++ b/apps/ade-cli/src/services/credentials/credentialStore.test.ts @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + ElectronSafeStorageCredentialStore, + EncryptedFileCredentialStore, + KeytarCredentialStore, + createDefaultCredentialStore, +} from "./credentialStore"; + +let tempDir = ""; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-credentials-")); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("EncryptedFileCredentialStore", () => { + it("persists credentials encrypted on disk", async () => { + const store = new EncryptedFileCredentialStore({ secretsDir: tempDir }); + + await store.set("linear.token.v1", "lin_secret"); + + expect(await store.get("linear.token.v1")).toBe("lin_secret"); + expect(fs.readFileSync(path.join(tempDir, "credentials.json.enc"), "utf8")).not.toContain("lin_secret"); + + const reloaded = new EncryptedFileCredentialStore({ secretsDir: tempDir }); + expect(reloaded.getSync("linear.token.v1")).toBe("lin_secret"); + }); + + it("deletes credentials without removing the machine key", async () => { + const store = new EncryptedFileCredentialStore({ secretsDir: tempDir }); + + await store.set("agent.token", "secret"); + await store.delete("agent.token"); + + expect(await store.get("agent.token")).toBeNull(); + expect(fs.existsSync(path.join(tempDir, ".machine-key"))).toBe(true); + }); +}); + +describe("ElectronSafeStorageCredentialStore", () => { + it("delegates encryption to the injected safeStorage implementation", async () => { + const safeStorage = { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`enc:${value}`, "utf8"), + decryptString: (value: Buffer) => value.toString("utf8").replace(/^enc:/, ""), + }; + const store = new ElectronSafeStorageCredentialStore({ secretsDir: tempDir, safeStorage }); + + await store.set("openai", "sk-test"); + + expect(await store.get("openai")).toBe("sk-test"); + expect(fs.readFileSync(path.join(tempDir, "credentials.json.enc"), "utf8")).toContain("enc:"); + }); +}); + +describe("KeytarCredentialStore", () => { + it("uses keytar account names without touching the filesystem", async () => { + const values = new Map(); + const store = new KeytarCredentialStore({ + keytar: { + async getPassword(service, account) { + return values.get(`${service}:${account}`) ?? null; + }, + async setPassword(service, account, password) { + values.set(`${service}:${account}`, password); + }, + async deletePassword(service, account) { + return values.delete(`${service}:${account}`); + }, + }, + service: "test.service", + }); + + await store.set("cursor", "cur_secret"); + expect(await store.get("cursor")).toBe("cur_secret"); + await store.delete("cursor"); + expect(await store.get("cursor")).toBeNull(); + }); +}); + +describe("createDefaultCredentialStore", () => { + it("falls back to encrypted-file storage when keytar is disabled", async () => { + const store = await createDefaultCredentialStore({ + env: { ADE_CREDENTIAL_STORE_DISABLE_KEYTAR: "1" } as NodeJS.ProcessEnv, + secretsDir: tempDir, + }); + + await store.set("codex", "token"); + + expect(await store.get("codex")).toBe("token"); + expect(fs.existsSync(path.join(tempDir, "credentials.json.enc"))).toBe(true); + }); +}); diff --git a/apps/ade-cli/src/services/credentials/credentialStore.ts b/apps/ade-cli/src/services/credentials/credentialStore.ts new file mode 100644 index 000000000..cbee0ab18 --- /dev/null +++ b/apps/ade-cli/src/services/credentials/credentialStore.ts @@ -0,0 +1,331 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { resolveMachineAdeLayout } from "../projects/machineLayout"; + +export interface CredentialStore { + get(key: string): Promise; + set(key: string, value: string): Promise; + delete(key: string): Promise; +} + +export type SyncCredentialStore = CredentialStore & { + getSync(key: string): string | null; + setSync(key: string, value: string): void; + deleteSync(key: string): void; +}; + +type StoredCredentialEnvelope = { + version: 1; + alg: "aes-256-gcm"; + iv: string; + tag: string; + ciphertext: string; +}; + +type SafeStorageLike = { + isEncryptionAvailable(): boolean; + encryptString(value: string): Buffer; + decryptString(value: Buffer): string; +}; + +const DEFAULT_CREDENTIALS_FILE = "credentials.json.enc"; +const DEFAULT_MACHINE_KEY_FILE = ".machine-key"; +const STORE_AAD = Buffer.from("ade.credentials.v1"); + +function normalizeKey(key: string): string { + const normalized = key.trim(); + if (!normalized.length) throw new Error("Credential key is required."); + if (normalized.includes("\0")) throw new Error("Credential key cannot contain null bytes."); + return normalized; +} + +function ensureMode600(filePath: string): void { + if (process.platform === "win32") return; + try { + fs.chmodSync(filePath, 0o600); + } catch { + // Best effort; some filesystems do not support chmod. + } +} + +function writeFileAtomic(filePath: string, contents: string | Buffer): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmpPath, contents); + ensureMode600(tmpPath); + fs.renameSync(tmpPath, filePath); + ensureMode600(filePath); +} + +function isEnoent(error: unknown): boolean { + return typeof error === "object" + && error !== null + && "code" in error + && (error as { code?: unknown }).code === "ENOENT"; +} + +function readJsonObject(filePath: string): Record | null { + try { + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return parsed as Record; + } catch (error: unknown) { + if (isEnoent(error)) return {}; + throw error; + } +} + +function serializeStore(values: Record, machineKey: Buffer): StoredCredentialEnvelope { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", machineKey, iv); + cipher.setAAD(STORE_AAD); + const ciphertext = Buffer.concat([ + cipher.update(JSON.stringify(values), "utf8"), + cipher.final(), + ]); + return { + version: 1, + alg: "aes-256-gcm", + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + ciphertext: ciphertext.toString("base64"), + }; +} + +function deserializeStore(raw: Record | null, machineKey: Buffer): Record { + if (!raw || Object.keys(raw).length === 0) return {}; + if (raw.version !== 1 || raw.alg !== "aes-256-gcm") { + throw new Error("Unsupported ADE credential store format."); + } + if (typeof raw.iv !== "string" || typeof raw.tag !== "string" || typeof raw.ciphertext !== "string") { + throw new Error("ADE credential store is malformed."); + } + const decipher = crypto.createDecipheriv("aes-256-gcm", machineKey, Buffer.from(raw.iv, "base64")); + decipher.setAAD(STORE_AAD); + decipher.setAuthTag(Buffer.from(raw.tag, "base64")); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(raw.ciphertext, "base64")), + decipher.final(), + ]).toString("utf8"); + const parsed = JSON.parse(plaintext) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value === "string") out[key] = value; + } + return out; +} + +function readOrCreateMachineKey(machineKeyPath: string): Buffer { + try { + const raw = fs.readFileSync(machineKeyPath, "utf8").trim(); + const key = Buffer.from(raw, "base64"); + if (key.length === 32) return key; + throw new Error("ADE credential machine key is invalid."); + } catch (error: unknown) { + if (!isEnoent(error)) throw error; + } + const key = crypto.randomBytes(32); + writeFileAtomic(machineKeyPath, `${key.toString("base64")}\n`); + return key; +} + +export class EncryptedFileCredentialStore implements SyncCredentialStore { + private readonly credentialsPath: string; + private readonly machineKeyPath: string; + + constructor(args: { secretsDir?: string; credentialsPath?: string; machineKeyPath?: string } = {}) { + const secretsDir = args.secretsDir ?? resolveMachineAdeLayout().secretsDir; + this.credentialsPath = args.credentialsPath ?? path.join(secretsDir, DEFAULT_CREDENTIALS_FILE); + this.machineKeyPath = args.machineKeyPath ?? path.join(secretsDir, DEFAULT_MACHINE_KEY_FILE); + } + + async get(key: string): Promise { + return this.getSync(key); + } + + async set(key: string, value: string): Promise { + this.setSync(key, value); + } + + async delete(key: string): Promise { + this.deleteSync(key); + } + + getSync(key: string): string | null { + const normalized = normalizeKey(key); + return this.readAll()[normalized] ?? null; + } + + setSync(key: string, value: string): void { + const normalized = normalizeKey(key); + const nextValue = value.trim(); + if (!nextValue.length) { + this.deleteSync(normalized); + return; + } + const values = this.readAll(); + values[normalized] = nextValue; + this.writeAll(values); + } + + deleteSync(key: string): void { + const normalized = normalizeKey(key); + const values = this.readAll(); + if (!(normalized in values)) return; + delete values[normalized]; + this.writeAll(values); + } + + private readAll(): Record { + const key = readOrCreateMachineKey(this.machineKeyPath); + return deserializeStore(readJsonObject(this.credentialsPath), key); + } + + private writeAll(values: Record): void { + const key = readOrCreateMachineKey(this.machineKeyPath); + writeFileAtomic(this.credentialsPath, `${JSON.stringify(serializeStore(values, key), null, 2)}\n`); + } +} + +export class ElectronSafeStorageCredentialStore implements SyncCredentialStore { + private readonly safeStorage: SafeStorageLike; + private readonly credentialsPath: string; + + constructor(args: { safeStorage: SafeStorageLike; credentialsPath?: string; secretsDir?: string }) { + this.safeStorage = args.safeStorage; + const secretsDir = args.secretsDir ?? resolveMachineAdeLayout().secretsDir; + this.credentialsPath = args.credentialsPath ?? path.join(secretsDir, DEFAULT_CREDENTIALS_FILE); + } + + async get(key: string): Promise { + return this.getSync(key); + } + + async set(key: string, value: string): Promise { + this.setSync(key, value); + } + + async delete(key: string): Promise { + this.deleteSync(key); + } + + getSync(key: string): string | null { + const normalized = normalizeKey(key); + return this.readAll()[normalized] ?? null; + } + + setSync(key: string, value: string): void { + const normalized = normalizeKey(key); + const nextValue = value.trim(); + if (!nextValue.length) { + this.deleteSync(normalized); + return; + } + const values = this.readAll(); + values[normalized] = nextValue; + this.writeAll(values); + } + + deleteSync(key: string): void { + const normalized = normalizeKey(key); + const values = this.readAll(); + if (!(normalized in values)) return; + delete values[normalized]; + this.writeAll(values); + } + + private readAll(): Record { + if (!this.safeStorage.isEncryptionAvailable()) { + throw new Error("Electron safeStorage is unavailable."); + } + try { + const encrypted = fs.readFileSync(this.credentialsPath); + const decrypted = this.safeStorage.decryptString(encrypted); + const parsed = JSON.parse(decrypted) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value === "string") out[key] = value; + } + return out; + } catch (error: unknown) { + if (isEnoent(error)) return {}; + throw error; + } + } + + private writeAll(values: Record): void { + if (!this.safeStorage.isEncryptionAvailable()) { + throw new Error("Electron safeStorage is unavailable."); + } + writeFileAtomic(this.credentialsPath, this.safeStorage.encryptString(JSON.stringify(values))); + } +} + +type KeytarModule = { + getPassword(service: string, account: string): Promise; + setPassword(service: string, account: string, password: string): Promise; + deletePassword(service: string, account: string): Promise; +}; + +async function loadOptionalKeytar(): Promise { + try { + const dynamicImport = new Function("specifier", "return import(specifier)") as (specifier: string) => Promise; + const mod = await dynamicImport("keytar"); + const candidate = (mod && typeof mod === "object" && "default" in mod ? (mod as { default: unknown }).default : mod) as Partial; + if ( + typeof candidate.getPassword === "function" + && typeof candidate.setPassword === "function" + && typeof candidate.deletePassword === "function" + ) { + return candidate as KeytarModule; + } + } catch { + return null; + } + return null; +} + +export class KeytarCredentialStore implements CredentialStore { + private readonly keytar: KeytarModule; + private readonly service: string; + + constructor(args: { keytar: KeytarModule; service?: string }) { + this.keytar = args.keytar; + this.service = args.service ?? "com.ade.runtime.credentials.v1"; + } + + async get(key: string): Promise { + return this.keytar.getPassword(this.service, normalizeKey(key)); + } + + async set(key: string, value: string): Promise { + const normalized = normalizeKey(key); + const nextValue = value.trim(); + if (!nextValue.length) { + await this.delete(normalized); + return; + } + await this.keytar.setPassword(this.service, normalized, nextValue); + } + + async delete(key: string): Promise { + await this.keytar.deletePassword(this.service, normalizeKey(key)); + } +} + +export async function createDefaultCredentialStore(args: { + env?: NodeJS.ProcessEnv; + secretsDir?: string; + preferKeytar?: boolean; +} = {}): Promise { + const env = args.env ?? process.env; + if (args.preferKeytar !== false && env.ADE_CREDENTIAL_STORE_DISABLE_KEYTAR !== "1") { + const keytar = await loadOptionalKeytar(); + if (keytar) return new KeytarCredentialStore({ keytar }); + } + return new EncryptedFileCredentialStore({ secretsDir: args.secretsDir }); +} diff --git a/apps/ade-cli/src/services/projects/machineLayout.test.ts b/apps/ade-cli/src/services/projects/machineLayout.test.ts new file mode 100644 index 000000000..9d81bb473 --- /dev/null +++ b/apps/ade-cli/src/services/projects/machineLayout.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { resolveMachineAdeLayout } from "./machineLayout"; + +describe("resolveMachineAdeLayout", () => { + it("keeps the stable Windows runtime pipe name for the default ADE home", () => { + const layout = resolveMachineAdeLayout( + { ADE_HOME: "/Users/arul/.ade" }, + "win32", + ); + + expect(layout.socketPath).toBe("\\\\.\\pipe\\ade-runtime"); + }); + + it("uses distinct Windows runtime pipes for channel ADE homes", () => { + const alpha = resolveMachineAdeLayout( + { ADE_HOME: "/Users/arul/.ade-alpha" }, + "win32", + ); + const beta = resolveMachineAdeLayout( + { ADE_HOME: "/Users/arul/.ade-beta" }, + "win32", + ); + + expect(alpha.socketPath).toBe("\\\\.\\pipe\\ade-runtime-ade-alpha"); + expect(beta.socketPath).toBe("\\\\.\\pipe\\ade-runtime-ade-beta"); + }); +}); diff --git a/apps/ade-cli/src/services/projects/machineLayout.ts b/apps/ade-cli/src/services/projects/machineLayout.ts new file mode 100644 index 000000000..2790962eb --- /dev/null +++ b/apps/ade-cli/src/services/projects/machineLayout.ts @@ -0,0 +1,45 @@ +import os from "node:os"; +import path from "node:path"; + +export type MachineAdeLayout = { + adeDir: string; + projectsPath: string; + secretsDir: string; + sockDir: string; + socketPath: string; + binDir: string; + runtimeDir: string; +}; + +export function resolveMachineAdeDir(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.ADE_HOME?.trim(); + if (explicit) return path.resolve(explicit); + return path.join(os.homedir(), ".ade"); +} + +function windowsPipePathForAdeDir(adeDir: string): string { + const homeName = path.basename(adeDir).replace(/[^a-zA-Z0-9_-]+/g, "-"); + if (!homeName || homeName === "-ade") return "\\\\.\\pipe\\ade-runtime"; + return `\\\\.\\pipe\\ade-runtime-${homeName.replace(/^-+/, "")}`; +} + +export function resolveMachineAdeLayout( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): MachineAdeLayout { + const adeDir = resolveMachineAdeDir(env); + const secretsDir = path.join(adeDir, "secrets"); + const sockDir = path.join(adeDir, "sock"); + const socketPath = platform === "win32" + ? windowsPipePathForAdeDir(adeDir) + : path.join(sockDir, "ade.sock"); + return { + adeDir, + projectsPath: path.join(adeDir, "projects.json"), + secretsDir, + sockDir, + socketPath, + binDir: path.join(adeDir, "bin"), + runtimeDir: path.join(adeDir, "runtime"), + }; +} diff --git a/apps/ade-cli/src/services/projects/projectRegistry.test.ts b/apps/ade-cli/src/services/projects/projectRegistry.test.ts new file mode 100644 index 000000000..6d081cb03 --- /dev/null +++ b/apps/ade-cli/src/services/projects/projectRegistry.test.ts @@ -0,0 +1,123 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ProjectRegistry, + deriveProjectId, + isDisallowedProjectRoot, +} from "./projectRegistry"; + +const tempRoots = new Set(); + +function makeTempRoot(prefix = "ade-project-registry-"): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempRoots.add(root); + return root; +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const root of tempRoots) { + fs.rmSync(root, { recursive: true, force: true }); + } + tempRoots.clear(); +}); + +describe("ProjectRegistry", () => { + it("rejects registering the user home directory", () => { + const homeDir = makeTempRoot("ade-project-registry-home-"); + vi.spyOn(os, "homedir").mockReturnValue(homeDir); + const registry = new ProjectRegistry({ + adeDir: path.join(homeDir, ".ade-runtime"), + projectsPath: path.join(homeDir, ".ade-runtime", "projects.json"), + secretsDir: path.join(homeDir, ".ade-runtime", "secrets"), + sockDir: path.join(homeDir, ".ade-runtime", "sock"), + socketPath: path.join(homeDir, ".ade-runtime", "sock", "ade.sock"), + binDir: path.join(homeDir, ".ade-runtime", "bin"), + runtimeDir: path.join(homeDir, ".ade-runtime", "runtime"), + }); + + expect(isDisallowedProjectRoot(homeDir)).toBe(true); + expect(() => registry.add(homeDir)).toThrow(/Refusing to register/); + }); + + it("filters already-persisted user home entries from project lists", () => { + const homeDir = makeTempRoot("ade-project-registry-home-"); + const rawProjectRoot = path.join(homeDir, "Projects", "ADE"); + const registryDir = path.join(homeDir, ".ade-runtime"); + fs.mkdirSync(rawProjectRoot, { recursive: true }); + const projectRoot = fs.realpathSync.native(rawProjectRoot); + fs.mkdirSync(registryDir, { recursive: true }); + vi.spyOn(os, "homedir").mockReturnValue(homeDir); + const projectsPath = path.join(registryDir, "projects.json"); + fs.writeFileSync( + projectsPath, + `${JSON.stringify({ + version: 1, + projects: [ + { + projectId: deriveProjectId(homeDir), + rootPath: homeDir, + displayName: "admin", + }, + { + projectId: deriveProjectId(projectRoot), + rootPath: projectRoot, + displayName: "ADE", + }, + ], + })}\n`, + "utf8", + ); + const registry = new ProjectRegistry({ + adeDir: registryDir, + projectsPath, + secretsDir: path.join(registryDir, "secrets"), + sockDir: path.join(registryDir, "sock"), + socketPath: path.join(registryDir, "sock", "ade.sock"), + binDir: path.join(registryDir, "bin"), + runtimeDir: path.join(registryDir, "runtime"), + }); + + expect(registry.list().map((project) => project.rootPath)).toEqual([ + projectRoot, + ]); + }); + + it("registers an ADE-managed lane worktree as the parent project", () => { + const homeDir = makeTempRoot("ade-project-registry-worktree-"); + const rawProjectRoot = path.join(homeDir, "ADE"); + fs.mkdirSync(path.join(rawProjectRoot, ".ade"), { recursive: true }); + const projectRoot = fs.realpathSync.native(rawProjectRoot); + const worktreeRoot = path.join(projectRoot, ".ade", "worktrees", "feature-a"); + fs.mkdirSync(path.join(worktreeRoot, "apps", "desktop"), { recursive: true }); + const registryDir = path.join(homeDir, ".ade-runtime"); + const registry = new ProjectRegistry({ + adeDir: registryDir, + projectsPath: path.join(registryDir, "projects.json"), + secretsDir: path.join(registryDir, "secrets"), + sockDir: path.join(registryDir, "sock"), + socketPath: path.join(registryDir, "sock", "ade.sock"), + binDir: path.join(registryDir, "bin"), + runtimeDir: path.join(registryDir, "runtime"), + }); + + const registered = registry.add(path.join(worktreeRoot, "apps", "desktop")); + + expect(registered.rootPath).toBe(projectRoot); + expect(registered.projectId).toBe(deriveProjectId(projectRoot)); + expect(fs.existsSync(path.join(worktreeRoot, ".ade"))).toBe(false); + }); + + it("derives the same project id for symlink aliases", () => { + const homeDir = makeTempRoot("ade-project-registry-alias-"); + const rawProjectRoot = path.join(homeDir, "ADE"); + const aliasRoot = path.join(homeDir, "ADE-link"); + fs.mkdirSync(rawProjectRoot, { recursive: true }); + const projectRoot = fs.realpathSync.native(rawProjectRoot); + fs.symlinkSync(projectRoot, aliasRoot, "dir"); + + expect(deriveProjectId(aliasRoot)).toBe(deriveProjectId(projectRoot)); + }); +}); diff --git a/apps/ade-cli/src/services/projects/projectRegistry.ts b/apps/ade-cli/src/services/projects/projectRegistry.ts new file mode 100644 index 000000000..6fa960850 --- /dev/null +++ b/apps/ade-cli/src/services/projects/projectRegistry.ts @@ -0,0 +1,235 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + resolveMachineAdeLayout, + type MachineAdeLayout, +} from "./machineLayout"; +import { normalizeProjectRootPath } from "./projectRoots"; + +export type ProjectId = string; + +export type ProjectRecord = { + projectId: ProjectId; + rootPath: string; + displayName: string; + addedAt: number; + lastOpenedAt: number; + gitOriginUrl: string | null; +}; + +type ProjectRegistryFile = { + version: 1; + projects: ProjectRecord[]; +}; + +function normalizeRoot(rootPath: string): string { + return normalizeProjectRootPath(rootPath); +} + +function isSamePath(left: string, right: string): boolean { + return normalizeRoot(left) === normalizeRoot(right); +} + +export function isDisallowedProjectRoot( + rootPath: string, + homeDir = os.homedir(), +): boolean { + const normalized = normalizeRoot(rootPath); + if (homeDir && isSamePath(normalized, homeDir)) return true; + return normalized === path.parse(normalized).root; +} + +export function deriveProjectId(rootPath: string): ProjectId { + const normalized = normalizeRoot(rootPath); + const digest = createHash("sha256") + .update(normalized) + .digest("hex") + .slice(0, 24); + return `project_${digest}`; +} + +function readGitOriginUrl(rootPath: string): string | null { + const result = spawnSync("git", ["config", "--get", "remote.origin.url"], { + cwd: rootPath, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5_000, + }); + if (result.status !== 0) return null; + const value = result.stdout.trim(); + return value.length ? value : null; +} + +function ensureProjectAdeDir(rootPath: string): void { + fs.mkdirSync(path.join(rootPath, ".ade"), { recursive: true }); +} + +function emptyFile(): ProjectRegistryFile { + return { version: 1, projects: [] }; +} + +function coerceRecord(value: unknown): ProjectRecord | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const rootPath = + typeof record.rootPath === "string" ? normalizeRoot(record.rootPath) : ""; + if (!rootPath) return null; + if (isDisallowedProjectRoot(rootPath)) return null; + const projectId = deriveProjectId(rootPath); + const now = Date.now(); + return { + projectId, + rootPath, + displayName: + typeof record.displayName === "string" && record.displayName.trim() + ? record.displayName.trim() + : path.basename(rootPath), + addedAt: + typeof record.addedAt === "number" && Number.isFinite(record.addedAt) + ? record.addedAt + : now, + lastOpenedAt: + typeof record.lastOpenedAt === "number" && + Number.isFinite(record.lastOpenedAt) + ? record.lastOpenedAt + : now, + gitOriginUrl: + typeof record.gitOriginUrl === "string" && record.gitOriginUrl.trim() + ? record.gitOriginUrl.trim() + : null, + }; +} + +export class ProjectRegistry { + private readonly layout: MachineAdeLayout; + + constructor(layout: MachineAdeLayout = resolveMachineAdeLayout()) { + this.layout = layout; + } + + get path(): string { + return this.layout.projectsPath; + } + + list(): ProjectRecord[] { + return this.read().projects; + } + + get(projectId: ProjectId): ProjectRecord | null { + return this.list().find((record) => record.projectId === projectId) ?? null; + } + + findByRootPath(rootPath: string): ProjectRecord | null { + const normalized = normalizeRoot(rootPath); + return this.list().find((record) => record.rootPath === normalized) ?? null; + } + + add(rootPath: string): ProjectRecord { + const normalized = normalizeRoot(rootPath); + if (isDisallowedProjectRoot(normalized)) { + throw new Error( + "Refusing to register the user home directory or filesystem root as an ADE project. Choose a project folder.", + ); + } + const stat = fs.statSync(normalized); + if (!stat.isDirectory()) { + throw new Error(`Project root is not a directory: ${normalized}`); + } + + ensureProjectAdeDir(normalized); + + const file = this.read(); + const now = Date.now(); + const projectId = deriveProjectId(normalized); + const existingIndex = file.projects.findIndex( + (record) => + record.projectId === projectId || record.rootPath === normalized, + ); + const existing = existingIndex >= 0 ? file.projects[existingIndex] : null; + const next: ProjectRecord = { + projectId, + rootPath: normalized, + displayName: existing?.displayName ?? path.basename(normalized), + addedAt: existing?.addedAt ?? now, + lastOpenedAt: now, + gitOriginUrl: readGitOriginUrl(normalized), + }; + if (existingIndex >= 0) { + file.projects[existingIndex] = next; + } else { + file.projects.push(next); + } + this.write(file); + return next; + } + + remove(projectId: ProjectId): boolean { + const file = this.read(); + const nextProjects = file.projects.filter( + (record) => record.projectId !== projectId, + ); + if (nextProjects.length === file.projects.length) return false; + this.write({ ...file, projects: nextProjects }); + return true; + } + + touch(projectId: ProjectId): ProjectRecord { + const file = this.read(); + const index = file.projects.findIndex( + (record) => record.projectId === projectId, + ); + if (index < 0) throw new Error(`Unknown projectId: ${projectId}`); + const next: ProjectRecord = { + ...file.projects[index]!, + lastOpenedAt: Date.now(), + gitOriginUrl: readGitOriginUrl(file.projects[index]!.rootPath), + }; + file.projects[index] = next; + this.write(file); + return next; + } + + private read(): ProjectRegistryFile { + try { + const raw = fs.readFileSync(this.layout.projectsPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + return emptyFile(); + const projects = Array.isArray( + (parsed as { projects?: unknown }).projects, + ) + ? (parsed as { projects: unknown[] }).projects + .map(coerceRecord) + .filter((entry): entry is ProjectRecord => entry != null) + : []; + const seen = new Set(); + return { + version: 1, + projects: projects.filter((project) => { + if (seen.has(project.rootPath)) return false; + seen.add(project.rootPath); + return true; + }), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return emptyFile(); + throw error; + } + } + + private write(file: ProjectRegistryFile): void { + fs.mkdirSync(this.layout.adeDir, { recursive: true, mode: 0o700 }); + fs.mkdirSync(path.dirname(this.layout.projectsPath), { + recursive: true, + mode: 0o700, + }); + const tempPath = `${this.layout.projectsPath}.${process.pid}.${Date.now()}.tmp`; + const payload = `${JSON.stringify({ version: 1, projects: file.projects }, null, 2)}\n`; + fs.writeFileSync(tempPath, payload, { encoding: "utf8", mode: 0o600 }); + fs.renameSync(tempPath, this.layout.projectsPath); + } +} diff --git a/apps/ade-cli/src/services/projects/projectRoots.ts b/apps/ade-cli/src/services/projects/projectRoots.ts new file mode 100644 index 000000000..8c6f5fb98 --- /dev/null +++ b/apps/ade-cli/src/services/projects/projectRoots.ts @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; + +export type ProjectRootResolution = { + projectRoot: string; + workspaceRoot: string; +}; + +export function realpathIfExists(value: string): string { + const resolved = path.resolve(value); + try { + return fs.realpathSync.native(resolved); + } catch { + return resolved; + } +} + +export function findAdeManagedWorktreeRoot(startDir: string): ProjectRootResolution | null { + const resolved = realpathIfExists(startDir); + const segments = resolved.split(path.sep); + for (let index = segments.length - 2; index >= 0; index -= 1) { + if (segments[index] !== ".ade" || segments[index + 1] !== "worktrees") continue; + const worktreeName = segments[index + 2]; + if (!worktreeName) continue; + const projectRoot = segments.slice(0, index).join(path.sep) || path.sep; + const workspaceRoot = segments.slice(0, index + 3).join(path.sep) || path.sep; + if (!fs.existsSync(path.join(projectRoot, ".ade"))) continue; + return { + projectRoot: realpathIfExists(projectRoot), + workspaceRoot: realpathIfExists(workspaceRoot), + }; + } + return null; +} + +export function normalizeProjectRootPath(rootPath: string): string { + const managedWorktree = findAdeManagedWorktreeRoot(rootPath); + if (managedWorktree) return managedWorktree.projectRoot; + return realpathIfExists(rootPath); +} diff --git a/apps/ade-cli/src/services/projects/projectScope.test.ts b/apps/ade-cli/src/services/projects/projectScope.test.ts new file mode 100644 index 000000000..46586af8c --- /dev/null +++ b/apps/ade-cli/src/services/projects/projectScope.test.ts @@ -0,0 +1,197 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ProjectRegistry } from "./projectRegistry"; +import { ProjectScopeRegistry } from "./projectScope"; + +const createAdeRuntimeMock = vi.fn(); + +vi.mock("../../bootstrap", () => ({ + createAdeRuntime: createAdeRuntimeMock, +})); + +function createRegistry() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-scope-")); + const projectsRoot = path.join(root, "projects"); + const firstProjectRoot = path.join(projectsRoot, "first"); + const secondProjectRoot = path.join(projectsRoot, "second"); + fs.mkdirSync(firstProjectRoot, { recursive: true }); + fs.mkdirSync(secondProjectRoot, { recursive: true }); + + const registry = new ProjectRegistry({ + adeDir: path.join(root, "home"), + projectsPath: path.join(root, "home", "projects.json"), + secretsDir: path.join(root, "home", "secrets"), + sockDir: path.join(root, "home", "sock"), + socketPath: path.join(root, "home", "sock", "ade.sock"), + binDir: path.join(root, "home", "bin"), + runtimeDir: path.join(root, "home", "runtime"), + }); + + return { + registry, + first: registry.add(firstProjectRoot), + second: registry.add(secondProjectRoot), + }; +} + +describe("ProjectScopeRegistry", () => { + beforeEach(() => { + createAdeRuntimeMock.mockReset(); + createAdeRuntimeMock.mockImplementation(async () => ({ + dispose: vi.fn(), + })); + }); + + it("starts sync discovery only for the first opened daemon project scope", async () => { + const { registry, first, second } = createRegistry(); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: true, + runtimeKind: "daemon", + appVersion: "test", + localDeviceIdPath: "/tmp/ade-sync-device", + phonePairingStateDir: "/tmp/ade-phone-pairing", + }, + }); + + await scopeRegistry.get(first.projectId); + await scopeRegistry.get(second.projectId); + + expect(createAdeRuntimeMock).toHaveBeenCalledTimes(2); + expect(createAdeRuntimeMock.mock.calls[0]?.[0]).toMatchObject({ + projectRoot: first.rootPath, + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + runtimeKind: "daemon", + }, + }); + expect(createAdeRuntimeMock.mock.calls[1]?.[0]).toMatchObject({ + projectRoot: second.rootPath, + syncRuntime: { + enabled: true, + hostStartupEnabled: false, + hostDiscoveryEnabled: false, + runtimeKind: "daemon", + }, + }); + + await scopeRegistry.disposeAll(); + }); + + it("does not pass sync runtime options when machine sync is disabled", async () => { + const { registry, first } = createRegistry(); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { enabled: false }, + }); + + await scopeRegistry.get(first.projectId); + + expect(createAdeRuntimeMock).toHaveBeenCalledTimes(1); + expect(createAdeRuntimeMock.mock.calls[0]?.[0]).not.toHaveProperty("syncRuntime"); + + await scopeRegistry.disposeAll(); + }); + + it("warms the most recently opened project as the sync host", async () => { + const { registry, first, second } = createRegistry(); + const file = JSON.parse(fs.readFileSync(registry.path, "utf8")) as { + projects: Array<{ projectId: string; lastOpenedAt: number; addedAt: number }>; + }; + file.projects = file.projects.map((project) => ({ + ...project, + lastOpenedAt: project.projectId === first.projectId ? 2_000 : 1_000, + addedAt: project.projectId === first.projectId ? 2_000 : 1_000, + })); + fs.writeFileSync(registry.path, JSON.stringify(file, null, 2)); + + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: true, + runtimeKind: "daemon", + }, + }); + + const scope = await scopeRegistry.ensureSyncHost(); + + expect(scope?.registryProjectId).toBe(first.projectId); + expect(createAdeRuntimeMock).toHaveBeenCalledTimes(1); + expect(createAdeRuntimeMock.mock.calls[0]?.[0]).toMatchObject({ + projectRoot: first.rootPath, + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + }, + }); + + await scopeRegistry.disposeAll(); + }); + + it("can switch the daemon sync host to a requested project", async () => { + const { registry, first, second } = createRegistry(); + const firstDispose = vi.fn(); + const secondDispose = vi.fn(); + const onDisposeProject = vi.fn(); + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: firstDispose }) + .mockResolvedValueOnce({ dispose: secondDispose }); + const scopeRegistry = new ProjectScopeRegistry(registry, { + onDisposeProject, + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: true, + runtimeKind: "daemon", + }, + }); + + await scopeRegistry.ensureSyncHost(first.projectId); + await scopeRegistry.ensureSyncHost(second.projectId); + + expect(firstDispose).toHaveBeenCalledTimes(1); + expect(onDisposeProject).toHaveBeenCalledWith(first.projectId); + expect(createAdeRuntimeMock).toHaveBeenCalledTimes(2); + expect(createAdeRuntimeMock.mock.calls[1]?.[0]).toMatchObject({ + projectRoot: second.rootPath, + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + }, + }); + + await scopeRegistry.disposeAll(); + expect(secondDispose).toHaveBeenCalledTimes(1); + }); + + it("passes runtime capability options into project runtimes", async () => { + const { registry, first } = createRegistry(); + const scopeRegistry = new ProjectScopeRegistry(registry, { + runtimeCapabilities: { + memory: false, + }, + }); + + await scopeRegistry.get(first.projectId); + + expect(createAdeRuntimeMock).toHaveBeenCalledTimes(1); + expect(createAdeRuntimeMock.mock.calls[0]?.[0]).toMatchObject({ + capabilities: { + memory: false, + }, + }); + + await scopeRegistry.disposeAll(); + }); +}); diff --git a/apps/ade-cli/src/services/projects/projectScope.ts b/apps/ade-cli/src/services/projects/projectScope.ts new file mode 100644 index 000000000..96632df3f --- /dev/null +++ b/apps/ade-cli/src/services/projects/projectScope.ts @@ -0,0 +1,176 @@ +import type { AdeRuntime, AdeRuntimeSyncOptions } from "../../bootstrap"; +import type { SyncCommandPayload } from "../../../../desktop/src/shared/types"; +import { ProjectRegistry, type ProjectId, type ProjectRecord } from "./projectRegistry"; + +export class ProjectScope { + readonly registryProjectId: ProjectId; + readonly record: ProjectRecord; + readonly runtime: AdeRuntime; + + constructor(args: { + registryProjectId: ProjectId; + record: ProjectRecord; + runtime: AdeRuntime; + }) { + this.registryProjectId = args.registryProjectId; + this.record = args.record; + this.runtime = args.runtime; + } + + dispose(): void { + this.runtime.dispose(); + } +} + +export class ProjectScopeRegistry { + private readonly scopes = new Map>(); + private readonly disposeListeners = new Set<(projectId: ProjectId) => void>(); + private syncHostProjectId: ProjectId | null = null; + private readonly remoteCommandExecutor = { + execute: async (payload: SyncCommandPayload): Promise => { + return await this.executeRemoteCommand(payload); + }, + }; + + constructor( + private readonly projectRegistry: ProjectRegistry, + private readonly options: { + syncRuntime?: AdeRuntimeSyncOptions; + runtimeCapabilities?: { + memory?: boolean; + }; + onDisposeProject?: (projectId: ProjectId) => void; + } = {}, + ) {} + + onDispose(listener: (projectId: ProjectId) => void): () => void { + this.disposeListeners.add(listener); + return () => { + this.disposeListeners.delete(listener); + }; + } + + async get(projectId: ProjectId): Promise { + const cached = this.scopes.get(projectId); + if (cached) return await cached; + + const record = this.projectRegistry.get(projectId); + if (!record) { + throw new Error(`Unknown projectId: ${projectId}`); + } + + const pending = (async () => { + this.projectRegistry.touch(projectId); + const syncRuntime = this.buildSyncRuntimeOptions(projectId); + const { createAdeRuntime } = await import("../../bootstrap"); + const runtime = await createAdeRuntime({ + projectRoot: record.rootPath, + workspaceRoot: record.rootPath, + chatRuntime: "agent", + capabilities: this.options.runtimeCapabilities, + ...(syncRuntime ? { syncRuntime } : {}), + }); + return new ProjectScope({ + registryProjectId: projectId, + record, + runtime, + }); + })(); + this.scopes.set(projectId, pending); + + try { + return await pending; + } catch (error) { + this.scopes.delete(projectId); + if (this.syncHostProjectId === projectId) { + this.syncHostProjectId = null; + } + throw error; + } + } + + async dispose(projectId: ProjectId): Promise { + const cached = this.scopes.get(projectId); + if (!cached) return; + this.scopes.delete(projectId); + const scope = await cached.catch(() => null); + scope?.dispose(); + if (this.syncHostProjectId === projectId) { + this.syncHostProjectId = null; + } + this.options.onDisposeProject?.(projectId); + for (const listener of this.disposeListeners) { + listener(projectId); + } + } + + async disposeAll(): Promise { + const projectIds = [...this.scopes.keys()]; + await Promise.all(projectIds.map((projectId) => this.dispose(projectId))); + } + + async ensureSyncHost(projectId?: ProjectId): Promise { + if (!this.options.syncRuntime?.enabled) return null; + if (projectId) { + if (this.scopes.has(projectId) && this.syncHostProjectId !== projectId) { + await this.dispose(projectId); + } + const existingHostId = this.syncHostProjectId; + if (existingHostId && existingHostId !== projectId) { + await this.dispose(existingHostId); + } + this.syncHostProjectId = projectId; + return await this.get(projectId); + } + + const existingHostId = this.syncHostProjectId; + if (existingHostId) { + try { + return await this.get(existingHostId); + } catch { + this.syncHostProjectId = null; + } + } + + const record = this.projectRegistry + .list() + .slice() + .sort((left, right) => { + const openedDelta = right.lastOpenedAt - left.lastOpenedAt; + return openedDelta !== 0 ? openedDelta : right.addedAt - left.addedAt; + })[0]; + return record ? this.get(record.projectId) : null; + } + + private buildSyncRuntimeOptions(projectId: ProjectId): AdeRuntimeSyncOptions | null { + const base = this.options.syncRuntime; + if (!base?.enabled) return null; + const isHost = this.syncHostProjectId === null || this.syncHostProjectId === projectId; + if (isHost && this.syncHostProjectId === null) { + this.syncHostProjectId = projectId; + } + return { + ...base, + enabled: true, + registryProjectId: projectId, + hostStartupEnabled: isHost ? base.hostStartupEnabled ?? true : false, + hostDiscoveryEnabled: isHost ? base.hostDiscoveryEnabled ?? true : false, + remoteCommandExecutor: base.remoteCommandExecutor ?? this.remoteCommandExecutor, + }; + } + + private async executeRemoteCommand(payload: SyncCommandPayload): Promise { + const projectId = typeof payload.projectId === "string" && payload.projectId.trim() + ? payload.projectId.trim() + : null; + if (!projectId) { + throw new Error(`Remote command ${payload.action} requires projectId.`); + } + const scope = await this.get(projectId); + const syncService = scope.runtime.syncService; + if (!syncService) { + throw new Error(`Phone sync is not available for project ${projectId}.`); + } + return await syncService.executeRemoteCommand(payload); + } +} diff --git a/apps/ade-cli/src/services/sync/deviceRegistryService.ts b/apps/ade-cli/src/services/sync/deviceRegistryService.ts new file mode 100644 index 000000000..c4facdf0f --- /dev/null +++ b/apps/ade-cli/src/services/sync/deviceRegistryService.ts @@ -0,0 +1,673 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { resolveAdeLayout } from "../../../../desktop/src/shared/adeLayout"; +import type { + SyncBrainStatusPayload, + SyncClusterState, + SyncDeviceRecord, + SyncPeerConnectionState, + SyncPeerDeviceType, + SyncPeerMetadata, + SyncPeerPlatform, +} from "../../../../desktop/src/shared/types"; +import { normalizeNotificationPreferences, type NotificationPreferences } from "../../../../desktop/src/shared/types/sync"; +import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; +import { mapPlatform } from "./syncProtocol"; +import { resolveTailscaleCliPath } from "./resolveTailscaleCliPath"; +import type { AdeDb } from "../../../../desktop/src/main/services/state/kvDb"; +import { nowIso, safeJsonParse, toOptionalString, uniqueStrings } from "../../../../desktop/src/main/services/shared/utils"; + +type DeviceRegistryServiceArgs = { + db: AdeDb; + logger: Logger; + projectRoot: string; + localDeviceIdPath?: string; +}; + +type DeviceRow = { + device_id: string; + site_id: string; + name: string; + platform: string; + device_type: string; + created_at: string; + updated_at: string; + last_seen_at: string | null; + last_host: string | null; + last_port: number | null; + tailscale_ip: string | null; + ip_addresses_json: string | null; + metadata_json: string | null; +}; + +type ClusterStateRow = { + cluster_id: string; + brain_device_id: string; + brain_epoch: number; + updated_at: string; + updated_by_device_id: string; +}; + +const DEVICE_ID_FILE = "sync-device-id"; +export const DEFAULT_SYNC_CLUSTER_ID = "default"; +const WORKSPACE_ACTIVITY_ID = "workspace"; +const TAILSCALE_STATUS_CACHE_MS = 30_000; + +let tailscaleStatusCache: + | { + expiresAt: number; + dnsName: string | null; + } + | null = null; + +function normalizeDeviceType(value: unknown): SyncPeerDeviceType { + const raw = typeof value === "string" ? value.trim() : ""; + if (raw === "desktop" || raw === "phone" || raw === "vps") return raw; + return "unknown"; +} + +function normalizePlatform(value: unknown): SyncPeerPlatform { + const raw = typeof value === "string" ? value.trim() : ""; + if (raw === "macOS" || raw === "linux" || raw === "windows" || raw === "iOS") return raw; + return "unknown"; +} + +function readJsonArray(raw: string | null | undefined): string[] { + return safeJsonParse(raw, []).filter((value) => typeof value === "string" && value.trim().length > 0); +} + +function mapDeviceRow(row: DeviceRow | null): SyncDeviceRecord | null { + if (!row) return null; + return { + deviceId: String(row.device_id), + siteId: String(row.site_id), + name: String(row.name), + platform: normalizePlatform(row.platform), + deviceType: normalizeDeviceType(row.device_type), + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + lastSeenAt: row.last_seen_at ? String(row.last_seen_at) : null, + lastHost: row.last_host ? String(row.last_host) : null, + lastPort: row.last_port == null ? null : Number(row.last_port), + tailscaleIp: row.tailscale_ip ? String(row.tailscale_ip) : null, + ipAddresses: readJsonArray(row.ip_addresses_json), + metadata: safeJsonParse>(row.metadata_json, {}), + }; +} + +function mapClusterStateRow(row: ClusterStateRow | null): SyncClusterState | null { + if (!row) return null; + return { + clusterId: String(row.cluster_id), + brainDeviceId: String(row.brain_device_id), + brainEpoch: Number(row.brain_epoch ?? 0), + updatedAt: String(row.updated_at), + updatedByDeviceId: String(row.updated_by_device_id), + }; +} + +type LocalNetworkMetadata = { + lanIpAddresses: string[]; + tailscaleIp: string | null; + tailscaleDnsName: string | null; +}; + +function isTailscaleAddress(ipAddress: string): boolean { + const parts = ipAddress.split("."); + if (parts.length !== 4) return false; + const octets = parts.map((part) => Number(part)); + if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false; + return octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127; +} + +function readLocalNetworkMetadata(): LocalNetworkMetadata { + const interfaces = os.networkInterfaces(); + const lan: string[] = []; + const tailscale: string[] = []; + for (const [interfaceName, entries] of Object.entries(interfaces)) { + const isLikelyTailscaleInterface = /tailscale|utun|tun/i.test(interfaceName); + for (const entry of entries ?? []) { + if (!entry || entry.internal || entry.family !== "IPv4") continue; + if (isLikelyTailscaleInterface || isTailscaleAddress(entry.address)) { + tailscale.push(entry.address); + } else { + lan.push(entry.address); + } + } + } + return { + lanIpAddresses: uniqueStrings(lan), + tailscaleIp: uniqueStrings(tailscale)[0] ?? null, + tailscaleDnsName: readLocalTailscaleDnsName(), + }; +} + +function normalizeTailscaleDnsName(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim().replace(/\.$/, "").toLowerCase(); + return normalized.endsWith(".ts.net") ? normalized : null; +} + +function readLocalTailscaleDnsName(): string | null { + const now = Date.now(); + if (tailscaleStatusCache && tailscaleStatusCache.expiresAt > now) { + return tailscaleStatusCache.dnsName; + } + let dnsName: string | null = null; + try { + const raw = execFileSync(resolveTailscaleCliPath(), ["status", "--json"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 1_000, + }); + const parsed = safeJsonParse<{ Self?: { DNSName?: unknown } }>(raw, {}); + dnsName = normalizeTailscaleDnsName(parsed.Self?.DNSName); + } catch { + dnsName = null; + } + tailscaleStatusCache = { + expiresAt: now + TAILSCALE_STATUS_CACHE_MS, + dnsName, + }; + return dnsName; +} + +function firstPreferredHost(ipAddresses: string[]): string { + return ipAddresses[0] ?? os.hostname(); +} + +export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { + const layout = resolveAdeLayout(args.projectRoot); + const deviceIdPath = args.localDeviceIdPath ?? path.join(layout.secretsDir, DEVICE_ID_FILE); + const legacyProjectDeviceIdPath = path.join(layout.secretsDir, DEVICE_ID_FILE); + fs.mkdirSync(path.dirname(deviceIdPath), { recursive: true }); + + const readOrCreateLocalDeviceId = (): string => { + // One desktop, one device id: the shared file is authoritative across + // projects so each project's `sync_cluster_state.brain_device_id` agrees + // on the same local identity. If the shared file is empty, seed it from + // the first legacy per-project id we happen to see (one-time migration), + // otherwise mint a fresh id. `O_EXCL` on the seed write keeps two + // concurrent project contexts from racing to mint different ids. + const shared = fs.existsSync(deviceIdPath) ? fs.readFileSync(deviceIdPath, "utf8").trim() : ""; + if (shared.length > 0) return shared; + + const legacy = deviceIdPath !== legacyProjectDeviceIdPath && fs.existsSync(legacyProjectDeviceIdPath) + ? fs.readFileSync(legacyProjectDeviceIdPath, "utf8").trim() + : ""; + const candidate = legacy.length > 0 ? legacy : randomUUID(); + try { + fs.writeFileSync(deviceIdPath, `${candidate}\n`, { flag: "wx" }); + return candidate; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + // Another context won the race; use whatever they wrote. + return fs.readFileSync(deviceIdPath, "utf8").trim(); + } + }; + + const localDeviceId = readOrCreateLocalDeviceId(); + const localSiteId = args.db.sync.getSiteId(); + + const getLocalDefaults = () => { + const network = readLocalNetworkMetadata(); + const metadata: Record = { + hostname: os.hostname(), + }; + if (network.tailscaleDnsName) { + metadata.tailscaleDnsName = network.tailscaleDnsName; + } + return { + name: os.hostname(), + platform: mapPlatform(process.platform), + deviceType: "desktop" as SyncPeerDeviceType, + ipAddresses: network.lanIpAddresses, + tailscaleIp: network.tailscaleIp, + lastHost: firstPreferredHost(network.lanIpAddresses), + metadata, + }; + }; + + const upsertDeviceRecord = (record: { + deviceId: string; + siteId: string; + name: string; + platform: SyncPeerPlatform; + deviceType: SyncPeerDeviceType; + createdAt?: string; + updatedAt?: string; + lastSeenAt?: string | null; + lastHost?: string | null; + lastPort?: number | null; + tailscaleIp?: string | null; + ipAddresses?: string[]; + metadata?: Record; + }): SyncDeviceRecord => { + const now = nowIso(); + const existing = mapDeviceRow(args.db.get("select * from devices where device_id = ? limit 1", [record.deviceId])); + const nextCreatedAt = record.createdAt ?? existing?.createdAt ?? now; + const nextUpdatedAt = record.updatedAt ?? now; + const nextIpAddresses = uniqueStrings(record.ipAddresses ?? existing?.ipAddresses ?? []); + const nextMetadata = { + ...(existing?.metadata ?? {}), + ...(record.metadata ?? {}), + }; + args.db.run( + ` + insert into devices( + device_id, site_id, name, platform, device_type, + created_at, updated_at, last_seen_at, last_host, last_port, + tailscale_ip, ip_addresses_json, metadata_json + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(device_id) do update set + site_id = excluded.site_id, + name = excluded.name, + platform = excluded.platform, + device_type = excluded.device_type, + updated_at = excluded.updated_at, + last_seen_at = excluded.last_seen_at, + last_host = excluded.last_host, + last_port = excluded.last_port, + tailscale_ip = excluded.tailscale_ip, + ip_addresses_json = excluded.ip_addresses_json, + metadata_json = excluded.metadata_json + `, + [ + record.deviceId, + record.siteId, + record.name, + record.platform, + record.deviceType, + nextCreatedAt, + nextUpdatedAt, + record.lastSeenAt ?? existing?.lastSeenAt ?? null, + record.lastHost ?? existing?.lastHost ?? null, + record.lastPort ?? existing?.lastPort ?? null, + record.tailscaleIp ?? existing?.tailscaleIp ?? null, + JSON.stringify(nextIpAddresses), + JSON.stringify(nextMetadata), + ], + ); + return mapDeviceRow(args.db.get("select * from devices where device_id = ? limit 1", [record.deviceId]))!; + }; + + const ensureLocalDevice = (): SyncDeviceRecord => { + const existing = mapDeviceRow(args.db.get("select * from devices where device_id = ? limit 1", [localDeviceId])); + const defaults = getLocalDefaults(); + return upsertDeviceRecord({ + deviceId: localDeviceId, + siteId: localSiteId, + name: existing?.name ?? defaults.name, + platform: existing?.platform ?? defaults.platform, + deviceType: existing?.deviceType ?? defaults.deviceType, + lastSeenAt: nowIso(), + lastHost: defaults.lastHost ?? existing?.lastHost ?? null, + lastPort: existing?.lastPort ?? null, + tailscaleIp: defaults.tailscaleIp ?? existing?.tailscaleIp ?? null, + ipAddresses: defaults.ipAddresses.length > 0 ? defaults.ipAddresses : (existing?.ipAddresses ?? []), + metadata: { + ...(existing?.metadata ?? {}), + ...defaults.metadata, + }, + }); + }; + + const listDevices = (): SyncDeviceRecord[] => { + return args.db + .all("select * from devices order by case when device_id = ? then 0 else 1 end, name collate nocase asc", [localDeviceId]) + .map((row) => mapDeviceRow(row)) + .filter((row): row is SyncDeviceRecord => row != null); + }; + + const getDevice = (deviceId: string): SyncDeviceRecord | null => { + const normalized = deviceId.trim(); + if (!normalized) return null; + return mapDeviceRow(args.db.get("select * from devices where device_id = ? limit 1", [normalized])); + }; + + const getClusterState = (): SyncClusterState | null => { + return mapClusterStateRow( + args.db.get("select * from sync_cluster_state where cluster_id = ? limit 1", [DEFAULT_SYNC_CLUSTER_ID]), + ); + }; + + const setClusterState = (argsIn: { + brainDeviceId: string; + brainEpoch: number; + updatedByDeviceId?: string; + }): SyncClusterState => { + const now = nowIso(); + args.db.run( + ` + insert into sync_cluster_state(cluster_id, brain_device_id, brain_epoch, updated_at, updated_by_device_id) + values (?, ?, ?, ?, ?) + on conflict(cluster_id) do update set + brain_device_id = excluded.brain_device_id, + brain_epoch = excluded.brain_epoch, + updated_at = excluded.updated_at, + updated_by_device_id = excluded.updated_by_device_id + `, + [ + DEFAULT_SYNC_CLUSTER_ID, + argsIn.brainDeviceId, + argsIn.brainEpoch, + now, + argsIn.updatedByDeviceId ?? localDeviceId, + ], + ); + return getClusterState()!; + }; + + const bootstrapLocalBrainIfNeeded = (): SyncClusterState => { + const existing = getClusterState(); + if (existing) return existing; + ensureLocalDevice(); + return setClusterState({ + brainDeviceId: localDeviceId, + brainEpoch: 1, + updatedByDeviceId: localDeviceId, + }); + }; + + const updateLocalDevice = (updates: { + name?: string; + deviceType?: SyncPeerDeviceType; + }): SyncDeviceRecord => { + const current = ensureLocalDevice(); + return upsertDeviceRecord({ + deviceId: localDeviceId, + siteId: localSiteId, + name: toOptionalString(updates.name) ?? current.name, + platform: current.platform, + deviceType: updates.deviceType ?? current.deviceType, + lastSeenAt: nowIso(), + lastHost: current.lastHost, + lastPort: current.lastPort, + tailscaleIp: current.tailscaleIp, + ipAddresses: current.ipAddresses, + metadata: current.metadata, + }); + }; + + const touchLocalDevice = (argsIn: { + lastSeenAt?: string | null; + lastHost?: string | null; + lastPort?: number | null; + metadata?: Record; + } = {}): SyncDeviceRecord => { + const current = ensureLocalDevice(); + const network = readLocalNetworkMetadata(); + return upsertDeviceRecord({ + deviceId: current.deviceId, + siteId: current.siteId, + name: current.name, + platform: current.platform, + deviceType: current.deviceType, + lastSeenAt: argsIn.lastSeenAt ?? nowIso(), + lastHost: argsIn.lastHost ?? current.lastHost ?? firstPreferredHost(network.lanIpAddresses), + lastPort: argsIn.lastPort ?? current.lastPort, + tailscaleIp: network.tailscaleIp ?? current.tailscaleIp, + ipAddresses: network.lanIpAddresses.length > 0 ? network.lanIpAddresses : current.ipAddresses, + metadata: { + ...current.metadata, + ...(argsIn.metadata ?? {}), + }, + }); + }; + + const upsertPeerMetadata = ( + peer: SyncPeerMetadata | SyncPeerConnectionState, + extras: { + lastSeenAt?: string | null; + lastHost?: string | null; + lastPort?: number | null; + metadata?: Record; + } = {}, + ): SyncDeviceRecord => { + return upsertDeviceRecord({ + deviceId: peer.deviceId, + siteId: peer.siteId, + name: peer.deviceName, + platform: peer.platform, + deviceType: peer.deviceType, + lastSeenAt: extras.lastSeenAt ?? ("lastSeenAt" in peer ? peer.lastSeenAt : nowIso()), + lastHost: extras.lastHost ?? ("remoteAddress" in peer ? peer.remoteAddress : null), + lastPort: extras.lastPort ?? ("remotePort" in peer ? peer.remotePort : null), + metadata: { + dbVersion: peer.dbVersion, + ...(extras.metadata ?? {}), + }, + }); + }; + + type ApnsTokenKind = "alert" | "activity-start" | "activity-update"; + + const apnsMetaKey = (kind: ApnsTokenKind): string => { + if (kind === "alert") return "apnsAlertToken"; + if (kind === "activity-start") return "apnsActivityStartToken"; + return "apnsActivityUpdateTokens"; + }; + + const setApnsToken = ( + deviceId: string, + token: string, + kind: ApnsTokenKind, + env: "sandbox" | "production", + extras: { bundleId?: string; activityId?: string } = {}, + ): SyncDeviceRecord | null => { + const device = getDevice(deviceId); + if (!device) return null; + const nextMetadata: Record = { + ...device.metadata, + apnsEnv: env, + apnsTokenUpdatedAt: nowIso(), + }; + if (extras.bundleId) nextMetadata.apnsBundleId = extras.bundleId; + if (kind === "activity-update") { + const existing = (device.metadata.apnsActivityUpdateTokens as Record | undefined) ?? {}; + const activityId = extras.activityId?.trim() || WORKSPACE_ACTIVITY_ID; + nextMetadata.apnsActivityUpdateTokens = { ...existing, [activityId]: token }; + } else { + nextMetadata[apnsMetaKey(kind)] = token; + } + return upsertDeviceRecord({ + deviceId: device.deviceId, + siteId: device.siteId, + name: device.name, + platform: device.platform, + deviceType: device.deviceType, + lastSeenAt: device.lastSeenAt, + lastHost: device.lastHost, + lastPort: device.lastPort, + tailscaleIp: device.tailscaleIp, + ipAddresses: device.ipAddresses, + metadata: nextMetadata, + }); + }; + + const getApnsTokenForDevice = ( + deviceId: string, + kind: ApnsTokenKind, + activityId?: string, + ): string | null => { + const device = getDevice(deviceId); + if (!device) return null; + if (kind === "activity-update") { + const map = (device.metadata.apnsActivityUpdateTokens as Record | undefined) ?? {}; + return map[activityId?.trim() || WORKSPACE_ACTIVITY_ID] ?? null; + } + const raw = device.metadata[apnsMetaKey(kind)]; + return typeof raw === "string" && raw.trim().length > 0 ? raw : null; + }; + + const setNotificationPreferences = ( + deviceId: string, + prefs: NotificationPreferences, + ): SyncDeviceRecord | null => { + const device = getDevice(deviceId); + if (!device) return null; + const normalizedPrefs = normalizeNotificationPreferences(prefs); + return upsertDeviceRecord({ + deviceId: device.deviceId, + siteId: device.siteId, + name: device.name, + platform: device.platform, + deviceType: device.deviceType, + lastSeenAt: device.lastSeenAt, + lastHost: device.lastHost, + lastPort: device.lastPort, + tailscaleIp: device.tailscaleIp, + ipAddresses: device.ipAddresses, + metadata: { + ...device.metadata, + notificationPreferences: normalizedPrefs, + notificationPreferencesUpdatedAt: nowIso(), + }, + }); + }; + + const getNotificationPreferences = (deviceId: string): NotificationPreferences | null => { + const prefs = getDevice(deviceId)?.metadata.notificationPreferences; + if (!prefs || typeof prefs !== "object" || Array.isArray(prefs)) return null; + return normalizeNotificationPreferences(prefs); + }; + + const invalidateApnsToken = (deviceToken: string): void => { + const token = deviceToken.trim(); + if (!token) return; + const device = findDeviceByApnsToken(token); + if (!device) return; + const nextMetadata = { ...device.metadata }; + if (nextMetadata.apnsAlertToken === token) { + delete nextMetadata.apnsAlertToken; + } + if (nextMetadata.apnsActivityStartToken === token) { + delete nextMetadata.apnsActivityStartToken; + } + const updates = nextMetadata.apnsActivityUpdateTokens; + if (updates && typeof updates === "object" && !Array.isArray(updates)) { + const nextUpdates = { ...(updates as Record) }; + for (const [activityId, value] of Object.entries(nextUpdates)) { + if (value === token) delete nextUpdates[activityId]; + } + if (Object.keys(nextUpdates).length > 0) { + nextMetadata.apnsActivityUpdateTokens = nextUpdates; + } else { + delete nextMetadata.apnsActivityUpdateTokens; + } + } + upsertDeviceRecord({ + deviceId: device.deviceId, + siteId: device.siteId, + name: device.name, + platform: device.platform, + deviceType: device.deviceType, + lastSeenAt: device.lastSeenAt, + lastHost: device.lastHost, + lastPort: device.lastPort, + tailscaleIp: device.tailscaleIp, + ipAddresses: device.ipAddresses, + metadata: nextMetadata, + }); + }; + + const invalidateApnsTokensForDevice = (deviceId: string): void => { + const device = getDevice(deviceId); + if (!device) return; + const nextMetadata = { ...device.metadata }; + delete nextMetadata.apnsAlertToken; + delete nextMetadata.apnsActivityStartToken; + delete nextMetadata.apnsActivityUpdateTokens; + upsertDeviceRecord({ + deviceId: device.deviceId, + siteId: device.siteId, + name: device.name, + platform: device.platform, + deviceType: device.deviceType, + lastSeenAt: device.lastSeenAt, + lastHost: device.lastHost, + lastPort: device.lastPort, + tailscaleIp: device.tailscaleIp, + ipAddresses: device.ipAddresses, + metadata: nextMetadata, + }); + }; + + const findDeviceByApnsToken = (token: string): SyncDeviceRecord | null => { + for (const device of listDevices()) { + const alert = device.metadata.apnsAlertToken; + const activity = device.metadata.apnsActivityStartToken; + if (alert === token || activity === token) return device; + const updates = device.metadata.apnsActivityUpdateTokens; + if (updates && typeof updates === "object") { + for (const value of Object.values(updates as Record)) { + if (value === token) return device; + } + } + } + return null; + }; + + const applyBrainStatus = (payload: SyncBrainStatusPayload): void => { + upsertPeerMetadata(payload.brain, { lastSeenAt: nowIso() }); + for (const peer of payload.connectedPeers) { + upsertPeerMetadata(peer, { + lastSeenAt: peer.lastSeenAt, + lastHost: peer.remoteAddress, + lastPort: peer.remotePort, + }); + } + }; + + const clearClusterRegistryForViewerJoin = (): void => { + args.logger.info("sync.device_registry.clear_for_viewer_join", { + projectRoot: args.projectRoot, + localDeviceId, + }); + args.db.run("delete from sync_cluster_state"); + args.db.run("delete from devices"); + }; + + const forgetDevice = (deviceId: string): void => { + const normalized = deviceId.trim(); + if (!normalized || normalized === localDeviceId) return; + args.db.run("delete from devices where device_id = ?", [normalized]); + }; + + ensureLocalDevice(); + + return { + getLocalDeviceId(): string { + return localDeviceId; + }, + + getLocalSiteId(): string { + return localSiteId; + }, + + ensureLocalDevice, + touchLocalDevice, + updateLocalDevice, + listDevices, + getDevice, + getClusterState, + setClusterState, + bootstrapLocalBrainIfNeeded, + upsertPeerMetadata, + applyBrainStatus, + clearClusterRegistryForViewerJoin, + forgetDevice, + setApnsToken, + getApnsTokenForDevice, + setNotificationPreferences, + getNotificationPreferences, + invalidateApnsToken, + invalidateApnsTokensForDevice, + findDeviceByApnsToken, + }; +} + +export type DeviceRegistryService = ReturnType; diff --git a/apps/ade-cli/src/services/sync/resolveTailscaleCliPath.ts b/apps/ade-cli/src/services/sync/resolveTailscaleCliPath.ts new file mode 100644 index 000000000..d667af972 --- /dev/null +++ b/apps/ade-cli/src/services/sync/resolveTailscaleCliPath.ts @@ -0,0 +1,67 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { PathLike } from "node:fs"; + +const TAILSCALE_CLI_MACOS_STANDALONE_PATHS = [ + "/opt/homebrew/bin/tailscale", + "/usr/local/bin/tailscale", +]; +const TAILSCALE_CLI_MACOS_APP_PATH = + "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; + +function windowsTailscaleExeCandidates(env: NodeJS.ProcessEnv): string[] { + const programFiles = env.ProgramFiles?.trim(); + const programFilesX86 = env["ProgramFiles(x86)"]?.trim(); + const { join: winJoin } = path.win32; + const out: string[] = []; + if (programFiles) { + out.push(winJoin(programFiles, "Tailscale", "tailscale.exe")); + } + if (programFilesX86) { + out.push(winJoin(programFilesX86, "Tailscale", "tailscale.exe")); + } + if (out.length === 0) { + out.push( + "C:\\Program Files\\Tailscale\\tailscale.exe", + "C:\\Program Files (x86)\\Tailscale\\tailscale.exe", + ); + } + return out; +} + +export type ResolveTailscaleCliPathOptions = { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + /** Test seam; production uses `fs.existsSync`. */ + existsSync?: (path: PathLike) => boolean; +}; + +/** + * Resolves the Tailscale CLI for `status`, `serve`, etc. + * Precedence: `ADE_TAILSCALE_CLI`, known standalone macOS CLI paths, known + * macOS app bundle path, known Windows install paths, then `tailscale` (PATH + * lookup). + */ +export function resolveTailscaleCliPath( + options?: ResolveTailscaleCliPathOptions, +): string { + const env = options?.env ?? process.env; + const platform = options?.platform ?? process.platform; + const exists = options?.existsSync ?? ((p: PathLike) => fs.existsSync(p)); + const configured = env.ADE_TAILSCALE_CLI?.trim(); + if (configured) return configured; + if (platform === "darwin") { + for (const candidate of TAILSCALE_CLI_MACOS_STANDALONE_PATHS) { + if (exists(candidate)) return candidate; + } + if (exists(TAILSCALE_CLI_MACOS_APP_PATH)) { + return TAILSCALE_CLI_MACOS_APP_PATH; + } + } + if (platform === "win32") { + for (const candidate of windowsTailscaleExeCandidates(env)) { + if (exists(candidate)) return candidate; + } + } + return "tailscale"; +} diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts new file mode 100644 index 000000000..bb8982a63 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -0,0 +1,343 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + SyncMobileProjectSummary, + SyncPeerMetadata, + SyncRemoteCommandDescriptor, +} from "../../../../desktop/src/shared/types"; +import { + buildSyncHostHelloOkPayload, + createSyncHostService, + resolveSyncHostInboundProjectScope, +} from "./syncHostService"; + +const publishMock = vi.hoisted(() => vi.fn()); +const bonjourDestroyMock = vi.hoisted(() => vi.fn()); +const bonjourConstructorMock = vi.hoisted(() => vi.fn()); + +vi.mock("bonjour-service", () => ({ + Bonjour: bonjourConstructorMock, +})); + +type BonjourPublishArgs = { + name: string; + type: string; + protocol: string; + port: number; + txt: Record; + disableIPv6: boolean; +}; + +describe("resolveSyncHostInboundProjectScope", () => { + it("keeps runtime-scoped envelopes projectless", () => { + expect(resolveSyncHostInboundProjectScope("hello", "project-1", "project-1")).toEqual({ + ok: true, + projectId: null, + usedSingleProjectFallback: false, + }); + expect(resolveSyncHostInboundProjectScope("project_catalog_request", null, "project-1")).toEqual({ + ok: true, + projectId: null, + usedSingleProjectFallback: false, + }); + }); + + it("resolves missing project id through the single-active-project fallback", () => { + expect(resolveSyncHostInboundProjectScope("file_request", null, " project-1 ")).toEqual({ + ok: true, + projectId: "project-1", + usedSingleProjectFallback: true, + }); + expect(resolveSyncHostInboundProjectScope("terminal_input", " ", "project-1")).toEqual({ + ok: true, + projectId: "project-1", + usedSingleProjectFallback: true, + }); + }); + + it("accepts matching project-scoped envelopes", () => { + expect(resolveSyncHostInboundProjectScope("changeset_batch", " project-1 ", "project-1")).toEqual({ + ok: true, + projectId: "project-1", + usedSingleProjectFallback: false, + }); + expect(resolveSyncHostInboundProjectScope("chat_subscribe", "project-1", " project-1 ")).toEqual({ + ok: true, + projectId: "project-1", + usedSingleProjectFallback: false, + }); + }); + + it("rejects project-scoped envelopes for a different active project", () => { + expect(resolveSyncHostInboundProjectScope("changeset_ack", "project-2", "project-1")).toMatchObject({ + ok: false, + code: "project_mismatch", + expectedProjectId: "project-1", + receivedProjectId: "project-2", + }); + }); + + it("rejects project-scoped envelopes when no project is open", () => { + expect(resolveSyncHostInboundProjectScope("terminal_subscribe", "project-1", null)).toMatchObject({ + ok: false, + code: "project_not_open", + expectedProjectId: null, + receivedProjectId: "project-1", + }); + }); +}); + +describe("buildSyncHostHelloOkPayload", () => { + it("advertises daemon-hosted project catalog support in hello_ok without desktop", () => { + const peer = { + deviceId: "ios-phone-1", + deviceName: "Arul iPhone", + platform: "iOS", + deviceType: "phone", + siteId: "ios-site-1", + dbVersion: 0, + } satisfies SyncPeerMetadata; + const brain = { + deviceId: "daemon-host-1", + deviceName: "ADE daemon", + platform: "linux", + deviceType: "vps", + siteId: "daemon-site-1", + dbVersion: 7, + } satisfies SyncPeerMetadata; + const project = { + id: "project-1", + displayName: "ADE", + rootPath: "/Users/admin/Projects/ADE", + defaultBaseRef: "main", + lastOpenedAt: "2026-04-22T12:00:00.000Z", + laneCount: 3, + isAvailable: true, + isCached: true, + isOpen: false, + } satisfies SyncMobileProjectSummary; + const remoteCommand = { + action: "work.runQuickCommand", + scope: "project", + policy: { viewerAllowed: true }, + } satisfies SyncRemoteCommandDescriptor; + const localPresenceCommand = { + action: "lanes.presence.announce", + scope: "project", + policy: { viewerAllowed: true }, + } satisfies SyncRemoteCommandDescriptor; + + const payload = buildSyncHostHelloOkPayload({ + peer, + brain, + serverDbVersion: 7, + heartbeatIntervalMs: 30_000, + pollIntervalMs: 400, + projectCatalog: { projects: [project] }, + projectCatalogEnabled: true, + remoteCommandSupportedActions: [remoteCommand.action], + remoteCommandDescriptors: [remoteCommand], + localCommandDescriptors: [localPresenceCommand], + compressionThresholdBytes: 100_000, + }); + + expect(payload.peer).toBe(peer); + expect(payload.brain).toBe(brain); + expect(payload.serverDbVersion).toBe(7); + expect(payload.projects).toEqual([project]); + expect(payload.features.projectCatalog).toEqual({ enabled: true }); + expect(payload.features.fileAccess).toBe(true); + expect(payload.features.terminalStreaming).toBe(true); + expect(payload.features.chatStreaming).toEqual({ enabled: true }); + expect(payload.features.commandRouting).toEqual({ + mode: "allowlisted", + supportedActions: [remoteCommand.action, localPresenceCommand.action], + actions: [remoteCommand, localPresenceCommand], + }); + }); +}); + +function createDiscoveryLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function createTempProjectRoot(): { projectRoot: string; cleanup: () => void } { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-sync-discovery-")); + return { + projectRoot, + cleanup: () => fs.rmSync(projectRoot, { recursive: true, force: true }), + }; +} + +function createDiscoveryProject(overrides: Partial): SyncMobileProjectSummary { + return { + id: "project-1", + displayName: "Project", + rootPath: "/srv/project", + defaultBaseRef: "main", + lastOpenedAt: "2026-05-10T12:00:00.000Z", + laneCount: 0, + isAvailable: true, + isCached: true, + isOpen: false, + ...overrides, + }; +} + +function publishedAnnouncements(): BonjourPublishArgs[] { + return publishMock.mock.calls.map(([payload]) => payload as BonjourPublishArgs); +} + +function createHostArgs(projectRoot: string, projects: SyncMobileProjectSummary[]) { + return { + db: { + sync: { + getSiteId: () => "site-host-1", + getDbVersion: () => 7, + }, + }, + logger: createDiscoveryLogger(), + projectRoot, + port: 0, + discoveryEnabled: true, + runtimeKind: "headless" as const, + runtimeVersion: "2.0.0", + heartbeatIntervalMs: 60_000, + pollIntervalMs: 60_000, + brainStatusIntervalMs: 60_000, + pinStore: { + getPin: () => null, + hasPin: () => false, + verifyPin: () => false, + setPin: vi.fn(), + clearPin: vi.fn(), + }, + deviceRegistryService: { + ensureLocalDevice: () => ({ + deviceId: "host-device-1", + siteId: "host-site-1", + name: "ADE Build Host", + platform: "linux", + deviceType: "vps", + createdAt: "2026-05-10T12:00:00.000Z", + updatedAt: "2026-05-10T12:00:00.000Z", + lastSeenAt: "2026-05-10T12:00:00.000Z", + lastHost: "build-host.local", + lastPort: 8787, + tailscaleIp: "100.64.0.10", + ipAddresses: ["192.168.1.50"], + metadata: { tailscaleDnsName: "ade-build.tailnet.ts.net." }, + }), + }, + fileService: {}, + laneService: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn(), + archive: vi.fn(), + }, + prService: { + listAll: vi.fn().mockResolvedValue([]), + getDetail: vi.fn(), + getStatus: vi.fn(), + getChecks: vi.fn(), + getReviews: vi.fn(), + getComments: vi.fn(), + getFiles: vi.fn(), + createFromLane: vi.fn(), + land: vi.fn(), + closePr: vi.fn(), + requestReviewers: vi.fn(), + }, + sessionService: { + list: () => [], + get: () => null, + readTranscriptTail: async () => "", + }, + ptyService: { + create: vi.fn(), + enrichSessions: (rows: unknown[]) => rows, + }, + computerUseArtifactBrokerService: { + listArtifacts: () => [], + }, + projectCatalogProvider: { + listProjects: vi.fn(async () => ({ projects })), + prepareProjectConnection: vi.fn(), + }, + }; +} + +describe("createSyncHostService LAN discovery", () => { + beforeEach(() => { + publishMock.mockReset(); + bonjourDestroyMock.mockReset(); + bonjourConstructorMock.mockReset(); + bonjourConstructorMock.mockImplementation(() => ({ + publish: publishMock, + destroy: bonjourDestroyMock, + })); + publishMock.mockImplementation(() => ({ + on: vi.fn(), + stop: vi.fn(), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("publishes headless runtime project metadata in Bonjour TXT records", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const projects = [ + createDiscoveryProject({ id: "project-1", displayName: "API, Server\nOne", rootPath: "/srv/api" }), + createDiscoveryProject({ id: "project-2", displayName: "Worker", rootPath: "/srv/worker" }), + ]; + const host = createSyncHostService( + createHostArgs(projectRoot, projects) as unknown as Parameters[0], + ); + + try { + const port = await host.waitUntilListening(); + await vi.waitFor(() => { + expect(publishedAnnouncements().some((announcement) => announcement.txt.projectCount === "2")).toBe(true); + }); + + const announcement = publishedAnnouncements() + .find((candidate) => candidate.txt.projectCount === "2"); + expect(announcement).toBeDefined(); + expect(announcement).toMatchObject({ + name: `ADE Sync ADE Build Host ${port}`, + type: "ade-sync", + protocol: "tcp", + port, + disableIPv6: true, + }); + expect(announcement?.txt).toEqual({ + version: "1", + runtimeKind: "headless", + runtimeVersion: "2.0.0", + projects: "project-1,project-2", + projectNames: "API Server One,Worker", + projectCount: "2", + deviceId: "host-device-1", + siteId: "host-site-1", + deviceName: "ADE Build Host", + port: String(port), + host: "192.168.1.50", + addresses: "192.168.1.50,100.64.0.10", + tailscaleIp: "100.64.0.10", + tailscaleDnsName: "ade-build.tailnet.ts.net", + }); + } finally { + await host.dispose(); + cleanup(); + } + }); +}); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts new file mode 100644 index 000000000..8e5d76293 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -0,0 +1,3267 @@ +import fs from "node:fs"; +import { execFile } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { createHash, randomBytes } from "node:crypto"; +import { Bonjour, type Service as BonjourService } from "bonjour-service"; +import { WebSocketServer, WebSocket, type RawData } from "ws"; +import { resolveAdeLayout } from "../../../../desktop/src/shared/adeLayout"; +import type { + AgentChatEventEnvelope, + CrsqlChangeRow, + DeviceMarker, + FileContent, + FileTreeNode, + FilesQuickOpenItem, + FilesSearchTextMatch, + FilesWorkspace, + LaneDetailPayload, + LaneListSnapshot, + LaneSummary, + PtyDataEvent, + PtyExitEvent, + SyncBrainStatusPayload, + SyncChangesetAckPayload, + SyncChangesetBatchPayload, + SyncCommandAckPayload, + SyncCommandPayload, + SyncCommandResultPayload, + SyncEnvelope, + SyncChatSubscribeSnapshotPayload, + SyncChatUnsubscribePayload, + SyncFileBlob, + SyncFileRequest, + SyncFileResponsePayload, + SyncHelloOkPayload, + SyncHelloPayload, + SyncMobileProjectSummary, + SyncPairingRequestPayload, + SyncPeerConnectionState, + SyncPeerMetadata, + SyncProjectCatalogChunkPayload, + SyncProjectCatalogPayload, + SyncProjectSwitchRequestPayload, + SyncProjectSwitchResultPayload, + SyncRemoteCommandDescriptor, + SyncTailnetDiscoveryStatus, + SyncTerminalSnapshotPayload, +} from "../../../../desktop/src/shared/types"; +import { parseAgentChatTranscript } from "../../../../desktop/src/shared/chatTranscript"; +import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; +import type { createAgentChatService } from "../../../../desktop/src/main/services/chat/agentChatService"; +import type { createCtoStateService } from "../../../../desktop/src/main/services/cto/ctoStateService"; +import type { createFlowPolicyService } from "../../../../desktop/src/main/services/cto/flowPolicyService"; +import type { createLinearCredentialService } from "../../../../desktop/src/main/services/cto/linearCredentialService"; +import type { createLinearIngressService } from "../../../../desktop/src/main/services/cto/linearIngressService"; +import type { createLinearIssueTracker } from "../../../../desktop/src/main/services/cto/linearIssueTracker"; +import type { createLinearSyncService } from "../../../../desktop/src/main/services/cto/linearSyncService"; +import type { createWorkerAgentService } from "../../../../desktop/src/main/services/cto/workerAgentService"; +import type { createWorkerBudgetService } from "../../../../desktop/src/main/services/cto/workerBudgetService"; +import type { createWorkerHeartbeatService } from "../../../../desktop/src/main/services/cto/workerHeartbeatService"; +import type { createWorkerRevisionService } from "../../../../desktop/src/main/services/cto/workerRevisionService"; +import type { createProjectConfigService } from "../../../../desktop/src/main/services/config/projectConfigService"; +import type { createConflictService } from "../../../../desktop/src/main/services/conflicts/conflictService"; +import type { createFileService } from "../../../../desktop/src/main/services/files/fileService"; +import type { createDiffService } from "../../../../desktop/src/main/services/diffs/diffService"; +import type { createGitOperationsService } from "../../../../desktop/src/main/services/git/gitOperationsService"; +import type { createAutoRebaseService } from "../../../../desktop/src/main/services/lanes/autoRebaseService"; +import type { createLaneEnvironmentService } from "../../../../desktop/src/main/services/lanes/laneEnvironmentService"; +import type { createLaneService } from "../../../../desktop/src/main/services/lanes/laneService"; +import type { createLaneTemplateService } from "../../../../desktop/src/main/services/lanes/laneTemplateService"; +import type { createPortAllocationService } from "../../../../desktop/src/main/services/lanes/portAllocationService"; +import type { createRebaseSuggestionService } from "../../../../desktop/src/main/services/lanes/rebaseSuggestionService"; +import type { createProcessService } from "../../../../desktop/src/main/services/processes/processService"; +import type { createPtyService } from "../../../../desktop/src/main/services/pty/ptyService"; +import type { createIssueInventoryService } from "../../../../desktop/src/main/services/prs/issueInventoryService"; +import type { PathToMergeOrchestrator } from "../../../../desktop/src/main/services/prs/pathToMergeOrchestrator"; +import type { createPrService } from "../../../../desktop/src/main/services/prs/prService"; +import type { createQueueLandingService } from "../../../../desktop/src/main/services/prs/queueLandingService"; +import type { createSessionService } from "../../../../desktop/src/main/services/sessions/sessionService"; +import type { createComputerUseArtifactBrokerService } from "../../../../desktop/src/main/services/computerUse/computerUseArtifactBrokerService"; +import type { AdeDb } from "../../../../desktop/src/main/services/state/kvDb"; +import { hasNullByte, normalizeRelative, nowIso, resolvePathWithinRoot, safeJsonParse, toOptionalString, uniqueStrings, writeTextAtomic } from "../../../../desktop/src/main/services/shared/utils"; +import type { DeviceRegistryService } from "./deviceRegistryService"; +import { createSyncPairingStore } from "./syncPairingStore"; +import type { NotificationEventBus } from "../../../../desktop/src/main/services/notifications/notificationEventBus"; +import type { + ApnsEnvironment, + ApnsPushTokenKind, + NotificationPreferences, + SyncInAppNotificationPayload, + SyncNotificationPrefsPayload, + SyncRegisterPushTokenPayload, + SyncSendTestPushPayload, +} from "../../../../desktop/src/shared/types/sync"; +import { DEFAULT_NOTIFICATION_PREFERENCES, normalizeNotificationPreferences } from "../../../../desktop/src/shared/types/sync"; +import type { SyncPinStore } from "./syncPinStore"; +import { DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, DEFAULT_SYNC_HOST_PORT, encodeSyncEnvelope, mapPlatform, parseSyncEnvelope, wsDataToText } from "./syncProtocol"; +import { resolveTailscaleCliPath } from "./resolveTailscaleCliPath"; +import { createSyncRemoteCommandService, type SyncRemoteCommandService } from "./syncRemoteCommandService"; +const execFileAsync = promisify(execFile); +const DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS = 30_000; +const DEFAULT_SYNC_HEARTBEAT_MISS_LIMIT = 2; +const MOBILE_SYNC_HEARTBEAT_MISS_LIMIT = 6; +const DEFAULT_SYNC_POLL_INTERVAL_MS = 400; +const DEFAULT_BRAIN_STATUS_INTERVAL_MS = 5_000; +const DEFAULT_TERMINAL_SNAPSHOT_BYTES = 220_000; +const PEER_BACKPRESSURE_BYTES = 4 * 1024 * 1024; +const MOBILE_COMMAND_RESULT_CACHE_TTL_MS = 30 * 60 * 1000; +const MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES = 512; +const CHANGESET_ACK_TIMEOUT_MS = 10_000; +const MAX_CHANGESET_ACK_RETRIES = 6; +const LANE_PRESENCE_TTL_MS = 60_000; +const SYNC_MDNS_SERVICE_TYPE = "ade-sync"; +const MAX_PROJECT_CATALOG_ENVELOPE_BYTES = 768 * 1024; +const BONJOUR_PROJECT_TXT_ENTRY_LIMIT = 24; +const BONJOUR_PROJECT_NAME_MAX_LENGTH = 48; +export const SYNC_TAILNET_DISCOVERY_SERVICE_NAME = "svc:ade-sync"; +export const SYNC_TAILNET_DISCOVERY_SERVICE_PORT = DEFAULT_SYNC_HOST_PORT; +export type SyncRuntimeKind = "desktop-embedded" | "headless" | "remote-stdio" | "desktop" | "daemon" | "remote"; +const MOBILE_MUTATING_FILE_ACTIONS = new Set([ + "writeText", + "createFile", + "createDirectory", + "rename", + "deletePath", +]); + +type LanePresenceEntry = { + marker: DeviceMarker; + lastAnnouncedAtMs: number; + source: "local" | "remote"; +}; + +type PeerState = { + ws: WebSocket; + metadata: SyncPeerMetadata | null; + authenticated: boolean; + authKind: "bootstrap" | "paired" | null; + pairedDeviceId: string | null; + connectedAt: string; + lastSeenAt: string; + lastAppliedAt: string | null; + lastKnownServerDbVersion: number; + latencyMs: number | null; + awaitingHeartbeatAt: string | null; + missedHeartbeatCount: number; + remoteAddress: string | null; + remotePort: number | null; + subscribedSessionIds: Set; + subscribedChatSessionIds: Set; + chatTranscriptOffsets: Map; + chatEventIdsSent: Map>; + pendingChangesetBatch: PendingChangesetBatch | null; +}; + +type PendingChangesetBatch = { + batchId: string; + fromDbVersion: number; + toDbVersion: number; + changes: CrsqlChangeRow[]; + reason: SyncChangesetBatchPayload["reason"]; + sentAtMs: number; + retryCount: number; +}; + +type CachedMobileCommandWaiter = { + peer: PeerState; + requestId: string | null; +}; + +type CachedMobileCommand = { + commandId: string; + action: string; + argsKey: string; + argsFingerprint: string; + ack: SyncCommandAckPayload; + result: SyncCommandResultPayload | null; + waiters: CachedMobileCommandWaiter[]; + acceptedAtMs: number; + completedAtMs: number | null; +}; + +type PersistedMobileCommand = { + key: string; + projectRoot: string; + deviceId: string; + commandId: string; + action: string; + argsFingerprint: string; + ack: SyncCommandAckPayload; + result: SyncCommandResultPayload; + acceptedAtMs: number; + completedAtMs: number; +}; + +const PERSISTED_MOBILE_COMMAND_ACTIONS = new Set([ + "lanes.presence.announce", + "lanes.presence.release", + "notification_prefs", + "work.runQuickCommand", + "work.startCliSession", + "work.closeSession", + "processes.start", + "processes.stop", + "processes.kill", + "chat.interrupt", + "chat.approve", + "chat.respondToInput", + "chat.dispose", + "chat.archive", + "chat.unarchive", + "chat.delete", +]); + +function stableJsonValue(value: unknown): unknown { + if (value == null) return value; + if (Array.isArray(value)) return value.map(stableJsonValue); + if (typeof value !== "object") return value; + const input = value as Record; + const output: Record = {}; + for (const key of Object.keys(input).sort()) { + output[key] = stableJsonValue(input[key]); + } + return output; +} + +function stableJsonKey(value: unknown): string { + return JSON.stringify(stableJsonValue(value)) ?? "null"; +} + +function mobileCommandArgsFingerprint(argsKey: string): string { + return createHash("sha256").update(argsKey).digest("hex"); +} + +function safeObjectValue(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function persistedMobileCommandResult(action: string, result: SyncCommandResultPayload): SyncCommandResultPayload | null { + if (!PERSISTED_MOBILE_COMMAND_ACTIONS.has(action)) return null; + if (!result.ok) { + return { + commandId: result.commandId, + ok: false, + error: { + code: result.error?.code ?? "command_failed", + message: "Command failed before reconnect.", + }, + }; + } + if (action === "work.runQuickCommand" || action === "work.startCliSession") { + const raw = safeObjectValue(result.result); + const replayResult: Record = {}; + if (typeof raw?.sessionId === "string") replayResult.sessionId = raw.sessionId; + if (typeof raw?.ptyId === "string") replayResult.ptyId = raw.ptyId; + if (action === "work.startCliSession" && safeObjectValue(raw?.session)) replayResult.session = raw?.session; + return { + commandId: result.commandId, + ok: true, + result: Object.keys(replayResult).length > 0 ? replayResult : { ok: true }, + }; + } + return { + commandId: result.commandId, + ok: true, + result: { ok: true }, + }; +} + +function mobileCommandCacheKey(projectScopeKey: string, peer: PeerState, commandId: string): string | null { + const deviceId = peer.metadata?.deviceId ?? peer.pairedDeviceId; + if (!deviceId || !commandId) return null; + return `${projectScopeKey}:${deviceId}:${commandId}`; +} + +function addMobileCommandWaiter(record: CachedMobileCommand, peer: PeerState, requestId: string | null): void { + if (record.waiters.some((waiter) => waiter.peer === peer && waiter.requestId === requestId)) return; + record.waiters.push({ peer, requestId }); +} + +type SyncHostServiceArgs = { + db: AdeDb; + logger: Logger; + projectId?: string | null; + projectRoot: string; + fileService: ReturnType; + laneService: ReturnType; + gitService?: ReturnType; + diffService?: ReturnType; + conflictService?: ReturnType; + prService: ReturnType; + issueInventoryService?: ReturnType | null; + /** Optional Path-to-Merge orchestrator (forwarded to remote command service). */ + pathToMergeOrchestrator?: PathToMergeOrchestrator | null; + queueLandingService?: ReturnType | null; + sessionService: ReturnType; + ptyService: ReturnType; + processService?: ReturnType; + agentChatService?: ReturnType; + workerAgentService?: ReturnType | null; + workerBudgetService?: ReturnType | null; + workerHeartbeatService?: ReturnType | null; + workerRevisionService?: ReturnType | null; + ctoStateService?: ReturnType | null; + flowPolicyService?: ReturnType | null; + linearCredentialService?: ReturnType | null; + getLinearIngressService?: () => ReturnType | null; + getLinearIssueTracker?: () => ReturnType | null; + getLinearSyncService?: () => ReturnType | null; + projectConfigService?: ReturnType; + portAllocationService?: ReturnType; + laneEnvironmentService?: ReturnType; + laneTemplateService?: ReturnType; + rebaseSuggestionService?: ReturnType; + autoRebaseService?: ReturnType; + computerUseArtifactBrokerService: ReturnType; + pinStore: SyncPinStore; + bootstrapTokenPath?: string; + pairingSecretsPath?: string; + port?: number; + discoveryEnabled?: boolean; + runtimeKind?: SyncRuntimeKind; + runtimeVersion?: string; + heartbeatIntervalMs?: number; + pollIntervalMs?: number; + brainStatusIntervalMs?: number; + compressionThresholdBytes?: number; + deviceRegistryService?: DeviceRegistryService; + projectCatalogProvider?: { + listProjects: () => Promise; + prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise; + completeProjectConnection?: ( + args: SyncProjectSwitchRequestPayload, + result: SyncProjectSwitchResultPayload, + ) => Promise; + }; + onStateChanged?: () => void; + notificationEventBus?: NotificationEventBus | null; + remoteCommandService?: SyncRemoteCommandService; + remoteCommandExecutor?: Pick; +}; + +function sanitizeRemoteAddress(remoteAddress: string | null | undefined): string | null { + const value = toOptionalString(remoteAddress); + if (!value) return null; + return value.startsWith("::ffff:") ? value.slice("::ffff:".length) : value; +} + +function ensureBootstrapToken(filePath: string): string { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, randomBytes(24).toString("hex"), { encoding: "utf8", mode: 0o600 }); + } + try { + fs.chmodSync(filePath, 0o600); + } catch { + // ignore chmod failures on platforms that don't support it + } + return fs.readFileSync(filePath, "utf8").trim(); +} + +function inferMimeType(filePath: string): string | null { + const ext = path.extname(filePath).toLowerCase(); + switch (ext) { + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".gif": + return "image/gif"; + case ".webp": + return "image/webp"; + case ".mp4": + return "video/mp4"; + case ".mov": + return "video/quicktime"; + case ".zip": + return "application/zip"; + case ".json": + return "application/json"; + case ".md": + return "text/markdown"; + case ".txt": + case ".log": + return "text/plain"; + case ".yaml": + case ".yml": + return "application/yaml"; + default: + return null; + } +} + +function fileContentToBlob(filePath: string, content: FileContent): SyncFileBlob { + return { + path: filePath, + size: content.size, + mimeType: content.mimeType ?? inferMimeType(filePath), + encoding: content.encoding, + isBinary: content.isBinary, + content: content.content, + languageId: content.languageId, + }; +} + +function createBlobFromBuffer(filePath: string, buf: Buffer): SyncFileBlob { + const isBinary = hasNullByte(buf); + return { + path: filePath, + size: buf.length, + mimeType: inferMimeType(filePath), + encoding: isBinary ? "base64" : "utf-8", + isBinary, + content: isBinary ? buf.toString("base64") : buf.toString("utf8"), + languageId: null, + }; +} + +function toSyncPeerConnectionState(peer: PeerState, currentServerDbVersion: number): SyncPeerConnectionState | null { + if (!peer.metadata) return null; + return { + ...peer.metadata, + connectedAt: peer.connectedAt, + lastSeenAt: peer.lastSeenAt, + lastAppliedAt: peer.lastAppliedAt, + remoteAddress: peer.remoteAddress, + remotePort: peer.remotePort, + latencyMs: peer.latencyMs, + syncLag: Math.max(0, currentServerDbVersion - peer.lastKnownServerDbVersion), + isBrain: false, + isAuthenticated: peer.authenticated, + }; +} + +export function syncHeartbeatMissLimitForPeerMetadata(metadata: Pick | null | undefined): number { + return metadata?.platform === "iOS" || metadata?.deviceType === "phone" + ? MOBILE_SYNC_HEARTBEAT_MISS_LIMIT + : DEFAULT_SYNC_HEARTBEAT_MISS_LIMIT; +} + +const SYNC_HOST_PROJECT_SCOPED_INBOUND_ENVELOPE_TYPES = new Set([ + "changeset_batch", + "changeset_ack", + "file_request", + "terminal_subscribe", + "terminal_unsubscribe", + "terminal_input", + "terminal_resize", + "chat_subscribe", + "chat_unsubscribe", +]); + +type SyncHostProjectScopeResolution = + | { + ok: true; + projectId: string | null; + usedSingleProjectFallback: boolean; + } + | { + ok: false; + code: "project_not_open" | "project_mismatch"; + message: string; + expectedProjectId: string | null; + receivedProjectId: string | null; + }; + +export function resolveSyncHostInboundProjectScope( + type: SyncEnvelope["type"], + receivedProjectId: string | null | undefined, + hostProjectId: string | null | undefined, +): SyncHostProjectScopeResolution { + if (!SYNC_HOST_PROJECT_SCOPED_INBOUND_ENVELOPE_TYPES.has(type)) { + return { ok: true, projectId: null, usedSingleProjectFallback: false }; + } + + const received = toOptionalString(receivedProjectId); + const host = toOptionalString(hostProjectId); + if (!host) { + return { + ok: false, + code: "project_not_open", + message: "This ADE machine does not have a project open for phone sync.", + expectedProjectId: null, + receivedProjectId: received, + }; + } + if (!received) { + return { ok: true, projectId: host, usedSingleProjectFallback: true }; + } + if (received !== host) { + return { + ok: false, + code: "project_mismatch", + message: "This ADE machine is hosting a different project. Select the project again and retry.", + expectedProjectId: host, + receivedProjectId: received, + }; + } + return { ok: true, projectId: host, usedSingleProjectFallback: false }; +} + +export function buildSyncHostHelloOkPayload(args: { + peer: SyncPeerMetadata; + brain: SyncPeerMetadata; + serverDbVersion: number; + heartbeatIntervalMs: number; + pollIntervalMs: number; + projectCatalog: SyncProjectCatalogPayload; + projectCatalogEnabled: boolean; + remoteCommandSupportedActions: string[]; + remoteCommandDescriptors: SyncRemoteCommandDescriptor[]; + localCommandDescriptors: SyncRemoteCommandDescriptor[]; + compressionThresholdBytes?: number; + maxProjectCatalogEnvelopeBytes?: number; +}): SyncHelloOkPayload { + const actions = [ + ...args.remoteCommandDescriptors, + ...args.localCommandDescriptors, + ]; + const payload: SyncHelloOkPayload = { + peer: args.peer, + brain: args.brain, + serverDbVersion: args.serverDbVersion, + heartbeatIntervalMs: args.heartbeatIntervalMs, + pollIntervalMs: args.pollIntervalMs, + projects: args.projectCatalog.projects, + features: { + fileAccess: true, + terminalStreaming: true, + chatStreaming: { + enabled: true, + }, + projectCatalog: { + enabled: args.projectCatalogEnabled, + }, + changesetAck: { + enabled: true, + }, + bootstrapAuth: true, + pairingAuth: { + enabled: true, + pinDigits: 6, + }, + commandRouting: { + mode: "allowlisted", + supportedActions: [ + ...args.remoteCommandSupportedActions, + ...args.localCommandDescriptors.map((entry) => entry.action), + ], + actions, + }, + }, + }; + const envelopeBytes = Buffer.byteLength(encodeSyncEnvelope({ + type: "hello_ok", + payload, + compressionThresholdBytes: args.compressionThresholdBytes, + }), "utf8"); + return envelopeBytes <= (args.maxProjectCatalogEnvelopeBytes ?? MAX_PROJECT_CATALOG_ENVELOPE_BYTES) + ? payload + : { ...payload, projects: [] }; +} + +function parseHelloPayload(payload: unknown): SyncHelloPayload | null { + const value = payload as SyncHelloPayload | null; + const peer = value?.peer; + if (!peer || typeof peer !== "object") return null; + if (!toOptionalString(peer.deviceId) || !toOptionalString(peer.deviceName) || !toOptionalString(peer.siteId)) { + return null; + } + const auth = value?.auth; + let normalizedAuth = auth ?? null; + if (!normalizedAuth) { + const token = toOptionalString(value?.token); + if (!token) return null; + normalizedAuth = { + kind: "bootstrap", + token, + }; + } + if (normalizedAuth.kind === "bootstrap") { + if (!toOptionalString(normalizedAuth.token)) return null; + } else if (normalizedAuth.kind === "paired") { + if (!toOptionalString(normalizedAuth.deviceId) || !toOptionalString(normalizedAuth.secret)) return null; + } else { + return null; + } + return { + peer: { + deviceId: String(peer.deviceId).trim(), + deviceName: String(peer.deviceName).trim(), + platform: peer.platform ?? "unknown", + deviceType: peer.deviceType ?? "unknown", + siteId: String(peer.siteId).trim(), + dbVersion: Number(peer.dbVersion ?? 0), + capabilities: Array.isArray(peer.capabilities) + ? peer.capabilities + .filter((capability): capability is string => typeof capability === "string") + .map((capability) => capability.trim()) + .filter(Boolean) + : [], + }, + auth: normalizedAuth, + }; +} + +function parsePairingRequestPayload(payload: unknown): SyncPairingRequestPayload | null { + const value = payload as SyncPairingRequestPayload | null; + const code = toOptionalString(value?.code); + const peer = value?.peer; + if (!code || !peer || typeof peer !== "object") return null; + if (!toOptionalString(peer.deviceId) || !toOptionalString(peer.deviceName) || !toOptionalString(peer.siteId)) { + return null; + } + return { + code, + peer: { + deviceId: String(peer.deviceId).trim(), + deviceName: String(peer.deviceName).trim(), + platform: peer.platform ?? "unknown", + deviceType: peer.deviceType ?? "unknown", + siteId: String(peer.siteId).trim(), + dbVersion: Number(peer.dbVersion ?? 0), + }, + }; +} + +function shouldAttemptTailnetServiceAdvertise(): boolean { + if (process.env.ADE_TAILSCALE_SERVE === "0") return false; + if (process.env.NODE_ENV === "test" || process.env.VITEST) return false; + return process.platform === "darwin" || process.platform === "linux" || process.platform === "win32"; +} + +function looksLikePendingTailnetApproval(text: string): boolean { + return /\b(pending|approval|approve|review)\b/i.test(text); +} + +export function createSyncHostService(args: SyncHostServiceArgs) { + const layout = resolveAdeLayout(args.projectRoot); + const bootstrapTokenPath = args.bootstrapTokenPath ?? path.join(layout.secretsDir, "sync-bootstrap-token"); + const pairingSecretsPath = args.pairingSecretsPath ?? path.join(layout.secretsDir, "sync-paired-devices.json"); + const commandLedgerPath = path.join(layout.cacheDir, "sync-mobile-command-ledger.json"); + const bootstrapToken = ensureBootstrapToken(bootstrapTokenPath); + const pairingStore = createSyncPairingStore({ + filePath: pairingSecretsPath, + pinStore: args.pinStore, + }); + const remoteCommandService = args.remoteCommandService ?? createSyncRemoteCommandService({ + laneService: args.laneService, + prService: args.prService, + ptyService: args.ptyService, + sessionService: args.sessionService, + fileService: args.fileService, + gitService: args.gitService, + diffService: args.diffService, + conflictService: args.conflictService, + agentChatService: args.agentChatService, + workerAgentService: args.workerAgentService, + workerBudgetService: args.workerBudgetService, + workerHeartbeatService: args.workerHeartbeatService, + workerRevisionService: args.workerRevisionService, + ctoStateService: args.ctoStateService, + flowPolicyService: args.flowPolicyService, + linearCredentialService: args.linearCredentialService, + getLinearIngressService: args.getLinearIngressService, + getLinearIssueTracker: args.getLinearIssueTracker, + getLinearSyncService: args.getLinearSyncService, + issueInventoryService: args.issueInventoryService, + pathToMergeOrchestrator: args.pathToMergeOrchestrator, + queueLandingService: args.queueLandingService, + projectConfigService: args.projectConfigService, + processService: args.processService, + portAllocationService: args.portAllocationService, + laneEnvironmentService: args.laneEnvironmentService, + laneTemplateService: args.laneTemplateService, + rebaseSuggestionService: args.rebaseSuggestionService, + autoRebaseService: args.autoRebaseService, + logger: args.logger, + }); + const heartbeatIntervalMs = Math.max(5_000, Math.floor(args.heartbeatIntervalMs ?? DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS)); + const pollIntervalMs = Math.max(100, Math.floor(args.pollIntervalMs ?? DEFAULT_SYNC_POLL_INTERVAL_MS)); + const brainStatusIntervalMs = Math.max(1_000, Math.floor(args.brainStatusIntervalMs ?? DEFAULT_BRAIN_STATUS_INTERVAL_MS)); + const compressionThresholdBytes = Math.max(256, Math.floor(args.compressionThresholdBytes ?? DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES)); + const maxChangesetBatchBytes = 256 * 1024; + const maxChangesetBatchRows = 250; + const maxProjectCatalogEnvelopeBytes = MAX_PROJECT_CATALOG_ENVELOPE_BYTES; + const maxProjectCatalogChunkBytes = 192 * 1024; + const localPresenceCommandDescriptors: SyncRemoteCommandDescriptor[] = [ + { + action: "lanes.presence.announce", + scope: "project", + policy: { viewerAllowed: true }, + }, + { + action: "lanes.presence.release", + scope: "project", + policy: { viewerAllowed: true }, + }, + ]; + + const readBrainMetadata = (): SyncPeerMetadata => { + const localDevice = args.deviceRegistryService?.ensureLocalDevice(); + return { + deviceId: localDevice?.deviceId ?? args.db.sync.getSiteId(), + deviceName: localDevice?.name ?? os.hostname(), + platform: localDevice?.platform ?? mapPlatform(process.platform), + deviceType: localDevice?.deviceType ?? "desktop", + siteId: localDevice?.siteId ?? args.db.sync.getSiteId(), + dbVersion: args.db.sync.getDbVersion(), + }; + }; + + const peers = new Set(); + const mobileCommandResultCache = new Map(); + let commandReplayCount = 0; + let commandConflictCount = 0; + let lastCommandResultLatencyMs: number | null = null; + let lastChangesetAckLatencyMs: number | null = null; + + const pruneMobileCommandResultCache = (nowMs = Date.now()): void => { + for (const [key, record] of mobileCommandResultCache) { + if (record.completedAtMs == null) continue; + if (nowMs - record.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) { + mobileCommandResultCache.delete(key); + } + } + if (mobileCommandResultCache.size <= MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES) return; + + const completed = [...mobileCommandResultCache.entries()] + .filter(([, record]) => record.completedAtMs != null) + .sort(([, left], [, right]) => (left.completedAtMs ?? left.acceptedAtMs) - (right.completedAtMs ?? right.acceptedAtMs)); + for (const [key] of completed) { + if (mobileCommandResultCache.size <= MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES) break; + mobileCommandResultCache.delete(key); + } + }; + + const readPersistedCommandLedger = (): PersistedMobileCommand[] => { + try { + if (!fs.existsSync(commandLedgerPath)) return []; + const parsed = safeJsonParse<{ commands?: PersistedMobileCommand[] }>( + fs.readFileSync(commandLedgerPath, "utf8"), + { commands: [] }, + ); + return Array.isArray(parsed.commands) ? parsed.commands : []; + } catch (error) { + args.logger.warn("sync_host.command_ledger_read_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + }; + const commandLedgerScopeKey = (): string => + toOptionalString(args.projectId) ?? args.projectRoot; + const commandLedgerKeyPrefix = (): string => `${commandLedgerScopeKey()}:`; + const commandLedgerLegacyRootPrefix = (): string => `${args.projectRoot}:`; + const writePersistedCommandLedger = (): void => { + const nowMs = Date.now(); + const commands: PersistedMobileCommand[] = []; + const prefix = commandLedgerKeyPrefix(); + for (const [key, record] of mobileCommandResultCache) { + if (!record.result || record.completedAtMs == null) continue; + const persistedResult = persistedMobileCommandResult(record.action, record.result); + if (!persistedResult) continue; + if (!key.startsWith(prefix)) continue; + if (nowMs - record.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) continue; + const deviceId = key.slice(prefix.length).split(":")[0] ?? ""; + commands.push({ + key, + projectRoot: args.projectRoot, + deviceId, + commandId: record.commandId, + action: record.action, + argsFingerprint: record.argsFingerprint, + ack: record.ack, + result: persistedResult, + acceptedAtMs: record.acceptedAtMs, + completedAtMs: record.completedAtMs, + }); + } + commands.sort((left, right) => right.completedAtMs - left.completedAtMs); + writeTextAtomic(commandLedgerPath, `${JSON.stringify({ commands: commands.slice(0, MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES) }, null, 2)}\n`); + }; + const loadPersistedCommandLedger = (): void => { + const nowMs = Date.now(); + for (const command of readPersistedCommandLedger()) { + if (command.projectRoot !== args.projectRoot) continue; + if (nowMs - command.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) continue; + const replayResult = persistedMobileCommandResult(command.action, command.result); + if (!replayResult) continue; + const legacyArgsKey = (command as { argsKey?: unknown }).argsKey; + const argsFingerprint = typeof command.argsFingerprint === "string" + ? command.argsFingerprint + : typeof legacyArgsKey === "string" + ? mobileCommandArgsFingerprint(legacyArgsKey) + : null; + if (!argsFingerprint) continue; + const key = + command.key.startsWith(commandLedgerLegacyRootPrefix()) && + commandLedgerScopeKey() !== args.projectRoot + ? `${commandLedgerKeyPrefix()}${command.key.slice(commandLedgerLegacyRootPrefix().length)}` + : command.key; + mobileCommandResultCache.set(key, { + commandId: command.commandId, + action: command.action, + argsKey: argsFingerprint, + argsFingerprint, + ack: command.ack, + result: replayResult, + waiters: [], + acceptedAtMs: command.acceptedAtMs, + completedAtMs: command.completedAtMs, + }); + } + }; + const commandLedgerSizeForProject = (): number => + [...mobileCommandResultCache.keys()].filter((key) => + key.startsWith(commandLedgerKeyPrefix()), + ).length; + const dropInFlightCommandRecordsForProject = (): void => { + for (const [key, record] of mobileCommandResultCache) { + if (!key.startsWith(commandLedgerKeyPrefix())) continue; + if (record.result == null) mobileCommandResultCache.delete(key); + } + }; + loadPersistedCommandLedger(); + /** Notification preferences keyed by deviceId. The map is a hot cache; + * device metadata is the restart-safe source for offline push fan-out. */ + const notificationPrefsByDeviceId = new Map(); + const storeNotificationPrefsForDevice = (deviceId: string, prefs: NotificationPreferences): void => { + const normalizedPrefs = normalizeNotificationPreferences(prefs); + notificationPrefsByDeviceId.set(deviceId, normalizedPrefs); + args.deviceRegistryService?.setNotificationPreferences?.(deviceId, normalizedPrefs); + }; + const readNotificationPrefsForDevice = (deviceId: string): NotificationPreferences => { + return notificationPrefsByDeviceId.get(deviceId) + ?? args.deviceRegistryService?.getNotificationPreferences?.(deviceId) + ?? DEFAULT_NOTIFICATION_PREFERENCES; + }; + const lanePresenceByLaneId = new Map>(); + let localActiveLaneIds = new Set(); + const PAIR_FAILURE_THRESHOLD = 5; + const PAIR_COOLDOWN_MS = 10 * 60_000; + const PAIR_FAILURE_WINDOW_MS = 10 * 60_000; + const pairFailures = new Map(); + const pruneExpiredPairFailures = (now = Date.now()): boolean => { + let changed = false; + for (const [ip, entry] of pairFailures) { + const cooldownExpired = entry.cooldownUntilMs > 0 && entry.cooldownUntilMs <= now; + const failureWindowExpired = entry.updatedAtMs + PAIR_FAILURE_WINDOW_MS <= now; + if (cooldownExpired || failureWindowExpired) { + pairFailures.delete(ip); + changed = true; + } + } + return changed; + }; + const registerPairFailure = (ip: string | null): void => { + if (!ip) return; + const now = Date.now(); + pruneExpiredPairFailures(now); + const entry = pairFailures.get(ip) ?? { count: 0, cooldownUntilMs: 0, updatedAtMs: now }; + entry.count += 1; + entry.updatedAtMs = now; + if (entry.count >= PAIR_FAILURE_THRESHOLD) { + entry.cooldownUntilMs = now + PAIR_COOLDOWN_MS; + entry.count = 0; + } + pairFailures.set(ip, entry); + }; + const pairingCooldownMsRemaining = (ip: string | null): number => { + if (!ip) return 0; + const entry = pairFailures.get(ip); + if (!entry) return 0; + const now = Date.now(); + const remaining = entry.cooldownUntilMs - now; + if (remaining > 0) return remaining; + if ( + (entry.cooldownUntilMs > 0 && remaining <= 0) + || entry.updatedAtMs + PAIR_FAILURE_WINDOW_MS <= now + ) { + pairFailures.delete(ip); + } + return 0; + }; + + const normalizeLaneId = (laneId: string | null | undefined): string | null => { + const normalized = toOptionalString(laneId); + return normalized && normalized.length > 0 ? normalized : null; + }; + + const listLanePresenceMarkers = (laneId: string): DeviceMarker[] => { + const entries = lanePresenceByLaneId.get(laneId); + if (!entries) return []; + return [...entries.values()] + .map((entry) => entry.marker) + .sort((left, right) => left.displayName.localeCompare(right.displayName)); + }; + + const upsertLanePresence = (argsIn: { + laneId: string; + marker: DeviceMarker; + source: "local" | "remote"; + }): boolean => { + const laneId = normalizeLaneId(argsIn.laneId); + if (!laneId) return false; + const byDevice = lanePresenceByLaneId.get(laneId) ?? new Map(); + const existing = byDevice.get(argsIn.marker.deviceId) ?? null; + const nextEntry: LanePresenceEntry = { + marker: argsIn.marker, + lastAnnouncedAtMs: Date.now(), + source: argsIn.source, + }; + byDevice.set(argsIn.marker.deviceId, nextEntry); + lanePresenceByLaneId.set(laneId, byDevice); + return ( + existing == null + || existing.source !== nextEntry.source + || existing.marker.displayName !== nextEntry.marker.displayName + || existing.marker.platform !== nextEntry.marker.platform + ); + }; + + const removeLanePresence = (laneId: string | null | undefined, deviceId: string | null | undefined): boolean => { + const normalizedLaneId = normalizeLaneId(laneId); + const normalizedDeviceId = toOptionalString(deviceId); + if (!normalizedLaneId || !normalizedDeviceId) return false; + const byDevice = lanePresenceByLaneId.get(normalizedLaneId); + if (!byDevice?.delete(normalizedDeviceId)) return false; + if (byDevice.size === 0) { + lanePresenceByLaneId.delete(normalizedLaneId); + } + return true; + }; + + const removeAllPresenceForDevice = ( + deviceId: string | null | undefined, + source?: LanePresenceEntry["source"], + ): boolean => { + const normalizedDeviceId = toOptionalString(deviceId); + if (!normalizedDeviceId) return false; + let changed = false; + for (const [laneId, byDevice] of lanePresenceByLaneId) { + const entry = byDevice.get(normalizedDeviceId); + if (!entry || (source && entry.source !== source)) continue; + byDevice.delete(normalizedDeviceId); + changed = true; + if (byDevice.size === 0) { + lanePresenceByLaneId.delete(laneId); + } + } + return changed; + }; + + const pruneExpiredLanePresence = (): boolean => { + const cutoff = Date.now() - LANE_PRESENCE_TTL_MS; + let changed = false; + for (const [laneId, byDevice] of lanePresenceByLaneId) { + for (const [deviceId, entry] of byDevice) { + if (entry.lastAnnouncedAtMs > cutoff) continue; + byDevice.delete(deviceId); + changed = true; + } + if (byDevice.size === 0) { + lanePresenceByLaneId.delete(laneId); + } + } + return changed; + }; + + const readLocalPresenceMarker = (): DeviceMarker | null => { + const localDevice = args.deviceRegistryService?.ensureLocalDevice() ?? null; + if (!localDevice) return null; + return { + deviceId: localDevice.deviceId, + displayName: localDevice.name, + platform: localDevice.platform, + }; + }; + + const refreshLocalLanePresence = (): boolean => { + if (localActiveLaneIds.size === 0) return false; + const marker = readLocalPresenceMarker(); + if (!marker) return false; + let changed = false; + for (const laneId of localActiveLaneIds) { + changed = upsertLanePresence({ + laneId, + marker, + source: "local", + }) || changed; + } + return changed; + }; + + const setLocalActiveLanePresence = (laneIds: string[]): void => { + const nextLaneIds = new Set( + laneIds + .map((laneId) => normalizeLaneId(laneId)) + .filter((laneId): laneId is string => laneId != null), + ); + const marker = readLocalPresenceMarker(); + let changed = false; + if (marker) { + for (const laneId of localActiveLaneIds) { + if (!nextLaneIds.has(laneId)) { + changed = removeLanePresence(laneId, marker.deviceId) || changed; + } + } + } + localActiveLaneIds = nextLaneIds; + if (marker) { + for (const laneId of localActiveLaneIds) { + changed = upsertLanePresence({ laneId, marker, source: "local" }) || changed; + } + } + if (changed) { + args.onStateChanged?.(); + broadcastBrainStatus(); + } + }; + + const buildRemotePresenceMarker = (peer: PeerState): DeviceMarker | null => { + if (!peer.metadata) return null; + return { + deviceId: peer.metadata.deviceId, + displayName: peer.metadata.deviceName, + platform: peer.metadata.platform, + }; + }; + + const decorateLaneSummary = (lane: LaneSummary): LaneSummary => { + const devicesOpen = listLanePresenceMarkers(lane.id); + return devicesOpen.length > 0 ? { ...lane, devicesOpen } : lane; + }; + + const decorateLaneSummaries = (lanes: LaneSummary[]): LaneSummary[] => + lanes.map((lane) => decorateLaneSummary(lane)); + + const decorateLaneListSnapshots = (snapshots: LaneListSnapshot[]): LaneListSnapshot[] => + snapshots.map((snapshot) => ({ + ...snapshot, + lane: decorateLaneSummary(snapshot.lane), + })); + + const decorateLaneDetailPayload = (detail: LaneDetailPayload): LaneDetailPayload => ({ + ...detail, + lane: decorateLaneSummary(detail.lane), + children: decorateLaneSummaries(detail.children), + }); + + const decorateCommandResult = ( + action: SyncCommandPayload["action"], + result: unknown, + ): unknown => { + pruneExpiredLanePresence(); + switch (action) { + case "lanes.list": + case "lanes.getChildren": + return Array.isArray(result) ? decorateLaneSummaries(result as LaneSummary[]) : result; + case "lanes.refreshSnapshots": { + const payload = result as + | { lanes?: LaneSummary[]; snapshots?: LaneListSnapshot[] } + | null + | undefined; + if (!payload || typeof payload !== "object") return result; + return { + ...payload, + ...(Array.isArray(payload.lanes) ? { lanes: decorateLaneSummaries(payload.lanes) } : {}), + ...(Array.isArray(payload.snapshots) + ? { snapshots: decorateLaneListSnapshots(payload.snapshots) } + : {}), + }; + } + case "lanes.getDetail": + return result && typeof result === "object" + ? decorateLaneDetailPayload(result as LaneDetailPayload) + : result; + case "lanes.create": + case "lanes.createChild": + case "lanes.createFromUnstaged": + case "lanes.importBranch": + case "lanes.attach": + case "lanes.adoptAttached": + return result && typeof result === "object" + ? decorateLaneSummary(result as LaneSummary) + : result; + default: + return result; + } + }; + const server = new WebSocketServer({ + host: "0.0.0.0", + port: args.port ?? DEFAULT_SYNC_HOST_PORT, + maxPayload: 25 * 1024 * 1024, + }); + + let disposed = false; + let startupError: Error | null = null; + let bonjourInstance: Bonjour | null = null; + let bonjourAnnouncement: BonjourService | null = null; + let bonjourPort: number | null = null; + let bonjourSignature: string | null = null; + let bonjourProjectTxt: { projects: string; projectNames: string; projectCount: string } = { + projects: typeof args.projectId === "string" && args.projectId.trim() ? args.projectId.trim() : "", + projectNames: typeof args.projectId === "string" && args.projectId.trim() ? "Current project" : "", + projectCount: typeof args.projectId === "string" && args.projectId.trim() ? "1" : "", + }; + let bonjourProjectRefreshInFlight = false; + let tailnetServeSignature: string | null = null; + let tailnetServeLastFailureSignature: string | null = null; + let tailnetServePublishSequence = 0; + let tailnetServeActivePublishToken = 0; + let discoveryEnabled = args.discoveryEnabled !== false; + let tailnetDiscoveryStatus: SyncTailnetDiscoveryStatus = { + state: !discoveryEnabled + ? "disabled" + : shouldAttemptTailnetServiceAdvertise() ? "disabled" : "unavailable", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: null, + error: !discoveryEnabled + ? "Tailnet discovery is disabled for this background project context." + : shouldAttemptTailnetServiceAdvertise() + ? "Tailnet discovery has not been published yet." + : "Tailscale Serve discovery is not available in this ADE process.", + stderr: null, + }; + let lastBroadcastAt: string | null = null; + const startedAtMs = Date.now(); + + server.on("error", (error: unknown) => { + const normalized = error instanceof Error ? error : new Error(String(error)); + if (!disposed && !server.address()) { + startupError = normalized; + } + args.logger.warn("sync_host.server_error", { + error: normalized.message, + code: (normalized as NodeJS.ErrnoException).code ?? null, + port: args.port ?? DEFAULT_SYNC_HOST_PORT, + }); + args.onStateChanged?.(); + }); + + const pollTimer = setInterval(() => { + void pumpChanges().catch((error) => { + args.logger.warn("sync_host.poll_failed", { error: error instanceof Error ? error.message : String(error) }); + }); + void pumpChatEvents().catch((error) => { + args.logger.warn("sync_host.chat_poll_failed", { error: error instanceof Error ? error.message : String(error) }); + }); + }, pollIntervalMs); + const heartbeatTimer = setInterval(() => { + pruneExpiredPairFailures(); + const refreshedLocalPresence = refreshLocalLanePresence(); + if (refreshedLocalPresence || pruneExpiredLanePresence()) { + args.onStateChanged?.(); + broadcastBrainStatus(); + } + const sentAt = nowIso(); + for (const peer of peers) { + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + if (isPeerBackpressured(peer)) { + args.logger.debug("sync_host.heartbeat_deferred_backpressure", { + peerDeviceId: peer.metadata?.deviceId ?? null, + bufferedAmount: peer.ws.bufferedAmount, + }); + continue; + } + if (peer.awaitingHeartbeatAt) { + peer.missedHeartbeatCount += 1; + if (peer.missedHeartbeatCount >= syncHeartbeatMissLimitForPeerMetadata(peer.metadata)) { + try { + peer.ws.close(4001, "Heartbeat timed out"); + } catch { + // ignore + } + continue; + } + } else { + peer.missedHeartbeatCount = 0; + } + peer.awaitingHeartbeatAt = sentAt; + send(peer.ws, "heartbeat", { kind: "ping", sentAt, dbVersion: args.db.sync.getDbVersion() }); + } + }, heartbeatIntervalMs); + const brainStatusTimer = setInterval(() => { + broadcastBrainStatus(); + }, brainStatusIntervalMs); + const chatEventSubscription = args.agentChatService?.subscribeToEvents( + (event) => { + broadcastChatEvent(event); + // Let the notification bus (mobile push fan-out) observe chat events. + // Failures here must never break chat delivery to the UI. + try { + args.notificationEventBus?.publishChatEvent(event); + } catch (error) { + args.logger.warn("sync_host.notification_publish_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }, + ) ?? null; + + server.on("connection", (ws, request) => { + const remoteAddress = sanitizeRemoteAddress(request.socket.remoteAddress); + const peer: PeerState = { + ws, + metadata: null, + authenticated: false, + authKind: null, + pairedDeviceId: null, + connectedAt: nowIso(), + lastSeenAt: nowIso(), + lastAppliedAt: null, + lastKnownServerDbVersion: 0, + latencyMs: null, + awaitingHeartbeatAt: null, + missedHeartbeatCount: 0, + remoteAddress, + remotePort: request.socket.remotePort ?? null, + subscribedSessionIds: new Set(), + subscribedChatSessionIds: new Set(), + chatTranscriptOffsets: new Map(), + chatEventIdsSent: new Map(), + pendingChangesetBatch: null, + }; + peers.add(peer); + ws.on("message", (raw) => { + void handleMessage(peer, raw).catch((error) => { + args.logger.warn("sync_host.message_failed", { + error: error instanceof Error ? error.message : String(error), + peerDeviceId: peer.metadata?.deviceId ?? null, + }); + }); + }); + ws.on("close", () => { + if (removeAllPresenceForDevice(peer.metadata?.deviceId, "remote")) { + broadcastBrainStatus(); + } + peers.delete(peer); + args.onStateChanged?.(); + broadcastBrainStatus(); + }); + ws.on("error", (error) => { + args.logger.warn("sync_host.socket_error", { + error: error instanceof Error ? error.message : String(error), + peerDeviceId: peer.metadata?.deviceId ?? null, + }); + }); + }); + + const publishLanDiscovery = (port: number): void => { + if (disposed) return; + if (!discoveryEnabled) { + unpublishLanDiscovery(); + return; + } + const localDevice = args.deviceRegistryService?.ensureLocalDevice() ?? null; + const hostName = localDevice?.name ?? os.hostname(); + const tailscaleDnsName = + typeof localDevice?.metadata?.tailscaleDnsName === "string" + ? localDevice.metadata.tailscaleDnsName.trim().replace(/\.$/, "").toLowerCase() + : ""; + const ipAddresses = uniqueStrings([ + ...(localDevice?.ipAddresses ?? []), + localDevice?.tailscaleIp ?? null, + ].filter((value): value is string => typeof value === "string" && value.trim().length > 0)); + const addressesCsv = ipAddresses.length > 0 ? ipAddresses.join(",") : "127.0.0.1"; + const preferredHost = ipAddresses[0] ?? localDevice?.lastHost ?? ""; + const txt = { + version: "1", + runtimeKind: args.runtimeKind ?? "desktop-embedded", + runtimeVersion: args.runtimeVersion ?? "", + projects: bonjourProjectTxt.projects, + projectNames: bonjourProjectTxt.projectNames, + projectCount: bonjourProjectTxt.projectCount, + deviceId: localDevice?.deviceId ?? "", + siteId: localDevice?.siteId ?? "", + deviceName: hostName, + port: String(port), + host: preferredHost, + addresses: addressesCsv, + tailscaleIp: localDevice?.tailscaleIp ?? "", + tailscaleDnsName: tailscaleDnsName.endsWith(".ts.net") ? tailscaleDnsName : "", + }; + const signature = JSON.stringify({ hostName, port, txt }); + if (bonjourAnnouncement && bonjourPort === port && bonjourSignature === signature) return; + if (!bonjourInstance) { + bonjourInstance = new Bonjour(undefined, (error: unknown) => { + args.logger.warn("sync_host.discovery_error", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } + if (bonjourAnnouncement) { + try { + bonjourAnnouncement.stop?.(); + } catch { + // ignore cleanup failures + } + bonjourAnnouncement = null; + } + bonjourPort = port; + bonjourSignature = signature; + bonjourAnnouncement = bonjourInstance.publish({ + name: `ADE Sync ${hostName} ${port}`, + type: SYNC_MDNS_SERVICE_TYPE, + protocol: "tcp", + port, + txt, + disableIPv6: true, + }); + bonjourAnnouncement.on("error", (error: unknown) => { + args.logger.warn("sync_host.discovery_publish_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + refreshLanDiscoveryProjects(port); + }; + + const refreshLanDiscoveryProjects = (port: number, projectCatalog?: SyncProjectCatalogPayload): void => { + if ((!args.projectCatalogProvider && !projectCatalog) || bonjourProjectRefreshInFlight) return; + bonjourProjectRefreshInFlight = true; + void Promise.resolve(projectCatalog ?? buildProjectCatalogPayload()) + .then((catalog) => { + const projectIds = uniqueStrings(catalog.projects + .map((project) => project.id) + .filter((value): value is string => typeof value === "string" && value.trim().length > 0)) + .slice(0, BONJOUR_PROJECT_TXT_ENTRY_LIMIT); + const projectNames = uniqueStrings(catalog.projects + .map((project) => typeof project.displayName === "string" ? project.displayName : "") + .map((value) => value.replace(/[,\r\n]/g, " ").replace(/\s+/g, " ").trim().slice(0, BONJOUR_PROJECT_NAME_MAX_LENGTH)) + .filter((value) => value.length > 0)) + .slice(0, BONJOUR_PROJECT_TXT_ENTRY_LIMIT); + const next = { + projects: projectIds.join(","), + projectNames: projectNames.join(","), + projectCount: String(catalog.projects.length), + }; + if ( + next.projects === bonjourProjectTxt.projects + && next.projectNames === bonjourProjectTxt.projectNames + && next.projectCount === bonjourProjectTxt.projectCount + ) { + return; + } + bonjourProjectTxt = next; + if (bonjourPort === port) { + publishLanDiscovery(port); + } + }) + .catch((error) => { + args.logger.warn("sync_host.discovery_project_catalog_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + bonjourProjectRefreshInFlight = false; + }); + }; + + const unpublishLanDiscovery = (): void => { + if (!bonjourAnnouncement) return; + try { + bonjourAnnouncement.stop?.(); + } catch { + // ignore cleanup failures + } + bonjourAnnouncement = null; + bonjourPort = null; + bonjourSignature = null; + }; + + const updateTailnetDiscoveryStatus = ( + next: SyncTailnetDiscoveryStatus, + ): void => { + tailnetDiscoveryStatus = next; + setTimeout(() => { + if (!disposed) args.onStateChanged?.(); + }, 0); + }; + + const publishTailnetDiscovery = ( + port: number, + options?: { force?: boolean }, + ): void => { + if (disposed) return; + if (!discoveryEnabled) { + void unpublishTailnetDiscovery(); + updateTailnetDiscoveryStatus({ + state: "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: "Tailnet discovery is disabled for this background project context.", + stderr: null, + }); + return; + } + if (!shouldAttemptTailnetServiceAdvertise()) { + updateTailnetDiscoveryStatus({ + state: "unavailable", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: "Tailscale Serve discovery is not available in this ADE process.", + stderr: null, + }); + return; + } + const cli = resolveTailscaleCliPath(); + const signature = `${SYNC_TAILNET_DISCOVERY_SERVICE_NAME}:${SYNC_TAILNET_DISCOVERY_SERVICE_PORT}->${port}`; + if (tailnetServeSignature === signature && !options?.force) return; + if (tailnetServeLastFailureSignature === signature && !options?.force) return; + const publishToken = ++tailnetServePublishSequence; + tailnetServeActivePublishToken = publishToken; + tailnetServeSignature = signature; + const target = `tcp://127.0.0.1:${port}`; + updateTailnetDiscoveryStatus({ + state: "publishing", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target, + updatedAt: nowIso(), + error: null, + stderr: null, + }); + const cliArgs = [ + "serve", + "--yes", + `--service=${SYNC_TAILNET_DISCOVERY_SERVICE_NAME}`, + `--tcp=${SYNC_TAILNET_DISCOVERY_SERVICE_PORT}`, + target, + ]; + void execFileAsync(cli, cliArgs, { timeout: 10_000 }) + .then(({ stdout, stderr }) => { + if (tailnetServeActivePublishToken !== publishToken) return; + tailnetServeLastFailureSignature = null; + const stdoutText = stdout.trim(); + const stderrText = stderr.trim(); + const outputText = [stdoutText, stderrText].filter(Boolean).join("\n"); + updateTailnetDiscoveryStatus({ + state: looksLikePendingTailnetApproval(outputText) ? "pending_approval" : "published", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target, + updatedAt: nowIso(), + error: null, + stderr: stderrText || null, + }); + args.logger.info("sync_host.tailnet_discovery_published", { + service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target, + stdout: stdoutText || null, + stderr: stderrText || null, + }); + }) + .catch((error: unknown) => { + if (tailnetServeActivePublishToken !== publishToken) return; + if (tailnetServeSignature === signature) { + tailnetServeSignature = null; + } + tailnetServeLastFailureSignature = signature; + const errorMessage = error instanceof Error ? error.message : String(error); + const code = (error as NodeJS.ErrnoException | null | undefined)?.code ?? null; + const stderr = typeof (error as { stderr?: unknown })?.stderr === "string" + ? String((error as { stderr?: string }).stderr).trim() + : null; + const errorText = [errorMessage, stderr].filter(Boolean).join("\n"); + updateTailnetDiscoveryStatus({ + state: code === "ENOENT" + ? "unavailable" + : looksLikePendingTailnetApproval(errorText) + ? "pending_approval" + : "failed", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target, + updatedAt: nowIso(), + error: code === "ENOENT" ? "Tailscale CLI was not found." : errorMessage, + stderr, + }); + const logPayload = { + service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target, + error: errorMessage, + code, + stderr, + }; + if (code === "ENOENT") { + args.logger.info("sync_host.tailnet_discovery_unavailable", logPayload); + } else { + args.logger.warn("sync_host.tailnet_discovery_failed", logPayload); + } + }); + }; + + const unpublishTailnetDiscovery = async (): Promise => { + if (!tailnetServeSignature) return; + tailnetServeActivePublishToken = ++tailnetServePublishSequence; + tailnetServeSignature = null; + if (!shouldAttemptTailnetServiceAdvertise()) { + updateTailnetDiscoveryStatus({ + state: "unavailable", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: null, + stderr: null, + }); + return; + } + const cli = resolveTailscaleCliPath(); + try { + await execFileAsync( + cli, + ["serve", "--yes", `--service=${SYNC_TAILNET_DISCOVERY_SERVICE_NAME}`, "off"], + { timeout: 10_000 }, + ); + updateTailnetDiscoveryStatus({ + state: "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: null, + stderr: null, + }); + args.logger.info("sync_host.tailnet_discovery_unpublished", { + service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + }); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + const code = (error as NodeJS.ErrnoException | null | undefined)?.code ?? null; + updateTailnetDiscoveryStatus({ + state: code === "ENOENT" ? "unavailable" : "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: code === "ENOENT" ? "Tailscale CLI was not found." : errorMessage, + stderr: null, + }); + args.logger.warn("sync_host.tailnet_discovery_unpublish_failed", { + service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + error: errorMessage, + code, + }); + } + }; + + function send(target: WebSocket | PeerState, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): boolean { + const ws = target instanceof WebSocket ? target : target.ws; + if (ws.readyState !== WebSocket.OPEN) return false; + // Drop sends to backpressured peers as the default — most envelopes are + // either replayable (chat events / changesets re-derived from db state) or + // tolerable to lose (acks, status pings). Routes that *must* deliver under + // backpressure should call ws.send / sendAndWait directly. + if (target instanceof WebSocket ? ws.bufferedAmount >= PEER_BACKPRESSURE_BYTES : isPeerBackpressured(target)) { + return false; + } + ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes })); + return true; + } + + function sendRequired(peer: PeerState, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): boolean { + const ws = peer.ws; + if (ws.readyState !== WebSocket.OPEN) return false; + ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), (error) => { + if (!error) return; + args.logger.warn("sync_host.required_send_failed", { + type, + requestId: requestId ?? null, + peerDeviceId: peer.metadata?.deviceId ?? peer.pairedDeviceId ?? null, + error: error.message, + }); + }); + return true; + } + + function isPeerBackpressured(peer: PeerState): boolean { + return peer.ws.bufferedAmount >= PEER_BACKPRESSURE_BYTES; + } + + function sendAndWait( + ws: WebSocket, + type: SyncEnvelope["type"], + payload: TPayload, + requestId?: string | null, + ): Promise { + if (ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) { + return Promise.reject(new Error("Cannot send on closed WebSocket.")); + } + return new Promise((resolve, reject) => { + ws.send( + encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), + (error) => { + if (error) reject(error); + else resolve(); + }, + ); + }); + } + + function encodedEnvelopeBytes( + type: SyncEnvelope["type"], + payload: TPayload, + requestId?: string | null, + ): number { + return Buffer.byteLength(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), "utf8"); + } + + function closeExistingPeersForDevice(deviceId: string, currentPeer: PeerState): void { + const normalized = toOptionalString(deviceId); + if (!normalized) return; + for (const peer of peers) { + if (peer === currentPeer) continue; + if (peer.metadata?.deviceId !== normalized && peer.pairedDeviceId !== normalized) continue; + peer.authenticated = false; + peer.metadata = null; + peer.authKind = null; + peer.pairedDeviceId = null; + try { + peer.ws.close(4000, "Superseded by a newer connection for this device"); + } catch { + // ignore close failures + } + } + } + + function makeChangesetBatchId(peer: PeerState, fromDbVersion: number, toDbVersion: number): string { + const deviceId = peer.metadata?.deviceId ?? peer.pairedDeviceId ?? "peer"; + return `changeset:${deviceId}:${fromDbVersion}:${toDbVersion}:${Date.now()}:${randomBytes(4).toString("hex")}`; + } + + function peerSupportsChangesetAck(peer: PeerState): boolean { + return Array.isArray(peer.metadata?.capabilities) && peer.metadata.capabilities.includes("changesetAck"); + } + + function sendNextChangesetBatch( + peer: PeerState, + reason: SyncChangesetBatchPayload["reason"], + fromDbVersion: number, + toDbVersion: number, + changes: CrsqlChangeRow[], + ): PendingChangesetBatch | null { + let chunk: CrsqlChangeRow[] = []; + let chunkBytes = 0; + + for (const change of changes) { + const changeBytes = Buffer.byteLength(JSON.stringify(change), "utf8"); + if ( + chunk.length > 0 + && (chunk.length >= maxChangesetBatchRows || chunkBytes + changeBytes > maxChangesetBatchBytes) + ) { + break; + } + chunk.push(change); + chunkBytes += changeBytes; + } + if (chunk.length === 0 && changes.length > 0) { + chunk = [changes[0]!]; + } + if (chunk.length === 0 && toDbVersion <= fromDbVersion) return null; + + const chunkToDbVersion = chunk.length > 0 + ? Math.max(...chunk.map((change) => Number(change.db_version ?? fromDbVersion))) + : toDbVersion; + const batch: PendingChangesetBatch = { + batchId: makeChangesetBatchId(peer, fromDbVersion, chunkToDbVersion), + reason, + fromDbVersion, + toDbVersion: chunkToDbVersion, + changes: chunk, + sentAtMs: Date.now(), + retryCount: 0, + }; + const sent = send(peer, "changeset_batch", { + batchId: batch.batchId, + reason, + fromDbVersion, + toDbVersion: chunkToDbVersion, + changes: chunk, + }); + return sent ? batch : null; + } + + function resendPendingChangesetBatch(peer: PeerState): boolean { + const batch = peer.pendingChangesetBatch; + if (!batch) return false; + batch.sentAtMs = Date.now(); + batch.retryCount += 1; + return send(peer, "changeset_batch", { + batchId: batch.batchId, + reason: batch.reason, + fromDbVersion: batch.fromDbVersion, + toDbVersion: batch.toDbVersion, + changes: batch.changes, + }); + } + + async function buildProjectCatalogPayload(): Promise { + if (!args.projectCatalogProvider) { + return { projects: [] }; + } + try { + return await args.projectCatalogProvider.listProjects(); + } catch (error) { + args.logger.warn("sync_host.project_catalog_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return { projects: [] }; + } + } + + function splitProjectCatalog(projects: SyncMobileProjectSummary[]): SyncMobileProjectSummary[][] { + const chunks: SyncMobileProjectSummary[][] = []; + let chunk: SyncMobileProjectSummary[] = []; + let chunkBytes = 0; + + const flush = (): void => { + if (chunk.length === 0) return; + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + }; + + for (const project of projects) { + const projectBytes = Buffer.byteLength(JSON.stringify(project), "utf8"); + if (chunk.length > 0 && chunkBytes + projectBytes > maxProjectCatalogChunkBytes) { + flush(); + } + chunk.push(project); + chunkBytes += projectBytes; + } + flush(); + return chunks; + } + + function sendProjectCatalog( + peer: PeerState, + projectCatalog: SyncProjectCatalogPayload, + requestId?: string | null, + ): void { + if (encodedEnvelopeBytes("project_catalog", projectCatalog, requestId) <= maxProjectCatalogEnvelopeBytes) { + send(peer.ws, "project_catalog", projectCatalog, requestId); + return; + } + + const chunks = splitProjectCatalog(projectCatalog.projects); + const total = Math.max(1, chunks.length); + const catalogId = randomBytes(8).toString("hex"); + if (chunks.length === 0) { + send(peer.ws, "project_catalog_chunk", { + catalogId, + index: 0, + total, + done: true, + projects: [], + } satisfies SyncProjectCatalogChunkPayload, requestId); + return; + } + + chunks.forEach((projects, index) => { + send(peer.ws, "project_catalog_chunk", { + catalogId, + index, + total, + done: index === total - 1, + projects, + } satisfies SyncProjectCatalogChunkPayload, requestId); + }); + } + + async function handleProjectSwitchRequest( + peer: PeerState, + requestId: string | null | undefined, + payload: SyncProjectSwitchRequestPayload | null, + ): Promise { + if (!args.projectCatalogProvider) { + sendRequired(peer, "project_switch_result", { + ok: false, + message: "Project switching is not available from this machine.", + }, requestId); + return; + } + try { + const result = await args.projectCatalogProvider.prepareProjectConnection(payload ?? {}); + await sendAndWait(peer.ws, "project_switch_result", result, requestId); + try { + await args.projectCatalogProvider.completeProjectConnection?.(payload ?? {}, result); + } catch (completionError) { + args.logger.warn("sync_host.project_switch_completion_failed", { + message: completionError instanceof Error ? completionError.message : String(completionError), + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + args.logger.warn("sync_host.project_switch_failed", { message }); + sendRequired(peer, "project_switch_result", { + ok: false, + message, + }, requestId); + } + } + + function buildBrainStatus(): SyncBrainStatusPayload { + const brainMetadata = readBrainMetadata(); + if (disposed) { + return { + brain: brainMetadata, + connectedPeers: [], + metrics: { + connectedPeerCount: 0, + runningSessionCount: 0, + dbVersion: brainMetadata.dbVersion, + uptimeMs: Date.now() - startedAtMs, + lastBroadcastAt, + pendingChangesetPeerCount: 0, + commandLedgerSize: commandLedgerSizeForProject(), + commandReplayCount, + commandConflictCount, + lastCommandResultLatencyMs, + lastChangesetAckLatencyMs, + }, + }; + } + const dbVersion = args.db.sync.getDbVersion(); + const connectedPeers = [...peers] + .map((peer) => toSyncPeerConnectionState(peer, dbVersion)) + .filter((peer): peer is SyncPeerConnectionState => peer != null); + return { + brain: { + ...brainMetadata, + dbVersion, + }, + connectedPeers, + metrics: { + connectedPeerCount: connectedPeers.length, + runningSessionCount: args.sessionService.list({ status: "running", limit: 200 }).length, + dbVersion, + uptimeMs: Date.now() - startedAtMs, + lastBroadcastAt, + pendingChangesetPeerCount: [...peers].filter((peer) => peer.pendingChangesetBatch != null).length, + commandLedgerSize: commandLedgerSizeForProject(), + commandReplayCount, + commandConflictCount, + lastCommandResultLatencyMs, + lastChangesetAckLatencyMs, + }, + }; + } + + function broadcastBrainStatus(): void { + if (disposed) return; + const payload = buildBrainStatus(); + for (const peer of peers) { + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + send(peer.ws, "brain_status", payload); + } + } + + async function readChatTranscriptEventsSince( + transcriptPath: string, + startOffset: number, + ): Promise<{ events: AgentChatEventEnvelope[]; nextOffset: number }> { + let fh: fs.promises.FileHandle | null = null; + try { + fh = await fs.promises.open(transcriptPath, "r"); + const stat = await fh.stat(); + const size = stat.size; + const normalizedStart = Math.max(0, Math.min(startOffset, size)); + if (size <= normalizedStart) { + return { events: [], nextOffset: size }; + } + + const out = Buffer.alloc(size - normalizedStart); + await fh.read(out, 0, out.length, normalizedStart); + const lastNewline = out.lastIndexOf(0x0a); + if (lastNewline < 0) { + return { events: [], nextOffset: normalizedStart }; + } + + const completeSlice = out.subarray(0, lastNewline + 1); + const raw = completeSlice.toString("utf8"); + return { + events: parseAgentChatTranscript(raw), + nextOffset: normalizedStart + completeSlice.length, + }; + } catch { + return { events: [], nextOffset: Math.max(0, startOffset) }; + } finally { + await fh?.close().catch(() => {}); + } + } + + function chatEventDeliveryKey(event: AgentChatEventEnvelope): string { + return `${event.sessionId}:${event.sequence ?? -1}:${event.timestamp}:${event.event.type}`; + } + + function rememberChatEventSent(peer: PeerState, event: AgentChatEventEnvelope): boolean { + const key = chatEventDeliveryKey(event); + let sent = peer.chatEventIdsSent.get(event.sessionId); + if (!sent) { + sent = new Set(); + peer.chatEventIdsSent.set(event.sessionId, sent); + } + if (sent.has(key)) return false; + sent.add(key); + if (sent.size > 800) { + const overflow = sent.size - 800; + let removed = 0; + for (const existingKey of sent) { + sent.delete(existingKey); + removed += 1; + if (removed >= overflow) break; + } + } + return true; + } + + async function pumpChatEvents(): Promise { + if (disposed) return; + + for (const peer of peers) { + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + if (isPeerBackpressured(peer)) continue; + for (const sessionId of peer.subscribedChatSessionIds) { + const session = args.sessionService.get(sessionId); + if (!session?.transcriptPath) continue; + + const startOffset = peer.chatTranscriptOffsets.get(sessionId) ?? 0; + const { events, nextOffset } = await readChatTranscriptEventsSince(session.transcriptPath, startOffset); + if (nextOffset !== startOffset) { + peer.chatTranscriptOffsets.set(sessionId, nextOffset); + } + for (const event of events) { + if (!rememberChatEventSent(peer, event)) continue; + send(peer.ws, "chat_event", event); + } + } + } + } + + function broadcastChatEvent(event: AgentChatEventEnvelope): void { + for (const peer of peers) { + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + if (isPeerBackpressured(peer)) continue; + if (!peer.subscribedChatSessionIds.has(event.sessionId)) continue; + if (!rememberChatEventSent(peer, event)) continue; + send(peer.ws, "chat_event", event); + } + } + + async function pumpChanges(): Promise { + if (disposed) return; + const currentDbVersion = args.db.sync.getDbVersion(); + const nowMs = Date.now(); + for (const peer of peers) { + if (!peer.authenticated || !peer.metadata || peer.ws.readyState !== WebSocket.OPEN) continue; + if (isPeerBackpressured(peer)) continue; + if (peer.pendingChangesetBatch) { + if (nowMs - peer.pendingChangesetBatch.sentAtMs >= CHANGESET_ACK_TIMEOUT_MS) { + const pending = peer.pendingChangesetBatch; + if (pending.retryCount >= MAX_CHANGESET_ACK_RETRIES) { + args.logger.warn("sync_host.changeset_ack_timeout", { + peerDeviceId: peer.metadata.deviceId, + batchId: pending.batchId, + fromDbVersion: pending.fromDbVersion, + toDbVersion: pending.toDbVersion, + retryCount: pending.retryCount, + }); + try { + peer.ws.close(4000, "Changeset acknowledgement timed out"); + } catch { + // ignore close failures + } + continue; + } + const resent = resendPendingChangesetBatch(peer); + args.logger.debug("sync_host.changeset_ack_retry", { + peerDeviceId: peer.metadata.deviceId, + batchId: pending.batchId, + fromDbVersion: pending.fromDbVersion, + toDbVersion: pending.toDbVersion, + retryCount: pending.retryCount, + resent, + }); + } + continue; + } + if (currentDbVersion <= peer.lastKnownServerDbVersion) continue; + const changes = args.db.sync + .exportChangesSince(peer.lastKnownServerDbVersion) + .filter((change: CrsqlChangeRow) => change.site_id !== peer.metadata?.siteId); + const pending = sendNextChangesetBatch(peer, "broadcast", peer.lastKnownServerDbVersion, currentDbVersion, changes); + if (pending) { + if (peerSupportsChangesetAck(peer)) { + peer.pendingChangesetBatch = pending; + } else { + peer.lastKnownServerDbVersion = Math.max(peer.lastKnownServerDbVersion, pending.toDbVersion); + } + lastBroadcastAt = nowIso(); + } else { + args.logger.debug("sync_host.changeset_deferred_backpressure", { + peerDeviceId: peer.metadata?.deviceId ?? null, + fromDbVersion: peer.lastKnownServerDbVersion, + toDbVersion: currentDbVersion, + bufferedAmount: peer.ws.bufferedAmount, + }); + } + } + } + + function handleChangesetAck(peer: PeerState, payload: SyncChangesetAckPayload | null | undefined): void { + const pending = peer.pendingChangesetBatch; + if (!pending || !payload) return; + if (payload.batchId !== pending.batchId) { + args.logger.debug("sync_host.changeset_ack_ignored", { + peerDeviceId: peer.metadata?.deviceId ?? null, + expectedBatchId: pending.batchId, + receivedBatchId: payload.batchId, + }); + return; + } + if (!payload.ok) { + pending.retryCount += 1; + pending.sentAtMs = Date.now(); + args.logger.warn("sync_host.changeset_ack_failed", { + peerDeviceId: peer.metadata?.deviceId ?? null, + batchId: pending.batchId, + fromDbVersion: pending.fromDbVersion, + toDbVersion: pending.toDbVersion, + retryCount: pending.retryCount, + error: payload.error?.message ?? "Changeset apply failed.", + }); + if (pending.retryCount >= MAX_CHANGESET_ACK_RETRIES) { + try { + peer.ws.close(4000, "Changeset apply failed repeatedly"); + } catch { + // ignore close failures + } + } + return; + } + if (payload.toDbVersion < pending.toDbVersion) return; + peer.lastKnownServerDbVersion = Math.max(peer.lastKnownServerDbVersion, pending.toDbVersion); + peer.pendingChangesetBatch = null; + peer.lastAppliedAt = nowIso(); + lastChangesetAckLatencyMs = Math.max(0, Date.now() - pending.sentAtMs); + args.logger.debug("sync_host.changeset_ack_applied", { + peerDeviceId: peer.metadata?.deviceId ?? null, + batchId: pending.batchId, + fromDbVersion: pending.fromDbVersion, + toDbVersion: pending.toDbVersion, + latencyMs: lastChangesetAckLatencyMs, + }); + broadcastBrainStatus(); + } + + function resolveArtifactPath(request: Extract["args"]): string { + const artifactId = toOptionalString(request.artifactId); + const explicitUri = toOptionalString(request.uri) ?? toOptionalString(request.path); + let candidate = explicitUri; + if (artifactId) { + const artifact = args.computerUseArtifactBrokerService.listArtifacts({ artifactId })[0] ?? null; + candidate = artifact?.uri ?? candidate; + } + if (!candidate) { + throw new Error("Artifact request requires artifactId, uri, or path."); + } + if (/^https?:\/\//i.test(candidate)) { + throw new Error("Remote artifact URLs are not supported by this sync host."); + } + if (/^file:\/\//i.test(candidate)) { + try { + candidate = fileURLToPath(candidate); + } catch { + throw new Error("Artifact file URL is invalid."); + } + } + const absolute = path.isAbsolute(candidate) + ? candidate + : path.resolve(args.projectRoot, candidate); + let resolvedArtifactPath: string; + try { + resolvedArtifactPath = resolvePathWithinRoot(layout.artifactsDir, absolute); + } catch { + throw new Error("Artifact path must resolve within .ade/artifacts."); + } + if (!fs.existsSync(resolvedArtifactPath) || !fs.statSync(resolvedArtifactPath).isFile()) { + throw new Error("Artifact file does not exist."); + } + return resolvedArtifactPath; + } + + function isMobilePeer(peer: PeerState): boolean { + return peer.metadata?.platform === "iOS" || peer.metadata?.deviceType === "phone"; + } + + function assertMobileFileMutationAllowed(peer: PeerState, payload: SyncFileRequest): void { + if (!MOBILE_MUTATING_FILE_ACTIONS.has(payload.action)) return; + if (!isMobilePeer(peer)) return; + + const workspaceId = toOptionalString((payload as { args?: { workspaceId?: unknown } }).args?.workspaceId); + if (!workspaceId) return; + const workspace = args.fileService.listWorkspaces({ includeArchived: true }) + .find((entry) => entry.id === workspaceId); + if (!workspace || workspace.mobileReadOnly === true || workspace.isReadOnlyByDefault) { + throw new Error("Mobile file access is read-only for this workspace."); + } + } + + function isMobileLaneFileMutationBlocked(payload: SyncCommandPayload): boolean { + const laneId = toOptionalString((payload.args as Record | null | undefined)?.laneId); + if (!laneId) return false; + const workspace = args.fileService.listWorkspaces({ includeArchived: true }) + .find((entry) => entry.laneId === laneId); + return workspace ? workspace.mobileReadOnly === true || workspace.isReadOnlyByDefault : true; + } + + async function handleFileRequest(peer: PeerState, requestId: string | null, payload: SyncFileRequest): Promise { + const respond = (response: SyncFileResponsePayload) => { + sendRequired(peer, "file_response", response, requestId); + }; + + try { + assertMobileFileMutationAllowed(peer, payload); + let result: + | FilesWorkspace[] + | FileTreeNode[] + | FileContent + | FilesQuickOpenItem[] + | FilesSearchTextMatch[] + | SyncFileBlob + | { ok: true } = { ok: true }; + + switch (payload.action) { + case "listWorkspaces": + result = args.fileService.listWorkspaces(payload.args ?? {}); + break; + case "listTree": + result = await args.fileService.listTree(payload.args); + break; + case "readFile": + result = fileContentToBlob(payload.args.path, args.fileService.readFile(payload.args)); + break; + case "writeText": + args.fileService.writeWorkspaceText(payload.args); + result = { ok: true }; + break; + case "createFile": + args.fileService.createFile(payload.args); + result = { ok: true }; + break; + case "createDirectory": + args.fileService.createDirectory(payload.args); + result = { ok: true }; + break; + case "rename": + args.fileService.rename(payload.args); + result = { ok: true }; + break; + case "deletePath": + args.fileService.deletePath(payload.args); + result = { ok: true }; + break; + case "quickOpen": + result = await args.fileService.quickOpen(payload.args); + break; + case "searchText": + result = await args.fileService.searchText(payload.args); + break; + case "readArtifact": { + const artifactPath = resolveArtifactPath(payload.args); + result = createBlobFromBuffer(normalizeRelative(path.relative(args.projectRoot, artifactPath)), fs.readFileSync(artifactPath)); + break; + } + default: + throw new Error(`Unsupported file action: ${(payload as { action?: string }).action ?? "unknown"}`); + } + + respond({ + ok: true, + action: payload.action, + result, + }); + } catch (error) { + respond({ + ok: false, + action: payload.action, + error: { + code: "file_request_failed", + message: error instanceof Error ? error.message : String(error), + }, + }); + } + } + + async function handleCommand(peer: PeerState, requestId: string | null, payload: SyncCommandPayload): Promise { + const commandId = toOptionalString(payload.commandId) ?? requestId ?? `cmd-${Date.now()}`; + const requestedProjectId = toOptionalString(payload.projectId); + const hostProjectId = toOptionalString(args.projectId); + const commandScopeKey = requestedProjectId ?? hostProjectId ?? args.projectRoot; + const commandCacheKey = mobileCommandCacheKey(commandScopeKey, peer, commandId); + const commandArgsKey = stableJsonKey(payload.args ?? {}); + const commandArgsFingerprint = mobileCommandArgsFingerprint(commandArgsKey); + pruneMobileCommandResultCache(); + + const sendResult = (record: CachedMobileCommand | null, result: SyncCommandResultPayload) => { + if (!record) { + sendRequired(peer, "command_result", result, requestId); + return; + } + record.result = result; + record.completedAtMs = Date.now(); + lastCommandResultLatencyMs = Math.max(0, record.completedAtMs - record.acceptedAtMs); + const waiters = record.waiters.splice(0); + for (const waiter of waiters) { + sendRequired(waiter.peer, "command_result", result, waiter.requestId); + } + pruneMobileCommandResultCache(); + try { + writePersistedCommandLedger(); + } catch (error) { + args.logger.warn("sync_host.command_ledger_write_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }; + const startCommandRecord = (ack: SyncCommandAckPayload): CachedMobileCommand | null => { + sendRequired(peer, "command_ack", ack, requestId); + if (!commandCacheKey) return null; + const record: CachedMobileCommand = { + commandId, + action: payload.action, + argsKey: commandArgsKey, + argsFingerprint: commandArgsFingerprint, + ack, + result: null, + waiters: [{ peer, requestId }], + acceptedAtMs: Date.now(), + completedAtMs: null, + }; + mobileCommandResultCache.set(commandCacheKey, record); + return record; + }; + const existingCommand = commandCacheKey ? mobileCommandResultCache.get(commandCacheKey) : null; + if (existingCommand) { + if (existingCommand.action !== payload.action || existingCommand.argsFingerprint !== commandArgsFingerprint) { + commandConflictCount += 1; + const mismatchResult: SyncCommandResultPayload = { + commandId, + ok: false, + error: { + code: "duplicate_command_mismatch", + message: "A command with this id already exists for a different action or payload.", + }, + }; + sendRequired(peer, "command_ack", { + commandId, + accepted: false, + status: "rejected", + message: mismatchResult.error?.message ?? null, + }, requestId); + sendRequired(peer, "command_result", mismatchResult, requestId); + return; + } + commandReplayCount += 1; + sendRequired(peer, "command_ack", existingCommand.ack, requestId); + if (existingCommand.result) { + sendRequired(peer, "command_result", existingCommand.result, requestId); + } else { + addMobileCommandWaiter(existingCommand, peer, requestId); + } + return; + } + + const reject = (message: string, code = "unsupported_command") => { + const ack: SyncCommandAckPayload = { + commandId, + accepted: false, + status: "rejected", + message, + }; + const result: SyncCommandResultPayload = { + commandId, + ok: false, + error: { + code, + message, + }, + }; + sendResult(startCommandRecord(ack), result); + }; + + const descriptor = remoteCommandService.getDescriptor(payload.action); + const policy = descriptor?.policy ?? null; + const shouldRouteToProject = + Boolean(args.remoteCommandExecutor) + && Boolean(requestedProjectId) + && requestedProjectId !== hostProjectId; + if (requestedProjectId && hostProjectId && requestedProjectId !== hostProjectId && !shouldRouteToProject) { + reject("This ADE machine is hosting a different project. Select the project again and retry.", "project_not_open"); + return; + } + if (payload.action === "notification_prefs") { + // iOS bridges `SyncService.setMutePush` through the command envelope + // rather than a second `notification_prefs` envelope. We translate by + // merging `{ muteUntil }` into the device's existing prefs (or the + // default prefs if none have been uploaded yet) so the notification + // bus starts gating immediately — the same `isAllowedByPrefs` path the + // envelope-based update feeds. + const deviceId = peer.metadata?.deviceId; + if (!deviceId) { + reject("notification_prefs requires an authenticated device.", "invalid_command"); + return; + } + const rawArgs = (payload.args as Record | null | undefined) ?? {}; + const rawMute = rawArgs.muteUntil; + const muteUntil = typeof rawMute === "string" && rawMute.length > 0 ? rawMute : null; + const existing = readNotificationPrefsForDevice(deviceId); + storeNotificationPrefsForDevice(deviceId, { ...existing, muteUntil }); + const ack: SyncCommandAckPayload = { + commandId, + accepted: true, + status: "accepted", + message: muteUntil ? `Muted pushes until ${muteUntil}.` : "Cleared push mute.", + }; + sendResult(startCommandRecord(ack), { + commandId, + ok: true, + result: { ok: true, muteUntil }, + }); + return; + } + if (payload.action === "lanes.presence.announce" || payload.action === "lanes.presence.release") { + if (requestedProjectId && hostProjectId && requestedProjectId !== hostProjectId) { + reject("Lane presence is not available for a project that is not open in this phone sync host.", "project_not_open"); + return; + } + if (hostProjectId && !requestedProjectId) { + reject(`${payload.action} requires projectId. Select the project again and retry.`, "missing_project"); + return; + } + const laneId = normalizeLaneId((payload.args as Record | null | undefined)?.laneId as string | null); + if (!laneId) { + reject(`${payload.action} requires laneId.`, "invalid_command"); + return; + } + const marker = buildRemotePresenceMarker(peer); + if (!marker) { + reject("Lane presence requires authenticated peer metadata.", "invalid_command"); + return; + } + const changed = payload.action === "lanes.presence.announce" + ? upsertLanePresence({ laneId, marker, source: "remote" }) + : removeLanePresence(laneId, marker.deviceId); + if (changed) { + args.onStateChanged?.(); + broadcastBrainStatus(); + } + const ack: SyncCommandAckPayload = { + commandId, + accepted: true, + status: "accepted", + message: payload.action === "lanes.presence.announce" + ? `Marked ${laneId} as open on ${marker.displayName}.` + : `Released ${laneId} on ${marker.displayName}.`, + }; + sendResult(startCommandRecord(ack), { + commandId, + ok: true, + result: { ok: true }, + }); + return; + } + if (!policy) { + reject(`Unsupported remote command: ${payload.action}.`); + return; + } + if (descriptor?.scope === "project") { + if (hostProjectId && !requestedProjectId) { + reject(`Remote command ${payload.action} requires projectId. Select the project again and retry.`, "missing_project"); + return; + } + if (requestedProjectId && !hostProjectId) { + reject(`Remote command ${payload.action} requires an open project on this ADE machine.`, "project_not_open"); + return; + } + } + if (!policy.viewerAllowed) { + reject(`Remote command ${payload.action} is not available to paired controller devices.`, "forbidden_command"); + return; + } + if (payload.action === "files.writeTextAtomic" && isMobilePeer(peer) && isMobileLaneFileMutationBlocked(payload)) { + reject("Mobile file access is read-only for this workspace.", "mobile_read_only"); + return; + } + if (policy.localOnly || policy.requiresApproval) { + reject(`Remote command ${payload.action} requires approval on this machine.`, "approval_required"); + return; + } + + const acceptedRecord = startCommandRecord({ + commandId, + accepted: true, + status: "accepted", + message: `Executing ${payload.action}.`, + }); + + try { + const executor = shouldRouteToProject && args.remoteCommandExecutor + ? args.remoteCommandExecutor + : remoteCommandService; + const created = await executor.execute(payload); + sendResult(acceptedRecord, { + commandId, + ok: true, + result: decorateCommandResult(payload.action, created), + }); + } catch (error) { + sendResult(acceptedRecord, { + commandId, + ok: false, + error: { + code: "command_failed", + message: error instanceof Error ? error.message : String(error), + }, + }); + } + } + + function rejectProjectScopedEnvelope( + peer: PeerState, + type: SyncEnvelope["type"], + requestId: string | null, + payload: unknown, + resolution: Extract, + ): void { + args.logger.warn("sync_host.project_scope_rejected", { + type, + requestId, + code: resolution.code, + expectedProjectId: resolution.expectedProjectId, + receivedProjectId: resolution.receivedProjectId, + peerDeviceId: peer.metadata?.deviceId ?? peer.pairedDeviceId ?? null, + }); + + if (type === "changeset_batch") { + const batchPayload = (payload ?? {}) as Partial; + sendRequired(peer, "changeset_ack", { + batchId: toOptionalString(batchPayload.batchId) ?? requestId ?? "", + fromDbVersion: Number(batchPayload.fromDbVersion ?? 0), + toDbVersion: Number(batchPayload.toDbVersion ?? 0), + appliedDbVersion: args.db.sync.getDbVersion(), + appliedCount: 0, + ok: false, + error: { + code: resolution.code, + message: resolution.message, + }, + } satisfies SyncChangesetAckPayload, requestId); + return; + } + + if (type === "file_request") { + const action = toOptionalString((payload as Partial | null | undefined)?.action) ?? "unknown"; + sendRequired(peer, "file_response", { + ok: false, + action: action as SyncFileRequest["action"], + error: { + code: resolution.code, + message: resolution.message, + }, + } satisfies SyncFileResponsePayload, requestId); + } + } + + async function handleMessage(peer: PeerState, raw: RawData): Promise { + const rawText = wsDataToText(raw); + const envelope = parseSyncEnvelope(rawText); + const heartbeatAwaitedAt = peer.awaitingHeartbeatAt; + peer.lastSeenAt = nowIso(); + peer.awaitingHeartbeatAt = null; + peer.missedHeartbeatCount = 0; + + if (!peer.authenticated) { + if (envelope.type !== "hello" && envelope.type !== "pairing_request") { + send(peer.ws, "hello_error", { + code: "invalid_hello", + message: "Authenticate with hello or pairing_request before sending other messages.", + }, envelope.requestId); + try { + peer.ws.close(4003, "Authentication required"); + } catch { + // ignore + } + return; + } + if (envelope.type === "pairing_request") { + const pairing = parsePairingRequestPayload(envelope.payload); + if (!pairing) { + send(peer.ws, "pairing_result", { + ok: false, + error: { + code: "pairing_failed", + message: "Invalid pairing request payload.", + }, + }, envelope.requestId); + try { peer.ws.close(4003, "Pairing failed"); } catch { /* ignore */ } + return; + } + const cooldownMs = pairingCooldownMsRemaining(peer.remoteAddress); + if (cooldownMs > 0) { + const minutes = Math.ceil(cooldownMs / 60_000); + send(peer.ws, "pairing_result", { + ok: false, + error: { + code: "pairing_failed", + message: `Too many failed PIN attempts. Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.`, + }, + }, envelope.requestId); + try { peer.ws.close(4004, "Pairing cooldown"); } catch { /* ignore */ } + return; + } + try { + const result = pairingStore.pairPeer(pairing.peer, pairing.code); + if (peer.remoteAddress) { + pairFailures.delete(peer.remoteAddress); + } + args.deviceRegistryService?.upsertPeerMetadata(pairing.peer, { + lastSeenAt: nowIso(), + lastHost: peer.remoteAddress, + lastPort: peer.remotePort, + }); + send(peer.ws, "pairing_result", { + ok: true, + deviceId: result.deviceId, + secret: result.secret, + }, envelope.requestId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const thrownCode = (error as { code?: string } | null)?.code ?? null; + const resultCode: "pin_not_set" | "invalid_pin" | "pairing_failed" = + thrownCode === "pin_not_set" || thrownCode === "invalid_pin" + ? thrownCode + : "pairing_failed"; + send(peer.ws, "pairing_result", { + ok: false, + error: { + code: resultCode, + message, + }, + }, envelope.requestId); + // Drop the socket after any failed pair so brute-forcing the 6-digit + // PIN requires a new TCP+WS handshake per attempt, and track per-IP + // failures so sustained guessers hit a cooldown. + if (resultCode === "invalid_pin" || resultCode === "pairing_failed") { + registerPairFailure(peer.remoteAddress); + } + try { peer.ws.close(4003, "Pairing failed"); } catch { /* ignore */ } + } + return; + } + const hello = parseHelloPayload(envelope.payload); + if (!hello) { + send(peer.ws, "hello_error", { + code: "invalid_hello", + message: "Invalid hello payload.", + }, envelope.requestId); + try { + peer.ws.close(4003, "Authentication failed"); + } catch { + // ignore + } + return; + } + const authFailed = (() => { + if (hello.auth?.kind === "bootstrap") { + return hello.auth.token !== bootstrapToken; + } + if (hello.auth?.kind === "paired") { + if (hello.auth.deviceId !== hello.peer.deviceId) return true; + return !pairingStore.authenticate(hello.auth.deviceId, hello.auth.secret); + } + return true; + })(); + if (authFailed) { + send(peer.ws, "hello_error", { + code: "auth_failed", + message: "Sync authentication failed.", + }, envelope.requestId); + try { + peer.ws.close(4003, "Authentication failed"); + } catch { + // ignore + } + return; + } + + closeExistingPeersForDevice(hello.peer.deviceId, peer); + peer.authenticated = true; + peer.metadata = hello.peer; + const auth = hello.auth ?? { kind: "bootstrap", token: "" }; + peer.authKind = auth.kind; + peer.pairedDeviceId = auth.kind === "paired" ? auth.deviceId : null; + peer.lastKnownServerDbVersion = Math.max(0, Math.floor(hello.peer.dbVersion)); + args.deviceRegistryService?.upsertPeerMetadata(hello.peer, { + lastSeenAt: nowIso(), + lastHost: peer.remoteAddress, + lastPort: peer.remotePort, + }); + const projectCatalog = await buildProjectCatalogPayload(); + send(peer.ws, "hello_ok", buildSyncHostHelloOkPayload({ + peer: hello.peer, + brain: readBrainMetadata(), + serverDbVersion: args.db.sync.getDbVersion(), + heartbeatIntervalMs, + pollIntervalMs, + projectCatalog, + projectCatalogEnabled: Boolean(args.projectCatalogProvider), + remoteCommandSupportedActions: remoteCommandService.getSupportedActions(), + remoteCommandDescriptors: remoteCommandService.getDescriptors(), + localCommandDescriptors: localPresenceCommandDescriptors, + compressionThresholdBytes, + maxProjectCatalogEnvelopeBytes, + }), envelope.requestId); + args.onStateChanged?.(); + await pumpChanges(); + broadcastBrainStatus(); + return; + } + + const projectScope = resolveSyncHostInboundProjectScope(envelope.type, envelope.projectId, args.projectId); + if (!projectScope.ok) { + rejectProjectScopedEnvelope(peer, envelope.type, envelope.requestId, envelope.payload, projectScope); + return; + } + if (projectScope.usedSingleProjectFallback) { + args.logger.warn("sync_host.project_scope_missing", { + type: envelope.type, + requestId: envelope.requestId, + resolvedProjectId: projectScope.projectId, + peerDeviceId: peer.metadata?.deviceId ?? peer.pairedDeviceId ?? null, + }); + } + + switch (envelope.type) { + case "project_catalog_request": { + sendProjectCatalog(peer, await buildProjectCatalogPayload(), envelope.requestId); + break; + } + case "project_switch_request": { + await handleProjectSwitchRequest(peer, envelope.requestId, envelope.payload as SyncProjectSwitchRequestPayload); + break; + } + case "heartbeat": { + const payload = envelope.payload as { kind?: string; sentAt?: string } | null; + if (payload?.kind === "ping") { + send(peer.ws, "heartbeat", { + kind: "pong", + sentAt: payload.sentAt ?? nowIso(), + dbVersion: args.db.sync.getDbVersion(), + }, envelope.requestId); + } else if (payload?.kind === "pong" && heartbeatAwaitedAt) { + const now = Date.now(); + const sentAtMs = Date.parse(heartbeatAwaitedAt); + peer.latencyMs = Number.isFinite(sentAtMs) ? Math.max(0, now - sentAtMs) : null; + peer.awaitingHeartbeatAt = null; + } + break; + } + case "changeset_batch": { + const payload = (envelope.payload ?? {}) as SyncChangesetBatchPayload; + const batchId = payload.batchId || envelope.requestId || ""; + const changes = Array.isArray(payload.changes) ? payload.changes as CrsqlChangeRow[] : []; + try { + let appliedCount = 0; + if (changes.length > 0) { + args.db.sync.applyChanges(changes); + appliedCount = changes.length; + peer.lastAppliedAt = nowIso(); + lastBroadcastAt = nowIso(); + args.onStateChanged?.(); + broadcastBrainStatus(); + } + sendRequired(peer, "changeset_ack", { + batchId, + fromDbVersion: Number(payload.fromDbVersion ?? 0), + toDbVersion: Number(payload.toDbVersion ?? 0), + appliedDbVersion: args.db.sync.getDbVersion(), + appliedCount, + ok: true, + } satisfies SyncChangesetAckPayload, envelope.requestId); + } catch (error) { + sendRequired(peer, "changeset_ack", { + batchId, + fromDbVersion: Number(payload.fromDbVersion ?? 0), + toDbVersion: Number(payload.toDbVersion ?? 0), + appliedDbVersion: args.db.sync.getDbVersion(), + appliedCount: 0, + ok: false, + error: { + code: "changeset_apply_failed", + message: error instanceof Error ? error.message : String(error), + }, + } satisfies SyncChangesetAckPayload, envelope.requestId); + throw error; + } + break; + } + case "changeset_ack": { + handleChangesetAck(peer, envelope.payload as SyncChangesetAckPayload); + break; + } + case "file_request": + await handleFileRequest(peer, envelope.requestId, envelope.payload as SyncFileRequest); + break; + case "terminal_subscribe": { + const payload = envelope.payload as { sessionId?: string; maxBytes?: number } | null; + const sessionId = toOptionalString(payload?.sessionId); + if (!sessionId) break; + peer.subscribedSessionIds.add(sessionId); + const session = args.sessionService.get(sessionId); + const transcript = session + ? await args.sessionService.readTranscriptTail( + session.transcriptPath, + Math.max(1_024, Math.min(2_000_000, Math.floor(payload?.maxBytes ?? DEFAULT_TERMINAL_SNAPSHOT_BYTES))), + { raw: true, alignToLineBoundary: true }, + ) + : ""; + const snapshot: SyncTerminalSnapshotPayload = { + sessionId, + transcript, + status: session?.status ?? null, + runtimeState: session?.runtimeState ?? null, + lastOutputPreview: session?.lastOutputPreview ?? null, + capturedAt: nowIso(), + }; + sendRequired(peer, "terminal_snapshot", snapshot, envelope.requestId); + break; + } + case "terminal_unsubscribe": { + const payload = envelope.payload as { sessionId?: string } | null; + const sessionId = toOptionalString(payload?.sessionId); + if (sessionId) { + peer.subscribedSessionIds.delete(sessionId); + } + break; + } + case "terminal_input": { + // Forward keystrokes / pasted text from a mobile client into the + // active PTY for the named session. We require a prior subscribe so + // only an attached peer can drive the shell — protects against an + // attacker who acquired a session id but is not actively viewing. + const payload = envelope.payload as { sessionId?: string; data?: string } | null; + const sessionId = toOptionalString(payload?.sessionId); + const data = typeof payload?.data === "string" ? payload.data : null; + if (!sessionId || data == null) break; + if (!peer.subscribedSessionIds.has(sessionId)) { + args.logger.warn("sync.terminal_input_unsubscribed_session", { sessionId }); + break; + } + const accepted = args.ptyService.writeBySessionId(sessionId, data); + if (!accepted) { + args.logger.info("sync.terminal_input_no_active_pty", { sessionId }); + } + break; + } + case "terminal_resize": { + // Mobile clients re-emit this whenever their visible viewport + // changes (rotation, split view, dynamic font). We forward to the + // active PTY so command-line apps re-flow correctly. Out-of-bound + // values are clamped inside ptyService. + const payload = envelope.payload as { sessionId?: string; cols?: number; rows?: number } | null; + const sessionId = toOptionalString(payload?.sessionId); + const cols = typeof payload?.cols === "number" ? Math.floor(payload.cols) : null; + const rows = typeof payload?.rows === "number" ? Math.floor(payload.rows) : null; + if (!sessionId || cols == null || rows == null) break; + if (!peer.subscribedSessionIds.has(sessionId)) break; + args.ptyService.resizeBySessionId(sessionId, cols, rows); + break; + } + case "chat_subscribe": { + const payload = envelope.payload as { sessionId?: string; maxBytes?: number } | null; + const sessionId = toOptionalString(payload?.sessionId); + if (!sessionId) break; + peer.subscribedChatSessionIds.add(sessionId); + + const session = args.sessionService.get(sessionId); + const maxBytes = Math.max( + 1_024, + Math.min(2_000_000, Math.floor(typeof payload?.maxBytes === "number" ? payload.maxBytes : DEFAULT_TERMINAL_SNAPSHOT_BYTES)), + ); + const raw = session?.transcriptPath + ? await args.sessionService.readTranscriptTail( + session.transcriptPath, + maxBytes, + { raw: true, alignToLineBoundary: true }, + ) + : ""; + const events = parseAgentChatTranscript(raw).filter((event) => event.sessionId === sessionId); + const transcriptSize = session?.transcriptPath && fs.existsSync(session.transcriptPath) + ? fs.statSync(session.transcriptPath).size + : 0; + peer.chatTranscriptOffsets.set(sessionId, transcriptSize); + const snapshot: SyncChatSubscribeSnapshotPayload = { + sessionId, + capturedAt: nowIso(), + truncated: transcriptSize > maxBytes, + events, + }; + sendRequired(peer, "chat_subscribe", snapshot, envelope.requestId); + break; + } + case "chat_unsubscribe": { + const payload = envelope.payload as SyncChatUnsubscribePayload | null; + const sessionId = toOptionalString(payload?.sessionId); + if (sessionId) { + peer.subscribedChatSessionIds.delete(sessionId); + peer.chatTranscriptOffsets.delete(sessionId); + peer.chatEventIdsSent.delete(sessionId); + } + break; + } + case "command": + await handleCommand(peer, envelope.requestId, { + ...(envelope.payload as SyncCommandPayload), + ...(!toOptionalString((envelope.payload as SyncCommandPayload | null)?.projectId) && envelope.projectId + ? { projectId: envelope.projectId } + : {}), + }); + break; + case "register_push_token": { + const payload = envelope.payload as SyncRegisterPushTokenPayload | null; + handleRegisterPushToken(peer, envelope.requestId, payload); + break; + } + case "notification_prefs": { + const payload = envelope.payload as SyncNotificationPrefsPayload | null; + handleNotificationPrefs(peer, payload); + break; + } + case "send_test_push": { + const payload = envelope.payload as SyncSendTestPushPayload | null; + await handleSendTestPush(peer, envelope.requestId, payload); + break; + } + default: + break; + } + } + + function handleRegisterPushToken( + peer: PeerState, + requestId: string | null | undefined, + payload: SyncRegisterPushTokenPayload | null, + ): void { + const deviceId = peer.metadata?.deviceId; + if (!deviceId) { + args.logger.warn("sync_host.push_token_missing_device", {}); + sendRequired(peer, "command_ack", { + commandId: "push-token:unknown", + accepted: false, + status: "missing_device_id", + message: "Cannot store push token before device registration completes.", + }, requestId ?? null); + return; + } + if (!payload || typeof payload.token !== "string" || payload.token.trim().length === 0) { + args.logger.warn("sync_host.push_token_missing", { deviceId }); + sendRequired(peer, "command_ack", { + commandId: `push-token:${deviceId}:unknown`, + accepted: false, + status: "invalid_payload", + message: "Push token registration did not include a token.", + }, requestId ?? null); + return; + } + const kind: ApnsPushTokenKind = + payload.kind === "alert" || payload.kind === "activity-start" || payload.kind === "activity-update" + ? payload.kind + : "alert"; + if (kind === "activity-update" && !payload.activityId?.trim()) { + args.logger.warn("sync_host.push_token_missing_activity_id", { deviceId }); + sendRequired(peer, "command_ack", { + commandId: `push-token:${deviceId}:${kind}`, + accepted: false, + status: "missing_activity_id", + message: "Live Activity update tokens require an activity id.", + }, requestId ?? null); + return; + } + const env: ApnsEnvironment = payload.env === "production" ? "production" : "sandbox"; + const stored = args.deviceRegistryService?.setApnsToken?.(deviceId, payload.token.trim(), kind, env, { + bundleId: payload.bundleId, + activityId: payload.activityId, + }); + if (!stored) { + sendRequired(peer, "command_ack", { + commandId: `push-token:${deviceId}:${kind}`, + accepted: false, + status: "device_not_found", + message: `Could not store ${kind} push token for ${deviceId}.`, + }, requestId ?? null); + return; + } + // Optional ack so the client can retry on failure. + sendRequired(peer, "command_ack", { + commandId: `push-token:${deviceId}:${kind}`, + accepted: true, + status: "accepted", + message: `Stored ${kind} push token for ${deviceId}.`, + }, requestId ?? null); + } + + function handleNotificationPrefs(peer: PeerState, payload: SyncNotificationPrefsPayload | null): void { + const deviceId = peer.metadata?.deviceId; + if (!deviceId || !payload || !payload.prefs) return; + storeNotificationPrefsForDevice(deviceId, normalizeNotificationPreferences(payload.prefs)); + } + + async function handleSendTestPush( + peer: PeerState, + requestId: string | null | undefined, + payload: SyncSendTestPushPayload | null, + ): Promise { + const deviceId = peer.metadata?.deviceId; + if (!deviceId) return; + const kind = payload?.kind === "activity" ? "activity" : "alert"; + const result = args.notificationEventBus + ? await args.notificationEventBus.sendTestPush(deviceId, kind) + : { ok: false, reason: "notification_bus_unavailable" as const }; + sendRequired(peer, "command_result", { + commandId: `push-test:${deviceId}:${kind}`, + ok: result.ok, + ...(result.ok ? {} : { error: { code: "test_push_failed", message: result.reason ?? "unknown" } }), + }, requestId ?? null); + } + + /** + * Deliver a foreground-only notification to a specific iOS peer over the + * existing WebSocket. Used by the notification bus when the device is + * currently connected, in place of (or alongside) an APNs alert. + */ + function sendInAppNotification( + deviceId: string, + payload: Omit, + ): void { + const fullPayload: SyncInAppNotificationPayload = { + ...payload, + generatedAt: nowIso(), + }; + for (const peer of peers) { + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + if (peer.metadata?.deviceId !== deviceId) continue; + send(peer.ws, "in_app_notification", fullPayload); + } + } + + function getNotificationPrefsForDevice(deviceId: string): NotificationPreferences | null { + return readNotificationPrefsForDevice(deviceId); + } + + function isIosPeerConnected(deviceId: string): boolean { + for (const peer of peers) { + if (peer.metadata?.deviceId !== deviceId) continue; + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + return true; + } + return false; + } + + const getLanePresenceSnapshot = (): Array<{ laneId: string; devicesOpen: DeviceMarker[] }> => { + return [...lanePresenceByLaneId.keys()] + .sort((left, right) => left.localeCompare(right)) + .map((laneId) => ({ + laneId, + devicesOpen: listLanePresenceMarkers(laneId), + })) + .filter((entry) => entry.devicesOpen.length > 0); + }; + + return { + async waitUntilListening(): Promise { + if (startupError) { + throw startupError; + } + if (server.address()) { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT; + publishLanDiscovery(port); + publishTailnetDiscovery(port); + return port; + } + await new Promise((resolve, reject) => { + const onListening = () => { + cleanup(); + resolve(); + }; + const onError = (error: unknown) => { + cleanup(); + const normalized = error instanceof Error ? error : new Error(String(error)); + startupError = normalized; + reject(normalized); + }; + const cleanup = () => { + server.off("listening", onListening); + server.off("error", onError); + }; + server.on("listening", onListening); + server.on("error", onError); + if (startupError) { + cleanup(); + reject(startupError); + return; + } + if (server.address()) { + cleanup(); + resolve(); + } + }); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT; + publishLanDiscovery(port); + publishTailnetDiscovery(port); + return port; + }, + + getPort(): number | null { + const address = server.address(); + return typeof address === "object" && address ? address.port : null; + }, + + getBootstrapToken(): string { + return bootstrapToken; + }, + + setLocalActiveLanePresence(laneIds: string[]): void { + setLocalActiveLanePresence(laneIds); + }, + + refreshLanDiscovery(options?: { forceTailnet?: boolean }): void { + const address = server.address(); + if (typeof address === "object" && address) { + publishLanDiscovery(address.port); + publishTailnetDiscovery(address.port, { force: options?.forceTailnet }); + } + }, + + setDiscoveryEnabled(enabled: boolean): void { + if (discoveryEnabled === enabled) return; + discoveryEnabled = enabled; + const address = server.address(); + if (!enabled) { + unpublishLanDiscovery(); + void unpublishTailnetDiscovery(); + updateTailnetDiscoveryStatus({ + state: "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: "Tailnet discovery is disabled for this background project context.", + stderr: null, + }); + return; + } + if (typeof address === "object" && address) { + publishLanDiscovery(address.port); + publishTailnetDiscovery(address.port, { force: true }); + } + }, + + revokePairedDevice(deviceId: string): void { + pairingStore.revoke(deviceId); + let revokedConnectedPeer = false; + for (const peer of peers) { + if (!peer.authenticated || peer.authKind !== "paired" || peer.pairedDeviceId !== deviceId) continue; + revokedConnectedPeer = true; + peer.authenticated = false; + peer.metadata = null; + peer.authKind = null; + peer.pairedDeviceId = null; + try { + peer.ws.close(4003, "Pairing revoked"); + } catch { + // ignore close failures + } + } + if (revokedConnectedPeer) { + args.onStateChanged?.(); + broadcastBrainStatus(); + } + }, + + getPeerStates(): SyncPeerConnectionState[] { + const dbVersion = args.db.sync.getDbVersion(); + const latestByDevice = new Map(); + for (const peer of [...peers] + .map((peer) => toSyncPeerConnectionState(peer, dbVersion)) + .filter((peer): peer is SyncPeerConnectionState => peer != null)) { + const existing = latestByDevice.get(peer.deviceId); + if (!existing || peer.connectedAt > existing.connectedAt) { + latestByDevice.set(peer.deviceId, peer); + } + } + return [...latestByDevice.values()]; + }, + + getTailnetDiscoveryStatus(): SyncTailnetDiscoveryStatus { + return { ...tailnetDiscoveryStatus }; + }, + + getLanePresenceSnapshot(): Array<{ laneId: string; devicesOpen: DeviceMarker[] }> { + return getLanePresenceSnapshot(); + }, + + getChatSubscriptionSnapshot(): Array<{ deviceId: string; subscribedChatSessionIds: string[] }> { + return [...peers] + .map((peer) => { + if (!peer.metadata) return null; + return { + deviceId: peer.metadata.deviceId, + subscribedChatSessionIds: [...peer.subscribedChatSessionIds].sort(), + }; + }) + .filter((peer): peer is { deviceId: string; subscribedChatSessionIds: string[] } => peer != null); + }, + + getBrainStatusSnapshot(): SyncBrainStatusPayload { + return buildBrainStatus(); + }, + + async broadcastProjectCatalog(): Promise { + const payload = await buildProjectCatalogPayload(); + if (bonjourPort != null) { + refreshLanDiscoveryProjects(bonjourPort, payload); + } + for (const peer of peers) { + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + sendProjectCatalog(peer, payload); + } + }, + + /** + * Push an in-app notification to a specific iOS peer over the WebSocket. + * Used by the notification event bus as the foreground-delivery path. + */ + sendInAppNotification( + deviceId: string, + payload: Omit, + ): void { + sendInAppNotification(deviceId, payload); + }, + + /** Returns the latest announced notification prefs for a device, or null. */ + getNotificationPrefsForDevice(deviceId: string): NotificationPreferences | null { + return getNotificationPrefsForDevice(deviceId); + }, + + /** Whether a given device is currently connected + authenticated. */ + isIosPeerConnected(deviceId: string): boolean { + return isIosPeerConnected(deviceId); + }, + + handlePtyData(event: PtyDataEvent): void { + const payload = { + sessionId: event.sessionId, + ptyId: event.ptyId, + data: event.data, + at: nowIso(), + }; + for (const peer of peers) { + if (!peer.authenticated || !peer.subscribedSessionIds.has(event.sessionId) || peer.ws.readyState !== WebSocket.OPEN) continue; + if (isPeerBackpressured(peer)) continue; + send(peer.ws, "terminal_data", payload); + } + }, + + handlePtyExit(event: PtyExitEvent): void { + const payload = { + sessionId: event.sessionId, + ptyId: event.ptyId, + exitCode: event.exitCode, + at: nowIso(), + }; + for (const peer of peers) { + if (!peer.authenticated || !peer.subscribedSessionIds.has(event.sessionId) || peer.ws.readyState !== WebSocket.OPEN) continue; + if (isPeerBackpressured(peer)) continue; + send(peer.ws, "terminal_exit", payload); + } + }, + + async dispose(): Promise { + if (disposed) return; + disposed = true; + localActiveLaneIds = new Set(); + lanePresenceByLaneId.clear(); + dropInFlightCommandRecordsForProject(); + chatEventSubscription?.(); + clearInterval(pollTimer); + clearInterval(heartbeatTimer); + clearInterval(brainStatusTimer); + unpublishLanDiscovery(); + try { + await unpublishTailnetDiscovery(); + } catch { + // Never throw from dispose. + } + await new Promise((resolve) => { + const finish = () => resolve(); + for (const peer of peers) { + try { + peer.ws.close(); + } catch { + // ignore + } + } + if (!server.address()) { + finish(); + return; + } + try { + server.close(() => finish()); + } catch { + finish(); + } + }); + if (bonjourAnnouncement) { + try { + bonjourAnnouncement.stop?.(); + } catch { + // ignore cleanup failures + } + bonjourAnnouncement = null; + } + bonjourPort = null; + bonjourSignature = null; + if (bonjourInstance) { + try { + bonjourInstance.destroy(); + } catch { + // ignore cleanup failures + } + bonjourInstance = null; + } + }, + }; +} + +export type SyncHostService = ReturnType; diff --git a/apps/ade-cli/src/services/sync/syncPairingStore.ts b/apps/ade-cli/src/services/sync/syncPairingStore.ts new file mode 100644 index 000000000..c7b487003 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncPairingStore.ts @@ -0,0 +1,110 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import type { SyncPeerMetadata } from "../../../../desktop/src/shared/types"; +import { nowIso, safeJsonParse, writeTextAtomic } from "../../../../desktop/src/main/services/shared/utils"; +import type { SyncPinStore } from "./syncPinStore"; + +type PairingRecord = { + secretHash: string; + createdAt: string; + lastUsedAt: string | null; + peerName: string; + peerPlatform: string; + peerDeviceType: string; +}; + +type PairingSecretsFile = Record; + +type SyncPairingStoreArgs = { + filePath: string; + pinStore: SyncPinStore; +}; + +function hashSecret(secret: string): string { + return createHash("sha256").update(secret).digest("hex"); +} + +function safeHashEquals(expectedHash: string, actualHash: string): boolean { + const expected = Buffer.from(expectedHash, "utf8"); + const actual = Buffer.from(actualHash, "utf8"); + if (expected.length !== actual.length) { + timingSafeEqual(expected, Buffer.alloc(expected.length)); + return false; + } + return timingSafeEqual(expected, actual); +} + +function pairingError(code: "pin_not_set" | "invalid_pin", message: string): Error { + const err = new Error(message) as Error & { code?: string }; + err.code = code; + return err; +} + +export function createSyncPairingStore(args: SyncPairingStoreArgs) { + fs.mkdirSync(path.dirname(args.filePath), { recursive: true }); + + const readRecords = (): PairingSecretsFile => { + if (!fs.existsSync(args.filePath)) return {}; + return safeJsonParse(fs.readFileSync(args.filePath, "utf8"), {}); + }; + + const writeRecords = (records: PairingSecretsFile): void => { + writeTextAtomic(args.filePath, `${JSON.stringify(records, null, 2)}\n`); + try { + fs.chmodSync(args.filePath, 0o600); + } catch { + // ignore chmod failures on platforms that don't support it + } + }; + + return { + pairPeer(peer: SyncPeerMetadata, pin: string): { deviceId: string; secret: string } { + if (!args.pinStore.hasPin()) { + throw pairingError("pin_not_set", "No pairing PIN is set on this computer."); + } + if (!args.pinStore.verifyPin(pin)) { + throw pairingError("invalid_pin", "Incorrect pairing PIN."); + } + const secret = randomBytes(24).toString("hex"); + const records = readRecords(); + const existing = records[peer.deviceId] ?? null; + records[peer.deviceId] = { + secretHash: hashSecret(secret), + createdAt: existing?.createdAt ?? nowIso(), + lastUsedAt: null, + peerName: peer.deviceName, + peerPlatform: peer.platform, + peerDeviceType: peer.deviceType, + }; + writeRecords(records); + return { + deviceId: peer.deviceId, + secret, + }; + }, + + authenticate(deviceId: string, secret: string): boolean { + const normalized = deviceId.trim(); + if (!normalized) return false; + const records = readRecords(); + const entry = records[normalized]; + if (!entry) return false; + if (!safeHashEquals(entry.secretHash, hashSecret(secret))) return false; + entry.lastUsedAt = nowIso(); + writeRecords(records); + return true; + }, + + revoke(deviceId: string): void { + const normalized = deviceId.trim(); + if (!normalized) return; + const records = readRecords(); + if (!(normalized in records)) return; + delete records[normalized]; + writeRecords(records); + }, + }; +} + +export type SyncPairingStore = ReturnType; diff --git a/apps/ade-cli/src/services/sync/syncPeerService.ts b/apps/ade-cli/src/services/sync/syncPeerService.ts new file mode 100644 index 000000000..3ec1a2d77 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncPeerService.ts @@ -0,0 +1,579 @@ +import { WebSocket, type RawData } from "ws"; +import type { + SyncBrainStatusPayload, + SyncChangesetAckPayload, + SyncChangesetBatchPayload, + SyncClientStatus, + SyncCommandAckPayload, + SyncCommandResultPayload, + SyncDesktopConnectionDraft, + SyncRemoteCommandAction, + SyncPeerMetadata, + SyncRunQuickCommandArgs, +} from "../../../../desktop/src/shared/types"; +import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; +import type { AdeDb } from "../../../../desktop/src/main/services/state/kvDb"; +import { nowIso } from "../../../../desktop/src/main/services/shared/utils"; +import type { DeviceRegistryService } from "./deviceRegistryService"; +import { DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, encodeSyncEnvelope, parseSyncEnvelope, wsDataToText } from "./syncProtocol"; + +type SyncPeerServiceArgs = { + db: AdeDb; + logger: Logger; + deviceRegistryService: DeviceRegistryService; + onStatusChange?: (status: SyncClientStatus) => void; + onBrainStatus?: (payload: SyncBrainStatusPayload) => void; + onRemoteChangesApplied?: () => void; +}; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +type InternalStatus = SyncClientStatus; +type PendingChangesetBatch = { + batchId: string; + payload: SyncChangesetBatchPayload; + sentAtMs: number; + retryCount: number; +}; + +const CHANGESET_ACK_TIMEOUT_MS = 10_000; +const MAX_CHANGESET_ACK_RETRIES = 6; + +export function createSyncPeerService(args: SyncPeerServiceArgs) { + let ws: WebSocket | null = null; + let disposed = false; + let relayTimer: NodeJS.Timeout | null = null; + let heartbeatTimer: NodeJS.Timeout | null = null; + let connectionDraft: SyncDesktopConnectionDraft | null = null; + let latestBrainStatus: SyncBrainStatusPayload | null = null; + let outboundLocalDbVersion = args.db.sync.getDbVersion(); + let latestRemoteDbVersion = 0; + let pendingOutboundChangeset: PendingChangesetBatch | null = null; + const pendingRequests = new Map(); + let pendingConnect: { resolve: () => void; reject: (error: Error) => void } | null = null; + + const status: InternalStatus = { + state: "disconnected", + host: null, + port: null, + connectedAt: null, + lastSeenAt: null, + latencyMs: null, + syncLag: null, + lastRemoteDbVersion: 0, + brainDeviceId: null, + hostName: null, + error: null, + message: null, + savedDraft: null, + }; + + const emitStatus = () => { + status.lastRemoteDbVersion = latestRemoteDbVersion; + status.savedDraft = connectionDraft + ? { + host: connectionDraft.host, + port: connectionDraft.port, + authKind: connectionDraft.authKind ?? "bootstrap", + pairedDeviceId: connectionDraft.pairedDeviceId ?? null, + lastRemoteDbVersion: connectionDraft.lastRemoteDbVersion ?? latestRemoteDbVersion, + } + : null; + args.onStatusChange?.({ ...status }); + }; + + const stopTimers = () => { + if (relayTimer) { + clearInterval(relayTimer); + relayTimer = null; + } + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + }; + + const clearPendingRequests = (message: string) => { + for (const [requestId, pending] of pendingRequests) { + clearTimeout(pending.timer); + pending.reject(new Error(message)); + pendingRequests.delete(requestId); + } + }; + + const applyDraft = (draft: SyncDesktopConnectionDraft | null) => { + connectionDraft = draft + ? { + host: draft.host.trim(), + port: Math.max(1, Math.floor(draft.port)), + token: draft.token, + authKind: draft.authKind ?? "bootstrap", + pairedDeviceId: draft.pairedDeviceId ?? null, + lastRemoteDbVersion: Math.max(0, Math.floor(draft.lastRemoteDbVersion ?? 0)), + } + : null; + emitStatus(); + }; + + const currentLocalPeerMetadata = (): SyncPeerMetadata => { + const localDevice = args.deviceRegistryService.ensureLocalDevice(); + return { + deviceId: localDevice.deviceId, + deviceName: localDevice.name, + platform: localDevice.platform, + deviceType: localDevice.deviceType, + siteId: localDevice.siteId, + dbVersion: latestRemoteDbVersion, + capabilities: ["changesetAck"], + }; + }; + + const sendChangesetAck = ( + batch: SyncChangesetBatchPayload, + ok: boolean, + appliedDbVersion: number, + appliedCount: number, + error?: unknown, + ) => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const payload: SyncChangesetAckPayload = { + batchId: batch.batchId, + fromDbVersion: Number(batch.fromDbVersion ?? 0), + toDbVersion: Number(batch.toDbVersion ?? 0), + appliedDbVersion, + appliedCount, + ok, + ...(error + ? { error: { code: "changeset_apply_failed", message: error instanceof Error ? error.message : String(error) } } + : {}), + }; + ws.send( + encodeSyncEnvelope({ + type: "changeset_ack", + requestId: batch.batchId, + payload, + compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, + }), + ); + }; + + const sendOutboundChangeset = (pending: PendingChangesetBatch) => { + if (!ws || ws.readyState !== WebSocket.OPEN) return false; + ws.send( + encodeSyncEnvelope({ + type: "changeset_batch", + requestId: pending.batchId, + payload: pending.payload, + compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, + }), + ); + return true; + }; + + const sendLocalChanges = () => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const nowMs = Date.now(); + if (pendingOutboundChangeset) { + if (nowMs - pendingOutboundChangeset.sentAtMs >= CHANGESET_ACK_TIMEOUT_MS) { + if (pendingOutboundChangeset.retryCount >= MAX_CHANGESET_ACK_RETRIES) { + args.logger.warn("sync_peer.changeset_ack_timeout_exhausted", { + batchId: pendingOutboundChangeset.batchId, + retryCount: pendingOutboundChangeset.retryCount, + }); + disconnectInternal("error", null, "Changeset acknowledgement timed out."); + return; + } + pendingOutboundChangeset.sentAtMs = nowMs; + pendingOutboundChangeset.retryCount += 1; + sendOutboundChangeset(pendingOutboundChangeset); + } + return; + } + const currentDbVersion = args.db.sync.getDbVersion(); + if (currentDbVersion <= outboundLocalDbVersion) return; + const localSiteId = args.deviceRegistryService.getLocalSiteId(); + const changes = args.db.sync + .exportChangesSince(outboundLocalDbVersion) + .filter((change) => change.site_id === localSiteId); + const previousDbVersion = outboundLocalDbVersion; + if (!changes.length) { + outboundLocalDbVersion = currentDbVersion; + return; + } + const batchId = `changeset:${currentLocalPeerMetadata().deviceId}:${previousDbVersion}:${currentDbVersion}:${Date.now()}:${Math.random().toString(16).slice(2)}`; + pendingOutboundChangeset = { + batchId, + payload: { + batchId, + reason: "relay", + fromDbVersion: previousDbVersion, + toDbVersion: currentDbVersion, + changes, + }, + sentAtMs: nowMs, + retryCount: 0, + }; + sendOutboundChangeset(pendingOutboundChangeset); + }; + + const startRelay = () => { + stopTimers(); + relayTimer = setInterval(() => { + try { + sendLocalChanges(); + } catch (error) { + args.logger.warn("sync_peer.relay_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }, 400); + }; + + const startHeartbeatFallback = () => { + heartbeatTimer = setInterval(() => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + ws.send( + encodeSyncEnvelope({ + type: "heartbeat", + payload: { + kind: "ping", + sentAt: nowIso(), + dbVersion: latestRemoteDbVersion, + }, + }), + ); + }, 30_000); + }; + + const disconnectInternal = (state: SyncClientStatus["state"], message: string | null, error: string | null) => { + stopTimers(); + if (ws) { + try { + ws.removeAllListeners(); + ws.close(); + } catch { + // ignore + } + } + ws = null; + pendingOutboundChangeset = null; + latestBrainStatus = null; + status.state = state; + status.connectedAt = null; + status.lastSeenAt = null; + status.latencyMs = null; + status.syncLag = null; + status.brainDeviceId = null; + status.hostName = null; + status.message = message; + status.error = error; + clearPendingRequests(error ?? message ?? "Sync peer disconnected."); + emitStatus(); + }; + + const handleMessage = (raw: RawData) => { + const envelope = parseSyncEnvelope(wsDataToText(raw)); + status.lastSeenAt = nowIso(); + switch (envelope.type) { + case "hello_ok": { + const payload = envelope.payload as { + brain: SyncPeerMetadata; + serverDbVersion: number; + }; + latestRemoteDbVersion = Math.max(0, Math.floor(payload.serverDbVersion ?? 0)); + status.state = "connected"; + status.connectedAt = nowIso(); + status.message = `Connected to host ${payload.brain.deviceName}.`; + status.error = null; + status.brainDeviceId = payload.brain.deviceId; + status.hostName = payload.brain.deviceName; + if (connectionDraft) { + connectionDraft.lastRemoteDbVersion = latestRemoteDbVersion; + } + outboundLocalDbVersion = Math.max(outboundLocalDbVersion, args.db.sync.getDbVersion()); + emitStatus(); + startRelay(); + startHeartbeatFallback(); + pendingConnect?.resolve(); + pendingConnect = null; + break; + } + case "hello_error": { + const payload = envelope.payload as { message?: string }; + pendingConnect?.reject(new Error(payload?.message ?? "Sync peer authentication failed.")); + pendingConnect = null; + disconnectInternal("error", null, payload?.message ?? "Sync peer authentication failed."); + break; + } + case "changeset_batch": { + const payload = (envelope.payload ?? {}) as SyncChangesetBatchPayload; + const changes = Array.isArray(payload.changes) ? payload.changes : []; + try { + if (changes.length) { + args.db.sync.applyChanges(changes); + args.onRemoteChangesApplied?.(); + } + latestRemoteDbVersion = Math.max(latestRemoteDbVersion, Math.floor(payload.toDbVersion ?? latestRemoteDbVersion)); + if (connectionDraft) connectionDraft.lastRemoteDbVersion = latestRemoteDbVersion; + sendChangesetAck(payload, true, args.db.sync.getDbVersion(), changes.length); + emitStatus(); + } catch (error) { + sendChangesetAck(payload, false, args.db.sync.getDbVersion(), 0, error); + throw error; + } + break; + } + case "changeset_ack": { + const payload = envelope.payload as SyncChangesetAckPayload; + if (!pendingOutboundChangeset || payload.batchId !== pendingOutboundChangeset.batchId) break; + if (!payload.ok) { + if (pendingOutboundChangeset.retryCount >= MAX_CHANGESET_ACK_RETRIES) { + const message = payload.error?.message ?? "Changeset apply failed repeatedly."; + args.logger.warn("sync_peer.changeset_ack_failed_exhausted", { + batchId: pendingOutboundChangeset.batchId, + retryCount: pendingOutboundChangeset.retryCount, + error: message, + }); + disconnectInternal("error", null, message); + break; + } + pendingOutboundChangeset.sentAtMs = Date.now(); + pendingOutboundChangeset.retryCount += 1; + args.logger.warn("sync_peer.changeset_ack_failed", { + batchId: pendingOutboundChangeset.batchId, + error: payload.error?.message ?? "Changeset apply failed.", + }); + break; + } + if (payload.toDbVersion < pendingOutboundChangeset.payload.toDbVersion) break; + const acknowledgedRemoteVersion = Math.max( + latestRemoteDbVersion, + pendingOutboundChangeset.payload.toDbVersion, + Math.floor(payload.toDbVersion ?? 0), + ); + latestRemoteDbVersion = acknowledgedRemoteVersion; + if (connectionDraft) { + connectionDraft.lastRemoteDbVersion = acknowledgedRemoteVersion; + } + outboundLocalDbVersion = Math.max(outboundLocalDbVersion, pendingOutboundChangeset.payload.toDbVersion); + pendingOutboundChangeset = null; + emitStatus(); + break; + } + case "brain_status": { + const payload = envelope.payload as SyncBrainStatusPayload; + latestBrainStatus = payload; + status.brainDeviceId = payload.brain.deviceId; + status.hostName = payload.brain.deviceName; + const localDeviceId = args.deviceRegistryService.getLocalDeviceId(); + const localPeer = payload.connectedPeers.find((peer) => peer.deviceId === localDeviceId) ?? null; + status.latencyMs = localPeer?.latencyMs ?? null; + status.syncLag = localPeer?.syncLag ?? 0; + args.onBrainStatus?.(payload); + emitStatus(); + break; + } + case "heartbeat": { + const payload = envelope.payload as { kind?: string; sentAt?: string }; + if (payload?.kind === "ping" && ws && ws.readyState === WebSocket.OPEN) { + ws.send( + encodeSyncEnvelope({ + type: "heartbeat", + requestId: envelope.requestId ?? null, + payload: { + kind: "pong", + sentAt: payload.sentAt ?? nowIso(), + dbVersion: latestRemoteDbVersion, + }, + }), + ); + } + break; + } + case "command_ack": + case "command_result": { + const requestId = envelope.requestId ?? null; + if (!requestId) break; + const pending = pendingRequests.get(requestId); + if (!pending) break; + if (envelope.type === "command_result") { + clearTimeout(pending.timer); + pendingRequests.delete(requestId); + const payload = envelope.payload as SyncCommandResultPayload; + if (payload.ok) { + pending.resolve(payload.result ?? null); + } else { + pending.reject(new Error(payload.error?.message ?? "Remote command failed.")); + } + } else { + const payload = envelope.payload as SyncCommandAckPayload; + if (!payload.accepted) { + clearTimeout(pending.timer); + pendingRequests.delete(requestId); + pending.reject(new Error(payload.message ?? "Remote command rejected.")); + } + } + break; + } + default: + break; + } + }; + + return { + setSavedDraft(draft: SyncDesktopConnectionDraft | null): void { + applyDraft(draft); + }, + + async connect(draft: SyncDesktopConnectionDraft): Promise { + if (disposed) { + throw new Error("Sync peer service is disposed."); + } + this.disconnect({ preserveDraft: true }); + applyDraft(draft); + latestRemoteDbVersion = Math.max(0, Math.floor(draft.lastRemoteDbVersion ?? 0)); + status.state = "connecting"; + status.host = draft.host.trim(); + status.port = Math.max(1, Math.floor(draft.port)); + status.message = `Connecting to ${status.host}:${String(status.port)}...`; + status.error = null; + emitStatus(); + + await new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://${status.host}:${String(status.port)}`); + ws = socket; + pendingConnect = { resolve, reject }; + + const cleanup = () => { + socket.removeListener("open", onOpen); + socket.removeListener("error", onError); + }; + + const onOpen = () => { + cleanup(); + const peer = currentLocalPeerMetadata(); + const auth = draft.authKind === "paired" && draft.pairedDeviceId + ? { + kind: "paired" as const, + deviceId: draft.pairedDeviceId, + secret: draft.token, + } + : { + kind: "bootstrap" as const, + token: draft.token, + }; + socket.send( + encodeSyncEnvelope({ + type: "hello", + requestId: "hello", + payload: { + peer, + auth, + }, + compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, + }), + ); + }; + + const onError = (error: Error) => { + cleanup(); + pendingConnect?.reject(error); + pendingConnect = null; + disconnectInternal("error", null, error.message); + }; + + socket.once("open", onOpen); + socket.once("error", onError); + socket.on("message", (raw) => { + try { + handleMessage(raw); + } catch (error) { + args.logger.warn("sync_peer.message_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }); + socket.on("close", () => { + if (disposed) return; + if (pendingConnect) { + pendingConnect.reject(new Error("Connection closed before authentication completed.")); + pendingConnect = null; + } + disconnectInternal("disconnected", "Disconnected from host.", null); + }); + }); + }, + + disconnect(options: { preserveDraft?: boolean } = {}): void { + const nextDraft = options.preserveDraft ? connectionDraft : null; + disconnectInternal("disconnected", connectionDraft ? "Disconnected from host." : null, null); + if (!options.preserveDraft) { + applyDraft(null); + } else { + applyDraft(nextDraft); + } + }, + + getStatus(): SyncClientStatus { + return { ...status }; + }, + + getLatestBrainStatus(): SyncBrainStatusPayload | null { + return latestBrainStatus ? { ...latestBrainStatus, connectedPeers: [...latestBrainStatus.connectedPeers] } : null; + }, + + getConnectionDraft(): SyncDesktopConnectionDraft | null { + return connectionDraft ? { ...connectionDraft } : null; + }, + + isConnected(): boolean { + return status.state === "connected" && Boolean(ws) && ws?.readyState === WebSocket.OPEN; + }, + + flushLocalChanges(): void { + sendLocalChanges(); + }, + + async executeRemoteCommand(action: SyncRemoteCommandAction | (string & {}), commandArgs: Record): Promise { + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error("Not connected to a host device."); + } + const requestId = `sync-command-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const promise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingRequests.delete(requestId); + reject(new Error("Timed out waiting for remote command result.")); + }, 20_000); + pendingRequests.set(requestId, { resolve, reject, timer }); + }); + ws.send( + encodeSyncEnvelope({ + type: "command", + requestId, + payload: { + commandId: requestId, + action, + args: commandArgs, + }, + compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, + }), + ); + return await promise; + }, + + async runQuickCommand(argsIn: SyncRunQuickCommandArgs): Promise { + return await this.executeRemoteCommand("work.runQuickCommand", argsIn); + }, + + async dispose(): Promise { + disposed = true; + this.disconnect(); + }, + }; +} + +export type SyncPeerService = ReturnType; diff --git a/apps/ade-cli/src/services/sync/syncPinStore.ts b/apps/ade-cli/src/services/sync/syncPinStore.ts new file mode 100644 index 000000000..5fe1702a3 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncPinStore.ts @@ -0,0 +1,147 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pbkdf2Sync, randomBytes, timingSafeEqual } from "node:crypto"; +import { safeJsonParse, writeTextAtomic } from "../../../../desktop/src/main/services/shared/utils"; + +type SyncPinStoreArgs = { + filePath: string; +}; + +type LegacySyncPinFile = { + pin: string; + updatedAt: string; +}; + +type HashedSyncPinFile = { + version: 2; + algorithm: "pbkdf2-sha256"; + iterations: number; + salt: string; + hash: string; + updatedAt: string; +}; + +type SyncPinFile = LegacySyncPinFile | HashedSyncPinFile; + +const PIN_PATTERN = /^\d{6}$/; +const PIN_HASH_ITERATIONS = 120_000; +const PIN_HASH_BYTES = 32; + +function derivePinHash(pin: string, salt: string, iterations: number): string { + return pbkdf2Sync(pin, salt, iterations, PIN_HASH_BYTES, "sha256").toString("hex"); +} + +function safeEqualHex(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, "hex"); + const rightBuffer = Buffer.from(right, "hex"); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} + +function createHashedPinFile(pin: string, updatedAt = new Date().toISOString()): HashedSyncPinFile { + const salt = randomBytes(16).toString("hex"); + return { + version: 2, + algorithm: "pbkdf2-sha256", + iterations: PIN_HASH_ITERATIONS, + salt, + hash: derivePinHash(pin, salt, PIN_HASH_ITERATIONS), + updatedAt, + }; +} + +function isHashedPinFile(value: SyncPinFile | null): value is HashedSyncPinFile { + if (!value || !("version" in value)) return false; + return value.version === 2 + && value.algorithm === "pbkdf2-sha256" + && Number.isInteger(value.iterations) + && value.iterations > 0 + && typeof value.salt === "string" + && /^[0-9a-f]+$/i.test(value.salt) + && typeof value.hash === "string" + && /^[0-9a-f]+$/i.test(value.hash); +} + +export function createSyncPinStore(args: SyncPinStoreArgs) { + fs.mkdirSync(path.dirname(args.filePath), { recursive: true }); + + let cachedPlainPin: string | null = null; + let cachedRecord: HashedSyncPinFile | null | undefined; + + const writeRecord = (record: HashedSyncPinFile): void => { + writeTextAtomic(args.filePath, `${JSON.stringify(record, null, 2)}\n`); + try { + fs.chmodSync(args.filePath, 0o600); + } catch { + // ignore chmod failures on platforms that don't support it + } + }; + + const readFromDisk = (): HashedSyncPinFile | null => { + if (!fs.existsSync(args.filePath)) return null; + const parsed = safeJsonParse( + fs.readFileSync(args.filePath, "utf8"), + null, + ); + if (isHashedPinFile(parsed)) return parsed; + + const pin = typeof (parsed as LegacySyncPinFile | null)?.pin === "string" + ? (parsed as LegacySyncPinFile).pin.trim() + : ""; + if (!PIN_PATTERN.test(pin)) return null; + + const migrated = createHashedPinFile(pin, (parsed as LegacySyncPinFile).updatedAt); + writeRecord(migrated); + cachedPlainPin = pin; + return migrated; + }; + + const loadRecord = (): HashedSyncPinFile | null => { + if (cachedRecord !== undefined) return cachedRecord; + cachedRecord = readFromDisk(); + return cachedRecord; + }; + + return { + getPin(): string | null { + if (cachedPlainPin !== null) return cachedPlainPin; + loadRecord(); + return cachedPlainPin; + }, + + hasPin(): boolean { + return loadRecord() !== null; + }, + + verifyPin(pin: string): boolean { + const trimmed = pin.trim(); + if (!PIN_PATTERN.test(trimmed)) return false; + const record = loadRecord(); + if (!record) return false; + const hash = derivePinHash(trimmed, record.salt, record.iterations); + return safeEqualHex(hash, record.hash); + }, + + setPin(pin: string): void { + const trimmed = pin.trim(); + if (!PIN_PATTERN.test(trimmed)) { + throw new Error("PIN must be 6 digits."); + } + const payload = createHashedPinFile(trimmed); + writeRecord(payload); + cachedRecord = payload; + cachedPlainPin = trimmed; + }, + + clearPin(): void { + try { + fs.rmSync(args.filePath, { force: true }); + } catch { + // ignore cleanup failures + } + cachedRecord = null; + cachedPlainPin = null; + }, + }; +} + +export type SyncPinStore = ReturnType; diff --git a/apps/ade-cli/src/services/sync/syncProtocol.ts b/apps/ade-cli/src/services/sync/syncProtocol.ts new file mode 100644 index 000000000..895ea90f9 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncProtocol.ts @@ -0,0 +1,148 @@ +import { gunzipSync, gzipSync } from "node:zlib"; +import type { SyncCompressionCodec, SyncEnvelope, SyncPeerPlatform, SyncProtocolVersion } from "../../../../desktop/src/shared/types"; +import { safeJsonParse } from "../../../../desktop/src/main/services/shared/utils"; + +export const SYNC_PROTOCOL_VERSION: SyncProtocolVersion = 1; +export const DEFAULT_SYNC_HOST_PORT = 8787; +export const DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES = 4 * 1024; +export const MAX_UNCOMPRESSED_SYNC_ENVELOPE_BYTES = 25 * 1024 * 1024; + +export function mapPlatform(platform: NodeJS.Platform): SyncPeerPlatform { + switch (platform) { + case "darwin": + return "macOS"; + case "linux": + return "linux"; + case "win32": + return "windows"; + default: + return "unknown"; + } +} + +export function wsDataToText(data: unknown): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); + return String(data); +} + +export type ParsedSyncEnvelope = { + version: SyncProtocolVersion; + type: SyncEnvelope["type"]; + projectId: string | null; + requestId: string | null; + compression: SyncCompressionCodec; + payload: unknown; + raw: SyncEnvelope; +}; + +type EncodeEnvelopeArgs = { + type: SyncEnvelope["type"]; + projectId?: string | null; + requestId?: string | null; + payload: unknown; + compressionThresholdBytes?: number; +}; + +function asSyncEnvelope(value: unknown): SyncEnvelope { + return value as SyncEnvelope; +} + +export function encodeSyncEnvelope(args: EncodeEnvelopeArgs): string { + const payloadJson = JSON.stringify(args.payload ?? null); + const payloadBytes = Buffer.byteLength(payloadJson, "utf8"); + const requestId = typeof args.requestId === "string" && args.requestId.trim().length > 0 + ? args.requestId.trim() + : null; + const projectId = typeof args.projectId === "string" && args.projectId.trim().length > 0 + ? args.projectId.trim() + : null; + const threshold = Math.max(0, Math.floor(args.compressionThresholdBytes ?? DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES)); + + if (payloadBytes >= threshold) { + const compressed = gzipSync(Buffer.from(payloadJson, "utf8")); + return JSON.stringify(asSyncEnvelope({ + version: SYNC_PROTOCOL_VERSION, + type: args.type, + ...(projectId ? { projectId } : {}), + requestId, + compression: "gzip", + payloadEncoding: "base64", + payload: compressed.toString("base64"), + uncompressedBytes: payloadBytes, + })); + } + + return JSON.stringify(asSyncEnvelope({ + version: SYNC_PROTOCOL_VERSION, + type: args.type, + ...(projectId ? { projectId } : {}), + requestId, + compression: "none", + payloadEncoding: "json", + payload: args.payload ?? null, + })); +} + +export function parseSyncEnvelope(rawText: string): ParsedSyncEnvelope { + const decoded = safeJsonParse(rawText, null); + if (!decoded || typeof decoded !== "object") { + throw new Error("Invalid sync envelope JSON."); + } + if (decoded.version !== SYNC_PROTOCOL_VERSION) { + throw new Error(`Unsupported sync protocol version: ${String((decoded as { version?: unknown }).version ?? "unknown")}`); + } + + const requestId = typeof decoded.requestId === "string" && decoded.requestId.trim().length > 0 + ? decoded.requestId.trim() + : null; + const projectId = typeof decoded.projectId === "string" && decoded.projectId.trim().length > 0 + ? decoded.projectId.trim() + : null; + + if (decoded.compression === "gzip") { + if (decoded.payloadEncoding !== "base64" || typeof decoded.payload !== "string") { + throw new Error("Compressed sync envelopes must use base64 payload encoding."); + } + let uncompressedBuffer: Buffer; + try { + uncompressedBuffer = gunzipSync(Buffer.from(decoded.payload, "base64")); + } catch (error) { + throw new Error(`Failed to decode gzip sync envelope${requestId ? ` ${requestId}` : ""}${projectId ? ` for project ${projectId}` : ""}: ${error instanceof Error ? error.message : String(error)}`); + } + if (uncompressedBuffer.byteLength > MAX_UNCOMPRESSED_SYNC_ENVELOPE_BYTES) { + throw new Error(`Decoded sync envelope exceeds ${MAX_UNCOMPRESSED_SYNC_ENVELOPE_BYTES} bytes.`); + } + if ( + typeof decoded.uncompressedBytes === "number" + && decoded.uncompressedBytes !== uncompressedBuffer.byteLength + ) { + throw new Error("Decoded sync envelope size does not match declared uncompressedBytes."); + } + const uncompressed = uncompressedBuffer.toString("utf8"); + return { + version: decoded.version, + type: decoded.type, + projectId, + requestId, + compression: "gzip", + payload: safeJsonParse(uncompressed, null), + raw: decoded, + }; + } + + if (decoded.payloadEncoding !== "json") { + throw new Error("Uncompressed sync envelopes must use JSON payload encoding."); + } + + return { + version: decoded.version, + type: decoded.type, + projectId, + requestId, + compression: "none", + payload: decoded.payload, + raw: decoded, + }; +} diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts new file mode 100644 index 000000000..e5aa946d8 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -0,0 +1,2528 @@ +import { randomUUID } from "node:crypto"; +import type { + AgentChatCreateArgs, + AgentChatArchiveArgs, + AgentChatApproveArgs, + AgentChatDisposeArgs, + AgentChatFileRef, + AgentChatGetSummaryArgs, + AgentChatListArgs, + AgentChatProvider, + AgentChatRespondToInputArgs, + AgentChatResumeArgs, + AgentChatSendArgs, + AgentChatSession, + AgentChatSessionSummary, + AgentChatSteerArgs, + AgentChatCancelSteerArgs, + AgentChatEditSteerArgs, + AgentChatDispatchSteerArgs, + AgentChatCancelDispatchedSteerArgs, + AgentChatInterruptArgs, + AgentChatUpdateSessionArgs, + AgentStatus, + AddPrCommentArgs, + AiReviewSummaryArgs, + ApplyLaneTemplateArgs, + ArchiveLaneArgs, + AttachLaneArgs, + ClosePrArgs, + CancelQueueAutomationArgs, + CtoCoreMemory, + CtoIdentity, + CtoTriggerAgentWakeupArgs, + CreateChildLaneArgs, + CreateLaneArgs, + CreateLaneFromUnstagedArgs, + CreatePrFromLaneArgs, + CreateIntegrationLaneForProposalArgs, + ConvergenceRuntimeState, + CleanupIntegrationWorkflowArgs, + DeleteLaneArgs, + DeleteIntegrationProposalArgs, + DismissIntegrationCleanupArgs, + DraftPrDescriptionArgs, + GetDiffChangesArgs, + GetFileDiffArgs, + GitBatchFileActionArgs, + GitCherryPickArgs, + GitCommitArgs, + GitFileActionArgs, + GitGenerateCommitMessageArgs, + GitGetCommitMessageArgs, + GitGetFileHistoryArgs, + GitCheckoutBranchArgs, + GitListBranchesArgs, + GitListCommitFilesArgs, + GitPushArgs, + GitRevertArgs, + GitStashPushArgs, + GitStashRefArgs, + GitSyncArgs, + ImportBranchLaneArgs, + LandPrArgs, + LandQueueNextArgs, + PauseQueueAutomationArgs, + PipelineSettings, + PrConvergenceStatePatch, + LaneEnvInitConfig, + LaneEnvInitProgress, + LaneDetailPayload, + LaneListSnapshot, + LaneOverlayOverrides, + LaneStateSnapshotSummary, + ListLanesArgs, + ListIntegrationWorkflowsArgs, + ListSessionsArgs, + LinkPrToLaneArgs, + RebasePushArgs, + RebaseStartArgs, + RenameLaneArgs, + ReopenPrArgs, + RecheckIntegrationStepArgs, + ReactToPrCommentArgs, + ReplyToPrReviewThreadArgs, + ReparentLaneArgs, + RequestPrReviewersArgs, + ReorderQueuePrsArgs, + ResumeQueueAutomationArgs, + RerunPrChecksArgs, + SetPrLabelsArgs, + SetPrReviewThreadResolvedArgs, + StartIntegrationResolutionArgs, + SubmitPrReviewArgs, + SyncCommandPayload, + SyncRemoteCommandAction, + SyncRemoteCommandDescriptor, + SyncRemoteCommandPolicy, + SyncStartCliSessionArgs, + SyncStartCliSessionResult, + SyncRunQuickCommandArgs, + TerminalSessionSummary, + UpdateSessionMetaArgs, + UpdateIntegrationProposalArgs, + TerminalToolType, + UpdateLaneAppearanceArgs, + UpdatePrBodyArgs, + UpdatePrTitleArgs, + WriteTextAtomicArgs, +} from "../../../../desktop/src/shared/types"; +import { + buildTrackedCliLaunchCommand, + buildTrackedCliResumeCommand, + isLaunchProfile, + isTrackedCliPermissionMode, + LAUNCH_PROFILE_TITLE, + LAUNCH_PROFILE_TOOL_TYPE, + launchProfileForTerminalSession, + resolveTrackedCliResumeCommand, + validateLaunchProfilePermissionMode, +} from "../../../../desktop/src/shared/cliLaunch"; +import { normalizePrCreationStrategy } from "../../../../desktop/src/shared/prStrategy"; +import type { createAgentChatService } from "../../../../desktop/src/main/services/chat/agentChatService"; +import type { createCtoStateService } from "../../../../desktop/src/main/services/cto/ctoStateService"; +import type { createFlowPolicyService } from "../../../../desktop/src/main/services/cto/flowPolicyService"; +import type { createLinearCredentialService } from "../../../../desktop/src/main/services/cto/linearCredentialService"; +import type { createLinearIngressService } from "../../../../desktop/src/main/services/cto/linearIngressService"; +import type { createLinearIssueTracker } from "../../../../desktop/src/main/services/cto/linearIssueTracker"; +import type { createLinearSyncService } from "../../../../desktop/src/main/services/cto/linearSyncService"; +import type { createWorkerAgentService } from "../../../../desktop/src/main/services/cto/workerAgentService"; +import type { createWorkerBudgetService } from "../../../../desktop/src/main/services/cto/workerBudgetService"; +import type { createWorkerHeartbeatService } from "../../../../desktop/src/main/services/cto/workerHeartbeatService"; +import type { createWorkerRevisionService } from "../../../../desktop/src/main/services/cto/workerRevisionService"; +import { matchLaneOverlayPolicies } from "../../../../desktop/src/main/services/config/laneOverlayMatcher"; +import type { createProjectConfigService } from "../../../../desktop/src/main/services/config/projectConfigService"; +import type { createConflictService } from "../../../../desktop/src/main/services/conflicts/conflictService"; +import type { createDiffService } from "../../../../desktop/src/main/services/diffs/diffService"; +import type { createFileService } from "../../../../desktop/src/main/services/files/fileService"; +import type { createGitOperationsService } from "../../../../desktop/src/main/services/git/gitOperationsService"; +import type { createAutoRebaseService } from "../../../../desktop/src/main/services/lanes/autoRebaseService"; +import type { createLaneEnvironmentService } from "../../../../desktop/src/main/services/lanes/laneEnvironmentService"; +import type { createLaneService } from "../../../../desktop/src/main/services/lanes/laneService"; +import type { createLaneTemplateService } from "../../../../desktop/src/main/services/lanes/laneTemplateService"; +import type { createPortAllocationService } from "../../../../desktop/src/main/services/lanes/portAllocationService"; +import type { createRebaseSuggestionService } from "../../../../desktop/src/main/services/lanes/rebaseSuggestionService"; +import type { createProcessService } from "../../../../desktop/src/main/services/processes/processService"; +import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; +import type { createPrService } from "../../../../desktop/src/main/services/prs/prService"; +import type { createIssueInventoryService } from "../../../../desktop/src/main/services/prs/issueInventoryService"; +import type { PathToMergeOrchestrator } from "../../../../desktop/src/main/services/prs/pathToMergeOrchestrator"; +import type { createQueueLandingService } from "../../../../desktop/src/main/services/prs/queueLandingService"; +import type { createPtyService } from "../../../../desktop/src/main/services/pty/ptyService"; +import type { createSessionService } from "../../../../desktop/src/main/services/sessions/sessionService"; + +type SyncRemoteCommandServiceArgs = { + laneService: ReturnType; + prService: ReturnType; + issueInventoryService?: ReturnType | null; + /** + * Optional Path-to-Merge orchestrator. When present, iOS callers can start + * and stop the convergence loop via the `prs.pathToMerge.start` / + * `prs.pathToMerge.stop` sync commands. Optional so older builds (without + * the orchestrator wired) keep compiling and degrade gracefully on iOS. + */ + pathToMergeOrchestrator?: PathToMergeOrchestrator | null; + queueLandingService?: ReturnType | null; + ptyService: ReturnType; + sessionService: ReturnType; + fileService: ReturnType; + gitService?: ReturnType; + diffService?: ReturnType; + conflictService?: ReturnType; + agentChatService?: ReturnType; + workerAgentService?: ReturnType | null; + workerBudgetService?: ReturnType | null; + workerHeartbeatService?: ReturnType | null; + workerRevisionService?: ReturnType | null; + ctoStateService?: ReturnType | null; + flowPolicyService?: ReturnType | null; + linearCredentialService?: ReturnType | null; + /** + * Resolvers for services created after createSyncService in main.ts. + * Router handlers read them lazily so init order is not load-bearing. + */ + getLinearIngressService?: () => ReturnType | null; + getLinearIssueTracker?: () => ReturnType | null; + getLinearSyncService?: () => ReturnType | null; + projectConfigService?: ReturnType; + processService?: ReturnType | null; + portAllocationService?: ReturnType | null; + laneEnvironmentService?: ReturnType | null; + laneTemplateService?: ReturnType | null; + rebaseSuggestionService?: ReturnType | null; + autoRebaseService?: ReturnType | null; + logger: Logger; +}; + +type RegisteredRemoteCommand = { + descriptor: SyncRemoteCommandDescriptor; + handler: (args: Record) => Promise; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function asOptionalBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function asOptionalNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((entry) => asTrimmedString(entry)).filter((entry): entry is string => Boolean(entry)); +} + +function parseAgentChatFileRefs(value: unknown): AgentChatFileRef[] | undefined { + if (!Array.isArray(value)) return undefined; + const attachments: AgentChatFileRef[] = []; + for (const entry of value) { + if (!isRecord(entry)) continue; + const path = asTrimmedString(entry.path); + let type: "image" | "file" | null = null; + if (entry.type === "image") type = "image"; + else if (entry.type === "file") type = "file"; + if (!path || !type) continue; + attachments.push({ path, type }); + } + return attachments; +} + +function parseCursorConfigValues( + value: unknown, +): AgentChatUpdateSessionArgs["cursorConfigValues"] | AgentChatCreateArgs["cursorConfigValues"] { + if (value == null) return null; + if (!isRecord(value)) return {}; + return Object.fromEntries( + Object.entries(value) + .filter((entry): entry is [string, string | boolean | number] => ( + typeof entry[1] === "string" + || typeof entry[1] === "boolean" + || (typeof entry[1] === "number" && Number.isFinite(entry[1])) + )) + .map(([key, entryValue]): [string, string | boolean | number] => [key.trim(), entryValue]) + .filter(([key]) => key.length > 0), + ); +} + +function requireString(value: unknown, message: string): string { + const parsed = asTrimmedString(value); + if (!parsed) throw new Error(message); + return parsed; +} + +function requireStringArray(value: unknown, message: string): string[] { + const parsed = asStringArray(value); + if (parsed.length === 0) throw new Error(message); + return parsed; +} + +function requireService(value: T | null | undefined, message: string): T { + if (value == null) throw new Error(message); + return value; +} + +function parseProcessLaneArgs(payload: Record, action: string): { laneId: string } { + return { + laneId: requireString(payload.laneId, `${action} requires laneId.`), + }; +} + +function parseProcessActionArgs(payload: Record, action: string): { laneId: string; processId: string; runId?: string } { + const parsed = { + laneId: requireString(payload.laneId, `${action} requires laneId.`), + processId: requireString(payload.processId, `${action} requires processId.`), + }; + const runId = asTrimmedString(payload.runId); + return runId ? { ...parsed, runId } : parsed; +} + +async function summarizeChatSessionForRemote( + agentChatService: ReturnType, + session: AgentChatSession, +): Promise { + const summary = await agentChatService.getSessionSummary(session.id); + if (summary) return summary; + + return { + sessionId: session.id, + laneId: session.laneId, + provider: session.provider, + model: session.model, + ...(session.modelId ? { modelId: session.modelId } : {}), + ...(session.sessionProfile ? { sessionProfile: session.sessionProfile } : {}), + reasoningEffort: session.reasoningEffort ?? null, + codexFastMode: session.codexFastMode === true, + executionMode: session.executionMode ?? null, + ...(session.permissionMode ? { permissionMode: session.permissionMode } : {}), + ...(session.interactionMode !== undefined ? { interactionMode: session.interactionMode } : {}), + ...(session.claudePermissionMode ? { claudePermissionMode: session.claudePermissionMode } : {}), + ...(session.codexApprovalPolicy ? { codexApprovalPolicy: session.codexApprovalPolicy } : {}), + ...(session.codexSandbox ? { codexSandbox: session.codexSandbox } : {}), + ...(session.codexConfigSource ? { codexConfigSource: session.codexConfigSource } : {}), + ...(session.opencodePermissionMode ? { opencodePermissionMode: session.opencodePermissionMode } : {}), + ...(session.droidPermissionMode ? { droidPermissionMode: session.droidPermissionMode } : {}), + ...(session.cursorModeSnapshot ? { cursorModeSnapshot: session.cursorModeSnapshot } : {}), + ...(session.cursorModeId !== undefined ? { cursorModeId: session.cursorModeId } : {}), + ...(session.cursorConfigValues ? { cursorConfigValues: session.cursorConfigValues } : {}), + ...(session.identityKey ? { identityKey: session.identityKey } : {}), + ...(session.surface ? { surface: session.surface } : {}), + automationId: session.automationId ?? null, + automationRunId: session.automationRunId ?? null, + ...(session.capabilityMode ? { capabilityMode: session.capabilityMode } : {}), + completion: session.completion ?? null, + status: session.status, + idleSinceAt: session.idleSinceAt ?? null, + startedAt: session.createdAt, + endedAt: null, + lastActivityAt: session.lastActivityAt, + lastOutputPreview: null, + summary: null, + ...(session.threadId ? { threadId: session.threadId } : {}), + ...(session.requestedCwd !== undefined ? { requestedCwd: session.requestedCwd } : {}), + }; +} + +function parseListLanesArgs(value: Record): ListLanesArgs { + return { + includeArchived: asOptionalBoolean(value.includeArchived), + includeStatus: asOptionalBoolean(value.includeStatus), + }; +} + +function parseCreateLaneArgs(value: Record): CreateLaneArgs { + return { + name: requireString(value.name, "lanes.create requires name."), + ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), + ...(asTrimmedString(value.parentLaneId) ? { parentLaneId: asTrimmedString(value.parentLaneId)! } : {}), + ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), + }; +} + +function parseCreateChildLaneArgs(value: Record): CreateChildLaneArgs { + return { + name: requireString(value.name, "lanes.createChild requires name."), + parentLaneId: requireString(value.parentLaneId, "lanes.createChild requires parentLaneId."), + ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), + ...(asTrimmedString(value.folder) ? { folder: asTrimmedString(value.folder)! } : {}), + }; +} + +function parseCreateLaneFromUnstagedArgs(value: Record): CreateLaneFromUnstagedArgs { + return { + name: requireString(value.name, "lanes.createFromUnstaged requires name."), + sourceLaneId: requireString(value.sourceLaneId, "lanes.createFromUnstaged requires sourceLaneId."), + }; +} + +function parseImportBranchArgs(value: Record): ImportBranchLaneArgs { + return { + branchRef: requireString(value.branchRef, "lanes.importBranch requires branchRef."), + ...(asTrimmedString(value.name) ? { name: asTrimmedString(value.name)! } : {}), + ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), + ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), + }; +} + +function parseAttachLaneArgs(value: Record): AttachLaneArgs { + return { + name: requireString(value.name, "lanes.attach requires name."), + attachedPath: requireString(value.attachedPath, "lanes.attach requires attachedPath."), + ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), + }; +} + +function parseArchiveLaneArgs(value: Record, action: string): ArchiveLaneArgs { + return { + laneId: requireString(value.laneId, `${action} requires laneId.`), + }; +} + +function parseDeleteLaneArgs(value: Record): DeleteLaneArgs { + return { + laneId: requireString(value.laneId, "lanes.delete requires laneId."), + deleteBranch: asOptionalBoolean(value.deleteBranch), + deleteRemoteBranch: asOptionalBoolean(value.deleteRemoteBranch), + ...(asTrimmedString(value.remoteName) ? { remoteName: asTrimmedString(value.remoteName)! } : {}), + force: asOptionalBoolean(value.force), + }; +} + +function parseRenameLaneArgs(value: Record): RenameLaneArgs { + return { + laneId: requireString(value.laneId, "lanes.rename requires laneId."), + name: requireString(value.name, "lanes.rename requires name."), + }; +} + +function parseReparentLaneArgs(value: Record): ReparentLaneArgs { + return { + laneId: requireString(value.laneId, "lanes.reparent requires laneId."), + newParentLaneId: requireString(value.newParentLaneId, "lanes.reparent requires newParentLaneId."), + }; +} + +function parseUpdateLaneAppearanceArgs(value: Record): UpdateLaneAppearanceArgs { + const parsed: UpdateLaneAppearanceArgs = { + laneId: requireString(value.laneId, "lanes.updateAppearance requires laneId."), + }; + if ("color" in value) { + parsed.color = value.color == null ? null : asTrimmedString(value.color) ?? null; + } + if ("icon" in value) { + parsed.icon = value.icon == null ? null : (asTrimmedString(value.icon) as UpdateLaneAppearanceArgs["icon"]); + } + if ("tags" in value) { + parsed.tags = value.tags == null ? null : asStringArray(value.tags); + } + return parsed; +} + +function parseRebaseStartArgs(value: Record): RebaseStartArgs { + return { + laneId: requireString(value.laneId, "lanes.rebaseStart requires laneId."), + ...(asTrimmedString(value.scope) ? { scope: value.scope as RebaseStartArgs["scope"] } : {}), + ...(asTrimmedString(value.pushMode) ? { pushMode: value.pushMode as RebaseStartArgs["pushMode"] } : {}), + ...(asTrimmedString(value.actor) ? { actor: asTrimmedString(value.actor)! } : {}), + ...(asTrimmedString(value.reason) ? { reason: asTrimmedString(value.reason)! } : {}), + ...(asTrimmedString(value.baseBranchOverride) ? { baseBranchOverride: asTrimmedString(value.baseBranchOverride)! } : {}), + }; +} + +function parseRebasePushArgs(value: Record): RebasePushArgs { + return { + runId: requireString(value.runId, "lanes.rebasePush requires runId."), + laneIds: requireStringArray(value.laneIds, "lanes.rebasePush requires laneIds."), + }; +} + +function parseRunIdArgs(value: Record, action: string): { runId: string } { + return { + runId: requireString(value.runId, `${action} requires runId.`), + }; +} + +function parseListSessionsArgs(value: Record): ListSessionsArgs { + const laneId = asTrimmedString(value.laneId); + const status = asTrimmedString(value.status) as ListSessionsArgs["status"]; + const limit = asOptionalNumber(value.limit); + return { + ...(laneId ? { laneId } : {}), + ...(status ? { status } : {}), + ...(typeof limit === "number" ? { limit } : {}), + }; +} + +function parseUpdateSessionMetaArgs(value: Record): UpdateSessionMetaArgs { + const parsed: UpdateSessionMetaArgs = { + sessionId: requireString(value.sessionId, "work.updateSessionMeta requires sessionId."), + }; + + if ("pinned" in value) parsed.pinned = value.pinned === true; + if ("manuallyNamed" in value) parsed.manuallyNamed = value.manuallyNamed === true; + if ("title" in value) parsed.title = value.title == null ? undefined : requireString(value.title, "work.updateSessionMeta requires a non-empty title when title is provided."); + if ("goal" in value) parsed.goal = value.goal == null ? null : asTrimmedString(value.goal) ?? null; + if ("toolType" in value) { + parsed.toolType = value.toolType == null + ? null + : asTrimmedString(value.toolType) as UpdateSessionMetaArgs["toolType"]; + } + if ("resumeCommand" in value) { + parsed.resumeCommand = value.resumeCommand == null ? null : asTrimmedString(value.resumeCommand) ?? null; + } + + return parsed; +} + +function parseQuickCommandArgs(value: Record): SyncRunQuickCommandArgs { + const laneId = requireString(value.laneId, "work.runQuickCommand requires laneId."); + const title = requireString(value.title, "work.runQuickCommand requires title."); + const toolType = asTrimmedString(value.toolType); + const startupCommand = asTrimmedString(value.startupCommand); + if (!startupCommand && toolType !== "shell") { + throw new Error("work.runQuickCommand requires startupCommand unless toolType is shell."); + } + return { + laneId, + title, + ...(startupCommand ? { startupCommand } : {}), + cols: asOptionalNumber(value.cols), + rows: asOptionalNumber(value.rows), + toolType, + tracked: asOptionalBoolean(value.tracked), + }; +} + +const DEFAULT_CLI_COLS = 120; +const DEFAULT_CLI_ROWS = 36; + +function clampCliDimension(value: number | undefined, fallback: number, min: number, max: number): number { + return Math.max(min, Math.min(max, Math.floor(value ?? fallback))); +} + +function parseCliProvider(value: unknown): SyncStartCliSessionArgs["provider"] { + const provider = asTrimmedString(value)?.toLowerCase(); + if (!isLaunchProfile(provider)) throw new Error("work.startCliSession requires provider."); + return provider; +} + +function parseCliPermissionMode(value: unknown): SyncStartCliSessionArgs["permissionMode"] { + const mode = asTrimmedString(value); + return isTrackedCliPermissionMode(mode) ? mode : "default"; +} + +function parseStartCliSessionArgs(value: Record): SyncStartCliSessionArgs { + const laneId = requireString(value.laneId, "work.startCliSession requires laneId."); + const provider = parseCliProvider(value.provider); + const initialInput = typeof value.initialInput === "string" && value.initialInput.trim().length > 0 + ? value.initialInput.slice(0, 20_000) + : null; + return { + laneId, + provider, + permissionMode: parseCliPermissionMode(value.permissionMode), + title: asTrimmedString(value.title), + initialInput, + cols: asOptionalNumber(value.cols), + rows: asOptionalNumber(value.rows), + resumeSessionId: asTrimmedString(value.resumeSessionId), + }; +} + +function requireResumeSessionForProvider( + sessionService: ReturnType, + sessionId: string, + provider: SyncStartCliSessionArgs["provider"], +): TerminalSessionSummary { + const session = sessionService.get(sessionId) as TerminalSessionSummary | null; + if (!session) throw new Error(`work.startCliSession resumeSessionId '${sessionId}' was not found.`); + const existingProvider = launchProfileForTerminalSession(session); + if (existingProvider && existingProvider !== provider) { + throw new Error(`work.startCliSession resumeSessionId '${sessionId}' belongs to ${existingProvider}, not ${provider}.`); + } + return session; +} + +function isChatToolType(toolType: string | null | undefined): boolean { + if (!toolType) return false; + const t = toolType.trim().toLowerCase(); + return t === "cursor" || t.endsWith("-chat"); +} + +async function listRemoteWorkSessions( + args: SyncRemoteCommandServiceArgs, + filters: ListSessionsArgs, +) { + const sessions = args.ptyService.enrichSessions(args.sessionService.list(filters)); + const laneId = typeof filters.laneId === "string" ? filters.laneId.trim() : ""; + const allChats = await args.agentChatService + ?.listSessions(laneId || undefined, { includeIdentity: true }) + .catch(() => [] as AgentChatSessionSummary[]) ?? []; + + const identitySessionIds = new Set( + allChats.filter((chat) => Boolean(chat.identityKey)).map((chat) => chat.sessionId), + ); + const visibleSessions = identitySessionIds.size > 0 + ? sessions.filter((session) => !identitySessionIds.has(session.id)) + : sessions; + + const chatSummaryBySessionId = new Map( + allChats.filter((chat) => !chat.identityKey).map((chat) => [chat.sessionId, chat] as const), + ); + if (chatSummaryBySessionId.size === 0) return visibleSessions; + + return visibleSessions.map((session) => { + if (!isChatToolType(session.toolType) || session.status !== "running") return session; + const chat = chatSummaryBySessionId.get(session.id); + if (!chat) return session; + if (chat.awaitingInput) return { ...session, runtimeState: "waiting-input" as const, chatIdleSinceAt: null }; + if (chat.status === "active") return { ...session, runtimeState: "running" as const, chatIdleSinceAt: null }; + if (chat.status === "idle") return { ...session, runtimeState: "idle" as const, chatIdleSinceAt: chat.idleSinceAt ?? null }; + return session; + }); +} + +function parseCloseSessionArgs(value: Record): { sessionId: string } { + return { + sessionId: requireString(value.sessionId, "work.closeSession requires sessionId."), + }; +} + +function parseAgentChatListArgs(value: Record): AgentChatListArgs { + return { + ...(asTrimmedString(value.laneId) ? { laneId: asTrimmedString(value.laneId)! } : {}), + includeAutomation: asOptionalBoolean(value.includeAutomation), + }; +} + +function parseAgentChatGetSummaryArgs(value: Record): AgentChatGetSummaryArgs { + return { + sessionId: requireString(value.sessionId, "chat.getSummary requires sessionId."), + }; +} + +function parseAgentChatCreateArgs(value: Record): AgentChatCreateArgs { + const parsed: AgentChatCreateArgs = { + laneId: requireString(value.laneId, "chat.create requires laneId."), + provider: (asTrimmedString(value.provider) ?? "codex") as AgentChatCreateArgs["provider"], + model: asTrimmedString(value.model) ?? "", + ...(asTrimmedString(value.modelId) ? { modelId: asTrimmedString(value.modelId)! } : {}), + ...(asTrimmedString(value.reasoningEffort) ? { reasoningEffort: asTrimmedString(value.reasoningEffort)! } : {}), + }; + + if ("sessionProfile" in value) parsed.sessionProfile = value.sessionProfile == null ? undefined : asTrimmedString(value.sessionProfile) as AgentChatCreateArgs["sessionProfile"]; + if ("permissionMode" in value) parsed.permissionMode = value.permissionMode == null ? undefined : asTrimmedString(value.permissionMode) as AgentChatCreateArgs["permissionMode"]; + if ("interactionMode" in value) parsed.interactionMode = value.interactionMode == null ? null : asTrimmedString(value.interactionMode) as AgentChatCreateArgs["interactionMode"]; + if ("claudePermissionMode" in value) parsed.claudePermissionMode = value.claudePermissionMode == null ? undefined : asTrimmedString(value.claudePermissionMode) as AgentChatCreateArgs["claudePermissionMode"]; + if ("codexApprovalPolicy" in value) parsed.codexApprovalPolicy = value.codexApprovalPolicy == null ? undefined : asTrimmedString(value.codexApprovalPolicy) as AgentChatCreateArgs["codexApprovalPolicy"]; + if ("codexSandbox" in value) parsed.codexSandbox = value.codexSandbox == null ? undefined : asTrimmedString(value.codexSandbox) as AgentChatCreateArgs["codexSandbox"]; + if ("codexConfigSource" in value) parsed.codexConfigSource = value.codexConfigSource == null ? undefined : asTrimmedString(value.codexConfigSource) as AgentChatCreateArgs["codexConfigSource"]; + if ("codexFastMode" in value) parsed.codexFastMode = asOptionalBoolean(value.codexFastMode); + if ("opencodePermissionMode" in value) parsed.opencodePermissionMode = value.opencodePermissionMode == null ? undefined : asTrimmedString(value.opencodePermissionMode) as AgentChatCreateArgs["opencodePermissionMode"]; + if ("droidPermissionMode" in value) parsed.droidPermissionMode = value.droidPermissionMode == null ? undefined : (asTrimmedString(value.droidPermissionMode) ?? undefined) as AgentChatCreateArgs["droidPermissionMode"]; + if ("cursorModeId" in value) parsed.cursorModeId = value.cursorModeId == null ? null : asTrimmedString(value.cursorModeId) ?? null; + if ("cursorConfigValues" in value) parsed.cursorConfigValues = parseCursorConfigValues(value.cursorConfigValues); + if ("requestedCwd" in value) parsed.requestedCwd = value.requestedCwd == null ? undefined : requireString(value.requestedCwd, "chat.create requires a non-empty requestedCwd when provided."); + + return parsed; +} + +function parseAgentChatSendArgs(value: Record): AgentChatSendArgs { + const attachments = parseAgentChatFileRefs(value.attachments); + return { + sessionId: requireString(value.sessionId, "chat.send requires sessionId."), + text: requireString(value.text, "chat.send requires text."), + ...(asTrimmedString(value.displayText) ? { displayText: asTrimmedString(value.displayText)! } : {}), + ...(attachments?.length ? { attachments } : {}), + ...(asTrimmedString(value.reasoningEffort) ? { reasoningEffort: asTrimmedString(value.reasoningEffort)! } : {}), + ...(asTrimmedString(value.executionMode) ? { executionMode: asTrimmedString(value.executionMode)! as AgentChatSendArgs["executionMode"] } : {}), + ...(asTrimmedString(value.interactionMode) ? { interactionMode: asTrimmedString(value.interactionMode)! as AgentChatSendArgs["interactionMode"] } : {}), + }; +} + +function parseAgentChatSteerArgs(value: Record): AgentChatSteerArgs { + const attachments = parseAgentChatFileRefs(value.attachments); + return { + sessionId: requireString(value.sessionId, "chat.steer requires sessionId."), + text: requireString(value.text, "chat.steer requires text."), + ...(attachments?.length ? { attachments } : {}), + }; +} + +function parseAgentChatCancelSteerArgs(value: Record): AgentChatCancelSteerArgs { + return { + sessionId: requireString(value.sessionId, "chat.cancelSteer requires sessionId."), + steerId: requireString(value.steerId, "chat.cancelSteer requires steerId."), + }; +} + +function parseAgentChatEditSteerArgs(value: Record): AgentChatEditSteerArgs { + return { + sessionId: requireString(value.sessionId, "chat.editSteer requires sessionId."), + steerId: requireString(value.steerId, "chat.editSteer requires steerId."), + text: requireString(value.text, "chat.editSteer requires text."), + }; +} + +function parseAgentChatDispatchSteerArgs(value: Record): AgentChatDispatchSteerArgs { + const mode = value.mode; + if (mode !== "inline" && mode !== "interrupt") { + throw new Error("chat.dispatchSteer requires mode of 'inline' or 'interrupt'."); + } + return { + sessionId: requireString(value.sessionId, "chat.dispatchSteer requires sessionId."), + steerId: requireString(value.steerId, "chat.dispatchSteer requires steerId."), + mode, + }; +} + +function parseAgentChatCancelDispatchedSteerArgs(value: Record): AgentChatCancelDispatchedSteerArgs { + return { + sessionId: requireString(value.sessionId, "chat.cancelDispatchedSteer requires sessionId."), + steerId: requireString(value.steerId, "chat.cancelDispatchedSteer requires steerId."), + }; +} + +function parseAgentChatInterruptArgs(value: Record): AgentChatInterruptArgs { + return { + sessionId: requireString(value.sessionId, "chat.interrupt requires sessionId."), + }; +} + +function parseAgentChatResumeArgs(value: Record): AgentChatResumeArgs { + return { + sessionId: requireString(value.sessionId, "chat.resume requires sessionId."), + }; +} + +function parseAgentChatApproveArgs(value: Record): AgentChatApproveArgs { + return { + sessionId: requireString(value.sessionId, "chat.approve requires sessionId."), + itemId: requireString(value.itemId, "chat.approve requires itemId."), + decision: requireString(value.decision, "chat.approve requires decision.") as AgentChatApproveArgs["decision"], + ...(asTrimmedString(value.responseText) ? { responseText: asTrimmedString(value.responseText)! } : {}), + }; +} + +function parseAgentChatRespondToInputArgs(value: Record): AgentChatRespondToInputArgs { + const parsed: AgentChatRespondToInputArgs = { + sessionId: requireString(value.sessionId, "chat.respondToInput requires sessionId."), + itemId: requireString(value.itemId, "chat.respondToInput requires itemId."), + }; + + if (typeof value.decision === "string" && value.decision.trim().length > 0) { + parsed.decision = value.decision.trim() as AgentChatRespondToInputArgs["decision"]; + } + if (isRecord(value.answers)) { + parsed.answers = Object.fromEntries( + Object.entries(value.answers).map(([key, entry]) => { + if (Array.isArray(entry)) { + return [key, entry.map((item) => String(item))]; + } + return [key, String(entry)]; + }), + ); + } + if (typeof value.responseText === "string" && value.responseText.trim().length > 0) { + parsed.responseText = value.responseText.trim(); + } + return parsed; +} + +function parseAgentChatUpdateSessionArgs(value: Record): AgentChatUpdateSessionArgs { + const parsed: AgentChatUpdateSessionArgs = { + sessionId: requireString(value.sessionId, "chat.updateSession requires sessionId."), + }; + + if ("title" in value) parsed.title = value.title == null ? null : asTrimmedString(value.title) ?? null; + if ("modelId" in value) parsed.modelId = value.modelId == null ? undefined : asTrimmedString(value.modelId) as AgentChatUpdateSessionArgs["modelId"]; + if ("reasoningEffort" in value) parsed.reasoningEffort = value.reasoningEffort == null ? null : asTrimmedString(value.reasoningEffort) ?? null; + if ("permissionMode" in value) parsed.permissionMode = value.permissionMode == null ? undefined : asTrimmedString(value.permissionMode) as AgentChatUpdateSessionArgs["permissionMode"]; + if ("interactionMode" in value) parsed.interactionMode = value.interactionMode == null ? null : asTrimmedString(value.interactionMode) as AgentChatUpdateSessionArgs["interactionMode"]; + if ("claudePermissionMode" in value) parsed.claudePermissionMode = value.claudePermissionMode == null ? undefined : asTrimmedString(value.claudePermissionMode) as AgentChatUpdateSessionArgs["claudePermissionMode"]; + if ("codexApprovalPolicy" in value) parsed.codexApprovalPolicy = value.codexApprovalPolicy == null ? undefined : asTrimmedString(value.codexApprovalPolicy) as AgentChatUpdateSessionArgs["codexApprovalPolicy"]; + if ("codexSandbox" in value) parsed.codexSandbox = value.codexSandbox == null ? undefined : asTrimmedString(value.codexSandbox) as AgentChatUpdateSessionArgs["codexSandbox"]; + if ("codexConfigSource" in value) parsed.codexConfigSource = value.codexConfigSource == null ? undefined : asTrimmedString(value.codexConfigSource) as AgentChatUpdateSessionArgs["codexConfigSource"]; + if ("codexFastMode" in value) parsed.codexFastMode = asOptionalBoolean(value.codexFastMode); + if ("opencodePermissionMode" in value) parsed.opencodePermissionMode = value.opencodePermissionMode == null ? undefined : asTrimmedString(value.opencodePermissionMode) as AgentChatUpdateSessionArgs["opencodePermissionMode"]; + if ("droidPermissionMode" in value) parsed.droidPermissionMode = value.droidPermissionMode == null ? undefined : asTrimmedString(value.droidPermissionMode) as AgentChatUpdateSessionArgs["droidPermissionMode"]; + if ("cursorModeId" in value) parsed.cursorModeId = value.cursorModeId == null ? null : asTrimmedString(value.cursorModeId) ?? null; + if ("cursorConfigValues" in value) { + parsed.cursorConfigValues = parseCursorConfigValues(value.cursorConfigValues); + } + if ("manuallyNamed" in value) parsed.manuallyNamed = value.manuallyNamed === true; + return parsed; +} + +function parseAgentChatDisposeArgs(value: Record): AgentChatDisposeArgs { + return { + sessionId: requireString(value.sessionId, "chat.dispose requires sessionId."), + }; +} + +function parseAgentChatArchiveArgs(value: Record, action: string): AgentChatArchiveArgs { + return { + sessionId: requireString(value.sessionId, `${action} requires sessionId.`), + }; +} + +function parseGetTranscriptArgs(value: Record): { + sessionId: string; + limit?: number; + maxChars?: number; +} { + return { + sessionId: requireString(value.sessionId, "chat.getTranscript requires sessionId."), + limit: asOptionalNumber(value.limit), + maxChars: asOptionalNumber(value.maxChars), + }; +} + +function parseGitFileActionArgs(value: Record, action: string): GitFileActionArgs { + return { + laneId: requireString(value.laneId, `${action} requires laneId.`), + path: requireString(value.path, `${action} requires path.`), + }; +} + +function parseGitBatchFileActionArgs(value: Record, action: string): GitBatchFileActionArgs { + return { + laneId: requireString(value.laneId, `${action} requires laneId.`), + paths: requireStringArray(value.paths, `${action} requires paths.`), + }; +} + +function parseWriteTextAtomicArgs(value: Record): WriteTextAtomicArgs { + if (typeof value.text !== "string") { + throw new Error("files.writeTextAtomic requires text."); + } + return { + laneId: requireString(value.laneId, "files.writeTextAtomic requires laneId."), + path: requireString(value.path, "files.writeTextAtomic requires path."), + text: value.text, + }; +} + +function parseGitCommitArgs(value: Record): GitCommitArgs { + return { + laneId: requireString(value.laneId, "git.commit requires laneId."), + message: requireString(value.message, "git.commit requires message."), + amend: asOptionalBoolean(value.amend), + }; +} + +function parseGitGenerateCommitMessageArgs(value: Record): GitGenerateCommitMessageArgs { + return { + laneId: requireString(value.laneId, "git.generateCommitMessage requires laneId."), + amend: asOptionalBoolean(value.amend), + }; +} + +function parseGitListRecentCommitsArgs(value: Record): { laneId: string; limit?: number } { + return { + laneId: requireString(value.laneId, "git.listRecentCommits requires laneId."), + limit: asOptionalNumber(value.limit), + }; +} + +function parseGitListCommitFilesArgs(value: Record): GitListCommitFilesArgs { + return { + laneId: requireString(value.laneId, "git.listCommitFiles requires laneId."), + commitSha: requireString(value.commitSha, "git.listCommitFiles requires commitSha."), + }; +} + +function parseGitGetCommitMessageArgs(value: Record): GitGetCommitMessageArgs { + return { + laneId: requireString(value.laneId, "git.getCommitMessage requires laneId."), + commitSha: requireString(value.commitSha, "git.getCommitMessage requires commitSha."), + }; +} + +function parseGitGetFileHistoryArgs(value: Record): GitGetFileHistoryArgs { + return { + laneId: requireString(value.laneId, "git.getFileHistory requires laneId."), + path: requireString(value.path, "git.getFileHistory requires path."), + limit: asOptionalNumber(value.limit), + }; +} + +function parseGitRevertArgs(value: Record): GitRevertArgs { + return { + laneId: requireString(value.laneId, "git.revertCommit requires laneId."), + commitSha: requireString(value.commitSha, "git.revertCommit requires commitSha."), + }; +} + +function parseGitCherryPickArgs(value: Record): GitCherryPickArgs { + return { + laneId: requireString(value.laneId, "git.cherryPickCommit requires laneId."), + commitSha: requireString(value.commitSha, "git.cherryPickCommit requires commitSha."), + }; +} + +function parseGitStashPushArgs(value: Record): GitStashPushArgs { + return { + laneId: requireString(value.laneId, "git.stashPush requires laneId."), + ...(asTrimmedString(value.message) ? { message: asTrimmedString(value.message)! } : {}), + includeUntracked: asOptionalBoolean(value.includeUntracked), + }; +} + +function parseGitStashRefArgs(value: Record, action: string): GitStashRefArgs { + return { + laneId: requireString(value.laneId, `${action} requires laneId.`), + stashRef: requireString(value.stashRef, `${action} requires stashRef.`), + }; +} + +function parseGitSyncArgs(value: Record): GitSyncArgs { + return { + laneId: requireString(value.laneId, "git.sync requires laneId."), + ...(asTrimmedString(value.mode) ? { mode: value.mode as GitSyncArgs["mode"] } : {}), + ...(asTrimmedString(value.baseRef) ? { baseRef: asTrimmedString(value.baseRef)! } : {}), + }; +} + +function parseGitPushArgs(value: Record): GitPushArgs { + return { + laneId: requireString(value.laneId, "git.push requires laneId."), + forceWithLease: asOptionalBoolean(value.forceWithLease), + }; +} + +function parseGetDiffChangesArgs(value: Record): GetDiffChangesArgs { + return { + laneId: requireString(value.laneId, "git.getChanges requires laneId."), + }; +} + +function parseGetFileDiffArgs(value: Record): GetFileDiffArgs { + return { + laneId: requireString(value.laneId, "git.getFile requires laneId."), + path: requireString(value.path, "git.getFile requires path."), + mode: requireString(value.mode, "git.getFile requires mode.") as GetFileDiffArgs["mode"], + ...(asTrimmedString(value.compareRef) ? { compareRef: asTrimmedString(value.compareRef)! } : {}), + ...(asTrimmedString(value.compareTo) ? { compareTo: value.compareTo as GetFileDiffArgs["compareTo"] } : {}), + }; +} + +function parseGitListBranchesArgs(value: Record): GitListBranchesArgs { + return { + laneId: requireString(value.laneId, "git.listBranches requires laneId."), + }; +} + +function parseGitCheckoutBranchArgs(value: Record): GitCheckoutBranchArgs { + return { + laneId: requireString(value.laneId, "git.checkoutBranch requires laneId."), + branchName: requireString(value.branchName, "git.checkoutBranch requires branchName."), + ...(asTrimmedString(value.mode) ? { mode: value.mode as GitCheckoutBranchArgs["mode"] } : {}), + ...(asTrimmedString(value.startPoint) ? { startPoint: asTrimmedString(value.startPoint)! } : {}), + ...(asTrimmedString(value.baseRef) ? { baseRef: asTrimmedString(value.baseRef)! } : {}), + ...(asOptionalBoolean(value.acknowledgeActiveWork) !== undefined + ? { acknowledgeActiveWork: asOptionalBoolean(value.acknowledgeActiveWork) } + : {}), + }; +} + +function parseConflictLaneArgs(value: Record, action: string): { laneId: string } { + return { + laneId: requireString(value.laneId, `${action} requires laneId.`), + }; +} + +function parseChatModelsArgs(value: Record): { provider: AgentChatProvider; activateRuntime?: boolean } { + return { + provider: (asTrimmedString(value.provider) ?? "codex") as AgentChatProvider, + ...(value.activateRuntime === true ? { activateRuntime: true } : {}), + }; +} + +function requirePrId(value: Record, action: string): string { + return requireString(value.prId, `${action} requires prId.`); +} + +function parseCreatePrArgs(value: Record): CreatePrFromLaneArgs { + const laneId = asTrimmedString(value.laneId); + const title = asTrimmedString(value.title); + const body = typeof value.body === "string" ? value.body : ""; + if (!laneId || !title) throw new Error("prs.createFromLane requires laneId and title."); + const strategy: CreatePrFromLaneArgs["strategy"] = + normalizePrCreationStrategy(asTrimmedString(value.strategy)) ?? undefined; + return { + laneId, + title, + body, + draft: value.draft === true, + ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), + ...(asStringArray(value.labels).length ? { labels: asStringArray(value.labels) } : {}), + ...(asStringArray(value.reviewers).length ? { reviewers: asStringArray(value.reviewers) } : {}), + ...(typeof value.allowDirtyWorktree === "boolean" ? { allowDirtyWorktree: value.allowDirtyWorktree } : {}), + ...(typeof value.closeLinearIssueOnMerge === "boolean" ? { closeLinearIssueOnMerge: value.closeLinearIssueOnMerge } : {}), + ...(strategy ? { strategy } : {}), + }; +} + +function parseLinkPrToLaneArgs(value: Record): LinkPrToLaneArgs { + return { + laneId: requireString(value.laneId, "prs.linkToLane requires laneId."), + prUrlOrNumber: requireString(value.prUrlOrNumber, "prs.linkToLane requires prUrlOrNumber."), + }; +} + +function parseDraftPrDescriptionArgs(value: Record): DraftPrDescriptionArgs { + return { + laneId: requireString(value.laneId, "prs.draftDescription requires laneId."), + ...(asTrimmedString(value.model) ? { model: asTrimmedString(value.model)! } : {}), + ...("reasoningEffort" in value + ? { reasoningEffort: value.reasoningEffort == null ? null : (asTrimmedString(value.reasoningEffort) ?? null) } + : {}), + ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), + ...(typeof value.closeLinearIssueOnMerge === "boolean" ? { closeLinearIssueOnMerge: value.closeLinearIssueOnMerge } : {}), + }; +} + +function parseLandPrArgs(value: Record): LandPrArgs { + const prId = requirePrId(value, "prs.land"); + const method = asTrimmedString(value.method) as LandPrArgs["method"]; + if (!method || !["merge", "squash", "rebase"].includes(method)) { + throw new Error("prs.land requires method to be merge, squash, or rebase."); + } + return { prId, method }; +} + +function parseClosePrArgs(value: Record): ClosePrArgs { + return { + prId: requirePrId(value, "prs.close"), + ...(typeof value.comment === "string" ? { comment: value.comment } : {}), + }; +} + +function parseReopenPrArgs(value: Record): ReopenPrArgs { + return { + prId: requirePrId(value, "prs.reopen"), + }; +} + +function parseRequestReviewersArgs(value: Record): RequestPrReviewersArgs { + const prId = requirePrId(value, "prs.requestReviewers"); + const reviewers = asStringArray(value.reviewers); + if (reviewers.length === 0) throw new Error("prs.requestReviewers requires at least one reviewer."); + return { prId, reviewers }; +} + +function parseRerunPrChecksArgs(value: Record): RerunPrChecksArgs { + const checkRunIds = (() => { + if (value.checkRunIds == null) return undefined; + if (!Array.isArray(value.checkRunIds)) { + throw new Error("prs.rerunChecks requires checkRunIds to be an array of numbers when provided."); + } + return value.checkRunIds.map((entry) => { + if (typeof entry !== "number" || !Number.isSafeInteger(entry) || entry <= 0) { + throw new Error("prs.rerunChecks requires checkRunIds to be an array of numbers when provided."); + } + return entry; + }); + })(); + return { + prId: requirePrId(value, "prs.rerunChecks"), + ...(checkRunIds?.length ? { checkRunIds } : {}), + }; +} + +function parseAddPrCommentArgs(value: Record): AddPrCommentArgs { + return { + prId: requirePrId(value, "prs.addComment"), + body: requireString(value.body, "prs.addComment requires body."), + ...(asTrimmedString(value.inReplyToCommentId) ? { inReplyToCommentId: asTrimmedString(value.inReplyToCommentId)! } : {}), + }; +} + +function parseUpdatePrTitleArgs(value: Record): UpdatePrTitleArgs { + return { + prId: requirePrId(value, "prs.updateTitle"), + title: requireString(value.title, "prs.updateTitle requires title."), + }; +} + +function parseUpdatePrBodyArgs(value: Record): UpdatePrBodyArgs { + return { + prId: requirePrId(value, "prs.updateBody"), + body: typeof value.body === "string" ? value.body : "", + }; +} + +function parseSetPrLabelsArgs(value: Record): SetPrLabelsArgs { + return { + prId: requirePrId(value, "prs.setLabels"), + labels: asStringArray(value.labels), + }; +} + +function parseSubmitPrReviewArgs(value: Record): SubmitPrReviewArgs { + const event = asTrimmedString(value.event); + if (event !== "APPROVE" && event !== "REQUEST_CHANGES" && event !== "COMMENT") { + throw new Error("prs.submitReview requires event to be APPROVE, REQUEST_CHANGES, or COMMENT."); + } + return { + prId: requirePrId(value, "prs.submitReview"), + event, + ...(typeof value.body === "string" ? { body: value.body } : {}), + }; +} + +function parseReplyToReviewThreadArgs(value: Record): ReplyToPrReviewThreadArgs { + return { + prId: requirePrId(value, "prs.replyToReviewThread"), + threadId: requireString(value.threadId, "prs.replyToReviewThread requires threadId."), + body: requireString(value.body, "prs.replyToReviewThread requires body."), + }; +} + +function parseSetReviewThreadResolvedArgs(value: Record): SetPrReviewThreadResolvedArgs { + return { + prId: requirePrId(value, "prs.setReviewThreadResolved"), + threadId: requireString(value.threadId, "prs.setReviewThreadResolved requires threadId."), + resolved: value.resolved === true, + }; +} + +function parseReactToCommentArgs(value: Record): ReactToPrCommentArgs { + const content = asTrimmedString(value.content); + if (!content) throw new Error("prs.reactToComment requires content."); + return { + prId: requirePrId(value, "prs.reactToComment"), + commentId: requireString(value.commentId, "prs.reactToComment requires commentId."), + content: content as ReactToPrCommentArgs["content"], + }; +} + +function parseAiReviewSummaryArgs(value: Record): AiReviewSummaryArgs { + return { + prId: requirePrId(value, "prs.aiReviewSummary"), + ...(asTrimmedString(value.model) ? { model: asTrimmedString(value.model)! } : {}), + }; +} + +function parseListIntegrationWorkflowsArgs(value: Record): ListIntegrationWorkflowsArgs { + const view = asTrimmedString(value.view); + return view ? { view: view as ListIntegrationWorkflowsArgs["view"] } : {}; +} + +function parseUpdateIntegrationProposalArgs(value: Record): UpdateIntegrationProposalArgs { + return { + proposalId: requireString(value.proposalId, "prs.updateIntegrationProposal requires proposalId."), + ...(typeof value.title === "string" ? { title: value.title } : {}), + ...(typeof value.body === "string" ? { body: value.body } : {}), + ...(typeof value.draft === "boolean" ? { draft: value.draft } : {}), + ...(typeof value.integrationLaneName === "string" ? { integrationLaneName: value.integrationLaneName } : {}), + ...(typeof value.preferredIntegrationLaneId === "string" || value.preferredIntegrationLaneId === null + ? { preferredIntegrationLaneId: value.preferredIntegrationLaneId } + : {}), + ...(typeof value.mergeIntoHeadSha === "string" || value.mergeIntoHeadSha === null + ? { mergeIntoHeadSha: value.mergeIntoHeadSha } + : {}), + }; +} + +function parseDeleteIntegrationProposalArgs(value: Record): DeleteIntegrationProposalArgs { + return { + proposalId: requireString(value.proposalId, "prs.deleteIntegrationProposal requires proposalId."), + ...(typeof value.deleteIntegrationLane === "boolean" ? { deleteIntegrationLane: value.deleteIntegrationLane } : {}), + }; +} + +function parseDismissIntegrationCleanupArgs(value: Record): DismissIntegrationCleanupArgs { + return { + proposalId: requireString(value.proposalId, "prs.dismissIntegrationCleanup requires proposalId."), + }; +} + +function parseCleanupIntegrationWorkflowArgs(value: Record): CleanupIntegrationWorkflowArgs { + const rawLaneIds = Array.isArray(value.archiveSourceLaneIds) ? value.archiveSourceLaneIds : []; + const archiveSourceLaneIds = rawLaneIds + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter((entry) => entry.length > 0); + return { + proposalId: requireString(value.proposalId, "prs.cleanupIntegrationWorkflow requires proposalId."), + ...(typeof value.archiveIntegrationLane === "boolean" ? { archiveIntegrationLane: value.archiveIntegrationLane } : {}), + ...(archiveSourceLaneIds.length > 0 ? { archiveSourceLaneIds } : {}), + }; +} + +function parseCreateIntegrationLaneForProposalArgs(value: Record): CreateIntegrationLaneForProposalArgs { + return { + proposalId: requireString(value.proposalId, "prs.createIntegrationLaneForProposal requires proposalId."), + }; +} + +function parseStartIntegrationResolutionArgs(value: Record): StartIntegrationResolutionArgs { + return { + proposalId: requireString(value.proposalId, "prs.startIntegrationResolution requires proposalId."), + laneId: requireString(value.laneId, "prs.startIntegrationResolution requires laneId."), + }; +} + +function parseRecheckIntegrationStepArgs(value: Record): RecheckIntegrationStepArgs { + return { + proposalId: requireString(value.proposalId, "prs.recheckIntegrationStep requires proposalId."), + laneId: requireString(value.laneId, "prs.recheckIntegrationStep requires laneId."), + }; +} + +function parseLandQueueNextArgs(value: Record): LandQueueNextArgs { + const method = asTrimmedString(value.method) as LandQueueNextArgs["method"]; + if (!method || !["merge", "squash", "rebase"].includes(method)) { + throw new Error("prs.landQueueNext requires method to be merge, squash, or rebase."); + } + return { + groupId: requireString(value.groupId, "prs.landQueueNext requires groupId."), + method, + ...(typeof value.archiveLane === "boolean" ? { archiveLane: value.archiveLane } : {}), + ...(typeof value.autoResolve === "boolean" ? { autoResolve: value.autoResolve } : {}), + ...(asOptionalNumber(value.confidenceThreshold) != null ? { confidenceThreshold: asOptionalNumber(value.confidenceThreshold)! } : {}), + }; +} + +function parseReorderQueuePrsArgs(value: Record): ReorderQueuePrsArgs { + return { + groupId: requireString(value.groupId, "prs.reorderQueue requires groupId."), + prIds: requireStringArray(value.prIds, "prs.reorderQueue requires prIds."), + }; +} + +function parsePauseQueueAutomationArgs(value: Record): PauseQueueAutomationArgs { + return { + queueId: requireString(value.queueId, "prs.pauseQueueAutomation requires queueId."), + }; +} + +function parseResumeQueueAutomationArgs(value: Record): ResumeQueueAutomationArgs { + const method = asTrimmedString(value.method); + if (method && !["merge", "squash", "rebase"].includes(method)) { + throw new Error("prs.resumeQueueAutomation requires method to be merge, squash, or rebase when provided."); + } + return { + queueId: requireString(value.queueId, "prs.resumeQueueAutomation requires queueId."), + ...(method ? { method: method as ResumeQueueAutomationArgs["method"] } : {}), + ...(typeof value.archiveLane === "boolean" ? { archiveLane: value.archiveLane } : {}), + ...(typeof value.autoResolve === "boolean" ? { autoResolve: value.autoResolve } : {}), + ...(typeof value.ciGating === "boolean" ? { ciGating: value.ciGating } : {}), + ...(asOptionalNumber(value.confidenceThreshold) != null ? { confidenceThreshold: asOptionalNumber(value.confidenceThreshold)! } : {}), + ...(asTrimmedString(value.originLabel) ? { originLabel: asTrimmedString(value.originLabel)! } : {}), + }; +} + +function parseCancelQueueAutomationArgs(value: Record): CancelQueueAutomationArgs { + return { + queueId: requireString(value.queueId, "prs.cancelQueueAutomation requires queueId."), + }; +} + +function parseIssueInventoryPrArgs(value: Record, action: string): { prId: string } { + return { + prId: requirePrId(value, action), + }; +} + +function parseIssueInventoryItemsArgs(value: Record, action: string): { prId: string; itemIds: string[] } { + return { + prId: requirePrId(value, action), + itemIds: requireStringArray(value.itemIds, `${action} requires itemIds.`), + }; +} + +function parseIssueInventoryDismissArgs(value: Record): { prId: string; itemIds: string[]; reason: string } { + return { + ...parseIssueInventoryItemsArgs(value, "prs.issueInventory.markDismissed"), + reason: typeof value.reason === "string" ? value.reason : "", + }; +} + +function parsePipelineSettingsPatch(value: Record): { prId: string; settings: Partial } { + const settings = isRecord(value.settings) ? value.settings : value; + const patch: Partial = {}; + if (typeof settings.autoMerge === "boolean") patch.autoMerge = settings.autoMerge; + const mergeMethod = asTrimmedString(settings.mergeMethod); + if (mergeMethod && ["merge", "squash", "rebase", "repo_default"].includes(mergeMethod)) { + patch.mergeMethod = mergeMethod as PipelineSettings["mergeMethod"]; + } + const maxRounds = asOptionalNumber(settings.maxRounds); + if (maxRounds != null && maxRounds >= 1) patch.maxRounds = Math.floor(maxRounds); + const onRebaseNeeded = asTrimmedString(settings.onRebaseNeeded); + if (onRebaseNeeded === "pause" || onRebaseNeeded === "auto_rebase") { + patch.onRebaseNeeded = onRebaseNeeded; + } + const conflictStrategy = asTrimmedString(settings.conflictStrategy); + if (conflictStrategy && ["pause", "rebase", "merge", "auto"].includes(conflictStrategy)) { + patch.conflictStrategy = conflictStrategy as PipelineSettings["conflictStrategy"]; + } + const forceFinalizeMode = asTrimmedString(settings.forceFinalizeMode); + if (forceFinalizeMode && ["off", "conditional", "unconditional"].includes(forceFinalizeMode)) { + patch.forceFinalizeMode = forceFinalizeMode as PipelineSettings["forceFinalizeMode"]; + } + if (typeof settings.forceFinalizeRequireNoCiFailures === "boolean") { + patch.forceFinalizeRequireNoCiFailures = settings.forceFinalizeRequireNoCiFailures; + } + if (typeof settings.earlyMergeOnGreen === "boolean") { + patch.earlyMergeOnGreen = settings.earlyMergeOnGreen; + } + const atCapPolicy = asTrimmedString(settings.atCapPolicy); + if (atCapPolicy && ["stop", "wait_for_ci", "ci_retry_once", "ci_retry_loop", "force_merge"].includes(atCapPolicy)) { + patch.atCapPolicy = atCapPolicy as PipelineSettings["atCapPolicy"]; + } + const atCapWaitMinutes = asOptionalNumber(settings.atCapWaitMinutes); + if (atCapWaitMinutes != null && atCapWaitMinutes >= 1) patch.atCapWaitMinutes = Math.floor(atCapWaitMinutes); + const atCapCiRetryMax = asOptionalNumber(settings.atCapCiRetryMax); + if (atCapCiRetryMax != null && atCapCiRetryMax >= 1) patch.atCapCiRetryMax = Math.floor(atCapCiRetryMax); + if (typeof settings.forceMergeRequiresConfirmation === "boolean") { + patch.forceMergeRequiresConfirmation = settings.forceMergeRequiresConfirmation; + } + if (isRecord(settings.autoAgentSettings)) { + const autoAgentSettings: Partial = {}; + const provider = settings.autoAgentSettings.provider; + if (provider === null || provider === "claude" || provider === "codex") autoAgentSettings.provider = provider; + for (const key of ["model", "reasoningEffort"] as const) { + const value = settings.autoAgentSettings[key]; + if (value === null || typeof value === "string") autoAgentSettings[key] = value; + } + const permissionMode = settings.autoAgentSettings.permissionMode; + if ( + permissionMode === null || + permissionMode === "read_only" || + permissionMode === "guarded_edit" || + permissionMode === "full_edit" || + permissionMode === "default" || + permissionMode === "plan" || + permissionMode === "edit" || + permissionMode === "full-auto" || + permissionMode === "config-toml" + ) { + autoAgentSettings.permissionMode = permissionMode; + } + const confidenceThreshold = asOptionalNumber(settings.autoAgentSettings.confidenceThreshold); + if (settings.autoAgentSettings.confidenceThreshold === null || (confidenceThreshold != null && confidenceThreshold >= 0 && confidenceThreshold <= 1)) { + autoAgentSettings.confidenceThreshold = settings.autoAgentSettings.confidenceThreshold === null ? null : confidenceThreshold; + } + if (Object.keys(autoAgentSettings).length > 0) patch.autoAgentSettings = autoAgentSettings as PipelineSettings["autoAgentSettings"]; + } + return { + prId: requirePrId(value, "prs.pipelineSettings.save"), + settings: patch, + }; +} + +function parseConvergenceStatePatch(value: Record): { prId: string; state: PrConvergenceStatePatch } { + const raw = isRecord(value.state) ? value.state : value; + const patch: PrConvergenceStatePatch = {}; + const statuses = new Set(["idle", "launching", "running", "polling", "paused", "converged", "merged", "failed", "cancelled", "stopped"]); + const pollerStatuses = new Set(["idle", "scheduled", "polling", "waiting_for_checks", "waiting_for_comments", "paused", "stopped"]); + if (typeof raw.autoConvergeEnabled === "boolean") patch.autoConvergeEnabled = raw.autoConvergeEnabled; + const status = asTrimmedString(raw.status); + if (status && statuses.has(status)) patch.status = status as ConvergenceRuntimeState["status"]; + const pollerStatus = asTrimmedString(raw.pollerStatus); + if (pollerStatus && pollerStatuses.has(pollerStatus)) patch.pollerStatus = pollerStatus as ConvergenceRuntimeState["pollerStatus"]; + const currentRound = asOptionalNumber(raw.currentRound); + if (currentRound != null && currentRound >= 0) patch.currentRound = Math.floor(currentRound); + if (typeof raw.forceFinalizeUsed === "boolean") patch.forceFinalizeUsed = raw.forceFinalizeUsed; + const ciRetryAttemptsUsed = asOptionalNumber(raw.ciRetryAttemptsUsed); + if (ciRetryAttemptsUsed != null && ciRetryAttemptsUsed >= 0) patch.ciRetryAttemptsUsed = Math.floor(ciRetryAttemptsUsed); + const pauseRepeatCount = asOptionalNumber(raw.pauseRepeatCount); + if (pauseRepeatCount != null && pauseRepeatCount >= 0) patch.pauseRepeatCount = Math.floor(pauseRepeatCount); + for (const key of [ + "activeSessionId", + "activeLaneId", + "activeHref", + "pauseReason", + "errorMessage", + "waitForCiStartedAt", + "lastDispatchHeadSha", + "lastPauseReasonHash", + "lastStartedAt", + "lastPolledAt", + "lastPausedAt", + "lastStoppedAt", + ] as const) { + const next = raw[key]; + if (next === null || typeof next === "string") { + (patch as Record)[key] = next; + } + } + return { + prId: requirePrId(value, "prs.convergenceState.save"), + state: patch, + }; +} + +function mergeLaneDockerConfig( + current: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, + next: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, +) { + if (!current && !next) return undefined; + if (!current) return next ? { ...next, ...(next.services ? { services: [...next.services] } : {}) } : undefined; + if (!next) return { ...current, ...(current.services ? { services: [...current.services] } : {}) }; + return { + ...current, + ...next, + ...(next.services != null + ? { services: [...next.services] } + : current.services != null + ? { services: [...current.services] } + : {}), + }; +} + +function mergeLaneEnvInitConfig( + current: LaneEnvInitConfig | undefined, + next: LaneEnvInitConfig | undefined, +): LaneEnvInitConfig | undefined { + if (!current && !next) return undefined; + if (!current) { + return next + ? { + ...(next.envFiles ? { envFiles: [...next.envFiles] } : {}), + ...(mergeLaneDockerConfig(undefined, next.docker) ? { docker: mergeLaneDockerConfig(undefined, next.docker) } : {}), + ...(next.dependencies ? { dependencies: [...next.dependencies] } : {}), + ...(next.mountPoints ? { mountPoints: [...next.mountPoints] } : {}), + ...(next.copyPaths ? { copyPaths: [...next.copyPaths] } : {}), + } + : undefined; + } + if (!next) { + return { + ...(current.envFiles ? { envFiles: [...current.envFiles] } : {}), + ...(mergeLaneDockerConfig(undefined, current.docker) ? { docker: mergeLaneDockerConfig(undefined, current.docker) } : {}), + ...(current.dependencies ? { dependencies: [...current.dependencies] } : {}), + ...(current.mountPoints ? { mountPoints: [...current.mountPoints] } : {}), + ...(current.copyPaths ? { copyPaths: [...current.copyPaths] } : {}), + }; + } + return { + envFiles: [...(current.envFiles ?? []), ...(next.envFiles ?? [])], + ...(mergeLaneDockerConfig(current.docker, next.docker) ? { docker: mergeLaneDockerConfig(current.docker, next.docker) } : {}), + dependencies: [...(current.dependencies ?? []), ...(next.dependencies ?? [])], + mountPoints: [...(current.mountPoints ?? []), ...(next.mountPoints ?? [])], + copyPaths: [...(current.copyPaths ?? []), ...(next.copyPaths ?? [])], + }; +} + +function mergeLaneOverrides(base: LaneOverlayOverrides, next: Partial): LaneOverlayOverrides { + return { + ...base, + ...next, + ...(base.env || next.env ? { env: { ...(base.env ?? {}), ...(next.env ?? {}) } } : {}), + ...(base.processIds || next.processIds ? { processIds: [...(next.processIds ?? base.processIds ?? [])] } : {}), + ...(base.testSuiteIds || next.testSuiteIds ? { testSuiteIds: [...(next.testSuiteIds ?? base.testSuiteIds ?? [])] } : {}), + ...(mergeLaneEnvInitConfig(base.envInit, next.envInit) ? { envInit: mergeLaneEnvInitConfig(base.envInit, next.envInit) } : {}), + }; +} + +function applyLeaseToOverrides( + overrides: LaneOverlayOverrides, + lease: { status: string; rangeStart: number; rangeEnd: number } | null, +): LaneOverlayOverrides { + if (!lease || lease.status !== "active" || overrides.portRange) { + return { ...overrides }; + } + return { + ...overrides, + portRange: { start: lease.rangeStart, end: lease.rangeEnd }, + }; +} + +/** + * Strict resolver for identity-pinned sessions (CTO + worker agents). Never + * slips a foreign lane through via a `lanes[0]` fallback — if no primary lane + * exists, the caller must error out rather than silently host the identity on + * a non-primary lane. + */ +async function resolvePrimaryLaneIdOnlyForSync(args: SyncRemoteCommandServiceArgs): Promise { + await args.laneService.ensurePrimaryLane?.().catch(() => {}); + const lanes = await args.laneService.list({ includeArchived: false, includeStatus: false }); + return lanes.find((lane) => lane.laneType === "primary")?.id ?? ""; +} + +async function resolveLaneOverlayContext(args: SyncRemoteCommandServiceArgs, laneId: string) { + const projectConfigService = requireService(args.projectConfigService, "Project config service not available."); + const lanes = await args.laneService.list({ includeStatus: false }); + const lane = lanes.find((entry) => entry.id === laneId); + if (!lane) throw new Error(`Lane not found: ${laneId}`); + + const config = projectConfigService.getEffective(); + const overlayOverrides = matchLaneOverlayPolicies(lane, config.laneOverlayPolicies ?? []); + const lease = args.portAllocationService?.getLease(lane.id) ?? null; + const overrides = applyLeaseToOverrides(overlayOverrides, lease); + const envInitConfig = args.laneEnvironmentService?.resolveEnvInitConfig(config.laneEnvInit, overrides); + + return { + lane, + overrides, + envInitConfig, + }; +} + +async function resolveChatCreateArgs( + service: ReturnType, + payload: AgentChatCreateArgs, +): Promise { + if (payload.model.trim().length > 0) return payload; + const available = await service.getAvailableModels({ + provider: payload.provider, + ...(payload.provider === "opencode" ? { activateRuntime: true } : {}), + }); + const chosen = available[0]; + if (!chosen) { + throw new Error(`No configured ${payload.provider} chat model is available on the host.`); + } + return { + ...payload, + model: chosen.id, + ...(!payload.modelId && chosen.modelId ? { modelId: chosen.modelId } : {}), + }; +} + +function sessionStatusBucket(argsIn: { + status: string; + lastOutputPreview: string | null | undefined; + runtimeState?: string | null; +}): "running" | "awaiting-input" | "ended" { + if (argsIn.status === "running") { + if (argsIn.runtimeState === "waiting-input") return "awaiting-input"; + const preview = argsIn.lastOutputPreview ?? ""; + if (/\b(?:waiting|awaiting)\b.{0,28}\b(?:input|confirmation|response|prompt)\b/i.test(preview)) { + return "awaiting-input"; + } + if (/\((?:y\/n|yes\/no)\)/i.test(preview) || /\[(?:y\/n|yes\/no)\]/i.test(preview)) { + return "awaiting-input"; + } + return "running"; + } + return "ended"; +} + +function summarizeLaneRuntime( + laneId: string, + sessions: Array<{ + laneId: string; + status: string; + lastOutputPreview: string | null; + runtimeState?: string | null; + }>, +): LaneListSnapshot["runtime"] { + let runningCount = 0; + let awaitingInputCount = 0; + let endedCount = 0; + let sessionCount = 0; + for (const session of sessions) { + if (session.laneId !== laneId) continue; + sessionCount += 1; + const bucket = sessionStatusBucket(session); + if (bucket === "running") runningCount += 1; + else if (bucket === "awaiting-input") awaitingInputCount += 1; + else endedCount += 1; + } + const bucket = runningCount > 0 + ? "running" + : awaitingInputCount > 0 + ? "awaiting-input" + : endedCount > 0 + ? "ended" + : "none"; + return { + bucket, + runningCount, + awaitingInputCount, + endedCount, + sessionCount, + }; +} + +async function buildLaneListSnapshots( + args: SyncRemoteCommandServiceArgs, + lanes: Awaited["list"]>>, +): Promise { + const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ + Promise.resolve(args.sessionService.list({ limit: 500 })), + Promise.resolve(args.rebaseSuggestionService?.listSuggestions() ?? []), + Promise.resolve(args.autoRebaseService?.listStatuses() ?? []), + Promise.resolve(args.laneService.listStateSnapshots()), + args.conflictService?.getBatchAssessment({ lanes }).catch(() => null) ?? Promise.resolve(null), + ]); + + const rebaseByLaneId = new Map(rebaseSuggestions.map((entry) => [entry.laneId, entry] as const)); + const autoRebaseByLaneId = new Map(autoRebaseStatuses.map((entry) => [entry.laneId, entry] as const)); + const stateByLaneId = new Map(stateSnapshots.map((entry) => [entry.laneId, entry] as const)); + const conflictByLaneId = new Map((batchAssessment?.lanes ?? []).map((entry) => [entry.laneId, entry] as const)); + + return lanes.map((lane) => ({ + lane, + runtime: summarizeLaneRuntime(lane.id, sessions), + rebaseSuggestion: rebaseByLaneId.get(lane.id) ?? null, + autoRebaseStatus: autoRebaseByLaneId.get(lane.id) ?? null, + conflictStatus: conflictByLaneId.get(lane.id) ?? null, + stateSnapshot: stateByLaneId.get(lane.id) ?? null, + adoptableAttached: lane.laneType === "attached" && lane.archivedAt == null, + })); +} + +async function buildLaneDetailPayload(args: SyncRemoteCommandServiceArgs, laneId: string): Promise { + const lane = (await args.laneService.list({ includeArchived: true, includeStatus: true })).find((entry) => entry.id === laneId) ?? null; + if (!lane) throw new Error(`Lane not found: ${laneId}`); + + const [ + stackChain, + children, + sessions, + chatSessions, + rebaseSuggestions, + autoRebaseStatuses, + stateSnapshot, + recentCommits, + diffChanges, + stashes, + syncStatus, + conflictState, + conflictStatus, + overlaps, + envInitProgress, + ] = await Promise.all([ + args.laneService.getStackChain(laneId), + args.laneService.getChildren(laneId), + Promise.resolve(args.sessionService.list({ laneId, limit: 200 })), + args.agentChatService?.listSessions(laneId, { includeAutomation: true }) ?? Promise.resolve([]), + Promise.resolve(args.rebaseSuggestionService?.listSuggestions() ?? []), + Promise.resolve(args.autoRebaseService?.listStatuses() ?? []), + Promise.resolve(args.laneService.getStateSnapshot(laneId)), + args.gitService?.listRecentCommits({ laneId, limit: 20 }) ?? Promise.resolve([]), + args.diffService?.getChanges(laneId).catch(() => null) ?? Promise.resolve(null), + args.gitService?.listStashes({ laneId }) ?? Promise.resolve([]), + args.gitService?.getSyncStatus({ laneId }).catch(() => null) ?? Promise.resolve(null), + args.gitService?.getConflictState({ laneId }).catch(() => null) ?? Promise.resolve(null), + args.conflictService?.getLaneStatus({ laneId }).catch(() => null) ?? Promise.resolve(null), + args.conflictService?.listOverlaps({ laneId }).catch(() => []) ?? Promise.resolve([]), + Promise.resolve(args.laneEnvironmentService?.getProgress(laneId) ?? null), + ]); + + return { + lane, + runtime: summarizeLaneRuntime(laneId, sessions), + stackChain, + children, + stateSnapshot: stateSnapshot as LaneStateSnapshotSummary | null, + rebaseSuggestion: rebaseSuggestions.find((entry) => entry.laneId === laneId) ?? null, + autoRebaseStatus: autoRebaseStatuses.find((entry) => entry.laneId === laneId) ?? null, + conflictStatus, + overlaps, + syncStatus, + conflictState, + recentCommits, + diffChanges, + stashes, + envInitProgress, + sessions, + chatSessions, + }; +} + +export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArgs) { + const registry = new Map(); + + const register = ( + action: SyncRemoteCommandAction, + policy: SyncRemoteCommandPolicy, + handler: (payload: Record) => Promise, + scope: SyncRemoteCommandDescriptor["scope"] = "project", + ) => { + registry.set(action, { + descriptor: { action, scope, policy }, + handler, + }); + }; + + register("lanes.list", { viewerAllowed: true }, async (payload) => args.laneService.list(parseListLanesArgs(payload))); + register("lanes.refreshSnapshots", { viewerAllowed: true }, async (payload) => { + const refreshed = await args.laneService.refreshSnapshots(parseListLanesArgs(payload)); + return { + ...refreshed, + snapshots: await buildLaneListSnapshots(args, refreshed.lanes), + }; + }); + register("lanes.getDetail", { viewerAllowed: true }, async (payload) => + buildLaneDetailPayload(args, requireString(payload.laneId, "lanes.getDetail requires laneId."))); + register("lanes.create", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.create(parseCreateLaneArgs(payload))); + register("lanes.createChild", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.createChild(parseCreateChildLaneArgs(payload))); + register("lanes.createFromUnstaged", { viewerAllowed: true, queueable: true }, async (payload) => + args.laneService.createFromUnstaged(parseCreateLaneFromUnstagedArgs(payload))); + register("lanes.importBranch", { viewerAllowed: true, queueable: true }, async (payload) => + args.laneService.importBranch(parseImportBranchArgs(payload))); + register("lanes.previewBranchSwitch", { viewerAllowed: true }, async (payload) => + args.laneService.previewBranchSwitch(parseGitCheckoutBranchArgs(payload))); + register("lanes.attach", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.attach(parseAttachLaneArgs(payload))); + register("lanes.adoptAttached", { viewerAllowed: true, queueable: true }, async (payload) => + args.laneService.adoptAttached({ laneId: requireString(payload.laneId, "lanes.adoptAttached requires laneId.") })); + register("lanes.rename", { viewerAllowed: true, queueable: true }, async (payload) => { + args.laneService.rename(parseRenameLaneArgs(payload)); + return { ok: true }; + }); + register("lanes.reparent", { viewerAllowed: true, queueable: true }, async (payload) => + args.laneService.reparent(parseReparentLaneArgs(payload))); + register("lanes.updateAppearance", { viewerAllowed: true, queueable: true }, async (payload) => { + args.laneService.updateAppearance(parseUpdateLaneAppearanceArgs(payload)); + return { ok: true }; + }); + register("lanes.archive", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.laneService.archive(parseArchiveLaneArgs(payload, "lanes.archive")); + return { ok: true }; + }); + register("lanes.unarchive", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.laneService.unarchive(parseArchiveLaneArgs(payload, "lanes.unarchive")); + return { ok: true }; + }); + register("lanes.delete", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.laneService.delete(parseDeleteLaneArgs(payload)); + return { ok: true }; + }); + register("lanes.getStackChain", { viewerAllowed: true }, async (payload) => + args.laneService.getStackChain(requireString(payload.laneId, "lanes.getStackChain requires laneId."))); + register("lanes.getChildren", { viewerAllowed: true }, async (payload) => + args.laneService.getChildren(requireString(payload.laneId, "lanes.getChildren requires laneId."))); + register("lanes.rebaseStart", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseStart(parseRebaseStartArgs(payload))); + register("lanes.rebasePush", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebasePush(parseRebasePushArgs(payload))); + register("lanes.rebaseRollback", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseRollback(parseRunIdArgs(payload, "lanes.rebaseRollback"))); + register("lanes.rebaseAbort", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseAbort(parseRunIdArgs(payload, "lanes.rebaseAbort"))); + register("lanes.listRebaseSuggestions", { viewerAllowed: true }, async () => args.rebaseSuggestionService?.listSuggestions() ?? []); + register("lanes.dismissRebaseSuggestion", { viewerAllowed: true, queueable: true }, async (payload) => { + const laneId = requireString(payload.laneId, "lanes.dismissRebaseSuggestion requires laneId."); + args.conflictService?.dismissRebase(laneId); + if (args.rebaseSuggestionService) { + await args.rebaseSuggestionService.dismiss({ laneId }); + } + return { ok: true }; + }); + register("lanes.deferRebaseSuggestion", { viewerAllowed: true, queueable: true }, async (payload) => { + const laneId = requireString(payload.laneId, "lanes.deferRebaseSuggestion requires laneId."); + const minutes = Math.max(5, Math.min(7 * 24 * 60, Math.floor(asOptionalNumber(payload.minutes) ?? 60))); + const until = new Date(Date.now() + minutes * 60_000).toISOString(); + args.conflictService?.deferRebase(laneId, until); + if (args.rebaseSuggestionService) { + await args.rebaseSuggestionService.defer({ + laneId, + minutes, + }); + } + return { ok: true }; + }); + register("lanes.listAutoRebaseStatuses", { viewerAllowed: true }, async () => args.autoRebaseService?.listStatuses() ?? []); + register("lanes.dismissAutoRebaseStatus", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.autoRebaseService) return { ok: true }; + await args.autoRebaseService.dismissStatus({ + laneId: requireString(payload.laneId, "lanes.dismissAutoRebaseStatus requires laneId."), + }); + return { ok: true }; + }); + register("lanes.listTemplates", { viewerAllowed: true }, async () => args.laneTemplateService?.listTemplates() ?? []); + register("lanes.getDefaultTemplate", { viewerAllowed: true }, async () => args.laneTemplateService?.getDefaultTemplateId() ?? null); + register("lanes.getEnvStatus", { viewerAllowed: true }, async (payload) => args.laneEnvironmentService?.getProgress(requireString(payload.laneId, "lanes.getEnvStatus requires laneId.")) ?? null); + register("lanes.initEnv", { viewerAllowed: true, queueable: true }, async (payload) => { + const laneEnvironmentService = requireService(args.laneEnvironmentService, "Lane environment service not available."); + const laneId = requireString(payload.laneId, "lanes.initEnv requires laneId."); + const context = await resolveLaneOverlayContext(args, laneId); + if (!context.envInitConfig) { + const now = new Date().toISOString(); + return { + laneId, + steps: [], + startedAt: now, + completedAt: now, + overallStatus: "completed", + } satisfies LaneEnvInitProgress; + } + return await laneEnvironmentService.initLaneEnvironment(context.lane, context.envInitConfig, context.overrides); + }); + register("lanes.applyTemplate", { viewerAllowed: true, queueable: true }, async (payload) => { + const laneTemplateService = requireService(args.laneTemplateService, "Lane template service not available."); + const laneEnvironmentService = requireService(args.laneEnvironmentService, "Lane environment service not available."); + const parsed = { + laneId: requireString(payload.laneId, "lanes.applyTemplate requires laneId."), + templateId: requireString(payload.templateId, "lanes.applyTemplate requires templateId."), + } satisfies ApplyLaneTemplateArgs; + const context = await resolveLaneOverlayContext(args, parsed.laneId); + const template = laneTemplateService.getTemplate(parsed.templateId); + if (!template) throw new Error(`Template not found: ${parsed.templateId}`); + const templateEnvInit = laneTemplateService.resolveTemplateAsEnvInit(template); + const mergedOverrides = mergeLaneOverrides(context.overrides, { + ...(template.envVars ? { env: template.envVars } : {}), + ...(!context.overrides.portRange && template.portRange ? { portRange: template.portRange } : {}), + envInit: templateEnvInit, + }); + const mergedEnvInitConfig = mergeLaneEnvInitConfig(context.envInitConfig, templateEnvInit) ?? templateEnvInit; + return await laneEnvironmentService.initLaneEnvironment(context.lane, mergedEnvInitConfig, mergedOverrides); + }); + + register("work.listSessions", { viewerAllowed: true }, async (payload) => listRemoteWorkSessions(args, parseListSessionsArgs(payload))); + register("work.updateSessionMeta", { viewerAllowed: true, queueable: true }, async (payload) => { + args.sessionService.updateMeta(parseUpdateSessionMetaArgs(payload)); + return { ok: true }; + }); + register("work.runQuickCommand", { viewerAllowed: true, queueable: true }, async (payload) => { + const parsed = parseQuickCommandArgs(payload); + return await args.ptyService.create({ + laneId: parsed.laneId, + title: parsed.title, + ...(parsed.toolType === "shell" || !parsed.startupCommand ? {} : { startupCommand: parsed.startupCommand }), + tracked: parsed.tracked ?? true, + cols: parsed.cols ?? 120, + rows: parsed.rows ?? 36, + toolType: (parsed.toolType ?? "run-shell") as TerminalToolType, + }); + }); + register("work.startCliSession", { viewerAllowed: true, queueable: true }, async (payload) => { + const parsed = parseStartCliSessionArgs(payload); + const cols = clampCliDimension(parsed.cols, DEFAULT_CLI_COLS, 20, 240); + const rows = clampCliDimension(parsed.rows, DEFAULT_CLI_ROWS, 4, 120); + const resumeSessionId = parsed.resumeSessionId?.trim() || undefined; + const { provider } = parsed; + const permissionMode = parsed.permissionMode ?? "default"; + validateLaunchProfilePermissionMode(provider, permissionMode); + const resumeSession = resumeSessionId + ? requireResumeSessionForProvider(args.sessionService, resumeSessionId, provider) + : null; + const toolType = LAUNCH_PROFILE_TOOL_TYPE[provider] as TerminalToolType; + const title = parsed.title?.trim() || LAUNCH_PROFILE_TITLE[provider]; + const preassignedSessionId = provider === "claude" && !resumeSessionId ? randomUUID() : undefined; + + function resolveLaunch(): { startupCommand?: string; command?: string; args?: string[]; env?: Record } { + if (provider === "shell") return {}; + if (resumeSessionId) { + if (!resumeSession) throw new Error(`work.startCliSession resumeSessionId '${resumeSessionId}' was not found.`); + const startupCommand = resolveTrackedCliResumeCommand(resumeSession) + ?? buildTrackedCliResumeCommand({ + provider, + targetKind: "session", + targetId: null, + launch: { permissionMode }, + }); + return { startupCommand }; + } + return buildTrackedCliLaunchCommand({ provider, permissionMode, sessionId: preassignedSessionId }); + } + + const sessionId = resumeSessionId ?? preassignedSessionId; + const result = await args.ptyService.create({ + ...(sessionId ? { sessionId } : {}), + allowNewSessionId: Boolean(preassignedSessionId), + laneId: parsed.laneId, + title, + tracked: true, + toolType, + cols, + rows, + ...resolveLaunch(), + }); + + if (parsed.initialInput && provider !== "shell") { + const written = args.ptyService.writeBySessionId(result.sessionId, `${parsed.initialInput}\r`); + if (!written) { + try { + args.ptyService.dispose({ ptyId: result.ptyId, sessionId: result.sessionId }); + } catch (err) { + args.logger.warn("sync_remote.start_cli_session_initial_input_cleanup_failed", { + sessionId: result.sessionId, + err: String(err), + }); + } + throw new Error("work.startCliSession created a terminal session but could not write initialInput."); + } + } + + const session = args.sessionService.get(result.sessionId); + const enriched = session ? args.ptyService.enrichSessions([session])[0] ?? session : null; + return { + sessionId: result.sessionId, + ptyId: result.ptyId, + session: enriched, + } satisfies SyncStartCliSessionResult; + }); + register("work.closeSession", { viewerAllowed: true, queueable: true }, async (payload) => { + const { sessionId } = parseCloseSessionArgs(payload); + const session = args.sessionService.get(sessionId); + if (session?.ptyId) { + await args.ptyService.dispose({ ptyId: session.ptyId, sessionId }); + } + return { ok: true }; + }); + + register("processes.listDefinitions", { viewerAllowed: true }, async () => + requireService(args.processService, "Process service not available.").listDefinitions()); + register("processes.listRuntime", { viewerAllowed: true }, async (payload) => + requireService(args.processService, "Process service not available.").listRuntime( + parseProcessLaneArgs(payload, "processes.listRuntime").laneId, + )); + register("processes.start", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.processService, "Process service not available.").start( + parseProcessActionArgs(payload, "processes.start"), + )); + register("processes.stop", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.processService, "Process service not available.").stop( + parseProcessActionArgs(payload, "processes.stop"), + )); + register("processes.kill", { viewerAllowed: true, queueable: false }, async (payload) => + requireService(args.processService, "Process service not available.").kill( + parseProcessActionArgs(payload, "processes.kill"), + )); + + register("chat.listSessions", { viewerAllowed: true }, async (payload) => { + const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); + const parsed = parseAgentChatListArgs(payload); + return agentChatService.listSessions(parsed.laneId, { includeAutomation: parsed.includeAutomation }); + }); + register("chat.getSummary", { viewerAllowed: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").getSessionSummary(parseAgentChatGetSummaryArgs(payload).sessionId)); + register("chat.getTranscript", { viewerAllowed: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").getChatTranscript(parseGetTranscriptArgs(payload))); + register("chat.create", { viewerAllowed: true, queueable: true }, async (payload) => { + const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); + const parsed = parseAgentChatCreateArgs(payload); + const session = await agentChatService.createSession(await resolveChatCreateArgs(agentChatService, parsed)); + return summarizeChatSessionForRemote(agentChatService, session); + }); + register("chat.send", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").sendMessage( + parseAgentChatSendArgs(payload), + { awaitDispatch: true }, + ); + return { ok: true }; + }); + register("chat.interrupt", { viewerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").interrupt(parseAgentChatInterruptArgs(payload)); + return { ok: true }; + }); + register("chat.steer", { viewerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").steer(parseAgentChatSteerArgs(payload)); + return { ok: true }; + }); + register("chat.cancelSteer", { viewerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").cancelSteer(parseAgentChatCancelSteerArgs(payload)); + return { ok: true }; + }); + register("chat.editSteer", { viewerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").editSteer(parseAgentChatEditSteerArgs(payload)); + return { ok: true }; + }); + register("chat.dispatchSteer", { viewerAllowed: true, queueable: false }, async (payload) => { + const result = await requireService(args.agentChatService, "Agent chat service not available.").dispatchSteer(parseAgentChatDispatchSteerArgs(payload)); + return { ok: true, dispatchedAt: result.dispatchedAt }; + }); + register("chat.cancelDispatchedSteer", { viewerAllowed: true, queueable: false }, async (payload) => { + const result = await requireService(args.agentChatService, "Agent chat service not available.").cancelDispatchedSteer(parseAgentChatCancelDispatchedSteerArgs(payload)); + return { ok: true, cancelled: result.cancelled }; + }); + register("chat.approve", { viewerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").approveToolUse(parseAgentChatApproveArgs(payload)); + return { ok: true }; + }); + register("chat.respondToInput", { viewerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").respondToInput(parseAgentChatRespondToInputArgs(payload)); + return { ok: true }; + }); + register("chat.resume", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").resumeSession(parseAgentChatResumeArgs(payload))); + // Restart: fired by iOS Live Activity + Attention Drawer "Restart" pill on + // a failed agent. Alias to resumeSession — same runtime-rewire behaviour. + // Keep as a distinct action name so telemetry can distinguish explicit + // restart intent from ordinary resume. + register("chat.restart", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").resumeSession(parseAgentChatResumeArgs(payload))); + register("chat.updateSession", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").updateSession(parseAgentChatUpdateSessionArgs(payload))); + register("chat.dispose", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").dispose(parseAgentChatDisposeArgs(payload)); + return { ok: true }; + }); + register("chat.archive", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").archiveSession(parseAgentChatArchiveArgs(payload, "chat.archive")); + return { ok: true }; + }); + register("chat.unarchive", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").unarchiveSession(parseAgentChatArchiveArgs(payload, "chat.unarchive")); + return { ok: true }; + }); + register("chat.delete", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").deleteSession(parseAgentChatArchiveArgs(payload, "chat.delete")); + return { ok: true }; + }); + register("chat.models", { viewerAllowed: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").getAvailableModels(parseChatModelsArgs(payload))); + register("chat.modelCatalog", { viewerAllowed: true }, async () => + requireService(args.agentChatService, "Agent chat service not available.").getModelCatalog()); + + register("cto.getRoster", { viewerAllowed: true }, async () => { + const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); + const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); + const sessions = await agentChatService.listSessions(undefined, { includeIdentity: true }); + const activityTimestamp = (value: string | null | undefined): number => { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; + }; + const sortedByRecency = [...sessions].sort( + (a, b) => activityTimestamp(b.lastActivityAt) - activityTimestamp(a.lastActivityAt), + ); + const ctoSummary = sortedByRecency.find((entry) => entry.identityKey === "cto") ?? null; + const agents = workerAgentService.listAgents(); + const knownAgentIds = new Set(agents.map((agent) => agent.id)); + const liveWorkers = agents.map((agent) => { + const sessionSummary = sortedByRecency.find( + (entry) => entry.identityKey === `agent:${agent.id}`, + ) ?? null; + return { + agentId: agent.id, + name: agent.name, + avatarSeed: agent.slug || null, + status: agent.status as string, + sessionSummary, + }; + }); + // Include agent: sessions whose identity is no longer in the roster + // so mobile users can still see / resume orphan chats. These are marked + // with a synthetic "orphaned" status and no avatar seed. + const orphanPrefix = "agent:"; + const orphanWorkers: typeof liveWorkers = []; + const seenOrphanIds = new Set(); + for (const entry of sortedByRecency) { + const key = entry.identityKey ?? ""; + if (!key.startsWith(orphanPrefix)) continue; + const agentId = key.slice(orphanPrefix.length); + if (!agentId.length) continue; + if (knownAgentIds.has(agentId)) continue; + if (seenOrphanIds.has(agentId)) continue; + seenOrphanIds.add(agentId); + orphanWorkers.push({ + agentId, + name: agentId, + avatarSeed: null, + status: "orphaned", + sessionSummary: entry, + }); + } + liveWorkers.sort((a, b) => a.name.localeCompare(b.name)); + orphanWorkers.sort((a, b) => a.name.localeCompare(b.name)); + const workers = [...liveWorkers, ...orphanWorkers]; + return { cto: ctoSummary, workers }; + }); + register("cto.ensureSession", { viewerAllowed: true }, async (payload) => { + const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); + const laneId = await resolvePrimaryLaneIdOnlyForSync(args); + if (!laneId) throw new Error("No primary lane is available to host the CTO chat session."); + const modelId = asTrimmedString(payload.modelId); + const reasoningEffort = asTrimmedString(payload.reasoningEffort); + const session = await agentChatService.ensureIdentitySession({ + identityKey: "cto", + laneId, + modelId: modelId ?? null, + reasoningEffort: reasoningEffort ?? null, + permissionMode: "full-auto", + }); + return summarizeChatSessionForRemote(agentChatService, session); + }); + register("cto.ensureAgentSession", { viewerAllowed: true }, async (payload) => { + const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); + const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); + const agentId = requireString(payload.agentId, "cto.ensureAgentSession requires agentId."); + // Reject unknown agentIds before we spin up an identity-bound session — + // otherwise clients could spawn orphan `agent:` sessions for agents + // that don't exist. + const agent = typeof workerAgentService.getAgent === "function" + ? workerAgentService.getAgent(agentId) + : workerAgentService.listAgents().find((entry) => entry.id === agentId) ?? null; + if (!agent) { + throw new Error(`cto.ensureAgentSession: unknown agentId '${agentId}'`); + } + const laneId = await resolvePrimaryLaneIdOnlyForSync(args); + if (!laneId) throw new Error("No primary lane is available to host the agent chat session."); + const modelId = asTrimmedString(payload.modelId); + const reasoningEffort = asTrimmedString(payload.reasoningEffort); + const session = await agentChatService.ensureIdentitySession({ + identityKey: `agent:${agentId}`, + laneId, + modelId: modelId ?? null, + reasoningEffort: reasoningEffort ?? null, + permissionMode: "full-auto", + }); + return summarizeChatSessionForRemote(agentChatService, session); + }); + + register("cto.getState", { viewerAllowed: true }, async (payload) => { + const ctoStateService = requireService(args.ctoStateService, "CTO state service not available."); + const recentLimit = asOptionalNumber(payload.recentLimit); + return ctoStateService.getSnapshot(recentLimit ?? 20); + }); + register("cto.listAgents", { viewerAllowed: true }, async (payload) => { + const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); + const includeDeleted = asOptionalBoolean(payload.includeDeleted); + return workerAgentService.listAgents(includeDeleted === undefined ? {} : { includeDeleted }); + }); + register("cto.getBudgetSnapshot", { viewerAllowed: true }, async (payload) => { + const workerBudgetService = requireService(args.workerBudgetService, "Worker budget service not available."); + const monthKey = asTrimmedString(payload.monthKey); + return workerBudgetService.getBudgetSnapshot(monthKey ? { monthKey } : {}); + }); + register("cto.getAgentCoreMemory", { viewerAllowed: true }, async (payload) => { + const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); + const agentId = requireString(payload.agentId, "cto.getAgentCoreMemory requires agentId."); + return workerHeartbeatService.getAgentCoreMemory(agentId); + }); + register("cto.listAgentRuns", { viewerAllowed: true }, async (payload) => { + const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); + const agentId = requireString(payload.agentId, "cto.listAgentRuns requires agentId."); + const limit = asOptionalNumber(payload.limit); + return workerHeartbeatService.listRuns({ agentId, ...(typeof limit === "number" ? { limit } : {}) }); + }); + register("cto.listAgentSessionLogs", { viewerAllowed: true }, async (payload) => { + const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); + const agentId = requireString(payload.agentId, "cto.listAgentSessionLogs requires agentId."); + const limit = asOptionalNumber(payload.limit); + return workerHeartbeatService.listAgentSessionLogs(agentId, limit ?? 40); + }); + register("cto.listAgentRevisions", { viewerAllowed: true }, async (payload) => { + const workerRevisionService = requireService(args.workerRevisionService, "Worker revision service not available."); + const agentId = requireString(payload.agentId, "cto.listAgentRevisions requires agentId."); + const limit = asOptionalNumber(payload.limit); + return workerRevisionService.listAgentRevisions(agentId, limit ?? 20); + }); + register("cto.getFlowPolicy", { viewerAllowed: true }, async () => { + const flowPolicyService = requireService(args.flowPolicyService, "Flow policy service not available."); + return flowPolicyService.getPolicy(); + }); + register("cto.getLinearConnectionStatus", { viewerAllowed: true }, async () => { + const linearCredentialService = requireService(args.linearCredentialService, "Linear credential service not available."); + const credentialStatus = linearCredentialService.getStatus(); + const tokenStored = Boolean(credentialStatus.tokenStored); + const checkedAt = new Date().toISOString(); + const linearIssueTracker = args.getLinearIssueTracker?.() ?? null; + if (!linearIssueTracker || !tokenStored) { + return { + tokenStored, + connected: false, + viewerId: null, + viewerName: null, + checkedAt, + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: tokenStored ? "Linear tracker service unavailable." : "Linear token not configured.", + }; + } + const status = await linearIssueTracker.getConnectionStatus(); + return { + tokenStored, + connected: status.connected, + viewerId: status.viewerId, + viewerName: status.viewerName, + organizationId: status.organizationId, + organizationName: status.organizationName, + organizationUrlKey: status.organizationUrlKey, + organizationLogoUrl: status.organizationLogoUrl, + checkedAt, + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: status.message, + }; + }); + register("cto.getLinearSyncDashboard", { viewerAllowed: true }, async () => { + const linearSyncService = requireService(args.getLinearSyncService?.() ?? null, "Linear sync service not available."); + return linearSyncService.getDashboard(); + }); + register("cto.listLinearSyncQueue", { viewerAllowed: true }, async () => { + const linearSyncService = requireService(args.getLinearSyncService?.() ?? null, "Linear sync service not available."); + return linearSyncService.listQueue({ limit: 300 }); + }); + register("cto.listLinearIngressEvents", { viewerAllowed: true }, async (payload) => { + const linearIngressService = requireService(args.getLinearIngressService?.() ?? null, "Linear ingress service not available."); + const limit = asOptionalNumber(payload.limit); + return linearIngressService.listRecentEvents(limit ?? 20); + }); + register("cto.updateIdentity", { viewerAllowed: true, queueable: true }, async (payload) => { + const ctoStateService = requireService(args.ctoStateService, "CTO state service not available."); + const patch = isRecord(payload.patch) ? (payload.patch as Partial) : {}; + return ctoStateService.updateIdentity(patch); + }); + register("cto.updateCoreMemory", { viewerAllowed: true, queueable: true }, async (payload) => { + const ctoStateService = requireService(args.ctoStateService, "CTO state service not available."); + const patch = isRecord(payload.patch) ? (payload.patch as Partial) : {}; + return ctoStateService.updateCoreMemory(patch); + }); + register("cto.setAgentStatus", { viewerAllowed: true, queueable: true }, async (payload) => { + const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); + const agentId = requireString(payload.agentId, "cto.setAgentStatus requires agentId."); + const status = requireString(payload.status, "cto.setAgentStatus requires status.") as AgentStatus; + workerAgentService.setAgentStatus(agentId, status); + return {}; + }); + register("cto.triggerAgentWakeup", { viewerAllowed: true, queueable: true }, async (payload) => { + const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); + const agentId = requireString(payload.agentId, "cto.triggerAgentWakeup requires agentId."); + const reason = asTrimmedString(payload.reason); + const context = isRecord(payload.context) ? payload.context : undefined; + return workerHeartbeatService.triggerWakeup({ + agentId, + ...(reason ? { reason: reason as CtoTriggerAgentWakeupArgs["reason"] } : {}), + ...(context ? { context } : {}), + }); + }); + register("cto.rollbackAgentRevision", { viewerAllowed: true, queueable: true }, async (payload) => { + const workerRevisionService = requireService(args.workerRevisionService, "Worker revision service not available."); + const agentId = requireString(payload.agentId, "cto.rollbackAgentRevision requires agentId."); + const revisionId = requireString(payload.revisionId, "cto.rollbackAgentRevision requires revisionId."); + await workerRevisionService.rollbackAgentRevision(agentId, revisionId, "user"); + return {}; + }); + + register("git.getChanges", { viewerAllowed: true }, async (payload) => + requireService(args.diffService, "Diff service not available.").getChanges(parseGetDiffChangesArgs(payload).laneId)); + register("git.getFile", { viewerAllowed: true }, async (payload) => { + const diffService = requireService(args.diffService, "Diff service not available."); + const parsed = parseGetFileDiffArgs(payload); + return await diffService.getFileDiff({ + laneId: parsed.laneId, + filePath: parsed.path, + mode: parsed.mode, + compareRef: parsed.compareRef, + compareTo: parsed.compareTo, + }); + }); + register("files.writeTextAtomic", { viewerAllowed: true, queueable: true }, async (payload) => { + const parsed = parseWriteTextAtomicArgs(payload); + args.fileService.writeTextAtomic({ laneId: parsed.laneId, relPath: parsed.path, text: parsed.text }); + return { ok: true }; + }); + register("git.stageFile", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stageFile(parseGitFileActionArgs(payload, "git.stageFile"))); + register("git.stageAll", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stageAll(parseGitBatchFileActionArgs(payload, "git.stageAll"))); + register("git.unstageFile", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").unstageFile(parseGitFileActionArgs(payload, "git.unstageFile"))); + register("git.unstageAll", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").unstageAll(parseGitBatchFileActionArgs(payload, "git.unstageAll"))); + register("git.discardFile", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").discardFile(parseGitFileActionArgs(payload, "git.discardFile"))); + register("git.restoreStagedFile", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").restoreStagedFile(parseGitFileActionArgs(payload, "git.restoreStagedFile"))); + register("git.commit", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").commit(parseGitCommitArgs(payload))); + register("git.generateCommitMessage", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").generateCommitMessage(parseGitGenerateCommitMessageArgs(payload))); + register("git.listRecentCommits", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").listRecentCommits(parseGitListRecentCommitsArgs(payload))); + register("git.listCommitFiles", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").listCommitFiles(parseGitListCommitFilesArgs(payload))); + register("git.getFileHistory", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").getFileHistory(parseGitGetFileHistoryArgs(payload))); + register("git.getCommitMessage", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").getCommitMessage(parseGitGetCommitMessageArgs(payload))); + register("git.revertCommit", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").revertCommit(parseGitRevertArgs(payload))); + register("git.cherryPickCommit", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").cherryPickCommit(parseGitCherryPickArgs(payload))); + register("git.stashPush", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stashPush(parseGitStashPushArgs(payload))); + register("git.stashList", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").listStashes(parseConflictLaneArgs(payload, "git.stashList"))); + register("git.stashApply", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stashApply(parseGitStashRefArgs(payload, "git.stashApply"))); + register("git.stashPop", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stashPop(parseGitStashRefArgs(payload, "git.stashPop"))); + register("git.stashDrop", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stashDrop(parseGitStashRefArgs(payload, "git.stashDrop"))); + register("git.fetch", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").fetch(parseConflictLaneArgs(payload, "git.fetch"))); + register("git.pull", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").pull(parseConflictLaneArgs(payload, "git.pull"))); + register("git.getSyncStatus", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").getSyncStatus(parseConflictLaneArgs(payload, "git.getSyncStatus"))); + register("git.sync", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").sync(parseGitSyncArgs(payload))); + register("git.push", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").push(parseGitPushArgs(payload))); + register("git.getConflictState", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").getConflictState(parseConflictLaneArgs(payload, "git.getConflictState"))); + register("git.rebaseContinue", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").rebaseContinue(parseConflictLaneArgs(payload, "git.rebaseContinue"))); + register("git.rebaseAbort", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").rebaseAbort(parseConflictLaneArgs(payload, "git.rebaseAbort"))); + register("git.listBranches", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").listBranches(parseGitListBranchesArgs(payload))); + register("git.checkoutBranch", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").checkoutBranch(parseGitCheckoutBranchArgs(payload))); + + register("conflicts.getLaneStatus", { viewerAllowed: true }, async (payload) => + requireService(args.conflictService, "Conflict service not available.").getLaneStatus(parseConflictLaneArgs(payload, "conflicts.getLaneStatus"))); + register("conflicts.listOverlaps", { viewerAllowed: true }, async (payload) => + requireService(args.conflictService, "Conflict service not available.").listOverlaps(parseConflictLaneArgs(payload, "conflicts.listOverlaps"))); + register("conflicts.getBatchAssessment", { viewerAllowed: true }, async () => + requireService(args.conflictService, "Conflict service not available.").getBatchAssessment()); + + register("prs.list", { viewerAllowed: true }, async () => args.prService.listAll()); + register("prs.refresh", { viewerAllowed: true }, async (payload) => { + const prId = asTrimmedString(payload.prId); + const prIds = asStringArray(payload.prIds); + let refreshArgs: { prId?: string; prIds?: string[] } = {}; + if (prId) refreshArgs = { prId }; + else if (prIds.length > 0) refreshArgs = { prIds }; + await args.prService.refresh(refreshArgs); + const prs = await args.prService.listAll(); + let refreshedCount = prs.length; + if (prId) refreshedCount = 1; + else if (prIds.length > 0) refreshedCount = prIds.length; + return { + refreshedCount, + prs, + snapshots: args.prService.listSnapshots(), + }; + }); + register("prs.getDetail", { viewerAllowed: true }, async (payload) => args.prService.getDetail(requirePrId(payload, "prs.getDetail"))); + register("prs.getStatus", { viewerAllowed: true }, async (payload) => args.prService.getStatus(requirePrId(payload, "prs.getStatus"))); + register("prs.getChecks", { viewerAllowed: true }, async (payload) => args.prService.getChecks(requirePrId(payload, "prs.getChecks"))); + register("prs.getReviews", { viewerAllowed: true }, async (payload) => args.prService.getReviews(requirePrId(payload, "prs.getReviews"))); + register("prs.getComments", { viewerAllowed: true }, async (payload) => args.prService.getComments(requirePrId(payload, "prs.getComments"))); + register("prs.getFiles", { viewerAllowed: true }, async (payload) => args.prService.getFiles(requirePrId(payload, "prs.getFiles"))); + register("prs.getGitHubSnapshot", { viewerAllowed: true }, async (payload) => + args.prService.getGithubSnapshot({ force: payload.force === true })); + register("prs.getReviewThreads", { viewerAllowed: true }, async (payload) => args.prService.getReviewThreads(requirePrId(payload, "prs.getReviewThreads"))); + register("prs.getActionRuns", { viewerAllowed: true }, async (payload) => args.prService.getActionRuns(requirePrId(payload, "prs.getActionRuns"))); + register("prs.getActivity", { viewerAllowed: true }, async (payload) => args.prService.getActivity(requirePrId(payload, "prs.getActivity"))); + register("prs.getDeployments", { viewerAllowed: true }, async (payload) => args.prService.getDeployments(requirePrId(payload, "prs.getDeployments"))); + register("prs.createFromLane", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.createFromLane(parseCreatePrArgs(payload))); + register("prs.linkToLane", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.linkToLane(parseLinkPrToLaneArgs(payload))); + register("prs.draftDescription", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.draftDescription(parseDraftPrDescriptionArgs(payload))); + register("prs.land", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.land(parseLandPrArgs(payload))); + register("prs.close", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.closePr(parseClosePrArgs(payload)); + return { ok: true }; + }); + register("prs.reopen", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.reopenPr(parseReopenPrArgs(payload)); + return { ok: true }; + }); + register("prs.requestReviewers", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.requestReviewers(parseRequestReviewersArgs(payload)); + return { ok: true }; + }); + register("prs.rerunChecks", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.rerunChecks(parseRerunPrChecksArgs(payload)); + return { ok: true }; + }); + register("prs.addComment", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.addComment(parseAddPrCommentArgs(payload))); + register("prs.updateTitle", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.updateTitle(parseUpdatePrTitleArgs(payload)); + return { ok: true }; + }); + register("prs.updateBody", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.updateBody(parseUpdatePrBodyArgs(payload)); + return { ok: true }; + }); + register("prs.setLabels", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.setLabels(parseSetPrLabelsArgs(payload)); + return { ok: true }; + }); + register("prs.submitReview", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.submitReview(parseSubmitPrReviewArgs(payload)); + return { ok: true }; + }); + register("prs.replyToReviewThread", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.replyToReviewThread(parseReplyToReviewThreadArgs(payload))); + register("prs.setReviewThreadResolved", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.setReviewThreadResolved(parseSetReviewThreadResolvedArgs(payload))); + register("prs.reactToComment", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.reactToComment(parseReactToCommentArgs(payload)); + return { ok: true }; + }); + register("prs.aiReviewSummary", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.aiReviewSummary(parseAiReviewSummaryArgs(payload))); + register("prs.listIntegrationWorkflows", { viewerAllowed: true }, async (payload) => + args.prService.listIntegrationWorkflows(parseListIntegrationWorkflowsArgs(payload))); + register("prs.updateIntegrationProposal", { viewerAllowed: true, queueable: true }, async (payload) => { + args.prService.updateIntegrationProposal(parseUpdateIntegrationProposalArgs(payload)); + return { ok: true }; + }); + register("prs.deleteIntegrationProposal", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.deleteIntegrationProposal(parseDeleteIntegrationProposalArgs(payload))); + register("prs.dismissIntegrationCleanup", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.dismissIntegrationCleanup(parseDismissIntegrationCleanupArgs(payload))); + register("prs.cleanupIntegrationWorkflow", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.cleanupIntegrationWorkflow(parseCleanupIntegrationWorkflowArgs(payload))); + register("prs.createIntegrationLaneForProposal", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.createIntegrationLaneForProposal(parseCreateIntegrationLaneForProposalArgs(payload))); + register("prs.startIntegrationResolution", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.startIntegrationResolution(parseStartIntegrationResolutionArgs(payload))); + register("prs.recheckIntegrationStep", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.recheckIntegrationStep(parseRecheckIntegrationStepArgs(payload))); + register("prs.landQueueNext", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.landQueueNext(parseLandQueueNextArgs(payload))); + register("prs.pauseQueueAutomation", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.queueLandingService) throw new Error("Queue automation is not available."); + return args.queueLandingService.pauseQueue(parsePauseQueueAutomationArgs(payload).queueId); + }); + register("prs.resumeQueueAutomation", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.queueLandingService) throw new Error("Queue automation is not available."); + return args.queueLandingService.resumeQueue(parseResumeQueueAutomationArgs(payload)); + }); + register("prs.cancelQueueAutomation", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.queueLandingService) throw new Error("Queue automation is not available."); + return args.queueLandingService.cancelQueue(parseCancelQueueAutomationArgs(payload).queueId); + }); + register("prs.reorderQueue", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.reorderQueuePrs(parseReorderQueuePrsArgs(payload)); + return { ok: true }; + }); + register("prs.issueInventory.sync", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + const { prId } = parseIssueInventoryPrArgs(payload, "prs.issueInventory.sync"); + const [checks, reviewThreads, comments] = await Promise.all([ + args.prService.getChecks(prId), + args.prService.getReviewThreads(prId), + args.prService.getComments(prId).catch(() => []), + ]); + return args.issueInventoryService.syncFromPrData(prId, checks, reviewThreads, comments); + }); + register("prs.issueInventory.get", { viewerAllowed: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + return args.issueInventoryService.getInventory(parseIssueInventoryPrArgs(payload, "prs.issueInventory.get").prId); + }); + register("prs.issueInventory.getNew", { viewerAllowed: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + return args.issueInventoryService.getNewItems(parseIssueInventoryPrArgs(payload, "prs.issueInventory.getNew").prId); + }); + register("prs.issueInventory.markFixed", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + const parsed = parseIssueInventoryItemsArgs(payload, "prs.issueInventory.markFixed"); + args.issueInventoryService.markFixed(parsed.prId, parsed.itemIds); + return { ok: true }; + }); + register("prs.issueInventory.markDismissed", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + const parsed = parseIssueInventoryDismissArgs(payload); + args.issueInventoryService.markDismissed(parsed.prId, parsed.itemIds, parsed.reason); + return { ok: true }; + }); + register("prs.issueInventory.markEscalated", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + const parsed = parseIssueInventoryItemsArgs(payload, "prs.issueInventory.markEscalated"); + args.issueInventoryService.markEscalated(parsed.prId, parsed.itemIds); + return { ok: true }; + }); + register("prs.issueInventory.getConvergence", { viewerAllowed: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + return args.issueInventoryService.getConvergenceStatus(parseIssueInventoryPrArgs(payload, "prs.issueInventory.getConvergence").prId); + }); + register("prs.issueInventory.reset", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + args.issueInventoryService.resetInventory(parseIssueInventoryPrArgs(payload, "prs.issueInventory.reset").prId); + return { ok: true }; + }); + register("prs.convergenceState.get", { viewerAllowed: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + return args.issueInventoryService.getConvergenceRuntime(parseIssueInventoryPrArgs(payload, "prs.convergenceState.get").prId); + }); + register("prs.convergenceState.save", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + const parsed = parseConvergenceStatePatch(payload); + return args.issueInventoryService.saveConvergenceRuntime(parsed.prId, parsed.state); + }); + register("prs.convergenceState.delete", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + args.issueInventoryService.resetConvergenceRuntime(parseIssueInventoryPrArgs(payload, "prs.convergenceState.delete").prId); + return { ok: true }; + }); + register("prs.pipelineSettings.get", { viewerAllowed: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + return args.issueInventoryService.getPipelineSettings(parseIssueInventoryPrArgs(payload, "prs.pipelineSettings.get").prId); + }); + register("prs.pipelineSettings.save", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + const parsed = parsePipelineSettingsPatch(payload); + args.issueInventoryService.savePipelineSettings(parsed.prId, parsed.settings); + return { ok: true }; + }); + register("prs.pipelineSettings.delete", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + args.issueInventoryService.deletePipelineSettings(parseIssueInventoryPrArgs(payload, "prs.pipelineSettings.delete").prId); + return { ok: true }; + }); + register("prs.pathToMerge.start", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.pathToMergeOrchestrator) { + throw new Error("Path to Merge orchestrator is not available in this build."); + } + const { prId } = parseIssueInventoryPrArgs(payload, "prs.pathToMerge.start"); + const modelId = typeof payload?.modelId === "string" ? payload.modelId : null; + const reasoning = typeof payload?.reasoning === "string" ? payload.reasoning : null; + const additionalInstructions = typeof payload?.additionalInstructions === "string" + ? payload.additionalInstructions + : null; + const rawScope = payload?.scope; + const scope = rawScope === "checks" || rawScope === "comments" || rawScope === "both" + ? rawScope + : undefined; + return args.pathToMergeOrchestrator.startPathToMerge({ + prId, + modelId, + reasoning, + scope, + additionalInstructions, + }); + }); + register("prs.pathToMerge.stop", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.pathToMergeOrchestrator) { + throw new Error("Path to Merge orchestrator is not available in this build."); + } + const { prId } = parseIssueInventoryPrArgs(payload, "prs.pathToMerge.stop"); + const reason = typeof payload?.reason === "string" ? payload.reason : null; + return args.pathToMergeOrchestrator.stopPathToMerge({ prId, reason }); + }); + register("prs.getMobileSnapshot", { viewerAllowed: true }, async () => args.prService.getMobileSnapshot()); + + return { + getSupportedActions(): SyncRemoteCommandAction[] { + return [...registry.keys()]; + }, + + getDescriptors(): SyncRemoteCommandDescriptor[] { + return [...registry.values()].map((entry) => entry.descriptor); + }, + + getPolicy(action: string): SyncRemoteCommandPolicy | null { + return registry.get(action as SyncRemoteCommandAction)?.descriptor.policy ?? null; + }, + + getDescriptor(action: string): SyncRemoteCommandDescriptor | null { + return registry.get(action as SyncRemoteCommandAction)?.descriptor ?? null; + }, + + async execute(payload: SyncCommandPayload): Promise { + const handler = registry.get(payload.action as SyncRemoteCommandAction); + if (!handler) { + throw new Error(`Unsupported remote command: ${payload.action}`); + } + const commandArgs = isRecord(payload.args) ? payload.args : {}; + args.logger.debug?.("sync.remote_command.execute", { + action: payload.action, + scope: handler.descriptor.scope, + policy: handler.descriptor.policy, + }); + return await handler.handler(commandArgs); + }, + }; +} + +export type SyncRemoteCommandService = ReturnType; diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts new file mode 100644 index 000000000..bf9dacab0 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -0,0 +1,1155 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomInt } from "node:crypto"; +import { resolveAdeLayout } from "../../../../desktop/src/shared/adeLayout"; +import type { + SyncAddressCandidate, + SyncDesktopConnectionDraft, + SyncDeviceRuntimeState, + SyncGetStatusArgs, + SyncPairingConnectInfo, + SyncProjectCatalogPayload, + SyncProjectSwitchRequestPayload, + SyncProjectSwitchResultPayload, + SyncRoleSnapshot, + SyncTailnetDiscoveryStatus, + SyncTransferBlocker, + SyncTransferReadiness, +} from "../../../../desktop/src/shared/types"; +import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; +import type { createAgentChatService } from "../../../../desktop/src/main/services/chat/agentChatService"; +import type { createCtoStateService } from "../../../../desktop/src/main/services/cto/ctoStateService"; +import type { createFlowPolicyService } from "../../../../desktop/src/main/services/cto/flowPolicyService"; +import type { createLinearCredentialService } from "../../../../desktop/src/main/services/cto/linearCredentialService"; +import type { createLinearIngressService } from "../../../../desktop/src/main/services/cto/linearIngressService"; +import type { createLinearIssueTracker } from "../../../../desktop/src/main/services/cto/linearIssueTracker"; +import type { createLinearSyncService } from "../../../../desktop/src/main/services/cto/linearSyncService"; +import type { createWorkerAgentService } from "../../../../desktop/src/main/services/cto/workerAgentService"; +import type { createWorkerBudgetService } from "../../../../desktop/src/main/services/cto/workerBudgetService"; +import type { createWorkerHeartbeatService } from "../../../../desktop/src/main/services/cto/workerHeartbeatService"; +import type { createWorkerRevisionService } from "../../../../desktop/src/main/services/cto/workerRevisionService"; +import type { createComputerUseArtifactBrokerService } from "../../../../desktop/src/main/services/computerUse/computerUseArtifactBrokerService"; +import type { createProjectConfigService } from "../../../../desktop/src/main/services/config/projectConfigService"; +import type { createFileService } from "../../../../desktop/src/main/services/files/fileService"; +import type { createDiffService } from "../../../../desktop/src/main/services/diffs/diffService"; +import type { createGitOperationsService } from "../../../../desktop/src/main/services/git/gitOperationsService"; +import type { createConflictService } from "../../../../desktop/src/main/services/conflicts/conflictService"; +import type { createLaneEnvironmentService } from "../../../../desktop/src/main/services/lanes/laneEnvironmentService"; +import type { createLaneService } from "../../../../desktop/src/main/services/lanes/laneService"; +import type { createLaneTemplateService } from "../../../../desktop/src/main/services/lanes/laneTemplateService"; +import type { createAutoRebaseService } from "../../../../desktop/src/main/services/lanes/autoRebaseService"; +import type { createPortAllocationService } from "../../../../desktop/src/main/services/lanes/portAllocationService"; +import type { createRebaseSuggestionService } from "../../../../desktop/src/main/services/lanes/rebaseSuggestionService"; +import type { createMissionService } from "../../../../desktop/src/main/services/missions/missionService"; +import type { createProcessService } from "../../../../desktop/src/main/services/processes/processService"; +import type { createIssueInventoryService } from "../../../../desktop/src/main/services/prs/issueInventoryService"; +import type { PathToMergeOrchestrator } from "../../../../desktop/src/main/services/prs/pathToMergeOrchestrator"; +import type { createPrService } from "../../../../desktop/src/main/services/prs/prService"; +import type { createQueueLandingService } from "../../../../desktop/src/main/services/prs/queueLandingService"; +import type { createPtyService } from "../../../../desktop/src/main/services/pty/ptyService"; +import type { createSessionService } from "../../../../desktop/src/main/services/sessions/sessionService"; +import type { NotificationEventBus } from "../../../../desktop/src/main/services/notifications/notificationEventBus"; +import type { AdeDb } from "../../../../desktop/src/main/services/state/kvDb"; +import { nowIso, safeJsonParse, sleep, writeTextAtomic } from "../../../../desktop/src/main/services/shared/utils"; +import { createDeviceRegistryService } from "./deviceRegistryService"; +import { + createSyncHostService, + SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + type SyncHostService, + type SyncRuntimeKind, +} from "./syncHostService"; +import { createSyncPeerService } from "./syncPeerService"; +import { createSyncPinStore } from "./syncPinStore"; +import { DEFAULT_SYNC_HOST_PORT } from "./syncProtocol"; +import { createSyncRemoteCommandService, type SyncRemoteCommandService } from "./syncRemoteCommandService"; + +type SyncServiceArgs = { + db: AdeDb; + logger: Logger; + projectId?: string | null; + projectRoot: string; + appVersion?: string; + runtimeKind?: SyncRuntimeKind; + localDeviceIdPath?: string; + phonePairingStateDir?: string; + fileService: ReturnType; + laneService: ReturnType; + gitService?: ReturnType; + diffService?: ReturnType; + conflictService?: ReturnType; + prService: ReturnType; + issueInventoryService?: ReturnType | null; + /** + * Optional Path-to-Merge orchestrator forwarded to the embedded sync host so + * iOS callers can drive the convergence loop via remote commands. + */ + pathToMergeOrchestrator?: PathToMergeOrchestrator | null; + queueLandingService?: ReturnType | null; + sessionService: ReturnType; + ptyService: ReturnType; + projectConfigService?: ReturnType; + portAllocationService?: ReturnType; + laneEnvironmentService?: ReturnType; + laneTemplateService?: ReturnType; + rebaseSuggestionService?: ReturnType< + typeof createRebaseSuggestionService + > | null; + autoRebaseService?: ReturnType | null; + computerUseArtifactBrokerService: ReturnType< + typeof createComputerUseArtifactBrokerService + >; + missionService: ReturnType; + agentChatService: ReturnType; + workerAgentService?: ReturnType | null; + workerBudgetService?: ReturnType | null; + workerHeartbeatService?: ReturnType | null; + workerRevisionService?: ReturnType | null; + ctoStateService?: ReturnType | null; + flowPolicyService?: ReturnType | null; + linearCredentialService?: ReturnType | null; + /** + * Resolvers for services that are constructed AFTER createSyncService in + * main.ts. Using lazy getters lets the sync router forward remote commands + * to them without requiring a specific init order. + */ + getLinearIngressService?: () => ReturnType | null; + getLinearIssueTracker?: () => ReturnType | null; + getLinearSyncService?: () => ReturnType | null; + processService: ReturnType; + hostStartupEnabled?: boolean; + hostDiscoveryEnabled?: boolean; + /** + * Phone sync is hosted by the local ADE service. When enabled, legacy + * machine-to-machine viewer state stored in a project DB cannot demote the + * phone sync surface into viewer mode. + */ + forceHostRole?: boolean; + onStatusChanged?: (snapshot: SyncRoleSnapshot) => void; + /** + * Optional notification bus forwarded to the sync host. The host publishes + * chat/PR/mission/system events and invokes `sendInAppNotification` for + * connected iOS peers. + */ + notificationEventBus?: NotificationEventBus | null; + projectCatalogProvider?: { + listProjects: () => Promise; + prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise; + completeProjectConnection?: ( + args: SyncProjectSwitchRequestPayload, + result: SyncProjectSwitchResultPayload, + ) => Promise; + }; + remoteCommandExecutor?: Pick; +}; + +const DRAFT_FILE = "sync-peer-draft.json"; +const TOKEN_FILE = "sync-bootstrap-token"; +const PIN_FILE = "sync-pin.json"; +const PAIRED_DEVICES_FILE = "sync-paired-devices.json"; + +function readPairingRecords(filePath: string): Record { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } +} + +function migrateLegacySyncSecretFile(args: { + legacyPath: string; + appPath: string; + logger: Logger; + label: string; +}): void { + if (args.legacyPath === args.appPath) return; + if (!fs.existsSync(args.legacyPath)) return; + if (args.label === PAIRED_DEVICES_FILE && fs.existsSync(args.appPath)) { + const merged = readPairingRecords(args.appPath); + const legacy = readPairingRecords(args.legacyPath); + let changed = false; + for (const [deviceId, record] of Object.entries(legacy)) { + if (!deviceId.trim() || Object.prototype.hasOwnProperty.call(merged, deviceId)) continue; + merged[deviceId] = record; + changed = true; + } + if (!changed) return; + try { + fs.mkdirSync(path.dirname(args.appPath), { recursive: true }); + fs.writeFileSync(args.appPath, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 }); + args.logger.info("sync.app_pairing_state_merged", { + label: args.label, + legacyPath: args.legacyPath, + appPath: args.appPath, + }); + } catch (error) { + args.logger.warn("sync.app_pairing_state_migration_failed", { + label: args.label, + legacyPath: args.legacyPath, + appPath: args.appPath, + error: error instanceof Error ? error.message : String(error), + }); + } + return; + } + if (fs.existsSync(args.appPath)) return; + try { + fs.mkdirSync(path.dirname(args.appPath), { recursive: true }); + fs.copyFileSync(args.legacyPath, args.appPath, fs.constants.COPYFILE_EXCL); + args.logger.info("sync.app_pairing_state_migrated", { + label: args.label, + legacyPath: args.legacyPath, + appPath: args.appPath, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException | null | undefined)?.code === "EEXIST") return; + args.logger.warn("sync.app_pairing_state_migration_failed", { + label: args.label, + legacyPath: args.legacyPath, + appPath: args.appPath, + error: error instanceof Error ? error.message : String(error), + }); + } +} +const RUNNING_PROCESS_STATES = new Set(["starting", "running", "degraded"]); +const CHAT_TOOL_TYPES = new Set(["codex-chat", "claude-chat", "opencode-chat"]); +const SYNC_HOST_PORT_RETRY_WINDOW = 12; +const LOCAL_LANE_PRESENCE_HEARTBEAT_MS = 30_000; +const TRANSFER_READINESS_CACHE_MS = 15_000; + +function generatePairingPin(): string { + return randomInt(0, 1_000_000).toString().padStart(6, "0"); +} + +function buildSkippedTransferReadiness(): SyncTransferReadiness { + return { + ready: false, + blockers: [], + survivableState: [ + "Transfer readiness was skipped for this lightweight sync status request.", + ], + }; +} + +function sanitizeDraft( + raw: unknown, + token: string | null, +): SyncDesktopConnectionDraft | null { + if (!raw || typeof raw !== "object" || !token) return null; + const row = raw as Record; + const host = typeof row.host === "string" ? row.host.trim() : ""; + const port = Number(row.port ?? 0); + if (!host || !Number.isFinite(port) || port <= 0) return null; + return { + host, + port: Math.floor(port), + token, + authKind: row.authKind === "paired" ? "paired" : "bootstrap", + pairedDeviceId: + typeof row.pairedDeviceId === "string" ? row.pairedDeviceId : null, + lastRemoteDbVersion: Number.isFinite(row.lastRemoteDbVersion) + ? Number(row.lastRemoteDbVersion) + : 0, + }; +} + +function normalizeHost(host: string | null | undefined): string | null { + if (!host) return null; + const normalized = host.trim().toLowerCase(); + return normalized.length > 0 ? normalized : null; +} + +function tailscaleDnsNameFromDevice( + localDevice: SyncRoleSnapshot["localDevice"], +): string | null { + const value = localDevice.metadata?.tailscaleDnsName; + return typeof value === "string" && value.trim().toLowerCase().endsWith(".ts.net") + ? value.trim().replace(/\.$/, "").toLowerCase() + : null; +} + +function buildAddressCandidates( + localDevice: SyncRoleSnapshot["localDevice"], +): SyncAddressCandidate[] { + const candidates: SyncAddressCandidate[] = []; + const seen = new Set(); + const append = ( + host: string | null | undefined, + kind: SyncAddressCandidate["kind"], + ) => { + const normalized = normalizeHost(host); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + candidates.push({ host: normalized, kind }); + }; + const preferredSavedHost = normalizeHost(localDevice.lastHost); + const preferredSavedHostIsCurrent = preferredSavedHost != null && ( + localDevice.ipAddresses.some((host) => normalizeHost(host) === preferredSavedHost) + || normalizeHost(localDevice.tailscaleIp) === preferredSavedHost + || tailscaleDnsNameFromDevice(localDevice) === preferredSavedHost + ); + if (preferredSavedHostIsCurrent) { + append(localDevice.lastHost, "saved"); + } + for (const lanAddress of localDevice.ipAddresses) { + append(lanAddress, "lan"); + } + if (!preferredSavedHostIsCurrent) { + append(localDevice.lastHost, "saved"); + } + append(tailscaleDnsNameFromDevice(localDevice), "tailscale"); + append(localDevice.tailscaleIp, "tailscale"); + append("127.0.0.1", "loopback"); + return candidates; +} + +function buildPairingConnectInfo(argsIn: { + localDevice: SyncRoleSnapshot["localDevice"]; +}): SyncPairingConnectInfo { + const port = argsIn.localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT; + const addressCandidates = buildAddressCandidates(argsIn.localDevice); + const hostIdentity = { + deviceId: argsIn.localDevice.deviceId, + siteId: argsIn.localDevice.siteId, + name: argsIn.localDevice.name, + platform: argsIn.localDevice.platform, + deviceType: argsIn.localDevice.deviceType, + }; + return { + hostIdentity, + port, + addressCandidates, + }; +} + +function isRetryableHostBindError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null | undefined)?.code ?? ""; + return code === "EADDRINUSE" || code === "EACCES"; +} + +function createInactiveTailnetDiscoveryStatus( + error: string, +): SyncTailnetDiscoveryStatus { + return { + state: "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: null, + error, + stderr: null, + }; +} + +function buildHostPortCandidates(preferredPort: number | null | undefined): number[] { + const preferred = Number.isFinite(preferredPort) + ? Math.max(0, Math.min(65_535, Math.floor(Number(preferredPort)))) + : DEFAULT_SYNC_HOST_PORT; + const candidates: number[] = []; + const seen = new Set(); + const add = (port: number) => { + const normalized = Math.max(0, Math.min(65_535, Math.floor(port))); + if (seen.has(normalized)) return; + seen.add(normalized); + candidates.push(normalized); + }; + add(preferred); + if (preferred !== DEFAULT_SYNC_HOST_PORT) { + add(DEFAULT_SYNC_HOST_PORT); + } + for (let offset = 1; offset <= SYNC_HOST_PORT_RETRY_WINDOW; offset += 1) { + if (preferred + offset <= 65_535) { + add(preferred + offset); + } + } + if (preferred !== DEFAULT_SYNC_HOST_PORT) { + for (let offset = 1; offset <= Math.min(4, SYNC_HOST_PORT_RETRY_WINDOW); offset += 1) { + if (DEFAULT_SYNC_HOST_PORT + offset <= 65_535) { + add(DEFAULT_SYNC_HOST_PORT + offset); + } + } + } + add(0); + return candidates; +} + +export function createSyncService(args: SyncServiceArgs) { + const layout = resolveAdeLayout(args.projectRoot); + const pairingStateDir = args.phonePairingStateDir ?? layout.secretsDir; + const draftPath = path.join(pairingStateDir, DRAFT_FILE); + const tokenPath = path.join(pairingStateDir, TOKEN_FILE); + const pinPath = path.join(pairingStateDir, PIN_FILE); + const pairingSecretsPath = path.join(pairingStateDir, PAIRED_DEVICES_FILE); + migrateLegacySyncSecretFile({ + legacyPath: path.join(layout.secretsDir, DRAFT_FILE), + appPath: draftPath, + logger: args.logger, + label: DRAFT_FILE, + }); + migrateLegacySyncSecretFile({ + legacyPath: path.join(layout.secretsDir, TOKEN_FILE), + appPath: tokenPath, + logger: args.logger, + label: TOKEN_FILE, + }); + migrateLegacySyncSecretFile({ + legacyPath: path.join(layout.secretsDir, PIN_FILE), + appPath: pinPath, + logger: args.logger, + label: PIN_FILE, + }); + migrateLegacySyncSecretFile({ + legacyPath: path.join(layout.secretsDir, PAIRED_DEVICES_FILE), + appPath: pairingSecretsPath, + logger: args.logger, + label: PAIRED_DEVICES_FILE, + }); + fs.mkdirSync(path.dirname(draftPath), { recursive: true }); + + const pinStore = createSyncPinStore({ filePath: pinPath }); + + const deviceRegistryService = createDeviceRegistryService({ + db: args.db, + logger: args.logger, + projectRoot: args.projectRoot, + localDeviceIdPath: args.localDeviceIdPath, + }); + + let hostService: SyncHostService | null = null; + let refreshRunning = false; + let refreshQueued = false; + let disposed = false; + // Mobile project switch can fire `sync.initialize` as a background task and + // then immediately await `service.initialize()` from the dialog handler. + // Coalesce concurrent calls so the second await rides the first promise + // rather than re-running ensureLocalDevice/refreshRoleState in parallel. + let initializingPromise: Promise | null = null; + let initialized = false; + let hostStartupEnabled = args.hostStartupEnabled !== false; + let hostDiscoveryEnabled = args.hostDiscoveryEnabled !== false; + let transferReadinessCache: { value: SyncTransferReadiness; expiresAtMs: number } | null = null; + let transferReadinessInFlight: Promise | null = null; + const forceHostRole = args.forceHostRole === true; + const isCrdtSyncAvailable = (): boolean => args.db.sync.isAvailable?.() !== false; + const assertPhonePairingAvailable = (): void => { + if (!hostStartupEnabled) { + throw new Error( + "Phone pairing is unavailable because the sync host is disabled for this ADE process.", + ); + } + if (!isCrdtSyncAvailable()) { + throw new Error( + "Phone pairing is unavailable because the CRDT database extension is unavailable on this platform.", + ); + } + }; + let activeLocalLanePresenceIds: string[] = []; + const localLanePresenceHeartbeatTimer = setInterval(() => { + if (disposed || !hostService || activeLocalLanePresenceIds.length === 0) return; + hostService.setLocalActiveLanePresence?.(activeLocalLanePresenceIds); + }, LOCAL_LANE_PRESENCE_HEARTBEAT_MS); + + const readToken = (): string | null => { + if (!fs.existsSync(tokenPath)) return null; + const value = fs.readFileSync(tokenPath, "utf8").trim(); + return value.length > 0 ? value : null; + }; + + const writeToken = (token: string): void => { + writeTextAtomic(tokenPath, `${token.trim()}\n`); + }; + + const readSavedDraft = (): SyncDesktopConnectionDraft | null => { + if (forceHostRole) return null; + if (!fs.existsSync(draftPath)) return null; + const token = readToken(); + return sanitizeDraft( + safeJsonParse(fs.readFileSync(draftPath, "utf8"), null), + token, + ); + }; + + const writeSavedDraft = (draft: SyncDesktopConnectionDraft | null): void => { + if (!draft) { + try { + fs.rmSync(draftPath, { force: true }); + } catch { + // ignore + } + return; + } + writeToken(draft.token); + writeTextAtomic( + draftPath, + `${JSON.stringify( + { + host: draft.host, + port: draft.port, + authKind: draft.authKind ?? "bootstrap", + pairedDeviceId: draft.pairedDeviceId ?? null, + lastRemoteDbVersion: draft.lastRemoteDbVersion ?? 0, + }, + null, + 2, + )}\n`, + ); + }; + + const syncPeerService = createSyncPeerService({ + db: args.db, + logger: args.logger, + deviceRegistryService, + onStatusChange: (status) => { + if (forceHostRole) return; + if (status.savedDraft) { + const token = readToken(); + if (token) { + writeSavedDraft({ + host: status.savedDraft.host, + port: status.savedDraft.port, + token, + authKind: status.savedDraft.authKind ?? "bootstrap", + pairedDeviceId: status.savedDraft.pairedDeviceId ?? null, + lastRemoteDbVersion: status.savedDraft.lastRemoteDbVersion ?? 0, + }); + } + } + void emitStatus(); + }, + onBrainStatus: (payload) => { + deviceRegistryService.applyBrainStatus(payload); + void emitStatus(); + }, + onRemoteChangesApplied: () => { + void refreshRoleState(); + }, + }); + + const remoteCommandService = createSyncRemoteCommandService({ + laneService: args.laneService, + prService: args.prService, + issueInventoryService: args.issueInventoryService, + pathToMergeOrchestrator: args.pathToMergeOrchestrator, + queueLandingService: args.queueLandingService, + ptyService: args.ptyService, + sessionService: args.sessionService, + fileService: args.fileService, + gitService: args.gitService, + diffService: args.diffService, + conflictService: args.conflictService, + agentChatService: args.agentChatService, + workerAgentService: args.workerAgentService, + workerBudgetService: args.workerBudgetService, + workerHeartbeatService: args.workerHeartbeatService, + workerRevisionService: args.workerRevisionService, + ctoStateService: args.ctoStateService, + flowPolicyService: args.flowPolicyService, + linearCredentialService: args.linearCredentialService, + getLinearIngressService: args.getLinearIngressService, + getLinearIssueTracker: args.getLinearIssueTracker, + getLinearSyncService: args.getLinearSyncService, + projectConfigService: args.projectConfigService, + processService: args.processService, + portAllocationService: args.portAllocationService, + laneEnvironmentService: args.laneEnvironmentService, + laneTemplateService: args.laneTemplateService, + rebaseSuggestionService: args.rebaseSuggestionService ?? undefined, + autoRebaseService: args.autoRebaseService ?? undefined, + logger: args.logger, + }); + + const emitStatus = async (): Promise => { + if (disposed) return; + args.onStatusChanged?.(await service.getStatus()); + }; + + const startHostIfNeeded = async (): Promise => { + if (!hostStartupEnabled || !isCrdtSyncAvailable()) { + if (hostService) { + await stopHostIfRunning(); + } + const currentLocalDevice = deviceRegistryService.ensureLocalDevice(); + deviceRegistryService.touchLocalDevice({ + lastSeenAt: nowIso(), + lastHost: currentLocalDevice.ipAddresses[0] ?? currentLocalDevice.tailscaleIp ?? currentLocalDevice.lastHost, + }); + return; + } + if (hostService) { + const currentLocalDevice = deviceRegistryService.ensureLocalDevice(); + deviceRegistryService.touchLocalDevice({ + lastSeenAt: nowIso(), + lastHost: currentLocalDevice.ipAddresses[0] ?? currentLocalDevice.tailscaleIp ?? currentLocalDevice.lastHost, + lastPort: hostService.getPort(), + }); + hostService.refreshLanDiscovery?.(); + return; + } + const localDevice = deviceRegistryService.ensureLocalDevice(); + const preferredPort = localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT; + let lastError: unknown = null; + for (const attemptedPort of buildHostPortCandidates(preferredPort)) { + const candidateHostService = createSyncHostService({ + db: args.db, + logger: args.logger, + projectId: args.projectId ?? null, + projectRoot: args.projectRoot, + fileService: args.fileService, + laneService: args.laneService, + gitService: args.gitService, + diffService: args.diffService, + conflictService: args.conflictService, + prService: args.prService, + issueInventoryService: args.issueInventoryService, + pathToMergeOrchestrator: args.pathToMergeOrchestrator, + queueLandingService: args.queueLandingService, + sessionService: args.sessionService, + ptyService: args.ptyService, + processService: args.processService, + agentChatService: args.agentChatService, + workerAgentService: args.workerAgentService, + workerBudgetService: args.workerBudgetService, + workerHeartbeatService: args.workerHeartbeatService, + workerRevisionService: args.workerRevisionService, + ctoStateService: args.ctoStateService, + flowPolicyService: args.flowPolicyService, + linearCredentialService: args.linearCredentialService, + getLinearIngressService: args.getLinearIngressService, + getLinearIssueTracker: args.getLinearIssueTracker, + getLinearSyncService: args.getLinearSyncService, + projectConfigService: args.projectConfigService, + portAllocationService: args.portAllocationService, + laneEnvironmentService: args.laneEnvironmentService, + laneTemplateService: args.laneTemplateService, + rebaseSuggestionService: args.rebaseSuggestionService ?? undefined, + autoRebaseService: args.autoRebaseService ?? undefined, + computerUseArtifactBrokerService: args.computerUseArtifactBrokerService, + pinStore, + bootstrapTokenPath: tokenPath, + pairingSecretsPath, + port: attemptedPort, + discoveryEnabled: hostDiscoveryEnabled, + runtimeKind: args.runtimeKind ?? "desktop-embedded", + runtimeVersion: args.appVersion ?? "", + deviceRegistryService, + notificationEventBus: args.notificationEventBus ?? null, + projectCatalogProvider: args.projectCatalogProvider, + remoteCommandService, + remoteCommandExecutor: args.remoteCommandExecutor, + onStateChanged: () => { + void refreshRoleState(); + }, + }); + try { + const resolvedPort = await candidateHostService.waitUntilListening(); + hostService = candidateHostService; + hostService.setLocalActiveLanePresence?.(activeLocalLanePresenceIds); + deviceRegistryService.touchLocalDevice({ + lastSeenAt: nowIso(), + lastHost: localDevice.ipAddresses[0] ?? localDevice.tailscaleIp ?? localDevice.lastHost, + lastPort: resolvedPort, + }); + return; + } catch (error) { + lastError = error; + await candidateHostService.dispose().catch(() => {}); + const retryable = isRetryableHostBindError(error) && attemptedPort !== 0; + args.logger.warn( + retryable ? "sync.host_start_port_conflict" : "sync.host_start_failed", + { + preferredPort, + attemptedPort, + error: error instanceof Error ? error.message : String(error), + code: (error as NodeJS.ErrnoException | null | undefined)?.code ?? null, + }, + ); + if (!retryable) { + throw error; + } + } + } + throw lastError instanceof Error + ? lastError + : new Error("Unable to start the sync host."); + }; + + const stopHostIfRunning = async (): Promise => { + if (!hostService) return; + const current = hostService; + hostService = null; + await current.dispose(); + }; + + const resolveViewerDraftFromRegistry = + (): SyncDesktopConnectionDraft | null => { + if (forceHostRole) return null; + const cluster = deviceRegistryService.getClusterState(); + const token = readToken(); + if (!cluster || !token) return null; + const brain = deviceRegistryService.getDevice(cluster.brainDeviceId); + const host = + brain != null ? buildAddressCandidates(brain)[0]?.host ?? null : null; + const port = brain?.lastPort ?? DEFAULT_SYNC_HOST_PORT; + if (!host) return null; + return { + host, + port, + token, + lastRemoteDbVersion: + syncPeerService.getStatus().lastRemoteDbVersion ?? 0, + }; + }; + + const refreshRoleState = async (): Promise => { + if (disposed) return; + if (refreshRunning) { + refreshQueued = true; + return; + } + refreshRunning = true; + try { + do { + refreshQueued = false; + const savedDraft = readSavedDraft(); + syncPeerService.setSavedDraft(savedDraft); + const localDevice = deviceRegistryService.ensureLocalDevice(); + let cluster = deviceRegistryService.getClusterState(); + if (forceHostRole) { + if (!cluster || cluster.brainDeviceId !== localDevice.deviceId) { + cluster = deviceRegistryService.setClusterState({ + brainDeviceId: localDevice.deviceId, + brainEpoch: (cluster?.brainEpoch ?? 0) + 1, + updatedByDeviceId: localDevice.deviceId, + }); + } + } else if (!cluster && !savedDraft) { + cluster = deviceRegistryService.bootstrapLocalBrainIfNeeded(); + } + const isLocalBrain = forceHostRole || (cluster + ? cluster.brainDeviceId === localDevice.deviceId + : !savedDraft); + if (isLocalBrain) { + if (syncPeerService.isConnected()) { + syncPeerService.disconnect({ preserveDraft: true }); + } + await startHostIfNeeded(); + } else { + await stopHostIfRunning(); + if (!isCrdtSyncAvailable()) { + if (syncPeerService.isConnected()) { + syncPeerService.disconnect({ preserveDraft: true }); + } + continue; + } + const draft = savedDraft ?? resolveViewerDraftFromRegistry(); + if (draft && !syncPeerService.isConnected()) { + syncPeerService.setSavedDraft(draft); + try { + await syncPeerService.connect(draft); + deviceRegistryService.touchLocalDevice({ lastSeenAt: nowIso() }); + syncPeerService.flushLocalChanges(); + } catch (error) { + args.logger.warn("sync.role.viewer_connect_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + } while (refreshQueued); + } finally { + refreshRunning = false; + await emitStatus(); + } + }; + + const listRuntimeDevices = async (): Promise => { + const devices = deviceRegistryService.listDevices(); + const cluster = deviceRegistryService.getClusterState(); + const currentBrainId = cluster?.brainDeviceId ?? null; + const peerStates = hostService + ? hostService.getPeerStates() + : (syncPeerService.getLatestBrainStatus()?.connectedPeers ?? []); + const localDeviceId = deviceRegistryService.getLocalDeviceId(); + return devices.map((device) => { + const peer = + peerStates.find((entry) => entry.deviceId === device.deviceId) ?? null; + const isLocal = device.deviceId === localDeviceId; + return { + ...device, + isLocal, + isBrain: device.deviceId === currentBrainId, + connectionState: isLocal ? "self" : peer ? "connected" : "disconnected", + connectedAt: peer?.connectedAt ?? null, + lastAppliedAt: peer?.lastAppliedAt ?? null, + remoteAddress: peer?.remoteAddress ?? null, + remotePort: peer?.remotePort ?? null, + latencyMs: peer?.latencyMs ?? null, + syncLag: peer?.syncLag ?? null, + }; + }); + }; + + const computeTransferReadiness = async (): Promise => { + const blockers: SyncTransferBlocker[] = []; + + for (const mission of args.missionService.list({ + status: "active", + limit: 200, + })) { + blockers.push({ + kind: "mission_run", + id: mission.id, + label: mission.title || mission.id, + detail: `Mission is ${mission.status}. Paused missions can transfer, but active mission work cannot.`, + }); + } + + const chats = await args.agentChatService.listSessions(undefined, { + includeIdentity: true, + includeAutomation: true, + }); + const chatSummaries = new Map( + chats.map((chat) => [chat.sessionId, chat] as const), + ); + + for (const session of args.sessionService.list({ + status: "running", + limit: 500, + })) { + if (CHAT_TOOL_TYPES.has(session.toolType ?? "")) { + const chat = chatSummaries.get(session.id); + const isCto = chat?.identityKey === "cto"; + blockers.push({ + kind: "chat_runtime", + id: session.id, + label: chat?.title || (isCto ? "CTO thread" : session.title), + detail: isCto + ? "A running CTO turn must stop before handoff. CTO history and idle threads still transfer." + : "Live chat sessions do not hot-transfer. Let the turn finish or interrupt it first.", + }); + continue; + } + blockers.push({ + kind: "terminal_session", + id: session.id, + label: session.title, + detail: + "Running terminal sessions must stop before the host role can move.", + }); + } + + const lanes = args.db.all<{ id: string }>( + "select id from lanes where status != 'archived'", + ); + for (const lane of lanes) { + for (const runtime of args.processService.listRuntime(lane.id)) { + if (!RUNNING_PROCESS_STATES.has(runtime.status)) continue; + blockers.push({ + kind: "managed_process", + id: `${lane.id}:${runtime.processId}`, + label: runtime.processId, + detail: + "Managed run processes must stop before the host role can move.", + }); + } + } + + return { + ready: blockers.length === 0, + blockers, + survivableState: [ + "Paused missions remain paused and can resume on the new host.", + "CTO history and idle threads remain available on the new host.", + "Idle and ended agent chats remain available and resumable on the new host.", + ], + }; + }; + + const getTransferReadiness = async (options?: { force?: boolean }): Promise => { + const now = Date.now(); + if (!options?.force && transferReadinessCache && transferReadinessCache.expiresAtMs > now) { + return transferReadinessCache.value; + } + // `force` should skip the cached value but still share the in-flight + // promise — otherwise overlapping forced callers each spawn their own + // computeTransferReadiness() run. + if (transferReadinessInFlight) return transferReadinessInFlight; + transferReadinessInFlight = computeTransferReadiness() + .then((value) => { + transferReadinessCache = { + value, + expiresAtMs: Date.now() + TRANSFER_READINESS_CACHE_MS, + }; + return value; + }) + .finally(() => { + transferReadinessInFlight = null; + }); + return transferReadinessInFlight; + }; + + const service = { + async initialize(): Promise { + if (initialized) return; + if (initializingPromise) return initializingPromise; + initializingPromise = (async () => { + deviceRegistryService.ensureLocalDevice(); + await refreshRoleState(); + initialized = true; + })().finally(() => { + initializingPromise = null; + }); + return initializingPromise; + }, + + async getStatus(options?: SyncGetStatusArgs): Promise { + const localDevice = deviceRegistryService.ensureLocalDevice(); + const cluster = deviceRegistryService.getClusterState(); + const savedDraft = readSavedDraft(); + const currentBrain = cluster + ? deviceRegistryService.getDevice(cluster.brainDeviceId) + : localDevice; + const isLocalBrain = forceHostRole || (cluster + ? cluster.brainDeviceId === localDevice.deviceId + : !savedDraft && !syncPeerService.isConnected()); + const role = isLocalBrain ? "brain" : "viewer"; + const crdtSyncAvailable = isCrdtSyncAvailable(); + const canHostPhonePairing = role === "brain" && hostStartupEnabled && crdtSyncAvailable; + const client = syncPeerService.getStatus(); + const mode = + role === "viewer" + ? "viewer" + : client.state === "connected" + ? "brain" + : "standalone"; + return { + mode, + role, + localDevice, + currentBrain, + clusterState: cluster, + bootstrapToken: + canHostPhonePairing ? readToken() : null, + pairingPin: canHostPhonePairing ? pinStore.getPin() : null, + pairingPinConfigured: canHostPhonePairing ? pinStore.hasPin() : false, + pairingConnectInfo: + canHostPhonePairing + ? buildPairingConnectInfo({ localDevice }) + : null, + connectedPeers: hostService + ? hostService.getPeerStates() + : (syncPeerService.getLatestBrainStatus()?.connectedPeers ?? []), + tailnetDiscovery: canHostPhonePairing && hostService + ? hostService.getTailnetDiscoveryStatus() + : createInactiveTailnetDiscoveryStatus( + canHostPhonePairing + ? "Tailnet discovery is waiting for the machine sync host to start." + : "Tailnet discovery is only published by the host machine.", + ), + client, + transferReadiness: options?.includeTransferReadiness === false + ? (transferReadinessCache?.value ?? buildSkippedTransferReadiness()) + : await getTransferReadiness({ force: options?.forceTransferReadiness === true }), + survivableStateText: + crdtSyncAvailable + ? "Paused and idle state will remain available on the new host." + : "Machine sync is disabled because the CRDT database extension is unavailable on this platform.", + blockingStateText: + crdtSyncAvailable + ? "Live missions, chats, terminals, or run processes must stop first." + : "Install Windows cr-sqlite support before pairing or syncing devices.", + }; + }, + + async listDevices(): Promise { + return await listRuntimeDevices(); + }, + + async refreshDiscovery(): Promise { + hostService?.refreshLanDiscovery?.({ forceTailnet: true }); + const snapshot = await this.getStatus(); + args.onStatusChanged?.(snapshot); + return snapshot; + }, + + setHostDiscoveryEnabled(enabled: boolean): void { + if (hostDiscoveryEnabled === enabled) return; + hostDiscoveryEnabled = enabled; + hostService?.setDiscoveryEnabled(enabled); + void emitStatus(); + }, + + async setHostStartupEnabled(enabled: boolean): Promise { + if (hostStartupEnabled === enabled) return; + hostStartupEnabled = enabled; + await refreshRoleState(); + }, + + async updateLocalDevice(argsIn: { + name?: string; + deviceType?: "desktop" | "phone" | "vps" | "unknown"; + }) { + const updated = deviceRegistryService.updateLocalDevice(argsIn); + hostService?.setLocalActiveLanePresence(activeLocalLanePresenceIds); + await emitStatus(); + return updated; + }, + + async connectToBrain( + draft: SyncDesktopConnectionDraft, + ): Promise { + if (!isCrdtSyncAvailable()) { + throw new Error("Machine sync is unavailable because the CRDT database extension is not loaded."); + } + await stopHostIfRunning(); + deviceRegistryService.clearClusterRegistryForViewerJoin(); + writeSavedDraft(draft); + syncPeerService.setSavedDraft(draft); + try { + await syncPeerService.connect(draft); + deviceRegistryService.touchLocalDevice({ lastSeenAt: nowIso() }); + syncPeerService.flushLocalChanges(); + await sleep(150); + await refreshRoleState(); + return await this.getStatus(); + } catch (error) { + writeSavedDraft(null); + syncPeerService.setSavedDraft(null); + await refreshRoleState(); + throw error; + } + }, + + async disconnectFromBrain(): Promise { + syncPeerService.disconnect(); + writeSavedDraft(null); + deviceRegistryService.clearClusterRegistryForViewerJoin(); + await refreshRoleState(); + return await this.getStatus(); + }, + + getPin(): string | null { + return pinStore.getPin(); + }, + + async setPin(pin: string): Promise { + assertPhonePairingAvailable(); + const current = await service.getStatus(); + if (current.role !== "brain") { + throw new Error("Phone pairing PINs can only be managed on the host machine."); + } + pinStore.setPin(pin); + const snapshot = await service.getStatus(); + args.onStatusChanged?.(snapshot); + return snapshot; + }, + + async generatePin(): Promise { + return await service.setPin(generatePairingPin()); + }, + + async clearPin(): Promise { + assertPhonePairingAvailable(); + const current = await service.getStatus(); + if (current.role !== "brain") { + throw new Error("Phone pairing PINs can only be managed on the host machine."); + } + pinStore.clearPin(); + const snapshot = await service.getStatus(); + args.onStatusChanged?.(snapshot); + return snapshot; + }, + + async setActiveLanePresence(laneIds: string[]): Promise { + const normalized = Array.isArray(laneIds) + ? [...new Set( + laneIds + .map((laneId) => (typeof laneId === "string" ? laneId.trim() : "")) + .filter((laneId) => laneId.length > 0), + )] + : []; + activeLocalLanePresenceIds = normalized; + hostService?.setLocalActiveLanePresence(activeLocalLanePresenceIds); + }, + + async forgetDevice(deviceId: string): Promise { + hostService?.revokePairedDevice(deviceId); + deviceRegistryService.forgetDevice(deviceId); + await emitStatus(); + return await this.getStatus(); + }, + + async getTransferReadiness(): Promise { + return await getTransferReadiness({ force: true }); + }, + + async transferBrainToLocal(): Promise { + const current = await this.getStatus({ forceTransferReadiness: true }); + if (current.role === "brain") return current; + if (!current.transferReadiness.ready) { + throw new Error( + "Stop live missions, chats, terminals, and run processes before transferring the host role.", + ); + } + const localDevice = deviceRegistryService.ensureLocalDevice(); + const currentCluster = deviceRegistryService.getClusterState(); + deviceRegistryService.touchLocalDevice({ + lastSeenAt: nowIso(), + lastHost: localDevice.lastHost, + lastPort: localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT, + }); + deviceRegistryService.setClusterState({ + brainDeviceId: localDevice.deviceId, + brainEpoch: (currentCluster?.brainEpoch ?? 0) + 1, + updatedByDeviceId: localDevice.deviceId, + }); + syncPeerService.flushLocalChanges(); + await sleep(300); + await refreshRoleState(); + return await this.getStatus(); + }, + + handlePtyData( + event: Parameters[0], + ): void { + hostService?.handlePtyData(event); + }, + + handlePtyExit( + event: Parameters[0], + ): void { + hostService?.handlePtyExit(event); + }, + + getHostService(): SyncHostService | null { + return hostService; + }, + + getRemoteCommandDescriptor(action: string) { + return remoteCommandService.getDescriptor(action); + }, + + async executeRemoteCommand(payload: Parameters[0]): Promise { + return await remoteCommandService.execute(payload); + }, + + getDeviceRegistryService() { + return deviceRegistryService; + }, + + async dispose(): Promise { + disposed = true; + syncPeerService.disconnect(); + clearInterval(localLanePresenceHeartbeatTimer); + await stopHostIfRunning(); + await syncPeerService.dispose(); + }, + }; + + return service; +} + +export type SyncService = ReturnType; diff --git a/apps/ade-cli/src/stdioRpcDaemon.test.ts b/apps/ade-cli/src/stdioRpcDaemon.test.ts new file mode 100644 index 000000000..55fde0a39 --- /dev/null +++ b/apps/ade-cli/src/stdioRpcDaemon.test.ts @@ -0,0 +1,297 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +type JsonRpcResponse = { + id?: number; + result?: unknown; + error?: { + message?: string; + }; +}; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +function withTsxNodeOptions(value: string | undefined): string { + const existing = value?.trim(); + return existing ? `${existing} --import tsx` : "--import tsx"; +} + +async function waitForSocket(socketPath: string, timeoutMs = 10_000): Promise { + const startedAt = Date.now(); + let lastError: Error | null = null; + while (Date.now() - startedAt < timeoutMs) { + try { + await new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath); + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error(`Timed out connecting to ${socketPath}`)); + }, 500); + socket.once("connect", () => { + clearTimeout(timer); + socket.destroy(); + resolve(); + }); + socket.once("error", (error) => { + clearTimeout(timer); + socket.destroy(); + reject(error); + }); + }); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + throw lastError ?? new Error(`ADE runtime socket did not become available: ${socketPath}`); +} + +function startServeProcess(args: { + cliPath: string; + cwd: string; + env: NodeJS.ProcessEnv; + socketPath: string; +}): ChildProcessWithoutNullStreams { + return spawn(process.execPath, [args.cliPath, "serve", "--socket", args.socketPath, "--no-sync"], { + cwd: args.cwd, + env: args.env, + stdio: ["pipe", "pipe", "pipe"], + }); +} + +class StdioRpcProcess { + private nextId = 1; + private stdout = ""; + private stderr = ""; + private readonly pending = new Map(); + + constructor(private readonly child: ChildProcessWithoutNullStreams) { + child.stdout.on("data", (chunk) => this.handleStdout(chunk.toString("utf8"))); + child.stderr.on("data", (chunk) => { + this.stderr += chunk.toString("utf8"); + }); + child.once("exit", (code, signal) => { + const error = new Error(`ADE stdio RPC process exited before response: code=${code} signal=${signal} stderr=${this.stderr.trim()}`); + for (const [id, pending] of this.pending) { + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(error); + } + }); + } + + static start(args: { + cliPath: string; + cwd: string; + env: NodeJS.ProcessEnv; + }): StdioRpcProcess { + return new StdioRpcProcess(spawn(process.execPath, [args.cliPath, "rpc", "--stdio"], { + cwd: args.cwd, + env: args.env, + stdio: ["pipe", "pipe", "pipe"], + })); + } + + request(method: string, params?: unknown): Promise { + const id = this.nextId++; + const payload = { + jsonrpc: "2.0", + id, + method, + ...(params !== undefined ? { params } : {}), + }; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Timed out waiting for ${method}. stderr=${this.stderr.trim()}`)); + }, 15_000); + this.pending.set(id, { resolve, reject, timer }); + this.child.stdin.write(`${JSON.stringify(payload)}\n`, "utf8", (error) => { + if (!error) return; + this.pending.delete(id); + clearTimeout(timer); + reject(error); + }); + }); + } + + closeInput(): void { + this.child.stdin.end(); + } + + waitForExit(): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`ADE stdio RPC process did not exit. stderr=${this.stderr.trim()}`)); + }, 15_000); + this.child.once("exit", (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }); + }); + } + + kill(): void { + try { + this.child.kill(); + } catch { + // Best-effort cleanup. + } + } + + private handleStdout(chunk: string): void { + this.stdout += chunk; + while (true) { + const newline = this.stdout.indexOf("\n"); + if (newline < 0) return; + const line = this.stdout.slice(0, newline).trim(); + this.stdout = this.stdout.slice(newline + 1); + if (!line) continue; + let parsed: JsonRpcResponse; + try { + parsed = JSON.parse(line) as JsonRpcResponse; + } catch { + continue; + } + if (typeof parsed.id !== "number") continue; + const pending = this.pending.get(parsed.id); + if (!pending) continue; + this.pending.delete(parsed.id); + clearTimeout(pending.timer); + if (parsed.error) { + pending.reject(new Error(parsed.error.message ?? "ADE JSON-RPC request failed.")); + } else { + pending.resolve(parsed.result); + } + } + } +} + +const itUnix = process.platform === "win32" ? it.skip : it; + +describe("ade rpc --stdio daemon bridge", () => { + itUnix("keeps the machine runtime alive after the stdio client exits", async () => { + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const cliPath = path.join(packageRoot, "src", "cli.ts"); + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-project-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const env = { + ...process.env, + ADE_HOME: adeHome, + ADE_RUNTIME_SOCKET_PATH: socketPath, + NODE_OPTIONS: withTsxNodeOptions(process.env.NODE_OPTIONS), + }; + + let first: StdioRpcProcess | null = null; + let second: StdioRpcProcess | null = null; + try { + first = StdioRpcProcess.start({ cliPath, cwd: packageRoot, env }); + const initialize = await first.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "stdio-daemon-test-1", + identity: { role: "external", callerId: "stdio-daemon-test-1" }, + }); + const project = await first.request("projects.add", { rootPath: projectRoot }); + + first.closeInput(); + await expect(first.waitForExit()).resolves.toMatchObject({ code: 0, signal: null }); + + second = StdioRpcProcess.start({ cliPath, cwd: packageRoot, env }); + await second.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "stdio-daemon-test-2", + identity: { role: "external", callerId: "stdio-daemon-test-2" }, + }); + const persisted = await second.request("projects.list"); + + expect(initialize).toMatchObject({ + runtimeInfo: { + multiProject: true, + }, + }); + expect(project).toMatchObject({ + rootPath: projectRoot, + }); + expect(Array.isArray(persisted)).toBe(true); + expect((persisted as Array<{ projectId?: string }>)).toContainEqual( + expect.objectContaining({ + projectId: (project as { projectId: string }).projectId, + }), + ); + + await expect(second.request("shutdown")).resolves.toEqual({}); + second.closeInput(); + await expect(second.waitForExit()).resolves.toMatchObject({ code: 0, signal: null }); + } finally { + first?.kill(); + second?.kill(); + } + }, 45_000); + + itUnix("restarts a stale daemon before bridging stdio requests", async () => { + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const cliPath = path.join(packageRoot, "src", "cli.ts"); + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-version-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const baseEnv = { + ...process.env, + ADE_HOME: adeHome, + ADE_RUNTIME_SOCKET_PATH: socketPath, + NODE_OPTIONS: withTsxNodeOptions(process.env.NODE_OPTIONS), + }; + const oldDaemon = startServeProcess({ + cliPath, + cwd: packageRoot, + env: { + ...baseEnv, + ADE_CLI_VERSION: "1.0.0", + }, + socketPath, + }); + + let proxy: StdioRpcProcess | null = null; + try { + await waitForSocket(socketPath); + + proxy = StdioRpcProcess.start({ + cliPath, + cwd: packageRoot, + env: { + ...baseEnv, + ADE_CLI_VERSION: "2.0.0", + }, + }); + const initialize = await proxy.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "stdio-daemon-version-test", + identity: { role: "external", callerId: "stdio-daemon-version-test" }, + }); + + expect(initialize).toMatchObject({ + runtimeInfo: { + version: "2.0.0", + multiProject: true, + }, + }); + + await expect(proxy.request("shutdown")).resolves.toEqual({}); + proxy.closeInput(); + await expect(proxy.waitForExit()).resolves.toMatchObject({ code: 0, signal: null }); + } finally { + proxy?.kill(); + if (!oldDaemon.killed) oldDaemon.kill(); + } + }, 45_000); +}); diff --git a/apps/ade-cli/src/transports/stdioTransport.ts b/apps/ade-cli/src/transports/stdioTransport.ts new file mode 100644 index 000000000..fa543bafa --- /dev/null +++ b/apps/ade-cli/src/transports/stdioTransport.ts @@ -0,0 +1,18 @@ +import { Buffer } from "node:buffer"; +import type { JsonRpcTransport } from "../jsonrpc"; + +export function createStdioTransport(): JsonRpcTransport { + return { + onData(callback) { + process.stdin.on("data", (chunk) => { + callback(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + }, + write(data) { + process.stdout.write(data); + }, + close() { + process.stdin.pause(); + }, + }; +} diff --git a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx new file mode 100644 index 000000000..a02ac34ab --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx @@ -0,0 +1,162 @@ +import React from "react"; +import { describe, expect, it } from "vitest"; +import { render } from "ink-testing-library"; +import { ChatView } from "../components/ChatView"; +import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; + +const session: AgentChatSessionSummary = { + sessionId: "s1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + status: "idle", + startedAt: "2026-01-01T12:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-01-01T12:00:00.000Z", + lastOutputPreview: null, + summary: null, +}; + +function renderEvents( + events: AgentChatEventEnvelope[], + options: { maxRows?: number; scrollOffsetRows?: number; width?: number } = {}, +): string { + const result = render( + , + ); + return result.lastFrame() ?? ""; +} + +describe("ChatView", () => { + it("renders a bordered hero card with the ADE wordmark when the chat is empty", () => { + const frame = renderEvents([]); + expect(frame).toMatch(/[╭╮╯╰]/); + expect(frame).toContain("██████"); + expect(frame).toContain("ade code"); + expect(frame).toContain("v0.1"); + expect(frame).toContain("Project"); + expect(frame).toContain("Lane"); + expect(frame).toContain("Branch"); + expect(frame).toContain("Primary"); + expect(frame).toContain("type to chat"); + expect(frame).toContain("commands"); + }); + + it("right-aligns user messages inside an accent-bordered bubble", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "user_message", text: "hello" }, + }, + ]); + const lines = frame.split(/\r?\n/); + const bubbleLine = lines.find((line) => line.includes("hello")); + expect(bubbleLine, "expected the rendered frame to include the user message").toBeDefined(); + // Round border characters wrap the bubble; verify presence so layout stays a bubble. + expect(frame).toMatch(/[╭╮╯╰]/); + // Bubble is right-aligned: the content sits past the half-width of the frame. + const helloIndex = (bubbleLine ?? "").indexOf("hello"); + expect(helloIndex).toBeGreaterThan(0); + }); + + it("renders assistant messages flat without the bubble border", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "text", text: "I'm Codex." }, + }, + ]); + expect(frame).toContain("I'm Codex."); + // No round-border glyphs in an assistant-only frame. + expect(frame).not.toMatch(/[╭╮╯╰]/); + }); + + it("renders markdown-like assistant output into readable blocks", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "text", + text: [ + "## Fix plan", + "", + "- Trace commands", + "1. Patch renderer", + "", + "```ts", + "const ok = true;", + "```", + ].join("\n"), + }, + }, + ], { width: 60 }); + expect(frame).toContain("Fix plan"); + expect(frame).toContain("• Trace commands"); + expect(frame).toContain("1. Patch renderer"); + expect(frame).toContain("│ const ok = true;"); + }); + + it("wraps long assistant paragraphs to the supplied width", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "text", text: "This paragraph should wrap cleanly across more than one terminal row instead of flattening into an unreadable single line." }, + }, + ], { width: 42 }); + expect(frame).toContain("This paragraph should wrap cleanly"); + expect(frame).toContain("across more than one terminal row"); + }); + + it("shows the bottom viewport by default and older rows when scrolled", () => { + const events = Array.from({ length: 12 }, (_, index): AgentChatEventEnvelope => ({ + sessionId: "s1", + timestamp: `2026-01-01T12:00:${String(index).padStart(2, "0")}.000Z`, + sequence: index + 1, + event: index % 2 === 0 + ? { type: "user_message", text: `user row ${index + 1}` } + : { type: "text", text: `assistant row ${index + 1}` }, + })); + const bottom = renderEvents(events, { maxRows: 5, width: 80 }); + expect(bottom).toContain("assistant row 12"); + expect(bottom).not.toContain("user row 1"); + expect(bottom).toContain("↑ older messages"); + + const older = renderEvents(events, { maxRows: 5, scrollOffsetRows: 8, width: 80 }); + expect(older).toContain("row"); + expect(older).toContain("↓ newer messages"); + expect(older).not.toContain("assistant row 12"); + }); + + it("indents tool call output", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "command", command: "git branch", cwd: "/repo", output: "main", itemId: "cmd-1", status: "completed", exitCode: 0, durationMs: 12 }, + }, + ]); + const lines = frame.split(/\r?\n/).filter((line) => line.includes("run git branch")); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(line.startsWith(" ")).toBe(true); + } + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx new file mode 100644 index 000000000..8b0242efa --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { describe, expect, it } from "vitest"; +import { render } from "ink-testing-library"; +import { RightPane } from "../components/RightPane"; +import type { ProviderReadinessRow, RightPaneContent, SetupPaneRow } from "../types"; + +const setupRows: SetupPaneRow[] = [ + { kind: "provider", label: "Provider", value: "Codex", cyclable: true }, + { kind: "model", label: "Model", value: "GPT-5.5", cyclable: true, detail: "5 available" }, + { kind: "reasoning", label: "Reasoning", value: "medium", cyclable: true, detail: "low, medium, high" }, + { kind: "permission", label: "Permissions", value: "default", cyclable: true }, + { kind: "codex-fast", label: "Fast mode", value: "off", cyclable: true, detail: "Codex service tier" }, + { kind: "refresh-status", label: "Refresh status", value: "run", detail: "checks provider auth/runtime state" }, + { kind: "open-settings", label: "Full settings", value: "open desktop", detail: "Settings > AI Providers" }, +]; + +const providerRows: ProviderReadinessRow[] = [ + { provider: "codex", label: "Codex", status: "ready", detail: "ready at /usr/local/bin/codex", modelCount: 6 }, + { provider: "claude", label: "Claude", status: "ready", detail: "ready at /usr/local/bin/claude", modelCount: 4 }, + { provider: "cursor", label: "Cursor", status: "unknown", detail: "API key store not yet readable", modelCount: 0 }, + { provider: "droid", label: "Droid", status: "unavailable", detail: "no Factory Droid CLI or FACTORY_API_KEY", modelCount: 0 }, + { provider: "opencode", label: "OpenCode", status: "ready", detail: "user-installed · 0 shared runtime", modelCount: 4442 }, +]; + +function content(overrides: Partial> = {}): RightPaneContent { + return { + kind: "model-setup", + rows: setupRows, + providerRows, + activeProvider: "codex", + checkedAt: "2026-05-09T19:57:09.000Z", + desktopAttached: true, + ...overrides, + }; +} + +function renderModelSetup(selectedIndex: number, overrides: Partial> = {}): string { + const result = render( + , + ); + return result.lastFrame() ?? ""; +} + +describe("RightPane model-setup", () => { + it("renders MODEL and PROVIDERS section headers", () => { + const frame = renderModelSetup(0); + expect(frame).toContain("MODEL"); + expect(frame).toContain("PROVIDERS"); + }); + + it("shows ‹ › chevron on cyclable setup rows and ↵ on action rows", () => { + const frame = renderModelSetup(0); + expect(frame).toContain("‹ ›"); + expect(frame).toContain("↵"); + }); + + it("renders all five providers with their brand glyphs", () => { + const frame = renderModelSetup(0); + expect(frame).toContain("◇ Codex"); + expect(frame).toContain("◆ Claude"); + expect(frame).toContain("▲ Cursor"); + expect(frame).toContain("▣ Droid"); + expect(frame).toContain("◈ OpenCode"); + }); + + it("collapses provider detail when no provider row is selected", () => { + const frame = renderModelSetup(0); + expect(frame).not.toContain("4 models"); + expect(frame).not.toContain("/usr/local/bin/claude"); + }); + + it("expands provider detail when its row is selected", () => { + const claudeIndex = setupRows.length + 1; + const frame = renderModelSetup(claudeIndex); + expect(frame).toContain("4 models"); + expect(frame).toContain("/usr/local/bin/claude"); + }); + + it("renders the footer with checked time and key hints", () => { + const frame = renderModelSetup(0); + expect(frame).toContain("19:57:09"); + expect(frame).toContain("↑↓"); + expect(frame).toContain("←→"); + expect(frame).toContain("enter"); + }); + + it("marks the active provider in the providers list", () => { + const frame = renderModelSetup(0); + expect(frame).toMatch(/◇ Codex.*active/); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts new file mode 100644 index 000000000..7d191c4c7 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -0,0 +1,251 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; +import { createChatSession, DEFAULT_CODEX_REASONING_EFFORT, discoverProjectSlashCommands, latestTokenStats, sendChatMessage } from "../adeApi"; +import type { AdeCodeConnection } from "../types"; + +const tmpPaths: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const tmpPath of tmpPaths.splice(0)) { + fs.rmSync(tmpPath, { recursive: true, force: true }); + } +}); + +function makeTmpRoot(prefix: string): string { + const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tmpPaths.push(tmpPath); + return tmpPath; +} + +function envelope( + sequence: number, + event: AgentChatEventEnvelope["event"], +): AgentChatEventEnvelope { + return { + sessionId: "s1", + timestamp: `2026-01-01T12:00:0${sequence}.000Z`, + sequence, + event, + }; +} + +describe("latestTokenStats", () => { + it("tracks streaming state, context percentage, token counts, and cost", () => { + const events = [ + envelope(1, { type: "status", turnStatus: "started" }), + envelope(2, { + type: "tokens", + turnId: "turn-1", + inputTokens: 2_000, + outputTokens: 500, + contextWindow: 10_000, + } as AgentChatEventEnvelope["event"]), + envelope(3, { + type: "done", + turnId: "turn-1", + status: "completed", + usage: { inputTokens: 2_100, outputTokens: 700 }, + costUsd: 0.42, + }), + ]; + + expect(latestTokenStats(events)).toEqual({ + percent: 28, + streaming: false, + inputTokens: 2_100, + outputTokens: 700, + costUsd: 0.42, + }); + }); + + it("falls back to the active model contextWindow when the event omits one", () => { + const events = [ + envelope(1, { type: "status", turnStatus: "started" }), + envelope(2, { + type: "done", + turnId: "turn-1", + status: "completed", + usage: { inputTokens: 40_000, outputTokens: 10_000 }, + costUsd: 0.12, + }), + ]; + + expect(latestTokenStats(events, 200_000)).toEqual({ + percent: 25, + streaming: false, + inputTokens: 40_000, + outputTokens: 10_000, + costUsd: 0.12, + }); + }); + + it("returns null percent when no contextWindow is available", () => { + const events = [ + envelope(1, { + type: "done", + turnId: "turn-1", + status: "completed", + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ]; + expect(latestTokenStats(events).percent).toBeNull(); + }); +}); + +describe("discoverProjectSlashCommands", () => { + it("prefers project .claude command metadata over same-named global Codex prompts", () => { + const projectRoot = makeTmpRoot("ade-code-project-commands-"); + const homeRoot = makeTmpRoot("ade-code-home-prompts-"); + vi.spyOn(os, "homedir").mockReturnValue(homeRoot); + const commandsDir = path.join(projectRoot, ".claude", "commands"); + const promptsDir = path.join(homeRoot, ".codex", "prompts"); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, "automate.md"), [ + "---", + "description: Project ADE automate", + "---", + "", + "Run project automate.", + "", + ].join("\n")); + fs.writeFileSync(path.join(promptsDir, "automate.md"), "# Global Codex automate\n"); + + const commands = discoverProjectSlashCommands(projectRoot); + expect(commands.filter((command) => command.name.toLowerCase() === "/automate")).toHaveLength(1); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/automate", + description: "Project ADE automate", + }), + ])); + }); + + it("hides login commands regardless of project command filename casing", () => { + const projectRoot = makeTmpRoot("ade-code-login-command-"); + const commandsDir = path.join(projectRoot, ".claude", "commands"); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, "Login.md"), [ + "---", + "description: Case variant login", + "---", + "", + "Login.", + "", + ].join("\n")); + fs.writeFileSync(path.join(commandsDir, "ship.md"), "Ship.\n"); + + const commands = discoverProjectSlashCommands(projectRoot); + expect(commands.some((command) => command.name.toLowerCase() === "/login")).toBe(false); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "/ship" }), + ])); + }); +}); + +describe("createChatSession", () => { + it("defaults Codex chats to GPT-5.5 low reasoning", async () => { + const calls: Array<{ domain: string; action: string; args?: Record }> = []; + const connection = { + action: async (domain: string, action: string, args?: Record) => { + calls.push({ domain, action, args }); + return { + id: "chat-1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + status: "idle", + createdAt: "2026-01-01T00:00:00.000Z", + lastActivityAt: "2026-01-01T00:00:00.000Z", + }; + }, + } as unknown as AdeCodeConnection; + + await createChatSession({ connection, laneId: "lane-1" }); + + expect(calls).toEqual([ + expect.objectContaining({ + domain: "chat", + action: "createSession", + args: expect.objectContaining({ + provider: "codex", + model: "gpt-5.5", + modelId: "openai/gpt-5.5", + reasoningEffort: DEFAULT_CODEX_REASONING_EFFORT, + surface: "work", + }), + }), + ]); + }); + + it("passes native model controls when creating chats", async () => { + const calls: Array<{ domain: string; action: string; args?: Record }> = []; + const connection = { + action: async (domain: string, action: string, args?: Record) => { + calls.push({ domain, action, args }); + return { + id: "chat-1", + laneId: "lane-1", + provider: args?.provider, + model: args?.model, + status: "idle", + createdAt: "2026-01-01T00:00:00.000Z", + lastActivityAt: "2026-01-01T00:00:00.000Z", + }; + }, + } as unknown as AdeCodeConnection; + + await createChatSession({ + connection, + laneId: "lane-1", + provider: "codex", + modelId: "openai/gpt-5.5", + reasoningEffort: "high", + codexFastMode: true, + permissionMode: "plan", + codexApprovalPolicy: "on-request", + codexSandbox: "read-only", + codexConfigSource: "flags", + }); + + expect(calls[0]?.args).toEqual(expect.objectContaining({ + provider: "codex", + model: "gpt-5.5", + modelId: "openai/gpt-5.5", + reasoningEffort: "high", + codexFastMode: true, + permissionMode: "plan", + codexApprovalPolicy: "on-request", + codexSandbox: "read-only", + codexConfigSource: "flags", + })); + }); +}); + +describe("sendChatMessage", () => { + it("waits until the runtime has accepted the turn", async () => { + const calls: Array<{ domain: string; action: string; argsList: unknown[] }> = []; + const connection = { + actionList: async (domain: string, action: string, argsList: unknown[]) => { + calls.push({ domain, action, argsList }); + }, + } as unknown as AdeCodeConnection; + + await sendChatMessage(connection, "chat-1", "hello"); + + expect(calls).toEqual([ + { + domain: "chat", + action: "sendMessage", + argsList: [ + { sessionId: "chat-1", text: "hello" }, + { awaitDispatch: true }, + ], + }, + ]); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts new file mode 100644 index 000000000..b90421961 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { commandPlacement, parseCommand, paletteCommands } from "../commands"; + +describe("commands", () => { + it("parses multi-word ADE commands before generic slash commands", () => { + const parsed = parseCommand("/linear pull ADE-123"); + expect(parsed?.name).toBe("/linear pull"); + expect(parsed?.args).toBe("ADE-123"); + expect(parsed ? commandPlacement(parsed) : null).toBe("right"); + }); + + it("routes the generic ADE action escape hatch to the right pane", () => { + const parsed = parseCommand("/ade git.listBranches {\"laneId\":\"lane-1\"}"); + expect(parsed?.name).toBe("/ade"); + expect(parsed?.args).toBe("git.listBranches {\"laneId\":\"lane-1\"}"); + expect(parsed ? commandPlacement(parsed) : null).toBe("right"); + }); + + it("routes runtime commands to chat", () => { + const parsed = parseCommand("/ship now", [ + { name: "/ship", description: "Ship it", source: "sdk" }, + ]); + expect(parsed?.userCommand?.name).toBe("/ship"); + expect(parsed ? commandPlacement(parsed) : null).toBe("chat"); + }); + + it("lets runtime commands override single-word ADE built-ins on exact name", () => { + const parsed = parseCommand("/status please", [ + { name: "/status", description: "Runtime status", source: "sdk" }, + ]); + expect(parsed?.spec).toBeNull(); + expect(parsed?.userCommand?.name).toBe("/status"); + expect(parsed ? commandPlacement(parsed) : null).toBe("chat"); + }); + + it("keeps provider login as an ADE-code terminal command", () => { + const parsed = parseCommand("/login", [ + { name: "/login", description: "Claude SDK login", source: "sdk" }, + ]); + expect(parsed?.spec?.name).toBe("/login"); + expect(parsed?.userCommand).toBeNull(); + expect(parsed ? commandPlacement(parsed) : null).toBe("inline"); + }); + + it("keeps terminal control commands in ADE Code", () => { + const parsed = parseCommand("/quit", [ + { name: "/quit", description: "Runtime quit", source: "sdk" }, + ]); + expect(parsed?.spec?.name).toBe("/quit"); + expect(parsed?.userCommand).toBeNull(); + expect(parsed ? commandPlacement(parsed) : null).toBe("inline"); + }); + + it("keeps multi-word ADE commands ahead of first-token runtime commands", () => { + const parsed = parseCommand("/new lane perf-pass", [ + { name: "/new", description: "Start a new runtime chat", source: "sdk" }, + ]); + expect(parsed?.name).toBe("/new lane"); + expect(parsed?.args).toBe("perf-pass"); + expect(parsed ? commandPlacement(parsed) : null).toBe("right"); + }); + + it("tags built-ins and user commands in the palette", () => { + const rows = paletteCommands("/ship", [ + { name: "/ship", description: "Ship it", source: "sdk" }, + ]); + expect(rows).toContainEqual(expect.objectContaining({ name: "/ship", source: "user" })); + }); + + it("surfaces SDK commands like /compact when filtering", () => { + const rows = paletteCommands("/comp", [ + { name: "/compact", description: "Free up context by summarizing", source: "sdk" }, + ]); + expect(rows).toContainEqual(expect.objectContaining({ + name: "/compact", + source: "user", + description: "Free up context by summarizing", + })); + }); + + it("keeps ADE-owned inline commands aligned with dispatch when deduping", () => { + // /clear is an ADE terminal control, so the palette must not advertise the SDK command. + const rows = paletteCommands("/clear", [ + { name: "/clear", description: "Start a new conversation with empty context", source: "sdk" }, + ]); + const clearRows = rows.filter((row) => row.name === "/clear"); + expect(clearRows).toHaveLength(1); + expect(clearRows[0]?.source).toBe("ade"); + expect(clearRows[0]?.description).toBe("Clear the local terminal transcript view"); + + const parsed = parseCommand("/clear", [ + { name: "/clear", description: "Start a new conversation with empty context", source: "sdk" }, + ]); + expect(parsed?.spec?.name).toBe("/clear"); + expect(parsed?.userCommand).toBeNull(); + }); + + it("dedupes slash command case variants and keeps runtime casing", () => { + const rows = paletteCommands("/ship", [ + { name: "/shipLane", description: "Ship the lane", source: "sdk" }, + { name: "/shiplane", description: "Duplicate lower-case command", source: "sdk" }, + ]); + expect(rows.filter((row) => row.name.toLowerCase() === "/shiplane")).toHaveLength(1); + expect(rows.find((row) => row.name.toLowerCase() === "/shiplane")?.name).toBe("/shiplane"); + + const parsed = parseCommand("/shipLane now", [ + { name: "/shiplane", description: "Ship the lane", source: "sdk" }, + ]); + expect(parsed?.userCommand?.name).toBe("/shiplane"); + expect(parsed?.args).toBe("now"); + }); + + it("returns more than 9 results for empty/short queries", () => { + const userCommands = Array.from({ length: 20 }, (_, i) => ({ + name: `/sdk-cmd-${i}`, + description: `SDK command ${i}`, + source: "sdk" as const, + })); + const rows = paletteCommands("/", userCommands); + expect(rows.length).toBeGreaterThan(20); + }); + + it("ranks prefix matches above substring matches", () => { + const rows = paletteCommands("/compact", [ + { name: "/compact", description: "Free up context", source: "sdk" }, + { name: "/something-compact-related", description: "Other", source: "sdk" }, + ]); + expect(rows[0]?.name).toBe("/compact"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts new file mode 100644 index 000000000..47c27a32d --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -0,0 +1,267 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { connectToAde } from "../connection"; +import { JsonRpcClient } from "../jsonRpcClient"; +import type { ProjectLaunchContext } from "../types"; + +const childProcess = vi.hoisted(() => { + const child = { unref: vi.fn() }; + return { + child, + spawn: vi.fn(() => child), + }; +}); + +vi.mock("node:child_process", () => ({ + spawn: childProcess.spawn, +})); + +const embedded = vi.hoisted(() => { + const requests: Array<{ jsonrpc: string; id: number; method: string; params?: unknown }> = []; + const runtime = { + dispose: vi.fn(), + agentChatService: { + subscribeToEvents: vi.fn(() => vi.fn()), + }, + }; + const handler = Object.assign( + vi.fn(async (message: { jsonrpc: string; id: number; method: string; params?: unknown }) => { + requests.push(message); + return { ok: true, method: message.method }; + }), + { dispose: vi.fn() }, + ); + + return { + requests, + runtime, + handler, + createAdeRuntime: vi.fn(async () => runtime), + createAdeRpcRequestHandler: vi.fn(() => handler), + }; +}); + +vi.mock("../../bootstrap", () => ({ + createAdeRuntime: embedded.createAdeRuntime, +})); + +vi.mock("../../adeRpcServer", () => ({ + createAdeRpcRequestHandler: embedded.createAdeRpcRequestHandler, +})); + +const project: ProjectLaunchContext = { + launchCwd: "/tmp/ade-code", + projectRoot: "/tmp/ade-code", + workspaceRoot: "/tmp/ade-code", + laneHint: null, +}; + +const originalArgv1 = process.argv[1]; +const originalAdeHome = process.env.ADE_HOME; +const originalAdeRpcSocketPath = process.env.ADE_RPC_SOCKET_PATH; + +function restoreEnv(): void { + process.argv[1] = originalArgv1; + if (originalAdeHome === undefined) delete process.env.ADE_HOME; + else process.env.ADE_HOME = originalAdeHome; + if (originalAdeRpcSocketPath === undefined) + delete process.env.ADE_RPC_SOCKET_PATH; + else process.env.ADE_RPC_SOCKET_PATH = originalAdeRpcSocketPath; +} + +function useMissingMachineSocket(): string { + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-machine-")); + process.env.ADE_HOME = adeHome; + delete process.env.ADE_RPC_SOCKET_PATH; + return path.join(adeHome, "sock", "ade.sock"); +} + +function mockAttachedClient(): { + request: ReturnType; + onNotification: ReturnType; + close: ReturnType; +} { + const client = { + request: vi.fn(async (method: string) => { + if (method === "ade/initialize") return {}; + if (method === "ade/initialized") return null; + return { ok: true }; + }), + onNotification: vi.fn(() => vi.fn()), + close: vi.fn(), + }; + vi.spyOn(JsonRpcClient, "connect").mockResolvedValue( + client as unknown as JsonRpcClient, + ); + return client; +} + +describe("connectToAde embedded mode", () => { + beforeEach(() => { + embedded.requests.length = 0; + embedded.runtime.dispose.mockClear(); + embedded.runtime.agentChatService.subscribeToEvents.mockClear(); + embedded.handler.mockClear(); + embedded.handler.dispose.mockClear(); + embedded.createAdeRuntime.mockClear(); + embedded.createAdeRpcRequestHandler.mockClear(); + childProcess.spawn.mockClear(); + childProcess.child.unref.mockClear(); + childProcess.spawn.mockImplementation(() => childProcess.child); + }); + + afterEach(() => { + vi.restoreAllMocks(); + restoreEnv(); + }); + + it("uses unique JSON-RPC ids for direct embedded requests", async () => { + const connection = await connectToAde({ + project, + forceEmbedded: true, + }); + + try { + await Promise.all([ + connection.request("ade/actions/list"), + connection.request("ping"), + ]); + } finally { + await connection.close(); + } + + expect(embedded.requests.map((request) => request.method)).toEqual([ + "ade/initialize", + "ade/initialized", + "ade/actions/list", + "ping", + ]); + expect(embedded.requests.map((request) => request.id)).toEqual([1, 2, 3, 4]); + expect(new Set(embedded.requests.map((request) => request.id)).size).toBe(4); + }); + + it("does not silently fall back to embedded mode when socket attach fails", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-missing-socket-")); + const socketPath = path.join(tmpDir, "missing.sock"); + + await expect(connectToAde({ + project, + socketPath, + })).rejects.toThrow(/ade code --embedded/); + + expect(embedded.createAdeRuntime).not.toHaveBeenCalled(); + }); + + it("registers the project and injects projectId when attached to the machine daemon", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-")); + const socketPath = path.join(tmpDir, "ade.sock"); + const requests: Array<{ method: string; params?: Record }> = []; + const server = net.createServer((socket) => { + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + while (true) { + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) continue; + const request = JSON.parse(line) as { id: number; method: string; params?: Record }; + requests.push({ method: request.method, params: request.params }); + const result = (() => { + if (request.method === "ade/initialize") { + return { + runtimeInfo: { multiProject: true }, + capabilities: { projects: true }, + }; + } + if (request.method === "projects.add") { + return { projectId: "project-daemon", rootPath: project.projectRoot }; + } + if (request.method === "ade/actions/list") { + return { projectId: request.params?.projectId ?? null }; + } + return null; + })(); + socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, result })}\n`); + } + }); + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + + const connection = await connectToAde({ + project, + socketPath, + }); + try { + const listed = await connection.request<{ projectId: string }>("ade/actions/list", {}); + expect(listed.projectId).toBe("project-daemon"); + } finally { + await connection.close(); + await new Promise((resolve) => server.close(() => resolve())); + } + + expect(requests.map((request) => request.method)).toEqual([ + "ade/initialize", + "ade/initialized", + "projects.add", + "ade/actions/list", + ]); + expect(requests.at(-1)?.params).toMatchObject({ projectId: "project-daemon" }); + }); + + it("spawns the standalone binary directly when no CLI script entrypoint exists", async () => { + const socketPath = useMissingMachineSocket(); + const missingEntrypointDir = fs.mkdtempSync( + path.join(os.tmpdir(), "ade-code-missing-entrypoint-"), + ); + process.argv[1] = path.join(missingEntrypointDir, "missing-cli"); + const client = mockAttachedClient(); + + const connection = await connectToAde({ project }); + try { + expect(connection.mode).toBe("attached"); + } finally { + await connection.close(); + } + + expect(childProcess.spawn).toHaveBeenCalledTimes(1); + const spawnCall = childProcess.spawn.mock.calls[0] as unknown[] | undefined; + expect(spawnCall?.[0]).toBe(process.execPath); + expect(spawnCall?.[1]).toEqual(["serve", "--socket", socketPath]); + expect(spawnCall?.[2]).toMatchObject({ + detached: true, + stdio: "ignore", + env: expect.objectContaining({ ADE_RPC_SOCKET_PATH: socketPath }), + }); + expect(childProcess.child.unref).toHaveBeenCalledTimes(1); + expect(client.close).toHaveBeenCalledTimes(1); + }); + + it("keeps the script entrypoint argv shape when a CLI script is resolved", async () => { + const socketPath = useMissingMachineSocket(); + const entrypointDir = fs.mkdtempSync( + path.join(os.tmpdir(), "ade-code-entrypoint-"), + ); + const entrypoint = path.join(entrypointDir, "cli.cjs"); + fs.writeFileSync(entrypoint, "#!/usr/bin/env node\n"); + process.argv[1] = entrypoint; + mockAttachedClient(); + + const connection = await connectToAde({ project }); + await connection.close(); + + expect(childProcess.spawn).toHaveBeenCalledTimes(1); + const spawnCall = childProcess.spawn.mock.calls[0] as unknown[] | undefined; + expect(spawnCall?.[0]).toBe(process.execPath); + expect(spawnCall?.[1]).toEqual([ + entrypoint, + "serve", + "--socket", + socketPath, + ]); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts new file mode 100644 index 000000000..18438c099 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it } from "vitest"; +import { latestExpandableFailureId, parseAssistantMarkdown, renderChatLines, renderObject } from "../format"; + +describe("renderChatLines", () => { + it("parses assistant markdown into stable blocks", () => { + expect(parseAssistantMarkdown([ + "# Heading", + "", + "Paragraph text", + "", + "- Bullet", + "1. Numbered", + "> Quote", + "", + "```sh", + "npm test", + "```", + ].join("\n"))).toEqual([ + { kind: "heading", level: 1, text: "Heading" }, + { kind: "paragraph", text: "Paragraph text" }, + { kind: "bullet", text: "Bullet" }, + { kind: "numbered", number: "1", text: "Numbered" }, + { kind: "quote", text: "Quote" }, + { kind: "code", language: "sh", lines: ["npm test"] }, + ]); + }); + + it("renders compact rule-separated chat turns", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "user_message", text: "hello" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "text", text: "hi" }, + }, + ], + }); + expect(lines.map((line) => line.tone)).toEqual(["user", "assistant"]); + expect(lines[0]?.header).toContain("you"); + expect(lines[1]?.header).toContain("ADE"); + }); + + it("orders local notices and chat events by timestamp", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [ + { + id: "notice-1", + timestamp: "2026-01-01T12:00:02.000Z", + tone: "success", + text: "Auth completed.", + }, + ], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 1, + event: { type: "user_message", text: "hello" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 2, + event: { type: "text", text: "hi" }, + }, + ], + }); + + expect(lines.map((line) => line.body)).toEqual(["hello", "Auth completed.", "hi"]); + }); + + it("keeps terminal formatting artifacts out of model labels", () => { + const lines = renderChatLines({ + activeSession: { + sessionId: "s1", + laneId: "lane-1", + provider: "claude", + model: "claude-opus-4-7[1m]", + status: "idle", + startedAt: "2026-01-01T12:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-01-01T12:00:00.000Z", + lastOutputPreview: null, + summary: null, + }, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 1, + event: { type: "text", text: "hi" }, + }, + ], + }); + + expect(lines[0]?.header).toMatch(/^Claude · .* · claude-opus-4-7$/); + }); + + it("renders non-JSON-safe objects without throwing", () => { + const value: { self?: unknown } = {}; + value.self = value; + expect(renderObject(value)).toBe("[object Object]"); + }); + + it("renders tool, edit, and compaction events compactly", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "tool_call", tool: "read", args: { path: "src/app.ts" }, itemId: "tool-1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { + type: "file_change", + path: "src/app.ts", + kind: "modify", + status: "completed", + itemId: "edit-1", + diff: "+hello\n-world", + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { type: "context_compact", trigger: "auto" }, + }, + ], + }); + + expect(lines).toEqual([ + expect.objectContaining({ tone: "tool", body: expect.stringContaining("> read") }), + expect.objectContaining({ tone: "tool", body: expect.stringContaining("> edit src/app.ts") }), + expect.objectContaining({ tone: "notice", body: expect.stringContaining("context compacted") }), + ]); + }); + + it("summarizes command pass and fail counts when present", () => { + const events = [{ + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "command", + command: "vitest", + cwd: "/repo", + output: "Test Files 1 failed | Tests 7 passed, 1 failed", + itemId: "cmd-1", + status: "failed", + exitCode: 1, + durationMs: 2100, + }, + }] as const; + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [...events], + }); + + expect(lines[0]).toEqual(expect.objectContaining({ + tone: "error", + body: expect.stringContaining("7 passed · 1 failed"), + })); + expect(lines[0]?.body).toContain("↵ expands"); + expect(latestExpandableFailureId([...events])).toBe("1:command:2026-01-01T12:00:00.000Z"); + }); + + it("coalesces consecutive streamed text events from the same provider into one line", () => { + const session = { + sessionId: "s1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + status: "idle", + startedAt: "2026-01-01T12:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-01-01T12:00:00.000Z", + lastOutputPreview: null, + summary: null, + } as const; + const lines = renderChatLines({ + activeSession: session, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 1, + event: { type: "text", text: "I'm Codex," }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 2, + event: { type: "text", text: " running as a GPT-5 based" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 3, + event: { type: "text", text: " software engineering agent." }, + }, + ], + }); + expect(lines).toHaveLength(1); + expect(lines[0]?.tone).toBe("assistant"); + expect(lines[0]?.body).toBe("I'm Codex, running as a GPT-5 based software engineering agent."); + expect(lines[0]?.blocks).toEqual([ + { kind: "paragraph", text: "I'm Codex, running as a GPT-5 based software engineering agent." }, + ]); + expect(lines[0]?.header).toMatch(/^Codex /); + }); + + it("does not coalesce assistant text across a tool call", () => { + const session = { + sessionId: "s1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + status: "idle", + startedAt: "2026-01-01T12:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-01-01T12:00:00.000Z", + lastOutputPreview: null, + summary: null, + } as const; + const lines = renderChatLines({ + activeSession: session, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 1, + event: { type: "text", text: "I'll check the branch." }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 2, + event: { type: "tool_call", tool: "shell", args: { command: "git branch" }, itemId: "tool-1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 3, + event: { type: "text", text: "We're on main." }, + }, + ], + }); + expect(lines.map((line) => line.tone)).toEqual(["assistant", "tool", "assistant"]); + expect(lines[0]?.body).toBe("I'll check the branch."); + expect(lines[2]?.body).toBe("We're on main."); + expect(lines[2]?.header).toMatch(/^Codex /); + }); + + it("renders expanded failed tool output when requested", () => { + const events = [{ + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "tool_result", + tool: "read", + result: { error: "Permission denied", path: "/repo/secret" }, + itemId: "tool-1", + status: "failed", + }, + }] as const; + const id = latestExpandableFailureId([...events]); + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [...events], + expandedLineIds: new Set(id ? [id] : []), + }); + + expect(lines[0]).toEqual(expect.objectContaining({ + tone: "error", + body: expect.stringContaining("Permission denied"), + })); + expect(lines[0]?.body).not.toContain("↵ expands"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/heartbeat.test.ts b/apps/ade-cli/src/tuiClient/__tests__/heartbeat.test.ts new file mode 100644 index 000000000..44de87b1d --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/heartbeat.test.ts @@ -0,0 +1,64 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { startTuiHeartbeat, type TuiHeartbeat } from "../heartbeat"; + +const heartbeats: TuiHeartbeat[] = []; + +function tempProjectRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-heartbeat-")); +} + +function heartbeatFile(projectRoot: string): string { + return path.join(projectRoot, ".ade", "cache", "ade-code", "clients", `${process.pid}.json`); +} + +afterEach(() => { + for (const heartbeat of heartbeats.splice(0)) { + heartbeat.stop(); + } +}); + +describe("startTuiHeartbeat", () => { + it("shares process cleanup handlers across active heartbeats", () => { + const exitListeners = process.listenerCount("exit"); + const sigintListeners = process.listenerCount("SIGINT"); + const firstRoot = tempProjectRoot(); + const secondRoot = tempProjectRoot(); + + const first = startTuiHeartbeat(firstRoot); + heartbeats.push(first); + const second = startTuiHeartbeat(secondRoot); + heartbeats.push(second); + + expect(process.listenerCount("exit")).toBe(exitListeners + 1); + expect(process.listenerCount("SIGINT")).toBe(sigintListeners + 1); + expect(fs.existsSync(heartbeatFile(firstRoot))).toBe(true); + expect(fs.existsSync(heartbeatFile(secondRoot))).toBe(true); + + first.stop(); + expect(process.listenerCount("exit")).toBe(exitListeners + 1); + expect(process.listenerCount("SIGINT")).toBe(sigintListeners + 1); + expect(fs.existsSync(heartbeatFile(firstRoot))).toBe(false); + expect(fs.existsSync(heartbeatFile(secondRoot))).toBe(true); + + second.stop(); + expect(process.listenerCount("exit")).toBe(exitListeners); + expect(process.listenerCount("SIGINT")).toBe(sigintListeners); + expect(fs.existsSync(heartbeatFile(secondRoot))).toBe(false); + }); + + it("makes stop idempotent", () => { + const exitListeners = process.listenerCount("exit"); + const projectRoot = tempProjectRoot(); + const heartbeat = startTuiHeartbeat(projectRoot); + heartbeats.push(heartbeat); + + heartbeat.stop(); + heartbeat.stop(); + + expect(process.listenerCount("exit")).toBe(exitListeners); + expect(fs.existsSync(heartbeatFile(projectRoot))).toBe(false); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/jsonRpcClient.test.ts b/apps/ade-cli/src/tuiClient/__tests__/jsonRpcClient.test.ts new file mode 100644 index 000000000..40b313108 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/jsonRpcClient.test.ts @@ -0,0 +1,102 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { JsonRpcClient } from "../jsonRpcClient"; + +function listen(server: net.Server, socketPath: string): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => { + server.off("error", reject); + resolve(); + }); + }); +} + +function closeServer(server: net.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +describe("JsonRpcClient", () => { + it("handles framed notifications before JSONL responses", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); + const socketPath = path.join(tmpDir, "rpc.sock"); + let resolveServerSocket: (socket: net.Socket) => void = () => {}; + const serverSocketReady = new Promise((resolve) => { + resolveServerSocket = resolve; + }); + const server = net.createServer((socket) => { + resolveServerSocket(socket); + socket.on("data", (chunk) => { + const text = String(chunk); + const match = /"id":(\d+)/.exec(text); + const id = match ? Number.parseInt(match[1]!, 10) : 1; + socket.write(`${JSON.stringify({ jsonrpc: "2.0", id, result: { ok: true } })}\n`); + }); + }); + + await listen(server, socketPath); + const client = await JsonRpcClient.connect(socketPath); + const socket = await serverSocketReady; + try { + const notification = new Promise((resolve) => { + client.onNotification("chat/event", resolve); + }); + const payload = JSON.stringify({ + jsonrpc: "2.0", + method: "chat/event", + params: { sessionId: "s1" }, + }); + socket.write(`Content-Length: ${Buffer.byteLength(payload, "utf8")}\r\n\r\n${payload}`); + + await expect(notification).resolves.toEqual({ sessionId: "s1" }); + await expect(client.request("ping")).resolves.toEqual({ ok: true }); + } finally { + client.close(); + socket.destroy(); + await closeServer(server); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("honors byte-based Content-Length framing for unicode payloads", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); + const socketPath = path.join(tmpDir, "rpc.sock"); + let resolveServerSocket: (socket: net.Socket) => void = () => {}; + const serverSocketReady = new Promise((resolve) => { + resolveServerSocket = resolve; + }); + const server = net.createServer((socket) => { + resolveServerSocket(socket); + }); + + await listen(server, socketPath); + const client = await JsonRpcClient.connect(socketPath); + const socket = await serverSocketReady; + try { + const notification = new Promise((resolve) => { + client.onNotification("chat/event", resolve); + }); + const payload = JSON.stringify({ + jsonrpc: "2.0", + method: "chat/event", + params: { message: "héllo ✅" }, + }); + const framed = Buffer.concat([ + Buffer.from(`Content-Length: ${Buffer.byteLength(payload, "utf8")}\r\n\r\n`, "ascii"), + Buffer.from(payload, "utf8"), + ]); + socket.write(framed.subarray(0, 20)); + socket.write(framed.subarray(20)); + + await expect(notification).resolves.toEqual({ message: "héllo ✅" }); + } finally { + client.close(); + socket.destroy(); + await closeServer(server); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/linearCommands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/linearCommands.test.ts new file mode 100644 index 000000000..c0fd61398 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/linearCommands.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { buildLinearToolRequest, parseLinearArgs } from "../linearCommands"; + +describe("linear command routing", () => { + it("parses flags and quoted values", () => { + expect(parseLinearArgs("run cancel run-1 --reason \"not ready\" --launch false")).toEqual({ + positionals: ["run", "cancel", "run-1"], + options: { reason: "not ready", launch: false }, + }); + }); + + it("routes sync dashboard and queue resolution", () => { + expect(buildLinearToolRequest("sync dashboard")).toEqual({ + kind: "tool", + title: "Linear sync dashboard", + toolName: "getLinearSyncDashboard", + args: {}, + }); + expect(buildLinearToolRequest("sync resolve queue-1 approve --note ok")).toEqual({ + kind: "tool", + title: "Linear sync resolve", + toolName: "resolveLinearSyncQueueItem", + args: { + queueItemId: "queue-1", + action: "approve", + note: "ok", + }, + }); + }); + + it("routes worker handoff and reports usage for missing fields", () => { + expect(buildLinearToolRequest("route worker LIN-123 agent-1")).toEqual({ + kind: "tool", + title: "Linear route worker", + toolName: "routeLinearIssueToWorker", + args: { issueId: "LIN-123", agentId: "agent-1" }, + }); + expect(buildLinearToolRequest("run cancel run-1")).toEqual({ + kind: "usage", + title: "Linear run cancel", + body: "Usage: /linear run cancel --reason ", + }); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts new file mode 100644 index 000000000..6f363fb19 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import type { AgentChatEventEnvelope, PendingInputRequest } from "../../../../desktop/src/shared/types/chat"; +import { buildPendingInputAnswers, latestPendingApproval } from "../pendingInput"; + +const baseRequest: PendingInputRequest = { + requestId: "req-1", + source: "codex", + kind: "structured_question", + title: "Pick path", + questions: [{ + id: "path", + question: "Which path?", + options: [ + { label: "Recommended", value: "recommended" }, + { label: "Manual", value: "manual" }, + ], + allowsFreeform: true, + }], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, +}; + +describe("pendingInput", () => { + it("maps option numbers to structured answers", () => { + expect(buildPendingInputAnswers(baseRequest, "2")).toEqual({ path: "manual" }); + }); + + it("keeps multi-select answers as arrays", () => { + const request: PendingInputRequest = { + ...baseRequest, + questions: [{ + ...baseRequest.questions[0]!, + multiSelect: true, + }], + }; + expect(buildPendingInputAnswers(request, "1, Manual")).toEqual({ path: ["recommended", "manual"] }); + }); + + it("returns the latest unresolved pending input request", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T00:00:00.000Z", + sequence: 1, + event: { + type: "approval_request", + itemId: "item-1", + kind: "tool_call", + description: "Need input", + detail: { request: { ...baseRequest, itemId: "item-1" } }, + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T00:00:01.000Z", + sequence: 2, + event: { + type: "pending_input_resolved", + itemId: "item-1", + resolution: "accepted", + }, + }, + ]; + expect(latestPendingApproval(events)).toBeNull(); + + events.push({ + sessionId: "s1", + timestamp: "2026-01-01T00:00:02.000Z", + sequence: 3, + event: { + type: "approval_request", + itemId: "item-2", + kind: "tool_call", + description: "Need input", + detail: { request: { ...baseRequest, requestId: "req-2", itemId: "item-2" } }, + }, + }); + expect(latestPendingApproval(events)).toEqual(expect.objectContaining({ + itemId: "item-2", + mode: "question", + highStakes: false, + })); + }); + + it("flags destructive or external-impact approvals as high stakes", () => { + const events: AgentChatEventEnvelope[] = [{ + sessionId: "s1", + timestamp: "2026-01-01T00:00:00.000Z", + sequence: 1, + event: { + type: "approval_request", + itemId: "item-1", + kind: "tool_call", + description: "Force-push the main branch to production", + detail: { command: "git push --force origin main" }, + }, + }]; + + expect(latestPendingApproval(events)).toEqual(expect.objectContaining({ + itemId: "item-1", + mode: "approval", + highStakes: true, + })); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/project.test.ts b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts new file mode 100644 index 000000000..bdb138060 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts @@ -0,0 +1,51 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { chooseInitialLane } from "../project"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; + +function lane(overrides: Partial): LaneSummary { + return { + id: "main", + name: "main", + laneType: "primary", + baseRef: "main", + branchRef: "main", + worktreePath: "/repo", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + createdAt: new Date(0).toISOString(), + ...overrides, + }; +} + +describe("chooseInitialLane", () => { + it("prefers the ADE worktree lane hint", () => { + const lanes = [ + lane({ id: "main", name: "main", laneType: "primary", worktreePath: "/repo" }), + lane({ id: "feature-a", name: "Feature A", laneType: "worktree", branchRef: "feature/a", worktreePath: "/repo/.ade/worktrees/feature-a" }), + ]; + expect(chooseInitialLane(lanes, { + workspaceRoot: "/repo/.ade/worktrees/feature-a", + laneHint: "feature-a", + })?.id).toBe("feature-a"); + }); + + it("falls back to matching the workspace path", () => { + const worktreePath = path.resolve("/repo/.ade/worktrees/feature-b"); + const lanes = [ + lane({ id: "main", laneType: "primary", worktreePath: "/repo" }), + lane({ id: "feature-b", laneType: "worktree", worktreePath }), + ]; + expect(chooseInitialLane(lanes, { + workspaceRoot: path.join(worktreePath, "apps/desktop"), + laneHint: null, + })?.id).toBe("feature-b"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts new file mode 100644 index 000000000..36d20dd16 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -0,0 +1,318 @@ +import { + getDefaultModelDescriptor, + getModelById, + getRuntimeModelRefForDescriptor, + resolveProviderGroupForModel, + type ModelProviderGroup, +} from "../../../desktop/src/shared/modelRegistry"; +import type { + AgentChatClaudePermissionMode, + AgentChatCodexApprovalPolicy, + AgentChatCodexConfigSource, + AgentChatCodexSandbox, + AgentChatCursorConfigValue, + AgentChatDroidPermissionMode, + AgentChatEventEnvelope, + AgentChatFileRef, + AgentChatInteractionMode, + AgentChatModelInfo, + AgentChatOpenCodePermissionMode, + AgentChatPermissionMode, + AgentChatProvider, + AgentChatSession, + AgentChatSessionSummary, + AgentChatSlashCommand, +} from "../../../desktop/src/shared/types/chat"; +import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config"; +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import { discoverClaudeSlashCommands } from "../../../desktop/src/main/services/chat/claudeSlashCommandDiscovery"; +import { discoverCodexSlashCommands } from "../../../desktop/src/main/services/chat/codexSlashCommandDiscovery"; +import type { AdeCodeConnection, ChatHistorySnapshot, CreatedChat, NavigateRequest, NavigateResult } from "./types"; + +export const DEFAULT_CODEX_REASONING_EFFORT = "low"; + +export async function listLanes(connection: AdeCodeConnection): Promise { + return await connection.action("lane", "list", { + includeArchived: false, + includeStatus: true, + }); +} + +export async function listChatSessions( + connection: AdeCodeConnection, + laneId?: string | null, +): Promise { + const argsList = laneId ? [laneId] : []; + return await connection.actionList("chat", "listSessions", argsList); +} + +export async function getChatHistory( + connection: AdeCodeConnection, + sessionId: string, + maxEvents = 500, +): Promise { + return await connection.actionList("chat", "getChatEventHistory", [sessionId, { maxEvents }]); +} + +export async function getSlashCommands( + connection: AdeCodeConnection, + sessionId: string | null, +): Promise { + if (!sessionId) return []; + return await connection.action("chat", "getSlashCommands", { sessionId }); +} + +function slashCommandKey(value: string): string { + return value.trim().toLowerCase(); +} + +export function discoverProjectSlashCommands(workspaceRoot: string): AgentChatSlashCommand[] { + const byName = new Map(); + const add = (command: { name: string; description: string; argumentHint?: string }) => { + const key = slashCommandKey(command.name); + if (key === "/login") return; + if (byName.has(key)) return; + byName.set(key, { + name: command.name, + description: command.description, + argumentHint: command.argumentHint, + source: "sdk", + }); + }; + for (const command of discoverClaudeSlashCommands(workspaceRoot)) add(command); + for (const command of discoverCodexSlashCommands(workspaceRoot)) add(command); + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); +} + +export async function getAvailableModels( + connection: AdeCodeConnection, + provider: AgentChatProvider, +): Promise { + return await connection.action("chat", "getAvailableModels", { + provider, + activateRuntime: false, + }); +} + +export async function getAiSettingsStatus( + connection: AdeCodeConnection, + args: { force?: boolean; refreshOpenCodeInventory?: boolean } = {}, +): Promise { + return await connection.action("ai", "getStatus", args); +} + +export async function getStoredApiKeyProviders(connection: AdeCodeConnection): Promise { + return await connection.action("ai", "listApiKeys", {}); +} + +export async function getOpenCodeRuntimeDiagnostics(connection: AdeCodeConnection): Promise { + return await connection.action("ai", "getOpenCodeRuntimeDiagnostics", {}); +} + +export async function createChatSession(args: { + connection: AdeCodeConnection; + laneId: string; + title?: string | null; + provider?: ModelProviderGroup; + modelId?: string | null; + reasoningEffort?: string | null; + codexFastMode?: boolean; + permissionMode?: AgentChatPermissionMode; + interactionMode?: AgentChatInteractionMode; + claudePermissionMode?: AgentChatClaudePermissionMode; + codexApprovalPolicy?: AgentChatCodexApprovalPolicy; + codexSandbox?: AgentChatCodexSandbox; + codexConfigSource?: AgentChatCodexConfigSource; + opencodePermissionMode?: AgentChatOpenCodePermissionMode; + droidPermissionMode?: AgentChatDroidPermissionMode; + cursorModeId?: string | null; + cursorConfigValues?: Record; +}): Promise { + const requestedDescriptor = args.modelId ? getModelById(args.modelId) : undefined; + const provider = args.provider + ?? (requestedDescriptor ? resolveProviderGroupForModel(requestedDescriptor) : "codex"); + const descriptor = requestedDescriptor ?? getDefaultModelDescriptor(provider); + const modelId = args.modelId ?? descriptor?.id ?? null; + const model = descriptor + ? getRuntimeModelRefForDescriptor(descriptor, provider) + : provider === "claude" + ? "sonnet" + : provider === "cursor" + ? "auto" + : provider === "droid" + ? "claude-sonnet-4-5-20250929" + : "gpt-5.5"; + const reasoningEffort = args.reasoningEffort ?? (provider === "codex" ? DEFAULT_CODEX_REASONING_EFFORT : null); + return await args.connection.action("chat", "createSession", { + laneId: args.laneId, + provider, + model, + ...(modelId ? { modelId } : {}), + ...(args.title?.trim() ? { title: args.title.trim() } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(provider === "codex" && args.codexFastMode === true ? { codexFastMode: true } : {}), + ...(args.permissionMode ? { permissionMode: args.permissionMode } : {}), + ...(provider === "claude" && args.interactionMode ? { interactionMode: args.interactionMode } : {}), + ...(provider === "claude" && args.claudePermissionMode ? { claudePermissionMode: args.claudePermissionMode } : {}), + ...(provider === "codex" && args.codexApprovalPolicy ? { codexApprovalPolicy: args.codexApprovalPolicy } : {}), + ...(provider === "codex" && args.codexSandbox ? { codexSandbox: args.codexSandbox } : {}), + ...(provider === "codex" && args.codexConfigSource ? { codexConfigSource: args.codexConfigSource } : {}), + ...(provider === "opencode" && args.opencodePermissionMode ? { opencodePermissionMode: args.opencodePermissionMode } : {}), + ...(provider === "droid" && args.droidPermissionMode ? { droidPermissionMode: args.droidPermissionMode } : {}), + ...(provider === "cursor" && args.cursorModeId !== undefined ? { cursorModeId: args.cursorModeId } : {}), + ...(provider === "cursor" && args.cursorConfigValues ? { cursorConfigValues: args.cursorConfigValues } : {}), + surface: "work", + }); +} + +export async function sendChatMessage( + connection: AdeCodeConnection, + sessionId: string, + text: string, + attachments: AgentChatFileRef[] = [], +): Promise { + await connection.actionList("chat", "sendMessage", [ + { + sessionId, + text, + ...(attachments.length ? { attachments } : {}), + }, + { awaitDispatch: true }, + ]); +} + +export async function approveToolUse(args: { + connection: AdeCodeConnection; + sessionId: string; + itemId: string; + decision: "accept" | "accept_for_session" | "decline" | "cancel"; + responseText?: string | null; +}): Promise { + await args.connection.action("chat", "approveToolUse", { + sessionId: args.sessionId, + itemId: args.itemId, + decision: args.decision, + ...(args.responseText ? { responseText: args.responseText } : {}), + }); +} + +export async function respondToInput(args: { + connection: AdeCodeConnection; + sessionId: string; + itemId: string; + decision?: "accept" | "accept_for_session" | "decline" | "cancel"; + answers?: Record; + responseText?: string | null; +}): Promise { + await args.connection.action("chat", "respondToInput", { + sessionId: args.sessionId, + itemId: args.itemId, + ...(args.decision ? { decision: args.decision } : {}), + ...(args.answers ? { answers: args.answers } : {}), + ...(args.responseText ? { responseText: args.responseText } : {}), + }); +} + +export async function interruptChat(connection: AdeCodeConnection, sessionId: string): Promise { + await connection.action("chat", "interrupt", { sessionId }); +} + +export async function resumeChat(connection: AdeCodeConnection, sessionId: string): Promise { + return await connection.action("chat", "resumeSession", { sessionId }); +} + +export async function renameChat(connection: AdeCodeConnection, sessionId: string, title: string): Promise { + return await connection.action("chat", "updateSession", { + sessionId, + title, + manuallyNamed: true, + }); +} + +export async function updateChatModel(args: { + connection: AdeCodeConnection; + sessionId: string; + modelId?: string | null; + reasoningEffort?: string | null; + codexFastMode?: boolean; + permissionMode?: AgentChatPermissionMode; + interactionMode?: AgentChatInteractionMode; + claudePermissionMode?: AgentChatClaudePermissionMode; + codexApprovalPolicy?: AgentChatCodexApprovalPolicy; + codexSandbox?: AgentChatCodexSandbox; + codexConfigSource?: AgentChatCodexConfigSource; + opencodePermissionMode?: AgentChatOpenCodePermissionMode; + droidPermissionMode?: AgentChatDroidPermissionMode; + cursorModeId?: string | null; + cursorConfigValues?: Record; +}): Promise { + return await args.connection.action("chat", "updateSession", { + sessionId: args.sessionId, + ...(args.modelId !== undefined ? { modelId: args.modelId } : {}), + ...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}), + ...(args.codexFastMode !== undefined ? { codexFastMode: args.codexFastMode } : {}), + ...(args.permissionMode !== undefined ? { permissionMode: args.permissionMode } : {}), + ...(args.interactionMode !== undefined ? { interactionMode: args.interactionMode } : {}), + ...(args.claudePermissionMode !== undefined ? { claudePermissionMode: args.claudePermissionMode } : {}), + ...(args.codexApprovalPolicy !== undefined ? { codexApprovalPolicy: args.codexApprovalPolicy } : {}), + ...(args.codexSandbox !== undefined ? { codexSandbox: args.codexSandbox } : {}), + ...(args.codexConfigSource !== undefined ? { codexConfigSource: args.codexConfigSource } : {}), + ...(args.opencodePermissionMode !== undefined ? { opencodePermissionMode: args.opencodePermissionMode } : {}), + ...(args.droidPermissionMode !== undefined ? { droidPermissionMode: args.droidPermissionMode } : {}), + ...(args.cursorModeId !== undefined ? { cursorModeId: args.cursorModeId } : {}), + ...(args.cursorConfigValues !== undefined ? { cursorConfigValues: args.cursorConfigValues } : {}), + }); +} + +export async function navigateDesktop(connection: AdeCodeConnection, request: NavigateRequest): Promise { + return await connection.request("app/navigate", request); +} + +export function newestSession(sessions: AgentChatSessionSummary[]): AgentChatSessionSummary | null { + return [...sessions].sort((left, right) => ( + new Date(right.lastActivityAt ?? right.startedAt).getTime() + - new Date(left.lastActivityAt ?? left.startedAt).getTime() + ))[0] ?? null; +} + +export type TokenStats = { + percent: number | null; + streaming: boolean; + inputTokens: number | null; + outputTokens: number | null; + costUsd: number | null; +}; + +export function latestTokenStats( + events: AgentChatEventEnvelope[], + fallbackContextWindow?: number | null, +): TokenStats { + let percent: number | null = null; + let streaming = false; + let inputTokens: number | null = null; + let outputTokens: number | null = null; + let costUsd: number | null = null; + let eventLimit: number | null = null; + for (const envelope of events) { + const event = envelope.event as Record; + if (event.type === "status" && event.turnStatus === "started") streaming = true; + if (event.type === "done" || (event.type === "status" && event.turnStatus === "completed")) streaming = false; + if (event.type === "tokens") { + inputTokens = typeof event.inputTokens === "number" ? event.inputTokens : inputTokens; + outputTokens = typeof event.outputTokens === "number" ? event.outputTokens : outputTokens; + if (typeof event.contextWindow === "number") eventLimit = event.contextWindow; + } + if (event.type === "done") { + const usage = event.usage && typeof event.usage === "object" ? event.usage as Record : null; + inputTokens = typeof usage?.inputTokens === "number" ? usage.inputTokens : inputTokens; + outputTokens = typeof usage?.outputTokens === "number" ? usage.outputTokens : outputTokens; + costUsd = typeof event.costUsd === "number" ? event.costUsd : costUsd; + } + } + const used = inputTokens != null || outputTokens != null ? (inputTokens ?? 0) + (outputTokens ?? 0) : null; + const limit = eventLimit ?? (typeof fallbackContextWindow === "number" && fallbackContextWindow > 0 ? fallbackContextWindow : null); + if (used != null && limit != null && limit > 0) { + percent = Math.max(0, Math.min(100, Math.round((used / limit) * 100))); + } + return { percent, streaming, inputTokens, outputTokens, costUsd }; +} diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx new file mode 100644 index 000000000..dc3d619ea --- /dev/null +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -0,0 +1,3078 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import { Box, Text, useApp, useInput } from "ink"; +import { + getDefaultModelDescriptor, + getModelById, + listModelDescriptorsForProvider, + modelSupportsFastMode, + resolveProviderGroupForModel, +} from "../../../desktop/src/shared/modelRegistry"; +import { CURSOR_AVAILABLE_MODE_IDS, CURSOR_MODE_LABELS } from "../../../desktop/src/shared/cursorModes"; +import type { + AgentChatCodexApprovalPolicy, + AgentChatCodexConfigSource, + AgentChatCodexSandbox, + AgentChatEventEnvelope, + AgentChatFileRef, + AgentChatModelInfo, + AgentChatPermissionMode, + AgentChatSessionSummary, + AgentChatSlashCommand, +} from "../../../desktop/src/shared/types/chat"; +import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config"; +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import { + DEFAULT_CODEX_REASONING_EFFORT, + approveToolUse, + createChatSession, + discoverProjectSlashCommands, + getAvailableModels, + getAiSettingsStatus, + getChatHistory, + getOpenCodeRuntimeDiagnostics, + getSlashCommands, + getStoredApiKeyProviders, + interruptChat, + latestTokenStats, + listChatSessions, + listLanes, + navigateDesktop, + newestSession, + renameChat, + respondToInput, + resumeChat, + sendChatMessage, + updateChatModel, +} from "./adeApi"; +import { paletteCommands, parseCommand } from "./commands"; +import { connectToAde } from "./connection"; +import { Drawer, visibleDrawerChatCount, visibleDrawerLaneCount } from "./components/Drawer"; +import { ChatView } from "./components/ChatView"; +import { Header } from "./components/Header"; +import { LANE_DETAIL_ACTIONS, RightPane } from "./components/RightPane"; +import { SlashPalette } from "./components/SlashPalette"; +import { MentionPalette } from "./components/MentionPalette"; +import { ApprovalPrompt } from "./components/ApprovalPrompt"; +import { ModelStatus } from "./components/ModelStatus"; +import { FooterControls } from "./components/FooterControls"; +import { theme } from "./theme"; +import { chooseInitialLane } from "./project"; +import { latestExpandableFailureId, renderObject, summarizeDiffChanges } from "./format"; +import { startTuiHeartbeat, type TuiHeartbeat } from "./heartbeat"; +import { loadAdeCodeState, saveAdeCodeState } from "./state"; +import { buildLinearToolRequest } from "./linearCommands"; +import { buildPendingInputAnswers, latestPendingApproval } from "./pendingInput"; +import type { + AdeCodeConnection, + AdeCodeProvider, + AdeCodeModelState, + LocalNotice, + MentionSuggestion, + PendingApproval, + ProviderReadinessRow, + ProjectLaunchContext, + RightPaneContent, + SetupPaneRow, + RuntimeMode, +} from "./types"; + +const PURPLE = theme.color.accent; +const EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +const PROVIDER_OPTIONS: Array<{ value: AdeCodeProvider; label: string }> = [ + { value: "codex", label: "Codex" }, + { value: "claude", label: "Claude" }, + { value: "opencode", label: "OpenCode" }, + { value: "cursor", label: "Cursor" }, + { value: "droid", label: "Droid" }, +]; +const PROVIDERS = new Set(PROVIDER_OPTIONS.map((provider) => provider.value)); +const CODEX_PRESETS = ["default", "plan", "full-auto", "config-toml"] as const; +const CLAUDE_PERMISSION_OPTIONS = ["default", "plan", "acceptEdits", "bypassPermissions"] as const; +const OPENCODE_PERMISSION_OPTIONS = ["plan", "edit", "full-auto"] as const; +const DROID_PERMISSION_OPTIONS = ["read-only", "auto-low", "auto-medium", "auto-high"] as const; +const SETTINGS_AI_ROUTE = "/settings?tab=ai#ai-providers"; +type PaneFocus = "drawer" | "chat" | "details"; +type FooterControl = "drawer" | "details"; +type DrawerLaneAction = "new-lane"; +type DrawerChatAction = "new-chat"; +const DESKTOP_COMMAND_ROUTES: Record = { + "/app-control": "/app-control", + "/browser": "/browser", + "/computer": "/proof", + "/computer-use": "/proof", + "/ios": "/ios-sim", + "/ios-sim": "/ios-sim", + "/macos-vm": "/macos-vm", + "/mission": "/missions", + "/missions": "/missions", + "/pencil": "/pencil", + "/proof": "/proof", +}; + +type AdeCodeAppProps = { + project: ProjectLaunchContext; + forceEmbedded?: boolean; + requireSocket?: boolean; + socketPath?: string | null; +}; + +function initialModelState(): AdeCodeModelState { + const descriptor = getDefaultModelDescriptor("codex"); + return { + provider: "codex", + model: descriptor?.providerModelId ?? "gpt-5.5", + modelId: descriptor?.id ?? null, + displayName: descriptor?.displayName ?? "GPT-5.5", + reasoningEffort: DEFAULT_CODEX_REASONING_EFFORT, + codexFastMode: false, + permissionMode: "default", + interactionMode: "default", + claudePermissionMode: "default", + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + opencodePermissionMode: "edit", + droidPermissionMode: "auto-low", + cursorModeId: "agent", + cursorConfigValues: {}, + }; +} + +type CodexPreset = (typeof CODEX_PRESETS)[number]; + +function providerLabel(provider: AdeCodeProvider): string { + return PROVIDER_OPTIONS.find((entry) => entry.value === provider)?.label ?? provider; +} + +function normalizeProvider(value: string | null | undefined): AdeCodeProvider { + return PROVIDERS.has(value as AdeCodeProvider) ? value as AdeCodeProvider : "codex"; +} + +function firstReasoningEffortForModel(model: AgentChatModelInfo | null | undefined, provider: AdeCodeProvider): string | null { + const efforts = model?.reasoningEfforts?.map((entry) => entry.effort).filter(Boolean) ?? []; + if (efforts.includes(DEFAULT_CODEX_REASONING_EFFORT)) return DEFAULT_CODEX_REASONING_EFFORT; + if (efforts.length) return efforts[0] ?? null; + const descriptor = model?.modelId || model?.id ? getModelById(model.modelId ?? model.id) : undefined; + const descriptorEfforts = descriptor?.reasoningTiers ?? []; + if (descriptorEfforts.includes(DEFAULT_CODEX_REASONING_EFFORT)) return DEFAULT_CODEX_REASONING_EFFORT; + if (descriptorEfforts.length) return descriptorEfforts[0] ?? null; + return provider === "codex" ? DEFAULT_CODEX_REASONING_EFFORT : null; +} + +function modelStatePatchForModel(provider: AdeCodeProvider, model: AgentChatModelInfo): Pick { + const modelId = model.modelId ?? model.id; + const descriptor = getModelById(modelId); + const resolvedProvider = descriptor ? normalizeProvider(resolveProviderGroupForModel(descriptor)) : provider; + return { + provider: resolvedProvider, + model: model.id, + modelId, + displayName: model.displayName, + reasoningEffort: firstReasoningEffortForModel(model, resolvedProvider), + }; +} + +function fallbackModelStatePatch(provider: AdeCodeProvider): Pick { + const descriptor = getDefaultModelDescriptor(provider) + ?? listModelDescriptorsForProvider(provider)[0] + ?? getDefaultModelDescriptor("codex"); + return { + provider, + model: descriptor?.providerModelId ?? descriptor?.shortId ?? descriptor?.id ?? "gpt-5.5", + modelId: descriptor?.id ?? null, + displayName: descriptor?.displayName ?? providerLabel(provider), + reasoningEffort: descriptor?.reasoningTiers?.[0] ?? (provider === "codex" ? DEFAULT_CODEX_REASONING_EFFORT : null), + }; +} + +function modelReasoningEfforts(modelState: AdeCodeModelState, models: AgentChatModelInfo[]): string[] { + if (modelState.provider === "cursor" || modelState.provider === "droid") return []; + const model = models.find((entry) => entry.id === modelState.modelId || entry.modelId === modelState.modelId); + const fromModel = model?.reasoningEfforts?.map((entry) => entry.effort).filter(Boolean) ?? []; + if (fromModel.length) return fromModel; + const descriptor = modelState.modelId ? getModelById(modelState.modelId) : undefined; + return descriptor?.reasoningTiers?.length ? descriptor.reasoningTiers : EFFORTS; +} + +function resolveCodexPreset(modelState: AdeCodeModelState): CodexPreset | "custom" { + if (modelState.codexConfigSource === "config-toml") return "config-toml"; + if (modelState.codexApprovalPolicy === "never" && modelState.codexSandbox === "danger-full-access") return "full-auto"; + if ( + (modelState.codexApprovalPolicy === "on-request" || modelState.codexApprovalPolicy === "untrusted") + && modelState.codexSandbox === "read-only" + ) return "plan"; + if ( + (modelState.codexApprovalPolicy === "on-request" || modelState.codexApprovalPolicy === "on-failure" || modelState.codexApprovalPolicy === "untrusted") + && modelState.codexSandbox === "workspace-write" + ) return "default"; + return "custom"; +} + +function codexPresetPatch(preset: CodexPreset): Pick { + if (preset === "full-auto") { + return { + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + permissionMode: "full-auto", + }; + } + if (preset === "plan") { + return { + codexApprovalPolicy: "on-request", + codexSandbox: "read-only", + codexConfigSource: "flags", + permissionMode: "plan", + }; + } + if (preset === "config-toml") { + return { + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "config-toml", + permissionMode: "config-toml", + }; + } + return { + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + permissionMode: "default", + }; +} + +function droidPermissionToLegacy(mode: AdeCodeModelState["droidPermissionMode"]): AgentChatPermissionMode { + if (mode === "read-only") return "plan"; + if (mode === "auto-low") return "edit"; + if (mode === "auto-medium") return "default"; + return "full-auto"; +} + +function cursorModeLabel(modeId: string | null | undefined): string { + const normalized = modeId?.trim().toLowerCase() || "agent"; + return CURSOR_MODE_LABELS[normalized] ?? normalized; +} + +function permissionSummary(modelState: AdeCodeModelState): string { + if (modelState.provider === "codex") return resolveCodexPreset(modelState); + if (modelState.provider === "claude") { + if (modelState.interactionMode === "plan" || modelState.claudePermissionMode === "plan") return "plan"; + if (modelState.claudePermissionMode === "acceptEdits") return "accept edits"; + if (modelState.claudePermissionMode === "bypassPermissions") return "bypass"; + return "default"; + } + if (modelState.provider === "opencode") return modelState.opencodePermissionMode; + if (modelState.provider === "droid") return modelState.droidPermissionMode; + return cursorModeLabel(modelState.cursorModeId); +} + +function applyProviderPermissionMode(modelState: AdeCodeModelState): Partial { + if (modelState.provider === "codex") { + const preset = resolveCodexPreset(modelState); + return { permissionMode: preset === "custom" ? modelState.permissionMode : preset }; + } + if (modelState.provider === "claude") { + if (modelState.interactionMode === "plan" || modelState.claudePermissionMode === "plan") { + return { permissionMode: "plan", interactionMode: "plan", claudePermissionMode: "plan" }; + } + if (modelState.claudePermissionMode === "acceptEdits") return { permissionMode: "edit", interactionMode: "default" }; + if (modelState.claudePermissionMode === "bypassPermissions") return { permissionMode: "full-auto", interactionMode: "default" }; + return { permissionMode: "default", interactionMode: "default" }; + } + if (modelState.provider === "opencode") return { permissionMode: modelState.opencodePermissionMode }; + if (modelState.provider === "droid") return { permissionMode: droidPermissionToLegacy(modelState.droidPermissionMode) }; + if (modelState.provider === "cursor") { + if (modelState.cursorModeId === "plan") return { permissionMode: "plan" }; + if (modelState.cursorModeId === "ask") return { permissionMode: "edit" }; + if (modelState.cursorModeId === "full-auto") return { permissionMode: "full-auto" }; + return { permissionMode: "default" }; + } + return {}; +} + +function noticeId(): string { + return `${Date.now()}:${Math.random().toString(36).slice(2)}`; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function routeRows(value: unknown): string[] { + if (Array.isArray(value)) return value.slice(0, 16).map((entry) => { + const record = entry && typeof entry === "object" ? entry as Record : {}; + return String(record.title ?? record.name ?? record.branchRef ?? record.id ?? JSON.stringify(entry)).slice(0, 90); + }); + const record = value && typeof value === "object" ? value as Record : {}; + const list = Object.values(record).find(Array.isArray); + return Array.isArray(list) ? routeRows(list) : renderObject(value, 12).split(/\r?\n/); +} + +function compactNumber(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; + return String(value); +} + +function formatTokenSummary(stats: ReturnType): string | null { + const parts: string[] = []; + if (stats.inputTokens != null) parts.push(`in ${compactNumber(stats.inputTokens)}`); + if (stats.outputTokens != null) parts.push(`out ${compactNumber(stats.outputTokens)}`); + if (stats.costUsd != null) parts.push(`$${stats.costUsd.toFixed(2)}`); + return parts.length ? parts.join(" · ") : null; +} + +function buildSetupRows(args: { + modelState: AdeCodeModelState; + models: AgentChatModelInfo[]; + includeRefresh: boolean; + includeApply: boolean; +}): SetupPaneRow[] { + const efforts = modelReasoningEfforts(args.modelState, args.models); + const descriptor = args.modelState.modelId ? getModelById(args.modelState.modelId) : undefined; + const fastSupported = args.modelState.provider === "codex" && modelSupportsFastMode(descriptor); + const rows: SetupPaneRow[] = [ + { + kind: "provider", + label: "Provider", + value: providerLabel(args.modelState.provider), + cyclable: true, + }, + { + kind: "model", + label: "Model", + value: args.modelState.displayName, + detail: args.models.length ? `${args.models.length} available` : "using registry default", + cyclable: true, + }, + { + kind: "reasoning", + label: "Reasoning", + value: args.modelState.reasoningEffort ?? "none", + detail: efforts.length ? efforts.join(", ") : "not exposed by this model", + disabled: !efforts.length, + cyclable: true, + }, + { + kind: "permission", + label: "Permissions", + value: permissionSummary(args.modelState), + detail: args.modelState.provider === "codex" + ? `${args.modelState.codexApprovalPolicy} / ${args.modelState.codexSandbox}` + : args.modelState.provider === "cursor" + ? "Cursor mode" + : "provider native", + cyclable: true, + }, + ]; + if (args.modelState.provider === "codex") { + rows.push({ + kind: "codex-fast", + label: "Fast mode", + value: fastSupported ? (args.modelState.codexFastMode ? "on" : "off") : "unsupported", + detail: "Codex service tier", + disabled: !fastSupported, + cyclable: true, + }); + } + if (args.includeRefresh) { + rows.push({ + kind: "refresh-status", + label: "Refresh status", + value: "run", + detail: "checks provider auth/runtime state", + }); + } + rows.push({ + kind: "open-settings", + label: "Full settings", + value: "open desktop", + detail: "Settings > AI Providers", + }); + if (args.includeApply) { + rows.push({ + kind: "apply", + label: "Use this setup", + value: "ready", + detail: "returns focus to the chat composer", + }); + } + return rows; +} + +function setupRowsForRuntime(rows: SetupPaneRow[], mode: RuntimeMode | "connecting"): SetupPaneRow[] { + if (mode === "attached") return rows; + return rows.map((row) => row.kind === "open-settings" + ? { + ...row, + value: "unavailable", + detail: "use /login for Claude, Codex, or OpenCode; open ADE desktop for full settings", + disabled: true, + } + : row); +} + +function providerConnectionDetail(status: AiSettingsStatus | null, provider: Exclude): ProviderReadinessRow { + const connection = status?.providerConnections?.[provider]; + const modelCount = status?.models?.[provider]?.length ?? 0; + if (connection?.runtimeAvailable) { + return { + provider, + label: providerLabel(provider), + status: "ready", + detail: connection.path ? `ready at ${connection.path}` : "runtime and auth ready", + modelCount, + }; + } + if (connection?.runtimeDetected || connection?.authAvailable) { + return { + provider, + label: providerLabel(provider), + status: "unknown", + detail: connection.blocker ?? "detected but not fully ready", + modelCount, + }; + } + return { + provider, + label: providerLabel(provider), + status: "unavailable", + detail: connection?.blocker ?? "not detected", + modelCount, + }; +} + +function buildProviderReadinessRows( + status: AiSettingsStatus | null, + storedApiKeyProviders: string[], + openCodeDiagnostics: OpenCodeRuntimeSnapshot | null, +): ProviderReadinessRow[] { + const rows: ProviderReadinessRow[] = [ + providerConnectionDetail(status, "codex"), + providerConnectionDetail(status, "claude"), + providerConnectionDetail(status, "cursor"), + providerConnectionDetail(status, "droid"), + ]; + const opencodeProviders = status?.opencodeProviders ?? []; + const opencodeModelCount = opencodeProviders.reduce((sum, provider) => sum + provider.modelCount, 0); + rows.push({ + provider: "opencode", + label: "OpenCode", + status: status?.opencodeBinaryInstalled ? "ready" : "unavailable", + detail: status?.opencodeInventoryError + ?? (status?.opencodeBinaryInstalled + ? `${status.opencodeBinarySource ?? "installed"} · ${openCodeDiagnostics?.sharedCount ?? 0} shared runtime` + : "binary missing"), + modelCount: opencodeModelCount, + }); + if (storedApiKeyProviders.includes("cursor")) { + const cursor = rows.find((row) => row.provider === "cursor"); + if (cursor && cursor.status !== "ready") { + cursor.detail = `${cursor.detail} · Cursor key stored`; + } + } + return rows; +} + +function desktopRouteForCommand(commandName: string | null | undefined): string | null { + if (!commandName) return null; + return DESKTOP_COMMAND_ROUTES[commandName] ?? null; +} + +function splitFirstArg(input: string): { first: string; rest: string } { + const trimmed = input.trim(); + const match = trimmed.match(/^(\S+)(?:\s+([\s\S]*))?$/); + return { + first: match?.[1] ?? "", + rest: match?.[2]?.trim() ?? "", + }; +} + +type ParsedAdeActionPayload = + | { args: Record } + | { argsList: unknown[] } + | { arg: unknown }; + +function parseAdeActionPayload(input: string): ParsedAdeActionPayload { + const trimmed = input.trim(); + if (!trimmed) return { args: {} }; + const parsed = JSON.parse(trimmed) as unknown; + if (Array.isArray(parsed)) { + return { argsList: parsed }; + } + if (parsed && typeof parsed === "object") { + return { args: parsed as Record }; + } + return { arg: parsed }; +} + +function parseLinearIssueListArgs(input: string): Record { + const projectSlugs: string[] = []; + const stateTypes: string[] = []; + let limit: number | undefined; + const tokens = input.match(/"([^"\\]*(?:\\.[^"\\]*)*)"|'([^']*)'|(\S+)/g)?.map((token) => ( + token.startsWith("\"") && token.endsWith("\"") + ? token.slice(1, -1).replace(/\\"/g, "\"") + : token.startsWith("'") && token.endsWith("'") + ? token.slice(1, -1) + : token + )) ?? []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] ?? ""; + const next = tokens[index + 1]; + if ((token === "--project" || token === "--project-slug" || token === "--projects") && next) { + projectSlugs.push(...next.split(",").map((entry) => entry.trim()).filter(Boolean)); + index += 1; + } else if ((token === "--state" || token === "--states" || token === "--state-type") && next) { + stateTypes.push(...next.split(",").map((entry) => entry.trim()).filter(Boolean)); + index += 1; + } else if (token === "--limit" && next && Number.isFinite(Number(next))) { + limit = Math.max(1, Math.min(100, Math.floor(Number(next)))); + index += 1; + } else if (!token.startsWith("--")) { + projectSlugs.push(token); + } + } + return { + projectSlugs, + stateTypes, + ...(limit ? { limit } : {}), + }; +} + +function printableInput(input: string): string { + return input.replace(/[\u0000-\u001f\u007f]/g, ""); +} + +function inputBeforeLineBreak(input: string): string | null { + const index = input.search(/[\r\n]/); + return index === -1 ? null : input.slice(0, index); +} + +function runInteractiveTerminalCommand(command: string, args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const stdin = process.stdin as NodeJS.ReadStream & { isRaw?: boolean; setRawMode?: (mode: boolean) => void }; + const wasRaw = Boolean(stdin.isRaw); + if (typeof stdin.setRawMode === "function") { + stdin.setRawMode(false); + } + process.stdout.write("\n"); + const child = spawn(command, args, { + cwd, + stdio: "inherit", + env: process.env, + }); + const restore = () => { + if (typeof stdin.setRawMode === "function") { + stdin.setRawMode(wasRaw); + } + }; + child.once("error", (error) => { + restore(); + reject(error); + }); + child.once("close", (code) => { + restore(); + process.stdout.write("\n"); + resolve(code); + }); + }); +} + +type ProviderLoginCommand = { command: string; args: string[]; label: string }; + +function loginCommandsForProvider(provider: AdeCodeProvider): ProviderLoginCommand[] { + if (provider === "claude") return [{ command: "claude", args: ["auth", "login"], label: "claude auth login" }]; + if (provider === "codex") return [{ command: "codex", args: ["login"], label: "codex login" }]; + if (provider === "opencode") return [{ command: "opencode", args: ["auth", "login"], label: "opencode auth login" }]; + return []; +} + +function loginUnavailableHint(provider: AdeCodeProvider): string { + if (provider === "cursor") { + return "ADE Cursor chat uses @cursor/sdk, which requires a Cursor API key. Open Settings > AI Providers, use ADE's encrypted key store, or set CURSOR_API_KEY before launching ADE."; + } + if (provider === "droid") { + return "ADE Droid chat runs Factory Droid over ACP. Set FACTORY_API_KEY before launching ADE, or run `droid` and use its interactive `/login`."; + } + return "No terminal login command is known for this provider."; +} + +function activeMention(value: string): { start: number; query: string } | null { + const match = value.match(/(^|\s)@([^\s@]*)$/); + if (!match || match.index == null) return null; + return { + start: match.index + match[1].length, + query: match[2] ?? "", + }; +} + +function useTerminalDimensions(): [number, number] { + const read = (): [number, number] => [ + process.stdout.columns ?? 120, + process.stdout.rows ?? 40, + ]; + const [dimensions, setDimensions] = useState<[number, number]>(read); + useEffect(() => { + const handleResize = () => setDimensions(read()); + process.stdout.on("resize", handleResize); + return () => { + process.stdout.off("resize", handleResize); + }; + }, []); + return dimensions; +} + +export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath }: AdeCodeAppProps) { + const { exit } = useApp(); + const [columns, rows] = useTerminalDimensions(); + const [connection, setConnection] = useState(null); + const [mode, setMode] = useState("connecting"); + const [lanes, setLanes] = useState([]); + const [sessions, setSessions] = useState([]); + const [activeLaneId, setActiveLaneId] = useState(null); + const [activeSessionId, setActiveSessionId] = useState(null); + const [events, setEvents] = useState([]); + const [notices, setNotices] = useState([]); + const [slashCommands, setSlashCommands] = useState([]); + const [models, setModels] = useState([]); + const [modelState, setModelState] = useState(initialModelState); + const [draftChatActive, setDraftChatActive] = useState(false); + const [aiStatus, setAiStatus] = useState(null); + const [aiStatusCheckedAt, setAiStatusCheckedAt] = useState(null); + const [storedApiKeyProviders, setStoredApiKeyProviders] = useState([]); + const [openCodeDiagnostics, setOpenCodeDiagnostics] = useState(null); + const [rightPane, setRightPane] = useState({ kind: "empty" }); + const [formValues, setFormValues] = useState>({}); + const [formFieldIndex, setFormFieldIndex] = useState(0); + const [rightSelectionIndex, setRightSelectionIndex] = useState(0); + const [drawerOpen, setDrawerOpen] = useState(false); + const [rightOpen, setRightOpen] = useState(false); + const [activePane, setActivePane] = useState("chat"); + const [prompt, setPrompt] = useState(""); + const [error, setError] = useState(null); + const [contextPercent, setContextPercent] = useState(null); + const [tokenSummary, setTokenSummary] = useState(null); + const [streaming, setStreaming] = useState(false); + const [clearedAt, setClearedAt] = useState(null); + const [expandedLineIds, setExpandedLineIds] = useState>(() => new Set()); + const [chatScrollOffsetRows, setChatScrollOffsetRows] = useState(0); + const [mentionSuggestions, setMentionSuggestions] = useState([]); + const [mentionIndex, setMentionIndex] = useState(0); + const [selectedMentions, setSelectedMentions] = useState([]); + const [slashIndex, setSlashIndex] = useState(0); + const [drawerSection, setDrawerSection] = useState<"lanes" | "chats">("lanes"); + const [drawerLaneId, setDrawerLaneId] = useState(null); + const [selectedDrawerLaneId, setSelectedDrawerLaneId] = useState(null); + const [selectedDrawerChatId, setSelectedDrawerChatId] = useState(null); + const [selectedDrawerLaneAction, setSelectedDrawerLaneAction] = useState(null); + const [selectedDrawerChatAction, setSelectedDrawerChatAction] = useState(null); + const [formDiscardArmed, setFormDiscardArmed] = useState(false); + const [footerControl, setFooterControl] = useState(null); + + const connectionRef = useRef(null); + const activeLaneIdRef = useRef(null); + const activeSessionIdRef = useRef(null); + const draftChatActiveRef = useRef(false); + const activePaneRef = useRef("chat"); + const footerControlRef = useRef(null); + const paneBeforeDetailsRef = useRef("chat"); + const chatDraftRef = useRef(""); + const promptRef = useRef(""); + const lastLocalSendAtRef = useRef(0); + const eventCountRef = useRef(0); + const chatScrollOffsetRowsRef = useRef(0); + const heartbeatRef = useRef(null); + const draftSeededFromHistoryRef = useRef(false); + const attachProbeInFlightRef = useRef(false); + const lastChatByLaneRef = useRef>(new Map(Object.entries(loadAdeCodeState().lastChatByLane))); + const lastChatByLaneWriteTimerRef = useRef(null); + const pendingNewChatTitleRef = useRef(null); + + const persistLastChatByLane = useCallback(() => { + if (lastChatByLaneWriteTimerRef.current) { + clearTimeout(lastChatByLaneWriteTimerRef.current); + } + lastChatByLaneWriteTimerRef.current = setTimeout(() => { + lastChatByLaneWriteTimerRef.current = null; + const lastChatByLane: Record = {}; + for (const [laneId, sessionId] of lastChatByLaneRef.current) { + lastChatByLane[laneId] = sessionId; + } + saveAdeCodeState({ lastChatByLane }); + }, 500); + }, []); + + const setChatScrollOffset = useCallback((value: number | ((previous: number) => number)) => { + setChatScrollOffsetRows((previous) => { + const next = Math.max(0, typeof value === "function" ? value(previous) : value); + chatScrollOffsetRowsRef.current = next; + return next; + }); + }, []); + + const selectActiveLaneId = useCallback((laneId: string | null) => { + if (activeLaneIdRef.current !== laneId) setChatScrollOffset(0); + activeLaneIdRef.current = laneId; + setActiveLaneId(laneId); + }, [setChatScrollOffset]); + + const selectActiveSessionId = useCallback((sessionId: string | null) => { + if (activeSessionIdRef.current !== sessionId) setChatScrollOffset(0); + if (sessionId) { + draftChatActiveRef.current = false; + setDraftChatActive(false); + setSelectedDrawerChatAction(null); + const laneId = activeLaneIdRef.current; + if (laneId && lastChatByLaneRef.current.get(laneId) !== sessionId) { + lastChatByLaneRef.current.set(laneId, sessionId); + persistLastChatByLane(); + } + } + activeSessionIdRef.current = sessionId; + setActiveSessionId(sessionId); + }, [persistLastChatByLane, setChatScrollOffset]); + + const setDraftChatMode = useCallback((active: boolean) => { + setChatScrollOffset(0); + draftChatActiveRef.current = active; + setDraftChatActive(active); + }, [setChatScrollOffset]); + + const setPaneFocus = useCallback((pane: PaneFocus) => { + activePaneRef.current = pane; + setActivePane(pane); + }, []); + + const selectFooterControl = useCallback((control: FooterControl | null) => { + footerControlRef.current = control; + setFooterControl(control); + }, []); + + useEffect(() => { + promptRef.current = prompt; + }, [prompt]); + + const stashActiveInput = useCallback(() => { + const pane = activePaneRef.current; + if (pane === "chat") { + chatDraftRef.current = promptRef.current; + return; + } + if (pane === "details" && rightPane.kind === "form") { + const field = rightPane.fields[formFieldIndex] ?? rightPane.fields[0]; + if (field) { + setFormValues((prev) => ({ ...prev, [field.name]: promptRef.current })); + } + } + }, [formFieldIndex, rightPane]); + + const focusChat = useCallback(() => { + stashActiveInput(); + setFormDiscardArmed(false); + selectFooterControl(null); + setPrompt(chatDraftRef.current); + setPaneFocus("chat"); + }, [selectFooterControl, setPaneFocus, stashActiveInput]); + + const focusDrawer = useCallback(() => { + stashActiveInput(); + setFormDiscardArmed(false); + selectFooterControl(null); + setPrompt(""); + setDrawerOpen(true); + setPaneFocus("drawer"); + }, [selectFooterControl, setPaneFocus, stashActiveInput]); + + const focusDetails = useCallback(() => { + const previousPane = activePaneRef.current; + stashActiveInput(); + selectFooterControl(null); + if (previousPane !== "details") { + paneBeforeDetailsRef.current = previousPane; + } + setFormDiscardArmed(false); + setRightOpen(true); + if (rightPane.kind === "form") { + const field = rightPane.fields[formFieldIndex] ?? rightPane.fields[0]; + setPrompt(field ? formValues[field.name] ?? field.initialValue ?? "" : ""); + } else { + setPrompt(""); + } + setPaneFocus("details"); + }, [formFieldIndex, formValues, rightPane, selectFooterControl, setPaneFocus, stashActiveInput]); + + const toggleDrawerPane = useCallback(() => { + selectFooterControl(null); + if (drawerOpen) { + setDrawerOpen(false); + focusChat(); + return; + } + focusDrawer(); + }, [drawerOpen, focusChat, focusDrawer, selectFooterControl]); + + const toggleDetailsPane = useCallback(() => { + selectFooterControl(null); + if (rightOpen && rightPane.kind !== "form") { + setRightOpen(false); + focusChat(); + return; + } + if (activePaneRef.current === "details") { + focusChat(); + return; + } + focusDetails(); + }, [focusChat, focusDetails, rightOpen, rightPane.kind, selectFooterControl]); + + const cyclePaneFocus = useCallback(() => { + const order: PaneFocus[] = ["drawer", "chat", "details"]; + const currentIndex = order.indexOf(activePaneRef.current); + const nextPane = order[(currentIndex + 1) % order.length] ?? "chat"; + if (nextPane === "drawer") { + focusDrawer(); + } else if (nextPane === "details") { + focusDetails(); + } else { + focusChat(); + } + }, [focusChat, focusDetails, focusDrawer]); + + const focusAfterDetails = useCallback(() => { + if (paneBeforeDetailsRef.current === "drawer" && drawerOpen) { + focusDrawer(); + return; + } + focusChat(); + }, [drawerOpen, focusChat, focusDrawer]); + + const projectName = path.basename(project.projectRoot); + const activeLane = useMemo( + () => lanes.find((lane) => lane.id === activeLaneId) ?? null, + [activeLaneId, lanes], + ); + const activeSession = useMemo( + () => sessions.find((session) => session.sessionId === activeSessionId) ?? null, + [activeSessionId, sessions], + ); + const latestFailedLineId = useMemo(() => latestExpandableFailureId(events), [events]); + const drawerLaneRows = useMemo( + () => lanes.slice(0, visibleDrawerLaneCount(rows, lanes.length)), + [lanes, rows], + ); + const drawerLaneSessions = useMemo( + () => sessions.filter((session) => session.laneId === drawerLaneId), + [drawerLaneId, sessions], + ); + const drawerVisibleLaneSessions = useMemo( + () => drawerLaneSessions.slice(0, visibleDrawerChatCount(drawerLaneSessions.length)), + [drawerLaneSessions], + ); + const selectedLaneIndex = useMemo(() => { + if (selectedDrawerLaneAction === "new-lane") return drawerLaneRows.length; + const targetId = selectedDrawerLaneId ?? drawerLaneId ?? activeLaneId; + const index = drawerLaneRows.findIndex((lane) => lane.id === targetId); + return index >= 0 ? index : 0; + }, [activeLaneId, drawerLaneId, drawerLaneRows, selectedDrawerLaneAction, selectedDrawerLaneId]); + const selectedChatIndex = useMemo(() => { + if (selectedDrawerChatAction === "new-chat") return drawerVisibleLaneSessions.length; + const targetId = selectedDrawerChatId + ?? (drawerLaneId === activeLaneId ? activeSessionId : null); + const index = drawerVisibleLaneSessions.findIndex((session) => session.sessionId === targetId); + return index >= 0 ? index : 0; + }, [activeLaneId, activeSessionId, drawerLaneId, drawerVisibleLaneSessions, selectedDrawerChatAction, selectedDrawerChatId]); + const activeMentionRange = useMemo(() => ( + activePane === "chat" ? activeMention(prompt) : null + ), [activePane, prompt]); + const slashRows = useMemo(() => ( + activePane === "chat" && prompt.startsWith("/") ? paletteCommands(prompt, slashCommands) : [] + ), [activePane, prompt, slashCommands]); + const pendingApproval = useMemo(() => latestPendingApproval(events), [events]); + const activeFormField = rightPane.kind === "form" + ? rightPane.fields[formFieldIndex] ?? rightPane.fields[0] ?? null + : null; + const providerReadinessRows = useMemo( + () => buildProviderReadinessRows(aiStatus, storedApiKeyProviders, openCodeDiagnostics), + [aiStatus, openCodeDiagnostics, storedApiKeyProviders], + ); + const newChatSetupRows = useMemo( + () => setupRowsForRuntime(buildSetupRows({ modelState, models, includeRefresh: false, includeApply: true }), mode), + [mode, modelState, models], + ); + const modelSetupRows = useMemo( + () => setupRowsForRuntime(buildSetupRows({ modelState, models, includeRefresh: true, includeApply: false }), mode), + [mode, modelState, models], + ); + + useEffect(() => { + activeLaneIdRef.current = activeLaneId; + }, [activeLaneId]); + + useEffect(() => { + activeSessionIdRef.current = activeSessionId; + }, [activeSessionId]); + + useEffect(() => { + if (rightPane.kind === "new-chat-setup") { + setRightPane((prev) => prev.kind === "new-chat-setup" + ? { + ...prev, + laneId: activeLaneId ?? prev.laneId, + laneLabel: activeLane?.name ?? prev.laneLabel, + rows: newChatSetupRows, + } + : prev); + } else if (rightPane.kind === "model-setup") { + setRightPane((prev) => prev.kind === "model-setup" + ? { + ...prev, + rows: modelSetupRows, + providerRows: providerReadinessRows, + activeProvider: modelState.provider, + checkedAt: aiStatusCheckedAt, + desktopAttached: mode === "attached", + } + : prev); + } + }, [activeLane?.name, activeLaneId, aiStatusCheckedAt, mode, modelSetupRows, modelState.provider, newChatSetupRows, providerReadinessRows, rightPane.kind]); + + useEffect(() => { + if (activePane !== "details" || !rightOpen) return; + if (!activeLane || !activeLaneId) return; + if (rightPane.kind !== "empty" && rightPane.kind !== "lane-details") return; + + let cancelled = false; + const lane = activeLane; + const laneId = activeLaneId; + + const refresh = async () => { + const conn = connectionRef.current; + if (!conn) return; + try { + const [syncRes, changesRes, prsRes] = await Promise.all([ + conn.action<{ ahead?: number; behind?: number; upstreamRef?: string | null }>("git", "getSyncStatus", { laneId }).catch(() => null), + conn.actionList<{ staged: { path: string; kind: string }[]; unstaged: { path: string; kind: string }[] }>("diff", "getChanges", [laneId]).catch(() => null), + conn.action>>("pr", "listAll", { laneId }).catch(() => [] as Array>), + ]); + if (cancelled) return; + + const ahead = typeof syncRes?.ahead === "number" ? syncRes.ahead : 0; + const behind = typeof syncRes?.behind === "number" ? syncRes.behind : 0; + const remote = typeof syncRes?.upstreamRef === "string" ? syncRes.upstreamRef : null; + + const staged = changesRes?.staged ?? []; + const unstaged = changesRes?.unstaged ?? []; + const fileMap = new Map(); + const toStatus = (kind: string): "M" | "A" | "D" | "?" => { + if (kind === "added" || kind === "untracked") return kind === "untracked" ? "?" : "A"; + if (kind === "deleted") return "D"; + if (kind === "modified" || kind === "renamed") return "M"; + return "?"; + }; + for (const file of staged) { + fileMap.set(file.path, { path: file.path, status: toStatus(file.kind), staged: true }); + } + for (const file of unstaged) { + if (!fileMap.has(file.path)) { + fileMap.set(file.path, { path: file.path, status: toStatus(file.kind), staged: false }); + } + } + const files = [...fileMap.values()]; + + const activePr = prsRes[0] ?? null; + let pr: { number: number; state: "open" | "closed" | "merged"; url: string; checksPassed: number; checksTotal: number } | null = null; + if (activePr) { + const number = typeof activePr.githubPrNumber === "number" + ? activePr.githubPrNumber + : typeof activePr.number === "number" + ? activePr.number + : null; + const url = typeof activePr.githubUrl === "string" + ? activePr.githubUrl + : typeof activePr.url === "string" + ? activePr.url + : ""; + const rawState = typeof activePr.state === "string" ? activePr.state : "open"; + const state: "open" | "closed" | "merged" = + rawState === "merged" ? "merged" : rawState === "closed" ? "closed" : "open"; + const prId = typeof activePr.id === "string" ? activePr.id : typeof activePr.prId === "string" ? activePr.prId : ""; + let checksPassed = 0; + let checksTotal = 0; + if (prId) { + const checks = await conn.actionList>("pr", "getChecks", [prId]).catch(() => null); + if (!cancelled && Array.isArray(checks)) { + checksTotal = checks.length; + checksPassed = checks.filter((check) => check.status === "completed" && check.conclusion === "success").length; + } + } + if (number != null && url) { + pr = { number, state, url, checksPassed, checksTotal }; + } + } + + if (cancelled) return; + setRightPane((prev) => { + if (cancelled) return prev; + if (prev.kind !== "lane-details" && prev.kind !== "empty") return prev; + const previousIndex = prev.kind === "lane-details" ? prev.selectedActionIndex : 0; + const previousShowFiles = prev.kind === "lane-details" ? prev.showFiles : false; + const maxIndex = LANE_DETAIL_ACTIONS.length - 1 + (pr ? 1 : 0); + return { + kind: "lane-details", + lane, + git: { + staged: staged.length, + unstaged: unstaged.length, + total: files.length, + ahead, + behind, + remote, + }, + files, + pr, + showFiles: previousShowFiles, + selectedActionIndex: Math.max(0, Math.min(previousIndex, maxIndex)), + }; + }); + } catch { + // best-effort — leave the existing pane content alone on transient errors + } + }; + + void refresh(); + const interval = setInterval(() => { + void refresh(); + }, 5000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [activeLane, activeLaneId, activePane, rightOpen, rightPane.kind]); + + useEffect(() => { + if (!drawerLaneId || !lanes.some((lane) => lane.id === drawerLaneId)) { + setDrawerLaneId(activeLaneId); + } + }, [activeLaneId, drawerLaneId, lanes]); + + useEffect(() => { + if (selectedDrawerLaneAction) return; + if (selectedDrawerLaneId && drawerLaneRows.some((lane) => lane.id === selectedDrawerLaneId)) return; + setSelectedDrawerLaneId(drawerLaneId ?? activeLaneId ?? drawerLaneRows[0]?.id ?? null); + }, [activeLaneId, drawerLaneId, drawerLaneRows, selectedDrawerLaneAction, selectedDrawerLaneId]); + + useEffect(() => { + if (selectedDrawerChatAction) return; + if (draftChatActive && drawerLaneId === activeLaneId) { + setSelectedDrawerChatId(null); + setSelectedDrawerChatAction("new-chat"); + return; + } + if (selectedDrawerChatId && drawerVisibleLaneSessions.some((session) => session.sessionId === selectedDrawerChatId)) return; + const activeChatInDrawer = drawerVisibleLaneSessions.find((session) => session.sessionId === activeSessionId); + setSelectedDrawerChatId(activeChatInDrawer?.sessionId ?? drawerVisibleLaneSessions[0]?.sessionId ?? null); + }, [activeLaneId, activeSessionId, draftChatActive, drawerLaneId, drawerVisibleLaneSessions, selectedDrawerChatAction, selectedDrawerChatId]); + + useEffect(() => { + setSlashIndex(0); + }, [prompt]); + + const addNotice = useCallback((text: string, tone: LocalNotice["tone"] = "info") => { + setNotices((prev) => [ + ...prev.slice(-10), + { id: noticeId(), timestamp: new Date().toISOString(), text, tone }, + ]); + }, []); + + const refreshAiSetupStatus = useCallback(async (options: { force?: boolean } = {}) => { + const conn = connectionRef.current; + if (!conn) return; + const [status, storedProviders, diagnostics] = await Promise.all([ + getAiSettingsStatus(conn, { + force: options.force === true, + refreshOpenCodeInventory: true, + }), + getStoredApiKeyProviders(conn).catch(() => []), + getOpenCodeRuntimeDiagnostics(conn).catch(() => null), + ]); + setAiStatus(status); + setStoredApiKeyProviders(storedProviders.map((provider) => provider.trim().toLowerCase()).filter(Boolean)); + setOpenCodeDiagnostics(diagnostics); + setAiStatusCheckedAt(new Date().toISOString()); + }, []); + + const loadProviderModels = useCallback(async (provider: AdeCodeProvider, options: { applyDefault?: boolean } = {}) => { + const conn = connectionRef.current; + const nextModels = conn + ? await getAvailableModels(conn, provider).catch(() => []) + : []; + setModels(nextModels); + if (options.applyDefault !== false) { + const model = nextModels.find((entry) => entry.isDefault) ?? nextModels[0] ?? null; + setModelState((prev) => ({ + ...prev, + ...(model ? modelStatePatchForModel(provider, model) : fallbackModelStatePatch(provider)), + })); + } + return nextModels; + }, []); + + const openForm = useCallback((content: Extract) => { + const previousPane = activePaneRef.current; + stashActiveInput(); + if (previousPane !== "details") { + paneBeforeDetailsRef.current = previousPane; + } + const nextValues = Object.fromEntries(content.fields.map((field) => [field.name, field.initialValue ?? ""])); + setFormValues(nextValues); + setFormFieldIndex(0); + setFormDiscardArmed(false); + setPrompt(content.fields[0]?.initialValue ?? ""); + setRightPane(content); + setRightOpen(true); + setPaneFocus("details"); + }, [setPaneFocus, stashActiveInput]); + + const openNewLaneForm = useCallback(() => { + openForm({ + kind: "form", + title: "New lane", + command: "new-lane", + fields: [ + { name: "name", label: "Name", required: true, placeholder: "feature-name" }, + { name: "baseBranch", label: "Base branch", placeholder: "default" }, + ], + }); + }, [openForm]); + + const openNewChatSetup = useCallback((title?: string | null) => { + if (!activeLaneIdRef.current) { + setRightPane({ kind: "details", title: "New chat", body: "No active lane is available." }); + focusDetails(); + return; + } + const trimmedTitle = title?.trim() || null; + pendingNewChatTitleRef.current = trimmedTitle; + draftSeededFromHistoryRef.current = true; + const previousPane = activePaneRef.current; + stashActiveInput(); + if (previousPane !== "details") { + paneBeforeDetailsRef.current = previousPane; + } + setDraftChatMode(true); + selectActiveSessionId(null); + setEvents([]); + setClearedAt(null); + chatDraftRef.current = ""; + setPrompt(""); + setRightSelectionIndex(0); + setFormDiscardArmed(false); + setRightPane({ + kind: "new-chat-setup", + laneId: activeLaneIdRef.current, + laneLabel: activeLane?.name ?? activeLaneIdRef.current, + rows: newChatSetupRows, + }); + setRightOpen(true); + setPaneFocus("details"); + void refreshAiSetupStatus().catch(() => undefined); + void loadProviderModels(modelState.provider, { applyDefault: false }).catch(() => undefined); + }, [activeLane?.name, focusDetails, loadProviderModels, modelState.provider, newChatSetupRows, refreshAiSetupStatus, selectActiveSessionId, setDraftChatMode, setPaneFocus, stashActiveInput]); + + const openModelSetup = useCallback((options: { forceRefresh?: boolean } = {}) => { + const previousPane = activePaneRef.current; + stashActiveInput(); + if (previousPane !== "details") { + paneBeforeDetailsRef.current = previousPane; + } + setRightSelectionIndex(0); + setRightPane({ + kind: "model-setup", + rows: modelSetupRows, + providerRows: providerReadinessRows, + activeProvider: modelState.provider, + checkedAt: aiStatusCheckedAt, + desktopAttached: mode === "attached", + }); + setRightOpen(true); + setPrompt(""); + setPaneFocus("details"); + void refreshAiSetupStatus({ force: options.forceRefresh === true }).catch((err) => { + addNotice(err instanceof Error ? err.message : String(err), "error"); + }); + void loadProviderModels(modelState.provider, { applyDefault: false }).catch(() => undefined); + }, [addNotice, aiStatusCheckedAt, loadProviderModels, mode, modelSetupRows, modelState.provider, providerReadinessRows, refreshAiSetupStatus, setPaneFocus, stashActiveInput]); + + useEffect(() => { + const range = activeMentionRange; + const conn = connectionRef.current; + const laneId = activeLaneIdRef.current; + if (!range) { + setMentionSuggestions([]); + setMentionIndex(0); + return; + } + let cancelled = false; + const query = range.query.toLowerCase(); + const localSuggestions: MentionSuggestion[] = [ + ...lanes.map((lane) => ({ + kind: "lane" as const, + label: lane.name, + insertText: `@lane:${lane.id}`, + detail: lane.branchRef ?? lane.id, + })), + ...sessions.slice(0, 30).map((session) => ({ + kind: "chat" as const, + label: session.title ?? session.sessionId, + insertText: `@chat:${session.sessionId}`, + detail: session.laneId, + })), + ].filter((suggestion) => ( + !query + || suggestion.label.toLowerCase().includes(query) + || suggestion.insertText.toLowerCase().includes(query) + || suggestion.detail?.toLowerCase().includes(query) + )); + + const loadRemoteSuggestions = async () => { + const remote: MentionSuggestion[] = []; + if (conn && laneId) { + const [files, commits, prs] = await Promise.all([ + query + ? conn.action>("file", "quickOpen", { + workspaceId: laneId, + query, + limit: 5, + }).catch(() => []) + : Promise.resolve([]), + conn.action>>("git", "listRecentCommits", { + laneId, + limit: 8, + }).catch(() => []), + conn.action>>("pr", "listAll", { laneId }).catch(() => []), + ]); + remote.push(...files.map((file) => ({ + kind: "file" as const, + label: file.path, + insertText: `@file:${file.path}`, + detail: "file", + filePath: file.path, + }))); + remote.push(...commits + .filter((commit) => { + const subject = String(commit.subject ?? commit.message ?? ""); + const sha = String(commit.shortSha ?? commit.sha ?? ""); + return !query || subject.toLowerCase().includes(query) || sha.toLowerCase().includes(query); + }) + .slice(0, 5) + .map((commit) => { + const sha = String(commit.shortSha ?? commit.sha ?? "commit"); + return { + kind: "commit" as const, + label: String(commit.subject ?? commit.message ?? sha), + insertText: `@commit:${sha}`, + detail: sha, + }; + })); + remote.push(...prs + .filter((pr) => { + const title = String(pr.title ?? ""); + const number = String(pr.number ?? pr.prNumber ?? ""); + return !query || title.toLowerCase().includes(query) || number.includes(query); + }) + .slice(0, 5) + .map((pr) => { + const id = String(pr.id ?? pr.prId ?? pr.number ?? "pr"); + return { + kind: "pr" as const, + label: String(pr.title ?? `PR ${id}`), + insertText: `@pr:${id}`, + detail: pr.number != null ? `#${String(pr.number)}` : id, + }; + })); + } + if (cancelled) return; + const next = [...localSuggestions, ...remote].slice(0, 10); + setMentionSuggestions(next); + setMentionIndex((index) => Math.min(index, Math.max(0, next.length - 1))); + }; + void loadRemoteSuggestions(); + return () => { + cancelled = true; + }; + }, [activeMentionRange, lanes, sessions]); + + const refreshState = useCallback(async () => { + const conn = connectionRef.current; + if (!conn) return; + const nextLanes = await listLanes(conn); + const nextLane = nextLanes.find((lane) => lane.id === activeLaneIdRef.current) + ?? chooseInitialLane(nextLanes, project); + const nextLaneId = nextLane?.id ?? null; + const nextSessions = await listChatSessions(conn); + const laneSessions = nextSessions.filter((session) => session.laneId === nextLaneId); + const draftMode = draftChatActiveRef.current; + const seedSession = draftMode ? newestSession(laneSessions) : null; + const nextSession = draftMode + ? null + : nextSessions.find((session) => session.sessionId === activeSessionIdRef.current) + ?? newestSession(laneSessions); + const nextSessionId = nextSession?.sessionId ?? null; + let nextEvents: AgentChatEventEnvelope[] = []; + if (nextSessionId) { + const history = await getChatHistory(conn, nextSessionId); + nextEvents = clearedAt + ? history.events.filter((event) => event.timestamp > clearedAt) + : history.events; + const activeModelId = nextSession?.modelId ?? null; + const fallbackContext = activeModelId ? getModelById(activeModelId)?.contextWindow ?? null : null; + const stats = latestTokenStats(history.events, fallbackContext); + setContextPercent(stats.percent); + setTokenSummary(formatTokenSummary(stats)); + setStreaming(nextSession?.status === "active"); + eventCountRef.current = history.events.length; + } else { + setContextPercent(null); + setTokenSummary(null); + setStreaming(false); + eventCountRef.current = 0; + } + const configSession = nextSession ?? (!draftSeededFromHistoryRef.current ? seedSession : null); + const nextProvider = configSession?.provider ?? modelState.provider ?? "codex"; + const commandSessionId = nextSessionId ?? configSession?.sessionId ?? null; + const remoteCommands = commandSessionId ? await getSlashCommands(conn, commandSessionId).catch(() => []) : []; + const projectCommands = discoverProjectSlashCommands(nextLane?.worktreePath || project.workspaceRoot); + const nextCommands = remoteCommands.length ? remoteCommands : projectCommands; + const nextModels = await getAvailableModels(conn, nextProvider).catch(() => []); + const activeModel = nextModels.find((model) => model.modelId === configSession?.modelId || model.id === configSession?.modelId) + ?? nextModels.find((model) => model.isDefault) + ?? null; + setLanes(nextLanes); + setSessions(nextSessions); + selectActiveLaneId(nextLaneId); + selectActiveSessionId(nextSessionId); + setEvents(nextEvents); + setSlashCommands(nextCommands); + setModels(nextModels); + if (configSession && (!draftMode || !draftSeededFromHistoryRef.current)) { + const provider = normalizeProvider(nextProvider); + setModelState((prev) => ({ + ...prev, + provider, + model: configSession.model ?? activeModel?.id ?? prev.model, + modelId: configSession.modelId ?? activeModel?.modelId ?? activeModel?.id ?? prev.modelId, + displayName: activeModel?.displayName ?? configSession.model ?? prev.displayName, + reasoningEffort: configSession.reasoningEffort ?? prev.reasoningEffort, + codexFastMode: configSession.codexFastMode === true, + permissionMode: configSession.permissionMode ?? prev.permissionMode, + interactionMode: configSession.interactionMode ?? prev.interactionMode, + claudePermissionMode: configSession.claudePermissionMode ?? prev.claudePermissionMode, + codexApprovalPolicy: configSession.codexApprovalPolicy ?? prev.codexApprovalPolicy, + codexSandbox: configSession.codexSandbox ?? prev.codexSandbox, + codexConfigSource: configSession.codexConfigSource ?? prev.codexConfigSource, + opencodePermissionMode: configSession.opencodePermissionMode ?? prev.opencodePermissionMode, + droidPermissionMode: configSession.droidPermissionMode ?? prev.droidPermissionMode, + cursorModeId: configSession.cursorModeId ?? configSession.cursorModeSnapshot?.currentModeId ?? prev.cursorModeId, + cursorConfigValues: configSession.cursorConfigValues ?? prev.cursorConfigValues, + })); + if (draftMode) draftSeededFromHistoryRef.current = true; + } + }, [clearedAt, modelState.provider, project, selectActiveLaneId, selectActiveSessionId]); + + const commitModelStateToSession = useCallback(async (nextState: AdeCodeModelState) => { + const conn = connectionRef.current; + const sessionId = activeSessionIdRef.current; + if (!conn || !sessionId || draftChatActiveRef.current) return; + const normalized = { ...nextState, ...applyProviderPermissionMode(nextState) }; + await updateChatModel({ + connection: conn, + sessionId, + modelId: normalized.modelId, + reasoningEffort: normalized.reasoningEffort, + codexFastMode: normalized.provider === "codex" ? normalized.codexFastMode : undefined, + permissionMode: normalized.permissionMode, + interactionMode: normalized.provider === "claude" ? normalized.interactionMode : undefined, + claudePermissionMode: normalized.provider === "claude" ? normalized.claudePermissionMode : undefined, + codexApprovalPolicy: normalized.provider === "codex" ? normalized.codexApprovalPolicy : undefined, + codexSandbox: normalized.provider === "codex" ? normalized.codexSandbox : undefined, + codexConfigSource: normalized.provider === "codex" ? normalized.codexConfigSource : undefined, + opencodePermissionMode: normalized.provider === "opencode" ? normalized.opencodePermissionMode : undefined, + droidPermissionMode: normalized.provider === "droid" ? normalized.droidPermissionMode : undefined, + cursorModeId: normalized.provider === "cursor" ? normalized.cursorModeId : undefined, + cursorConfigValues: normalized.provider === "cursor" ? normalized.cursorConfigValues : undefined, + }); + await refreshState(); + }, [refreshState]); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const conn = await connectToAde({ project, forceEmbedded, requireSocket, socketPath }); + if (cancelled) { + await conn.close(); + return; + } + heartbeatRef.current = startTuiHeartbeat(project.projectRoot); + connectionRef.current = conn; + setConnection(conn); + setMode(conn.mode); + draftSeededFromHistoryRef.current = false; + setDraftChatMode(true); + selectActiveSessionId(null); + setEvents([]); + await refreshState(); + } catch (err) { + heartbeatRef.current?.stop(); + heartbeatRef.current = null; + setError(err instanceof Error ? err.message : String(err)); + } + })(); + return () => { + cancelled = true; + heartbeatRef.current?.stop(); + heartbeatRef.current = null; + if (lastChatByLaneWriteTimerRef.current) { + clearTimeout(lastChatByLaneWriteTimerRef.current); + lastChatByLaneWriteTimerRef.current = null; + const lastChatByLane: Record = {}; + for (const [laneId, sessionId] of lastChatByLaneRef.current) { + lastChatByLane[laneId] = sessionId; + } + saveAdeCodeState({ lastChatByLane }); + } + const conn = connectionRef.current; + connectionRef.current = null; + void conn?.close().catch(() => {}); + }; + }, [forceEmbedded, project, requireSocket, socketPath]); + + useEffect(() => { + if (!connection) return; + return connection.onChatEvent((envelope) => { + if (envelope.sessionId !== activeSessionIdRef.current) { + void refreshState().catch(() => undefined); + return; + } + if (clearedAt && envelope.timestamp <= clearedAt) return; + setEvents((prev) => { + const key = `${envelope.sequence ?? ""}:${envelope.timestamp}:${envelope.event.type}`; + if (prev.some((entry) => `${entry.sequence ?? ""}:${entry.timestamp}:${entry.event.type}` === key)) return prev; + return [...prev, envelope].slice(-500); + }); + const event = envelope.event as Record; + if (event.type === "status" && event.turnStatus === "started") setStreaming(true); + if (event.type === "done" || (event.type === "status" && event.turnStatus === "completed")) setStreaming(false); + }); + }, [clearedAt, connection, refreshState]); + + useEffect(() => { + if (!connection) return; + const timer = setInterval(() => { + void refreshState().catch((err) => { + setError(err instanceof Error ? err.message : String(err)); + }); + }, 1_000); + return () => clearInterval(timer); + }, [connection, refreshState]); + + useEffect(() => { + if (!connection || mode === "attached" || forceEmbedded) return; + const timer = setInterval(() => { + if (streaming || attachProbeInFlightRef.current) return; + attachProbeInFlightRef.current = true; + void (async () => { + let attached: AdeCodeConnection | null = null; + try { + attached = await connectToAde({ + project, + forceEmbedded: false, + requireSocket: true, + socketPath, + }); + if (attached.mode !== "attached") { + await attached.close().catch(() => {}); + return; + } + const previous = connectionRef.current; + connectionRef.current = attached; + setConnection(attached); + setMode(attached.mode); + await previous?.close().catch(() => {}); + await refreshState(); + } catch { + await attached?.close().catch(() => {}); + } finally { + attachProbeInFlightRef.current = false; + } + })(); + }, 3_000); + return () => clearInterval(timer); + }, [connection, forceEmbedded, mode, project, refreshState, socketPath, streaming]); + + const ensureActiveSession = useCallback(async (): Promise => { + const conn = connectionRef.current; + const laneId = activeLaneIdRef.current; + if (!conn || !laneId) return null; + if (activeSessionIdRef.current) return activeSessionIdRef.current; + const normalized = { ...modelState, ...applyProviderPermissionMode(modelState) }; + const created = await createChatSession({ + connection: conn, + laneId, + title: pendingNewChatTitleRef.current, + provider: normalized.provider, + modelId: normalized.modelId, + reasoningEffort: normalized.reasoningEffort, + codexFastMode: normalized.codexFastMode, + permissionMode: normalized.permissionMode, + interactionMode: normalized.interactionMode, + claudePermissionMode: normalized.claudePermissionMode, + codexApprovalPolicy: normalized.codexApprovalPolicy, + codexSandbox: normalized.codexSandbox, + codexConfigSource: normalized.codexConfigSource, + opencodePermissionMode: normalized.opencodePermissionMode, + droidPermissionMode: normalized.droidPermissionMode, + cursorModeId: normalized.cursorModeId, + cursorConfigValues: normalized.cursorConfigValues, + }); + pendingNewChatTitleRef.current = null; + setDraftChatMode(false); + selectActiveSessionId(created.id); + await refreshState(); + return created.id; + }, [modelState, refreshState, selectActiveSessionId, setDraftChatMode]); + + const resolvePendingApproval = useCallback(async ( + approval: PendingApproval, + decision: "accept" | "decline" | "cancel" | "accept_for_session", + responseText?: string | null, + ) => { + const conn = connectionRef.current; + const sessionId = activeSessionIdRef.current; + if (!conn || !sessionId) return; + await approveToolUse({ + connection: conn, + sessionId, + itemId: approval.itemId, + decision, + responseText, + }); + addNotice(decision === "accept" || decision === "accept_for_session" ? "Approved request." : "Declined request.", "info"); + await refreshState(); + }, [addNotice, refreshState]); + + const answerPendingInput = useCallback(async (approval: PendingApproval, text: string) => { + const conn = connectionRef.current; + const sessionId = activeSessionIdRef.current; + if (!conn || !sessionId) return; + const trimmed = text.trim(); + const lowered = trimmed.toLowerCase(); + if (lowered === "deny" || lowered === "decline" || lowered === "cancel") { + await respondToInput({ + connection: conn, + sessionId, + itemId: approval.itemId, + decision: lowered === "cancel" ? "cancel" : "decline", + }); + addNotice("Declined request.", "info"); + await refreshState(); + return; + } + await respondToInput({ + connection: conn, + sessionId, + itemId: approval.itemId, + decision: "accept", + answers: buildPendingInputAnswers(approval.request, trimmed), + responseText: trimmed, + }); + addNotice("Answered request.", "success"); + await refreshState(); + }, [addNotice, refreshState]); + + const runRightCommand = useCallback(async (name: string, args: string) => { + const conn = connectionRef.current; + if (!conn) return; + const laneId = activeLaneIdRef.current; + const sessionId = activeSessionIdRef.current; + focusDetails(); + + if (name === "/help") { + setRightPane({ kind: "help", title: "Help" }); + return; + } + if (name === "/status") { + setRightPane({ + kind: "status", + rows: [ + ["project", project.projectRoot], + ["workspace", project.workspaceRoot], + ["lane", activeLane?.name ?? laneId ?? "none"], + ["chat", activeSession?.title ?? activeSession?.sessionId ?? "none"], + ["ADE", "ready"], + ], + }); + return; + } + if (name === "/new chat") { + if (!laneId) { + setRightPane({ kind: "details", title: "New chat", body: "No active lane is available." }); + return; + } + openNewChatSetup(args); + return; + } + if (name === "/new lane") { + if (!args) { + openNewLaneForm(); + return; + } + const created = await conn.action("lane", "create", { name: args }); + selectActiveLaneId(created.id); + selectActiveSessionId(null); + setDrawerLaneId(created.id); + setSelectedDrawerLaneId(created.id); + setSelectedDrawerChatId(null); + setSelectedDrawerLaneAction(null); + setSelectedDrawerChatAction(null); + setDrawerSection("lanes"); + setRightPane({ kind: "details", title: "New lane", body: renderObject(created, 20) }); + await refreshState(); + setDrawerLaneId(created.id); + setSelectedDrawerLaneId(created.id); + setSelectedDrawerLaneAction(null); + return; + } + if (name === "/rename") { + if (!sessionId) { + setRightPane({ kind: "details", title: "Rename chat", body: "No active chat is selected." }); + return; + } + if (!args) { + openForm({ + kind: "form", + title: "Rename chat", + command: "rename", + fields: [ + { name: "title", label: "Title", required: true, initialValue: activeSession?.title ?? "" }, + ], + }); + return; + } + await renameChat(conn, sessionId, args); + addNotice(`Renamed chat to "${args}".`, "success"); + await refreshState(); + return; + } + if (name === "/diff") { + if (!laneId) { + setRightPane({ kind: "details", title: "Diff", body: "No active lane is selected." }); + return; + } + const diff = await conn.actionList("diff", "getChanges", [laneId]); + setRightPane({ kind: "diff", title: "Diff", files: summarizeDiffChanges(diff) }); + return; + } + if (name === "/log") { + if (!laneId) { + setRightPane({ kind: "details", title: "Recent commits", body: "No active lane is selected." }); + return; + } + const log = await conn.action("git", "listRecentCommits", { laneId, limit: 12 }); + setRightPane({ kind: "list", title: "Recent commits", rows: routeRows(log), emptyText: "No commits." }); + return; + } + if (name.startsWith("/pr")) { + if (!laneId) { + setRightPane({ kind: "details", title: name.slice(1) || "PR", body: "No active lane is selected." }); + return; + } + const prs = await conn.action>>("pr", "listAll", laneId ? { laneId } : {}); + const activePr = prs[0] ?? null; + const prId = activePr ? String(activePr.id ?? activePr.prId ?? "") : ""; + if (name === "/pr") { + const ahead = activeLane?.status?.ahead ?? 0; + setRightPane({ + kind: "details", + title: "PR", + body: activePr + ? renderObject(activePr, 24) + : `No PR is linked to this lane yet.\n${ahead > 0 ? `${ahead} commit${ahead === 1 ? "" : "s"} ahead of base.\n` : ""}Run /pr open to create a draft.`, + }); + return; + } + if (name === "/pr open") { + if (activePr) { + await navigateDesktop(conn, { + source: "ade-code", + target: { + kind: "pr", + prId, + laneId, + prNumber: typeof activePr.number === "number" ? activePr.number : null, + }, + }); + setRightPane({ kind: "details", title: "PR open", body: renderObject(activePr, 24) }); + return; + } + if (!args) { + openForm({ + kind: "form", + title: "Open PR", + command: "pr-open", + fields: [ + { name: "title", label: "Title", required: true, placeholder: activeLane?.name ?? "Draft PR" }, + { name: "body", label: "Body", placeholder: "Optional" }, + ], + }); + return; + } + const created = await conn.action("pr", "createFromLane", { + laneId, + title: args, + body: "", + draft: true, + }); + setRightPane({ kind: "details", title: "PR open", body: renderObject(created, 24) }); + return; + } + if (!prId) { + setRightPane({ kind: "details", title: name.slice(1), body: "No PR is linked to this lane yet." }); + return; + } + const pr = name === "/pr checks" + ? await conn.actionList("pr", "getChecks", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })) + : await Promise.all([ + conn.actionList("pr", "getReviews", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })), + conn.actionList("pr", "getReviewThreads", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })), + ]).then(([reviews, threads]) => ({ reviews, threads })); + setRightPane({ kind: "details", title: name.slice(1), body: renderObject(pr, 24) }); + return; + } + if (name === "/linear list") { + const linear = await conn.action("linear_issue_tracker", "listIssues", parseLinearIssueListArgs(args || "--limit 20")); + setRightPane({ kind: "list", title: "Linear", rows: routeRows(linear), emptyText: "No Linear issues." }); + return; + } + if (name === "/linear status") { + const status = await conn.action("linear_issue_tracker", "getStatus", {}); + setRightPane({ kind: "details", title: "Linear status", body: renderObject(status, 24) }); + return; + } + if (name === "/linear pull") { + if (!args) { + setRightPane({ kind: "details", title: "Linear pull", body: "Usage: /linear pull <issue-id>" }); + return; + } + const issue = await conn.actionList("linear_issue_tracker", "fetchIssueById", [args]); + if (!issue) { + setRightPane({ kind: "details", title: "Linear pull", body: `Linear issue ${args} was not found.` }); + return; + } + const targetSessionId = await ensureActiveSession(); + const issueContext = `Linear issue context:\n${renderObject(issue, 28)}`; + if (targetSessionId) { + await sendChatMessage(conn, targetSessionId, issueContext); + } + setRightPane({ kind: "details", title: "Linear pull", body: issueContext }); + return; + } + if (name === "/linear comment") { + const parsed = splitFirstArg(args); + if (!parsed.first || !parsed.rest) { + setRightPane({ kind: "details", title: "Linear comment", body: "Usage: /linear comment <issue-id> <text>" }); + return; + } + const result = await conn.actionList("linear_issue_tracker", "createComment", [parsed.first, parsed.rest]); + setRightPane({ kind: "details", title: "Linear comment", body: renderObject(result, 12) }); + addNotice(`Commented on ${parsed.first}.`, "success"); + return; + } + if (name === "/linear assign") { + const parsed = splitFirstArg(args); + if (!parsed.first || !parsed.rest) { + setRightPane({ kind: "details", title: "Linear assign", body: "Usage: /linear assign <issue-id> <user-id|none>" }); + return; + } + const normalizedAssignee = parsed.rest.toLowerCase(); + const assigneeId = normalizedAssignee === "none" || normalizedAssignee === "null" || normalizedAssignee === "unassigned" + ? null + : parsed.rest; + await conn.actionList("linear_issue_tracker", "updateIssueAssignee", [parsed.first, assigneeId]); + setRightPane({ + kind: "details", + title: "Linear assign", + body: assigneeId ? `Assigned ${parsed.first} to ${assigneeId}.` : `Cleared assignee for ${parsed.first}.`, + }); + addNotice(`Updated ${parsed.first}.`, "success"); + return; + } + if (name === "/linear" || name.startsWith("/linear ")) { + const linearInput = `${name.slice("/linear".length)} ${args}`.trim(); + const request = buildLinearToolRequest(linearInput); + if (request.kind === "usage") { + setRightPane({ kind: "details", title: request.title, body: request.body }); + return; + } + const result = await conn.tool(request.toolName, request.args); + setRightPane({ kind: "details", title: request.title, body: renderObject(result, 24) }); + return; + } + if (name === "/memory") { + const query = args || "project"; + const result = await conn.tool("memory_search", { query, scope: "project", limit: 10 }); + setRightPane({ kind: "details", title: "Memory", body: renderObject(result, 24) }); + return; + } + if (name === "/forget") { + setRightPane({ kind: "details", title: "Forget", body: "Memory lifecycle controls are available in desktop. Run /open to continue there." }); + return; + } + if (name === "/chats") { + const laneSessions = sessions.filter((session) => session.laneId === laneId); + const selectedIndex = Math.max(0, laneSessions.findIndex((session) => session.sessionId === sessionId)); + setRightSelectionIndex(selectedIndex); + setRightPane({ + kind: "list", + title: "Chats", + rows: laneSessions.map((session) => `${session.sessionId === sessionId ? "●" : "○"} ${session.title ?? session.sessionId}`), + emptyText: "No chats in this lane.", + action: { kind: "switch-chat", ids: laneSessions.map((session) => session.sessionId) }, + }); + return; + } + if (name === "/switch") { + const query = args.toLowerCase(); + if (!query) { + const selectedIndex = Math.max(0, lanes.findIndex((lane) => lane.id === laneId)); + setRightSelectionIndex(selectedIndex); + setRightPane({ + kind: "list", + title: "Switch", + rows: lanes.map((lane) => `${lane.id === laneId ? "●" : "○"} ${lane.name}`), + emptyText: "No lanes.", + action: { kind: "switch-lane", ids: lanes.map((lane) => lane.id) }, + }); + return; + } + const lane = lanes.find((entry) => entry.id.toLowerCase() === query || entry.name.toLowerCase().includes(query)); + if (lane) { + selectActiveLaneId(lane.id); + setDrawerLaneId(lane.id); + setSelectedDrawerLaneId(lane.id); + const session = newestSession(sessions.filter((entry) => entry.laneId === lane.id)); + selectActiveSessionId(session?.sessionId ?? null); + setSelectedDrawerChatId(session?.sessionId ?? null); + addNotice(`Switched to lane ${lane.name}.`, "success"); + } else { + setRightPane({ kind: "details", title: "Switch", body: `No lane matched "${args}".` }); + } + return; + } + if (name === "/resume") { + if (!sessionId) { + setRightPane({ kind: "details", title: "Resume", body: "No active chat is selected." }); + return; + } + await resumeChat(conn, sessionId); + addNotice("Resumed chat.", "success"); + await refreshState(); + return; + } + if (name === "/model") { + if (args) { + if (!sessionId) { + const model = models.find((entry) => entry.id === args || entry.modelId === args); + setModelState((prev) => ({ + ...prev, + model: model?.id ?? args, + modelId: model?.modelId ?? model?.id ?? args, + displayName: model?.displayName ?? args, + })); + addNotice(`Default model set to ${model?.displayName ?? args}.`, "success"); + return; + } + await updateChatModel({ connection: conn, sessionId, modelId: args }); + addNotice(`Model set to ${args}.`, "success"); + await refreshState(); + return; + } + openModelSetup(); + return; + } + if (name === "/effort") { + if (args) { + if (!EFFORTS.includes(args)) { + setRightPane({ kind: "details", title: "Effort", body: `Usage: /effort <${EFFORTS.join("|")}>` }); + return; + } + if (!sessionId) { + setModelState((prev) => ({ ...prev, reasoningEffort: args })); + addNotice(`Default effort set to ${args}.`, "success"); + return; + } + await updateChatModel({ connection: conn, sessionId, reasoningEffort: args }); + addNotice(`Effort set to ${args}.`, "success"); + await refreshState(); + return; + } + setRightSelectionIndex(Math.max(0, EFFORTS.findIndex((effort) => effort === modelState.reasoningEffort))); + setRightPane({ kind: "effort", efforts: EFFORTS, activeEffort: modelState.reasoningEffort }); + return; + } + if (name === "/system") { + setRightPane({ + kind: "details", + title: "System", + body: renderObject({ project, pid: process.pid }, 24), + }); + return; + } + if (name === "/ade") { + const parsed = splitFirstArg(args); + const possibleBuiltin = parsed.first.startsWith("/") ? parsed.first : `/${parsed.first}`; + const alias = possibleBuiltin !== "/ade" + ? parseCommand(`${possibleBuiltin}${parsed.rest ? ` ${parsed.rest}` : ""}`, []) + : null; + if (alias?.spec?.placement === "right") { + await runRightCommand(alias.name, alias.args); + return; + } + if (alias?.spec?.placement === "inline") { + setRightPane({ + kind: "details", + title: "ADE command", + body: `/${parsed.first.replace(/^\//, "")} is an inline TUI command. Run it before creating a runtime chat, or use the keyboard shortcut when available.`, + }); + return; + } + const [domain, action] = parsed.first.split(".", 2); + if (!domain || !action) { + setRightPane({ + kind: "details", + title: "ADE action", + body: "Usage: /ade <domain.action|status|diff|model|effort|help> [json-object|json-array|json-scalar]", + }); + return; + } + const result = await conn.tool("run_ade_action", { + domain, + action, + ...parseAdeActionPayload(parsed.rest), + }); + const body = result && typeof result === "object" && "result" in result + ? (result as { result?: unknown }).result + : result; + setRightPane({ kind: "details", title: `ADE ${domain}.${action}`, body: renderObject(body, 24) }); + } + }, [activeLane?.name, activeSession?.sessionId, activeSession?.title, addNotice, ensureActiveSession, focusDetails, lanes, mode, modelState.modelId, models, openForm, openModelSetup, openNewChatSetup, openNewLaneForm, project, refreshState, selectActiveLaneId, selectActiveSessionId, sessions, setChatScrollOffset]); + + const runInlineCommand = useCallback(async (name: string, args: string) => { + const conn = connectionRef.current; + if (!conn) return; + const laneId = activeLaneIdRef.current; + const sessionId = activeSessionIdRef.current; + if (name === "/quit") { + exit(); + return; + } + if (name === "/clear") { + setClearedAt(new Date().toISOString()); + setEvents([]); + setChatScrollOffset(0); + addNotice("Local transcript view cleared. The durable chat remains in ADE.", "info"); + return; + } + if (name === "/end") { + if (!sessionId) { + addNotice("No active chat is selected.", "error"); + return; + } + await conn.action("chat", "dispose", { sessionId }); + addNotice("Ended active chat runtime.", "success"); + await refreshState(); + return; + } + if (name === "/login") { + const provider = normalizeProvider(activeSession?.provider ?? modelState.provider); + const loginCommands = loginCommandsForProvider(provider); + if (!loginCommands.length) { + addNotice(`/login is not available for ${providerLabel(provider)}. ${loginUnavailableHint(provider)}`, "error"); + return; + } + let selectedLogin: ProviderLoginCommand | null = null; + let code: number | null = null; + let ranLogin = false; + for (const login of loginCommands) { + selectedLogin = login; + addNotice(`Starting \`${login.label}\` in this terminal.`, "info"); + try { + code = await runInteractiveTerminalCommand(login.command, login.args, project.projectRoot); + ranLogin = true; + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + throw error; + } + } + if (!selectedLogin || !ranLogin) { + addNotice(`Could not find a ${providerLabel(provider)} login command on PATH.`, "error"); + return; + } + if (code === 0) { + addNotice(`${providerLabel(provider)} auth completed. Refreshing provider status.`, "success"); + await refreshAiSetupStatus({ force: true }); + await loadProviderModels(provider, { applyDefault: false }); + } else { + addNotice(`${providerLabel(provider)} login exited with code ${code ?? "unknown"}.`, "error"); + } + return; + } + if (name === "/commit") { + if (!laneId) { + addNotice("No active lane is selected.", "error"); + return; + } + if (!args) { + addNotice("Usage: /commit <message>", "error"); + return; + } + const result = await conn.action("git", "commit", { laneId, message: args }); + addNotice(`Commit complete: ${renderObject(result, 4).replace(/\n/g, " ")}`, "success"); + return; + } + if (name === "/push") { + if (!laneId) { + addNotice("No active lane is selected.", "error"); + return; + } + const result = await conn.action("git", "push", { laneId }); + addNotice(`Push complete: ${renderObject(result, 4).replace(/\n/g, " ")}`, "success"); + return; + } + if (name === "/pull") { + if (!laneId) { + addNotice("No active lane is selected.", "error"); + return; + } + const result = await conn.action("git", "pull", { laneId }); + addNotice(`Pull complete: ${renderObject(result, 4).replace(/\n/g, " ")}`, "success"); + return; + } + if (name === "/stage all") { + if (!laneId) { + addNotice("No active lane is selected.", "error"); + return; + } + const result = await conn.action("git", "stageAll", { laneId }); + addNotice(`Stage all complete: ${renderObject(result, 4).replace(/\n/g, " ")}`, "success"); + return; + } + if (name === "/remember") { + if (!args) { + addNotice("Usage: /remember <durable fact>", "error"); + return; + } + const result = await conn.tool("memory_add", { + content: args, + scope: "project", + category: "decision", + importance: "medium", + }); + addNotice(`Memory saved: ${renderObject(result, 4).replace(/\n/g, " ")}`, "success"); + return; + } + if (name === "/open") { + const target = sessionId + ? { kind: "chat" as const, sessionId, laneId } + : laneId + ? { kind: "lane" as const, laneId } + : { kind: "work" as const }; + const result = await navigateDesktop(conn, { source: "ade-code", target }); + if (result.ok) { + addNotice("Opened ADE desktop at this context.", "success"); + return; + } + if (process.platform === "darwin") { + spawn("open", [ + "-a", + "ADE", + "--env", + `ADE_PROJECT_ROOT=${project.projectRoot}`, + project.projectRoot, + ], { stdio: "ignore", detached: true }).unref(); + addNotice(result.message ?? "Desktop route unavailable; launched ADE.", "info"); + for (let attempt = 0; attempt < 8; attempt += 1) { + await delay(750); + const attached = await connectToAde({ project, forceEmbedded: false, socketPath }).catch(() => null); + if (!attached || attached.mode !== "attached") { + await attached?.close().catch(() => {}); + continue; + } + const retry = await navigateDesktop(attached, { source: "ade-code", target }).catch(() => null); + if (!retry?.ok) { + await attached.close().catch(() => {}); + continue; + } + const previous = connectionRef.current; + connectionRef.current = attached; + setConnection(attached); + setMode(attached.mode); + await previous?.close().catch(() => {}); + addNotice("Opened ADE desktop at this context.", "success"); + await refreshState(); + return; + } + } else { + addNotice(result.message ?? "Desktop route unavailable from this runtime.", "error"); + } + } + }, [activeSession?.provider, addNotice, exit, loadProviderModels, modelState.provider, project, refreshAiSetupStatus, refreshState, setChatScrollOffset, socketPath]); + + const submitRightForm = useCallback(async ( + form: Extract<RightPaneContent, { kind: "form" }>, + values: Record<string, string>, + ) => { + const conn = connectionRef.current; + const laneId = activeLaneIdRef.current; + const sessionId = activeSessionIdRef.current; + if (!conn) return; + + const requireField = (name: string, label: string): string | null => { + const value = values[name]?.trim() ?? ""; + if (value) return value; + addNotice(`${label} is required.`, "error"); + return null; + }; + + if (form.command === "new-lane") { + const name = requireField("name", "Name"); + if (!name) return; + const baseBranch = values.baseBranch?.trim(); + const created = await conn.action<LaneSummary>("lane", "create", { + name, + ...(baseBranch ? { baseBranch } : {}), + }); + selectActiveLaneId(created.id); + selectActiveSessionId(null); + setDrawerLaneId(created.id); + setSelectedDrawerLaneId(created.id); + setSelectedDrawerChatId(null); + setSelectedDrawerLaneAction(null); + setSelectedDrawerChatAction(null); + setDrawerSection("lanes"); + setRightOpen(false); + setRightPane({ kind: "empty" }); + focusAfterDetails(); + addNotice(`Created lane ${created.name}.`, "success"); + await refreshState(); + setDrawerLaneId(created.id); + setSelectedDrawerLaneId(created.id); + setSelectedDrawerLaneAction(null); + return; + } + + if (form.command === "rename") { + if (!sessionId) return; + const title = requireField("title", "Title"); + if (!title) return; + await renameChat(conn, sessionId, title); + setRightOpen(false); + setRightPane({ kind: "empty" }); + focusAfterDetails(); + addNotice(`Renamed chat to "${title}".`, "success"); + await refreshState(); + return; + } + + if (form.command === "pr-open") { + if (!laneId) return; + const title = requireField("title", "Title"); + if (!title) return; + const body = values.body?.trim() ?? ""; + const created = await conn.action("pr", "createFromLane", { + laneId, + title, + body, + draft: true, + }); + setRightPane({ kind: "details", title: "PR open", body: renderObject(created, 24) }); + addNotice("Created draft PR.", "success"); + await refreshState(); + } + }, [addNotice, focusAfterDetails, refreshState, selectActiveLaneId, selectActiveSessionId]); + + const submitPrompt = useCallback(async (value: string) => { + const text = value.trim(); + if (!text && rightPane.kind !== "form") return; + const conn = connectionRef.current; + if (!conn) return; + try { + if (streaming && !text.startsWith("/") && rightPane.kind !== "form") { + addNotice("This chat is still responding. Press ctrl-c to interrupt before sending another message.", "info"); + return; + } + setPrompt(""); + promptRef.current = ""; + setChatScrollOffset(0); + if (activePaneRef.current === "chat") { + chatDraftRef.current = ""; + } + setError(null); + if (pendingApproval?.mode === "approval") { + const lowered = text.toLowerCase(); + if (pendingApproval.highStakes) { + if (lowered === "approve" || lowered === "deny") { + await resolvePendingApproval(pendingApproval, lowered === "approve" ? "accept" : "decline"); + return; + } + addNotice("Type approve or deny to resolve the high-stakes request.", "error"); + return; + } + if (lowered === "approve" || lowered === "a" || lowered === "deny" || lowered === "d") { + await resolvePendingApproval(pendingApproval, lowered === "approve" || lowered === "a" ? "accept" : "decline"); + return; + } + addNotice("Press a to approve or d to deny this request.", "error"); + return; + } + if (pendingApproval?.mode === "question") { + await answerPendingInput(pendingApproval, value); + return; + } + if (rightPane.kind === "form" && !text.startsWith("/")) { + const field = activeFormField; + const values = field ? { ...formValues, [field.name]: value } : formValues; + setFormValues(values); + await submitRightForm(rightPane, values); + return; + } + const parsed = parseCommand(text, slashCommands); + if (text.startsWith("/") && parsed && !parsed.spec && !parsed.userCommand && slashRows.length) { + const selected = slashRows[slashIndex] ?? slashRows[0]; + if (selected) { + const selectedCommand = parseCommand(selected.name, slashCommands); + if (selectedCommand?.spec?.placement === "inline") { + await runInlineCommand(selectedCommand.name, selectedCommand.args); + return; + } + if (selectedCommand?.spec?.placement === "right") { + await runRightCommand(selectedCommand.name, selectedCommand.args); + return; + } + const sessionId = await ensureActiveSession(); + if (sessionId) { + setStreaming(true); + await sendChatMessage(conn, sessionId, selected.name); + await refreshState(); + } + return; + } + } + if (parsed?.spec?.placement === "inline") { + await runInlineCommand(parsed.name, parsed.args); + return; + } + if (parsed?.spec?.placement === "right") { + await runRightCommand(parsed.name, parsed.args); + return; + } + const desktopRoute = desktopRouteForCommand(parsed?.name); + if (desktopRoute) { + const result = await navigateDesktop(conn, { + source: "ade-code", + target: { kind: "route", route: desktopRoute }, + }); + if (result.ok) { + addNotice(`Opened ADE desktop for ${parsed?.name}.`, "success"); + return; + } + await runInlineCommand("/open", ""); + addNotice(`${parsed?.name} is a desktop-only surface; opened ADE desktop.`, "info"); + return; + } + const sessionId = await ensureActiveSession(); + if (!sessionId) { + addNotice("No active lane is available for chat.", "error"); + return; + } + lastLocalSendAtRef.current = Date.now(); + const attachments: AgentChatFileRef[] = selectedMentions + .filter((mention) => mention.kind === "file" && mention.filePath && text.includes(mention.insertText)) + .map((mention) => ({ type: "file", path: mention.filePath! })); + setStreaming(true); + await sendChatMessage(conn, sessionId, text, attachments); + await refreshState(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setStreaming(false); + setError(message); + addNotice(message, "error"); + } + }, [activeFormField, addNotice, answerPendingInput, ensureActiveSession, formValues, pendingApproval, refreshState, resolvePendingApproval, rightPane, runInlineCommand, runRightCommand, selectedMentions, setChatScrollOffset, slashCommands, slashIndex, slashRows, streaming, submitRightForm]); + + const insertMention = useCallback((suggestion: MentionSuggestion) => { + const range = activeMention(prompt); + if (!range) return; + setPrompt(`${prompt.slice(0, range.start)}${suggestion.insertText} ${prompt.slice(range.start + range.query.length + 1)}`); + setSelectedMentions((prev) => { + if (prev.some((entry) => entry.insertText === suggestion.insertText)) return prev; + return [...prev, suggestion].slice(-12); + }); + setMentionSuggestions([]); + setMentionIndex(0); + }, [prompt]); + + const insertSlashCommand = useCallback(() => { + const selected = slashRows[slashIndex] ?? slashRows[0]; + if (!selected) return; + setPrompt(`${selected.name}${selected.argumentHint ? " " : ""}`); + }, [slashIndex, slashRows]); + + const applyModelState = useCallback((updater: (prev: AdeCodeModelState) => AdeCodeModelState) => { + setModelState((prev) => { + const next = updater(prev); + void commitModelStateToSession(next).catch((err) => { + addNotice(err instanceof Error ? err.message : String(err), "error"); + }); + return next; + }); + }, [addNotice, commitModelStateToSession]); + + const selectProvider = useCallback(async (provider: AdeCodeProvider) => { + const conn = connectionRef.current; + const nextModels = conn ? await getAvailableModels(conn, provider).catch(() => []) : []; + setModels(nextModels); + const model = nextModels.find((entry) => entry.isDefault) ?? nextModels[0] ?? null; + applyModelState((prev) => ({ + ...prev, + ...(model ? modelStatePatchForModel(provider, model) : fallbackModelStatePatch(provider)), + })); + }, [applyModelState]); + + const cycleProvider = useCallback((delta: number) => { + const index = Math.max(0, PROVIDER_OPTIONS.findIndex((entry) => entry.value === modelState.provider)); + const next = PROVIDER_OPTIONS[(index + delta + PROVIDER_OPTIONS.length) % PROVIDER_OPTIONS.length]?.value ?? "codex"; + void selectProvider(next).catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + }, [addNotice, modelState.provider, selectProvider]); + + const cycleModel = useCallback((delta: number) => { + const candidates = models.length + ? models + : listModelDescriptorsForProvider(modelState.provider).map((descriptor) => ({ + id: descriptor.id, + modelId: descriptor.id, + displayName: descriptor.displayName, + isDefault: descriptor.id === getDefaultModelDescriptor(modelState.provider)?.id, + reasoningEfforts: descriptor.reasoningTiers?.map((effort) => ({ effort, description: effort })), + })); + if (!candidates.length) return; + const index = Math.max(0, candidates.findIndex((entry) => entry.id === modelState.modelId || entry.modelId === modelState.modelId)); + const nextModel = candidates[(index + delta + candidates.length) % candidates.length] ?? candidates[0]!; + applyModelState((prev) => ({ + ...prev, + ...modelStatePatchForModel(modelState.provider, nextModel), + codexFastMode: modelSupportsFastMode(getModelById(nextModel.modelId ?? nextModel.id)) ? prev.codexFastMode : false, + })); + }, [applyModelState, modelState.modelId, modelState.provider, models]); + + const cycleReasoning = useCallback((delta: number) => { + const efforts = modelReasoningEfforts(modelState, models); + if (!efforts.length) return; + const index = Math.max(0, efforts.findIndex((effort) => effort === modelState.reasoningEffort)); + const nextEffort = efforts[(index + delta + efforts.length) % efforts.length] ?? efforts[0]!; + applyModelState((prev) => ({ ...prev, reasoningEffort: nextEffort })); + }, [applyModelState, modelState, models]); + + const cyclePermission = useCallback((delta: number) => { + if (modelState.provider === "codex") { + const current = resolveCodexPreset(modelState); + const index = Math.max(0, CODEX_PRESETS.findIndex((entry) => entry === current)); + const next = CODEX_PRESETS[(index + delta + CODEX_PRESETS.length) % CODEX_PRESETS.length] ?? "default"; + applyModelState((prev) => ({ ...prev, ...codexPresetPatch(next) })); + return; + } + if (modelState.provider === "claude") { + const current = modelState.interactionMode === "plan" ? "plan" : modelState.claudePermissionMode; + const index = Math.max(0, CLAUDE_PERMISSION_OPTIONS.findIndex((entry) => entry === current)); + const next = CLAUDE_PERMISSION_OPTIONS[(index + delta + CLAUDE_PERMISSION_OPTIONS.length) % CLAUDE_PERMISSION_OPTIONS.length] ?? "default"; + applyModelState((prev) => ({ + ...prev, + interactionMode: next === "plan" ? "plan" : "default", + claudePermissionMode: next, + permissionMode: next === "plan" + ? "plan" + : next === "acceptEdits" + ? "edit" + : next === "bypassPermissions" + ? "full-auto" + : "default", + })); + return; + } + if (modelState.provider === "opencode") { + const index = Math.max(0, OPENCODE_PERMISSION_OPTIONS.findIndex((entry) => entry === modelState.opencodePermissionMode)); + const next = OPENCODE_PERMISSION_OPTIONS[(index + delta + OPENCODE_PERMISSION_OPTIONS.length) % OPENCODE_PERMISSION_OPTIONS.length] ?? "edit"; + applyModelState((prev) => ({ ...prev, opencodePermissionMode: next, permissionMode: next })); + return; + } + if (modelState.provider === "droid") { + const index = Math.max(0, DROID_PERMISSION_OPTIONS.findIndex((entry) => entry === modelState.droidPermissionMode)); + const next = DROID_PERMISSION_OPTIONS[(index + delta + DROID_PERMISSION_OPTIONS.length) % DROID_PERMISSION_OPTIONS.length] ?? "auto-low"; + applyModelState((prev) => ({ ...prev, droidPermissionMode: next, permissionMode: droidPermissionToLegacy(next) })); + return; + } + const index = Math.max(0, CURSOR_AVAILABLE_MODE_IDS.findIndex((entry) => entry === modelState.cursorModeId)); + const next = CURSOR_AVAILABLE_MODE_IDS[(index + delta + CURSOR_AVAILABLE_MODE_IDS.length) % CURSOR_AVAILABLE_MODE_IDS.length] ?? "agent"; + applyModelState((prev) => ({ + ...prev, + cursorModeId: next, + permissionMode: next === "plan" + ? "plan" + : next === "ask" + ? "edit" + : next === "full-auto" + ? "full-auto" + : "default", + })); + }, [applyModelState, modelState]); + + const handleSetupRow = useCallback((row: SetupPaneRow, direction = 1) => { + const conn = connectionRef.current; + if (row.disabled) return; + if (row.kind === "provider") { + cycleProvider(direction); + return; + } + if (row.kind === "model") { + cycleModel(direction); + return; + } + if (row.kind === "reasoning") { + cycleReasoning(direction); + return; + } + if (row.kind === "permission") { + cyclePermission(direction); + return; + } + if (row.kind === "codex-fast") { + applyModelState((prev) => ({ ...prev, codexFastMode: !prev.codexFastMode })); + return; + } + if (row.kind === "refresh-status") { + void refreshAiSetupStatus({ force: true }) + .then(() => addNotice("AI provider status refreshed.", "success")) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + return; + } + if (row.kind === "open-settings") { + if (!conn) return; + void navigateDesktop(conn, { source: "ade-code", target: { kind: "route", route: SETTINGS_AI_ROUTE } }) + .then((result) => { + addNotice(result.ok ? "Opened ADE Settings > AI Providers." : result.message ?? "Desktop settings are unavailable.", result.ok ? "success" : "error"); + }) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + return; + } + if (row.kind === "apply") { + setRightOpen(false); + setRightPane({ kind: "empty" }); + focusChat(); + addNotice(`New chat ready in ${activeLane?.name ?? activeLaneIdRef.current ?? "current lane"}.`, "success"); + } + }, [activeLane?.name, addNotice, applyModelState, cycleModel, cyclePermission, cycleProvider, cycleReasoning, focusChat, refreshAiSetupStatus]); + + useInput((input, key) => { + const pane = activePaneRef.current; + const detailsFormActive = pane === "details" && rightOpen && rightPane.kind === "form"; + const footerActive = footerControlRef.current != null; + const textInputActive = (pane === "chat" && !footerActive) || detailsFormActive; + const currentFormValues = (): Record<string, string> => { + if (rightPane.kind !== "form") return formValues; + const currentField = rightPane.fields[formFieldIndex] ?? rightPane.fields[0]; + return currentField ? { ...formValues, [currentField.name]: prompt } : formValues; + }; + const formHasChanges = (values: Record<string, string>): boolean => { + if (rightPane.kind !== "form") return false; + return rightPane.fields.some((field) => (values[field.name] ?? "") !== (field.initialValue ?? "")); + }; + + if (key.tab && key.shift) { + cyclePaneFocus(); + return; + } + + if (key.ctrl && input === "o") { + focusDrawer(); + return; + } + + if (key.ctrl && input === "p") { + focusDetails(); + return; + } + + if (footerActive) { + if (key.leftArrow || key.rightArrow) { + selectFooterControl(footerControlRef.current === "drawer" ? "details" : "drawer"); + return; + } + if (key.upArrow || key.escape) { + selectFooterControl(null); + return; + } + if (key.return) { + if (footerControlRef.current === "drawer") { + toggleDrawerPane(); + } else { + toggleDetailsPane(); + } + return; + } + if (key.backspace || key.delete) { + selectFooterControl(null); + handlePromptChange(prompt.slice(0, -1)); + return; + } + if (!key.ctrl && input) { + const suffix = printableInput(input); + if (suffix) { + selectFooterControl(null); + handlePromptChange(`${prompt}${suffix}`); + } + return; + } + } + + if (key.escape) { + if (pane === "details" && rightOpen) { + if (rightPane.kind === "form") { + const values = currentFormValues(); + if (formHasChanges(values) && !formDiscardArmed) { + setFormValues(values); + setFormDiscardArmed(true); + addNotice("Press Esc again to discard this form.", "info"); + return; + } + setFormDiscardArmed(false); + setFormValues({}); + setFormFieldIndex(0); + setPrompt(""); + setRightPane({ kind: "empty" }); + } + setRightOpen(false); + focusAfterDetails(); + return; + } + if (pane === "drawer") { + setDrawerOpen(false); + focusChat(); + return; + } + setPrompt(""); + return; + } + + if (key.ctrl && input === "c") { + const conn = connectionRef.current; + const sessionId = activeSessionIdRef.current; + if (streaming && conn && sessionId) { + void interruptChat(conn, sessionId) + .then(() => addNotice("Interrupted chat.", "info")) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + return; + } + exit(); + return; + } + + if (pendingApproval?.mode === "approval" && !pendingApproval.highStakes && (input === "a" || input === "d")) { + void resolvePendingApproval(pendingApproval, input === "a" ? "accept" : "decline") + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + return; + } + + if (pane === "details" && rightOpen && rightPane.kind === "form" && (key.upArrow || key.downArrow || key.return)) { + const fields = rightPane.fields; + const nextValues = currentFormValues(); + if (key.return) { + if (prompt.trim().startsWith("/")) { + void submitPrompt(prompt); + } else { + setFormDiscardArmed(false); + void submitRightForm(rightPane, nextValues) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + } + return; + } + const delta = key.upArrow ? -1 : 1; + const nextIndex = fields.length ? (formFieldIndex + delta + fields.length) % fields.length : 0; + setFormValues(nextValues); + setFormFieldIndex(nextIndex); + setPrompt(fields[nextIndex] ? nextValues[fields[nextIndex]!.name] ?? "" : ""); + return; + } + + if ( + pane === "details" + && rightOpen + && (rightPane.kind === "new-chat-setup" || rightPane.kind === "model-setup") + && (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.return) + ) { + const rows = rightPane.rows; + const providerRowCount = rightPane.kind === "model-setup" ? rightPane.providerRows.length : 0; + const totalRows = rows.length + providerRowCount; + if (key.upArrow || key.downArrow) { + const delta = key.upArrow ? -1 : 1; + setRightSelectionIndex((index) => totalRows ? (index + delta + totalRows) % totalRows : 0); + return; + } + if (rightSelectionIndex >= rows.length) { + return; + } + const row = rows[rightSelectionIndex] ?? rows[0]; + if (!row) return; + handleSetupRow(row, key.leftArrow ? -1 : 1); + return; + } + + if (pane === "details" && rightOpen && rightPane.kind === "lane-details") { + const laneDetails = rightPane; + const maxIndex = LANE_DETAIL_ACTIONS.length - 1 + (laneDetails.pr ? 1 : 0); + if (key.upArrow) { + setRightPane((prev) => prev.kind === "lane-details" + ? { ...prev, selectedActionIndex: Math.max(0, prev.selectedActionIndex - 1) } + : prev); + return; + } + if (key.downArrow) { + setRightPane((prev) => prev.kind === "lane-details" + ? { ...prev, selectedActionIndex: Math.min(maxIndex, prev.selectedActionIndex + 1) } + : prev); + return; + } + if (input === "t" && !key.ctrl && !key.meta) { + setRightPane((prev) => prev.kind === "lane-details" ? { ...prev, showFiles: !prev.showFiles } : prev); + return; + } + if (key.return) { + const index = laneDetails.selectedActionIndex; + if (index < LANE_DETAIL_ACTIONS.length) { + const action = LANE_DETAIL_ACTIONS[index]; + if (action) { + const text = action.slashCommand === "/commit" ? `${action.slashCommand} ` : action.slashCommand; + setPrompt(text); + promptRef.current = text; + chatDraftRef.current = text; + focusChat(); + } + return; + } + if (laneDetails.pr) { + const url = laneDetails.pr.url; + const bridge = (globalThis as { window?: { ade?: { app?: { openExternal?: (url: string) => unknown } } } }).window; + const opener = bridge?.ade?.app?.openExternal; + if (typeof opener === "function") { + try { + opener(url); + addNotice("Opening PR in browser…", "info"); + return; + } catch { + // fall through to platform open + } + } + if (process.platform === "darwin" && url) { + spawn("open", [url], { stdio: "ignore", detached: true }).unref(); + addNotice("Opening PR in browser…", "info"); + return; + } + if (process.platform === "linux" && url) { + spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref(); + addNotice("Opening PR in browser…", "info"); + return; + } + setPrompt("/pr open"); + promptRef.current = "/pr open"; + void submitPrompt("/pr open"); + return; + } + return; + } + } + + if (pane === "details" && rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort" || (rightPane.kind === "list" && rightPane.action)) && key.upArrow) { + const max = rightPane.kind === "models" + ? rightPane.models.length + : rightPane.kind === "effort" + ? rightPane.efforts.length + : rightPane.rows.length; + setRightSelectionIndex((index) => (index <= 0 ? Math.max(0, max - 1) : index - 1)); + return; + } + if (pane === "details" && rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort" || (rightPane.kind === "list" && rightPane.action)) && key.downArrow) { + const max = rightPane.kind === "models" + ? rightPane.models.length + : rightPane.kind === "effort" + ? rightPane.efforts.length + : rightPane.rows.length; + setRightSelectionIndex((index) => (max > 0 ? (index + 1) % max : 0)); + return; + } + if (pane === "details" && rightOpen && rightPane.kind === "list" && rightPane.action && key.return) { + const selectedId = rightPane.action.ids[rightSelectionIndex] ?? rightPane.action.ids[0] ?? null; + if (!selectedId) return; + if (rightPane.action.kind === "switch-lane") { + const lane = lanes.find((entry) => entry.id === selectedId); + if (!lane) return; + selectActiveLaneId(lane.id); + setDrawerLaneId(lane.id); + setSelectedDrawerLaneId(lane.id); + const session = newestSession(sessions.filter((entry) => entry.laneId === lane.id)); + selectActiveSessionId(session?.sessionId ?? null); + setSelectedDrawerChatId(session?.sessionId ?? null); + addNotice(`Switched to lane ${lane.name}.`, "success"); + return; + } + const session = sessions.find((entry) => entry.sessionId === selectedId); + if (!session) return; + selectActiveLaneId(session.laneId); + setDrawerLaneId(session.laneId); + setSelectedDrawerLaneId(session.laneId); + selectActiveSessionId(session.sessionId); + setSelectedDrawerChatId(session.sessionId); + addNotice(`Switched to chat ${session.title ?? session.sessionId}.`, "success"); + return; + } + if (pane === "details" && rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort") && key.return) { + const conn = connectionRef.current; + const sessionId = activeSessionIdRef.current; + if (!conn) { + return; + } + if (rightPane.kind === "models") { + const model = rightPane.models[rightSelectionIndex] ?? rightPane.models[0]; + if (!model) return; + const modelId = model.modelId ?? model.id; + if (!sessionId) { + setModelState((prev) => ({ + ...prev, + model: model.id, + modelId, + displayName: model.displayName, + })); + addNotice(`Default model set to ${model.displayName}.`, "success"); + return; + } + void updateChatModel({ connection: conn, sessionId, modelId }) + .then(() => { + addNotice(`Model set to ${model.displayName}.`, "success"); + return refreshState(); + }) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + return; + } + const effort = rightPane.efforts[rightSelectionIndex] ?? rightPane.efforts[0]; + if (!effort) return; + if (!sessionId) { + setModelState((prev) => ({ ...prev, reasoningEffort: effort })); + addNotice(`Default effort set to ${effort}.`, "success"); + return; + } + void updateChatModel({ connection: conn, sessionId, reasoningEffort: effort }) + .then(() => { + addNotice(`Effort set to ${effort}.`, "success"); + return refreshState(); + }) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + return; + } + + const pageUp = Boolean((key as { pageUp?: boolean }).pageUp); + const pageDown = Boolean((key as { pageDown?: boolean }).pageDown); + const home = Boolean((key as { home?: boolean }).home); + const end = Boolean((key as { end?: boolean }).end); + if (pane === "chat" && !activeMentionRange && !slashRows.length) { + const pageRows = Math.max(1, chatRowBudget - 2); + if (pageUp || (key.ctrl && input === "u")) { + setChatScrollOffset((offset) => offset + (key.ctrl ? Math.max(1, Math.floor(pageRows / 2)) : pageRows)); + return; + } + if (pageDown || (key.ctrl && input === "d")) { + setChatScrollOffset((offset) => offset - (key.ctrl ? Math.max(1, Math.floor(pageRows / 2)) : pageRows)); + return; + } + if (home) { + setChatScrollOffset((offset) => Math.max(offset, 100_000)); + return; + } + if (end) { + setChatScrollOffset(0); + return; + } + } + + if (pane === "chat" && key.upArrow && activeMentionRange && mentionSuggestions.length) { + setMentionIndex((index) => (index <= 0 ? mentionSuggestions.length - 1 : index - 1)); + return; + } + if (pane === "chat" && key.downArrow && activeMentionRange && mentionSuggestions.length) { + setMentionIndex((index) => (index + 1) % mentionSuggestions.length); + return; + } + if (pane === "chat" && key.tab && activeMentionRange && mentionSuggestions.length) { + insertMention(mentionSuggestions[mentionIndex] ?? mentionSuggestions[0]!); + return; + } + if (pane === "chat" && key.upArrow && slashRows.length) { + setSlashIndex((index) => (index <= 0 ? slashRows.length - 1 : index - 1)); + return; + } + if (pane === "chat" && key.downArrow && slashRows.length) { + setSlashIndex((index) => (index + 1) % slashRows.length); + return; + } + if (pane === "chat" && key.tab && slashRows.length) { + insertSlashCommand(); + return; + } + if (pane === "chat" && key.downArrow && !activeMentionRange && !slashRows.length) { + selectFooterControl(footerControlRef.current ?? "drawer"); + setPaneFocus("chat"); + return; + } + + if (pane === "drawer" && drawerOpen && key.tab) { + setDrawerSection((section) => section === "lanes" ? "chats" : "lanes"); + return; + } + if (pane === "drawer" && drawerOpen && key.upArrow) { + if (drawerSection === "lanes") { + const nextIndex = Math.max(0, selectedLaneIndex - 1); + const lane = drawerLaneRows[nextIndex] ?? null; + setSelectedDrawerLaneAction(lane ? null : "new-lane"); + setSelectedDrawerLaneId(lane?.id ?? null); + } else if (selectedChatIndex <= 0) { + setDrawerSection("lanes"); + const lastLane = drawerLaneRows[drawerLaneRows.length - 1] ?? null; + setSelectedDrawerLaneAction("new-lane"); + setSelectedDrawerLaneId(lastLane?.id ?? null); + } else { + const nextIndex = Math.max(0, selectedChatIndex - 1); + const session = drawerVisibleLaneSessions[nextIndex] ?? null; + setSelectedDrawerChatAction(session ? null : "new-chat"); + setSelectedDrawerChatId(session?.sessionId ?? null); + } + return; + } + if (pane === "drawer" && drawerOpen && key.downArrow) { + if (drawerSection === "lanes") { + if (selectedLaneIndex >= drawerLaneRows.length) { + setDrawerSection("chats"); + const firstSession = drawerVisibleLaneSessions[0] ?? null; + setSelectedDrawerChatAction(firstSession ? null : "new-chat"); + setSelectedDrawerChatId(firstSession?.sessionId ?? null); + } else { + const nextIndex = Math.min(drawerLaneRows.length, selectedLaneIndex + 1); + const lane = drawerLaneRows[nextIndex] ?? null; + setSelectedDrawerLaneAction(lane ? null : "new-lane"); + setSelectedDrawerLaneId(lane?.id ?? null); + } + } else { + const nextIndex = Math.min(drawerVisibleLaneSessions.length, selectedChatIndex + 1); + const session = drawerVisibleLaneSessions[nextIndex] ?? null; + setSelectedDrawerChatAction(session ? null : "new-chat"); + setSelectedDrawerChatId(session?.sessionId ?? null); + } + return; + } + if (pane === "drawer" && drawerOpen && key.return) { + if (drawerSection === "lanes") { + if (selectedDrawerLaneAction === "new-lane" || selectedLaneIndex >= drawerLaneRows.length) { + openNewLaneForm(); + setRightOpen(true); + return; + } + const lane = drawerLaneRows[selectedLaneIndex]; + if (lane) { + selectActiveLaneId(lane.id); + setDrawerLaneId(lane.id); + setSelectedDrawerLaneId(lane.id); + setSelectedDrawerLaneAction(null); + const laneSessions = sessions.filter((entry) => entry.laneId === lane.id); + const lastSessionId = lastChatByLaneRef.current.get(lane.id); + const session = + laneSessions.find((s) => s.sessionId === lastSessionId) + ?? newestSession(laneSessions); + selectActiveSessionId(session?.sessionId ?? null); + setSelectedDrawerChatId(session?.sessionId ?? null); + setSelectedDrawerChatAction(session ? null : "new-chat"); + setDrawerSection("chats"); + addNotice(`Switched to lane ${lane.name}.`, "success"); + } + } else { + if (selectedDrawerChatAction === "new-chat" || selectedChatIndex >= drawerVisibleLaneSessions.length) { + openNewChatSetup(); + setRightOpen(true); + return; + } + const session = drawerVisibleLaneSessions[selectedChatIndex]; + if (session) { + selectActiveLaneId(session.laneId); + setDrawerLaneId(session.laneId); + setSelectedDrawerLaneId(session.laneId); + setSelectedDrawerLaneAction(null); + selectActiveSessionId(session.sessionId); + setSelectedDrawerChatId(session.sessionId); + setSelectedDrawerChatAction(null); + } + } + return; + } + + if (pane === "chat" && key.return && !prompt.trim() && latestFailedLineId && !pendingApproval && rightPane.kind !== "form" && !slashRows.length) { + setExpandedLineIds((prev) => { + const next = new Set(prev); + if (next.has(latestFailedLineId)) next.delete(latestFailedLineId); + else next.add(latestFailedLineId); + return next; + }); + return; + } + const linePrefix = inputBeforeLineBreak(input); + if (textInputActive && (key.return || linePrefix != null)) { + const suffix = linePrefix == null ? "" : printableInput(linePrefix); + void submitPrompt(`${prompt}${suffix}`); + return; + } + if (textInputActive && (key.backspace || key.delete)) { + handlePromptChange(prompt.slice(0, -1)); + return; + } + if (textInputActive && !key.ctrl && input) { + const suffix = printableInput(input); + if (suffix) handlePromptChange(`${prompt}${suffix}`); + } + }); + + const handlePromptChange = useCallback((value: string) => { + setFormDiscardArmed(false); + if (activePaneRef.current === "chat" && value === "?") { + setRightPane({ kind: "help", title: "Help" }); + focusDetails(); + setPrompt(""); + return; + } + if (activePaneRef.current === "chat") { + chatDraftRef.current = value; + } + if (activePaneRef.current === "details" && rightPane.kind === "form" && activeFormField) { + setFormValues((prev) => ({ ...prev, [activeFormField.name]: value })); + } + setPrompt(value); + }, [activeFormField, focusDetails, rightPane]); + + const centerWidth = Math.max(40, columns - (drawerOpen ? 30 : 0) - (rightOpen ? 40 : 0)); + const laneName = activeLane?.name ?? "main"; + const promptFocused = (activePane === "chat" && footerControl == null) || (activePane === "details" && rightPane.kind === "form"); + const drawerFooterSelected = footerControl === "drawer"; + const detailsFooterSelected = footerControl === "details"; + const statusRows = streaming ? 1 : 0; + const chatRowBudget = Math.max(4, rows - 12 - statusRows); + + if (error && !connection) { + return ( + <Box flexDirection="column"> + <Text color="red">ade-code failed to start</Text> + <Text>{error}</Text> + </Box> + ); + } + + return ( + <Box flexDirection="column" height={rows}> + <Header + projectName={projectName} + lane={activeLane} + /> + {streaming ? ( + <Text color={PURPLE}>● streaming live{tokenSummary ? ` · ${tokenSummary}` : ""} · ctrl-c interrupts</Text> + ) : null} + <Box flexGrow={1} minHeight={8}> + {drawerOpen ? ( + <Drawer + lanes={lanes} + sessions={sessions} + activeLaneId={activeLaneId} + activeSessionId={activeSessionId} + browsingLaneId={drawerLaneId ?? activeLaneId} + selectedLaneIndex={drawerSection === "lanes" ? selectedLaneIndex : -1} + selectedChatIndex={drawerSection === "chats" ? selectedChatIndex : -1} + panelHeight={rows} + focused={activePane === "drawer"} + /> + ) : null} + <Box width={centerWidth} flexDirection="column"> + {pendingApproval?.highStakes ? ( + <ApprovalPrompt approval={pendingApproval} modal /> + ) : ( + <> + <ChatView + events={events} + notices={notices} + activeSession={activeSession} + projectName={projectName} + laneName={laneName} + lane={activeLane} + expandedLineIds={expandedLineIds} + maxRows={chatRowBudget} + scrollOffsetRows={chatScrollOffsetRows} + width={centerWidth} + /> + <ApprovalPrompt approval={pendingApproval} /> + </> + )} + </Box> + {rightOpen ? ( + <RightPane + content={rightPane} + formValues={formValues} + activeFormField={formFieldIndex} + selectedIndex={rightSelectionIndex} + focused={activePane === "details"} + /> + ) : null} + </Box> + <MentionPalette suggestions={mentionSuggestions} selectedIndex={mentionIndex} /> + <SlashPalette query={prompt} userCommands={slashCommands} selectedIndex={slashIndex} /> + {error ? <Text color="red">{error}</Text> : null} + <Box borderStyle="round" borderColor={promptFocused ? PURPLE : theme.color.border} paddingX={1} flexShrink={0}> + <Text color={PURPLE}>› </Text> + <Text>{prompt}</Text> + <Text inverse> </Text> + </Box> + <ModelStatus + provider={modelState.provider} + displayName={modelState.displayName} + reasoningEffort={modelState.reasoningEffort} + permissionLabel={permissionSummary(modelState)} + fastMode={modelState.provider === "codex" && modelState.codexFastMode} + draftChatActive={draftChatActive} + contextPercent={contextPercent} + tokenSummary={tokenSummary} + /> + <FooterControls + drawerOpen={drawerOpen} + rightOpen={rightOpen} + drawerFocused={drawerFooterSelected} + detailsFocused={detailsFooterSelected} + footerControlActive={footerControl != null} + /> + </Box> + ); +} diff --git a/apps/ade-cli/src/tuiClient/cli.tsx b/apps/ade-cli/src/tuiClient/cli.tsx new file mode 100644 index 000000000..5087c2ac0 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/cli.tsx @@ -0,0 +1,154 @@ +#!/usr/bin/env node +import { pathToFileURL } from "node:url"; +import React from "react"; +import { render } from "ink"; + +type CliOptions = { + help: boolean; + printState: boolean; + forceEmbedded: boolean; + requireSocket: boolean; + projectRoot: string | null; + workspaceRoot: string | null; + socketPath: string | null; +}; + +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { + help: false, + printState: false, + forceEmbedded: false, + requireSocket: false, + projectRoot: null, + workspaceRoot: null, + socketPath: null, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--help" || arg === "-h") options.help = true; + else if (arg === "--print-state") options.printState = true; + else if (arg === "--embedded") options.forceEmbedded = true; + else if (arg === "--require-socket") options.requireSocket = true; + else if (arg === "--project-root") options.projectRoot = argv[++i] ?? null; + else if (arg === "--workspace-root") options.workspaceRoot = argv[++i] ?? null; + else if (arg === "--socket") options.socketPath = argv[++i] ?? null; + } + return options; +} + +function printHelp(): void { + process.stdout.write(`ade code + +Terminal-native ADE Work chat. + +Usage: + ade code [--project-root <path>] [--workspace-root <path>] [--socket <path>] + ade code --embedded + ade code --require-socket + ade code --print-state + +Keys: + ctrl-o open or focus lanes and chats + ctrl-p open or focus details + shift-tab cycle pane focus + esc return or cancel the active pane + ? help when it is the first and only prompt character + / command palette +`); +} + +function writeStdout(value: string): Promise<void> { + return new Promise((resolve, reject) => { + process.stdout.write(value, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function suppressTerminalWarnings(): void { + if (process.env.ADE_CODE_SHOW_WARNINGS === "1") return; + const originalEmitWarning = process.emitWarning; + process.emitWarning = function emitAdeCodeWarning(warning: string | Error, ...args: unknown[]): void { + const message = warning instanceof Error ? warning.message : String(warning); + const type = typeof args[0] === "string" ? args[0] : ""; + if (type === "ExperimentalWarning" && message.includes("SQLite is an experimental feature")) { + return; + } + (originalEmitWarning as (...innerArgs: unknown[]) => void).call(process, warning, ...args); + } as typeof process.emitWarning; +} + +async function printState(options: CliOptions): Promise<void> { + suppressTerminalWarnings(); + const { listChatSessions, listLanes } = await import("./adeApi"); + const { connectToAde } = await import("./connection"); + const { detectProjectLaunchContext } = await import("./project"); + const project = detectProjectLaunchContext({ + projectRoot: options.projectRoot, + workspaceRoot: options.workspaceRoot, + }); + const connection = await connectToAde({ + project, + forceEmbedded: options.forceEmbedded, + requireSocket: options.requireSocket, + socketPath: options.socketPath, + }); + try { + const lanes = await listLanes(connection); + const sessions = await listChatSessions(connection); + await writeStdout(`${JSON.stringify({ + mode: connection.mode, + projectRoot: project.projectRoot, + workspaceRoot: project.workspaceRoot, + laneCount: lanes.length, + chatCount: sessions.length, + socketPath: connection.socketPath, + }, null, 2)}\n`); + } finally { + await connection.close(); + } +} + +export async function runAdeCodeCli(argv: string[] = process.argv.slice(2)): Promise<number> { + const options = parseArgs(argv); + if (options.help) { + printHelp(); + return 0; + } + if (options.printState) { + await printState(options); + return 0; + } + suppressTerminalWarnings(); + const { AdeCodeApp } = await import("./app"); + const { detectProjectLaunchContext } = await import("./project"); + const project = detectProjectLaunchContext({ + projectRoot: options.projectRoot, + workspaceRoot: options.workspaceRoot, + }); + const instance = render( + <AdeCodeApp + project={project} + forceEmbedded={options.forceEmbedded} + requireSocket={options.requireSocket} + socketPath={options.socketPath} + />, + ); + await instance.waitUntilExit(); + return 0; +} + +const isDirectEntry = process.argv[1] + ? import.meta.url === pathToFileURL(process.argv[1]).href + : false; + +if (isDirectEntry) { + void runAdeCodeCli().then((exitCode) => { + process.exitCode = exitCode; + }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`ade code: ${message}\n`); + process.exitCode = 1; + }); +} diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts new file mode 100644 index 000000000..9b720c8e4 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -0,0 +1,198 @@ +import type { AgentChatSlashCommand } from "../../../desktop/src/shared/types/chat"; + +export type CommandPlacement = "inline" | "right" | "overlay" | "chat"; + +export type BuiltinCommand = { + name: string; + description: string; + placement: CommandPlacement; + argumentHint?: string; +}; + +export const BUILTIN_COMMANDS: BuiltinCommand[] = [ + { name: "/commit", description: "Commit lane changes", placement: "inline", argumentHint: "[message]" }, + { name: "/push", description: "Push the active lane branch", placement: "inline" }, + { name: "/pull", description: "Pull the active lane branch", placement: "inline" }, + { name: "/stage all", description: "Stage all changes in the active lane", placement: "inline" }, + { name: "/clear", description: "Clear the local terminal transcript view", placement: "inline" }, + { name: "/end", description: "End the active chat runtime", placement: "inline" }, + { name: "/login", description: "Sign in to the active CLI-backed provider from this terminal", placement: "inline" }, + { name: "/open", description: "Open this ADE context in desktop", placement: "inline" }, + { name: "/quit", description: "Exit ade code", placement: "inline" }, + { name: "/remember", description: "Write durable ADE memory", placement: "inline", argumentHint: "<fact>" }, + { name: "/new lane", description: "Create a new lane", placement: "right" }, + { name: "/new chat", description: "Create a new chat", placement: "right", argumentHint: "[title]" }, + { name: "/rename", description: "Rename the active chat", placement: "right", argumentHint: "[title]" }, + { name: "/status", description: "Show project, lane, and runtime state", placement: "right" }, + { name: "/diff", description: "Show active lane diff", placement: "right" }, + { name: "/log", description: "Show recent commits", placement: "right" }, + { name: "/pr", description: "Show pull request state", placement: "right" }, + { name: "/pr open", description: "Create or open a PR for the active lane", placement: "right" }, + { name: "/pr review", description: "Show PR reviews", placement: "right" }, + { name: "/pr checks", description: "Show PR checks", placement: "right" }, + { name: "/linear", description: "Run Linear workflow, route, sync, or ingress commands", placement: "right", argumentHint: "<group>" }, + { name: "/linear list", description: "List Linear work", placement: "right" }, + { name: "/linear workflows", description: "List Linear workflow runs", placement: "right" }, + { name: "/linear run", description: "Inspect or resolve a Linear run", placement: "right", argumentHint: "<status|resolve|cancel|reroute>" }, + { name: "/linear route", description: "Route a Linear issue", placement: "right", argumentHint: "<cto|mission|worker>" }, + { name: "/linear sync", description: "Operate Linear sync", placement: "right", argumentHint: "<dashboard|run|queue|resolve|detail>" }, + { name: "/linear ingress", description: "Inspect Linear ingress", placement: "right", argumentHint: "<status|events|webhook>" }, + { name: "/linear pull", description: "Pull a Linear ticket into chat context", placement: "right", argumentHint: "<id>" }, + { name: "/linear comment", description: "Comment on a Linear ticket", placement: "right", argumentHint: "<id> <text>" }, + { name: "/linear status", description: "Show Linear sync status", placement: "right" }, + { name: "/linear assign", description: "Assign a Linear ticket", placement: "right", argumentHint: "<id> <user>" }, + { name: "/memory", description: "Search ADE memory", placement: "right", argumentHint: "[query]" }, + { name: "/forget", description: "Open memory management", placement: "right" }, + { name: "/chats", description: "List chats in the active lane", placement: "right" }, + { name: "/switch", description: "Switch lane or chat", placement: "right", argumentHint: "[lane|chat]" }, + { name: "/resume", description: "Resume the active ended chat", placement: "right" }, + { name: "/help", description: "Show keymap and command help", placement: "right" }, + { name: "/model", description: "Pick the active chat model", placement: "right" }, + { name: "/effort", description: "Pick reasoning effort", placement: "right" }, + { name: "/system", description: "Show system and runtime details", placement: "right" }, + { name: "/ade", description: "Run an ADE action or force a TUI command", placement: "right", argumentHint: "<domain.action|command> [json]" }, +]; + +const ADE_OWNED_SINGLE_WORD_COMMANDS = new Set( + BUILTIN_COMMANDS + .filter((command) => command.placement === "inline" && !command.name.includes(" ")) + .map((command) => command.name.toLowerCase()), +); + +export type ParsedCommand = { + name: string; + args: string; + spec: BuiltinCommand | null; + userCommand: AgentChatSlashCommand | null; +}; + +function normalizeSlashName(value: string): string { + return value.trim().replace(/\s+/g, " "); +} + +function slashCommandKey(value: string): string { + return normalizeSlashName(value).toLowerCase(); +} + +export function parseCommand(input: string, userCommands: AgentChatSlashCommand[] = []): ParsedCommand | null { + const trimmed = input.trim(); + if (!trimmed.startsWith("/")) return null; + const [first = ""] = trimmed.split(/\s+/, 1); + const firstKey = slashCommandKey(first); + const candidates = [...BUILTIN_COMMANDS] + .sort((left, right) => right.name.length - left.name.length); + + // Preserve ADE's multi-word commands (`/new lane`, `/pr open`, `/linear pull`) + // even when a runtime exposes a first-token command like `/new`. + for (const spec of candidates.filter((candidate) => candidate.name.includes(" "))) { + const name = normalizeSlashName(spec.name); + if (trimmed === name || trimmed.startsWith(`${name} `)) { + return { + name, + args: trimmed.slice(name.length).trim(), + spec, + userCommand: null, + }; + } + } + + const exactUserCommand = userCommands.find((command) => slashCommandKey(command.name) === firstKey) ?? null; + const adeOwnedSingleWordCommand = candidates.find((command) => + slashCommandKey(command.name) === firstKey && ADE_OWNED_SINGLE_WORD_COMMANDS.has(slashCommandKey(command.name)) + ); + if (adeOwnedSingleWordCommand) { + return { + name: adeOwnedSingleWordCommand.name, + args: trimmed.slice(first.length).trim(), + spec: adeOwnedSingleWordCommand, + userCommand: null, + }; + } + + if (exactUserCommand) { + return { + name: exactUserCommand.name, + args: trimmed.slice(first.length).trim(), + spec: null, + userCommand: exactUserCommand, + }; + } + + for (const spec of candidates) { + const name = normalizeSlashName(spec.name); + if (trimmed === name || trimmed.startsWith(`${name} `)) { + return { + name, + args: trimmed.slice(name.length).trim(), + spec, + userCommand: null, + }; + } + } + + const userCommand = userCommands.find((command) => slashCommandKey(command.name) === firstKey) ?? null; + if (userCommand) { + return { + name: userCommand.name, + args: trimmed.slice(first.length).trim(), + spec: null, + userCommand, + }; + } + + return { + name: first, + args: trimmed.slice(first.length).trim(), + spec: null, + userCommand: null, + }; +} + +export function paletteCommands( + query: string, + userCommands: AgentChatSlashCommand[] = [], +): Array<{ name: string; description: string; source: "ade" | "user"; argumentHint?: string }> { + const normalizedQuery = query.trim().toLowerCase(); + const queryToken = normalizedQuery.replace(/^\//, ""); + const builtins = BUILTIN_COMMANDS.map((command) => ({ + name: command.name, + description: command.description, + source: "ade" as const, + argumentHint: command.argumentHint, + })); + const users = userCommands.map((command) => ({ + name: command.name, + description: command.description, + source: "user" as const, + argumentHint: command.argumentHint, + })); + // Dedupe by name. Most runtime/user commands win over ADE built-ins, but + // ADE-owned inline terminal controls must match parseCommand dispatch. + const byName = new Map<string, { name: string; description: string; source: "ade" | "user"; argumentHint?: string }>(); + for (const command of builtins) byName.set(slashCommandKey(command.name), command); + for (const command of users) { + const key = slashCommandKey(command.name); + if (ADE_OWNED_SINGLE_WORD_COMMANDS.has(key)) continue; + byName.set(key, command); + } + const merged = [...byName.values()]; + const filtered = !queryToken + ? merged + : merged.filter((command) => `${command.name} ${command.description}`.toLowerCase().includes(queryToken)); + // Rank: name-prefix matches first, then name-substring, then description matches, then alphabetical. + filtered.sort((a, b) => { + if (queryToken) { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + const aPrefix = aName.startsWith(`/${queryToken}`) ? 0 : aName.includes(queryToken) ? 1 : 2; + const bPrefix = bName.startsWith(`/${queryToken}`) ? 0 : bName.includes(queryToken) ? 1 : 2; + if (aPrefix !== bPrefix) return aPrefix - bPrefix; + } + return a.name.localeCompare(b.name); + }); + return filtered.slice(0, 30); +} + +export function commandPlacement(command: ParsedCommand): CommandPlacement { + return command.spec?.placement ?? "chat"; +} diff --git a/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx b/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx new file mode 100644 index 000000000..efa7fbe12 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { theme } from "../theme"; + +const ROWS = [ + " ████ █████ ██████", + "██ ██ ██ ██ ██ ", + "██████ ██ ██ █████ ", + "██ ██ ██ ██ ██ ", + "██ ██ █████ ██████", +]; + +export function AdeWordmark() { + return ( + <Box flexDirection="column" alignItems="flex-start"> + {ROWS.map((row, index) => ( + <Text key={index} color={theme.color.accent} bold> + {row} + </Text> + ))} + </Box> + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx b/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx new file mode 100644 index 000000000..8be16b5ce --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { PendingApproval } from "../types"; + +export function ApprovalPrompt({ + approval, + modal = false, +}: { + approval: PendingApproval | null; + modal?: boolean; +}) { + if (!approval) return null; + const question = approval.request?.questions[0] ?? null; + + let title: string; + if (approval.mode === "question") title = "Input requested"; + else if (approval.highStakes) title = "High-stakes approval required"; + else title = "Approval required"; + + let footer: string; + if (approval.mode === "question") footer = "Type an answer, option number/value, deny, or cancel."; + else if (approval.highStakes) footer = "Type approve or deny, then press enter."; + else footer = "Press a to approve, d to deny."; + + const card = ( + <Box + borderStyle="single" + borderColor={approval.highStakes ? "red" : "yellow"} + paddingX={1} + paddingY={modal ? 1 : 0} + flexDirection="column" + width={modal ? 60 : undefined} + > + <Text color={approval.highStakes ? "red" : "yellow"}>{title}</Text> + <Text>{question?.question ?? approval.description}</Text> + {question?.options?.length ? ( + <Box flexDirection="column"> + {question.options.slice(0, 6).map((option, index) => ( + <Text key={option.value} dimColor> + {index + 1}. {option.label}{option.description ? ` - ${option.description}` : ""} + </Text> + ))} + </Box> + ) : null} + <Text dimColor>{footer}</Text> + </Box> + ); + if (!modal) return card; + return ( + <Box flexGrow={1} alignItems="center" justifyContent="center"> + {card} + </Box> + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx new file mode 100644 index 000000000..6381c7480 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -0,0 +1,368 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; +import type { LocalNotice } from "../types"; +import { renderChatLines, type AssistantMarkdownBlock, type RenderedChatLine } from "../format"; +import { theme } from "../theme"; +import { AdeWordmark } from "./AdeWordmark"; +import { laneIconGlyph } from "./Header"; + +const HERO_TARGET_HALO_WIDTH = 56; +const HERO_MIN_HALO_WIDTH = 28; +const HERO_WORDMARK_MIN_USABLE = 24; +const DEFAULT_VIEW_WIDTH = 88; + +type RenderedChatRow = { + id: string; + text: string; + tone: RenderedChatLine["tone"] | "indicator"; + color?: string; + dim?: boolean; + bold?: boolean; +}; + +function textWidth(value: string): number { + return [...value].length; +} + +function repeat(value: string, count: number): string { + return value.repeat(Math.max(0, count)); +} + +function padRight(value: string, width: number): string { + return `${value}${repeat(" ", width - textWidth(value))}`; +} + +function alignRight(value: string, width: number): string { + return `${repeat(" ", width - textWidth(value))}${value}`; +} + +function hardWrapWord(word: string, width: number): string[] { + if (width <= 1) return [word]; + const chars = [...word]; + const chunks: string[] = []; + for (let index = 0; index < chars.length; index += width) { + chunks.push(chars.slice(index, index + width).join("")); + } + return chunks; +} + +function wrapText(value: string, width: number, firstPrefix = "", restPrefix = firstPrefix): string[] { + const availableFirst = Math.max(1, width - textWidth(firstPrefix)); + const availableRest = Math.max(1, width - textWidth(restPrefix)); + const rows: string[] = []; + for (const rawLine of value.split(/\r?\n/)) { + const words = rawLine.trim().split(/\s+/).filter(Boolean); + if (!words.length) { + rows.push(firstPrefix); + continue; + } + let prefix = firstPrefix; + let limit = availableFirst; + let current = ""; + for (const word of words) { + if (textWidth(word) > limit) { + if (current) { + rows.push(`${prefix}${current}`); + prefix = restPrefix; + limit = availableRest; + current = ""; + } + const chunks = hardWrapWord(word, limit); + for (const chunk of chunks.slice(0, -1)) { + rows.push(`${prefix}${chunk}`); + prefix = restPrefix; + limit = availableRest; + } + current = chunks[chunks.length - 1] ?? ""; + continue; + } + const next = current ? `${current} ${word}` : word; + if (textWidth(next) > limit && current) { + rows.push(`${prefix}${current}`); + prefix = restPrefix; + limit = availableRest; + current = word; + } else { + current = next; + } + } + if (current) rows.push(`${prefix}${current}`); + } + return rows; +} + +function HeroDivider({ width }: { width: number }) { + return <Text color={theme.color.border} dimColor>{"─".repeat(Math.max(4, width))}</Text>; +} + +function HeroMetaRow({ label, value, color }: { label: string; value: string; color?: string }) { + return ( + <Box flexDirection="row"> + <Box width={9}> + <Text dimColor>{label}</Text> + </Box> + <Text color={color ?? theme.color.fg}>{value}</Text> + </Box> + ); +} + +export function BootHero({ + projectName, + laneName, + lane, + width = DEFAULT_VIEW_WIDTH, +}: { + projectName: string; + laneName: string; + lane?: LaneSummary | null; + width?: number; +}) { + const laneColor = theme.lane(lane ?? null); + const laneGlyph = laneIconGlyph(lane?.icon ?? null); + const trimmedProject = projectName.trim(); + const projectLabel = trimmedProject || "—"; + const branchLabel = lane?.branchRef?.trim() || "—"; + + // Outer halo border + inner card border = 4 chars of horizontal chrome. + // Card border 2 + paddingX 4 + inner paddingX 2 = 8 chars between halo edge + // and content. Clamp so we don't blow out narrow terminals. + const haloWidth = Math.max(HERO_MIN_HALO_WIDTH, Math.min(HERO_TARGET_HALO_WIDTH, width - 2)); + const cardWidth = haloWidth - 4; + const usableWidth = Math.max(4, cardWidth - 8); + const showWordmark = usableWidth >= HERO_WORDMARK_MIN_USABLE; + + return ( + <Box flexDirection="column" alignItems="center" paddingY={1}> + <Box + borderStyle="round" + borderColor={theme.color.accentDim} + paddingX={1} + width={haloWidth} + flexDirection="column" + > + <Box + borderStyle="bold" + borderColor={theme.color.accent} + paddingX={2} + paddingY={1} + flexDirection="column" + width={cardWidth} + > + <Box flexDirection="column" paddingX={1}> + <Box flexDirection="column" alignItems="center"> + {showWordmark ? ( + <AdeWordmark /> + ) : ( + <Text color={theme.color.accent} bold>A · D · E</Text> + )} + <Box height={1} /> + <Text> + <Text color={theme.color.fg} bold>ade code</Text> + <Text dimColor> · v0.1</Text> + </Text> + </Box> + <Box height={1} /> + <HeroMetaRow label="Project" value={projectLabel} /> + <HeroMetaRow label="Lane" value={`${laneGlyph} ${laneName}`} color={laneColor} /> + <HeroMetaRow label="Branch" value={branchLabel === "—" ? branchLabel : `⎇ ${branchLabel}`} /> + <Box height={1} /> + <HeroDivider width={usableWidth} /> + <Box height={1} /> + <Text color={theme.color.fg}>type to chat</Text> + <Box height={1} /> + <Text> + <Text color={theme.color.accent} bold>/</Text> + <Text dimColor> commands </Text> + <Text color={theme.color.accent} bold>@</Text> + <Text dimColor> files </Text> + <Text color={theme.color.accent} bold>?</Text> + <Text dimColor> help</Text> + </Text> + </Box> + </Box> + </Box> + </Box> + ); +} + +function markdownRows(blocks: AssistantMarkdownBlock[], width: number, id: string): RenderedChatRow[] { + const rows: RenderedChatRow[] = []; + const pushWrapped = ( + text: string, + firstPrefix = "", + restPrefix = firstPrefix, + options: Partial<RenderedChatRow> = {}, + ) => { + for (const wrapped of wrapText(text, width, firstPrefix, restPrefix)) { + rows.push({ id, tone: "assistant", text: wrapped, color: theme.color.fg, ...options }); + } + }; + + for (const block of blocks) { + if (rows.length) rows.push({ id, tone: "assistant", text: "" }); + if (block.kind === "heading") { + pushWrapped(block.text, "", "", { color: theme.color.accent, bold: true }); + continue; + } + if (block.kind === "bullet") { + pushWrapped(block.text, "• ", " "); + continue; + } + if (block.kind === "numbered") { + const prefix = `${block.number}. `; + pushWrapped(block.text, prefix, repeat(" ", textWidth(prefix))); + continue; + } + if (block.kind === "quote") { + pushWrapped(block.text, "> ", "> ", { dim: true }); + continue; + } + if (block.kind === "code") { + const label = block.language ? ` ${block.language}` : ""; + rows.push({ id, tone: "assistant", text: ` ┌${repeat("─", Math.max(1, Math.min(width - 5, 24)))}${label}`, color: theme.color.border, dim: true }); + for (const codeLine of block.lines.length ? block.lines : [""]) { + const available = Math.max(1, width - 4); + const chunks = hardWrapWord(codeLine || " ", available); + for (const chunk of chunks) { + rows.push({ id, tone: "assistant", text: ` │ ${chunk}`, color: theme.color.tool, dim: true }); + } + } + rows.push({ id, tone: "assistant", text: " └", color: theme.color.border, dim: true }); + continue; + } + if (block.kind === "hr") { + rows.push({ id, tone: "assistant", text: repeat("─", Math.min(width, 72)), color: theme.color.border, dim: true }); + continue; + } + pushWrapped(block.text); + } + return rows; +} + +function rowsForLine(line: RenderedChatLine, prevTone: RenderedChatLine["tone"] | null, width: number): RenderedChatRow[] { + const isChatTurn = line.tone === "user" || line.tone === "assistant"; + const speakerChanged = prevTone !== line.tone; + const showSpacer = isChatTurn && speakerChanged && prevTone !== null; + const rows: RenderedChatRow[] = []; + const push = (row: Omit<RenderedChatRow, "id">) => rows.push({ id: line.id, ...row }); + if (showSpacer) push({ tone: line.tone, text: "" }); + + if (line.tone === "user") { + const bubbleWidth = Math.max(12, Math.min(width - 4, 78)); + const contentWidth = Math.max(1, bubbleWidth - 4); + if (line.header) push({ tone: "user", text: alignRight(line.header, width), dim: true }); + const bodyRows = wrapText(line.body, contentWidth); + push({ tone: "user", text: alignRight(`╭${repeat("─", bubbleWidth - 2)}╮`, width), color: theme.color.accent }); + for (const bodyRow of bodyRows) { + push({ tone: "user", text: alignRight(`│ ${padRight(bodyRow, contentWidth)} │`, width), color: theme.color.fg }); + } + push({ tone: "user", text: alignRight(`╰${repeat("─", bubbleWidth - 2)}╯`, width), color: theme.color.accent }); + return rows; + } + + if (line.tone === "tool" || line.tone === "error") { + const isErrorTone = line.tone === "error"; + for (const text of line.body.split(/\r?\n/)) { + for (const wrapped of wrapText(text, width, " ", " ")) { + push({ tone: line.tone, text: wrapped, color: isErrorTone ? theme.color.danger : theme.color.tool, dim: !isErrorTone }); + } + } + return rows; + } + + if (line.tone === "reasoning" || line.tone === "notice" || line.tone === "approval") { + if (line.header) push({ tone: line.tone, text: line.header, color: theme.tone(line.tone), dim: true }); + for (const wrapped of wrapText(line.body, width)) { + push({ tone: line.tone, text: wrapped, color: theme.tone(line.tone), dim: line.tone !== "approval" }); + } + return rows; + } + + // assistant + if (line.header) push({ tone: "assistant", text: line.header, dim: true }); + if (line.blocks?.length) { + rows.push(...markdownRows(line.blocks, width, line.id)); + } else { + for (const wrapped of wrapText(line.body, width)) { + push({ tone: "assistant", text: wrapped, color: theme.color.fg }); + } + } + return rows; +} + +function rowsForLines(lines: RenderedChatLine[], width: number): RenderedChatRow[] { + return lines.flatMap((line, index) => rowsForLine(line, index > 0 ? lines[index - 1]!.tone : null, width)); +} + +function sliceRows(rows: RenderedChatRow[], maxRows?: number, scrollOffsetRows = 0): RenderedChatRow[] { + if (!maxRows || maxRows <= 0 || rows.length <= maxRows) return rows; + let offset = Math.max(0, Math.min(scrollOffsetRows, rows.length - maxRows)); + for (let attempt = 0; attempt < 2; attempt += 1) { + const end = rows.length - offset; + const start = Math.max(0, end - maxRows); + const hasOlder = start > 0; + const hasNewer = end < rows.length; + const contentRows = Math.max(1, maxRows - (hasOlder ? 1 : 0) - (hasNewer ? 1 : 0)); + const nextEnd = rows.length - offset; + const nextStart = Math.max(0, nextEnd - contentRows); + const nextHasOlder = nextStart > 0; + const nextHasNewer = nextEnd < rows.length; + if (nextHasOlder === hasOlder && nextHasNewer === hasNewer) { + const visible = rows.slice(nextStart, nextEnd); + return [ + ...(nextHasOlder ? [{ id: "older-indicator", tone: "indicator" as const, text: "↑ older messages", dim: true }] : []), + ...visible, + ...(nextHasNewer ? [{ id: "newer-indicator", tone: "indicator" as const, text: "↓ newer messages", dim: true }] : []), + ]; + } + offset = Math.max(0, Math.min(offset, rows.length - contentRows)); + } + return rows.slice(-maxRows); +} + +function ChatRow({ row }: { row: RenderedChatRow }) { + return ( + <Text color={row.color ?? (row.tone === "indicator" ? theme.color.accent : undefined)} dimColor={row.dim} bold={row.bold}> + {row.text} + </Text> + ); +} + +export function ChatView({ + events, + notices, + activeSession, + projectName, + laneName, + lane, + expandedLineIds, + maxRows, + scrollOffsetRows = 0, + width = DEFAULT_VIEW_WIDTH, +}: { + events: AgentChatEventEnvelope[]; + notices: LocalNotice[]; + activeSession: AgentChatSessionSummary | null; + projectName: string; + laneName: string; + lane?: LaneSummary | null; + expandedLineIds?: Set<string>; + maxRows?: number; + scrollOffsetRows?: number; + width?: number; +}) { + const lines = renderChatLines({ events, notices, activeSession, expandedLineIds, maxLines: 200 }); + if (!lines.length) { + return <BootHero projectName={projectName} laneName={laneName} lane={lane ?? null} width={width} />; + } + const rows = sliceRows(rowsForLines(lines, Math.max(24, width - 2)), maxRows, scrollOffsetRows); + return ( + <Box flexDirection="column" paddingX={1}> + {rows.map((row, index) => ( + <ChatRow key={`${row.id}:${index}`} row={row} /> + ))} + </Box> + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/Drawer.tsx b/apps/ade-cli/src/tuiClient/components/Drawer.tsx new file mode 100644 index 000000000..846f0d6b4 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/Drawer.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { Box, Text, useStdout } from "ink"; +import type { AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; +import { formatLaneLabel, formatSessionLabel } from "../format"; + +const PURPLE = "#A78BFA"; +const AMBER = "#F59E0B"; + +export function visibleDrawerLaneCount(panelHeight: number, laneCount: number): number { + const lanesMaxRows = Math.max(2, Math.floor(panelHeight / 2) - 3); + return Math.min(laneCount, 10, lanesMaxRows); +} + +export function visibleDrawerChatCount(chatCount: number): number { + return Math.min(chatCount, 12); +} + +export function Drawer({ + lanes, + sessions, + activeLaneId, + activeSessionId, + browsingLaneId, + selectedLaneIndex, + selectedChatIndex, + panelHeight, + focused = false, +}: { + lanes: LaneSummary[]; + sessions: AgentChatSessionSummary[]; + activeLaneId: string | null; + activeSessionId: string | null; + browsingLaneId: string | null; + selectedLaneIndex: number; + selectedChatIndex: number; + panelHeight?: number; + focused?: boolean; +}) { + const { stdout } = useStdout(); + const resolvedPanelHeight = panelHeight ?? stdout?.rows ?? 40; + const laneSessions = sessions + .filter((session) => session.laneId === browsingLaneId) + .slice(0, visibleDrawerChatCount(sessions.length)); + const laneRows = lanes.slice(0, visibleDrawerLaneCount(resolvedPanelHeight, lanes.length)); + return ( + <Box width={28} flexDirection="column" borderStyle="single" borderColor={focused ? PURPLE : "gray"} paddingX={1}> + <Box flexDirection="column" flexShrink={1}> + <Text bold color={focused ? PURPLE : undefined}>LANES</Text> + {laneRows.map((lane, index) => ( + <Text key={lane.id} color={lane.id === activeLaneId ? AMBER : lane.id === browsingLaneId ? "white" : undefined}> + {index === selectedLaneIndex ? "›" : " "} {lane.id === activeLaneId ? "●" : lane.id === browsingLaneId ? "◐" : "○"} {formatLaneLabel(lane).slice(0, 20)} + </Text> + ))} + <Text color={selectedLaneIndex === laneRows.length ? PURPLE : undefined} dimColor={selectedLaneIndex !== laneRows.length}> + {selectedLaneIndex === laneRows.length ? "›" : " "} + new lane + </Text> + </Box> + <Box + flexDirection="column" + flexGrow={1} + borderStyle="single" + borderTop + borderLeft={false} + borderRight={false} + borderBottom={false} + borderColor="gray" + > + <Text bold>CHATS</Text> + {laneSessions.length === 0 ? ( + <Text dimColor>No chats in lane.</Text> + ) : laneSessions.map((session, index) => ( + <Text key={session.sessionId} color={session.sessionId === activeSessionId ? PURPLE : undefined}> + {index === selectedChatIndex ? "›" : " "} {formatSessionLabel(session).slice(0, 22)} + </Text> + ))} + <Text color={selectedChatIndex === laneSessions.length ? PURPLE : undefined} dimColor={selectedChatIndex !== laneSessions.length}> + {selectedChatIndex === laneSessions.length ? "›" : " "} + new chat + </Text> + </Box> + </Box> + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx new file mode 100644 index 000000000..a7f6d503a --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { theme } from "../theme"; + +function Toggle({ + label, + hint, + open, + focused, +}: { + label: string; + hint: string; + open: boolean; + focused: boolean; +}) { + const arrow = open ? "▾" : "▸"; + if (focused) { + return ( + <Text color={theme.color.accent} inverse> + {` ${arrow} ${label} ${hint} `} + </Text> + ); + } + return ( + <Text color={open ? theme.color.accent : theme.color.mutedFg} dimColor={!open}> + {`[${arrow} ${label} ${hint}]`} + </Text> + ); +} + +function Hint({ keyLabel, action }: { keyLabel: string; action: string }) { + return ( + <> + <Text color={theme.color.accent}>{keyLabel}</Text> + <Text dimColor>{` ${action}`}</Text> + </> + ); +} + +export function FooterControls({ + drawerOpen, + rightOpen, + drawerFocused, + detailsFocused, + footerControlActive, +}: { + drawerOpen: boolean; + rightOpen: boolean; + drawerFocused: boolean; + detailsFocused: boolean; + footerControlActive: boolean; +}) { + return ( + <Box flexDirection="row" paddingX={1} flexShrink={0} justifyContent="space-between"> + <Text wrap="truncate-end"> + <Toggle label="lanes" hint="^o" open={drawerOpen} focused={drawerFocused} /> + <Text> </Text> + <Toggle label="setup" hint="^p" open={rightOpen} focused={detailsFocused} /> + </Text> + <Text wrap="truncate-start"> + {footerControlActive ? ( + <Text dimColor>↵ toggle ← → choose ↑ exit</Text> + ) : ( + <> + <Hint keyLabel="↓" action="panes" /> + <Text dimColor> </Text> + <Hint keyLabel="⇥" action="cycle" /> + <Text dimColor> </Text> + <Hint keyLabel="/" action="cmds" /> + <Text dimColor> </Text> + <Hint keyLabel="?" action="help" /> + </> + )} + </Text> + </Box> + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/Header.tsx b/apps/ade-cli/src/tuiClient/components/Header.tsx new file mode 100644 index 000000000..6318653bb --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/Header.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { LaneIcon, LaneSummary } from "../../../../desktop/src/shared/types/lanes"; +import { formatLaneLabel } from "../format"; +import { theme } from "../theme"; + +const LANE_ICON_GLYPH: Record<NonNullable<LaneIcon>, string> = { + star: "★", + flag: "⚑", + bolt: "↯", + shield: "▣", + tag: "❯", +}; + +export function laneIconGlyph(icon: LaneIcon | null | undefined): string { + if (!icon) return "▎"; + return LANE_ICON_GLYPH[icon] ?? "▎"; +} + +export function Header({ projectName, lane }: { projectName: string; lane: LaneSummary | null }) { + const laneColor = theme.lane(lane); + const showProject = projectName.trim() && projectName.trim().toLowerCase() !== "ade"; + return ( + <Box + paddingX={1} + flexShrink={0} + borderStyle="single" + borderColor={theme.color.border} + borderTop={false} + borderLeft={false} + borderRight={false} + > + <Text wrap="truncate"> + <Text color={theme.color.accent} inverse bold>{" ADE "}</Text> + {showProject ? ( + <> + <Text>{" "}</Text> + <Text color={theme.color.fg}>{projectName}</Text> + </> + ) : null} + {lane ? ( + <> + <Text>{" "}</Text> + <Text color={laneColor}>{laneIconGlyph(lane.icon)} {formatLaneLabel(lane)}</Text> + </> + ) : null} + {lane?.branchRef ? ( + <> + <Text>{" "}</Text> + <Text dimColor>⎇ {lane.branchRef}</Text> + </> + ) : null} + </Text> + </Box> + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/MentionPalette.tsx b/apps/ade-cli/src/tuiClient/components/MentionPalette.tsx new file mode 100644 index 000000000..5477c91b0 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/MentionPalette.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { MentionSuggestion } from "../types"; + +const COLORS: Record<MentionSuggestion["kind"], string> = { + lane: "#F59E0B", + chat: "#A78BFA", + pr: "cyan", + file: "green", + commit: "yellow", +}; + +export function MentionPalette({ + suggestions, + selectedIndex, +}: { + suggestions: MentionSuggestion[]; + selectedIndex: number; +}) { + if (!suggestions.length) return null; + return ( + <Box flexDirection="column" borderStyle="single" borderColor="gray" paddingX={1}> + {suggestions.slice(0, 8).map((suggestion, index) => ( + <Text key={`${suggestion.kind}:${suggestion.insertText}`}> + <Text color={index === selectedIndex ? "#A78BFA" : "gray"}>{index === selectedIndex ? "›" : " "}</Text> + <Text color={COLORS[suggestion.kind]}> {suggestion.kind.padEnd(6)}</Text> + <Text> {suggestion.label.slice(0, 28).padEnd(28)}</Text> + <Text dimColor> {suggestion.detail ?? ""}</Text> + </Text> + ))} + <Text dimColor>tab inserts selected reference</Text> + </Box> + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/ModelStatus.tsx b/apps/ade-cli/src/tuiClient/components/ModelStatus.tsx new file mode 100644 index 000000000..5affc2a49 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/ModelStatus.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { AdeCodeProvider } from "../types"; +import { theme } from "../theme"; + +const BAR_CELLS = 10; + +function meterColor(percent: number): string { + if (percent >= 95) return theme.color.danger; + if (percent >= 80) return theme.color.warning; + return theme.color.accent; +} + +function ContextMeter({ percent, summary }: { percent: number; summary: string | null }) { + const filled = Math.max(0, Math.min(BAR_CELLS, Math.round((percent / 100) * BAR_CELLS))); + const empty = BAR_CELLS - filled; + const color = meterColor(percent); + return ( + <Text> + <Text dimColor>{percent}% </Text> + <Text color={color}>{"█".repeat(filled)}</Text> + <Text color={theme.color.border} dimColor>{"░".repeat(empty)}</Text> + {summary ? <Text dimColor>{` · ${summary}`}</Text> : null} + </Text> + ); +} + +export function ModelStatus({ + provider, + displayName, + reasoningEffort, + permissionLabel, + fastMode, + draftChatActive, + contextPercent, + tokenSummary, +}: { + provider: AdeCodeProvider; + displayName: string; + reasoningEffort: string | null; + permissionLabel: string; + fastMode?: boolean; + draftChatActive?: boolean; + contextPercent?: number | null; + tokenSummary?: string | null; +}) { + const brand = theme.provider(provider); + return ( + <Box paddingX={1} flexShrink={0} flexDirection="row" justifyContent="space-between"> + <Text wrap="truncate-end"> + <Text color={brand.color}>{brand.glyph} {brand.label}</Text> + <Text dimColor> · </Text> + <Text color={theme.color.fg}>{displayName}</Text> + <Text dimColor> · </Text> + <Text dimColor>{reasoningEffort ?? "no reasoning"}</Text> + <Text dimColor> · </Text> + <Text dimColor>{permissionLabel}</Text> + {fastMode ? ( + <> + <Text dimColor> · </Text> + <Text color={theme.color.warning}>fast</Text> + </> + ) : null} + {draftChatActive ? ( + <> + <Text dimColor> · </Text> + <Text color={theme.color.accent}>next chat</Text> + </> + ) : null} + </Text> + {contextPercent != null ? ( + <ContextMeter percent={contextPercent} summary={tokenSummary ?? null} /> + ) : null} + </Box> + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx new file mode 100644 index 000000000..827e2eb06 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -0,0 +1,303 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { ProviderReadinessRow, RightPaneContent } from "../types"; +import { theme } from "../theme"; + +const STATUS_DOT: Record<ProviderReadinessRow["status"], string> = { + ready: "●", + unknown: "◐", + unavailable: "○", +}; + +export const LANE_DETAIL_ACTIONS: ReadonlyArray<{ label: string; slashCommand: string }> = [ + { label: "stage all", slashCommand: "/stage all" }, + { label: "commit", slashCommand: "/commit" }, + { label: "push", slashCommand: "/push" }, + { label: "pull", slashCommand: "/pull" }, +]; + +function statusColor(status: ProviderReadinessRow["status"]): string { + if (status === "ready") return theme.color.success; + if (status === "unknown") return theme.color.warning; + return theme.color.mutedFg; +} + +function tailTruncate(value: string, max: number): string { + if (value.length <= max) return value; + return `…${value.slice(value.length - (max - 1))}`; +} + +function HelpPane() { + return ( + <Box flexDirection="column"> + <Text bold>Help</Text> + <Text dimColor>ctrl-o opens or focuses lanes and chats</Text> + <Text dimColor>ctrl-p opens or focuses setup</Text> + <Text dimColor>shift-tab cycles pane focus</Text> + <Text dimColor>esc closes the active side pane</Text> + <Text dimColor>ctrl-c interrupts a running chat; press again to quit</Text> + <Text dimColor>/ opens commands, @ opens references, tab inserts selected</Text> + <Text dimColor>/ade status forces ADE's TUI command when a runtime owns /status</Text> + </Box> + ); +} + +export function RightPane({ + content, + formValues = {}, + activeFormField = 0, + selectedIndex = 0, + focused = false, +}: { + content: RightPaneContent; + formValues?: Record<string, string>; + activeFormField?: number; + selectedIndex?: number; + focused?: boolean; +}) { + const paneTitle = content.kind === "lane-details" ? content.lane.name.toUpperCase() : "SETUP"; + return ( + <Box width={38} flexDirection="column" borderStyle="single" borderColor={focused ? "#A78BFA" : "gray"} paddingX={1}> + <Text bold color={focused ? "#A78BFA" : undefined}>{paneTitle}{focused ? " · focused" : ""}</Text> + {content.kind === "empty" ? ( + <Text dimColor>Run /status, /diff, /model, or /help.</Text> + ) : null} + {content.kind === "help" ? <HelpPane /> : null} + {content.kind === "status" ? ( + <Box flexDirection="column"> + <Text bold>Status</Text> + {content.rows.map(([key, value]) => ( + <Text key={key}><Text dimColor>{key.padEnd(10)}</Text> {value}</Text> + ))} + </Box> + ) : null} + {content.kind === "list" ? ( + <Box flexDirection="column"> + <Text bold>{content.title}</Text> + {content.rows.length ? content.rows.map((row, index) => ( + <Text key={`${content.action?.ids[index] ?? row}:${index}`} color={content.action && index === selectedIndex ? "#A78BFA" : undefined}> + {content.action ? `${index === selectedIndex ? "›" : " "} ${row}` : row} + </Text> + )) : <Text dimColor>{content.emptyText ?? "No data."}</Text>} + {content.action && content.rows.length ? <Text dimColor>arrows move · enter opens</Text> : null} + </Box> + ) : null} + {content.kind === "details" ? ( + <Box flexDirection="column"> + <Text bold>{content.title}</Text> + <Text>{content.body}</Text> + </Box> + ) : null} + {content.kind === "diff" ? ( + <Box flexDirection="column"> + <Text bold>{content.title}</Text> + {content.files.length ? content.files.map((file) => ( + <Box key={file.path} flexDirection="column" marginBottom={1}> + <Text color="cyan">{file.path} <Text dimColor>+{file.additions ?? 0} -{file.deletions ?? 0}</Text></Text> + {file.body ? <Text dimColor>{file.body.split(/\r?\n/).slice(0, 8).join("\n")}</Text> : null} + </Box> + )) : <Text dimColor>No changes.</Text>} + </Box> + ) : null} + {content.kind === "models" ? ( + <Box flexDirection="column"> + <Text bold>Model</Text> + {content.models.map((model, index) => ( + <Text key={model.id} color={(model.modelId ?? model.id) === content.activeModelId ? "#A78BFA" : undefined}> + {index === selectedIndex ? "›" : " "} {(model.modelId ?? model.id) === content.activeModelId ? "●" : "○"} {model.displayName} + </Text> + ))} + <Text dimColor>arrows move · enter applies</Text> + </Box> + ) : null} + {content.kind === "lane-details" ? ( + <Box flexDirection="column"> + <Text dimColor>{content.lane.branchRef}</Text> + <Text> + {content.git.staged + content.git.unstaged > 0 ? "DIRTY" : "CLEAN"} ↑{content.git.ahead} ↓{content.git.behind} + </Text> + {content.git.remote ? <Text dimColor>{content.git.remote}</Text> : null} + + <Box marginTop={1} flexDirection="column"> + <Text> + <Text bold>Changes</Text> + <Text dimColor> (t to toggle)</Text> + </Text> + {content.showFiles ? ( + content.files.length ? ( + content.files.slice(0, 8).map((file) => ( + <Text key={file.path}> {file.status} {file.path.slice(0, 26)}{file.staged ? " ●" : ""}</Text> + )) + ) : ( + <Text dimColor> No changes.</Text> + ) + ) : ( + <> + <Text> {content.git.staged} staged · {content.git.unstaged} unstaged</Text> + <Text dimColor> {content.git.total} files total</Text> + </> + )} + </Box> + + <Box marginTop={1} flexDirection="column"> + <Text bold>Actions</Text> + {LANE_DETAIL_ACTIONS.map((action, index) => ( + <Text key={action.label} color={index === content.selectedActionIndex ? "#A78BFA" : undefined}> + {index === content.selectedActionIndex ? "›" : " "} {action.label} + </Text> + ))} + </Box> + + {content.pr ? ( + <Box marginTop={1} flexDirection="column"> + <Text bold>Pull request</Text> + <Text color={content.selectedActionIndex === LANE_DETAIL_ACTIONS.length ? "#A78BFA" : undefined}> + {content.selectedActionIndex === LANE_DETAIL_ACTIONS.length ? "›" : " "} #{content.pr.number} {content.pr.state} {content.pr.checksPassed}/{content.pr.checksTotal} ✓ + </Text> + </Box> + ) : null} + </Box> + ) : null} + {content.kind === "effort" ? ( + <Box flexDirection="column"> + <Text bold>Effort</Text> + {content.efforts.map((effort, index) => ( + <Text key={effort} color={effort === content.activeEffort ? "#A78BFA" : undefined}> + {index === selectedIndex ? "›" : " "} {effort === content.activeEffort ? "●" : "○"} {effort} + </Text> + ))} + <Text dimColor>arrows move · enter applies</Text> + </Box> + ) : null} + {content.kind === "new-chat-setup" ? ( + <Box flexDirection="column"> + <Text bold>New chat</Text> + <Text dimColor>Lane: {content.laneLabel}</Text> + <Box flexDirection="column" marginTop={1}> + {content.rows.map((row, index) => ( + <Box key={`${row.kind}:${row.label}`} flexDirection="column"> + <Text color={index === selectedIndex ? "#A78BFA" : row.disabled ? "gray" : undefined}> + {index === selectedIndex ? "›" : " "} {row.label}: {row.value} + </Text> + {index === selectedIndex && row.detail ? <Text dimColor> {row.detail}</Text> : null} + </Box> + ))} + </Box> + <Text dimColor>up/down rows · left/right change · enter activates</Text> + </Box> + ) : null} + {content.kind === "model-setup" ? ( + <Box flexDirection="column"> + <Box marginTop={1}> + <Text bold color={theme.color.accent}>MODEL</Text> + </Box> + {content.rows.filter((row) => row.cyclable === true).map((row) => { + const index = content.rows.indexOf(row); + const selected = index === selectedIndex; + const labelColor = selected ? theme.color.accent : row.disabled ? "gray" : undefined; + const isProviderRow = row.kind === "provider"; + const valueColor = isProviderRow + ? theme.provider(content.activeProvider).color + : row.disabled + ? "gray" + : undefined; + const rightHint = row.disabled ? null : "‹ ›"; + const cursorGlyph = selected ? "›" : " "; + const paddedLabel = row.label.padEnd(12, " "); + return ( + <Box key={`${row.kind}:${row.label}`} flexDirection="column"> + <Box justifyContent="space-between"> + <Text> + <Text color={labelColor} bold={selected}>{cursorGlyph} {paddedLabel}</Text> + <Text color={valueColor} bold={isProviderRow}> + {isProviderRow ? `${theme.provider(content.activeProvider).glyph} ` : ""}{row.value} + </Text> + </Text> + {rightHint ? ( + <Text color={selected ? theme.color.accent : theme.color.mutedFg} dimColor={!selected}> + {rightHint} + </Text> + ) : null} + </Box> + {selected && row.detail ? <Text dimColor> {row.detail}</Text> : null} + </Box> + ); + })} + <Box flexDirection="column" marginTop={1}> + {content.rows.filter((row) => row.cyclable !== true).map((row) => { + const index = content.rows.indexOf(row); + const selected = index === selectedIndex; + const glyph = row.kind === "refresh-status" ? "↻" : row.kind === "open-settings" ? "↗" : "→"; + const labelColor = selected ? theme.color.accent : row.disabled ? "gray" : undefined; + const valueColor = row.disabled ? "gray" : theme.color.mutedFg; + const cursorGlyph = selected ? "›" : " "; + const showRunValue = row.kind !== "refresh-status"; + return ( + <Box key={`${row.kind}:${row.label}`} flexDirection="column"> + <Box justifyContent="space-between"> + <Text> + <Text color={labelColor} bold={selected}>{cursorGlyph} {glyph} {row.label}</Text> + {showRunValue ? <Text color={valueColor}> {row.value}</Text> : null} + </Text> + {row.disabled ? null : ( + <Text color={selected ? theme.color.accent : theme.color.mutedFg} dimColor={!selected}>↵</Text> + )} + </Box> + {selected && row.detail ? <Text dimColor> {row.detail}</Text> : null} + </Box> + ); + })} + </Box> + <Box flexDirection="column" marginTop={1}> + <Text bold color={theme.color.accent}>PROVIDERS</Text> + {content.providerRows.map((row, providerIdx) => { + const absoluteIndex = content.rows.length + providerIdx; + const providerSelected = absoluteIndex === selectedIndex; + const brand = theme.provider(row.provider); + const isActive = row.provider === content.activeProvider; + const cursorGlyph = providerSelected ? "›" : " "; + return ( + <Box key={row.provider} flexDirection="column"> + <Box justifyContent="space-between"> + <Text> + <Text color={providerSelected ? theme.color.accent : undefined} bold={providerSelected}>{cursorGlyph} </Text> + <Text color={brand.color} bold={isActive || providerSelected}>{brand.glyph} {row.label}</Text> + {isActive ? <Text dimColor> active</Text> : null} + </Text> + <Text color={statusColor(row.status)}>{STATUS_DOT[row.status]}</Text> + </Box> + {providerSelected ? ( + <Box flexDirection="column"> + <Text dimColor> {row.modelCount} models</Text> + <Text dimColor> {row.status === "ready" ? tailTruncate(row.detail, 30) : row.detail}</Text> + </Box> + ) : null} + </Box> + ); + })} + </Box> + <Box marginTop={1}> + <Text dimColor> + ↑↓ ←→ enter{content.checkedAt ? ` · ${content.checkedAt.slice(11, 19)}` : ""} + </Text> + </Box> + </Box> + ) : null} + {content.kind === "form" ? ( + <Box flexDirection="column"> + <Text bold>{content.title}</Text> + {content.fields.map((field, index) => { + const value = formValues[field.name]?.trim(); + return ( + <Text key={field.name} color={index === activeFormField ? "#A78BFA" : undefined}> + {index === activeFormField ? "›" : " "} {field.label} + {field.required ? " *" : ""}: {value || field.placeholder || ""} + </Text> + ); + })} + <Text dimColor>arrows move fields · enter submits · esc cancels</Text> + </Box> + ) : null} + </Box> + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx new file mode 100644 index 000000000..9f757f5f6 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { AgentChatSlashCommand } from "../../../../desktop/src/shared/types/chat"; +import { paletteCommands } from "../commands"; + +const VISIBLE_ROWS = 9; + +export function SlashPalette({ + query, + userCommands, + selectedIndex, +}: { + query: string; + userCommands: AgentChatSlashCommand[]; + selectedIndex: number; +}) { + const rows = paletteCommands(query, userCommands); + if (!query.startsWith("/") || !rows.length) return null; + const total = rows.length; + const safeIndex = Math.max(0, Math.min(selectedIndex, total - 1)); + const half = Math.floor(VISIBLE_ROWS / 2); + let start = Math.max(0, safeIndex - half); + let end = Math.min(total, start + VISIBLE_ROWS); + start = Math.max(0, end - VISIBLE_ROWS); + const window = rows.slice(start, end); + const aboveCount = start; + const belowCount = total - end; + return ( + <Box flexDirection="column" borderStyle="single" borderColor="gray" paddingX={1}> + {aboveCount ? <Text dimColor>↑ {aboveCount} more</Text> : null} + {window.map((row, index) => { + const absoluteIndex = start + index; + const selected = absoluteIndex === safeIndex; + return ( + <Text key={`${row.source}:${row.name}`}> + <Text color={selected ? "#A78BFA" : "gray"}>{selected ? "›" : " "}</Text> + <Text color={row.source === "user" ? "#A78BFA" : "gray"}>{row.source}</Text> + <Text> {row.name.padEnd(16)} </Text> + <Text dimColor>{row.description}</Text> + </Text> + ); + })} + {belowCount ? <Text dimColor>↓ {belowCount} more</Text> : null} + </Box> + ); +} diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts new file mode 100644 index 000000000..f1a996838 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -0,0 +1,535 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { resolveAdeLayout } from "../../../desktop/src/shared/adeLayout"; +import { resolveMachineAdeLayout } from "../services/projects/machineLayout"; +import { JsonRpcClient } from "./jsonRpcClient"; +import type { AdeCodeConnection, ProjectLaunchContext } from "./types"; +import type { AgentChatEventEnvelope } from "../../../desktop/src/shared/types/chat"; + +type RpcResponseEnvelope<T> = + | T + | { + ok: false; + error: { message?: string }; + }; + +type AdeRpcRequest = <T>(method: string, params?: unknown) => Promise<T>; + +type AdeActionHelpers = Pick< + AdeCodeConnection, + "tool" | "action" | "actionList" +>; + +type InitializeResult = { + runtimeInfo?: { + multiProject?: boolean; + }; + capabilities?: { + projects?: boolean; + }; +}; + +type ProjectRecord = { + projectId: string; +}; + +type EmbeddedRuntime = { + dispose: () => void; + agentChatService?: { + subscribeToEvents?: ( + callback: (event: AgentChatEventEnvelope) => void, + ) => () => void; + }; +}; + +type DirectHandler = { + (message: unknown): Promise<unknown>; + dispose: () => void; +}; + +type CreateEmbeddedRuntime = (args: { + projectRoot: string; + workspaceRoot: string; + chatRuntime: "agent"; + runtimeProfile: "chat"; +}) => Promise<EmbeddedRuntime>; + +type CreateEmbeddedRpcRequestHandler = (args: { + runtime: EmbeddedRuntime; + serverVersion: string; +}) => DirectHandler; + +const MULTI_PROJECT_RUNTIME_METHODS = new Set([ + "ade/initialize", + "ade/initialized", + "ping", + "shutdown", + "exit", + "runtime/info", + "machineInfo.get", + "projects.list", + "projects.add", + "projects.remove", + "projects.touch", + "projects.browseDirectories", + "projects.getDetail", + "projects.getDefaultParentDir", + "projects.create", + "projects.clone", + "projects.listMyGitHubRepos", +]); + +async function importRuntimeModule<T>(specifier: string): Promise<T> { + return (await import(specifier)) as T; +} + +function resolveBuiltRuntimeModules(): { + bootstrap: string; + rpc: string; +} | null { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + { + bootstrap: path.join(moduleDir, "bootstrap.cjs"), + rpc: path.join(moduleDir, "adeRpcServer.cjs"), + }, + { + bootstrap: path.join(moduleDir, "..", "bootstrap.cjs"), + rpc: path.join(moduleDir, "..", "adeRpcServer.cjs"), + }, + ]; + for (const candidate of candidates) { + if (!fs.existsSync(candidate.bootstrap) || !fs.existsSync(candidate.rpc)) { + continue; + } + return { + bootstrap: pathToFileURL(candidate.bootstrap).href, + rpc: pathToFileURL(candidate.rpc).href, + }; + } + return null; +} + +async function loadEmbeddedAdeCli(): Promise<{ + createAdeRuntime: (args: { + projectRoot: string; + workspaceRoot: string; + chatRuntime: "agent"; + runtimeProfile: "chat"; + }) => Promise<EmbeddedRuntime>; + createAdeRpcRequestHandler: CreateEmbeddedRpcRequestHandler; +}> { + const builtModules = resolveBuiltRuntimeModules(); + const [bootstrap, rpc] = await Promise.all([ + importRuntimeModule<typeof import("../bootstrap")>( + builtModules?.bootstrap ?? "../bootstrap", + ), + importRuntimeModule<typeof import("../adeRpcServer")>( + builtModules?.rpc ?? "../adeRpcServer", + ), + ]); + return { + createAdeRuntime: + bootstrap.createAdeRuntime as unknown as CreateEmbeddedRuntime, + createAdeRpcRequestHandler: + rpc.createAdeRpcRequestHandler as unknown as CreateEmbeddedRpcRequestHandler, + }; +} + +function failedEnvelopeMessage(payload: unknown): string | null { + if ( + !payload || + typeof payload !== "object" || + !("ok" in payload) || + (payload as { ok?: unknown }).ok !== false + ) { + return null; + } + const error = (payload as { error?: { message?: string } }).error; + return typeof error?.message === "string" ? error.message : ""; +} + +function unwrapActionResult<T>( + payload: RpcResponseEnvelope<unknown>, + domain: string, + action: string, +): T { + const errorMessage = failedEnvelopeMessage(payload); + if (errorMessage !== null) { + throw new Error(errorMessage || `ADE action failed: ${domain}.${action}`); + } + return (payload as { result?: unknown }).result as T; +} + +function createAdeActionHelpers(request: AdeRpcRequest): AdeActionHelpers { + return { + tool: async <T>( + name: string, + toolArgs?: Record<string, unknown>, + ): Promise<T> => { + const payload = await request<unknown>("ade/actions/call", { + name, + arguments: toolArgs ?? {}, + }); + const errorMessage = failedEnvelopeMessage(payload); + if (errorMessage !== null) { + throw new Error(errorMessage || `ADE tool failed: ${name}`); + } + return payload as T; + }, + action: async <T>( + domain: string, + action: string, + actionArgs?: Record<string, unknown>, + ): Promise<T> => { + const payload = await request<unknown>("ade/actions/call", { + name: "run_ade_action", + arguments: { domain, action, args: actionArgs ?? {} }, + }); + return unwrapActionResult<T>(payload, domain, action); + }, + actionList: async <T>( + domain: string, + action: string, + argsList: unknown[], + ): Promise<T> => { + const payload = await request<unknown>("ade/actions/call", { + name: "run_ade_action", + arguments: { domain, action, argsList }, + }); + return unwrapActionResult<T>(payload, domain, action); + }, + }; +} + +async function initialize(request: AdeRpcRequest): Promise<InitializeResult> { + const result = await request<InitializeResult>("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "ade-code", + identity: { + role: "cto", + callerId: `ade-code:${process.pid}`, + }, + }); + await request("ade/initialized"); + return result; +} + +async function withTimeout<T>( + promise: Promise<T>, + timeoutMs: number, + message: string, +): Promise<T> { + let timer: NodeJS.Timeout | null = null; + try { + return await Promise.race([ + promise, + new Promise<T>((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + timer.unref(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isMultiProjectRuntime(result: InitializeResult): boolean { + return ( + result.runtimeInfo?.multiProject === true || + result.capabilities?.projects === true + ); +} + +function withProjectId( + method: string, + params: unknown, + projectId: string, +): unknown { + if (MULTI_PROJECT_RUNTIME_METHODS.has(method)) return params; + if (isRecord(params)) { + const existing = + typeof params.projectId === "string" && params.projectId.trim().length > 0 + ? params.projectId.trim() + : null; + return existing ? params : { ...params, projectId }; + } + return { projectId }; +} + +function resolveCliEntrypoint(): string | null { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.join(moduleDir, "..", "cli.cjs"), + path.join(moduleDir, "..", "cli.js"), + path.join(moduleDir, "..", "cli.mjs"), + process.argv[1], + ].filter( + (candidate): candidate is string => + typeof candidate === "string" && candidate.trim().length > 0, + ); + for (const candidate of candidates) { + try { + const resolved = path.resolve(candidate); + if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) + return resolved; + } catch { + // Try the next candidate. + } + } + return null; +} + +function spawnDaemon(socketPath: string): boolean { + const cliEntrypoint = resolveCliEntrypoint(); + const daemonArgs = cliEntrypoint + ? [cliEntrypoint, "serve", "--socket", socketPath] + : ["serve", "--socket", socketPath]; + const child = spawn( + process.execPath, + daemonArgs, + { + detached: true, + stdio: "ignore", + env: { + ...process.env, + ADE_RPC_SOCKET_PATH: socketPath, + }, + }, + ); + child.unref(); + return true; +} + +async function connectAttachedSocket(args: { + socketPath: string; + project: ProjectLaunchContext; +}): Promise<AdeCodeConnection> { + let client: JsonRpcClient | null = await JsonRpcClient.connect( + args.socketPath, + ); + try { + const connectedClient = client; + const rawRequest: AdeRpcRequest = <T>(method: string, params?: unknown) => + connectedClient.request<T>(method, params); + const initializeResult = await withTimeout( + initialize(rawRequest), + 3000, + "ADE RPC socket did not finish initialization.", + ); + let request = rawRequest; + if (isMultiProjectRuntime(initializeResult)) { + const project = await rawRequest<ProjectRecord>("projects.add", { + rootPath: args.project.projectRoot, + }); + const projectId = + typeof project.projectId === "string" && + project.projectId.trim().length > 0 + ? project.projectId.trim() + : null; + if (!projectId) { + throw new Error( + "ADE daemon did not return a projectId for this project.", + ); + } + request = <T>(method: string, params?: unknown) => + rawRequest<T>(method, withProjectId(method, params, projectId)); + } + const attachedClient = connectedClient; + client = null; + return { + mode: "attached", + projectRoot: args.project.projectRoot, + workspaceRoot: args.project.workspaceRoot, + socketPath: args.socketPath, + request, + ...createAdeActionHelpers(request), + onChatEvent: (callback: (event: AgentChatEventEnvelope) => void) => + attachedClient.onNotification("chat/event", (params) => + callback(params as AgentChatEventEnvelope), + ), + close: async () => attachedClient.close(), + }; + } catch (error) { + client?.close(); + throw error; + } +} + +async function connectAttachedSocketWithRetry(args: { + socketPath: string; + project: ProjectLaunchContext; + attempts: number; + delayMs: number; +}): Promise<AdeCodeConnection> { + let lastError: unknown = null; + for (let attempt = 0; attempt < Math.max(1, args.attempts); attempt += 1) { + try { + return await connectAttachedSocket({ + socketPath: args.socketPath, + project: args.project, + }); + } catch (error) { + lastError = error; + if (attempt + 1 >= args.attempts) break; + await new Promise((resolve) => setTimeout(resolve, args.delayMs)); + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + +export async function connectToAde(args: { + project: ProjectLaunchContext; + forceEmbedded?: boolean; + requireSocket?: boolean; + socketPath?: string | null; +}): Promise<AdeCodeConnection> { + const layout = resolveAdeLayout(args.project.projectRoot); + const explicitSocketPath = + args.socketPath?.trim() || process.env.ADE_RPC_SOCKET_PATH?.trim() || null; + const machineSocketPath = resolveMachineAdeLayout().socketPath; + const socketPath = explicitSocketPath ?? machineSocketPath; + + if (args.forceEmbedded && args.requireSocket) { + throw new Error("Cannot use embedded mode when an ADE socket is required."); + } + + if (!args.forceEmbedded && explicitSocketPath) { + try { + return await connectAttachedSocketWithRetry({ + socketPath: explicitSocketPath, + project: args.project, + attempts: 1, + delayMs: 0, + }); + } catch (error) { + const message = errorMessage(error); + if (args.requireSocket) { + throw new Error( + `ADE RPC socket is required but unavailable at ${explicitSocketPath}: ${message}`, + ); + } + throw new Error( + `ADE RPC socket is unavailable at ${explicitSocketPath}: ${message}. ` + + "Start ade serve or run ade code --embedded to use the legacy embedded fallback.", + ); + } + } + + let attachError: unknown = null; + if (!args.forceEmbedded && !explicitSocketPath) { + const tryDaemon = async (attempts: number): Promise<AdeCodeConnection> => + connectAttachedSocketWithRetry({ + socketPath: machineSocketPath, + project: args.project, + attempts, + delayMs: 200, + }); + try { + if (!fs.existsSync(machineSocketPath)) { + const spawned = spawnDaemon(machineSocketPath); + return await tryDaemon(spawned ? 25 : 1); + } + return await tryDaemon(1); + } catch (firstError) { + try { + const spawned = spawnDaemon(machineSocketPath); + if (spawned) return await tryDaemon(25); + } catch (error) { + attachError = error; + } + const projectSocketPath = layout.socketPath; + if ( + projectSocketPath && + (args.requireSocket || fs.existsSync(projectSocketPath)) + ) { + try { + return await connectAttachedSocketWithRetry({ + socketPath: projectSocketPath, + project: args.project, + attempts: 1, + delayMs: 0, + }); + } catch (projectError) { + if (args.requireSocket) { + throw new Error( + `ADE RPC socket is required but unavailable at ${projectSocketPath}: ${errorMessage(projectError)}`, + ); + } + attachError = projectError; + } + } + if (args.requireSocket) { + throw new Error( + `ADE RPC socket is required but unavailable at ${machineSocketPath}: ${errorMessage(firstError)}`, + ); + } + attachError ??= firstError; + } + } + + if (!args.forceEmbedded) { + const message = + attachError instanceof Error ? ` Last error: ${attachError.message}` : ""; + throw new Error( + `Unable to attach to the ADE service at ${socketPath}.${message} ` + + "Start ade serve or run ade code --embedded to use the legacy embedded fallback.", + ); + } + + const { createAdeRuntime, createAdeRpcRequestHandler } = + await loadEmbeddedAdeCli(); + const runtime = await createAdeRuntime({ + projectRoot: args.project.projectRoot, + workspaceRoot: args.project.workspaceRoot, + chatRuntime: "agent", + runtimeProfile: "chat", + }); + const handler: DirectHandler = createAdeRpcRequestHandler({ + runtime, + serverVersion: "ade-code", + }); + let nextRequestId = 1; + const request: AdeRpcRequest = async <T>( + method: string, + params?: unknown, + ): Promise<T> => { + return (await handler({ + jsonrpc: "2.0", + id: nextRequestId++, + method, + params, + })) as T; + }; + await initialize(request); + const chatEvents = + typeof runtime.agentChatService?.subscribeToEvents === "function" + ? runtime.agentChatService.subscribeToEvents.bind( + runtime.agentChatService, + ) + : () => () => {}; + + return { + mode: "embedded", + projectRoot: args.project.projectRoot, + workspaceRoot: args.project.workspaceRoot, + socketPath: null, + request, + ...createAdeActionHelpers(request), + onChatEvent: (callback) => chatEvents(callback), + close: async () => { + handler.dispose(); + runtime.dispose(); + }, + }; +} diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts new file mode 100644 index 000000000..bb75ba01a --- /dev/null +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -0,0 +1,428 @@ +import path from "node:path"; +import { getModelById } from "../../../desktop/src/shared/modelRegistry"; +import type { AgentChatEventEnvelope, AgentChatProvider, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { LocalNotice } from "./types"; + +function timeLabel(value: string): string { + const d = new Date(value); + if (Number.isNaN(d.getTime())) return "--:--"; + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function singleLine(value: unknown, max = 96): string { + const text = (() => { + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } + })(); + return (text ?? "") + .replace(/\s+/g, " ") + .trim() + .slice(0, max); +} + +function summarizeCommandOutput(output: unknown): string { + const text = singleLine(output, 160); + const passed = /\b(\d+)\s+passed\b/i.exec(text)?.[1]; + const failed = /\b(\d+)\s+failed\b/i.exec(text)?.[1]; + if (passed || failed) { + return [ + passed ? `${passed} passed` : null, + failed ? `${failed} failed` : null, + ].filter(Boolean).join(" · "); + } + return text; +} + +export function compactPath(value: string, max = 42): string { + if (value.length <= max) return value; + const base = path.basename(value); + if (base.length + 3 >= max) return `...${base.slice(-(max - 3))}`; + return `.../${base}`; +} + +export type RenderedChatLine = { + id: string; + tone: "user" | "assistant" | "tool" | "error" | "notice" | "reasoning" | "approval"; + header?: string; + body: string; + blocks?: AssistantMarkdownBlock[]; +}; + +type TimelineEntry = + | { kind: "notice"; timestamp: string; index: number; notice: LocalNotice } + | { kind: "event"; timestamp: string; index: number; envelope: AgentChatEventEnvelope }; + +export type AssistantMarkdownBlock = + | { kind: "paragraph"; text: string } + | { kind: "heading"; level: number; text: string } + | { kind: "bullet"; text: string } + | { kind: "numbered"; number: string; text: string } + | { kind: "quote"; text: string } + | { kind: "code"; language?: string; lines: string[] } + | { kind: "hr" }; + +export function chatEventLineId(envelope: AgentChatEventEnvelope, index = 0): string { + return `${envelope.sequence ?? index}:${envelope.event.type}:${envelope.timestamp}`; +} + +function isFailedExpandableEvent(envelope: AgentChatEventEnvelope): boolean { + const event = envelope.event; + if (event.type === "tool_result") return event.status === "failed"; + if (event.type === "file_change") return event.status === "failed"; + if (event.type === "command") return event.status === "failed" || (event.exitCode ?? 0) !== 0; + return false; +} + +function providerEventLabel(provider: AgentChatProvider | null | undefined): string { + if (provider === "claude") return "Claude"; + if (provider === "codex") return "Codex"; + if (provider === "opencode") return "OpenCode"; + if (provider === "cursor") return "Cursor"; + if (provider === "droid") return "Droid"; + return "ADE"; +} + +function stripTerminalCodes(value: string): string { + return value + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\[[0-9;]*m\]?/g, "") + .trim(); +} + +function sessionModelLabel(session: AgentChatSessionSummary | null): string { + const descriptor = session?.modelId ? getModelById(session.modelId) : undefined; + if (descriptor) return descriptor.displayName; + return stripTerminalCodes(session?.model ?? "") || "model"; +} + +function multiLine(value: unknown, maxLines = 18): string { + if (typeof value === "string") return value.split(/\r?\n/).slice(0, maxLines).join("\n"); + return renderObject(value, maxLines); +} + +function isMarkdownBoundary(line: string): boolean { + const trimmed = line.trim(); + return ( + trimmed.length === 0 + || /^```/.test(trimmed) + || /^#{1,6}\s+/.test(trimmed) + || /^>\s?/.test(trimmed) + || /^[-*+]\s+/.test(trimmed) + || /^\d+[.)]\s+/.test(trimmed) + || /^([-*_])(?:\s*\1){2,}\s*$/.test(trimmed) + ); +} + +export function parseAssistantMarkdown(text: string): AssistantMarkdownBlock[] { + const sourceLines = text.replace(/\r\n/g, "\n").split("\n"); + const blocks: AssistantMarkdownBlock[] = []; + let paragraph: string[] = []; + + const flushParagraph = () => { + const value = paragraph.join(" ").replace(/\s+/g, " ").trim(); + if (value.length) blocks.push({ kind: "paragraph", text: value }); + paragraph = []; + }; + + for (let index = 0; index < sourceLines.length; index += 1) { + const line = sourceLines[index] ?? ""; + const trimmed = line.trim(); + + if (!trimmed.length) { + flushParagraph(); + continue; + } + + const fence = /^```([\w.+-]*)\s*$/.exec(trimmed); + if (fence) { + flushParagraph(); + const codeLines: string[] = []; + const language = fence[1]?.trim() || undefined; + index += 1; + for (; index < sourceLines.length; index += 1) { + const codeLine = sourceLines[index] ?? ""; + if (/^```\s*$/.test(codeLine.trim())) break; + codeLines.push(codeLine.replace(/\s+$/g, "")); + } + blocks.push({ kind: "code", ...(language ? { language } : {}), lines: codeLines }); + continue; + } + + const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed); + if (heading) { + flushParagraph(); + blocks.push({ kind: "heading", level: heading[1]?.length ?? 1, text: heading[2]?.trim() ?? "" }); + continue; + } + + if (/^([-*_])(?:\s*\1){2,}\s*$/.test(trimmed)) { + flushParagraph(); + blocks.push({ kind: "hr" }); + continue; + } + + const quote = /^>\s?(.*)$/.exec(trimmed); + if (quote) { + flushParagraph(); + blocks.push({ kind: "quote", text: quote[1]?.trim() ?? "" }); + continue; + } + + const bullet = /^[-*+]\s+(.+)$/.exec(trimmed); + if (bullet) { + flushParagraph(); + blocks.push({ kind: "bullet", text: bullet[1]?.trim() ?? "" }); + continue; + } + + const numbered = /^(\d+)[.)]\s+(.+)$/.exec(trimmed); + if (numbered) { + flushParagraph(); + blocks.push({ kind: "numbered", number: numbered[1] ?? "1", text: numbered[2]?.trim() ?? "" }); + continue; + } + + if (paragraph.length && isMarkdownBoundary(sourceLines[index - 1] ?? "")) { + flushParagraph(); + } + paragraph.push(trimmed); + } + + flushParagraph(); + if (!blocks.length && text.trim().length) { + blocks.push({ kind: "paragraph", text: text.trim() }); + } + return blocks; +} + +export function latestExpandableFailureId(events: AgentChatEventEnvelope[]): string | null { + for (let index = events.length - 1; index >= 0; index -= 1) { + const envelope = events[index]!; + if (isFailedExpandableEvent(envelope)) return chatEventLineId(envelope, index); + } + return null; +} + +export function renderChatLines(args: { + events: AgentChatEventEnvelope[]; + notices: LocalNotice[]; + activeSession: AgentChatSessionSummary | null; + expandedLineIds?: Set<string>; + maxLines?: number; +}): RenderedChatLine[] { + const lines: RenderedChatLine[] = []; + const timeline: TimelineEntry[] = [ + ...args.events.map((envelope, index): TimelineEntry => ({ + kind: "event", + timestamp: envelope.timestamp, + index, + envelope, + })), + ...args.notices.map((notice, index): TimelineEntry => ({ + kind: "notice", + timestamp: notice.timestamp, + index, + notice, + })), + ].sort((a, b) => { + const aTime = new Date(a.timestamp).getTime(); + const bTime = new Date(b.timestamp).getTime(); + const safeATime = Number.isNaN(aTime) ? 0 : aTime; + const safeBTime = Number.isNaN(bTime) ? 0 : bTime; + if (safeATime !== safeBTime) return safeATime - safeBTime; + if (a.kind !== b.kind) return a.kind === "event" ? -1 : 1; + return a.index - b.index; + }); + + for (const entry of timeline) { + if (entry.kind === "notice") { + const notice = entry.notice; + lines.push({ + id: notice.id, + tone: notice.tone === "error" ? "error" : "notice", + header: `ADE Code · ${timeLabel(notice.timestamp)}`, + body: notice.text, + }); + continue; + } + + const { envelope, index } = entry; + const event = envelope.event; + const id = chatEventLineId(envelope, index); + const expanded = args.expandedLineIds?.has(id) ?? false; + if (event.type === "user_message") { + lines.push({ + id, + tone: "user", + header: `you · ${timeLabel(envelope.timestamp)}`, + body: event.displayText ?? event.text, + }); + continue; + } + if (event.type === "text") { + lines.push({ + id, + tone: "assistant", + header: `${providerEventLabel(args.activeSession?.provider)} · ${timeLabel(envelope.timestamp)} · ${sessionModelLabel(args.activeSession)}`, + body: event.text, + blocks: parseAssistantMarkdown(event.text), + }); + continue; + } + if (event.type === "reasoning") { + lines.push({ + id, + tone: "reasoning", + body: `thinking ${singleLine(event.text, 120)}`, + }); + continue; + } + if (event.type === "tool_call") { + lines.push({ + id, + tone: "tool", + body: `> ${event.tool} ${singleLine(event.args, 96)}`, + }); + continue; + } + if (event.type === "tool_result") { + const failed = event.status === "failed"; + lines.push({ + id, + tone: failed ? "error" : "tool", + body: failed && expanded + ? `x ${event.tool}\n${multiLine(event.result, 18)}` + : `${failed ? "x" : "✓"} ${event.tool} ${singleLine(event.result, 120)}${failed ? " ↵ expands" : ""}`, + }); + continue; + } + if (event.type === "file_change") { + const diffLines = event.diff.split(/\r?\n/).slice(0, event.status === "failed" && expanded ? 24 : 10).join("\n"); + lines.push({ + id, + tone: event.status === "failed" ? "error" : "tool", + body: `> edit ${compactPath(event.path)} ${event.kind}${event.status === "failed" && !expanded ? " ↵ expands" : ""}\n${diffLines}`, + }); + continue; + } + if (event.type === "command") { + const failed = event.status === "failed" || (event.exitCode ?? 0) !== 0; + lines.push({ + id, + tone: failed ? "error" : "tool", + body: failed && expanded + ? `x run ${event.command} ${event.durationMs ? `${event.durationMs}ms` : ""}\n${multiLine(event.output, 24)}` + : `${failed ? "x" : "✓"} run ${event.command} ${event.durationMs ? `${event.durationMs}ms` : ""}${failed ? " ↵ expands" : ""}\n${summarizeCommandOutput(event.output)}`, + }); + continue; + } + if (event.type === "approval_request") { + const record = event as unknown as Record<string, unknown>; + const files = Array.isArray(record.files) ? record.files : []; + const additions = typeof record.totalAdditions === "number" ? record.totalAdditions : 0; + const deletions = typeof record.totalDeletions === "number" ? record.totalDeletions : 0; + lines.push({ + id, + tone: "approval", + body: `approval needed ${files.length} files +${additions} -${deletions}`, + }); + continue; + } + if (event.type === "context_compact") { + const preTokens = typeof event.preTokens === "number" ? ` · before ${event.preTokens.toLocaleString()} tokens` : ""; + lines.push({ + id, + tone: "notice", + body: `context compacted · ${event.trigger}${preTokens}`, + }); + continue; + } + if (event.type === "system_notice") { + lines.push({ + id, + tone: "notice", + header: `${providerEventLabel(args.activeSession?.provider)} · ${timeLabel(envelope.timestamp)}`, + body: singleLine((event as { message?: unknown }).message, 160), + }); + } + } + return coalesceLines(lines).slice(-(args.maxLines ?? 80)); +} + +function headerSpeakerKey(header: string | undefined): string { + if (!header) return ""; + const first = header.split("·")[0]; + return first ? first.trim() : ""; +} + +function smartConcat(prev: string, next: string): string { + if (!prev) return next; + if (!next) return prev; + if (/\s$/.test(prev) || /^\s/.test(next)) return `${prev}${next}`; + if (/\n$/.test(prev) || /^\n/.test(next)) return `${prev}${next}`; + return `${prev} ${next}`; +} + +function coalesceLines(lines: RenderedChatLine[]): RenderedChatLine[] { + const out: RenderedChatLine[] = []; + for (const line of lines) { + const last = out[out.length - 1]; + if ( + last + && line.tone === "assistant" + && last.tone === "assistant" + && headerSpeakerKey(line.header) === headerSpeakerKey(last.header) + ) { + const body = smartConcat(last.body, line.body); + out[out.length - 1] = { ...last, body, blocks: parseAssistantMarkdown(body) }; + continue; + } + out.push(line); + } + return out; +} + +export function formatLaneLabel(lane: LaneSummary | null): string { + if (!lane) return "no lane"; + const dirty = lane.status?.dirty ? "*" : ""; + const ahead = lane.status?.ahead ? ` ${lane.status.ahead}↑` : ""; + return `${lane.name}${dirty}${ahead}`; +} + +export function formatSessionLabel(session: AgentChatSessionSummary): string { + const label = (session.title ?? session.goal ?? session.summary ?? session.sessionId).trim(); + const state = session.awaitingInput ? " ?" : session.status === "active" ? " ●" : ""; + return `${label}${state}`; +} + +export function renderObject(value: unknown, maxLines = 24): string { + if (value == null) return "No data."; + if (typeof value === "string") return value; + try { + return JSON.stringify(value, null, 2).split(/\r?\n/).slice(0, maxLines).join("\n"); + } catch { + return String(value); + } +} + +export function summarizeDiffChanges(value: unknown): Array<{ path: string; additions?: number; deletions?: number; body?: string }> { + const record = value && typeof value === "object" ? value as Record<string, unknown> : {}; + const files = Array.isArray(record.files) ? record.files : Array.isArray(record.changes) ? record.changes : []; + return files + .map((entry) => { + const item = entry && typeof entry === "object" ? entry as Record<string, unknown> : {}; + const filePath = String(item.path ?? item.filePath ?? item.relativePath ?? "unknown"); + return { + path: filePath, + additions: typeof item.additions === "number" ? item.additions : undefined, + deletions: typeof item.deletions === "number" ? item.deletions : undefined, + body: typeof item.diff === "string" ? item.diff : undefined, + }; + }) + .slice(0, 20); +} diff --git a/apps/ade-cli/src/tuiClient/heartbeat.ts b/apps/ade-cli/src/tuiClient/heartbeat.ts new file mode 100644 index 000000000..23abf064d --- /dev/null +++ b/apps/ade-cli/src/tuiClient/heartbeat.ts @@ -0,0 +1,139 @@ +import fs from "node:fs"; +import path from "node:path"; + +const STALE_MS = 20_000; + +export type TuiHeartbeat = { + count: number; + stop: () => void; + readCount: () => number; +}; + +const EXIT_CODES_BY_SIGNAL: Partial<Record<NodeJS.Signals, number>> = { + SIGHUP: 129, + SIGINT: 130, + SIGTERM: 143, +}; +const EXIT_SIGNALS = Object.keys(EXIT_CODES_BY_SIGNAL) as NodeJS.Signals[]; +const activeHeartbeatCleanups = new Set<() => void>(); +const signalHandlers = new Map<NodeJS.Signals, () => void>(); +let processHandlersRegistered = false; + +function safeUnlink(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch { + // ignore + } +} + +function cleanupAndCount(dir: string, now = Date.now()): number { + let count = 0; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return 0; + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + const filePath = path.join(dir, entry.name); + try { + const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as { updatedAt?: number; pid?: number }; + const updatedAt = typeof raw.updatedAt === "number" ? raw.updatedAt : 0; + const pid = typeof raw.pid === "number" ? raw.pid : 0; + const stale = now - updatedAt > STALE_MS || (pid > 0 && pid !== process.pid && !processExists(pid)); + if (stale) { + safeUnlink(filePath); + } else { + count += 1; + } + } catch { + safeUnlink(filePath); + } + } + return count; +} + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function cleanupActiveHeartbeats(): void { + for (const cleanup of Array.from(activeHeartbeatCleanups)) { + cleanup(); + } +} + +function onProcessExit(): void { + cleanupActiveHeartbeats(); +} + +function ensureProcessHandlers(): void { + if (processHandlersRegistered) return; + process.once("exit", onProcessExit); + for (const signal of EXIT_SIGNALS) { + const handler = () => { + cleanupActiveHeartbeats(); + process.exit(EXIT_CODES_BY_SIGNAL[signal] ?? 1); + }; + signalHandlers.set(signal, handler); + process.once(signal, handler); + } + processHandlersRegistered = true; +} + +function removeProcessHandlersIfIdle(): void { + if (!processHandlersRegistered || activeHeartbeatCleanups.size > 0) return; + process.removeListener("exit", onProcessExit); + for (const [signal, handler] of signalHandlers) { + process.removeListener(signal, handler); + } + signalHandlers.clear(); + processHandlersRegistered = false; +} + +export function startTuiHeartbeat(projectRoot: string): TuiHeartbeat { + const dir = path.join(projectRoot, ".ade", "cache", "ade-code", "clients"); + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, `${process.pid}.json`); + const startedAt = new Date().toISOString(); + const write = () => { + try { + fs.writeFileSync(filePath, JSON.stringify({ + pid: process.pid, + startedAt, + updatedAt: Date.now(), + }), "utf8"); + } catch (error) { + console.error("ADE TUI heartbeat write failed", { filePath, error }); + } + }; + write(); + const timer = setInterval(() => { + write(); + cleanupAndCount(dir); + }, 5_000); + timer.unref?.(); + let stopped = false; + const stop = () => { + if (stopped) return; + stopped = true; + clearInterval(timer); + activeHeartbeatCleanups.delete(stop); + safeUnlink(filePath); + removeProcessHandlersIfIdle(); + }; + activeHeartbeatCleanups.add(stop); + ensureProcessHandlers(); + return { + count: cleanupAndCount(dir), + stop, + readCount: () => cleanupAndCount(dir), + }; +} diff --git a/apps/ade-cli/src/tuiClient/jsonRpcClient.ts b/apps/ade-cli/src/tuiClient/jsonRpcClient.ts new file mode 100644 index 000000000..25c2bf5f0 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/jsonRpcClient.ts @@ -0,0 +1,186 @@ +import net from "node:net"; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; +}; + +type JsonRpcResponse = { + jsonrpc: "2.0"; + id: number | string | null; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; + method?: string; + params?: unknown; +}; + +export class JsonRpcClient { + private nextId = 1; + private buffer = Buffer.alloc(0); + private pending = new Map<number, PendingRequest>(); + private notificationHandlers = new Map<string, Set<(params: unknown) => void>>(); + private closed = false; + + constructor(private readonly socket: net.Socket) { + socket.on("data", (chunk: Buffer | string) => this.handleData(chunk)); + socket.on("error", (error) => this.rejectAll(error)); + socket.on("close", () => { + this.closed = true; + this.rejectAll(new Error("ADE RPC socket closed.")); + }); + } + + static connect(socketPath: string): Promise<JsonRpcClient> { + return new Promise((resolve, reject) => { + let socket: net.Socket; + if (socketPath.startsWith("tcp://")) { + const parsed = new URL(socketPath); + socket = net.createConnection({ + host: parsed.hostname || "127.0.0.1", + port: Number.parseInt(parsed.port, 10), + }); + } else { + socket = net.createConnection(socketPath); + } + const cleanup = () => { + socket.off("connect", onConnect); + socket.off("error", onError); + }; + const onConnect = () => { + cleanup(); + resolve(new JsonRpcClient(socket)); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + socket.once("connect", onConnect); + socket.once("error", onError); + }); + } + + request<T = unknown>(method: string, params?: unknown): Promise<T> { + if (this.closed) return Promise.reject(new Error("ADE RPC socket is closed.")); + const id = this.nextId++; + const payload = { + jsonrpc: "2.0", + id, + method, + ...(params !== undefined ? { params } : {}), + }; + return new Promise<T>((resolve, reject) => { + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + }); + this.socket.write(`${JSON.stringify(payload)}\n`, "utf8", (error) => { + if (!error) return; + this.pending.delete(id); + reject(error); + }); + }); + } + + close(): void { + this.closed = true; + this.rejectAll(new Error("ADE RPC socket closed.")); + this.socket.end(); + this.socket.destroy(); + } + + onNotification(method: string, handler: (params: unknown) => void): () => void { + const handlers = this.notificationHandlers.get(method) ?? new Set<(params: unknown) => void>(); + handlers.add(handler); + this.notificationHandlers.set(method, handlers); + return () => { + handlers.delete(handler); + if (handlers.size === 0) this.notificationHandlers.delete(method); + }; + } + + private handleData(chunk: Buffer | string): void { + this.buffer = Buffer.concat([ + this.buffer, + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8"), + ]); + while (true) { + const next = this.takeNextPayload(); + if (!next) return; + const line = next.trim(); + if (!line) continue; + let parsed: JsonRpcResponse | JsonRpcResponse[] | null = null; + try { + parsed = JSON.parse(line) as JsonRpcResponse | JsonRpcResponse[]; + } catch { + continue; + } + const responses = Array.isArray(parsed) ? parsed : [parsed]; + for (const response of responses) this.handleResponse(response); + } + } + + private takeNextPayload(): string | null { + while (this.buffer.length && /\s/.test(String.fromCharCode(this.buffer[0]!))) { + this.buffer = this.buffer.subarray(1); + } + if (!this.buffer.length) return null; + const first = String.fromCharCode(this.buffer[0]!); + if (first === "{" || first === "[") { + const idx = this.buffer.indexOf(0x0a); + if (idx < 0) return null; + const payload = this.buffer.subarray(0, idx).toString("utf8"); + this.buffer = this.buffer.subarray(idx + 1); + return payload; + } + + const crlfBoundary = this.buffer.indexOf("\r\n\r\n"); + const lfBoundary = this.buffer.indexOf("\n\n"); + let boundary: { index: number; length: number } | null = null; + if (crlfBoundary >= 0) boundary = { index: crlfBoundary, length: 4 }; + else if (lfBoundary >= 0) boundary = { index: lfBoundary, length: 2 }; + if (!boundary) return null; + const header = this.buffer.subarray(0, boundary.index).toString("ascii"); + const match = /^content-length\s*:\s*(\d+)\s*$/im.exec(header); + if (!match) { + this.buffer = this.buffer.subarray(boundary.index + boundary.length); + return ""; + } + const length = Number.parseInt(match[1]!, 10); + const bodyStart = boundary.index + boundary.length; + const bodyEnd = bodyStart + length; + if (this.buffer.length < bodyEnd) return null; + const payload = this.buffer.subarray(bodyStart, bodyEnd).toString("utf8"); + this.buffer = this.buffer.subarray(bodyEnd); + return payload; + } + + private handleResponse(response: JsonRpcResponse): void { + if (typeof response.id !== "number") { + if (typeof response.method === "string") { + for (const handler of this.notificationHandlers.get(response.method) ?? []) { + handler(response.params); + } + } + return; + } + const pending = this.pending.get(response.id); + if (!pending) return; + this.pending.delete(response.id); + if (response.error) { + pending.reject(new Error(response.error.message)); + return; + } + pending.resolve(response.result); + } + + private rejectAll(error: Error): void { + for (const pending of this.pending.values()) { + pending.reject(error); + } + this.pending.clear(); + } +} diff --git a/apps/ade-cli/src/tuiClient/linearCommands.ts b/apps/ade-cli/src/tuiClient/linearCommands.ts new file mode 100644 index 000000000..02e5f9482 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/linearCommands.ts @@ -0,0 +1,201 @@ +export type LinearToolRequest = + | { + kind: "tool"; + title: string; + toolName: string; + args: Record<string, unknown>; + } + | { + kind: "usage"; + title: string; + body: string; + }; + +type ParsedArgs = { + positionals: string[]; + options: Record<string, unknown>; +}; + +function tokenize(input: string): string[] { + const tokens: string[] = []; + const pattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^']*)'|(\S+)/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(input)) != null) { + tokens.push(match[1]?.replace(/\\"/g, "\"") ?? match[2] ?? match[3] ?? ""); + } + return tokens; +} + +function toCamelCase(value: string): string { + return value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase()); +} + +function parseScalar(value: string): unknown { + if (value === "true") return true; + if (value === "false") return false; + if (/^-?\d+$/.test(value)) return Number(value); + return value; +} + +export function parseLinearArgs(input: string): ParsedArgs { + const positionals: string[] = []; + const options: Record<string, unknown> = {}; + const tokens = tokenize(input); + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] ?? ""; + if (token.startsWith("--")) { + const key = toCamelCase(token.slice(2)); + const next = tokens[index + 1]; + if (next && !next.startsWith("--")) { + options[key] = parseScalar(next); + index += 1; + } else { + options[key] = true; + } + } else { + positionals.push(token); + } + } + return { positionals, options }; +} + +function optionString(options: Record<string, unknown>, ...names: string[]): string | null { + for (const name of names) { + const value = options[name]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return null; +} + +function optionBoolean(options: Record<string, unknown>, name: string): boolean | undefined { + const value = options[name]; + return typeof value === "boolean" ? value : undefined; +} + +function usage(title: string, body: string): LinearToolRequest { + return { kind: "usage", title, body }; +} + +function compactArgs(args: Record<string, unknown>): Record<string, unknown> { + return Object.fromEntries(Object.entries(args).filter(([, value]) => value !== undefined)); +} + +function tool(title: string, toolName: string, args: Record<string, unknown> = {}): LinearToolRequest { + return { kind: "tool", title, toolName, args: compactArgs(args) }; +} + +export function buildLinearToolRequest(input: string): LinearToolRequest { + const parsed = parseLinearArgs(input); + const [group = "workflows", modeArg, ...rest] = parsed.positionals; + const options = parsed.options; + + if (group === "workflows") { + return tool("Linear workflows", "listLinearWorkflows"); + } + + if (group === "run") { + const mode = modeArg ?? "status"; + const runId = optionString(options, "runId", "run") ?? rest[0] ?? null; + if (mode === "status") { + if (!runId) return usage("Linear run", "Usage: /linear run status <run-id>"); + return tool("Linear run status", "getLinearRunStatus", { runId }); + } + if (mode === "resolve") { + const action = optionString(options, "action") ?? rest[1] ?? null; + if (!runId || !action) return usage("Linear run resolve", "Usage: /linear run resolve <run-id> <approve|reject|retry|resume|complete>"); + return tool("Linear run resolve", "resolveLinearRunAction", { + runId, + action, + note: optionString(options, "note") ?? undefined, + }); + } + if (mode === "cancel") { + const reason = optionString(options, "reason") ?? rest.slice(1).join(" "); + if (!runId || !reason) return usage("Linear run cancel", "Usage: /linear run cancel <run-id> --reason <reason>"); + return tool("Linear run cancel", "cancelLinearRun", { runId, reason }); + } + if (mode === "reroute") { + const target = optionString(options, "target") ?? rest[1] ?? null; + const reason = optionString(options, "reason") ?? rest.slice(2).join(" "); + if (!runId || !target || !reason) return usage("Linear run reroute", "Usage: /linear run reroute <run-id> <cto|mission|worker> --reason <reason>"); + return tool("Linear run reroute", "rerouteLinearRun", { + runId, + target, + reason, + laneId: optionString(options, "laneId", "lane") ?? undefined, + reuseExisting: optionBoolean(options, "reuseExisting"), + launch: optionBoolean(options, "launch"), + runMode: optionString(options, "runMode") ?? undefined, + agentId: optionString(options, "agentId", "agent") ?? undefined, + taskKey: optionString(options, "taskKey") ?? undefined, + }); + } + return usage("Linear run", "Usage: /linear run <status|resolve|cancel|reroute> ..."); + } + + if (group === "route") { + const mode = modeArg ?? "cto"; + const issueId = optionString(options, "issueId", "issue") ?? rest[0] ?? null; + if (!issueId) return usage("Linear route", "Usage: /linear route <cto|mission|worker> <issue-id>"); + if (mode === "cto") { + return tool("Linear route cto", "routeLinearIssueToCto", { + issueId, + laneId: optionString(options, "laneId", "lane") ?? undefined, + reuseExisting: optionBoolean(options, "reuseExisting"), + }); + } + if (mode === "mission") { + return tool("Linear route mission", "routeLinearIssueToMission", { + issueId, + laneId: optionString(options, "laneId", "lane") ?? undefined, + launch: optionBoolean(options, "launch"), + runMode: optionString(options, "runMode") ?? undefined, + }); + } + if (mode === "worker") { + const agentId = optionString(options, "agentId", "agent") ?? rest[1] ?? null; + if (!agentId) return usage("Linear route worker", "Usage: /linear route worker <issue-id> <agent-id>"); + return tool("Linear route worker", "routeLinearIssueToWorker", { + issueId, + agentId, + taskKey: optionString(options, "taskKey") ?? undefined, + }); + } + return usage("Linear route", "Usage: /linear route <cto|mission|worker> ..."); + } + + if (group === "sync") { + const mode = modeArg ?? "dashboard"; + if (mode === "dashboard") return tool("Linear sync dashboard", "getLinearSyncDashboard"); + if (mode === "run") return tool("Linear sync run", "runLinearSyncNow"); + if (mode === "queue") return tool("Linear sync queue", "listLinearSyncQueue"); + if (mode === "detail") { + const runId = optionString(options, "runId", "run") ?? rest[0] ?? null; + if (!runId) return usage("Linear sync detail", "Usage: /linear sync detail <run-id>"); + return tool("Linear sync detail", "getLinearWorkflowRunDetail", { runId }); + } + if (mode === "resolve") { + const queueItemId = optionString(options, "queueItemId", "queueItem") ?? rest[0] ?? null; + const action = optionString(options, "action") ?? rest[1] ?? null; + if (!queueItemId || !action) return usage("Linear sync resolve", "Usage: /linear sync resolve <queue-item-id> <approve|reject|retry|resume|complete>"); + return tool("Linear sync resolve", "resolveLinearSyncQueueItem", { + queueItemId, + action, + note: optionString(options, "note") ?? undefined, + employeeOverride: optionString(options, "employeeOverride") ?? undefined, + laneId: optionString(options, "laneId", "lane") ?? undefined, + }); + } + return usage("Linear sync", "Usage: /linear sync <dashboard|run|queue|resolve|detail> ..."); + } + + if (group === "ingress") { + const mode = modeArg ?? "status"; + if (mode === "status") return tool("Linear ingress status", "getLinearIngressStatus"); + if (mode === "events") return tool("Linear ingress events", "listLinearIngressEvents", { limit: options.limit ?? undefined }); + if (mode === "webhook") return tool("Linear ingress webhook", "ensureLinearWebhook", { force: optionBoolean(options, "force") }); + return usage("Linear ingress", "Usage: /linear ingress <status|events|webhook>"); + } + + return usage("Linear", "Usage: /linear <workflows|run|route|sync|ingress> ..."); +} diff --git a/apps/ade-cli/src/tuiClient/pendingInput.ts b/apps/ade-cli/src/tuiClient/pendingInput.ts new file mode 100644 index 000000000..a569665b2 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/pendingInput.ts @@ -0,0 +1,99 @@ +import type { + AgentChatEventEnvelope, + PendingInputOption, + PendingInputQuestion, + PendingInputRequest, +} from "../../../desktop/src/shared/types/chat"; +import { renderObject } from "./format"; +import type { PendingApproval } from "./types"; + +function looksHighStakesApproval(description: string, detail: unknown): boolean { + const text = `${description} ${renderObject(detail, 8)}`.toLowerCase(); + return /\b(drop|delete|destroy|force[- ]push|production|prod|schema|credential|secret|external|publish|release)\b/.test(text); +} + +function isPendingInputRequest(value: unknown): value is PendingInputRequest { + const record = value && typeof value === "object" ? value as Record<string, unknown> : null; + return Boolean( + record + && typeof record.requestId === "string" + && typeof record.kind === "string" + && Array.isArray(record.questions), + ); +} + +function requestFromApprovalEvent(event: Record<string, unknown>): PendingInputRequest | undefined { + const detail = event.detail && typeof event.detail === "object" ? event.detail as Record<string, unknown> : null; + const request = detail?.request; + return isPendingInputRequest(request) ? request : undefined; +} + +function isApprovalMode(request: PendingInputRequest | undefined): boolean { + return !request || request.kind === "approval" || request.kind === "permissions" || request.kind === "plan_approval"; +} + +export function latestPendingApproval(events: AgentChatEventEnvelope[]): PendingApproval | null { + const resolved = new Set<string>(); + for (const envelope of events) { + const event = envelope.event as Record<string, unknown>; + if (event.type === "pending_input_resolved" && typeof event.itemId === "string") { + resolved.add(event.itemId); + } + } + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]?.event as Record<string, unknown> | undefined; + if (!event || event.type !== "approval_request" || typeof event.itemId !== "string") continue; + if (resolved.has(event.itemId)) continue; + const request = requestFromApprovalEvent(event); + const description = typeof event.description === "string" ? event.description : "Approve this tool request?"; + const mode = isApprovalMode(request) ? "approval" : "question"; + return { + itemId: event.itemId, + description, + highStakes: mode === "approval" && ( + request?.kind === "permissions" + || request?.kind === "plan_approval" + || looksHighStakesApproval(description, event.detail) + ), + mode, + ...(request ? { request } : {}), + }; + } + return null; +} + +function optionMatches(input: string, option: PendingInputOption, index: number): boolean { + const normalized = input.trim().toLowerCase(); + return normalized === String(index + 1) + || normalized === option.value.toLowerCase() + || normalized === option.label.toLowerCase(); +} + +function answerForQuestion(question: PendingInputQuestion, text: string): string | string[] { + const trimmed = text.trim(); + if (!question.options?.length) return trimmed; + const values = trimmed.split(",").map((entry) => entry.trim()).filter(Boolean); + const matched = values.map((value) => { + const option = question.options?.find((candidate, index) => optionMatches(value, candidate, index)); + return option?.value ?? value; + }); + if (question.multiSelect) return matched; + return matched[0] ?? trimmed; +} + +export function buildPendingInputAnswers( + request: PendingInputRequest | undefined, + text: string, +): Record<string, string | string[]> | undefined { + const questions = request?.questions ?? []; + if (questions.length === 0) return undefined; + if (questions.length === 1) { + const question = questions[0]!; + return { [question.id]: answerForQuestion(question, text) }; + } + const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + return Object.fromEntries(questions.map((question, index) => [ + question.id, + answerForQuestion(question, lines[index] ?? text), + ])); +} diff --git a/apps/ade-cli/src/tuiClient/project.ts b/apps/ade-cli/src/tuiClient/project.ts new file mode 100644 index 000000000..f83695103 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/project.ts @@ -0,0 +1,114 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { ProjectLaunchContext } from "./types"; + +function normalizeRoot(value: string): string { + return path.resolve(value); +} + +function findGitRoot(cwd: string): string | null { + try { + const stdout = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const root = stdout.trim(); + return root ? path.resolve(root) : null; + } catch { + return null; + } +} + +function findAdeWorktreeContext(cwd: string): Pick<ProjectLaunchContext, "projectRoot" | "workspaceRoot" | "laneHint"> | null { + const resolved = path.resolve(cwd); + const parts = resolved.split(path.sep); + for (let i = parts.length - 1; i >= 0; i -= 1) { + if (parts[i] !== ".ade" || parts[i + 1] !== "worktrees" || !parts[i + 2]) continue; + const rootParts = parts.slice(0, i); + const projectRoot = rootParts.length === 0 ? path.sep : rootParts.join(path.sep); + const laneHint = parts[i + 2] ?? null; + const workspaceRoot = findGitRoot(resolved) ?? path.join(projectRoot, ".ade", "worktrees", laneHint ?? ""); + return { + projectRoot: normalizeRoot(projectRoot), + workspaceRoot: normalizeRoot(workspaceRoot), + laneHint, + }; + } + return null; +} + +export function detectProjectLaunchContext(args: { + cwd?: string; + projectRoot?: string | null; + workspaceRoot?: string | null; +} = {}): ProjectLaunchContext { + const launchCwd = normalizeRoot(args.cwd ?? process.cwd()); + const explicitProjectRoot = args.projectRoot?.trim(); + const explicitWorkspaceRoot = args.workspaceRoot?.trim(); + const worktree = findAdeWorktreeContext(launchCwd); + const gitRoot = findGitRoot(launchCwd); + + const projectRoot = normalizeRoot( + explicitProjectRoot + ?? worktree?.projectRoot + ?? gitRoot + ?? launchCwd, + ); + const workspaceRoot = normalizeRoot( + explicitWorkspaceRoot + ?? worktree?.workspaceRoot + ?? gitRoot + ?? projectRoot, + ); + + if (!fs.existsSync(projectRoot)) { + throw new Error(`Project root does not exist: ${projectRoot}`); + } + if (!fs.existsSync(workspaceRoot)) { + throw new Error(`Workspace root does not exist: ${workspaceRoot}`); + } + + return { + launchCwd, + projectRoot, + workspaceRoot, + laneHint: worktree?.laneHint ?? null, + }; +} + +export function chooseInitialLane( + lanes: LaneSummary[], + context: Pick<ProjectLaunchContext, "workspaceRoot" | "laneHint">, +): LaneSummary | null { + if (!lanes.length) return null; + const hint = context.laneHint?.trim(); + if (hint) { + const byHint = lanes.find((lane) => ( + lane.id === hint + || lane.name === hint + || lane.branchRef === hint + || path.basename(lane.worktreePath) === hint + )); + if (byHint) return byHint; + } + + const workspaceRoot = normalizeRoot(context.workspaceRoot); + const byPath = [...lanes] + .sort((left, right) => normalizeRoot(right.worktreePath).length - normalizeRoot(left.worktreePath).length) + .find((lane) => { + const worktreePath = normalizeRoot(lane.worktreePath); + const attachedRootPath = lane.attachedRootPath ? normalizeRoot(lane.attachedRootPath) : null; + return ( + workspaceRoot === worktreePath + || workspaceRoot.startsWith(`${worktreePath}${path.sep}`) + || (attachedRootPath !== null + && (workspaceRoot === attachedRootPath || workspaceRoot.startsWith(`${attachedRootPath}${path.sep}`))) + ); + }); + if (byPath) return byPath; + + return lanes.find((lane) => lane.laneType === "primary") ?? lanes[0] ?? null; +} diff --git a/apps/ade-cli/src/tuiClient/reactDevtoolsStub.ts b/apps/ade-cli/src/tuiClient/reactDevtoolsStub.ts new file mode 100644 index 000000000..c985cb2d3 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/reactDevtoolsStub.ts @@ -0,0 +1,7 @@ +const reactDevtoolsStub = { + connectToDevTools(): void { + // ADE's packaged TUI does not ship React DevTools. + }, +}; + +export default reactDevtoolsStub; diff --git a/apps/ade-cli/src/tuiClient/state.ts b/apps/ade-cli/src/tuiClient/state.ts new file mode 100644 index 000000000..c00638b0c --- /dev/null +++ b/apps/ade-cli/src/tuiClient/state.ts @@ -0,0 +1,37 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export type AdeCodeState = { + lastChatByLane: Record<string, string>; +}; + +const STATE_DIR = path.join(os.homedir(), ".ade"); +const STATE_PATH = path.join(STATE_DIR, "ade-code-state.json"); + +export function loadAdeCodeState(): AdeCodeState { + try { + const raw = fs.readFileSync(STATE_PATH, "utf8"); + const parsed = JSON.parse(raw) as Partial<AdeCodeState>; + const lastChatByLane: Record<string, string> = {}; + if (parsed && typeof parsed.lastChatByLane === "object" && parsed.lastChatByLane) { + for (const [laneId, sessionId] of Object.entries(parsed.lastChatByLane)) { + if (typeof laneId === "string" && typeof sessionId === "string") { + lastChatByLane[laneId] = sessionId; + } + } + } + return { lastChatByLane }; + } catch { + return { lastChatByLane: {} }; + } +} + +export function saveAdeCodeState(state: AdeCodeState): void { + try { + fs.mkdirSync(STATE_DIR, { recursive: true }); + fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), "utf8"); + } catch { + // best-effort persistence; ignore + } +} diff --git a/apps/ade-cli/src/tuiClient/theme.ts b/apps/ade-cli/src/tuiClient/theme.ts new file mode 100644 index 000000000..ec6227272 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/theme.ts @@ -0,0 +1,78 @@ +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { AdeCodeProvider } from "./types"; +import type { RenderedChatLine } from "./format"; + +/** + * Centralised design tokens for the ade-code TUI. + * + * Mirrors the ADE desktop renderer where it matters: accent #A78BFA (purple), + * lane.color for lane chips, and per-provider brand colors and glyphs that map + * the SVG marks used in the desktop ProviderLogos to single-cell BMP glyphs + * safe for Ink's string-width handling. + */ + +const ACCENT = "#A78BFA"; +const ACCENT_DIM = "#6D5DBF"; +const FG = "white"; +const MUTED_FG = "gray"; +const SUCCESS = "#22C55E"; +const WARNING = "#F59E0B"; +const DANGER = "#EF4444"; +const TOOL = "cyan"; +const REASONING = "gray"; +const NOTICE = "gray"; +const APPROVAL = "#F59E0B"; +const ERROR = DANGER; + +export type Tone = RenderedChatLine["tone"]; + +const TONE_COLORS: Record<Tone, string> = { + user: ACCENT, + assistant: FG, + tool: TOOL, + error: ERROR, + notice: NOTICE, + reasoning: REASONING, + approval: APPROVAL, +}; + +type ProviderTheme = { + glyph: string; + color: string; + label: string; +}; + +const PROVIDER_THEME: Record<AdeCodeProvider, ProviderTheme> = { + claude: { glyph: "◆", color: "#D97757", label: "Claude" }, + codex: { glyph: "◇", color: "#10A37F", label: "Codex" }, + cursor: { glyph: "▲", color: FG, label: "Cursor" }, + droid: { glyph: "▣", color: "#22D3EE", label: "Droid" }, + opencode: { glyph: "◈", color: ACCENT, label: "OpenCode" }, +}; + +const FALLBACK_PROVIDER: ProviderTheme = { glyph: "•", color: MUTED_FG, label: "Agent" }; + +export const theme = { + color: { + accent: ACCENT, + accentDim: ACCENT_DIM, + fg: FG, + mutedFg: MUTED_FG, + border: MUTED_FG, + borderFocused: ACCENT, + success: SUCCESS, + warning: WARNING, + danger: DANGER, + tool: TOOL, + }, + tone(tone: Tone): string { + return TONE_COLORS[tone] ?? FG; + }, + provider(provider: AdeCodeProvider | null | undefined): ProviderTheme { + if (!provider) return FALLBACK_PROVIDER; + return PROVIDER_THEME[provider] ?? FALLBACK_PROVIDER; + }, + lane(lane: LaneSummary | null | undefined): string { + return lane?.color || ACCENT; + }, +} as const; diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts new file mode 100644 index 000000000..8cca09cf6 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -0,0 +1,202 @@ +import type { AppNavigationRequest, AppNavigationResult } from "../../../desktop/src/shared/types/core"; +import type { + AgentChatClaudePermissionMode, + AgentChatCodexApprovalPolicy, + AgentChatCodexConfigSource, + AgentChatCodexSandbox, + AgentChatCursorConfigValue, + AgentChatDroidPermissionMode, + AgentChatEventEnvelope, + AgentChatInteractionMode, + AgentChatModelInfo, + AgentChatOpenCodePermissionMode, + AgentChatPermissionMode, + AgentChatProvider, + AgentChatSession, + AgentChatSessionSummary, + AgentChatSlashCommand, + PendingInputRequest, +} from "../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; + +export type RuntimeMode = "attached" | "embedded"; + +export type ProjectLaunchContext = { + launchCwd: string; + projectRoot: string; + workspaceRoot: string; + laneHint: string | null; +}; + +export type ChatHistorySnapshot = { + sessionId: string; + events: AgentChatEventEnvelope[]; + truncated: boolean; +}; + +export type RunAdeActionResult<T = unknown> = { + domain: string; + action: string; + result: T; + statusHints?: Record<string, unknown>; +}; + +export type AdeCodeConnection = { + mode: RuntimeMode; + projectRoot: string; + workspaceRoot: string; + socketPath: string | null; + fallbackReason?: string | null; + request<T = unknown>(method: string, params?: unknown): Promise<T>; + tool<T = unknown>(name: string, args?: Record<string, unknown>): Promise<T>; + action<T = unknown>(domain: string, action: string, args?: Record<string, unknown>): Promise<T>; + actionList<T = unknown>(domain: string, action: string, argsList: unknown[]): Promise<T>; + onChatEvent(callback: (event: AgentChatEventEnvelope) => void): () => void; + close(): Promise<void>; +}; + +export type AdeCodeProvider = Extract<AgentChatProvider, "codex" | "claude" | "opencode" | "cursor" | "droid">; + +export type AdeCodeModelState = { + provider: AdeCodeProvider; + model: string; + modelId: string | null; + displayName: string; + reasoningEffort: string | null; + codexFastMode: boolean; + permissionMode: AgentChatPermissionMode; + interactionMode: AgentChatInteractionMode; + claudePermissionMode: AgentChatClaudePermissionMode; + codexApprovalPolicy: AgentChatCodexApprovalPolicy; + codexSandbox: AgentChatCodexSandbox; + codexConfigSource: AgentChatCodexConfigSource; + opencodePermissionMode: AgentChatOpenCodePermissionMode; + droidPermissionMode: AgentChatDroidPermissionMode; + cursorModeId: string | null; + cursorConfigValues: Record<string, AgentChatCursorConfigValue>; +}; + +export type ProviderReadinessRow = { + provider: AdeCodeProvider; + label: string; + status: "ready" | "unavailable" | "unknown"; + detail: string; + modelCount: number; +}; + +export type SetupPaneRowKind = + | "provider" + | "model" + | "reasoning" + | "permission" + | "codex-fast" + | "refresh-status" + | "open-settings" + | "apply"; + +export type SetupPaneRow = { + kind: SetupPaneRowKind; + label: string; + value: string; + detail?: string; + disabled?: boolean; + cyclable?: boolean; +}; + +export type RightPaneContent = + | { kind: "empty" } + | { kind: "help"; title: string } + | { kind: "status"; rows: Array<[string, string]> } + | { + kind: "list"; + title: string; + rows: string[]; + emptyText?: string; + action?: { + kind: "switch-lane" | "switch-chat"; + ids: string[]; + }; + } + | { kind: "details"; title: string; body: string } + | { kind: "diff"; title: string; files: Array<{ path: string; additions?: number; deletions?: number; body?: string }> } + | { kind: "models"; models: AgentChatModelInfo[]; activeModelId: string | null } + | { kind: "effort"; efforts: string[]; activeEffort: string | null } + | { + kind: "new-chat-setup"; + laneId: string; + laneLabel: string; + rows: SetupPaneRow[]; + } + | { + kind: "model-setup"; + rows: SetupPaneRow[]; + providerRows: ProviderReadinessRow[]; + activeProvider: AdeCodeProvider; + checkedAt: string | null; + desktopAttached: boolean; + } + | { + kind: "form"; + title: string; + command: "new-lane" | "rename" | "pr-open"; + fields: Array<{ + name: string; + label: string; + required?: boolean; + placeholder?: string; + initialValue?: string; + }>; + } + | { + kind: "lane-details"; + lane: LaneSummary; + git: { staged: number; unstaged: number; total: number; ahead: number; behind: number; remote: string | null }; + files: { path: string; status: "M" | "A" | "D" | "?"; staged: boolean }[]; + pr: { number: number; state: "open" | "closed" | "merged"; url: string; checksPassed: number; checksTotal: number } | null; + showFiles: boolean; + selectedActionIndex: number; + }; + +export type LocalNotice = { + id: string; + timestamp: string; + tone: "info" | "error" | "success"; + text: string; +}; + +export type MentionSuggestion = { + kind: "lane" | "chat" | "pr" | "file" | "commit"; + label: string; + insertText: string; + detail?: string; + filePath?: string; +}; + +export type PendingApproval = { + itemId: string; + description: string; + highStakes: boolean; + mode: "approval" | "question"; + request?: PendingInputRequest; +}; + +export type ShellData = { + project: ProjectLaunchContext; + lanes: LaneSummary[]; + sessions: AgentChatSessionSummary[]; + activeLaneId: string | null; + activeSessionId: string | null; + activeSession: AgentChatSessionSummary | null; + events: AgentChatEventEnvelope[]; + notices: LocalNotice[]; + slashCommands: AgentChatSlashCommand[]; + models: AgentChatModelInfo[]; + modelState: AdeCodeModelState; + rightPane: RightPaneContent; + contextPercent: number | null; + streaming: boolean; +}; + +export type CreatedChat = AgentChatSession; +export type NavigateRequest = AppNavigationRequest; +export type NavigateResult = AppNavigationResult; diff --git a/apps/ade-cli/tsconfig.json b/apps/ade-cli/tsconfig.json index 6e74af44a..4fdc544f0 100644 --- a/apps/ade-cli/tsconfig.json +++ b/apps/ade-cli/tsconfig.json @@ -4,6 +4,7 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", + "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/apps/ade-cli/tsup.config.ts b/apps/ade-cli/tsup.config.ts index 58ed59bfc..efd367219 100644 --- a/apps/ade-cli/tsup.config.ts +++ b/apps/ade-cli/tsup.config.ts @@ -1,23 +1,83 @@ import { defineConfig } from "tsup"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; -export default defineConfig({ - entry: { - cli: "src/cli.ts" +const external = [ + "@agentclientprotocol/sdk", + "@anthropic-ai/claude-agent-sdk", + "@cursor/sdk", + "@opencode-ai/sdk", + "@wize-logic/nodejs-rfb", + "chokidar", + "node-pty", + "node:sqlite", + "sql.js", + "sqlite3", + "zod", +]; +const packageRoot = path.dirname(fileURLToPath(import.meta.url)); +const packageJson = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8")) as { version?: string }; +const version = process.env.ADE_CLI_VERSION?.trim() || packageJson.version || "0.0.0"; + +export default defineConfig([ + { + entry: { + cli: "src/cli.ts", + bootstrap: "src/bootstrap.ts", + adeRpcServer: "src/adeRpcServer.ts" + }, + format: ["cjs"], + platform: "node", + target: "node22", + outDir: "dist", + sourcemap: true, + clean: true, + outExtension: () => ({ + js: ".cjs" + }), + external, + esbuildOptions(options) { + options.define = { + ...(options.define ?? {}), + __ADE_VERSION__: JSON.stringify(version), + }; + options.alias = { + ...(options.alias ?? {}), + sqlite: "node:sqlite", + "react-devtools-core": path.join(packageRoot, "src", "tuiClient", "reactDevtoolsStub.ts"), + }; + }, }, - format: ["cjs"], - platform: "node", - target: "node22", - outDir: "dist", - sourcemap: true, - clean: true, - outExtension: () => ({ - js: ".cjs" - }), - external: ["node-pty", "sql.js", "node:sqlite", "@cursor/sdk", "sqlite3"], - esbuildOptions(options) { - options.alias = { - ...(options.alias ?? {}), - sqlite: "node:sqlite", - }; + { + entry: { + "tuiClient/cli": "src/tuiClient/cli.tsx" + }, + format: ["esm"], + platform: "node", + target: "node22", + outDir: "dist", + sourcemap: true, + clean: false, + splitting: false, + noExternal: ["ink", "ink-text-input", "react", "react/jsx-runtime"], + banner: { + js: "import { createRequire as __adeCreateRequire } from 'node:module'; const require = __adeCreateRequire(import.meta.url);", + }, + outExtension: () => ({ + js: ".mjs" + }), + external, + esbuildOptions(options) { + options.define = { + ...(options.define ?? {}), + __ADE_VERSION__: JSON.stringify(version), + }; + options.alias = { + ...(options.alias ?? {}), + sqlite: "node:sqlite", + "react-devtools-core": path.join(packageRoot, "src", "tuiClient", "reactDevtoolsStub.ts"), + }; + }, }, -}); +]); diff --git a/apps/ade-cli/vitest.config.ts b/apps/ade-cli/vitest.config.ts index 8242fa108..317da94c6 100644 --- a/apps/ade-cli/vitest.config.ts +++ b/apps/ade-cli/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.{ts,tsx}"], setupFiles: ["src/test/setup.ts"], coverage: { provider: "v8", diff --git a/apps/desktop/SYNC_REMOTE_API_ANALYSIS.md b/apps/desktop/SYNC_REMOTE_API_ANALYSIS.md deleted file mode 100644 index d38c9732f..000000000 --- a/apps/desktop/SYNC_REMOTE_API_ANALYSIS.md +++ /dev/null @@ -1,315 +0,0 @@ -# Sync Remote API Analysis for Mobile Chat Client - -## 1. All Existing Remote Commands - -The desktop exposes remote commands via `syncRemoteCommandService.ts`. Each command is routed through a WebSocket-based sync protocol. Commands are registered with a policy (`{ viewerAllowed, queueable? }`). Chat event streaming uses separate sync envelopes (`chat_subscribe`, `chat_unsubscribe`, `chat_event`) and is gated by `hello_ok.features.chatStreaming.enabled`. - -### Chat Commands (13 total) -| Command | Parameters | Response | Policy | -|---------|-----------|----------|--------| -| `chat.listSessions` | `{ laneId?: string, includeAutomation?: boolean }` | `AgentChatSessionSummary[]` | viewerAllowed | -| `chat.getSummary` | `{ sessionId: string }` | `AgentChatSessionSummary \| null` | viewerAllowed | -| `chat.getTranscript` | `{ sessionId: string, limit?: number, maxChars?: number }` | Transcript entries | viewerAllowed | -| `chat.create` | `{ laneId: string, provider?: string, model?: string, modelId?: string, reasoningEffort?: string }` | `AgentChatSession` | viewerAllowed, queueable | -| `chat.send` | `{ sessionId: string, text: string }` | `{ ok: true }` | viewerAllowed, queueable | -| `chat.interrupt` | `{ sessionId: string }` | `{ ok: true }` | viewerAllowed | -| `chat.steer` | `{ sessionId: string, text: string }` | `{ ok: true }` | viewerAllowed | -| `chat.approve` | `{ sessionId: string, itemId: string, decision: string, responseText?: string }` | `{ ok: true }` | viewerAllowed | -| `chat.respondToInput` | `{ sessionId: string, itemId: string, decision?: string, answers?: object, responseText?: string }` | `{ ok: true }` | viewerAllowed | -| `chat.resume` | `{ sessionId: string }` | `AgentChatSession` | viewerAllowed, queueable | -| `chat.updateSession` | `{ sessionId: string, title?, modelId?, reasoningEffort?, permissionMode?, ... }` | `AgentChatSession` | viewerAllowed, queueable | -| `chat.dispose` | `{ sessionId: string }` | `{ ok: true }` | viewerAllowed, queueable | -| `chat.models` | `{ provider?: string }` | `AgentChatModelInfo[]` | viewerAllowed | - -### Lane Commands (29 total) -| Command | Policy | -|---------|--------| -| `lanes.list` | viewerAllowed | -| `lanes.refreshSnapshots` | viewerAllowed | -| `lanes.getDetail` | viewerAllowed | -| `lanes.create` | viewerAllowed, queueable | -| `lanes.createChild` | viewerAllowed, queueable | -| `lanes.createFromUnstaged` | viewerAllowed, queueable | -| `lanes.attach` | viewerAllowed, queueable | -| `lanes.adoptAttached` | viewerAllowed, queueable | -| `lanes.rename` | viewerAllowed, queueable | -| `lanes.reparent` | viewerAllowed, queueable | -| `lanes.updateAppearance` | viewerAllowed, queueable | -| `lanes.archive` | viewerAllowed, queueable | -| `lanes.unarchive` | viewerAllowed, queueable | -| `lanes.delete` | viewerAllowed, queueable | -| `lanes.getStackChain` | viewerAllowed | -| `lanes.getChildren` | viewerAllowed | -| `lanes.rebaseStart` | viewerAllowed, queueable | -| `lanes.rebasePush` | viewerAllowed, queueable | -| `lanes.rebaseRollback` | viewerAllowed, queueable | -| `lanes.rebaseAbort` | viewerAllowed, queueable | -| `lanes.listRebaseSuggestions` | viewerAllowed | -| `lanes.dismissRebaseSuggestion` | viewerAllowed, queueable | -| `lanes.deferRebaseSuggestion` | viewerAllowed, queueable | -| `lanes.listAutoRebaseStatuses` | viewerAllowed | -| `lanes.listTemplates` | viewerAllowed | -| `lanes.getDefaultTemplate` | viewerAllowed | -| `lanes.initEnv` | viewerAllowed, queueable | -| `lanes.getEnvStatus` | viewerAllowed | -| `lanes.applyTemplate` | viewerAllowed, queueable | - -### Work/Session Commands (3 total) -| Command | Parameters | Policy | -|---------|-----------|--------| -| `work.listSessions` | `{ laneId?: string, status?: string, limit?: number }` | viewerAllowed | -| `work.runQuickCommand` | `{ laneId, title, startupCommand?, cols?, rows?, toolType?, tracked? }` | viewerAllowed, queueable | -| `work.closeSession` | `{ sessionId: string }` | viewerAllowed, queueable | - -### Git Commands (30 total) -`git.getChanges`, `git.getFile`, `git.stageFile`, `git.stageAll`, `git.unstageFile`, `git.unstageAll`, `git.discardFile`, `git.restoreStagedFile`, `git.commit`, `git.generateCommitMessage`, `git.listRecentCommits`, `git.listCommitFiles`, `git.getCommitMessage`, `git.revertCommit`, `git.cherryPickCommit`, `git.stashPush`, `git.stashList`, `git.stashApply`, `git.stashPop`, `git.stashDrop`, `git.fetch`, `git.pull`, `git.getSyncStatus`, `git.sync`, `git.push`, `git.getConflictState`, `git.rebaseContinue`, `git.rebaseAbort`, `git.listBranches`, `git.checkoutBranch` - -### File Commands (1) -| `files.writeTextAtomic` | `{ laneId, path, text }` | viewerAllowed, queueable | - -### Conflict Commands (3) -`conflicts.getLaneStatus`, `conflicts.listOverlaps`, `conflicts.getBatchAssessment` - -### PR Commands (13) -`prs.list`, `prs.refresh`, `prs.getDetail`, `prs.getStatus`, `prs.getChecks`, `prs.getReviews`, `prs.getComments`, `prs.getFiles`, `prs.createFromLane`, `prs.land`, `prs.close`, `prs.reopen`, `prs.requestReviewers` - -**Total: 92 registered remote commands** - ---- - -## 2. Streaming/Event Push Mechanism - -### Current State: chat event streaming is available to sync peers - -The desktop has two separate event delivery systems: - -1. **IPC Events (Electron renderer only)**: Chat events flow via `onEvent` callback → `emitProjectEvent(projectRoot, IPC.agentChatEvent, event)` which sends `AgentChatEventEnvelope` objects to the Electron renderer process. - -2. **Sync WebSocket (mobile/external peers)**: The sync host subscribes to `agentChatService` events and broadcasts matching `chat_event` envelopes to peers that sent `chat_subscribe { sessionId }`. `chat_unsubscribe { sessionId }` stops delivery. There is no `chat_snapshot` envelope in the current implementation. - -### How Sync Streaming Works -- Peer sends `chat_subscribe { sessionId }` -- Desktop starts pushing `chat_event` envelopes for that session to the subscribed peer -- Peer sends `chat_unsubscribe { sessionId }` to stop - -### How Terminal Streaming Works -- Peer sends `terminal_subscribe { sessionId, maxBytes? }` → receives `terminal_snapshot` with current transcript -- Desktop pushes `terminal_data` events as PTY data arrives -- Desktop pushes `terminal_exit` when PTY exits -- Peer sends `terminal_unsubscribe { sessionId }` to stop - -### Other Push Events -- `heartbeat` (ping/pong, every 30s) -- `changeset_batch` (cr-sqlite CRDT changes, polled every 400ms) -- `brain_status` (host metrics, every 5s) - ---- - -## 3. MISSING Commands for Full Mobile Chat Client - -The following `agentChatService` public methods have **NO remote command equivalent** in `syncRemoteCommandService`: - -| Missing Command | agentChatService Method | Priority | Description | -|----------------|------------------------|----------|-------------| -| `chat.handoff` | `handoffSession({ sourceSessionId, targetModelId })` | Medium | Switch model mid-session | -| `chat.getCapabilities` | `getSessionCapabilities({ sessionId })` | Medium | Get session capabilities | -| `chat.listSubagents` | `listSubagents({ sessionId })` | Medium | List active subagents | -| `chat.slashCommands` | `getSlashCommands({ sessionId })` | Low | Get available slash commands | -| `chat.fileSearch` | `codexFuzzyFileSearch({ sessionId, query })` | Low | Search for files to attach | -| `chat.warmupModel` | `warmupModel({ sessionId, modelId })` | Low | Pre-warm a model before use | - ---- - -## 4. Session State Machine - -### Session Status (`AgentChatSessionStatus`) -``` -"active" | "idle" | "ended" -``` - -### Turn Status (within `AgentChatEvent.status`) -``` -"started" → "completed" | "interrupted" | "failed" -``` - -### Session Lifecycle -``` -create → idle -idle + send/steer → active (turn started) -active + turn completes → idle -active + interrupt → idle (turn interrupted) -idle + dispose → ended -ended + resume → idle -``` - -### Runtime States -Each session has a `ChatRuntime` which can be: -- `CodexRuntime` (OpenAI Codex process) -- `ClaudeRuntime` (Anthropic Claude CLI SDK) -- `UnifiedRuntime` (Vercel AI SDK, direct API) - -Runtime-specific states: -- **busy**: a turn is currently executing -- **interrupted**: interrupt was requested -- **pendingSteers**: queue of messages to send after current turn completes (max 10) -- **pendingApprovals**: tool-use approval requests waiting for user input - ---- - -## 5. Message/Event Type Definitions - -### `AgentChatEvent` (27 event types) -| Type | Key Fields | Description | -|------|-----------|-------------| -| `user_message` | text, attachments?, turnId? | User sent a message | -| `text` | text, messageId?, turnId? | Assistant text delta (streaming) | -| `tool_call` | tool, args, itemId, turnId? | Tool invocation started | -| `tool_result` | tool, result, itemId, status? | Tool completed/failed | -| `file_change` | path, diff, kind, itemId, status? | File was created/modified/deleted | -| `command` | command, cwd, output, itemId, status | Shell command execution | -| `plan` | steps[], explanation? | Plan outline | -| `reasoning` | text, turnId?, summaryIndex? | Model reasoning/thinking | -| `approval_request` | itemId, kind, description | Tool use needs approval | -| `status` | turnStatus, turnId?, message? | Turn lifecycle event | -| `delegation_state` | contract, message? | Delegation/handoff state | -| `error` | message, errorInfo? | Error occurred | -| `done` | turnId, status, model?, usage? | Turn completed/interrupted/failed | -| `activity` | activity, detail? | Current activity indicator | -| `step_boundary` | stepNumber | Step separator | -| `todo_update` | items[] | Todo list changes | -| `subagent_started` | taskId, description | Subagent spawned | -| `subagent_progress` | taskId, summary, usage? | Subagent progress | -| `subagent_result` | taskId, status, summary | Subagent completed | -| `structured_question` | question, options?, itemId | Question for the user | -| `tool_use_summary` | summary, toolUseIds | Summarized tool uses | -| `context_compact` | trigger | Context was compacted | -| `system_notice` | noticeKind, message, detail? | System notification | -| `completion_report` | report | Session completion summary | -| `web_search` | query, itemId, status | Web search event | -| `auto_approval_review` | targetItemId, reviewStatus | Auto-approval decision | -| `prompt_suggestion` | suggestion | Suggested follow-up prompt | -| `plan_text` | text | Plan text content | - -### `AgentChatEventEnvelope` -```typescript -{ - sessionId: string; - timestamp: string; - event: AgentChatEvent; - sequence?: number; - provenance?: { - messageId?: string; - threadId?: string | null; - role?: "user" | "orchestrator" | "worker" | "agent" | null; - // ... more fields - }; -} -``` - -### `PendingInputRequest` (approval/question data) -```typescript -{ - requestId: string; - itemId?: string; - source: "claude" | "codex" | "unified" | "mission" | "ade"; - kind: "approval" | "question" | "structured_question" | "permissions" | "plan_approval"; - title?: string | null; - description?: string | null; - questions: PendingInputQuestion[]; - allowsFreeform: boolean; - blocking: boolean; - canProceedWithoutAnswer: boolean; - options?: PendingInputOption[]; - turnId?: string | null; -} -``` - ---- - -## 6. WebSocket Protocol Details - -### Connection Flow -1. Client opens WebSocket to `ws://<host>:8787` -2. Client sends `hello` or `pairing_request` envelope -3. Desktop validates auth (bootstrap token or paired device credentials) -4. Desktop sends `hello_ok` with features list (including `chatStreaming` and all supported command actions) -5. Authenticated peer can send commands, subscribe to terminals, and, when `chatStreaming.enabled` is true, subscribe to chat events. - -### Envelope Format -```typescript -{ - version: 1, // Protocol version - type: string, // Message type - requestId?: string | null, // For request/response correlation - compression: "none" | "gzip", // Payload compression - payloadEncoding: "json" | "base64", // Encoding - payload: unknown, // The actual data - uncompressedBytes?: number, // Original size if compressed -} -``` - -### Authentication -Two auth methods: -1. **Bootstrap token**: Shared secret stored at `.ade/secrets/sync-bootstrap-token` -2. **Paired device**: Device-specific credentials via QR code pairing flow - -### Command Protocol -``` -Client → command { commandId, action, args } -Desktop → command_ack { commandId, accepted, status, message } -Desktop → command_result { commandId, ok, result?, error? } -``` - ---- - -## 7. Connection Management - -- **No rate limiting** on commands or connections -- **No max peer limit** — peers are tracked in a `Set<PeerState>` -- **Heartbeat**: 30s interval, unanswered heartbeat → close with code 4001 -- **mDNS**: Host published via Bonjour (`ade-sync` service type) -- **Max WebSocket payload**: 25 MB -- **Compression threshold**: 4 KB (payloads ≥4KB are gzip compressed) - ---- - -## 8. Recommendations for Backend Changes - -### Critical (required for basic mobile chat) - -1. **Chat streaming is already wired through sync envelopes**: `chat_subscribe` and `chat_unsubscribe` gate `chat_event` pushes, and the capability should be checked via `hello_ok.features.chatStreaming.enabled` before subscribing. - -2. **Remaining chat commands for mobile**: - - `chat.handoff` → `agentChatService.handoffSession()` - - `chat.getCapabilities` → `agentChatService.getSessionCapabilities()` - - `chat.listSubagents` → `agentChatService.listSubagents()` - - `chat.slashCommands` → `agentChatService.getSlashCommands()` - - `chat.fileSearch` → `agentChatService.codexFuzzyFileSearch()` - - `chat.warmupModel` → `agentChatService.warmupModel()` - -### High Priority - -3. **Consider whether mobile needs the remaining read-only commands immediately**: `chat.getCapabilities`, `chat.listSubagents`, and `chat.slashCommands` are the most likely next additions. - -### Nice to Have - -4. **Consider connection-level rate limiting**: Currently there's no protection against command flooding from peers. - -### What Mobile Can Do with Existing APIs - -With the 13 existing chat commands, mobile can already: -- ✅ List chat sessions per lane -- ✅ Get session summaries -- ✅ Read chat transcripts (polling) -- ✅ Create new chat sessions -- ✅ Send initial messages -- ✅ Interrupt, steer, approve, and answer input requests in real time -- ✅ Resume, update, and dispose chat sessions -- ✅ List available models -- ✅ Receive real-time chat events after `chat_subscribe` -- ❌ Cannot hand off sessions to a different model -- ❌ Cannot query capabilities, subagents, slash commands, file search, or model warmup - -### Implementation Approach - -The most impactful single change is adding **chat event streaming** via `chat_subscribe`/`chat_event` envelopes and gating it behind `hello_ok.features.chatStreaming.enabled`. The additional command registrations are straightforward - they just wire existing service methods through the existing command dispatch pattern. diff --git a/apps/desktop/build/icon.alpha.icns b/apps/desktop/build/icon.alpha.icns new file mode 100644 index 000000000..639d1840a Binary files /dev/null and b/apps/desktop/build/icon.alpha.icns differ diff --git a/apps/desktop/build/icon.beta.icns b/apps/desktop/build/icon.beta.icns new file mode 100644 index 000000000..d8d0408ea Binary files /dev/null and b/apps/desktop/build/icon.beta.icns differ diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index ca61b2249..571ce7528 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -29,6 +29,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-virtual": "^3.13.21", "@types/canvas-confetti": "^1.9.0", + "@types/ssh2": "^1.15.5", "@wize-logic/nodejs-rfb": "^4.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", @@ -52,7 +53,6 @@ "node-pty": "^1.1.0", "onnxruntime-node": "^1.24.3", "path-browserify": "^1.0.1", - "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -63,6 +63,7 @@ "remark-gfm": "^4.0.1", "shiki": "^4.0.2", "sql.js": "^1.13.0", + "ssh2": "^1.17.0", "tailwind-merge": "^3.4.0", "ws": "^8.19.0", "yaml": "^2.8.2", @@ -81,7 +82,6 @@ "@types/node": "^20.11.30", "@types/node-cron": "^3.0.11", "@types/path-browserify": "^1.0.3", - "@types/qrcode": "^1.5.6", "@types/react": "^18.2.74", "@types/react-dom": "^18.2.24", "@types/sql.js": "^1.4.9", @@ -7041,16 +7041,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/qrcode": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", - "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -7100,6 +7090,30 @@ "@types/node": "*" } }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -7858,6 +7872,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -7867,6 +7882,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -8254,6 +8270,15 @@ "node": ">=8" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -8461,6 +8486,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -8646,6 +8680,15 @@ "dev": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/builder-util": { "version": "26.8.1", "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", @@ -8893,15 +8936,6 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001779", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", @@ -9263,6 +9297,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -9275,6 +9310,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/color-support": { @@ -9503,6 +9539,20 @@ "node": ">= 6" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/crc": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", @@ -10122,15 +10172,6 @@ } } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -10380,12 +10421,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/dijkstrajs": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", - "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", - "license": "MIT" - }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -10892,6 +10927,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "devOptional": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -12140,6 +12176,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -13245,6 +13282,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -15996,6 +16034,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.26.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -16452,15 +16497,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -16566,6 +16602,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16747,15 +16784,6 @@ "node": ">=10.4.0" } }, - "node_modules/pngjs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", - "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -17097,141 +17125,6 @@ "node": ">=6" } }, - "node_modules/qrcode": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", - "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", - "license": "MIT", - "dependencies": { - "dijkstrajs": "^1.0.1", - "pngjs": "^5.0.0", - "yargs": "^15.3.1" - }, - "bin": { - "qrcode": "bin/qrcode" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/qrcode/node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/qrcode/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/qrcode/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "license": "ISC" - }, - "node_modules/qrcode/node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -18333,6 +18226,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -18347,12 +18241,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "license": "ISC" - }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -18857,7 +18745,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/set-cookie-parser": { "version": "2.7.2", @@ -19739,6 +19628,23 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/ssri": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", @@ -19804,6 +19710,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -19848,6 +19755,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -20581,6 +20489,12 @@ "node": "*" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -22702,12 +22616,6 @@ "node": ">= 8" } }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "license": "ISC" - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a832f329e..171589b77 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,15 +15,17 @@ "dev:vite": "vite --port 5173 --strictPort", "export:browser-mock-ade": "node ./scripts/export-browser-mock-ade-snapshot.mjs", "build": "tsup && vite build", - "dist:win": "npm run validate:win:artifacts && npm run build && electron-builder --win --x64 --publish never && npm run validate:win:release", - "dist:mac": "npm run build && electron-builder --mac --publish never", - "dist:mac:dir": "npm run build && electron-builder --dir --mac --publish never -c.mac.identity=null -c.mac.notarize=false", - "dist:mac:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run build && electron-builder --mac --publish never", + "dist:win": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run validate:win:artifacts && npm run build && electron-builder --win --x64 --publish never && npm run validate:win:release", + "dist:mac": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run build && electron-builder --mac --publish never", + "dist:mac:dir": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run build && electron-builder --dir --mac --publish never -c.mac.identity=null -c.mac.notarize=false", + "dist:mac:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run build && electron-builder --mac --publish never", "prepare:mac:universal": "node ./scripts/prepare-universal-mac-inputs.mjs", - "dist:mac:universal:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run build && electron-builder --mac --universal --publish never", - "dist:mac:universal:signed:zip": "node ./scripts/require-macos-release-secrets.cjs && npm run build && electron-builder --mac zip --universal --publish never", + "dist:mac:universal:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run build && electron-builder --mac --universal --publish never", + "dist:mac:universal:signed:zip": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run build && electron-builder --mac zip --universal --publish never", "notarize:mac:dmg": "node ./scripts/notarize-mac-dmg.mjs", "validate:mac:artifacts": "node ./scripts/validate-mac-artifacts.mjs", + "materialize:runtime-resources": "node ./scripts/materialize-runtime-resources.mjs", + "validate:runtime-resources": "node ./scripts/validate-runtime-resources.mjs", "validate:win:artifacts": "node ./scripts/validate-win-artifacts.mjs --mode=preflight", "validate:win:release": "node ./scripts/validate-win-artifacts.mjs --mode=release", "release:mac:local": "node ./scripts/release-mac-local.mjs", @@ -67,6 +69,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-virtual": "^3.13.21", "@types/canvas-confetti": "^1.9.0", + "@types/ssh2": "^1.15.5", "@wize-logic/nodejs-rfb": "^4.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", @@ -90,7 +93,6 @@ "node-pty": "^1.1.0", "onnxruntime-node": "^1.24.3", "path-browserify": "^1.0.1", - "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -101,6 +103,7 @@ "remark-gfm": "^4.0.1", "shiki": "^4.0.2", "sql.js": "^1.13.0", + "ssh2": "^1.17.0", "tailwind-merge": "^3.4.0", "ws": "^8.19.0", "yaml": "^2.8.2", @@ -119,7 +122,6 @@ "@types/node": "^20.11.30", "@types/node-cron": "^3.0.11", "@types/path-browserify": "^1.0.3", - "@types/qrcode": "^1.5.6", "@types/react": "^18.2.74", "@types/react-dom": "^18.2.24", "@types/sql.js": "^1.4.9", @@ -175,6 +177,18 @@ "from": "../ade-cli/dist/cli.cjs", "to": "ade-cli/cli.cjs" }, + { + "from": "../ade-cli/dist/bootstrap.cjs", + "to": "ade-cli/bootstrap.cjs" + }, + { + "from": "../ade-cli/dist/adeRpcServer.cjs", + "to": "ade-cli/adeRpcServer.cjs" + }, + { + "from": "../ade-cli/dist/tuiClient", + "to": "ade-cli/tuiClient" + }, { "from": "scripts/ade-cli-macos-wrapper.sh", "to": "ade-cli/bin/ade" @@ -190,6 +204,13 @@ { "from": "scripts/ade-cli-install-path.cmd", "to": "ade-cli/install-path.cmd" + }, + { + "from": "resources/runtime", + "to": "runtime", + "filter": [ + "**/*" + ] } ], "afterPack": "./scripts/after-pack-runtime-fixes.cjs", diff --git a/apps/desktop/resources/runtime/.gitkeep b/apps/desktop/resources/runtime/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/desktop/resources/runtime/.gitkeep @@ -0,0 +1 @@ + diff --git a/apps/desktop/scripts/ade-cli-install-path.sh b/apps/desktop/scripts/ade-cli-install-path.sh index 84d159b06..a5a5ab3bc 100755 --- a/apps/desktop/scripts/ade-cli-install-path.sh +++ b/apps/desktop/scripts/ade-cli-install-path.sh @@ -12,8 +12,19 @@ while [ -L "$SOURCE" ]; do done SCRIPT_DIR=$(CDPATH= cd -P -- "$(dirname -- "$SOURCE")" && pwd) -ADE_BIN=${ADE_BIN:-"$SCRIPT_DIR/bin/ade"} -TARGET_PATH=${1:-"$HOME/.local/bin/ade"} + +CLI_NAME=${ADE_CLI_INSTALL_NAME:-} +if [ -z "$CLI_NAME" ] && [ -f "$SCRIPT_DIR/channel" ]; then + CHANNEL=$(tr -d '[:space:]' < "$SCRIPT_DIR/channel") + case "$CHANNEL" in + alpha) CLI_NAME=ade-alpha ;; + beta) CLI_NAME=ade-beta ;; + esac +fi +CLI_NAME=${CLI_NAME:-ade} + +ADE_BIN=${ADE_BIN:-"$SCRIPT_DIR/bin/$CLI_NAME"} +TARGET_PATH=${1:-"$HOME/.local/bin/$CLI_NAME"} TARGET_DIR=$(dirname -- "$TARGET_PATH") if [ ! -x "$ADE_BIN" ]; then @@ -24,5 +35,5 @@ fi mkdir -p "$TARGET_DIR" ln -sf "$ADE_BIN" "$TARGET_PATH" -echo "Installed ade -> $ADE_BIN" -echo "Ensure $TARGET_DIR is on PATH, then run: ade doctor" +echo "Installed $CLI_NAME -> $ADE_BIN" +echo "Ensure $TARGET_DIR is on PATH, then run: $CLI_NAME doctor" diff --git a/apps/desktop/scripts/ade-cli-macos-wrapper.sh b/apps/desktop/scripts/ade-cli-macos-wrapper.sh index 279057e63..276b36ec2 100755 --- a/apps/desktop/scripts/ade-cli-macos-wrapper.sh +++ b/apps/desktop/scripts/ade-cli-macos-wrapper.sh @@ -13,6 +13,26 @@ done SCRIPT_DIR=$(CDPATH= cd -P -- "$(dirname -- "$SOURCE")" && pwd) CLI_JS=${ADE_CLI_JS:-"$SCRIPT_DIR/../cli.cjs"} +CLI_NAME=$(basename -- "$SOURCE") +CHANNEL=${ADE_PACKAGE_CHANNEL:-} +if [ -z "$CHANNEL" ] && [ -f "$SCRIPT_DIR/../channel" ]; then + CHANNEL=$(tr -d '[:space:]' < "$SCRIPT_DIR/../channel") +fi + +case "$CLI_NAME:$CHANNEL" in + ade-alpha:*|ade:alpha) + export ADE_PACKAGE_CHANNEL=${ADE_PACKAGE_CHANNEL:-alpha} + export ADE_HOME=${ADE_HOME:-"$HOME/.ade-alpha"} + export ADE_DESKTOP_APP_NAME=${ADE_DESKTOP_APP_NAME:-"ADE Alpha"} + export ADE_DISABLE_RUNTIME_SERVICE_INSTALL=${ADE_DISABLE_RUNTIME_SERVICE_INSTALL:-1} + ;; + ade-beta:*|ade:beta) + export ADE_PACKAGE_CHANNEL=${ADE_PACKAGE_CHANNEL:-beta} + export ADE_HOME=${ADE_HOME:-"$HOME/.ade-beta"} + export ADE_DESKTOP_APP_NAME=${ADE_DESKTOP_APP_NAME:-"ADE Beta"} + export ADE_DISABLE_RUNTIME_SERVICE_INSTALL=${ADE_DISABLE_RUNTIME_SERVICE_INSTALL:-1} + ;; +esac if [ -n "${ADE_CLI_NODE:-}" ]; then exec "$ADE_CLI_NODE" "$CLI_JS" "$@" diff --git a/apps/desktop/scripts/after-pack-runtime-fixes.cjs b/apps/desktop/scripts/after-pack-runtime-fixes.cjs index 81adb715a..b3136c41e 100644 --- a/apps/desktop/scripts/after-pack-runtime-fixes.cjs +++ b/apps/desktop/scripts/after-pack-runtime-fixes.cjs @@ -33,6 +33,47 @@ function requireFile(filePath, label) { } } +function normalizePackageChannel(value) { + const normalized = String(value || "").trim().toLowerCase(); + return normalized === "alpha" || normalized === "beta" ? normalized : null; +} + +function resolvePackageChannel(context) { + const explicit = normalizePackageChannel(process.env.ADE_PACKAGE_CHANNEL); + if (explicit) return explicit; + const appInfo = context?.packager?.appInfo; + const candidates = [ + appInfo?.productName, + appInfo?.productFilename, + appInfo?.id, + ]; + for (const candidate of candidates) { + const text = String(candidate || "").toLowerCase(); + if (text.includes("alpha")) return "alpha"; + if (text.includes("beta")) return "beta"; + } + return null; +} + +function channelCliName(channel) { + if (channel === "alpha") return "ade-alpha"; + if (channel === "beta") return "ade-beta"; + return "ade"; +} + +function materializeChannelCliWrapper(resourcesRoot, channel) { + if (!channel) return null; + const cliRoot = path.join(resourcesRoot, "ade-cli"); + const binRoot = path.join(cliRoot, "bin"); + const sourcePath = path.join(binRoot, "ade"); + const targetPath = path.join(binRoot, channelCliName(channel)); + requireFile(sourcePath, "bundled ADE CLI wrapper"); + fs.copyFileSync(sourcePath, targetPath); + fs.chmodSync(targetPath, 0o755); + fs.writeFileSync(path.join(cliRoot, "channel"), `${channel}\n`); + return targetPath; +} + function removeIfPresent(rootPath, relativePath) { const targetPath = path.join(rootPath, relativePath); if (!fs.existsSync(targetPath)) return false; @@ -111,6 +152,7 @@ function pruneUnneededRuntimePayload(runtimeRoot, platform) { module.exports = async function afterPack(context) { const platform = context?.electronPlatformName; + const packageChannel = resolvePackageChannel(context); const { runtimeRoot, appBundlePath } = resolveUnpackedRuntimeRoot(context); if (!fs.existsSync(runtimeRoot)) { throw new Error(`[afterPack] Missing unpacked runtime payload: ${runtimeRoot}`); @@ -118,7 +160,13 @@ module.exports = async function afterPack(context) { const resourcesRoot = resolveExtraResourcesRoot(context, appBundlePath); const bundledCliPath = path.join(resourcesRoot, "ade-cli", "cli.cjs"); + const bundledCliBootstrapPath = path.join(resourcesRoot, "ade-cli", "bootstrap.cjs"); + const bundledCliRpcPath = path.join(resourcesRoot, "ade-cli", "adeRpcServer.cjs"); + const bundledCliTuiPath = path.join(resourcesRoot, "ade-cli", "tuiClient", "cli.mjs"); requireFile(bundledCliPath, "bundled ADE CLI entry"); + requireFile(bundledCliBootstrapPath, "bundled ADE CLI bootstrap entry"); + requireFile(bundledCliRpcPath, "bundled ADE CLI RPC entry"); + requireFile(bundledCliTuiPath, "bundled ADE CLI TUI entry"); if (platform === "darwin") { const bundledCliBinPath = path.join(resourcesRoot, "ade-cli", "bin", "ade"); @@ -133,6 +181,10 @@ module.exports = async function afterPack(context) { fs.chmodSync(bundledCliBinPath, 0o755); fs.chmodSync(bundledCliInstallerPath, 0o755); fs.chmodSync(iosSimHelperBuildScript, 0o755); + const channelWrapperPath = materializeChannelCliWrapper(resourcesRoot, packageChannel); + if (channelWrapperPath) { + console.log(`[afterPack] Added channel CLI wrapper: ${path.basename(channelWrapperPath)}`); + } } else if (platform === "win32") { requireFile(path.join(resourcesRoot, "ade-cli", "bin", "ade.cmd"), "bundled ADE CLI Windows wrapper"); requireFile(path.join(resourcesRoot, "ade-cli", "install-path.cmd"), "bundled ADE CLI Windows PATH installer"); diff --git a/apps/desktop/scripts/dev.cjs b/apps/desktop/scripts/dev.cjs index f24db490e..5720f9eaf 100644 --- a/apps/desktop/scripts/dev.cjs +++ b/apps/desktop/scripts/dev.cjs @@ -195,7 +195,20 @@ function terminateChild(child, signal) { } } +async function ensureDevIcon() { + const generator = path.join(__dirname, "generate-dev-icon.cjs"); + if (!fs.existsSync(generator)) return; + const result = cp.spawnSync(process.execPath, [generator], { + cwd: projectRoot, + stdio: "inherit", + }); + if (result.status !== 0) { + process.stderr.write("[ade] dev icon generation failed; falling back to default icon\n"); + } +} + async function main() { + await ensureDevIcon(); const devPort = await choosePort(5173, 32); const devServerUrl = `http://localhost:${devPort}`; const remoteDebugPortRaw = diff --git a/apps/desktop/scripts/generate-dev-icon.cjs b/apps/desktop/scripts/generate-dev-icon.cjs new file mode 100644 index 000000000..52f63c5e0 --- /dev/null +++ b/apps/desktop/scripts/generate-dev-icon.cjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); + +const projectRoot = path.resolve(__dirname, ".."); +const inputPath = path.join(projectRoot, "build", "icon.png"); +const outputPath = path.join(projectRoot, "build", "icon.dev.png"); + +const ADE_PURPLE = [127, 65, 238]; +const ADE_WHITE = [255, 255, 255]; +// Background swap target: matches the renderer's `backgroundColor` (#0F0D14) so +// the dev icon visually ties to the actual app window. +const DEV_BG = [15, 13, 20]; + +async function main() { + if (!fs.existsSync(inputPath)) { + throw new Error(`source icon not found: ${inputPath}`); + } + + if (fs.existsSync(outputPath)) { + const inputStat = fs.statSync(inputPath); + const outputStat = fs.statSync(outputPath); + if (outputStat.mtimeMs >= inputStat.mtimeMs) { + return; + } + } + + const sharp = require("sharp"); + const { data, info } = await sharp(inputPath).raw().toBuffer({ resolveWithObject: true }); + const channels = info.channels; + const out = Buffer.alloc(data.length); + + for (let i = 0; i < data.length; i += channels) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const a = channels === 4 ? data[i + 3] : 255; + + if (a < 8) { + out[i] = r; + out[i + 1] = g; + out[i + 2] = b; + if (channels === 4) out[i + 3] = a; + continue; + } + + // Decompose pixel as a non-negative combination of ADE purple, white, and black: + // (r, g, b) = wp * PURPLE + ww * WHITE + wd * BLACK + // Solving with PURPLE = (127, 65, 238), WHITE = (255, 255, 255): + // r - g = 62 * wp → wp = (r - g) / 62 + // g = 65*wp + 255*ww → ww = (g - 65*wp) / 255 + let wp = (r - g) / 62; + if (wp < 0) wp = 0; + if (wp > 1) wp = 1; + let ww = (g - 65 * wp) / 255; + if (ww < 0) ww = 0; + if (ww > 1) ww = 1; + + // Reproject the original pixel onto the {PURPLE, WHITE, BLACK} subspace and + // capture any residual so unrelated colors (e.g. shadow tints) survive. + const projR = wp * ADE_PURPLE[0] + ww * ADE_WHITE[0]; + const projG = wp * ADE_PURPLE[1] + ww * ADE_WHITE[1]; + const projB = wp * ADE_PURPLE[2] + ww * ADE_WHITE[2]; + const resR = r - projR; + const resG = g - projG; + const resB = b - projB; + + // Swap purple bg out for the dev bg, and white text out for purple. The + // black/shadow contribution flows through `res*` so antialiased edges stay + // smooth (and existing dark-shadow pixels just blend toward the dark bg). + let nr = wp * DEV_BG[0] + ww * ADE_PURPLE[0] + resR; + let ng = wp * DEV_BG[1] + ww * ADE_PURPLE[1] + resG; + let nb = wp * DEV_BG[2] + ww * ADE_PURPLE[2] + resB; + + if (nr < 0) nr = 0; + else if (nr > 255) nr = 255; + if (ng < 0) ng = 0; + else if (ng > 255) ng = 255; + if (nb < 0) nb = 0; + else if (nb > 255) nb = 255; + + out[i] = Math.round(nr); + out[i + 1] = Math.round(ng); + out[i + 2] = Math.round(nb); + if (channels === 4) out[i + 3] = a; + } + + await sharp(out, { raw: info }).png().toFile(outputPath); + process.stdout.write(`[generate-dev-icon] wrote ${path.relative(projectRoot, outputPath)}\n`); +} + +main().catch((err) => { + process.stderr.write(`[generate-dev-icon] failed: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/apps/desktop/scripts/materialize-runtime-resources.mjs b/apps/desktop/scripts/materialize-runtime-resources.mjs new file mode 100644 index 000000000..8485aa19a --- /dev/null +++ b/apps/desktop/scripts/materialize-runtime-resources.mjs @@ -0,0 +1,358 @@ +import { execFile } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import https from "node:https"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const desktopRoot = path.resolve(scriptDir, ".."); +const repoRoot = path.resolve(desktopRoot, "..", ".."); +const cliRoot = path.join(repoRoot, "apps", "ade-cli"); +const runtimeRoot = path.join(desktopRoot, "resources", "runtime"); +const cliDistStaticRoot = path.join(cliRoot, "dist-static"); +const targets = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; +const seaFuse = "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2"; +const allowHostOnlyRuntimeResources = process.env.ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY === "1"; +const maxDownloadRedirects = 10; + +function currentTarget() { + const platform = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform; + const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch; + return `${platform}-${arch}`; +} + +function npmCommand() { + return process.platform === "win32" ? "npm.cmd" : "npm"; +} + +function artifactNamesForTarget(target) { + return [`ade-${target}`, `ade-${target}.native.tar.gz`]; +} + +function isRuntimeBinaryName(name) { + return targets.some((target) => name === `ade-${target}`); +} + +function isRuntimeArtifactName(name) { + return targets.some((target) => artifactNamesForTarget(target).includes(name)); +} + +function uniquePaths(paths) { + const seen = new Set(); + const result = []; + for (const entry of paths) { + if (!entry) continue; + const resolved = path.resolve(entry); + if (seen.has(resolved)) continue; + seen.add(resolved); + result.push(resolved); + } + return result; +} + +async function pathExists(targetPath) { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + +async function isSeaCapableNodeBinary(binaryPath) { + try { + const contents = await fs.readFile(binaryPath); + return contents.includes(Buffer.from(seaFuse)); + } catch { + return false; + } +} + +function nodeArchiveCandidates(version, target) { + return [ + { + name: `node-${version}-${target}.tar.gz`, + tarFlag: "-xzf", + }, + { + name: `node-${version}-${target}.tar.xz`, + tarFlag: "-xJf", + }, + ]; +} + +async function downloadFile(url, destinationPath, redirectsRemaining = maxDownloadRedirects) { + await fs.mkdir(path.dirname(destinationPath), { recursive: true }); + await new Promise((resolve, reject) => { + const request = https.get(url, (response) => { + if ( + response.statusCode && + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + response.resume(); + if (redirectsRemaining <= 0) { + reject(new Error(`Too many redirects while downloading ${url}`)); + return; + } + downloadFile( + new URL(response.headers.location, url).toString(), + destinationPath, + redirectsRemaining - 1 + ).then(resolve, reject); + return; + } + if (response.statusCode !== 200) { + response.resume(); + reject(new Error(`HTTP ${response.statusCode ?? "unknown"}`)); + return; + } + const output = createWriteStream(destinationPath, { mode: 0o644 }); + response.pipe(output); + output.once("finish", () => { + output.close(resolve); + }); + output.once("error", reject); + }); + request.once("error", reject); + }); +} + +async function downloadOfficialNodeBinary(target) { + const version = (process.env.ADE_STATIC_NODE_VERSION?.trim() || process.version).replace(/^([^v])/, "v$1"); + const cacheRoot = path.resolve( + process.env.ADE_STATIC_NODE_CACHE_DIR?.trim() || path.join(desktopRoot, ".cache", "runtime-node") + ); + const extractRoot = path.join(cacheRoot, version, target); + const binaryPath = path.join(extractRoot, `node-${version}-${target}`, "bin", "node"); + if (await isSeaCapableNodeBinary(binaryPath)) { + return binaryPath; + } + + await fs.rm(extractRoot, { recursive: true, force: true }); + await fs.mkdir(extractRoot, { recursive: true }); + + let lastError = null; + for (const candidate of nodeArchiveCandidates(version, target)) { + const archivePath = path.join(cacheRoot, version, candidate.name); + const url = `https://nodejs.org/dist/${version}/${candidate.name}`; + try { + console.log(`[runtime-resources] Downloading official Node ${version} for ${target}: ${url}`); + await downloadFile(url, archivePath); + await execFileAsync("tar", [candidate.tarFlag, archivePath, "-C", extractRoot], { + cwd: desktopRoot, + maxBuffer: 20 * 1024 * 1024, + }); + if (await isSeaCapableNodeBinary(binaryPath)) { + await fs.chmod(binaryPath, 0o755); + return binaryPath; + } + throw new Error(`Downloaded Node binary is not SEA-capable: ${binaryPath}`); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + await fs.rm(extractRoot, { recursive: true, force: true }); + await fs.mkdir(extractRoot, { recursive: true }); + } + } + + throw lastError ?? new Error(`Unable to download official Node ${version} for ${target}.`); +} + +async function resolveSeaNodeBinaryForHostBuild(target) { + const explicit = process.env.ADE_STATIC_NODE_BINARY?.trim(); + if (explicit) { + if (await isSeaCapableNodeBinary(explicit)) return explicit; + throw new Error(`ADE_STATIC_NODE_BINARY is not SEA-capable: ${explicit}`); + } + if (await isSeaCapableNodeBinary(process.execPath)) return null; + if (process.env.ADE_RUNTIME_DISABLE_NODE_DOWNLOAD === "1") { + throw new Error( + "This local source build needs to create ADE's bundled runtime helper, but this machine's Node install " + + `cannot build it (${process.execPath}) and automatic helper-tool download is disabled. ` + + "This does not affect people downloading ADE releases; release downloads already include the helper." + ); + } + return downloadOfficialNodeBinary(target); +} + +async function walkFiles(rootPath, files = []) { + let entries; + try { + entries = await fs.readdir(rootPath, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return files; + throw error; + } + + for (const entry of entries) { + const entryPath = path.join(rootPath, entry.name); + if (entry.isDirectory()) { + await walkFiles(entryPath, files); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + + return files; +} + +async function collectArtifacts(rootPath) { + const files = await walkFiles(rootPath); + const matches = new Map(); + for (const filePath of files.sort((a, b) => a.localeCompare(b))) { + const name = path.basename(filePath); + if (!isRuntimeArtifactName(name) || matches.has(name)) continue; + matches.set(name, filePath); + } + return matches; +} + +async function copyArtifactsFrom(rootPath) { + if (!(await pathExists(rootPath))) { + console.log(`[runtime-resources] Artifact source missing, skipping: ${rootPath}`); + return 0; + } + + const artifacts = await collectArtifacts(rootPath); + let copied = 0; + await fs.mkdir(runtimeRoot, { recursive: true }); + + for (const [name, sourcePath] of artifacts) { + const destinationPath = path.join(runtimeRoot, name); + if (path.resolve(sourcePath) !== path.resolve(destinationPath)) { + await fs.copyFile(sourcePath, destinationPath); + copied += 1; + } + if (isRuntimeBinaryName(name)) { + await fs.chmod(destinationPath, 0o755); + } + } + + if (artifacts.size > 0) { + console.log(`[runtime-resources] Materialized ${artifacts.size} artifact(s) from ${rootPath}.`); + } + return copied; +} + +async function missingArtifactsForTarget(target) { + const missing = []; + for (const name of artifactNamesForTarget(target)) { + const artifactPath = path.join(runtimeRoot, name); + if (!(await pathExists(artifactPath))) { + missing.push(name); + } + } + return missing; +} + +async function missingArtifacts() { + const missing = []; + for (const target of targets) { + missing.push(...await missingArtifactsForTarget(target)); + } + return missing; +} + +async function buildHostArtifactsIfNeeded() { + const target = currentTarget(); + if (!targets.includes(target)) return false; + + const missingHostArtifacts = await missingArtifactsForTarget(target); + if (missingHostArtifacts.length === 0) return false; + + console.log( + `[runtime-resources] Missing host runtime artifact(s) for ${target}; building ${missingHostArtifacts.join(", ")}.` + ); + const env = { ...process.env }; + const seaNodeBinary = await resolveSeaNodeBinaryForHostBuild(target); + if (seaNodeBinary) { + env.ADE_STATIC_NODE_BINARY = seaNodeBinary; + console.log(`[runtime-resources] Using SEA-capable Node binary for ${target}: ${seaNodeBinary}`); + } + let stdout = ""; + let stderr = ""; + try { + const result = await execFileAsync(npmCommand(), [ + "--prefix", + cliRoot, + "run", + "build:static", + "--", + "--target", + target, + "--out-dir", + runtimeRoot, + ], { + cwd: desktopRoot, + env, + maxBuffer: 100 * 1024 * 1024, + }); + stdout = result.stdout; + stderr = result.stderr; + } catch (error) { + stdout = typeof error?.stdout === "string" ? error.stdout : ""; + stderr = typeof error?.stderr === "string" ? error.stderr : ""; + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `[runtime-resources] Failed to build host runtime artifacts for ${target}. ` + + "This only affects local source builds; ADE release downloads already include these files. " + + "Provide prebuilt runtime artifacts, or allow the script to download the official build helper. " + + `Original error: ${message}` + ); + } + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + return true; +} + +function formatMissing(missing) { + return missing.map((name) => ` - ${name}`).join("\n"); +} + +async function main() { + await fs.mkdir(runtimeRoot, { recursive: true }); + const artifactRoots = uniquePaths([ + process.env.ADE_RUNTIME_ARTIFACTS_DIR?.trim() || null, + cliDistStaticRoot, + ]); + + for (const artifactRoot of artifactRoots) { + await copyArtifactsFrom(artifactRoot); + } + + await buildHostArtifactsIfNeeded(); + + const missing = await missingArtifacts(); + if (missing.length > 0) { + if (allowHostOnlyRuntimeResources) { + console.warn( + "[runtime-resources] Host-only local package mode is enabled; missing remote runtime artifact(s):\n" + + `${formatMissing(missing)}\n` + + "\nRemote runtime bootstrap to those targets will be unavailable in this local package. " + + "Release builds still require the full runtime artifact set." + ); + return; + } + throw new Error( + "[runtime-resources] Missing remote ADE runtime artifact(s):\n" + + `${formatMissing(missing)}\n` + + "\nThis only affects local source builds; ADE release downloads already include these files. " + + "For local packaging, point ADE_RUNTIME_ARTIFACTS_DIR at the CI runtime artifacts, or run the " + + "runtime-binary workflow and download the `ade-runtime-*` artifacts before packaging." + ); + } + + console.log(`[runtime-resources] Materialized runtime resources for ${targets.length} target(s).`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/apps/desktop/scripts/set-ci-version.mjs b/apps/desktop/scripts/set-ci-version.mjs index 1ed27d460..f5781359f 100644 --- a/apps/desktop/scripts/set-ci-version.mjs +++ b/apps/desktop/scripts/set-ci-version.mjs @@ -4,7 +4,11 @@ import { fileURLToPath } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const appDir = path.resolve(scriptDir, ".."); -const packageJsonPath = path.join(appDir, "package.json"); +const repoRoot = path.resolve(appDir, "..", ".."); +const packageJsonPaths = [ + path.join(appDir, "package.json"), + path.join(repoRoot, "apps", "ade-cli", "package.json"), +]; const buildNumberRaw = process.env.ADE_BUILD_NUMBER ?? process.env.GITHUB_RUN_NUMBER; if (!buildNumberRaw) { @@ -16,10 +20,13 @@ if (!Number.isFinite(buildNumber) || buildNumber <= 0) { throw new Error(`Invalid build number: ${buildNumberRaw}`); } -const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); +const packageJson = JSON.parse(await readFile(packageJsonPaths[0], "utf8")); const [major = "1", minor = "0"] = String(packageJson.version ?? "1.0.0").split("."); packageJson.version = `${major}.${minor}.${buildNumber}`; -await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); +for (const packageJsonPath of packageJsonPaths) { + const nextPackageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); + nextPackageJson.version = packageJson.version; + await writeFile(packageJsonPath, `${JSON.stringify(nextPackageJson, null, 2)}\n`); +} process.stdout.write(`${packageJson.version}\n`); - diff --git a/apps/desktop/scripts/set-release-version.mjs b/apps/desktop/scripts/set-release-version.mjs index 53fa10d3d..5a9b2e6a8 100644 --- a/apps/desktop/scripts/set-release-version.mjs +++ b/apps/desktop/scripts/set-release-version.mjs @@ -4,7 +4,11 @@ import { fileURLToPath } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const appDir = path.resolve(scriptDir, ".."); -const packageJsonPath = path.join(appDir, "package.json"); +const repoRoot = path.resolve(appDir, "..", ".."); +const packageJsonPaths = [ + path.join(appDir, "package.json"), + path.join(repoRoot, "apps", "ade-cli", "package.json"), +]; const releaseTag = (process.env.ADE_RELEASE_TAG ?? process.env.GITHUB_REF_NAME ?? "").trim(); if (!releaseTag) { @@ -20,8 +24,11 @@ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/.test(version)) { throw new Error(`Release tag must contain a semver-compatible version, received: ${releaseTag}`); } -const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); -packageJson.version = version; +for (const packageJsonPath of packageJsonPaths) { + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); + packageJson.version = version; -await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); -process.stdout.write(`${packageJson.version}\n`); + await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); +} + +process.stdout.write(`${version}\n`); diff --git a/apps/desktop/scripts/validate-mac-artifacts.mjs b/apps/desktop/scripts/validate-mac-artifacts.mjs index b26588fb2..c5361b6df 100644 --- a/apps/desktop/scripts/validate-mac-artifacts.mjs +++ b/apps/desktop/scripts/validate-mac-artifacts.mjs @@ -14,6 +14,7 @@ const appDir = path.resolve(scriptDir, ".."); const releaseDir = path.join(appDir, "release"); const DEFAULT_MAX_APP_ASAR_BYTES = 900 * 1024 * 1024; const DEFAULT_MAX_UNPACKED_BYTES = 600 * 1024 * 1024; +const REMOTE_RUNTIME_TARGETS = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; function readFlag(name) { const prefix = `${name}=`; @@ -151,6 +152,22 @@ async function assertExecutable(targetPath, description) { } } +async function assertRemoteRuntimeBundle(resourcesPath, description) { + const runtimeRoot = path.join(resourcesPath, "runtime"); + await assertPathExists(runtimeRoot, `remote runtime bundle directory for ${description}`); + for (const target of REMOTE_RUNTIME_TARGETS) { + const binaryPath = path.join(runtimeRoot, `ade-${target}`); + const nativeArchivePath = path.join(runtimeRoot, `ade-${target}.native.tar.gz`); + await assertPathExists(binaryPath, `remote runtime binary ${target} for ${description}`); + await assertExecutable(binaryPath, `remote runtime binary ${target}`); + await assertPathExists(nativeArchivePath, `remote runtime native dependency archive ${target} for ${description}`); + const { stdout } = await execFileAsync("tar", ["-tzf", nativeArchivePath]); + if (!stdout.split(/\r?\n/).some((entry) => entry.startsWith("./node_modules/"))) { + throw new Error(`[release:mac] Remote runtime native archive for ${target} does not contain ./node_modules/: ${nativeArchivePath}`); + } + } +} + async function findFirstNodeAddon(rootPath) { const entries = await fs.readdir(rootPath, { withFileTypes: true }); @@ -300,6 +317,9 @@ async function validatePackagedRuntime(appPath, description) { const appAsarPath = path.join(resourcesPath, "app.asar"); const unpackedPath = await resolveRuntimeUnpackedPath(resourcesPath); const adeCliPath = path.join(resourcesPath, "ade-cli", "cli.cjs"); + const adeCliBootstrapPath = path.join(resourcesPath, "ade-cli", "bootstrap.cjs"); + const adeCliRpcPath = path.join(resourcesPath, "ade-cli", "adeRpcServer.cjs"); + const adeCliTuiPath = path.join(resourcesPath, "ade-cli", "tuiClient", "cli.mjs"); const adeCliBinPath = path.join(resourcesPath, "ade-cli", "bin", "ade"); const adeCliInstallerPath = path.join(resourcesPath, "ade-cli", "install-path.sh"); const iosSimHelperRoot = path.join(resourcesPath, "native", "ios-sim-helpers"); @@ -313,6 +333,9 @@ async function validatePackagedRuntime(appPath, description) { await assertPathExists(appAsarPath, "app.asar payload"); await assertPathExists(unpackedPath, "unpacked runtime payload"); await assertPathExists(adeCliPath, "bundled ADE CLI entry"); + await assertPathExists(adeCliBootstrapPath, "bundled ADE CLI bootstrap entry"); + await assertPathExists(adeCliRpcPath, "bundled ADE CLI RPC entry"); + await assertPathExists(adeCliTuiPath, "bundled ADE CLI TUI entry"); await assertPathExists(adeCliBinPath, "bundled ADE CLI wrapper"); await assertPathExists(adeCliInstallerPath, "bundled ADE CLI PATH installer"); await assertPathExists(iosSimHelperBuildScript, "bundled iOS simulator helper build script"); @@ -324,6 +347,7 @@ async function validatePackagedRuntime(appPath, description) { await assertExecutable(iosSimHelperBuildScript, "bundled iOS simulator helper build script"); await assertPathExists(nodePtyModulePath, "unpacked node-pty module"); await assertPathExists(smokeScriptPath, "unpacked packaged runtime smoke script"); + await assertRemoteRuntimeBundle(resourcesPath, description); await validatePackageHygiene(appPath, description); const nodePtyAddon = await findNodePtyAddon(nodePtyModulePath); diff --git a/apps/desktop/scripts/validate-runtime-resources.mjs b/apps/desktop/scripts/validate-runtime-resources.mjs new file mode 100644 index 000000000..de783c309 --- /dev/null +++ b/apps/desktop/scripts/validate-runtime-resources.mjs @@ -0,0 +1,73 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const desktopRoot = path.resolve(scriptDir, ".."); +const runtimeRoot = path.join(desktopRoot, "resources", "runtime"); +const allTargets = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; + +function currentTarget() { + const platform = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform; + const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch; + return `${platform}-${arch}`; +} + +const targets = process.env.ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY === "1" + ? [currentTarget()] + : allTargets; + +function fail(message) { + throw new Error(`[runtime-resources] ${message}`); +} + +async function statFile(filePath, label) { + let stat; + try { + stat = await fs.stat(filePath); + } catch { + fail(`Missing ${label}: ${filePath}`); + } + if (!stat.isFile()) { + fail(`Expected ${label} to be a file: ${filePath}`); + } + if (stat.size <= 0) { + fail(`Expected ${label} to be non-empty: ${filePath}`); + } + return stat; +} + +async function validateExecutable(filePath, label) { + const stat = await statFile(filePath, label); + if (process.platform !== "win32" && (stat.mode & 0o111) === 0) { + fail(`Expected ${label} to be executable: ${filePath}`); + } +} + +async function main() { + for (const target of targets) { + await validateExecutable(path.join(runtimeRoot, `ade-${target}`), `remote ADE service binary ${target}`); + await statFile( + path.join(runtimeRoot, `ade-${target}.native.tar.gz`), + `remote ADE service native dependency archive ${target}`, + ); + } + + const mode = process.env.ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY === "1" ? "host-only local" : "full"; + console.log(`[runtime-resources] Found ${targets.length} ${mode} ADE service binaries and native archives.`); +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + console.error( + "[runtime-resources] Populate apps/desktop/resources/runtime with every " + + "`ade-{darwin,linux}-{arm64,x64}` binary and matching `.native.tar.gz` archive. " + + "Run `npm --prefix apps/desktop run materialize:runtime-resources` to copy downloaded artifacts " + + "or build the local host target. For a direct local same-platform build, run " + + "`npm --prefix apps/ade-cli run build:static -- --target <target> --out-dir ../desktop/resources/runtime`; " + + "release CI uses the artifact download step. Local channel packages may set " + + "ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY=1 to validate only the host target.", + ); + process.exit(1); +}); diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index b8864e4a7..96450589d 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -14,6 +14,7 @@ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); const productName = pkg.build?.productName ?? pkg.productName ?? "ADE"; const DEFAULT_MAX_APP_ASAR_BYTES = 900 * 1024 * 1024; const DEFAULT_MAX_UNPACKED_BYTES = 600 * 1024 * 1024; +const REMOTE_RUNTIME_TARGETS = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; function readFlag(name) { const prefix = `${name}=`; @@ -120,6 +121,16 @@ async function assertPathMissing(targetPath, description) { fail(`Unexpected ${description}: ${targetPath}`); } +async function assertExecutable(targetPath, description) { + if (process.platform === "win32") { + return; + } + const stat = await fsp.stat(targetPath); + if ((stat.mode & 0o111) !== 0o111) { + fail(`Expected ${description} to be executable: ${targetPath}`); + } +} + function requireFile(relativePath, label) { const absolutePath = path.join(desktopRoot, relativePath); if (!fs.existsSync(absolutePath)) { @@ -164,6 +175,15 @@ function validatePreflight() { if (!hasExtraResource("ade-cli/bin/ade.cmd")) { fail("package.json build.extraResources must ship ade-cli/bin/ade.cmd"); } + if (!hasExtraResource("ade-cli/bootstrap.cjs")) { + fail("package.json build.extraResources must ship ade-cli/bootstrap.cjs"); + } + if (!hasExtraResource("ade-cli/adeRpcServer.cjs")) { + fail("package.json build.extraResources must ship ade-cli/adeRpcServer.cjs"); + } + if (!hasExtraResource("ade-cli/tuiClient")) { + fail("package.json build.extraResources must ship ade-cli/tuiClient"); + } if (!hasExtraResource("ade-cli/install-path.cmd")) { fail("package.json build.extraResources must ship ade-cli/install-path.cmd"); } @@ -312,6 +332,22 @@ function runCommand(command, args, options = {}) { }); } +async function assertRemoteRuntimeBundle(resourcesPath) { + const runtimeRoot = path.join(resourcesPath, "runtime"); + await assertPathExists(runtimeRoot, "remote runtime bundle directory"); + for (const target of REMOTE_RUNTIME_TARGETS) { + const binaryPath = path.join(runtimeRoot, `ade-${target}`); + const nativeArchivePath = path.join(runtimeRoot, `ade-${target}.native.tar.gz`); + await assertPathExists(binaryPath, `remote runtime binary ${target}`); + await assertExecutable(binaryPath, `remote runtime binary ${target}`); + await assertPathExists(nativeArchivePath, `remote runtime native dependency archive ${target}`); + const { stdout } = await runCommand("tar", ["-tzf", nativeArchivePath]); + if (!stdout.split(/\r?\n/).some((entry) => entry.startsWith("./node_modules/"))) { + fail(`Remote runtime native archive for ${target} does not contain ./node_modules/: ${nativeArchivePath}`); + } + } +} + async function findFirstNodeAddon(rootPath) { const entries = await fsp.readdir(rootPath, { withFileTypes: true }); @@ -415,6 +451,9 @@ async function validatePackagedRuntime(appDir) { const appAsarPath = path.join(resourcesPath, "app.asar"); const unpackedPath = path.join(resourcesPath, "app.asar.unpacked"); const adeCliPath = path.join(resourcesPath, "ade-cli", "cli.cjs"); + const adeCliBootstrapPath = path.join(resourcesPath, "ade-cli", "bootstrap.cjs"); + const adeCliRpcPath = path.join(resourcesPath, "ade-cli", "adeRpcServer.cjs"); + const adeCliTuiPath = path.join(resourcesPath, "ade-cli", "tuiClient", "cli.mjs"); const adeCliBinPath = path.join(resourcesPath, "ade-cli", "bin", "ade.cmd"); const adeCliInstallerPath = path.join(resourcesPath, "ade-cli", "install-path.cmd"); const nodeModulesPath = path.join(unpackedPath, "node_modules"); @@ -438,6 +477,9 @@ async function validatePackagedRuntime(appDir) { await assertPathExists(appAsarPath, "app.asar payload"); await assertPathExists(unpackedPath, "app.asar.unpacked runtime payload"); await assertPathExists(adeCliPath, "bundled ADE CLI entry"); + await assertPathExists(adeCliBootstrapPath, "bundled ADE CLI bootstrap entry"); + await assertPathExists(adeCliRpcPath, "bundled ADE CLI RPC entry"); + await assertPathExists(adeCliTuiPath, "bundled ADE CLI TUI entry"); await assertPathExists(adeCliBinPath, "bundled ADE CLI wrapper"); await assertPathExists(adeCliInstallerPath, "bundled ADE CLI PATH installer"); await assertPathExists(nodePtyModulePath, "unpacked node-pty module"); @@ -447,6 +489,7 @@ async function validatePackagedRuntime(appDir) { await assertPathExists(path.join(onnxRuntimeWinPath, "DirectML.dll"), "Windows DirectML DLL"); await assertPathExists(smokeScriptPath, "unpacked packaged runtime smoke script"); await assertPathExists(crsqliteDllPath, "unpacked Windows cr-sqlite extension"); + await assertRemoteRuntimeBundle(resourcesPath); await validatePackageHygiene(resourcesPath); const nodePtyAddon = await findNodePtyAddon(nodePtyModulePath); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index d402d70a4..e80cba424 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,4 +1,5 @@ -import { app, BrowserWindow, dialog, nativeImage, protocol, safeStorage, shell } from "electron"; +import { app, BrowserWindow, dialog, Menu, nativeImage, protocol, safeStorage, shell } from "electron"; +import { AsyncLocalStorage } from "node:async_hooks"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -56,11 +57,21 @@ import { } from "./services/projects/projectService"; import { inspectRecentProject, type RecentProjectInspection } from "./services/projects/recentProjectSummary"; import { resolveProjectIcon } from "./services/projects/projectIconResolver"; +import { normalizeStartupProjectState, resolveStartupProject } from "./services/projects/startupProjectResolver"; import { createAdeProjectService } from "./services/projects/adeProjectService"; import { createConfigReloadService } from "./services/projects/configReloadService"; import { IPC } from "../shared/ipc"; import { resolveAdeLayout } from "../shared/adeLayout"; -import type { PortLease, ProjectInfo, SyncMobileProjectSummary, SyncProjectConnectionPayload, SyncProjectSwitchRequestPayload, SyncProjectSwitchResultPayload } from "../shared/types"; +import type { + OpenProjectBinding, + PortLease, + PrEventPayload, + ProjectInfo, + SyncMobileProjectSummary, + SyncProjectConnectionPayload, + SyncProjectSwitchRequestPayload, + SyncProjectSwitchResultPayload, +} from "../shared/types"; import type { AutomationTriggerType } from "../shared/types/config"; import type { AutomationTriggerLinearIssueContext } from "../shared/types/automations"; import type { LinearIngressEventRecord } from "../shared/types/linearSync"; @@ -75,6 +86,8 @@ import { type AdeRuntimePaths, } from "../../../ade-cli/src/bootstrap"; import { startJsonRpcServer, type JsonRpcTransport } from "../../../ade-cli/src/jsonrpc"; +import { resolveMachineAdeLayout } from "../../../ade-cli/src/services/projects/machineLayout"; +import { normalizeProjectRootPath } from "../../../ade-cli/src/services/projects/projectRoots"; import { createKeybindingsService } from "./services/keybindings/keybindingsService"; import { createAgentToolsService } from "./services/agentTools/agentToolsService"; import { createAdeCliService } from "./services/cli/adeCliService"; @@ -95,6 +108,11 @@ import { } from "./services/adeActions/registry"; import { createUsageTrackingService } from "./services/usage/usageTrackingService"; import { createBudgetCapService } from "./services/usage/budgetCapService"; +import { + markMachineStateMigrationComplete, + readMachineRegistryRecentProjects, + runMachineStateMigration, +} from "./services/runtime/machineStateMigration"; import { createRebaseSuggestionService } from "./services/lanes/rebaseSuggestionService"; import { createAutoRebaseService } from "./services/lanes/autoRebaseService"; import { createMissionService } from "./services/missions/missionService"; @@ -135,7 +153,6 @@ import { createLinearCloseoutService } from "./services/cto/linearCloseoutServic import { createLinearDispatcherService } from "./services/cto/linearDispatcherService"; import { createLinearIngressService } from "./services/cto/linearIngressService"; import { createLinearSyncService } from "./services/cto/linearSyncService"; -import { createOpenclawBridgeService } from "./services/cto/openclawBridgeService"; import { createOrchestratorService } from "./services/orchestrator/orchestratorService"; import { createAiOrchestratorService } from "./services/orchestrator/aiOrchestratorService"; import { createMissionBudgetService } from "./services/orchestrator/missionBudgetService"; @@ -146,6 +163,7 @@ import { createAppControlService } from "./services/appControl/appControlService import { createBuiltInBrowserService } from "./services/builtInBrowser/builtInBrowserService"; import { createMacosVmService } from "./services/macosVm/macosVmService"; import { configureBuiltInBrowserWebAuthn } from "./services/builtInBrowser/builtInBrowserWebAuthn"; +import { LocalRuntimeConnectionPool } from "./services/localRuntime/localRuntimeConnectionPool"; import { createSyncService } from "./services/sync/syncService"; import { ApnsService, ApnsKeyStore } from "./services/notifications/apnsService"; import { @@ -161,6 +179,49 @@ import type { Logger } from "./services/logging/logger"; const AUTO_UPDATER_CACHE_DIR_NAME = "ade-desktop-updater"; const ADE_BROWSER_WEBVIEW_PARTITION = "persist:ade-browser"; +type AdePackageChannel = "alpha" | "beta"; + +function normalizeAdePackageChannel(value: unknown): AdePackageChannel | null { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + return normalized === "alpha" || normalized === "beta" ? normalized : null; +} + +function readBundledAdePackageChannel(): AdePackageChannel | null { + const envChannel = normalizeAdePackageChannel(process.env.ADE_PACKAGE_CHANNEL); + if (envChannel) return envChannel; + + try { + const packageJsonPath = path.join(app.getAppPath(), "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + adePackageChannel?: unknown; + productName?: unknown; + }; + const packageChannel = normalizeAdePackageChannel(packageJson.adePackageChannel); + if (packageChannel) return packageChannel; + const productName = typeof packageJson.productName === "string" ? packageJson.productName : ""; + if (/\balpha\b/i.test(productName)) return "alpha"; + if (/\bbeta\b/i.test(productName)) return "beta"; + } catch { + // Dev builds and older packaged apps do not need channel metadata. + } + + const appName = app.getName(); + if (/\balpha\b/i.test(appName)) return "alpha"; + if (/\bbeta\b/i.test(appName)) return "beta"; + return null; +} + +function applyPackagedChannelDefaults(): void { + const channel = readBundledAdePackageChannel(); + if (!channel) return; + + process.env.ADE_PACKAGE_CHANNEL = process.env.ADE_PACKAGE_CHANNEL || channel; + process.env.ADE_DESKTOP_APP_NAME = process.env.ADE_DESKTOP_APP_NAME || (channel === "alpha" ? "ADE Alpha" : "ADE Beta"); + process.env.ADE_HOME = process.env.ADE_HOME || path.join(os.homedir(), `.ade-${channel}`); + process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL = process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL || "1"; +} + +applyPackagedChannelDefaults(); function resolveAutoUpdaterCacheDir(): string { const homeDir = os.homedir(); @@ -202,6 +263,27 @@ function fixElectronShellPath(): void { // Must run before any service or child process is created. fixElectronShellPath(); +function installAdeCliForTerminalInBackground( + adeCliService: ReturnType<typeof createAdeCliService>, + logger: Logger, +): void { + if (process.env.ADE_DISABLE_CLI_AUTO_INSTALL === "1") return; + void adeCliService.installForUser() + .then((result) => { + logger.info("ade_cli.auto_install", { + ok: result.ok, + command: result.status.command, + installTargetPath: result.status.installTargetPath, + message: result.message, + }); + }) + .catch((error) => { + logger.warn("ade_cli.auto_install_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); +} + const disableHardwareAcceleration = process.env.ADE_DISABLE_HARDWARE_ACCEL === "1"; if (disableHardwareAcceleration) { @@ -394,15 +476,22 @@ function isAllowedAdeBrowserWebviewNavigation(rawUrl: string): boolean { async function createWindow(args: { logger?: Logger; + onCreated?: (win: BrowserWindow) => void; onCloseRequested?: (win: BrowserWindow, event: Electron.Event) => void; } = {}): Promise<BrowserWindow> { - // Load the app icon from the build directory. + // Load the app icon from the build directory. In dev (`npm run dev` sets + // VITE_DEV_SERVER_URL) prefer the inverted icon so the dock/window icon makes + // it obvious at a glance that this is a dev build, not the installed app. const iconDir = path.join(__dirname, "../../build"); const icoPath = path.join(iconDir, "icon.ico"); const pngPath = path.join(iconDir, "icon.png"); + const devPngPath = path.join(iconDir, "icon.dev.png"); const icnsPath = path.join(iconDir, "icon.icns"); + const isDev = !!process.env.VITE_DEV_SERVER_URL; let icon: Electron.NativeImage; - if (process.platform === "win32" && fs.existsSync(icoPath)) { + if (isDev && fs.existsSync(devPngPath)) { + icon = nativeImage.createFromPath(devPngPath); + } else if (process.platform === "win32" && fs.existsSync(icoPath)) { icon = nativeImage.createFromPath(icoPath); } else if (fs.existsSync(pngPath)) { icon = nativeImage.createFromPath(pngPath); @@ -429,6 +518,8 @@ async function createWindow(args: { }, }); + args.onCreated?.(win); + win.webContents.on("will-attach-webview", (event, webPreferences, params) => { const src = typeof params.src === "string" ? params.src : ""; if (!isAllowedAdeBrowserWebviewSource(src)) { @@ -685,6 +776,19 @@ protocol.registerSchemesAsPrivileged([ }, ]); +let pendingProjectOpenFiles: string[] = []; +let handleProjectOpenFile: ((filePath: string) => void) | null = null; + +app.on("open-file", (event, filePath) => { + event.preventDefault(); + if (!filePath) return; + if (handleProjectOpenFile) { + handleProjectOpenFile(filePath); + return; + } + pendingProjectOpenFiles.push(filePath); +}); + app.whenReady().then(async () => { /** Canonical artifacts dir for the active project; ade-artifact:// only serves under this path. */ let adeArtifactAllowedDir: string | null = null; @@ -859,7 +963,7 @@ app.whenReady().then(async () => { app.getPath("userData"), "ade-project", ); - const normalizeProjectPath = (value: string) => path.resolve(value); + const normalizeProjectPath = (value: string) => normalizeProjectRootPath(value); const isLikelyRepoRoot = (value: string) => { const resolved = normalizeProjectPath(value); return ( @@ -870,67 +974,51 @@ app.whenReady().then(async () => { ); }; - const cleanedRecentProjects = (saved.recentProjects ?? []).reduce( - (acc, entry) => { - const rootPath = - typeof entry?.rootPath === "string" - ? normalizeProjectPath(entry.rootPath) - : ""; - if (!isLikelyRepoRoot(rootPath)) return acc; - if (acc.some((item) => item.rootPath === rootPath)) return acc; - const displayName = - typeof entry?.displayName === "string" && - entry.displayName.trim().length > 0 - ? entry.displayName - : path.basename(rootPath); - const lastOpenedAt = - typeof entry?.lastOpenedAt === "string" && - entry.lastOpenedAt.trim().length > 0 - ? entry.lastOpenedAt - : new Date().toISOString(); - acc.push({ rootPath, displayName, lastOpenedAt }); - return acc; - }, - [] as Array<{ - rootPath: string; - displayName: string; - lastOpenedAt: string; - }>, - ); - const hadRecentProjectsChanges = - cleanedRecentProjects.length !== (saved.recentProjects ?? []).length; - const cleanedLastProjectRoot = saved.lastProjectRoot - ? normalizeProjectPath(saved.lastProjectRoot) - : ""; - const validLastProjectRoot = - isLikelyRepoRoot(cleanedLastProjectRoot) && - cleanedRecentProjects.some( - (project) => project.rootPath === cleanedLastProjectRoot, - ) - ? cleanedLastProjectRoot - : ""; - const hadLastProjectRootChanges = - saved.lastProjectRoot !== validLastProjectRoot; - const normalizedState = { - ...saved, - lastProjectRoot: validLastProjectRoot || undefined, - recentProjects: cleanedRecentProjects, - }; + const machineAdeLayout = resolveMachineAdeLayout(); + const startupState = normalizeStartupProjectState({ + saved, + additionalRecentProjects: readMachineRegistryRecentProjects(machineAdeLayout), + isLikelyRepoRoot, + normalizeProjectPath, + }); + const cleanedRecentProjects = startupState.recentProjects; + const validLastProjectRoot = startupState.validLastProjectRoot; - if (hadRecentProjectsChanges || hadLastProjectRootChanges) { - writeGlobalState(globalStatePath, normalizedState); + if (startupState.changed) { + writeGlobalState(globalStatePath, startupState.state); } - const envRoot = process.env.ADE_PROJECT_ROOT; - const devFallbackProject = process.env.VITE_DEV_SERVER_URL - ? path.resolve(process.cwd(), "..", "..") - : fallbackProjectRoot; + const machineStateMigration = runMachineStateMigration({ + layout: machineAdeLayout, + recentProjects: cleanedRecentProjects, + }); + const shouldAttemptRuntimeServiceInstall = + machineStateMigration.didRun + && app.isPackaged + && process.env.NODE_ENV !== "test" + && process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL !== "1"; + const shouldShowRuntimeMigrationNotice = + shouldAttemptRuntimeServiceInstall && machineStateMigration.shouldShowNotice; + const packagedChannel = normalizeAdePackageChannel(process.env.ADE_PACKAGE_CHANNEL); - const startupUserSelected = Boolean(envRoot && envRoot.trim().length); - const initialCandidate = - envRoot && envRoot.trim().length - ? normalizeProjectPath(envRoot) - : devFallbackProject; + const envRoot = process.env.ADE_PROJECT_ROOT; + const pendingStartupProjectRoot = + pendingProjectOpenFiles + .map((filePath) => normalizeProjectPath(filePath)) + .find((filePath) => isLikelyRepoRoot(filePath)) ?? null; + if (pendingStartupProjectRoot) { + pendingProjectOpenFiles = pendingProjectOpenFiles.filter( + (filePath) => normalizeProjectPath(filePath) !== pendingStartupProjectRoot, + ); + } + const startupProject = resolveStartupProject({ + envRoot, + pendingStartupProjectRoot, + validLastProjectRoot, + recentProjects: cleanedRecentProjects, + normalizeProjectPath, + }); + const shouldOpenStartupProject = startupProject.rootPath != null; const broadcast = (channel: string, payload: unknown) => { for (const win of BrowserWindow.getAllWindows()) { @@ -958,20 +1046,105 @@ app.whenReady().then(async () => { const projectContexts = new Map<string, AppContext>(); const projectInitPromises = new Map<string, Promise<AppContext>>(); const closeContextPromises = new Map<string, Promise<void>>(); + const windowProjectRoots = new Map<number, string | null>(); + const windowProjectBindings = new Map<number, OpenProjectBinding & { kind: "remote" }>(); + const ipcWindowScope = new AsyncLocalStorage<number | null>(); const rpcSocketCleanupByRoot = new Map<string, () => void>(); const projectLastActivatedAt = new Map<string, number>(); const mobileSyncHandoffLeases = new Map<string, number>(); const mobileSyncHandoffLeaseTimers = new Map<string, ReturnType<typeof setTimeout>>(); const mobileSyncPreparationPromises = new Map<string, Promise<SyncProjectSwitchResultPayload>>(); + const localRuntimeLogger = createFileLogger(path.join(app.getPath("userData"), "local-runtime.jsonl")); + const localRuntimePool = new LocalRuntimeConnectionPool(app.getVersion(), localRuntimeLogger); + if (shouldAttemptRuntimeServiceInstall) { + void localRuntimePool.installServiceBestEffort() + .then(() => { + const status = localRuntimePool.getStatus().serviceInstall; + if (status.state === "installed") { + markMachineStateMigrationComplete({ layout: machineAdeLayout }); + } + }) + .catch((error) => { + localRuntimeLogger.warn("local_runtime.service_install_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } else if (!machineStateMigration.didRun) { + localRuntimePool.noteServiceInstallSkipped("Background service migration already completed."); + } else if (process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL === "1") { + localRuntimePool.noteServiceInstallSkipped("Background service installation is disabled by ADE_DISABLE_RUNTIME_SERVICE_INSTALL."); + if (machineStateMigration.didRun && app.isPackaged && packagedChannel) { + markMachineStateMigrationComplete({ layout: machineAdeLayout }); + } + } else if (!app.isPackaged) { + localRuntimePool.noteServiceInstallSkipped("Background service installation is skipped in dev builds."); + } else if (process.env.NODE_ENV === "test") { + localRuntimePool.noteServiceInstallSkipped("Background service installation is skipped in tests."); + } const MAX_WARM_IDLE_PROJECT_CONTEXTS = 1; const MOBILE_SYNC_HANDOFF_LEASE_MS = 60_000; let activeProjectRoot: string | null = null; let mobileSyncSelectedRoot: string | null = null; let dormantContext!: AppContext; let projectContextRebalancePromise: Promise<void> = Promise.resolve(); + const closeWindowWithoutQuitPrompt = new Set<number>(); + + const currentIpcWindowId = (): number | null => + ipcWindowScope.getStore() ?? null; + + const useInProcessProjectRuntime = (): boolean => + process.env.NODE_ENV === "test" + || process.env.ADE_ENABLE_DESKTOP_IN_PROCESS_RUNTIME === "1" + || process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON === "1" + || process.env.ADE_LOCAL_RUNTIME_FALLBACK === "1"; + + const projectForRoot = (projectRoot: string | null): ProjectInfo | null => { + if (!projectRoot) return null; + return projectContexts.get(projectRoot)?.project ?? null; + }; - const emitProjectChanged = (project: ProjectInfo | null): void => { - broadcast(IPC.appProjectChanged, project); + const bindingForLocalProject = (project: ProjectInfo | null): OpenProjectBinding | null => + project + ? { + kind: "local", + key: `local:${project.rootPath}`, + rootPath: project.rootPath, + displayName: project.displayName, + } + : null; + + const rootsBoundToWindows = (): Set<string> => { + const roots = new Set<string>(); + for (const root of windowProjectRoots.values()) { + if (root) roots.add(root); + } + return roots; + }; + + const emitProjectChangedToWindow = ( + windowId: number | null, + project: ProjectInfo | null, + ): void => { + const win = windowId == null ? null : BrowserWindow.fromId(windowId); + if (!win || win.isDestroyed()) return; + try { + win.webContents.send(IPC.appProjectChanged, project); + } catch { + // ignore + } + }; + + const emitProjectBindingChangedToWindow = ( + windowId: number | null, + binding: OpenProjectBinding | null, + ): void => { + const win = windowId == null ? null : BrowserWindow.fromId(windowId); + if (!win || win.isDestroyed()) return; + try { + win.webContents.send(IPC.appProjectBindingChanged, binding); + } catch { + // ignore + } }; const firstAvailableRecentProjectRoot = (): string | null => { @@ -984,10 +1157,16 @@ app.whenReady().then(async () => { return null; }; + const isDesktopSyncHostEnabled = (): boolean => + process.env.ADE_ENABLE_DESKTOP_SYNC_HOST === "1" + && process.env.ADE_DISABLE_SYNC_HOST !== "1"; + const getMobileSyncHostRoot = (): string | null => - mobileSyncSelectedRoot - ?? activeProjectRoot - ?? firstAvailableRecentProjectRoot(); + isDesktopSyncHostEnabled() + ? mobileSyncSelectedRoot + ?? activeProjectRoot + ?? firstAvailableRecentProjectRoot() + : null; const getMobileSyncService = (): ReturnType<typeof createSyncService> | null => { const hostRoot = getMobileSyncHostRoot(); @@ -1024,7 +1203,7 @@ app.whenReady().then(async () => { return next; }; - const setActiveProject = (projectRoot: string | null): void => { + const setForegroundProject = (projectRoot: string | null): void => { activeProjectRoot = projectRoot ? normalizeProjectRoot(projectRoot) : null; void reconcileSyncHostContexts().then(() => { notifyMobileSyncProjectCatalogChanged(); @@ -1046,7 +1225,65 @@ app.whenReady().then(async () => { } }; + const bindWindowToProject = ( + windowId: number | null, + projectRoot: string | null, + options: { emit?: boolean; foreground?: boolean } = {}, + ): void => { + const normalizedRoot = projectRoot ? normalizeProjectRoot(projectRoot) : null; + if (windowId != null) { + windowProjectRoots.set(windowId, normalizedRoot); + windowProjectBindings.delete(windowId); + } + if (options.foreground ?? true) { + setForegroundProject(normalizedRoot); + } + if (normalizedRoot) { + projectLastActivatedAt.set(normalizedRoot, Date.now()); + const ctx = projectContexts.get(normalizedRoot); + if (ctx) { + persistRecentProject(ctx.project, { recordLastProject: false, preserveRecentOrder: true }); + } + if (process.env.NODE_ENV !== "test" && process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON !== "1") { + void localRuntimePool.ensureProject(normalizedRoot).catch((error) => { + localRuntimeLogger.warn("local_runtime.project_registration_failed", { + rootPath: normalizedRoot, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + } + if (options.emit !== false) { + const project = projectForRoot(normalizedRoot); + emitProjectChangedToWindow(windowId, project); + emitProjectBindingChangedToWindow(windowId, bindingForLocalProject(project)); + } + }; + + const bindWindowToRemoteProject = ( + windowId: number | null, + binding: OpenProjectBinding & { kind: "remote" }, + ): void => { + if (windowId != null) { + windowProjectRoots.set(windowId, null); + windowProjectBindings.set(windowId, binding); + } + setForegroundProject(null); + emitProjectChangedToWindow(windowId, null); + emitProjectBindingChangedToWindow(windowId, binding); + }; + const getActiveContext = (): AppContext => { + const windowId = currentIpcWindowId(); + if (windowId != null) { + const windowProjectRoot = windowProjectRoots.get(windowId) ?? null; + if (windowProjectRoot) { + const ctx = projectContexts.get(windowProjectRoot); + if (ctx) return ctx; + windowProjectRoots.set(windowId, null); + } + return dormantContext; + } if (activeProjectRoot) { const ctx = projectContexts.get(activeProjectRoot); if (ctx) return ctx; @@ -1060,9 +1297,15 @@ app.whenReady().then(async () => { channel: string, payload: unknown, ): void => { - if (!activeProjectRoot) return; - if (normalizeProjectRoot(projectRoot) !== activeProjectRoot) return; - broadcast(channel, payload); + const normalizedRoot = normalizeProjectRoot(projectRoot); + for (const win of BrowserWindow.getAllWindows()) { + if (windowProjectRoots.get(win.id) !== normalizedRoot) continue; + try { + win.webContents.send(channel, payload); + } catch { + // ignore + } + } }; const hasActiveProjectWorkloads = async ( @@ -1082,7 +1325,7 @@ app.whenReady().then(async () => { }; try { - if (ctx.sessionService.list({ status: "running", limit: 1 }).length > 0) { + if (ctx.sessionService?.list({ status: "running", limit: 1 }).length > 0) { return true; } } catch (error) { @@ -1090,7 +1333,7 @@ app.whenReady().then(async () => { } try { - if (ctx.missionService.list({ status: "active", limit: 1 }).length > 0) { + if (ctx.missionService?.list({ status: "active", limit: 1 }).length > 0) { return true; } } catch (error) { @@ -1098,7 +1341,7 @@ app.whenReady().then(async () => { } try { - if (ctx.testService.hasActiveRuns()) { + if (ctx.testService?.hasActiveRuns()) { return true; } } catch (error) { @@ -1106,20 +1349,22 @@ app.whenReady().then(async () => { } try { - const lanes = await ctx.laneService.list({ - includeArchived: false, - includeStatus: false, - }); - for (const lane of lanes) { - if ( - ctx.processService.listRuntime(lane.id).some((runtime) => - runtime.status === "starting" - || runtime.status === "running" - || runtime.status === "degraded" - || runtime.status === "stopping" - ) - ) { - return true; + if (ctx.laneService && ctx.processService) { + const lanes = await ctx.laneService.list({ + includeArchived: false, + includeStatus: false, + }); + for (const lane of lanes) { + if ( + ctx.processService.listRuntime(lane.id).some((runtime) => + runtime.status === "starting" + || runtime.status === "running" + || runtime.status === "degraded" + || runtime.status === "stopping" + ) + ) { + return true; + } } } } catch (error) { @@ -1183,12 +1428,14 @@ app.whenReady().then(async () => { }; const rebalanceProjectContexts = async (): Promise<void> => { - const currentActiveRoot = activeProjectRoot; - if (!currentActiveRoot) return; + const activeRoots = rootsBoundToWindows(); + if (activeProjectRoot) activeRoots.add(activeProjectRoot); + if (activeRoots.size === 0) return; + const currentActiveRoot = activeProjectRoot ?? [...activeRoots][0] ?? null; const idleRoots: string[] = []; for (const [projectRoot, ctx] of projectContexts.entries()) { - if (projectRoot === currentActiveRoot) continue; + if (activeRoots.has(projectRoot)) continue; if (await hasActiveProjectWorkloads(projectRoot, ctx)) { ctx.logger.info("project.context_retained", { projectRoot, @@ -1209,12 +1456,17 @@ app.whenReady().then(async () => { ); for (const projectRoot of idleRoots) { - if (activeProjectRoot !== currentActiveRoot) { + const nextActiveRoots = rootsBoundToWindows(); + if (activeProjectRoot) nextActiveRoots.add(activeProjectRoot); + const stillSameActiveSet = + nextActiveRoots.size === activeRoots.size + && [...activeRoots].every((root) => nextActiveRoots.has(root)); + if (!stillSameActiveSet) { return; } const ctx = projectContexts.get(projectRoot); if (!ctx) continue; - if (projectRoot === activeProjectRoot) continue; + if (rootsBoundToWindows().has(projectRoot) || projectRoot === activeProjectRoot) continue; if (warmRoots.has(projectRoot)) { ctx.logger.info("project.context_retained", { projectRoot, @@ -1362,6 +1614,7 @@ app.whenReady().then(async () => { logger, }); adeCliService.applyToProcessEnv(); + installAdeCliForTerminalInBackground(adeCliService, logger); const devToolsService = createDevToolsService({ logger }); const project = toProjectInfo(projectRoot, baseRef); @@ -1680,6 +1933,7 @@ app.whenReady().then(async () => { projectRoot, aiIntegrationService, githubService, + onSubmissionUpdated: (event) => broadcast(IPC.feedbackOnUpdate, event), }); const conflictService = createConflictService({ @@ -1849,13 +2103,23 @@ app.whenReady().then(async () => { registry?.invalidateApnsToken?.(deviceToken); }); + const rpcEventBuffer = createEventBuffer(); + const emitPrEvent = (event: PrEventPayload): void => { + emitProjectEvent(projectRoot, IPC.prsEvent, event); + rpcEventBuffer.push({ + timestamp: new Date().toISOString(), + category: "runtime", + payload: { type: "pr_event", event }, + }); + }; + const prPollingService = createPrPollingService({ logger, prService, projectConfigService, db, notificationEventBus, - onEvent: (event) => emitProjectEvent(projectRoot, IPC.prsEvent, event), + onEvent: emitPrEvent, onPullRequestsChanged: async ({ changedPrs, changes }) => { if (changedPrs.length > 0) { prService.markHotRefresh(changedPrs.map((pr) => pr.id)); @@ -1898,9 +2162,6 @@ app.whenReady().then(async () => { let linearDispatcherServiceRef: ReturnType< typeof createLinearDispatcherService > | null = null; - let openclawBridgeServiceRef: ReturnType< - typeof createOpenclawBridgeService - > | null = null; let linearSyncServiceRef: ReturnType< typeof createLinearSyncService > | null = null; @@ -1916,7 +2177,7 @@ app.whenReady().then(async () => { prService, laneService, conflictService, - emitEvent: (event) => emitProjectEvent(projectRoot, IPC.prsEvent, event), + emitEvent: emitPrEvent, onStateChanged: (state) => { const hotPrIds = new Set<string>(); const currentEntry = state.entries[state.currentPosition]; @@ -2425,7 +2686,6 @@ app.whenReady().then(async () => { getAdeCliAgentEnv: adeCliService.agentEnv, onEvent: (event) => { aiOrchestratorServiceRef?.onAgentChatEvent(event); - openclawBridgeServiceRef?.onAgentChatEvent(event); emitProjectEvent(projectRoot, IPC.agentChatEvent, event); // Capture agent session errors as failure gotchas for the memory system @@ -2520,7 +2780,6 @@ app.whenReady().then(async () => { laneService, projectConfigService, broadcastEvent: (ev) => { - openclawBridgeServiceRef?.onTestEvent(ev); emitProjectEvent(projectRoot, IPC.testsEvent, ev); }, }); @@ -2604,7 +2863,6 @@ app.whenReady().then(async () => { .catch(() => {}); }, onEvent: (event) => { - openclawBridgeServiceRef?.onMissionEvent(event); emitProjectEvent(projectRoot, IPC.missionsEvent, event); if (event.missionId) { automationService?.onMissionUpdated({ missionId: event.missionId }); @@ -2759,32 +3017,6 @@ app.whenReady().then(async () => { "ADE_ENABLE_PORT_ALLOCATION_RECOVERY", ); - const openclawBridgeService = createOpenclawBridgeService({ - projectRoot, - adeDir: adePaths.adeDir, - laneService, - agentChatService, - ctoStateService, - workerAgentService, - missionService, - logger, - appVersion: app.getVersion(), - onStatusChange: (status) => - emitProjectEvent(projectRoot, IPC.openclawConnectionStatus, status), - }); - openclawBridgeServiceRef = openclawBridgeService; - scheduleBackgroundProjectTask( - "openclaw_bridge.start", - () => openclawBridgeService.start(), - (error) => { - logger.warn("openclaw_bridge.start_failed", { - error: error instanceof Error ? error.message : String(error), - }); - }, - 0, - "ADE_ENABLE_OPENCLAW", - ); - const orchestratorService = createOrchestratorService({ db, projectId, @@ -2803,7 +3035,6 @@ app.whenReady().then(async () => { knowledgeCaptureService, onEvent: (event) => { aiOrchestratorServiceRef?.onOrchestratorRuntimeEvent(event); - openclawBridgeServiceRef?.onOrchestratorEvent(event); emitProjectEvent(projectRoot, IPC.orchestratorEvent, event); }, }); @@ -2987,21 +3218,22 @@ app.whenReady().then(async () => { emitProjectEvent(projectRoot, IPC.orchestratorDagMutation, event), }); aiOrchestratorServiceRef = aiOrchestratorService; - // Phone sync is an app-level feature. A single project context still backs - // the project-scoped data stream, but the backing context is selected by the - // app-level sync host root rather than the visible project tab alone. - // ADE_DISABLE_SYNC_HOST=1 is a global kill switch for tests / CI. + // Phone sync is owned by the per-machine ADE service. The desktop + // keeps a non-host sync service for legacy viewer state and explicit + // diagnostics only; ADE_ENABLE_DESKTOP_SYNC_HOST=1 re-enables the old + // in-process host path while debugging migrations. const mobileSyncHostRoot = getMobileSyncHostRoot(); const isMobileSyncHostContext = mobileSyncHostRoot != null && normalizeProjectRoot(projectRoot) === mobileSyncHostRoot; - const syncHostAutoStart = - process.env.ADE_DISABLE_SYNC_HOST !== "1" && isMobileSyncHostContext; + const syncHostAutoStart = isMobileSyncHostContext; const syncService = createSyncService({ db, logger, projectRoot, - localDeviceIdPath: path.join(app.getPath("userData"), "sync-device-id"), + projectId, + appVersion: app.getVersion(), + localDeviceIdPath: path.join(machineAdeLayout.secretsDir, "sync-device-id"), fileService, laneService, gitService, @@ -3034,7 +3266,7 @@ app.whenReady().then(async () => { getLinearSyncService: () => linearSyncServiceRef, processService, hostStartupEnabled: syncHostAutoStart, - phonePairingStateDir: path.join(app.getPath("userData"), "phone-sync"), + phonePairingStateDir: machineAdeLayout.secretsDir, hostDiscoveryEnabled: isMobileSyncHostContext, forceHostRole: true, notificationEventBus, @@ -3569,7 +3801,6 @@ app.whenReady().then(async () => { writeGlobalState(globalStatePath, state); // ── ADE RPC Socket Server (embedded mode) ───────────────────── - const rpcEventBuffer = createEventBuffer(); const rpcRuntime: AdeRuntime = { projectRoot, workspaceRoot: projectRoot, @@ -3617,7 +3848,6 @@ app.whenReady().then(async () => { workerHeartbeatService, workerTaskSessionService, linearCredentialService, - openclawBridgeService, flowPolicyService, linearDispatcherService, linearIssueTracker, @@ -3644,22 +3874,41 @@ app.whenReady().then(async () => { budgetCapService, sessionDeltaService, autoUpdateService, + appNavigationService: { + navigate: async (request) => { + const normalizedRoot = normalizeProjectRoot(projectRoot); + let targetWindow = BrowserWindow.getAllWindows() + .find((win) => !win.isDestroyed() && windowProjectRoots.get(win.id) === normalizedRoot) ?? null; + if (!targetWindow) { + const opened = await openAdeWindow({ projectRoot }); + targetWindow = opened.windowId != null ? BrowserWindow.fromId(opened.windowId) : null; + } + if (!targetWindow || targetWindow.isDestroyed()) { + return { + ok: false, + mode: "unavailable" as const, + message: "No ADE window is available for this project.", + }; + } + if (targetWindow.isMinimized()) targetWindow.restore(); + targetWindow.show(); + targetWindow.focus(); + targetWindow.webContents.send(IPC.appNavigate, request); + return { + ok: true, + mode: "desktop" as const, + windowId: targetWindow.id, + }; + }, + }, issueInventoryService, eventBuffer: rpcEventBuffer, dispose: () => {}, // desktop manages service lifecycle }; - // When ADE_RPC_SOCKET_PATH is set, derive a per-project socket path from - // the override so each project context gets its own socket and avoids - // EADDRINUSE. The first context uses the env path as-is for compatibility; - // subsequent contexts append a project-root hash suffix. - const envSocketOverride = process.env.ADE_RPC_SOCKET_PATH?.trim(); - const rpcSocketPath = envSocketOverride - ? projectContexts.size === 0 - ? envSocketOverride - : `${envSocketOverride}.${Buffer.from(normalizeProjectRoot(projectRoot)).toString("base64url").slice(0, 8)}` - : adePaths.socketPath; const activeRpcConnections = new Set<net.Socket>(); + let rpcSocketServer: net.Server | undefined; + let rpcSocketPath: string | undefined; const destroyActiveRpcConnections = (): void => { for (const conn of activeRpcConnections) { @@ -3676,66 +3925,93 @@ app.whenReady().then(async () => { destroyActiveRpcConnections, ); - if (!isAdeMcpNamedPipePath(rpcSocketPath)) { - try { - fs.unlinkSync(rpcSocketPath); - } catch {} - } + if (process.env.ADE_ENABLE_DESKTOP_RPC_SOCKET === "1") { + // Legacy compatibility: the ADE service owns ADE RPC by default. + // When explicitly enabled, derive a per-project socket path so multiple + // desktop project contexts do not collide on the same override. + const envSocketOverride = process.env.ADE_RPC_SOCKET_PATH?.trim(); + rpcSocketPath = envSocketOverride + ? projectContexts.size === 0 + ? envSocketOverride + : `${envSocketOverride}.${Buffer.from(normalizeProjectRoot(projectRoot)).toString("base64url").slice(0, 8)}` + : adePaths.socketPath; + + if (!isAdeMcpNamedPipePath(rpcSocketPath)) { + try { + fs.unlinkSync(rpcSocketPath); + } catch {} + } - const rpcSocketServer = net.createServer((conn) => { - activeRpcConnections.add(conn); - let stopped = false; - const transport: JsonRpcTransport = { - onData(callback) { - conn.on("data", callback); - }, - write(data) { - conn.write(data); - }, - close() { - if (!conn.destroyed) conn.destroy(); - }, - }; - let stop: ReturnType<typeof startJsonRpcServer> | null = null; - const rpcHandler = createAdeRpcRequestHandler({ - runtime: rpcRuntime, - serverVersion: app.getVersion(), - onActionsListChanged: () => { - stop?.notify("ade/actions/list_changed", {}); - }, - }); - stop = startJsonRpcServer(rpcHandler, transport, { nonFatal: true }); - const removeConnection = (): void => { - activeRpcConnections.delete(conn); - }; - conn.once("close", removeConnection); - conn.once("end", removeConnection); - conn.once("error", removeConnection); - conn.on("close", () => { - if (!stopped) { - stopped = true; - stop?.(); - } - rpcHandler.dispose(); - }); - conn.on("error", () => {}); // ignore connection errors - }); - await measureProjectInitStep("rpc.socket_server_start", () => - new Promise<void>((resolve, reject) => { - const handleListening = () => { - rpcSocketServer.off("error", handleError); - resolve(); + const server = net.createServer((conn) => { + activeRpcConnections.add(conn); + let stopped = false; + const transport: JsonRpcTransport = { + onData(callback) { + conn.on("data", callback); + }, + write(data) { + conn.write(data); + }, + close() { + if (!conn.destroyed) conn.destroy(); + }, }; - const handleError = (error: Error) => { - rpcSocketServer.off("listening", handleListening); - reject(error); + let stop: ReturnType<typeof startJsonRpcServer> | null = null; + const rpcHandler = createAdeRpcRequestHandler({ + runtime: rpcRuntime, + serverVersion: app.getVersion(), + onActionsListChanged: () => { + stop?.notify("ade/actions/list_changed", {}); + }, + }); + stop = startJsonRpcServer(rpcHandler, transport, { nonFatal: true }); + const unsubscribeChatEvents = rpcRuntime.agentChatService?.subscribeToEvents((event) => { + stop?.notify("chat/event", event); + }) ?? (() => {}); + let removedConnection = false; + const removeConnection = (): void => { + if (removedConnection) return; + removedConnection = true; + activeRpcConnections.delete(conn); + unsubscribeChatEvents(); }; - rpcSocketServer.once("listening", handleListening); - rpcSocketServer.once("error", handleError); - rpcSocketServer.listen(rpcSocketPath); - }), - ); - logger.info("rpc.socket_server_started", { socketPath: rpcSocketPath }); + conn.once("close", removeConnection); + conn.once("end", removeConnection); + conn.once("error", removeConnection); + conn.on("close", () => { + if (!stopped) { + stopped = true; + stop?.(); + } + rpcHandler.dispose(); + }); + conn.on("error", () => {}); // ignore connection errors + }); + rpcSocketServer = server; + await measureProjectInitStep("rpc.socket_server_start", () => + new Promise<void>((resolve, reject) => { + const handleListening = () => { + server.off("error", handleError); + resolve(); + }; + const handleError = (error: Error) => { + server.off("listening", handleListening); + reject(error); + }; + server.once("listening", handleListening); + server.once("error", handleError); + server.listen(rpcSocketPath); + }), + ); + logger.warn("rpc.socket_server_started", { + socketPath: rpcSocketPath, + mode: "legacy_desktop", + }); + } else { + logger.info("rpc.socket_server_skipped", { + reason: "runtime_daemon_owns_rpc", + }); + } // Wire the automation runtime into the shared ADE-action registry so // that `ade-action` automation steps can invoke the same domain services @@ -3892,7 +4168,6 @@ app.whenReady().then(async () => { embeddingService, embeddingWorkerService, ctoStateService, - openclawBridgeService, workerAgentService, adeProjectService, workerRevisionService, @@ -3911,6 +4186,37 @@ app.whenReady().then(async () => { }; }; + const initRuntimeBackedProjectContext = async ({ + projectRoot, + baseRef, + userSelectedProject, + }: { + projectRoot: string; + baseRef: string; + userSelectedProject: boolean; + }): Promise<AppContext> => { + const adePaths = ensureAdeDirs(projectRoot); + const logger = createFileLogger(path.join(adePaths.logsDir, "main.jsonl")); + const project = toProjectInfo(projectRoot, baseRef); + const runtimeProject = await localRuntimePool.ensureProject(projectRoot); + const shellContext = createDormantProjectContext(projectRoot); + logger.info("project.runtime_bound", { + projectRoot, + projectId: runtimeProject.projectId, + mode: "local_runtime_daemon", + }); + return { + ...shellContext, + logger, + project, + projectId: runtimeProject.projectId, + adeDir: adePaths.adeDir, + hasUserSelectedProject: userSelectedProject, + adeCliService: shellContext.adeCliService, + builtInBrowserService, + } as AppContext; + }; + const createDormantProjectContext = (projectRoot = ""): AppContext => { const rootIsDefined = typeof projectRoot === "string" && projectRoot.trim().length > 0; @@ -3936,6 +4242,15 @@ app.whenReady().then(async () => { logger, githubService: dormantGithubService, }); + const adeCliService = createAdeCliService({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + userDataPath: app.getPath("userData"), + appExecutablePath: process.execPath, + logger, + }); + adeCliService.applyToProcessEnv(); + installAdeCliForTerminalInBackground(adeCliService, logger); return { db: null, logger, @@ -3947,13 +4262,7 @@ app.whenReady().then(async () => { disposeHeadWatcher: () => {}, keybindingsService: null, agentToolsService: null, - adeCliService: createAdeCliService({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - userDataPath: app.getPath("userData"), - appExecutablePath: process.execPath, - logger, - }), + adeCliService, devToolsService: null, onboardingService: null, laneService: null, @@ -4019,7 +4328,6 @@ app.whenReady().then(async () => { proceduralLearningService: null, skillRegistryService: null, ctoStateService: null, - openclawBridgeService: null, workerAgentService: null, adeProjectService: null, workerRevisionService: null, @@ -4152,11 +4460,6 @@ app.whenReady().then(async () => { } catch { // ignore } - try { - await ctx.openclawBridgeService?.stop?.(); - } catch { - // ignore - } try { await ctx.skillRegistryService?.dispose?.(); } catch { @@ -4275,7 +4578,7 @@ app.whenReady().then(async () => { for (const root of roots) { await closeProjectContext(root); } - setActiveProject(null); + setForegroundProject(null); }; async function mobileProjectSummaryForContext( @@ -4371,7 +4674,7 @@ app.whenReady().then(async () => { const existing = projectContexts.get(normalizedRoot); if (existing) return existing; if (!fs.existsSync(normalizedRoot)) { - throw new Error("Project is no longer available on this desktop."); + throw new Error("Project is no longer available on this machine."); } let initPromise = projectInitPromises.get(normalizedRoot); @@ -4429,14 +4732,14 @@ app.whenReady().then(async () => { if (!catalogEntry || !catalogEntry.isAvailable) { return { ok: false, - message: "That project is not available from this desktop.", + message: "That project is not available from this machine.", }; } const targetRoot = catalogEntry.rootPath ? normalizeProjectRoot(catalogEntry.rootPath) : null; if (!targetRoot) { return { ok: false, - message: "Choose a desktop project first.", + message: "Choose a machine project first.", }; } @@ -4597,12 +4900,11 @@ app.whenReady().then(async () => { const existing = projectContexts.get(repoRoot); if (existing) { existing.hasUserSelectedProject = true; - setActiveProject(repoRoot); persistRecentProject(existing.project, { recordLastProject: true, preserveRecentOrder: isKnownRecentProject, }); - emitProjectChanged(existing.project); + bindWindowToProject(currentIpcWindowId(), repoRoot, { emit: true, foreground: true }); scheduleProjectContextRebalance(); projectOpenLogger.info("project.open.done", { selectedPath, @@ -4625,18 +4927,25 @@ app.whenReady().then(async () => { durationMs: Date.now() - baseRefStartedAt, }); const initStartedAt = Date.now(); - const ctx = await initContextForProjectRoot({ - projectRoot: repoRoot!, - baseRef, - ensureExclude: true, - recordLastProject: true, - recordRecent: true, - preserveRecentOrder: isKnownRecentProject, - userSelectedProject: true, - }); + const ctx = useInProcessProjectRuntime() + ? await initContextForProjectRoot({ + projectRoot: repoRoot!, + baseRef, + ensureExclude: true, + recordLastProject: true, + recordRecent: true, + preserveRecentOrder: isKnownRecentProject, + userSelectedProject: true, + }) + : await initRuntimeBackedProjectContext({ + projectRoot: repoRoot!, + baseRef, + userSelectedProject: true, + }); projectOpenLogger.info("project.open.context_initialized", { selectedPath, repoRoot, + mode: useInProcessProjectRuntime() ? "in_process" : "local_runtime_daemon", durationMs: Date.now() - initStartedAt, }); projectContexts.set(repoRoot!, ctx); @@ -4651,12 +4960,12 @@ app.whenReady().then(async () => { const ctx = await initPromise; ctx.hasUserSelectedProject = true; - setActiveProject(repoRoot); persistRecentProject(ctx.project, { recordLastProject: true, - recordRecent: false, + recordRecent: true, + preserveRecentOrder: isKnownRecentProject, }); - emitProjectChanged(ctx.project); + bindWindowToProject(currentIpcWindowId(), repoRoot, { emit: true, foreground: true }); scheduleProjectContextRebalance(); projectOpenLogger.info("project.open.done", { selectedPath, @@ -4680,22 +4989,36 @@ app.whenReady().then(async () => { const closeProjectByPath = async (projectRoot: string): Promise<void> => { const normalizedRoot = normalizeProjectRoot(projectRoot); const wasActive = activeProjectRoot === normalizedRoot; + for (const [windowId, root] of windowProjectRoots) { + if (root === normalizedRoot) { + windowProjectRoots.set(windowId, null); + windowProjectBindings.delete(windowId); + emitProjectChangedToWindow(windowId, null); + emitProjectBindingChangedToWindow(windowId, null); + } + } await closeProjectContext(normalizedRoot); if (wasActive) { + setForegroundProject(firstOpenWindowProjectRoot()); dormantContext = createDormantProjectContext(normalizedRoot); - emitProjectChanged(null); } }; const closeCurrentProject = async () => { const current = getActiveContext(); const previousRoot = current.project?.rootPath ?? ""; + const windowId = currentIpcWindowId(); + if (windowId != null) { + bindWindowToProject(windowId, null, { emit: true, foreground: true }); + dormantContext = createDormantProjectContext(previousRoot); + scheduleProjectContextRebalance(); + return; + } if (activeProjectRoot) { await closeProjectContext(activeProjectRoot); } - setActiveProject(null); + setForegroundProject(null); dormantContext = createDormantProjectContext(previousRoot); - emitProjectChanged(null); }; dormantContext = createDormantProjectContext(); @@ -4843,7 +5166,7 @@ app.whenReady().then(async () => { } catch { // ignore } - setActiveProject(null); + setForegroundProject(null); dormantContext = createDormantProjectContext(previousRoot); try { @@ -4861,38 +5184,85 @@ app.whenReady().then(async () => { }); }; - const confirmQuitWarning = (): boolean => { - if (quitWarningAcknowledged || shutdownRequested) return true; - const options = { + const showWindowCloseWarning = ( + ownerWindow: BrowserWindow | null | undefined, + options: { + buttons: string[]; + title: string; + message: string; + detail: string; + rememberQuitAcknowledgement?: boolean; + }, + ): boolean => { + if (shutdownRequested) return true; + if (options.rememberQuitAcknowledgement && quitWarningAcknowledged) return true; + const dialogOptions = { type: "warning" as const, - buttons: ["Keep ADE open", "Quit ADE"], + buttons: options.buttons, defaultId: 0, cancelId: 0, noLink: true, - title: "Quit ADE?", - message: "Save your work before closing ADE.", - detail: - "Quitting ADE will end any running agents and stop background processes started by ADE, including OpenCode servers, terminal sessions, and test runs.", + title: options.title, + message: options.message, + detail: options.detail, }; - const parentWindow = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + const parentWindow = + ownerWindow && !ownerWindow.isDestroyed() + ? ownerWindow + : BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; const response = parentWindow - ? dialog.showMessageBoxSync(parentWindow, options) - : dialog.showMessageBoxSync(options); + ? dialog.showMessageBoxSync(parentWindow, dialogOptions) + : dialog.showMessageBoxSync(dialogOptions); if (response !== 1) { return false; } - quitWarningAcknowledged = true; + if (options.rememberQuitAcknowledgement) { + quitWarningAcknowledged = true; + } return true; }; + const confirmQuitWarning = (ownerWindow?: BrowserWindow | null): boolean => + showWindowCloseWarning(ownerWindow, { + buttons: ["Keep ADE open", "Quit ADE"], + title: "Quit ADE?", + message: "Save your work before closing ADE.", + detail: + "Quitting ADE will end agents and background processes owned by this desktop session, including OpenCode servers, terminal sessions, and test runs. The ADE service login item keeps running separately when it is installed.", + rememberQuitAcknowledgement: true, + }); + + const confirmCloseWindowWarning = (ownerWindow: BrowserWindow): boolean => + showWindowCloseWarning(ownerWindow, { + buttons: ["Keep window open", "Close window"], + title: "Close ADE window?", + message: "Close this ADE window?", + detail: + "ADE will keep running in other windows. Active agents and background processes continue unless you quit ADE.", + rememberQuitAcknowledgement: false, + }); + + const closeWindowWithoutPrompt = (win: BrowserWindow): void => { + closeWindowWithoutQuitPrompt.add(win.id); + win.close(); + if (!win.isDestroyed()) { + closeWindowWithoutQuitPrompt.delete(win.id); + } + }; + const handleMainWindowCloseRequested = ( - _win: BrowserWindow, + win: BrowserWindow, event: Electron.Event, ): void => { if (shutdownRequested) return; - if (BrowserWindow.getAllWindows().length > 1) return; + if (closeWindowWithoutQuitPrompt.delete(win.id)) return; event.preventDefault(); - if (!confirmQuitWarning()) return; + if (BrowserWindow.getAllWindows().filter((openWindow) => !openWindow.isDestroyed()).length > 1) { + if (!confirmCloseWindowWarning(win)) return; + closeWindowWithoutPrompt(win); + return; + } + if (!confirmQuitWarning(win)) return; requestAppShutdown({ reason: "window_close", exitCode: 0 }); }; @@ -4977,6 +5347,177 @@ app.whenReady().then(async () => { }); }); + const firstOpenWindowProjectRoot = (): string | null => { + for (const win of BrowserWindow.getAllWindows()) { + const root = windowProjectRoots.get(win.id); + if (root) return root; + } + return null; + }; + + const registerWindowSession = (win: BrowserWindow, projectRoot: string | null = null): void => { + windowProjectRoots.set(win.id, projectRoot ? normalizeProjectRoot(projectRoot) : null); + windowProjectBindings.delete(win.id); + win.on("focus", () => { + setForegroundProject(windowProjectRoots.get(win.id) ?? null); + builtInBrowserService.attachToWindow(win); + }); + win.on("closed", () => { + const previousRoot = windowProjectRoots.get(win.id) ?? null; + windowProjectRoots.delete(win.id); + windowProjectBindings.delete(win.id); + if (activeProjectRoot === previousRoot) { + setForegroundProject(firstOpenWindowProjectRoot()); + } + scheduleProjectContextRebalance(); + }); + }; + + const getWindowSession = (windowId: number | null): { windowId: number | null; project: ProjectInfo | null; binding: OpenProjectBinding | null } => { + if (windowId == null) { + const project = projectForRoot(activeProjectRoot); + return { windowId: null, project, binding: bindingForLocalProject(project) }; + } + const remoteBinding = windowProjectBindings.get(windowId) ?? null; + if (remoteBinding) return { windowId, project: null, binding: remoteBinding }; + const project = projectForRoot(windowProjectRoots.get(windowId) ?? null); + return { + windowId, + project, + binding: bindingForLocalProject(project), + }; + }; + + const openAdeWindow = async ( + args: { projectRoot?: string | null } = {}, + ): Promise<{ windowId: number | null; project: ProjectInfo | null }> => { + const win = await createWindow({ + logger: getActiveContext().logger, + onCreated: (createdWindow) => registerWindowSession(createdWindow, null), + onCloseRequested: handleMainWindowCloseRequested, + }); + builtInBrowserService.attachToWindow(win); + if (args.projectRoot) { + await ipcWindowScope.run(win.id, async () => { + await switchProjectFromDialog(args.projectRoot!); + }); + } else { + emitProjectChangedToWindow(win.id, null); + emitProjectBindingChangedToWindow(win.id, null); + } + return getWindowSession(win.id); + }; + + const openProjectFileRequest = async (filePath: string): Promise<void> => { + const projectRoot = normalizeProjectPath(filePath); + if (!isLikelyRepoRoot(projectRoot)) return; + const normalizedRoot = normalizeProjectRoot(projectRoot); + const existing = BrowserWindow.getAllWindows() + .find((win) => !win.isDestroyed() && windowProjectRoots.get(win.id) === normalizedRoot) ?? null; + if (existing) { + if (existing.isMinimized()) existing.restore(); + existing.show(); + existing.focus(); + return; + } + await openAdeWindow({ projectRoot: normalizedRoot }); + }; + + handleProjectOpenFile = (filePath) => { + void openProjectFileRequest(filePath).catch((error) => { + getActiveContext().logger.warn("project.open_file_request_failed", { + filePath, + error: error instanceof Error ? error.message : String(error), + }); + }); + }; + + for (const filePath of pendingProjectOpenFiles.splice(0)) { + handleProjectOpenFile(filePath); + } + + const closeAdeWindow = async (windowId: number | null): Promise<{ closed: boolean }> => { + if (windowId == null) return { closed: false }; + const win = BrowserWindow.fromId(windowId); + if (!win || win.isDestroyed()) return { closed: false }; + closeWindowWithoutPrompt(win); + return { closed: true }; + }; + + const installApplicationMenu = (): void => { + const template: Electron.MenuItemConstructorOptions[] = [ + ...(process.platform === "darwin" + ? [{ + label: app.name, + submenu: [ + { role: "about" as const }, + { type: "separator" as const }, + { role: "hide" as const }, + { role: "hideOthers" as const }, + { role: "unhide" as const }, + { type: "separator" as const }, + { role: "quit" as const }, + ], + }] + : []), + { + label: "File", + submenu: [ + { + label: "New window", + accelerator: "CommandOrControl+N", + click: () => { + void openAdeWindow(); + }, + }, + { type: "separator" }, + { role: "close" }, + ], + }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "selectAll" }, + ], + }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { + label: "Window", + submenu: [ + { role: "minimize" }, + { role: "zoom" }, + ...(process.platform === "darwin" + ? [ + { type: "separator" as const }, + { role: "front" as const }, + ] + : []), + ], + }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); + }; + + installApplicationMenu(); + registerIpc({ getCtx: () => { const ctx = getActiveContext(); @@ -4989,6 +5530,13 @@ app.whenReady().then(async () => { return getMobileSyncService(); }, resolveSyncService: ensureMobileSyncService, + runWithIpcWindow: (event, fn) => + ipcWindowScope.run(BrowserWindow.fromWebContents(event.sender)?.id ?? null, fn), + getWindowSession, + bindRemoteProject: bindWindowToRemoteProject, + localRuntimeConnectionPool: localRuntimePool, + createWindow: openAdeWindow, + closeWindow: closeAdeWindow, switchProjectFromDialog, closeCurrentProject, closeProjectByPath, @@ -4996,31 +5544,41 @@ app.whenReady().then(async () => { builtInBrowserService, }); - // Dogfood and other explicit ADE_PROJECT_ROOT launches need the project - // context ready before the renderer boots, otherwise the window can paint - // the welcome state and swallow project selection into a confusing no-op. - if (startupUserSelected) { + // Restore the startup project before the renderer boots so packaged launches + // do not flash into the welcome state and lose the previous project context. + if (shouldOpenStartupProject && startupProject.rootPath) { try { - await switchProjectFromDialog(initialCandidate); + await switchProjectFromDialog(startupProject.rootPath); } catch { - setActiveProject(null); + setForegroundProject(null); dormantContext = createDormantProjectContext(); } } + const initialWindowProjectRoot = shouldOpenStartupProject ? activeProjectRoot : null; const initialWindow = await createWindow({ logger: getActiveContext().logger, + onCreated: (createdWindow) => registerWindowSession(createdWindow, initialWindowProjectRoot), onCloseRequested: handleMainWindowCloseRequested, }); builtInBrowserService.attachToWindow(initialWindow); + if (shouldShowRuntimeMigrationNotice && process.env.NODE_ENV !== "test") { + void dialog.showMessageBox(initialWindow, { + type: "info", + buttons: ["Got it"], + defaultId: 0, + title: "ADE now runs in the background", + message: "ADE now runs in the background", + detail: [ + "Your machine can stay available for mobile pairing and agent work after the app window closes.", + "You can remove the background service by running `ade serve --uninstall-service`.", + ].join("\n\n"), + }).catch(() => {}); + } app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { - const activatedWindow = await createWindow({ - logger: getActiveContext().logger, - onCloseRequested: handleMainWindowCloseRequested, - }); - builtInBrowserService.attachToWindow(activatedWindow); + await openAdeWindow(); } }); diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 9b4605abf..b8db308d2 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { LaneListSnapshot, LaneSummary, TerminalSessionSummary } from "../../../shared/types"; import { ADE_ACTION_ALLOWLIST, + getAdeActionDomainServices, isCtoOnlyAdeAction, isAllowedAdeAction, listAllowedAdeActionNames, @@ -104,6 +106,13 @@ describe("isCtoOnlyAdeAction", () => { expect(isCtoOnlyAdeAction("path_to_merge", "startPathToMerge")).toBe(true); expect(isCtoOnlyAdeAction("path_to_merge", "stopPathToMerge")).toBe(true); }); + + it("keeps AI credential mutations CTO-only", () => { + expect(isCtoOnlyAdeAction("ai", "storeApiKey")).toBe(true); + expect(isCtoOnlyAdeAction("ai", "deleteApiKey")).toBe(true); + expect(isCtoOnlyAdeAction("ai", "listApiKeys")).toBe(false); + }); + }); describe("ADE_ACTION_ALLOWLIST shape", () => { @@ -142,6 +151,46 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { } }); + it("exposes lane.listSnapshots for runtime-backed lane snapshot parity", () => { + const actions = ADE_ACTION_ALLOWLIST.lane ?? []; + expect(actions).toContain("listSnapshots"); + }); + + it("exposes ade_project.clearLocalData for runtime-backed cleanup", () => { + const actions = ADE_ACTION_ALLOWLIST.ade_project ?? []; + expect(actions).toContain("clearLocalData"); + }); + + it("exposes session.getDelta for runtime-backed session delta reads", () => { + const actions = ADE_ACTION_ALLOWLIST.session ?? []; + expect(actions).toContain("getDelta"); + }); + + it("exposes computer_use_artifacts.readArtifactPreview for runtime-backed proof previews", () => { + const actions = ADE_ACTION_ALLOWLIST.computer_use_artifacts ?? []; + expect(actions).toContain("readArtifactPreview"); + }); + + it("exposes Linear issue tracker composite reads for runtime-backed CTO views", () => { + const actions = ADE_ACTION_ALLOWLIST.linear_issue_tracker ?? []; + expect(actions).toContain("getWorkflowCatalog"); + expect(actions).toContain("getIssuePickerData"); + expect(actions).toContain("getConnectionStatus"); + expect(actions).toContain("getQuickView"); + expect(ADE_ACTION_ALLOWLIST.linear_routing ?? []).toContain("simulateRoute"); + expect(ADE_ACTION_ALLOWLIST.linear_oauth ?? []).toEqual(expect.arrayContaining([ + "getSession", + "startSession", + ])); + }); + + it("exposes CTO identity session and scan wrappers for runtime-backed CTO views", () => { + const chatActions = ADE_ACTION_ALLOWLIST.chat ?? []; + expect(chatActions).toContain("ensureCtoSession"); + expect(chatActions).toContain("ensureAgentIdentitySession"); + expect(ADE_ACTION_ALLOWLIST.cto_state ?? []).toContain("runProjectScan"); + }); + it("exposes the browser panel and tab control surface", () => { const actions = ADE_ACTION_ALLOWLIST.built_in_browser ?? []; for (const name of ["showPanel", "navigate", "createTab", "switchTab", "closeTab"]) { @@ -156,3 +205,694 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { } }); }); + +describe("runtime Linear issue tracker actions", () => { + it("builds catalog and picker payloads from tracker reads", async () => { + const projects = [{ id: "project-1", name: "ADE" }]; + const users = [{ id: "user-1", name: "Arul" }]; + const labels = [{ id: "label-1", name: "Bug" }]; + const states = [{ id: "state-1", name: "Todo" }]; + const issues = [ + { id: "LIN-1", title: "First" }, + { id: "LIN-2", title: "Second" }, + { id: "LIN-3", title: "Third" }, + ]; + const fetchCandidateIssues = vi.fn(async () => issues); + const tracker = { + getConnectionStatus: vi.fn(async () => ({ + connected: true, + viewerId: "user-1", + viewerName: "Arul", + message: null, + })), + fetchCandidateIssues, + listProjects: vi.fn(async () => projects), + listUsers: vi.fn(async () => users), + listLabels: vi.fn(async () => labels), + listWorkflowStates: vi.fn(async () => states), + }; + const runtime = { + linearCredentialService: { + getStatus: vi.fn(() => ({ + tokenStored: true, + authMode: "oauth", + oauthConfigured: true, + tokenExpiresAt: null, + })), + }, + linearIssueTracker: tracker, + } as unknown as Parameters<typeof getAdeActionDomainServices>[0]; + const service = getAdeActionDomainServices(runtime).linear_issue_tracker as { + getStatus: () => Promise<unknown>; + getWorkflowCatalog: () => Promise<unknown>; + getIssuePickerData: () => Promise<unknown>; + listIssues: (args?: Record<string, unknown>) => Promise<unknown>; + } & Record<string, unknown>; + + expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getStatus"); + expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("listIssues"); + expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getWorkflowCatalog"); + expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getIssuePickerData"); + await expect(service.getStatus()).resolves.toMatchObject({ connected: true, tokenStored: true }); + await expect(service.listIssues({ project: "desktop,cli", state: ["open"], limit: 2 })).resolves.toEqual(issues.slice(0, 2)); + expect(fetchCandidateIssues).toHaveBeenCalledWith({ projectSlugs: ["desktop", "cli"], stateTypes: ["open"] }); + await expect(service.getWorkflowCatalog()).resolves.toEqual({ users, labels, states }); + await expect(service.getIssuePickerData()).resolves.toEqual({ projects, users, states }); + }); +}); + +describe("runtime Linear OAuth actions", () => { + it("adds connection status to completed OAuth sessions", async () => { + const start = { sessionId: "linear-oauth-1", authUrl: "https://linear.app/oauth/authorize", redirectUri: "http://127.0.0.1:19836/oauth/callback" }; + const runtime = { + linearOAuthService: { + startSession: vi.fn(async () => start), + getSession: vi.fn(() => ({ status: "completed" })), + }, + linearCredentialService: { + getStatus: vi.fn(() => ({ + tokenStored: true, + authMode: "oauth", + oauthConfigured: true, + tokenExpiresAt: "2026-05-10T00:00:00.000Z", + })), + }, + linearIssueTracker: { + getConnectionStatus: vi.fn(async () => ({ + connected: true, + viewerId: "user-1", + viewerName: "Arul", + message: null, + })), + }, + } as unknown as Parameters<typeof getAdeActionDomainServices>[0]; + const service = getAdeActionDomainServices(runtime).linear_oauth as { + startSession: () => Promise<unknown>; + getSession: (sessionId: string) => Promise<unknown>; + } & Record<string, unknown>; + + expect(listAllowedAdeActionNames("linear_oauth", service)).toEqual(["getSession", "startSession"]); + await expect(service.startSession()).resolves.toEqual(start); + await expect(service.getSession("linear-oauth-1")).resolves.toMatchObject({ + status: "completed", + connection: { + tokenStored: true, + connected: true, + viewerId: "user-1", + viewerName: "Arul", + authMode: "oauth", + oauthAvailable: true, + }, + }); + }); +}); + +describe("runtime session actions", () => { + it("adds getDelta from the runtime session delta service", () => { + const delta = { sessionId: "session-1", filesChanged: 2 }; + const runtime = { + sessionService: { + get: vi.fn(), + list: vi.fn(), + readTranscriptTail: vi.fn(), + }, + sessionDeltaService: { + getSessionDelta: vi.fn(() => delta), + }, + } as unknown as Parameters<typeof getAdeActionDomainServices>[0]; + const sessionService = getAdeActionDomainServices(runtime).session as { + getDelta: (args: { sessionId: string }) => unknown; + } & Record<string, unknown>; + + expect(listAllowedAdeActionNames("session", sessionService)).toContain("getDelta"); + expect(sessionService.getDelta({ sessionId: "session-1" })).toEqual(delta); + expect(runtime.sessionDeltaService?.getSessionDelta).toHaveBeenCalledWith("session-1"); + }); +}); + +describe("runtime computer-use artifact actions", () => { + it("exposes artifact preview reads from the broker", async () => { + const broker = { + getBackendStatus: vi.fn(), + ingest: vi.fn(), + listArtifacts: vi.fn(), + readArtifactPreview: vi.fn(async () => "data:image/png;base64,AAAA"), + routeArtifact: vi.fn(), + updateArtifactReview: vi.fn(), + }; + const runtime = { + computerUseArtifactBrokerService: broker, + } as unknown as Parameters<typeof getAdeActionDomainServices>[0]; + const artifactService = getAdeActionDomainServices(runtime).computer_use_artifacts as { + readArtifactPreview: (args: { uri: string }) => Promise<string | null>; + } & Record<string, unknown>; + + expect(listAllowedAdeActionNames("computer_use_artifacts", artifactService)).toContain("readArtifactPreview"); + await expect(artifactService.readArtifactPreview({ uri: ".ade/artifacts/a.png" })).resolves.toBe("data:image/png;base64,AAAA"); + expect(broker.readArtifactPreview).toHaveBeenCalledWith({ uri: ".ade/artifacts/a.png" }); + }); +}); + +const TEST_NOW = "2026-05-10T00:00:00.000Z"; + +function makeLane(overrides: Partial<LaneSummary> & Pick<LaneSummary, "id" | "name">): LaneSummary { + return { + description: null, + laneType: "worktree", + baseRef: "main", + branchRef: `feature/${overrides.id}`, + worktreePath: `/tmp/${overrides.id}`, + attachedRootPath: null, + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { + dirty: false, + ahead: 0, + behind: 0, + remoteBehind: 0, + rebaseInProgress: false, + }, + color: null, + icon: null, + tags: [], + folder: null, + missionId: null, + laneRole: null, + createdAt: TEST_NOW, + archivedAt: null, + ...overrides, + }; +} + +function makeSession( + overrides: Partial<TerminalSessionSummary> & Pick<TerminalSessionSummary, "id" | "laneId">, +): TerminalSessionSummary { + return { + laneName: "Runtime lane", + ptyId: null, + tracked: true, + pinned: false, + goal: null, + toolType: "codex", + title: overrides.id, + status: "running", + startedAt: TEST_NOW, + endedAt: null, + exitCode: null, + transcriptPath: `/tmp/${overrides.id}.log`, + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "running", + resumeCommand: null, + ...overrides, + }; +} + +describe("runtime lane snapshot actions", () => { + it("builds rich lane.listSnapshots results from runtime services", async () => { + const lane = makeLane({ id: "lane-runtime", name: "Runtime lane" }); + const attachedLane = makeLane({ + id: "lane-attached", + name: "Attached lane", + laneType: "attached", + attachedRootPath: "/external/attached", + }); + const device = { deviceId: "ios-1", displayName: "iPhone", platform: "ios" }; + const rebaseSuggestion = { + laneId: lane.id, + parentLaneId: "parent-1", + parentHeadSha: "abc1234", + behindCount: 3, + baseLabel: "main", + groupContext: null, + lastSuggestedAt: TEST_NOW, + deferredUntil: null, + dismissedAt: null, + hasPr: true, + }; + const autoRebaseStatus = { + laneId: lane.id, + parentLaneId: "parent-1", + parentHeadSha: "def5678", + state: "rebaseConflict" as const, + updatedAt: TEST_NOW, + conflictCount: 2, + message: "Rebase needs attention.", + }; + const conflictStatus = { + laneId: lane.id, + status: "conflict-predicted" as const, + overlappingFileCount: 4, + peerConflictCount: 1, + lastPredictedAt: TEST_NOW, + }; + const stateSnapshot = { + laneId: lane.id, + agentSummary: { activeAgent: "codex" }, + missionSummary: { missionId: "mission-1" }, + updatedAt: TEST_NOW, + }; + const sessions = [ + makeSession({ + id: "running-terminal", + laneId: lane.id, + lastOutputPreview: "running tests", + }), + makeSession({ + id: "awaiting-chat", + laneId: lane.id, + toolType: "codex-chat", + lastOutputPreview: "thinking", + }), + makeSession({ + id: "ended-terminal", + laneId: lane.id, + status: "completed", + runtimeState: "exited", + endedAt: TEST_NOW, + exitCode: 0, + lastOutputPreview: "done", + }), + makeSession({ + id: "attached-running-terminal", + laneId: attachedLane.id, + }), + ]; + const list = vi.fn(() => [lane, attachedLane]); + const listStateSnapshots = vi.fn(() => [stateSnapshot]); + const runtime = { + laneService: { + list, + listStateSnapshots, + }, + sessionService: { + list: vi.fn(() => sessions), + }, + ptyService: { + enrichSessions: vi.fn((entries: TerminalSessionSummary[]) => entries), + }, + agentChatService: { + listSessions: vi.fn(async () => [ + { + sessionId: "awaiting-chat", + status: "active", + awaitingInput: true, + identityKey: null, + }, + ]), + }, + rebaseSuggestionService: { + listSuggestions: vi.fn(() => [rebaseSuggestion]), + }, + autoRebaseService: { + listStatuses: vi.fn(() => [autoRebaseStatus]), + }, + conflictService: { + getBatchAssessment: vi.fn(() => ({ lanes: [conflictStatus] })), + }, + syncService: { + getHostService: () => ({ + getLanePresenceSnapshot: () => [{ laneId: lane.id, devicesOpen: [device] }], + }), + }, + logger: { + info: vi.fn(), + warn: vi.fn(), + }, + } as unknown as Parameters<typeof getAdeActionDomainServices>[0]; + const laneService = getAdeActionDomainServices(runtime).lane as { + listSnapshots?: (args?: unknown) => Promise<LaneListSnapshot[]>; + }; + + expect(laneService.listSnapshots).toEqual(expect.any(Function)); + expect(listAllowedAdeActionNames("lane", laneService as Record<string, unknown>)).toContain("listSnapshots"); + + const snapshots = await laneService.listSnapshots?.({ + includeConflictStatus: true, + includeRebaseSuggestions: true, + includeAutoRebaseStatus: true, + }); + + expect(list).toHaveBeenCalledWith({ + includeArchived: false, + includeStatus: true, + }); + expect(snapshots).toEqual([ + { + lane: { + ...lane, + devicesOpen: [device], + }, + runtime: { + bucket: "awaiting-input", + runningCount: 1, + awaitingInputCount: 1, + endedCount: 1, + sessionCount: 3, + }, + rebaseSuggestion, + autoRebaseStatus, + conflictStatus, + stateSnapshot, + adoptableAttached: false, + }, + { + lane: attachedLane, + runtime: { + bucket: "running", + runningCount: 1, + awaitingInputCount: 0, + endedCount: 0, + sessionCount: 1, + }, + rebaseSuggestion: null, + autoRebaseStatus: null, + conflictStatus: null, + stateSnapshot: null, + adoptableAttached: true, + }, + ]); + }); +}); + +describe("runtime AI actions", () => { + it("exposes AI status and key storage actions through the allowlist", () => { + const runtime = { + aiIntegrationService: { + getStatus: vi.fn(), + getDailyUsageBatch: vi.fn(() => new Map()), + getFeatureFlag: vi.fn(), + getDailyBudgetLimit: vi.fn(), + verifyApiKeyConnection: vi.fn(), + storeApiKey: vi.fn(), + deleteApiKey: vi.fn(), + listApiKeys: vi.fn(), + }, + } as unknown as Parameters<typeof getAdeActionDomainServices>[0]; + const aiService = getAdeActionDomainServices(runtime).ai as Record<string, unknown>; + + for (const action of ["getStatus", "getOpenCodeRuntimeDiagnostics", "storeApiKey", "deleteApiKey", "listApiKeys"]) { + expect(aiService[action]).toEqual(expect.any(Function)); + expect(listAllowedAdeActionNames("ai", aiService)).toContain(action); + } + }); + + it("returns IPC-shaped AI status rows from the runtime AI service", async () => { + const featureUsage = new Map([["narratives", 2]]); + const getStatus = vi.fn(async () => ({ + mode: "subscription", + availableProviders: { claude: true, codex: false, cursor: false, droid: false }, + models: { claude: [], codex: [], cursor: [], droid: [] }, + detectedAuth: [], + providerConnections: undefined, + runtimeConnections: {}, + availableModelIds: [], + opencodeBinaryInstalled: false, + opencodeBinarySource: "missing", + opencodeInventoryError: null, + opencodeProviders: [], + apiKeyStore: { + secureStorageAvailable: true, + legacyPlaintextDetected: false, + decryptionFailed: false, + }, + })); + const runtime = { + aiIntegrationService: { + getStatus, + getDailyUsageBatch: vi.fn(() => featureUsage), + getFeatureFlag: vi.fn((feature: string) => feature === "narratives"), + getDailyBudgetLimit: vi.fn((feature: string) => feature === "narratives" ? 5 : null), + }, + } as unknown as Parameters<typeof getAdeActionDomainServices>[0]; + const aiService = getAdeActionDomainServices(runtime).ai as { + getStatus(args?: { force?: boolean; refreshOpenCodeInventory?: boolean }): Promise<{ + features: Array<{ feature: string; enabled: boolean; dailyUsage: number; dailyLimit: number | null }>; + }>; + }; + + const status = await aiService.getStatus({ force: true, refreshOpenCodeInventory: true }); + + expect(getStatus).toHaveBeenCalledWith({ + force: true, + refreshOpenCodeInventory: true, + }); + expect(status.features).toContainEqual({ + feature: "narratives", + enabled: true, + dailyUsage: 2, + dailyLimit: 5, + }); + expect(status.features).toContainEqual({ + feature: "mission_planning", + enabled: false, + dailyUsage: 0, + dailyLimit: null, + }); + }); + + it("delegates AI key mutations to the runtime service", () => { + const storeApiKey = vi.fn(); + const deleteApiKey = vi.fn(); + const listApiKeys = vi.fn(() => ["cursor"]); + const runtime = { + aiIntegrationService: { + verifyApiKeyConnection: vi.fn(), + storeApiKey, + deleteApiKey, + listApiKeys, + }, + } as unknown as Parameters<typeof getAdeActionDomainServices>[0]; + const aiService = getAdeActionDomainServices(runtime).ai as { + storeApiKey(args?: { provider?: string; key?: string }): void; + deleteApiKey(args?: { provider?: string }): void; + listApiKeys(): string[]; + }; + + aiService.storeApiKey({ provider: " Cursor ", key: " key " }); + aiService.deleteApiKey({ provider: " Cursor " }); + + expect(aiService.listApiKeys()).toEqual(["cursor"]); + expect(storeApiKey).toHaveBeenCalledWith("Cursor", "key"); + expect(deleteApiKey).toHaveBeenCalledWith("Cursor"); + }); +}); + +describe("runtime GitHub actions", () => { + it("allowlists github.detectRepo when the runtime service exposes it", () => { + const runtime = { + githubService: { + getStatus: vi.fn(), + setToken: vi.fn(), + clearToken: vi.fn(), + getRepoOrThrow: vi.fn(), + detectRepo: vi.fn(), + listRepoLabels: vi.fn(), + listRepoCollaborators: vi.fn(), + publishCurrentProject: vi.fn(), + }, + } as unknown as Parameters<typeof getAdeActionDomainServices>[0]; + const githubService = getAdeActionDomainServices(runtime).github as Record<string, unknown>; + + expect(githubService.detectRepo).toEqual(expect.any(Function)); + expect(listAllowedAdeActionNames("github", githubService)).toContain("detectRepo"); + expect(listAllowedAdeActionNames("github", githubService)).toEqual(expect.arrayContaining([ + "listRepoCollaborators", + "listRepoLabels", + "publishCurrentProject", + ])); + }); + + it("routes object-shaped GitHub repo picker args to the positional service methods", async () => { + const listRepoLabels = vi.fn(async () => [{ name: "bug" }]); + const listRepoCollaborators = vi.fn(async () => [{ login: "octocat" }]); + const runtime = { + githubService: { + getStatus: vi.fn(), + setToken: vi.fn(), + clearToken: vi.fn(), + getRepoOrThrow: vi.fn(), + detectRepo: vi.fn(), + listRepoLabels, + listRepoCollaborators, + }, + } as unknown as Parameters<typeof getAdeActionDomainServices>[0]; + const githubService = getAdeActionDomainServices(runtime).github as { + listRepoLabels(args?: { owner?: string; name?: string }): Promise<unknown>; + listRepoCollaborators(args?: { owner?: string; name?: string }): Promise<unknown>; + }; + + await expect(githubService.listRepoLabels({ owner: " acme ", name: " ade " })).resolves.toEqual([{ name: "bug" }]); + await expect(githubService.listRepoCollaborators({ owner: " acme ", name: " ade " })).resolves.toEqual([{ login: "octocat" }]); + + expect(listRepoLabels).toHaveBeenCalledWith("acme", "ade"); + expect(listRepoCollaborators).toHaveBeenCalledWith("acme", "ade"); + }); + + it("routes object-shaped publish args to the GitHub service", async () => { + const publishCurrentProject = vi.fn(async () => ({ + state: "pushed" as const, + htmlUrl: "https://github.com/acme/ade", + })); + const runtime = { + githubService: { + getStatus: vi.fn(), + setToken: vi.fn(), + clearToken: vi.fn(), + getRepoOrThrow: vi.fn(), + detectRepo: vi.fn(), + publishCurrentProject, + }, + } as unknown as Parameters<typeof getAdeActionDomainServices>[0]; + const githubService = getAdeActionDomainServices(runtime).github as { + publishCurrentProject(args?: { name?: string; description?: string; isPrivate?: boolean }): Promise<unknown>; + }; + + await expect(githubService.publishCurrentProject({ + name: " ade ", + description: "Local-first agent desk", + isPrivate: true, + })).resolves.toEqual({ + state: "pushed", + htmlUrl: "https://github.com/acme/ade", + }); + await expect(githubService.publishCurrentProject({ name: "ade" })).rejects.toThrow("Expected 'isPrivate' to be a boolean."); + + expect(publishCurrentProject).toHaveBeenCalledWith({ + name: "ade", + description: "Local-first agent desk", + isPrivate: true, + }); + }); + + it("returns fresh GitHub status after token mutations", async () => { + let tokenStored = false; + const setToken = vi.fn((token: string) => { + tokenStored = token.length > 0; + }); + const clearToken = vi.fn(() => { + tokenStored = false; + }); + const runtime = { + githubService: { + getStatus: vi.fn(async () => ({ + tokenStored, + tokenDecryptionFailed: false, + storageScope: "app", + tokenType: tokenStored ? "classic" : "unknown", + repo: { owner: "ade", name: "runtime" }, + hasOrigin: true, + userLogin: null, + scopes: [], + checkedAt: tokenStored ? TEST_NOW : null, + repoAccessOk: tokenStored, + repoAccessError: tokenStored ? null : "GitHub token missing.", + connected: tokenStored, + })), + setToken, + clearToken, + }, + } as unknown as Parameters<typeof getAdeActionDomainServices>[0]; + + const githubService = getAdeActionDomainServices(runtime).github as { + setToken(token: string): Promise<{ + connected: boolean; + hasOrigin: boolean; + repoAccessError: string | null; + repoAccessOk: boolean | null; + tokenStored: boolean; + }>; + clearToken(): Promise<{ + connected: boolean; + hasOrigin: boolean; + repoAccessError: string | null; + repoAccessOk: boolean | null; + tokenStored: boolean; + }>; + }; + + await expect(githubService.setToken("ghp_test")).resolves.toMatchObject({ + connected: true, + hasOrigin: true, + repoAccessError: null, + repoAccessOk: true, + tokenStored: true, + }); + await expect(githubService.clearToken()).resolves.toMatchObject({ + connected: false, + hasOrigin: true, + repoAccessError: "GitHub token missing.", + repoAccessOk: false, + tokenStored: false, + }); + expect(setToken).toHaveBeenCalledWith("ghp_test"); + expect(clearToken).toHaveBeenCalled(); + }); +}); + +describe("runtime file actions", () => { + it("uses the runtime client id as the file watcher sender without leaking metadata to file args", async () => { + const pushedEvents: unknown[] = []; + const watchWorkspace = vi.fn(async (args, callback, senderId) => { + callback({ + workspaceId: "ws-1", + type: "modified", + path: "src/App.tsx", + ts: "2026-05-10T00:00:00.000Z", + }); + return { args, senderId }; + }); + const stopWatching = vi.fn(); + const runtime = { + fileService: { + watchWorkspace, + stopWatching, + }, + eventBuffer: { + push(event: unknown) { + pushedEvents.push(event); + }, + }, + } as unknown as Parameters<typeof getAdeActionDomainServices>[0]; + + const fileService = getAdeActionDomainServices(runtime).file as { + watchWorkspace(args?: unknown): Promise<{ ok: true }>; + stopWatching(args?: unknown): { ok: true }; + }; + + await fileService.watchWorkspace({ + workspaceId: "ws-1", + includeIgnored: true, + __adeRuntimeClientId: 42, + }); + fileService.stopWatching({ + workspaceId: "ws-1", + includeIgnored: true, + __adeRuntimeClientId: 42, + }); + + expect(watchWorkspace).toHaveBeenCalledWith( + { workspaceId: "ws-1", includeIgnored: true }, + expect.any(Function), + 42, + ); + expect(stopWatching).toHaveBeenCalledWith( + { workspaceId: "ws-1", includeIgnored: true }, + 42, + ); + expect(pushedEvents).toEqual([ + expect.objectContaining({ + category: "runtime", + payload: { + type: "file_change", + event: expect.objectContaining({ path: "src/App.tsx" }), + }, + }), + ]); + }); +}); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index e2bc3b589..83499bb34 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -1,6 +1,11 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; import type { AdeRuntime } from "../../../../../ade-cli/src/bootstrap"; import type { AutomationManualTriggerRequest, + AutomationIngressEventRecord, + AutomationIngressStatus, AutomationRun, AutomationRunDetail, AutomationRunListArgs, @@ -8,7 +13,72 @@ import type { AutomationSaveDraftRequest, AutomationSaveDraftResult, } from "../../../shared/types/automations"; +import type { ComputerUseOwnerSnapshotArgs } from "../../../shared/types/computerUseArtifacts"; +import type { + AgentChatFileSearchArgs, + AgentChatFileSearchResult, + AgentChatGetTurnFileDiffArgs, + AgentChatParallelLaunchState, + AgentChatSetParallelLaunchStateArgs, + AgentChatTurnFileDiff, +} from "../../../shared/types/chat"; import type { AutomationRule } from "../../../shared/types/config"; +import { buildPrAiResolutionContextKey } from "../../../shared/types"; +import type { + OrchestratorChatMessage, + OrchestratorRun, + OrchestratorRunGraph, +} from "../../../shared/types/orchestrator"; +import type { + AiConfig, + ApplyLaneTemplateArgs, + FileChangeEvent, + FilesWatchArgs, + LaneEnvInitConfig, + LaneEnvInitProgress, + LaneListSnapshot, + LaneOverlayOverrides, + LanePreviewInfo, + ListLanesArgs, + LaunchPrIssueResolutionFromThreadArgs, + PortLease, + PrAgentPermissionMode, + PrAiResolutionContext, + PrAiResolutionEventPayload, + PrAiResolutionGetSessionResult, + PrAiResolutionInputArgs, + PrAiResolutionSessionInfo, + PrAiResolutionSessionStatus, + PrAiResolutionStartArgs, + PrAiResolutionStartResult, + PrAiResolutionStopArgs, + PrIssueResolutionPromptPreviewArgs, + PrIssueResolutionStartArgs, + ProxyStatus, + RebaseResolutionStartArgs, + AiFeatureKey, + MemoryHealthStats, + AiSettingsStatus, + CtoRunProjectScanResult, + CtoLinearQuickView, + CtoSimulateFlowRouteArgs, + LinearConnectionStatus, + LinearRouteDecision, + NormalizedLinearIssue, + OnboardingDetectionResult, +} from "../../../shared/types"; +import { getModelById } from "../../../shared/modelRegistry"; +import { matchLaneOverlayPolicies } from "../config/laneOverlayMatcher"; +import { mergeAiConfig } from "../config/projectConfigService"; +import { appendDiffTruncationNotice, MAX_DIFF_SIDE_TEXT_BYTES } from "../diffs/diffService"; +import { runGit } from "../git/git"; +import { buildComputerUseOwnerSnapshot } from "../computerUse/controlPlane"; +import { buildLaneListSnapshots } from "../lanes/laneListSnapshotService"; +import { launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "../prs/prIssueResolver"; +import { launchRebaseResolutionChat } from "../prs/prRebaseResolver"; +import { mapPermissionModeForModelFamily } from "../prs/resolverUtils"; +import { getErrorMessage, isRecord, nowIso, toMemoryEntryDto } from "../shared/utils"; +import { readCoordinatorCheckpoint } from "../orchestrator/missionStateDoc"; export const ADE_ACTION_DOMAIN_NAMES = [ "lane", @@ -19,21 +89,25 @@ export const ADE_ACTION_DOMAIN_NAMES = [ "tests", "chat", "keybindings", + "ai", "onboarding", "automation_planner", "mission", "orchestrator", "orchestrator_core", + "mission_budget", "memory", "cto_state", "worker_agent", "session", "operation", + "ade_project", "project_config", "issue_inventory", "path_to_merge", "flow_policy", "linear_credentials", + "linear_oauth", "linear_dispatcher", "linear_issue_tracker", "linear_sync", @@ -57,6 +131,7 @@ export const ADE_ACTION_DOMAIN_NAMES = [ "built_in_browser", "macos_vm", "automations", + "review", "issue", ] as const; @@ -79,11 +154,13 @@ export const ADE_ACTION_CTO_ONLY: Partial<Record<AdeActionDomain, readonly strin "clearToken", "clearOAuthClientCredentials", ], + linear_oauth: ["startSession"], github: ["setToken", "clearToken"], update: ["quitAndInstall"], flow_policy: ["savePolicy", "rollbackRevision"], linear_sync: ["runSyncNow", "resolveQueueItem"], linear_ingress: ["ensureRelayWebhook"], + ai: ["updateConfig", "storeApiKey", "deleteApiKey"], budget: ["updateConfig"], feedback: ["submitPreparedDraft"], usage: ["forceRefresh", "poll", "start", "stop"], @@ -109,18 +186,69 @@ export function callerHasRoleAtLeast(role: AdeActionRole | undefined | null, min export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly string[]>> = { lane: [ "adoptAttached", + "archive", "attach", + "cancelDelete", "create", + "createChild", "createFromUnstaged", + "deferRebaseSuggestion", "delete", + "deleteTemplate", + "diagnosticsActivateFallback", + "diagnosticsDeactivateFallback", + "diagnosticsGetLaneHealth", + "diagnosticsGetStatus", + "diagnosticsRunFullCheck", + "diagnosticsRunHealthCheck", + "dismissAutoRebaseStatus", + "dismissRebaseSuggestion", "getChildren", + "getDefaultTemplate", + "getDeleteRisk", + "getEnvStatus", + "getOverlay", "getStackChain", + "getTemplate", "importBranch", + "initEnv", + "listAutoRebaseStatuses", "list", + "listSnapshots", + "listRebaseSuggestions", + "listTemplates", "listUnregisteredWorktrees", + "oauthDecodeState", + "oauthEncodeState", + "oauthGenerateRedirectUris", + "oauthGetStatus", + "oauthListSessions", + "oauthUpdateConfig", + "portAcquire", + "portGetLease", + "portListConflicts", + "portListLeases", + "portRecoverOrphans", + "portRelease", + "previewBranchSwitch", + "proxyAddRoute", + "proxyGetPreviewInfo", + "proxyGetStatus", + "proxyRemoveRoute", + "proxyStart", + "proxyStop", "refreshSnapshots", + "rebaseAbort", + "rebasePush", + "rebaseRollback", + "rebaseStart", "rename", "reparent", + "applyTemplate", + "saveTemplate", + "setDefaultTemplate", + "switchBranch", + "unarchive", "updateAppearance", ], git: [ @@ -135,6 +263,9 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri "getCommitMessage", "getConflictState", "getFileHistory", + "getOpenPrForBranch", + "getOriginRemote", + "getUserIdentity", "getSyncStatus", "listBranches", "listCommitFiles", @@ -157,41 +288,88 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri "stashDrop", "stashPop", "stashPush", + "sync", "unstageAll", "unstageFile", "unstagePaths", ], diff: ["getChanges", "getFileDiff", "getFilePatch"], - conflicts: ["getLaneStatus", "listOverlaps", "rebaseLane", "runPrediction"], + conflicts: [ + "applyProposal", + "attachResolverSession", + "cancelResolverSession", + "commitExternalResolverRun", + "finalizeResolverSession", + "getBatchAssessment", + "getLaneStatus", + "getRiskMatrix", + "listExternalResolverRuns", + "listOverlaps", + "listProposals", + "prepareProposal", + "prepareResolverSession", + "rebaseLane", + "requestProposal", + "runExternalResolver", + "runPrediction", + "simulateMerge", + "suggestResolverTarget", + "undoProposal", + "scanRebaseNeeds", + "getRebaseNeed", + "dismissRebase", + "deferRebase", + ], pr: [ "addComment", + "aiResolutionGetSession", + "aiResolutionInput", + "aiResolutionStart", + "aiResolutionStop", "aiReviewSummary", + "cleanupBranch", "cleanupIntegrationWorkflow", + "closePr", + "commitIntegration", "createFromLane", "createIntegrationLane", + "createIntegrationLaneForProposal", "createIntegrationPr", "createQueuePrs", + "delete", + "deleteIntegrationProposal", "dismissIntegrationCleanup", "draftDescription", "getActionRuns", "getChecks", "getComments", + "getCommits", + "getConflictAnalysis", "getDetail", + "getDeployments", + "getForLane", + "getFiles", "getGithubSnapshot", "getIntegrationResolutionState", + "getMergeContext", "getMobileSnapshot", "getPrHealth", "getQueueState", + "getAiSummary", "getReviewThreads", "getReviews", + "getStatus", + "land", "landQueueNext", "landStack", "landStackEnhanced", "linkToLane", "listAll", + "listQueueStates", "listGroupPrs", "listIntegrationProposals", "listIntegrationWorkflows", + "listOpenPullRequests", "listWithConflicts", "postReviewComment", "reactToComment", @@ -199,54 +377,173 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri "refresh", "reorderQueuePrs", "requestReviewers", + "resolveReviewThread", + "retargetBase", + "reopenPr", + "replyToReviewThread", + "rerunChecks", + "regenerateAiSummary", "setLabels", "setReviewThreadResolved", "simulateIntegration", "startIntegrationResolution", + "startQueueAutomation", + "pauseQueueAutomation", + "resumeQueueAutomation", + "cancelQueueAutomation", + "issueResolutionStart", + "issueResolutionPreviewPrompt", + "launchIssueResolutionFromThread", + "rebaseResolutionStart", "submitReview", + "updateBody", "updateDescription", "updateIntegrationProposal", "updateTitle", ], tests: ["getLogTail", "listRuns", "listSuites", "run", "stop"], chat: [ + "archiveSession", + "cancelDispatchedSteer", + "cancelSteer", "createSession", "deleteSession", + "dispatchSteer", + "dispose", + "editSteer", + "ensureAgentIdentitySession", + "ensureCtoSession", "getAvailableModels", + "getChatEventHistory", + "getSessionCapabilities", "getSessionSummary", "getSlashCommands", + "getTurnFileDiff", + "getParallelLaunchState", "interrupt", "listSessions", + "listSubagents", + "approveToolUse", + "codexFuzzyFileSearch", + "fileSearch", + "handoffSession", + "respondToInput", "resumeSession", + "saveTempAttachment", "sendMessage", + "setParallelLaunchState", + "steer", + "suggestLaneNameFromPrompt", + "unarchiveSession", + "updateSession", + "warmupModel", ], keybindings: ["get", "set"], + ai: [ + "getStatus", + "getOpenCodeRuntimeDiagnostics", + "verifyApiKeyConnection", + "storeApiKey", + "deleteApiKey", + "listApiKeys", + "updateConfig", + "listCursorCloudRepositories", + "listCursorCloudAgents", + "listCursorCloudRuns", + "createCursorCloudRun", + "archiveCursorCloudAgent", + "unarchiveCursorCloudAgent", + "deleteCursorCloudAgent", + "getCursorCloudAgent", + "listCursorCloudArtifacts", + "downloadCursorCloudArtifact", + "cancelCursorCloudRun", + "cursorCloudFollowUp", + "openCursorCloudChat", + ], onboarding: [ "complete", "detectDefaults", + "detectExistingLanes", "getStatus", + "getTourProgress", + "markGlossaryTermSeen", + "markTourCompleted", + "markTourDismissed", + "markTutorialCompleted", + "markTutorialDismissed", + "markTutorialStarted", + "markWizardCompleted", + "markWizardDismissed", + "resetTourProgress", "setDismissed", + "setTutorialSilenced", + "shouldPromptTutorial", + "updateTourStep", + "updateTutorialAct", ], automation_planner: ["parseNaturalLanguage", "saveDraft", "simulate", "validateDraft"], mission: [ "addIntervention", + "addArtifact", "archive", + "clonePhaseProfile", "create", "delete", + "deletePhaseItem", + "deletePhaseProfile", + "exportPhaseItems", + "exportPhaseProfile", "get", + "getDashboard", + "getFullMissionView", + "getPhaseConfiguration", + "getRunView", + "importPhaseItems", + "importPhaseProfile", "list", + "listPhaseItems", + "listPhaseProfiles", + "preflight", "resolveIntervention", + "savePhaseItem", + "savePhaseProfile", "update", + "updateStep", ], orchestrator: [ "cancelRunGracefully", + "cleanupTeamResources", "finalizeRun", + "getActiveAgents", + "getAggregatedUsage", + "getChat", + "getContextCheckpoint", + "getExecutionPlanPreview", + "getGlobalChat", + "getMissionLogs", "getMissionMetrics", + "getMissionStateDocument", + "getModelCapabilities", + "getCheckpointStatus", + "getPlanningPromptPreview", + "getPromptInspector", + "getRunView", "getTeamMembers", "getThreadMessages", + "getWorkerDigest", "getWorkerStates", + "exportMissionLogs", + "listArtifacts", "listChatThreads", + "listLaneDecisions", + "listWorkerCheckpoints", + "listWorkerDigests", "resumeRun", + "sendAgentMessage", + "sendChat", + "sendThreadMessage", + "setMissionMetricsConfig", "startMissionRun", "steerMission", ], @@ -258,7 +555,11 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri "completeAttempt", "createHandoff", "emitRuntimeUpdate", + "finalizeRun", + "getLatestGateReport", "getRunGraph", + "getRunState", + "heartbeatClaims", "listAttempts", "listRetrospectivePatternStats", "listRetrospectiveTrends", @@ -268,23 +569,81 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri "pauseRun", "resumeRun", "skipStep", + "startAttempt", + "startRun", "startReadyAutopilotAttempts", "supersedeStep", + "tick", "updateStepDependencies", "updateStepMetadata", ], + mission_budget: ["getMissionBudgetStatus", "getMissionBudgetTelemetry"], cto_state: [ + "completeOnboardingStep", + "dismissOnboarding", "getIdentity", + "getOnboardingState", + "getSessionLogs", "getSnapshot", + "previewSystemPrompt", + "resetOnboarding", + "runProjectScan", "updateCoreMemory", + "updateIdentity", ], worker_agent: [ + "clearAgentTaskSession", + "getCoreMemory", + "getBudgetSnapshot", + "listAgents", + "listAgentRevisions", + "listAgentRuns", + "listSessionLogs", + "listAgentTaskSessions", + "removeAgent", + "rollbackAgentRevision", + "saveAgent", + "setAgentStatus", + "triggerWakeup", "updateCoreMemory", ], - memory: ["addSharedFact", "pinMemory", "searchMemories", "writeMemory"], - session: ["get", "readTranscriptTail"], + memory: [ + "add", + "addMemory", + "addSharedFact", + "archive", + "archiveMemory", + "downloadEmbeddingModel", + "exportProcedureSkill", + "getBudget", + "getCandidateMemories", + "getCandidates", + "getHealthStats", + "getKnowledgeSyncStatus", + "getProcedureDetail", + "healthStats", + "list", + "listIndexedSkills", + "listMemories", + "listMissionEntries", + "listProcedures", + "pin", + "pinMemory", + "promote", + "promoteMemory", + "promoteMissionEntry", + "reindexSkills", + "runConsolidation", + "runSweep", + "search", + "searchMemories", + "syncKnowledge", + "writeMemory", + ], + session: ["deleteSession", "get", "getDelta", "list", "readTranscriptTail", "updateMeta"], operation: ["finish", "list", "start"], - project_config: ["get", "save"], + ade_project: ["clearLocalData", "getSnapshot", "initializeOrRepair", "runIntegrityCheck"], + project_config: ["confirmTrust", "diffAgainstDisk", "get", "save", "validate"], issue_inventory: [ "deletePipelineSettings", "getConvergenceRuntime", @@ -323,12 +682,41 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri "setOAuthToken", "setToken", ], + linear_oauth: [ + "getSession", + "startSession", + ], linear_dispatcher: ["dispatchIssue", "getDashboard", "listEmployees", "listQueue"], - linear_issue_tracker: ["getStatus", "listIssues"], + linear_issue_tracker: [ + "createComment", + "fetchIssueById", + "fetchIssuesByIds", + "getIssuePickerData", + "getConnectionStatus", + "getQuickView", + "getStatus", + "getWorkflowCatalog", + "listLabels", + "listIssues", + "listProjects", + "listWorkflowStates", + "listUsers", + "searchIssues", + "updateIssueAssignee", + ], linear_sync: ["getDashboard", "getRunDetail", "listQueue", "resolveQueueItem", "runSyncNow"], linear_ingress: ["ensureRelayWebhook", "getStatus", "listRecentEvents"], linear_routing: ["simulateRoute"], - github: ["clearToken", "getRepoOrThrow", "getStatus", "setToken"], + github: [ + "clearToken", + "detectRepo", + "getRepoOrThrow", + "getStatus", + "listRepoCollaborators", + "listRepoLabels", + "publishCurrentProject", + "setToken", + ], feedback: ["list", "prepareDraft", "submitPreparedDraft"], usage: ["forceRefresh", "getUsageSnapshot", "poll", "start", "stop"], budget: ["checkBudget", "getConfig", "getCumulativeUsage", "recordUsage", "updateConfig"], @@ -343,16 +731,35 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri "readFile", "rename", "searchText", + "stopWatching", + "watchWorkspace", + "writeTextAtomic", "writeWorkspaceText", ], - process: ["getLogTail", "listDefinitions", "listRuntime", "startAll", "stopAll"], + process: [ + "getLogTail", + "kill", + "listDefinitions", + "listRuntime", + "restart", + "restartGroup", + "restartStack", + "start", + "startAll", + "startGroup", + "startStack", + "stop", + "stopAll", + "stopGroup", + "stopStack", + ], pty: ["create", "dispose", "resize", "write"], terminal: ["list", "read", "write", "signal", "activeForChat"], layout: ["get", "set"], tiling_tree: ["get", "set"], graph_state: ["get", "set"], - computer_use_artifacts: ["ingest", "listArtifacts"], - ios_simulator: ["getStatus", "listDevices", "listLaunchTargets", "launch", "shutdown", "screenshot", "getScreenSnapshot", "getInspectorSnapshot", "inspectPoint", "getPreviewCapability", "listPreviewTargets", "renderPreview", "openPreviewWorkspace", "startStream", "stopStream", "getStreamStatus", "tap", "typeText", "drag", "swipe", "selectPoint"], + computer_use_artifacts: ["getOwnerSnapshot", "ingest", "listArtifacts", "readArtifactPreview", "routeArtifact", "updateArtifactReview"], + ios_simulator: ["getStatus", "listDevices", "listLaunchTargets", "launch", "attachToChatSession", "shutdown", "screenshot", "getScreenSnapshot", "getInspectorSnapshot", "inspectPoint", "getPreviewCapability", "listPreviewTargets", "renderPreview", "openPreviewWorkspace", "startStream", "stopStream", "getStreamStatus", "tap", "typeText", "drag", "swipe", "selectPoint"], app_control: ["getStatus", "launch", "launchInTerminal", "connect", "stop", "screenshot", "getSnapshot", "inspectPoint", "selectPoint", "click", "typeText", "scroll", "dispatchKey", "listTargets", "attachToTarget", "readTerminal", "writeTerminal", "signalTerminal"], built_in_browser: ["getStatus", "showPanel", "setBounds", "navigate", "createTab", "switchTab", "closeTab", "reload", "goBack", "goForward", "stop", "startInspect", "stopInspect", "captureScreenshot", "selectPoint", "selectCurrent", "clearSelection"], macos_vm: ["getStatus", "provision", "start", "stop", "delete", "getAgentGuide", "getSharePolicy", "focusWindow", "captureScreenshot", "click", "selectPoint", "typeText"], @@ -363,8 +770,23 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri "deleteRule", "toggleRule", "triggerManually", + "getHistory", "listRuns", "getRunDetail", + "getIngressStatus", + "listIngressEvents", + ], + review: [ + "cancelRun", + "deleteSuppression", + "getRunDetail", + "listLaunchContext", + "listRuns", + "listSuppressions", + "qualityReport", + "recordFeedback", + "rerun", + "startRun", ], issue: [ "addComment", @@ -383,8 +805,11 @@ type AutomationsDomainService = { deleteRule(args: { id: string }): AutomationRuleSummary[]; toggleRule(args: { id: string; enabled: boolean }): AutomationRuleSummary[]; triggerManually(args: AutomationManualTriggerRequest): Promise<AutomationRun>; + getHistory(args: { id: string; limit?: number }): AutomationRun[]; listRuns(args?: AutomationRunListArgs): AutomationRun[]; getRunDetail(args: { runId: string }): Promise<AutomationRunDetail | null>; + getIngressStatus(): AutomationIngressStatus; + listIngressEvents(args?: { limit?: number }): AutomationIngressEventRecord[]; }; function buildAutomationsDomainService(runtime: AdeRuntime): AutomationsDomainService | null { @@ -403,8 +828,11 @@ function buildAutomationsDomainService(runtime: AdeRuntime): AutomationsDomainSe deleteRule: ({ id }) => automationService.deleteRule({ id }), toggleRule: ({ id, enabled }) => automationService.toggle({ id, enabled }), triggerManually: (args) => automationService.triggerManually(args), + getHistory: (args) => automationService.getHistory(args), listRuns: (args = {}) => automationService.listRuns(args), getRunDetail: ({ runId }) => automationService.getRunDetail({ runId }), + getIngressStatus: () => automationService.getIngressStatus(), + listIngressEvents: (args = {}) => automationService.listIngressEvents(args.limit), }; } @@ -462,6 +890,1587 @@ function toService(value: unknown): OpaqueService | null { return (value ?? null) as OpaqueService | null; } +const MAX_TEMP_ATTACHMENT_BYTES = 10 * 1024 * 1024; + +function agentChatParallelLaunchStateKey(projectRoot: string, parentLaneId: string): string { + return `agent-chat-parallel-launch:${projectRoot}:${parentLaneId}`; +} + +function normalizeStringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0); +} + +function normalizeAgentChatParallelLaunchState( + raw: unknown, + parentLaneId: string, +): AgentChatParallelLaunchState | null { + if (!isRecord(raw)) return null; + const status = typeof raw.status === "string" ? raw.status : ""; + if (!["creating_lanes", "sending", "completed", "cleanup_pending"].includes(status)) return null; + return { + parentLaneId, + createdLaneIds: normalizeStringList(raw.createdLaneIds), + sentLaneIds: normalizeStringList(raw.sentLaneIds), + status: status as AgentChatParallelLaunchState["status"], + updatedAt: typeof raw.updatedAt === "string" && raw.updatedAt.trim().length + ? raw.updatedAt + : new Date().toISOString(), + lastError: typeof raw.lastError === "string" && raw.lastError.trim().length ? raw.lastError.trim() : null, + }; +} + +async function getTurnFileDiffFromGit( + projectRoot: string, + arg: AgentChatGetTurnFileDiffArgs, +): Promise<AgentChatTurnFileDiff> { + const lang = arg.filePath.split(".").pop() ?? undefined; + const readSide = async (spec: string): Promise<{ + exists: boolean; + text: string; + isTruncated?: boolean; + isBinary?: boolean; + }> => { + const result = await runGit(["show", spec], { + cwd: projectRoot, + timeoutMs: 10_000, + maxOutputBytes: MAX_DIFF_SIDE_TEXT_BYTES + 64 * 1024, + }); + if (result.exitCode !== 0) return { exists: false, text: "" }; + const buf = Buffer.from(result.stdout, "utf8"); + if (buf.includes(0)) return { exists: true, text: "", isBinary: true }; + if (buf.length <= MAX_DIFF_SIDE_TEXT_BYTES) return { exists: true, text: result.stdout }; + return { + exists: true, + text: appendDiffTruncationNotice(buf.subarray(0, MAX_DIFF_SIDE_TEXT_BYTES).toString("utf8")), + isTruncated: true, + }; + }; + const origResult = await readSide(`${arg.beforeSha}:${arg.filePath}`); + const modResult = await readSide(`${arg.afterSha}:${arg.filePath}`); + return { + path: arg.filePath, + mode: "commit", + ...(lang ? { language: lang } : {}), + original: origResult, + modified: modResult, + ...(origResult.isBinary || modResult.isBinary ? { isBinary: true } : {}), + }; +} + +function saveAgentChatTempAttachment(projectRoot: string, arg: { data?: string; filename?: string }): { path: string } { + const maxEncodedLength = Math.ceil(MAX_TEMP_ATTACHMENT_BYTES / 3) * 4; + if (typeof arg.data !== "string") { + throw new Error("Temporary attachment data is required."); + } + if (arg.data.length > maxEncodedLength) { + throw new Error("Temporary attachments must be 10 MB or smaller."); + } + const content = Buffer.from(arg.data, "base64"); + if (content.byteLength > MAX_TEMP_ATTACHMENT_BYTES) { + throw new Error("Temporary attachments must be 10 MB or smaller."); + } + const baseDir = path.join(projectRoot, ".ade", "attachments"); + fs.mkdirSync(baseDir, { recursive: true }); + const filename = typeof arg.filename === "string" ? arg.filename : ""; + const ext = path.extname(filename) || ".png"; + const destPath = path.join(baseDir, `${randomUUID()}${ext}`); + fs.writeFileSync(destPath, content); + return { path: destPath }; +} + +function buildChatDomainService(runtime: AdeRuntime): OpaqueService | null { + const agentChatService = runtime.agentChatService; + if (!agentChatService) return null; + const base = agentChatService as unknown as OpaqueService; + return { + ...base, + ensureCtoSession: async (args?: { modelId?: string | null; reasoningEffort?: string | null }) => { + const laneId = await resolvePrimaryLaneId(runtime); + return agentChatService.ensureIdentitySession({ + identityKey: "cto", + laneId, + modelId: args?.modelId ?? null, + reasoningEffort: args?.reasoningEffort ?? null, + permissionMode: "full-auto", + }); + }, + ensureAgentIdentitySession: async (args?: { + agentId?: string; + modelId?: string | null; + reasoningEffort?: string | null; + }) => { + const agentId = requireNonEmptyString(args?.agentId, "agentId"); + const laneId = await resolvePrimaryLaneId(runtime); + return agentChatService.ensureIdentitySession({ + identityKey: `agent:${agentId}`, + laneId, + modelId: args?.modelId ?? null, + reasoningEffort: args?.reasoningEffort ?? null, + }); + }, + getParallelLaunchState: (args?: { parentLaneId?: string }) => { + const parentLaneId = requireNonEmptyString(args?.parentLaneId, "parentLaneId"); + const key = agentChatParallelLaunchStateKey(runtime.projectRoot, parentLaneId); + return normalizeAgentChatParallelLaunchState( + runtime.db.getJson<AgentChatParallelLaunchState | null>(key), + parentLaneId, + ); + }, + setParallelLaunchState: (args?: AgentChatSetParallelLaunchStateArgs) => { + const parentLaneId = requireNonEmptyString(args?.parentLaneId, "parentLaneId"); + const key = agentChatParallelLaunchStateKey(runtime.projectRoot, parentLaneId); + runtime.db.setJson(key, normalizeAgentChatParallelLaunchState(args?.state ?? null, parentLaneId)); + }, + fileSearch: async (args?: AgentChatFileSearchArgs): Promise<AgentChatFileSearchResult[]> => { + const sessionId = requireNonEmptyString(args?.sessionId, "sessionId"); + const query = typeof args?.query === "string" ? args.query : ""; + const session = (await agentChatService.listSessions()).find((entry) => entry.sessionId === sessionId); + if (!session?.laneId || !runtime.fileService) return []; + const matches = await runtime.fileService.quickOpen({ + workspaceId: session.laneId, + query, + limit: 20, + }); + return matches.map((match) => ({ + path: match.path, + ...(typeof match.score === "number" ? { score: match.score } : {}), + })); + }, + getTurnFileDiff: (args?: AgentChatGetTurnFileDiffArgs) => { + if (!args) throw new Error("Turn file diff args are required."); + return getTurnFileDiffFromGit(runtime.projectRoot, args); + }, + saveTempAttachment: (args?: { data?: string; filename?: string }) => + saveAgentChatTempAttachment(runtime.projectRoot, args ?? {}), + }; +} + +async function resolvePrimaryLaneId(runtime: AdeRuntime): Promise<string> { + const laneService = requireService(runtime.laneService, "Lane service not available."); + await laneService.ensurePrimaryLane(); + const lanes = await laneService.list(); + const primary = lanes.find((lane) => lane.laneType === "primary"); + if (!primary?.id) { + throw new Error("No primary lane is available to host the identity chat session."); + } + return primary.id; +} + +function summarizeProjectScan(result: OnboardingDetectionResult | null): Partial<{ + projectSummary: string; + criticalConventions: string[]; + activeFocus: string[]; + notes: string[]; +}> { + if (!result) return {}; + const projectTypes = result.projectTypes.filter((entry) => entry.trim().length > 0); + const signalFiles = result.indicators + .slice(0, 4) + .map((indicator) => indicator.file.trim()) + .filter((entry) => entry.length > 0); + const workflowPaths = result.suggestedWorkflows + .slice(0, 4) + .map((workflow) => workflow.path.trim()) + .filter((entry) => entry.length > 0); + + return { + projectSummary: `Detected ${projectTypes.join(", ") || "project"} setup from ${signalFiles.join(", ") || "repository signals"}.`, + criticalConventions: projectTypes.map((type) => `${type} conventions`), + activeFocus: projectTypes.length > 0 ? [`stabilize ${projectTypes[0]} workflows`] : [], + notes: workflowPaths.length > 0 ? workflowPaths.map((workflow) => `Detected workflow: ${workflow}`) : [], + }; +} + +function buildCtoStateDomainService(runtime: AdeRuntime): OpaqueService | null { + const ctoStateService = runtime.ctoStateService; + if (!ctoStateService) return null; + return { + ...(ctoStateService as unknown as OpaqueService), + runProjectScan: async (): Promise<CtoRunProjectScanResult> => { + const detection = await runtime.onboardingService?.detectDefaults().catch(() => null) ?? null; + const summary = summarizeProjectScan(detection); + const coreMemoryPatch = { + projectSummary: summary.projectSummary ?? "", + criticalConventions: summary.criticalConventions ?? [], + activeFocus: summary.activeFocus ?? [], + notes: summary.notes ?? [], + }; + + ctoStateService.updateCoreMemory(coreMemoryPatch); + + const createdMemoryIds: string[] = []; + if (runtime.memoryService) { + if (coreMemoryPatch.projectSummary) { + createdMemoryIds.push( + runtime.memoryService.addMemory({ + projectId: runtime.projectId, + scope: "project", + category: "fact", + content: coreMemoryPatch.projectSummary, + importance: "high", + }).id, + ); + } + for (const convention of coreMemoryPatch.criticalConventions) { + createdMemoryIds.push( + runtime.memoryService.addMemory({ + projectId: runtime.projectId, + scope: "project", + category: "convention", + content: convention, + importance: "medium", + }).id, + ); + } + } + + return { detection, coreMemoryPatch, createdMemoryIds }; + }, + }; +} + +function buildComputerUseArtifactsDomainService(runtime: AdeRuntime): OpaqueService | null { + const broker = runtime.computerUseArtifactBrokerService; + if (!broker) return null; + return { + ...(broker as unknown as OpaqueService), + getOwnerSnapshot: (args?: ComputerUseOwnerSnapshotArgs) => { + if (!args?.owner) throw new Error("owner is required."); + return buildComputerUseOwnerSnapshot({ + broker, + owner: args.owner, + ...(args.limit !== undefined ? { limit: args.limit } : {}), + }); + }, + }; +} + +function buildSessionDomainService(runtime: AdeRuntime): OpaqueService | null { + const sessionService = runtime.sessionService; + if (!sessionService) return null; + return { + ...(sessionService as unknown as OpaqueService), + getDelta: (args?: { sessionId?: string } | string) => { + const sessionId = typeof args === "string" + ? requireNonEmptyString(args, "sessionId") + : requireNonEmptyString(args?.sessionId, "sessionId"); + return runtime.sessionDeltaService?.getSessionDelta(sessionId) ?? null; + }, + }; +} + +function buildWorkerAgentDomainService(runtime: AdeRuntime): OpaqueService | null { + const workerAgentService = runtime.workerAgentService; + if (!workerAgentService) return null; + return { + ...(workerAgentService as unknown as OpaqueService), + saveAgent: (args?: { agent?: unknown; actor?: string }) => + requireService(runtime.workerRevisionService, "Worker revision service not available.").saveAgent( + args?.agent as never, + args?.actor ?? "user", + ), + removeAgent: (args?: { agentId?: string }) => { + workerAgentService.removeAgent(requireNonEmptyString(args?.agentId, "agentId")); + runtime.workerHeartbeatService?.syncFromConfig(); + }, + setAgentStatus: (args?: { agentId?: string; status?: string }) => { + workerAgentService.setAgentStatus(requireNonEmptyString(args?.agentId, "agentId"), args?.status as never); + runtime.workerHeartbeatService?.syncFromConfig(); + }, + listAgentRevisions: (args?: { agentId?: string; limit?: number }) => + requireService(runtime.workerRevisionService, "Worker revision service not available.").listAgentRevisions( + requireNonEmptyString(args?.agentId, "agentId"), + args?.limit ?? 20, + ), + rollbackAgentRevision: (args?: { agentId?: string; revisionId?: string; actor?: string }) => + requireService(runtime.workerRevisionService, "Worker revision service not available.").rollbackAgentRevision( + requireNonEmptyString(args?.agentId, "agentId"), + requireNonEmptyString(args?.revisionId, "revisionId"), + args?.actor ?? "user", + ), + getBudgetSnapshot: (args?: { monthKey?: string }) => + requireService(runtime.workerBudgetService, "Worker budget service not available.").getBudgetSnapshot( + args?.monthKey ? { monthKey: args.monthKey } : {}, + ), + triggerWakeup: (args?: unknown) => + requireService(runtime.workerHeartbeatService, "Worker heartbeat service not available.").triggerWakeup(args as never), + listAgentRuns: (args?: unknown) => + requireService(runtime.workerHeartbeatService, "Worker heartbeat service not available.").listRuns(args as never), + clearAgentTaskSession: (args?: unknown) => + requireService(runtime.workerTaskSessionService, "Worker task session service not available.").clearAgentTaskSession(args as never), + listAgentTaskSessions: (args?: { agentId?: string; limit?: number }) => + requireService(runtime.workerTaskSessionService, "Worker task session service not available.").listAgentTaskSessions( + requireNonEmptyString(args?.agentId, "agentId"), + args?.limit ?? 40, + ), + }; +} + +type MemoryWriteScope = "user" | "project" | "lane" | "mission"; +type MemoryScope = "project" | "agent" | "mission"; + +type MemoryRuntimeExtraService = + | "missionMemoryLifecycleService" + | "proceduralLearningService" + | "skillRegistryService" + | "humanWorkDigestService" + | "embeddingService" + | "embeddingWorkerService" + | "memoryLifecycleService" + | "batchConsolidationService"; + +type MemoryHealthCountRow = { + scope: string | null; + tier: number | null; + status: string | null; + count: number | null; +}; + +type MemorySweepLogRow = { + sweep_id: string; + project_id: string; + trigger_reason: string | null; + started_at: string; + completed_at: string; + entries_decayed: number | null; + entries_demoted: number | null; + entries_promoted: number | null; + entries_archived: number | null; + entries_orphaned: number | null; + duration_ms: number | null; +}; + +type MemoryConsolidationLogRow = { + consolidation_id: string; + project_id: string; + trigger_reason: string | null; + started_at: string; + completed_at: string; + clusters_found: number | null; + entries_merged: number | null; + entries_created: number | null; + tokens_used: number | null; + duration_ms: number | null; +}; + +const MEMORY_HEALTH_SCOPES = ["project", "agent", "mission"] as const; +const MEMORY_HEALTH_LIMITS: Record<(typeof MEMORY_HEALTH_SCOPES)[number], number> = { + project: 2000, + agent: 500, + mission: 200, +}; + +function normalizeMemoryWriteScope(rawScope: unknown): MemoryWriteScope | undefined { + const trimmed = typeof rawScope === "string" ? rawScope.trim() : ""; + if (trimmed === "agent") return "user"; + if (trimmed === "user" || trimmed === "project" || trimmed === "lane" || trimmed === "mission") return trimmed; + return undefined; +} + +function normalizeMemoryScope(rawScope: unknown): MemoryScope | undefined { + const trimmed = typeof rawScope === "string" ? rawScope.trim() : ""; + if (trimmed === "project") return "project"; + if (trimmed === "agent" || trimmed === "user") return "agent"; + if (trimmed === "mission" || trimmed === "lane") return "mission"; + return undefined; +} + +function normalizeMemoryHealthScope(rawScope: unknown): (typeof MEMORY_HEALTH_SCOPES)[number] | null { + const scope = normalizeMemoryScope(rawScope); + return scope ?? null; +} + +function getMemoryExtraService(runtime: AdeRuntime, key: MemoryRuntimeExtraService): OpaqueService | null { + return toService((runtime as unknown as Record<MemoryRuntimeExtraService, unknown>)[key]); +} + +function createEmptyRuntimeMemoryHealthStats(): MemoryHealthStats { + const model: MemoryHealthStats["embeddings"]["model"] = { + modelId: "Xenova/all-MiniLM-L6-v2", + state: "idle", + activity: "idle", + installState: "missing", + cacheDir: null, + installPath: null, + progress: null, + loaded: null, + total: null, + file: null, + error: null, + }; + + return { + scopes: MEMORY_HEALTH_SCOPES.map((scope) => ({ + scope, + current: 0, + max: MEMORY_HEALTH_LIMITS[scope], + counts: { + tier1: 0, + tier2: 0, + tier3: 0, + archived: 0, + }, + })), + lastSweep: null, + lastConsolidation: null, + embeddings: { + entriesEmbedded: 0, + entriesTotal: 0, + queueDepth: 0, + processing: false, + lastBatchProcessedAt: null, + cacheEntries: 0, + cacheHits: 0, + cacheMisses: 0, + cacheHitRate: 0, + model, + }, + }; +} + +function numberOrZero(value: unknown): number { + const next = Number(value ?? 0); + return Number.isFinite(next) ? next : 0; +} + +function getRuntimeMemoryHealthStats(runtime: AdeRuntime): MemoryHealthStats { + const stats = createEmptyRuntimeMemoryHealthStats(); + const scopes = new Map(stats.scopes.map((entry) => [entry.scope, entry] as const)); + + const rows = runtime.db.all<MemoryHealthCountRow>( + ` + SELECT scope, tier, status, COUNT(*) AS count + FROM unified_memories + WHERE project_id = ? + GROUP BY scope, tier, status + `, + [runtime.projectId], + ); + + for (const row of rows) { + const scope = normalizeMemoryHealthScope(row.scope); + if (!scope) continue; + const target = scopes.get(scope); + if (!target) continue; + const count = numberOrZero(row.count); + if (count <= 0) continue; + + if (String(row.status ?? "").trim() === "archived") { + target.counts.archived += count; + continue; + } + + const tier = Number(row.tier ?? 0); + if (tier === 1) target.counts.tier1 += count; + else if (tier === 2) target.counts.tier2 += count; + else target.counts.tier3 += count; + } + + for (const scope of stats.scopes) { + scope.current = scope.counts.tier1 + scope.counts.tier2 + scope.counts.tier3; + } + + const embeddingService = getMemoryExtraService(runtime, "embeddingService"); + const embeddingWorkerService = getMemoryExtraService(runtime, "embeddingWorkerService"); + const getEmbeddingStatus = embeddingService?.getStatus; + const getEmbeddingWorkerStatus = embeddingWorkerService?.getStatus; + const embeddingStatus = typeof getEmbeddingStatus === "function" + ? asActionRecord(getEmbeddingStatus.call(embeddingService)) + : {}; + const embeddingWorkerStatus = typeof getEmbeddingWorkerStatus === "function" + ? asActionRecord(getEmbeddingWorkerStatus.call(embeddingWorkerService)) + : {}; + const embeddedCountRow = runtime.db.get<{ count: number | null }>( + ` + SELECT COUNT(*) AS count + FROM unified_memories m + WHERE m.project_id = ? + AND m.status != 'archived' + AND EXISTS ( + SELECT 1 + FROM unified_memory_embeddings e + WHERE e.memory_id = m.id + ) + `, + [runtime.projectId], + ); + const entriesEmbedded = numberOrZero(embeddedCountRow?.count); + const entriesTotal = stats.scopes.reduce((total, scope) => total + scope.current, 0); + const cacheHits = numberOrZero(embeddingStatus.cacheHits); + const cacheMisses = numberOrZero(embeddingStatus.cacheMisses); + const cacheTotal = cacheHits + cacheMisses; + + stats.embeddings = { + entriesEmbedded, + entriesTotal, + queueDepth: numberOrZero(embeddingWorkerStatus.queueDepth), + processing: embeddingWorkerStatus.processing === true, + lastBatchProcessedAt: typeof embeddingWorkerStatus.lastProcessedAt === "string" + ? embeddingWorkerStatus.lastProcessedAt + : null, + cacheEntries: numberOrZero(embeddingStatus.cacheEntries), + cacheHits, + cacheMisses, + cacheHitRate: cacheTotal > 0 ? cacheHits / cacheTotal : 0, + model: { + modelId: typeof embeddingStatus.modelId === "string" ? embeddingStatus.modelId : "Xenova/all-MiniLM-L6-v2", + state: typeof embeddingStatus.state === "string" ? embeddingStatus.state as never : "idle", + activity: typeof embeddingStatus.activity === "string" ? embeddingStatus.activity as never : "idle", + installState: typeof embeddingStatus.installState === "string" ? embeddingStatus.installState as never : "missing", + cacheDir: typeof embeddingStatus.cacheDir === "string" ? embeddingStatus.cacheDir : null, + installPath: typeof embeddingStatus.installPath === "string" ? embeddingStatus.installPath : null, + progress: typeof embeddingStatus.progress === "number" ? embeddingStatus.progress : null, + loaded: typeof embeddingStatus.loaded === "number" ? embeddingStatus.loaded : null, + total: typeof embeddingStatus.total === "number" ? embeddingStatus.total : null, + file: typeof embeddingStatus.file === "string" ? embeddingStatus.file : null, + error: typeof embeddingStatus.error === "string" ? embeddingStatus.error : null, + }, + }; + + const lastSweep = runtime.db.get<MemorySweepLogRow>( + ` + SELECT sweep_id, project_id, trigger_reason, started_at, completed_at, + entries_decayed, entries_demoted, entries_promoted, entries_archived, + entries_orphaned, duration_ms + FROM memory_sweep_log + WHERE project_id = ? + ORDER BY completed_at DESC + LIMIT 1 + `, + [runtime.projectId], + ); + if (lastSweep) { + stats.lastSweep = { + sweepId: lastSweep.sweep_id, + projectId: lastSweep.project_id, + reason: lastSweep.trigger_reason === "startup" ? "startup" : "manual", + startedAt: lastSweep.started_at, + completedAt: lastSweep.completed_at, + entriesDecayed: numberOrZero(lastSweep.entries_decayed), + entriesDemoted: numberOrZero(lastSweep.entries_demoted), + entriesPromoted: numberOrZero(lastSweep.entries_promoted), + entriesArchived: numberOrZero(lastSweep.entries_archived), + entriesOrphaned: numberOrZero(lastSweep.entries_orphaned), + durationMs: numberOrZero(lastSweep.duration_ms), + }; + } + + const lastConsolidation = runtime.db.get<MemoryConsolidationLogRow>( + ` + SELECT consolidation_id, project_id, trigger_reason, started_at, completed_at, + clusters_found, entries_merged, entries_created, tokens_used, duration_ms + FROM memory_consolidation_log + WHERE project_id = ? + ORDER BY completed_at DESC + LIMIT 1 + `, + [runtime.projectId], + ); + if (lastConsolidation) { + stats.lastConsolidation = { + consolidationId: lastConsolidation.consolidation_id, + projectId: lastConsolidation.project_id, + reason: lastConsolidation.trigger_reason === "auto" ? "auto" : "manual", + startedAt: lastConsolidation.started_at, + completedAt: lastConsolidation.completed_at, + clustersFound: numberOrZero(lastConsolidation.clusters_found), + entriesMerged: numberOrZero(lastConsolidation.entries_merged), + entriesCreated: numberOrZero(lastConsolidation.entries_created), + tokensUsed: numberOrZero(lastConsolidation.tokens_used), + durationMs: numberOrZero(lastConsolidation.duration_ms), + }; + } + + return stats; +} + +function buildMemoryDomainService(runtime: AdeRuntime): OpaqueService | null { + const memoryService = runtime.memoryService; + if (!memoryService || runtime.capabilities?.memory === false) return null; + + return { + ...(memoryService as unknown as OpaqueService), + add: (args?: unknown) => { + const record = asActionRecord(args); + const projectId = typeof record.projectId === "string" && record.projectId.trim() + ? record.projectId.trim() + : runtime.projectId; + const scope = normalizeMemoryWriteScope(record.scope) ?? "project"; + const content = typeof record.content === "string" ? record.content.trim() : ""; + if (!content) { + throw new Error("memory.add requires non-empty content."); + } + const category = typeof record.category === "string" && record.category.trim() + ? record.category.trim() + : ""; + if (!category) { + throw new Error("memory.add requires category."); + } + const importance = record.importance === "low" || record.importance === "medium" || record.importance === "high" + ? record.importance + : "medium"; + const sourceRunId = typeof record.sourceRunId === "string" && record.sourceRunId.trim() + ? record.sourceRunId.trim() + : undefined; + const scopeOwnerIdRaw = typeof record.scopeOwnerId === "string" ? record.scopeOwnerId.trim() : ""; + const scopeOwnerId = scopeOwnerIdRaw || (scope === "mission" && sourceRunId ? sourceRunId : undefined); + return memoryService.addMemory({ + projectId, + scope, + ...(scopeOwnerId ? { scopeOwnerId } : {}), + category: category as never, + content, + importance, + ...(sourceRunId ? { sourceRunId } : {}), + }); + }, + pin: (args?: { id?: string }) => { + memoryService.pinMemory(requireNonEmptyString(args?.id, "id")); + }, + getBudget: (args?: unknown) => { + const record = asActionRecord(args); + const projectId = typeof record.projectId === "string" && record.projectId.trim() + ? record.projectId.trim() + : runtime.projectId; + const level = record.level === "lite" || record.level === "standard" || record.level === "deep" + ? record.level + : "standard"; + const scope = normalizeMemoryScope(record.scope); + const scopeOwnerId = typeof record.scopeOwnerId === "string" && record.scopeOwnerId.trim() + ? record.scopeOwnerId.trim() + : undefined; + return memoryService.getMemoryBudget(projectId, level, { + ...(scope ? { scope } : {}), + ...(scopeOwnerId ? { scopeOwnerId } : {}), + }).map((memory) => toMemoryEntryDto(memory)); + }, + getCandidates: (args?: { projectId?: string; limit?: number }) => { + const projectId = typeof args?.projectId === "string" && args.projectId.trim() + ? args.projectId.trim() + : runtime.projectId; + return memoryService.getCandidateMemories(projectId, args?.limit ?? 20) + .map((memory) => toMemoryEntryDto(memory)); + }, + promote: (args?: { id?: string }) => { + memoryService.promoteMemory(requireNonEmptyString(args?.id, "id")); + }, + promoteMissionEntry: async (args?: { id?: string; missionId?: string; runId?: string | null }) => { + const service = getMemoryExtraService(runtime, "missionMemoryLifecycleService"); + const promoteMissionMemoryEntry = service?.promoteMissionMemoryEntry; + if (typeof promoteMissionMemoryEntry !== "function") return null; + const result = await promoteMissionMemoryEntry.call(service, { + memoryId: requireNonEmptyString(args?.id, "id"), + missionId: requireNonEmptyString(args?.missionId, "missionId"), + ...(args?.runId ? { runId: args.runId } : {}), + }); + return result ? toMemoryEntryDto(result as { embedded?: boolean }) : null; + }, + archive: (args?: { id?: string }) => { + memoryService.archiveMemory(requireNonEmptyString(args?.id, "id")); + }, + search: async (args?: unknown) => { + const record = asActionRecord(args); + const query = typeof record.query === "string" ? record.query : ""; + const projectId = typeof record.projectId === "string" && record.projectId.trim() + ? record.projectId.trim() + : runtime.projectId; + const scope = normalizeMemoryScope(record.scope); + const scopeOwnerId = typeof record.scopeOwnerId === "string" && record.scopeOwnerId.trim() + ? record.scopeOwnerId.trim() + : undefined; + const status = record.status === "all" + ? (["promoted", "candidate", "archived"] as const) + : record.status === "promoted" || record.status === "candidate" || record.status === "archived" + ? record.status + : "promoted"; + const memories = await memoryService.searchMemories( + query, + projectId, + scope, + typeof record.limit === "number" ? record.limit : 10, + status, + scopeOwnerId, + record.mode === "lexical" ? "lexical" : "hybrid", + ); + return memories.map((memory) => toMemoryEntryDto(memory)); + }, + list: (args?: unknown) => { + const record = asActionRecord(args); + const scope = normalizeMemoryScope(record.scope); + const status = record.status === "all" + ? (["promoted", "candidate", "archived"] as const) + : record.status === "candidate" || record.status === "promoted" || record.status === "archived" + ? record.status + : undefined; + const tier = record.tier === 1 || record.tier === 2 || record.tier === 3 ? record.tier : undefined; + const tiers = tier ? [tier] as const : undefined; + + return memoryService.listMemories({ + projectId: runtime.projectId, + ...(scope ? { scope } : {}), + ...(status ? { status } : {}), + ...(tiers ? { tiers } : {}), + limit: Math.max(1, Math.min(200, Math.floor(typeof record.limit === "number" ? record.limit : 100))), + }).map((memory) => toMemoryEntryDto(memory)); + }, + listMissionEntries: (args?: { missionId?: string; runId?: string | null; status?: string }) => { + const service = getMemoryExtraService(runtime, "missionMemoryLifecycleService"); + const listMissionEntries = service?.listMissionEntries; + if (typeof listMissionEntries !== "function") return []; + const status = args?.status ?? "all"; + return (listMissionEntries.call(service, { + projectId: runtime.projectId, + missionId: requireNonEmptyString(args?.missionId, "missionId"), + runId: args?.runId, + status, + }) as Array<{ embedded?: boolean }>).map((memory) => toMemoryEntryDto(memory)); + }, + listProcedures: (args?: unknown) => { + const service = getMemoryExtraService(runtime, "proceduralLearningService"); + const listProcedures = service?.listProcedures; + return typeof listProcedures === "function" ? listProcedures.call(service, asActionRecord(args)) : []; + }, + getProcedureDetail: (args?: { id?: string }) => { + const service = getMemoryExtraService(runtime, "proceduralLearningService"); + const getProcedureDetail = service?.getProcedureDetail; + return typeof getProcedureDetail === "function" + ? getProcedureDetail.call(service, requireNonEmptyString(args?.id, "id")) + : null; + }, + exportProcedureSkill: (args?: unknown) => { + const service = getMemoryExtraService(runtime, "skillRegistryService"); + const exportProcedureSkill = service?.exportProcedureSkill; + return typeof exportProcedureSkill === "function" + ? exportProcedureSkill.call(service, asActionRecord(args)) + : null; + }, + listIndexedSkills: () => { + const service = getMemoryExtraService(runtime, "skillRegistryService"); + const listIndexedSkills = service?.listIndexedSkills; + return typeof listIndexedSkills === "function" ? listIndexedSkills.call(service) : []; + }, + reindexSkills: (args?: unknown) => { + const service = getMemoryExtraService(runtime, "skillRegistryService"); + const reindexSkills = service?.reindexSkills; + return typeof reindexSkills === "function" ? reindexSkills.call(service, asActionRecord(args)) : []; + }, + syncKnowledge: () => { + const service = getMemoryExtraService(runtime, "humanWorkDigestService"); + const syncKnowledge = service?.syncKnowledge; + return typeof syncKnowledge === "function" ? syncKnowledge.call(service) : null; + }, + getKnowledgeSyncStatus: () => { + const service = getMemoryExtraService(runtime, "humanWorkDigestService"); + const getKnowledgeSyncStatus = service?.getKnowledgeSyncStatus; + return typeof getKnowledgeSyncStatus === "function" + ? getKnowledgeSyncStatus.call(service) + : { + syncing: false, + lastSeenHeadSha: null, + currentHeadSha: null, + diverged: false, + lastDigestAt: null, + lastDigestMemoryId: null, + lastError: null, + }; + }, + getHealthStats: () => getRuntimeMemoryHealthStats(runtime), + healthStats: () => getRuntimeMemoryHealthStats(runtime), + downloadEmbeddingModel: async () => { + const service = requireService(getMemoryExtraService(runtime, "embeddingService"), "Embedding service is not available."); + const preload = service.preload; + if (typeof preload !== "function") { + throw new Error("Embedding service is not available."); + } + const getStatus = service.getStatus; + const status = typeof getStatus === "function" ? asActionRecord(getStatus.call(service)) : {}; + const localFilesOnly = status.installState === "installed" && status.state !== "unavailable"; + if (!localFilesOnly && status.installState !== "missing" && typeof service.clearCache === "function") { + await service.clearCache.call(service); + } + void Promise.resolve(preload.call(service, { forceRetry: true, localFilesOnly })).catch(() => { + // Health polling will surface the unavailable state. + }); + return getRuntimeMemoryHealthStats(runtime); + }, + runSweep: () => { + const service = getMemoryExtraService(runtime, "memoryLifecycleService"); + const runSweep = service?.runSweep; + if (typeof runSweep !== "function") { + throw new Error("Memory lifecycle service is not available."); + } + return runSweep.call(service, { reason: "manual" }); + }, + runConsolidation: () => { + const service = getMemoryExtraService(runtime, "batchConsolidationService"); + const runConsolidation = service?.runConsolidation; + if (typeof runConsolidation !== "function") { + throw new Error("Batch consolidation service is not available."); + } + return runConsolidation.call(service, { reason: "manual" }); + }, + }; +} + +const RUNTIME_CURSOR_DOC_REF_TRANSPORT_LIMIT = 12; +const PAYLOAD_DOC_REF_TRANSPORT_LIMIT = 12; +const RUN_GRAPH_CONTEXT_SNAPSHOT_TRANSPORT_LIMIT = 5; +const CHAT_TOOL_RESULT_STRING_LIMIT = 1_200; +const CHAT_TOOL_RESULT_ARRAY_PREVIEW_LIMIT = 5; +const CHAT_TOOL_RESULT_KEY_PREVIEW_LIMIT = 12; + +function isAdeInternalDocPath(value: unknown): boolean { + if (typeof value !== "string") return false; + const normalized = value.replace(/\\/g, "/"); + return normalized === ".ade" || normalized.startsWith(".ade/") || normalized.includes("/.ade/"); +} + +function compactRuntimeCursorForTransport(value: unknown): unknown { + if (!isRecord(value)) return value; + const rawDocs = Array.isArray(value.docs) ? value.docs : []; + const docs = rawDocs + .filter((entry) => !isAdeInternalDocPath(isRecord(entry) ? entry.path : null)) + .slice(0, RUNTIME_CURSOR_DOC_REF_TRANSPORT_LIMIT) + .map((entry) => { + if (!isRecord(entry)) return entry; + return { + path: typeof entry.path === "string" ? entry.path : "", + bytes: typeof entry.bytes === "number" ? entry.bytes : 0, + sha256: typeof entry.sha256 === "string" ? entry.sha256 : "", + truncated: entry.truncated === true, + mode: typeof entry.mode === "string" ? entry.mode : undefined, + }; + }); + return { + ...value, + docs, + docsOmittedCount: Math.max(0, rawDocs.length - docs.length), + }; +} + +function compactDocRefsArrayForTransport(rawDocs: unknown[], limit: number): unknown[] { + return rawDocs + .filter((entry) => !isAdeInternalDocPath(isRecord(entry) ? entry.path : null)) + .slice(0, limit); +} + +function compactPayloadForTransport(payload: Record<string, unknown> | null): Record<string, unknown> | null { + if (!payload) return payload; + const next: Record<string, unknown> = { ...payload }; + if (Array.isArray(next.docsRefs)) { + const rawDocsRefs = next.docsRefs; + const docsRefs = compactDocRefsArrayForTransport(rawDocsRefs, PAYLOAD_DOC_REF_TRANSPORT_LIMIT); + next.docsRefs = docsRefs; + next.docsRefsOmittedCount = Math.max(0, rawDocsRefs.length - docsRefs.length); + } + return next; +} + +function compactChatToolValueForTransport(value: unknown): unknown { + if (value == null || typeof value === "boolean" || typeof value === "number") return value; + if (typeof value === "string") { + if (value.length <= CHAT_TOOL_RESULT_STRING_LIMIT) return value; + return { + preview: value.slice(0, CHAT_TOOL_RESULT_STRING_LIMIT), + omittedChars: value.length - CHAT_TOOL_RESULT_STRING_LIMIT, + }; + } + if (Array.isArray(value)) { + return { + type: "array", + length: value.length, + preview: value + .slice(0, CHAT_TOOL_RESULT_ARRAY_PREVIEW_LIMIT) + .map((entry) => compactChatToolValueForTransport(entry)), + omittedItems: Math.max(0, value.length - CHAT_TOOL_RESULT_ARRAY_PREVIEW_LIMIT), + }; + } + if (!isRecord(value)) return value; + + const safeKeys = [ + "ok", + "status", + "outcome", + "summary", + "message", + "error", + "workerId", + "stepId", + "stepKey", + "runId", + "missionId", + "filesChanged", + "testsRun", + "artifacts", + ]; + const next: Record<string, unknown> = {}; + for (const key of safeKeys) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + next[key] = compactChatToolValueForTransport(value[key]); + } + } + const keys = Object.keys(value); + next.__adeTransportCompact = true; + next.keys = keys.slice(0, CHAT_TOOL_RESULT_KEY_PREVIEW_LIMIT); + next.omittedKeys = Math.max(0, keys.length - CHAT_TOOL_RESULT_KEY_PREVIEW_LIMIT); + return next; +} + +function compactChatMessageMetadataForTransport(metadata: OrchestratorChatMessage["metadata"]): OrchestratorChatMessage["metadata"] { + if (!isRecord(metadata)) return metadata; + const structuredStream = isRecord(metadata.structuredStream) ? metadata.structuredStream : null; + if (!structuredStream) return metadata; + const nextStructured = { ...structuredStream }; + if (Object.prototype.hasOwnProperty.call(nextStructured, "result")) { + nextStructured.result = compactChatToolValueForTransport(nextStructured.result); + } + return { + ...metadata, + structuredStream: nextStructured, + }; +} + +function compactChatMessageForTransport(message: OrchestratorChatMessage): OrchestratorChatMessage { + return { + ...message, + metadata: compactChatMessageMetadataForTransport(message.metadata), + }; +} + +function compactRunMetadataForTransport(metadata: OrchestratorRun["metadata"]): OrchestratorRun["metadata"] { + if (!isRecord(metadata)) return metadata; + const next: Record<string, unknown> = { ...metadata }; + if (isRecord(next.runtimeCursor)) { + next.runtimeCursor = compactRuntimeCursorForTransport(next.runtimeCursor); + } + return next; +} + +function compactRunForTransport(run: OrchestratorRun): OrchestratorRun { + return { + ...run, + metadata: compactRunMetadataForTransport(run.metadata), + }; +} + +function compactRunGraphForTransport(graph: OrchestratorRunGraph): OrchestratorRunGraph { + return { + ...graph, + run: compactRunForTransport(graph.run), + contextSnapshots: graph.contextSnapshots + .slice(0, RUN_GRAPH_CONTEXT_SNAPSHOT_TRANSPORT_LIMIT) + .map((snapshot) => ({ + ...snapshot, + cursor: compactRuntimeCursorForTransport(snapshot.cursor) as typeof snapshot.cursor, + })), + handoffs: graph.handoffs.map((handoff) => ({ + ...handoff, + payload: compactPayloadForTransport(handoff.payload) ?? {}, + })), + timeline: graph.timeline.map((event) => ({ + ...event, + detail: compactPayloadForTransport(event.detail), + })), + runtimeEvents: graph.runtimeEvents?.map((event) => ({ + ...event, + payload: compactPayloadForTransport(event.payload), + })), + }; +} + +function buildMissionDomainService(runtime: AdeRuntime): OpaqueService | null { + const service = runtime.missionService; + if (!service) return null; + return { + ...(service as OpaqueService), + async getFullMissionView(args?: unknown): Promise<Record<string, unknown>> { + const request = asActionRecord(args); + const missionId = typeof request.missionId === "string" ? request.missionId.trim() : ""; + if (!missionId) { + return { mission: null, runGraph: null, artifacts: [], checkpoints: [], dashboard: null }; + } + + let dashboard: unknown = null; + try { + dashboard = service.getDashboard(); + } catch { + // Dashboard is supplemental for this composed view. + } + + const mission = await service.get(missionId); + let runGraph: unknown = null; + let artifacts: unknown[] = []; + let checkpoints: unknown[] = []; + + const orchestratorService = requireService(runtime.orchestratorService, "Orchestrator service not available."); + const aiOrchestratorService = requireService(runtime.aiOrchestratorService, "AI orchestrator service not available."); + const runs = await orchestratorService.listRuns({ missionId, limit: 20 }); + const activeStatuses = new Set(["active", "bootstrapping", "queued", "paused"]); + const preferredRun = runs.find((entry) => activeStatuses.has(entry.status)) ?? runs[0]; + if (preferredRun) { + const [graph, arts, cps] = await Promise.all([ + Promise.resolve(orchestratorService.getRunGraph({ runId: preferredRun.id, timelineLimit: 120 })), + Promise.resolve(aiOrchestratorService.listArtifacts({ missionId, runId: preferredRun.id })).catch(() => []), + Promise.resolve(aiOrchestratorService.listWorkerCheckpoints({ missionId, runId: preferredRun.id })).catch(() => []), + ]); + runGraph = compactRunGraphForTransport(graph); + artifacts = Array.isArray(arts) ? arts : []; + checkpoints = Array.isArray(cps) ? cps : []; + } + + return { mission, runGraph, artifacts, checkpoints, dashboard }; + }, + preflight(args?: unknown): Promise<unknown> { + const missionPreflightService = requireService(runtime.missionPreflightService, "Mission preflight service not available."); + return missionPreflightService.runPreflight(asActionRecord(args) as never); + }, + getRunView(args?: unknown): Promise<unknown> { + const aiOrchestratorService = requireService(runtime.aiOrchestratorService, "AI orchestrator service not available."); + return aiOrchestratorService.getRunView(asActionRecord(args) as never); + }, + }; +} + +function buildOrchestratorCoreDomainService(runtime: AdeRuntime): OpaqueService | null { + const service = runtime.orchestratorService; + if (!service) return null; + return { + ...(service as OpaqueService), + listRuns: (args?: Parameters<typeof service.listRuns>[0]) => + service.listRuns(args).map(compactRunForTransport), + getRunGraph: (args: Parameters<typeof service.getRunGraph>[0]) => + compactRunGraphForTransport(service.getRunGraph(args)), + startRun: (args: Parameters<typeof service.startRun>[0]) => { + const started = service.startRun(args); + return { ...started, run: compactRunForTransport(started.run) }; + }, + tick: (args: Parameters<typeof service.tick>[0]) => + compactRunForTransport(service.tick(args)), + pauseRun: (args: Parameters<typeof service.pauseRun>[0]) => + compactRunForTransport(service.pauseRun(args)), + resumeRun: (args: Parameters<typeof service.resumeRun>[0]) => + compactRunForTransport(service.resumeRun(args)), + finalizeRun: (args: Parameters<typeof service.finalizeRun>[0]) => + service.finalizeRun(args), + }; +} + +function buildAiOrchestratorDomainService(runtime: AdeRuntime): OpaqueService | null { + const service = runtime.aiOrchestratorService; + if (!service) return null; + return { + ...(service as OpaqueService), + sendChat: async (args: Parameters<typeof service.sendChat>[0]) => + compactChatMessageForTransport(await service.sendChat(args)), + getChat: (args: Parameters<typeof service.getChat>[0]) => + service.getChat(args).map(compactChatMessageForTransport), + getThreadMessages: (args: Parameters<typeof service.getThreadMessages>[0]) => + service.getThreadMessages(args).map(compactChatMessageForTransport), + sendThreadMessage: async (args: Parameters<typeof service.sendThreadMessage>[0]) => + compactChatMessageForTransport(await service.sendThreadMessage(args)), + cancelRunGracefully: async (args: Parameters<typeof service.cancelRunGracefully>[0]) => + compactRunForTransport(await service.cancelRunGracefully(args)), + resumeRun: async (args: Parameters<typeof service.resumeRun>[0]) => + compactRunForTransport(await service.resumeRun(args)), + startMissionRun: async (args: Parameters<typeof service.startMissionRun>[0]) => { + const result = await service.startMissionRun(args); + return result?.started + ? { ...result, started: { ...result.started, run: compactRunForTransport(result.started.run) } } + : result; + }, + getGlobalChat: (args: Parameters<typeof service.getGlobalChat>[0]) => + service.getGlobalChat(args).map(compactChatMessageForTransport), + sendAgentMessage: async (args: Parameters<typeof service.sendAgentMessage>[0]) => + compactChatMessageForTransport(await service.sendAgentMessage(args)), + async getCheckpointStatus(args?: unknown): Promise<Record<string, unknown> | null> { + const runId = typeof asActionRecord(args).runId === "string" + ? String(asActionRecord(args).runId).trim() + : ""; + if (!runId) return null; + const checkpoint = await readCoordinatorCheckpoint(runtime.projectRoot, runId); + if (!checkpoint) return null; + return { + savedAt: checkpoint.savedAt, + turnCount: checkpoint.turnCount, + compactionCount: checkpoint.compactionCount, + }; + }, + }; +} + +function mergeLaneDockerConfig( + current: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, + next: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, +) { + if (!current && !next) return undefined; + if (!current) return next ? { ...next, ...(next.services ? { services: [...next.services] } : {}) } : undefined; + if (!next) return { ...current, ...(current.services ? { services: [...current.services] } : {}) }; + return { + ...current, + ...next, + ...(next.services != null + ? { services: [...next.services] } + : current.services != null + ? { services: [...current.services] } + : {}), + }; +} + +function mergeLaneEnvInitConfig( + current: LaneEnvInitConfig | undefined, + next: LaneEnvInitConfig | undefined, +): LaneEnvInitConfig | undefined { + if (!current && !next) return undefined; + if (!current) { + return next + ? { + ...(next.envFiles ? { envFiles: [...next.envFiles] } : {}), + ...(mergeLaneDockerConfig(undefined, next.docker) ? { docker: mergeLaneDockerConfig(undefined, next.docker) } : {}), + ...(next.dependencies ? { dependencies: [...next.dependencies] } : {}), + ...(next.mountPoints ? { mountPoints: [...next.mountPoints] } : {}), + ...(next.copyPaths ? { copyPaths: [...next.copyPaths] } : {}), + } + : undefined; + } + if (!next) { + return { + ...(current.envFiles ? { envFiles: [...current.envFiles] } : {}), + ...(mergeLaneDockerConfig(undefined, current.docker) ? { docker: mergeLaneDockerConfig(undefined, current.docker) } : {}), + ...(current.dependencies ? { dependencies: [...current.dependencies] } : {}), + ...(current.mountPoints ? { mountPoints: [...current.mountPoints] } : {}), + ...(current.copyPaths ? { copyPaths: [...current.copyPaths] } : {}), + }; + } + return { + envFiles: [...(current.envFiles ?? []), ...(next.envFiles ?? [])], + ...(mergeLaneDockerConfig(current.docker, next.docker) ? { docker: mergeLaneDockerConfig(current.docker, next.docker) } : {}), + dependencies: [...(current.dependencies ?? []), ...(next.dependencies ?? [])], + mountPoints: [...(current.mountPoints ?? []), ...(next.mountPoints ?? [])], + copyPaths: [...(current.copyPaths ?? []), ...(next.copyPaths ?? [])], + }; +} + +function mergeLaneOverrides(base: LaneOverlayOverrides, next: Partial<LaneOverlayOverrides>): LaneOverlayOverrides { + return { + ...base, + ...next, + ...(base.env || next.env ? { env: { ...(base.env ?? {}), ...(next.env ?? {}) } } : {}), + ...(base.processIds || next.processIds ? { processIds: [...(next.processIds ?? base.processIds ?? [])] } : {}), + ...(base.testSuiteIds || next.testSuiteIds ? { testSuiteIds: [...(next.testSuiteIds ?? base.testSuiteIds ?? [])] } : {}), + ...(mergeLaneEnvInitConfig(base.envInit, next.envInit) ? { envInit: mergeLaneEnvInitConfig(base.envInit, next.envInit) } : {}), + }; +} + +function applyLeaseToOverrides(overrides: LaneOverlayOverrides, lease: PortLease | null): LaneOverlayOverrides { + if (!lease || lease.status !== "active" || overrides.portRange) { + return { ...overrides }; + } + return { + ...overrides, + portRange: { start: lease.rangeStart, end: lease.rangeEnd }, + }; +} + +function requireService<T>(service: T | null | undefined, message: string): T { + if (!service) throw new Error(message); + return service; +} + +async function resolveLane(runtime: AdeRuntime, laneId: string) { + const lanes = await runtime.laneService.list({ includeArchived: true, includeStatus: false }); + const lane = lanes.find((entry) => entry.id === laneId); + if (!lane) throw new Error(`Lane not found: ${laneId}`); + return lane; +} + +async function resolveActiveLaneIds(runtime: AdeRuntime): Promise<string[]> { + const lanes = await runtime.laneService.list({ includeArchived: false, includeStatus: false }); + return lanes.map((lane) => lane.id); +} + +async function resolveLaneOverlayContext(runtime: AdeRuntime, laneId: string) { + const lane = await resolveLane(runtime, laneId); + const config = runtime.projectConfigService.getEffective(); + const overlayOverrides = matchLaneOverlayPolicies(lane, config.laneOverlayPolicies ?? []); + const lease = runtime.portAllocationService?.getLease(lane.id) ?? null; + const overrides = applyLeaseToOverrides(overlayOverrides, lease); + const envInitConfig = runtime.laneEnvironmentService?.resolveEnvInitConfig(config.laneEnvInit, overrides); + return { + lane, + overrides, + envInitConfig, + lease, + }; +} + +async function ensureLanePortLease(runtime: AdeRuntime, laneId: string): Promise<PortLease | null> { + await resolveLane(runtime, laneId); + const portAllocationService = runtime.portAllocationService; + if (!portAllocationService) return null; + return portAllocationService.getLease(laneId) ?? portAllocationService.acquire(laneId); +} + +async function ensureLanePreviewInfo(runtime: AdeRuntime, laneId: string): Promise<LanePreviewInfo | null> { + const laneProxyService = runtime.laneProxyService; + const portAllocationService = runtime.portAllocationService; + if (!laneProxyService || !portAllocationService) return null; + + const lane = await resolveLane(runtime, laneId).catch(() => null); + if (!lane || lane.archivedAt != null) { + laneProxyService.removeRoute(laneId); + return null; + } + + const lease = portAllocationService.getLease(laneId) ?? portAllocationService.acquire(laneId); + if (lease.status !== "active") { + laneProxyService.removeRoute(laneId); + return null; + } + + if (!laneProxyService.getStatus().running) { + await laneProxyService.start().catch((error: unknown) => { + runtime.logger.warn("lane_proxy.preview_start_failed", { + laneId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + if (!laneProxyService.getStatus().running) return null; + + const expectedHostname = laneProxyService.generateHostname(laneId, lane.name); + const health = runtime.runtimeDiagnosticsService + ? await runtime.runtimeDiagnosticsService.checkLaneHealth(laneId).catch(() => null) + : null; + const respondingPort = Number.isInteger(health?.respondingPort) + && (health?.respondingPort as number) >= lease.rangeStart + && (health?.respondingPort as number) <= lease.rangeEnd + ? (health?.respondingPort as number) + : null; + const targetPort = respondingPort ?? lease.rangeStart; + const currentRoute = laneProxyService.getRoute(laneId); + if ( + !currentRoute || + currentRoute.targetPort !== targetPort || + currentRoute.hostname !== expectedHostname || + currentRoute.status !== "active" + ) { + laneProxyService.addRoute(laneId, targetPort, lane.name); + } + return laneProxyService.getPreviewInfo(laneId); +} + +function buildLaneDomainService(runtime: AdeRuntime): OpaqueService { + const laneService = runtime.laneService as unknown as OpaqueService; + return { + ...laneService, + listSnapshots: async (args?: ListLanesArgs): Promise<LaneListSnapshot[]> => { + const lanes = await runtime.laneService.list({ + includeArchived: Boolean(args?.includeArchived), + includeStatus: args?.includeStatus !== false, + }); + return buildLaneListSnapshots( + { + laneService: runtime.laneService, + sessionService: runtime.sessionService, + ptyService: runtime.ptyService, + agentChatService: runtime.agentChatService ?? null, + rebaseSuggestionService: runtime.rebaseSuggestionService ?? null, + autoRebaseService: runtime.autoRebaseService ?? null, + conflictService: runtime.conflictService ?? null, + syncService: runtime.syncService ?? null, + logger: runtime.logger, + }, + lanes, + { + includeConflictStatus: args?.includeConflictStatus !== false, + includeRebaseSuggestions: args?.includeRebaseSuggestions !== false, + includeAutoRebaseStatus: args?.includeAutoRebaseStatus !== false, + }, + ); + }, + listRebaseSuggestions: () => runtime.rebaseSuggestionService?.listSuggestions() ?? [], + dismissRebaseSuggestion: async (args?: { laneId?: string }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + runtime.conflictService?.dismissRebase(laneId); + await runtime.rebaseSuggestionService?.dismiss({ laneId }); + }, + deferRebaseSuggestion: async (args?: { laneId?: string; minutes?: number }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + const minutes = Math.max(5, Math.min(7 * 24 * 60, Math.floor(args?.minutes ?? 60))); + const until = new Date(Date.now() + minutes * 60_000).toISOString(); + runtime.conflictService?.deferRebase(laneId, until); + await runtime.rebaseSuggestionService?.defer({ laneId, minutes }); + }, + listAutoRebaseStatuses: () => runtime.autoRebaseService?.listStatuses() ?? [], + dismissAutoRebaseStatus: async (args?: { laneId?: string }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + await runtime.autoRebaseService?.dismissStatus({ laneId }); + }, + initEnv: async (args?: { laneId?: string }): Promise<LaneEnvInitProgress> => { + const laneEnvironmentService = requireService(runtime.laneEnvironmentService, "Lane environment service not available."); + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + const context = await resolveLaneOverlayContext(runtime, laneId); + if (!context.envInitConfig) { + const now = new Date().toISOString(); + return { laneId, steps: [], startedAt: now, completedAt: now, overallStatus: "completed" }; + } + return laneEnvironmentService.initLaneEnvironment(context.lane, context.envInitConfig, context.overrides); + }, + getEnvStatus: (args?: { laneId?: string }) => + runtime.laneEnvironmentService?.getProgress(requireNonEmptyString(args?.laneId, "laneId")) ?? null, + getOverlay: async (args?: { laneId?: string }) => { + const context = await resolveLaneOverlayContext(runtime, requireNonEmptyString(args?.laneId, "laneId")); + return context.overrides; + }, + listTemplates: () => runtime.laneTemplateService?.listTemplates() ?? [], + getTemplate: (args?: { templateId?: string }) => + runtime.laneTemplateService?.getTemplate(requireNonEmptyString(args?.templateId, "templateId")) ?? null, + getDefaultTemplate: () => runtime.laneTemplateService?.getDefaultTemplateId() ?? null, + setDefaultTemplate: (args?: { templateId?: string | null }) => { + requireService(runtime.laneTemplateService, "Lane template service not available.").setDefaultTemplateId(args?.templateId ?? null); + }, + applyTemplate: async (args?: ApplyLaneTemplateArgs): Promise<LaneEnvInitProgress> => { + const laneTemplateService = requireService(runtime.laneTemplateService, "Lane template service not available."); + const laneEnvironmentService = requireService(runtime.laneEnvironmentService, "Lane environment service not available."); + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + const templateId = requireNonEmptyString(args?.templateId, "templateId"); + const context = await resolveLaneOverlayContext(runtime, laneId); + const template = laneTemplateService.getTemplate(templateId); + if (!template) throw new Error(`Template not found: ${templateId}`); + const templateEnvInit = laneTemplateService.resolveTemplateAsEnvInit(template); + const mergedOverrides = mergeLaneOverrides(context.overrides, { + ...(template.envVars ? { env: template.envVars } : {}), + ...(!context.overrides.portRange && template.portRange ? { portRange: template.portRange } : {}), + envInit: templateEnvInit, + }); + const mergedEnvInitConfig = mergeLaneEnvInitConfig(context.envInitConfig, templateEnvInit) ?? templateEnvInit; + return laneEnvironmentService.initLaneEnvironment(context.lane, mergedEnvInitConfig, mergedOverrides); + }, + saveTemplate: (args?: { template?: unknown }) => { + const template = args?.template; + if (!template || typeof template !== "object" || Array.isArray(template)) { + throw new Error("Lane template payload is required."); + } + requireService(runtime.laneTemplateService, "Lane template service not available.").saveTemplate(template as Parameters<NonNullable<AdeRuntime["laneTemplateService"]>["saveTemplate"]>[0]); + }, + deleteTemplate: (args?: { templateId?: string }) => { + requireService(runtime.laneTemplateService, "Lane template service not available.").deleteTemplate(requireNonEmptyString(args?.templateId, "templateId")); + }, + portGetLease: async (args?: { laneId?: string }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + await ensureLanePortLease(runtime, laneId); + return runtime.portAllocationService?.getLease(laneId) ?? null; + }, + portListLeases: () => runtime.portAllocationService?.listLeases() ?? [], + portAcquire: async (args?: { laneId?: string }) => { + const lease = await ensureLanePortLease(runtime, requireNonEmptyString(args?.laneId, "laneId")); + if (!lease) throw new Error("Port allocation service not available."); + return lease; + }, + portRelease: async (args?: { laneId?: string }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + await resolveLane(runtime, laneId); + runtime.portAllocationService?.release(laneId); + }, + portListConflicts: () => runtime.portAllocationService?.listConflicts() ?? [], + portRecoverOrphans: async () => { + if (!runtime.portAllocationService) return []; + const validIds = new Set(await resolveActiveLaneIds(runtime)); + return runtime.portAllocationService.recoverOrphans(validIds); + }, + proxyGetStatus: (): ProxyStatus => runtime.laneProxyService?.getStatus() ?? { running: false, proxyPort: 8080, routes: [] }, + proxyStart: (args?: { port?: number }) => requireService(runtime.laneProxyService, "Proxy service not available.").start(args?.port), + proxyStop: async () => { + await runtime.laneProxyService?.stop(); + }, + proxyAddRoute: async (args?: { laneId?: string; targetPort?: number }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + const targetPort = args?.targetPort; + if (!Number.isInteger(targetPort) || Number(targetPort) <= 0) { + throw new Error("targetPort must be a positive integer."); + } + const lane = await resolveLane(runtime, laneId); + return requireService(runtime.laneProxyService, "Proxy service not available.").addRoute(laneId, Number(targetPort), lane.name); + }, + proxyRemoveRoute: (args?: { laneId?: string }) => + runtime.laneProxyService?.removeRoute(requireNonEmptyString(args?.laneId, "laneId")), + proxyGetPreviewInfo: (args?: { laneId?: string }) => + ensureLanePreviewInfo(runtime, requireNonEmptyString(args?.laneId, "laneId")), + oauthGetStatus: () => runtime.oauthRedirectService?.getStatus() ?? { enabled: false, routingMode: "state-parameter", activeSessions: [], callbackPaths: [] }, + oauthUpdateConfig: (args?: Record<string, unknown>) => { + requireService(runtime.oauthRedirectService, "OAuth redirect service not available.").updateConfig(args ?? {}); + }, + oauthGenerateRedirectUris: (args?: { provider?: string }) => + runtime.oauthRedirectService?.generateRedirectUris(args?.provider) ?? [], + oauthEncodeState: (args?: { laneId?: string; originalState?: string }) => + requireService(runtime.oauthRedirectService, "OAuth redirect service not available.").encodeState( + requireNonEmptyString(args?.laneId, "laneId"), + typeof args?.originalState === "string" ? args.originalState : "", + ), + oauthDecodeState: (args?: { encodedState?: string }) => + runtime.oauthRedirectService?.decodeState(requireNonEmptyString(args?.encodedState, "encodedState")) ?? null, + oauthListSessions: () => runtime.oauthRedirectService?.listSessions() ?? [], + diagnosticsGetStatus: async () => { + const laneIds = await resolveActiveLaneIds(runtime); + return runtime.runtimeDiagnosticsService?.getStatus(laneIds) ?? { + lanes: [], + proxyRunning: false, + proxyPort: runtime.laneProxyService?.getStatus().proxyPort ?? 0, + totalRoutes: 0, + activeConflicts: 0, + fallbackLanes: [], + }; + }, + diagnosticsGetLaneHealth: (args?: { laneId?: string }) => + runtime.runtimeDiagnosticsService?.getLaneHealth(requireNonEmptyString(args?.laneId, "laneId")) ?? null, + diagnosticsRunHealthCheck: async (args?: { laneId?: string }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + await resolveLane(runtime, laneId); + return requireService(runtime.runtimeDiagnosticsService, "Runtime diagnostics service not available.").checkLaneHealth(laneId); + }, + diagnosticsRunFullCheck: async () => { + const laneIds = await resolveActiveLaneIds(runtime); + return runtime.runtimeDiagnosticsService?.checkAllLanes(laneIds) ?? []; + }, + diagnosticsActivateFallback: async (args?: { laneId?: string }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + await resolveLane(runtime, laneId); + runtime.runtimeDiagnosticsService?.activateFallback(laneId); + }, + diagnosticsDeactivateFallback: async (args?: { laneId?: string }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + await resolveLane(runtime, laneId); + runtime.runtimeDiagnosticsService?.deactivateFallback(laneId); + }, + }; +} + +function buildAiDomainService(runtime: AdeRuntime): OpaqueService | null { + const aiIntegrationService = runtime.aiIntegrationService; + if (!aiIntegrationService) return null; + return { + getStatus: (args?: { force?: boolean; refreshOpenCodeInventory?: boolean }) => + buildAiSettingsStatus(aiIntegrationService, args), + getOpenCodeRuntimeDiagnostics: async () => { + const { getOpenCodeRuntimeSnapshot } = await import("../opencode/openCodeRuntime"); + return getOpenCodeRuntimeSnapshot(); + }, + verifyApiKeyConnection: (args?: { provider?: string }) => + aiIntegrationService.verifyApiKeyConnection(requireNonEmptyString(args?.provider, "provider")), + storeApiKey: (args?: { provider?: string; key?: string }) => + aiIntegrationService.storeApiKey( + requireNonEmptyString(args?.provider, "provider"), + requireNonEmptyString(args?.key, "key"), + ), + deleteApiKey: (args?: { provider?: string }) => + aiIntegrationService.deleteApiKey(requireNonEmptyString(args?.provider, "provider")), + listApiKeys: () => aiIntegrationService.listApiKeys(), + updateConfig: (partial?: Partial<AiConfig>) => { + const projectConfigService = requireService(runtime.projectConfigService, "Project config service not available."); + const snapshot = projectConfigService.get(); + const currentAi = snapshot.shared?.ai ?? {}; + const merged = mergeAiConfig(currentAi, partial ?? {}) ?? {}; + projectConfigService.save({ + shared: { ...snapshot.shared, ai: merged }, + local: snapshot.local ?? {}, + }); + }, + listCursorCloudRepositories: () => aiIntegrationService.listCursorCloudRepositories(), + listCursorCloudAgents: (args?: { includeArchived?: boolean; limit?: number; cursor?: string | null }) => + aiIntegrationService.listCursorCloudAgents(args ?? {}), + listCursorCloudRuns: (args?: { agentId?: string; limit?: number; cursor?: string | null }) => + aiIntegrationService.listCursorCloudRuns({ + agentId: requireNonEmptyString(args?.agentId, "agentId"), + ...(args?.limit !== undefined ? { limit: args.limit } : {}), + ...(args?.cursor !== undefined ? { cursor: args.cursor } : {}), + }), + createCursorCloudRun: (args: Parameters<typeof aiIntegrationService.createCursorCloudRun>[0]) => + aiIntegrationService.createCursorCloudRun(args), + archiveCursorCloudAgent: (args?: { agentId?: string }) => + aiIntegrationService.archiveCursorCloudAgent(requireNonEmptyString(args?.agentId, "agentId")), + unarchiveCursorCloudAgent: (args?: { agentId?: string }) => + aiIntegrationService.unarchiveCursorCloudAgent(requireNonEmptyString(args?.agentId, "agentId")), + deleteCursorCloudAgent: (args?: { agentId?: string }) => + aiIntegrationService.deleteCursorCloudAgent(requireNonEmptyString(args?.agentId, "agentId")), + getCursorCloudAgent: (args?: { agentId?: string }) => + aiIntegrationService.getCursorCloudAgent(requireNonEmptyString(args?.agentId, "agentId")), + listCursorCloudArtifacts: async (args?: { agentId?: string }) => { + const items = await aiIntegrationService.listCursorCloudArtifacts(requireNonEmptyString(args?.agentId, "agentId")); + return items.map((entry) => ({ + path: entry.path, + ...(typeof entry.sizeBytes === "number" ? { sizeBytes: entry.sizeBytes } : {}), + ...(entry.updatedAt !== undefined ? { updatedAt: entry.updatedAt } : {}), + ...(entry.mimeType !== undefined ? { mimeType: entry.mimeType } : {}), + })); + }, + downloadCursorCloudArtifact: (args?: { agentId?: string; path?: string }) => + aiIntegrationService.downloadCursorCloudArtifact({ + agentId: requireNonEmptyString(args?.agentId, "agentId"), + path: requireNonEmptyString(args?.path, "path"), + }), + cancelCursorCloudRun: (args?: { agentId?: string; runId?: string }) => + requireService(runtime.agentChatService, "Agent chat service not available.").cancelCursorCloudRun({ + agentId: requireNonEmptyString(args?.agentId, "agentId"), + runId: requireNonEmptyString(args?.runId, "runId"), + }), + cursorCloudFollowUp: (args?: { agentId?: string; prompt?: string; modelId?: string | null }) => + requireService(runtime.agentChatService, "Agent chat service not available.").cursorCloudFollowUp({ + agentId: requireNonEmptyString(args?.agentId, "agentId"), + prompt: requireNonEmptyString(args?.prompt, "prompt"), + ...(args?.modelId !== undefined ? { modelId: args.modelId } : {}), + }), + openCursorCloudChat: (args?: { cloudAgentId?: string; laneId?: string }) => + requireService(runtime.agentChatService, "Agent chat service not available.").openCursorCloudChat({ + cloudAgentId: requireNonEmptyString(args?.cloudAgentId, "cloudAgentId"), + laneId: requireNonEmptyString(args?.laneId, "laneId"), + }), + }; +} + +const AI_SETTINGS_FEATURE_KEYS: AiFeatureKey[] = [ + "narratives", + "conflict_proposals", + "commit_messages", + "pr_descriptions", + "terminal_summaries", + "memory_consolidation", + "mission_planning", + "orchestrator", + "initial_context", +]; + +async function buildAiSettingsStatus( + aiIntegrationService: NonNullable<AdeRuntime["aiIntegrationService"]>, + options?: { force?: boolean; refreshOpenCodeInventory?: boolean }, +): Promise<AiSettingsStatus> { + const status = await aiIntegrationService.getStatus({ + force: options?.force === true, + refreshOpenCodeInventory: options?.refreshOpenCodeInventory === true, + }); + const usageBatch = aiIntegrationService.getDailyUsageBatch(AI_SETTINGS_FEATURE_KEYS); + return { + mode: status.mode, + availableProviders: status.availableProviders, + models: status.models, + detectedAuth: status.detectedAuth, + providerConnections: status.providerConnections, + runtimeConnections: status.runtimeConnections, + availableModelIds: status.availableModelIds, + opencodeBinaryInstalled: status.opencodeBinaryInstalled, + opencodeBinarySource: status.opencodeBinarySource, + opencodeInventoryError: status.opencodeInventoryError, + opencodeProviders: status.opencodeProviders, + apiKeyStore: status.apiKeyStore, + features: AI_SETTINGS_FEATURE_KEYS.map((feature) => ({ + feature, + enabled: aiIntegrationService.getFeatureFlag(feature), + dailyUsage: usageBatch.get(feature) ?? 0, + dailyLimit: aiIntegrationService.getDailyBudgetLimit(feature), + })), + }; +} + function requireNonEmptyString(value: unknown, field: string): string { if (typeof value !== "string") { throw new Error(`Expected '${field}' to be a non-empty string.`); @@ -579,6 +2588,889 @@ type TerminalDomainService = { activeForChat(args?: unknown): unknown; }; +const RUNTIME_FILE_WATCH_CLIENT_ID_FIELD = "__adeRuntimeClientId"; +const RUNTIME_FILE_WATCH_DEFAULT_SENDER_ID = 1; + +function asActionRecord(value: unknown): Record<string, unknown> { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record<string, unknown> + : {}; +} + +function readRuntimeFileWatchSenderId(args: Record<string, unknown>): number { + const raw = args[RUNTIME_FILE_WATCH_CLIENT_ID_FIELD]; + const numeric = typeof raw === "number" + ? raw + : typeof raw === "string" + ? Number.parseInt(raw, 10) + : NaN; + if (Number.isSafeInteger(numeric) && numeric > 0) { + return numeric; + } + return RUNTIME_FILE_WATCH_DEFAULT_SENDER_ID; +} + +function toRuntimeFileWatchArgs(args: Record<string, unknown>): FilesWatchArgs { + const { [RUNTIME_FILE_WATCH_CLIENT_ID_FIELD]: _clientId, ...watchArgs } = args; + return watchArgs as unknown as FilesWatchArgs; +} + +function readStringActionArg(value: unknown, field: string): string { + if (typeof value === "string") { + return requireNonEmptyString(value, field); + } + return requireNonEmptyString(asActionRecord(value)[field], field); +} + +function buildIssueResolutionInstructionsFromThread(arg: LaunchPrIssueResolutionFromThreadArgs): string { + const lines = [`Focus on review thread ${arg.threadId} on PR ${arg.prId}.`]; + if (arg.commentId) { + lines.push(`The relevant comment id is ${arg.commentId}.`); + } + const fileContext = arg.fileContext; + if (fileContext?.path) { + const lineNumber = fileContext.startLine ?? fileContext.line ?? null; + lines.push( + lineNumber != null + ? `Start by inspecting ${fileContext.path}:${lineNumber}.` + : `Start by inspecting ${fileContext.path}.`, + ); + } + if (arg.additionalInstructions) { + lines.push("", arg.additionalInstructions); + } + return lines.join("\n"); +} + +function buildPrIssueResolutionDeps(runtime: AdeRuntime) { + return { + prService: requireService(runtime.prService, "PR service not available."), + laneService: runtime.laneService, + agentChatService: requireService(runtime.agentChatService, "Agent chat service not available."), + sessionService: runtime.sessionService, + issueInventoryService: runtime.issueInventoryService, + laneWorktreeLockService: runtime.laneWorktreeLockService ?? null, + }; +} + +type PrAiRuntimeSession = { + sessionId: string; + ptyId: string | null; + runId: string; + provider: "codex" | "claude"; + contextKey: string; + context: PrAiResolutionContext; + modelId: string; + reasoning: string | null; + permissionMode: PrAgentPermissionMode; + pollTimer: ReturnType<typeof setInterval> | null; + finalizing: boolean; +}; + +type PrAiRuntimeBridge = { + getSession(args?: unknown): Promise<PrAiResolutionGetSessionResult>; + start(args?: unknown): Promise<PrAiResolutionStartResult>; + input(args?: unknown): Promise<void>; + stop(args?: unknown): Promise<void>; +}; + +const prAiRuntimeBridges = new WeakMap<AdeRuntime, PrAiRuntimeBridge>(); + +function inferPrAiProvider(modelId: string): "codex" | "claude" { + const descriptor = getModelById(modelId); + return descriptor?.family === "anthropic" ? "claude" : "codex"; +} + +function collectPrAiSourceLaneIds(context: PrAiResolutionContext): string[] { + const sourceLaneIds = new Set<string>(); + const add = (value: string | null | undefined) => { + const normalized = typeof value === "string" ? value.trim() : ""; + if (normalized) sourceLaneIds.add(normalized); + }; + for (const laneId of context.sourceLaneIds ?? []) { + add(laneId); + } + add(context.sourceLaneId ?? null); + if (context.sourceTab !== "integration") { + add(context.laneId ?? null); + } + return Array.from(sourceLaneIds); +} + +function mapExternalResolverStatusToPrAi(status: string): PrAiResolutionSessionStatus { + if (status === "completed") return "completed"; + if (status === "failed" || status === "blocked") return "failed"; + if (status === "canceled") return "cancelled"; + return "running"; +} + +function buildPrAiDisplayText(context: PrAiResolutionContext): string { + if (context.sourceTab === "rebase") return "Resolve this rebase with AI."; + if (context.sourceTab === "queue") return "Resolve this queued PR with AI."; + if (context.sourceTab === "integration") { + return context.proposalId + ? "Resolve this integration proposal with AI." + : "Resolve this integration PR with AI."; + } + return "Resolve this PR with AI."; +} + +function emitPrAiResolutionRuntimeEvent(runtime: AdeRuntime, payload: PrAiResolutionEventPayload): void { + runtime.eventBuffer.push({ + timestamp: nowIso(), + category: "runtime", + payload: { type: "pr_ai_resolution_event", event: payload }, + }); +} + +function readSummaryPermissionMode(summary: unknown): PrAgentPermissionMode | null { + const record = asActionRecord(summary); + return typeof record.permissionMode === "string" + ? record.permissionMode as PrAgentPermissionMode + : null; +} + +function buildPrAiSessionInfo(args: { + context: PrAiResolutionContext; + contextKey: string; + sessionId: string; + provider: "codex" | "claude"; + model: string | null; + modelId: string | null; + reasoning: string | null; + permissionMode: PrAgentPermissionMode | null; + status: PrAiResolutionSessionStatus; +}): PrAiResolutionSessionInfo { + return { + contextKey: args.contextKey, + sessionId: args.sessionId, + provider: args.provider, + model: args.model, + modelId: args.modelId, + reasoning: args.reasoning, + permissionMode: args.permissionMode, + context: args.context, + status: args.status, + }; +} + +function getPrAiRuntimeBridge(runtime: AdeRuntime): PrAiRuntimeBridge { + const existing = prAiRuntimeBridges.get(runtime); + if (existing) return existing; + + const prAiSessions = new Map<string, PrAiRuntimeSession>(); + const prAiSessionsByContextKey = new Map<string, string>(); + + const clearSession = (sessionId: string): void => { + const session = prAiSessions.get(sessionId); + if (!session) return; + if (session.pollTimer) clearInterval(session.pollTimer); + if (prAiSessionsByContextKey.get(session.contextKey) === sessionId) { + prAiSessionsByContextKey.delete(session.contextKey); + } + prAiSessions.delete(sessionId); + }; + + const finalize = async ( + sessionId: string, + opts: { forceStatus?: "cancelled" | "completed" | "failed"; message?: string } = {}, + ): Promise<void> => { + const session = prAiSessions.get(sessionId); + if (!session || session.finalizing) return; + session.finalizing = true; + try { + const detail = runtime.sessionService.get(sessionId); + const derivedExitCode = opts.forceStatus === "cancelled" + ? 130 + : (detail?.exitCode ?? (detail?.status === "completed" ? 0 : 1)); + try { + await runtime.conflictService.finalizeResolverSession({ + runId: session.runId, + exitCode: derivedExitCode, + }); + } catch (error) { + runtime.logger.debug("ade_actions.prs_ai_resolution_finalize_failed", { + sessionId, + runId: session.runId, + error: getErrorMessage(error), + }); + } + + const status = opts.forceStatus + ?? (detail?.status === "disposed" + ? "cancelled" + : derivedExitCode === 0 + ? "completed" + : "failed"); + emitPrAiResolutionRuntimeEvent(runtime, { + sessionId, + status, + message: opts.message ?? null, + timestamp: nowIso(), + }); + } finally { + clearSession(sessionId); + } + }; + + const bridge: PrAiRuntimeBridge = { + async getSession(args?: unknown): Promise<PrAiResolutionGetSessionResult> { + const context = (asActionRecord(args).context ?? {}) as PrAiResolutionContext; + const contextKey = buildPrAiResolutionContextKey(context); + const liveSessionId = prAiSessionsByContextKey.get(contextKey); + const agentChatService = requireService(runtime.agentChatService, "Agent chat service not available."); + const sessionSummaries = await agentChatService.listSessions(); + + if (liveSessionId) { + const liveSession = prAiSessions.get(liveSessionId); + if (liveSession) { + const summary = sessionSummaries.find((entry) => entry.sessionId === liveSessionId) ?? null; + const summaryRecord = asActionRecord(summary); + return buildPrAiSessionInfo({ + context: liveSession.context, + contextKey, + sessionId: liveSessionId, + provider: liveSession.provider, + model: typeof summaryRecord.model === "string" ? summaryRecord.model : liveSession.modelId, + modelId: typeof summaryRecord.modelId === "string" ? summaryRecord.modelId : liveSession.modelId, + reasoning: typeof summaryRecord.reasoningEffort === "string" ? summaryRecord.reasoningEffort : liveSession.reasoning, + permissionMode: readSummaryPermissionMode(summary) ?? liveSession.permissionMode, + status: "running", + }); + } + prAiSessionsByContextKey.delete(contextKey); + } + + const persistedRun = runtime.conflictService + .listExternalResolverRuns({ limit: 200 }) + .find((entry) => entry.resolverContextKey === contextKey && entry.sessionId); + if (!persistedRun?.sessionId) return null; + + const summary = sessionSummaries.find((entry) => entry.sessionId === persistedRun.sessionId) ?? null; + const summaryRecord = asActionRecord(summary); + return buildPrAiSessionInfo({ + context, + contextKey, + sessionId: persistedRun.sessionId, + provider: persistedRun.provider === "claude" ? "claude" : "codex", + model: typeof summaryRecord.model === "string" ? summaryRecord.model : persistedRun.model ?? null, + modelId: typeof summaryRecord.modelId === "string" ? summaryRecord.modelId : persistedRun.model ?? null, + reasoning: typeof summaryRecord.reasoningEffort === "string" ? summaryRecord.reasoningEffort : persistedRun.reasoningEffort ?? null, + permissionMode: readSummaryPermissionMode(summary) ?? persistedRun.permissionMode ?? null, + status: mapExternalResolverStatusToPrAi(persistedRun.status), + }); + }, + async start(args?: unknown): Promise<PrAiResolutionStartResult> { + const startArgs = asActionRecord(args) as unknown as PrAiResolutionStartArgs; + const context = (startArgs.context ?? {}) as PrAiResolutionContext; + const model = typeof startArgs.model === "string" ? startArgs.model.trim() : ""; + const targetLaneId = typeof context.targetLaneId === "string" ? context.targetLaneId.trim() : ""; + const sourceLaneIds = collectPrAiSourceLaneIds(context); + const permissionMode: PrAgentPermissionMode = startArgs.permissionMode ?? "default"; + const reasoning = typeof startArgs.reasoning === "string" && startArgs.reasoning.trim().length > 0 + ? startArgs.reasoning.trim() + : null; + const additionalInstructions = typeof startArgs.additionalInstructions === "string" && startArgs.additionalInstructions.trim().length > 0 + ? startArgs.additionalInstructions.trim() + : null; + let runId = ""; + + if (!model) { + const sessionId = randomUUID(); + const error = "Model is required to start AI resolution."; + emitPrAiResolutionRuntimeEvent(runtime, { sessionId, status: "failed", message: error, timestamp: nowIso() }); + return { sessionId, provider: "codex", ptyId: null, status: "failed", error, context }; + } + if (!targetLaneId) { + const sessionId = randomUUID(); + const error = "Target lane is required to start AI resolution."; + emitPrAiResolutionRuntimeEvent(runtime, { sessionId, status: "failed", message: error, timestamp: nowIso() }); + return { sessionId, provider: inferPrAiProvider(model), ptyId: null, status: "failed", error, context }; + } + if (sourceLaneIds.length === 0) { + const sessionId = randomUUID(); + const error = "At least one source lane is required to start AI resolution."; + emitPrAiResolutionRuntimeEvent(runtime, { sessionId, status: "failed", message: error, timestamp: nowIso() }); + return { sessionId, provider: inferPrAiProvider(model), ptyId: null, status: "failed", error, context }; + } + + try { + const provider = inferPrAiProvider(model); + const modelDescriptor = getModelById(model); + const prep = await runtime.conflictService.prepareResolverSession({ + provider, + targetLaneId, + sourceLaneIds, + cwdLaneId: typeof context.integrationLaneId === "string" && context.integrationLaneId.trim().length > 0 + ? context.integrationLaneId.trim() + : (typeof context.laneId === "string" && context.laneId.trim().length > 0 ? context.laneId.trim() : undefined), + proposalId: typeof context.proposalId === "string" && context.proposalId.trim().length > 0 + ? context.proposalId.trim() + : undefined, + sourceTab: context.sourceTab, + scenario: context.scenario ?? (sourceLaneIds.length > 1 ? "integration-merge" : "single-merge"), + model, + reasoningEffort: reasoning, + permissionMode, + additionalInstructions, + originSurface: context.sourceTab === "integration" || context.sourceTab === "rebase" ? context.sourceTab : "manual", + }); + runId = prep.runId; + if (prep.status === "blocked") { + const sessionId = randomUUID(); + const reason = prep.contextGaps.length + ? prep.contextGaps.map((gap) => gap.message).join(", ") + : "Resolver session blocked due to insufficient context."; + emitPrAiResolutionRuntimeEvent(runtime, { sessionId, status: "failed", message: reason, timestamp: nowIso() }); + return { sessionId, provider, ptyId: null, status: "failed", error: reason, context }; + } + + const agentChatService = requireService(runtime.agentChatService, "Agent chat service not available."); + const session = await agentChatService.createSession({ + laneId: prep.cwdLaneId, + provider, + model: modelDescriptor?.shortId ?? model, + ...(modelDescriptor?.id ? { modelId: modelDescriptor.id } : {}), + ...(reasoning ? { reasoningEffort: reasoning } : {}), + permissionMode: mapPermissionModeForModelFamily(permissionMode, modelDescriptor?.family), + }); + const promptText = fs.readFileSync(prep.promptFilePath, "utf8"); + const runtimeContext: PrAiResolutionContext = { + ...context, + laneId: prep.cwdLaneId, + targetLaneId, + sourceLaneId: sourceLaneIds[0] ?? context.sourceLaneId ?? context.laneId ?? null, + sourceLaneIds, + integrationLaneId: prep.integrationLaneId ?? context.integrationLaneId ?? null, + }; + const contextKey = buildPrAiResolutionContextKey(runtimeContext); + const runtimeSession: PrAiRuntimeSession = { + sessionId: session.id, + ptyId: null, + runId: prep.runId, + provider, + contextKey, + context: runtimeContext, + modelId: model, + reasoning, + permissionMode, + pollTimer: null, + finalizing: false, + }; + await runtime.conflictService.attachResolverSession({ + runId: prep.runId, + ptyId: null, + sessionId: session.id, + command: [], + }); + runtimeSession.pollTimer = setInterval(() => { + const current = prAiSessions.get(runtimeSession.sessionId); + if (!current || current.finalizing) return; + const detail = runtime.sessionService.get(runtimeSession.sessionId); + if (!detail || detail.status === "running") return; + void finalize(runtimeSession.sessionId); + }, 1_000); + prAiSessions.set(runtimeSession.sessionId, runtimeSession); + prAiSessionsByContextKey.set(contextKey, runtimeSession.sessionId); + emitPrAiResolutionRuntimeEvent(runtime, { + sessionId: runtimeSession.sessionId, + status: "running", + message: null, + timestamp: nowIso(), + }); + void agentChatService.sendMessage({ + sessionId: runtimeSession.sessionId, + text: promptText, + displayText: buildPrAiDisplayText(runtimeContext), + ...(reasoning ? { reasoningEffort: reasoning } : {}), + }).catch(async (error: unknown) => { + runtime.logger.warn("ade_actions.prs_ai_resolution_send_failed", { + sessionId: runtimeSession.sessionId, + runId: prep.runId, + error: getErrorMessage(error), + }); + await finalize(runtimeSession.sessionId, { forceStatus: "failed", message: getErrorMessage(error) }); + }); + return { + sessionId: runtimeSession.sessionId, + provider, + ptyId: null, + status: "started", + error: null, + context: runtimeContext, + }; + } catch (error) { + if (runId) { + try { + await runtime.conflictService.finalizeResolverSession({ runId, exitCode: 1 }); + } catch { + // Preserve the original error. + } + } + const sessionId = randomUUID(); + const message = getErrorMessage(error); + emitPrAiResolutionRuntimeEvent(runtime, { sessionId, status: "failed", message, timestamp: nowIso() }); + return { sessionId, provider: inferPrAiProvider(model), ptyId: null, status: "failed", error: message, context }; + } + }, + async input(args?: unknown): Promise<void> { + const inputArgs = asActionRecord(args) as unknown as PrAiResolutionInputArgs; + const sessionId = typeof inputArgs.sessionId === "string" ? inputArgs.sessionId.trim() : ""; + const text = typeof inputArgs.text === "string" ? inputArgs.text : ""; + if (!sessionId || !text.length) return; + if (!prAiSessions.has(sessionId)) throw new Error(`AI resolution session not found: ${sessionId}`); + const agentChatService = requireService(runtime.agentChatService, "Agent chat service not available."); + const sessionDetail = runtime.sessionService.get(sessionId); + if (sessionDetail?.status === "running") { + await agentChatService.steer({ sessionId, text }); + return; + } + await agentChatService.sendMessage({ sessionId, text }); + }, + async stop(args?: unknown): Promise<void> { + const stopArgs = asActionRecord(args) as unknown as PrAiResolutionStopArgs; + const sessionId = typeof stopArgs.sessionId === "string" ? stopArgs.sessionId.trim() : ""; + if (!sessionId) return; + if (!prAiSessions.has(sessionId)) return; + const agentChatService = requireService(runtime.agentChatService, "Agent chat service not available."); + await agentChatService.interrupt({ sessionId }); + await finalize(sessionId, { forceStatus: "cancelled", message: "AI resolution stopped by user." }); + }, + }; + + prAiRuntimeBridges.set(runtime, bridge); + return bridge; +} + +async function persistIssueResolutionRuntime( + runtime: AdeRuntime, + args: PrIssueResolutionStartArgs, + result: { sessionId: string; laneId: string; href: string }, +): Promise<void> { + try { + const status = runtime.issueInventoryService.getConvergenceStatus(args.prId); + runtime.issueInventoryService.saveConvergenceRuntime(args.prId, { + currentRound: status.currentRound, + status: "running", + pollerStatus: "idle", + activeSessionId: result.sessionId, + activeLaneId: result.laneId, + activeHref: result.href, + lastStartedAt: nowIso(), + errorMessage: null, + pauseReason: null, + }); + } catch (error) { + runtime.logger.warn("ade_actions.pr_issue_resolution_convergence_persist_failed", { + prId: args.prId, + sessionId: result.sessionId, + laneId: result.laneId, + href: result.href, + error: getErrorMessage(error), + }); + } +} + +function buildPrDomainService(runtime: AdeRuntime): OpaqueService | null { + const prService = runtime.prService; + if (!prService) return null; + const queueLandingService = runtime.queueLandingService ?? null; + const prSummaryService = runtime.prSummaryService ?? null; + + return { + ...(prService as unknown as OpaqueService), + aiResolutionGetSession(args?: unknown) { + return getPrAiRuntimeBridge(runtime).getSession(args); + }, + aiResolutionStart(args?: unknown) { + return getPrAiRuntimeBridge(runtime).start(args); + }, + aiResolutionInput(args?: unknown) { + return getPrAiRuntimeBridge(runtime).input(args); + }, + aiResolutionStop(args?: unknown) { + return getPrAiRuntimeBridge(runtime).stop(args); + }, + ...(queueLandingService + ? { + async startQueueAutomation(args?: unknown) { + return await queueLandingService.startQueue(asActionRecord(args) as Parameters<typeof queueLandingService.startQueue>[0]); + }, + pauseQueueAutomation(args?: unknown) { + return queueLandingService.pauseQueue(readStringActionArg(args, "queueId")); + }, + resumeQueueAutomation(args?: unknown) { + return queueLandingService.resumeQueue(asActionRecord(args) as Parameters<typeof queueLandingService.resumeQueue>[0]); + }, + cancelQueueAutomation(args?: unknown) { + return queueLandingService.cancelQueue(readStringActionArg(args, "queueId")); + }, + getQueueState(args?: unknown) { + return queueLandingService.getQueueStateByGroup(readStringActionArg(args, "groupId")); + }, + listQueueStates(args?: unknown) { + return queueLandingService.listQueueStates(asActionRecord(args) as Parameters<typeof queueLandingService.listQueueStates>[0]); + }, + } + : {}), + ...(prSummaryService + ? { + getAiSummary(prId: unknown) { + return prSummaryService.getSummary(readStringActionArg(prId, "prId")); + }, + regenerateAiSummary(prId: unknown) { + return prSummaryService.regenerateSummary(readStringActionArg(prId, "prId")); + }, + } + : {}), + async issueResolutionStart(args?: unknown) { + const startArgs = asActionRecord(args) as unknown as PrIssueResolutionStartArgs; + const result = await launchPrIssueResolutionChat(buildPrIssueResolutionDeps(runtime), startArgs); + await persistIssueResolutionRuntime(runtime, startArgs, result); + return result; + }, + issueResolutionPreviewPrompt(args?: unknown) { + return previewPrIssueResolutionPrompt( + buildPrIssueResolutionDeps(runtime), + asActionRecord(args) as unknown as PrIssueResolutionPromptPreviewArgs, + ); + }, + rebaseResolutionStart(args?: unknown) { + return launchRebaseResolutionChat( + { + laneService: runtime.laneService, + agentChatService: requireService(runtime.agentChatService, "Agent chat service not available."), + sessionService: runtime.sessionService, + conflictService: runtime.conflictService, + }, + asActionRecord(args) as unknown as RebaseResolutionStartArgs, + ); + }, + async launchIssueResolutionFromThread(args?: unknown) { + const threadArgs = asActionRecord(args) as unknown as LaunchPrIssueResolutionFromThreadArgs; + if (!threadArgs.modelId) { + throw new Error("modelId is required for launchIssueResolutionFromThread."); + } + const startArgs: PrIssueResolutionStartArgs = { + prId: threadArgs.prId, + scope: "comments", + modelId: threadArgs.modelId, + reasoning: threadArgs.reasoning ?? null, + permissionMode: threadArgs.permissionMode, + additionalInstructions: buildIssueResolutionInstructionsFromThread(threadArgs), + }; + const result = await launchPrIssueResolutionChat(buildPrIssueResolutionDeps(runtime), startArgs); + await persistIssueResolutionRuntime(runtime, startArgs, result); + return result; + }, + }; +} + +function buildGithubDomainService(runtime: AdeRuntime): OpaqueService | null { + const githubService = runtime.githubService; + if (!githubService) return null; + return { + ...(githubService as unknown as OpaqueService), + async listRepoLabels(args?: unknown) { + const actionArgs = asActionRecord(args); + return githubService.listRepoLabels( + requireNonEmptyString(actionArgs.owner, "owner"), + requireNonEmptyString(actionArgs.name, "name"), + ); + }, + async listRepoCollaborators(args?: unknown) { + const actionArgs = asActionRecord(args); + return githubService.listRepoCollaborators( + requireNonEmptyString(actionArgs.owner, "owner"), + requireNonEmptyString(actionArgs.name, "name"), + ); + }, + async publishCurrentProject(args?: unknown) { + const actionArgs = asActionRecord(args); + const isPrivate = actionArgs.isPrivate; + if (typeof isPrivate !== "boolean") { + throw new Error("Expected 'isPrivate' to be a boolean."); + } + const description = typeof actionArgs.description === "string" + ? actionArgs.description + : undefined; + return githubService.publishCurrentProject({ + name: requireNonEmptyString(actionArgs.name, "name"), + description, + isPrivate, + }); + }, + async setToken(args?: unknown) { + githubService.setToken(readStringActionArg(args, "token")); + return githubService.getStatus(); + }, + async clearToken() { + githubService.clearToken(); + return githubService.getStatus(); + }, + }; +} + +function buildLinearIssueTrackerDomainService(runtime: AdeRuntime): OpaqueService | null { + const tracker = runtime.linearIssueTracker; + if (!tracker) return null; + return { + ...(tracker as unknown as OpaqueService), + async getStatus() { + return buildRuntimeLinearConnectionStatus(runtime); + }, + async getConnectionStatus() { + return buildRuntimeLinearConnectionStatus(runtime); + }, + async listIssues(args?: unknown) { + const actionArgs = asActionRecord(args); + const issues = await tracker.fetchCandidateIssues({ + projectSlugs: asStringArray(actionArgs.projectSlugs ?? actionArgs.projectSlug ?? actionArgs.projects ?? actionArgs.project), + stateTypes: asStringArray(actionArgs.stateTypes ?? actionArgs.stateType ?? actionArgs.states ?? actionArgs.state), + }); + const limit = typeof actionArgs.limit === "number" && Number.isFinite(actionArgs.limit) + ? Math.max(1, Math.min(100, Math.floor(actionArgs.limit))) + : 20; + return issues.slice(0, limit); + }, + async getQuickView(connection?: LinearConnectionStatus): Promise<CtoLinearQuickView> { + const nextConnection = connection ?? await buildRuntimeLinearConnectionStatus(runtime); + if (!nextConnection.connected) return createEmptyLinearQuickView(nextConnection); + try { + return await tracker.getQuickView(nextConnection); + } catch (error) { + return createEmptyLinearQuickView({ + ...nextConnection, + connected: false, + viewerId: null, + viewerName: null, + checkedAt: nowIso(), + message: getErrorMessage(error) || "Linear tracker error", + }); + } + }, + async getWorkflowCatalog() { + const [users, labels, states] = await Promise.all([ + tracker.listUsers(), + tracker.listLabels(), + tracker.listWorkflowStates(), + ]); + return { users, labels, states }; + }, + async getIssuePickerData() { + const [projects, users, states] = await Promise.all([ + tracker.listProjects().catch(() => []), + tracker.listUsers().catch(() => []), + tracker.listWorkflowStates().catch(() => []), + ]); + return { projects, users, states }; + }, + }; +} + +function asStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => entry.trim()); + } + if (typeof value === "string" && value.trim().length) { + return value.split(",").map((entry) => entry.trim()).filter(Boolean); + } + return []; +} + +async function buildRuntimeLinearConnectionStatus(runtime: AdeRuntime): Promise<LinearConnectionStatus> { + const credentialStatus = runtime.linearCredentialService?.getStatus() ?? { + tokenStored: false, + authMode: null, + oauthConfigured: false, + tokenExpiresAt: null, + }; + const tokenStored = Boolean(credentialStatus.tokenStored); + if (!runtime.linearIssueTracker || !tokenStored) { + return { + tokenStored, + connected: false, + viewerId: null, + viewerName: null, + checkedAt: nowIso(), + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: tokenStored ? "Linear tracker service unavailable." : "Linear token not configured.", + }; + } + try { + const status = await runtime.linearIssueTracker.getConnectionStatus(); + return { + tokenStored, + connected: status.connected, + viewerId: status.viewerId, + viewerName: status.viewerName, + organizationId: status.organizationId ?? null, + organizationName: status.organizationName ?? null, + organizationUrlKey: status.organizationUrlKey ?? null, + organizationLogoUrl: status.organizationLogoUrl ?? null, + checkedAt: nowIso(), + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: formatLinearConnectionMessage(status.message, credentialStatus.authMode), + }; + } catch (error) { + return { + tokenStored, + connected: false, + viewerId: null, + viewerName: null, + checkedAt: nowIso(), + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: formatLinearConnectionMessage( + getErrorMessage(error) || "Linear connection check failed.", + credentialStatus.authMode, + ), + }; + } +} + +function formatLinearConnectionMessage( + message: string | null | undefined, + authMode: "manual" | "oauth" | null | undefined, +): string | null { + const trimmed = message?.trim(); + if ( + authMode === "manual" + && trimmed + && /authentication required|not authenticated/i.test(trimmed) + ) { + return "Linear rejected the API key. Paste a Linear personal API key from linear.app/settings/api; it should start with lin_api_."; + } + return trimmed || null; +} + +function buildLinearOAuthDomainService(runtime: AdeRuntime): OpaqueService | null { + const service = runtime.linearOAuthService; + if (!service) return null; + return { + async startSession() { + return service.startSession(); + }, + async getSession(args?: unknown) { + const session = service.getSession(readStringActionArg(args, "sessionId")); + if (session.status !== "completed") { + return session; + } + return { + ...session, + connection: await buildRuntimeLinearConnectionStatus(runtime), + }; + }, + }; +} + +function createEmptyLinearQuickView(connection: LinearConnectionStatus): CtoLinearQuickView { + return { + connection, + organization: null, + viewer: null, + projects: [], + teams: [], + assignedIssues: [], + recentIssues: [], + fetchedAt: nowIso(), + sdk: { + packageName: "@linear/sdk", + surfaces: [], + }, + }; +} + +function normalizeSimulatedLinearIssue(runtime: AdeRuntime, args?: CtoSimulateFlowRouteArgs): NormalizedLinearIssue { + const issueInput = args?.issue; + if (!issueInput?.title?.trim()) { + throw new Error("issue.title is required."); + } + const policy = runtime.flowPolicyService?.getPolicy(); + const defaultProjectSlug = + policy?.workflows.flatMap((workflow) => workflow.triggers.projectSlugs ?? []).find(Boolean) + ?? policy?.legacyConfig?.projects?.[0]?.slug + ?? "sim-project"; + const now = nowIso(); + return { + id: issueInput.id ?? `sim-${randomUUID()}`, + identifier: issueInput.identifier ?? "SIM-1", + title: issueInput.title, + description: issueInput.description ?? "", + url: issueInput.url ?? null, + projectId: issueInput.projectId ?? "sim-project", + projectSlug: issueInput.projectSlug ?? defaultProjectSlug, + teamId: issueInput.teamId ?? "sim-team", + teamKey: issueInput.teamKey ?? "SIM", + stateId: issueInput.stateId ?? "sim-state", + stateName: issueInput.stateName ?? "Todo", + stateType: issueInput.stateType ?? "unstarted", + priority: Number.isFinite(Number(issueInput.priority)) ? Number(issueInput.priority) : 3, + priorityLabel: issueInput.priorityLabel ?? "normal", + labels: Array.isArray(issueInput.labels) ? issueInput.labels : [], + metadataTags: Array.isArray(issueInput.metadataTags) ? issueInput.metadataTags : [], + assigneeId: issueInput.assigneeId ?? null, + assigneeName: issueInput.assigneeName ?? null, + ownerId: issueInput.ownerId ?? null, + creatorId: issueInput.creatorId ?? null, + creatorName: issueInput.creatorName ?? null, + blockerIssueIds: Array.isArray(issueInput.blockerIssueIds) ? issueInput.blockerIssueIds : [], + hasOpenBlockers: Boolean(issueInput.hasOpenBlockers), + createdAt: issueInput.createdAt ?? now, + updatedAt: issueInput.updatedAt ?? now, + raw: isRecord(issueInput.raw) ? issueInput.raw : {}, + }; +} + +function buildLinearRoutingDomainService(runtime: AdeRuntime): OpaqueService | null { + const routingService = runtime.linearRoutingService; + if (!routingService) return null; + return { + ...(routingService as unknown as OpaqueService), + simulateRoute: (args?: CtoSimulateFlowRouteArgs): Promise<LinearRouteDecision> => + routingService.simulateRoute({ issue: normalizeSimulatedLinearIssue(runtime, args) }), + }; +} + +function buildFileDomainService(runtime: AdeRuntime): OpaqueService | null { + const fileService = runtime.fileService; + if (!fileService) return null; + return { + ...(fileService as unknown as OpaqueService), + async watchWorkspace(args?: unknown): Promise<{ ok: true }> { + const actionArgs = asActionRecord(args); + const senderId = readRuntimeFileWatchSenderId(actionArgs); + await fileService.watchWorkspace( + toRuntimeFileWatchArgs(actionArgs), + (event: FileChangeEvent) => { + runtime.eventBuffer.push({ + timestamp: new Date().toISOString(), + category: "runtime", + payload: { type: "file_change", event }, + }); + }, + senderId, + ); + return { ok: true }; + }, + stopWatching(args?: unknown): { ok: true } { + const actionArgs = asActionRecord(args); + const senderId = readRuntimeFileWatchSenderId(actionArgs); + fileService.stopWatching( + toRuntimeFileWatchArgs(actionArgs), + senderId, + ); + return { ok: true }; + }, + }; +} + function buildTerminalDomainService(runtime: AdeRuntime): TerminalDomainService | null { if (!runtime.ptyService) return null; return { @@ -604,52 +3496,57 @@ export function getAdeActionDomainServices( runtime: AdeRuntime, ): Partial<Record<AdeActionDomain, OpaqueService | null | undefined>> { return { - lane: toService(runtime.laneService), + lane: toService(buildLaneDomainService(runtime)), git: toService(runtime.gitService), diff: toService(runtime.diffService), conflicts: toService(runtime.conflictService), - pr: toService(runtime.prService), + pr: toService(buildPrDomainService(runtime)), tests: toService(runtime.testService), - chat: toService(runtime.agentChatService), + chat: toService(buildChatDomainService(runtime)), keybindings: toService(runtime.keybindingsService), + ai: toService(buildAiDomainService(runtime)), onboarding: toService(runtime.onboardingService), automation_planner: toService(runtime.automationPlannerService), - mission: toService(runtime.missionService), - orchestrator: toService(runtime.aiOrchestratorService), - orchestrator_core: toService(runtime.orchestratorService), - memory: toService(runtime.memoryService), - cto_state: toService(runtime.ctoStateService), - worker_agent: toService(runtime.workerAgentService), - session: toService(runtime.sessionService), + mission: toService(buildMissionDomainService(runtime)), + orchestrator: toService(buildAiOrchestratorDomainService(runtime)), + orchestrator_core: toService(buildOrchestratorCoreDomainService(runtime)), + mission_budget: toService(runtime.missionBudgetService), + memory: toService(buildMemoryDomainService(runtime)), + cto_state: toService(buildCtoStateDomainService(runtime)), + worker_agent: toService(buildWorkerAgentDomainService(runtime)), + session: toService(buildSessionDomainService(runtime)), operation: toService(runtime.operationService), + ade_project: toService(runtime.adeProjectService), project_config: toService(runtime.projectConfigService), issue_inventory: toService(runtime.issueInventoryService), path_to_merge: toService(runtime.pathToMergeOrchestrator), flow_policy: toService(runtime.flowPolicyService), linear_credentials: toService(runtime.linearCredentialService), + linear_oauth: buildLinearOAuthDomainService(runtime), linear_dispatcher: toService(runtime.linearDispatcherService), - linear_issue_tracker: toService(runtime.linearIssueTracker), + linear_issue_tracker: toService(buildLinearIssueTrackerDomainService(runtime)), linear_sync: toService(runtime.linearSyncService), linear_ingress: toService(runtime.linearIngressService), - linear_routing: toService(runtime.linearRoutingService), - github: toService(runtime.githubService), + linear_routing: toService(buildLinearRoutingDomainService(runtime)), + github: buildGithubDomainService(runtime), feedback: toService(runtime.feedbackReporterService), usage: toService(runtime.usageTrackingService), budget: toService(runtime.budgetCapService), update: toService(runtime.autoUpdateService), - file: toService(runtime.fileService), + file: toService(buildFileDomainService(runtime)), process: toService(runtime.processService), pty: toService(runtime.ptyService), terminal: toService(buildTerminalDomainService(runtime)), layout: toService(buildLayoutDomainService(runtime)), tiling_tree: toService(buildTilingTreeDomainService(runtime)), graph_state: toService(buildGraphStateDomainService(runtime)), - computer_use_artifacts: toService(runtime.computerUseArtifactBrokerService), + computer_use_artifacts: toService(buildComputerUseArtifactsDomainService(runtime)), ios_simulator: toService(runtime.iosSimulatorService), app_control: toService(runtime.appControlService), built_in_browser: toService(runtime.builtInBrowserService), macos_vm: toService(runtime.macosVmService), automations: toService(buildAutomationsDomainService(runtime)), + review: toService(runtime.reviewService), issue: toService(buildIssueDomainService(runtime)), }; } diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 09844a925..1b75e65bb 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -51,7 +51,13 @@ import { initialize as initModelsDevService } from "./modelsDevService"; import { updateModelPricing } from "../../../shared/modelProfiles"; import { isRecord } from "../shared/utils"; import { parseStructuredOutput } from "./utils"; -import { getAllApiKeys, getApiKeyStoreStatus } from "./apiKeyStore"; +import { + deleteApiKey as deleteStoredApiKey, + getAllApiKeys, + getApiKeyStoreStatus, + listStoredProviders, + storeApiKey as storeStoredApiKey, +} from "./apiKeyStore"; import type { createMemoryService } from "../memory/memoryService"; import { inspectLocalProvider } from "./localModelDiscovery"; import { @@ -1798,6 +1804,17 @@ export function createAiIntegrationService(args: { getAvailability: getAvailabilitySync, verifyApiKeyConnection, + storeApiKey(provider: string, key: string): void { + storeStoredApiKey(provider, key); + invalidateProviderReadinessCaches(); + }, + deleteApiKey(provider: string): void { + deleteStoredApiKey(provider); + invalidateProviderReadinessCaches(); + }, + listApiKeys(): string[] { + return listStoredProviders(); + }, listCursorCloudRepositories, listCursorCloudAgents, listCursorCloudRuns, diff --git a/apps/desktop/src/main/services/ai/aiSettingsStatus.ts b/apps/desktop/src/main/services/ai/aiSettingsStatus.ts new file mode 100644 index 000000000..62ec2b46d --- /dev/null +++ b/apps/desktop/src/main/services/ai/aiSettingsStatus.ts @@ -0,0 +1,138 @@ +import type { + AiFeatureKey, + AiSettingsStatus, +} from "../../../shared/types"; + +export const AI_USAGE_FEATURE_KEYS: AiFeatureKey[] = [ + "narratives", + "conflict_proposals", + "commit_messages", + "pr_descriptions", + "terminal_summaries", + "memory_consolidation", + "mission_planning", + "orchestrator", + "initial_context", +]; + +type AiSettingsStatusSource = { + getStatus(args?: { force?: boolean; refreshOpenCodeInventory?: boolean }): Promise<Omit<AiSettingsStatus, "features"> & Partial<Pick<AiSettingsStatus, "features">>>; + getDailyUsageBatch(features: AiFeatureKey[]): Map<AiFeatureKey, number>; + getFeatureFlag(feature: AiFeatureKey): boolean; + getDailyBudgetLimit(feature: AiFeatureKey): number | null; +}; + +export function isDatabaseClosedError(error: unknown): boolean { + return error instanceof Error && /database closed/i.test(error.message); +} + +export function getUnavailableAiStatus(): AiSettingsStatus { + return { + mode: "guest", + availableProviders: { + claude: false, + codex: false, + cursor: false, + droid: false, + }, + models: { + claude: [], + codex: [], + cursor: [], + droid: [], + }, + detectedAuth: [], + providerConnections: { + claude: { + provider: "claude", + authAvailable: false, + runtimeDetected: false, + runtimeAvailable: false, + usageAvailable: false, + path: null, + blocker: "AI integration service unavailable.", + lastCheckedAt: new Date(0).toISOString(), + sources: [], + }, + codex: { + provider: "codex", + authAvailable: false, + runtimeDetected: false, + runtimeAvailable: false, + usageAvailable: false, + path: null, + blocker: "AI integration service unavailable.", + lastCheckedAt: new Date(0).toISOString(), + sources: [], + }, + cursor: { + provider: "cursor", + authAvailable: false, + runtimeDetected: false, + runtimeAvailable: false, + usageAvailable: false, + path: null, + blocker: "AI integration service unavailable.", + lastCheckedAt: new Date(0).toISOString(), + sources: [], + }, + droid: { + provider: "droid", + authAvailable: false, + runtimeDetected: false, + runtimeAvailable: false, + usageAvailable: false, + path: null, + blocker: "AI integration service unavailable.", + lastCheckedAt: new Date(0).toISOString(), + sources: [], + }, + }, + features: AI_USAGE_FEATURE_KEYS.map((feature) => ({ + feature, + enabled: false, + dailyUsage: 0, + dailyLimit: null, + })), + runtimeConnections: {}, + availableModelIds: [], + opencodeBinaryInstalled: false, + opencodeBinarySource: "missing", + opencodeInventoryError: null, + opencodeProviders: [], + }; +} + +export async function buildAiSettingsStatus( + service: AiSettingsStatusSource | null | undefined, + args?: { force?: boolean; refreshOpenCodeInventory?: boolean }, +): Promise<AiSettingsStatus> { + if (!service) { + return getUnavailableAiStatus(); + } + const status = await service.getStatus({ + force: args?.force === true, + refreshOpenCodeInventory: args?.refreshOpenCodeInventory === true, + }); + const usageBatch = service.getDailyUsageBatch(AI_USAGE_FEATURE_KEYS); + return { + mode: status.mode, + availableProviders: status.availableProviders, + models: status.models, + detectedAuth: status.detectedAuth, + providerConnections: status.providerConnections, + runtimeConnections: status.runtimeConnections, + availableModelIds: status.availableModelIds, + opencodeBinaryInstalled: status.opencodeBinaryInstalled, + opencodeBinarySource: status.opencodeBinarySource, + opencodeInventoryError: status.opencodeInventoryError, + opencodeProviders: status.opencodeProviders, + apiKeyStore: status.apiKeyStore, + features: AI_USAGE_FEATURE_KEYS.map((feature) => ({ + feature, + enabled: service.getFeatureFlag(feature), + dailyUsage: usageBatch.get(feature) ?? 0, + dailyLimit: service.getDailyBudgetLimit(feature), + })), + }; +} diff --git a/apps/desktop/src/main/services/ai/apiKeyStore.test.ts b/apps/desktop/src/main/services/ai/apiKeyStore.test.ts index 843384733..324e1faee 100644 --- a/apps/desktop/src/main/services/ai/apiKeyStore.test.ts +++ b/apps/desktop/src/main/services/ai/apiKeyStore.test.ts @@ -106,6 +106,34 @@ async function loadStoreModule() { return mod; } +class MemoryCredentialStore { + readonly values = new Map<string, string>(); + + async get(key: string): Promise<string | null> { + return this.getSync(key); + } + + async set(key: string, value: string): Promise<void> { + this.setSync(key, value); + } + + async delete(key: string): Promise<void> { + this.deleteSync(key); + } + + getSync(key: string): string | null { + return this.values.get(key) ?? null; + } + + setSync(key: string, value: string): void { + this.values.set(key, value); + } + + deleteSync(key: string): void { + this.values.delete(key); + } +} + describe("apiKeyStore", () => { let tempRoot: string; let keychain: Map<string, string>; @@ -276,4 +304,70 @@ describe("apiKeyStore", () => { ]); expect(securityAccountsFor("add-generic-password")).toContain("__ade_provider_index__"); }); + + it("stores, lists, returns, and deletes API keys through a provided credential store", async () => { + delete process.env.OPENAI_API_KEY; + const credentialStore = new MemoryCredentialStore(); + const store = await loadStoreModule(); + store.initApiKeyStore(tempRoot, { credentialStore }); + + store.storeApiKey(" OpenAI ", " sk-test-key "); + store.storeApiKey("CURSOR", " crsr_test_key "); + + expect(store.getApiKey("openai")).toBe("sk-test-key"); + expect(store.getAllApiKeys()).toEqual({ + cursor: "crsr_test_key", + openai: "sk-test-key", + }); + expect(store.listStoredProviders().sort()).toEqual(["cursor", "openai"]); + expect(credentialStore.values.get("ai.api_key.openai.v1")).toBe("sk-test-key"); + expect(credentialStore.values.get("ai.api_key.cursor.v1")).toBe("crsr_test_key"); + expect(JSON.parse(credentialStore.values.get("ai.api_key.index.v1") ?? "[]")).toEqual(["cursor", "openai"]); + expect(keychain.size).toBe(0); + + store.deleteApiKey("OPENAI"); + + expect(store.getApiKey("openai")).toBeNull(); + expect(store.getAllApiKeys()).toEqual({ cursor: "crsr_test_key" }); + expect(store.listStoredProviders()).toEqual(["cursor"]); + expect(credentialStore.values.has("ai.api_key.openai.v1")).toBe(false); + expect(JSON.parse(credentialStore.values.get("ai.api_key.index.v1") ?? "[]")).toEqual(["cursor"]); + }); + + it("reads an unindexed credential-store provider on demand and updates the index", async () => { + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("ai.api_key.openai.v1", "sk-unindexed-key"); + const store = await loadStoreModule(); + store.initApiKeyStore(tempRoot, { credentialStore }); + + expect(store.listStoredProviders()).toEqual([]); + expect(store.getApiKey("OPENAI")).toBe("sk-unindexed-key"); + + expect(store.listStoredProviders()).toEqual(["openai"]); + expect(JSON.parse(credentialStore.values.get("ai.api_key.index.v1") ?? "[]")).toEqual(["openai"]); + }); + + it("can use the ADE CLI encrypted credential store without persisting the raw key", async () => { + process.env.ADE_API_KEY_STORE_DISABLE_KEYCHAIN = "1"; + const credentialsPath = path.join(tempRoot, "credentials.json.enc"); + const machineKeyPath = path.join(tempRoot, ".machine-key"); + const { EncryptedFileCredentialStore } = await import("../../../../../ade-cli/src/services/credentials/credentialStore"); + const credentialStore = new EncryptedFileCredentialStore({ credentialsPath, machineKeyPath }); + const store = await loadStoreModule(); + store.initApiKeyStore(tempRoot, { credentialStore }); + + store.storeApiKey("OpenAI", "sk-raw-secret-value"); + + expect(store.getApiKey("openai")).toBe("sk-raw-secret-value"); + expect(store.listStoredProviders()).toEqual(["openai"]); + const persisted = fs.readFileSync(credentialsPath, "utf8"); + expect(persisted).toContain("ciphertext"); + expect(persisted).not.toContain("sk-raw-secret-value"); + expect(fs.existsSync(path.join(tempRoot, ".ade", "secrets", "api-keys.v1.bin"))).toBe(false); + expect(store.getApiKeyStoreStatus()).toMatchObject({ + secureStorageAvailable: true, + encryptedStorePath: null, + decryptionFailed: false, + }); + }); }); diff --git a/apps/desktop/src/main/services/ai/apiKeyStore.ts b/apps/desktop/src/main/services/ai/apiKeyStore.ts index 801eaf6bf..f5c79afa3 100644 --- a/apps/desktop/src/main/services/ai/apiKeyStore.ts +++ b/apps/desktop/src/main/services/ai/apiKeyStore.ts @@ -21,6 +21,19 @@ try { type StoredKeys = Record<string, string>; +export type ApiKeyCredentialStore = { + get?: (key: string) => Promise<string | null> | string | null; + set?: (key: string, value: string) => Promise<void> | void; + delete?: (key: string) => Promise<void> | void; + getSync?: (key: string) => string | null; + setSync?: (key: string, value: string) => void; + deleteSync?: (key: string) => void; +}; + +export type InitApiKeyStoreOptions = { + credentialStore?: ApiKeyCredentialStore | null; +}; + export type ApiKeyStoreStatus = { secureStorageAvailable: boolean; macosKeychainAvailable: boolean; @@ -54,13 +67,16 @@ const MACOS_KEYCHAIN_MISSING_PATTERNS = [ /the specified item could not be found/i, ]; const SECURITY_TIMEOUT_MS = 5_000; +const CREDENTIAL_PROVIDER_INDEX_KEY = "ai.api_key.index.v1"; let storePath: string | null = null; let legacyStorePath: string | null = null; +let credentialStore: ApiKeyCredentialStore | null = null; let cache: StoredKeys | null = null; let decryptionFailed = false; let macosKeychainError: string | null = null; let missingMacosKeychainProviders = new Set<string>(); +let missingCredentialProviders = new Set<string>(); export function __setSafeStorageForTests(next: SafeStorage | null): void { safeStorage = next; @@ -79,15 +95,20 @@ function isMacosKeychainAvailable(): boolean { } function isPersistentSecureStorageAvailable(): boolean { + if (credentialStore) return true; return isMacosKeychainAvailable() || isSecureStorageAvailable(); } +function normalizeProvider(provider: string): string { + return provider.trim().toLowerCase(); +} + function normalizeStoredKeys(value: unknown): StoredKeys { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const out: StoredKeys = {}; for (const [provider, rawValue] of Object.entries(value as Record<string, unknown>)) { if (typeof rawValue !== "string") continue; - const normalizedProvider = provider.trim().toLowerCase(); + const normalizedProvider = normalizeProvider(provider); const normalizedKey = rawValue.trim(); if (!normalizedProvider.length || !normalizedKey.length) continue; out[normalizedProvider] = normalizedKey; @@ -213,6 +234,77 @@ function normalizeProviderList(value: unknown): string[] { return Array.from(providers).sort(); } +function credentialProviderKey(provider: string): string { + return `ai.api_key.${provider}.v1`; +} + +function getSyncCredentialStore(): Required<Pick<ApiKeyCredentialStore, "getSync" | "setSync" | "deleteSync">> | null { + if (!credentialStore) return null; + if ( + typeof credentialStore.getSync === "function" + && typeof credentialStore.setSync === "function" + && typeof credentialStore.deleteSync === "function" + ) { + return credentialStore as Required<Pick<ApiKeyCredentialStore, "getSync" | "setSync" | "deleteSync">>; + } + throw new Error("API key credentialStore must provide getSync, setSync, and deleteSync."); +} + +function readCredentialSecret(key: string): string | null { + const store = getSyncCredentialStore(); + if (!store) return null; + try { + const value = store.getSync(key); + decryptionFailed = false; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; + } catch { + decryptionFailed = true; + return null; + } +} + +function writeCredentialSecret(key: string, value: string): void { + const store = getSyncCredentialStore(); + if (!store) return; + store.setSync(key, value); + decryptionFailed = false; +} + +function deleteCredentialSecret(key: string): void { + const store = getSyncCredentialStore(); + if (!store) return; + store.deleteSync(key); + decryptionFailed = false; +} + +function readCredentialProviderIndex(): { exists: boolean; providers: string[] } { + const raw = readCredentialSecret(CREDENTIAL_PROVIDER_INDEX_KEY); + if (!raw) return { exists: false, providers: [] }; + try { + return { exists: true, providers: normalizeProviderList(JSON.parse(raw)) }; + } catch { + decryptionFailed = true; + return { exists: true, providers: [] }; + } +} + +function writeCredentialProviderIndex(providers: Iterable<string>): void { + writeCredentialSecret(CREDENTIAL_PROVIDER_INDEX_KEY, JSON.stringify(normalizeProviderList(Array.from(providers)))); +} + +function readCredentialStore(providerCandidates: Iterable<string>): StoredKeys { + const out: StoredKeys = {}; + for (const provider of providerCandidates) { + const normalizedProvider = normalizeProvider(provider); + if (!normalizedProvider.length) continue; + const value = readCredentialSecret(credentialProviderKey(normalizedProvider)); + if (value) out[normalizedProvider] = value; + } + return out; +} + function readMacosKeychainProviderIndex(): { exists: boolean; providers: string[] } { const raw = readMacosKeychainSecret(MACOS_KEYCHAIN_PROVIDER_INDEX_ACCOUNT); if (!raw) return { exists: false, providers: [] }; @@ -308,6 +400,12 @@ function ensureStore(): StoredKeys { if (cache) return cache; ensureInitialized(); + if (credentialStore) { + const index = readCredentialProviderIndex(); + cache = index.exists ? readCredentialStore(index.providers) : {}; + return cache; + } + const encryptedStore = loadEncryptedStore(); if (isMacosKeychainAvailable()) { const indexBeforeMigration = readMacosKeychainProviderIndex(); @@ -352,14 +450,16 @@ function persistEncryptedStore(nextStore: StoredKeys = cache ?? {}): void { } } -export function initApiKeyStore(projectRoot: string): void { +export function initApiKeyStore(projectRoot: string, options: InitApiKeyStoreOptions = {}): void { const layout = resolveAdeLayout(projectRoot); storePath = layout.apiKeysPath; legacyStorePath = layout.legacyApiKeysPath; + credentialStore = options.credentialStore ?? null; cache = null; decryptionFailed = false; macosKeychainError = null; missingMacosKeychainProviders = new Set<string>(); + missingCredentialProviders = new Set<string>(); } export function getApiKeyStoreStatus(): ApiKeyStoreStatus { @@ -380,7 +480,7 @@ export function getApiKeyStoreStatus(): ApiKeyStoreStatus { macosKeychainAvailable: isMacosKeychainAvailable(), macosKeychainService: isMacosKeychainAvailable() ? MACOS_KEYCHAIN_SERVICE : null, macosKeychainError, - encryptedStorePath: storePath, + encryptedStorePath: credentialStore ? null : storePath, legacyPlaintextDetected: Boolean(legacyStorePath && fs.existsSync(legacyStorePath)), legacyPlaintextPath: legacyStorePath && fs.existsSync(legacyStorePath) ? legacyStorePath : null, decryptionFailed, @@ -388,12 +488,20 @@ export function getApiKeyStoreStatus(): ApiKeyStoreStatus { } export function storeApiKey(provider: string, key: string): void { - const normalizedProvider = provider.trim().toLowerCase(); + const normalizedProvider = normalizeProvider(provider); const normalizedKey = key.trim(); if (!normalizedProvider.length || !normalizedKey.length) { throw new Error("Provider and key are required."); } const store = ensureStore(); + if (credentialStore) { + writeCredentialSecret(credentialProviderKey(normalizedProvider), normalizedKey); + store[normalizedProvider] = normalizedKey; + missingCredentialProviders.delete(normalizedProvider); + const index = readCredentialProviderIndex(); + writeCredentialProviderIndex(new Set([...index.providers, normalizedProvider])); + return; + } if (isMacosKeychainAvailable()) { writeMacosKeychainSecret(normalizedProvider, normalizedKey); store[normalizedProvider] = normalizedKey; @@ -408,11 +516,21 @@ export function storeApiKey(provider: string, key: string): void { } export function getApiKey(provider: string): string | null { - const normalizedProvider = provider.trim().toLowerCase(); + const normalizedProvider = normalizeProvider(provider); if (!normalizedProvider.length) return null; const store = ensureStore(); const stored = store[normalizedProvider]; if (stored) return stored; + if (credentialStore && !missingCredentialProviders.has(normalizedProvider)) { + const credentialValue = readCredentialSecret(credentialProviderKey(normalizedProvider)); + if (credentialValue) { + store[normalizedProvider] = credentialValue; + const index = readCredentialProviderIndex(); + writeCredentialProviderIndex(new Set([...index.providers, normalizedProvider])); + return credentialValue; + } + missingCredentialProviders.add(normalizedProvider); + } if (isMacosKeychainAvailable() && !missingMacosKeychainProviders.has(normalizedProvider)) { const keychainValue = readMacosKeychainSecret(normalizedProvider); if (keychainValue) { @@ -432,9 +550,17 @@ export function getApiKey(provider: string): string | null { } export function deleteApiKey(provider: string): void { - const normalizedProvider = provider.trim().toLowerCase(); + const normalizedProvider = normalizeProvider(provider); if (!normalizedProvider.length) return; const store = ensureStore(); + if (credentialStore) { + deleteCredentialSecret(credentialProviderKey(normalizedProvider)); + delete store[normalizedProvider]; + missingCredentialProviders.add(normalizedProvider); + const index = readCredentialProviderIndex(); + writeCredentialProviderIndex(index.providers.filter((entry) => entry !== normalizedProvider)); + return; + } if (isMacosKeychainAvailable()) { deleteMacosKeychainSecret(normalizedProvider); delete store[normalizedProvider]; diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts index 13870c0d6..a25ae203c 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts @@ -11,7 +11,7 @@ import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable"; const PROBE_TIMEOUT_MS = 20_000; const PROBE_CACHE_TTL_MS = 30_000; export const CLAUDE_RUNTIME_AUTH_ERROR = - "Claude Code is detected, but ADE chat could not authenticate it. Run /login in chat or sign in with `claude auth login`, then refresh AI settings."; + "Claude Code is detected, but ADE chat could not authenticate it. Run `claude auth login` in a terminal or configure ANTHROPIC_API_KEY, then refresh AI settings."; const DEFAULT_RUNTIME_FAILURE = "Claude Code is installed, but ADE could not confirm that the Claude chat runtime can start from this app session."; diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts index 632150950..d26a2eeda 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts @@ -277,6 +277,7 @@ function readShellPath( { encoding: "utf-8", env, + stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, }, ); diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index 32664464b..4a1202554 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -154,7 +154,7 @@ export async function buildProviderConnections( cli: claudeCli, localCreds: claudeLocalCreds, label: "Claude", - loginHint: "claude auth login", + loginHint: "claude auth login or set ANTHROPIC_API_KEY", health: claudeRuntimeHealth, }); diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 08d42df6e..39658e723 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -1254,7 +1254,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record<string title: z.string().optional(), reportsTo: z.string().nullable().optional(), capabilities: z.array(z.string()).optional(), - adapterType: z.enum(["claude-local", "codex-local", "openclaw-webhook", "process"]).default("claude-local"), + adapterType: z.enum(["claude-local", "codex-local", "process"]).default("claude-local"), modelId: z.string().optional(), budgetMonthlyCents: z.number().int().nonnegative().optional(), }), diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index acf836871..42d91cbdf 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1245,6 +1245,32 @@ describe("createAgentChatService", () => { expect(opts?.systemPrompt?.append).toContain("clean up old, stale, or finished processes"); }); + it("keeps Claude SDK project and user setting sources enabled for filesystem skills", async () => { + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send: vi.fn(), + stream: vi.fn(async function* () { + return; + }), + close: vi.fn(), + sessionId: "sdk-session-skills", + } as any); + + const { service } = createService(); + await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await vi.waitFor(() => { + expect(unstable_v2_createSession).toHaveBeenCalled(); + }); + + const opts = vi.mocked(unstable_v2_createSession).mock.calls[0]?.[0] as { settingSources?: string[]; skills?: string[] } | undefined; + expect(opts?.settingSources).toEqual(expect.arrayContaining(["user", "project"])); + expect(opts?.skills).toBeUndefined(); + }); + it("appends discovered project slash commands to the Claude system prompt", async () => { const commandsDir = path.join(tmpRoot, ".claude", "commands"); fs.mkdirSync(commandsDir, { recursive: true }); @@ -1287,7 +1313,7 @@ describe("createAgentChatService", () => { const opts = vi.mocked(unstable_v2_createSession).mock.calls[0]?.[0] as { systemPrompt?: { append?: string } } | undefined; expect(opts?.systemPrompt?.append).toContain("## Project slash commands"); - expect(opts?.systemPrompt?.append).toContain("auto-expands the command body"); + expect(opts?.systemPrompt?.append).toContain("pre-expands the file's body"); expect(opts?.systemPrompt?.append).toContain("/audit — Audit recent work for bugs and gaps"); expect(opts?.systemPrompt?.append).toContain("/ship-lane — Drive a lane through CI + review"); }); @@ -2449,7 +2475,7 @@ describe("createAgentChatService", () => { const persisted = readPersistedChatState(session.id); writePersistedChatState(session.id, { ...persisted, - continuitySummary: "- Keep the OpenClaw bridge runtime state in machine-local cache.", + continuitySummary: "- Keep runtime cache state machine-local.", continuitySummaryUpdatedAt: new Date().toISOString(), recentConversationEntries: [ { role: "user", text: "What lane should frontend use?" }, @@ -2471,7 +2497,7 @@ describe("createAgentChatService", () => { expect(result.sessionId).toBe(session.id); expect(send).toHaveBeenCalledTimes(1); expect(send).toHaveBeenCalledWith(expect.stringContaining("Continuity Summary")); - expect(send).toHaveBeenCalledWith(expect.stringContaining("Keep the OpenClaw bridge runtime state in machine-local cache.")); + expect(send).toHaveBeenCalledWith(expect.stringContaining("Keep runtime cache state machine-local.")); expect(send).toHaveBeenCalledWith(expect.stringContaining("User: What lane should frontend use?")); expect(send).toHaveBeenCalledWith(expect.stringContaining("Assistant: Use the primary-hosted coordinator first.")); }); @@ -2727,7 +2753,7 @@ describe("createAgentChatService", () => { const result = await service.runSessionTurn({ sessionId: session.id, - text: "Please keep the OpenClaw bridge state private.", + text: "Please keep the runtime bridge state private.", timeoutMs: 15_000, }); await new Promise((resolve) => setTimeout(resolve, 25)); @@ -2736,7 +2762,7 @@ describe("createAgentChatService", () => { expect(result.outputText).toContain("Partial answer"); expect(persisted.sdkSessionId).toBe("sdk-session-2"); expect(persisted.continuitySummary).toContain("Recent continuity snapshot:"); - expect(persisted.continuitySummary).toContain("User: Please keep the OpenClaw bridge state private."); + expect(persisted.continuitySummary).toContain("User: Please keep the runtime bridge state private."); expect(persisted.continuitySummary).toContain("Assistant: Partial answer"); expect(unstable_v2_createSession).toHaveBeenCalledTimes(2); expect(recoverySend).toHaveBeenCalledWith("System initialization check. Respond with only the word READY."); @@ -3101,7 +3127,7 @@ describe("createAgentChatService", () => { expect(clearCmd!.source).toBe("local"); }); - it("includes /login command for claude sessions", async () => { + it("does not advertise /login as a Claude SDK command", async () => { const { service } = createService(); const session = await service.createSession({ laneId: "lane-1", @@ -3111,8 +3137,7 @@ describe("createAgentChatService", () => { const commands = service.getSlashCommands({ sessionId: session.id }); const loginCmd = commands.find((c: any) => c.name === "/login"); - expect(loginCmd).toBeDefined(); - expect(loginCmd!.source).toBe("sdk"); + expect(loginCmd).toBeUndefined(); }); it("includes project Claude Code command files before SDK init completes", async () => { @@ -3146,7 +3171,7 @@ describe("createAgentChatService", () => { ])); }); - it("keeps reserved local Claude commands ahead of filesystem commands", async () => { + it("does not let a filesystem /login command replace provider auth guidance", async () => { const commandsDir = path.join(tmpRoot, ".claude", "commands"); fs.mkdirSync(commandsDir, { recursive: true }); fs.writeFileSync(path.join(commandsDir, "login.md"), [ @@ -3167,10 +3192,7 @@ describe("createAgentChatService", () => { const commands = service.getSlashCommands({ sessionId: session.id }); const loginCmd = commands.find((c: any) => c.name === "/login"); - expect(loginCmd).toMatchObject({ - description: "Sign in to Claude Code for this chat runtime", - source: "sdk", - }); + expect(loginCmd).toBeUndefined(); }); it("does not include /login for opencode sessions", async () => { @@ -3208,6 +3230,39 @@ describe("createAgentChatService", () => { }), ])); }); + + it("includes project Claude command files for Codex-backed sessions", async () => { + const commandsDir = path.join(tmpRoot, ".claude", "commands"); + const promptsDir = path.join(tmpRoot, ".codex", "prompts"); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, "shipLane.md"), [ + "---", + "description: Ship the active lane", + "---", + "", + "Ship lane.", + "", + ].join("\n")); + fs.writeFileSync(path.join(promptsDir, "shipLane.md"), "# Codex ship lane prompt\n"); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + const commands = service.getSlashCommands({ sessionId: session.id }); + expect(commands.filter((command: any) => command.name.toLowerCase() === "/shiplane")).toHaveLength(1); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/shipLane", + description: "Ship the active lane", + source: "sdk", + }), + ])); + }); }); it("sends Claude provider slash commands as the raw SDK prompt", async () => { @@ -3260,6 +3315,38 @@ describe("createAgentChatService", () => { }); }); + it("does not forward Claude /login into the Agent SDK", async () => { + const send = vi.fn().mockResolvedValue(undefined); + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream: vi.fn(() => (async function* () { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-login-command", + slash_commands: ["/login"], + }; + })()), + close: vi.fn(), + sessionId: "sdk-session-login-command", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-4-6", + modelId: "anthropic/claude-sonnet-4-6", + }); + + await expect(service.sendMessage({ + sessionId: session.id, + text: "/login", + })).rejects.toThrow("/login is not an SDK-dispatchable command"); + expect(send).not.toHaveBeenCalledWith("/login"); + }); + it("expands project Claude command files before sending to the SDK", async () => { const commandsDir = path.join(tmpRoot, ".claude", "commands"); fs.mkdirSync(commandsDir, { recursive: true }); @@ -3357,6 +3444,53 @@ describe("createAgentChatService", () => { ])); }); + it("expands project Claude command files before sending to Codex", async () => { + const commandsDir = path.join(tmpRoot, ".claude", "commands"); + const promptsDir = path.join(tmpRoot, ".codex", "prompts"); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, "audit.md"), [ + "---", + "description: Audit recent work", + "---", + "", + "Audit the work.", + "", + "Focus: $ARGUMENTS", + "", + ].join("\n")); + fs.writeFileSync(path.join(promptsDir, "audit.md"), [ + "Audit the Codex prompt.", + "", + "Focus: $ARGUMENTS", + "", + ].join("\n")); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + modelId: "openai/gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/audit command rendering", + }, { awaitDispatch: true }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start") as any; + expect(turnStartRequest.params.input).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: "Audit the work.\n\nFocus: command rendering", + }), + ])); + }); + it("keeps built-in Codex slash commands routed to the app server", async () => { const promptsDir = path.join(tmpRoot, ".codex", "prompts"); fs.mkdirSync(promptsDir, { recursive: true }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 57d315d12..e5e089dee 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -31,6 +31,7 @@ type ClaudeV2Session = { import { buildClaudeV2Message, inferAttachmentMediaType } from "./buildClaudeV2Message"; import { discoverClaudeSlashCommands, resolveClaudeSlashCommandInvocation } from "./claudeSlashCommandDiscovery"; import { discoverCodexSlashCommands, resolveCodexSlashCommandInvocation } from "./codexSlashCommandDiscovery"; +import { classifyAgentCliError } from "../../../../../ade-cli/src/services/agentRegistry"; import type { RuntimeFilePart as FilePart, RuntimeImagePart as ImagePart, @@ -544,7 +545,6 @@ const CLAUDE_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [ { name: "/hooks", description: "View hook configurations for tool events.", source: "sdk" }, { name: "/ide", description: "Manage IDE integrations and show status.", source: "sdk" }, { name: "/init", description: "Initialize project with a CLAUDE.md guide.", source: "sdk" }, - { name: "/login", description: "Sign in to Claude Code for this chat runtime", source: "sdk" }, { name: "/logout", description: "Sign out from Anthropic.", source: "sdk" }, { name: "/mcp", description: "Manage MCP server connections and OAuth authentication.", source: "sdk" }, { name: "/memory", description: "Edit CLAUDE.md memory files and memory settings.", source: "sdk" }, @@ -566,8 +566,17 @@ const CLAUDE_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [ { name: "/usage", description: "Show session cost, plan usage limits, and activity stats.", source: "sdk" }, ]; -const CODEX_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CODEX_BUILT_IN_SLASH_COMMANDS.map((command) => command.name)); -const CLAUDE_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CLAUDE_BUILT_IN_SLASH_COMMANDS.map((command) => command.name)); +const CODEX_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CODEX_BUILT_IN_SLASH_COMMANDS.map((command) => slashCommandKey(command.name))); +const CLAUDE_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CLAUDE_BUILT_IN_SLASH_COMMANDS.map((command) => slashCommandKey(command.name))); +const CLAUDE_LOGIN_NOT_SDK_COMMAND = "ADE Claude chat is hosted through the Claude Agent SDK, and /login is not an SDK-dispatchable command. Run `claude auth login` in a terminal or configure ANTHROPIC_API_KEY, then refresh AI settings."; + +function slashCommandKey(value: string): string { + return value.trim().toLowerCase(); +} + +function isDispatchableClaudeSdkSlashCommand(command: { name: string }): boolean { + return command.name !== "/login"; +} type PendingOpenCodeApproval = { category: "bash" | "write"; @@ -6721,21 +6730,49 @@ export function createAgentChatService(args: { setSessionPreview(managed, event.text); }; + const decorateAgentCliError = ( + managed: ManagedChatSession, + event: Extract<AgentChatEvent, { type: "error" }>, + ): Extract<AgentChatEvent, { type: "error" }> => { + const existingInfo = typeof event.errorInfo === "object" && event.errorInfo ? event.errorInfo : null; + if (existingInfo?.agentCli) return event; + + const match = classifyAgentCliError(`${event.message}\n${event.detail ?? ""}`, managed.session.provider); + if (!match) return event; + + return { + ...event, + errorInfo: { + category: match.category === "missing" ? "agent_cli_missing" : "agent_cli_auth", + ...(existingInfo?.provider ? { provider: existingInfo.provider } : { provider: match.displayName }), + ...(existingInfo?.model ? { model: existingInfo.model } : {}), + agentCli: { + agent: match.agent, + displayName: match.displayName, + category: match.category, + installCommand: match.installCommand, + authCommand: match.authCommand, + }, + }, + }; + }; + const commitChatEvent = (managed: ManagedChatSession, event: AgentChatEvent): void => { + const storedEvent = event.type === "error" ? decorateAgentCliError(managed, event) : event; managed.session.lastActivityAt = nowIso(); - trackSubagentEvent(managed, event); - appendRecentConversationEntry(managed, event); + trackSubagentEvent(managed, storedEvent); + appendRecentConversationEntry(managed, storedEvent); - if (event.type === "text") { - updatePreviewFromText(managed, event); - } else if (event.type === "command") { - setSessionPreview(managed, event.output); - } else if (event.type === "error") { - setSessionPreview(managed, event.message); - } else if (event.type === "completion_report") { - managed.session.completion = event.report; - if (event.report.summary.trim().length > 0) { - setSessionPreview(managed, event.report.summary); + if (storedEvent.type === "text") { + updatePreviewFromText(managed, storedEvent); + } else if (storedEvent.type === "command") { + setSessionPreview(managed, storedEvent.output); + } else if (storedEvent.type === "error") { + setSessionPreview(managed, storedEvent.message); + } else if (storedEvent.type === "completion_report") { + managed.session.completion = storedEvent.report; + if (storedEvent.report.summary.trim().length > 0) { + setSessionPreview(managed, storedEvent.report.summary); } } @@ -6745,7 +6782,7 @@ export function createAgentChatService(args: { const envelope: AgentChatEventEnvelope = { sessionId: managed.session.id, timestamp: nowIso(), - event, + event: storedEvent, sequence: ++managed.eventSequence, }; @@ -6766,24 +6803,24 @@ export function createAgentChatService(args: { const collector = sessionTurnCollectors.get(managed.session.id); if (!collector) return; - if (event.type === "text") { - collector.outputText += event.text; + if (storedEvent.type === "text") { + collector.outputText += storedEvent.text; return; } - if (event.type === "error") { - collector.lastError = event.message; + if (storedEvent.type === "error") { + collector.lastError = storedEvent.message; return; } - if (event.type === "status" && event.turnStatus === "failed" && event.message) { - collector.lastError = event.message; + if (storedEvent.type === "status" && storedEvent.turnStatus === "failed" && storedEvent.message) { + collector.lastError = storedEvent.message; return; } - if (event.type !== "done") return; + if (storedEvent.type !== "done") return; - collector.usage = event.usage; + collector.usage = storedEvent.usage; if (collector.timeout) { clearTimeout(collector.timeout); } @@ -6795,7 +6832,7 @@ export function createAgentChatService(args: { ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), outputText: collector.outputText.trim() || managed.preview?.trim() || "", ...(collector.usage ? { usage: collector.usage } : {}), - ...(event.turnId ? { turnId: event.turnId } : {}), + ...(storedEvent.turnId ? { turnId: storedEvent.turnId } : {}), ...(managed.session.threadId ? { threadId: managed.session.threadId } : {}), ...(managed.runtime?.kind === "claude" ? { sdkSessionId: managed.runtime.sdkSessionId ?? null } : {}), }); @@ -11404,22 +11441,39 @@ export function createAgentChatService(args: { }; const projectSlashCommands = (() => { try { - return discoverClaudeSlashCommands(managed.laneWorktreePath); + return discoverClaudeSlashCommands(managed.laneWorktreePath).filter(isDispatchableClaudeSdkSlashCommand); } catch { return []; } })(); + const projectCommandFiles = projectSlashCommands.filter((cmd) => cmd.source === "command"); + const projectSkillFiles = projectSlashCommands.filter((cmd) => cmd.source === "skill"); const slashCommandsSection = projectSlashCommands.length ? [ "", - "## Project slash commands", - "The user can invoke custom slash commands defined in `.claude/commands/*.md` (project scope) and `~/.claude/commands/*.md` (user scope). When the user sends a message that is exactly `/<name>` or `/<name> <args>`, ADE auto-expands the command body into the message before it reaches you — so in that case you will already see the expanded instructions, not the literal `/<name>`.", - "When the user references a command mid-sentence (e.g. \"please run /audit\", \"can you do a /security-review\") the message is not auto-expanded. In that case, read the matching file at `.claude/commands/<name>.md` (prefer project scope; fall back to user scope) and follow its instructions as if the user had run it.", - "Available commands in this workspace:", - ...projectSlashCommands.map((cmd) => { - const desc = cmd.description.trim(); - return desc.length ? `- ${cmd.name} — ${desc}` : `- ${cmd.name}`; - }), + "## Project slash commands and skills", + "ADE walks up from the lane worktree to discover `.claude/commands/*.md` (slash commands) and `.claude/skills/<name>/SKILL.md` (skills) at every ancestor directory plus `~/.claude/`. The Claude Agent SDK only auto-discovers `<cwd>/.claude/` and `~/.claude/`, so ADE injects the rest here.", + "**User-invoked (`/<name>`):** When the user sends a message that is exactly `/<name>` or `/<name> <args>`, ADE pre-expands the file's body (commands take precedence over same-named skills) and substitutes `$ARGUMENTS` before it reaches you. You'll see the expanded instructions, not the literal `/<name>`.", + "**Mid-sentence reference:** When the user mentions a command/skill mid-sentence (e.g. \"please /audit this\", \"can you do a /security-review\") the message is NOT auto-expanded. Read the file at the path below and follow it.", + "**Autonomous skill use:** If, while working on a task, you decide a discovered skill applies (its description matches the situation), Read its SKILL.md file and follow it as if it had been invoked. Don't ask the user — just use the skill when warranted.", + ...(projectCommandFiles.length ? [ + "", + "Commands (file-backed prompts):", + ...projectCommandFiles.map((cmd) => { + const desc = cmd.description.trim(); + const head = desc.length ? `- ${cmd.name} — ${desc}` : `- ${cmd.name}`; + return `${head}\n file: ${cmd.filePath}`; + }), + ] : []), + ...(projectSkillFiles.length ? [ + "", + "Skills (autonomously usable when relevant):", + ...projectSkillFiles.map((cmd) => { + const desc = cmd.description.trim(); + const head = desc.length ? `- ${cmd.name} — ${desc}` : `- ${cmd.name}`; + return `${head}\n file: ${cmd.filePath}`; + }), + ] : []), ] : []; opts.systemPrompt = { @@ -11636,7 +11690,7 @@ export function createAgentChatService(args: { runtime: ClaudeRuntime, commands: Array<string | { name?: string; description?: string; argumentHint?: string }>, ): void => { - const existing = new Map(runtime.slashCommands.map((command) => [command.name, command])); + const existing = new Map(runtime.slashCommands.map((command) => [slashCommandKey(command.name), command])); for (const command of commands .map((command) => { if (typeof command === "string") { @@ -11656,12 +11710,13 @@ export function createAgentChatService(args: { }; }) .filter((command): command is { name: string; description: string; argumentHint?: string } => Boolean(command))) { - existing.set(command.name, { - ...existing.get(command.name), + const key = slashCommandKey(command.name); + existing.set(key, { + ...existing.get(key), ...command, }); } - runtime.slashCommands = [...existing.values()].sort((a, b) => a.name.localeCompare(b.name)); + runtime.slashCommands = [...existing.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); }; const deliverNextQueuedSteer = async ( @@ -12711,14 +12766,15 @@ export function createAgentChatService(args: { ); } }); - const allowClaudeLoginCommand = managed.session.provider === "claude" && slashCommand === "/login"; + if (managed.session.provider === "claude" && slashCommand === "/login") { + throw new Error(CLAUDE_LOGIN_NOT_SDK_COMMAND); + } const claudeRuntimeHealth = managed.session.provider === "claude" ? getProviderRuntimeHealth("claude") : null; if ( managed.session.provider === "claude" && claudeRuntimeHealth?.state === "auth-failed" - && !allowClaudeLoginCommand ) { throw new Error(claudeRuntimeHealth.message ?? CLAUDE_RUNTIME_AUTH_ERROR); } @@ -12765,10 +12821,10 @@ export function createAgentChatService(args: { const providerHasPersistentGuidance = managed.session.provider === "claude"; const shouldInjectGuidance = !providerHasPersistentGuidance; const claudeRuntimeSlashCommandNames = managed.runtime?.kind === "claude" - ? new Set(managed.runtime.slashCommands.map((command) => command.name)) + ? new Set(managed.runtime.slashCommands.map((command) => slashCommandKey(command.name))) : new Set<string>(); const codexRuntimeSlashCommandNames = managed.runtime?.kind === "codex" - ? new Set((managed.runtime as { slashCommands?: Array<{ name: string }> }).slashCommands?.map((command) => command.name) ?? []) + ? new Set((managed.runtime as { slashCommands?: Array<{ name: string }> }).slashCommands?.map((command) => slashCommandKey(command.name)) ?? []) : new Set<string>(); const expandedClaudeSlashCommand = providerSlashCommand && managed.session.provider === "claude" @@ -12777,18 +12833,26 @@ export function createAgentChatService(args: { && !claudeRuntimeSlashCommandNames.has(slashCommand) ? resolveClaudeSlashCommandInvocation(managed.laneWorktreePath, trimmed) : null; + const expandedClaudeProjectSlashCommandForCodex = providerSlashCommand + && managed.session.provider === "codex" + && slashCommand != null + && !CODEX_BUILT_IN_SLASH_COMMAND_NAMES.has(slashCommand) + && !codexRuntimeSlashCommandNames.has(slashCommand) + ? resolveClaudeSlashCommandInvocation(managed.laneWorktreePath, trimmed) + : null; const expandedCodexSlashCommand = providerSlashCommand && managed.session.provider === "codex" && slashCommand != null && !CODEX_BUILT_IN_SLASH_COMMAND_NAMES.has(slashCommand) && !codexRuntimeSlashCommandNames.has(slashCommand) + && expandedClaudeProjectSlashCommandForCodex == null ? resolveCodexSlashCommandInvocation(managed.laneWorktreePath, trimmed) : null; const contextAttachmentPrompt = providerSlashCommand ? "" : buildChatContextAttachmentPrompt(publicContextAttachments); const promptText = providerSlashCommand - ? expandedClaudeSlashCommand?.promptText ?? expandedCodexSlashCommand?.promptText ?? trimmed + ? expandedClaudeSlashCommand?.promptText ?? expandedCodexSlashCommand?.promptText ?? expandedClaudeProjectSlashCommandForCodex?.promptText ?? trimmed : composeLaunchDirectives(trimmed, [ shouldInjectLaneDirective ? buildLaneWorktreeDirective({ @@ -12805,7 +12869,7 @@ export function createAgentChatService(args: { contextAttachmentPrompt || null, ]); const autoTitleSeed = providerSlashCommand - ? expandedClaudeSlashCommand?.promptText ?? expandedCodexSlashCommand?.promptText ?? null + ? expandedClaudeSlashCommand?.promptText ?? expandedCodexSlashCommand?.promptText ?? expandedClaudeProjectSlashCommandForCodex?.promptText ?? null : visibleText; if (!managed.autoTitleSeed && autoTitleSeed) { managed.autoTitleSeed = autoTitleSeed; @@ -17066,7 +17130,7 @@ export function createAgentChatService(args: { const providerFromPreference: AgentChatProvider = (() => { if (workerIdentity?.adapterType === "claude-local") return "claude"; if (workerIdentity?.adapterType === "codex-local") return "codex"; - if (workerIdentity?.adapterType === "openclaw-webhook" || workerIdentity?.adapterType === "process") return "opencode"; + if (workerIdentity?.adapterType === "process") return "opencode"; if (preferredProviderRaw.includes("codex") || preferredProviderRaw.includes("openai")) return "codex"; if (preferredProviderRaw.includes("claude") || preferredProviderRaw.includes("anthropic")) return "claude"; if (preferredProviderRaw.includes("droid") || preferredProviderRaw.includes("factory")) return "droid"; @@ -18358,26 +18422,30 @@ export function createAgentChatService(args: { const merged = new Map<string, AgentChatSlashCommand>(); for (const group of groups) { for (const command of group) { - merged.set(command.name, command); + merged.set(slashCommandKey(command.name), command); } } - return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)); + return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); }; // Claude SDK commands plus filesystem-backed Claude Code commands/skills. if (provider === "claude") { - const runtimeCommands: AgentChatSlashCommand[] = (managed.runtime?.kind === "claude" ? managed.runtime.slashCommands : []).map((cmd: { name: string; description: string; argumentHint?: string }) => ({ - name: cmd.name, - description: cmd.description, - argumentHint: cmd.argumentHint, - source: "sdk" as const, - })); - const projectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(managed.laneWorktreePath).map((cmd: { name: string; description: string; argumentHint?: string }) => ({ - name: cmd.name, - description: cmd.description, - argumentHint: cmd.argumentHint, - source: "sdk" as const, - })); + const runtimeCommands: AgentChatSlashCommand[] = (managed.runtime?.kind === "claude" ? managed.runtime.slashCommands : []) + .filter(isDispatchableClaudeSdkSlashCommand) + .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + source: "sdk" as const, + })); + const projectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(managed.laneWorktreePath) + .filter(isDispatchableClaudeSdkSlashCommand) + .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + source: "sdk" as const, + })); return mergeSlashCommands([projectCommands, CLAUDE_BUILT_IN_SLASH_COMMANDS, runtimeCommands]); } @@ -18396,7 +18464,15 @@ export function createAgentChatService(args: { argumentHint: cmd.argumentHint, source: "sdk" as const, })); - return mergeSlashCommands([promptCommands, CODEX_BUILT_IN_SLASH_COMMANDS, dynamicCommands]); + const claudeProjectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(managed.laneWorktreePath) + .filter(isDispatchableClaudeSdkSlashCommand) + .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + source: "sdk" as const, + })); + return mergeSlashCommands([promptCommands, claudeProjectCommands, CODEX_BUILT_IN_SLASH_COMMANDS, dynamicCommands]); } // OpenCode / Cursor — only local commands diff --git a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts index 0ab3f7b62..161e4c5de 100644 --- a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts +++ b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts @@ -33,7 +33,7 @@ describe("discoverClaudeSlashCommands", () => { "", ].join("\n")); - expect(discoverClaudeSlashCommands(tmpRoot)).toEqual([ + expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ { name: "/automate", description: "Generate comprehensive test suites", @@ -42,7 +42,7 @@ describe("discoverClaudeSlashCommands", () => { ]); }); - it("namespaces nested project command files like Claude Code", () => { + it("uses nested project command paths for unambiguous discovery", () => { const commandsDir = path.join(tmpRoot, ".claude", "commands", "frontend"); fs.mkdirSync(commandsDir, { recursive: true }); fs.writeFileSync(path.join(commandsDir, "test.md"), [ @@ -54,7 +54,7 @@ describe("discoverClaudeSlashCommands", () => { "", ].join("\n")); - expect(discoverClaudeSlashCommands(tmpRoot)).toEqual([ + expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ { name: "/frontend:test", description: "Run frontend tests", @@ -84,7 +84,7 @@ describe("discoverClaudeSlashCommands", () => { "", ].join("\n")); - expect(discoverClaudeSlashCommands(tmpRoot)).toEqual([ + expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ { name: "/level-0:level-1:level-2:level-3:level-4:level-5:level-6:level-7:level-8:level-9:visible", description: "Visible nested command", @@ -92,6 +92,36 @@ describe("discoverClaudeSlashCommands", () => { ]); }); + it("preserves command filename casing and dedupes case variants by project precedence", () => { + fs.mkdirSync(path.join(homeRoot, ".claude", "commands"), { recursive: true }); + fs.mkdirSync(path.join(tmpRoot, ".claude", "commands"), { recursive: true }); + fs.writeFileSync(path.join(homeRoot, ".claude", "commands", "shipLane.md"), [ + "---", + "description: Personal ship lane", + "---", + "", + "Personal.", + "", + ].join("\n")); + fs.writeFileSync(path.join(tmpRoot, ".claude", "commands", "shipLane.md"), [ + "---", + "description: Project ship lane", + "---", + "", + "Project.", + "", + ].join("\n")); + + const commands = discoverClaudeSlashCommands(tmpRoot); + expect(commands.filter((command) => command.name.toLowerCase() === "/shiplane")).toHaveLength(1); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/shipLane", + description: "Project ship lane", + }), + ])); + }); + it("discovers invocable skills and hides non-user-invocable skills", () => { const visibleSkill = path.join(tmpRoot, ".claude", "skills", "fix-issue"); const hiddenSkill = path.join(tmpRoot, ".claude", "skills", "background-context"); @@ -117,7 +147,7 @@ describe("discoverClaudeSlashCommands", () => { "", ].join("\n")); - expect(discoverClaudeSlashCommands(tmpRoot)).toEqual([ + expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ { name: "/fix-issue", description: "Fix a GitHub issue", @@ -125,6 +155,58 @@ describe("discoverClaudeSlashCommands", () => { ]); }); + it("walks up parent directories to discover .claude/commands at workspace root from a lane subdir", () => { + const workspaceCommands = path.join(tmpRoot, ".claude", "commands"); + fs.mkdirSync(workspaceCommands, { recursive: true }); + fs.writeFileSync(path.join(workspaceCommands, "audit.md"), [ + "---", + "description: Workspace-root audit", + "---", + "", + "Audit.", + "", + ].join("\n")); + const laneWorktree = path.join(tmpRoot, "lanes", "feature-x", "worktree"); + fs.mkdirSync(laneWorktree, { recursive: true }); + + expect(discoverClaudeSlashCommands(laneWorktree)).toMatchObject([ + { + name: "/audit", + description: "Workspace-root audit", + source: "command", + }, + ]); + }); + + it("walks up parent directories for resolveClaudeSlashCommandInvocation as well", () => { + const workspaceCommands = path.join(tmpRoot, ".claude", "commands"); + fs.mkdirSync(workspaceCommands, { recursive: true }); + fs.writeFileSync(path.join(workspaceCommands, "audit.md"), [ + "---", + "description: Workspace-root audit", + "---", + "", + "Audit $ARGUMENTS.", + "", + ].join("\n")); + const laneWorktree = path.join(tmpRoot, "lanes", "feature-y", "worktree"); + fs.mkdirSync(laneWorktree, { recursive: true }); + + expect(resolveClaudeSlashCommandInvocation(laneWorktree, "/audit the model pane")?.promptText) + .toBe("Audit the model pane."); + }); + + it("resolves nested commands by basename and keeps legacy colon names working", () => { + const nestedCommands = path.join(tmpRoot, ".claude", "commands", "frontend"); + fs.mkdirSync(nestedCommands, { recursive: true }); + fs.writeFileSync(path.join(nestedCommands, "component.md"), "Build component $ARGUMENTS.\n"); + + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/component button")?.promptText) + .toBe("Build component button."); + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/frontend:component button")?.promptText) + .toBe("Build component button."); + }); + it("includes personal commands and lets project commands with the same name win", () => { fs.mkdirSync(path.join(homeRoot, ".claude", "commands"), { recursive: true }); fs.mkdirSync(path.join(tmpRoot, ".claude", "commands"), { recursive: true }); @@ -145,13 +227,42 @@ describe("discoverClaudeSlashCommands", () => { "", ].join("\n")); - expect(discoverClaudeSlashCommands(tmpRoot)).toEqual([ + expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ { name: "/ship", description: "Project ship", }, ]); }); + + it("does not let home commands override project commands when the project is under home", () => { + const projectRoot = path.join(homeRoot, "workspace", "project"); + fs.mkdirSync(path.join(homeRoot, ".claude", "commands"), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, ".claude", "commands"), { recursive: true }); + fs.writeFileSync(path.join(homeRoot, ".claude", "commands", "audit.md"), "Personal audit.\n"); + fs.writeFileSync(path.join(projectRoot, ".claude", "commands", "audit.md"), "Project audit.\n"); + + expect(discoverClaudeSlashCommands(projectRoot)).toMatchObject([ + { + name: "/audit", + description: "Project audit.", + }, + ]); + }); + + it("keeps nested commands with the same basename distinct", () => { + const frontendCommands = path.join(tmpRoot, ".claude", "commands", "frontend"); + const backendCommands = path.join(tmpRoot, ".claude", "commands", "backend"); + fs.mkdirSync(frontendCommands, { recursive: true }); + fs.mkdirSync(backendCommands, { recursive: true }); + fs.writeFileSync(path.join(frontendCommands, "button.md"), "Frontend button.\n"); + fs.writeFileSync(path.join(backendCommands, "button.md"), "Backend button.\n"); + + expect(discoverClaudeSlashCommands(tmpRoot)).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "/backend:button", description: "Backend button." }), + expect.objectContaining({ name: "/frontend:button", description: "Frontend button." }), + ])); + }); }); describe("resolveClaudeSlashCommandInvocation", () => { @@ -185,8 +296,106 @@ describe("resolveClaudeSlashCommandInvocation", () => { expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/ship now")?.promptText).toBe("Project now"); }); + it("lets project command files under home override same-named personal command files", () => { + const projectRoot = path.join(homeRoot, "workspace", "project"); + fs.mkdirSync(path.join(homeRoot, ".claude", "commands"), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, ".claude", "commands"), { recursive: true }); + fs.writeFileSync(path.join(homeRoot, ".claude", "commands", "ship.md"), "Personal $ARGUMENTS\n"); + fs.writeFileSync(path.join(projectRoot, ".claude", "commands", "ship.md"), "Project $ARGUMENTS\n"); + + expect(resolveClaudeSlashCommandInvocation(projectRoot, "/ship now")?.promptText).toBe("Project now"); + }); + + it("requires colon paths when nested command basenames are ambiguous", () => { + const frontendCommands = path.join(tmpRoot, ".claude", "commands", "frontend"); + const backendCommands = path.join(tmpRoot, ".claude", "commands", "backend"); + fs.mkdirSync(frontendCommands, { recursive: true }); + fs.mkdirSync(backendCommands, { recursive: true }); + fs.writeFileSync(path.join(frontendCommands, "button.md"), "Frontend $ARGUMENTS\n"); + fs.writeFileSync(path.join(backendCommands, "button.md"), "Backend $ARGUMENTS\n"); + + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/button primary")).toBeNull(); + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/frontend:button primary")?.promptText) + .toBe("Frontend primary"); + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/backend:button primary")?.promptText) + .toBe("Backend primary"); + }); + it("returns null for built-in commands and unknown command files", () => { expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/help")).toBeNull(); expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/missing")).toBeNull(); }); + + it("falls back to a skill SKILL.md when no command file matches", () => { + const skillDir = path.join(tmpRoot, ".claude", "skills", "audit"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), [ + "---", + "name: audit", + "description: Audit recent work", + "---", + "", + "Audit the work for $ARGUMENTS.", + "", + ].join("\n")); + + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/audit slash menu")?.promptText) + .toBe("Audit the work for slash menu."); + }); + + it("prefers a command file over a same-named skill", () => { + const cmdDir = path.join(tmpRoot, ".claude", "commands"); + const skillDir = path.join(tmpRoot, ".claude", "skills", "ship"); + fs.mkdirSync(cmdDir, { recursive: true }); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(cmdDir, "ship.md"), "Command body $ARGUMENTS\n"); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), [ + "---", + "name: ship", + "description: Ship", + "---", + "", + "Skill body $ARGUMENTS", + "", + ].join("\n")); + + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/ship now")?.promptText) + .toBe("Command body now"); + }); + + it("walks up ancestors to resolve a skill at workspace root from a lane subdir", () => { + const skillDir = path.join(tmpRoot, ".claude", "skills", "audit"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), [ + "---", + "name: audit", + "description: Audit", + "---", + "", + "Run audit on $ARGUMENTS.", + "", + ].join("\n")); + const lane = path.join(tmpRoot, "lanes", "feat", "wt"); + fs.mkdirSync(lane, { recursive: true }); + + expect(resolveClaudeSlashCommandInvocation(lane, "/audit X")?.promptText) + .toBe("Run audit on X."); + }); + + it("ignores skills marked user-invocable: false", () => { + const skillDir = path.join(tmpRoot, ".claude", "skills", "internal"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), [ + "---", + "name: internal", + "description: Internal", + "user-invocable: false", + "---", + "", + "Hidden body.", + "", + ].join("\n")); + + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/internal")).toBeNull(); + }); }); diff --git a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts index 24bae7e40..eb7f1cece 100644 --- a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts +++ b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts @@ -7,6 +7,8 @@ export type DiscoveredClaudeSlashCommand = { name: string; description: string; argumentHint?: string; + source: "command" | "skill"; + filePath: string; }; export type ResolvedClaudeSlashCommandInvocation = { @@ -57,10 +59,14 @@ function stripFrontmatter(markdown: string): string { } function normalizeSlashCommandName(value: string): string | null { - const name = value.trim().replace(/\.md$/i, "").replace(/[^A-Za-z0-9_:-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase(); + const name = value.trim().replace(/\.md$/i, "").replace(/[^A-Za-z0-9_:-]+/g, "-").replace(/^-+|-+$/g, ""); return name.length ? `/${name}` : null; } +function slashCommandKey(value: string): string { + return value.trim().toLowerCase(); +} + function maybeString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } @@ -95,8 +101,9 @@ function discoverLegacyCommands(commandsDir: string): DiscoveredClaudeSlashComma } if (!entry.isFile() || !entry.name.endsWith(".md")) continue; const relative = path.relative(commandsDir, entryPath).replace(/\.md$/i, ""); - const commandPath = relative.split(path.sep).filter(Boolean).join(":"); - const name = normalizeSlashCommandName(commandPath); + const parts = relative.split(path.sep).filter(Boolean); + const commandName = parts.join(":"); + const name = normalizeSlashCommandName(commandName); if (!name) continue; let content = ""; try { @@ -105,10 +112,13 @@ function discoverLegacyCommands(commandsDir: string): DiscoveredClaudeSlashComma continue; } const frontmatter = readFrontmatter(content) as CommandFrontmatter; + const description = maybeString(frontmatter.description) ?? firstMarkdownParagraph(content); commands.push({ name, - description: maybeString(frontmatter.description) ?? firstMarkdownParagraph(content), + description, argumentHint: maybeArgumentHint(frontmatter["argument-hint"]) ?? maybeArgumentHint(frontmatter.argumentHint), + source: "command", + filePath: entryPath, }); } }; @@ -133,13 +143,14 @@ function resolveLegacyCommandFile(commandsDir: string, commandName: string): str } } // Slow path: discovery normalizes filenames (lowercase + slugified), so a - // file like `My Command.md` is exposed as `/my-command` but the literal - // path above won't find it. Walk the directory and match by normalized - // name so non-canonical filenames still resolve. - const targetName = commandName.toLowerCase(); - let match: string | null = null; + // file like `My Command.md` is exposed as `/My-Command` but the literal + // path above won't find it. Unique basename lookup is accepted for older ADE + // command references, but duplicate basenames must use their colon path. + const targetName = slashCommandKey(commandName); + const pathMatches: string[] = []; + const baseMatches: string[] = []; const visit = (dir: string, prefix: string[], depth: number): void => { - if (match || depth > MAX_LEGACY_COMMAND_DEPTH) return; + if (depth > MAX_LEGACY_COMMAND_DEPTH) return; let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -147,7 +158,6 @@ function resolveLegacyCommandFile(commandsDir: string, commandName: string): str return; } for (const entry of entries) { - if (match) return; const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { visit(entryPath, [...prefix, entry.name], depth + 1); @@ -155,15 +165,15 @@ function resolveLegacyCommandFile(commandsDir: string, commandName: string): str } if (!entry.isFile() || !entry.name.endsWith(".md")) continue; const commandPath = [...prefix, entry.name].join(":"); - const normalized = normalizeSlashCommandName(commandPath); - if (normalized && normalized.toLowerCase() === targetName) { - match = entryPath; - return; - } + const normalizedPath = normalizeSlashCommandName(commandPath); + const normalizedBase = normalizeSlashCommandName(entry.name); + if (normalizedPath && slashCommandKey(normalizedPath) === targetName) pathMatches.push(entryPath); + if (normalizedBase && slashCommandKey(normalizedBase) === targetName) baseMatches.push(entryPath); } }; visit(commandsDir, [], 0); - return match; + if (pathMatches.length > 0) return pathMatches[0] ?? null; + return baseMatches.length === 1 ? baseMatches[0] ?? null : null; } function discoverSkills(skillsDir: string): DiscoveredClaudeSlashCommand[] { @@ -195,17 +205,54 @@ function discoverSkills(skillsDir: string): DiscoveredClaudeSlashCommand[] { name, description: maybeString(frontmatter.description) ?? firstMarkdownParagraph(content), argumentHint: maybeArgumentHint(frontmatter["argument-hint"]) ?? maybeArgumentHint(frontmatter.argumentHint), + source: "skill", + filePath: skillPath, }); } return commands; } +function ancestorClaudeRoots(cwd: string): string[] { + const roots: string[] = []; + const seen = new Set<string>(); + const home = path.resolve(os.homedir()); + let current = path.resolve(cwd); + let depth = 0; + while (depth < 25) { + const candidate = path.join(current, ".claude"); + if (!seen.has(candidate)) { + seen.add(candidate); + roots.push(candidate); + } + const parent = path.dirname(current); + if (parent === current) break; + if (current === home) break; + current = parent; + depth += 1; + } + return roots; +} + +function claudeRootsByPrecedence(cwd: string): string[] { + const roots: string[] = []; + const seen = new Set<string>(); + const home = path.resolve(os.homedir()); + const addRoot = (root: string): void => { + if (seen.has(root)) return; + seen.add(root); + roots.push(root); + }; + + for (const root of ancestorClaudeRoots(cwd)) { + addRoot(root); + } + addRoot(path.join(home, ".claude")); + return roots; +} + export function discoverClaudeSlashCommands(cwd: string): DiscoveredClaudeSlashCommand[] { - const roots = [ - path.join(os.homedir(), ".claude"), - path.join(cwd, ".claude"), - ]; + const roots = claudeRootsByPrecedence(cwd); const byName = new Map<string, DiscoveredClaudeSlashCommand>(); for (const root of roots) { @@ -214,11 +261,49 @@ export function discoverClaudeSlashCommands(cwd: string): DiscoveredClaudeSlashC ...discoverSkills(path.join(root, "skills")), ]; for (const command of discovered) { - byName.set(command.name, command); + const key = slashCommandKey(command.name); + if (!byName.has(key)) byName.set(key, command); } } - return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); +} + +function resolveSkillFile(skillsDir: string, commandName: string): string | null { + if (!fs.existsSync(skillsDir)) return null; + const target = commandName.replace(/^\//, "").toLowerCase(); + if (!target.length) return null; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + } catch { + return null; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillPath = path.join(skillsDir, entry.name, "SKILL.md"); + if (!fs.existsSync(skillPath)) continue; + let content = ""; + try { + content = fs.readFileSync(skillPath, "utf8"); + } catch { + continue; + } + const frontmatter = readFrontmatter(content) as { name?: unknown; "user-invocable"?: unknown; userInvocable?: unknown }; + if (frontmatter["user-invocable"] === false || frontmatter.userInvocable === false) continue; + const declaredName = maybeString(frontmatter.name); + const candidateNames = new Set<string>(); + const dirNormalized = normalizeSlashCommandName(entry.name); + if (dirNormalized) candidateNames.add(dirNormalized.toLowerCase()); + if (declaredName) { + const fmNormalized = normalizeSlashCommandName(declaredName); + if (fmNormalized) candidateNames.add(fmNormalized.toLowerCase()); + } + if (candidateNames.has(`/${target}`) || candidateNames.has(target)) { + return skillPath; + } + } + return null; } export function resolveClaudeSlashCommandInvocation( @@ -229,22 +314,27 @@ export function resolveClaudeSlashCommandInvocation( const match = trimmed.match(/^(\/[A-Za-z0-9][A-Za-z0-9_-]*(?::[A-Za-z0-9][A-Za-z0-9_-]*)*)(?:\s+([\s\S]*))?$/); if (!match) return null; - const name = match[1]?.toLowerCase(); + const name = match[1]; if (!name) return null; const argumentsText = match[2]?.trim() ?? ""; - const roots = [ - path.join(os.homedir(), ".claude"), - path.join(cwd, ".claude"), - ]; + const roots = claudeRootsByPrecedence(cwd); - let commandFile: string | null = null; + // Prefer command files; fall back to user-invocable skills (SKILL.md). + let resolvedFile: string | null = null; for (const root of roots) { - commandFile = resolveLegacyCommandFile(path.join(root, "commands"), name) ?? commandFile; + resolvedFile = resolveLegacyCommandFile(path.join(root, "commands"), name); + if (resolvedFile) break; + } + if (!resolvedFile) { + for (const root of roots) { + resolvedFile = resolveSkillFile(path.join(root, "skills"), name); + if (resolvedFile) break; + } } - if (!commandFile) return null; + if (!resolvedFile) return null; try { - const content = fs.readFileSync(commandFile, "utf8"); + const content = fs.readFileSync(resolvedFile, "utf8"); const body = stripFrontmatter(content).trim(); if (!body.length) return null; const hasPlaceholder = /\$ARGUMENTS/.test(body); diff --git a/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts b/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts index f66e80587..8956438cc 100644 --- a/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts +++ b/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts @@ -123,10 +123,24 @@ function resolvePromptFile(promptsDir: string, commandName: string): string | nu } function codexPromptRoots(cwd: string): string[] { - return [ - path.join(os.homedir(), ".codex", "prompts"), - path.join(cwd, ".codex", "prompts"), - ]; + const roots: string[] = [path.join(os.homedir(), ".codex", "prompts")]; + const seen = new Set<string>(roots); + const home = os.homedir(); + let current = path.resolve(cwd); + let depth = 0; + while (depth < 25) { + const candidate = path.join(current, ".codex", "prompts"); + if (!seen.has(candidate)) { + seen.add(candidate); + roots.push(candidate); + } + const parent = path.dirname(current); + if (parent === current) break; + if (current === home) break; + current = parent; + depth += 1; + } + return roots; } export function discoverCodexSlashCommands(cwd: string): DiscoveredCodexSlashCommand[] { diff --git a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts index 9f20fbebd..27a989fa9 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts @@ -281,6 +281,7 @@ export type CursorSdkTurnEndedTokensMeta = { turnId: string; itemId?: string; runtime?: AgentChatRuntime; + contextWindow?: number; }; export function mapTurnEndedTokensToEvent( @@ -311,5 +312,6 @@ export function mapTurnEndedTokensToEvent( ...(outputTokens != null ? { outputTokens } : {}), ...(cacheReadTokens != null ? { cacheReadTokens } : {}), ...(cacheWriteTokens != null ? { cacheWriteTokens } : {}), + ...(meta.contextWindow != null ? { contextWindow: meta.contextWindow } : {}), }; } diff --git a/apps/desktop/src/main/services/cli/adeCliService.test.ts b/apps/desktop/src/main/services/cli/adeCliService.test.ts index bed3ce13d..2c402ffce 100644 --- a/apps/desktop/src/main/services/cli/adeCliService.test.ts +++ b/apps/desktop/src/main/services/cli/adeCliService.test.ts @@ -80,6 +80,32 @@ describe("createAdeCliService", () => { expect(service.agentEnv({ PATH: "/usr/bin:/bin" }).PATH?.split(path.delimiter)[0]).toBe(packagedBinDir); }); + it("uses channel-specific packaged CLI commands and install targets", async () => { + const root = makeTempRoot(); + const home = path.join(root, "home"); + const resourcesPath = path.join(root, "Resources"); + const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin"); + const packagedCommandPath = path.join(packagedBinDir, "ade-alpha"); + writeExecutable(packagedCommandPath); + writeExecutable(path.join(resourcesPath, "ade-cli", "install-path.sh")); + fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n"); + + const service = createAdeCliService({ + isPackaged: true, + resourcesPath, + userDataPath: path.join(root, "user-data"), + appExecutablePath: path.join(root, "ADE Alpha.app", "Contents", "MacOS", "ADE Alpha"), + env: { ADE_PACKAGE_CHANNEL: "alpha", HOME: home, PATH: "/usr/bin:/bin" }, + logger: logger() as any, + }); + + const status = await service.getStatus(); + expect(service.resolved.commandPath).toBe(packagedCommandPath); + expect(status.command).toBe("ade-alpha"); + expect(status.installTargetPath).toBe(path.join(home, ".local", "bin", "ade-alpha")); + expect(status.nextAction).toBe("Install the ade-alpha command for Terminal access."); + }); + it("uses packaged Windows cmd wrappers and Path casing", async () => { setPlatform("win32"); const root = makeTempRoot(); @@ -442,7 +468,7 @@ describe("createAdeCliService", () => { logger: logger() as any, }); - const shimPath = path.join(userDataPath, "ade-cli", "bin", "ade"); + const shimPath = path.join(userDataPath, "ade-cli", "bin", "ade-dev"); expect(service.resolved.source).toBe("dev"); expect(service.resolved.commandPath).toBe(shimPath); expect(fs.existsSync(shimPath)).toBe(true); @@ -471,7 +497,7 @@ describe("createAdeCliService", () => { logger: logger() as any, }); - const shimPath = path.join(userDataPath, "ade-cli", "bin", "ade.cmd"); + const shimPath = path.join(userDataPath, "ade-cli", "bin", "ade-dev.cmd"); const script = fs.readFileSync(shimPath, "utf8"); expect(service.resolved.source).toBe("dev"); @@ -503,7 +529,7 @@ describe("createAdeCliService", () => { logger: logger() as any, }); - const shimPath = path.join(userDataPath, "ade-cli", "bin", "ade"); + const shimPath = path.join(userDataPath, "ade-cli", "bin", "ade-dev"); const shimScript = fs.readFileSync(shimPath, "utf8"); expect(service.resolved.source).toBe("dev"); @@ -547,7 +573,7 @@ describe("createAdeCliService", () => { expect(service.resolved.cliJsPath).toBe(sourceCliPath); }); - it("does not run a global installer from dev builds", async () => { + it("installs the dev CLI command separately from prod", async () => { const root = makeTempRoot(); vi.spyOn(process, "cwd").mockReturnValue(root); @@ -556,11 +582,13 @@ describe("createAdeCliService", () => { resourcesPath: path.join(root, "missing-resources"), userDataPath: path.join(root, "user-data"), appExecutablePath: "/Applications/ADE.app/Contents/MacOS/ADE", + env: { HOME: path.join(root, "home"), PATH: "/usr/bin:/bin" }, logger: logger() as any, }); const result = await service.installForUser(); - expect(result.ok).toBe(false); - expect(result.message).toContain("local development"); + expect(result.ok).toBe(true); + expect(result.status.command).toBe("ade-dev"); + expect(fs.existsSync(path.join(root, "home", ".local", "bin", "ade-dev"))).toBe(true); }); }); diff --git a/apps/desktop/src/main/services/cli/adeCliService.ts b/apps/desktop/src/main/services/cli/adeCliService.ts index c0779cbf0..479248707 100644 --- a/apps/desktop/src/main/services/cli/adeCliService.ts +++ b/apps/desktop/src/main/services/cli/adeCliService.ts @@ -34,6 +34,7 @@ type DevCliEntry = { }; const PATH_DELIMITER = path.delimiter; +const VALID_COMMAND_NAME = /^ade(?:-[a-z0-9][a-z0-9-]*)?$/; function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; @@ -43,8 +44,8 @@ function pathDelimiter(): string { return process.platform === "win32" ? ";" : PATH_DELIMITER; } -function commandFileName(): "ade" | "ade.cmd" { - return process.platform === "win32" ? "ade.cmd" : "ade"; +function commandFileName(commandName: string): string { + return process.platform === "win32" ? `${commandName}.cmd` : commandName; } function installerFileName(): "install-path.sh" | "install-path.cmd" { @@ -64,6 +65,24 @@ function isExecutable(filePath: string | null | undefined): boolean { } } +function normalizePackageChannel(value: unknown): "alpha" | "beta" | null { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + return normalized === "alpha" || normalized === "beta" ? normalized : null; +} + +function sanitizeCommandName(value: unknown): string | null { + const normalized = typeof value === "string" ? value.trim() : ""; + return VALID_COMMAND_NAME.test(normalized) ? normalized : null; +} + +function resolveCommandName(args: CreateAdeCliServiceArgs): string { + const explicit = sanitizeCommandName(args.env?.ADE_CLI_INSTALL_NAME ?? process.env.ADE_CLI_INSTALL_NAME); + if (explicit) return explicit; + const channel = normalizePackageChannel(args.env?.ADE_PACKAGE_CHANNEL ?? process.env.ADE_PACKAGE_CHANNEL); + if (channel) return `ade-${channel}`; + return args.isPackaged ? "ade" : "ade-dev"; +} + function splitPathEntries(value: string | null | undefined): string[] { return (value ?? "").split(pathDelimiter()).map((entry) => entry.trim()).filter(Boolean); } @@ -268,6 +287,7 @@ function resolveDevCliEntry(devRepoRoot?: string | null): DevCliEntry | null { } function writeDevShim(args: { + commandName: string; cliJsPath: string; entryKind: "built" | "source"; tsxBinPath: string | null; @@ -277,7 +297,7 @@ function writeDevShim(args: { logger: Logger; }): { commandPath: string; binDir: string } | null { const binDir = path.join(args.userDataPath, "ade-cli", "bin"); - const commandPath = path.join(binDir, commandFileName()); + const commandPath = path.join(binDir, commandFileName(args.commandName)); const script = process.platform === "win32" ? createWindowsShimScript(args) : [ "#!/bin/sh", "set -eu", @@ -348,16 +368,17 @@ function writeDevShim(args: { } } -function resolveCliPaths(args: CreateAdeCliServiceArgs): ResolvedCliPaths { +function resolveCliPaths(args: CreateAdeCliServiceArgs, commandName: string): ResolvedCliPaths { const resourcesPath = args.resourcesPath ? path.resolve(args.resourcesPath) : null; const packagedBinDir = resourcesPath ? path.join(resourcesPath, "ade-cli", "bin") : null; - const packagedCommandPath = packagedBinDir ? path.join(packagedBinDir, commandFileName()) : null; + const packagedCommandPath = packagedBinDir ? path.join(packagedBinDir, commandFileName(commandName)) : null; + const fallbackPackagedCommandPath = packagedBinDir && commandName !== "ade" ? path.join(packagedBinDir, commandFileName("ade")) : null; const packagedCliJsPath = resourcesPath ? path.join(resourcesPath, "ade-cli", "cli.cjs") : null; const packagedInstallerPath = resourcesPath ? path.join(resourcesPath, "ade-cli", installerFileName()) : null; - if (args.isPackaged && isExecutable(packagedCommandPath)) { + if (args.isPackaged && (isExecutable(packagedCommandPath) || isExecutable(fallbackPackagedCommandPath))) { return { - commandPath: packagedCommandPath, + commandPath: isExecutable(packagedCommandPath) ? packagedCommandPath : fallbackPackagedCommandPath, binDir: packagedBinDir, installerPath: isExecutable(packagedInstallerPath) ? packagedInstallerPath : null, cliJsPath: fs.existsSync(packagedCliJsPath ?? "") ? packagedCliJsPath : null, @@ -368,6 +389,7 @@ function resolveCliPaths(args: CreateAdeCliServiceArgs): ResolvedCliPaths { const devCli = resolveDevCliEntry(args.devRepoRoot); if (devCli) { const shim = writeDevShim({ + commandName, cliJsPath: devCli.cliPath, entryKind: devCli.entryKind, tsxBinPath: path.join(devCli.repoRoot, "apps", "ade-cli", "node_modules", ".bin", process.platform === "win32" ? "tsx.cmd" : "tsx"), @@ -403,12 +425,12 @@ function homeDir(env: NodeJS.ProcessEnv = process.env): string { return env.HOME?.trim() || os.homedir(); } -function installTargetPath(env: NodeJS.ProcessEnv = process.env): string { +function installTargetPath(commandName: string, env: NodeJS.ProcessEnv = process.env): string { if (process.platform === "win32") { const localAppData = env.LOCALAPPDATA?.trim() || path.join(homeDir(env), "AppData", "Local"); - return path.join(localAppData, "ADE", "bin", "ade.cmd"); + return path.join(localAppData, "ADE", "bin", `${commandName}.cmd`); } - return path.join(homeDir(env), ".local", "bin", "ade"); + return path.join(homeDir(env), ".local", "bin", commandName); } type ShellProfile = { path: string; flavor: "posix" | "fish" }; @@ -454,6 +476,7 @@ function ensureUserBinOnShellPath( } function statusMessage(args: { + commandName: string; terminalInstalled: boolean; bundledAvailable: boolean; agentPathReady: boolean; @@ -462,27 +485,27 @@ function statusMessage(args: { }): { message: string; nextAction: string | null } { if (args.terminalInstalled && args.agentPathReady) { return { - message: "The ade command is available to Terminal and ADE-launched agents.", + message: `The ${args.commandName} command is available to Terminal and ADE-launched agents.`, nextAction: null, }; } if (args.agentPathReady && args.bundledAvailable) { return { - message: "ADE-launched agents can use ade. Terminal access is not installed yet.", + message: `ADE-launched agents can use ${args.commandName}. Terminal access is not installed yet.`, nextAction: args.installAvailable - ? "Install the ade command for Terminal access." + ? `Install the ${args.commandName} command for Terminal access.` : "Run npm link in apps/ade-cli for local development.", }; } if (args.bundledAvailable) { return { - message: "The bundled ade command is present, but it is not on the agent PATH yet.", + message: `The bundled ${args.commandName} command is present, but it is not on the agent PATH yet.`, nextAction: "Restart ADE so new agent sessions receive the bundled CLI path.", }; } return { message: args.isPackaged - ? "The bundled ade command is missing from this app build." + ? `The bundled ${args.commandName} command is missing from this app build.` : "The local ADE CLI build was not found.", nextAction: args.isPackaged ? "Reinstall or update ADE." @@ -491,7 +514,8 @@ function statusMessage(args: { } export function createAdeCliService(args: CreateAdeCliServiceArgs) { - const resolved = resolveCliPaths(args); + const commandName = resolveCommandName(args); + const resolved = resolveCliPaths(args, commandName); const envSnapshot = args.env ?? process.env; const hostPathSnapshot = getPathEnvValue(envSnapshot); @@ -513,16 +537,18 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) { }; const getStatus = async (): Promise<AdeCliStatus> => { - const terminalCommandPath = resolveCommandOnPath("ade", hostPathSnapshot, envSnapshot); - const targetPath = installTargetPath(envSnapshot); + const terminalCommandPath = resolveCommandOnPath(commandName, hostPathSnapshot, envSnapshot); + const targetPath = installTargetPath(commandName, envSnapshot); const targetDir = path.dirname(targetPath); const terminalInstalled = Boolean(terminalCommandPath); const bundledAvailable = Boolean(resolved.commandPath && isExecutable(resolved.commandPath)); const hostPathEnv: NodeJS.ProcessEnv = {}; if (hostPathSnapshot) setPathEnvValue(hostPathEnv, hostPathSnapshot); const agentPathReady = bundledAvailable && pathContainsDir(getPathEnvValue(agentEnv(hostPathEnv)), resolved.binDir); - const installAvailable = resolved.source === "packaged" && isExecutable(resolved.installerPath); + const packagedInstallAvailable = resolved.source === "packaged" && isExecutable(resolved.installerPath); + const installAvailable = packagedInstallAvailable || (resolved.source === "dev" && bundledAvailable); const message = statusMessage({ + commandName, terminalInstalled, bundledAvailable, agentPathReady, @@ -531,7 +557,7 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) { }); return { - command: "ade", + command: commandName, platform: process.platform, isPackaged: args.isPackaged, bundledAvailable, @@ -550,36 +576,44 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) { }; const installForUser = async (): Promise<AdeCliInstallResult> => { - if (!isExecutable(resolved.installerPath)) { + const installDevCommand = resolved.source === "dev" && isExecutable(resolved.commandPath); + if (!isExecutable(resolved.installerPath) && !installDevCommand) { const status = await getStatus(); return { ok: false, message: args.isPackaged ? "The ADE CLI installer is missing from this app build." - : "Terminal install is available from packaged ADE builds. For local development, run npm link in apps/ade-cli.", + : "The local ADE CLI build was not found.", status, }; } try { - const result = await spawnAsync(resolved.installerPath!, []); - if (result.status !== 0) { - throw new Error(result.stderr.trim() || result.stdout.trim() || "ADE CLI installer failed."); + if (installDevCommand) { + const targetPath = installTargetPath(commandName, envSnapshot); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.rmSync(targetPath, { force: true }); + fs.symlinkSync(resolved.commandPath!, targetPath); + } else { + const result = await spawnAsync(resolved.installerPath!, []); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || result.stdout.trim() || "ADE CLI installer failed."); + } } - const targetDir = path.dirname(installTargetPath(envSnapshot)); + const targetDir = path.dirname(installTargetPath(commandName, envSnapshot)); const profileResult = ensureUserBinOnShellPath(targetDir, envSnapshot); const status = await getStatus(); return { ok: true, message: process.platform === "win32" - ? `Installed ade for Terminal access and added ${targetDir} to the user PATH if it was missing. Open a new terminal, then run: ade doctor.` + ? `Installed ${commandName} for Terminal access and added ${targetDir} to the user PATH if it was missing. Open a new terminal, then run: ${commandName} doctor.` : profileResult ? profileResult.modified - ? `Installed ade for Terminal access and added ${targetDir} to ${profileResult.profilePath}. Open a new terminal or source that file.` - : `Installed ade for Terminal access. PATH entry already present in ${profileResult.profilePath}; open a new terminal or source that file.` + ? `Installed ${commandName} for Terminal access and added ${targetDir} to ${profileResult.profilePath}. Open a new terminal or source that file.` + : `Installed ${commandName} for Terminal access. PATH entry already present in ${profileResult.profilePath}; open a new terminal or source that file.` : status.installTargetDirOnPath - ? "Installed ade for Terminal access." - : `Installed ade at ${status.installTargetPath}. Add ${path.dirname(status.installTargetPath)} to PATH if your shell cannot find it.`, + ? `Installed ${commandName} for Terminal access.` + : `Installed ${commandName} at ${status.installTargetPath}. Add ${path.dirname(status.installTargetPath)} to PATH if your shell cannot find it.`, status, }; } catch (error) { diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts index c67598580..9e70c10ad 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts @@ -117,6 +117,32 @@ describe("computerUseArtifactBrokerService", () => { ]); }); + it("reads image previews only from the project artifact directory", async () => { + const missionService = { addArtifact: vi.fn() } as any; + const orchestratorService = { registerArtifact: vi.fn() } as any; + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + missionService, + orchestratorService, + logger: createLogger(), + }); + const artifactDir = path.join(projectRoot, ".ade", "artifacts", "computer-use"); + fs.mkdirSync(artifactDir, { recursive: true }); + const artifactPath = path.join(artifactDir, "preview.png"); + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + fs.writeFileSync(artifactPath, bytes); + + await expect(broker.readArtifactPreview({ + uri: "ade-artifact://project/.ade/artifacts/computer-use/preview.png", + })).resolves.toBe(`data:image/png;base64,${bytes.toString("base64")}`); + + const outsidePath = path.join(projectRoot, "outside.png"); + fs.writeFileSync(outsidePath, bytes); + await expect(broker.readArtifactPreview({ uri: outsidePath })).resolves.toBeNull(); + }); + it("rejects local file imports outside allowed artifact roots", () => { const missionService = { addArtifact: vi.fn() } as any; const orchestratorService = { registerArtifact: vi.fn() } as any; diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index 3e27fd1ef..dfd123f84 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import type { ComputerUseArtifactIngestionRequest, ComputerUseArtifactIngestionResult, @@ -60,6 +61,16 @@ type StoredArtifactRow = { const DEFAULT_REVIEW_STATE: ComputerUseArtifactReviewState = "accepted"; const DEFAULT_WORKFLOW_STATE: ComputerUseArtifactWorkflowState = "evidence_only"; +const ARTIFACT_PREVIEW_SIZE_CAP = 10 * 1024 * 1024; +const ARTIFACT_PREVIEW_MIME_BY_EXTENSION: Record<string, string> = { + bmp: "image/bmp", + gif: "image/gif", + jpeg: "image/jpeg", + jpg: "image/jpeg", + png: "image/png", + svg: "image/svg+xml", + webp: "image/webp", +}; type StoredLinkRow = { id: string; @@ -89,6 +100,50 @@ function isAllowedExternalArtifactSource( }); } +function resolveRendererArtifactPath(rawPath: string, projectRoot: string): string { + let inputPath = rawPath; + if (/^ade-artifact:\/\/project(?:\/|$)/i.test(inputPath)) { + const parsed = new URL(inputPath); + inputPath = decodeURIComponent(parsed.pathname.replace(/^\/+/, "")); + } + if (/^file:\/\//i.test(inputPath)) { + try { + inputPath = fileURLToPath(inputPath); + } catch { + inputPath = decodeURIComponent(inputPath.replace(/^file:\/\//i, "")); + } + } + return path.resolve(path.isAbsolute(inputPath) ? inputPath : path.join(projectRoot, inputPath)); +} + +async function readArtifactPreviewDataUrl(args: { + uri?: string; + projectRoot: string; + artifactsDir: string; +}): Promise<string | null> { + const uri = typeof args.uri === "string" ? args.uri.trim() : ""; + if (!uri) return null; + const filePath = resolveRendererArtifactPath(uri, args.projectRoot); + const canonical = path.normalize(path.resolve(filePath)); + try { + resolvePathWithinRoot(args.artifactsDir, canonical); + } catch { + return null; + } + + try { + const stat = await fs.promises.stat(canonical); + if (!stat.isFile() || stat.size > ARTIFACT_PREVIEW_SIZE_CAP) return null; + const ext = path.extname(canonical).replace(/^\./, "").toLowerCase(); + const mime = ARTIFACT_PREVIEW_MIME_BY_EXTENSION[ext]; + if (!mime) return null; + const buf = await fs.promises.readFile(canonical); + return `data:${mime};base64,${buf.toString("base64")}`; + } catch { + return null; + } +} + function secureCopyFromDescriptor(sourcePath: string, targetPath: string): void { const sourceFlags = fs.constants.O_RDONLY | (typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0); const sourceFd = fs.openSync(sourcePath, sourceFlags); @@ -688,6 +743,14 @@ export function createComputerUseArtifactBrokerService(args: { return updated; }, + readArtifactPreview(args: { uri?: string }): Promise<string | null> { + return readArtifactPreviewDataUrl({ + uri: args?.uri, + projectRoot, + artifactsDir: layout.artifactsDir, + }); + }, + getBackendStatus, }; } diff --git a/apps/desktop/src/main/services/config/projectConfigService.processGroups.test.ts b/apps/desktop/src/main/services/config/projectConfigService.processGroups.test.ts index 046989469..b168a1950 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.processGroups.test.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.processGroups.test.ts @@ -90,6 +90,46 @@ describe("projectConfigService process groups", () => { expect(byId.get("frontend")!.name).toBe("Web"); }); + it("rolls back config-derived row refreshes when a snapshot write fails", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-rollback-")); + tempDirs.push(root); + + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + fs.writeFileSync( + path.join(adeDir, "ade.yaml"), + YAML.stringify({ + version: 1, + processes: [], + processGroups: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }), + "utf8", + ); + const db = makeDb(); + db.run.mockImplementation((sql: string) => { + if (/delete from stack_buttons/i.test(sql)) { + throw new Error("delete failed"); + } + }); + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-rollback", + db, + logger: makeLogger(), + }); + + expect(() => service.get()).toThrow("delete failed"); + const statements = db.run.mock.calls.map((call: unknown[]) => String(call[0]).trim()); + expect(statements[0]).toBe("BEGIN IMMEDIATE"); + expect(statements).toContain("ROLLBACK"); + expect(statements).not.toContain("COMMIT"); + }); + it("falls back to id when an effective processGroup has no name", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-groups-fallback-")); tempDirs.push(root); diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index 0aaf1b8a0..1c18c1578 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -3106,75 +3106,86 @@ export function createProjectConfigService({ const syncSnapshots = (effective: EffectiveProjectConfig) => { const now = new Date().toISOString(); - db.run("delete from process_definitions where project_id = ?", [projectId]); - db.run("delete from stack_buttons where project_id = ?", [projectId]); - db.run("delete from test_suites where project_id = ?", [projectId]); - - for (const proc of effective.processes) { - db.run( - ` - insert into process_definitions( - id, project_id, key, name, command_json, cwd, env_json, autostart, - restart_policy, graceful_shutdown_ms, depends_on_json, readiness_json, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - createDefId(projectId, `proc:${proc.id}`), - projectId, - proc.id, - proc.name, - JSON.stringify(proc.command), - proc.cwd, - JSON.stringify(proc.env), - proc.autostart ? 1 : 0, - proc.restart, - proc.gracefulShutdownMs, - JSON.stringify(proc.dependsOn), - JSON.stringify(proc.readiness), - now - ] - ); - } + db.run("BEGIN IMMEDIATE"); + try { + db.run("delete from process_definitions where project_id = ?", [projectId]); + db.run("delete from stack_buttons where project_id = ?", [projectId]); + db.run("delete from test_suites where project_id = ?", [projectId]); + + for (const proc of effective.processes) { + db.run( + ` + insert into process_definitions( + id, project_id, key, name, command_json, cwd, env_json, autostart, + restart_policy, graceful_shutdown_ms, depends_on_json, readiness_json, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + createDefId(projectId, `proc:${proc.id}`), + projectId, + proc.id, + proc.name, + JSON.stringify(proc.command), + proc.cwd, + JSON.stringify(proc.env), + proc.autostart ? 1 : 0, + proc.restart, + proc.gracefulShutdownMs, + JSON.stringify(proc.dependsOn), + JSON.stringify(proc.readiness), + now + ] + ); + } - for (const stack of effective.stackButtons) { - db.run( - ` - insert into stack_buttons( - id, project_id, key, name, process_keys_json, start_order, updated_at - ) values (?, ?, ?, ?, ?, ?, ?) - `, - [ - createDefId(projectId, `stack:${stack.id}`), - projectId, - stack.id, - stack.name, - JSON.stringify(stack.processIds), - stack.startOrder, - now - ] - ); - } + for (const stack of effective.stackButtons) { + db.run( + ` + insert into stack_buttons( + id, project_id, key, name, process_keys_json, start_order, updated_at + ) values (?, ?, ?, ?, ?, ?, ?) + `, + [ + createDefId(projectId, `stack:${stack.id}`), + projectId, + stack.id, + stack.name, + JSON.stringify(stack.processIds), + stack.startOrder, + now + ] + ); + } - for (const suite of effective.testSuites) { - db.run( - ` - insert into test_suites( - id, project_id, key, name, command_json, cwd, env_json, timeout_ms, tags_json, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - createDefId(projectId, `suite:${suite.id}`), - projectId, - suite.id, - suite.name, - JSON.stringify(suite.command), - suite.cwd, - JSON.stringify(suite.env), - suite.timeoutMs, - JSON.stringify(suite.tags), - now - ] - ); + for (const suite of effective.testSuites) { + db.run( + ` + insert into test_suites( + id, project_id, key, name, command_json, cwd, env_json, timeout_ms, tags_json, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + createDefId(projectId, `suite:${suite.id}`), + projectId, + suite.id, + suite.name, + JSON.stringify(suite.command), + suite.cwd, + JSON.stringify(suite.env), + suite.timeoutMs, + JSON.stringify(suite.tags), + now + ] + ); + } + db.run("COMMIT"); + } catch (error) { + try { + db.run("ROLLBACK"); + } catch { + // Preserve the original snapshot failure. + } + throw error; } }; diff --git a/apps/desktop/src/main/services/cto/ctoState.test.ts b/apps/desktop/src/main/services/cto/ctoState.test.ts index aa961aa3c..285b4e184 100644 --- a/apps/desktop/src/main/services/cto/ctoState.test.ts +++ b/apps/desktop/src/main/services/cto/ctoState.test.ts @@ -61,7 +61,6 @@ describe("ctoStateService", () => { expect(buildAdeGitignore()).toContain("!cto/identity.yaml"); expect(buildAdeGitignore()).not.toContain("cto/core-memory.json"); expect(buildAdeGitignore()).not.toContain("cto/CURRENT.md"); - expect(buildAdeGitignore()).not.toContain("cto/openclaw-history.json"); fixture.db.close(); }); diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index 96cf8a809..ab68838a7 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -5,7 +5,6 @@ import YAML from "yaml"; import type { CtoCoreMemory, CtoIdentity, - OpenclawContextPolicy, CtoOnboardingState, CtoSessionLogEntry, CtoSubordinateActivityEntry, @@ -548,7 +547,6 @@ function normalizeIdentity(input: unknown): CtoIdentity | null { source.communicationStyle && typeof source.communicationStyle === "object" ? (source.communicationStyle as Record<string, unknown>) : {}; - const openclawContextPolicy = normalizeOpenclawContextPolicy(source.openclawContextPolicy); const onboardingState = normalizeOnboardingState(source.onboardingState); const personality = normalizePersonalityPreset(source.personality); const customPersonality = @@ -616,24 +614,11 @@ function normalizeIdentity(input: unknown): CtoIdentity | null { ? Math.max(1, Math.floor(Number(memoryPolicyRaw.temporalDecayHalfLifeDays))) : 30, }, - ...(openclawContextPolicy ? { openclawContextPolicy } : {}), ...(onboardingState ? { onboardingState } : {}), updatedAt, }; } -function normalizeOpenclawContextPolicy(value: unknown): OpenclawContextPolicy | undefined { - if (!value || typeof value !== "object") return undefined; - const source = value as Record<string, unknown>; - const blockedCategories = Array.isArray(source.blockedCategories) - ? [...new Set(source.blockedCategories.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0))] - : []; - return { - shareMode: source.shareMode === "full" ? "full" : "filtered", - blockedCategories, - }; -} - function squishText(value: string): string { return String(value ?? "").replace(/\s+/g, " ").trim(); } @@ -752,10 +737,6 @@ function makeDefaultIdentity(): CtoIdentity { preCompactionFlush: true, temporalDecayHalfLifeDays: 30, }, - openclawContextPolicy: { - shareMode: "filtered", - blockedCategories: ["secret", "token", "system_prompt"], - }, updatedAt: timestamp, }; } @@ -1367,7 +1348,6 @@ export function createCtoStateService(args: CtoStateServiceArgs) { ...patch, modelPreferences: { ...current.modelPreferences, ...(patch.modelPreferences ?? {}) }, memoryPolicy: { ...current.memoryPolicy, ...(patch.memoryPolicy ?? {}) }, - openclawContextPolicy: normalizeOpenclawContextPolicy(patch.openclawContextPolicy) ?? current.openclawContextPolicy, version: current.version + 1, updatedAt: timestamp, }; diff --git a/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts b/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts index 76ce6d38f..d4d32eeab 100644 --- a/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts +++ b/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts @@ -1,11 +1,9 @@ -import YAML from "yaml"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { AgentIdentity, WorkerAgentRunStatus, WorkerAgentWakeupReason } from "../../../shared/types"; import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createOpenclawBridgeService } from "./openclawBridgeService"; import { createWorkerAdapterRuntimeService } from "./workerAdapterRuntimeService"; import { createWorkerAgentService } from "./workerAgentService"; import { createWorkerBudgetService } from "./workerBudgetService"; @@ -1096,36 +1094,6 @@ describe("workerAdapterRuntimeService (file group)", () => { }); }); - it("sends openclaw-webhook request with resolved env header", async () => { - process.env.OPENCLAW_WEBHOOK_TOKEN = "secret-token"; - const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { - return { - ok: true, - status: 200, - text: async () => JSON.stringify({ output: "webhook-ok" }), - } as any; - }); - const service = createWorkerAdapterRuntimeService({ fetchImpl: fetchMock as any }); - const result = await service.run({ - agent: makeAgent({ - adapterType: "openclaw-webhook", - adapterConfig: { - url: "https://example.com/hook", - headers: { - Authorization: "Bearer ${env:OPENCLAW_WEBHOOK_TOKEN}", - }, - }, - }), - prompt: "run remote", - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect((init.headers as Record<string, string>).Authorization).toBe("Bearer secret-token"); - expect(result.ok).toBe(true); - expect(result.outputText).toBe("webhook-ok"); - }); - it("runs process adapter and blocks unsafe commands", async () => { const { spawn } = createSpawnStub("process-output"); const service = createWorkerAdapterRuntimeService({ spawnImpl: spawn as any }); @@ -1310,12 +1278,10 @@ describe("workerAgentService (file group)", () => { fixture.service.saveAgent({ name: "Remote", role: "researcher", - adapterType: "openclaw-webhook", + adapterType: "process", adapterConfig: { - url: "https://example.com/hook", - headers: { - Authorization: "Bearer sk-secret-value", - }, + command: "echo", + env: { API_TOKEN: "Bearer sk-secret-value" }, }, }) ).toThrow(/raw secret-like value/i); @@ -1323,12 +1289,10 @@ describe("workerAgentService (file group)", () => { const ok = fixture.service.saveAgent({ name: "Remote 2", role: "researcher", - adapterType: "openclaw-webhook", + adapterType: "process", adapterConfig: { - url: "https://example.com/hook", - headers: { - Authorization: "Bearer ${env:OPENCLAW_WEBHOOK_TOKEN}", - }, + command: "echo", + env: { API_TOKEN: "${env:PROCESS_ADAPTER_TOKEN}" }, }, }); expect(ok.id).toBeTruthy(); @@ -1659,10 +1623,10 @@ describe("workerRevisionService (file group)", () => { { name: "Redacted Worker", role: "researcher", - adapterType: "openclaw-webhook", + adapterType: "process", adapterConfig: { - url: "https://example.com", - headers: { Authorization: "${env:OPENCLAW_WEBHOOK_TOKEN}" }, + command: "echo", + env: { API_TOKEN: "${env:PROCESS_ADAPTER_TOKEN}" }, }, }, "tester" @@ -1681,7 +1645,7 @@ describe("workerRevisionService (file group)", () => { created.id, JSON.stringify({ ...created, name: "__REDACTED__" }), JSON.stringify(created), - JSON.stringify(["adapterConfig.headers.Authorization"]), + JSON.stringify(["adapterConfig.env.API_TOKEN"]), 1, "tester", new Date().toISOString(), @@ -1862,477 +1826,3 @@ describe("workerTaskSessionService (file group)", () => { }); }); - -describe("openclawBridgeService (file group)", () => { - - function writeOpenclawConfig(adeDir: string, patch: Record<string, unknown>): void { - fs.mkdirSync(adeDir, { recursive: true }); - fs.writeFileSync( - path.join(adeDir, "local.secret.yaml"), - YAML.stringify({ - openclaw: { - bridgePort: 0, - hooksToken: "test-hook-token", - ...patch, - }, - }), - "utf8", - ); - } - - describe("openclawBridgeService", () => { - const services: Array<ReturnType<typeof createOpenclawBridgeService>> = []; - - afterEach(async () => { - while (services.length) { - const service = services.pop(); - await service?.stop(); - } - }); - - it("handles synchronous query replies end to end", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-query-")); - writeOpenclawConfig(adeDir, { enabled: false }); - - let service!: ReturnType<typeof createOpenclawBridgeService>; - const sentMessages: Array<{ sessionId: string; text: string; displayText?: string }> = []; - const agentChatService = { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage: vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { - sentMessages.push({ sessionId, text, displayText }); - const turnId = "turn-1"; - queueMicrotask(() => { - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "user_message", text: displayText ?? text, turnId }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "text", text: "CTO reply from ADE", turnId }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "done", turnId, status: "completed" }, - }); - }); - }), - } as any; - - service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [ - { id: "lane-2", laneType: "feature" }, - { id: "lane-1", laneType: "primary" }, - ]), - } as any, - agentChatService, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: ["secret"] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const state = service.getState(); - const res = await fetch(state.endpoints.queryUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify({ - requestId: "req-query-1", - agentId: "discord-cto", - sessionKey: "discord:thread:123", - message: "What changed?", - context: { channel: "discord", secret: "redact-me" }, - }), - }); - - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.reply).toBe("CTO reply from ADE"); - expect(agentChatService.ensureIdentitySession).toHaveBeenCalledWith( - expect.objectContaining({ identityKey: "cto", laneId: "lane-1" }), - ); - expect(sentMessages[0]?.text).toContain("Treat this routing context as turn-scoped bridge metadata only."); - expect(sentMessages[0]?.text).toContain("What changed?"); - }); - - it("routes worker targets by slug and falls back unknown targets to CTO", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-target-")); - writeOpenclawConfig(adeDir, { enabled: false, allowEmployeeTargets: true }); - - let service!: ReturnType<typeof createOpenclawBridgeService>; - const ensureIdentitySession = vi.fn(async ({ identityKey }: { identityKey: string }) => ({ - id: identityKey === "cto" ? "session-cto" : "session-worker", - laneId: "lane-1", - })); - const sendMessage = vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { - const turnId = sessionId === "session-worker" ? "turn-worker" : "turn-cto"; - queueMicrotask(() => { - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "user_message", text: displayText ?? text, turnId }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "text", text: sessionId === "session-worker" ? "worker reply" : "cto fallback reply", turnId }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "done", turnId, status: "completed" }, - }); - }); - }); - - service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession, - sendMessage, - } as any, - workerAgentService: { - listAgents: vi.fn(() => [ - { id: "worker-1", slug: "frontend", status: "active", deletedAt: null }, - ]), - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: [] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const state = service.getState(); - const good = await fetch(state.endpoints.queryUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify({ - requestId: "req-good-target", - message: "Ping frontend worker", - targetHint: "agent:frontend", - }), - }); - expect(good.status).toBe(200); - await expect(good.json()).resolves.toEqual(expect.objectContaining({ - accepted: true, - async: true, - status: "working", - routeTarget: "agent:frontend", - })); - expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ identityKey: "agent:worker-1" })); - - const fallback = await fetch(state.endpoints.queryUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify({ - requestId: "req-bad-target", - message: "Ping unknown worker", - targetHint: "agent:ghost", - }), - }); - expect(fallback.status).toBe(200); - const latestInbound = service.listMessages(4).find((entry) => entry.requestId === "req-bad-target" && entry.direction === "inbound"); - expect(latestInbound?.resolvedTarget).toBe("cto"); - expect(latestInbound?.metadata).toEqual(expect.objectContaining({ - fallbackReason: expect.stringContaining("ghost"), - })); - }); - - it("deduplicates async hook requests by idempotency key", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-hook-")); - writeOpenclawConfig(adeDir, { enabled: false }); - - let service!: ReturnType<typeof createOpenclawBridgeService>; - const sendMessage = vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { - queueMicrotask(() => { - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "user_message", text: displayText ?? text, turnId: "turn-hook" }, - }); - }); - }); - - service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage, - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: [] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const state = service.getState(); - const request = { - requestId: "dup-key-1", - message: "Fire and forget", - }; - const first = await fetch(state.endpoints.hookUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify(request), - }); - const second = await fetch(state.endpoints.hookUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify(request), - }); - - expect(first.status).toBe(202); - expect(second.status).toBe(202); - expect(sendMessage).toHaveBeenCalledTimes(1); - expect(await second.json()).toEqual(expect.objectContaining({ duplicate: true })); - }); - - it("queues outbound messages when the operator socket is unavailable", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-outbox-")); - writeOpenclawConfig(adeDir, { enabled: false }); - - const service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage: vi.fn(async () => {}), - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: ["secret"] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const record = await service.sendMessage({ - requestId: "queued-message-1", - agentId: "discord-cto", - message: "Mission finished", - context: { secret: "hide-me", lane: "lane-1" }, - }); - - expect(record.status).toBe("queued"); - expect(service.getState().status.queuedMessages).toBe(1); - expect(record.context).toEqual({ lane: "lane-1" }); - }); - - it("recursively redacts inbound bridge context before prompting and persistence", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-redact-")); - writeOpenclawConfig(adeDir, { enabled: false }); - - let service!: ReturnType<typeof createOpenclawBridgeService>; - const sentMessages: Array<{ text: string }> = []; - service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage: vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { - sentMessages.push({ text }); - queueMicrotask(() => { - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "user_message", text: displayText ?? text, turnId: "turn-1" }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "text", text: "redacted", turnId: "turn-1" }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "done", turnId: "turn-1", status: "completed" }, - }); - }); - }), - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: ["secret"] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const res = await fetch(service.getState().endpoints.queryUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify({ - requestId: "req-redact-1", - message: "Review this", - context: { - nested: { - apiKey: "test-api-key-placeholder", - note: "safe", - }, - secret: "remove-me", - }, - }), - }); - - expect(res.status).toBe(200); - expect(sentMessages[0]?.text).toContain("\"apiKey\": \"[REDACTED]\""); - expect(sentMessages[0]?.text).toContain("\"note\": \"safe\""); - expect(sentMessages[0]?.text).not.toContain("remove-me"); - const inbound = service.listMessages(10).find((entry) => entry.requestId === "req-redact-1" && entry.direction === "inbound"); - expect(inbound?.context).toEqual({ - nested: { - apiKey: "[REDACTED]", - note: "safe", - }, - }); - }); - - it("keeps shareMode full while still redacting sensitive values", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-full-share-")); - writeOpenclawConfig(adeDir, { enabled: false }); - - const service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage: vi.fn(async () => {}), - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "full", blockedCategories: ["secret"] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const record = await service.sendMessage({ - requestId: "queued-message-2", - agentId: "discord-cto", - message: "Mission finished", - context: { - secret: "Bearer very-secret-token-value", - lane: "lane-1", - }, - }); - - expect(record.context).toEqual({ - secret: "[REDACTED]", - lane: "lane-1", - }); - }); - - it("migrates legacy runtime files into cache and removes repo-visible copies", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-migrate-")); - writeOpenclawConfig(adeDir, { enabled: false }); - fs.mkdirSync(path.join(adeDir, "cto"), { recursive: true }); - fs.writeFileSync( - path.join(adeDir, "cto", "openclaw-history.json"), - JSON.stringify([{ - id: "legacy-1", - requestId: "legacy-request", - direction: "inbound", - mode: "hook", - status: "received", - body: "Legacy body", - summary: "Legacy summary", - context: { - apiKey: "test-api-key-placeholder", - }, - createdAt: new Date().toISOString(), - }], null, 2), - "utf8", - ); - - const service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage: vi.fn(async () => {}), - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: [] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - expect(fs.existsSync(path.join(adeDir, "cto", "openclaw-history.json"))).toBe(false); - expect(fs.existsSync(path.join(adeDir, "cache", "openclaw", "openclaw-history.json"))).toBe(true); - expect(service.listMessages(10)[0]?.context).toEqual({ apiKey: "[REDACTED]" }); - }); - }); - -}); diff --git a/apps/desktop/src/main/services/cto/linearClient.ts b/apps/desktop/src/main/services/cto/linearClient.ts index 2f4814994..fa5901dbb 100644 --- a/apps/desktop/src/main/services/cto/linearClient.ts +++ b/apps/desktop/src/main/services/cto/linearClient.ts @@ -92,6 +92,11 @@ function toNormalizedIssue(node: Record<string, unknown>): NormalizedLinearIssue const assignee = isRecord(node.assignee) ? node.assignee : null; const owner = isRecord(node.creator) ? node.creator : null; const priority = Number(node.priority ?? 0); + const metadataRecord = isRecord(node.metadata) ? node.metadata : null; + const metadataTagsRaw = metadataRecord && Array.isArray(metadataRecord.tags) + ? (metadataRecord.tags as unknown[]) + : []; + const metadataTags = metadataTagsRaw.filter((tag): tag is string => typeof tag === "string"); return { id, @@ -111,7 +116,7 @@ function toNormalizedIssue(node: Record<string, unknown>): NormalizedLinearIssue priority: Number.isFinite(priority) ? priority : 0, priorityLabel: mapPriorityLabel(Number.isFinite(priority) ? priority : 0), labels, - metadataTags: [], + metadataTags, assigneeId: assignee ? asString(assignee.id) : null, assigneeName: assignee ? (asString(assignee.displayName) ?? asString(assignee.name)) : null, ownerId: owner ? asString(owner.id) : null, diff --git a/apps/desktop/src/main/services/cto/openclawBridgeService.ts b/apps/desktop/src/main/services/cto/openclawBridgeService.ts deleted file mode 100644 index af049b729..000000000 --- a/apps/desktop/src/main/services/cto/openclawBridgeService.ts +++ /dev/null @@ -1,1689 +0,0 @@ -import crypto, { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import http, { type IncomingMessage, type ServerResponse } from "node:http"; -import path from "node:path"; -import YAML from "yaml"; -import { WebSocket, type RawData } from "ws"; -import type { Logger } from "../logging/logger"; -import type { createAgentChatService } from "../chat/agentChatService"; -import type { createLaneService } from "../lanes/laneService"; -import type { createCtoStateService } from "./ctoStateService"; -import type { createWorkerAgentService } from "./workerAgentService"; -import type { createMissionService } from "../missions/missionService"; -import type { - AgentChatEventEnvelope, - MissionsEventPayload, - OpenclawBridgeConfig, - OpenclawBridgeState, - OpenclawBridgeStatus, - OpenclawContextPolicy, - OpenclawInboundEnvelope, - OpenclawMessageRecord, - OpenclawNotificationRoute, - OpenclawNotificationType, - OpenclawOutboundEnvelope, - OpenclawTargetHint, - TestEvent, - OrchestratorRuntimeEvent, -} from "../../../shared/types"; -import { - clipText, - getErrorMessage, - isRecord, - nowIso, - parseIsoToEpoch, - sanitizeStructuredData, - toBase64Url, - writeTextAtomic, -} from "../shared/utils"; - -const DEFAULT_BRIDGE_PORT = 18791; -const HTTP_BODY_LIMIT_BYTES = 1_000_000; -const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000; -const ROUTE_TTL_MS = 60 * 60 * 1000; -const HISTORY_CAP = 400; -const MAX_OUTBOX_ATTEMPTS = 10; -const MAX_RECONNECT_BACKOFF_MS = 30_000; -const CONNECT_CHALLENGE_TIMEOUT_MS = 2_000; -const TICK_WATCH_FLOOR_MS = 1_000; -const DEFAULT_TICK_INTERVAL_MS = 30_000; -const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); -const BRIDGE_CONTEXT_MAX_STRING_LENGTH = 4_000; -const BRIDGE_CONTEXT_MAX_OBJECT_ENTRIES = 50; -const BRIDGE_CONTEXT_MAX_ARRAY_ENTRIES = 50; -const HISTORY_BODY_MAX_LENGTH = 1_200; -const HISTORY_ERROR_MAX_LENGTH = 400; -const HISTORY_SUMMARY_MAX_LENGTH = 160; -const OPENCLAW_HISTORY_FILE = "openclaw-history.json"; -const OPENCLAW_OUTBOX_FILE = "openclaw-outbox.json"; -const OPENCLAW_IDEMPOTENCY_FILE = "openclaw-idempotency.json"; -const OPENCLAW_ROUTES_FILE = "openclaw-routes.json"; - -type DeviceIdentity = { - deviceId: string; - publicKeyPem: string; - privateKeyPem: string; -}; - -type OpenclawRequestFrame = { - type: "req"; - id: string; - method: string; - params: Record<string, unknown>; -}; - -type OpenclawResponseFrame = { - type: "res"; - id: string; - ok: boolean; - payload?: Record<string, unknown>; - error?: { message?: string }; -}; - -type OpenclawEventFrame = { - type: "evt"; - event: string; - seq?: number; - payload?: Record<string, unknown>; -}; - -type PersistedIdempotencyState = Record<string, number>; - -type PersistedRouteCacheEntry = { - agentId?: string | null; - sessionKey?: string | null; - channel?: string | null; - replyChannel?: string | null; - accountId?: string | null; - replyAccountId?: string | null; - threadId?: string | null; - updatedAt: string; - expiresAt: number; -}; - -type PersistedRouteCache = { - byAgentId: Record<string, PersistedRouteCacheEntry>; -}; - -type OutboxEntry = { - id: string; - envelope: OpenclawOutboundEnvelope; - queuedAt: string; - attempts: number; - lastAttemptAt?: string | null; - lastError?: string | null; -}; - -type PendingWsRequest = { - resolve: (value: Record<string, unknown>) => void; - reject: (error: Error) => void; - expectFinal: boolean; -}; - -type ConversationRoute = PersistedRouteCacheEntry & { - sessionId?: string | null; - targetHint?: OpenclawTargetHint | null; -}; - -type PendingBridgeTurn = { - requestId: string; - mode: "hook" | "query" | "ambient"; - route: ConversationRoute; - sessionId: string; - displayText: string; - createdAt: string; - turnId?: string; - chunks: string[]; - outputSent: boolean; - resolve?: (value: { reply: string; sessionId: string; route: ConversationRoute }) => void; - reject?: (error: Error) => void; - timeoutHandle?: ReturnType<typeof setTimeout>; -}; - -type OpenclawBridgeServiceArgs = { - projectRoot: string; - adeDir: string; - laneService: ReturnType<typeof createLaneService>; - agentChatService: ReturnType<typeof createAgentChatService>; - ctoStateService?: ReturnType<typeof createCtoStateService> | null; - workerAgentService?: ReturnType<typeof createWorkerAgentService> | null; - missionService?: ReturnType<typeof createMissionService> | null; - logger?: Logger | null; - appVersion?: string; - onStatusChange?: (status: OpenclawBridgeStatus) => void; -}; - -function trimToNull(value: unknown): string | null { - const trimmed = typeof value === "string" ? value.trim() : ""; - return trimmed.length ? trimmed : null; -} - -function summarizeMessage(text: string, maxLength = 120): string { - const normalized = text.replace(/\s+/g, " ").trim(); - if (normalized.length <= maxLength) return normalized; - return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`; -} - -function sanitizeContext( - context: unknown, - options?: { blockedTopLevelKeys?: Iterable<string> }, -): Record<string, unknown> | null { - return sanitizeStructuredData(context, { - blockedTopLevelKeys: options?.blockedTopLevelKeys, - maxStringLength: BRIDGE_CONTEXT_MAX_STRING_LENGTH, - maxObjectEntries: BRIDGE_CONTEXT_MAX_OBJECT_ENTRIES, - maxArrayEntries: BRIDGE_CONTEXT_MAX_ARRAY_ENTRIES, - }); -} - -function buildDeviceAuthPayloadV3(params: { - deviceId: string; - clientId: string; - clientMode: string; - role: string; - scopes: string[]; - signedAtMs: number; - token: string | null; - nonce: string; - platform: string; - deviceFamily: string; -}): string { - return [ - "v3", - params.deviceId, - params.clientId, - params.clientMode, - params.role, - params.scopes.join(","), - String(params.signedAtMs), - params.token ?? "", - params.nonce, - params.platform, - params.deviceFamily, - ].join("|"); -} - -function derivePublicKeyRaw(publicKeyPem: string): Buffer { - const spki = crypto.createPublicKey(publicKeyPem).export({ - type: "spki", - format: "der", - }); - if (spki.length === ED25519_SPKI_PREFIX.length + 32 - && spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) { - return spki.subarray(ED25519_SPKI_PREFIX.length); - } - return spki; -} - -function fingerprintPublicKey(publicKeyPem: string): string { - return crypto.createHash("sha256").update(derivePublicKeyRaw(publicKeyPem)).digest("hex"); -} - -function generateDeviceIdentity(): DeviceIdentity { - const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); - const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); - const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); - return { - deviceId: fingerprintPublicKey(publicKeyPem), - publicKeyPem, - privateKeyPem, - }; -} - -function loadOrCreateDeviceIdentity(filePath: string): DeviceIdentity { - try { - if (fs.existsSync(filePath)) { - const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Record<string, unknown>; - if ( - parsed.version === 1 - && typeof parsed.deviceId === "string" - && typeof parsed.publicKeyPem === "string" - && typeof parsed.privateKeyPem === "string" - ) { - return { - deviceId: parsed.deviceId, - publicKeyPem: parsed.publicKeyPem, - privateKeyPem: parsed.privateKeyPem, - }; - } - } - } catch { - // fall through to regeneration - } - const identity = generateDeviceIdentity(); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify({ version: 1, ...identity }, null, 2)}\n`, { mode: 0o600 }); - try { - fs.chmodSync(filePath, 0o600); - } catch { - // best effort - } - return identity; -} - -function signDevicePayload(privateKeyPem: string, payload: string): string { - return toBase64Url(crypto.sign(null, Buffer.from(payload, "utf8"), crypto.createPrivateKey(privateKeyPem))); -} - -function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string { - return toBase64Url(derivePublicKeyRaw(publicKeyPem)); -} - -function normalizeNotificationRoute(value: unknown): OpenclawNotificationRoute | null { - if (!isRecord(value)) return null; - const notificationType = trimToNull(value.notificationType); - if (notificationType !== "mission_complete" && notificationType !== "ci_broken" && notificationType !== "blocked_run") { - return null; - } - return { - notificationType, - agentId: trimToNull(value.agentId), - sessionKey: trimToNull(value.sessionKey), - enabled: value.enabled !== false, - }; -} - -function normalizeTargetHint(value: unknown, fallback: OpenclawTargetHint = "cto"): OpenclawTargetHint { - const trimmed = trimToNull(value); - if (trimmed === "cto") return "cto"; - if (trimmed?.startsWith("agent:")) return trimmed as OpenclawTargetHint; - return fallback; -} - -function normalizeConfig(value: unknown): OpenclawBridgeConfig { - const source = isRecord(value) ? value : {}; - const allowedAgentIds = Array.isArray(source.allowedAgentIds) - ? [...new Set(source.allowedAgentIds.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0))] - : []; - const notificationRoutes = Array.isArray(source.notificationRoutes) - ? source.notificationRoutes.map(normalizeNotificationRoute).filter((entry): entry is OpenclawNotificationRoute => entry != null) - : []; - const bridgePort = Number(source.bridgePort); - return { - enabled: source.enabled === true, - bridgePort: Number.isFinite(bridgePort) ? Math.max(0, Math.floor(bridgePort)) : DEFAULT_BRIDGE_PORT, - gatewayUrl: trimToNull(source.gatewayUrl), - gatewayToken: trimToNull(source.gatewayToken), - deviceToken: trimToNull(source.deviceToken), - hooksToken: trimToNull(source.hooksToken), - allowedAgentIds, - defaultTarget: normalizeTargetHint(source.defaultTarget, "cto"), - allowEmployeeTargets: source.allowEmployeeTargets !== false, - notificationRoutes, - }; -} - -function normalizeContextPolicy(value: OpenclawContextPolicy | undefined | null): OpenclawContextPolicy { - return { - shareMode: value?.shareMode === "full" ? "full" : "filtered", - blockedCategories: Array.isArray(value?.blockedCategories) - ? [...new Set(value.blockedCategories.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0))] - : [], - }; -} - -async function readBody(req: IncomingMessage): Promise<string> { - return await new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - let received = 0; - req.on("data", (chunk) => { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - received += buffer.length; - if (received > HTTP_BODY_LIMIT_BYTES) { - reject(new Error("Request body exceeded the 1MB limit.")); - req.destroy(); - return; - } - chunks.push(buffer); - }); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); - req.on("error", reject); - }); -} - -function jsonResponse(res: ServerResponse, statusCode: number, payload: Record<string, unknown>): void { - const body = JSON.stringify(payload); - res.writeHead(statusCode, { - "content-type": "application/json; charset=utf-8", - "content-length": Buffer.byteLength(body), - "cache-control": "no-store", - }); - res.end(body); -} - -function parseJsonBody(raw: string): unknown { - try { - return JSON.parse(raw); - } catch (error) { - throw new Error(`Invalid JSON body: ${getErrorMessage(error)}`); - } -} - -function isResponseFrame(value: unknown): value is OpenclawResponseFrame { - return isRecord(value) && value.type === "res" && typeof value.id === "string"; -} - -function isEventFrame(value: unknown): value is OpenclawEventFrame { - return isRecord(value) && value.type === "evt" && typeof value.event === "string"; -} - -function getRequestToken(req: IncomingMessage): string | null { - const authHeader = req.headers.authorization; - if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) { - return authHeader.slice("bearer ".length).trim(); - } - const header = req.headers["x-openclaw-hook-token"]; - if (typeof header === "string") return header.trim(); - if (Array.isArray(header) && header[0]) return header[0].trim(); - return null; -} - -function createInitialStatus(config: OpenclawBridgeConfig, deviceId: string | null): OpenclawBridgeStatus { - return { - state: config.enabled ? "disconnected" : "disabled", - enabled: config.enabled, - fallbackMode: !config.gatewayUrl, - httpListening: false, - bridgePort: config.bridgePort, - gatewayUrl: config.gatewayUrl, - deviceId, - paired: Boolean(config.deviceToken), - deviceTokenStored: Boolean(config.deviceToken), - lastConnectedAt: null, - lastEventAt: null, - lastMessageAt: null, - lastError: null, - queuedMessages: 0, - }; -} - -export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { - const logger = args.logger ?? null; - const secretPath = path.join(args.adeDir, "local.secret.yaml"); - const ctoDir = path.join(args.adeDir, "cto"); - const cacheDir = path.join(args.adeDir, "cache", "openclaw"); - const devicePath = path.join(ctoDir, "openclaw-device.json"); - const historyPath = path.join(cacheDir, OPENCLAW_HISTORY_FILE); - const outboxPath = path.join(cacheDir, OPENCLAW_OUTBOX_FILE); - const idempotencyPath = path.join(cacheDir, OPENCLAW_IDEMPOTENCY_FILE); - const routeCachePath = path.join(cacheDir, OPENCLAW_ROUTES_FILE); - fs.mkdirSync(ctoDir, { recursive: true }); - fs.mkdirSync(cacheDir, { recursive: true }); - - const migrateLegacyRuntimeFile = (legacyFileName: string, nextPath: string): void => { - const legacyPath = path.join(ctoDir, legacyFileName); - if (!fs.existsSync(legacyPath)) return; - let copied = false; - try { - if (!fs.existsSync(nextPath)) { - writeTextAtomic(nextPath, fs.readFileSync(legacyPath, "utf8")); - copied = true; - } - } catch (error) { - logger?.warn("openclaw.runtime_state_migration_failed", { - legacyPath, - nextPath, - error: getErrorMessage(error), - }); - return; - } - if (!copied) return; - try { - fs.unlinkSync(legacyPath); - } catch (error) { - logger?.warn("openclaw.runtime_state_cleanup_failed", { - legacyPath, - error: getErrorMessage(error), - }); - } - }; - - migrateLegacyRuntimeFile(OPENCLAW_HISTORY_FILE, historyPath); - migrateLegacyRuntimeFile(OPENCLAW_OUTBOX_FILE, outboxPath); - migrateLegacyRuntimeFile(OPENCLAW_IDEMPOTENCY_FILE, idempotencyPath); - migrateLegacyRuntimeFile(OPENCLAW_ROUTES_FILE, routeCachePath); - - const deviceIdentity = loadOrCreateDeviceIdentity(devicePath); - let config = readConfig(); - let history: OpenclawMessageRecord[] = readJsonFile<OpenclawMessageRecord[]>(historyPath, []).map((record): OpenclawMessageRecord => ({ - ...record, - body: clipText(String(record.body ?? ""), HISTORY_BODY_MAX_LENGTH), - summary: summarizeMessage(String(record.summary ?? record.body ?? ""), HISTORY_SUMMARY_MAX_LENGTH), - ...(sanitizeContext(record.context) ? { context: sanitizeContext(record.context) } : { context: null }), - ...(sanitizeContext(record.metadata) ? { metadata: sanitizeContext(record.metadata) } : { metadata: null }), - ...(typeof record.error === "string" ? { error: clipText(record.error, HISTORY_ERROR_MAX_LENGTH) } : {}), - })); - let outbox: OutboxEntry[] = readJsonFile<OutboxEntry[]>(outboxPath, []).map((entry): OutboxEntry => ({ - ...entry, - envelope: { - ...entry.envelope, - context: sanitizeContext(entry.envelope.context) ?? null, - }, - })); - let idempotencyState = pruneIdempotencyState(readJsonFile<PersistedIdempotencyState>(idempotencyPath, {})); - let routeCache = readJsonFile<PersistedRouteCache>(routeCachePath, { byAgentId: {} }); - - let httpServer: http.Server | null = null; - let currentHttpPort = Number.isFinite(config.bridgePort) ? config.bridgePort : DEFAULT_BRIDGE_PORT; - let ws: WebSocket | null = null; - let wsConnectNonce: string | null = null; - let wsConnectTimer: ReturnType<typeof setTimeout> | null = null; - let reconnectTimer: ReturnType<typeof setTimeout> | null = null; - let reconnectAttempt = 0; - let tickTimer: ReturnType<typeof setInterval> | null = null; - let lastTickAt: number | null = null; - let requestedStop = false; - const pendingWsRequests = new Map<string, PendingWsRequest>(); - const pendingTurnsBySession = new Map<string, PendingBridgeTurn[]>(); - const turnBindings = new Map<string, PendingBridgeTurn>(); - const activeSessionRoutes = new Map<string, ConversationRoute>(); - let status = createInitialStatus(config, deviceIdentity.deviceId); - - function readJsonFile<T>(filePath: string, fallback: T): T { - try { - if (!fs.existsSync(filePath)) return fallback; - return JSON.parse(fs.readFileSync(filePath, "utf8")) as T; - } catch { - return fallback; - } - } - - function writeJsonFile(filePath: string, payload: unknown): void { - writeTextAtomic(filePath, `${JSON.stringify(payload, null, 2)}\n`); - } - - function readSecretDocument(): Record<string, unknown> { - try { - if (!fs.existsSync(secretPath)) return {}; - const parsed = YAML.parse(fs.readFileSync(secretPath, "utf8")); - return isRecord(parsed) ? parsed : {}; - } catch { - return {}; - } - } - - function writeSecretDocument(doc: Record<string, unknown>): void { - writeTextAtomic(secretPath, YAML.stringify(doc, { indent: 2 })); - } - - function readConfig(): OpenclawBridgeConfig { - const doc = readSecretDocument(); - return normalizeConfig(doc.openclaw); - } - - function persistConfig(next: OpenclawBridgeConfig): void { - const doc = readSecretDocument(); - doc.openclaw = { - enabled: next.enabled, - bridgePort: next.bridgePort, - gatewayUrl: next.gatewayUrl ?? null, - gatewayToken: next.gatewayToken ?? null, - deviceToken: next.deviceToken ?? null, - hooksToken: next.hooksToken ?? null, - allowedAgentIds: next.allowedAgentIds, - defaultTarget: next.defaultTarget, - allowEmployeeTargets: next.allowEmployeeTargets, - notificationRoutes: next.notificationRoutes, - }; - writeSecretDocument(doc); - } - - function pruneIdempotencyState(raw: PersistedIdempotencyState): PersistedIdempotencyState { - const now = Date.now(); - return Object.fromEntries( - Object.entries(raw).filter(([, expiresAt]) => Number.isFinite(expiresAt) && expiresAt > now), - ); - } - - function persistRuntimeState(): void { - writeJsonFile(historyPath, history.slice(-HISTORY_CAP)); - writeJsonFile(outboxPath, outbox); - writeJsonFile(idempotencyPath, idempotencyState); - writeJsonFile(routeCachePath, routeCache); - } - - function setStatus(patch: Partial<OpenclawBridgeStatus>): void { - status = { - ...status, - ...patch, - enabled: config.enabled, - fallbackMode: !config.gatewayUrl, - bridgePort: currentHttpPort, - gatewayUrl: config.gatewayUrl, - paired: Boolean(config.deviceToken), - deviceTokenStored: Boolean(config.deviceToken), - queuedMessages: outbox.length, - deviceId: deviceIdentity.deviceId, - }; - args.onStatusChange?.(status); - } - - function endpoints() { - const base = status.httpListening ? `http://127.0.0.1:${currentHttpPort}` : null; - return { - healthUrl: base ? `${base}/openclaw/health` : null, - hookUrl: base ? `${base}/openclaw/hook` : null, - queryUrl: base ? `${base}/openclaw/query` : null, - }; - } - - function readBridgeState(): OpenclawBridgeState { - return { - config, - status, - endpoints: endpoints(), - }; - } - - function saveHistoryRecord(record: OpenclawMessageRecord): OpenclawMessageRecord { - const sanitizedRecord: OpenclawMessageRecord = { - ...record, - body: clipText(record.body, HISTORY_BODY_MAX_LENGTH), - summary: summarizeMessage(record.summary || record.body, HISTORY_SUMMARY_MAX_LENGTH), - context: sanitizeContext(record.context), - ...(record.error ? { error: clipText(record.error, HISTORY_ERROR_MAX_LENGTH) } : {}), - ...(record.metadata ? { metadata: sanitizeContext(record.metadata) } : {}), - }; - history = [...history.filter((entry) => entry.id !== sanitizedRecord.id), sanitizedRecord] - .sort((a, b) => parseIsoToEpoch(a.createdAt) - parseIsoToEpoch(b.createdAt)) - .slice(-HISTORY_CAP); - persistRuntimeState(); - setStatus({ lastMessageAt: sanitizedRecord.createdAt }); - return sanitizedRecord; - } - - function getHistoryMessages(limit = 40): OpenclawMessageRecord[] { - return [...history] - .sort((a, b) => parseIsoToEpoch(b.createdAt) - parseIsoToEpoch(a.createdAt)) - .slice(0, Math.max(1, Math.min(200, Math.floor(limit)))); - } - - function rememberRoute(route: ConversationRoute): void { - const expiresAt = Date.now() + ROUTE_TTL_MS; - const stored: PersistedRouteCacheEntry = { - agentId: route.agentId ?? null, - sessionKey: route.sessionKey ?? null, - channel: route.channel ?? null, - replyChannel: route.replyChannel ?? null, - accountId: route.accountId ?? null, - replyAccountId: route.replyAccountId ?? null, - threadId: route.threadId ?? null, - updatedAt: nowIso(), - expiresAt, - }; - if (route.agentId) { - routeCache.byAgentId[route.agentId] = stored; - persistRuntimeState(); - } - } - - function pruneRouteCache(): void { - const now = Date.now(); - for (const [agentId, entry] of Object.entries(routeCache.byAgentId)) { - if ((entry?.expiresAt ?? 0) <= now) { - delete routeCache.byAgentId[agentId]; - } - } - } - - function markIdempotency(key: string): void { - idempotencyState[key] = Date.now() + IDEMPOTENCY_TTL_MS; - idempotencyState = pruneIdempotencyState(idempotencyState); - persistRuntimeState(); - } - - function hasSeenIdempotency(key: string): boolean { - idempotencyState = pruneIdempotencyState(idempotencyState); - return Number.isFinite(idempotencyState[key]); - } - - function buildReplyText(turn: PendingBridgeTurn, fallbackMessage?: string): string { - const text = turn.chunks.join("").trim(); - if (text.length) return text; - return fallbackMessage?.trim() || "No reply was generated."; - } - - async function resolvePrimaryLaneId(): Promise<string> { - await args.laneService.ensurePrimaryLane().catch(() => {}); - const lanes = await args.laneService.list({ includeArchived: false, includeStatus: false }); - const preferred = lanes.find((entry) => entry.laneType === "primary") ?? lanes[0] ?? null; - if (!preferred?.id) { - throw new Error("No lane is available to host the OpenClaw bridge session."); - } - return preferred.id; - } - - function resolveTarget(targetHint?: OpenclawTargetHint | null): { identityKey: "cto" | `agent:${string}`; resolvedTarget: OpenclawTargetHint; fallbackReason?: string } { - const requestedTarget = normalizeTargetHint(targetHint, config.defaultTarget); - if (requestedTarget === "cto") { - return { identityKey: "cto", resolvedTarget: "cto" }; - } - if (!config.allowEmployeeTargets) { - return { - identityKey: "cto", - resolvedTarget: "cto", - fallbackReason: `Employee targets are disabled; routed ${requestedTarget} to CTO instead.`, - }; - } - const slug = requestedTarget.slice("agent:".length).trim().toLowerCase(); - const workers = args.workerAgentService?.listAgents({ includeDeleted: false }) ?? []; - const match = workers.find((agent) => agent.slug.toLowerCase() === slug && agent.deletedAt == null && agent.status !== "paused"); - if (!match) { - return { - identityKey: "cto", - resolvedTarget: "cto", - fallbackReason: `Unknown or unavailable worker '${slug}'; routed to CTO instead.`, - }; - } - return { - identityKey: `agent:${match.id}`, - resolvedTarget: `agent:${match.slug}`, - }; - } - - function applyContextPolicy(context: Record<string, unknown> | null | undefined): Record<string, unknown> | null { - const policy = normalizeContextPolicy(args.ctoStateService?.getIdentity().openclawContextPolicy); - return sanitizeContext(context, { - blockedTopLevelKeys: policy.shareMode === "full" ? [] : policy.blockedCategories, - }); - } - - function buildPromptFromInbound( - envelope: OpenclawInboundEnvelope, - requestId: string, - resolvedTarget: OpenclawTargetHint, - fallbackReason?: string, - ): string { - const sections = [ - "OpenClaw bridge request. Treat this routing context as turn-scoped bridge metadata only.", - "Do not automatically promote it to durable ADE memory.", - `Bridge request ID: ${requestId}`, - envelope.agentId ? `Origin agent ID: ${envelope.agentId}` : null, - envelope.sessionKey ? `Origin session key: ${envelope.sessionKey}` : null, - envelope.channel ? `Origin channel: ${envelope.channel}` : null, - envelope.threadId ? `Origin thread: ${envelope.threadId}` : null, - `Resolved target: ${resolvedTarget}`, - fallbackReason ? `Routing note: ${fallbackReason}` : null, - envelope.context ? `Structured bridge context:\n${JSON.stringify(envelope.context, null, 2)}` : null, - "", - "User message:", - envelope.message.trim(), - ].filter((entry): entry is string => Boolean(entry)); - return sections.join("\n"); - } - - async function ensureTargetSession(targetHint?: OpenclawTargetHint | null): Promise<{ - sessionId: string; - routeTarget: OpenclawTargetHint; - fallbackReason?: string; - }> { - const laneId = await resolvePrimaryLaneId(); - const resolved = resolveTarget(targetHint); - const session = await args.agentChatService.ensureIdentitySession({ - identityKey: resolved.identityKey, - laneId, - }); - return { - sessionId: session.id, - routeTarget: resolved.resolvedTarget, - fallbackReason: resolved.fallbackReason, - }; - } - - function queuePendingTurn(turn: PendingBridgeTurn): void { - const queue = pendingTurnsBySession.get(turn.sessionId) ?? []; - queue.push(turn); - pendingTurnsBySession.set(turn.sessionId, queue); - } - - function dequeuePendingTurn(turn: PendingBridgeTurn): void { - const queue = pendingTurnsBySession.get(turn.sessionId) ?? []; - const nextQueue = queue.filter((entry) => entry.requestId !== turn.requestId); - if (nextQueue.length) { - pendingTurnsBySession.set(turn.sessionId, nextQueue); - } else { - pendingTurnsBySession.delete(turn.sessionId); - } - if (turn.turnId) { - turnBindings.delete(turn.turnId); - } - if (turn.timeoutHandle) clearTimeout(turn.timeoutHandle); - } - - async function sendOutboundNow(envelope: OpenclawOutboundEnvelope): Promise<OpenclawMessageRecord> { - const requestId = trimToNull(envelope.requestId) ?? randomUUID(); - const filteredContext = applyContextPolicy(envelope.context); - const message = filteredContext - ? `${envelope.message.trim()}\n\n[filtered_context]\n${JSON.stringify(filteredContext, null, 2)}` - : envelope.message.trim(); - const historyBody = envelope.message.trim(); - const recordBase: OpenclawMessageRecord = { - id: randomUUID(), - requestId, - direction: "outbound", - mode: envelope.notificationType ? "notification" : "manual", - status: "queued", - agentId: envelope.agentId ?? null, - sessionKey: envelope.sessionKey ?? null, - body: historyBody, - summary: summarizeMessage(historyBody), - context: filteredContext, - createdAt: nowIso(), - metadata: envelope.notificationType ? { notificationType: envelope.notificationType } : undefined, - }; - - if (!ws || ws.readyState !== WebSocket.OPEN || !config.enabled || !config.gatewayUrl) { - const queuedEnvelope = { ...envelope, requestId, context: filteredContext }; - outbox = [ - ...outbox.filter((entry) => entry.envelope.requestId !== requestId), - { - id: randomUUID(), - envelope: queuedEnvelope, - queuedAt: nowIso(), - attempts: 0, - }, - ]; - persistRuntimeState(); - setStatus({ queuedMessages: outbox.length }); - return saveHistoryRecord(recordBase); - } - - try { - if (envelope.sessionKey) { - await requestGateway("chat.send", { - sessionKey: envelope.sessionKey, - message, - deliver: envelope.deliver !== false, - attachments: [], - timeoutMs: envelope.timeoutMs ?? 60_000, - idempotencyKey: requestId, - }); - } else if (envelope.agentId) { - await requestGateway("agent", { - message, - agentId: envelope.agentId, - channel: envelope.channel ?? undefined, - replyChannel: envelope.replyChannel ?? undefined, - accountId: envelope.accountId ?? undefined, - replyAccountId: envelope.replyAccountId ?? undefined, - threadId: envelope.threadId ?? undefined, - deliver: envelope.deliver !== false, - bestEffortDeliver: envelope.bestEffort === true, - inputProvenance: { kind: "tool", sourceTool: "ade:openclaw-bridge" }, - idempotencyKey: requestId, - label: envelope.label ?? "ade-bridge", - }); - } else { - throw new Error("OpenClaw outbound envelope requires either sessionKey or agentId."); - } - return saveHistoryRecord({ - ...recordBase, - status: "sent", - }); - } catch (error) { - const failure = saveHistoryRecord({ - ...recordBase, - status: "failed", - error: getErrorMessage(error), - }); - if (envelope.bestEffort !== true) { - outbox = [ - ...outbox.filter((entry) => entry.envelope.requestId !== requestId), - { - id: randomUUID(), - envelope: { ...envelope, requestId, context: filteredContext }, - queuedAt: nowIso(), - attempts: 1, - lastAttemptAt: nowIso(), - lastError: getErrorMessage(error), - }, - ]; - persistRuntimeState(); - } - throw Object.assign(new Error(getErrorMessage(error)), { record: failure }); - } - } - - async function flushOutbox(): Promise<void> { - if (!ws || ws.readyState !== WebSocket.OPEN || !config.enabled || !config.gatewayUrl) return; - const nextOutbox: OutboxEntry[] = []; - for (const entry of outbox) { - if (entry.attempts >= MAX_OUTBOX_ATTEMPTS) { - saveHistoryRecord({ - id: randomUUID(), - requestId: trimToNull(entry.envelope.requestId) ?? randomUUID(), - direction: "outbound", - mode: entry.envelope.notificationType ? "notification" : "manual", - status: "failed", - agentId: entry.envelope.agentId ?? null, - sessionKey: entry.envelope.sessionKey ?? null, - body: entry.envelope.message, - summary: summarizeMessage(entry.envelope.message), - context: applyContextPolicy(entry.envelope.context), - createdAt: nowIso(), - error: entry.lastError ?? "Outbox attempts exhausted.", - }); - continue; - } - try { - await sendOutboundNow({ - ...entry.envelope, - bestEffort: true, - }); - } catch (error) { - nextOutbox.push({ - ...entry, - attempts: entry.attempts + 1, - lastAttemptAt: nowIso(), - lastError: getErrorMessage(error), - }); - } - } - outbox = nextOutbox; - persistRuntimeState(); - setStatus({ queuedMessages: outbox.length }); - } - - async function finalizeTurn(turn: PendingBridgeTurn, outcome: "completed" | "failed" | "interrupted", fallbackMessage?: string): Promise<void> { - if (turn.outputSent) return; - turn.outputSent = true; - const reply = buildReplyText(turn, fallbackMessage); - dequeuePendingTurn(turn); - if (turn.mode === "query") { - if (outcome === "failed") { - turn.reject?.(new Error(reply)); - } else { - turn.resolve?.({ reply, sessionId: turn.sessionId, route: turn.route }); - } - return; - } - if (outcome === "failed" && !reply.trim().length) { - saveHistoryRecord({ - id: randomUUID(), - requestId: turn.requestId, - direction: "outbound", - mode: "reply", - status: "failed", - agentId: turn.route.agentId ?? null, - sessionKey: turn.route.sessionKey ?? null, - body: reply, - summary: summarizeMessage(reply || fallbackMessage || "Bridge turn failed."), - context: null, - createdAt: nowIso(), - error: fallbackMessage ?? "Bridge turn failed.", - }); - return; - } - try { - await sendOutboundNow({ - requestId: turn.requestId, - sessionKey: turn.route.sessionKey ?? null, - agentId: turn.route.agentId ?? null, - channel: turn.route.channel ?? null, - replyChannel: turn.route.replyChannel ?? null, - accountId: turn.route.accountId ?? null, - replyAccountId: turn.route.replyAccountId ?? null, - threadId: turn.route.threadId ?? null, - message: reply, - bestEffort: true, - }); - } catch (error) { - logger?.warn("openclaw.reply_delivery_failed", { - requestId: turn.requestId, - error: getErrorMessage(error), - }); - } - } - - async function deliverNotification(type: OpenclawNotificationType, message: string, context?: Record<string, unknown> | null): Promise<void> { - pruneRouteCache(); - const routes = config.notificationRoutes.filter((route) => route.enabled !== false && route.notificationType === type); - for (const route of routes) { - const remembered = route.agentId ? routeCache.byAgentId[route.agentId] : null; - const sessionKey = trimToNull(route.sessionKey) ?? trimToNull(remembered?.sessionKey) ?? null; - const outbound: OpenclawOutboundEnvelope = { - requestId: randomUUID(), - agentId: route.agentId ?? remembered?.agentId ?? null, - sessionKey, - message, - context: context ?? null, - notificationType: type, - bestEffort: true, - }; - try { - await sendOutboundNow(outbound); - } catch { - // best effort queueing already handled in sendOutboundNow - } - } - } - - async function dispatchInbound( - mode: "hook" | "query", - envelope: OpenclawInboundEnvelope, - options?: { - onQueryResolved?: (value: { reply: string; sessionId: string; route: ConversationRoute }) => void; - onQueryRejected?: (error: Error) => void; - timeoutMs?: number; - }, - ): Promise<{ requestId: string; sessionId: string; routeTarget: OpenclawTargetHint; duplicate: boolean }> { - const message = trimToNull(envelope.message); - if (!message) { - throw new Error("OpenClaw inbound message is required."); - } - const requestId = trimToNull(envelope.requestId) ?? trimToNull(envelope.idempotencyKey) ?? randomUUID(); - const normalizedContext = applyContextPolicy(envelope.context); - if (hasSeenIdempotency(requestId)) { - saveHistoryRecord({ - id: randomUUID(), - requestId, - direction: "inbound", - mode, - status: "duplicate", - agentId: envelope.agentId ?? null, - sessionKey: envelope.sessionKey ?? null, - targetHint: envelope.targetHint ?? null, - body: message, - summary: summarizeMessage(message), - context: normalizedContext, - createdAt: nowIso(), - }); - return { requestId, sessionId: "", routeTarget: config.defaultTarget, duplicate: true }; - } - if (config.allowedAgentIds.length > 0) { - const agentId = trimToNull(envelope.agentId); - if (!agentId || !config.allowedAgentIds.includes(agentId)) { - throw new Error("OpenClaw agent is not allowed by this bridge configuration."); - } - } - - markIdempotency(requestId); - const targetSession = await ensureTargetSession(envelope.targetHint ?? config.defaultTarget); - const route: ConversationRoute = { - agentId: trimToNull(envelope.agentId), - sessionKey: trimToNull(envelope.sessionKey), - channel: trimToNull(envelope.channel), - replyChannel: trimToNull(envelope.replyChannel), - accountId: trimToNull(envelope.accountId), - replyAccountId: trimToNull(envelope.replyAccountId), - threadId: trimToNull(envelope.threadId), - updatedAt: nowIso(), - expiresAt: Date.now() + ROUTE_TTL_MS, - sessionId: targetSession.sessionId, - targetHint: targetSession.routeTarget, - }; - activeSessionRoutes.set(targetSession.sessionId, route); - rememberRoute(route); - - saveHistoryRecord({ - id: randomUUID(), - requestId, - direction: "inbound", - mode, - status: "received", - agentId: route.agentId ?? null, - sessionKey: route.sessionKey ?? null, - targetHint: envelope.targetHint ?? null, - resolvedTarget: targetSession.routeTarget, - body: message, - summary: summarizeMessage(message), - context: normalizedContext, - createdAt: nowIso(), - metadata: targetSession.fallbackReason ? { fallbackReason: targetSession.fallbackReason } : undefined, - }); - - const pendingTurn: PendingBridgeTurn = { - requestId, - mode, - route, - sessionId: targetSession.sessionId, - displayText: message, - createdAt: nowIso(), - chunks: [], - outputSent: false, - resolve: options?.onQueryResolved, - reject: options?.onQueryRejected, - timeoutHandle: mode === "query" && options?.timeoutMs - ? setTimeout(() => { - pendingTurn.reject?.(new Error("ADE timed out while waiting for the bridge reply.")); - }, options.timeoutMs) - : undefined, - }; - queuePendingTurn(pendingTurn); - - const promptText = buildPromptFromInbound( - { ...envelope, message, context: normalizedContext }, - requestId, - targetSession.routeTarget, - targetSession.fallbackReason, - ); - await args.agentChatService.sendMessage({ - sessionId: targetSession.sessionId, - text: promptText, - displayText: message, - }); - - return { - requestId, - sessionId: targetSession.sessionId, - routeTarget: targetSession.routeTarget, - duplicate: false, - }; - } - - function clearConnectTimer(): void { - if (wsConnectTimer) { - clearTimeout(wsConnectTimer); - wsConnectTimer = null; - } - } - - function clearTickTimer(): void { - if (tickTimer) { - clearInterval(tickTimer); - tickTimer = null; - } - } - - function clearReconnectTimer(): void { - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - } - - function flushPendingWsErrors(error: Error): void { - for (const [, pending] of pendingWsRequests) pending.reject(error); - pendingWsRequests.clear(); - } - - function queueConnectTimeout(): void { - clearConnectTimer(); - wsConnectTimer = setTimeout(() => { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - setStatus({ - state: "error", - lastError: "OpenClaw gateway connect challenge timed out.", - }); - ws.close(1008, "connect challenge timeout"); - }, CONNECT_CHALLENGE_TIMEOUT_MS); - } - - function startTickWatch(intervalMs: number): void { - clearTickTimer(); - tickTimer = setInterval(() => { - if (!lastTickAt || !ws) return; - if (Date.now() - lastTickAt > intervalMs * 2) { - ws.close(4000, "tick timeout"); - } - }, Math.max(intervalMs, TICK_WATCH_FLOOR_MS)); - } - - async function requestGateway( - method: string, - params: Record<string, unknown>, - options?: { expectFinal?: boolean }, - ): Promise<Record<string, unknown>> { - if (!ws || ws.readyState !== WebSocket.OPEN) { - throw new Error("OpenClaw gateway is not connected."); - } - const id = randomUUID(); - const frame: OpenclawRequestFrame = { type: "req", id, method, params }; - const promise = new Promise<Record<string, unknown>>((resolve, reject) => { - pendingWsRequests.set(id, { - resolve, - reject, - expectFinal: options?.expectFinal === true, - }); - }); - ws.send(JSON.stringify(frame)); - return await promise; - } - - function sendConnectFrame(): void { - if (!ws || ws.readyState !== WebSocket.OPEN || !wsConnectNonce) return; - const authToken = trimToNull(config.gatewayToken); - const deviceToken = trimToNull(config.deviceToken); - const signedAtMs = Date.now(); - const scopes = ["operator.admin"]; - const payload = buildDeviceAuthPayloadV3({ - deviceId: deviceIdentity.deviceId, - clientId: "ade.openclaw.bridge", - clientMode: "backend", - role: "operator", - scopes, - signedAtMs, - token: authToken, - nonce: wsConnectNonce, - platform: process.platform, - deviceFamily: "ade", - }); - const params = { - minProtocol: 1, - maxProtocol: 1, - client: { - id: "ade.openclaw.bridge", - displayName: "ADE OpenClaw Bridge", - version: args.appVersion ?? "dev", - platform: process.platform, - deviceFamily: "ade", - mode: "backend", - }, - caps: [], - auth: authToken || deviceToken - ? { - ...(authToken ? { token: authToken } : {}), - ...(deviceToken ? { deviceToken } : {}), - } - : undefined, - role: "operator", - scopes, - device: { - id: deviceIdentity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem), - signature: signDevicePayload(deviceIdentity.privateKeyPem, payload), - signedAt: signedAtMs, - nonce: wsConnectNonce, - }, - }; - void requestGateway("connect", params) - .then((hello) => { - const nextDeviceToken = trimToNull(isRecord(hello.auth) ? hello.auth.deviceToken : null); - if (nextDeviceToken && nextDeviceToken !== config.deviceToken) { - config = { ...config, deviceToken: nextDeviceToken }; - persistConfig(config); - } - reconnectAttempt = 0; - lastTickAt = Date.now(); - startTickWatch( - Number.isFinite(Number(isRecord(hello.policy) ? hello.policy.tickIntervalMs : null)) - ? Math.max(1_000, Number((hello.policy as Record<string, unknown>).tickIntervalMs)) - : DEFAULT_TICK_INTERVAL_MS, - ); - setStatus({ - state: "connected", - lastConnectedAt: nowIso(), - lastError: null, - lastEventAt: nowIso(), - }); - void flushOutbox(); - }) - .catch((error) => { - setStatus({ - state: "error", - lastError: getErrorMessage(error), - }); - ws?.close(1008, "connect failed"); - }); - } - - function scheduleReconnect(): void { - if (requestedStop || !config.enabled || !config.gatewayUrl) return; - clearReconnectTimer(); - const delay = Math.min(1_000 * Math.max(1, 2 ** reconnectAttempt), MAX_RECONNECT_BACKOFF_MS); - reconnectAttempt += 1; - setStatus({ state: "reconnecting" }); - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - void connectGateway(); - }, delay); - } - - function handleWsMessage(raw: string): void { - try { - const parsed = JSON.parse(raw) as unknown; - if (isEventFrame(parsed)) { - if (parsed.event === "connect.challenge") { - const nonce = trimToNull(parsed.payload?.nonce); - if (!nonce) { - throw new Error("OpenClaw gateway connect challenge did not include a nonce."); - } - wsConnectNonce = nonce; - clearConnectTimer(); - sendConnectFrame(); - return; - } - if (parsed.event === "tick") { - lastTickAt = Date.now(); - } - setStatus({ lastEventAt: nowIso() }); - return; - } - if (isResponseFrame(parsed)) { - const pending = pendingWsRequests.get(parsed.id); - if (!pending) return; - const responseStatus = isRecord(parsed.payload) ? parsed.payload.status : null; - if (pending.expectFinal && responseStatus === "accepted") return; - pendingWsRequests.delete(parsed.id); - if (parsed.ok) { - pending.resolve(parsed.payload ?? {}); - } else { - pending.reject(new Error(parsed.error?.message ?? "OpenClaw gateway returned an unknown error.")); - } - } - } catch (error) { - logger?.warn("openclaw.ws_message_parse_failed", { - error: getErrorMessage(error), - }); - } - } - - async function disconnectGateway(): Promise<void> { - requestedStop = true; - clearConnectTimer(); - clearReconnectTimer(); - clearTickTimer(); - flushPendingWsErrors(new Error("OpenClaw gateway disconnected.")); - if (ws) { - const current = ws; - ws = null; - try { - current.close(); - } catch { - // best effort - } - } - if (config.enabled) { - setStatus({ state: "disconnected" }); - } else { - setStatus({ state: "disabled" }); - } - } - - async function connectGateway(): Promise<void> { - requestedStop = false; - clearReconnectTimer(); - clearConnectTimer(); - if (!config.enabled) { - await disconnectGateway(); - return; - } - if (!config.gatewayUrl) { - setStatus({ - state: "disconnected", - lastError: "Gateway URL is not configured. HTTP fallback remains available.", - }); - return; - } - if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { - return; - } - - setStatus({ state: status.lastConnectedAt ? "reconnecting" : "connecting", lastError: null }); - try { - ws = new WebSocket(config.gatewayUrl, { maxPayload: 25 * 1024 * 1024 }); - ws.on("open", () => { - wsConnectNonce = null; - queueConnectTimeout(); - }); - ws.on("message", (data: RawData) => { - const raw = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf8") : String(data); - handleWsMessage(raw); - }); - ws.on("close", (code: number, reason: Buffer) => { - ws = null; - clearConnectTimer(); - clearTickTimer(); - flushPendingWsErrors(new Error(`gateway closed (${code}): ${String(reason)}`)); - if (code === 1008 && String(reason).toLowerCase().includes("device token mismatch") && !config.gatewayToken) { - config = { ...config, deviceToken: null }; - persistConfig(config); - } - setStatus({ - state: config.enabled ? "disconnected" : "disabled", - lastError: `Gateway closed (${code}): ${String(reason)}`, - }); - scheduleReconnect(); - }); - ws.on("error", (error: Error) => { - setStatus({ - state: "error", - lastError: getErrorMessage(error), - }); - }); - } catch (error) { - setStatus({ - state: "error", - lastError: getErrorMessage(error), - }); - scheduleReconnect(); - } - } - - async function restartHttpServer(): Promise<void> { - if (httpServer) { - const server = httpServer; - httpServer = null; - await new Promise<void>((resolve) => server.close(() => resolve())); - setStatus({ httpListening: false }); - } - httpServer = http.createServer((req, res) => { - void handleHttpRequest(req, res).catch((error) => { - jsonResponse(res, 500, { ok: false, error: getErrorMessage(error) }); - }); - }); - await new Promise<void>((resolve, reject) => { - httpServer!.once("error", reject); - const requestedPort = Number.isFinite(config.bridgePort) ? config.bridgePort : DEFAULT_BRIDGE_PORT; - httpServer!.listen(requestedPort, "127.0.0.1", () => resolve()); - }); - const address = httpServer.address(); - currentHttpPort = typeof address === "object" && address - ? address.port - : (Number.isFinite(config.bridgePort) ? config.bridgePort : DEFAULT_BRIDGE_PORT); - setStatus({ httpListening: true, bridgePort: currentHttpPort }); - } - - function authorizeRequest(req: IncomingMessage): void { - const configured = trimToNull(config.hooksToken); - if (!configured) return; - const provided = getRequestToken(req); - if (provided !== configured) { - throw new Error("Invalid OpenClaw hook token."); - } - } - - async function handleQueryRequest(envelope: OpenclawInboundEnvelope, res: ServerResponse): Promise<void> { - const resolvedTarget = resolveTarget(envelope.targetHint ?? config.defaultTarget); - if (resolvedTarget.resolvedTarget !== "cto") { - const dispatch = await dispatchInbound("hook", envelope); - jsonResponse(res, 200, { - ok: true, - accepted: true, - async: true, - status: "working", - requestId: dispatch.requestId, - duplicate: dispatch.duplicate, - sessionId: dispatch.sessionId, - routeTarget: dispatch.routeTarget, - }); - return; - } - const timeoutMs = Number.isFinite(Number(envelope.timeoutMs)) - ? Math.max(1_000, Math.min(300_000, Math.floor(Number(envelope.timeoutMs)))) - : 120_000; - const requestId = trimToNull(envelope.requestId) ?? trimToNull(envelope.idempotencyKey) ?? randomUUID(); - const result = await new Promise<{ reply: string; sessionId: string; route: ConversationRoute }>(async (resolve, reject) => { - try { - const dispatch = await dispatchInbound( - "query", - { ...envelope, requestId }, - { - onQueryResolved: resolve, - onQueryRejected: reject, - timeoutMs, - }, - ); - if (dispatch.duplicate) { - reject(new Error("Duplicate idempotency key.")); - return; - } - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - jsonResponse(res, 200, { - ok: true, - requestId, - reply: result.reply, - sessionId: result.sessionId, - route: { - agentId: result.route.agentId ?? null, - sessionKey: result.route.sessionKey ?? null, - targetHint: result.route.targetHint ?? null, - }, - }); - } - - async function handleHookRequest(envelope: OpenclawInboundEnvelope, res: ServerResponse): Promise<void> { - const dispatch = await dispatchInbound("hook", envelope); - jsonResponse(res, 202, { - ok: true, - accepted: true, - duplicate: dispatch.duplicate, - requestId: dispatch.requestId, - sessionId: dispatch.sessionId, - routeTarget: dispatch.routeTarget, - }); - } - - async function handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> { - const method = req.method?.toUpperCase() ?? "GET"; - const pathname = new URL(req.url ?? "/", "http://127.0.0.1").pathname; - if (method === "GET" && pathname === "/openclaw/health") { - jsonResponse(res, 200, { - ok: true, - projectRoot: args.projectRoot, - state: readBridgeState(), - }); - return; - } - if (pathname !== "/openclaw/hook" && pathname !== "/openclaw/query") { - jsonResponse(res, 404, { ok: false, error: "Not found." }); - return; - } - if (method !== "POST") { - jsonResponse(res, 405, { ok: false, error: "Method not allowed." }); - return; - } - authorizeRequest(req); - const raw = await readBody(req); - const parsed = parseJsonBody(raw); - if (!isRecord(parsed)) { - jsonResponse(res, 400, { ok: false, error: "OpenClaw request body must be a JSON object." }); - return; - } - const envelope: OpenclawInboundEnvelope = { - requestId: trimToNull(parsed.requestId) ?? undefined, - idempotencyKey: trimToNull(parsed.idempotencyKey) ?? undefined, - agentId: trimToNull(parsed.agentId), - sessionKey: trimToNull(parsed.sessionKey), - channel: trimToNull(parsed.channel), - replyChannel: trimToNull(parsed.replyChannel), - accountId: trimToNull(parsed.accountId), - replyAccountId: trimToNull(parsed.replyAccountId), - threadId: trimToNull(parsed.threadId), - message: String(parsed.message ?? "").trim(), - targetHint: parsed.targetHint ? normalizeTargetHint(parsed.targetHint, config.defaultTarget) : undefined, - context: isRecord(parsed.context) ? parsed.context : null, - timeoutMs: Number.isFinite(Number(parsed.timeoutMs)) ? Number(parsed.timeoutMs) : undefined, - }; - try { - if (pathname === "/openclaw/query") { - await handleQueryRequest(envelope, res); - } else { - await handleHookRequest(envelope, res); - } - } catch (error) { - const statusCode = /timed out/i.test(getErrorMessage(error)) ? 504 : 400; - jsonResponse(res, statusCode, { ok: false, error: getErrorMessage(error) }); - } - } - - return { - async start(): Promise<void> { - idempotencyState = pruneIdempotencyState(idempotencyState); - pruneRouteCache(); - persistRuntimeState(); - await restartHttpServer(); - if (config.enabled) { - await connectGateway(); - } else { - setStatus({ state: "disabled" }); - } - }, - - async stop(): Promise<void> { - await disconnectGateway(); - // Clear all pending turn timeout handles and in-memory tracking maps. - for (const queue of pendingTurnsBySession.values()) { - for (const turn of queue) { - if (turn.timeoutHandle) clearTimeout(turn.timeoutHandle); - turn.reject?.(new Error("OpenClaw bridge stopped.")); - } - } - pendingTurnsBySession.clear(); - turnBindings.clear(); - activeSessionRoutes.clear(); - if (httpServer) { - const server = httpServer; - httpServer = null; - await new Promise<void>((resolve) => server.close(() => resolve())); - } - setStatus({ httpListening: false, state: config.enabled ? "disconnected" : "disabled" }); - }, - - getState(): OpenclawBridgeState { - return readBridgeState(); - }, - - listMessages(limit = 40): OpenclawMessageRecord[] { - return getHistoryMessages(limit); - }, - - async updateConfig(patch: Partial<OpenclawBridgeConfig>): Promise<OpenclawBridgeState> { - config = normalizeConfig({ ...config, ...patch }); - persistConfig(config); - await restartHttpServer(); - if (config.enabled) { - await connectGateway(); - } else { - await disconnectGateway(); - } - setStatus({ - state: config.enabled ? status.state : "disabled", - lastError: config.enabled ? status.lastError : null, - }); - return readBridgeState(); - }, - - async testConnection(): Promise<OpenclawBridgeStatus> { - await restartHttpServer(); - if (!config.enabled || !config.gatewayUrl) { - setStatus({ - state: config.enabled ? "disconnected" : "disabled", - lastError: config.enabled && !config.gatewayUrl - ? "Gateway URL is not configured. HTTP fallback is ready." - : null, - }); - return status; - } - await connectGateway(); - const deadline = Date.now() + 8_000; - while (Date.now() < deadline) { - if (status.state === "connected") return status; - if (status.state === "error") break; - await new Promise((resolve) => setTimeout(resolve, 150)); - } - return status; - }, - - async sendMessage(envelope: OpenclawOutboundEnvelope): Promise<OpenclawMessageRecord> { - return await sendOutboundNow(envelope); - }, - - onAgentChatEvent(envelope: AgentChatEventEnvelope): void { - const queue = pendingTurnsBySession.get(envelope.sessionId) ?? []; - if (envelope.event.type === "user_message" && envelope.event.turnId) { - const pending = queue.find((entry) => !entry.turnId); - if (pending) { - pending.turnId = envelope.event.turnId; - turnBindings.set(envelope.event.turnId, pending); - return; - } - const ambientRoute = activeSessionRoutes.get(envelope.sessionId); - if (ambientRoute && ambientRoute.expiresAt > Date.now()) { - const ambient: PendingBridgeTurn = { - requestId: randomUUID(), - mode: "ambient", - route: ambientRoute, - sessionId: envelope.sessionId, - displayText: envelope.event.text, - createdAt: nowIso(), - turnId: envelope.event.turnId, - chunks: [], - outputSent: false, - }; - turnBindings.set(envelope.event.turnId, ambient); - } - return; - } - - const turnId = envelope.event.type === "done" - ? envelope.event.turnId - : "turnId" in envelope.event - ? envelope.event.turnId - : undefined; - if (!turnId) return; - const binding = turnBindings.get(turnId); - if (!binding) return; - - if (envelope.event.type === "text") { - binding.chunks.push(envelope.event.text); - return; - } - - if (envelope.event.type === "status" && envelope.event.turnStatus === "failed") { - void finalizeTurn(binding, "failed", envelope.event.message ?? "ADE failed to complete the bridge turn."); - return; - } - - if (envelope.event.type === "status" && envelope.event.turnStatus === "interrupted") { - void finalizeTurn(binding, "interrupted", envelope.event.message ?? "ADE interrupted the bridge turn."); - return; - } - - if (envelope.event.type === "error") { - binding.chunks.push(`\n${envelope.event.message}`); - return; - } - - if (envelope.event.type === "done") { - void finalizeTurn(binding, envelope.event.status === "failed" ? "failed" : envelope.event.status === "interrupted" ? "interrupted" : "completed"); - } - }, - - onMissionEvent(event: MissionsEventPayload): void { - if (!event.missionId || event.reason !== "updated") return; - const mission = args.missionService?.get(event.missionId); - if (!mission || mission.status !== "completed") return; - void deliverNotification( - "mission_complete", - `Mission completed: ${mission.title}`, - { - missionId: mission.id, - status: mission.status, - updatedAt: mission.updatedAt, - }, - ); - }, - - onTestEvent(event: TestEvent): void { - if (event.type !== "run" || event.run.status !== "failed") return; - void deliverNotification( - "ci_broken", - `CI/test run failed: ${event.run.suiteName}`, - { - suiteId: event.run.suiteId, - runId: event.run.id, - laneId: event.run.laneId, - exitCode: event.run.exitCode, - }, - ); - }, - - onOrchestratorEvent(event: OrchestratorRuntimeEvent): void { - const reason = (event.reason ?? "").toLowerCase(); - if (!reason.includes("blocked")) return; - void deliverNotification( - "blocked_run", - `Orchestrator blocked: ${event.reason}`, - { - runId: event.runId ?? null, - stepId: event.stepId ?? null, - attemptId: event.attemptId ?? null, - }, - ); - }, - }; -} diff --git a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts index e8424eebe..ce52f20aa 100644 --- a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts +++ b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts @@ -12,7 +12,6 @@ import type { createAgentChatService } from "../chat/agentChatService"; const ADE_CLI_WORKER_GUIDANCE = ADE_CLI_AGENT_GUIDANCE; type WorkerAdapterRuntimeServiceArgs = { - fetchImpl?: typeof fetch; spawnImpl?: typeof spawn; getAgentChatService?: () => Pick<ReturnType<typeof createAgentChatService>, "ensureIdentitySession" | "runSessionTurn"> | null; }; @@ -158,7 +157,6 @@ function runCommand( } export function createWorkerAdapterRuntimeService(args: WorkerAdapterRuntimeServiceArgs = {}) { - const fetchImpl = args.fetchImpl ?? fetch; const spawnImpl = args.spawnImpl ?? spawn; const run = async (input: WorkerAdapterRunArgs): Promise<WorkerAdapterRunResult> => { @@ -241,76 +239,6 @@ export function createWorkerAdapterRuntimeService(args: WorkerAdapterRuntimeServ }; } - if (adapterType === "openclaw-webhook") { - const url = String(config.url ?? "").trim(); - if (!/^https?:\/\//i.test(url)) { - throw new Error("openclaw-webhook requires a valid http(s) URL."); - } - const method = String(config.method ?? "POST").toUpperCase(); - if (method !== "POST") { - throw new Error("openclaw-webhook only supports POST."); - } - const headersRaw = config.headers && typeof config.headers === "object" ? config.headers as Record<string, unknown> : {}; - const headers: Record<string, string> = { - "content-type": "application/json", - }; - for (const [key, value] of Object.entries(headersRaw)) { - if (typeof value !== "string") continue; - headers[key] = value; - } - const timeoutMs = toPositiveTimeout(input.timeoutMs ?? config.timeoutMs, 60_000); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const body = { - agentId: input.agent.id, - agentName: input.agent.name, - adapterType, - prompt, - context: input.context ?? {}, - bodyTemplate: typeof config.bodyTemplate === "string" ? config.bodyTemplate : undefined, - }; - const response = await fetchImpl(url, { - method, - headers, - body: JSON.stringify(body), - signal: controller.signal, - }); - const text = await response.text(); - let parsed: unknown = text; - try { - parsed = JSON.parse(text); - } catch { - // keep text payload - } - const outputText = typeof parsed === "string" - ? parsed - : (parsed && typeof parsed === "object" && typeof (parsed as { output?: unknown }).output === "string") - ? String((parsed as { output?: unknown }).output) - : text; - return { - adapterType, - effectiveSurface: "openclaw_webhook", - ok: response.ok, - statusCode: response.status, - outputText: outputText.trim(), - raw: parsed, - provider: null, - model: requestedModel, - modelId: requestedModelId, - continuation: { - surface: "openclaw_webhook", - provider: null, - model: requestedModel, - modelId: requestedModelId, - reasoningEffort: toOptionalString(config.reasoningEffort), - }, - }; - } finally { - clearTimeout(timeout); - } - } - if (adapterType === "process") { const command = String(config.command ?? "").trim(); if (!command.length) throw new Error("process adapter requires command."); diff --git a/apps/desktop/src/main/services/cto/workerAgentService.ts b/apps/desktop/src/main/services/cto/workerAgentService.ts index 472556383..684f4b0ba 100644 --- a/apps/desktop/src/main/services/cto/workerAgentService.ts +++ b/apps/desktop/src/main/services/cto/workerAgentService.ts @@ -67,7 +67,6 @@ const ALLOWED_STATUSES = new Set<AgentStatus>(["idle", "active", "paused", "runn const ALLOWED_ADAPTER_TYPES = new Set<AdapterType>([ "claude-local", "codex-local", - "openclaw-webhook", "process", ]); @@ -267,35 +266,6 @@ function normalizeAdapterConfig(adapterType: AdapterType, config: Record<string, return result; } - if (adapterType === "openclaw-webhook") { - const url = typeof config.url === "string" ? config.url.trim() : ""; - if (!/^https?:\/\//i.test(url)) { - throw new Error("openclaw-webhook adapter requires an absolute http(s) url."); - } - result.url = url; - if (config.method != null) { - const method = String(config.method).trim().toUpperCase(); - if (method !== "POST") throw new Error("openclaw-webhook only supports method=POST."); - result.method = "POST"; - } - if (config.headers != null) { - if (!config.headers || typeof config.headers !== "object" || Array.isArray(config.headers)) { - throw new Error("openclaw-webhook headers must be a key/value object."); - } - const headers: Record<string, string> = {}; - for (const [key, value] of Object.entries(config.headers as Record<string, unknown>)) { - if (typeof value !== "string") continue; - headers[key] = value.trim(); - } - result.headers = headers; - } - if (Number.isFinite(timeoutMs) && timeoutMs > 0) result.timeoutMs = Math.floor(timeoutMs); - if (typeof config.bodyTemplate === "string" && config.bodyTemplate.trim()) { - result.bodyTemplate = config.bodyTemplate; - } - return result; - } - if (adapterType === "process") { const command = typeof config.command === "string" ? config.command.trim() : ""; if (!command.length) throw new Error("process adapter requires a non-empty command."); diff --git a/apps/desktop/src/main/services/cto/workerHeartbeatService.ts b/apps/desktop/src/main/services/cto/workerHeartbeatService.ts index 02dad02c2..9be5272a9 100644 --- a/apps/desktop/src/main/services/cto/workerHeartbeatService.ts +++ b/apps/desktop/src/main/services/cto/workerHeartbeatService.ts @@ -636,7 +636,7 @@ export function createWorkerHeartbeatService(args: WorkerHeartbeatServiceArgs) { run.task_key ? `Task: ${run.task_key}.` : "", run.issue_key ? `Issue: ${run.issue_key}.` : "", runtimeResult.ok ? "Adapter run completed." : "Adapter run failed.", - runtimeResult.effectiveSurface !== "process" && runtimeResult.effectiveSurface !== "openclaw_webhook" + runtimeResult.effectiveSurface !== "process" ? `Resumed via ${runtimeResult.effectiveSurface}.` : "", heartbeatOk ? "No action required." : outputPreview || "No output.", @@ -650,7 +650,7 @@ export function createWorkerHeartbeatService(args: WorkerHeartbeatServiceArgs) { provider: runtimeResult.provider ?? agent.adapterType, modelId: adapterModelId, capabilityMode: - runtimeResult.effectiveSurface === "process" || runtimeResult.effectiveSurface === "openclaw_webhook" + runtimeResult.effectiveSurface === "process" ? "fallback" : "full_tooling", }); diff --git a/apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts b/apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts index d4a575993..ce210606b 100644 --- a/apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts +++ b/apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts @@ -1,12 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createFeedbackReporterService } from "./feedbackReporterService"; -vi.mock("electron", () => ({ - BrowserWindow: { - getAllWindows: () => [], - }, -})); - function createDb() { const store = new Map<string, unknown>(); return { @@ -114,6 +108,7 @@ describe("createFeedbackReporterService", () => { it("stores a failed submission when GitHub posting fails", async () => { const db = createDb(); const logger = createLogger(); + const onSubmissionUpdated = vi.fn(); const apiRequest = vi.fn(async () => { throw new Error("GitHub API unavailable"); }); @@ -124,6 +119,7 @@ describe("createFeedbackReporterService", () => { projectRoot: "/Users/admin/Projects/ADE", aiIntegrationService: { executeTask: vi.fn() } as any, githubService: { apiRequest } as any, + onSubmissionUpdated, }); const submission = await service.submitPreparedDraft({ @@ -162,6 +158,7 @@ describe("createFeedbackReporterService", () => { error: "Posting failed: GitHub API unavailable", }), ); + expect(onSubmissionUpdated).toHaveBeenCalledTimes(2); }); it("stores a posted submission after a reviewed draft is submitted", async () => { diff --git a/apps/desktop/src/main/services/feedback/feedbackReporterService.ts b/apps/desktop/src/main/services/feedback/feedbackReporterService.ts index 53cc4f3e9..5718bda58 100644 --- a/apps/desktop/src/main/services/feedback/feedbackReporterService.ts +++ b/apps/desktop/src/main/services/feedback/feedbackReporterService.ts @@ -1,6 +1,4 @@ import { randomUUID } from "node:crypto"; -import { BrowserWindow } from "electron"; -import { IPC } from "../../../shared/ipc"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; import type { createAiIntegrationService } from "../ai/aiIntegrationService"; @@ -247,14 +245,18 @@ function normalizeStoredSubmission(submission: FeedbackSubmission): FeedbackSubm }; } -function emitUpdate(submission: FeedbackSubmission): void { - const event: FeedbackSubmissionEvent = { +function toSubmissionUpdateEvent(submission: FeedbackSubmission): FeedbackSubmissionEvent { + return { type: "feedback-submission-updated", submission, }; - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(IPC.feedbackOnUpdate, event); - } +} + +function emitUpdate( + submission: FeedbackSubmission, + onSubmissionUpdated: ((event: FeedbackSubmissionEvent) => void) | undefined, +): void { + onSubmissionUpdated?.(toSubmissionUpdateEvent(submission)); } const METADATA_SYSTEM_PROMPT = `You help convert structured ADE feedback into GitHub issue metadata. @@ -343,12 +345,14 @@ export function createFeedbackReporterService({ projectRoot, aiIntegrationService, githubService, + onSubmissionUpdated, }: { db: AdeDb; logger: Logger; projectRoot: string; aiIntegrationService: ReturnType<typeof createAiIntegrationService>; githubService: ReturnType<typeof createGithubService>; + onSubmissionUpdated?: (event: FeedbackSubmissionEvent) => void; }) { function loadAll(): FeedbackSubmission[] { return (db.getJson<FeedbackSubmission[]>(DB_KEY) ?? []).map(normalizeStoredSubmission); @@ -461,7 +465,7 @@ export function createFeedbackReporterService({ }; save(submission); - emitUpdate(submission); + emitUpdate(submission, onSubmissionUpdated); try { const { data } = await githubService.apiRequest<{ @@ -483,7 +487,7 @@ export function createFeedbackReporterService({ submission.status = "posted"; submission.completedAt = nowIso(); save(submission); - emitUpdate(submission); + emitUpdate(submission, onSubmissionUpdated); logger.info("feedback.posted", { id: submission.id, @@ -495,7 +499,7 @@ export function createFeedbackReporterService({ submission.error = `Posting failed: ${message}`; submission.completedAt = nowIso(); save(submission); - emitUpdate(submission); + emitUpdate(submission, onSubmissionUpdated); logger.error("feedback.failed", { id: submission.id, diff --git a/apps/desktop/src/main/services/git/gitOperationsService.ts b/apps/desktop/src/main/services/git/gitOperationsService.ts index 88ece3511..38a435f4a 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.ts @@ -1,3 +1,4 @@ +import { spawn } from "node:child_process"; import path from "node:path"; import { getHeadSha, runGit, runGitOrThrow } from "./git"; import { detectConflictKind, parseNameOnly } from "./gitConflictState"; @@ -59,6 +60,18 @@ type CachedReadEntry<T> = { promise?: Promise<T>; }; +type GitOriginRemoteSummary = { + remoteUrl: string | null; + branch: string | null; +}; + +type GitOpenPrSummary = { + prUrl: string | null; + prNumber: number | null; + title: string | null; + headRefName: string | null; +}; + function localBranchNameFromRemoteRef(ref: string): string { const normalized = ref.trim(); const slashIndex = normalized.indexOf("/"); @@ -1243,6 +1256,87 @@ export function createGitOperationsService({ return { name, email }; }, + async getOriginRemote(args: { laneId: string }): Promise<GitOriginRemoteSummary> { + const fallback: GitOriginRemoteSummary = { remoteUrl: null, branch: null }; + const laneId = args.laneId.trim(); + if (!laneId) return fallback; + const lane = laneService.getLaneBaseAndBranch(laneId); + const [remoteRes, branchRes] = await Promise.all([ + runGit(["remote", "get-url", "origin"], { cwd: lane.worktreePath, timeoutMs: 8_000 }).catch(() => null), + lane.branchRef?.trim() + ? Promise.resolve(null) + : runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: lane.worktreePath, timeoutMs: 8_000 }).catch(() => null), + ]); + const rawRemote = remoteRes?.exitCode === 0 ? remoteRes.stdout.trim() || null : null; + const remoteUrl = ((): string | null => { + if (!rawRemote) return rawRemote; + try { + const parsed = new URL(rawRemote); + if (parsed.username || parsed.password) { + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); + } + return rawRemote; + } catch { + return rawRemote; + } + })(); + let branch = lane.branchRef?.trim() || null; + if (!branch && branchRes?.exitCode === 0) { + const out = branchRes.stdout.trim(); + branch = out && out !== "HEAD" ? out : null; + } + return { remoteUrl, branch }; + }, + + async getOpenPrForBranch(args: { laneId: string; branch?: string }): Promise<GitOpenPrSummary> { + const fallback: GitOpenPrSummary = { prUrl: null, prNumber: null, title: null, headRefName: null }; + const laneId = args.laneId.trim(); + if (!laneId) return fallback; + const lane = laneService.getLaneBaseAndBranch(laneId); + const branch = args.branch?.trim() || lane.branchRef?.trim() || ""; + if (!branch) return fallback; + + try { + const stdout = await new Promise<string>((resolve) => { + let settled = false; + let out = ""; + const child = spawn("gh", ["pr", "list", "--head", branch, "--state", "open", "--json", "url,number,title,headRefName", "--limit", "1"], { + cwd: lane.worktreePath, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + const finish = (value: string) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { child.kill("SIGKILL"); } catch { /* noop */ } + resolve(value); + }; + const timer = setTimeout(() => finish(""), 8_000); + child.stdout.on("data", (d: Buffer | string) => { + out += Buffer.isBuffer(d) ? d.toString("utf8") : String(d); + }); + child.stderr.on("data", () => { /* swallow auth state */ }); + child.on("error", () => finish("")); + child.on("close", (code) => finish(code === 0 ? out : "")); + }); + if (!stdout.trim()) return fallback; + const parsed: unknown = JSON.parse(stdout); + if (!Array.isArray(parsed) || parsed.length === 0) return fallback; + const entry = parsed[0] as Record<string, unknown>; + return { + prUrl: typeof entry.url === "string" && entry.url ? entry.url : null, + prNumber: typeof entry.number === "number" ? entry.number : null, + title: typeof entry.title === "string" && entry.title ? entry.title : null, + headRefName: typeof entry.headRefName === "string" && entry.headRefName ? entry.headRefName : null, + }; + } catch { + return fallback; + } + }, + async checkoutBranch(args: GitCheckoutBranchArgs): Promise<GitActionResult> { const branchName = args.branchName.trim(); if (!branchName.length) throw new Error("Branch name is required"); diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 9812d7bc2..5a65ccae8 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -10,6 +10,22 @@ import { getGitHubTokenAccessState, parseGitHubScopeHeaders } from "../../../sha import { nowIso, asString } from "../shared/utils"; const AUTH_STORE_FILE_NAME = "github-token.v1.bin"; +const GITHUB_API_TIMEOUT_MS = 20_000; + +async function fetchGitHub(input: string | URL, init: RequestInit): Promise<Response> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error("GitHub API request timed out. Check network access on this machine."); + } + throw error; + } finally { + clearTimeout(timer); + } +} function detectGitHubTokenType(token: string): GitHubStatus["tokenType"] { if (token.startsWith("github_pat_")) return "fine-grained"; @@ -195,7 +211,7 @@ export function createGithubService({ }; const validateToken = async (token: string): Promise<{ userLogin: string | null; scopes: string[]; tokenType: GitHubStatus["tokenType"] }> => { - const response = await fetch("https://api.github.com/user", { + const response = await fetchGitHub("https://api.github.com/user", { method: "GET", headers: { accept: "application/vnd.github+json", @@ -229,7 +245,7 @@ export function createGithubService({ repo: GitHubRepoRef, ): Promise<{ ok: boolean; error: string | null }> => { try { - const response = await fetch( + const response = await fetchGitHub( `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`, { method: "GET", @@ -290,7 +306,7 @@ export function createGithubService({ } } - const response = await fetch(url.toString(), { + const response = await fetchGitHub(url.toString(), { method: args.method, headers, body: args.body != null ? JSON.stringify(args.body) : undefined diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 38879d209..e6188a2e0 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -270,7 +270,6 @@ import type { AgentChatFileSearchResult, AgentChatGetTurnFileDiffArgs, AgentTool, - DeviceMarker, KeybindingOverride, KeybindingsSnapshot, ImportBranchLaneArgs, @@ -280,7 +279,6 @@ import type { OnboardingTourProgress, OnboardingTourVariant, LaneListSnapshot, - LaneRuntimeSummary, LaneSummary, ListOperationsArgs, ListOverlapsArgs, @@ -307,6 +305,7 @@ import type { ProjectDetail, ProjectIcon, ProjectInfo, + OpenProjectBinding, CreateProjectInput, CreateProjectResult, CloneProjectInput, @@ -438,13 +437,6 @@ import type { CtoUpdateIdentityArgs, CtoUpdateCoreMemoryArgs, CtoListSessionLogsArgs, - CtoGetOpenclawStateResult, - CtoUpdateOpenclawConfigArgs, - CtoTestOpenclawConnectionArgs, - CtoTestOpenclawConnectionResult, - CtoListOpenclawMessagesArgs, - CtoListOpenclawMessagesResult, - CtoSendOpenclawMessageArgs, CtoSnapshot, CtoSessionLogEntry, GetOrchestratorWorkerStatesArgs, @@ -654,7 +646,6 @@ import type { createOrchestratorService } from "../orchestrator/orchestratorServ import type { createAiOrchestratorService } from "../orchestrator/aiOrchestratorService"; import { readCoordinatorCheckpoint } from "../orchestrator/missionStateDoc"; import type { createMemoryService } from "../memory/memoryService"; -import type { createOpenclawBridgeService } from "../cto/openclawBridgeService"; import type { createBatchConsolidationService } from "../memory/batchConsolidationService"; import type { createMemoryLifecycleService } from "../memory/memoryLifecycleService"; import type { createMemoryBriefingService } from "../memory/memoryBriefingService"; @@ -673,6 +664,8 @@ import type { createWorkerHeartbeatService } from "../cto/workerHeartbeatService import type { createWorkerTaskSessionService } from "../cto/workerTaskSessionService"; import type { createLinearCredentialService } from "../cto/linearCredentialService"; import { createLinearOAuthService, type LinearOAuthService } from "../cto/linearOAuthService"; +import type { LocalRuntimeConnectionPool } from "../localRuntime/localRuntimeConnectionPool"; +import { registerRuntimeBridge } from "./runtimeBridge"; import type { createFlowPolicyService } from "../cto/flowPolicyService"; import type { createLinearRoutingService } from "../cto/linearRoutingService"; import type { createLinearIngressService } from "../cto/linearIngressService"; @@ -682,6 +675,11 @@ import type { createUsageTrackingService } from "../usage/usageTrackingService"; import type { createBudgetCapService } from "../usage/budgetCapService"; import type { createSyncHostService } from "../sync/syncHostService"; import type { createSyncService } from "../sync/syncService"; +import { + buildLaneListSnapshots, + buildLanePresenceByLaneId, + decorateLaneSummariesWithPresence, +} from "../lanes/laneListSnapshotService"; import type { createFeedbackReporterService } from "../feedback/feedbackReporterService"; import type { AdeProjectService } from "../projects/adeProjectService"; import type { ConfigReloadService } from "../projects/configReloadService"; @@ -689,7 +687,6 @@ import type { createProjectScaffoldService } from "../projects/projectScaffoldSe import type { createAdeCliService } from "../cli/adeCliService"; import { getErrorMessage, isRecord, nowIso, resolvePathWithinRoot, toMemoryEntryDto } from "../shared/utils"; import { quoteWindowsCmdArg } from "../shared/processExecution"; -import { resolveAdeLayout } from "../../../shared/adeLayout"; export type AppContext = { db: AdeDb; @@ -764,7 +761,6 @@ export type AppContext = { embeddingService?: ReturnType<typeof createEmbeddingService> | null; embeddingWorkerService?: ReturnType<typeof createEmbeddingWorkerService> | null; ctoStateService?: ReturnType<typeof createCtoStateService> | null; - openclawBridgeService?: ReturnType<typeof createOpenclawBridgeService> | null; workerAgentService?: ReturnType<typeof createWorkerAgentService> | null; adeProjectService?: AdeProjectService | null; workerRevisionService?: ReturnType<typeof createWorkerRevisionService> | null; @@ -814,204 +810,6 @@ function escapeCsvCell(value: string | null | undefined): string { return /[",\r\n]/.test(input) ? `"${input.replace(/"/g, "\"\"")}"` : input; } -function sessionStatusBucket(args: { - status: string; - lastOutputPreview: string | null | undefined; - runtimeState?: string | null; -}): "running" | "awaiting-input" | "ended" { - if (args.status === "running") { - if (args.runtimeState === "waiting-input") return "awaiting-input"; - const preview = args.lastOutputPreview ?? ""; - if (/\b(?:waiting|awaiting)\b.{0,28}\b(?:input|confirmation|response|prompt)\b/i.test(preview)) { - return "awaiting-input"; - } - if (/\((?:y\/n|yes\/no)\)/i.test(preview) || /\[(?:y\/n|yes\/no)\]/i.test(preview)) { - return "awaiting-input"; - } - return "running"; - } - return "ended"; -} - -function summarizeLaneRuntime( - laneId: string, - sessions: Array<{ - laneId: string; - status: string; - lastOutputPreview: string | null; - runtimeState?: string | null; - }>, -): LaneRuntimeSummary { - let runningCount = 0; - let awaitingInputCount = 0; - let endedCount = 0; - let sessionCount = 0; - - for (const session of sessions) { - if (session.laneId !== laneId) continue; - sessionCount += 1; - const bucket = sessionStatusBucket(session); - if (bucket === "running") runningCount += 1; - else if (bucket === "awaiting-input") awaitingInputCount += 1; - else endedCount += 1; - } - - const bucket = awaitingInputCount > 0 - ? "awaiting-input" - : runningCount > 0 - ? "running" - : endedCount > 0 - ? "ended" - : "none"; - - return { - bucket, - runningCount, - awaitingInputCount, - endedCount, - sessionCount, - }; -} - -function buildLanePresenceByLaneId(syncService: ReturnType<typeof createSyncService> | null | undefined): Map<string, DeviceMarker[]> { - const hostService = syncService?.getHostService?.() ?? null; - const snapshot = hostService?.getLanePresenceSnapshot?.() ?? []; - return new Map(snapshot.map((entry) => [entry.laneId, entry.devicesOpen] as const)); -} - -function decorateLaneSummaryWithPresence( - lane: LaneSummary, - devicesOpenByLaneId: Map<string, DeviceMarker[]>, -): LaneSummary { - const devicesOpen = devicesOpenByLaneId.get(lane.id) ?? []; - return { ...lane, devicesOpen: devicesOpen.length > 0 ? devicesOpen : undefined }; -} - -function decorateLaneSummariesWithPresence( - lanes: LaneSummary[], - devicesOpenByLaneId: Map<string, DeviceMarker[]>, -): LaneSummary[] { - return lanes.map((lane) => decorateLaneSummaryWithPresence(lane, devicesOpenByLaneId)); -} - -async function enrichSessionsForLaneList( - args: Pick<AppContext, "sessionService" | "ptyService" | "agentChatService">, -): Promise<TerminalSessionSummary[]> { - let sessions = args.ptyService.enrichSessions(args.sessionService.list({})); - let allChats: AgentChatSessionSummary[] = []; - try { - allChats = await args.agentChatService.listSessions(undefined, { includeIdentity: true }); - } catch { - allChats = []; - } - const identitySessionIds = new Set( - allChats - .filter((chat) => Boolean(chat.identityKey)) - .map((chat) => chat.sessionId), - ); - if (identitySessionIds.size > 0) { - sessions = sessions.filter((session) => !identitySessionIds.has(session.id)); - } - const chats = allChats.filter((chat) => !chat.identityKey); - if (chats.length === 0) return sessions; - const chatSummaryBySessionId = new Map(chats.map((chat) => [chat.sessionId, chat] as const)); - return sessions.map((session) => { - if (!isChatToolType(session.toolType)) return session; - if (session.status !== "running") return session; - const chat = chatSummaryBySessionId.get(session.id); - if (!chat) return session; - if (chat.awaitingInput) return { ...session, runtimeState: "waiting-input" as const, chatIdleSinceAt: null }; - if (chat.status === "active") return { ...session, runtimeState: "running" as const, chatIdleSinceAt: null }; - if (chat.status === "idle") return { ...session, runtimeState: "idle" as const, chatIdleSinceAt: chat.idleSinceAt ?? null }; - return session; - }); -} - -async function buildLaneListSnapshots( - args: Pick<AppContext, "laneService" | "sessionService" | "ptyService" | "agentChatService" | "rebaseSuggestionService" | "autoRebaseService" | "conflictService" | "logger"> & { - syncService?: ReturnType<typeof createSyncService> | null; - }, - lanes: LaneSummary[], - options: { includeConflictStatus?: boolean; includeRebaseSuggestions?: boolean; includeAutoRebaseStatus?: boolean } = {}, -): Promise<LaneListSnapshot[]> { - const startedAt = Date.now(); - const phases: Array<{ phase: string; durationMs: number }> = []; - const timePhase = async <T>(phase: string, work: () => Promise<T> | T): Promise<T> => { - const phaseStartedAt = Date.now(); - try { - return await work(); - } finally { - const durationMs = Date.now() - phaseStartedAt; - phases.push({ phase, durationMs }); - if (durationMs >= 120) { - args.logger.info("lanes.listSnapshots.phase", { - phase, - durationMs, - laneCount: lanes.length, - includeConflictStatus: options.includeConflictStatus !== false, - includeRebaseSuggestions: options.includeRebaseSuggestions !== false, - includeAutoRebaseStatus: options.includeAutoRebaseStatus !== false, - }); - } - } - }; - - const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ - timePhase("sessions", () => enrichSessionsForLaneList(args)), - options.includeRebaseSuggestions === false - ? Promise.resolve([]) - : timePhase("rebase_suggestions", () => - Promise.resolve() - .then(() => args.rebaseSuggestionService?.listSuggestions({ lanes }) ?? []) - .catch(() => [])), - options.includeAutoRebaseStatus === false - ? Promise.resolve([]) - : timePhase("auto_rebase_statuses", () => - Promise.resolve() - .then(() => args.autoRebaseService?.listStatuses({ lanes }) ?? []) - .catch(() => [])), - timePhase("state_snapshots", () => - Promise.resolve() - .then(() => args.laneService.listStateSnapshots()) - .catch(() => [])), - options.includeConflictStatus === false - ? Promise.resolve(null) - : timePhase("conflict_assessment", () => - Promise.resolve() - .then(() => args.conflictService?.getBatchAssessment({ lanes }) ?? null) - .catch(() => null)), - ]); - const durationMs = Date.now() - startedAt; - if (durationMs >= 120) { - args.logger.info("lanes.listSnapshots.summary", { - durationMs, - laneCount: lanes.length, - includeConflictStatus: options.includeConflictStatus !== false, - includeRebaseSuggestions: options.includeRebaseSuggestions !== false, - includeAutoRebaseStatus: options.includeAutoRebaseStatus !== false, - phases: phases - .filter((phase) => phase.durationMs >= 10) - .sort((left, right) => right.durationMs - left.durationMs), - }); - } - - const rebaseByLaneId = new Map(rebaseSuggestions.map((entry) => [entry.laneId, entry] as const)); - const autoRebaseByLaneId = new Map(autoRebaseStatuses.map((entry) => [entry.laneId, entry] as const)); - const stateByLaneId = new Map(stateSnapshots.map((entry) => [entry.laneId, entry] as const)); - const conflictByLaneId = new Map((batchAssessment?.lanes ?? []).map((entry) => [entry.laneId, entry] as const)); - const devicesOpenByLaneId = buildLanePresenceByLaneId(args.syncService); - - return lanes.map((lane) => ({ - lane: decorateLaneSummaryWithPresence(lane, devicesOpenByLaneId), - runtime: summarizeLaneRuntime(lane.id, sessions), - rebaseSuggestion: rebaseByLaneId.get(lane.id) ?? null, - autoRebaseStatus: autoRebaseByLaneId.get(lane.id) ?? null, - conflictStatus: conflictByLaneId.get(lane.id) ?? null, - stateSnapshot: stateByLaneId.get(lane.id) ?? null, - adoptableAttached: lane.laneType === "attached" && lane.archivedAt == null, - })); -} - const AI_USAGE_FEATURE_KEYS: AiFeatureKey[] = [ "narratives", "conflict_proposals", @@ -1690,10 +1488,25 @@ async function buildLinearConnectionStatus( authMode: credentialStatus.authMode, oauthAvailable: credentialStatus.oauthConfigured, tokenExpiresAt: credentialStatus.tokenExpiresAt, - message: status.message, + message: formatLinearConnectionMessage(status.message, credentialStatus.authMode), }; } +function formatLinearConnectionMessage( + message: string | null | undefined, + authMode: "manual" | "oauth" | null | undefined, +): string | null { + const trimmed = message?.trim(); + if ( + authMode === "manual" + && trimmed + && /authentication required|not authenticated/i.test(trimmed) + ) { + return "Linear rejected the API key. Paste a Linear personal API key from linear.app/settings/api; it should start with lin_api_."; + } + return trimmed || null; +} + function summarizeProjectScan(result: OnboardingDetectionResult | null): Partial<{ projectSummary: string; criticalConventions: string[]; @@ -1913,6 +1726,12 @@ export function registerIpc({ getCtx, getSyncService, resolveSyncService, + runWithIpcWindow, + getWindowSession, + bindRemoteProject, + localRuntimeConnectionPool, + createWindow, + closeWindow, switchProjectFromDialog, closeCurrentProject, closeProjectByPath, @@ -1922,6 +1741,12 @@ export function registerIpc({ getCtx: () => AppContext; getSyncService?: () => ReturnType<typeof createSyncService> | null | undefined; resolveSyncService?: () => Promise<ReturnType<typeof createSyncService> | null | undefined>; + runWithIpcWindow?: <T>(event: { sender: Electron.WebContents }, fn: () => T | Promise<T>) => T | Promise<T>; + getWindowSession?: (windowId: number | null) => { windowId: number | null; project: ProjectInfo | null; binding: OpenProjectBinding | null }; + bindRemoteProject?: (windowId: number | null, binding: OpenProjectBinding & { kind: "remote" }) => void; + localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null; + createWindow?: (args?: { projectRoot?: string | null }) => Promise<{ windowId: number | null; project: ProjectInfo | null }>; + closeWindow?: (windowId: number | null) => Promise<{ closed: boolean }>; switchProjectFromDialog: (selectedPath: string) => Promise<ProjectInfo>; closeCurrentProject: () => Promise<void>; closeProjectByPath: (projectRoot: string) => Promise<void>; @@ -1939,6 +1764,9 @@ export function registerIpc({ if (getSyncService) return getSyncService() ?? null; return getCtx().syncService ?? null; }; + const allowLocalRuntimeFallback = + process.env.ADE_LOCAL_RUNTIME_FALLBACK === "1" || + process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON === "1"; const requireSyncService = async (): Promise<ReturnType<typeof createSyncService>> => { const service = resolveSyncService @@ -1950,6 +1778,32 @@ export function registerIpc({ return service; }; + const getLocalRuntimeRootForEvent = (event: { sender: Electron.WebContents }): string | null => { + if (!getWindowSession) return null; + const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; + const session = getWindowSession(windowId); + const binding = session?.binding; + if (binding?.kind === "local") return binding.rootPath; + return session?.project?.rootPath ?? null; + }; + + const tryLocalRuntimeSync = async <T>( + event: { sender: Electron.WebContents }, + action: (pool: LocalRuntimeConnectionPool, rootPath: string) => Promise<T>, + ): Promise<T | null> => { + if (!localRuntimeConnectionPool) return null; + const rootPath = getLocalRuntimeRootForEvent(event); + if (!rootPath) return null; + try { + return await action(localRuntimeConnectionPool, rootPath); + } catch (error) { + if (!allowLocalRuntimeFallback) { + throw error; + } + return null; + } + }; + // Backend services use Error.code for known failures (e.g. // "github_not_connected", "remote_already_exists"). Electron IPC strips // custom properties from thrown errors, so we re-throw with the code @@ -2110,8 +1964,21 @@ export function registerIpc({ type TracedIpcMain = typeof ipcMain & { __adeTraceWrapped?: boolean; __adeOriginalHandle?: typeof ipcMain.handle; + __adeWindowScopeWrapped?: boolean; + __adeWindowScopeOriginalHandle?: typeof ipcMain.handle; }; + const tracedIpcMain = ipcMain as TracedIpcMain; + if (runWithIpcWindow && !tracedIpcMain.__adeWindowScopeWrapped) { + const originalHandle = tracedIpcMain.handle.bind(ipcMain); + tracedIpcMain.__adeWindowScopeOriginalHandle = originalHandle; + tracedIpcMain.handle = ((channel, listener) => + originalHandle(channel, (event, ...args) => + runWithIpcWindow(event, () => listener(event, ...args)) + )) as typeof ipcMain.handle; + tracedIpcMain.__adeWindowScopeWrapped = true; + } + type IpcInvokeAggregate = { channel: string; winId: number | null; @@ -2197,7 +2064,6 @@ export function registerIpc({ } }; - const tracedIpcMain = ipcMain as TracedIpcMain; if (traceIpcInvokes && !tracedIpcMain.__adeTraceWrapped) { const originalHandle = tracedIpcMain.handle.bind(ipcMain); tracedIpcMain.__adeOriginalHandle = originalHandle; @@ -3215,6 +3081,45 @@ export function registerIpc({ return ctx.hasUserSelectedProject ? ctx.project : null; }); + ipcMain.handle(IPC.appGetWindowSession, async (event) => { + const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; + if (getWindowSession) return getWindowSession(windowId); + const ctx = getCtx(); + return { + windowId, + project: ctx.hasUserSelectedProject ? ctx.project : null, + binding: ctx.hasUserSelectedProject + ? { + kind: "local", + key: `local:${ctx.project.rootPath}`, + rootPath: ctx.project.rootPath, + displayName: ctx.project.displayName, + } + : null, + }; + }); + + ipcMain.handle(IPC.appNewWindow, async () => { + if (!createWindow) return { windowId: null }; + const result = await createWindow({ projectRoot: null }); + return { windowId: result.windowId }; + }); + + ipcMain.handle(IPC.appOpenProjectInNewWindow, async (_event, arg: { rootPath?: string }) => { + const rootPath = typeof arg?.rootPath === "string" ? arg.rootPath.trim() : ""; + if (!rootPath) throw new Error("rootPath is required"); + if (!createWindow) return { windowId: null, project: null }; + return createWindow({ projectRoot: rootPath }); + }); + + ipcMain.handle(IPC.appCloseWindow, async (event, arg: { windowId?: number | null } = {}) => { + const requestedWindowId = Number.isFinite(arg?.windowId) + ? Number(arg.windowId) + : BrowserWindow.fromWebContents(event.sender)?.id ?? null; + if (!closeWindow) return { closed: false }; + return closeWindow(requestedWindowId); + }); + ipcMain.handle(IPC.appOpenExternal, async (_event, arg: { url: string }): Promise<void> => { const urlRaw = typeof arg?.url === "string" ? arg.url.trim() : ""; if (!urlRaw) return; @@ -3605,7 +3510,8 @@ export function registerIpc({ env: { nodeEnv: process.env.NODE_ENV, viteDevServerUrl: process.env.VITE_DEV_SERVER_URL - } + }, + localRuntime: localRuntimeConnectionPool?.getStatus() ?? null }; }); @@ -3770,7 +3676,10 @@ export function registerIpc({ ipcMain.handle(IPC.projectClearLocalData, async (_event, arg: ClearLocalAdeDataArgs = {}): Promise<ClearLocalAdeDataResult> => { const ctx = getCtx(); - const adePaths = ctx.adeProjectService?.paths; + if (ctx.adeProjectService) { + return ctx.adeProjectService.clearLocalData(arg); + } + const clearedAt = nowIso(); const deletedPaths: string[] = []; @@ -3785,9 +3694,9 @@ export function registerIpc({ deletedPaths.push(resolved); }; - if (arg.packs) rmrf(adePaths?.artifactsDir ?? path.join(ctx.adeDir, "artifacts")); - if (arg.logs) rmrf(adePaths?.logsDir ?? path.join(ctx.adeDir, "transcripts", "logs")); - if (arg.transcripts) rmrf(adePaths?.transcriptsDir ?? path.join(ctx.adeDir, "transcripts")); + if (arg.packs) rmrf(path.join(ctx.adeDir, "artifacts")); + if (arg.logs) rmrf(path.join(ctx.adeDir, "transcripts", "logs")); + if (arg.transcripts) rmrf(path.join(ctx.adeDir, "transcripts")); return { deletedPaths, clearedAt }; }); @@ -3797,6 +3706,21 @@ export function registerIpc({ return (state.recentProjects ?? []).map(toRecentProjectSummary); }); + registerRuntimeBridge({ + appVersion: app.getVersion(), + bindRemoteProject, + getGitHubTokenForRemoteClone: () => { + try { + return getCtx().githubService.getTokenOrThrow(); + } catch { + return null; + } + }, + getWindowSession, + globalStatePath, + localRuntimeConnectionPool, + }); + ipcMain.handle( IPC.projectCreateLocal, async (_event, arg: CreateProjectInput): Promise<CreateProjectResult> => { @@ -4136,27 +4060,46 @@ export function registerIpc({ }, ); - ipcMain.handle(IPC.syncGetStatus, async (_event, arg?: SyncGetStatusArgs): Promise<SyncRoleSnapshot> => { + ipcMain.handle(IPC.syncGetStatus, async (event, arg?: SyncGetStatusArgs): Promise<SyncRoleSnapshot> => { + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.syncStatusForRoot(rootPath, arg ?? {}) + ); + if (runtimeStatus) return runtimeStatus; return await (await requireSyncService()).getStatus({ includeTransferReadiness: arg?.includeTransferReadiness, forceTransferReadiness: arg?.forceTransferReadiness, }); }); - ipcMain.handle(IPC.syncRefreshDiscovery, async (): Promise<SyncRoleSnapshot> => { + ipcMain.handle(IPC.syncRefreshDiscovery, async (event): Promise<SyncRoleSnapshot> => { + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.refreshSyncDiscoveryForRoot(rootPath) + ); + if (runtimeStatus) return runtimeStatus; return await (await requireSyncService()).refreshDiscovery(); }); - ipcMain.handle(IPC.syncListDevices, async (): Promise<SyncDeviceRuntimeState[]> => { + ipcMain.handle(IPC.syncListDevices, async (event): Promise<SyncDeviceRuntimeState[]> => { + const runtimeDevices = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.syncDevicesForRoot(rootPath) + ); + if (runtimeDevices) return runtimeDevices; return await (await requireSyncService()).listDevices(); }); ipcMain.handle( IPC.syncUpdateLocalDevice, async ( - _event, + event, arg: { name?: string; deviceType?: SyncPeerDeviceType }, ): Promise<SyncDeviceRecord> => { + const runtimeDevice = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.updateSyncLocalDeviceForRoot(rootPath, { + name: typeof arg?.name === "string" ? arg.name : undefined, + deviceType: arg?.deviceType, + }) + ); + if (runtimeDevice) return runtimeDevice; return await (await requireSyncService()).updateLocalDevice({ name: typeof arg?.name === "string" ? arg.name : undefined, deviceType: arg?.deviceType, @@ -4166,44 +4109,102 @@ export function registerIpc({ ipcMain.handle( IPC.syncConnectToBrain, - async (_event, arg: SyncDesktopConnectionDraft): Promise<SyncRoleSnapshot> => { + async (event, arg: SyncDesktopConnectionDraft): Promise<SyncRoleSnapshot> => { + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.callSyncForRoot<SyncRoleSnapshot>( + rootPath, + "sync.connectToBrain", + (arg ?? {}) as unknown as Record<string, unknown>, + ) + ); + if (runtimeStatus) return runtimeStatus; return await (await requireSyncService()).connectToBrain(arg); }, ); - ipcMain.handle(IPC.syncDisconnectFromBrain, async (): Promise<SyncRoleSnapshot> => { + ipcMain.handle(IPC.syncDisconnectFromBrain, async (event): Promise<SyncRoleSnapshot> => { + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.callSyncForRoot<SyncRoleSnapshot>(rootPath, "sync.disconnectFromBrain") + ); + if (runtimeStatus) return runtimeStatus; return await (await requireSyncService()).disconnectFromBrain(); }); - ipcMain.handle(IPC.syncForgetDevice, async (_event, arg: { deviceId: string }): Promise<SyncRoleSnapshot> => { - return await (await requireSyncService()).forgetDevice(typeof arg?.deviceId === "string" ? arg.deviceId : ""); + ipcMain.handle(IPC.syncForgetDevice, async (event, arg: { deviceId: string }): Promise<SyncRoleSnapshot> => { + const deviceId = typeof arg?.deviceId === "string" ? arg.deviceId : ""; + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.forgetSyncDeviceForRoot(rootPath, deviceId) + ); + if (runtimeStatus) return runtimeStatus; + return await (await requireSyncService()).forgetDevice(deviceId); }); - ipcMain.handle(IPC.syncGetTransferReadiness, async (): Promise<SyncTransferReadiness> => { + ipcMain.handle(IPC.syncGetTransferReadiness, async (event): Promise<SyncTransferReadiness> => { + const runtimeReadiness = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.callSyncForRoot<SyncTransferReadiness>(rootPath, "sync.getTransferReadiness") + ); + if (runtimeReadiness) return runtimeReadiness; return await (await requireSyncService()).getTransferReadiness(); }); - ipcMain.handle(IPC.syncTransferBrainToLocal, async (): Promise<SyncRoleSnapshot> => { + ipcMain.handle(IPC.syncTransferBrainToLocal, async (event): Promise<SyncRoleSnapshot> => { + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.callSyncForRoot<SyncRoleSnapshot>(rootPath, "sync.transferBrainToLocal") + ); + if (runtimeStatus) return runtimeStatus; return await (await requireSyncService()).transferBrainToLocal(); }); - ipcMain.handle(IPC.syncGetPin, async (): Promise<{ pin: string | null }> => { + ipcMain.handle(IPC.syncGetPin, async (event): Promise<{ pin: string | null }> => { + const runtimePin = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.syncPinForRoot(rootPath) + ); + if (runtimePin) return runtimePin; return { pin: (await requireSyncService()).getPin() }; }); - ipcMain.handle(IPC.syncSetPin, async (_event, pin: string): Promise<SyncRoleSnapshot> => { - return await (await requireSyncService()).setPin(typeof pin === "string" ? pin : ""); + ipcMain.handle(IPC.syncSetPin, async (event, pin: string): Promise<SyncRoleSnapshot> => { + const normalizedPin = typeof pin === "string" ? pin : ""; + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.setSyncPinForRoot(rootPath, normalizedPin) + ); + if (runtimeStatus) return runtimeStatus; + return await (await requireSyncService()).setPin(normalizedPin); }); - ipcMain.handle(IPC.syncClearPin, async (): Promise<SyncRoleSnapshot> => { + ipcMain.handle(IPC.syncGeneratePin, async (event): Promise<SyncRoleSnapshot> => { + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.generateSyncPinForRoot(rootPath) + ); + if (runtimeStatus) return runtimeStatus; + return await (await requireSyncService()).generatePin(); + }); + + ipcMain.handle(IPC.syncClearPin, async (event): Promise<SyncRoleSnapshot> => { + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.clearSyncPinForRoot(rootPath) + ); + if (runtimeStatus) return runtimeStatus; return await (await requireSyncService()).clearPin(); }); ipcMain.handle( IPC.syncSetActiveLanePresence, - async (_event, arg: { laneIds?: string[] | null }): Promise<void> => { + async (event, arg: { laneIds?: string[] | null }): Promise<void> => { + const laneIds = Array.isArray(arg?.laneIds) ? arg.laneIds : []; + const rootPath = getLocalRuntimeRootForEvent(event); + if (localRuntimeConnectionPool && rootPath) { + try { + await localRuntimeConnectionPool.callSyncForRoot(rootPath, "sync.setActiveLanePresence", { laneIds }); + return; + } catch (error) { + if (!allowLocalRuntimeFallback) { + throw error; + } + } + } await (await requireSyncService()).setActiveLanePresence( - Array.isArray(arg?.laneIds) ? arg.laneIds : [], + laneIds, ); }, ); @@ -6534,41 +6535,8 @@ export function registerIpc({ }); ipcMain.handle(IPC.computerUseReadArtifactPreview, async (_event, arg: { uri: string }): Promise<string | null> => { - const ctx = getCtx(); - const projectRoot = ctx.project.rootPath; - const layout = resolveAdeLayout(projectRoot); - // Only allow files under artifactsDir — consistent with the ade-artifact:// protocol - // handler in main.ts which validates exclusively against currentArtifactsDir. - const allowedRoots = [layout.artifactsDir]; - - const filePath = resolveRendererSuppliedPath(arg.uri, projectRoot); - // Canonicalize and verify the resolved path is inside an allowed artifact root. - const canonical = path.normalize(path.resolve(filePath)); - const inside = allowedRoots.some((root) => { - try { - resolvePathWithinRoot(root, canonical); - return true; - } catch { - return false; - } - }); - if (!inside) return null; - - // Cap preview size to 10 MB to avoid loading arbitrarily large files into memory. - const PREVIEW_SIZE_CAP = 10 * 1024 * 1024; - try { - const stat = await fs.promises.stat(canonical); - if (!stat.isFile()) return null; - if (stat.size > PREVIEW_SIZE_CAP) return null; - const buf = await fs.promises.readFile(canonical); - const ext = path.extname(canonical).replace(/^\./, "").toLowerCase(); - const mimeMap: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif", bmp: "image/bmp", svg: "image/svg+xml" }; - const mime = mimeMap[ext]; - if (!mime) return null; - return `data:${mime};base64,${buf.toString("base64")}`; - } catch { - return null; - } + const ctx = ensureComputerUseBroker(); + return ctx.computerUseArtifactBrokerService.readArtifactPreview(arg); }); ipcMain.handle(IPC.iosSimulatorGetStatus, async () => ensureIosSimulator().getStatus()); @@ -8628,27 +8596,47 @@ export function registerIpc({ return ctx.operationService.list(arg); }); - ipcMain.handle(IPC.historyExportOperations, async (event, arg: ExportHistoryArgs): Promise<ExportHistoryResult> => { + type HistoryExportIpcArgs = ExportHistoryArgs & { + rows?: OperationRecord[]; + project?: { + rootPath?: string | null; + displayName?: string | null; + } | null; + }; + + ipcMain.handle(IPC.historyExportOperations, async (event, arg: HistoryExportIpcArgs): Promise<ExportHistoryResult> => { const ctx = getCtx(); const format: "csv" | "json" = arg?.format === "csv" ? "csv" : "json"; const laneId = typeof arg?.laneId === "string" && arg.laneId.trim().length > 0 ? arg.laneId.trim() : undefined; const kind = typeof arg?.kind === "string" && arg.kind.trim().length > 0 ? arg.kind.trim() : undefined; const status = arg?.status; - const rows = ctx.operationService.list({ - laneId, - kind, - limit: typeof arg?.limit === "number" ? arg.limit : 1000 - }); + const rows = Array.isArray(arg?.rows) + ? arg.rows + : ctx.operationService.list({ + laneId, + kind, + limit: typeof arg?.limit === "number" ? arg.limit : 1000 + }); const filteredRows = status && status !== "all" ? rows.filter((row) => row.status === status) : rows; const exportedAt = nowIso(); - const projectSlug = ctx.project.displayName.replace(/[^a-zA-Z0-9._-]+/g, "_"); + const exportProject = arg?.project; + const projectDisplayName = + typeof exportProject?.displayName === "string" && exportProject.displayName.trim() + ? exportProject.displayName.trim() + : ctx.project.displayName; + const projectRoot = + typeof exportProject?.rootPath === "string" && exportProject.rootPath.trim() + ? exportProject.rootPath.trim() + : ctx.project.rootPath; + const projectSlug = projectDisplayName.replace(/[^a-zA-Z0-9._-]+/g, "_"); const dateStamp = exportedAt.slice(0, 10); - const defaultPath = path.join(ctx.project.rootPath, `ade-history-${projectSlug}-${dateStamp}.${format}`); + const defaultDir = fs.existsSync(projectRoot) ? projectRoot : app.getPath("documents"); + const defaultPath = path.join(defaultDir, `ade-history-${projectSlug}-${dateStamp}.${format}`); const win = BrowserWindow.fromWebContents(event.sender) ?? undefined; const result = win @@ -8681,8 +8669,8 @@ export function registerIpc({ { exportedAt, project: { - rootPath: ctx.project.rootPath, - displayName: ctx.project.displayName + rootPath: projectRoot, + displayName: projectDisplayName }, filters: { laneId: laneId ?? null, @@ -9256,36 +9244,6 @@ export function registerIpc({ return ctx.ctoStateService.updateIdentity(arg.patch ?? {}); }); - ipcMain.handle(IPC.ctoGetOpenclawState, async (): Promise<CtoGetOpenclawStateResult> => { - const ctx = getCtx(); - if (!ctx.openclawBridgeService) throw new Error("OpenClaw bridge service is not available."); - return ctx.openclawBridgeService.getState(); - }); - - ipcMain.handle(IPC.ctoUpdateOpenclawConfig, async (_event, arg: CtoUpdateOpenclawConfigArgs): Promise<CtoGetOpenclawStateResult> => { - const ctx = getCtx(); - if (!ctx.openclawBridgeService) throw new Error("OpenClaw bridge service is not available."); - return await ctx.openclawBridgeService.updateConfig(arg.patch ?? {}); - }); - - ipcMain.handle(IPC.ctoTestOpenclawConnection, async (_event, _arg: CtoTestOpenclawConnectionArgs = {}): Promise<CtoTestOpenclawConnectionResult> => { - const ctx = getCtx(); - if (!ctx.openclawBridgeService) throw new Error("OpenClaw bridge service is not available."); - return await ctx.openclawBridgeService.testConnection(); - }); - - ipcMain.handle(IPC.ctoListOpenclawMessages, async (_event, arg: CtoListOpenclawMessagesArgs = {}): Promise<CtoListOpenclawMessagesResult> => { - const ctx = getCtx(); - if (!ctx.openclawBridgeService) throw new Error("OpenClaw bridge service is not available."); - return ctx.openclawBridgeService.listMessages(arg.limit ?? 40); - }); - - ipcMain.handle(IPC.ctoSendOpenclawMessage, async (_event, arg: CtoSendOpenclawMessageArgs): Promise<CtoListOpenclawMessagesResult[number]> => { - const ctx = getCtx(); - if (!ctx.openclawBridgeService) throw new Error("OpenClaw bridge service is not available."); - return await ctx.openclawBridgeService.sendMessage(arg); - }); - // -- W3: Heartbeat & Activation -- ipcMain.handle(IPC.ctoTriggerAgentWakeup, async (_event, arg: CtoTriggerAgentWakeupArgs): Promise<CtoTriggerAgentWakeupResult> => { diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts new file mode 100644 index 000000000..881115a02 --- /dev/null +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -0,0 +1,375 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { IPC } from "../../../shared/ipc"; +import type { + OpenProjectBinding, + RemoteRuntimeTarget, +} from "../../../shared/types"; + +const ipcHandlers = vi.hoisted( + () => new Map<string, (...args: any[]) => unknown>(), +); +const browserWindowFromWebContents = vi.hoisted(() => vi.fn()); +const browserWindowGetAllWindows = vi.hoisted(() => vi.fn(() => [])); +const remoteRegistryGetMock = vi.hoisted(() => vi.fn()); +const remoteRegistryListMock = vi.hoisted(() => vi.fn(() => [])); +const remoteRegistrySaveMock = vi.hoisted(() => vi.fn()); +const remoteRegistryRemoveMock = vi.hoisted(() => vi.fn()); +const remoteConnectMock = vi.hoisted(() => vi.fn()); +const remoteProjectsForTargetMock = vi.hoisted(() => vi.fn()); +const remoteCallActionForTargetMock = vi.hoisted(() => vi.fn()); +const remoteCallSyncForTargetMock = vi.hoisted(() => vi.fn()); +const remoteCallMachineForTargetMock = vi.hoisted(() => vi.fn()); +const remoteDisconnectMock = vi.hoisted(() => vi.fn()); + +vi.mock("electron", () => ({ + BrowserWindow: { + fromWebContents: browserWindowFromWebContents, + getAllWindows: browserWindowGetAllWindows, + }, + ipcMain: { + handle: vi.fn((channel: string, handler: (...args: any[]) => unknown) => { + ipcHandlers.set(channel, handler); + }), + }, +})); + +vi.mock("../remoteRuntime/remoteTargetRegistry", () => ({ + RemoteTargetRegistry: vi.fn().mockImplementation(() => ({ + get: remoteRegistryGetMock, + list: remoteRegistryListMock, + save: remoteRegistrySaveMock, + remove: remoteRegistryRemoveMock, + })), +})); + +vi.mock("../remoteRuntime/remoteConnectionPool", () => ({ + RemoteConnectionPool: vi.fn().mockImplementation(() => ({ + connect: remoteConnectMock, + projectsForTarget: remoteProjectsForTargetMock, + callActionForTarget: remoteCallActionForTargetMock, + callSyncForTarget: remoteCallSyncForTargetMock, + callMachineForTarget: remoteCallMachineForTargetMock, + disconnect: remoteDisconnectMock, + })), +})); + +vi.mock("../remoteRuntime/runtimeDiscovery", () => ({ + discoverLanRuntimes: vi.fn(() => []), +})); + +vi.mock("../git/git", () => ({ + runGit: vi.fn(), +})); + +import { registerRuntimeBridge } from "./runtimeBridge"; + +const target: RemoteRuntimeTarget = { + id: "target-1", + name: "Remote", + hostname: "remote.example.test", + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, +}; + +function sender(id = 42) { + return { + id, + isDestroyed: vi.fn(() => false), + once: vi.fn(), + send: vi.fn(), + } as any; +} + +function eventForSender(nextSender = sender()) { + return { sender: nextSender } as any; +} + +function localBinding(rootPath = "/repo"): OpenProjectBinding { + return { + kind: "local", + key: `local:${rootPath}`, + rootPath, + displayName: "Repo", + }; +} + +describe("registerRuntimeBridge", () => { + beforeEach(() => { + ipcHandlers.clear(); + browserWindowFromWebContents.mockReset(); + browserWindowGetAllWindows.mockReset().mockReturnValue([]); + remoteRegistryGetMock.mockReset(); + remoteRegistryListMock.mockReset().mockReturnValue([]); + remoteRegistrySaveMock.mockReset(); + remoteRegistryRemoveMock.mockReset(); + remoteConnectMock.mockReset().mockResolvedValue({ + target, + arch: "darwin-arm64", + version: null, + projects: [], + }); + remoteProjectsForTargetMock.mockReset(); + remoteCallActionForTargetMock.mockReset(); + remoteCallSyncForTargetMock.mockReset(); + remoteCallMachineForTargetMock.mockReset(); + remoteDisconnectMock.mockReset(); + browserWindowFromWebContents.mockReturnValue({ id: 7 }); + }); + + it("forwards local project runtime actions with renderer client metadata for file watches", async () => { + const localRuntimeConnectionPool = { + callActionForRoot: vi.fn(async () => ({ + ok: true, + domain: "file", + action: "watchWorkspace", + result: { ok: true }, + statusHints: {}, + })), + }; + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + localRuntimeConnectionPool: localRuntimeConnectionPool as any, + getWindowSession: () => ({ + windowId: 7, + project: null, + binding: localBinding("/repo"), + }), + }); + + await expect( + ipcHandlers.get(IPC.localRuntimeCallAction)?.( + eventForSender(sender(101)), + { + request: { + domain: "file", + action: "watchWorkspace", + args: { workspaceId: "main" }, + }, + }, + ), + ).resolves.toMatchObject({ result: { ok: true } }); + + expect(localRuntimeConnectionPool.callActionForRoot).toHaveBeenCalledWith( + "/repo", + { + domain: "file", + action: "watchWorkspace", + args: { + workspaceId: "main", + __adeRuntimeClientId: 101, + }, + }, + ); + }); + + it("forwards remote project runtime actions through the selected target and project", async () => { + remoteRegistryGetMock.mockReturnValue(target); + remoteConnectMock.mockResolvedValue({ + target, + arch: "linux-x64", + version: "1.0.0", + projects: [], + }); + remoteCallActionForTargetMock.mockResolvedValue({ + ok: true, + domain: "pty", + action: "create", + result: { ptyId: "pty-1" }, + statusHints: {}, + }); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeCallAction)?.( + eventForSender(sender(202)), + { + id: "target-1", + projectId: "project-1", + request: { + domain: "pty", + action: "create", + args: { startupCommand: "codex login" }, + }, + }, + ), + ).resolves.toMatchObject({ result: { ptyId: "pty-1" } }); + + expect(remoteConnectMock).toHaveBeenCalledWith(target); + expect(remoteCallActionForTargetMock).toHaveBeenCalledWith( + target, + "project-1", + { + domain: "pty", + action: "create", + args: { startupCommand: "codex login" }, + }, + ); + }); + + it("rejects unexposed sync methods before calling local or remote runtimes", async () => { + const localRuntimeConnectionPool = { + callSyncForRoot: vi.fn(), + }; + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + localRuntimeConnectionPool: localRuntimeConnectionPool as any, + getWindowSession: () => ({ + windowId: 7, + project: null, + binding: localBinding("/repo"), + }), + }); + remoteRegistryGetMock.mockReturnValue(target); + + await expect( + ipcHandlers.get(IPC.localRuntimeCallSync)?.(eventForSender(), { + method: "git.status", + params: {}, + }), + ).rejects.toThrow(/not exposed/i); + await expect( + ipcHandlers.get(IPC.remoteRuntimeCallSync)?.(eventForSender(), { + id: "target-1", + projectId: "project-1", + method: "git.status", + params: {}, + }), + ).rejects.toThrow(/not exposed/i); + + expect(localRuntimeConnectionPool.callSyncForRoot).not.toHaveBeenCalled(); + expect(remoteCallSyncForTargetMock).not.toHaveBeenCalled(); + }); + + it("forwards allowlisted sync methods with project scope", async () => { + remoteRegistryGetMock.mockReturnValue(target); + remoteCallSyncForTargetMock.mockResolvedValue({ connectedPeers: [] }); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeCallSync)?.(eventForSender(), { + id: "target-1", + projectId: "project-1", + method: "sync.getStatus", + params: { includeTransferReadiness: true }, + }), + ).resolves.toEqual({ connectedPeers: [] }); + + expect(remoteCallSyncForTargetMock).toHaveBeenCalledWith( + target, + "project-1", + "sync.getStatus", + { + includeTransferReadiness: true, + }, + ); + }); + + it("opens a remote project after refreshing a stale connect project list", async () => { + const project = { + projectId: "project-1", + rootPath: "/srv/ade", + displayName: "ADE", + addedAt: 1, + lastOpenedAt: 2, + gitOriginUrl: "git@github.com:example/ade.git", + }; + const bindRemoteProject = vi.fn(); + remoteRegistryGetMock.mockReturnValue(target); + remoteConnectMock.mockResolvedValue({ + target, + arch: "linux-x64", + version: "1.0.0", + projects: [], + }); + remoteProjectsForTargetMock.mockResolvedValue([project]); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + bindRemoteProject, + }); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeOpenProject)?.( + eventForSender(sender(303)), + { + id: " target-1 ", + projectId: " project-1 ", + }, + ), + ).resolves.toEqual({ + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/srv/ade", + displayName: "ADE", + }); + + expect(remoteConnectMock).toHaveBeenCalledWith(target); + expect(remoteProjectsForTargetMock).toHaveBeenCalledWith(target); + expect(bindRemoteProject).toHaveBeenCalledWith(7, { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/srv/ade", + displayName: "ADE", + }); + }); + + it("forwards a one-shot local GitHub auth header for remote clones", async () => { + remoteRegistryGetMock.mockReturnValue(target); + remoteCallMachineForTargetMock.mockResolvedValue({ + projectId: "project-cloned", + rootPath: "/srv/ADE", + displayName: "ADE", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: "https://github.com/example/ADE.git", + }); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + getGitHubTokenForRemoteClone: () => "ghp_local_secret", + }); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeCloneProject)?.(eventForSender(), { + id: "target-1", + input: { + url: "https://github.com/example/ADE.git", + parentDir: "/srv", + }, + }), + ).resolves.toMatchObject({ rootPath: "/srv/ADE" }); + + const expectedBasic = Buffer.from( + "x-access-token:ghp_local_secret", + "utf8", + ).toString("base64"); + expect(remoteCallMachineForTargetMock).toHaveBeenCalledWith( + target, + "projects.clone", + { + url: "https://github.com/example/ADE.git", + parentDir: "/srv", + githubAuthHeader: `basic ${expectedBasic}`, + }, + { retryOnConnectionError: false }, + ); + }); +}); diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts new file mode 100644 index 000000000..816bb3101 --- /dev/null +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -0,0 +1,857 @@ +import { BrowserWindow, ipcMain, type WebContents } from "electron"; +import fs from "node:fs"; +import path from "node:path"; +import { IPC } from "../../../shared/ipc"; +import type { + CloneProjectInput, + CreateProjectInput, + ListMyGitHubReposInput, + ListMyGitHubReposResult, + OpenProjectBinding, + ProjectInfo, + ProjectBrowseInput, + ProjectBrowseResult, + ProjectDetail, + RemoteRuntimeConnectionSnapshot, + RemoteRuntimeActionRequest, + RemoteRuntimeActionResult, + RemoteRuntimeBufferedEvent, + RemoteRuntimeConnectResult, + RemoteRuntimeDiscoveredMachine, + RemoteRuntimeEventNotificationPayload, + RemoteRuntimeLocalWorkCheckResult, + RemoteRuntimeProjectRecord, + RemoteRuntimeProjectWorkSummary, + RemoteRuntimeStreamEventsRequest, + RemoteRuntimeStreamEventsResult, + RemoteRuntimeTarget, + RemoteRuntimeTargetInput, +} from "../../../shared/types"; +import type { LocalRuntimeConnectionPool } from "../localRuntime/localRuntimeConnectionPool"; +import { RemoteConnectionPool } from "../remoteRuntime/remoteConnectionPool"; +import { RemoteConnectionService } from "../remoteRuntime/remoteConnectionService"; +import { discoverLanRuntimes } from "../remoteRuntime/runtimeDiscovery"; +import { RemoteTargetRegistry } from "../remoteRuntime/remoteTargetRegistry"; +import { runGit } from "../git/git"; +import { getProjectWorkSummary } from "../projects/projectDetailService"; +import { toRecentProjectSummary } from "../projects/recentProjectSummary"; +import { readGlobalState } from "../state/globalState"; + +type RuntimeBridgeArgs = { + appVersion: string; + globalStatePath: string; + getWindowSession?: (windowId: number | null) => { + windowId: number | null; + project: ProjectInfo | null; + binding: OpenProjectBinding | null; + }; + bindRemoteProject?: ( + windowId: number | null, + binding: OpenProjectBinding & { kind: "remote" }, + ) => void; + localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null; + getGitHubTokenForRemoteClone?: (() => string | null) | null; +}; + +const RUNTIME_ACTION_CLIENT_ID_FIELD = "__adeRuntimeClientId"; +const REMOTE_RUNTIME_SYNC_METHODS = new Set([ + "sync.getStatus", + "sync.refreshDiscovery", + "sync.listDevices", + "sync.updateLocalDevice", + "sync.connectToBrain", + "sync.disconnectFromBrain", + "sync.forgetDevice", + "sync.getTransferReadiness", + "sync.transferBrainToLocal", + "sync.getPin", + "sync.setPin", + "sync.generatePin", + "sync.clearPin", + "sync.setActiveLanePresence", +]); + +type RuntimeEventWindowSubscription = { + bindingKey: string; + cleanup: (() => void) | null; +}; + +type RuntimeEventSubscribe = ( + onEvent: (event: RemoteRuntimeBufferedEvent) => void, + onEnded: () => void, +) => Promise<() => void>; + +function isObjectRecord(value: unknown): value is Record<string, unknown> { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isRemoteRuntimeSyncMethod(value: string): boolean { + return REMOTE_RUNTIME_SYNC_METHODS.has(value); +} + +function withRuntimeActionClientMetadata( + request: RemoteRuntimeActionRequest, + senderId: number, +): RemoteRuntimeActionRequest { + if ( + request.domain !== "file" || + (request.action !== "watchWorkspace" && + request.action !== "stopWatching") || + !Number.isInteger(senderId) || + senderId <= 0 + ) { + return request; + } + + const args = isObjectRecord(request.args) ? request.args : {}; + return { + ...request, + args: { + ...args, + [RUNTIME_ACTION_CLIENT_ID_FIELD]: senderId, + }, + }; +} + +function normalizeGitRemoteForComparison( + value: string | null | undefined, +): string | null { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) return null; + const withoutGitSuffix = trimmed.replace(/\.git$/i, ""); + if (!withoutGitSuffix.includes("://")) { + const scpLike = /^(?:[^@/:]+@)?([^:]+):(.+)$/.exec(withoutGitSuffix); + if (scpLike?.[1] && scpLike[2]) { + return `${scpLike[1].toLowerCase()}/${scpLike[2].replace(/^\/+/, "")}`.toLowerCase(); + } + } + try { + const parsed = new URL(withoutGitSuffix); + return `${parsed.hostname.toLowerCase()}/${parsed.pathname.replace(/^\/+/, "")}`.toLowerCase(); + } catch { + return withoutGitSuffix.toLowerCase(); + } +} + +async function inspectLocalWorkForRemoteOrigin(args: { + rootPath: string; + displayName: string; + remoteOriginKey: string; +}): Promise<RemoteRuntimeLocalWorkCheckResult["matches"][number] | null> { + if (!fs.existsSync(args.rootPath)) return null; + const origin = await runGit(["remote", "get-url", "origin"], { + cwd: args.rootPath, + timeoutMs: 8_000, + }); + if (origin.exitCode !== 0) return null; + const originUrl = origin.stdout.trim(); + if (normalizeGitRemoteForComparison(originUrl) !== args.remoteOriginKey) + return null; + const workSummary = await getProjectWorkSummary(args.rootPath).catch( + () => null, + ); + const dirtyCount = workSummary?.dirtyFileCount ?? 0; + if (dirtyCount <= 0) return null; + return { + rootPath: args.rootPath, + displayName: args.displayName, + gitOriginUrl: originUrl, + dirtyCount, + workSummary, + }; +} + +async function getRemoteProjectWorkSummary(args: { + targetId: string; + rootPath: string | null; + remoteConnectionService: RemoteConnectionService; +}): Promise<RemoteRuntimeProjectWorkSummary | null> { + if (!args.targetId || !args.rootPath) return null; + return await args.remoteConnectionService + .getProjectWorkSummary(args.targetId, args.rootPath) + .catch(() => null); +} + +function createGitHubAuthHeader(token: string | null | undefined): string | null { + const trimmed = token?.trim(); + if (!trimmed) return null; + const basic = Buffer.from(`x-access-token:${trimmed}`, "utf8").toString("base64"); + return `basic ${basic}`; +} + +export function registerRuntimeBridge({ + appVersion, + bindRemoteProject, + getGitHubTokenForRemoteClone, + getWindowSession, + globalStatePath, + localRuntimeConnectionPool, +}: RuntimeBridgeArgs): void { + const remoteTargetRegistry = new RemoteTargetRegistry(); + const remoteConnectionPool = new RemoteConnectionPool( + remoteTargetRegistry, + appVersion, + ); + const remoteConnectionService = new RemoteConnectionService( + remoteTargetRegistry, + remoteConnectionPool, + ); + const runtimeEventSubscriptions = new Map< + number, + RuntimeEventWindowSubscription + >(); + const runtimeEventWatchedSenders = new Set<number>(); + + remoteConnectionService.onSnapshotChanged((snapshot) => { + for (const window of BrowserWindow.getAllWindows()) { + if (window.webContents.isDestroyed()) continue; + window.webContents.send( + IPC.remoteRuntimeConnectionSnapshotChanged, + snapshot, + ); + } + }); + const autoconnectTimer = setTimeout(() => { + remoteConnectionService.startAutoconnect(); + }, 0); + autoconnectTimer.unref?.(); + + const cleanupRuntimeEventSubscription = (senderId: number): void => { + const existing = runtimeEventSubscriptions.get(senderId); + runtimeEventSubscriptions.delete(senderId); + try { + existing?.cleanup?.(); + } catch { + // Best-effort subscription cleanup. + } + }; + + const watchRuntimeEventSender = (sender: WebContents): void => { + if (runtimeEventWatchedSenders.has(sender.id)) return; + runtimeEventWatchedSenders.add(sender.id); + sender.once("destroyed", () => { + runtimeEventWatchedSenders.delete(sender.id); + cleanupRuntimeEventSubscription(sender.id); + }); + }; + + const sendRuntimeEvent = ( + sender: WebContents, + bindingKey: string, + event: RemoteRuntimeBufferedEvent, + ): void => { + const existing = runtimeEventSubscriptions.get(sender.id); + if (!existing || existing.bindingKey !== bindingKey || sender.isDestroyed()) + return; + const payload: RemoteRuntimeEventNotificationPayload = { + bindingKey, + event, + }; + try { + sender.send(IPC.runtimeEvent, payload); + } catch { + // Renderer may have gone away between the destroyed check and send. + } + }; + + const ensureRuntimeEventSubscription = ( + sender: WebContents, + bindingKey: string, + subscribe: RuntimeEventSubscribe, + ): void => { + const existing = runtimeEventSubscriptions.get(sender.id); + if (existing?.bindingKey === bindingKey) return; + cleanupRuntimeEventSubscription(sender.id); + watchRuntimeEventSender(sender); + runtimeEventSubscriptions.set(sender.id, { bindingKey, cleanup: null }); + const onEnded = () => { + const current = runtimeEventSubscriptions.get(sender.id); + if (current?.bindingKey === bindingKey) { + runtimeEventSubscriptions.delete(sender.id); + } + }; + void subscribe( + (event) => sendRuntimeEvent(sender, bindingKey, event), + onEnded, + ) + .then((cleanup) => { + const current = runtimeEventSubscriptions.get(sender.id); + if ( + !current || + current.bindingKey !== bindingKey || + sender.isDestroyed() + ) { + cleanup(); + return; + } + current.cleanup = cleanup; + }) + .catch((error) => { + const current = runtimeEventSubscriptions.get(sender.id); + if (current?.bindingKey === bindingKey && !current.cleanup) { + runtimeEventSubscriptions.delete(sender.id); + } + console.warn("Runtime event subscription failed", error); + }); + }; + + ipcMain.handle( + IPC.remoteRuntimeListTargets, + async (): Promise<RemoteRuntimeTarget[]> => { + return remoteConnectionService.listTargets(); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeGetConnectionSnapshot, + async (): Promise<RemoteRuntimeConnectionSnapshot> => { + return remoteConnectionService.snapshot(); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeListDiscoveredMachines, + async (): Promise<RemoteRuntimeDiscoveredMachine[]> => { + return discoverLanRuntimes(); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeSaveTarget, + async ( + _event, + arg: RemoteRuntimeTargetInput, + ): Promise<RemoteRuntimeTarget> => { + return remoteConnectionService.saveTarget(arg); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeRemoveTarget, + async (_event, arg: { id: string }): Promise<{ removed: boolean }> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + if (!id) return { removed: false }; + return { removed: remoteConnectionService.removeTarget(id) }; + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeConnect, + async ( + _event, + arg: { id: string }, + ): Promise<RemoteRuntimeConnectResult> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + return await remoteConnectionService.connect(id); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeListProjects, + async ( + _event, + arg: { id: string }, + ): Promise<RemoteRuntimeProjectRecord[]> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + if (!id) return []; + return await remoteConnectionService.projects(id); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeAddProject, + async ( + _event, + arg: { id: string; rootPath: string }, + ): Promise<RemoteRuntimeProjectRecord> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const rootPath = + typeof arg?.rootPath === "string" ? arg.rootPath.trim() : ""; + if (!rootPath) throw new Error("Remote project path is required."); + return await remoteConnectionService.addProject(id, rootPath); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeBrowseDirectories, + async ( + _event, + arg: { id: string; args?: ProjectBrowseInput }, + ): Promise<ProjectBrowseResult> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + return await remoteConnectionService.browseDirectories( + id, + arg?.args ?? {}, + ); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeGetProjectDetail, + async ( + _event, + arg: { id: string; rootPath: string }, + ): Promise<ProjectDetail> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const rootPath = + typeof arg?.rootPath === "string" ? arg.rootPath.trim() : ""; + if (!rootPath) throw new Error("Remote project path is required."); + return await remoteConnectionService.getProjectDetail(id, rootPath); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeGetDefaultParentDir, + async (_event, arg: { id: string }): Promise<string> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + return await remoteConnectionService.getDefaultParentDir(id); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeCreateProject, + async ( + _event, + arg: { id: string; input?: CreateProjectInput }, + ): Promise<RemoteRuntimeProjectRecord> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + return await remoteConnectionService.createProject( + id, + arg?.input ?? { name: "", parentDir: "" }, + ); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeCloneProject, + async ( + _event, + arg: { id: string; input?: CloneProjectInput }, + ): Promise<RemoteRuntimeProjectRecord> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const input = arg?.input ?? { url: "", parentDir: "" }; + let githubAuthHeader: string | null = null; + try { + githubAuthHeader = createGitHubAuthHeader( + getGitHubTokenForRemoteClone?.() ?? null, + ); + } catch { + githubAuthHeader = null; + } + return await remoteConnectionService.cloneProject( + id, + githubAuthHeader && !input.githubAuthHeader + ? { ...input, githubAuthHeader } + : input, + ); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeListMyGitHubRepos, + async ( + _event, + arg: { id: string; input?: ListMyGitHubReposInput }, + ): Promise<ListMyGitHubReposResult> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + return await remoteConnectionService.listMyGitHubRepos( + id, + arg?.input ?? {}, + ); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeOpenProject, + async ( + event, + arg: { id: string; projectId: string }, + ): Promise<OpenProjectBinding & { kind: "remote" }> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const projectId = + typeof arg?.projectId === "string" ? arg.projectId.trim() : ""; + const target = id ? remoteConnectionService.getTarget(id) : null; + if (!target) throw new Error("Remote target was not found."); + if (!projectId) throw new Error("Remote project is required."); + + const connection = await remoteConnectionService.connect(target.id); + let project = + connection.projects.find( + (candidate) => candidate.projectId === projectId, + ) ?? null; + if (!project) { + const projects = await remoteConnectionService.projects(target.id); + project = + projects.find((candidate) => candidate.projectId === projectId) ?? + null; + } + if (!project) + throw new Error("Remote project was not found on this runtime."); + + const binding: OpenProjectBinding & { kind: "remote" } = { + kind: "remote", + key: `remote:${target.id}:${project.projectId}`, + targetId: target.id, + runtimeName: target.name, + projectId: project.projectId, + rootPath: project.rootPath, + displayName: project.displayName || path.basename(project.rootPath), + }; + bindRemoteProject?.( + BrowserWindow.fromWebContents(event.sender)?.id ?? null, + binding, + ); + return binding; + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeCallAction, + async ( + event, + arg: { + id: string; + projectId: string; + request: RemoteRuntimeActionRequest; + }, + ): Promise<RemoteRuntimeActionResult> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const projectId = + typeof arg?.projectId === "string" ? arg.projectId.trim() : ""; + const request = + arg?.request && + typeof arg.request === "object" && + !Array.isArray(arg.request) + ? arg.request + : null; + const target = id ? remoteConnectionService.getTarget(id) : null; + const domain = + typeof request?.domain === "string" ? request.domain.trim() : ""; + const action = + typeof request?.action === "string" ? request.action.trim() : ""; + if (!target) throw new Error("Remote target was not found."); + if (!projectId) throw new Error("Remote project is required."); + if (!domain || !action) + throw new Error("Remote action domain and action are required."); + await remoteConnectionService.connect(target.id); + const actionRequest = withRuntimeActionClientMetadata( + { ...request!, domain, action }, + event.sender.id, + ); + return await remoteConnectionPool.callActionForTarget( + target, + projectId, + actionRequest, + ); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeCallSync, + async ( + _event, + arg: { + id: string; + projectId: string; + method: string; + params?: Record<string, unknown>; + }, + ): Promise<unknown> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const projectId = + typeof arg?.projectId === "string" ? arg.projectId.trim() : ""; + const method = typeof arg?.method === "string" ? arg.method.trim() : ""; + const params = isObjectRecord(arg?.params) ? arg.params : {}; + const target = id ? remoteConnectionService.getTarget(id) : null; + if (!target) throw new Error("Remote target was not found."); + if (!projectId) throw new Error("Remote project is required."); + if (!isRemoteRuntimeSyncMethod(method)) + throw new Error("Remote sync method is not exposed."); + await remoteConnectionService.connect(target.id); + return await remoteConnectionPool.callSyncForTarget( + target, + projectId, + method, + params, + ); + }, + ); + + ipcMain.handle( + IPC.localRuntimeCallAction, + async ( + event, + arg: { request: RemoteRuntimeActionRequest }, + ): Promise<RemoteRuntimeActionResult> => { + if (!localRuntimeConnectionPool) { + throw new Error("Local runtime daemon is not available."); + } + const request = + arg?.request && + typeof arg.request === "object" && + !Array.isArray(arg.request) + ? arg.request + : null; + const domain = + typeof request?.domain === "string" ? request.domain.trim() : ""; + const action = + typeof request?.action === "string" ? request.action.trim() : ""; + if (!domain || !action) + throw new Error("Local runtime action domain and action are required."); + + const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; + const session = getWindowSession ? getWindowSession(windowId) : null; + const binding = session?.binding; + const rootPath = + binding?.kind === "local" + ? binding.rootPath + : (session?.project?.rootPath ?? null); + if (!rootPath) { + throw new Error( + "Local runtime project is not available for this window.", + ); + } + const actionRequest = withRuntimeActionClientMetadata( + { ...request!, domain, action }, + event.sender.id, + ); + return await localRuntimeConnectionPool.callActionForRoot( + rootPath, + actionRequest, + ); + }, + ); + + ipcMain.handle( + IPC.localRuntimeCallSync, + async ( + event, + arg: { method: string; params?: Record<string, unknown> }, + ): Promise<unknown> => { + if (!localRuntimeConnectionPool) { + throw new Error("Local runtime daemon is not available."); + } + const method = typeof arg?.method === "string" ? arg.method.trim() : ""; + const params = isObjectRecord(arg?.params) ? arg.params : {}; + if (!isRemoteRuntimeSyncMethod(method)) + throw new Error("Local sync method is not exposed."); + + const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; + const session = getWindowSession ? getWindowSession(windowId) : null; + const binding = session?.binding; + const rootPath = + binding?.kind === "local" + ? binding.rootPath + : (session?.project?.rootPath ?? null); + if (!rootPath) { + throw new Error( + "Local runtime project is not available for this window.", + ); + } + return await localRuntimeConnectionPool.callSyncForRoot( + rootPath, + method, + params, + ); + }, + ); + + ipcMain.handle( + IPC.localRuntimeStreamEvents, + async ( + event, + arg: { request?: RemoteRuntimeStreamEventsRequest }, + ): Promise<RemoteRuntimeStreamEventsResult> => { + if (!localRuntimeConnectionPool) { + throw new Error("Local runtime daemon is not available."); + } + + const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; + const session = getWindowSession ? getWindowSession(windowId) : null; + const binding = session?.binding; + const rootPath = + binding?.kind === "local" + ? binding.rootPath + : (session?.project?.rootPath ?? null); + if (!rootPath) { + return { events: [], nextCursor: 0, hasMore: false }; + } + if (binding?.kind === "local") { + ensureRuntimeEventSubscription( + event.sender, + binding.key, + (onEvent, onEnded) => + localRuntimeConnectionPool.subscribeEventsForRoot( + rootPath, + { + cursor: arg?.request?.cursor, + limit: arg?.request?.limit, + category: "runtime", + }, + onEvent, + onEnded, + ), + ); + } + return await localRuntimeConnectionPool.streamEventsForRoot( + rootPath, + arg?.request ?? {}, + ); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeStreamEvents, + async ( + event, + arg: { + id: string; + projectId: string; + request?: RemoteRuntimeStreamEventsRequest; + }, + ): Promise<RemoteRuntimeStreamEventsResult> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const projectId = + typeof arg?.projectId === "string" ? arg.projectId.trim() : ""; + if (!id) throw new Error("Remote target id is required."); + if (!projectId) throw new Error("Remote project id is required."); + const target = remoteConnectionService.getTarget(id); + if (!target) throw new Error("Remote target was not found."); + await remoteConnectionService.connect(target.id); + ensureRuntimeEventSubscription( + event.sender, + `remote:${target.id}:${projectId}`, + (onEvent, onEnded) => + remoteConnectionPool.subscribeEventsForTarget( + target, + projectId, + { + cursor: arg?.request?.cursor, + limit: arg?.request?.limit, + category: "runtime", + }, + onEvent, + onEnded, + ), + ); + return remoteConnectionPool.streamEventsForTarget( + target, + projectId, + arg?.request ?? {}, + ); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeCheckLocalWork, + async ( + _event, + arg: { id?: string; project?: RemoteRuntimeProjectRecord }, + ): Promise<RemoteRuntimeLocalWorkCheckResult> => { + const targetId = typeof arg?.id === "string" ? arg.id.trim() : ""; + const project = + arg?.project && + typeof arg.project === "object" && + !Array.isArray(arg.project) + ? arg.project + : null; + const remoteProjectId = + typeof project?.projectId === "string" ? project.projectId : ""; + const remoteDisplayName = + typeof project?.displayName === "string" && project.displayName.trim() + ? project.displayName.trim() + : typeof project?.rootPath === "string" + ? path.basename(project.rootPath) + : "remote project"; + const remoteGitOriginUrl = + typeof project?.gitOriginUrl === "string" && project.gitOriginUrl.trim() + ? project.gitOriginUrl.trim() + : null; + const remoteWorkSummary = await getRemoteProjectWorkSummary({ + targetId, + rootPath: + typeof project?.rootPath === "string" && project.rootPath.trim() + ? project.rootPath.trim() + : null, + remoteConnectionService, + }); + const remoteOriginKey = + normalizeGitRemoteForComparison(remoteGitOriginUrl); + if (!remoteOriginKey) { + return { + remoteProjectId, + remoteDisplayName, + remoteGitOriginUrl, + remoteWorkSummary, + matches: [], + hasDirtyWork: false, + }; + } + + const state = readGlobalState(globalStatePath); + const recents = (state.recentProjects ?? []) + .slice(0, 100) + .map((entry) => ({ + rootPath: entry.rootPath, + displayName: toRecentProjectSummary(entry).displayName, + })); + const localRuntimeProjects = localRuntimeConnectionPool + ? await localRuntimeConnectionPool + .projects() + .catch(() => [] as RemoteRuntimeProjectRecord[]) + : []; + const entriesByRoot = new Map< + string, + { rootPath: string; displayName: string } + >(); + for (const entry of recents) { + if (!entry.rootPath) continue; + entriesByRoot.set(path.resolve(entry.rootPath), entry); + } + for (const project of localRuntimeProjects) { + if (!project.rootPath) continue; + const rootPath = path.resolve(project.rootPath); + if (entriesByRoot.has(rootPath)) continue; + entriesByRoot.set(rootPath, { + rootPath: project.rootPath, + displayName: project.displayName || path.basename(project.rootPath), + }); + } + const matches = ( + await Promise.all( + [...entriesByRoot.values()].map((entry) => + inspectLocalWorkForRemoteOrigin({ + rootPath: entry.rootPath, + displayName: entry.displayName, + remoteOriginKey, + }), + ), + ) + ).filter( + ( + entry, + ): entry is RemoteRuntimeLocalWorkCheckResult["matches"][number] => + entry != null, + ); + + return { + remoteProjectId, + remoteDisplayName, + remoteGitOriginUrl, + remoteWorkSummary, + matches, + hasDirtyWork: matches.length > 0, + }; + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeDisconnect, + async (_event, arg: { id: string }): Promise<{ disconnected: boolean }> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + if (!id) return { disconnected: false }; + remoteConnectionService.disconnect(id); + return { disconnected: true }; + }, + ); +} diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts new file mode 100644 index 000000000..5b3d2dd0b --- /dev/null +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts @@ -0,0 +1,259 @@ +import type { + AgentChatSessionSummary, + DeviceMarker, + LaneListSnapshot, + LaneRuntimeSummary, + LaneStateSnapshotSummary, + LaneSummary, + TerminalSessionSummary, +} from "../../../shared/types"; +import type { Logger } from "../logging/logger"; + +type LanePresenceHost = { + getLanePresenceSnapshot?: () => Array<{ laneId: string; devicesOpen: DeviceMarker[] }>; +}; + +type LanePresenceSyncService = { + getHostService?: () => LanePresenceHost | null | undefined; +}; + +type LaneListSnapshotServices = { + laneService: { + listStateSnapshots: () => Promise<LaneStateSnapshotSummary[]> | LaneStateSnapshotSummary[]; + }; + sessionService: { + list: (args: Record<string, unknown>) => TerminalSessionSummary[]; + }; + ptyService: { + enrichSessions: <T extends TerminalSessionSummary>(rows: T[]) => T[]; + }; + agentChatService?: { + listSessions: ( + laneId?: string, + options?: { includeIdentity?: boolean }, + ) => Promise<AgentChatSessionSummary[]> | AgentChatSessionSummary[]; + } | null; + rebaseSuggestionService?: { + listSuggestions: (args?: { lanes?: LaneSummary[] }) => + | Promise<Array<NonNullable<LaneListSnapshot["rebaseSuggestion"]>>> + | Array<NonNullable<LaneListSnapshot["rebaseSuggestion"]>>; + } | null; + autoRebaseService?: { + listStatuses: (args?: { lanes?: LaneSummary[] }) => + | Promise<Array<NonNullable<LaneListSnapshot["autoRebaseStatus"]>>> + | Array<NonNullable<LaneListSnapshot["autoRebaseStatus"]>>; + } | null; + conflictService?: { + getBatchAssessment: (args: { lanes: LaneSummary[] }) => + | Promise<{ lanes?: Array<NonNullable<LaneListSnapshot["conflictStatus"]>> } | null> + | { lanes?: Array<NonNullable<LaneListSnapshot["conflictStatus"]>> } | null; + } | null; + syncService?: LanePresenceSyncService | null; + logger: Pick<Logger, "info">; +}; + +export type LaneListSnapshotOptions = { + includeConflictStatus?: boolean; + includeRebaseSuggestions?: boolean; + includeAutoRebaseStatus?: boolean; +}; + +function isChatToolType(toolType: string | null | undefined): boolean { + if (!toolType) return false; + const t = toolType.trim().toLowerCase(); + return t === "cursor" || t.endsWith("-chat"); +} + +function sessionStatusBucket(args: { + status: string; + lastOutputPreview: string | null | undefined; + runtimeState?: string | null; +}): "running" | "awaiting-input" | "ended" { + if (args.status === "running") { + if (args.runtimeState === "waiting-input") return "awaiting-input"; + const preview = args.lastOutputPreview ?? ""; + if (/\b(?:waiting|awaiting)\b.{0,28}\b(?:input|confirmation|response|prompt)\b/i.test(preview)) { + return "awaiting-input"; + } + if (/\((?:y\/n|yes\/no)\)/i.test(preview) || /\[(?:y\/n|yes\/no)\]/i.test(preview)) { + return "awaiting-input"; + } + return "running"; + } + return "ended"; +} + +function summarizeLaneRuntime( + laneId: string, + sessions: Array<{ + laneId: string; + status: string; + lastOutputPreview: string | null; + runtimeState?: string | null; + }>, +): LaneRuntimeSummary { + let runningCount = 0; + let awaitingInputCount = 0; + let endedCount = 0; + let sessionCount = 0; + + for (const session of sessions) { + if (session.laneId !== laneId) continue; + sessionCount += 1; + const bucket = sessionStatusBucket(session); + if (bucket === "running") runningCount += 1; + else if (bucket === "awaiting-input") awaitingInputCount += 1; + else endedCount += 1; + } + + let bucket: LaneRuntimeSummary["bucket"]; + if (awaitingInputCount > 0) bucket = "awaiting-input"; + else if (runningCount > 0) bucket = "running"; + else if (endedCount > 0) bucket = "ended"; + else bucket = "none"; + + return { + bucket, + runningCount, + awaitingInputCount, + endedCount, + sessionCount, + }; +} + +export function buildLanePresenceByLaneId(syncService: LanePresenceSyncService | null | undefined): Map<string, DeviceMarker[]> { + const hostService = syncService?.getHostService?.() ?? null; + const snapshot = hostService?.getLanePresenceSnapshot?.() ?? []; + return new Map(snapshot.map((entry) => [entry.laneId, entry.devicesOpen] as const)); +} + +function decorateLaneSummaryWithPresence( + lane: LaneSummary, + devicesOpenByLaneId: Map<string, DeviceMarker[]>, +): LaneSummary { + const devicesOpen = devicesOpenByLaneId.get(lane.id) ?? []; + return { ...lane, devicesOpen: devicesOpen.length > 0 ? devicesOpen : undefined }; +} + +export function decorateLaneSummariesWithPresence( + lanes: LaneSummary[], + devicesOpenByLaneId: Map<string, DeviceMarker[]>, +): LaneSummary[] { + return lanes.map((lane) => decorateLaneSummaryWithPresence(lane, devicesOpenByLaneId)); +} + +async function enrichSessionsForLaneList( + args: Pick<LaneListSnapshotServices, "sessionService" | "ptyService" | "agentChatService">, +): Promise<TerminalSessionSummary[]> { + let sessions = args.ptyService.enrichSessions(args.sessionService.list({})); + let allChats: AgentChatSessionSummary[] = []; + try { + allChats = await (args.agentChatService?.listSessions(undefined, { includeIdentity: true }) ?? []); + } catch { + allChats = []; + } + const identitySessionIds = new Set( + allChats + .filter((chat) => Boolean(chat.identityKey)) + .map((chat) => chat.sessionId), + ); + if (identitySessionIds.size > 0) { + sessions = sessions.filter((session) => !identitySessionIds.has(session.id)); + } + const chats = allChats.filter((chat) => !chat.identityKey); + if (chats.length === 0) return sessions; + const chatSummaryBySessionId = new Map(chats.map((chat) => [chat.sessionId, chat] as const)); + return sessions.map((session) => { + if (!isChatToolType(session.toolType)) return session; + if (session.status !== "running") return session; + const chat = chatSummaryBySessionId.get(session.id); + if (!chat) return session; + if (chat.awaitingInput) return { ...session, runtimeState: "waiting-input" as const, chatIdleSinceAt: null }; + if (chat.status === "active") return { ...session, runtimeState: "running" as const, chatIdleSinceAt: null }; + if (chat.status === "idle") return { ...session, runtimeState: "idle" as const, chatIdleSinceAt: chat.idleSinceAt ?? null }; + return session; + }); +} + +export async function buildLaneListSnapshots( + args: LaneListSnapshotServices, + lanes: LaneSummary[], + options: LaneListSnapshotOptions = {}, +): Promise<LaneListSnapshot[]> { + const startedAt = Date.now(); + const phases: Array<{ phase: string; durationMs: number }> = []; + const timePhase = async <T>(phase: string, work: () => Promise<T> | T): Promise<T> => { + const phaseStartedAt = Date.now(); + try { + return await work(); + } finally { + const durationMs = Date.now() - phaseStartedAt; + phases.push({ phase, durationMs }); + if (durationMs >= 120) { + args.logger.info("lanes.listSnapshots.phase", { + phase, + durationMs, + laneCount: lanes.length, + includeConflictStatus: options.includeConflictStatus !== false, + includeRebaseSuggestions: options.includeRebaseSuggestions !== false, + includeAutoRebaseStatus: options.includeAutoRebaseStatus !== false, + }); + } + } + }; + + const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ + timePhase("sessions", () => enrichSessionsForLaneList(args)), + options.includeRebaseSuggestions === false + ? Promise.resolve([]) + : timePhase("rebase_suggestions", () => + Promise.resolve() + .then(() => args.rebaseSuggestionService?.listSuggestions({ lanes }) ?? []) + .catch(() => [])), + options.includeAutoRebaseStatus === false + ? Promise.resolve([]) + : timePhase("auto_rebase_statuses", () => + Promise.resolve() + .then(() => args.autoRebaseService?.listStatuses({ lanes }) ?? []) + .catch(() => [])), + timePhase("state_snapshots", () => + Promise.resolve() + .then(() => args.laneService.listStateSnapshots()) + .catch(() => [])), + options.includeConflictStatus === false + ? Promise.resolve(null) + : timePhase("conflict_assessment", () => + Promise.resolve() + .then(() => args.conflictService?.getBatchAssessment({ lanes }) ?? null) + .catch(() => null)), + ]); + const durationMs = Date.now() - startedAt; + if (durationMs >= 120) { + args.logger.info("lanes.listSnapshots.summary", { + durationMs, + laneCount: lanes.length, + includeConflictStatus: options.includeConflictStatus !== false, + includeRebaseSuggestions: options.includeRebaseSuggestions !== false, + includeAutoRebaseStatus: options.includeAutoRebaseStatus !== false, + phases: phases + .filter((phase) => phase.durationMs >= 10) + .sort((left, right) => right.durationMs - left.durationMs), + }); + } + + const rebaseByLaneId = new Map(rebaseSuggestions.map((entry) => [entry.laneId, entry] as const)); + const autoRebaseByLaneId = new Map(autoRebaseStatuses.map((entry) => [entry.laneId, entry] as const)); + const stateByLaneId = new Map(stateSnapshots.map((entry) => [entry.laneId, entry] as const)); + const conflictByLaneId = new Map((batchAssessment?.lanes ?? []).map((entry) => [entry.laneId, entry] as const)); + const devicesOpenByLaneId = buildLanePresenceByLaneId(args.syncService); + + return lanes.map((lane) => ({ + lane: decorateLaneSummaryWithPresence(lane, devicesOpenByLaneId), + runtime: summarizeLaneRuntime(lane.id, sessions), + rebaseSuggestion: rebaseByLaneId.get(lane.id) ?? null, + autoRebaseStatus: autoRebaseByLaneId.get(lane.id) ?? null, + conflictStatus: conflictByLaneId.get(lane.id) ?? null, + stateSnapshot: stateByLaneId.get(lane.id) ?? null, + adoptableAttached: lane.laneType === "attached" && lane.archivedAt == null, + })); +} diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 9e58f4e7c..56cad9dd0 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -145,15 +145,18 @@ function normAbs(p: string): string { return path.resolve(p); } -type GitWorktreeInfo = { - path: string; - branch: string; +type GitWorktreeInfo = UnregisteredLaneCandidate & { isBare: boolean; }; function worktreeStdout(value: unknown): string { if (typeof value === "string") return value; - if (value && typeof value === "object" && "stdout" in value && typeof (value as { stdout?: unknown }).stdout === "string") { + if ( + value && + typeof value === "object" && + "stdout" in value && + typeof (value as { stdout?: unknown }).stdout === "string" + ) { return (value as { stdout: string }).stdout; } return ""; @@ -180,15 +183,12 @@ function parseGitWorktreePorcelain(stdout: string): GitWorktreeInfo[] { return worktrees; } -function laneNameFromRecoveredWorktree(worktreePath: string, branchRef: string): string { - const basename = path.basename(worktreePath).replace(/-[0-9a-f]{8}$/i, ""); - const candidate = basename || branchRef.split("/").filter(Boolean).pop() || branchRef || "Recovered lane"; - const words = candidate - .replace(/[-_]+/g, " ") - .replace(/\s+/g, " ") - .trim(); - if (!words) return "Recovered lane"; - return words.charAt(0).toUpperCase() + words.slice(1); +function inferLaneNameFromManagedWorktree(candidate: UnregisteredLaneCandidate): string { + const basename = path.basename(candidate.path).trim(); + const branchSlug = candidate.branch.trim().replace(/^ade\//, ""); + const slug = (branchSlug || basename).replace(/-[0-9a-f]{8}$/i, ""); + const name = slug.replace(/[-_]+/g, " ").trim(); + return name || basename || "Recovered lane"; } function parseLaneIcon(value: string | null): LaneIcon { @@ -825,9 +825,6 @@ export function createLaneService({ const normalizeBranchKey = (ref: string): string => normalizeBranchName(ref).trim(); - const normalizedProjectRoot = normAbs(projectRoot); - const normalizedWorktreesDir = normAbs(worktreesDir); - const toLaneBranchProfile = (row: LaneBranchProfileRow): LaneBranchProfile => ({ id: row.id, laneId: row.lane_id, @@ -1020,9 +1017,13 @@ export function createLaneService({ branchName?: string | null; linearIssue?: LaneLinearIssue | null; }): Promise<string> => { - const suggested = args.branchName?.trim() - || (args.linearIssue ? linearIssueBranchName(args.linearIssue) : ""); + const explicitBranch = args.branchName?.trim() ?? ""; + const linearBranch = !explicitBranch && args.linearIssue + ? linearIssueBranchName(args.linearIssue) + : ""; + const suggested = explicitBranch || linearBranch; const isCustomBranch = suggested.length > 0; + const isLinearBranch = !explicitBranch && linearBranch.length > 0; const slug = slugify(args.name); const fallback = `ade/${slug}-${args.laneId.slice(0, 8)}`; const branchRef = suggested @@ -1045,13 +1046,17 @@ export function createLaneService({ throw new Error(`Branch "${branchRef}" already exists locally.`); } + const remoteCollisionMessage = isLinearBranch + ? `Branch "origin/${branchRef}" already exists on the remote. Detach the Linear issue or choose one whose branch name is unused.` + : `Branch "origin/${branchRef}" already exists on the remote. Choose a different branch name.`; + if (isCustomBranch) { const remoteTrackingExists = await runGit(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branchRef}`], { cwd: projectRoot, timeoutMs: 8_000, }).then((res) => res.exitCode === 0); if (remoteTrackingExists) { - throw new Error(`Branch "origin/${branchRef}" already exists. Detach the Linear issue or choose a different issue.`); + throw new Error(remoteCollisionMessage); } const remoteExists = await runGit(["ls-remote", "--heads", "origin", branchRef], { @@ -1059,7 +1064,7 @@ export function createLaneService({ timeoutMs: 15_000, }).then((res) => res.exitCode === 0 && res.stdout.trim().length > 0); if (remoteExists) { - throw new Error(`Branch "origin/${branchRef}" already exists. Detach the Linear issue or choose a different issue.`); + throw new Error(remoteCollisionMessage); } } @@ -1216,24 +1221,27 @@ export function createLaneService({ return run; }; + const normalizedProjectRoot = normAbs(projectRoot); + const normalizedWorktreesDir = normAbs(worktreesDir); + const getGitTopLevel = async (cwd: string): Promise<string> => { const top = await runGitOrThrow(["rev-parse", "--path-format=absolute", "--show-toplevel"], { cwd, timeoutMs: 10_000 }); return normAbs(top.trim()); }; + const getGitCommonDir = async (cwd: string): Promise<string> => { + const commonDir = await runGitOrThrow(["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, timeoutMs: 10_000 }); + return normAbs(commonDir.trim()); + }; + const listGitWorktrees = async (): Promise<GitWorktreeInfo[]> => { const result = await runGitOrThrow( ["worktree", "list", "--porcelain"], - { cwd: projectRoot, timeoutMs: 15_000 }, + { cwd: projectRoot, timeoutMs: 15_000 } ); return parseGitWorktreePorcelain(worktreeStdout(result)); }; - const isManagedWorktreePath = (candidatePath: string): boolean => { - const resolvedPath = normAbs(candidatePath); - return resolvedPath !== normalizedWorktreesDir && isWithinDir(normalizedWorktreesDir, resolvedPath); - }; - const findGitWorktreeForBranch = async (branchRef: string): Promise<GitWorktreeInfo | null> => { const normalizedBranch = normalizeBranchKey(branchRef); if (!normalizedBranch) return null; @@ -1241,61 +1249,75 @@ export function createLaneService({ return worktrees.find((wt) => !wt.isBare && normalizeBranchKey(wt.branch) === normalizedBranch) ?? null; }; - const reconcileManagedWorktrees = async (): Promise<number> => { - const gitWorktrees = await listGitWorktrees(); - const registeredRows = getAllLaneRows(true); - const registeredPaths = new Set(registeredRows.map((row) => normAbs(row.worktree_path))); - const registeredBranches = new Set(registeredRows.map((row) => normalizeBranchKey(row.branch_ref)).filter(Boolean)); + const listUnregisteredWorktreeCandidates = async (): Promise<UnregisteredLaneCandidate[]> => { + const worktrees = await listGitWorktrees(); + const registeredPaths = new Set( + db.all<{ worktree_path: string }>( + "select worktree_path from lanes where project_id = ?", + [projectId] + ).map((row) => normAbs(row.worktree_path)) + ); + + return worktrees.filter( + (wt) => !wt.isBare && wt.path !== normalizedProjectRoot && !registeredPaths.has(wt.path) + ); + }; - let restoredCount = 0; - for (const wt of gitWorktrees) { - const branchRef = normalizeBranchKey(wt.branch); - if (wt.isBare || !branchRef) continue; - if (wt.path === normalizedProjectRoot) continue; - if (!isManagedWorktreePath(wt.path)) continue; - if (registeredPaths.has(wt.path) || registeredBranches.has(branchRef)) continue; + const recoverManagedWorktreeRows = async (): Promise<number> => { + const candidates = await listUnregisteredWorktreeCandidates(); + let recoveredCount = 0; + + for (const candidate of candidates) { + const worktreePath = normAbs(candidate.path); + const branchRef = candidate.branch.trim(); + if (!branchRef) continue; + if (path.dirname(worktreePath) !== normalizedWorktreesDir) continue; + + const existingPath = db.get<{ id: string }>( + "select id from lanes where project_id = ? and worktree_path = ? limit 1", + [projectId, worktreePath] + ); + if (existingPath?.id) continue; + + const existingBranch = db.get<{ id: string }>( + "select id from lanes where project_id = ? and branch_ref = ? limit 1", + [projectId, branchRef] + ); + if (existingBranch?.id) continue; const laneId = randomUUID(); const now = new Date().toISOString(); - const name = laneNameFromRecoveredWorktree(wt.path, branchRef); - + const displayName = inferLaneNameFromManagedWorktree(candidate); db.run( ` insert into lanes( id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at ) - values(?, ?, ?, ?, 'worktree', ?, ?, ?, null, 0, null, null, null, null, 'active', ?, null) + values(?, ?, ?, null, 'worktree', ?, ?, ?, null, 0, null, null, null, null, 'active', ?, null) `, - [ - laneId, - projectId, - name, - "Recovered from existing .ade worktree", - defaultBaseRef, - branchRef, - wt.path, - now, - ], + [laneId, projectId, displayName, defaultBaseRef, branchRef, worktreePath, now] ); const row = getLaneRow(laneId); - if (row) ensureBranchProfileForRow(row); - registeredPaths.add(wt.path); - registeredBranches.add(branchRef); - restoredCount += 1; + if (row) { + upsertBranchProfileForRow(row, { + branchRef, + baseRef: defaultBaseRef, + parentLaneId: null, + }); + } + recoveredCount += 1; } - if (restoredCount > 0) { + if (recoveredCount > 0) { invalidateLaneListCache(); - logger.info("laneService.reconciled_managed_worktrees", { restoredCount }); + logger.info("laneService.recovered_managed_worktrees", { + projectRoot, + count: recoveredCount, + }); } - return restoredCount; - }; - - const getGitCommonDir = async (cwd: string): Promise<string> => { - const commonDir = await runGitOrThrow(["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, timeoutMs: 10_000 }); - return normAbs(commonDir.trim()); + return recoveredCount; }; const ensureAttachableWorktreeRoot = async (candidatePath: string): Promise<void> => { @@ -1499,9 +1521,9 @@ export function createLaneService({ logger.warn("laneService.repairLegacyPrimaryBaseRootLanes_failed", { error: err instanceof Error ? err.message : String(err) }); } try { - await reconcileManagedWorktrees(); + await recoverManagedWorktreeRows(); } catch (err) { - logger.warn("laneService.reconcileManagedWorktrees_failed", { error: err instanceof Error ? err.message : String(err) }); + logger.warn("laneService.recoverManagedWorktreeRows_failed", { error: err instanceof Error ? err.message : String(err) }); } try { backfillLaneBranchProfiles(); @@ -1928,26 +1950,7 @@ export function createLaneService({ }, async listUnregisteredWorktrees(): Promise<UnregisteredLaneCandidate[]> { - try { - await reconcileManagedWorktrees(); - } catch (err) { - logger.warn("laneService.listUnregisteredWorktrees.reconcile_failed", { error: err instanceof Error ? err.message : String(err) }); - } - const worktrees = (await listGitWorktrees()) - .filter((wt) => !wt.isBare) - .map((wt): UnregisteredLaneCandidate => ({ path: wt.path, branch: wt.branch })); - - // Filter out primary worktree and worktrees already tracked as lanes - const registeredPaths = new Set( - db.all<{ worktree_path: string }>( - "select worktree_path from lanes where project_id = ?", - [projectId] - ).map((row) => normAbs(row.worktree_path)) - ); - - return worktrees.filter( - (wt) => wt.path !== normalizedProjectRoot && !registeredPaths.has(wt.path) - ); + return listUnregisteredWorktreeCandidates(); }, getStateSnapshot(laneId: string): LaneStateSnapshotSummary | null { @@ -2343,9 +2346,9 @@ export function createLaneService({ } try { - await reconcileManagedWorktrees(); + await recoverManagedWorktreeRows(); } catch (err) { - logger.warn("laneService.importBranch.reconcileManagedWorktrees_failed", { error: err instanceof Error ? err.message : String(err) }); + logger.warn("laneService.importBranch.recoverManagedWorktreeRows_failed", { error: err instanceof Error ? err.message : String(err) }); } // Prevent duplicates. diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts new file mode 100644 index 000000000..7ab2270e6 --- /dev/null +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -0,0 +1,720 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getAppPath: () => "/Applications/ADE.app/Contents/Resources/app.asar", + }, +})); + +import { + buildLocalRuntimeNodeEnv, + buildLocalRuntimeNodePath, + buildLocalRuntimeServeArgs, + computeLocalRuntimeBuildHash, + LocalRuntimeConnectionPool, + parseRuntimeServiceManagerOutput, +} from "./localRuntimeConnectionPool"; + +type RawPendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +}; + +class RawRuntimeSocketClient { + private nextId = 1; + private buffer = ""; + private readonly pending = new Map<number, RawPendingRequest>(); + + private constructor(private readonly socket: net.Socket) { + socket.on("data", (chunk) => this.handleData(chunk.toString("utf8"))); + socket.on("error", (error) => this.rejectAll(error)); + socket.on("close", () => this.rejectAll(new Error("ADE service socket closed."))); + } + + static connect(socketPath: string): Promise<RawRuntimeSocketClient> { + return new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath); + const cleanup = () => { + socket.off("connect", onConnect); + socket.off("error", onError); + }; + const onConnect = () => { + cleanup(); + resolve(new RawRuntimeSocketClient(socket)); + }; + const onError = (error: Error) => { + cleanup(); + socket.destroy(); + reject(error); + }; + socket.once("connect", onConnect); + socket.once("error", onError); + }); + } + + request(method: string, params?: unknown): Promise<unknown> { + const id = this.nextId++; + const payload = { + jsonrpc: "2.0", + id, + method, + ...(params !== undefined ? { params } : {}), + }; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.write(`${JSON.stringify(payload)}\n`, "utf8", (error) => { + if (!error) return; + this.pending.delete(id); + reject(error); + }); + }); + } + + close(): void { + this.socket.destroy(); + } + + private handleData(chunk: string): void { + this.buffer += chunk; + while (true) { + const newline = this.buffer.indexOf("\n"); + if (newline < 0) return; + const line = this.buffer.slice(0, newline).trim(); + this.buffer = this.buffer.slice(newline + 1); + if (!line) continue; + const parsed = JSON.parse(line) as { id?: number; result?: unknown; error?: { message?: string } }; + if (typeof parsed.id !== "number") continue; + const pending = this.pending.get(parsed.id); + if (!pending) continue; + this.pending.delete(parsed.id); + if (parsed.error) pending.reject(new Error(parsed.error.message ?? "ADE service request failed.")); + else pending.resolve(parsed.result); + } + } + + private rejectAll(error: Error): void { + for (const [id, pending] of this.pending) { + this.pending.delete(id); + pending.reject(error); + } + } +} + +function withTsxNodeOptions(value: string | undefined, loaderPath: string): string { + const existing = value?.trim(); + return existing ? `${existing} --import ${loaderPath}` : `--import ${loaderPath}`; +} + +async function waitForRuntimeSocket(socketPath: string, timeoutMs = 10_000): Promise<void> { + const startedAt = Date.now(); + let lastError: Error | null = null; + while (Date.now() - startedAt < timeoutMs) { + try { + const client = await RawRuntimeSocketClient.connect(socketPath); + client.close(); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + throw lastError ?? new Error(`ADE service socket did not become available: ${socketPath}`); +} + +function startServeProcess(args: { + cliPath: string; + cwd: string; + env: NodeJS.ProcessEnv; + socketPath: string; +}): ChildProcess { + return spawn(process.execPath, [args.cliPath, "serve", "--socket", args.socketPath, "--no-sync"], { + cwd: args.cwd, + env: args.env, + stdio: ["ignore", "ignore", "ignore"], + }); +} + +async function shutdownRuntime(socketPath: string): Promise<void> { + let client: RawRuntimeSocketClient | null = null; + try { + client = await RawRuntimeSocketClient.connect(socketPath); + await client.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "local-runtime-test-cleanup", + identity: { role: "external", callerId: "local-runtime-test-cleanup" }, + }); + await client.request("shutdown").catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("socket closed")) throw error; + }); + } catch { + // Best-effort cleanup; a failed test should not mask the original assertion. + } finally { + client?.close(); + } +} + +describe("local runtime connection pool", () => { + it("starts fallback runtimes with sync enabled by default", () => { + const args = buildLocalRuntimeServeArgs("/opt/ade/cli.cjs", "/tmp/ade.sock"); + + expect(args).toEqual(["/opt/ade/cli.cjs", "serve", "--socket", "/tmp/ade.sock"]); + expect(args).not.toContain("--no-sync"); + }); + + it("keeps explicit no-sync support for narrow test or diagnostic launches", () => { + const args = buildLocalRuntimeServeArgs("/opt/ade/cli.cjs", "/tmp/ade.sock", { disableSync: true }); + + expect(args).toContain("--no-sync"); + }); + + it("builds packaged runtime NODE_PATH for macOS universal app layouts", () => { + const nodePath = buildLocalRuntimeNodePath({ + resourcesPath: "/Applications/ADE.app/Contents/Resources", + platform: "darwin", + arch: "arm64", + existingNodePath: "/custom/node_modules", + }); + + expect(nodePath?.split(path.delimiter)).toEqual([ + "/Applications/ADE.app/Contents/Resources/app-arm64.asar.unpacked/node_modules", + "/Applications/ADE.app/Contents/Resources/app.asar.unpacked/node_modules", + "/Applications/ADE.app/Contents/Resources/app-arm64.asar/node_modules", + "/Applications/ADE.app/Contents/Resources/app.asar/node_modules", + "/custom/node_modules", + ]); + }); + + it("uses the packaged runtime module path when spawning the service", () => { + const env = buildLocalRuntimeNodeEnv( + "1.2.3", + { NODE_PATH: "/custom/node_modules" }, + { resourcesPath: "/Applications/ADE.app/Contents/Resources", platform: "darwin", arch: "x64" }, + ); + + expect(env.ADE_DEFAULT_ROLE).toBe("cto"); + expect(env.ELECTRON_RUN_AS_NODE).toBe("1"); + expect(env.ADE_CLI_VERSION).toBe("1.2.3"); + expect(env.NODE_PATH).toContain("app-x64.asar.unpacked"); + expect(env.NODE_PATH).toContain("app.asar.unpacked"); + expect(env.NODE_PATH).toContain("/custom/node_modules"); + }); + + it("reports local ADE service install and connection status", () => { + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never, { + queryServiceStatus: () => ({ + ok: true, + serviceName: "com.ade.runtime", + action: "status", + installed: true, + running: true, + path: "/tmp/com.ade.runtime.plist", + message: "ADE service is running.", + }), + }); + + expect(pool.getStatus()).toMatchObject({ + connectionState: "idle", + serviceInstall: { + state: "not_attempted", + attempted: false, + }, + serviceHealth: { + state: "running", + installed: true, + running: true, + path: "/tmp/com.ade.runtime.plist", + }, + }); + + pool.noteServiceInstallSkipped("Disabled for this test."); + (pool as unknown as { activeClient: unknown }).activeClient = {}; + + expect(pool.getStatus()).toMatchObject({ + connectionState: "connected", + serviceInstall: { + state: "skipped", + attempted: false, + message: "Disabled for this test.", + }, + serviceHealth: { + state: "running", + }, + }); + }); + + it("parses structured service manager output for settings status", () => { + expect(parseRuntimeServiceManagerOutput(JSON.stringify({ + ok: false, + serviceName: "com.ade.runtime", + action: "status", + installed: true, + running: false, + path: "/Users/admin/Library/LaunchAgents/com.ade.runtime.plist", + message: "launchctl failed", + }))).toEqual({ + ok: false, + path: "/Users/admin/Library/LaunchAgents/com.ade.runtime.plist", + message: "launchctl failed", + }); + + expect(parseRuntimeServiceManagerOutput("not json")).toBeNull(); + }); + + it("disposes the desktop client without shutting down the ADE service", async () => { + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never, { disableSync: true }); + const client = { + call: vi.fn(), + close: vi.fn(), + }; + (pool as unknown as { connection: Promise<unknown>; activeClient: unknown }).connection = Promise.resolve({ + client, + child: null, + socketPath: "/tmp/ade.sock", + }); + (pool as unknown as { activeClient: unknown }).activeClient = client; + + pool.dispose(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(client.close).toHaveBeenCalledTimes(1); + expect(client.call).not.toHaveBeenCalledWith("shutdown", expect.anything()); + expect(pool.getStatus().connectionState).toBe("idle"); + }); + + it("reattaches to a machine daemon after the desktop-side client disconnects", async () => { + const adeCliRoot = path.resolve(process.cwd(), "../ade-cli"); + const cliPath = path.join(adeCliRoot, "src", "cli.ts"); + const tsxLoaderPath = path.join(adeCliRoot, "node_modules", "tsx", "dist", "loader.mjs"); + expect(fs.existsSync(cliPath)).toBe(true); + expect(fs.existsSync(tsxLoaderPath)).toBe(true); + + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-project-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const originalEnv = { + ADE_CLI_JS: process.env.ADE_CLI_JS, + ADE_HOME: process.env.ADE_HOME, + ADE_RUNTIME_SOCKET_PATH: process.env.ADE_RUNTIME_SOCKET_PATH, + NODE_OPTIONS: process.env.NODE_OPTIONS, + }; + + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + let firstPool: LocalRuntimeConnectionPool | null = null; + let secondPool: LocalRuntimeConnectionPool | null = null; + + try { + process.env.ADE_CLI_JS = cliPath; + process.env.ADE_HOME = adeHome; + process.env.ADE_RUNTIME_SOCKET_PATH = socketPath; + process.env.NODE_OPTIONS = withTsxNodeOptions(originalEnv.NODE_OPTIONS, tsxLoaderPath); + + firstPool = new LocalRuntimeConnectionPool("1.2.3", logger as never, { disableSync: true }); + const registered = await firstPool.ensureProject(projectRoot); + firstPool.dispose(); + + secondPool = new LocalRuntimeConnectionPool("1.2.3", logger as never, { disableSync: true }); + const projects = await secondPool.projects(); + + expect(registered.rootPath).toBe(projectRoot); + expect(projects).toContainEqual(expect.objectContaining({ + projectId: registered.projectId, + rootPath: projectRoot, + })); + } finally { + firstPool?.dispose(); + secondPool?.dispose(); + await shutdownRuntime(socketPath); + if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; + else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; + if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; + else process.env.ADE_HOME = originalEnv.ADE_HOME; + if (originalEnv.ADE_RUNTIME_SOCKET_PATH === undefined) delete process.env.ADE_RUNTIME_SOCKET_PATH; + else process.env.ADE_RUNTIME_SOCKET_PATH = originalEnv.ADE_RUNTIME_SOCKET_PATH; + if (originalEnv.NODE_OPTIONS === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalEnv.NODE_OPTIONS; + } + }, 45_000); + + it("restarts a stale local daemon before attaching", async () => { + const adeCliRoot = path.resolve(process.cwd(), "../ade-cli"); + const cliPath = path.join(adeCliRoot, "src", "cli.ts"); + const tsxLoaderPath = path.join(adeCliRoot, "node_modules", "tsx", "dist", "loader.mjs"); + expect(fs.existsSync(cliPath)).toBe(true); + expect(fs.existsSync(tsxLoaderPath)).toBe(true); + + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-project-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const originalEnv = { + ADE_CLI_JS: process.env.ADE_CLI_JS, + ADE_HOME: process.env.ADE_HOME, + ADE_RUNTIME_SOCKET_PATH: process.env.ADE_RUNTIME_SOCKET_PATH, + NODE_OPTIONS: process.env.NODE_OPTIONS, + }; + const baseEnv = { + ...process.env, + ADE_HOME: adeHome, + ADE_RUNTIME_SOCKET_PATH: socketPath, + NODE_OPTIONS: withTsxNodeOptions(originalEnv.NODE_OPTIONS, tsxLoaderPath), + }; + const oldDaemon = startServeProcess({ + cliPath, + cwd: adeCliRoot, + env: { + ...baseEnv, + ADE_CLI_VERSION: "1.0.0", + }, + socketPath, + }); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + let pool: LocalRuntimeConnectionPool | null = null; + + try { + await waitForRuntimeSocket(socketPath); + process.env.ADE_CLI_JS = cliPath; + process.env.ADE_HOME = adeHome; + process.env.ADE_RUNTIME_SOCKET_PATH = socketPath; + process.env.NODE_OPTIONS = baseEnv.NODE_OPTIONS; + + pool = new LocalRuntimeConnectionPool("2.0.0", logger as never, { disableSync: true }); + const registered = await pool.ensureProject(projectRoot); + + expect(registered.rootPath).toBe(projectRoot); + expect(logger.info).toHaveBeenCalledWith("local_runtime.version_mismatch_restart", expect.objectContaining({ + runtimeVersion: "1.0.0", + appVersion: "2.0.0", + })); + + pool.dispose(); + const client = await RawRuntimeSocketClient.connect(socketPath); + try { + const initialized = await client.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "local-runtime-version-test", + identity: { role: "external", callerId: "local-runtime-version-test" }, + }); + expect(initialized).toMatchObject({ + runtimeInfo: { + version: "2.0.0", + }, + }); + } finally { + client.close(); + } + } finally { + pool?.dispose(); + await shutdownRuntime(socketPath); + if (!oldDaemon.killed) oldDaemon.kill(); + if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; + else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; + if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; + else process.env.ADE_HOME = originalEnv.ADE_HOME; + if (originalEnv.ADE_RUNTIME_SOCKET_PATH === undefined) delete process.env.ADE_RUNTIME_SOCKET_PATH; + else process.env.ADE_RUNTIME_SOCKET_PATH = originalEnv.ADE_RUNTIME_SOCKET_PATH; + if (originalEnv.NODE_OPTIONS === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalEnv.NODE_OPTIONS; + } + }, 45_000); + + it("restarts a same-version local daemon when the packaged runtime build changed", async () => { + const adeCliRoot = path.resolve(process.cwd(), "../ade-cli"); + const cliPath = path.join(adeCliRoot, "src", "cli.ts"); + const tsxLoaderPath = path.join(adeCliRoot, "node_modules", "tsx", "dist", "loader.mjs"); + expect(fs.existsSync(cliPath)).toBe(true); + expect(fs.existsSync(tsxLoaderPath)).toBe(true); + + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-project-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const originalEnv = { + ADE_CLI_JS: process.env.ADE_CLI_JS, + ADE_HOME: process.env.ADE_HOME, + ADE_RUNTIME_SOCKET_PATH: process.env.ADE_RUNTIME_SOCKET_PATH, + NODE_OPTIONS: process.env.NODE_OPTIONS, + }; + const baseEnv = { + ...process.env, + ADE_HOME: adeHome, + ADE_RUNTIME_SOCKET_PATH: socketPath, + NODE_OPTIONS: withTsxNodeOptions(originalEnv.NODE_OPTIONS, tsxLoaderPath), + }; + const oldDaemon = startServeProcess({ + cliPath, + cwd: adeCliRoot, + env: { + ...baseEnv, + ADE_CLI_VERSION: "1.0.0", + ADE_RUNTIME_BUILD_HASH: "old-build", + }, + socketPath, + }); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + let pool: LocalRuntimeConnectionPool | null = null; + + try { + await waitForRuntimeSocket(socketPath); + process.env.ADE_CLI_JS = cliPath; + process.env.ADE_HOME = adeHome; + process.env.ADE_RUNTIME_SOCKET_PATH = socketPath; + process.env.NODE_OPTIONS = baseEnv.NODE_OPTIONS; + + const expectedBuildHash = computeLocalRuntimeBuildHash(cliPath); + expect(expectedBuildHash).toBeTruthy(); + pool = new LocalRuntimeConnectionPool("1.0.0", logger as never, { disableSync: true }); + const registered = await pool.ensureProject(projectRoot); + + expect(registered.rootPath).toBe(projectRoot); + expect(logger.info).toHaveBeenCalledWith("local_runtime.build_mismatch_restart", expect.objectContaining({ + runtimeBuildHash: "old-build", + expectedBuildHash, + })); + + pool.dispose(); + const client = await RawRuntimeSocketClient.connect(socketPath); + try { + const initialized = await client.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "local-runtime-build-test", + identity: { role: "external", callerId: "local-runtime-build-test" }, + }); + expect(initialized).toMatchObject({ + runtimeInfo: { + version: "1.0.0", + buildHash: expectedBuildHash, + }, + }); + } finally { + client.close(); + } + } finally { + pool?.dispose(); + await shutdownRuntime(socketPath); + if (!oldDaemon.killed) oldDaemon.kill(); + if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; + else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; + if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; + else process.env.ADE_HOME = originalEnv.ADE_HOME; + if (originalEnv.ADE_RUNTIME_SOCKET_PATH === undefined) delete process.env.ADE_RUNTIME_SOCKET_PATH; + else process.env.ADE_RUNTIME_SOCKET_PATH = originalEnv.ADE_RUNTIME_SOCKET_PATH; + if (originalEnv.NODE_OPTIONS === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalEnv.NODE_OPTIONS; + } + }, 45_000); + + it("streams local runtime events through the project-scoped RPC action", async () => { + const call = vi.fn().mockResolvedValue({ + events: [ + { + id: 12, + timestamp: "2026-05-10T12:00:00.000Z", + category: "runtime", + payload: { type: "pty_data", event: { ptyId: "pty-1", data: "hello" } }, + }, + { id: "bad", timestamp: "nope", category: "runtime", payload: {} }, + ], + nextCursor: 13, + hasMore: true, + }); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never); + const rootPath = path.resolve("/repo"); + (pool as unknown as { projectsByRoot: Map<string, unknown> }).projectsByRoot.set(rootPath, { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }); + (pool as unknown as { connection: Promise<unknown> }).connection = Promise.resolve({ + client: { call }, + child: null, + socketPath: "/tmp/ade.sock", + }); + + const result = await pool.streamEventsForRoot(rootPath, { + cursor: 7.5, + limit: 2, + category: "runtime", + }); + + expect(call).toHaveBeenCalledWith("ade/actions/call", { + projectId: "project-1", + name: "stream_events", + arguments: { + cursor: 7, + limit: 2, + category: "runtime", + }, + }); + expect(result).toEqual({ + events: [ + { + id: 12, + timestamp: "2026-05-10T12:00:00.000Z", + category: "runtime", + payload: { type: "pty_data", event: { ptyId: "pty-1", data: "hello" } }, + }, + ], + nextCursor: 13, + hasMore: true, + }); + }); + + it("routes local sync calls through the project-scoped runtime RPC", async () => { + const call = vi.fn().mockResolvedValue({ + mode: "standalone", + connectedPeers: [], + }); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never); + const rootPath = path.resolve("/repo"); + (pool as unknown as { projectsByRoot: Map<string, unknown> }).projectsByRoot.set(rootPath, { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }); + (pool as unknown as { connection: Promise<unknown> }).connection = Promise.resolve({ + client: { call }, + child: null, + socketPath: "/tmp/ade.sock", + }); + + await expect(pool.callSyncForRoot(rootPath, "sync.getStatus", { + includeTransferReadiness: true, + })).resolves.toEqual({ + mode: "standalone", + connectedPeers: [], + }); + + expect(call).toHaveBeenCalledWith("sync.getStatus", { + projectId: "project-1", + includeTransferReadiness: true, + }); + }); + + it("subscribes to local runtime event notifications", async () => { + const notificationListeners = new Map<string, Set<(params: unknown) => void>>(); + const call = vi.fn(async (method: string) => { + if (method === "runtimeEvents.subscribe") { + for (const listener of notificationListeners.get("runtime/event") ?? []) { + listener({ + subscriptionId: "runtime-events-4", + projectId: "project-1", + event: { + id: 21, + timestamp: "2026-05-10T12:00:00.000Z", + category: "runtime", + payload: { type: "file_change" }, + }, + }); + } + return { subscriptionId: "runtime-events-4", nextCursor: 22, hasMore: false }; + } + if (method === "runtimeEvents.unsubscribe") { + return { removed: true }; + } + return null; + }); + const client = { + call, + onDisconnect: vi.fn(() => () => {}), + onNotification: vi.fn((method: string, callback: (params: unknown) => void) => { + const existing = notificationListeners.get(method) ?? new Set<(params: unknown) => void>(); + existing.add(callback); + notificationListeners.set(method, existing); + return () => { + existing.delete(callback); + if (existing.size === 0) { + notificationListeners.delete(method); + } + }; + }), + }; + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never); + const rootPath = path.resolve("/repo"); + (pool as unknown as { projectsByRoot: Map<string, unknown> }).projectsByRoot.set(rootPath, { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }); + (pool as unknown as { connection: Promise<unknown> }).connection = Promise.resolve({ + client, + child: null, + socketPath: "/tmp/ade.sock", + }); + const onEvent = vi.fn(); + + const cleanup = await pool.subscribeEventsForRoot(rootPath, { + cursor: 20, + limit: 5, + category: "runtime", + }, onEvent); + + expect(call).toHaveBeenCalledWith("runtimeEvents.subscribe", { + projectId: "project-1", + cursor: 20, + limit: 5, + category: "runtime", + }); + expect(onEvent).toHaveBeenCalledWith({ + id: 21, + timestamp: "2026-05-10T12:00:00.000Z", + category: "runtime", + payload: { type: "file_change" }, + }); + + cleanup(); + expect(call).toHaveBeenCalledWith("runtimeEvents.unsubscribe", { subscriptionId: "runtime-events-4" }); + }); +}); diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts new file mode 100644 index 000000000..f35cb05af --- /dev/null +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -0,0 +1,823 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; +import { app } from "electron"; +import { isAdeMcpNamedPipePath } from "../../../shared/adeMcpIpc"; +import type { + RemoteRuntimeActionRequest, + RemoteRuntimeActionResult, + RemoteRuntimeBufferedEvent, + RemoteRuntimeEventCategory, + RemoteRuntimeProjectRecord, + RemoteRuntimeStreamEventsRequest, + RemoteRuntimeStreamEventsResult, +} from "../../../shared/types/remoteRuntime"; +import type { + LocalRuntimeStatus, + SyncDeviceRecord, + SyncDeviceRuntimeState, + SyncGetStatusArgs, + SyncPeerDeviceType, + SyncRoleSnapshot, +} from "../../../shared/types"; +import { resolveMachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; +import { RuntimeRpcClient, type RuntimeRpcTransport } from "../remoteRuntime/runtimeRpcClient"; +import { coerceProjects } from "../remoteRuntime/remoteBootstrap"; +import type { Logger } from "../logging/logger"; +import { getRuntimeServiceStatus, type ServiceManagerStatusResult } from "../../../../../ade-cli/src/serviceManager"; + +type LocalRuntimeConnection = { + client: RuntimeRpcClient; + child: ChildProcess | null; + socketPath: string; +}; + +type RuntimeEventNotification = { + subscriptionId: string; + projectId: string; + event: RemoteRuntimeBufferedEvent; +}; + +type RuntimeServiceManagerOutput = { + ok: boolean | null; + path: string | null; + message: string | null; +}; + +type LocalRuntimeConnectionPoolOptions = { + disableSync?: boolean; + queryServiceStatus?: () => ServiceManagerStatusResult; +}; + +type LocalRuntimeNodePathOptions = { + resourcesPath?: string; + platform?: NodeJS.Platform; + arch?: NodeJS.Architecture; + existingNodePath?: string; +}; + +export function buildLocalRuntimeServeArgs( + cliPath: string, + socketPath: string, + options: { disableSync?: boolean } = {}, +): string[] { + const args = [cliPath, "serve", "--socket", socketPath]; + if (options.disableSync) args.push("--no-sync"); + return args; +} + +export function buildLocalRuntimeNodePath(options: LocalRuntimeNodePathOptions = {}): string | undefined { + const resourcesPath = options.resourcesPath ?? process.resourcesPath; + const platform = options.platform ?? process.platform; + const arch = options.arch ?? process.arch; + const entries: string[] = []; + + if (resourcesPath) { + if (platform === "darwin") { + const archAsar = arch === "arm64" ? "app-arm64.asar" : "app-x64.asar"; + entries.push( + path.join(resourcesPath, `${archAsar}.unpacked`, "node_modules"), + path.join(resourcesPath, "app.asar.unpacked", "node_modules"), + path.join(resourcesPath, archAsar, "node_modules"), + path.join(resourcesPath, "app.asar", "node_modules"), + ); + } else { + entries.push( + path.join(resourcesPath, "app.asar.unpacked", "node_modules"), + path.join(resourcesPath, "app.asar", "node_modules"), + ); + } + } + + const existingNodePath = options.existingNodePath ?? process.env.NODE_PATH; + if (existingNodePath?.trim()) entries.push(existingNodePath); + return entries.length ? entries.join(path.delimiter) : undefined; +} + +export function buildLocalRuntimeNodeEnv( + appVersion: string, + baseEnv: NodeJS.ProcessEnv = process.env, + nodePathOptions: Omit<LocalRuntimeNodePathOptions, "existingNodePath"> = {}, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...baseEnv, + ADE_DEFAULT_ROLE: "cto", + ELECTRON_RUN_AS_NODE: "1", + ADE_CLI_VERSION: appVersion, + }; + const nodePath = buildLocalRuntimeNodePath({ ...nodePathOptions, existingNodePath: baseEnv.NODE_PATH }); + if (nodePath) env.NODE_PATH = nodePath; + return env; +} + +function resolveCliScriptPath(): string { + const override = process.env.ADE_CLI_JS?.trim(); + if (override) return path.resolve(override); + + const candidates = [ + path.join(process.resourcesPath ?? "", "ade-cli", "cli.cjs"), + path.join(app.getAppPath(), "..", "ade-cli", "dist", "cli.cjs"), + path.resolve(process.cwd(), "..", "ade-cli", "dist", "cli.cjs"), + ]; + return candidates.find((candidate) => { + try { + return Boolean(candidate) && fs.statSync(candidate).isFile(); + } catch { + return false; + } + }) ?? path.resolve(process.cwd(), "..", "ade-cli", "dist", "cli.cjs"); +} + +function openSocketTransport(socketPath: string, timeoutMs = 3_000): Promise<RuntimeRpcTransport> { + return new Promise((resolve, reject) => { + const socket = isAdeMcpNamedPipePath(socketPath) + ? net.createConnection(socketPath) + : net.createConnection({ path: socketPath }); + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + socket.destroy(); + reject(new Error(`Timed out connecting to ADE service socket: ${socketPath}`)); + }, timeoutMs); + const fail = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + reject(error); + }; + socket.once("error", fail); + socket.once("connect", () => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.off("error", fail); + const closeCallbacks = new Set<() => void>(); + const errorCallbacks = new Set<(error: Error) => void>(); + socket.on("error", (error) => { + for (const callback of [...errorCallbacks]) { + callback(error); + } + }); + socket.on("close", () => { + for (const callback of [...closeCallbacks]) { + callback(); + } + }); + resolve({ + onData(callback) { + socket.on("data", (chunk) => callback(Buffer.from(chunk))); + }, + onClose(callback) { + closeCallbacks.add(callback); + }, + onError(callback) { + errorCallbacks.add(callback); + }, + write(data) { + socket.write(data); + }, + close() { + socket.end(); + }, + }); + }); + }); +} + +function readRuntimeInfo(value: unknown): { version: string | null; buildHash: string | null } { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { version: null, buildHash: null }; + } + const runtimeInfo = (value as { runtimeInfo?: unknown }).runtimeInfo; + if (!runtimeInfo || typeof runtimeInfo !== "object" || Array.isArray(runtimeInfo)) { + return { version: null, buildHash: null }; + } + const version = (runtimeInfo as { version?: unknown }).version; + const buildHash = (runtimeInfo as { buildHash?: unknown }).buildHash; + return { + version: typeof version === "string" && version.trim() ? version.trim() : null, + buildHash: typeof buildHash === "string" && buildHash.trim() ? buildHash.trim() : null, + }; +} + +export function computeLocalRuntimeBuildHash(cliPath = resolveCliScriptPath()): string | null { + try { + const content = fs.readFileSync(cliPath); + return createHash("sha256").update(content).digest("hex"); + } catch { + return null; + } +} + +async function shutdownRuntimeClient(client: RuntimeRpcClient): Promise<void> { + try { + await client.call("shutdown", {}); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("socket closed")) throw error; + } finally { + try { client.close(); } catch {} + } +} + +async function waitForSocket(socketPath: string, timeoutMs = 10_000): Promise<void> { + const startedAt = Date.now(); + let lastError: Error | null = null; + while (Date.now() - startedAt < timeoutMs) { + try { + const transport = await openSocketTransport(socketPath, 500); + transport.close(); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + throw lastError ?? new Error(`ADE service socket did not become available: ${socketPath}`); +} + +export function parseRuntimeServiceManagerOutput(output: string): RuntimeServiceManagerOutput | null { + const trimmed = output.trim(); + if (!trimmed) return null; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const record = parsed as Record<string, unknown>; + return { + ok: typeof record.ok === "boolean" ? record.ok : null, + path: typeof record.path === "string" && record.path.trim() ? record.path.trim() : null, + message: typeof record.message === "string" && record.message.trim() ? record.message.trim() : null, + }; +} + +function serviceHealthState( + status: ServiceManagerStatusResult, +): LocalRuntimeStatus["serviceHealth"]["state"] { + if (!status.ok) return status.installed == null ? "unsupported" : "error"; + if (status.installed === false) return "not_installed"; + if (status.running === true) return "running"; + if (status.installed === true) return "installed"; + return "unknown"; +} + +export class LocalRuntimeConnectionPool { + private connection: Promise<LocalRuntimeConnection> | null = null; + private activeClient: RuntimeRpcClient | null = null; + private readonly projectsByRoot = new Map<string, RemoteRuntimeProjectRecord>(); + private serviceInstallStatus: LocalRuntimeStatus["serviceInstall"] = { + state: "not_attempted", + attempted: false, + path: null, + message: "Background service installation has not run in this session.", + exitCode: null, + updatedAt: null, + }; + private serviceHealthStatus: LocalRuntimeStatus["serviceHealth"] = { + state: "unknown", + installed: null, + running: null, + path: null, + message: "Background service status has not been checked in this session.", + checkedAt: null, + }; + private serviceHealthCheckedAtMs = 0; + + constructor( + private readonly appVersion: string, + private readonly logger: Logger, + private readonly options: LocalRuntimeConnectionPoolOptions = {}, + ) {} + + async ensureRunning(): Promise<void> { + await this.connect(); + } + + getStatus(): LocalRuntimeStatus { + this.refreshServiceHealthIfStale(); + return { + connectionState: this.activeClient + ? "connected" + : this.connection + ? "connecting" + : "idle", + serviceInstall: { ...this.serviceInstallStatus }, + serviceHealth: { ...this.serviceHealthStatus }, + }; + } + + noteServiceInstallSkipped(message: string): void { + this.serviceInstallStatus = { + state: "skipped", + attempted: false, + path: null, + message, + exitCode: null, + updatedAt: new Date().toISOString(), + }; + } + + private refreshServiceHealthIfStale(maxAgeMs = 2_000): void { + if (Date.now() - this.serviceHealthCheckedAtMs < maxAgeMs) return; + this.serviceHealthCheckedAtMs = Date.now(); + try { + const status = (this.options.queryServiceStatus ?? getRuntimeServiceStatus)(); + this.serviceHealthStatus = { + state: serviceHealthState(status), + installed: status.installed, + running: status.running, + path: status.path, + message: status.message, + checkedAt: new Date().toISOString(), + }; + } catch (error) { + this.serviceHealthStatus = { + state: "error", + installed: null, + running: null, + path: null, + message: error instanceof Error ? error.message : String(error), + checkedAt: new Date().toISOString(), + }; + this.logger.warn("local_runtime.service_status_failed", { + error: this.serviceHealthStatus.message, + }); + } + } + + async installServiceBestEffort(): Promise<void> { + const cliPath = resolveCliScriptPath(); + this.serviceInstallStatus = { + state: "installing", + attempted: true, + path: cliPath, + message: "Installing the ADE service login item.", + exitCode: null, + updatedAt: new Date().toISOString(), + }; + await new Promise<void>((resolve) => { + const child = spawn(process.execPath, [cliPath, "serve", "--install-service"], { + env: buildLocalRuntimeNodeEnv(this.appVersion), + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + child.once("error", (error) => { + this.serviceInstallStatus = { + state: "failed", + attempted: true, + path: cliPath, + message: error.message, + exitCode: null, + updatedAt: new Date().toISOString(), + }; + this.logger.warn("local_runtime.service_install_failed", { error: error.message }); + resolve(); + }); + child.once("close", (code) => { + const output = stdout.trim(); + const errorOutput = stderr.trim(); + const parsed = parseRuntimeServiceManagerOutput(output); + const failed = code !== 0 || parsed?.ok === false; + const statusPath = parsed ? parsed.path : cliPath; + const payload = { + cliPath, + servicePath: parsed?.path ?? null, + exitCode: code, + stdout: output || null, + stderr: errorOutput || null, + }; + if (!failed) { + this.serviceInstallStatus = { + state: "installed", + attempted: true, + path: statusPath, + message: parsed?.message || output || "ADE service login item is installed.", + exitCode: code, + updatedAt: new Date().toISOString(), + }; + this.logger.info("local_runtime.service_install_succeeded", payload); + } else { + this.serviceInstallStatus = { + state: "failed", + attempted: true, + path: statusPath, + message: parsed?.message || errorOutput || output || "ADE service login item installation failed.", + exitCode: code, + updatedAt: new Date().toISOString(), + }; + this.logger.warn("local_runtime.service_install_failed", payload); + } + resolve(); + }); + }); + } + + async ensureProject(rootPath: string): Promise<RemoteRuntimeProjectRecord> { + const normalizedRoot = path.resolve(rootPath); + const cached = this.projectsByRoot.get(normalizedRoot); + if (cached) return cached; + const entry = await this.connect(); + const project = await entry.client.call("projects.add", { rootPath: normalizedRoot }); + const record = coerceProjects([project])[0]; + if (!record) throw new Error("Local ADE service did not return a project record."); + this.projectsByRoot.set(normalizedRoot, record); + return record; + } + + async projects(): Promise<RemoteRuntimeProjectRecord[]> { + const entry = await this.connect(); + return coerceProjects(await entry.client.call("projects.list", {})); + } + + async syncStatusForRoot(rootPath: string, args: SyncGetStatusArgs = {}): Promise<SyncRoleSnapshot> { + return await this.callSyncForRoot<SyncRoleSnapshot>(rootPath, "sync.getStatus", { + includeTransferReadiness: args.includeTransferReadiness === true, + forceTransferReadiness: args.forceTransferReadiness === true, + }); + } + + async refreshSyncDiscoveryForRoot(rootPath: string): Promise<SyncRoleSnapshot> { + return await this.callSyncForRoot<SyncRoleSnapshot>(rootPath, "sync.refreshDiscovery"); + } + + async syncDevicesForRoot(rootPath: string): Promise<SyncDeviceRuntimeState[]> { + return await this.callSyncForRoot<SyncDeviceRuntimeState[]>(rootPath, "sync.listDevices"); + } + + async updateSyncLocalDeviceForRoot( + rootPath: string, + args: { name?: string; deviceType?: SyncPeerDeviceType }, + ): Promise<SyncDeviceRecord> { + return await this.callSyncForRoot<SyncDeviceRecord>(rootPath, "sync.updateLocalDevice", args); + } + + async forgetSyncDeviceForRoot(rootPath: string, deviceId: string): Promise<SyncRoleSnapshot> { + return await this.callSyncForRoot<SyncRoleSnapshot>(rootPath, "sync.forgetDevice", { deviceId }); + } + + async syncPinForRoot(rootPath: string): Promise<{ pin: string | null }> { + return await this.callSyncForRoot<{ pin: string | null }>(rootPath, "sync.getPin"); + } + + async setSyncPinForRoot(rootPath: string, pin: string): Promise<SyncRoleSnapshot> { + return await this.callSyncForRoot<SyncRoleSnapshot>(rootPath, "sync.setPin", { pin }); + } + + async generateSyncPinForRoot(rootPath: string): Promise<SyncRoleSnapshot> { + return await this.callSyncForRoot<SyncRoleSnapshot>(rootPath, "sync.generatePin"); + } + + async clearSyncPinForRoot(rootPath: string): Promise<SyncRoleSnapshot> { + return await this.callSyncForRoot<SyncRoleSnapshot>(rootPath, "sync.clearPin"); + } + + async callActionForRoot( + rootPath: string, + request: RemoteRuntimeActionRequest, + ): Promise<RemoteRuntimeActionResult> { + const project = await this.ensureProject(rootPath); + const entry = await this.connect(); + const value = await entry.client.call("ade/actions/call", { + projectId: project.projectId, + name: "run_ade_action", + arguments: { + domain: request.domain, + action: request.action, + ...(request.args ? { args: request.args } : {}), + ...(Object.prototype.hasOwnProperty.call(request, "arg") ? { arg: request.arg } : {}), + ...(request.argsList ? { argsList: request.argsList } : {}), + }, + }); + + if (value && typeof value === "object" && !Array.isArray(value)) { + const record = value as Record<string, unknown>; + if (record.ok === false) { + const error = record.error && typeof record.error === "object" && !Array.isArray(record.error) + ? record.error as Record<string, unknown> + : {}; + throw new Error(typeof error.message === "string" ? error.message : "Local ADE service action failed."); + } + return { + domain: typeof record.domain === "string" ? record.domain : request.domain, + action: typeof record.action === "string" ? record.action : request.action, + result: record.result, + statusHints: record.statusHints && typeof record.statusHints === "object" && !Array.isArray(record.statusHints) + ? record.statusHints as Record<string, unknown> + : {}, + }; + } + + return { + domain: request.domain, + action: request.action, + result: value, + statusHints: {}, + }; + } + + async streamEventsForRoot( + rootPath: string, + request: RemoteRuntimeStreamEventsRequest = {}, + ): Promise<RemoteRuntimeStreamEventsResult> { + const project = await this.ensureProject(rootPath); + const entry = await this.connect(); + const value = await entry.client.call("ade/actions/call", { + projectId: project.projectId, + name: "stream_events", + arguments: { + cursor: clampCursor(request.cursor), + limit: clampLimit(request.limit), + ...(isRemoteRuntimeEventCategory(request.category) ? { category: request.category } : {}), + }, + }); + + if (value && typeof value === "object" && !Array.isArray(value)) { + const record = value as Record<string, unknown>; + if (record.ok === false) { + const error = record.error && typeof record.error === "object" && !Array.isArray(record.error) + ? record.error as Record<string, unknown> + : {}; + throw new Error(typeof error.message === "string" ? error.message : "Local ADE service event stream failed."); + } + + return { + events: Array.isArray(record.events) + ? record.events.map(normalizeBufferedEvent).filter((event): event is RemoteRuntimeBufferedEvent => event != null) + : [], + nextCursor: typeof record.nextCursor === "number" && Number.isFinite(record.nextCursor) + ? Math.max(0, Math.floor(record.nextCursor)) + : clampCursor(request.cursor), + hasMore: record.hasMore === true, + }; + } + + return { + events: [], + nextCursor: clampCursor(request.cursor), + hasMore: false, + }; + } + + async subscribeEventsForRoot( + rootPath: string, + request: RemoteRuntimeStreamEventsRequest = {}, + onEvent: (event: RemoteRuntimeBufferedEvent) => void, + onEnded?: () => void, + ): Promise<() => void> { + const project = await this.ensureProject(rootPath); + const entry = await this.connect(); + return await subscribeToRuntimeEvents(entry.client, project.projectId, request, onEvent, onEnded); + } + + async callSyncForRoot<T>( + rootPath: string, + method: string, + params: Record<string, unknown> = {}, + ): Promise<T> { + const project = await this.ensureProject(rootPath); + const entry = await this.connect(); + return await entry.client.call(method, { + ...params, + projectId: project.projectId, + }) as T; + } + + dispose(): void { + const pending = this.connection; + this.connection = null; + this.activeClient = null; + this.projectsByRoot.clear(); + void pending?.then((entry) => { + try { entry.client.close(); } catch {} + }).catch(() => {}); + } + + private async connect(): Promise<LocalRuntimeConnection> { + if (this.connection) return this.connection; + this.connection = this.createConnection().catch((error) => { + this.connection = null; + throw error; + }); + return this.connection; + } + + private async createConnection(): Promise<LocalRuntimeConnection> { + const layout = resolveMachineAdeLayout(); + const socketPath = process.env.ADE_RUNTIME_SOCKET_PATH?.trim() || layout.socketPath; + const existing = await this.tryConnect(socketPath); + if (existing) return { client: existing, child: null, socketPath }; + + const child = this.spawnRuntime(socketPath); + await waitForSocket(socketPath); + const client = await this.connectClient(socketPath); + return { client, child, socketPath }; + } + + private async tryConnect(socketPath: string): Promise<RuntimeRpcClient | null> { + try { + return await this.connectClient(socketPath); + } catch (error) { + this.logger.debug("local_runtime.connect_existing_failed", { + socketPath, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + + private async connectClient(socketPath: string): Promise<RuntimeRpcClient> { + const transport = await openSocketTransport(socketPath); + const client = new RuntimeRpcClient(transport); + const initializeResult = await client.initialize("ade-desktop-local", this.appVersion); + const runtimeInfo = readRuntimeInfo(initializeResult); + if (runtimeInfo.version && runtimeInfo.version !== this.appVersion) { + this.logger.info("local_runtime.version_mismatch_restart", { + socketPath, + runtimeVersion: runtimeInfo.version, + appVersion: this.appVersion, + }); + await shutdownRuntimeClient(client); + throw new Error(`ADE service version ${runtimeInfo.version} does not match desktop version ${this.appVersion}.`); + } + const expectedBuildHash = computeLocalRuntimeBuildHash(); + if (expectedBuildHash && runtimeInfo.buildHash !== expectedBuildHash) { + this.logger.info("local_runtime.build_mismatch_restart", { + socketPath, + runtimeBuildHash: runtimeInfo.buildHash, + expectedBuildHash, + }); + await shutdownRuntimeClient(client); + throw new Error("ADE service build does not match the packaged desktop runtime."); + } + this.activeClient = client; + client.onDisconnect((error) => { + if (this.activeClient !== client) return; + this.logger.warn("local_runtime.disconnected", { + socketPath, + error: error.message, + }); + this.connection = null; + this.activeClient = null; + this.projectsByRoot.clear(); + }); + return client; + } + + private spawnRuntime(socketPath: string): ChildProcess { + const cliPath = resolveCliScriptPath(); + const args = buildLocalRuntimeServeArgs(cliPath, socketPath, this.options); + this.logger.info("local_runtime.spawn", { cliPath, socketPath, disableSync: this.options.disableSync === true }); + const env = buildLocalRuntimeNodeEnv(this.appVersion); + const buildHash = computeLocalRuntimeBuildHash(cliPath); + if (buildHash) env.ADE_RUNTIME_BUILD_HASH = buildHash; + const child = spawn(process.execPath, args, { + env, + stdio: "ignore", + detached: true, + }); + child.unref(); + child.once("exit", (code, signal) => { + this.logger.warn("local_runtime.exited", { code, signal }); + this.connection = null; + }); + child.once("error", (error) => { + this.logger.warn("local_runtime.spawn_failed", { error: error.message }); + this.connection = null; + }); + return child; + } +} + +function clampCursor(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(0, Math.floor(value)) + : 0; +} + +function clampLimit(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(1, Math.min(1000, Math.floor(value))) + : 100; +} + +function isRemoteRuntimeEventCategory(value: unknown): value is RemoteRuntimeEventCategory { + return value === "orchestrator" || value === "dag_mutation" || value === "runtime" || value === "mission"; +} + +function normalizeBufferedEvent(value: unknown): RemoteRuntimeBufferedEvent | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record<string, unknown>; + if (typeof record.id !== "number" || !Number.isFinite(record.id)) return null; + if (typeof record.timestamp !== "string") return null; + if (!isRemoteRuntimeEventCategory(record.category)) return null; + const payload = record.payload && typeof record.payload === "object" && !Array.isArray(record.payload) + ? record.payload as Record<string, unknown> + : {}; + return { + id: Math.max(0, Math.floor(record.id)), + timestamp: record.timestamp, + category: record.category, + payload, + }; +} + +async function subscribeToRuntimeEvents( + client: RuntimeRpcClient, + projectId: string, + request: RemoteRuntimeStreamEventsRequest, + onEvent: (event: RemoteRuntimeBufferedEvent) => void, + onEnded?: () => void, +): Promise<() => void> { + const pendingNotifications: RuntimeEventNotification[] = []; + let closed = false; + let subscriptionId: string | null = null; + + const removeNotificationListener = client.onNotification("runtime/event", (params) => { + if (closed) return; + const notification = normalizeRuntimeEventNotification(params); + if (!notification || notification.projectId !== projectId) return; + if (subscriptionId == null) { + pendingNotifications.push(notification); + return; + } + if (notification.subscriptionId === subscriptionId) { + onEvent(notification.event); + } + }); + const removeDisconnectListener = client.onDisconnect(() => { + if (closed) return; + closed = true; + removeNotificationListener(); + onEnded?.(); + }); + + try { + const value = await client.call("runtimeEvents.subscribe", { + projectId, + cursor: clampCursor(request.cursor), + limit: clampLimit(request.limit), + ...(isRemoteRuntimeEventCategory(request.category) ? { category: request.category } : {}), + }); + subscriptionId = readSubscriptionId(value); + for (const notification of pendingNotifications) { + if (closed) break; + if (notification.subscriptionId === subscriptionId) { + onEvent(notification.event); + } + } + } catch (error) { + closed = true; + removeNotificationListener(); + removeDisconnectListener(); + throw error; + } + + return () => { + if (closed) return; + closed = true; + removeNotificationListener(); + removeDisconnectListener(); + const id = subscriptionId; + if (id != null) { + void client.call("runtimeEvents.unsubscribe", { subscriptionId: id }).catch(() => {}); + } + }; +} + +function readSubscriptionId(value: unknown): string { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("ADE service event subscription did not return a subscription id."); + } + const id = (value as Record<string, unknown>).subscriptionId; + if (typeof id !== "string" || !id.trim()) { + throw new Error("ADE service event subscription did not return a subscription id."); + } + return id.trim(); +} + +function normalizeRuntimeEventNotification(value: unknown): RuntimeEventNotification | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record<string, unknown>; + const subscriptionId = typeof record.subscriptionId === "string" && record.subscriptionId.trim() + ? record.subscriptionId.trim() + : null; + const projectId = typeof record.projectId === "string" ? record.projectId : ""; + const event = normalizeBufferedEvent(record.event); + if (subscriptionId == null || !projectId || !event) return null; + return { subscriptionId, projectId, event }; +} diff --git a/apps/desktop/src/main/services/macosVm/macosVmService.test.ts b/apps/desktop/src/main/services/macosVm/macosVmService.test.ts index 7f95530fd..0369f95da 100644 --- a/apps/desktop/src/main/services/macosVm/macosVmService.test.ts +++ b/apps/desktop/src/main/services/macosVm/macosVmService.test.ts @@ -118,7 +118,6 @@ describe("createMacosVmService", () => { expect(commands.some(({ command, args }) => command === "rsync" && args.includes("--delete-excluded"))).toBe(true); expect(commands.some(({ command, args }) => command === "rsync" && args.includes("--exclude") && args.includes("/.ade/secrets/***"))).toBe(true); expect(commands.some(({ command, args }) => command === "rsync" && args.includes("--exclude") && args.includes("/.ade/ade.db*"))).toBe(true); - expect(commands.some(({ command, args }) => command === "rsync" && args.includes("--exclude") && args.includes("/.ade/cto/openclaw-*.json"))).toBe(true); expect(commands.filter(({ command }) => command === "rsync")).toHaveLength(1); expect(commands.some(({ command, args }) => path.basename(command) === "lume" && JSON.stringify(args) === JSON.stringify(["run", started.name, "--shared-dir", policy.hostPath]), diff --git a/apps/desktop/src/main/services/macosVm/macosVmService.ts b/apps/desktop/src/main/services/macosVm/macosVmService.ts index 2c6bf6891..fe7c956e3 100644 --- a/apps/desktop/src/main/services/macosVm/macosVmService.ts +++ b/apps/desktop/src/main/services/macosVm/macosVmService.ts @@ -72,7 +72,6 @@ const MIRROR_SYNC_EXCLUDES = [ "/.ade/cto/daily/***", "/.ade/cto/sessions.jsonl", "/.ade/cto/subordinate-activity.jsonl", - "/.ade/cto/openclaw-*.json", "/.ade/context/***", "/.ade/memory/***", "/.ade/history/***", @@ -277,8 +276,7 @@ function isIgnoredMirrorSyncPath(value: string | Buffer | null | undefined): boo || relative === ".ade/cto/MEMORY.md" || relative === ".ade/cto/core-memory.json" || relative === ".ade/cto/sessions.jsonl" - || relative === ".ade/cto/subordinate-activity.jsonl" - || /^\.ade\/cto\/openclaw-.*\.json$/.test(relative); + || relative === ".ade/cto/subordinate-activity.jsonl"; } function readPngDataUrl(filePath: string): string | null { diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index e056d93c7..138750879 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -4964,8 +4964,8 @@ describe("aiOrchestratorService", () => { "12f2b.txt')\"", "ADE_MISSION_ID='mission-1' exec claude --model 'sonnet' --permission-mode 'default'", "orchestrator/worker-prompts/worker-ce33e94c-b964-42c9-9127-dfdeb6853d36", - "/Users/admin/.zshrc:3: no such file or directory: /Users/admin/.openclaw/get-codex-token.sh", - "/Users/admin/.openclaw/completions/openclaw.zsh:3803: command not found: compdef", + "/Users/admin/.zshrc:3: no such file or directory: /Users/admin/.legacy-cli/get-codex-token.sh", + "/Users/admin/.legacy-cli/completions/legacy.zsh:3803: command not found: compdef", "apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts:428: const result =", "- `.ade/step-output-worker_validate-test-tab_1772818763484.md` — structured step output for orchestration", "\"type\": \"text\",", diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index 58ad3210f..1c4f538a6 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -800,7 +800,7 @@ export function createAiOrchestratorService(args: { logger, missionService, orchestratorService, - agentChatService, + agentChatService: initialAgentChatService, laneService, projectConfigService, aiIntegrationService, @@ -814,6 +814,7 @@ export function createAiOrchestratorService(args: { onDagMutation, hookCommandRunner = runOrchestratorHookCommand } = args; + let agentChatService = initialAgentChatService ?? null; const plannerMemoryService = createMemoryService(db); const syncLocks = new Set<string>(); const workerStates = new Map<string, OrchestratorWorkerState>(); @@ -880,7 +881,7 @@ export function createAiOrchestratorService(args: { logger, missionService, orchestratorService, - agentChatService: agentChatService ?? null, + agentChatService, laneService: laneService ?? null, projectConfigService: projectConfigService ?? null, aiIntegrationService: aiIntegrationService ?? null, @@ -7819,8 +7820,9 @@ Check all worker statuses and continue managing the mission from here. Read work let interruptedSessions = 0; let disposedSessions = 0; - if (agentChatService) { - if (typeof agentChatService.sendMessage === "function") { + const chatService = agentChatService; + if (chatService) { + if (typeof chatService.sendMessage === "function") { const outcomes = await Promise.all( targets.map(async (target) => ({ sessionId: target.sessionId, @@ -7844,13 +7846,13 @@ Check all worker statuses and continue managing the mission from here. Read work } } - if (typeof agentChatService.interrupt === "function") { + if (typeof chatService.interrupt === "function") { const outcomes = await Promise.all( targets.map(async (target) => ({ sessionId: target.sessionId, outcome: await runBestEffortWithTimeout({ timeoutMs: GRACEFUL_CANCEL_INTERRUPT_TIMEOUT_MS, - work: () => agentChatService.interrupt({ sessionId: target.sessionId }) + work: () => chatService.interrupt({ sessionId: target.sessionId }) }) })) ); @@ -7868,13 +7870,13 @@ Check all worker statuses and continue managing the mission from here. Read work } } - if (typeof agentChatService.dispose === "function") { + if (typeof chatService.dispose === "function") { const outcomes = await Promise.all( targets.map(async (target) => ({ sessionId: target.sessionId, outcome: await runBestEffortWithTimeout({ timeoutMs: GRACEFUL_CANCEL_DISPOSE_TIMEOUT_MS, - work: () => agentChatService.dispose({ sessionId: target.sessionId }) + work: () => chatService.dispose({ sessionId: target.sessionId }) }) })) ); @@ -11123,6 +11125,10 @@ Check all worker statuses and continue managing the mission from here. Read work runHealthSweep: (reason = "manual") => runHealthSweep(reason), getMissionLogs, exportMissionLogs, + setAgentChatService: (service: ReturnType<typeof createAgentChatService> | null) => { + agentChatService = service; + ctx.agentChatService = service; + }, dispose: () => { disposed = true; disposedRef.current = true; diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts index c756e624a..d123b21ae 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts @@ -6195,8 +6195,8 @@ describe("orchestratorService", () => { transcriptPath, [ "ADE_MISSION_ID='mission-1' ADE_RUN_ID='run-1' exec claude --model 'sonnet' --permission-mode 'default'", - "/Users/admin/.zshrc:3: no such file or directory: /Users/admin/.openclaw/get-codex-token.sh", - "/Users/admin/.openclaw/completions/openclaw.zsh:3803: command not found: compdef", + "/Users/admin/.zshrc:3: no such file or directory: /Users/admin/.legacy-cli/get-codex-token.sh", + "/Users/admin/.legacy-cli/completions/legacy.zsh:3803: command not found: compdef", "admin@Mac test-10-f4bb12de %", "-p \"$(cat '/Users/admin/Projects/ADE/.ade/orchestrator/worker-prompts/worker-123.txt')\"", ].join("\n"), diff --git a/apps/desktop/src/main/services/projects/adeProjectService.ts b/apps/desktop/src/main/services/projects/adeProjectService.ts index 8f89c4af3..3f0fec095 100644 --- a/apps/desktop/src/main/services/projects/adeProjectService.ts +++ b/apps/desktop/src/main/services/projects/adeProjectService.ts @@ -8,6 +8,8 @@ import type { AdePathEntry, AdeProjectSnapshot, AdeSyncAction, + ClearLocalAdeDataArgs, + ClearLocalAdeDataResult, } from "../../../shared/types"; import { buildAdeGitignore, ADE_LAYOUT_DEFINITIONS, resolveAdeLayout, type AdeLayoutPaths } from "../../../shared/adeLayout"; import type { Logger } from "../logging/logger"; @@ -75,10 +77,6 @@ const DEFAULT_CTO_IDENTITY = YAML.stringify( preCompactionFlush: true, temporalDecayHalfLifeDays: 30, }, - openclawContextPolicy: { - shareMode: "filtered", - blockedCategories: ["secret", "token", "system_prompt"], - }, updatedAt: "1970-01-01T00:00:00.000Z", }, { indent: 2 }, @@ -290,10 +288,6 @@ function repairLegacyPaths(paths: AdeLayoutPaths, actions: AdeSyncAction[]): voi moveIfExists(path.join(paths.adeDir, "log-bundles"), paths.logBundlesDir, "artifacts/log-bundles", actions); moveIfExists(path.join(paths.adeDir, "github"), paths.githubSecretsDir, "secrets/github", actions); moveIfExists(path.join(paths.adeDir, "api-keys.json"), path.join(paths.secretsDir, "api-keys.json"), "secrets/api-keys.json", actions); - moveIfExists(path.join(paths.ctoDir, "openclaw-history.json"), path.join(paths.cacheDir, "openclaw", "openclaw-history.json"), "cache/openclaw/openclaw-history.json", actions); - moveIfExists(path.join(paths.ctoDir, "openclaw-idempotency.json"), path.join(paths.cacheDir, "openclaw", "openclaw-idempotency.json"), "cache/openclaw/openclaw-idempotency.json", actions); - moveIfExists(path.join(paths.ctoDir, "openclaw-outbox.json"), path.join(paths.cacheDir, "openclaw", "openclaw-outbox.json"), "cache/openclaw/openclaw-outbox.json", actions); - moveIfExists(path.join(paths.ctoDir, "openclaw-routes.json"), path.join(paths.cacheDir, "openclaw", "openclaw-routes.json"), "cache/openclaw/openclaw-routes.json", actions); const legacyFiles = fs.existsSync(paths.adeDir) ? fs.readdirSync(paths.adeDir) : []; for (const fileName of legacyFiles) { @@ -366,7 +360,6 @@ export function initializeOrRepairAdeProject(projectRoot: string, options: Repai ensureDir(paths.chatSessionsDir, "cache/chat-sessions", actions); ensureDir(paths.chatTranscriptsDir, "transcripts/chat", actions); ensureDir(paths.orchestratorCacheDir, "cache/orchestrator", actions); - ensureDir(path.join(paths.cacheDir, "openclaw"), "cache/openclaw", actions); ensureDir(paths.missionStateDir, "cache/mission-state", actions); ensureDir(paths.packsDir, "artifacts/packs", actions); ensureDir(paths.logBundlesDir, "artifacts/log-bundles", actions); @@ -475,6 +468,28 @@ export function createAdeProjectService(args: AdeProjectServiceArgs) { return { changed: actions.length > 0, actions }; }; + const clearLocalData = (options: ClearLocalAdeDataArgs = {}): ClearLocalAdeDataResult => { + const clearedAt = new Date().toISOString(); + const deletedPaths: string[] = []; + + const rmrf = (absPath: string) => { + const resolved = path.resolve(absPath); + const allowedRoot = path.resolve(repair.paths.adeDir) + path.sep; + if (!resolved.startsWith(allowedRoot)) { + throw new Error("Refusing to delete outside .ade directory"); + } + if (!fs.existsSync(resolved)) return; + fs.rmSync(resolved, { recursive: true, force: true }); + deletedPaths.push(resolved); + }; + + if (options.packs) rmrf(repair.paths.artifactsDir); + if (options.logs) rmrf(repair.paths.logsDir); + if (options.transcripts) rmrf(repair.paths.transcriptsDir); + + return { deletedPaths, clearedAt }; + }; + const getSnapshot = (): AdeProjectSnapshot => { const configSnapshot = args.projectConfigService.get(); const configValidation = configSnapshot.validation; @@ -528,6 +543,7 @@ export function createAdeProjectService(args: AdeProjectServiceArgs) { getSnapshot, initializeOrRepair: () => initializeOrRepairAdeProject(args.projectRoot, { logger: args.logger, mode: "shared" }).cleanup, runIntegrityCheck, + clearLocalData, logIntegrityService, }; } diff --git a/apps/desktop/src/main/services/projects/projectDetailService.ts b/apps/desktop/src/main/services/projects/projectDetailService.ts index 72ae67544..e4679177b 100644 --- a/apps/desktop/src/main/services/projects/projectDetailService.ts +++ b/apps/desktop/src/main/services/projects/projectDetailService.ts @@ -1,6 +1,13 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { ProjectDetail, ProjectLanguageShare, ProjectLastCommit, RecentProjectSummary } from "../../../shared/types"; +import type { + ProjectDetail, + ProjectLanguageShare, + ProjectLastCommit, + RecentProjectSummary, + RemoteRuntimeProjectWorkSummary, + RemoteRuntimeProjectWorktreeSummary, +} from "../../../shared/types"; import { runGit } from "../git/git"; import { readGlobalState } from "../state/globalState"; import { toRecentProjectSummary } from "./recentProjectSummary"; @@ -199,6 +206,52 @@ async function readGitMetadata(rootPath: string): Promise<Pick<ProjectDetail, "b return { branchName, dirtyCount, lastCommit, aheadBehind }; } +async function readWorktreeSummary(args: { + rootPath: string; + name: string; + isPrimary: boolean; +}): Promise<RemoteRuntimeProjectWorktreeSummary | null> { + const isRepo = await isGitRepo(args.rootPath); + if (!isRepo) return null; + const [branchRes, dirtyRes] = await Promise.all([ + runGit(["rev-parse", "--abbrev-ref", "HEAD"], { + cwd: args.rootPath, + timeoutMs: 5_000, + }), + runGit(["status", "--porcelain=v1", "--untracked-files=all"], { + cwd: args.rootPath, + timeoutMs: 8_000, + }), + ]); + return { + rootPath: args.rootPath, + name: args.name, + branchName: branchRes.exitCode === 0 ? branchRes.stdout.trim() || null : null, + dirtyCount: + dirtyRes.exitCode === 0 + ? dirtyRes.stdout + .split(/\r?\n/) + .filter((line) => line.trim().length > 0).length + : 0, + isPrimary: args.isPrimary, + }; +} + +async function listAdeWorktreeRoots(rootPath: string): Promise<Array<{ rootPath: string; name: string }>> { + const worktreesPath = path.join(rootPath, ".ade", "worktrees"); + try { + const dirents = await fs.readdir(worktreesPath, { withFileTypes: true }); + return dirents + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => ({ + rootPath: path.join(worktreesPath, dirent.name), + name: dirent.name, + })); + } catch { + return []; + } +} + export type GetProjectDetailOptions = { globalStatePath?: string | null; }; @@ -285,6 +338,40 @@ export async function getProjectDetail(rootPath: string, options: GetProjectDeta }; } +export async function getProjectWorkSummary(rootPath: string): Promise<RemoteRuntimeProjectWorkSummary> { + const { requestedRoot, scanRoot } = await resolveProjectDetailScanRoot(rootPath); + const worktrees = await listAdeWorktreeRoots(scanRoot); + const summaries = ( + await Promise.all([ + readWorktreeSummary({ + rootPath: scanRoot, + name: "Primary", + isPrimary: true, + }), + ...worktrees.map((worktree) => + readWorktreeSummary({ + rootPath: worktree.rootPath, + name: worktree.name, + isPrimary: false, + }), + ), + ]) + ).filter((entry): entry is RemoteRuntimeProjectWorktreeSummary => entry != null); + const primary = summaries.find((summary) => summary.isPrimary); + return { + rootPath: requestedRoot, + laneCount: summaries.length, + checkedLaneCount: summaries.length, + dirtyLaneCount: summaries.filter((summary) => summary.dirtyCount > 0).length, + dirtyFileCount: summaries.reduce((sum, summary) => sum + summary.dirtyCount, 0), + primaryDirtyCount: primary?.dirtyCount ?? 0, + lanes: summaries.map((summary) => ({ + ...summary, + rootPath: summary.isPrimary ? requestedRoot : summary.rootPath, + })), + }; +} + export const __internal = { parseLastCommitLine, parseAheadBehind, diff --git a/apps/desktop/src/main/services/projects/projectLifecycle.test.ts b/apps/desktop/src/main/services/projects/projectLifecycle.test.ts index 2108c2f6a..262923102 100644 --- a/apps/desktop/src/main/services/projects/projectLifecycle.test.ts +++ b/apps/desktop/src/main/services/projects/projectLifecycle.test.ts @@ -5,7 +5,7 @@ import { execFileSync } from "node:child_process"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildAdeGitignore, resolveAdeLayout } from "../../../shared/adeLayout"; -import { initializeOrRepairAdeProject } from "./adeProjectService"; +import { createAdeProjectService, initializeOrRepairAdeProject } from "./adeProjectService"; import { browseProjectDirectories } from "./projectBrowserService"; import { __internal, getProjectDetail } from "./projectDetailService"; import { inspectRecentProject, toRecentProjectSummary } from "./recentProjectSummary"; @@ -144,8 +144,6 @@ describe("initializeOrRepairAdeProject", () => { fs.mkdirSync(path.join(root, ".ade", "chat-sessions"), { recursive: true }); fs.writeFileSync(path.join(root, ".ade", "chat-sessions", "session-1.json"), "{\"id\":\"session-1\"}\n", "utf8"); fs.writeFileSync(path.join(root, ".ade", "mission-state-run-1.json"), "{\"runId\":\"run-1\"}\n", "utf8"); - fs.mkdirSync(path.join(root, ".ade", "cto"), { recursive: true }); - fs.writeFileSync(path.join(root, ".ade", "cto", "openclaw-history.json"), "[]\n", "utf8"); return root; } @@ -173,10 +171,8 @@ describe("initializeOrRepairAdeProject", () => { expect(fs.existsSync(path.join(layout.logsDir, "main.jsonl"))).toBe(true); expect(fs.existsSync(path.join(layout.chatSessionsDir, "session-1.json"))).toBe(true); expect(fs.existsSync(path.join(layout.missionStateDir, "mission-state-run-1.json"))).toBe(true); - expect(fs.existsSync(path.join(layout.cacheDir, "openclaw", "openclaw-history.json"))).toBe(true); expect(fs.existsSync(path.join(layout.adeDir, "logs"))).toBe(false); expect(fs.existsSync(path.join(layout.adeDir, "chat-sessions"))).toBe(false); - expect(fs.existsSync(path.join(layout.ctoDir, "openclaw-history.json"))).toBe(false); }); it("is idempotent once the canonical structure is in place", () => { @@ -337,6 +333,46 @@ describe("initializeOrRepairAdeProject", () => { }); }); +describe("createAdeProjectService.clearLocalData", () => { + it("deletes only selected generated .ade data directories", () => { + const root = makeTempDir("ade-project-clear-local-data-"); + const layout = resolveAdeLayout(root); + const service = createAdeProjectService({ + projectRoot: root, + db: makeProjectConfigDb(), + projectId: "project-1", + logger: createLogger(), + projectConfigService: { + get: () => ({ validation: { ok: true, issues: [] } }), + }, + }); + fs.mkdirSync(layout.artifactsDir, { recursive: true }); + fs.mkdirSync(layout.logsDir, { recursive: true }); + fs.mkdirSync(layout.transcriptsDir, { recursive: true }); + fs.mkdirSync(layout.cacheDir, { recursive: true }); + fs.mkdirSync(layout.secretsDir, { recursive: true }); + fs.writeFileSync(path.join(layout.artifactsDir, "pack.txt"), "pack", "utf8"); + fs.writeFileSync(path.join(layout.logsDir, "run.log"), "log", "utf8"); + fs.writeFileSync(path.join(layout.transcriptsDir, "chat.jsonl"), "chat", "utf8"); + fs.writeFileSync(path.join(layout.cacheDir, "keep.json"), "cache", "utf8"); + fs.writeFileSync(path.join(layout.secretsDir, "keep"), "secret", "utf8"); + + const result = service.clearLocalData({ packs: true, logs: true, transcripts: true }); + + expect(result.clearedAt).toEqual(expect.any(String)); + expect(result.deletedPaths).toEqual(expect.arrayContaining([ + path.resolve(layout.artifactsDir), + path.resolve(layout.logsDir), + path.resolve(layout.transcriptsDir), + ])); + expect(fs.existsSync(layout.artifactsDir)).toBe(false); + expect(fs.existsSync(layout.logsDir)).toBe(false); + expect(fs.existsSync(layout.transcriptsDir)).toBe(false); + expect(fs.readFileSync(path.join(layout.cacheDir, "keep.json"), "utf8")).toBe("cache"); + expect(fs.readFileSync(path.join(layout.secretsDir, "keep"), "utf8")).toBe("secret"); + }); +}); + // --------------------------------------------------------------------------- // browseProjectDirectories — directory picker for "Add Project" // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts index 94c39cb5a..7bb278c08 100644 --- a/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts @@ -420,6 +420,34 @@ describe("cloneRepository", () => { ]); }); + it("uses an explicit one-shot GitHub auth header for remote clone requests", async () => { + runGitMock.mockResolvedValue(gitOk()); + const parentDir = makeTempDir("ade-scaffold-clone-explicit-auth-"); + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub({ + getTokenOrThrow: vi.fn(() => { + throw new Error("No local token on this machine."); + }), + }), + }); + + await service.cloneRepository({ + url: "https://github.com/octocat/Hello-World", + parentDir, + githubAuthHeader: "basic one-shot", + }); + + const cloneCall = runGitMock.mock.calls.find((c) => (c[0] as string[])[0] === "clone"); + expect(cloneCall?.[0]).toEqual([ + "clone", + "-c", + "http.https://github.com/.extraheader=AUTHORIZATION: basic one-shot", + "https://github.com/octocat/Hello-World", + path.join(parentDir, "Hello-World"), + ]); + }); + it("falls back to a plain clone (no extraheader) when no token is stored", async () => { runGitMock.mockResolvedValue(gitOk()); const parentDir = makeTempDir("ade-scaffold-clone-no-token-"); diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.ts index 72fcc880a..4fabd542c 100644 --- a/apps/desktop/src/main/services/projects/projectScaffoldService.ts +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.ts @@ -215,19 +215,22 @@ export function createProjectScaffoldService({ // clones work in environments without a system credential helper. Using // the basic-auth shape (x-access-token:<token>) is the GitHub-recommended // form and avoids leaking the token via the URL in process listings. - let storedToken: string | null = null; - try { - storedToken = githubService.getTokenOrThrow(); - } catch { - storedToken = null; + let authHeader = (input.githubAuthHeader ?? "").trim(); + if (!authHeader) { + try { + const storedToken = githubService.getTokenOrThrow(); + const basic = Buffer.from(`x-access-token:${storedToken}`, "utf8").toString("base64"); + authHeader = `basic ${basic}`; + } catch { + authHeader = ""; + } } const cloneArgs: string[] = ["clone"]; - if (storedToken) { - const basic = Buffer.from(`x-access-token:${storedToken}`, "utf8").toString("base64"); + if (authHeader) { cloneArgs.push( "-c", - `http.https://github.com/.extraheader=AUTHORIZATION: basic ${basic}`, + `http.https://github.com/.extraheader=AUTHORIZATION: ${authHeader}`, ); } cloneArgs.push(url, rootPath); diff --git a/apps/desktop/src/main/services/projects/projectService.test.ts b/apps/desktop/src/main/services/projects/projectService.test.ts new file mode 100644 index 000000000..b91052afd --- /dev/null +++ b/apps/desktop/src/main/services/projects/projectService.test.ts @@ -0,0 +1,75 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { openKvDb } from "../state/kvDb"; +import { resolveRepoRoot, upsertProjectRow } from "./projectService"; + +const tempRoots = new Set<string>(); + +function makeTempRoot(prefix = "ade-project-service-"): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempRoots.add(root); + return root; +} + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; +} + +afterEach(() => { + for (const root of tempRoots) { + fs.rmSync(root, { recursive: true, force: true }); + } + tempRoots.clear(); +}); + +describe("resolveRepoRoot", () => { + it("normalizes an ADE-managed lane worktree back to the parent project", async () => { + const rawProjectRoot = makeTempRoot("ade-project-service-worktree-"); + fs.mkdirSync(path.join(rawProjectRoot, ".ade"), { recursive: true }); + const projectRoot = fs.realpathSync.native(rawProjectRoot); + const nested = path.join(projectRoot, ".ade", "worktrees", "feature-a", "apps", "desktop"); + fs.mkdirSync(nested, { recursive: true }); + + await expect(resolveRepoRoot(nested)).resolves.toBe(projectRoot); + }); +}); + +describe("upsertProjectRow", () => { + it("repairs an existing project row recorded against an ADE-managed lane worktree", async () => { + const rawProjectRoot = makeTempRoot("ade-project-service-upsert-"); + fs.mkdirSync(path.join(rawProjectRoot, ".ade"), { recursive: true }); + const projectRoot = fs.realpathSync.native(rawProjectRoot); + const worktreeRoot = path.join(projectRoot, ".ade", "worktrees", "feature-a"); + fs.mkdirSync(worktreeRoot, { recursive: true }); + const db = await openKvDb(path.join(projectRoot, ".ade", "ade.db"), createLogger()); + try { + const now = "2026-05-11T00:00:00.000Z"; + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + ["project-worktree", worktreeRoot, "Feature A", "main", now, now], + ); + + const result = upsertProjectRow({ + db, + repoRoot: projectRoot, + displayName: "ADE", + baseRef: "main", + }); + + expect(result.projectId).toBe("project-worktree"); + expect(db.all("select id, root_path from projects")).toEqual([ + { id: "project-worktree", root_path: projectRoot }, + ]); + } finally { + db.close(); + } + }); +}); diff --git a/apps/desktop/src/main/services/projects/projectService.ts b/apps/desktop/src/main/services/projects/projectService.ts index 0e8573753..52bd63659 100644 --- a/apps/desktop/src/main/services/projects/projectService.ts +++ b/apps/desktop/src/main/services/projects/projectService.ts @@ -3,10 +3,16 @@ import { randomUUID } from "node:crypto"; import { runGit, runGitOrThrow } from "../git/git"; import type { AdeDb } from "../state/kvDb"; import type { ProjectInfo } from "../../../shared/types"; +import { + findAdeManagedWorktreeRoot, + normalizeProjectRootPath, +} from "../../../../../ade-cli/src/services/projects/projectRoots"; export async function resolveRepoRoot(selectedPath: string): Promise<string> { + const managedWorktree = findAdeManagedWorktreeRoot(selectedPath); + if (managedWorktree) return managedWorktree.projectRoot; const out = await runGitOrThrow(["rev-parse", "--show-toplevel"], { cwd: selectedPath, timeoutMs: 10_000 }); - return out.trim(); + return normalizeProjectRootPath(out.trim()); } export async function detectDefaultBaseRef(repoRoot: string): Promise<string> { @@ -39,10 +45,16 @@ export function upsertProjectRow({ baseRef: string; }): { projectId: string } { const now = new Date().toISOString(); - const existing = db.get<{ id: string }>("select id from projects where root_path = ? limit 1", [repoRoot]); + const normalizedRepoRoot = normalizeProjectRootPath(repoRoot); + const exactExisting = db.get<{ id: string }>("select id from projects where root_path = ? limit 1", [normalizedRepoRoot]); + const aliasExisting = exactExisting ?? db + .all<{ id: string; root_path: string }>("select id, root_path from projects") + .find((row) => normalizeProjectRootPath(String(row.root_path)) === normalizedRepoRoot); + const existing = aliasExisting ? { id: aliasExisting.id } : null; const id = existing?.id ?? randomUUID(); if (existing?.id) { - db.run("update projects set display_name = ?, default_base_ref = ?, last_opened_at = ? where id = ?", [ + db.run("update projects set root_path = ?, display_name = ?, default_base_ref = ?, last_opened_at = ? where id = ?", [ + normalizedRepoRoot, displayName, baseRef, now, @@ -51,12 +63,13 @@ export function upsertProjectRow({ } else { db.run( "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - [id, repoRoot, displayName, baseRef, now, now] + [id, normalizedRepoRoot, displayName, baseRef, now, now] ); } return { projectId: id }; } export function toProjectInfo(repoRoot: string, baseRef: string): ProjectInfo { - return { rootPath: repoRoot, displayName: path.basename(repoRoot), baseRef }; + const normalizedRepoRoot = normalizeProjectRootPath(repoRoot); + return { rootPath: normalizedRepoRoot, displayName: path.basename(normalizedRepoRoot), baseRef }; } diff --git a/apps/desktop/src/main/services/projects/startupProjectResolver.test.ts b/apps/desktop/src/main/services/projects/startupProjectResolver.test.ts new file mode 100644 index 000000000..42823aef8 --- /dev/null +++ b/apps/desktop/src/main/services/projects/startupProjectResolver.test.ts @@ -0,0 +1,116 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { normalizeStartupProjectState, resolveStartupProject } from "./startupProjectResolver"; + +const normalizeProjectPath = (value: string) => path.resolve("/", value); + +describe("resolveStartupProject", () => { + it("prefers an explicit ADE_PROJECT_ROOT", () => { + const result = resolveStartupProject({ + envRoot: "env-project", + pendingStartupProjectRoot: "pending-project", + validLastProjectRoot: "last-project", + recentProjects: [ + { rootPath: "recent-project", displayName: "Recent", lastOpenedAt: "2026-05-11T00:00:00.000Z" }, + ], + normalizeProjectPath, + }); + + expect(result).toEqual({ rootPath: "/env-project", source: "env" }); + }); + + it("restores the last valid project on normal packaged startup", () => { + const result = resolveStartupProject({ + envRoot: "", + pendingStartupProjectRoot: null, + validLastProjectRoot: "last-project", + recentProjects: [ + { rootPath: "recent-project", displayName: "Recent", lastOpenedAt: "2026-05-11T00:00:00.000Z" }, + ], + normalizeProjectPath, + }); + + expect(result).toEqual({ rootPath: "/last-project", source: "last-project" }); + }); + + it("falls back to the first recent project when lastProjectRoot is unavailable", () => { + const result = resolveStartupProject({ + envRoot: null, + pendingStartupProjectRoot: null, + validLastProjectRoot: "", + recentProjects: [ + { rootPath: "recent-project", displayName: "Recent", lastOpenedAt: "2026-05-11T00:00:00.000Z" }, + ], + normalizeProjectPath, + }); + + expect(result).toEqual({ rootPath: "/recent-project", source: "recent-project" }); + }); +}); + +describe("normalizeStartupProjectState", () => { + const nowIso = "2026-05-11T12:00:00.000Z"; + const isLikelyRepoRoot = (value: string) => value !== "/missing"; + + it("keeps a valid last project even when older runtime-backed opens did not add it to recents", () => { + const result = normalizeStartupProjectState({ + saved: { + lastProjectRoot: "lost-project", + recentProjects: [ + { rootPath: "other-project", displayName: "Other", lastOpenedAt: "2026-05-10T00:00:00.000Z" }, + ], + }, + isLikelyRepoRoot, + normalizeProjectPath, + nowIso, + }); + + expect(result.validLastProjectRoot).toBe("/lost-project"); + expect(result.recentProjects).toEqual([ + { rootPath: "/lost-project", displayName: "lost-project", lastOpenedAt: nowIso }, + { rootPath: "/other-project", displayName: "Other", lastOpenedAt: "2026-05-10T00:00:00.000Z" }, + ]); + expect(result.state.lastProjectRoot).toBe("/lost-project"); + expect(result.changed).toBe(true); + }); + + it("drops invalid startup roots without dropping valid recents", () => { + const result = normalizeStartupProjectState({ + saved: { + lastProjectRoot: "missing", + recentProjects: [ + { rootPath: "valid-project", displayName: "", lastOpenedAt: "" }, + { rootPath: "missing", displayName: "Missing", lastOpenedAt: "2026-05-10T00:00:00.000Z" }, + ], + }, + isLikelyRepoRoot, + normalizeProjectPath, + nowIso, + }); + + expect(result.validLastProjectRoot).toBe(""); + expect(result.recentProjects).toEqual([ + { rootPath: "/valid-project", displayName: "valid-project", lastOpenedAt: nowIso }, + ]); + expect(result.state.lastProjectRoot).toBeUndefined(); + expect(result.changed).toBe(true); + }); + + it("uses machine registry projects when desktop state has no recent projects", () => { + const result = normalizeStartupProjectState({ + saved: {}, + additionalRecentProjects: [ + { rootPath: "registry-project", displayName: "Registry project", lastOpenedAt: "2026-05-09T00:00:00.000Z" }, + ], + isLikelyRepoRoot, + normalizeProjectPath, + nowIso, + }); + + expect(result.recentProjects).toEqual([ + { rootPath: "/registry-project", displayName: "Registry project", lastOpenedAt: "2026-05-09T00:00:00.000Z" }, + ]); + expect(result.changed).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/services/projects/startupProjectResolver.ts b/apps/desktop/src/main/services/projects/startupProjectResolver.ts new file mode 100644 index 000000000..0023356be --- /dev/null +++ b/apps/desktop/src/main/services/projects/startupProjectResolver.ts @@ -0,0 +1,133 @@ +import path from "node:path"; +import type { GlobalState, RecentProject } from "../state/globalState"; + +export type StartupProjectSource = "env" | "pending-open" | "last-project" | "recent-project" | "none"; + +export type StartupProjectResolution = { + rootPath: string | null; + source: StartupProjectSource; +}; + +export type StartupProjectStateNormalization = { + state: GlobalState; + validLastProjectRoot: string; + recentProjects: RecentProject[]; + changed: boolean; +}; + +export function normalizeStartupProjectState(args: { + saved: GlobalState; + additionalRecentProjects?: RecentProject[]; + isLikelyRepoRoot: (value: string) => boolean; + normalizeProjectPath: (value: string) => string; + nowIso?: string; +}): StartupProjectStateNormalization { + const savedRecentProjects = args.saved.recentProjects ?? []; + const candidateRecentProjects = [ + ...savedRecentProjects, + ...(args.additionalRecentProjects ?? []), + ]; + const baseCleanedRecentProjects = candidateRecentProjects.reduce((acc, entry) => { + const rootPath = + typeof entry?.rootPath === "string" + ? args.normalizeProjectPath(entry.rootPath) + : ""; + if (!args.isLikelyRepoRoot(rootPath)) return acc; + if (acc.some((item) => item.rootPath === rootPath)) return acc; + const displayName = + typeof entry?.displayName === "string" && + entry.displayName.trim().length > 0 + ? entry.displayName + : path.basename(rootPath); + const lastOpenedAt = + typeof entry?.lastOpenedAt === "string" && + entry.lastOpenedAt.trim().length > 0 + ? entry.lastOpenedAt + : args.nowIso ?? new Date().toISOString(); + acc.push({ rootPath, displayName, lastOpenedAt }); + return acc; + }, [] as RecentProject[]); + const cleanedLastProjectRoot = args.saved.lastProjectRoot + ? args.normalizeProjectPath(args.saved.lastProjectRoot) + : ""; + const validLastProjectRoot = args.isLikelyRepoRoot(cleanedLastProjectRoot) + ? cleanedLastProjectRoot + : ""; + const recentProjects = + validLastProjectRoot && + !baseCleanedRecentProjects.some((project) => project.rootPath === validLastProjectRoot) + ? [ + { + rootPath: validLastProjectRoot, + displayName: path.basename(validLastProjectRoot), + lastOpenedAt: args.nowIso ?? new Date().toISOString(), + }, + ...baseCleanedRecentProjects, + ].slice(0, 12) + : baseCleanedRecentProjects; + const recentProjectsChanged = + recentProjects.length !== savedRecentProjects.length || + recentProjects.some((project, index) => { + const savedProject = savedRecentProjects[index]; + return !savedProject || + savedProject.rootPath !== project.rootPath || + savedProject.displayName !== project.displayName || + savedProject.lastOpenedAt !== project.lastOpenedAt; + }); + const normalizedLastProjectRoot = validLastProjectRoot || undefined; + const lastProjectRootChanged = + args.saved.lastProjectRoot !== normalizedLastProjectRoot; + return { + state: { + ...args.saved, + lastProjectRoot: normalizedLastProjectRoot, + recentProjects, + }, + validLastProjectRoot, + recentProjects, + changed: recentProjectsChanged || lastProjectRootChanged, + }; +} + +export function resolveStartupProject(args: { + envRoot?: string | null; + pendingStartupProjectRoot?: string | null; + validLastProjectRoot?: string | null; + recentProjects: RecentProject[]; + normalizeProjectPath: (value: string) => string; +}): StartupProjectResolution { + const envRoot = typeof args.envRoot === "string" ? args.envRoot.trim() : ""; + if (envRoot) { + return { rootPath: args.normalizeProjectPath(envRoot), source: "env" }; + } + + if (args.pendingStartupProjectRoot) { + return { + rootPath: args.normalizeProjectPath(args.pendingStartupProjectRoot), + source: "pending-open", + }; + } + + const lastProjectRoot = + typeof args.validLastProjectRoot === "string" + ? args.validLastProjectRoot.trim() + : ""; + if (lastProjectRoot) { + return { + rootPath: args.normalizeProjectPath(lastProjectRoot), + source: "last-project", + }; + } + + const recentProjectRoot = args.recentProjects.find( + (project) => typeof project.rootPath === "string" && project.rootPath.trim().length > 0, + )?.rootPath; + if (recentProjectRoot) { + return { + rootPath: args.normalizeProjectPath(recentProjectRoot), + source: "recent-project", + }; + } + + return { rootPath: null, source: "none" }; +} diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts new file mode 100644 index 000000000..1afd76db2 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -0,0 +1,534 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { Client } from "ssh2"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; +import { + bootstrapRemoteRuntime, + buildRemoteRuntimeEnvironmentPrefix, + normalizeRemoteArch, + normalizeRuntimeVersion, + resolveRemoteRuntimeLayout, + selectRemoteRuntimeVersion, + shouldUploadBundledRuntime, + validateRemoteRuntimeInitializeResult, +} from "./remoteBootstrap"; + +const connectSshMock = vi.hoisted(() => vi.fn()); +const execSshMock = vi.hoisted(() => vi.fn()); +const openSshRuntimeTransportMock = vi.hoisted(() => vi.fn()); +const initializeMock = vi.hoisted(() => vi.fn()); +const callMock = vi.hoisted(() => vi.fn()); +const runtimeRpcClientMock = vi.hoisted(() => vi.fn()); + +vi.mock("./sshTransport", () => ({ + connectSsh: connectSshMock, + execSsh: execSshMock, + openSshRuntimeTransport: openSshRuntimeTransportMock, +})); + +vi.mock("./runtimeRpcClient", () => ({ + RuntimeRpcClient: runtimeRpcClientMock, +})); + +describe("normalizeRemoteArch", () => { + it("normalizes supported uname platform and architecture pairs", () => { + expect(normalizeRemoteArch("Darwin arm64")).toEqual({ + platform: "darwin", + arch: "arm64", + label: "darwin-arm64", + }); + expect(normalizeRemoteArch("Linux x86_64")).toEqual({ + platform: "linux", + arch: "x64", + label: "linux-x64", + }); + expect(normalizeRemoteArch("Linux aarch64")).toEqual({ + platform: "linux", + arch: "arm64", + label: "linux-arm64", + }); + }); + + it("rejects unsupported remote ADE service targets instead of guessing", () => { + expect(() => normalizeRemoteArch("FreeBSD riscv64")).toThrow(/unsupported remote ade service platform/i); + expect(() => normalizeRemoteArch("Linux riscv64")).toThrow(/unsupported remote ade service platform/i); + }); +}); + +describe("normalizeRuntimeVersion", () => { + it("normalizes plain and prefixed ADE version output", () => { + expect(normalizeRuntimeVersion("1.0.0-beta.1\n")).toBe("1.0.0-beta.1"); + expect(normalizeRuntimeVersion("ade 1.0.0-beta.1\n")).toBe("1.0.0-beta.1"); + }); + + it("returns null for empty version output", () => { + expect(normalizeRuntimeVersion("\n")).toBeNull(); + }); +}); + +describe("selectRemoteRuntimeVersion", () => { + it("prefers executable output over the marker file", () => { + expect(selectRemoteRuntimeVersion({ + markerVersion: "1.0.0", + executableVersion: "1.0.1", + })).toBe("1.0.1"); + }); + + it("uses the marker when the executable cannot report a version", () => { + expect(selectRemoteRuntimeVersion({ + markerVersion: "1.0.0", + executableVersion: null, + })).toBe("1.0.0"); + }); +}); + +describe("shouldUploadBundledRuntime", () => { + it("uploads when the marker matches but the remote executable is missing", () => { + expect(shouldUploadBundledRuntime({ + localBinaryAvailable: true, + executableVersion: null, + appVersion: "1.0.0", + })).toBe(true); + }); + + it("skips upload when the executable itself matches the desktop version", () => { + expect(shouldUploadBundledRuntime({ + localBinaryAvailable: true, + executableVersion: "1.0.0", + appVersion: "1.0.0", + localBinarySha256: "abc", + remoteBinarySha256: "abc", + })).toBe(false); + }); + + it("uploads when the executable version matches but the binary hash changed", () => { + expect(shouldUploadBundledRuntime({ + localBinaryAvailable: true, + executableVersion: "1.0.0", + appVersion: "1.0.0", + localBinarySha256: "new", + remoteBinarySha256: "old", + })).toBe(true); + }); + + it("does not upload when no bundled runtime exists for the remote architecture", () => { + expect(shouldUploadBundledRuntime({ + localBinaryAvailable: false, + executableVersion: null, + appVersion: "1.0.0", + })).toBe(false); + }); +}); + +describe("buildRemoteRuntimeEnvironmentPrefix", () => { + it("adds ADE and user-install bins to the remote runtime PATH", () => { + expect(buildRemoteRuntimeEnvironmentPrefix({ + archLabel: "linux-x64", + nativeDepsReady: false, + })).toBe('ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" '); + }); + + it("adds the uploaded native dependency bundle to NODE_PATH", () => { + expect(buildRemoteRuntimeEnvironmentPrefix({ + archLabel: "darwin-arm64", + nativeDepsReady: true, + })).toContain('NODE_PATH="$HOME/.ade/runtime/darwin-arm64/node_modules${NODE_PATH:+:$NODE_PATH}"'); + }); + + it("uses isolated remote paths for Alpha and Beta channels", () => { + const alphaLayout = resolveRemoteRuntimeLayout({ ADE_PACKAGE_CHANNEL: "alpha" } as NodeJS.ProcessEnv); + const betaLayout = resolveRemoteRuntimeLayout({ ADE_PACKAGE_CHANNEL: "beta" } as NodeJS.ProcessEnv); + + expect(alphaLayout).toMatchObject({ + homeDirName: ".ade-alpha", + binaryRelative: ".ade-alpha/bin/ade", + versionExpr: "$HOME/.ade-alpha/bin/ade.version", + }); + expect(buildRemoteRuntimeEnvironmentPrefix({ + archLabel: "darwin-arm64", + nativeDepsReady: true, + layout: alphaLayout, + })).toBe('ADE_HOME="$HOME/.ade-alpha" PATH="$HOME/.ade-alpha/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PACKAGE_CHANNEL="alpha" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 NODE_PATH="$HOME/.ade-alpha/runtime/darwin-arm64/node_modules${NODE_PATH:+:$NODE_PATH}" '); + expect(betaLayout).toMatchObject({ + homeDirName: ".ade-beta", + binaryRelative: ".ade-beta/bin/ade", + versionExpr: "$HOME/.ade-beta/bin/ade.version", + }); + }); +}); + +describe("validateRemoteRuntimeInitializeResult", () => { + it("accepts a multi-project runtime with the expected version", () => { + expect(() => validateRemoteRuntimeInitializeResult({ + expectedVersion: "1.0.0", + result: { + runtimeInfo: { version: "1.0.0", multiProject: true }, + capabilities: { + projects: true, + machineProjects: { + browseDirectories: true, + getDetail: true, + getWorkSummary: true, + getDefaultParentDir: true, + create: true, + clone: true, + listMyGitHubRepos: true, + }, + }, + }, + })).not.toThrow(); + }); + + it("rejects a stale single-project runtime", () => { + expect(() => validateRemoteRuntimeInitializeResult({ + expectedVersion: null, + result: { + runtimeInfo: { version: "0.9.0" }, + capabilities: { actions: { listChanged: true } }, + }, + })).toThrow(/multi-project/i); + }); + + it("rejects a multi-project runtime that cannot handle machine-level project operations", () => { + expect(() => validateRemoteRuntimeInitializeResult({ + expectedVersion: "1.0.0", + result: { + runtimeInfo: { version: "1.0.0", multiProject: true }, + capabilities: { projects: true }, + }, + })).toThrow(/missing project capability/i); + }); + + it("rejects a bundled runtime with the wrong reported version", () => { + expect(() => validateRemoteRuntimeInitializeResult({ + expectedVersion: "1.0.0", + result: { + runtimeInfo: { version: "0.9.0", multiProject: true }, + capabilities: { + projects: true, + machineProjects: { + browseDirectories: true, + getDetail: true, + getWorkSummary: true, + getDefaultParentDir: true, + create: true, + clone: true, + listMyGitHubRepos: true, + }, + }, + }, + })).toThrow(/version mismatch/i); + }); +}); + +const APP_VERSION = "2.0.0"; + +const uploadTarget: RemoteRuntimeTarget = { + id: "target-1", + name: "Build host", + hostname: "build-host.local", + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, +}; + +function ok(stdout = "") { + return { stdout, stderr: "", code: 0 }; +} + +function createTempResources( + archLabel = "linux-x64", + options: { nativeDeps?: boolean } = {}, +): { resourcesPath: string; binaryPath: string; binarySha256: string; cleanup: () => void } { + const resourcesPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-remote-runtime-")); + const runtimeDir = path.join(resourcesPath, "runtime"); + fs.mkdirSync(runtimeDir, { recursive: true }); + const binaryPath = path.join(runtimeDir, `ade-${archLabel}`); + fs.writeFileSync(binaryPath, "#!/bin/sh\n"); + if (options.nativeDeps) { + fs.writeFileSync(path.join(runtimeDir, `ade-${archLabel}.native.tar.gz`), "native deps fixture\n"); + } + const binarySha256 = crypto.createHash("sha256").update(fs.readFileSync(binaryPath)).digest("hex"); + return { + resourcesPath, + binaryPath, + binarySha256, + cleanup: () => fs.rmSync(resourcesPath, { recursive: true, force: true }), + }; +} + +function createFakeSsh() { + const sftpEnd = vi.fn(); + const fastPut = vi.fn((_localPath: string, _remotePath: string, _options: object, callback: (error?: Error | null) => void) => { + callback(null); + }); + const sftp = vi.fn((callback: (error: Error | null, sftp: { fastPut: typeof fastPut; end: typeof sftpEnd }) => void) => { + callback(null, { fastPut, end: sftpEnd }); + }); + const end = vi.fn(); + const ssh = { sftp, end } as unknown as Client; + return { ssh, sftp, fastPut, sftpEnd, end }; +} + +function createRegistry() { + return { + update: vi.fn((_id: string, patch: Partial<RemoteRuntimeTarget>) => ({ + ...uploadTarget, + ...patch, + })), + } as unknown as RemoteTargetRegistry & { update: ReturnType<typeof vi.fn> }; +} + +describe("bootstrapRemoteRuntime upload flow", () => { + let cleanupResources: (() => void) | null = null; + const originalPackageChannel = process.env.ADE_PACKAGE_CHANNEL; + + beforeEach(() => { + if (originalPackageChannel === undefined) delete process.env.ADE_PACKAGE_CHANNEL; + else process.env.ADE_PACKAGE_CHANNEL = originalPackageChannel; + connectSshMock.mockReset(); + execSshMock.mockReset(); + openSshRuntimeTransportMock.mockReset(); + initializeMock.mockReset(); + callMock.mockReset(); + runtimeRpcClientMock.mockReset(); + cleanupResources = null; + + runtimeRpcClientMock.mockImplementation(() => ({ + initialize: initializeMock, + call: callMock, + close: vi.fn(), + })); + openSshRuntimeTransportMock.mockResolvedValue({ + onData: vi.fn(), + onError: vi.fn(), + onClose: vi.fn(), + write: vi.fn(), + close: vi.fn(), + }); + initializeMock.mockResolvedValue({ + runtimeInfo: { version: APP_VERSION, multiProject: true }, + capabilities: { + projects: true, + machineProjects: { + browseDirectories: true, + getDetail: true, + getWorkSummary: true, + getDefaultParentDir: true, + create: true, + clone: true, + listMyGitHubRepos: true, + }, + }, + }); + callMock.mockImplementation(async (method: string) => { + if (method === "projects.list") { + return [{ + projectId: "project-1", + rootPath: "/srv/ade", + displayName: "ADE", + addedAt: 1, + lastOpenedAt: 2, + gitOriginUrl: "git@github.com:example/ade.git", + }]; + } + throw new Error(`Unexpected RPC method: ${method}`); + }); + }); + + afterEach(() => { + if (originalPackageChannel === undefined) delete process.env.ADE_PACKAGE_CHANNEL; + else process.env.ADE_PACKAGE_CHANNEL = originalPackageChannel; + cleanupResources?.(); + }); + + it("uploads a missing bundled runtime, verifies its version, and opens stdio RPC from ~/.ade/bin", async () => { + const resources = createTempResources(); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshMock.mockResolvedValue(fakeSsh.ssh); + const commands: string[] = []; + execSshMock.mockImplementation(async (_client: Client, command: string) => { + commands.push(command); + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(""); + if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(""); + if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(""); + if (command === "mkdir -p $HOME/.ade/bin") return ok(""); + if (command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version")) return ok(""); + if (command.includes("$HOME/.ade/bin/ade --version")) return ok("ade 2.0.0\n"); + if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); + throw new Error(`Unexpected SSH command: ${command}`); + }); + + const connected = await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + + expect(connectSshMock).toHaveBeenCalledWith(uploadTarget); + expect(fakeSsh.fastPut).toHaveBeenCalledWith(resources.binaryPath, ".ade/bin/ade", {}, expect.any(Function)); + expect(commands).toEqual([ + "uname -sm", + "cat $HOME/.ade/bin/ade.version 2>/dev/null || true", + "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true", + "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true", + "mkdir -p $HOME/.ade/bin", + `chmod 700 $HOME/.ade/bin && chmod +x $HOME/.ade/bin/ade && printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version && printf '%s\\n' '${resources.binarySha256}' > $HOME/.ade/bin/ade.sha256 && chmod 600 $HOME/.ade/bin/ade.version && chmod 600 $HOME/.ade/bin/ade.sha256`, + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade --version', + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade runtime stop --text >/dev/null 2>&1 || true', + ]); + expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( + fakeSsh.ssh, + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade rpc --stdio', + ); + expect(initializeMock).toHaveBeenCalledWith("ade-desktop-remote", APP_VERSION); + expect(callMock).toHaveBeenCalledWith("projects.list", {}); + expect(registry.update).toHaveBeenCalledWith("target-1", { + lastSeenArch: "linux-x64", + runtimeBinaryVersion: APP_VERSION, + lastConnectedAt: expect.any(Number), + }); + expect(connected.result).toMatchObject({ + arch: "linux-x64", + version: APP_VERSION, + projects: [{ projectId: "project-1", rootPath: "/srv/ade" }], + }); + expect(fakeSsh.end).not.toHaveBeenCalled(); + }); + + it("fails closed when an uploaded runtime reports the wrong version", async () => { + const resources = createTempResources(); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshMock.mockResolvedValue(fakeSsh.ssh); + execSshMock.mockImplementation(async (_client: Client, command: string) => { + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(""); + if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(""); + if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(""); + if (command === "mkdir -p $HOME/.ade/bin") return ok(""); + if (command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version")) return ok(""); + if (command.includes("$HOME/.ade/bin/ade --version")) return ok("ade 1.9.0\n"); + throw new Error(`Unexpected SSH command: ${command}`); + }); + + await expect(bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + })).rejects.toThrow(/uploaded ade service version mismatch/i); + + expect(fakeSsh.fastPut).toHaveBeenCalledWith(resources.binaryPath, ".ade/bin/ade", {}, expect.any(Function)); + expect(openSshRuntimeTransportMock).not.toHaveBeenCalled(); + expect(initializeMock).not.toHaveBeenCalled(); + expect(registry.update).not.toHaveBeenCalled(); + expect(fakeSsh.end).toHaveBeenCalledTimes(1); + }); + + it("uses the matching isolated remote home for Alpha channel bootstrap", async () => { + process.env.ADE_PACKAGE_CHANNEL = "alpha"; + const resources = createTempResources("darwin-arm64", { nativeDeps: true }); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshMock.mockResolvedValue(fakeSsh.ssh); + execSshMock.mockImplementation(async (_client: Client, command: string) => { + if (command === "uname -sm") return ok("Darwin arm64\n"); + if (command === "cat $HOME/.ade-alpha/bin/ade.version 2>/dev/null || true") return ok(""); + if (command === "cat $HOME/.ade-alpha/bin/ade.sha256 2>/dev/null || true") return ok(""); + if (command === "test -x $HOME/.ade-alpha/bin/ade && $HOME/.ade-alpha/bin/ade --version || true") return ok(""); + if (command === "mkdir -p $HOME/.ade-alpha/bin") return ok(""); + if (command === "mkdir -p $HOME/.ade-alpha/runtime") return ok(""); + if (command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade-alpha/bin/ade.version")) return ok(""); + if (command.includes("test -d $HOME/.ade-alpha/runtime/darwin-arm64/node_modules")) return ok("ok\n"); + if (command.includes("tar -xzf $HOME/.ade-alpha/runtime/ade-darwin-arm64.native.tar.gz")) return ok(""); + if (command === "codesign --force --sign - $HOME/.ade-alpha/bin/ade") return ok(""); + if (command.includes("$HOME/.ade-alpha/bin/ade --version")) return ok("ade 2.0.0\n"); + if (command.includes("$HOME/.ade-alpha/bin/ade runtime stop --text")) return ok(""); + throw new Error(`Unexpected SSH command: ${command}`); + }); + + await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + + expect(fakeSsh.fastPut).toHaveBeenCalledWith(resources.binaryPath, ".ade-alpha/bin/ade", {}, expect.any(Function)); + expect(execSshMock).toHaveBeenCalledWith(fakeSsh.ssh, "codesign --force --sign - $HOME/.ade-alpha/bin/ade"); + expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( + fakeSsh.ssh, + 'ADE_HOME="$HOME/.ade-alpha" PATH="$HOME/.ade-alpha/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PACKAGE_CHANNEL="alpha" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 NODE_PATH="$HOME/.ade-alpha/runtime/darwin-arm64/node_modules${NODE_PATH:+:$NODE_PATH}" $HOME/.ade-alpha/bin/ade rpc --stdio', + ); + }); + + it("restarts and retries a same-version runtime daemon that is missing machine project capabilities", async () => { + const resources = createTempResources(); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshMock.mockResolvedValue(fakeSsh.ssh); + const commands: string[] = []; + execSshMock.mockImplementation(async (_client: Client, command: string) => { + commands.push(command); + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok("2.0.0\n"); + if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(`${resources.binarySha256}\n`); + if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok("ade 2.0.0\n"); + if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); + throw new Error(`Unexpected SSH command: ${command}`); + }); + initializeMock + .mockResolvedValueOnce({ + runtimeInfo: { version: APP_VERSION, multiProject: true }, + capabilities: { projects: true }, + }) + .mockResolvedValueOnce({ + runtimeInfo: { version: APP_VERSION, multiProject: true }, + capabilities: { + projects: true, + machineProjects: { + browseDirectories: true, + getDetail: true, + getWorkSummary: true, + getDefaultParentDir: true, + create: true, + clone: true, + listMyGitHubRepos: true, + }, + }, + }); + + await expect(bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + })).resolves.toMatchObject({ + result: { + arch: "linux-x64", + version: APP_VERSION, + }, + }); + + expect(fakeSsh.fastPut).not.toHaveBeenCalled(); + expect(openSshRuntimeTransportMock).toHaveBeenCalledTimes(2); + expect(commands).toContain( + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade runtime stop --text >/dev/null 2>&1 || true', + ); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts new file mode 100644 index 000000000..35c87cd40 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -0,0 +1,462 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { Client } from "ssh2"; +import type { RemoteRuntimeConnectResult, RemoteRuntimeProjectRecord, RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; +import { RuntimeRpcClient } from "./runtimeRpcClient"; +import { connectSsh, execSsh, openSshRuntimeTransport } from "./sshTransport"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; + +export function normalizeRemoteArch(raw: string): { platform: string; arch: string; label: string } { + const lower = raw.toLowerCase(); + const platform = lower.includes("darwin") + ? "darwin" + : lower.includes("linux") + ? "linux" + : null; + const arch = lower.includes("arm64") || lower.includes("aarch64") + ? "arm64" + : lower.includes("x86_64") || lower.includes("amd64") + ? "x64" + : null; + if (!platform || !arch) { + throw new Error(`Unsupported remote ADE service platform: ${raw.trim() || "unknown"}. Supported targets are macOS/Linux on arm64 or x64.`); + } + return { platform, arch, label: `${platform}-${arch}` }; +} + +export function normalizeRuntimeVersion(raw: string): string | null { + const version = raw.trim().replace(/^ade\s+/i, "").trim(); + return version || null; +} + +export function selectRemoteRuntimeVersion(args: { + markerVersion: string | null; + executableVersion: string | null; +}): string | null { + return args.executableVersion ?? args.markerVersion; +} + +export function shouldUploadBundledRuntime(args: { + localBinaryAvailable: boolean; + executableVersion: string | null; + appVersion: string; + localBinarySha256?: string | null; + remoteBinarySha256?: string | null; +}): boolean { + if (!args.localBinaryAvailable) return false; + if (args.executableVersion !== args.appVersion) return true; + if (args.localBinarySha256) { + return args.remoteBinarySha256 !== args.localBinarySha256; + } + return false; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function validateRemoteRuntimeInitializeResult(args: { + result: unknown; + expectedVersion: string | null; +}): void { + if (!isRecord(args.result)) { + throw new Error("Remote ADE service returned an invalid initialize response."); + } + const runtimeInfo = isRecord(args.result.runtimeInfo) ? args.result.runtimeInfo : {}; + const capabilities = isRecord(args.result.capabilities) ? args.result.capabilities : {}; + if (runtimeInfo.multiProject !== true || capabilities.projects !== true) { + throw new Error("Remote ADE service does not support multi-project mode. Update the ADE service on that machine."); + } + const machineProjects = isRecord(capabilities.machineProjects) + ? capabilities.machineProjects + : {}; + const requiredMachineProjectCapabilities = [ + "browseDirectories", + "getDetail", + "getWorkSummary", + "getDefaultParentDir", + "create", + "clone", + ]; + const missingMachineProjectCapability = + requiredMachineProjectCapabilities.find((capability) => machineProjects[capability] !== true); + if (missingMachineProjectCapability) { + throw new Error( + `Remote ADE service is missing project capability '${missingMachineProjectCapability}'. Reconnect after rebuilding or reinstalling ADE on that machine.`, + ); + } + const version = typeof runtimeInfo.version === "string" && runtimeInfo.version.trim() + ? runtimeInfo.version.trim() + : null; + if (args.expectedVersion && version !== args.expectedVersion) { + throw new Error(`Remote ADE service version mismatch: expected ${args.expectedVersion}, got ${version ?? "unknown"}.`); + } +} + +type RemoteRuntimeChannel = "alpha" | "beta" | null; + +type RemoteRuntimeLayout = { + channel: RemoteRuntimeChannel; + homeDirName: ".ade" | ".ade-alpha" | ".ade-beta"; + homeDirExpr: string; + binDirExpr: string; + binDirRelative: string; + runtimeDirExpr: string; + runtimeDirRelative: string; + binaryExpr: string; + binaryRelative: string; + versionExpr: string; + sha256Expr: string; +}; + +function normalizeRemoteRuntimeChannel(value: unknown): RemoteRuntimeChannel { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (normalized === "alpha" || normalized === "beta") return normalized; + return null; +} + +export function resolveRemoteRuntimeLayout(env: NodeJS.ProcessEnv = process.env): RemoteRuntimeLayout { + const channel = normalizeRemoteRuntimeChannel(env.ADE_PACKAGE_CHANNEL); + const homeDirName = channel === "alpha" + ? ".ade-alpha" + : channel === "beta" + ? ".ade-beta" + : ".ade"; + const homeDirExpr = `$HOME/${homeDirName}`; + const binDirExpr = `${homeDirExpr}/bin`; + const runtimeDirExpr = `${homeDirExpr}/runtime`; + return { + channel, + homeDirName, + homeDirExpr, + binDirExpr, + binDirRelative: `${homeDirName}/bin`, + runtimeDirExpr, + runtimeDirRelative: `${homeDirName}/runtime`, + binaryExpr: `${binDirExpr}/ade`, + binaryRelative: `${homeDirName}/bin/ade`, + versionExpr: `${binDirExpr}/ade.version`, + sha256Expr: `${binDirExpr}/ade.sha256`, + }; +} + +export function buildRemoteRuntimeEnvironmentPrefix(args: { + archLabel: string; + nativeDepsReady: boolean; + layout?: RemoteRuntimeLayout; +}): string { + const layout = args.layout ?? resolveRemoteRuntimeLayout(); + const parts = [ + `ADE_HOME="${layout.homeDirExpr}"`, + `PATH="${layout.binDirExpr}:$HOME/.local/bin:$HOME/.npm-global/bin${"${PATH:+:$PATH}"}"`, + `ADE_DEFAULT_ROLE="cto"`, + ]; + if (layout.channel) { + parts.push(`ADE_PACKAGE_CHANNEL="${layout.channel}"`); + parts.push("ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1"); + } + if (args.nativeDepsReady) { + parts.push(`NODE_PATH="${layout.runtimeDirExpr}/${args.archLabel}/node_modules${"${NODE_PATH:+:$NODE_PATH}"}"`); + } + return `${parts.join(" ")} `; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function bundledRuntimePath(resourcesPath: string, archLabel: string): string | null { + const candidates = [ + path.join(resourcesPath, "runtime", `ade-${archLabel}`), + path.join(resourcesPath, "app.asar.unpacked", "runtime", `ade-${archLabel}`), + path.resolve(process.cwd(), "resources", "runtime", `ade-${archLabel}`), + ]; + return candidates.find((candidate) => { + try { + return fs.statSync(candidate).isFile(); + } catch { + return false; + } + }) ?? null; +} + +function bundledNativeDepsPath(resourcesPath: string, archLabel: string): string | null { + const archiveName = `ade-${archLabel}.native.tar.gz`; + const candidates = [ + path.join(resourcesPath, "runtime", archiveName), + path.join(resourcesPath, "app.asar.unpacked", "runtime", archiveName), + path.resolve(process.cwd(), "resources", "runtime", archiveName), + ]; + return candidates.find((candidate) => { + try { + return fs.statSync(candidate).isFile(); + } catch { + return false; + } + }) ?? null; +} + +function hashRuntimeBinary(localPath: string): string { + return crypto.createHash("sha256").update(fs.readFileSync(localPath)).digest("hex"); +} + +async function uploadRuntimeBinary(client: Client, layout: RemoteRuntimeLayout, localPath: string, appVersion: string, localBinarySha256: string): Promise<void> { + await execSsh(client, `mkdir -p ${layout.binDirExpr}`); + await new Promise<void>((resolve, reject) => { + client.sftp((error, sftp) => { + if (error) { + reject(error); + return; + } + sftp.fastPut(localPath, layout.binaryRelative, {}, (putError) => { + sftp.end(); + if (putError) reject(putError); + else resolve(); + }); + }); + }); + await execSsh(client, [ + `chmod 700 ${layout.binDirExpr}`, + `chmod +x ${layout.binaryExpr}`, + `printf '%s\\n' ${shellQuote(appVersion)} > ${layout.versionExpr}`, + `printf '%s\\n' ${shellQuote(localBinarySha256)} > ${layout.sha256Expr}`, + `chmod 600 ${layout.versionExpr}`, + `chmod 600 ${layout.sha256Expr}`, + ].join(" && ")); +} + +async function signUploadedRuntimeBinaryIfNeeded(client: Client, layout: RemoteRuntimeLayout, platform: string): Promise<void> { + if (platform !== "darwin") return; + const signed = await execSsh(client, `codesign --force --sign - ${layout.binaryExpr}`); + if (signed.code !== 0) { + throw new Error( + signed.stderr.trim() || + signed.stdout.trim() || + "Uploaded ADE service could not be signed on the remote Mac.", + ); + } +} + +async function uploadNativeDepsBundle(client: Client, layout: RemoteRuntimeLayout, archLabel: string, localPath: string, appVersion: string): Promise<void> { + await execSsh(client, `mkdir -p ${layout.runtimeDirExpr}`); + const remoteArchive = `${layout.runtimeDirRelative}/ade-${archLabel}.native.tar.gz`; + await new Promise<void>((resolve, reject) => { + client.sftp((error, sftp) => { + if (error) { + reject(error); + return; + } + sftp.fastPut(localPath, remoteArchive, {}, (putError) => { + sftp.end(); + if (putError) reject(putError); + else resolve(); + }); + }); + }); + const extract = await execSsh(client, [ + `rm -rf ${layout.runtimeDirExpr}/${archLabel}`, + `mkdir -p ${layout.runtimeDirExpr}/${archLabel}`, + `tar -xzf ${layout.runtimeDirExpr}/ade-${archLabel}.native.tar.gz -C ${layout.runtimeDirExpr}/${archLabel}`, + `printf '%s\\n' ${shellQuote(appVersion)} > ${layout.runtimeDirExpr}/${archLabel}/.ade-version`, + ].join(" && ")); + if (extract.code !== 0) { + throw new Error(extract.stderr.trim() || "Unable to unpack ADE service native dependencies on the remote machine."); + } +} + +async function stopRemoteRuntimeDaemon(client: Client, layout: RemoteRuntimeLayout, runtimeEnvPrefix: string): Promise<void> { + await execSsh( + client, + `${runtimeEnvPrefix}${layout.binaryExpr} runtime stop --text >/dev/null 2>&1 || true`, + ); +} + +function isMissingMachineProjectCapability(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /missing project capability/i.test(message); +} + +async function openValidatedRuntimeClient(args: { + ssh: Client; + command: string; + appVersion: string; + expectedVersion: string | null; +}): Promise<RuntimeRpcClient> { + const transport = await openSshRuntimeTransport(args.ssh, args.command); + const client = new RuntimeRpcClient(transport); + try { + const initializeResult = await client.initialize( + "ade-desktop-remote", + args.appVersion, + ); + validateRemoteRuntimeInitializeResult({ + result: initializeResult, + expectedVersion: args.expectedVersion, + }); + return client; + } catch (error) { + client.close(); + throw error; + } +} + +export function coerceProjects(value: unknown): RemoteRuntimeProjectRecord[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; + const record = entry as Record<string, unknown>; + const projectId = typeof record.projectId === "string" ? record.projectId : ""; + const rootPath = typeof record.rootPath === "string" ? record.rootPath : ""; + if (!projectId || !rootPath) return []; + return [{ + projectId, + rootPath, + displayName: typeof record.displayName === "string" ? record.displayName : path.basename(rootPath), + addedAt: typeof record.addedAt === "number" ? record.addedAt : 0, + lastOpenedAt: typeof record.lastOpenedAt === "number" ? record.lastOpenedAt : 0, + gitOriginUrl: typeof record.gitOriginUrl === "string" ? record.gitOriginUrl : null, + }]; + }); +} + +export async function bootstrapRemoteRuntime(args: { + target: RemoteRuntimeTarget; + registry: RemoteTargetRegistry; + resourcesPath: string; + appVersion: string; +}): Promise<{ client: RuntimeRpcClient; result: RemoteRuntimeConnectResult; ssh: Client }> { + const ssh = await connectSsh(args.target); + try { + const uname = await execSsh(ssh, "uname -sm"); + if (uname.code !== 0) { + throw new Error(uname.stderr.trim() || "Unable to detect remote architecture."); + } + const arch = normalizeRemoteArch(uname.stdout.trim()); + const layout = resolveRemoteRuntimeLayout(); + const binaryMarkerCheck = await execSsh(ssh, `cat ${layout.versionExpr} 2>/dev/null || true`); + const markedRuntimeVersion = normalizeRuntimeVersion(binaryMarkerCheck.stdout); + const binaryHashCheck = await execSsh(ssh, `cat ${layout.sha256Expr} 2>/dev/null || true`); + const remoteBinarySha256 = binaryHashCheck.stdout.trim() || null; + const versionCheck = await execSsh(ssh, `test -x ${layout.binaryExpr} && ${layout.binaryExpr} --version || true`); + const executableRuntimeVersion = normalizeRuntimeVersion(versionCheck.stdout); + let runtimeVersion = selectRemoteRuntimeVersion({ + markerVersion: markedRuntimeVersion, + executableVersion: executableRuntimeVersion, + }); + const localBinary = bundledRuntimePath(args.resourcesPath, arch.label); + const localBinarySha256 = localBinary ? hashRuntimeBinary(localBinary) : null; + const nativeDepsBundle = bundledNativeDepsPath(args.resourcesPath, arch.label); + let runtimeUploaded = false; + if (localBinary && localBinarySha256 && shouldUploadBundledRuntime({ + localBinaryAvailable: true, + executableVersion: executableRuntimeVersion, + appVersion: args.appVersion, + localBinarySha256, + remoteBinarySha256, + })) { + await uploadRuntimeBinary(ssh, layout, localBinary, args.appVersion, localBinarySha256); + await signUploadedRuntimeBinaryIfNeeded(ssh, layout, arch.platform); + runtimeUploaded = true; + runtimeVersion = args.appVersion; + } + + let nativeDepsReady = false; + if (nativeDepsBundle) { + const nativeDepsCheck = await execSsh(ssh, [ + `test -d ${layout.runtimeDirExpr}/${arch.label}/node_modules`, + `test "$(cat ${layout.runtimeDirExpr}/${arch.label}/.ade-version 2>/dev/null)" = ${shellQuote(args.appVersion)}`, + "echo ok", + ].join(" && ") + " || true"); + const shouldUploadNativeDeps = runtimeUploaded || nativeDepsCheck.stdout.trim() !== "ok"; + if (shouldUploadNativeDeps) { + await uploadNativeDepsBundle(ssh, layout, arch.label, nativeDepsBundle, args.appVersion); + } + nativeDepsReady = true; + } + + const runtimeEnvPrefix = buildRemoteRuntimeEnvironmentPrefix({ + archLabel: arch.label, + nativeDepsReady, + layout, + }); + + if (runtimeUploaded) { + const uploadedVersionCheck = await execSsh(ssh, `${runtimeEnvPrefix}${layout.binaryExpr} --version`); + const uploadedVersion = normalizeRuntimeVersion(uploadedVersionCheck.stdout); + if (uploadedVersionCheck.code !== 0 || !uploadedVersion) { + throw new Error( + uploadedVersionCheck.stderr.trim() + || "Uploaded ADE service did not report a version on the remote machine.", + ); + } + if (uploadedVersion !== args.appVersion) { + throw new Error(`Uploaded ADE service version mismatch: expected ${args.appVersion}, got ${uploadedVersion}.`); + } + runtimeVersion = uploadedVersion; + } + + if (runtimeUploaded) { + await stopRemoteRuntimeDaemon(ssh, layout, runtimeEnvPrefix); + } + + if (!runtimeVersion) { + const pathVersionCheck = await execSsh(ssh, `${runtimeEnvPrefix}ade --version || true`); + runtimeVersion = normalizeRuntimeVersion(pathVersionCheck.stdout); + if (!runtimeVersion) { + throw new Error(`ADE service is not installed on the remote machine and no bundled ADE service is available for ${arch.label}.`); + } + } + + const command = localBinary || runtimeUploaded + ? `${runtimeEnvPrefix}${layout.binaryExpr} rpc --stdio` + : `${runtimeEnvPrefix}ade rpc --stdio`; + let client: RuntimeRpcClient; + const expectedVersion = localBinary || runtimeUploaded ? args.appVersion : null; + try { + client = await openValidatedRuntimeClient({ + ssh, + command, + appVersion: args.appVersion, + expectedVersion, + }); + } catch (error) { + if (!localBinary || !isMissingMachineProjectCapability(error)) { + throw error; + } + await stopRemoteRuntimeDaemon(ssh, layout, runtimeEnvPrefix); + client = await openValidatedRuntimeClient({ + ssh, + command, + appVersion: args.appVersion, + expectedVersion, + }); + } + const projects = coerceProjects(await client.call("projects.list", {})); + const updated = args.registry.update(args.target.id, { + lastSeenArch: arch.label, + runtimeBinaryVersion: runtimeVersion, + lastConnectedAt: Date.now(), + }); + return { + client, + ssh, + result: { + target: updated, + arch: arch.label, + version: runtimeVersion, + projects, + }, + }; + } catch (error) { + ssh.end(); + throw error; + } +} + +export async function ensureRemoteProject(client: RuntimeRpcClient, rootPath: string): Promise<RemoteRuntimeProjectRecord> { + const project = await client.call("projects.add", { rootPath }); + const records = coerceProjects([project]); + if (!records[0]) throw new Error("Remote ADE service did not return a project record."); + return records[0]; +} diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts new file mode 100644 index 000000000..672a59338 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts @@ -0,0 +1,548 @@ +import type { Client } from "ssh2"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + RemoteRuntimeConnectResult, + RemoteRuntimeTarget, +} from "../../../shared/types/remoteRuntime"; +import type { RuntimeRpcClient } from "./runtimeRpcClient"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; + +const bootstrapRemoteRuntimeMock = vi.hoisted(() => vi.fn()); +const ensureRemoteProjectMock = vi.hoisted(() => vi.fn()); + +vi.mock("electron", () => ({ + app: { + getAppPath: () => "/mock/app", + }, +})); + +vi.mock("./remoteBootstrap", () => ({ + bootstrapRemoteRuntime: bootstrapRemoteRuntimeMock, + ensureRemoteProject: ensureRemoteProjectMock, +})); + +import { RemoteConnectionPool } from "./remoteConnectionPool"; + +type DisconnectListener = (error: Error) => void; + +type FakeRuntimeRpcClient = RuntimeRpcClient & { + call: ReturnType<typeof vi.fn>; + close: ReturnType<typeof vi.fn>; + emitDisconnect(error?: Error): void; + emitNotification(method: string, params: unknown): void; + onDisconnect: ReturnType<typeof vi.fn>; + onNotification: ReturnType<typeof vi.fn>; +}; + +type SshListener = (...args: unknown[]) => void; + +type FakeSshClient = Client & { + emitOnce(event: "close" | "error", ...args: unknown[]): void; + end: ReturnType<typeof vi.fn>; + once: ReturnType<typeof vi.fn>; +}; + +const target: RemoteRuntimeTarget = { + id: "target-1", + name: "Remote", + hostname: "remote.example.test", + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, +}; + +function connectResult(version: string): RemoteRuntimeConnectResult { + return { + target, + arch: "linux-x64", + version, + projects: [], + }; +} + +function createClient(): FakeRuntimeRpcClient { + const listeners = new Set<DisconnectListener>(); + const notificationListeners = new Map< + string, + Set<(params: unknown) => void> + >(); + const client = { + call: vi.fn(), + close: vi.fn(() => { + for (const listener of [...listeners]) { + listener(new Error("closed")); + } + }), + onDisconnect: vi.fn((callback: DisconnectListener) => { + listeners.add(callback); + return () => { + listeners.delete(callback); + }; + }), + onNotification: vi.fn( + (method: string, callback: (params: unknown) => void) => { + const existing = + notificationListeners.get(method) ?? + new Set<(params: unknown) => void>(); + existing.add(callback); + notificationListeners.set(method, existing); + return () => { + existing.delete(callback); + if (existing.size === 0) { + notificationListeners.delete(method); + } + }; + }, + ), + emitDisconnect(error = new Error("lost")) { + for (const listener of [...listeners]) { + listener(error); + } + }, + emitNotification(method: string, params: unknown) { + for (const listener of [...(notificationListeners.get(method) ?? [])]) { + listener(params); + } + }, + }; + return client as unknown as FakeRuntimeRpcClient; +} + +function createSsh(): FakeSshClient { + const listeners = new Map<string, SshListener[]>(); + const fake = {} as { + emitOnce?: FakeSshClient["emitOnce"]; + end?: ReturnType<typeof vi.fn>; + once?: ReturnType<typeof vi.fn>; + }; + fake.end = vi.fn(); + fake.once = vi.fn((event: string, callback: SshListener): FakeSshClient => { + const existing = listeners.get(event) ?? []; + existing.push(callback); + listeners.set(event, existing); + return fake as unknown as FakeSshClient; + }); + fake.emitOnce = (event: "close" | "error", ...args: unknown[]): void => { + const callbacks = listeners.get(event) ?? []; + listeners.delete(event); + for (const callback of callbacks) { + callback(...args); + } + }; + return fake as unknown as FakeSshClient; +} + +describe("RemoteConnectionPool", () => { + beforeEach(() => { + bootstrapRemoteRuntimeMock.mockReset(); + ensureRemoteProjectMock.mockReset(); + }); + + it("evicts cached entries after the RPC client disconnects", async () => { + const firstClient = createClient(); + const firstSsh = createSsh(); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: firstSsh, + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect(pool.connect(target)).resolves.toMatchObject({ + version: "1.0.0", + }); + firstClient.emitDisconnect(new Error("stream closed")); + + expect(firstSsh.end).toHaveBeenCalledTimes(1); + + const secondClient = createClient(); + const secondSsh = createSsh(); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: secondClient, + ssh: secondSsh, + result: connectResult("1.0.1"), + }); + + await expect(pool.connect(target)).resolves.toMatchObject({ + version: "1.0.1", + }); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + }); + + it("evicts cached entries and closes the RPC client after SSH closes", async () => { + const firstClient = createClient(); + const firstSsh = createSsh(); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: firstSsh, + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await pool.connect(target); + firstSsh.emitOnce("close"); + + expect(firstClient.close).toHaveBeenCalledTimes(1); + expect(firstSsh.end).toHaveBeenCalledTimes(1); + + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: createClient(), + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + + await expect(pool.connect(target)).resolves.toMatchObject({ + version: "1.0.1", + }); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + }); + + it("connects before streaming events and reconnects after disconnect", async () => { + const firstClient = createClient(); + firstClient.call.mockResolvedValueOnce({ + ok: true, + events: [ + { + id: 1, + timestamp: "2026-05-10T00:00:00.000Z", + category: "runtime", + payload: {}, + }, + ], + nextCursor: 2, + hasMore: false, + }); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: createSsh(), + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect( + pool.streamEventsForTarget(target, "project-1", { cursor: 1, limit: 10 }), + ).resolves.toMatchObject({ + nextCursor: 2, + events: [{ id: 1, category: "runtime" }], + }); + expect(firstClient.call).toHaveBeenCalledWith("ade/actions/call", { + projectId: "project-1", + name: "stream_events", + arguments: { + cursor: 1, + limit: 10, + }, + }); + + firstClient.emitDisconnect(new Error("lost")); + + const secondClient = createClient(); + secondClient.call.mockResolvedValueOnce({ + ok: true, + events: [], + nextCursor: 2, + hasMore: false, + }); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: secondClient, + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + + await expect( + pool.streamEventsForTarget(target, "project-1", { cursor: 2 }), + ).resolves.toMatchObject({ + nextCursor: 2, + events: [], + }); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + }); + + it("retries idempotent reads once when the connection closes during the request", async () => { + const firstClient = createClient(); + const firstSsh = createSsh(); + firstClient.call.mockRejectedValueOnce( + new Error("Remote runtime connection closed."), + ); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: firstSsh, + result: connectResult("1.0.0"), + }); + const secondClient = createClient(); + secondClient.call.mockResolvedValueOnce([ + { + projectId: "project-1", + rootPath: "/srv/app", + displayName: "app", + addedAt: 1, + lastOpenedAt: 2, + gitOriginUrl: null, + }, + ]); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: secondClient, + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect(pool.projectsForTarget(target)).resolves.toEqual([ + { + projectId: "project-1", + rootPath: "/srv/app", + displayName: "app", + addedAt: 1, + lastOpenedAt: 2, + gitOriginUrl: null, + }, + ]); + + expect(firstSsh.end).toHaveBeenCalledTimes(1); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + expect(firstClient.call).toHaveBeenCalledWith("projects.list", {}); + expect(secondClient.call).toHaveBeenCalledWith("projects.list", {}); + }); + + it("does not replay non-idempotent machine calls after a connection interruption", async () => { + const firstClient = createClient(); + firstClient.call.mockRejectedValueOnce( + new Error("Remote ADE service connection closed."), + ); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: createSsh(), + result: connectResult("1.0.0"), + }); + const secondClient = createClient(); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: secondClient, + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect( + pool.callMachineForTarget( + target, + "projects.clone", + { url: "https://github.com/acme/app", parentDir: "/srv" }, + { retryOnConnectionError: false }, + ), + ).rejects.toThrow(/retry the action/i); + + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + expect(firstClient.call).toHaveBeenCalledWith("projects.clone", { + url: "https://github.com/acme/app", + parentDir: "/srv", + }); + expect(secondClient.call).not.toHaveBeenCalled(); + }); + + it("reconnects after interrupted mutating actions and asks the caller to retry", async () => { + const firstClient = createClient(); + firstClient.call.mockRejectedValueOnce( + new Error("Remote runtime connection failed: channel closed"), + ); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: createSsh(), + result: connectResult("1.0.0"), + }); + const secondClient = createClient(); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: secondClient, + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect( + pool.callActionForTarget(target, "project-1", { + domain: "lane", + action: "create", + args: { name: "work" }, + }), + ).rejects.toThrow(/retry the action/i); + + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + expect(secondClient.call).not.toHaveBeenCalled(); + }); + + it("reconnects before running a target-scoped action after the cached SSH session drops", async () => { + const firstClient = createClient(); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: createSsh(), + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect(pool.connect(target)).resolves.toMatchObject({ + version: "1.0.0", + }); + firstClient.emitDisconnect(new Error("lost")); + + const secondClient = createClient(); + secondClient.call.mockResolvedValueOnce({ + ok: true, + domain: "lane", + action: "list", + result: [{ id: "lane-main" }], + statusHints: { reconnected: true }, + }); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: secondClient, + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + + await expect( + pool.callActionForTarget(target, "project-1", { + domain: "lane", + action: "list", + }), + ).resolves.toEqual({ + domain: "lane", + action: "list", + result: [{ id: "lane-main" }], + statusHints: { reconnected: true }, + }); + + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + expect(firstClient.call).not.toHaveBeenCalled(); + expect(secondClient.call).toHaveBeenCalledWith("ade/actions/call", { + projectId: "project-1", + name: "run_ade_action", + arguments: { + domain: "lane", + action: "list", + }, + }); + }); + + it("calls project-scoped sync methods on the connected runtime", async () => { + const client = createClient(); + client.call.mockResolvedValueOnce({ + pairingPin: "123456", + connectedPeers: [], + }); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client, + ssh: createSsh(), + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect( + pool.callSyncForTarget(target, "project-1", "sync.getStatus", { + includeTransferReadiness: true, + }), + ).resolves.toEqual({ pairingPin: "123456", connectedPeers: [] }); + + expect(client.call).toHaveBeenCalledWith("sync.getStatus", { + projectId: "project-1", + includeTransferReadiness: true, + }); + }); + + it("subscribes to runtime event notifications and unsubscribes on cleanup", async () => { + const client = createClient(); + client.call.mockImplementation(async (method: string) => { + if (method === "runtimeEvents.subscribe") { + client.emitNotification("runtime/event", { + subscriptionId: "runtime-events-7", + projectId: "project-1", + event: { + id: 12, + timestamp: "2026-05-10T12:00:00.000Z", + category: "runtime", + payload: { type: "pty_data" }, + }, + }); + client.emitNotification("runtime/event", { + subscriptionId: "runtime-events-8", + projectId: "project-1", + event: { + id: 13, + timestamp: "2026-05-10T12:00:01.000Z", + category: "runtime", + payload: { type: "other_subscription" }, + }, + }); + return { + subscriptionId: "runtime-events-7", + nextCursor: 13, + hasMore: false, + }; + } + if (method === "runtimeEvents.unsubscribe") { + return { removed: true }; + } + return null; + }); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client, + ssh: createSsh(), + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + const onEvent = vi.fn(); + + const cleanup = await pool.subscribeEventsForTarget( + target, + "project-1", + { + cursor: 5, + limit: 10, + category: "runtime", + }, + onEvent, + ); + + expect(client.call).toHaveBeenCalledWith("runtimeEvents.subscribe", { + projectId: "project-1", + cursor: 5, + limit: 10, + category: "runtime", + }); + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith({ + id: 12, + timestamp: "2026-05-10T12:00:00.000Z", + category: "runtime", + payload: { type: "pty_data" }, + }); + + client.emitNotification("runtime/event", { + subscriptionId: "runtime-events-7", + projectId: "project-1", + event: { + id: 14, + timestamp: "2026-05-10T12:00:02.000Z", + category: "runtime", + payload: { type: "live" }, + }, + }); + expect(onEvent).toHaveBeenCalledTimes(2); + + cleanup(); + expect(client.call).toHaveBeenCalledWith("runtimeEvents.unsubscribe", { + subscriptionId: "runtime-events-7", + }); + client.emitNotification("runtime/event", { + subscriptionId: "runtime-events-7", + projectId: "project-1", + event: { + id: 15, + timestamp: "2026-05-10T12:00:03.000Z", + category: "runtime", + payload: { type: "after_cleanup" }, + }, + }); + expect(onEvent).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts new file mode 100644 index 000000000..76718bcac --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts @@ -0,0 +1,565 @@ +import { app } from "electron"; +import type { Client } from "ssh2"; +import type { + RemoteRuntimeActionRequest, + RemoteRuntimeActionResult, + RemoteRuntimeBufferedEvent, + RemoteRuntimeConnectResult, + RemoteRuntimeEventCategory, + RemoteRuntimeStreamEventsRequest, + RemoteRuntimeStreamEventsResult, + RemoteRuntimeProjectRecord, + RemoteRuntimeTarget, +} from "../../../shared/types/remoteRuntime"; +import type { RuntimeRpcClient } from "./runtimeRpcClient"; +import { bootstrapRemoteRuntime, ensureRemoteProject } from "./remoteBootstrap"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; + +type PoolEntry = { + client: RuntimeRpcClient; + ssh: Client; + result: RemoteRuntimeConnectResult; + dispose?: (closeClient: boolean) => void; +}; + +type RuntimeEventNotification = { + subscriptionId: string; + projectId: string; + event: RemoteRuntimeBufferedEvent; +}; + +function isRemoteRuntimeConnectionError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /remote (?:runtime|ADE service) connection (?:closed|failed)|stream closed|channel closed|connection lost|socket closed/i.test( + message, + ); +} + +export class RemoteConnectionPool { + private readonly entries = new Map<string, Promise<PoolEntry>>(); + + constructor( + private readonly registry: RemoteTargetRegistry, + private readonly appVersion: string, + ) {} + + async connect( + target: RemoteRuntimeTarget, + ): Promise<RemoteRuntimeConnectResult> { + return (await this.connectEntry(target)).result; + } + + private async connectEntry(target: RemoteRuntimeTarget): Promise<PoolEntry> { + const existing = this.entries.get(target.id); + if (existing) return await existing; + const pending = bootstrapRemoteRuntime({ + target, + registry: this.registry, + resourcesPath: process.resourcesPath ?? app.getAppPath(), + appVersion: this.appVersion, + }); + let entryPromise: Promise<PoolEntry>; + entryPromise = pending.then(({ client, ssh, result }) => { + const entry = { client, ssh, result }; + this.attachEntryLifecycle(target.id, entryPromise, entry); + return entry; + }); + this.entries.set(target.id, entryPromise); + try { + return await entryPromise; + } catch (error) { + this.entries.delete(target.id); + throw error; + } + } + + async projects(targetId: string): Promise<unknown> { + const entry = await this.requireEntry(targetId); + return await entry.client.call("projects.list", {}); + } + + async projectsForTarget(target: RemoteRuntimeTarget): Promise<unknown> { + return await this.withEntryForTarget( + target, + (entry) => entry.client.call("projects.list", {}), + { retryOnConnectionError: true }, + ); + } + + async callMachineForTarget( + target: RemoteRuntimeTarget, + method: string, + params: Record<string, unknown> = {}, + options: { retryOnConnectionError?: boolean } = {}, + ): Promise<unknown> { + return await this.withEntryForTarget( + target, + (entry) => entry.client.call(method, params), + { retryOnConnectionError: options.retryOnConnectionError ?? true }, + ); + } + + async addProject( + targetId: string, + rootPath: string, + ): Promise<RemoteRuntimeProjectRecord> { + const entry = await this.requireEntry(targetId); + return await this.addProjectWithEntry(entry, rootPath); + } + + async addProjectForTarget( + target: RemoteRuntimeTarget, + rootPath: string, + ): Promise<RemoteRuntimeProjectRecord> { + const entry = await this.connectEntry(target); + return await this.addProjectWithEntry(entry, rootPath); + } + + async callAction( + targetId: string, + projectId: string, + request: RemoteRuntimeActionRequest, + ): Promise<RemoteRuntimeActionResult> { + const entry = await this.requireEntry(targetId); + return await this.callActionWithEntry(entry, projectId, request); + } + + async callActionForTarget( + target: RemoteRuntimeTarget, + projectId: string, + request: RemoteRuntimeActionRequest, + ): Promise<RemoteRuntimeActionResult> { + return await this.withEntryForTarget( + target, + (entry) => this.callActionWithEntry(entry, projectId, request), + { retryOnConnectionError: false }, + ); + } + + async callSyncForTarget( + target: RemoteRuntimeTarget, + projectId: string, + method: string, + params: Record<string, unknown> = {}, + ): Promise<unknown> { + const entry = await this.connectEntry(target); + return await entry.client.call(method, { + ...params, + projectId, + }); + } + + private async addProjectWithEntry( + entry: PoolEntry, + rootPath: string, + ): Promise<RemoteRuntimeProjectRecord> { + const project = await ensureRemoteProject(entry.client, rootPath); + entry.result.projects = [ + project, + ...entry.result.projects.filter( + (candidate) => candidate.projectId !== project.projectId, + ), + ]; + return project; + } + + private async callActionWithEntry( + entry: PoolEntry, + projectId: string, + request: RemoteRuntimeActionRequest, + ): Promise<RemoteRuntimeActionResult> { + const value = await entry.client.call("ade/actions/call", { + projectId, + name: "run_ade_action", + arguments: { + domain: request.domain, + action: request.action, + ...(request.args ? { args: request.args } : {}), + ...(Object.prototype.hasOwnProperty.call(request, "arg") + ? { arg: request.arg } + : {}), + ...(request.argsList ? { argsList: request.argsList } : {}), + }, + }); + + if (value && typeof value === "object" && !Array.isArray(value)) { + const record = value as Record<string, unknown>; + if (record.ok === false) { + const error = + record.error && + typeof record.error === "object" && + !Array.isArray(record.error) + ? (record.error as Record<string, unknown>) + : {}; + throw new Error( + typeof error.message === "string" + ? error.message + : "Remote ADE service action failed.", + ); + } + return { + domain: + typeof record.domain === "string" ? record.domain : request.domain, + action: + typeof record.action === "string" ? record.action : request.action, + result: record.result, + statusHints: + record.statusHints && + typeof record.statusHints === "object" && + !Array.isArray(record.statusHints) + ? (record.statusHints as Record<string, unknown>) + : {}, + }; + } + + return { + domain: request.domain, + action: request.action, + result: value, + statusHints: {}, + }; + } + + async streamEvents( + targetId: string, + projectId: string, + request: RemoteRuntimeStreamEventsRequest = {}, + ): Promise<RemoteRuntimeStreamEventsResult> { + const entry = await this.requireEntry(targetId); + return await this.streamEventsWithEntry(entry, projectId, request); + } + + private async streamEventsWithEntry( + entry: PoolEntry, + projectId: string, + request: RemoteRuntimeStreamEventsRequest = {}, + ): Promise<RemoteRuntimeStreamEventsResult> { + const value = await entry.client.call("ade/actions/call", { + projectId, + name: "stream_events", + arguments: { + cursor: clampCursor(request.cursor), + limit: clampLimit(request.limit), + ...(isRemoteRuntimeEventCategory(request.category) + ? { category: request.category } + : {}), + }, + }); + + if (value && typeof value === "object" && !Array.isArray(value)) { + const record = value as Record<string, unknown>; + if (record.ok === false) { + const error = + record.error && + typeof record.error === "object" && + !Array.isArray(record.error) + ? (record.error as Record<string, unknown>) + : {}; + throw new Error( + typeof error.message === "string" + ? error.message + : "Remote ADE service event stream failed.", + ); + } + + return { + events: Array.isArray(record.events) + ? record.events + .map(normalizeBufferedEvent) + .filter( + (event): event is RemoteRuntimeBufferedEvent => event != null, + ) + : [], + nextCursor: + typeof record.nextCursor === "number" && + Number.isFinite(record.nextCursor) + ? Math.max(0, Math.floor(record.nextCursor)) + : clampCursor(request.cursor), + hasMore: record.hasMore === true, + }; + } + + return { + events: [], + nextCursor: clampCursor(request.cursor), + hasMore: false, + }; + } + + async streamEventsForTarget( + target: RemoteRuntimeTarget, + projectId: string, + request: RemoteRuntimeStreamEventsRequest = {}, + ): Promise<RemoteRuntimeStreamEventsResult> { + return await this.withEntryForTarget( + target, + (entry) => this.streamEventsWithEntry(entry, projectId, request), + { retryOnConnectionError: true }, + ); + } + + async subscribeEvents( + targetId: string, + projectId: string, + request: RemoteRuntimeStreamEventsRequest = {}, + onEvent: (event: RemoteRuntimeBufferedEvent) => void, + onEnded?: () => void, + ): Promise<() => void> { + const entry = await this.requireEntry(targetId); + return await subscribeToRuntimeEvents( + entry.client, + projectId, + request, + onEvent, + onEnded, + ); + } + + async subscribeEventsForTarget( + target: RemoteRuntimeTarget, + projectId: string, + request: RemoteRuntimeStreamEventsRequest = {}, + onEvent: (event: RemoteRuntimeBufferedEvent) => void, + onEnded?: () => void, + ): Promise<() => void> { + return await this.withEntryForTarget( + target, + (entry) => + subscribeToRuntimeEvents( + entry.client, + projectId, + request, + onEvent, + onEnded, + ), + { retryOnConnectionError: true }, + ); + } + + disconnect(targetId: string): void { + const existing = this.entries.get(targetId); + this.entries.delete(targetId); + void existing + ?.then((entry) => { + if (entry.dispose) { + entry.dispose(true); + return; + } + try { + entry.client.close(); + } catch {} + try { + entry.ssh.end(); + } catch {} + }) + .catch(() => {}); + } + + dispose(): void { + for (const targetId of [...this.entries.keys()]) { + this.disconnect(targetId); + } + } + + private async requireEntry(targetId: string): Promise<PoolEntry> { + const entry = this.entries.get(targetId); + if (!entry) throw new Error(`Remote target is not connected: ${targetId}`); + return await entry; + } + + private async withEntryForTarget<T>( + target: RemoteRuntimeTarget, + operation: (entry: PoolEntry) => Promise<T>, + options: { retryOnConnectionError: boolean }, + ): Promise<T> { + const entry = await this.connectEntry(target); + try { + return await operation(entry); + } catch (error) { + if (!isRemoteRuntimeConnectionError(error)) throw error; + this.disconnect(target.id); + const nextEntry = await this.connectEntry(target); + if (options.retryOnConnectionError) { + return await operation(nextEntry); + } + throw new Error( + "Remote ADE service connection was interrupted before ADE could confirm the action result. " + + "ADE reconnected to the machine; retry the action if it is still needed.", + ); + } + } + + private attachEntryLifecycle( + targetId: string, + entryPromise: Promise<PoolEntry>, + entry: PoolEntry, + ): void { + let cleanedUp = false; + const evict = (closeClient: boolean) => { + if (this.entries.get(targetId) === entryPromise) { + this.entries.delete(targetId); + } + if (cleanedUp) return; + cleanedUp = true; + if (closeClient) { + try { + entry.client.close(); + } catch {} + } + try { + entry.ssh.end(); + } catch {} + }; + + entry.client.onDisconnect(() => evict(false)); + entry.ssh.once("close", () => evict(true)); + entry.ssh.once("error", () => evict(true)); + entry.dispose = evict; + } +} + +function clampCursor(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(0, Math.floor(value)) + : 0; +} + +function clampLimit(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(1, Math.min(1000, Math.floor(value))) + : 100; +} + +function isRemoteRuntimeEventCategory( + value: unknown, +): value is RemoteRuntimeEventCategory { + return ( + value === "orchestrator" || + value === "dag_mutation" || + value === "runtime" || + value === "mission" + ); +} + +function normalizeBufferedEvent( + value: unknown, +): RemoteRuntimeBufferedEvent | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record<string, unknown>; + if (typeof record.id !== "number" || !Number.isFinite(record.id)) return null; + if (typeof record.timestamp !== "string") return null; + if (!isRemoteRuntimeEventCategory(record.category)) return null; + const payload = + record.payload && + typeof record.payload === "object" && + !Array.isArray(record.payload) + ? (record.payload as Record<string, unknown>) + : {}; + return { + id: Math.max(0, Math.floor(record.id)), + timestamp: record.timestamp, + category: record.category, + payload, + }; +} + +async function subscribeToRuntimeEvents( + client: RuntimeRpcClient, + projectId: string, + request: RemoteRuntimeStreamEventsRequest, + onEvent: (event: RemoteRuntimeBufferedEvent) => void, + onEnded?: () => void, +): Promise<() => void> { + const pendingNotifications: RuntimeEventNotification[] = []; + let closed = false; + let subscriptionId: string | null = null; + + const removeNotificationListener = client.onNotification( + "runtime/event", + (params) => { + if (closed) return; + const notification = normalizeRuntimeEventNotification(params); + if (!notification || notification.projectId !== projectId) return; + if (subscriptionId == null) { + pendingNotifications.push(notification); + return; + } + if (notification.subscriptionId === subscriptionId) { + onEvent(notification.event); + } + }, + ); + const removeDisconnectListener = client.onDisconnect(() => { + if (closed) return; + closed = true; + removeNotificationListener(); + onEnded?.(); + }); + + try { + const value = await client.call("runtimeEvents.subscribe", { + projectId, + cursor: clampCursor(request.cursor), + limit: clampLimit(request.limit), + ...(isRemoteRuntimeEventCategory(request.category) + ? { category: request.category } + : {}), + }); + subscriptionId = readSubscriptionId(value); + for (const notification of pendingNotifications) { + if (closed) break; + if (notification.subscriptionId === subscriptionId) { + onEvent(notification.event); + } + } + } catch (error) { + closed = true; + removeNotificationListener(); + removeDisconnectListener(); + throw error; + } + + return () => { + if (closed) return; + closed = true; + removeNotificationListener(); + removeDisconnectListener(); + const id = subscriptionId; + if (id != null) { + void client + .call("runtimeEvents.unsubscribe", { subscriptionId: id }) + .catch(() => {}); + } + }; +} + +function readSubscriptionId(value: unknown): string { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error( + "ADE service event subscription did not return a subscription id.", + ); + } + const id = (value as Record<string, unknown>).subscriptionId; + if (typeof id !== "string" || !id.trim()) { + throw new Error( + "ADE service event subscription did not return a subscription id.", + ); + } + return id.trim(); +} + +function normalizeRuntimeEventNotification( + value: unknown, +): RuntimeEventNotification | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record<string, unknown>; + const subscriptionId = + typeof record.subscriptionId === "string" && record.subscriptionId.trim() + ? record.subscriptionId.trim() + : null; + const projectId = + typeof record.projectId === "string" ? record.projectId : ""; + const event = normalizeBufferedEvent(record.event); + if (subscriptionId == null || !projectId || !event) return null; + return { subscriptionId, projectId, event }; +} diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts new file mode 100644 index 000000000..cbe604928 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts @@ -0,0 +1,387 @@ +import type { + CloneProjectInput, + CreateProjectInput, + ListMyGitHubReposInput, + ListMyGitHubReposResult, + ProjectBrowseInput, + ProjectBrowseResult, + ProjectDetail, + RemoteRuntimeProjectWorkSummary, + RemoteRuntimeConnectionSnapshot, + RemoteRuntimeConnectionState, + RemoteRuntimeConnectionStatus, + RemoteRuntimeConnectResult, + RemoteRuntimeProjectRecord, + RemoteRuntimeTarget, + RemoteRuntimeTargetInput, +} from "../../../shared/types"; +import { coerceProjects } from "./remoteBootstrap"; +import type { RemoteConnectionPool } from "./remoteConnectionPool"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; + +type StatusPatch = Partial<Omit<RemoteRuntimeConnectionStatus, "target">>; + +type RemoteConnectionServiceOptions = { + autoconnectIntervalMs?: number; +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function asRecord(value: unknown): Record<string, unknown> { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record<string, unknown>) + : {}; +} + +function coerceConnectionProject(value: unknown): RemoteRuntimeProjectRecord { + const project = coerceProjects([value])[0]; + if (!project) + throw new Error("Remote ADE service did not return a project record."); + return project; +} + +export class RemoteConnectionService { + private readonly statusById = new Map<string, StatusPatch>(); + private readonly listeners = new Set< + (snapshot: RemoteRuntimeConnectionSnapshot) => void + >(); + private autoconnectTimer: NodeJS.Timeout | null = null; + + constructor( + private readonly registry: RemoteTargetRegistry, + private readonly pool: RemoteConnectionPool, + private readonly options: RemoteConnectionServiceOptions = {}, + ) {} + + listTargets(): RemoteRuntimeTarget[] { + return this.registry.list(); + } + + getTarget(targetId: string): RemoteRuntimeTarget | null { + return this.registry.get(targetId); + } + + saveTarget(input: RemoteRuntimeTargetInput): RemoteRuntimeTarget { + const target = this.registry.save(input); + this.mergeStatus(target.id, { state: "idle", lastError: null }); + return target; + } + + removeTarget(targetId: string): boolean { + this.disconnect(targetId); + this.statusById.delete(targetId); + const removed = this.registry.remove(targetId); + this.emit(); + return removed; + } + + snapshot(): RemoteRuntimeConnectionSnapshot { + const connections = this.registry + .list() + .map((target): RemoteRuntimeConnectionStatus => { + const status = this.statusById.get(target.id) ?? {}; + return { + target, + state: status.state ?? (target.lastConnectedAt ? "idle" : "idle"), + arch: status.arch ?? target.lastSeenArch, + version: status.version ?? target.runtimeBinaryVersion, + projects: status.projects ?? [], + lastError: status.lastError ?? null, + lastAttemptedAt: status.lastAttemptedAt ?? null, + connectedAt: status.connectedAt ?? target.lastConnectedAt, + }; + }); + return { + connections, + connectedCount: connections.filter((entry) => entry.state === "connected") + .length, + updatedAt: Date.now(), + }; + } + + onSnapshotChanged( + listener: (snapshot: RemoteRuntimeConnectionSnapshot) => void, + ): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + startAutoconnect(): void { + for (const target of this.registry.list()) { + void this.connect(target.id).catch(() => {}); + } + if (this.autoconnectTimer) return; + this.autoconnectTimer = setInterval(() => { + void this.maintainSavedConnections(); + }, this.options.autoconnectIntervalMs ?? 30_000); + this.autoconnectTimer.unref?.(); + } + + stopAutoconnect(): void { + if (!this.autoconnectTimer) return; + clearInterval(this.autoconnectTimer); + this.autoconnectTimer = null; + } + + async connect(targetId: string): Promise<RemoteRuntimeConnectResult> { + const target = this.requireTarget(targetId); + this.mergeStatus(target.id, { + state: "connecting", + lastAttemptedAt: Date.now(), + lastError: null, + }); + try { + const result = await this.pool.connect(target); + this.mergeStatus(result.target.id, { + state: "connected", + arch: result.arch, + version: result.version, + projects: result.projects, + connectedAt: result.target.lastConnectedAt ?? Date.now(), + lastAttemptedAt: Date.now(), + lastError: null, + }); + return result; + } catch (error) { + this.mergeStatus(target.id, { + state: "error", + lastError: errorMessage(error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + + disconnect(targetId: string): void { + this.pool.disconnect(targetId); + this.mergeStatus(targetId, { state: "idle", lastError: null }); + } + + async projects(targetId: string): Promise<RemoteRuntimeProjectRecord[]> { + const target = this.requireTarget(targetId); + try { + const value = await this.pool.projectsForTarget(target); + const projects = coerceProjects(value); + this.mergeStatus(targetId, { + state: "connected", + projects, + lastError: null, + }); + return projects; + } catch (error) { + this.mergeStatus(targetId, { + state: "error", + lastError: errorMessage(error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + + async addProject( + targetId: string, + rootPath: string, + ): Promise<RemoteRuntimeProjectRecord> { + const target = this.requireTarget(targetId); + try { + const value = await this.pool.addProjectForTarget(target, rootPath); + const project = coerceConnectionProject(value); + this.upsertProject(targetId, project); + return project; + } catch (error) { + this.mergeStatus(targetId, { + state: "error", + lastError: errorMessage(error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + + async browseDirectories( + targetId: string, + input: ProjectBrowseInput, + ): Promise<ProjectBrowseResult> { + return (await this.callMachine( + this.requireTarget(targetId), + "projects.browseDirectories", + asRecord(input), + )) as ProjectBrowseResult; + } + + async getProjectDetail( + targetId: string, + rootPath: string, + ): Promise<ProjectDetail> { + return (await this.callMachine( + this.requireTarget(targetId), + "projects.getDetail", + { rootPath }, + )) as ProjectDetail; + } + + async getProjectWorkSummary( + targetId: string, + rootPath: string, + ): Promise<RemoteRuntimeProjectWorkSummary> { + return (await this.callMachine( + this.requireTarget(targetId), + "projects.getWorkSummary", + { rootPath }, + )) as RemoteRuntimeProjectWorkSummary; + } + + async getDefaultParentDir(targetId: string): Promise<string> { + const value = await this.callMachine( + this.requireTarget(targetId), + "projects.getDefaultParentDir", + {}, + ); + return typeof value === "string" && value.trim() + ? value.trim() + : "~/Projects"; + } + + async createProject( + targetId: string, + input: CreateProjectInput, + ): Promise<RemoteRuntimeProjectRecord> { + const value = await this.callMachine( + this.requireTarget(targetId), + "projects.create", + asRecord(input), + { retryOnConnectionError: false }, + ); + const project = coerceConnectionProject(value); + this.upsertProject(targetId, project); + return project; + } + + async cloneProject( + targetId: string, + input: CloneProjectInput, + ): Promise<RemoteRuntimeProjectRecord> { + const value = await this.callMachine( + this.requireTarget(targetId), + "projects.clone", + asRecord(input), + { retryOnConnectionError: false }, + ); + const project = coerceConnectionProject(value); + this.upsertProject(targetId, project); + return project; + } + + async listMyGitHubRepos( + targetId: string, + input: ListMyGitHubReposInput, + ): Promise<ListMyGitHubReposResult> { + return (await this.callMachine( + this.requireTarget(targetId), + "projects.listMyGitHubRepos", + asRecord(input), + )) as ListMyGitHubReposResult; + } + + dispose(): void { + this.stopAutoconnect(); + this.pool.dispose(); + this.listeners.clear(); + } + + private async maintainSavedConnections(): Promise<void> { + for (const target of this.registry.list()) { + const status = this.statusById.get(target.id); + if (status?.state === "connecting") continue; + if (status?.state === "connected") { + try { + await this.pool.callMachineForTarget(target, "ping", {}); + continue; + } catch { + this.pool.disconnect(target.id); + this.mergeStatus(target.id, { + state: "error", + lastError: "Remote ADE service connection was interrupted.", + }); + } + } + void this.connect(target.id).catch(() => {}); + } + } + + private async callMachine( + target: RemoteRuntimeTarget, + method: string, + params: Record<string, unknown>, + options: { retryOnConnectionError?: boolean } = {}, + ): Promise<unknown> { + try { + const result = await this.pool.callMachineForTarget( + target, + method, + params, + options, + ); + const current = this.statusById.get(target.id); + if (current?.state !== "connected") { + this.mergeStatus(target.id, { state: "connected", lastError: null }); + } + return result; + } catch (error) { + this.mergeStatus(target.id, { + state: "error", + lastError: errorMessage(error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + + private requireTarget(targetId: string): RemoteRuntimeTarget { + const target = this.registry.get(targetId); + if (!target) throw new Error("Remote target was not found."); + return target; + } + + private upsertProject( + targetId: string, + project: RemoteRuntimeProjectRecord, + ): void { + const current = this.statusById.get(targetId); + const projects = [ + project, + ...(current?.projects ?? []).filter( + (candidate) => candidate.projectId !== project.projectId, + ), + ]; + this.mergeStatus(targetId, { + state: "connected", + projects, + lastError: null, + }); + } + + private mergeStatus(targetId: string, patch: StatusPatch): void { + const current = this.statusById.get(targetId) ?? {}; + this.statusById.set(targetId, { + ...current, + ...patch, + state: (patch.state ?? + current.state ?? + "idle") as RemoteRuntimeConnectionState, + }); + this.emit(); + } + + private emit(): void { + if (this.listeners.size === 0) return; + const snapshot = this.snapshot(); + for (const listener of [...this.listeners]) { + listener(snapshot); + } + } +} diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.e2e.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.e2e.test.ts new file mode 100644 index 000000000..334dccba3 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.e2e.test.ts @@ -0,0 +1,169 @@ +import type { Client } from "ssh2"; +import { describe, expect, it } from "vitest"; +import type { RemoteRuntimeProjectRecord, RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; +import { bootstrapRemoteRuntime, ensureRemoteProject } from "./remoteBootstrap"; +import type { RuntimeRpcClient } from "./runtimeRpcClient"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; + +type RemoteRuntimeE2eConfig = { + host: string; + user: string | null; + port: number | null; + keyPath: string | null; + projectRoot: string; + resourcesPath: string; + appVersion: string; + mutate: boolean; + createPr: boolean; + chatProvider: string; + chatModel: string; +}; + +function readRemoteRuntimeE2eConfig(): RemoteRuntimeE2eConfig | null { + const host = process.env.ADE_REMOTE_RUNTIME_E2E_HOST?.trim(); + const projectRoot = process.env.ADE_REMOTE_RUNTIME_E2E_PROJECT?.trim(); + if (!host || !projectRoot) return null; + const portValue = Number.parseInt(process.env.ADE_REMOTE_RUNTIME_E2E_PORT ?? "", 10); + return { + host, + user: process.env.ADE_REMOTE_RUNTIME_E2E_USER?.trim() || null, + port: Number.isFinite(portValue) && portValue > 0 ? portValue : null, + keyPath: process.env.ADE_REMOTE_RUNTIME_E2E_KEY?.trim() || null, + projectRoot, + resourcesPath: process.env.ADE_REMOTE_RUNTIME_E2E_RESOURCES?.trim() || process.resourcesPath || process.cwd(), + appVersion: process.env.ADE_REMOTE_RUNTIME_E2E_APP_VERSION?.trim() || "0.0.0-e2e", + mutate: process.env.ADE_REMOTE_RUNTIME_E2E_MUTATE === "1", + createPr: process.env.ADE_REMOTE_RUNTIME_E2E_CREATE_PR === "1", + chatProvider: process.env.ADE_REMOTE_RUNTIME_E2E_CHAT_PROVIDER?.trim() || "codex", + chatModel: process.env.ADE_REMOTE_RUNTIME_E2E_CHAT_MODEL?.trim() || "gpt-5.4", + }; +} + +const e2eConfig = readRemoteRuntimeE2eConfig(); +const describeRemoteRuntimeE2e = e2eConfig ? describe : describe.skip; +const itMutatingRemoteRuntimeE2e = e2eConfig?.mutate ? it : it.skip; + +type RemoteRuntimeE2eContext = { + client: RuntimeRpcClient; + project: RemoteRuntimeProjectRecord; +}; + +function targetForConfig(config: RemoteRuntimeE2eConfig): RemoteRuntimeTarget { + return { + id: "remote-runtime-e2e", + name: "Remote runtime E2E", + hostname: config.host, + sshUser: config.user, + port: config.port, + sshKeyPath: config.keyPath, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, + }; +} + +async function withRemoteRuntimeE2e( + config: RemoteRuntimeE2eConfig, + run: (ctx: RemoteRuntimeE2eContext) => Promise<void>, +): Promise<void> { + const target = targetForConfig(config); + const registry = { + update: (_id: string, patch: Partial<RemoteRuntimeTarget>) => ({ ...target, ...patch }), + } as unknown as RemoteTargetRegistry; + + let client: RuntimeRpcClient | null = null; + let ssh: Client | null = null; + try { + const connected = await bootstrapRemoteRuntime({ + target, + registry, + resourcesPath: config.resourcesPath, + appVersion: config.appVersion, + }); + client = connected.client; + ssh = connected.ssh; + + expect(connected.result.projects).toEqual(expect.any(Array)); + const project = await ensureRemoteProject(client, config.projectRoot); + expect(project.rootPath).toBe(config.projectRoot); + await run({ client, project }); + } finally { + client?.close(); + ssh?.end(); + } +} + +async function runAdeAction( + client: RuntimeRpcClient, + projectId: string, + domain: string, + action: string, + args?: Record<string, unknown>, +): Promise<Record<string, unknown>> { + const value = await client.call("ade/actions/call", { + projectId, + name: "run_ade_action", + arguments: { + domain, + action, + ...(args ? { args } : {}), + }, + }); + expect(value).toMatchObject({ domain, action }); + return value as Record<string, unknown>; +} + +describeRemoteRuntimeE2e("remote runtime SSH E2E", () => { + it("connects over SSH, initializes stdio RPC, registers a project, and calls lane.list", async () => { + const config = e2eConfig; + expect(config).not.toBeNull(); + if (!config) return; + + await withRemoteRuntimeE2e(config, async ({ client, project }) => { + const lanes = await runAdeAction(client, project.projectId, "lane", "list"); + expect(Array.isArray(lanes.result)).toBe(true); + }); + }, 120_000); + + itMutatingRemoteRuntimeE2e("exercises remote lane, chat, git, and optional PR operations", async () => { + const config = e2eConfig; + expect(config).not.toBeNull(); + if (!config) return; + + const suffix = `${Date.now()}-${process.pid}`; + await withRemoteRuntimeE2e(config, async ({ client, project }) => { + const createdLane = await runAdeAction(client, project.projectId, "lane", "create", { + name: `Remote runtime E2E ${suffix}`, + branchName: `ade/remote-runtime-e2e-${suffix}`, + description: "Created by ADE_REMOTE_RUNTIME_E2E_MUTATE acceptance coverage.", + }); + const lane = createdLane.result as { id?: unknown; name?: unknown }; + expect(typeof lane.id).toBe("string"); + const laneId = lane.id as string; + + const chat = await runAdeAction(client, project.projectId, "chat", "createSession", { + laneId, + provider: config.chatProvider, + model: config.chatModel, + title: `Remote runtime E2E ${suffix}`, + openInUi: false, + }); + const chatSession = chat.result as { id?: unknown; laneId?: unknown }; + expect(typeof chatSession.id).toBe("string"); + expect(chatSession.laneId).toBe(laneId); + + const gitStatus = await runAdeAction(client, project.projectId, "git", "getSyncStatus", { laneId }); + expect(gitStatus.result).toEqual(expect.any(Object)); + + if (config.createPr) { + const pr = await runAdeAction(client, project.projectId, "pr", "createFromLane", { + laneId, + title: `Remote runtime E2E ${suffix}`, + body: "Created by ADE_REMOTE_RUNTIME_E2E_CREATE_PR acceptance coverage.", + draft: true, + }); + expect(pr.result).toEqual(expect.objectContaining({ id: expect.any(String) })); + } + }); + }, 180_000); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts new file mode 100644 index 000000000..fa98421a6 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts @@ -0,0 +1,460 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { Client } from "ssh2"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { startJsonRpcServer, type JsonRpcHandler, type JsonRpcTransport } from "../../../../../ade-cli/src/jsonrpc"; +import { createMultiProjectRpcRequestHandler } from "../../../../../ade-cli/src/multiProjectRpcServer"; +import { createEventBuffer } from "../../../../../ade-cli/src/eventBuffer"; +import { ProjectRegistry } from "../../../../../ade-cli/src/services/projects/projectRegistry"; +import type { ProjectScopeRegistry } from "../../../../../ade-cli/src/services/projects/projectScope"; +import type { RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; +import { RuntimeRpcClient, type RuntimeRpcTransport } from "./runtimeRpcClient"; + +const bootstrapRemoteRuntimeMock = vi.hoisted(() => vi.fn()); + +vi.mock("electron", () => ({ + app: { + getAppPath: () => "/mock/app", + }, +})); + +vi.mock("./remoteBootstrap", () => ({ + bootstrapRemoteRuntime: bootstrapRemoteRuntimeMock, + ensureRemoteProject: vi.fn(), +})); + +import { RemoteConnectionPool } from "./remoteConnectionPool"; + +type NotifiableHandler = JsonRpcHandler & { + dispose?: () => void; + setNotifier?: (notify: ((method: string, params?: unknown) => void) | null) => void; +}; + +class LinkedRuntimeTransport implements RuntimeRpcTransport { + private readonly dataCallbacks = new Set<(chunk: Buffer) => void>(); + private readonly closeCallbacks = new Set<() => void>(); + private readonly errorCallbacks = new Set<(error: Error) => void>(); + private peer: LinkedRuntimeTransport | null = null; + private closed = false; + + connect(peer: LinkedRuntimeTransport): void { + this.peer = peer; + } + + onData(callback: (chunk: Buffer) => void): void { + this.dataCallbacks.add(callback); + } + + onClose(callback: () => void): void { + this.closeCallbacks.add(callback); + } + + onError(callback: (error: Error) => void): void { + this.errorCallbacks.add(callback); + } + + write(data: string): void { + if (this.closed) throw new Error("Transport is closed."); + const peer = this.peer; + if (!peer || peer.closed) throw new Error("Remote runtime connection closed."); + queueMicrotask(() => peer.emitData(Buffer.from(data, "utf8"))); + } + + close(): void { + this.closeBoth(); + } + + fail(error: Error): void { + if (this.closed) return; + this.closed = true; + for (const callback of [...this.errorCallbacks]) callback(error); + for (const callback of [...this.closeCallbacks]) callback(); + this.peer?.closeLocal(); + } + + private closeBoth(): void { + this.closeLocal(); + this.peer?.closeLocal(); + } + + private closeLocal(): void { + if (this.closed) return; + this.closed = true; + for (const callback of [...this.closeCallbacks]) callback(); + } + + private emitData(chunk: Buffer): void { + if (this.closed) return; + for (const callback of [...this.dataCallbacks]) callback(chunk); + } +} + +function linkedTransports(): { client: LinkedRuntimeTransport; server: LinkedRuntimeTransport } { + const client = new LinkedRuntimeTransport(); + const server = new LinkedRuntimeTransport(); + client.connect(server); + server.connect(client); + return { client, server }; +} + +function startRuntimeClient(handler: NotifiableHandler): { + client: RuntimeRpcClient; + close: () => void; + serverTransport: LinkedRuntimeTransport; +} { + const transports = linkedTransports(); + const stop = startJsonRpcServer(handler, transports.server as JsonRpcTransport, { nonFatal: true }); + handler.setNotifier?.((method, params) => stop.notify(method, params)); + const client = new RuntimeRpcClient(transports.client, 5_000); + return { + client, + serverTransport: transports.server, + close: () => { + stop(); + handler.dispose?.(); + client.close(); + }, + }; +} + +function createSsh(): Client { + const listeners = new Map<string, Array<(...args: unknown[]) => void>>(); + const ssh: { + end: ReturnType<typeof vi.fn>; + once: ReturnType<typeof vi.fn>; + } = { + end: vi.fn(), + once: vi.fn((event: string, callback: (...args: unknown[]) => void): typeof ssh => { + const existing = listeners.get(event) ?? []; + existing.push(callback); + listeners.set(event, existing); + return ssh; + }), + }; + return ssh as unknown as Client; +} + +function createRegistry() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-desktop-remote-rpc-")); + const projectRoot = path.join(root, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + const registry = new ProjectRegistry({ + adeDir: path.join(root, "home"), + projectsPath: path.join(root, "home", "projects.json"), + secretsDir: path.join(root, "home", "secrets"), + sockDir: path.join(root, "home", "sock"), + socketPath: path.join(root, "home", "sock", "ade.sock"), + binDir: path.join(root, "home", "bin"), + runtimeDir: path.join(root, "home", "runtime"), + }); + return { root, projectRoot, registry }; +} + +function createRuntime(projectRoot: string) { + const operation = { operationId: "op-1" }; + const runGraph = { + run: { + id: "run-1", + missionId: "mission-1", + status: "running", + metadata: {}, + }, + steps: [], + attempts: [], + edges: [], + timeline: [], + contextSnapshots: [], + handoffs: [], + runtimeEvents: [], + }; + return { + projectRoot, + workspaceRoot: projectRoot, + projectId: "project-1", + project: { rootPath: projectRoot, displayName: "project", baseRef: "main" }, + paths: { + adeDir: path.join(projectRoot, ".ade"), + logsDir: path.join(projectRoot, ".ade", "logs"), + processLogsDir: path.join(projectRoot, ".ade", "logs", "processes"), + testLogsDir: path.join(projectRoot, ".ade", "logs", "tests"), + transcriptsDir: path.join(projectRoot, ".ade", "transcripts"), + worktreesDir: path.join(projectRoot, ".ade", "worktrees"), + dbPath: path.join(projectRoot, ".ade", "ade.db"), + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + laneService: { + list: vi.fn(async () => [{ id: "lane-main", name: "Main", archivedAt: null }]), + listUnregisteredWorktrees: vi.fn(async () => []), + }, + sessionService: { + get: vi.fn(), + readTranscriptTail: vi.fn(() => ""), + }, + operationService: { + start: vi.fn(() => operation), + finish: vi.fn(), + list: vi.fn(() => []), + }, + eventBuffer: createEventBuffer(), + orchestratorService: { + listRuns: vi.fn(() => [runGraph.run]), + getRunGraph: vi.fn(() => runGraph), + }, + dispose: vi.fn(), + }; +} + +function createScopeRegistry(projectId: string, runtime: ReturnType<typeof createRuntime>): ProjectScopeRegistry { + return { + get: vi.fn(async () => ({ + registryProjectId: projectId, + record: { + projectId, + rootPath: runtime.projectRoot, + displayName: "project", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }, + runtime, + dispose: vi.fn(), + })), + ensureSyncHost: vi.fn(), + dispose: vi.fn(), + disposeAll: vi.fn(), + } as unknown as ProjectScopeRegistry; +} + +const target: RemoteRuntimeTarget = { + id: "target-1", + name: "Remote", + hostname: "remote.example.test", + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, +}; + +describe("remote runtime offline RPC integration", () => { + const clients: Array<{ close: () => void }> = []; + + beforeEach(() => { + bootstrapRemoteRuntimeMock.mockReset(); + }); + + afterEach(() => { + for (const client of clients.splice(0)) { + client.close(); + } + }); + + it("routes a remote lane action through real JSON-RPC and the multi-project handler", async () => { + const { projectRoot, registry } = createRegistry(); + const project = registry.add(projectRoot); + const runtime = createRuntime(projectRoot); + const scopeRegistry = createScopeRegistry(project.projectId, runtime); + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "1.2.3", + projectRegistry: registry, + scopeRegistry, + disposeScopesOnDispose: false, + }); + const runtimeClient = startRuntimeClient(handler); + clients.push(runtimeClient); + await runtimeClient.client.initialize("desktop-offline-test", "1.2.3"); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: runtimeClient.client, + ssh: createSsh(), + result: { + target, + arch: "linux-x64", + version: "1.2.3", + projects: [project], + }, + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.2.3"); + + await expect(pool.callActionForTarget(target, project.projectId, { + domain: "lane", + action: "list", + args: { includeArchived: false }, + })).resolves.toEqual({ + domain: "lane", + action: "list", + result: [{ id: "lane-main", name: "Main", archivedAt: null }], + statusHints: { + operationId: null, + testRunId: null, + chatSessionId: null, + runId: null, + missionId: null, + }, + }); + + expect(runtime.laneService.list).toHaveBeenCalledWith({ includeArchived: false }); + expect(scopeRegistry.get).toHaveBeenCalledWith(project.projectId); + }); + + it("retries a project registry read after the JSON-RPC transport disconnects mid-request", async () => { + const { projectRoot, registry } = createRegistry(); + const project = registry.add(projectRoot); + const firstHandler: JsonRpcHandler = async (request) => { + if (request.method === "projects.list") { + firstRuntime.serverTransport.fail(new Error("channel closed")); + await new Promise(() => {}); + } + return request.method === "ade/initialize" + ? { runtimeInfo: { version: "1.2.3", multiProject: true }, capabilities: { projects: true } } + : {}; + }; + const firstRuntime = startRuntimeClient(firstHandler); + const secondHandler = createMultiProjectRpcRequestHandler({ + serverVersion: "1.2.4", + projectRegistry: registry, + disposeScopesOnDispose: false, + }); + const secondRuntime = startRuntimeClient(secondHandler); + clients.push(firstRuntime, secondRuntime); + await firstRuntime.client.initialize("desktop-offline-test", "1.2.3"); + await secondRuntime.client.initialize("desktop-offline-test", "1.2.4"); + bootstrapRemoteRuntimeMock + .mockResolvedValueOnce({ + client: firstRuntime.client, + ssh: createSsh(), + result: { target, arch: "linux-x64", version: "1.2.3", projects: [] }, + }) + .mockResolvedValueOnce({ + client: secondRuntime.client, + ssh: createSsh(), + result: { target, arch: "linux-x64", version: "1.2.4", projects: [project] }, + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.2.4"); + + await expect(pool.projectsForTarget(target)).resolves.toEqual([project]); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + }); + + it("reconnects but does not replay an interrupted mutating action", async () => { + const { projectRoot, registry } = createRegistry(); + const project = registry.add(projectRoot); + const firstHandler: JsonRpcHandler = async (request) => { + if (request.method === "ade/actions/call") { + firstRuntime.serverTransport.fail(new Error("channel closed")); + await new Promise(() => {}); + } + return request.method === "ade/initialize" + ? { runtimeInfo: { version: "1.2.3", multiProject: true }, capabilities: { projects: true } } + : {}; + }; + const firstRuntime = startRuntimeClient(firstHandler); + const secondHandler = vi.fn(async () => ({ + runtimeInfo: { version: "1.2.4", multiProject: true }, + capabilities: { projects: true }, + })); + const secondRuntime = startRuntimeClient(secondHandler); + clients.push(firstRuntime, secondRuntime); + await firstRuntime.client.initialize("desktop-offline-test", "1.2.3"); + await secondRuntime.client.initialize("desktop-offline-test", "1.2.4"); + bootstrapRemoteRuntimeMock + .mockResolvedValueOnce({ + client: firstRuntime.client, + ssh: createSsh(), + result: { target, arch: "linux-x64", version: "1.2.3", projects: [project] }, + }) + .mockResolvedValueOnce({ + client: secondRuntime.client, + ssh: createSsh(), + result: { target, arch: "linux-x64", version: "1.2.4", projects: [project] }, + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.2.4"); + + await expect(pool.callActionForTarget(target, project.projectId, { + domain: "orchestrator_core", + action: "resumeRun", + args: { runId: "run-1" }, + })).rejects.toThrow(/retry the action/i); + + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + expect(secondHandler).not.toHaveBeenCalledWith(expect.objectContaining({ method: "ade/actions/call" })); + }); + + it("reattaches to checkpointed run state and missed events after reconnect", async () => { + const { projectRoot, registry } = createRegistry(); + const project = registry.add(projectRoot); + const runtime = createRuntime(projectRoot); + const scopeRegistry = createScopeRegistry(project.projectId, runtime); + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "1.2.4", + projectRegistry: registry, + scopeRegistry, + disposeScopesOnDispose: false, + }); + runtime.eventBuffer.push({ + timestamp: "2026-05-10T12:00:00.000Z", + category: "mission", + payload: { type: "mission_started", missionId: "mission-1", runId: "run-1" }, + }); + const firstRuntime = startRuntimeClient(handler); + const secondRuntime = startRuntimeClient(handler); + clients.push(firstRuntime, secondRuntime); + await firstRuntime.client.initialize("desktop-offline-test", "1.2.3"); + await secondRuntime.client.initialize("desktop-offline-test", "1.2.4"); + bootstrapRemoteRuntimeMock + .mockResolvedValueOnce({ + client: firstRuntime.client, + ssh: createSsh(), + result: { target, arch: "linux-x64", version: "1.2.3", projects: [project] }, + }) + .mockResolvedValueOnce({ + client: secondRuntime.client, + ssh: createSsh(), + result: { target, arch: "linux-x64", version: "1.2.4", projects: [project] }, + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.2.4"); + + const initialEvents = await pool.streamEventsForTarget(target, project.projectId, { + cursor: 0, + limit: 10, + }); + expect(initialEvents.events.map((event) => event.payload.type)).toEqual(["mission_started"]); + expect(initialEvents.nextCursor).toBe(1); + + runtime.eventBuffer.push({ + timestamp: "2026-05-10T12:00:01.000Z", + category: "mission", + payload: { type: "mission_resume_recovered", missionId: "mission-1", runId: "run-1" }, + }); + firstRuntime.serverTransport.fail(new Error("channel closed")); + + await expect(pool.streamEventsForTarget(target, project.projectId, { + cursor: initialEvents.nextCursor, + limit: 10, + })).resolves.toMatchObject({ + events: [{ + id: 2, + category: "mission", + payload: { type: "mission_resume_recovered", missionId: "mission-1", runId: "run-1" }, + }], + nextCursor: 2, + hasMore: false, + }); + + await expect(pool.callActionForTarget(target, project.projectId, { + domain: "orchestrator_core", + action: "getRunGraph", + args: { runId: "run-1", timelineLimit: 0 }, + })).resolves.toMatchObject({ + domain: "orchestrator_core", + action: "getRunGraph", + result: { + run: { id: "run-1", missionId: "mission-1", status: "running" }, + }, + }); + expect(runtime.orchestratorService.getRunGraph).toHaveBeenCalledWith({ runId: "run-1", timelineLimit: 0 }); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.test.ts new file mode 100644 index 000000000..c6c711d94 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.test.ts @@ -0,0 +1,34 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { RemoteTargetRegistry } from "./remoteTargetRegistry"; + +const originalAdeHome = process.env.ADE_HOME; + +afterEach(() => { + if (originalAdeHome === undefined) delete process.env.ADE_HOME; + else process.env.ADE_HOME = originalAdeHome; +}); + +describe("RemoteTargetRegistry", () => { + it("stores targets under the active ADE_HOME", () => { + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-remote-targets-")); + process.env.ADE_HOME = adeHome; + + const registry = new RemoteTargetRegistry(); + const target = registry.save({ + name: "Mac Studio", + hostname: "100.75.20.63", + sshUser: "admin", + port: null, + sshKeyPath: null, + }); + + expect(registry.path).toBe(path.join(adeHome, "secrets", "remote-machines.json")); + expect(JSON.parse(fs.readFileSync(registry.path, "utf8"))).toMatchObject({ + version: 1, + targets: [target], + }); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.ts b/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.ts new file mode 100644 index 000000000..336c0819a --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.ts @@ -0,0 +1,126 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { resolveMachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; +import type { RemoteRuntimeTarget, RemoteRuntimeTargetInput } from "../../../shared/types/remoteRuntime"; + +type RegistryFile = { + version: 1; + targets: RemoteRuntimeTarget[]; +}; + +function registryPath(): string { + return path.join(resolveMachineAdeLayout().secretsDir, "remote-machines.json"); +} + +function normalizePort(port: number | null | undefined): number | null { + if (!port || !Number.isFinite(port)) return null; + return Math.max(1, Math.min(65_535, Math.floor(port))); +} + +function defaultName(input: RemoteRuntimeTargetInput): string { + return input.name?.trim() || input.hostname.trim(); +} + +function stableTargetId(input: RemoteRuntimeTargetInput): string { + const sshUser = input.sshUser?.trim() ?? ""; + const port = normalizePort(input.port) ?? ""; + return createHash("sha256") + .update(`${sshUser}@${input.hostname.trim()}:${port}`) + .digest("hex") + .slice(0, 24); +} + +function coerceTarget(value: unknown): RemoteRuntimeTarget | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record<string, unknown>; + const hostname = typeof record.hostname === "string" ? record.hostname.trim() : ""; + const sshUser = typeof record.sshUser === "string" && record.sshUser.trim() ? record.sshUser.trim() : null; + if (!hostname) return null; + const fallbackInput: RemoteRuntimeTargetInput = { hostname, sshUser, port: normalizePort(typeof record.port === "number" ? record.port : null) }; + return { + id: typeof record.id === "string" && record.id.trim() ? record.id.trim() : stableTargetId(fallbackInput), + name: typeof record.name === "string" && record.name.trim() ? record.name.trim() : hostname, + hostname, + sshUser, + port: normalizePort(typeof record.port === "number" ? record.port : null), + sshKeyPath: typeof record.sshKeyPath === "string" && record.sshKeyPath.trim() ? record.sshKeyPath.trim() : null, + lastSeenArch: typeof record.lastSeenArch === "string" && record.lastSeenArch.trim() ? record.lastSeenArch.trim() : null, + runtimeBinaryVersion: typeof record.runtimeBinaryVersion === "string" && record.runtimeBinaryVersion.trim() ? record.runtimeBinaryVersion.trim() : null, + lastConnectedAt: typeof record.lastConnectedAt === "number" && Number.isFinite(record.lastConnectedAt) ? record.lastConnectedAt : null, + }; +} + +export class RemoteTargetRegistry { + readonly path = registryPath(); + + list(): RemoteRuntimeTarget[] { + return this.read().targets; + } + + get(id: string): RemoteRuntimeTarget | null { + return this.list().find((target) => target.id === id) ?? null; + } + + save(input: RemoteRuntimeTargetInput): RemoteRuntimeTarget { + const hostname = input.hostname.trim(); + const sshUser = input.sshUser?.trim() || null; + if (!hostname) throw new Error("Remote hostname is required."); + const file = this.read(); + const id = stableTargetId(input); + const existing = file.targets.find((target) => target.id === id) ?? null; + const next: RemoteRuntimeTarget = { + id, + name: defaultName(input), + hostname, + sshUser, + port: normalizePort(input.port), + sshKeyPath: input.sshKeyPath?.trim() || null, + lastSeenArch: existing?.lastSeenArch ?? null, + runtimeBinaryVersion: existing?.runtimeBinaryVersion ?? null, + lastConnectedAt: existing?.lastConnectedAt ?? null, + }; + file.targets = [next, ...file.targets.filter((target) => target.id !== id)]; + this.write(file); + return next; + } + + update(id: string, patch: Partial<RemoteRuntimeTarget>): RemoteRuntimeTarget { + const file = this.read(); + const index = file.targets.findIndex((target) => target.id === id); + if (index < 0) throw new Error(`Unknown remote target: ${id}`); + const next = { ...file.targets[index]!, ...patch, id }; + file.targets[index] = next; + this.write(file); + return next; + } + + remove(id: string): boolean { + const file = this.read(); + const nextTargets = file.targets.filter((target) => target.id !== id); + if (nextTargets.length === file.targets.length) return false; + this.write({ version: 1, targets: nextTargets }); + return true; + } + + private read(): RegistryFile { + try { + const raw = fs.readFileSync(this.path, "utf8"); + const parsed = JSON.parse(raw) as unknown; + const targets = parsed && typeof parsed === "object" && !Array.isArray(parsed) && Array.isArray((parsed as { targets?: unknown }).targets) + ? (parsed as { targets: unknown[] }).targets.map(coerceTarget).filter((target): target is RemoteRuntimeTarget => target != null) + : []; + return { version: 1, targets }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, targets: [] }; + throw error; + } + } + + private write(file: RegistryFile): void { + fs.mkdirSync(path.dirname(this.path), { recursive: true, mode: 0o700 }); + const tmp = `${this.path}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + fs.renameSync(tmp, this.path); + } +} diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts new file mode 100644 index 000000000..4618acf9e --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + discoveredRuntimeFromBonjourService, + discoveredRuntimesFromTailscaleStatus, +} from "./runtimeDiscovery"; + +describe("runtimeDiscovery", () => { + it("parses ADE sync Bonjour metadata into a discovered machine", () => { + const discovered = discoveredRuntimeFromBonjourService( + { + name: "ADE Sync Studio 8787", + fqdn: "ADE Sync Studio 8787._ade-sync._tcp.local", + host: "studio.local", + port: 8787, + addresses: ["127.0.0.1", "192.168.1.42"], + txt: { + deviceId: "device-123", + deviceName: "Studio", + runtimeKind: "daemon", + runtimeVersion: "0.0.0", + projects: "project-a, project-b", + projectCount: "2", + host: "192.168.1.42", + addresses: "127.0.0.1,100.75.20.63", + tailscaleDnsName: "studio.tailnet.ts.net", + tailscaleIp: "100.75.20.63", + }, + }, + 1234, + ); + + expect(discovered).toMatchObject({ + id: "device-123::ADE Sync Studio 8787._ade-sync._tcp.local", + serviceName: "ADE Sync Studio 8787", + machineName: "Studio", + hostIdentity: "device-123", + hostName: "studio.local", + port: 8787, + addresses: ["192.168.1.42", "100.75.20.63", "127.0.0.1"], + primaryRoute: "192.168.1.42", + tailscaleAddress: "studio.tailnet.ts.net", + runtimeKind: "daemon", + runtimeVersion: "0.0.0", + projectIds: ["project-a", "project-b"], + projectCount: 2, + lastSeenAt: 1234, + }); + }); + + it("falls back to service metadata when TXT identity is partial", () => { + const discovered = discoveredRuntimeFromBonjourService( + { + name: "ADE Sync Laptop 8787", + host: "laptop.local", + port: 0, + addresses: ["127.0.0.1"], + txt: { + port: "8787", + runtimeKind: "", + }, + }, + 5678, + ); + + expect(discovered).toMatchObject({ + id: "ADE Sync Laptop 8787@laptop.local:8787", + machineName: "laptop.local", + hostIdentity: null, + hostName: "laptop.local", + port: 8787, + addresses: ["127.0.0.1"], + primaryRoute: "laptop.local", + runtimeKind: null, + runtimeVersion: null, + projectIds: [], + projectCount: null, + lastSeenAt: 5678, + }); + }); + + it("turns Tailscale peers into SSH discovery targets", () => { + const discovered = discoveredRuntimesFromTailscaleStatus( + { + Peer: { + "nodekey:abc": { + ID: "peer-1", + HostName: "aruls-mac-studio", + DNSName: "aruls-mac-studio.tail7497a6.ts.net.", + OS: "macOS", + TailscaleIPs: ["100.75.20.63", "fd7a:115c:a1e0::1"], + Online: true, + }, + }, + }, + 9012, + ); + + expect(discovered).toHaveLength(1); + expect(discovered[0]).toMatchObject({ + id: "tailscale:peer-1", + serviceName: "Tailscale peer", + machineName: "aruls-mac-studio", + hostIdentity: "peer-1", + hostName: "aruls-mac-studio", + port: 22, + addresses: ["100.75.20.63", "aruls-mac-studio.tail7497a6.ts.net"], + primaryRoute: "aruls-mac-studio.tail7497a6.ts.net", + tailscaleAddress: "aruls-mac-studio.tail7497a6.ts.net", + runtimeKind: "tailscale-peer", + runtimeVersion: null, + projectIds: [], + projectCount: null, + lastSeenAt: 9012, + }); + }); + + it("skips mobile Tailscale peers in the SSH discovery list", () => { + const discovered = discoveredRuntimesFromTailscaleStatus( + { + Peer: { + "nodekey:iphone": { + ID: "peer-phone", + HostName: "iPhone", + DNSName: "iphone.tail7497a6.ts.net.", + OS: "iOS", + TailscaleIPs: ["100.75.20.64"], + Online: true, + }, + "nodekey:mac": { + ID: "peer-mac", + HostName: "studio", + DNSName: "studio.tail7497a6.ts.net.", + OS: "macOS", + TailscaleIPs: ["100.75.20.63"], + Online: true, + }, + }, + }, + 123, + ); + + expect(discovered).toHaveLength(1); + expect(discovered[0]?.machineName).toBe("studio"); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts new file mode 100644 index 000000000..0829e4d95 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts @@ -0,0 +1,304 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { + Bonjour, + type Browser, + type Service as BonjourService, +} from "bonjour-service"; +import { resolveTailscaleCliPath } from "../../../../../ade-cli/src/services/sync/resolveTailscaleCliPath"; +import type { RemoteRuntimeDiscoveredMachine } from "../../../shared/types/remoteRuntime"; + +export const ADE_SYNC_MDNS_SERVICE_TYPE = "ade-sync"; +const TAILSCALE_SSH_PORT = 22; +const execFileAsync = promisify(execFile); + +type BonjourServiceLike = Partial<BonjourService> & { + rawTxt?: unknown; +}; + +type TxtRecord = Record<string, string>; +type TailscaleStatusPeer = { + ID?: unknown; + HostName?: unknown; + DNSName?: unknown; + OS?: unknown; + TailscaleIPs?: unknown; + Online?: unknown; +}; + +type TailscaleStatus = { + Peer?: unknown; +}; + +function trimmed(value: unknown): string | null { + if (value == null || value === false) return null; + const text = Buffer.isBuffer(value) ? value.toString("utf8") : String(value); + const next = text.trim(); + return next.length > 0 ? next : null; +} + +function normalizeTxtRecord(value: unknown): TxtRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const record: TxtRecord = {}; + for (const [key, entry] of Object.entries(value as Record<string, unknown>)) { + const normalizedKey = trimmed(key); + const normalizedValue = trimmed(entry); + if (!normalizedKey || normalizedValue == null) continue; + record[normalizedKey] = normalizedValue; + } + return record; +} + +function splitCsv(value: string | null): string[] { + if (!value) return []; + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function parsePositiveInteger(value: unknown): number | null { + const text = trimmed(value); + if (!text) return null; + const parsed = Number.parseInt(text, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function uniqueStrings(values: Array<string | null | undefined>): string[] { + const seen = new Set<string>(); + const result: string[] = []; + for (const value of values) { + const next = trimmed(value); + if (!next || seen.has(next)) continue; + seen.add(next); + result.push(next); + } + return result; +} + +function isLoopbackRoute(host: string): boolean { + const lower = host.toLowerCase(); + return ( + lower === "localhost" || + lower === "::1" || + lower === "0.0.0.0" || + lower.startsWith("127.") + ); +} + +function isTailscaleRoute(host: string): boolean { + const lower = host.toLowerCase().replace(/\.$/, ""); + if (lower.endsWith(".ts.net")) return true; + const match = /^100\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(lower); + if (!match) return false; + const second = Number.parseInt(match[1] ?? "", 10); + return second >= 64 && second <= 127; +} + +function normalizeTailscaleDnsName(value: unknown): string | null { + const text = trimmed(value); + if (!text) return null; + const normalized = text.replace(/\.$/, ""); + return normalized.endsWith(".ts.net") ? normalized : null; +} + +function isSshCapableTailscalePeer(osValue: unknown): boolean { + const os = trimmed(osValue)?.toLowerCase(); + if (!os) return true; + return os !== "ios" && os !== "android" && os !== "tvos" && os !== "watchos"; +} + +function orderAddresses(addresses: string[]): string[] { + const nonLoopback = addresses.filter((host) => !isLoopbackRoute(host)); + const loopback = addresses.filter(isLoopbackRoute); + return [...nonLoopback, ...loopback]; +} + +function firstNonEmpty(values: Array<unknown>): string | null { + for (const value of values) { + const next = trimmed(value); + if (next) return next; + } + return null; +} + +export function discoveredRuntimeFromBonjourService( + service: BonjourServiceLike, + nowMs = Date.now(), +): RemoteRuntimeDiscoveredMachine | null { + const txt = normalizeTxtRecord(service.txt); + const serviceName = + firstNonEmpty([service.name, service.fqdn, "ADE Sync"]) ?? "ADE Sync"; + const servicePort = + parsePositiveInteger(service.port) ?? parsePositiveInteger(txt.port); + const serviceKey = + firstNonEmpty([ + service.fqdn, + `${serviceName}@${firstNonEmpty([service.host, txt.host]) ?? "unknown"}:${servicePort ?? ""}`, + ]) ?? serviceName; + const hostName = firstNonEmpty([service.host]); + const machineName = + firstNonEmpty([txt.deviceName, hostName, serviceName]) ?? serviceName; + const hostIdentity = firstNonEmpty([txt.deviceId]); + const port = servicePort ?? 8787; + const announcedAddresses = splitCsv(txt.addresses); + const tailscaleAddress = firstNonEmpty( + [txt.tailscaleDnsName, txt.tailscaleIp].filter((value): value is string => + Boolean(value && isTailscaleRoute(value)), + ), + ); + const addresses = orderAddresses( + uniqueStrings([ + txt.host, + ...(service.addresses ?? []), + ...announcedAddresses, + txt.tailscaleIp, + ]), + ); + const primaryRoute = firstNonEmpty([ + addresses.find( + (address) => !isLoopbackRoute(address) && !isTailscaleRoute(address), + ), + tailscaleAddress, + addresses.find((address) => !isLoopbackRoute(address)), + hostName, + addresses[0], + ]); + const projectIds = splitCsv(txt.projects); + const projectCount = + parsePositiveInteger(txt.projectCount) ?? + (projectIds.length > 0 ? projectIds.length : null); + + return { + id: hostIdentity ? `${hostIdentity}::${serviceKey}` : serviceKey, + serviceName, + machineName, + hostIdentity, + hostName, + port, + addresses, + primaryRoute, + tailscaleAddress, + runtimeKind: firstNonEmpty([txt.runtimeKind]), + runtimeVersion: firstNonEmpty([txt.runtimeVersion]), + projectIds, + projectCount, + lastSeenAt: nowMs, + }; +} + +export function discoveredRuntimesFromTailscaleStatus( + value: unknown, + nowMs = Date.now(), +): RemoteRuntimeDiscoveredMachine[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const peers = (value as TailscaleStatus).Peer; + if (!peers || typeof peers !== "object" || Array.isArray(peers)) return []; + + const discovered: RemoteRuntimeDiscoveredMachine[] = []; + for (const [peerKey, rawPeer] of Object.entries( + peers as Record<string, unknown>, + )) { + if (!rawPeer || typeof rawPeer !== "object" || Array.isArray(rawPeer)) + continue; + const peer = rawPeer as TailscaleStatusPeer; + if (!isSshCapableTailscalePeer(peer.OS)) continue; + const tailscaleIps = Array.isArray(peer.TailscaleIPs) + ? peer.TailscaleIPs.map((entry) => trimmed(entry)).filter( + (entry): entry is string => Boolean(entry && isTailscaleRoute(entry)), + ) + : []; + const dnsName = normalizeTailscaleDnsName(peer.DNSName); + const tailscaleAddress = firstNonEmpty([dnsName, tailscaleIps[0]]); + if (!tailscaleAddress) continue; + + const hostName = trimmed(peer.HostName) ?? dnsName; + const machineName = trimmed(peer.HostName) ?? dnsName ?? tailscaleAddress; + const hostIdentity = trimmed(peer.ID) ?? trimmed(peerKey); + const online = peer.Online === true; + const addresses = uniqueStrings([...tailscaleIps, dnsName]); + discovered.push({ + id: `tailscale:${hostIdentity ?? tailscaleAddress}`, + serviceName: "Tailscale peer", + machineName, + hostIdentity, + hostName, + port: TAILSCALE_SSH_PORT, + addresses, + primaryRoute: tailscaleAddress, + tailscaleAddress, + runtimeKind: online ? "tailscale-peer" : "tailscale-peer-offline", + runtimeVersion: null, + projectIds: [], + projectCount: null, + lastSeenAt: nowMs, + }); + } + + return discovered; +} + +async function discoverTailscalePeers( + timeoutMs = 1_200, +): Promise<RemoteRuntimeDiscoveredMachine[]> { + try { + const { stdout } = await execFileAsync( + resolveTailscaleCliPath(), + ["status", "--json"], + { + timeout: Math.max(500, timeoutMs), + maxBuffer: 1024 * 1024, + }, + ); + return discoveredRuntimesFromTailscaleStatus( + JSON.parse(stdout), + Date.now(), + ); + } catch { + return []; + } +} + +export async function discoverLanRuntimes( + timeoutMs = 1_200, +): Promise<RemoteRuntimeDiscoveredMachine[]> { + const bonjour = new Bonjour(); + const discovered = new Map<string, RemoteRuntimeDiscoveredMachine>(); + let browser: Browser | null = null; + + const remember = (service: BonjourService): void => { + const machine = discoveredRuntimeFromBonjourService(service); + if (!machine) return; + discovered.set(machine.id, machine); + }; + + await Promise.all([ + (async () => { + try { + browser = bonjour.find({ type: ADE_SYNC_MDNS_SERVICE_TYPE }); + browser.on("up", remember); + browser.on("txt-update", remember); + await new Promise((resolve) => + setTimeout(resolve, Math.max(100, timeoutMs)), + ); + } finally { + browser?.stop(); + await new Promise<void>((resolve) => { + bonjour.destroy(() => resolve()); + setTimeout(resolve, 250); + }); + } + })(), + (async () => { + for (const machine of await discoverTailscalePeers(timeoutMs)) { + discovered.set(machine.id, machine); + } + })(), + ]); + + return [...discovered.values()].sort((a, b) => { + const name = a.machineName.localeCompare(b.machineName); + if (name !== 0) return name; + return a.serviceName.localeCompare(b.serviceName); + }); +} diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.test.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.test.ts new file mode 100644 index 000000000..1ffb2de60 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from "vitest"; +import { RuntimeRpcClient, type RuntimeRpcTransport } from "./runtimeRpcClient"; + +class MockTransport implements RuntimeRpcTransport { + readonly writes: string[] = []; + private readonly dataCallbacks = new Set<(chunk: Buffer) => void>(); + private readonly closeCallbacks = new Set<() => void>(); + private readonly errorCallbacks = new Set<(error: Error) => void>(); + writeError: Error | null = null; + closed = false; + + onData(callback: (chunk: Buffer) => void): void { + this.dataCallbacks.add(callback); + } + + onClose(callback: () => void): void { + this.closeCallbacks.add(callback); + } + + onError(callback: (error: Error) => void): void { + this.errorCallbacks.add(callback); + } + + write(data: string): void { + if (this.writeError) throw this.writeError; + this.writes.push(data); + } + + close(): void { + this.closed = true; + this.emitClose(); + } + + emitData(message: unknown): void { + const chunk = typeof message === "string" ? message : `${JSON.stringify(message)}\n`; + for (const callback of this.dataCallbacks) { + callback(Buffer.from(chunk, "utf8")); + } + } + + emitClose(): void { + for (const callback of this.closeCallbacks) { + callback(); + } + } + + emitError(error: Error): void { + for (const callback of this.errorCallbacks) { + callback(error); + } + } +} + +function requestId(write: string): number { + const parsed = JSON.parse(write.trim()) as { id?: unknown }; + if (typeof parsed.id !== "number") throw new Error("Expected numeric JSON-RPC id."); + return parsed.id; +} + +describe("RuntimeRpcClient", () => { + it("resolves calls from JSON-RPC responses", async () => { + const transport = new MockTransport(); + const client = new RuntimeRpcClient(transport); + + const pending = client.call("projects.list", {}); + transport.emitData({ jsonrpc: "2.0", id: requestId(transport.writes[0]!), result: ["project"] }); + + await expect(pending).resolves.toEqual(["project"]); + }); + + it("rejects pending and future calls when the transport closes", async () => { + const transport = new MockTransport(); + const client = new RuntimeRpcClient(transport); + + const pending = client.call("projects.list", {}); + transport.emitClose(); + + await expect(pending).rejects.toThrow("Remote ADE service connection closed."); + await expect(client.call("projects.list", {})).rejects.toThrow("Remote ADE service connection closed."); + }); + + it("rejects pending calls and notifies disconnect listeners when the transport errors", async () => { + const transport = new MockTransport(); + const client = new RuntimeRpcClient(transport); + const onDisconnect = vi.fn(); + client.onDisconnect(onDisconnect); + + const pending = client.call("projects.list", {}); + transport.emitError(new Error("ECONNRESET")); + transport.emitClose(); + + await expect(pending).rejects.toThrow("Remote ADE service connection failed: ECONNRESET"); + expect(onDisconnect).toHaveBeenCalledTimes(1); + expect(onDisconnect.mock.calls[0]?.[0]).toMatchObject({ + message: "Remote ADE service connection failed: ECONNRESET", + }); + }); + + it("clears pending calls when writes fail", async () => { + const transport = new MockTransport(); + transport.writeError = new Error("broken pipe"); + const client = new RuntimeRpcClient(transport); + + await expect(client.call("projects.list", {})).rejects.toThrow("broken pipe"); + }); + + it("dispatches JSON-RPC notifications without resolving pending calls", async () => { + const transport = new MockTransport(); + const client = new RuntimeRpcClient(transport); + const onRuntimeEvent = vi.fn(); + const unsubscribe = client.onNotification("runtime/event", onRuntimeEvent); + + const pending = client.call("projects.list", {}); + transport.emitData({ jsonrpc: "2.0", method: "runtime/event", params: { projectId: "project-1" } }); + expect(onRuntimeEvent).toHaveBeenCalledWith({ projectId: "project-1" }); + + transport.emitData({ jsonrpc: "2.0", id: requestId(transport.writes[0]!), result: ["project"] }); + await expect(pending).resolves.toEqual(["project"]); + + unsubscribe(); + transport.emitData({ jsonrpc: "2.0", method: "runtime/event", params: { projectId: "project-2" } }); + expect(onRuntimeEvent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts new file mode 100644 index 000000000..5a5619db5 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts @@ -0,0 +1,175 @@ +import type { JsonRpcId, JsonRpcRequest, JsonRpcTransport } from "../../../../../ade-cli/src/jsonrpc"; + +export type RuntimeRpcTransport = JsonRpcTransport & { + onClose?: (callback: () => void) => void; + onError?: (callback: (error: Error) => void) => void; +}; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType<typeof setTimeout>; +}; + +const MAX_RPC_BUFFER_CHARS = 16 * 1024 * 1024; + +export class RuntimeRpcClient { + private nextId = 1; + private buffer = ""; + private readonly pending = new Map<number, PendingRequest>(); + private readonly notificationHandlers = new Map<string, Set<(params: unknown) => void>>(); + private readonly disconnectCallbacks = new Set<(error: Error) => void>(); + private closedError: Error | null = null; + + constructor( + private readonly transport: RuntimeRpcTransport, + private readonly timeoutMs = 10 * 60 * 1000, + ) { + this.transport.onData((chunk) => this.onData(chunk.toString("utf8"))); + this.transport.onError?.((error) => { + this.failConnection(new Error(`Remote ADE service connection failed: ${error.message}`)); + }); + this.transport.onClose?.(() => { + this.failConnection(new Error("Remote ADE service connection closed.")); + }); + } + + async initialize(clientName: string, version: string): Promise<unknown> { + return await this.call("ade/initialize", { + protocolVersion: "2025-06-18", + clientInfo: { name: clientName, version }, + identity: { + callerId: `${clientName}:${process.pid}`, + role: "cto", + }, + }); + } + + call(method: string, params?: Record<string, unknown>): Promise<unknown> { + if (this.closedError) return Promise.reject(this.closedError); + const id = this.nextId++; + const payload: JsonRpcRequest = { + jsonrpc: "2.0", + id: id as JsonRpcId, + method, + ...(params ? { params } : {}), + }; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Timed out waiting for remote ADE service method ${method}.`)); + }, this.timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + try { + this.transport.write(`${JSON.stringify(payload)}\n`); + } catch (error) { + this.pending.delete(id); + clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + onDisconnect(callback: (error: Error) => void): () => void { + if (this.closedError) { + const error = this.closedError; + queueMicrotask(() => callback(error)); + return () => {}; + } + this.disconnectCallbacks.add(callback); + return () => { + this.disconnectCallbacks.delete(callback); + }; + } + + onNotification(method: string, callback: (params: unknown) => void): () => void { + const handlers = this.notificationHandlers.get(method) ?? new Set<(params: unknown) => void>(); + handlers.add(callback); + this.notificationHandlers.set(method, handlers); + return () => { + handlers.delete(callback); + if (handlers.size === 0) { + this.notificationHandlers.delete(method); + } + }; + } + + close(): void { + this.failConnection(new Error("Remote ADE service connection closed.")); + try { + this.transport.close(); + } catch { + // Best-effort close. Pending callers have already been rejected. + } + } + + private onData(chunk: string): void { + if (this.closedError) return; + this.buffer += chunk; + if (this.buffer.length > MAX_RPC_BUFFER_CHARS) { + this.failConnection(new Error("Remote ADE service response buffer exceeded 16 MiB.")); + return; + } + while (true) { + const newline = this.buffer.indexOf("\n"); + if (newline < 0) break; + const line = this.buffer.slice(0, newline).trim(); + this.buffer = this.buffer.slice(newline + 1); + if (!line) continue; + this.handleLine(line); + } + } + + private handleLine(line: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + this.failConnection(new Error(`Failed to parse remote ADE service response: ${error instanceof Error ? error.message : String(error)}`)); + return; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return; + const response = parsed as Record<string, unknown>; + const id = typeof response.id === "number" ? response.id : null; + if (id == null) { + const method = typeof response.method === "string" ? response.method : ""; + if (!method) return; + for (const handler of this.notificationHandlers.get(method) ?? []) { + try { + handler(response.params); + } catch (error) { + console.error("Remote ADE notification handler failed", { method, error }); + } + } + return; + } + const pending = this.pending.get(id); + if (!pending) return; + this.pending.delete(id); + clearTimeout(pending.timer); + const error = response.error; + if (error && typeof error === "object" && !Array.isArray(error)) { + pending.reject(new Error(String((error as { message?: unknown }).message ?? "Remote ADE service request failed."))); + return; + } + pending.resolve(response.result); + } + + private failConnection(error: Error): void { + if (this.closedError) return; + this.closedError = error; + this.rejectAll(error); + for (const callback of this.disconnectCallbacks) { + callback(error); + } + this.disconnectCallbacks.clear(); + } + + private rejectAll(error: Error): void { + for (const [id, pending] of this.pending) { + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(error); + } + } +} diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts new file mode 100644 index 000000000..8a5439166 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts @@ -0,0 +1,191 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; +import { buildSshConfig, buildSshConfigCandidates, buildSshUsernameCandidates, parseOpenSshHostConfig } from "./sshTransport"; + +const target: RemoteRuntimeTarget = { + id: "target-1", + name: "Remote", + hostname: "remote.example.test", + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, +}; + +const originalAgentSocket = process.env.SSH_AUTH_SOCK; + +afterEach(() => { + if (originalAgentSocket === undefined) { + delete process.env.SSH_AUTH_SOCK; + } else { + process.env.SSH_AUTH_SOCK = originalAgentSocket; + } +}); + +describe("buildSshConfig", () => { + it("uses the local ssh-agent socket when one is available", () => { + process.env.SSH_AUTH_SOCK = "/tmp/ade-agent.sock"; + + expect(buildSshConfig(target, { sshConfigPath: null })).toMatchObject({ + host: "remote.example.test", + port: 22, + username: "ade", + agent: "/tmp/ade-agent.sock", + }); + }); + + it("resolves OpenSSH HostName and IdentityFile entries", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-ssh-config-")); + const keyPath = path.join(dir, "id_ed25519"); + const configPath = path.join(dir, "config"); + fs.writeFileSync(keyPath, "PRIVATE KEY", "utf8"); + fs.writeFileSync(configPath, [ + "Host studio", + " HostName 192.168.1.42", + ` IdentityFile ${keyPath}`, + "", + "Host *", + " IdentityFile ~/.ssh/fallback", + ].join("\n"), "utf8"); + + const config = buildSshConfig({ ...target, hostname: "studio" }, { + env: {}, + sshConfigPath: configPath, + }); + + expect(config).toMatchObject({ + host: "192.168.1.42", + port: 22, + username: "ade", + privateKey: Buffer.from("PRIVATE KEY"), + }); + }); + + it("falls back to the local username and default SSH port when target and SSH config omit them", () => { + const config = buildSshConfig({ + ...target, + hostname: "studio", + sshUser: null, + port: null, + }, { + env: {}, + sshConfigPath: null, + }); + + expect(config).toMatchObject({ + host: "studio", + port: 22, + username: os.userInfo().username, + }); + }); + + it("builds an admin retry candidate when no SSH user is configured", () => { + expect(buildSshUsernameCandidates({ + ...target, + hostname: "100.75.20.63", + sshUser: null, + port: null, + }, { + sshConfigPath: null, + })).toEqual(Array.from(new Set([os.userInfo().username, "admin"]))); + }); + + it("does not add username retries when SSH config provides a user", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-ssh-config-")); + const configPath = path.join(dir, "config"); + fs.writeFileSync(configPath, [ + "Host studio", + " User remote-user", + ].join("\n"), "utf8"); + + expect(buildSshUsernameCandidates({ + ...target, + hostname: "studio", + sshUser: null, + port: null, + }, { + sshConfigPath: configPath, + })).toEqual(["remote-user"]); + }); + + it("builds retry configs with distinct SSH usernames", () => { + const configs = buildSshConfigCandidates({ + ...target, + hostname: "100.75.20.63", + sshUser: null, + port: null, + }, { + env: {}, + sshConfigPath: null, + }); + + expect(configs.map((config) => config.username)).toEqual(Array.from(new Set([os.userInfo().username, "admin"]))); + expect(configs.every((config) => config.host === "100.75.20.63" && config.port === 22)).toBe(true); + }); + + it("uses the first readable OpenSSH default identity when no explicit key is configured", () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-ssh-home-")); + const sshDir = path.join(homeDir, ".ssh"); + fs.mkdirSync(sshDir, { recursive: true }); + fs.writeFileSync(path.join(sshDir, "id_ed25519"), "DEFAULT PRIVATE KEY", "utf8"); + + const config = buildSshConfig(target, { + env: {}, + homeDir, + sshConfigPath: null, + }); + + expect(config).toMatchObject({ + privateKey: Buffer.from("DEFAULT PRIVATE KEY"), + }); + }); + + it("uses OpenSSH User and Port entries from matching aliases", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-ssh-config-")); + const configPath = path.join(dir, "config"); + fs.writeFileSync(configPath, [ + "Host studio", + " HostName 192.168.1.42", + " User remote-user", + " Port 2200", + ].join("\n"), "utf8"); + + const config = buildSshConfig({ + ...target, + hostname: "studio", + sshUser: null, + port: null, + }, { + env: {}, + sshConfigPath: configPath, + }); + + expect(config).toMatchObject({ + host: "192.168.1.42", + port: 2200, + username: "remote-user", + }); + }); +}); + +describe("parseOpenSshHostConfig", () => { + it("keeps the first matching value and supports wildcard blocks", () => { + expect(parseOpenSshHostConfig([ + "Host *.example.test", + " User remote-user", + " Port 2200", + "Host remote.example.test", + " User ignored", + " HostName 10.0.0.5", + ].join("\n"), "remote.example.test")).toEqual({ + user: "remote-user", + port: 2200, + hostName: "10.0.0.5", + }); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts new file mode 100644 index 000000000..8f585aec4 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts @@ -0,0 +1,308 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Client, type ConnectConfig } from "ssh2"; +import type { RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; +import type { RuntimeRpcTransport } from "./runtimeRpcClient"; + +export type SshExecResult = { + stdout: string; + stderr: string; + code: number | null; +}; + +const MAX_SSH_EXEC_OUTPUT_BYTES = 8 * 1024 * 1024; + +type OpenSshHostConfig = { + hostName?: string; + user?: string; + port?: number; + identityFile?: string; +}; + +type BuildSshConfigOptions = { + env?: NodeJS.ProcessEnv; + sshConfigPath?: string | null; + homeDir?: string; + usernameOverride?: string; +}; + +const DEFAULT_IDENTITY_FILES = [ + "id_ed25519", + "id_ecdsa", + "id_ecdsa_sk", + "id_rsa", +]; + +function stripInlineComment(line: string): string { + const hashIndex = line.indexOf("#"); + return hashIndex >= 0 ? line.slice(0, hashIndex).trim() : line.trim(); +} + +function splitSshConfigLine(line: string): [string, string] | null { + const trimmedLine = stripInlineComment(line); + if (!trimmedLine) return null; + const match = /^([A-Za-z][A-Za-z0-9]+)\s+(.*)$/.exec(trimmedLine); + if (!match) return null; + return [match[1]!.toLowerCase(), match[2]!.trim().replace(/^"|"$/g, "")]; +} + +function patternToRegExp(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + return new RegExp(`^${escaped}$`, "i"); +} + +function hostPatternsMatch(patterns: string, host: string): boolean { + const entries = patterns.split(/\s+/).filter(Boolean); + if (entries.length === 0) return false; + let matched = false; + for (const entry of entries) { + const negated = entry.startsWith("!"); + const pattern = negated ? entry.slice(1) : entry; + if (!pattern) continue; + if (!patternToRegExp(pattern).test(host)) continue; + if (negated) return false; + matched = true; + } + return matched; +} + +function expandSshPath(value: string, args: { host: string; username: string; port: number }): string { + const expanded = value + .replace(/%h/g, args.host) + .replace(/%r/g, args.username) + .replace(/%p/g, String(args.port)); + if (expanded === "~") return os.homedir(); + if (expanded.startsWith("~/")) return path.join(os.homedir(), expanded.slice(2)); + return expanded; +} + +function firstReadableDefaultIdentity(homeDir: string): string | null { + for (const fileName of DEFAULT_IDENTITY_FILES) { + const candidate = path.join(homeDir, ".ssh", fileName); + try { + if (fs.statSync(candidate).isFile()) return candidate; + } catch { + // Try the next OpenSSH default identity path. + } + } + return null; +} + +export function parseOpenSshHostConfig(configText: string, hostAlias: string): OpenSshHostConfig { + const result: OpenSshHostConfig = {}; + let active = false; + for (const line of configText.split(/\r?\n/)) { + const parsed = splitSshConfigLine(line); + if (!parsed) continue; + const [keyword, value] = parsed; + if (keyword === "host") { + active = hostPatternsMatch(value, hostAlias); + continue; + } + if (!active) continue; + if (keyword === "hostname" && !result.hostName) { + result.hostName = value; + } else if (keyword === "user" && !result.user) { + result.user = value; + } else if (keyword === "port" && result.port == null) { + const port = Number.parseInt(value, 10); + if (Number.isFinite(port) && port > 0) result.port = port; + } else if (keyword === "identityfile" && !result.identityFile) { + result.identityFile = value; + } + } + return result; +} + +function readOpenSshHostConfig(target: RemoteRuntimeTarget, options: BuildSshConfigOptions): OpenSshHostConfig { + const configPath = options.sshConfigPath === undefined + ? path.join(os.homedir(), ".ssh", "config") + : options.sshConfigPath; + if (!configPath) return {}; + try { + return parseOpenSshHostConfig(fs.readFileSync(configPath, "utf8"), target.hostname); + } catch { + return {}; + } +} + +export function buildSshConfig(target: RemoteRuntimeTarget, options: BuildSshConfigOptions = {}): ConnectConfig { + const hostConfig = readOpenSshHostConfig(target, options); + const host = hostConfig.hostName ?? target.hostname; + const port = target.port && target.port > 0 ? target.port : hostConfig.port ?? 22; + const username = (options.usernameOverride ?? target.sshUser?.trim()) || hostConfig.user || os.userInfo().username; + const homeDir = options.homeDir ?? os.homedir(); + const config: ConnectConfig = { + host, + port, + username, + readyTimeout: 20_000, + }; + const identityFile = target.sshKeyPath + ?? (hostConfig.identityFile ? expandSshPath(hostConfig.identityFile, { host, username, port }) : null) + ?? firstReadableDefaultIdentity(homeDir); + if (identityFile) { + config.privateKey = fs.readFileSync(identityFile); + } + const env = options.env ?? process.env; + if (env.SSH_AUTH_SOCK) { + config.agent = env.SSH_AUTH_SOCK; + } + return config; +} + +function uniqueUsernames(values: Array<string | null | undefined>): string[] { + const seen = new Set<string>(); + const result: string[] = []; + for (const value of values) { + const username = value?.trim(); + if (!username || seen.has(username)) continue; + seen.add(username); + result.push(username); + } + return result; +} + +export function buildSshUsernameCandidates(target: RemoteRuntimeTarget, options: BuildSshConfigOptions = {}): string[] { + const hostConfig = readOpenSshHostConfig(target, options); + const explicitUser = target.sshUser?.trim() || hostConfig.user; + const localUser = os.userInfo().username; + if (explicitUser) return [explicitUser]; + return uniqueUsernames([localUser, "admin"]); +} + +export function buildSshConfigCandidates(target: RemoteRuntimeTarget, options: BuildSshConfigOptions = {}): ConnectConfig[] { + return buildSshUsernameCandidates(target, options).map((username) => + buildSshConfig(target, { ...options, usernameOverride: username })); +} + +function isSshAuthenticationFailure(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const candidate = error as { level?: unknown; message?: unknown }; + return candidate.level === "client-authentication" || + (typeof candidate.message === "string" && /authentication/i.test(candidate.message)); +} + +function connectSshWithConfig(config: ConnectConfig): Promise<Client> { + return new Promise((resolve, reject) => { + const client = new Client(); + client.once("ready", () => resolve(client)); + client.once("error", reject); + client.connect(config); + }); +} + +export async function connectSsh(target: RemoteRuntimeTarget): Promise<Client> { + const configs = buildSshConfigCandidates(target); + let lastError: unknown = null; + for (const [index, config] of configs.entries()) { + try { + return await connectSshWithConfig(config); + } catch (error) { + lastError = error; + if (index >= configs.length - 1 || !isSshAuthenticationFailure(error)) throw error; + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "SSH connection failed.")); +} + +export function execSsh(client: Client, command: string): Promise<SshExecResult> { + return new Promise((resolve, reject) => { + client.exec(command, (error, stream) => { + if (error) { + reject(error); + return; + } + let stdout = ""; + let stderr = ""; + let stdoutBytes = 0; + let stderrBytes = 0; + let code: number | null = null; + stream.on("data", (chunk: Buffer) => { + stdoutBytes += chunk.byteLength; + if (stdoutBytes > MAX_SSH_EXEC_OUTPUT_BYTES) { + reject(new Error(`SSH command stdout exceeded ${MAX_SSH_EXEC_OUTPUT_BYTES} bytes.`)); + stream.close(); + return; + } + stdout += chunk.toString("utf8"); + }); + stream.stderr.on("data", (chunk: Buffer) => { + stderrBytes += chunk.byteLength; + if (stderrBytes > MAX_SSH_EXEC_OUTPUT_BYTES) { + reject(new Error(`SSH command stderr exceeded ${MAX_SSH_EXEC_OUTPUT_BYTES} bytes.`)); + stream.close(); + return; + } + stderr += chunk.toString("utf8"); + }); + stream.on("exit", (exitCode: number | null) => { + code = exitCode; + }); + stream.on("close", () => resolve({ stdout, stderr, code })); + stream.on("error", reject); + }); + }); +} + +export function openSshRuntimeTransport(client: Client, command = "~/.ade/bin/ade rpc --stdio"): Promise<RuntimeRpcTransport> { + return new Promise((resolve, reject) => { + client.exec(command, (error, stream) => { + if (error) { + reject(error); + return; + } + let closed = false; + let streamError: Error | null = null; + const closeCallbacks = new Set<() => void>(); + const errorCallbacks = new Set<(error: Error) => void>(); + + stream.once("error", (streamErrorValue: Error) => { + streamError = streamErrorValue; + for (const callback of errorCallbacks) { + callback(streamErrorValue); + } + errorCallbacks.clear(); + }); + stream.once("close", () => { + closed = true; + for (const callback of closeCallbacks) { + callback(); + } + closeCallbacks.clear(); + errorCallbacks.clear(); + }); + + resolve({ + onData(callback) { + stream.on("data", (chunk: Buffer) => callback(Buffer.from(chunk))); + }, + onError(callback) { + const currentError = streamError; + if (currentError) { + queueMicrotask(() => callback(currentError)); + return; + } + errorCallbacks.add(callback); + }, + onClose(callback) { + if (closed) { + queueMicrotask(callback); + return; + } + closeCallbacks.add(callback); + }, + write(data) { + stream.write(data); + }, + close() { + stream.end(); + }, + }); + }); + }); +} diff --git a/apps/desktop/src/main/services/runtime/machineStateMigration.test.ts b/apps/desktop/src/main/services/runtime/machineStateMigration.test.ts new file mode 100644 index 000000000..c6d724e9e --- /dev/null +++ b/apps/desktop/src/main/services/runtime/machineStateMigration.test.ts @@ -0,0 +1,206 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import type { MachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; +import { + MACHINE_STATE_MIGRATION_MARKER, + markMachineStateMigrationComplete, + readMachineRegistryRecentProjects, + runMachineStateMigration, +} from "./machineStateMigration"; + +function makeLayout(root: string): MachineAdeLayout { + return { + adeDir: root, + projectsPath: path.join(root, "projects.json"), + secretsDir: path.join(root, "secrets"), + sockDir: path.join(root, "sock"), + socketPath: path.join(root, "sock", "ade.sock"), + binDir: path.join(root, "bin"), + runtimeDir: path.join(root, "runtime"), + }; +} + +function makeProject(root: string, name: string): string { + const projectRoot = path.join(root, name); + fs.mkdirSync(path.join(projectRoot, ".ade", "secrets"), { recursive: true }); + return fs.realpathSync.native(projectRoot); +} + +describe("machine state migration", () => { + it("skips work when the migration marker already exists", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-migration-")); + const layout = makeLayout(path.join(root, ".ade-home")); + fs.mkdirSync(layout.adeDir, { recursive: true }); + fs.writeFileSync(path.join(layout.adeDir, MACHINE_STATE_MIGRATION_MARKER), "done\n", "utf8"); + const add = vi.fn(); + + const result = runMachineStateMigration({ + layout, + recentProjects: [{ rootPath: "/missing", displayName: "Missing", lastOpenedAt: "2026-05-10T00:00:00.000Z" }], + projectRegistry: { add }, + }); + + expect(result).toMatchObject({ didRun: false, shouldShowNotice: false }); + expect(add).not.toHaveBeenCalled(); + }); + + it("merges legacy sync secrets and registers valid recent projects", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-migration-")); + const layout = makeLayout(path.join(root, ".ade-home")); + fs.mkdirSync(layout.secretsDir, { recursive: true }); + fs.writeFileSync( + path.join(layout.secretsDir, "sync-paired-devices.json"), + `${JSON.stringify({ existing: { name: "Machine" } })}\n`, + "utf8", + ); + const projectA = makeProject(root, "project-a"); + const projectB = makeProject(root, "project-b"); + const missingAdeProject = path.join(root, "missing-ade"); + fs.mkdirSync(missingAdeProject, { recursive: true }); + fs.writeFileSync(path.join(projectA, ".ade", "secrets", "sync-bootstrap-token"), "token-a", "utf8"); + fs.writeFileSync(path.join(projectB, ".ade", "secrets", "sync-bootstrap-token"), "token-b", "utf8"); + fs.writeFileSync(path.join(projectB, ".ade", "secrets", "sync-pin.json"), "{\"pin\":\"123456\"}", "utf8"); + fs.writeFileSync( + path.join(projectA, ".ade", "secrets", "sync-paired-devices.json"), + `${JSON.stringify({ existing: { name: "Legacy" }, phoneA: { name: "Phone A" } })}\n`, + "utf8", + ); + fs.writeFileSync( + path.join(projectB, ".ade", "secrets", "sync-paired-devices.json"), + `${JSON.stringify({ phoneB: { name: "Phone B" } })}\n`, + "utf8", + ); + const add = vi.fn(); + + const result = runMachineStateMigration({ + layout, + recentProjects: [ + { rootPath: projectA, displayName: "A", lastOpenedAt: "2026-05-10T00:00:00.000Z" }, + { rootPath: projectB, displayName: "B", lastOpenedAt: "2026-05-10T00:00:01.000Z" }, + { rootPath: missingAdeProject, displayName: "Missing", lastOpenedAt: "2026-05-10T00:00:02.000Z" }, + ], + projectRegistry: { add }, + }); + + expect(result).toMatchObject({ didRun: true, shouldShowNotice: true }); + expect(fs.readFileSync(path.join(layout.secretsDir, "sync-bootstrap-token"), "utf8")).toBe("token-a"); + expect(fs.readFileSync(path.join(layout.secretsDir, "sync-pin.json"), "utf8")).toBe("{\"pin\":\"123456\"}"); + expect(JSON.parse(fs.readFileSync(path.join(layout.secretsDir, "sync-paired-devices.json"), "utf8"))).toEqual({ + existing: { name: "Machine" }, + phoneA: { name: "Phone A" }, + phoneB: { name: "Phone B" }, + }); + expect(add).toHaveBeenCalledWith(projectA); + expect(add).toHaveBeenCalledWith(projectB); + expect(add).not.toHaveBeenCalledWith(missingAdeProject); + expect(fs.existsSync(path.join(layout.adeDir, MACHINE_STATE_MIGRATION_MARKER))).toBe(false); + }); + + it("seeds channel registries from stable machine projects", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-migration-")); + const stableLayout = makeLayout(path.join(root, ".ade")); + const alphaLayout = makeLayout(path.join(root, ".ade-alpha")); + const projectA = makeProject(root, "project-a"); + const projectB = makeProject(root, "project-b"); + fs.mkdirSync(stableLayout.adeDir, { recursive: true }); + fs.writeFileSync( + stableLayout.projectsPath, + `${JSON.stringify({ + version: 1, + projects: [ + { + projectId: "project_a", + rootPath: projectA, + displayName: "Project A", + addedAt: Date.parse("2026-05-10T00:00:00.000Z"), + lastOpenedAt: Date.parse("2026-05-10T00:00:00.000Z"), + }, + { + projectId: "project_b", + rootPath: projectB, + displayName: "Project B", + addedAt: Date.parse("2026-05-10T00:00:01.000Z"), + lastOpenedAt: Date.parse("2026-05-10T00:00:01.000Z"), + }, + ], + })}\n`, + "utf8", + ); + const add = vi.fn(); + + const result = runMachineStateMigration({ + layout: alphaLayout, + recentProjects: [], + projectRegistry: { add }, + }); + + expect(result).toMatchObject({ didRun: true, shouldShowNotice: true }); + expect(add).toHaveBeenCalledWith(projectA); + expect(add).toHaveBeenCalledWith(projectB); + }); + + it("exposes machine registry projects as startup recents", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-migration-")); + const layout = makeLayout(path.join(root, ".ade")); + const projectRoot = makeProject(root, "project-a"); + fs.mkdirSync(layout.adeDir, { recursive: true }); + fs.writeFileSync( + layout.projectsPath, + `${JSON.stringify({ + version: 1, + projects: [ + { + projectId: "project_a", + rootPath: projectRoot, + displayName: "Project A", + addedAt: Date.parse("2026-05-10T00:00:00.000Z"), + lastOpenedAt: Date.parse("2026-05-10T00:00:00.000Z"), + }, + ], + })}\n`, + "utf8", + ); + + expect(readMachineRegistryRecentProjects(layout)).toEqual([ + { + rootPath: projectRoot, + displayName: "Project A", + lastOpenedAt: "2026-05-10T00:00:00.000Z", + }, + ]); + }); + + it("marks migration complete only when explicitly requested", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-migration-")); + const layout = makeLayout(path.join(root, ".ade-home")); + + markMachineStateMigrationComplete({ + layout, + completedAt: new Date("2026-05-10T12:00:00.000Z"), + }); + + expect(fs.readFileSync(path.join(layout.adeDir, MACHINE_STATE_MIGRATION_MARKER), "utf8")).toBe( + "2026-05-10T12:00:00.000Z\n", + ); + }); + + it("treats malformed legacy pairing files as empty", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-migration-")); + const layout = makeLayout(path.join(root, ".ade-home")); + const projectRoot = makeProject(root, "project-a"); + fs.writeFileSync(path.join(projectRoot, ".ade", "secrets", "sync-paired-devices.json"), "{not-json", "utf8"); + + expect(() => + runMachineStateMigration({ + layout, + recentProjects: [{ rootPath: projectRoot, displayName: "A", lastOpenedAt: "2026-05-10T00:00:00.000Z" }], + projectRegistry: { add: vi.fn() }, + }) + ).not.toThrow(); + expect(JSON.parse(fs.readFileSync(path.join(layout.secretsDir, "sync-paired-devices.json"), "utf8"))).toEqual({}); + }); +}); diff --git a/apps/desktop/src/main/services/runtime/machineStateMigration.ts b/apps/desktop/src/main/services/runtime/machineStateMigration.ts new file mode 100644 index 000000000..bad070e01 --- /dev/null +++ b/apps/desktop/src/main/services/runtime/machineStateMigration.ts @@ -0,0 +1,188 @@ +import fs from "node:fs"; +import path from "node:path"; + +import type { MachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; +import { ProjectRegistry, type ProjectRegistry as ProjectRegistryType } from "../../../../../ade-cli/src/services/projects/projectRegistry"; +import type { RecentProject } from "../state/globalState"; + +export const MACHINE_STATE_MIGRATION_MARKER = ".migrated-v2"; + +export type MachineStateMigrationResult = { + didRun: boolean; + shouldShowNotice: boolean; + markerPath: string; +}; + +type MachineStateMigrationArgs = { + layout: MachineAdeLayout; + recentProjects: RecentProject[]; + projectRegistry?: Pick<ProjectRegistryType, "add">; +}; + +function buildMachineLayout(adeDir: string): MachineAdeLayout { + const secretsDir = path.join(adeDir, "secrets"); + const sockDir = path.join(adeDir, "sock"); + return { + adeDir, + projectsPath: path.join(adeDir, "projects.json"), + secretsDir, + sockDir, + socketPath: path.join(sockDir, "ade.sock"), + binDir: path.join(adeDir, "bin"), + runtimeDir: path.join(adeDir, "runtime"), + }; +} + +function stableLayoutForChannelHome(layout: MachineAdeLayout): MachineAdeLayout | null { + const homeName = path.basename(layout.adeDir); + if (homeName !== ".ade-alpha" && homeName !== ".ade-beta") return null; + const stableAdeDir = path.join(path.dirname(layout.adeDir), ".ade"); + if (path.resolve(stableAdeDir) === path.resolve(layout.adeDir)) return null; + return buildMachineLayout(stableAdeDir); +} + +function readObjectFile(filePath: string): Record<string, unknown> { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record<string, unknown> + : {}; + } catch { + return {}; + } +} + +function markerPath(layout: MachineAdeLayout): string { + return path.join(layout.adeDir, MACHINE_STATE_MIGRATION_MARKER); +} + +function copyFirstLegacySecret(args: { + layout: MachineAdeLayout; + recentProjects: RecentProject[]; + fileName: string; +}): void { + const target = path.join(args.layout.secretsDir, args.fileName); + if (fs.existsSync(target)) return; + for (const project of args.recentProjects) { + const source = path.join(project.rootPath, ".ade", "secrets", args.fileName); + if (!fs.existsSync(source)) continue; + try { + fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL); + fs.chmodSync(target, 0o600); + } catch { + // Best effort migration; the project-local copy remains for rollback. + } + return; + } +} + +function mergePairedDevices(args: { + layout: MachineAdeLayout; + recentProjects: RecentProject[]; +}): void { + const pairedDevicesPath = path.join(args.layout.secretsDir, "sync-paired-devices.json"); + const pairedDevices = fs.existsSync(pairedDevicesPath) + ? readObjectFile(pairedDevicesPath) + : {}; + let pairedDevicesChanged = false; + for (const project of args.recentProjects) { + const source = path.join(project.rootPath, ".ade", "secrets", "sync-paired-devices.json"); + if (!fs.existsSync(source)) continue; + const legacy = readObjectFile(source); + for (const [deviceId, record] of Object.entries(legacy)) { + if (!deviceId.trim() || Object.prototype.hasOwnProperty.call(pairedDevices, deviceId)) continue; + pairedDevices[deviceId] = record; + pairedDevicesChanged = true; + } + } + if (pairedDevicesChanged || !fs.existsSync(pairedDevicesPath)) { + fs.writeFileSync(pairedDevicesPath, `${JSON.stringify(pairedDevices, null, 2)}\n`, { mode: 0o600 }); + } +} + +function stableRegistryProjectsForChannelHome(layout: MachineAdeLayout): RecentProject[] { + const stableLayout = stableLayoutForChannelHome(layout); + if (!stableLayout || !fs.existsSync(stableLayout.projectsPath)) return []; + try { + return new ProjectRegistry(stableLayout).list().map((project) => ({ + rootPath: project.rootPath, + displayName: project.displayName, + lastOpenedAt: new Date(project.lastOpenedAt).toISOString(), + })); + } catch { + return []; + } +} + +export function readMachineRegistryRecentProjects(layout: MachineAdeLayout): RecentProject[] { + const projects = [ + ...registryProjectsAsRecent(layout), + ...stableRegistryProjectsForChannelHome(layout), + ]; + return uniqueProjects(projects); +} + +function registryProjectsAsRecent(layout: MachineAdeLayout): RecentProject[] { + if (!fs.existsSync(layout.projectsPath)) return []; + try { + return new ProjectRegistry(layout).list().map((project) => ({ + rootPath: project.rootPath, + displayName: project.displayName, + lastOpenedAt: new Date(project.lastOpenedAt).toISOString(), + })); + } catch { + return []; + } +} + +function uniqueProjects(projects: RecentProject[]): RecentProject[] { + const seen = new Set<string>(); + const unique: RecentProject[] = []; + for (const project of projects) { + const rootPath = path.resolve(project.rootPath); + if (seen.has(rootPath)) continue; + seen.add(rootPath); + unique.push({ ...project, rootPath }); + } + return unique; +} + +export function runMachineStateMigration(args: MachineStateMigrationArgs): MachineStateMigrationResult { + const marker = markerPath(args.layout); + if (fs.existsSync(marker)) { + return { didRun: false, shouldShowNotice: false, markerPath: marker }; + } + + const migrationProjects = uniqueProjects([ + ...args.recentProjects, + ...readMachineRegistryRecentProjects(args.layout), + ]); + const hadExistingUserState = + migrationProjects.length > 0 || fs.existsSync(args.layout.secretsDir); + fs.mkdirSync(args.layout.secretsDir, { recursive: true, mode: 0o700 }); + + copyFirstLegacySecret({ layout: args.layout, recentProjects: migrationProjects, fileName: "sync-bootstrap-token" }); + copyFirstLegacySecret({ layout: args.layout, recentProjects: migrationProjects, fileName: "sync-pin.json" }); + mergePairedDevices({ layout: args.layout, recentProjects: migrationProjects }); + + const projectRegistry = args.projectRegistry ?? new ProjectRegistry(args.layout); + for (const project of migrationProjects) { + if (!fs.existsSync(path.join(project.rootPath, ".ade"))) continue; + try { + projectRegistry.add(project.rootPath); + } catch { + // Ignore projects that disappeared or became unreadable during startup. + } + } + + return { didRun: true, shouldShowNotice: hadExistingUserState, markerPath: marker }; +} + +export function markMachineStateMigrationComplete(args: { + layout: MachineAdeLayout; + completedAt?: Date; +}): void { + const marker = markerPath(args.layout); + fs.mkdirSync(args.layout.adeDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(marker, `${(args.completedAt ?? new Date()).toISOString()}\n`, { mode: 0o600 }); +} diff --git a/apps/desktop/src/main/services/state/kvDb.test.ts b/apps/desktop/src/main/services/state/kvDb.test.ts index 9d06a0402..444b0f463 100644 --- a/apps/desktop/src/main/services/state/kvDb.test.ts +++ b/apps/desktop/src/main/services/state/kvDb.test.ts @@ -1,10 +1,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { createRequire } from "node:module"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { openKvDb } from "./kvDb"; import { isCrsqliteAvailable } from "./crsqliteExtension"; +const require = createRequire(path.join(process.cwd(), "ade-runtime.cjs")); + function createLogger() { return { debug: () => {}, @@ -195,6 +198,29 @@ afterEach(async () => { } }); +describe("openKvDb SQL binding", () => { + it("binds boolean params and reports unsupported param types with context", async () => { + const projectRoot = makeProjectRoot("ade-kvdb-bind-values-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + activeDisposers.push(async () => db.close()); + + db.run("create table if not exists db_value_test(flag integer not null)"); + db.run("insert into db_value_test(flag) values (?)", [true]); + expect(db.get<{ flag: number }>("select flag from db_value_test limit 1")?.flag).toBe(1); + + expect(() => + db.run("insert into db_value_test(flag) values (?)", [{} as any]), + ).toThrow(/Unsupported database value at parameter 1: object .*sql=insert into db_value_test/i); + expect(() => + db.get("select flag from db_value_test where flag = ?", [{} as any]), + ).toThrow(/Unsupported database value at parameter 1: object .*sql=select flag from db_value_test/i); + expect(() => + db.all("select flag from db_value_test where flag = ?", [{} as any]), + ).toThrow(/Unsupported database value at parameter 1: object .*sql=select flag from db_value_test/i); + }); +}); + describe.skipIf(!isCrsqliteAvailable())("openKvDb CRR repair", () => { it("backfills phone-critical tables whose rows predate CRR enablement", async () => { const projectRoot = makeProjectRoot("ade-kvdb-pre-crr-"); @@ -248,3 +274,47 @@ describe.skipIf(!isCrsqliteAvailable())("openKvDb CRR repair", () => { ).toBe(1); }); }); + +describe.skipIf(!isCrsqliteAvailable())("openKvDb with unavailable crsqlite runtime", () => { + it("drops stale CRR triggers before migration writes touch CRR tables", async () => { + const projectRoot = makeProjectRoot("ade-kvdb-crr-unavailable-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const first = await openKvDb(dbPath, createLogger() as any); + first.close(); + + const { DatabaseSync } = require("node:sqlite") as typeof import("node:sqlite"); + const raw = new DatabaseSync(dbPath); + expect( + raw + .prepare( + "select 1 as present from sqlite_master where type = 'trigger' and name = 'unified_memories__crsql_utrig' limit 1", + ) + .get(), + ).toBeTruthy(); + raw.close(); + + vi.resetModules(); + vi.doMock("./crsqliteExtension", () => ({ + resolveCrsqliteExtensionPath: () => null, + isCrsqliteAvailable: () => false, + })); + const { openKvDb: openWithoutCrsqlite } = await import("./kvDb"); + const reopened = await openWithoutCrsqlite(dbPath, createLogger() as any); + activeDisposers.push(async () => reopened.close()); + + expect(reopened.sync.isAvailable?.()).toBe(false); + expect( + reopened.get<{ present: number }>( + "select 1 as present from sqlite_master where type = 'trigger' and name = 'unified_memories__crsql_utrig' limit 1", + ), + ).toBeNull(); + expect( + reopened.get<{ present: number }>( + "select 1 as present from sqlite_master where type = 'table' and name = 'unified_memories__crsql_clock' limit 1", + )?.present, + ).toBe(1); + + vi.doUnmock("./crsqliteExtension"); + vi.resetModules(); + }); +}); diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 4bda9125a..8dfb059e0 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -15,7 +15,7 @@ type DatabaseSyncConstructor = new (dbPath: string, options?: { allowExtension?: const require = createRequire(path.join(process.cwd(), "ade-runtime.cjs")); const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: DatabaseSyncConstructor }; -export type SqlValue = string | number | null | Uint8Array; +export type SqlValue = string | number | boolean | null | Uint8Array; export type AdeDbSyncApi = { isAvailable?: () => boolean; @@ -80,22 +80,41 @@ function openRawDatabase(dbPath: string): DatabaseSyncType { return db; } -function toDbValue(value: SqlValue | SyncScalar): string | number | null | Uint8Array { +function describeUnsupportedDbValue(value: unknown): string { + const kind = value === undefined + ? "undefined" + : value === null + ? "null" + : Array.isArray(value) + ? "array" + : typeof value; + const ctor = + value && typeof value === "object" && !Array.isArray(value) + ? (value as { constructor?: { name?: string } }).constructor?.name + : null; + return ctor && ctor !== "Object" ? `${kind} (${ctor})` : kind; +} + +function toDbValue(value: SqlValue | SyncScalar, index?: number): string | number | null | Uint8Array { if (value == null || typeof value === "string" || typeof value === "number") { return value; } + if (typeof value === "boolean") { + return value ? 1 : 0; + } if (value instanceof Uint8Array) { return value; } if (typeof value === "object" && "type" in value && value.type === "bytes") { return Buffer.from(value.base64, "base64"); } - throw new Error("Unsupported database value"); + const suffix = typeof index === "number" ? ` at parameter ${index + 1}` : ""; + throw new Error(`Unsupported database value${suffix}: ${describeUnsupportedDbValue(value)}`); } function runStatement(db: DatabaseSyncType, sql: string, params: Array<SqlValue | SyncScalar> = []): { changes: number } { try { - return db.prepare(sql).run(...params.map((param) => toDbValue(param))) as { changes: number }; + return db.prepare(sql).run(...params.map((param, index) => toDbValue(param, index))) as { changes: number }; } catch (error) { const statement = sql.replace(/\s+/g, " ").trim(); const message = error instanceof Error ? error.message : String(error); @@ -104,11 +123,23 @@ function runStatement(db: DatabaseSyncType, sql: string, params: Array<SqlValue } function getRow<T>(db: DatabaseSyncType, sql: string, params: Array<SqlValue | SyncScalar> = []): T | null { - return (db.prepare(sql).get(...params.map((param) => toDbValue(param))) as T | undefined) ?? null; + try { + return (db.prepare(sql).get(...params.map((param, index) => toDbValue(param, index))) as T | undefined) ?? null; + } catch (error) { + const statement = sql.replace(/\s+/g, " ").trim(); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${message} [sql=${statement}]`); + } } function allRows<T>(db: DatabaseSyncType, sql: string, params: Array<SqlValue | SyncScalar> = []): T[] { - return db.prepare(sql).all(...params.map((param) => toDbValue(param))) as T[]; + try { + return db.prepare(sql).all(...params.map((param, index) => toDbValue(param, index))) as T[]; + } catch (error) { + const statement = sql.replace(/\s+/g, " ").trim(); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${message} [sql=${statement}]`); + } } function rawHasTable(db: DatabaseSyncType, tableName: string): boolean { @@ -416,6 +447,16 @@ function hasCrsqlMetadata(db: DatabaseSyncType): boolean { ); } +function isCrsqliteRuntimeUsable(db: DatabaseSyncType): boolean { + try { + getRow(db, "select crsql_db_version() as db_version"); + getRow(db, "select crsql_internal_sync_bit() as sync_bit"); + return true; + } catch { + return false; + } +} + const PHONE_CRITICAL_CRR_TABLES = [ "lanes", "lane_state_snapshots", @@ -444,6 +485,49 @@ function tableNeedsCrrRepair(db: DatabaseSyncType, tableName: string): { baseRow return pkRowCount === baseRowCount ? null : { baseRowCount, pkRowCount }; } +function listCrrTriggers(db: DatabaseSyncType, tableName: string): string[] { + return allRows<{ name: string }>( + db, + `select name + from sqlite_master + where type = 'trigger' + and tbl_name = ? + and name like ?`, + [tableName, `${tableName}__crsql_%trig`], + ).map((row) => row.name); +} + +function tableNeedsCrrTriggerRepair(db: DatabaseSyncType, tableName: string): boolean { + if (!rawHasTable(db, `${tableName}__crsql_clock`)) { + return false; + } + return listCrrTriggers(db, tableName).length < 3; +} + +function disableCrrTriggersForUnavailableRuntime(db: DatabaseSyncType, logger?: Logger): void { + const triggers = allRows<{ name: string; tbl_name: string }>( + db, + `select name, tbl_name + from sqlite_master + where type = 'trigger' + and name like '%__crsql_%trig'`, + ); + for (const trigger of triggers) { + try { + runStatement(db, `drop trigger if exists ${quoteIdentifier(trigger.name)}`); + } catch (error) { + logger?.warn("db.crsqlite_trigger_disable_failed", { + tableName: trigger.tbl_name, + triggerName: trigger.name, + error: error instanceof Error ? error.message : String(error), + }); + } + } + if (triggers.length > 0) { + logger?.warn("db.crsqlite_triggers_disabled", { triggerCount: triggers.length }); + } +} + function rebuildCrrTableWithBackfill(db: DatabaseSyncType, tableName: string): void { const tableRow = getRow<{ sql: string | null }>( db, @@ -507,6 +591,9 @@ function ensureCrrTables(db: DatabaseSyncType, logger?: Logger): void { const repairTargets = new Set<string>(PHONE_CRITICAL_CRR_TABLES); for (const tableName of listEligibleCrrTables(db)) { if (rawHasTable(db, `${tableName}__crsql_clock`)) { + if (tableNeedsCrrTriggerRepair(db, tableName)) { + getRow(db, "select crsql_as_crr(?) as ok", [tableName]); + } if (!repairTargets.has(tableName)) { continue; } @@ -3489,7 +3576,11 @@ function migrate(db: MigrationDb) { and auto_agent_confidence_threshold is null and (ptm_defaults_backfilled_version is null or ptm_defaults_backfilled_version <> 'ptm-defaults-v1') `); - } catch {} + } catch (err) { + // Backfill failure leaves existing rows on the legacy defaults while new + // rows pick up the new defaults — surface this so the split is visible. + console.warn("kvDb.migrate.ptm_defaults_backfill_failed", err); + } db.run(` create table if not exists pr_convergence_state ( @@ -3575,10 +3666,24 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise<AdeDb> { const existedBeforeOpen = fs.existsSync(dbPath); let db = openRawDatabase(dbPath); let crsqliteLoaded = false; - const loadCrsqliteIfAvailable = (): void => { - if (!extensionPath || crsqliteLoaded) return; - loadCrsqlite(db, extensionPath); - crsqliteLoaded = true; + const loadCrsqliteIfAvailable = (): boolean => { + if (crsqliteLoaded) return true; + if (!extensionPath) return false; + try { + loadCrsqlite(db, extensionPath); + crsqliteLoaded = isCrsqliteRuntimeUsable(db); + if (!crsqliteLoaded) { + logger.warn("db.crsqlite_unavailable", { dbPath, reason: "extension loaded but required functions are unavailable" }); + } + } catch (error) { + crsqliteLoaded = false; + logger.warn("db.crsqlite_unavailable", { + dbPath, + reason: "extension failed to load", + error: error instanceof Error ? error.message : String(error), + }); + } + return crsqliteLoaded; }; repairMalformedUnifiedMemoryFtsSchema(db); @@ -3590,6 +3695,9 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise<AdeDb> { // updates can touch those tables in source-mode CLI and desktop startup. loadCrsqliteIfAvailable(); const hadCrsqlMetadata = hasCrsqlMetadata(db); + if (hadCrsqlMetadata && !crsqliteLoaded) { + disableCrrTriggersForUnavailableRuntime(db, logger); + } // Build a CRR-aware run wrapper: when crsqlite is loaded and a table has // been converted to a CRR, ALTER TABLE statements must be wrapped with @@ -3637,6 +3745,9 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise<AdeDb> { db = openRawDatabase(dbPath); crsqliteLoaded = false; loadCrsqliteIfAvailable(); + if (hasCrsqlMetadata(db) && !crsqliteLoaded) { + disableCrrTriggersForUnavailableRuntime(db, logger); + } const remigrateDb = makeMigrateDb(); repairUnifiedMemoryFtsSchemaForRuntime(remigrateDb); migrate(remigrateDb); @@ -3644,7 +3755,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise<AdeDb> { let retrofittedForeignKeySchema = false; try { - retrofittedForeignKeySchema = retrofitForeignKeyCascadeActions(db, hasCrsqlite); + retrofittedForeignKeySchema = retrofitForeignKeyCascadeActions(db, crsqliteLoaded); } catch (error) { if (!isReadonlyDatabaseError(error)) throw error; } @@ -3653,12 +3764,15 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise<AdeDb> { db = openRawDatabase(dbPath); crsqliteLoaded = false; loadCrsqliteIfAvailable(); + if (hasCrsqlMetadata(db) && !crsqliteLoaded) { + disableCrrTriggersForUnavailableRuntime(db, logger); + } const remigrateDb = makeMigrateDb(); repairUnifiedMemoryFtsSchemaForRuntime(remigrateDb); migrate(remigrateDb); } - if (hasCrsqlite) { + if (crsqliteLoaded) { loadCrsqliteIfAvailable(); ensureCrrTables(db, logger); forceSiteId(db, desiredSiteId); @@ -3668,10 +3782,18 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise<AdeDb> { db = openRawDatabase(dbPath); crsqliteLoaded = false; loadCrsqliteIfAvailable(); - forceSiteId(db, desiredSiteId); + if (hasCrsqlMetadata(db) && !crsqliteLoaded) { + disableCrrTriggersForUnavailableRuntime(db, logger); + } + if (crsqliteLoaded) { + forceSiteId(db, desiredSiteId); + } } } else { - logger.warn("db.crsqlite_unavailable", { dbPath, reason: "extension not found for this platform" }); + logger.warn("db.crsqlite_unavailable", { + dbPath, + reason: hasCrsqlite ? "extension not usable for this runtime" : "extension not found for this platform", + }); } } catch (err) { try { @@ -3694,7 +3816,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise<AdeDb> { const run = (sql: string, params: SqlValue[] = []) => { const alterTable = parseAlterTableTarget(sql); - if (hasCrsqlite && alterTable && rawHasTable(db, `${alterTable}__crsql_clock`)) { + if (crsqliteLoaded && alterTable && rawHasTable(db, `${alterTable}__crsql_clock`)) { getRow(db, "select crsql_begin_alter(?) as ok", [alterTable]); try { runStatement(db, sql, params); @@ -3716,15 +3838,15 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise<AdeDb> { }; const sync: AdeDbSyncApi = { - isAvailable: () => hasCrsqlite, + isAvailable: () => crsqliteLoaded, getSiteId: () => desiredSiteId, getDbVersion: () => { - if (!hasCrsqlite) return 0; + if (!crsqliteLoaded) return 0; const row = get<{ db_version: number }>("select crsql_db_version() as db_version"); return Number(row?.db_version ?? 0); }, exportChangesSince: (version: number) => { - if (!hasCrsqlite) return []; + if (!crsqliteLoaded) return []; const rows = allRows<{ table_name: string; pk: unknown; @@ -3765,7 +3887,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise<AdeDb> { })); }, applyChanges: (changes: CrsqlChangeRow[]) => { - if (!hasCrsqlite) return { appliedCount: 0, dbVersion: 0, touchedTables: [], rebuiltFts: false }; + if (!crsqliteLoaded) return { appliedCount: 0, dbVersion: 0, touchedTables: [], rebuiltFts: false }; let appliedCount = 0; const touchedTables = new Set<string>(); runStatement(db, "begin"); diff --git a/apps/desktop/src/main/services/sync/deviceRegistryService.ts b/apps/desktop/src/main/services/sync/deviceRegistryService.ts index 5889d2ffd..418815ca5 100644 --- a/apps/desktop/src/main/services/sync/deviceRegistryService.ts +++ b/apps/desktop/src/main/services/sync/deviceRegistryService.ts @@ -1,673 +1 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { randomUUID } from "node:crypto"; -import { execFileSync } from "node:child_process"; -import { resolveAdeLayout } from "../../../shared/adeLayout"; -import type { - SyncBrainStatusPayload, - SyncClusterState, - SyncDeviceRecord, - SyncPeerConnectionState, - SyncPeerDeviceType, - SyncPeerMetadata, - SyncPeerPlatform, -} from "../../../shared/types"; -import { normalizeNotificationPreferences, type NotificationPreferences } from "../../../shared/types/sync"; -import type { Logger } from "../logging/logger"; -import { mapPlatform } from "./syncProtocol"; -import { resolveTailscaleCliPath } from "./resolveTailscaleCliPath"; -import type { AdeDb } from "../state/kvDb"; -import { nowIso, safeJsonParse, toOptionalString, uniqueStrings } from "../shared/utils"; - -type DeviceRegistryServiceArgs = { - db: AdeDb; - logger: Logger; - projectRoot: string; - localDeviceIdPath?: string; -}; - -type DeviceRow = { - device_id: string; - site_id: string; - name: string; - platform: string; - device_type: string; - created_at: string; - updated_at: string; - last_seen_at: string | null; - last_host: string | null; - last_port: number | null; - tailscale_ip: string | null; - ip_addresses_json: string | null; - metadata_json: string | null; -}; - -type ClusterStateRow = { - cluster_id: string; - brain_device_id: string; - brain_epoch: number; - updated_at: string; - updated_by_device_id: string; -}; - -const DEVICE_ID_FILE = "sync-device-id"; -export const DEFAULT_SYNC_CLUSTER_ID = "default"; -const WORKSPACE_ACTIVITY_ID = "workspace"; -const TAILSCALE_STATUS_CACHE_MS = 30_000; - -let tailscaleStatusCache: - | { - expiresAt: number; - dnsName: string | null; - } - | null = null; - -function normalizeDeviceType(value: unknown): SyncPeerDeviceType { - const raw = typeof value === "string" ? value.trim() : ""; - if (raw === "desktop" || raw === "phone" || raw === "vps") return raw; - return "unknown"; -} - -function normalizePlatform(value: unknown): SyncPeerPlatform { - const raw = typeof value === "string" ? value.trim() : ""; - if (raw === "macOS" || raw === "linux" || raw === "windows" || raw === "iOS") return raw; - return "unknown"; -} - -function readJsonArray(raw: string | null | undefined): string[] { - return safeJsonParse<string[]>(raw, []).filter((value) => typeof value === "string" && value.trim().length > 0); -} - -function mapDeviceRow(row: DeviceRow | null): SyncDeviceRecord | null { - if (!row) return null; - return { - deviceId: String(row.device_id), - siteId: String(row.site_id), - name: String(row.name), - platform: normalizePlatform(row.platform), - deviceType: normalizeDeviceType(row.device_type), - createdAt: String(row.created_at), - updatedAt: String(row.updated_at), - lastSeenAt: row.last_seen_at ? String(row.last_seen_at) : null, - lastHost: row.last_host ? String(row.last_host) : null, - lastPort: row.last_port == null ? null : Number(row.last_port), - tailscaleIp: row.tailscale_ip ? String(row.tailscale_ip) : null, - ipAddresses: readJsonArray(row.ip_addresses_json), - metadata: safeJsonParse<Record<string, unknown>>(row.metadata_json, {}), - }; -} - -function mapClusterStateRow(row: ClusterStateRow | null): SyncClusterState | null { - if (!row) return null; - return { - clusterId: String(row.cluster_id), - brainDeviceId: String(row.brain_device_id), - brainEpoch: Number(row.brain_epoch ?? 0), - updatedAt: String(row.updated_at), - updatedByDeviceId: String(row.updated_by_device_id), - }; -} - -type LocalNetworkMetadata = { - lanIpAddresses: string[]; - tailscaleIp: string | null; - tailscaleDnsName: string | null; -}; - -function isTailscaleAddress(ipAddress: string): boolean { - const parts = ipAddress.split("."); - if (parts.length !== 4) return false; - const octets = parts.map((part) => Number(part)); - if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false; - return octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127; -} - -function readLocalNetworkMetadata(): LocalNetworkMetadata { - const interfaces = os.networkInterfaces(); - const lan: string[] = []; - const tailscale: string[] = []; - for (const [interfaceName, entries] of Object.entries(interfaces)) { - const isLikelyTailscaleInterface = /tailscale|utun|tun/i.test(interfaceName); - for (const entry of entries ?? []) { - if (!entry || entry.internal || entry.family !== "IPv4") continue; - if (isLikelyTailscaleInterface || isTailscaleAddress(entry.address)) { - tailscale.push(entry.address); - } else { - lan.push(entry.address); - } - } - } - return { - lanIpAddresses: uniqueStrings(lan), - tailscaleIp: uniqueStrings(tailscale)[0] ?? null, - tailscaleDnsName: readLocalTailscaleDnsName(), - }; -} - -function normalizeTailscaleDnsName(value: unknown): string | null { - if (typeof value !== "string") return null; - const normalized = value.trim().replace(/\.$/, "").toLowerCase(); - return normalized.endsWith(".ts.net") ? normalized : null; -} - -function readLocalTailscaleDnsName(): string | null { - const now = Date.now(); - if (tailscaleStatusCache && tailscaleStatusCache.expiresAt > now) { - return tailscaleStatusCache.dnsName; - } - let dnsName: string | null = null; - try { - const raw = execFileSync(resolveTailscaleCliPath(), ["status", "--json"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - timeout: 1_000, - }); - const parsed = safeJsonParse<{ Self?: { DNSName?: unknown } }>(raw, {}); - dnsName = normalizeTailscaleDnsName(parsed.Self?.DNSName); - } catch { - dnsName = null; - } - tailscaleStatusCache = { - expiresAt: now + TAILSCALE_STATUS_CACHE_MS, - dnsName, - }; - return dnsName; -} - -function firstPreferredHost(ipAddresses: string[]): string { - return ipAddresses[0] ?? os.hostname(); -} - -export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { - const layout = resolveAdeLayout(args.projectRoot); - const deviceIdPath = args.localDeviceIdPath ?? path.join(layout.secretsDir, DEVICE_ID_FILE); - const legacyProjectDeviceIdPath = path.join(layout.secretsDir, DEVICE_ID_FILE); - fs.mkdirSync(path.dirname(deviceIdPath), { recursive: true }); - - const readOrCreateLocalDeviceId = (): string => { - // One desktop, one device id: the shared file is authoritative across - // projects so each project's `sync_cluster_state.brain_device_id` agrees - // on the same local identity. If the shared file is empty, seed it from - // the first legacy per-project id we happen to see (one-time migration), - // otherwise mint a fresh id. `O_EXCL` on the seed write keeps two - // concurrent project contexts from racing to mint different ids. - const shared = fs.existsSync(deviceIdPath) ? fs.readFileSync(deviceIdPath, "utf8").trim() : ""; - if (shared.length > 0) return shared; - - const legacy = deviceIdPath !== legacyProjectDeviceIdPath && fs.existsSync(legacyProjectDeviceIdPath) - ? fs.readFileSync(legacyProjectDeviceIdPath, "utf8").trim() - : ""; - const candidate = legacy.length > 0 ? legacy : randomUUID(); - try { - fs.writeFileSync(deviceIdPath, `${candidate}\n`, { flag: "wx" }); - return candidate; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; - // Another context won the race; use whatever they wrote. - return fs.readFileSync(deviceIdPath, "utf8").trim(); - } - }; - - const localDeviceId = readOrCreateLocalDeviceId(); - const localSiteId = args.db.sync.getSiteId(); - - const getLocalDefaults = () => { - const network = readLocalNetworkMetadata(); - const metadata: Record<string, unknown> = { - hostname: os.hostname(), - }; - if (network.tailscaleDnsName) { - metadata.tailscaleDnsName = network.tailscaleDnsName; - } - return { - name: os.hostname(), - platform: mapPlatform(process.platform), - deviceType: "desktop" as SyncPeerDeviceType, - ipAddresses: network.lanIpAddresses, - tailscaleIp: network.tailscaleIp, - lastHost: firstPreferredHost(network.lanIpAddresses), - metadata, - }; - }; - - const upsertDeviceRecord = (record: { - deviceId: string; - siteId: string; - name: string; - platform: SyncPeerPlatform; - deviceType: SyncPeerDeviceType; - createdAt?: string; - updatedAt?: string; - lastSeenAt?: string | null; - lastHost?: string | null; - lastPort?: number | null; - tailscaleIp?: string | null; - ipAddresses?: string[]; - metadata?: Record<string, unknown>; - }): SyncDeviceRecord => { - const now = nowIso(); - const existing = mapDeviceRow(args.db.get<DeviceRow>("select * from devices where device_id = ? limit 1", [record.deviceId])); - const nextCreatedAt = record.createdAt ?? existing?.createdAt ?? now; - const nextUpdatedAt = record.updatedAt ?? now; - const nextIpAddresses = uniqueStrings(record.ipAddresses ?? existing?.ipAddresses ?? []); - const nextMetadata = { - ...(existing?.metadata ?? {}), - ...(record.metadata ?? {}), - }; - args.db.run( - ` - insert into devices( - device_id, site_id, name, platform, device_type, - created_at, updated_at, last_seen_at, last_host, last_port, - tailscale_ip, ip_addresses_json, metadata_json - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - on conflict(device_id) do update set - site_id = excluded.site_id, - name = excluded.name, - platform = excluded.platform, - device_type = excluded.device_type, - updated_at = excluded.updated_at, - last_seen_at = excluded.last_seen_at, - last_host = excluded.last_host, - last_port = excluded.last_port, - tailscale_ip = excluded.tailscale_ip, - ip_addresses_json = excluded.ip_addresses_json, - metadata_json = excluded.metadata_json - `, - [ - record.deviceId, - record.siteId, - record.name, - record.platform, - record.deviceType, - nextCreatedAt, - nextUpdatedAt, - record.lastSeenAt ?? existing?.lastSeenAt ?? null, - record.lastHost ?? existing?.lastHost ?? null, - record.lastPort ?? existing?.lastPort ?? null, - record.tailscaleIp ?? existing?.tailscaleIp ?? null, - JSON.stringify(nextIpAddresses), - JSON.stringify(nextMetadata), - ], - ); - return mapDeviceRow(args.db.get<DeviceRow>("select * from devices where device_id = ? limit 1", [record.deviceId]))!; - }; - - const ensureLocalDevice = (): SyncDeviceRecord => { - const existing = mapDeviceRow(args.db.get<DeviceRow>("select * from devices where device_id = ? limit 1", [localDeviceId])); - const defaults = getLocalDefaults(); - return upsertDeviceRecord({ - deviceId: localDeviceId, - siteId: localSiteId, - name: existing?.name ?? defaults.name, - platform: existing?.platform ?? defaults.platform, - deviceType: existing?.deviceType ?? defaults.deviceType, - lastSeenAt: nowIso(), - lastHost: defaults.lastHost ?? existing?.lastHost ?? null, - lastPort: existing?.lastPort ?? null, - tailscaleIp: defaults.tailscaleIp ?? existing?.tailscaleIp ?? null, - ipAddresses: defaults.ipAddresses.length > 0 ? defaults.ipAddresses : (existing?.ipAddresses ?? []), - metadata: { - ...(existing?.metadata ?? {}), - ...defaults.metadata, - }, - }); - }; - - const listDevices = (): SyncDeviceRecord[] => { - return args.db - .all<DeviceRow>("select * from devices order by case when device_id = ? then 0 else 1 end, name collate nocase asc", [localDeviceId]) - .map((row) => mapDeviceRow(row)) - .filter((row): row is SyncDeviceRecord => row != null); - }; - - const getDevice = (deviceId: string): SyncDeviceRecord | null => { - const normalized = deviceId.trim(); - if (!normalized) return null; - return mapDeviceRow(args.db.get<DeviceRow>("select * from devices where device_id = ? limit 1", [normalized])); - }; - - const getClusterState = (): SyncClusterState | null => { - return mapClusterStateRow( - args.db.get<ClusterStateRow>("select * from sync_cluster_state where cluster_id = ? limit 1", [DEFAULT_SYNC_CLUSTER_ID]), - ); - }; - - const setClusterState = (argsIn: { - brainDeviceId: string; - brainEpoch: number; - updatedByDeviceId?: string; - }): SyncClusterState => { - const now = nowIso(); - args.db.run( - ` - insert into sync_cluster_state(cluster_id, brain_device_id, brain_epoch, updated_at, updated_by_device_id) - values (?, ?, ?, ?, ?) - on conflict(cluster_id) do update set - brain_device_id = excluded.brain_device_id, - brain_epoch = excluded.brain_epoch, - updated_at = excluded.updated_at, - updated_by_device_id = excluded.updated_by_device_id - `, - [ - DEFAULT_SYNC_CLUSTER_ID, - argsIn.brainDeviceId, - argsIn.brainEpoch, - now, - argsIn.updatedByDeviceId ?? localDeviceId, - ], - ); - return getClusterState()!; - }; - - const bootstrapLocalBrainIfNeeded = (): SyncClusterState => { - const existing = getClusterState(); - if (existing) return existing; - ensureLocalDevice(); - return setClusterState({ - brainDeviceId: localDeviceId, - brainEpoch: 1, - updatedByDeviceId: localDeviceId, - }); - }; - - const updateLocalDevice = (updates: { - name?: string; - deviceType?: SyncPeerDeviceType; - }): SyncDeviceRecord => { - const current = ensureLocalDevice(); - return upsertDeviceRecord({ - deviceId: localDeviceId, - siteId: localSiteId, - name: toOptionalString(updates.name) ?? current.name, - platform: current.platform, - deviceType: updates.deviceType ?? current.deviceType, - lastSeenAt: nowIso(), - lastHost: current.lastHost, - lastPort: current.lastPort, - tailscaleIp: current.tailscaleIp, - ipAddresses: current.ipAddresses, - metadata: current.metadata, - }); - }; - - const touchLocalDevice = (argsIn: { - lastSeenAt?: string | null; - lastHost?: string | null; - lastPort?: number | null; - metadata?: Record<string, unknown>; - } = {}): SyncDeviceRecord => { - const current = ensureLocalDevice(); - const network = readLocalNetworkMetadata(); - return upsertDeviceRecord({ - deviceId: current.deviceId, - siteId: current.siteId, - name: current.name, - platform: current.platform, - deviceType: current.deviceType, - lastSeenAt: argsIn.lastSeenAt ?? nowIso(), - lastHost: argsIn.lastHost ?? current.lastHost ?? firstPreferredHost(network.lanIpAddresses), - lastPort: argsIn.lastPort ?? current.lastPort, - tailscaleIp: network.tailscaleIp ?? current.tailscaleIp, - ipAddresses: network.lanIpAddresses.length > 0 ? network.lanIpAddresses : current.ipAddresses, - metadata: { - ...current.metadata, - ...(argsIn.metadata ?? {}), - }, - }); - }; - - const upsertPeerMetadata = ( - peer: SyncPeerMetadata | SyncPeerConnectionState, - extras: { - lastSeenAt?: string | null; - lastHost?: string | null; - lastPort?: number | null; - metadata?: Record<string, unknown>; - } = {}, - ): SyncDeviceRecord => { - return upsertDeviceRecord({ - deviceId: peer.deviceId, - siteId: peer.siteId, - name: peer.deviceName, - platform: peer.platform, - deviceType: peer.deviceType, - lastSeenAt: extras.lastSeenAt ?? ("lastSeenAt" in peer ? peer.lastSeenAt : nowIso()), - lastHost: extras.lastHost ?? ("remoteAddress" in peer ? peer.remoteAddress : null), - lastPort: extras.lastPort ?? ("remotePort" in peer ? peer.remotePort : null), - metadata: { - dbVersion: peer.dbVersion, - ...(extras.metadata ?? {}), - }, - }); - }; - - type ApnsTokenKind = "alert" | "activity-start" | "activity-update"; - - const apnsMetaKey = (kind: ApnsTokenKind): string => { - if (kind === "alert") return "apnsAlertToken"; - if (kind === "activity-start") return "apnsActivityStartToken"; - return "apnsActivityUpdateTokens"; - }; - - const setApnsToken = ( - deviceId: string, - token: string, - kind: ApnsTokenKind, - env: "sandbox" | "production", - extras: { bundleId?: string; activityId?: string } = {}, - ): SyncDeviceRecord | null => { - const device = getDevice(deviceId); - if (!device) return null; - const nextMetadata: Record<string, unknown> = { - ...device.metadata, - apnsEnv: env, - apnsTokenUpdatedAt: nowIso(), - }; - if (extras.bundleId) nextMetadata.apnsBundleId = extras.bundleId; - if (kind === "activity-update") { - const existing = (device.metadata.apnsActivityUpdateTokens as Record<string, string> | undefined) ?? {}; - const activityId = extras.activityId?.trim() || WORKSPACE_ACTIVITY_ID; - nextMetadata.apnsActivityUpdateTokens = { ...existing, [activityId]: token }; - } else { - nextMetadata[apnsMetaKey(kind)] = token; - } - return upsertDeviceRecord({ - deviceId: device.deviceId, - siteId: device.siteId, - name: device.name, - platform: device.platform, - deviceType: device.deviceType, - lastSeenAt: device.lastSeenAt, - lastHost: device.lastHost, - lastPort: device.lastPort, - tailscaleIp: device.tailscaleIp, - ipAddresses: device.ipAddresses, - metadata: nextMetadata, - }); - }; - - const getApnsTokenForDevice = ( - deviceId: string, - kind: ApnsTokenKind, - activityId?: string, - ): string | null => { - const device = getDevice(deviceId); - if (!device) return null; - if (kind === "activity-update") { - const map = (device.metadata.apnsActivityUpdateTokens as Record<string, string> | undefined) ?? {}; - return map[activityId?.trim() || WORKSPACE_ACTIVITY_ID] ?? null; - } - const raw = device.metadata[apnsMetaKey(kind)]; - return typeof raw === "string" && raw.trim().length > 0 ? raw : null; - }; - - const setNotificationPreferences = ( - deviceId: string, - prefs: NotificationPreferences, - ): SyncDeviceRecord | null => { - const device = getDevice(deviceId); - if (!device) return null; - const normalizedPrefs = normalizeNotificationPreferences(prefs); - return upsertDeviceRecord({ - deviceId: device.deviceId, - siteId: device.siteId, - name: device.name, - platform: device.platform, - deviceType: device.deviceType, - lastSeenAt: device.lastSeenAt, - lastHost: device.lastHost, - lastPort: device.lastPort, - tailscaleIp: device.tailscaleIp, - ipAddresses: device.ipAddresses, - metadata: { - ...device.metadata, - notificationPreferences: normalizedPrefs, - notificationPreferencesUpdatedAt: nowIso(), - }, - }); - }; - - const getNotificationPreferences = (deviceId: string): NotificationPreferences | null => { - const prefs = getDevice(deviceId)?.metadata.notificationPreferences; - if (!prefs || typeof prefs !== "object" || Array.isArray(prefs)) return null; - return normalizeNotificationPreferences(prefs); - }; - - const invalidateApnsToken = (deviceToken: string): void => { - const token = deviceToken.trim(); - if (!token) return; - const device = findDeviceByApnsToken(token); - if (!device) return; - const nextMetadata = { ...device.metadata }; - if (nextMetadata.apnsAlertToken === token) { - delete nextMetadata.apnsAlertToken; - } - if (nextMetadata.apnsActivityStartToken === token) { - delete nextMetadata.apnsActivityStartToken; - } - const updates = nextMetadata.apnsActivityUpdateTokens; - if (updates && typeof updates === "object" && !Array.isArray(updates)) { - const nextUpdates = { ...(updates as Record<string, string>) }; - for (const [activityId, value] of Object.entries(nextUpdates)) { - if (value === token) delete nextUpdates[activityId]; - } - if (Object.keys(nextUpdates).length > 0) { - nextMetadata.apnsActivityUpdateTokens = nextUpdates; - } else { - delete nextMetadata.apnsActivityUpdateTokens; - } - } - upsertDeviceRecord({ - deviceId: device.deviceId, - siteId: device.siteId, - name: device.name, - platform: device.platform, - deviceType: device.deviceType, - lastSeenAt: device.lastSeenAt, - lastHost: device.lastHost, - lastPort: device.lastPort, - tailscaleIp: device.tailscaleIp, - ipAddresses: device.ipAddresses, - metadata: nextMetadata, - }); - }; - - const invalidateApnsTokensForDevice = (deviceId: string): void => { - const device = getDevice(deviceId); - if (!device) return; - const nextMetadata = { ...device.metadata }; - delete nextMetadata.apnsAlertToken; - delete nextMetadata.apnsActivityStartToken; - delete nextMetadata.apnsActivityUpdateTokens; - upsertDeviceRecord({ - deviceId: device.deviceId, - siteId: device.siteId, - name: device.name, - platform: device.platform, - deviceType: device.deviceType, - lastSeenAt: device.lastSeenAt, - lastHost: device.lastHost, - lastPort: device.lastPort, - tailscaleIp: device.tailscaleIp, - ipAddresses: device.ipAddresses, - metadata: nextMetadata, - }); - }; - - const findDeviceByApnsToken = (token: string): SyncDeviceRecord | null => { - for (const device of listDevices()) { - const alert = device.metadata.apnsAlertToken; - const activity = device.metadata.apnsActivityStartToken; - if (alert === token || activity === token) return device; - const updates = device.metadata.apnsActivityUpdateTokens; - if (updates && typeof updates === "object") { - for (const value of Object.values(updates as Record<string, unknown>)) { - if (value === token) return device; - } - } - } - return null; - }; - - const applyBrainStatus = (payload: SyncBrainStatusPayload): void => { - upsertPeerMetadata(payload.brain, { lastSeenAt: nowIso() }); - for (const peer of payload.connectedPeers) { - upsertPeerMetadata(peer, { - lastSeenAt: peer.lastSeenAt, - lastHost: peer.remoteAddress, - lastPort: peer.remotePort, - }); - } - }; - - const clearClusterRegistryForViewerJoin = (): void => { - args.logger.info("sync.device_registry.clear_for_viewer_join", { - projectRoot: args.projectRoot, - localDeviceId, - }); - args.db.run("delete from sync_cluster_state"); - args.db.run("delete from devices"); - }; - - const forgetDevice = (deviceId: string): void => { - const normalized = deviceId.trim(); - if (!normalized || normalized === localDeviceId) return; - args.db.run("delete from devices where device_id = ?", [normalized]); - }; - - ensureLocalDevice(); - - return { - getLocalDeviceId(): string { - return localDeviceId; - }, - - getLocalSiteId(): string { - return localSiteId; - }, - - ensureLocalDevice, - touchLocalDevice, - updateLocalDevice, - listDevices, - getDevice, - getClusterState, - setClusterState, - bootstrapLocalBrainIfNeeded, - upsertPeerMetadata, - applyBrainStatus, - clearClusterRegistryForViewerJoin, - forgetDevice, - setApnsToken, - getApnsTokenForDevice, - setNotificationPreferences, - getNotificationPreferences, - invalidateApnsToken, - invalidateApnsTokensForDevice, - findDeviceByApnsToken, - }; -} - -export type DeviceRegistryService = ReturnType<typeof createDeviceRegistryService>; +export * from "../../../../../ade-cli/src/services/sync/deviceRegistryService"; diff --git a/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.test.ts b/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.test.ts index 7f9db5944..08e7e556b 100644 --- a/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.test.ts +++ b/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.test.ts @@ -10,6 +10,28 @@ describe("resolveTailscaleCliPath", () => { ).toBe("C:\\custom\\tailscale.exe"); }); + it("prefers the standalone macOS CLI over the app bundle helper", () => { + const standalone = "/usr/local/bin/tailscale"; + const bundleHelper = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; + expect( + resolveTailscaleCliPath({ + platform: "darwin", + existsSync: (p) => + String(p) === standalone || String(p) === bundleHelper, + }), + ).toBe(standalone); + }); + + it("falls back to the macOS app bundle helper when no standalone CLI exists", () => { + const bundleHelper = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; + expect( + resolveTailscaleCliPath({ + platform: "darwin", + existsSync: (p) => String(p) === bundleHelper, + }), + ).toBe(bundleHelper); + }); + it("prefers a default Windows install path when that exe exists", () => { const target = "C:\\Program Files\\Tailscale\\tailscale.exe"; expect( diff --git a/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.ts b/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.ts index b613928c0..73529aed6 100644 --- a/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.ts +++ b/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.ts @@ -1,51 +1 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { PathLike } from "node:fs"; - -const TAILSCALE_CLI_MACOS_PATH = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; - -function windowsTailscaleExeCandidates(env: NodeJS.ProcessEnv): string[] { - const programFiles = env.ProgramFiles?.trim(); - const programFilesX86 = env["ProgramFiles(x86)"]?.trim(); - const { join: winJoin } = path.win32; - const out: string[] = []; - if (programFiles) { - out.push(winJoin(programFiles, "Tailscale", "tailscale.exe")); - } - if (programFilesX86) { - out.push(winJoin(programFilesX86, "Tailscale", "tailscale.exe")); - } - if (out.length === 0) { - out.push("C:\\Program Files\\Tailscale\\tailscale.exe", "C:\\Program Files (x86)\\Tailscale\\tailscale.exe"); - } - return out; -} - -export type ResolveTailscaleCliPathOptions = { - env?: NodeJS.ProcessEnv; - platform?: NodeJS.Platform; - /** Test seam; production uses `fs.existsSync`. */ - existsSync?: (path: PathLike) => boolean; -}; - -/** - * Resolves the Tailscale CLI for `status`, `serve`, etc. - * Precedence: `ADE_TAILSCALE_CLI`, known macOS bundle path, known Windows - * install paths, then `tailscale` (PATH lookup). - */ -export function resolveTailscaleCliPath(options?: ResolveTailscaleCliPathOptions): string { - const env = options?.env ?? process.env; - const platform = options?.platform ?? process.platform; - const exists = options?.existsSync ?? ((p: PathLike) => fs.existsSync(p)); - const configured = env.ADE_TAILSCALE_CLI?.trim(); - if (configured) return configured; - if (platform === "darwin" && exists(TAILSCALE_CLI_MACOS_PATH)) { - return TAILSCALE_CLI_MACOS_PATH; - } - if (platform === "win32") { - for (const candidate of windowsTailscaleExeCandidates(env)) { - if (exists(candidate)) return candidate; - } - } - return "tailscale"; -} +export * from "../../../../../ade-cli/src/services/sync/resolveTailscaleCliPath"; diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index f3894a69b..2b00660b0 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -267,12 +267,16 @@ function createStubChatService() { async function sendCommand(ws: WebSocket, queue: ReturnType<typeof createMessageQueue>, payload: { commandId: string; action: string; + projectId?: string | null; args: Record<string, unknown>; }) { ws.send(encodeSyncEnvelope({ type: "command", requestId: payload.commandId, - payload, + payload: { + projectId: "project-1", + ...payload, + }, })); const ack = await queue.next("command_ack"); const result = await queue.next("command_result"); @@ -690,6 +694,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const host = createSyncHostService({ db: brainDb, logger: createLogger() as any, + projectId: "project-1", projectRoot, port: 0, pinStore: createStubPinStore(), @@ -821,6 +826,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const host = createSyncHostService({ db: brainDb, logger: createLogger() as any, + projectId: "project-1", projectRoot, port: 0, pinStore: createStubPinStore(), @@ -944,6 +950,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const host = createSyncHostService({ db: brainDb, logger: createLogger() as any, + projectId: "project-1", projectRoot, port: 0, pinStore: createStubPinStore(), @@ -1147,6 +1154,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const host = createSyncHostService({ db: brainDb, logger: createLogger() as any, + projectId: "project-1", projectRoot, port: 0, pinStore: createStubPinStore(), @@ -1388,6 +1396,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const host = createSyncHostService({ db: brainDb, logger: createLogger() as any, + projectId: "project-1", projectRoot, port: 0, pinStore: createStubPinStore(), @@ -1596,6 +1605,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-quick-run", action: "work.runQuickCommand", + projectId: "project-1", args: { laneId: "lane-1", title: "Run tests", @@ -1622,6 +1632,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-quick-run", action: "work.runQuickCommand", + projectId: "project-1", args: { laneId: "lane-1", title: "Run tests", @@ -1641,6 +1652,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-quick-run", action: "work.runQuickCommand", + projectId: "project-1", args: { laneId: "lane-2", title: "Run a different command", @@ -1660,6 +1672,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-start-cli", action: "work.startCliSession", + projectId: "project-1", args: { laneId: "lane-1", provider: "codex", @@ -1696,6 +1709,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-start-cli", action: "work.startCliSession", + projectId: "project-1", args: { laneId: "lane-1", provider: "codex", @@ -1725,6 +1739,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-work-list", action: "work.listSessions", + projectId: "project-1", args: {}, }, })); @@ -1743,6 +1758,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-pr-refresh", action: "prs.refresh", + projectId: "project-1", args: {}, }, })); @@ -1767,6 +1783,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-unsupported", action: "prs.create", + projectId: "project-1", args: {}, }, })); @@ -1787,6 +1804,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const host = createSyncHostService({ db: brainDb, logger: createLogger() as any, + projectId: "project-1", projectRoot, port: 0, fileService: createStubFileService(workspaceRoot) as any, @@ -1933,6 +1951,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const host = createSyncHostService({ db: brainDb, logger: createLogger() as any, + projectId: "project-1", projectRoot, port: 0, fileService: createStubFileService(workspaceRoot) as any, @@ -2306,6 +2325,148 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { await new Promise((resolve) => revokedWs.once("close", resolve)); }); + it("rejects project-scoped commands without projectId when the host is project-bound", async () => { + const brainDb = await openKvDb(makeDbPath("ade-sync-command-project-scope-"), createLogger() as any); + const projectRoot = makeProjectRoot("ade-sync-command-project-scope-project-"); + const workspaceRoot = path.join(projectRoot, "workspace"); + fs.mkdirSync(workspaceRoot, { recursive: true }); + + const host = createSyncHostService({ + db: brainDb, + logger: createLogger() as any, + projectId: "project-1", + projectRoot, + port: 0, + pinStore: createStubPinStore(), + fileService: createStubFileService(workspaceRoot) as any, + laneService: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn(), + archive: vi.fn(), + } as any, + prService: { + listAll: vi.fn().mockResolvedValue([]), + refresh: vi.fn().mockResolvedValue([]), + } as any, + ptyService: { + create: vi.fn(), + enrichSessions: (rows: any[]) => rows, + } as any, + sessionService: { list: () => [] } as any, + computerUseArtifactBrokerService: { + listArtifacts: () => [], + } as any, + }); + activeDisposers.push(async () => { + await host.dispose(); + brainDb.close(); + }); + + const client = await connectClient({ + port: await host.waitUntilListening(), + token: host.getBootstrapToken(), + deviceId: "peer-project-scope", + deviceName: "Project Scope Phone", + siteId: brainDb.sync.getSiteId(), + dbVersion: brainDb.sync.getDbVersion(), + deviceType: "phone", + }); + activeDisposers.push(client.close); + + client.ws.send(encodeSyncEnvelope({ + type: "command", + requestId: "cmd-missing-project", + payload: { + commandId: "cmd-missing-project", + action: "lanes.list", + args: {}, + }, + })); + + const ack = await client.queue.next("command_ack"); + expect((ack.payload as { accepted: boolean }).accepted).toBe(false); + const result = await client.queue.next("command_result"); + expect((result.payload as { ok: boolean; error?: { code: string } }).ok).toBe(false); + expect((result.payload as { ok: boolean; error?: { code: string } }).error?.code).toBe("missing_project"); + }); + + it("routes project-scoped commands for another registered project through the remote command executor", async () => { + const brainDb = await openKvDb(makeDbPath("ade-sync-command-project-route-"), createLogger() as any); + const projectRoot = makeProjectRoot("ade-sync-command-project-route-project-"); + const workspaceRoot = path.join(projectRoot, "workspace"); + fs.mkdirSync(workspaceRoot, { recursive: true }); + const execute = vi.fn(async (payload: { projectId?: string | null; action?: string }) => ({ + routedProjectId: payload.projectId, + routedAction: payload.action, + })); + const laneList = vi.fn().mockResolvedValue([]); + + const host = createSyncHostService({ + db: brainDb, + logger: createLogger() as any, + projectId: "project-1", + projectRoot, + port: 0, + pinStore: createStubPinStore(), + fileService: createStubFileService(workspaceRoot) as any, + laneService: { + list: laneList, + create: vi.fn(), + archive: vi.fn(), + } as any, + prService: { + listAll: vi.fn().mockResolvedValue([]), + refresh: vi.fn().mockResolvedValue([]), + } as any, + ptyService: { + create: vi.fn(), + enrichSessions: (rows: any[]) => rows, + } as any, + sessionService: { list: () => [] } as any, + computerUseArtifactBrokerService: { + listArtifacts: () => [], + } as any, + remoteCommandExecutor: { execute }, + }); + activeDisposers.push(async () => { + await host.dispose(); + brainDb.close(); + }); + + const client = await connectClient({ + port: await host.waitUntilListening(), + token: host.getBootstrapToken(), + deviceId: "peer-project-route", + deviceName: "Project Route Phone", + siteId: brainDb.sync.getSiteId(), + dbVersion: brainDb.sync.getDbVersion(), + deviceType: "phone", + }); + activeDisposers.push(client.close); + + client.ws.send(encodeSyncEnvelope({ + type: "command", + projectId: "project-2", + requestId: "cmd-other-project", + payload: { + commandId: "cmd-other-project", + action: "lanes.list", + args: {}, + }, + })); + + const ack = await client.queue.next("command_ack"); + expect((ack.payload as { accepted: boolean }).accepted).toBe(true); + const result = await client.queue.next("command_result"); + expect((result.payload as { ok: boolean; result?: unknown }).ok).toBe(true); + expect((result.payload as { result: { routedProjectId: string; routedAction: string } }).result).toEqual({ + routedProjectId: "project-2", + routedAction: "lanes.list", + }); + expect(execute).toHaveBeenCalledTimes(1); + expect(laneList).not.toHaveBeenCalled(); + }); + it("clears prior PIN failures after a successful pair and still allows paired hello", async () => { const brainDb = await openKvDb(makeDbPath("ade-sync-pairing-cooldown-"), createLogger() as any); const projectRoot = makeProjectRoot("ade-sync-pairing-cooldown-project-"); diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index b8ac215a5..5649bf08f 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -1,2999 +1 @@ -import fs from "node:fs"; -import { execFile } from "node:child_process"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; -import { createHash, randomBytes } from "node:crypto"; -import { Bonjour, type Service as BonjourService } from "bonjour-service"; -import { WebSocketServer, WebSocket, type RawData } from "ws"; -import { resolveAdeLayout } from "../../../shared/adeLayout"; -import type { - AgentChatEventEnvelope, - CrsqlChangeRow, - DeviceMarker, - FileContent, - FileTreeNode, - FilesQuickOpenItem, - FilesSearchTextMatch, - FilesWorkspace, - LaneDetailPayload, - LaneListSnapshot, - LaneSummary, - PtyDataEvent, - PtyExitEvent, - SyncBrainStatusPayload, - SyncChangesetAckPayload, - SyncChangesetBatchPayload, - SyncCommandAckPayload, - SyncCommandPayload, - SyncCommandResultPayload, - SyncEnvelope, - SyncChatSubscribeSnapshotPayload, - SyncChatUnsubscribePayload, - SyncFileBlob, - SyncFileRequest, - SyncFileResponsePayload, - SyncHelloPayload, - SyncMobileProjectSummary, - SyncPairingRequestPayload, - SyncPeerConnectionState, - SyncPeerMetadata, - SyncProjectCatalogChunkPayload, - SyncProjectCatalogPayload, - SyncProjectSwitchRequestPayload, - SyncProjectSwitchResultPayload, - SyncRemoteCommandDescriptor, - SyncTailnetDiscoveryStatus, - SyncTerminalSnapshotPayload, -} from "../../../shared/types"; -import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; -import type { Logger } from "../logging/logger"; -import type { createAgentChatService } from "../chat/agentChatService"; -import type { createCtoStateService } from "../cto/ctoStateService"; -import type { createFlowPolicyService } from "../cto/flowPolicyService"; -import type { createLinearCredentialService } from "../cto/linearCredentialService"; -import type { createLinearIngressService } from "../cto/linearIngressService"; -import type { createLinearIssueTracker } from "../cto/linearIssueTracker"; -import type { createLinearSyncService } from "../cto/linearSyncService"; -import type { createWorkerAgentService } from "../cto/workerAgentService"; -import type { createWorkerBudgetService } from "../cto/workerBudgetService"; -import type { createWorkerHeartbeatService } from "../cto/workerHeartbeatService"; -import type { createWorkerRevisionService } from "../cto/workerRevisionService"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createConflictService } from "../conflicts/conflictService"; -import type { createFileService } from "../files/fileService"; -import type { createDiffService } from "../diffs/diffService"; -import type { createGitOperationsService } from "../git/gitOperationsService"; -import type { createAutoRebaseService } from "../lanes/autoRebaseService"; -import type { createLaneEnvironmentService } from "../lanes/laneEnvironmentService"; -import type { createLaneService } from "../lanes/laneService"; -import type { createLaneTemplateService } from "../lanes/laneTemplateService"; -import type { createPortAllocationService } from "../lanes/portAllocationService"; -import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionService"; -import type { createProcessService } from "../processes/processService"; -import type { createPtyService } from "../pty/ptyService"; -import type { createIssueInventoryService } from "../prs/issueInventoryService"; -import type { PathToMergeOrchestrator } from "../prs/pathToMergeOrchestrator"; -import type { createPrService } from "../prs/prService"; -import type { createQueueLandingService } from "../prs/queueLandingService"; -import type { createSessionService } from "../sessions/sessionService"; -import type { createComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; -import type { AdeDb } from "../state/kvDb"; -import { hasNullByte, normalizeRelative, nowIso, resolvePathWithinRoot, safeJsonParse, toOptionalString, uniqueStrings, writeTextAtomic } from "../shared/utils"; -import type { DeviceRegistryService } from "./deviceRegistryService"; -import { createSyncPairingStore } from "./syncPairingStore"; -import type { NotificationEventBus } from "../notifications/notificationEventBus"; -import type { - ApnsEnvironment, - ApnsPushTokenKind, - NotificationPreferences, - SyncInAppNotificationPayload, - SyncNotificationPrefsPayload, - SyncRegisterPushTokenPayload, - SyncSendTestPushPayload, -} from "../../../shared/types/sync"; -import { DEFAULT_NOTIFICATION_PREFERENCES, normalizeNotificationPreferences } from "../../../shared/types/sync"; -import type { SyncPinStore } from "./syncPinStore"; -import { DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, DEFAULT_SYNC_HOST_PORT, encodeSyncEnvelope, mapPlatform, parseSyncEnvelope, wsDataToText } from "./syncProtocol"; -import { resolveTailscaleCliPath } from "./resolveTailscaleCliPath"; -import { createSyncRemoteCommandService } from "./syncRemoteCommandService"; -const execFileAsync = promisify(execFile); -const DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS = 30_000; -const DEFAULT_SYNC_HEARTBEAT_MISS_LIMIT = 2; -const MOBILE_SYNC_HEARTBEAT_MISS_LIMIT = 6; -const DEFAULT_SYNC_POLL_INTERVAL_MS = 400; -const DEFAULT_BRAIN_STATUS_INTERVAL_MS = 5_000; -const DEFAULT_TERMINAL_SNAPSHOT_BYTES = 220_000; -const PEER_BACKPRESSURE_BYTES = 4 * 1024 * 1024; -const MOBILE_COMMAND_RESULT_CACHE_TTL_MS = 30 * 60 * 1000; -const MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES = 512; -const CHANGESET_ACK_TIMEOUT_MS = 10_000; -const MAX_CHANGESET_ACK_RETRIES = 6; -const LANE_PRESENCE_TTL_MS = 60_000; -const SYNC_MDNS_SERVICE_TYPE = "ade-sync"; -export const SYNC_TAILNET_DISCOVERY_SERVICE_NAME = "svc:ade-sync"; -export const SYNC_TAILNET_DISCOVERY_SERVICE_PORT = DEFAULT_SYNC_HOST_PORT; -const MOBILE_MUTATING_FILE_ACTIONS = new Set<SyncFileRequest["action"]>([ - "writeText", - "createFile", - "createDirectory", - "rename", - "deletePath", -]); - -type LanePresenceEntry = { - marker: DeviceMarker; - lastAnnouncedAtMs: number; - source: "local" | "remote"; -}; - -type PeerState = { - ws: WebSocket; - metadata: SyncPeerMetadata | null; - authenticated: boolean; - authKind: "bootstrap" | "paired" | null; - pairedDeviceId: string | null; - connectedAt: string; - lastSeenAt: string; - lastAppliedAt: string | null; - lastKnownServerDbVersion: number; - latencyMs: number | null; - awaitingHeartbeatAt: string | null; - missedHeartbeatCount: number; - remoteAddress: string | null; - remotePort: number | null; - subscribedSessionIds: Set<string>; - subscribedChatSessionIds: Set<string>; - chatTranscriptOffsets: Map<string, number>; - chatEventIdsSent: Map<string, Set<string>>; - pendingChangesetBatch: PendingChangesetBatch | null; -}; - -type PendingChangesetBatch = { - batchId: string; - fromDbVersion: number; - toDbVersion: number; - changes: CrsqlChangeRow[]; - reason: SyncChangesetBatchPayload["reason"]; - sentAtMs: number; - retryCount: number; -}; - -type CachedMobileCommandWaiter = { - peer: PeerState; - requestId: string | null; -}; - -type CachedMobileCommand = { - commandId: string; - action: string; - argsKey: string; - argsFingerprint: string; - ack: SyncCommandAckPayload; - result: SyncCommandResultPayload | null; - waiters: CachedMobileCommandWaiter[]; - acceptedAtMs: number; - completedAtMs: number | null; -}; - -type PersistedMobileCommand = { - key: string; - projectRoot: string; - deviceId: string; - commandId: string; - action: string; - argsFingerprint: string; - ack: SyncCommandAckPayload; - result: SyncCommandResultPayload; - acceptedAtMs: number; - completedAtMs: number; -}; - -const PERSISTED_MOBILE_COMMAND_ACTIONS = new Set<string>([ - "lanes.presence.announce", - "lanes.presence.release", - "notification_prefs", - "work.runQuickCommand", - "work.startCliSession", - "work.closeSession", - "processes.start", - "processes.stop", - "processes.kill", - "chat.interrupt", - "chat.approve", - "chat.respondToInput", - "chat.dispose", - "chat.archive", - "chat.unarchive", - "chat.delete", -]); - -function stableJsonValue(value: unknown): unknown { - if (value == null) return value; - if (Array.isArray(value)) return value.map(stableJsonValue); - if (typeof value !== "object") return value; - const input = value as Record<string, unknown>; - const output: Record<string, unknown> = {}; - for (const key of Object.keys(input).sort()) { - output[key] = stableJsonValue(input[key]); - } - return output; -} - -function stableJsonKey(value: unknown): string { - return JSON.stringify(stableJsonValue(value)) ?? "null"; -} - -function mobileCommandArgsFingerprint(argsKey: string): string { - return createHash("sha256").update(argsKey).digest("hex"); -} - -function safeObjectValue(value: unknown): Record<string, unknown> | null { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Record<string, unknown> - : null; -} - -function persistedMobileCommandResult(action: string, result: SyncCommandResultPayload): SyncCommandResultPayload | null { - if (!PERSISTED_MOBILE_COMMAND_ACTIONS.has(action)) return null; - if (!result.ok) { - return { - commandId: result.commandId, - ok: false, - error: { - code: result.error?.code ?? "command_failed", - message: "Command failed before reconnect.", - }, - }; - } - if (action === "work.runQuickCommand" || action === "work.startCliSession") { - const raw = safeObjectValue(result.result); - const replayResult: Record<string, unknown> = {}; - if (typeof raw?.sessionId === "string") replayResult.sessionId = raw.sessionId; - if (typeof raw?.ptyId === "string") replayResult.ptyId = raw.ptyId; - if (action === "work.startCliSession" && safeObjectValue(raw?.session)) replayResult.session = raw?.session; - return { - commandId: result.commandId, - ok: true, - result: Object.keys(replayResult).length > 0 ? replayResult : { ok: true }, - }; - } - return { - commandId: result.commandId, - ok: true, - result: { ok: true }, - }; -} - -function mobileCommandCacheKey(projectRoot: string, peer: PeerState, commandId: string): string | null { - const deviceId = peer.metadata?.deviceId ?? peer.pairedDeviceId; - if (!deviceId || !commandId) return null; - return `${projectRoot}:${deviceId}:${commandId}`; -} - -function addMobileCommandWaiter(record: CachedMobileCommand, peer: PeerState, requestId: string | null): void { - if (record.waiters.some((waiter) => waiter.peer === peer && waiter.requestId === requestId)) return; - record.waiters.push({ peer, requestId }); -} - -type SyncHostServiceArgs = { - db: AdeDb; - logger: Logger; - projectRoot: string; - fileService: ReturnType<typeof createFileService>; - laneService: ReturnType<typeof createLaneService>; - gitService?: ReturnType<typeof createGitOperationsService>; - diffService?: ReturnType<typeof createDiffService>; - conflictService?: ReturnType<typeof createConflictService>; - prService: ReturnType<typeof createPrService>; - issueInventoryService?: ReturnType<typeof createIssueInventoryService> | null; - /** Optional Path-to-Merge orchestrator (forwarded to remote command service). */ - pathToMergeOrchestrator?: PathToMergeOrchestrator | null; - queueLandingService?: ReturnType<typeof createQueueLandingService> | null; - sessionService: ReturnType<typeof createSessionService>; - ptyService: ReturnType<typeof createPtyService>; - processService?: ReturnType<typeof createProcessService>; - agentChatService?: ReturnType<typeof createAgentChatService>; - workerAgentService?: ReturnType<typeof createWorkerAgentService> | null; - workerBudgetService?: ReturnType<typeof createWorkerBudgetService> | null; - workerHeartbeatService?: ReturnType<typeof createWorkerHeartbeatService> | null; - workerRevisionService?: ReturnType<typeof createWorkerRevisionService> | null; - ctoStateService?: ReturnType<typeof createCtoStateService> | null; - flowPolicyService?: ReturnType<typeof createFlowPolicyService> | null; - linearCredentialService?: ReturnType<typeof createLinearCredentialService> | null; - getLinearIngressService?: () => ReturnType<typeof createLinearIngressService> | null; - getLinearIssueTracker?: () => ReturnType<typeof createLinearIssueTracker> | null; - getLinearSyncService?: () => ReturnType<typeof createLinearSyncService> | null; - projectConfigService?: ReturnType<typeof createProjectConfigService>; - portAllocationService?: ReturnType<typeof createPortAllocationService>; - laneEnvironmentService?: ReturnType<typeof createLaneEnvironmentService>; - laneTemplateService?: ReturnType<typeof createLaneTemplateService>; - rebaseSuggestionService?: ReturnType<typeof createRebaseSuggestionService>; - autoRebaseService?: ReturnType<typeof createAutoRebaseService>; - computerUseArtifactBrokerService: ReturnType<typeof createComputerUseArtifactBrokerService>; - pinStore: SyncPinStore; - bootstrapTokenPath?: string; - pairingSecretsPath?: string; - port?: number; - discoveryEnabled?: boolean; - heartbeatIntervalMs?: number; - pollIntervalMs?: number; - brainStatusIntervalMs?: number; - compressionThresholdBytes?: number; - deviceRegistryService?: DeviceRegistryService; - projectCatalogProvider?: { - listProjects: () => Promise<SyncProjectCatalogPayload>; - prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise<SyncProjectSwitchResultPayload>; - completeProjectConnection?: ( - args: SyncProjectSwitchRequestPayload, - result: SyncProjectSwitchResultPayload, - ) => Promise<void>; - }; - onStateChanged?: () => void; - notificationEventBus?: NotificationEventBus | null; -}; - -function sanitizeRemoteAddress(remoteAddress: string | null | undefined): string | null { - const value = toOptionalString(remoteAddress); - if (!value) return null; - return value.startsWith("::ffff:") ? value.slice("::ffff:".length) : value; -} - -function ensureBootstrapToken(filePath: string): string { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - if (!fs.existsSync(filePath)) { - fs.writeFileSync(filePath, randomBytes(24).toString("hex"), "utf8"); - } - return fs.readFileSync(filePath, "utf8").trim(); -} - -function inferMimeType(filePath: string): string | null { - const ext = path.extname(filePath).toLowerCase(); - switch (ext) { - case ".png": - return "image/png"; - case ".jpg": - case ".jpeg": - return "image/jpeg"; - case ".gif": - return "image/gif"; - case ".webp": - return "image/webp"; - case ".mp4": - return "video/mp4"; - case ".mov": - return "video/quicktime"; - case ".zip": - return "application/zip"; - case ".json": - return "application/json"; - case ".md": - return "text/markdown"; - case ".txt": - case ".log": - return "text/plain"; - case ".yaml": - case ".yml": - return "application/yaml"; - default: - return null; - } -} - -function fileContentToBlob(filePath: string, content: FileContent): SyncFileBlob { - return { - path: filePath, - size: content.size, - mimeType: content.mimeType ?? inferMimeType(filePath), - encoding: content.encoding, - isBinary: content.isBinary, - content: content.content, - languageId: content.languageId, - }; -} - -function createBlobFromBuffer(filePath: string, buf: Buffer): SyncFileBlob { - const isBinary = hasNullByte(buf); - return { - path: filePath, - size: buf.length, - mimeType: inferMimeType(filePath), - encoding: isBinary ? "base64" : "utf-8", - isBinary, - content: isBinary ? buf.toString("base64") : buf.toString("utf8"), - languageId: null, - }; -} - -function toSyncPeerConnectionState(peer: PeerState, currentServerDbVersion: number): SyncPeerConnectionState | null { - if (!peer.metadata) return null; - return { - ...peer.metadata, - connectedAt: peer.connectedAt, - lastSeenAt: peer.lastSeenAt, - lastAppliedAt: peer.lastAppliedAt, - remoteAddress: peer.remoteAddress, - remotePort: peer.remotePort, - latencyMs: peer.latencyMs, - syncLag: Math.max(0, currentServerDbVersion - peer.lastKnownServerDbVersion), - isBrain: false, - isAuthenticated: peer.authenticated, - }; -} - -export function syncHeartbeatMissLimitForPeerMetadata(metadata: Pick<SyncPeerMetadata, "platform" | "deviceType"> | null | undefined): number { - return metadata?.platform === "iOS" || metadata?.deviceType === "phone" - ? MOBILE_SYNC_HEARTBEAT_MISS_LIMIT - : DEFAULT_SYNC_HEARTBEAT_MISS_LIMIT; -} - -function parseHelloPayload(payload: unknown): SyncHelloPayload | null { - const value = payload as SyncHelloPayload | null; - const peer = value?.peer; - if (!peer || typeof peer !== "object") return null; - if (!toOptionalString(peer.deviceId) || !toOptionalString(peer.deviceName) || !toOptionalString(peer.siteId)) { - return null; - } - const auth = value?.auth; - let normalizedAuth = auth ?? null; - if (!normalizedAuth) { - const token = toOptionalString(value?.token); - if (!token) return null; - normalizedAuth = { - kind: "bootstrap", - token, - }; - } - if (normalizedAuth.kind === "bootstrap") { - if (!toOptionalString(normalizedAuth.token)) return null; - } else if (normalizedAuth.kind === "paired") { - if (!toOptionalString(normalizedAuth.deviceId) || !toOptionalString(normalizedAuth.secret)) return null; - } else { - return null; - } - return { - peer: { - deviceId: String(peer.deviceId).trim(), - deviceName: String(peer.deviceName).trim(), - platform: peer.platform ?? "unknown", - deviceType: peer.deviceType ?? "unknown", - siteId: String(peer.siteId).trim(), - dbVersion: Number(peer.dbVersion ?? 0), - capabilities: Array.isArray(peer.capabilities) - ? peer.capabilities - .filter((capability): capability is string => typeof capability === "string") - .map((capability) => capability.trim()) - .filter(Boolean) - : [], - }, - auth: normalizedAuth, - }; -} - -function parsePairingRequestPayload(payload: unknown): SyncPairingRequestPayload | null { - const value = payload as SyncPairingRequestPayload | null; - const code = toOptionalString(value?.code); - const peer = value?.peer; - if (!code || !peer || typeof peer !== "object") return null; - if (!toOptionalString(peer.deviceId) || !toOptionalString(peer.deviceName) || !toOptionalString(peer.siteId)) { - return null; - } - return { - code, - peer: { - deviceId: String(peer.deviceId).trim(), - deviceName: String(peer.deviceName).trim(), - platform: peer.platform ?? "unknown", - deviceType: peer.deviceType ?? "unknown", - siteId: String(peer.siteId).trim(), - dbVersion: Number(peer.dbVersion ?? 0), - }, - }; -} - -function shouldAttemptTailnetServiceAdvertise(): boolean { - if (process.env.ADE_TAILSCALE_SERVE === "0") return false; - if (process.env.NODE_ENV === "test" || process.env.VITEST) return false; - return process.platform === "darwin" || process.platform === "linux" || process.platform === "win32"; -} - -function looksLikePendingTailnetApproval(text: string): boolean { - return /\b(pending|approval|approve|review)\b/i.test(text); -} - -export function createSyncHostService(args: SyncHostServiceArgs) { - const layout = resolveAdeLayout(args.projectRoot); - const bootstrapTokenPath = args.bootstrapTokenPath ?? path.join(layout.secretsDir, "sync-bootstrap-token"); - const pairingSecretsPath = args.pairingSecretsPath ?? path.join(layout.secretsDir, "sync-paired-devices.json"); - const commandLedgerPath = path.join(layout.cacheDir, "sync-mobile-command-ledger.json"); - const bootstrapToken = ensureBootstrapToken(bootstrapTokenPath); - const pairingStore = createSyncPairingStore({ - filePath: pairingSecretsPath, - pinStore: args.pinStore, - }); - const remoteCommandService = createSyncRemoteCommandService({ - laneService: args.laneService, - prService: args.prService, - ptyService: args.ptyService, - sessionService: args.sessionService, - fileService: args.fileService, - gitService: args.gitService, - diffService: args.diffService, - conflictService: args.conflictService, - agentChatService: args.agentChatService, - workerAgentService: args.workerAgentService, - workerBudgetService: args.workerBudgetService, - workerHeartbeatService: args.workerHeartbeatService, - workerRevisionService: args.workerRevisionService, - ctoStateService: args.ctoStateService, - flowPolicyService: args.flowPolicyService, - linearCredentialService: args.linearCredentialService, - getLinearIngressService: args.getLinearIngressService, - getLinearIssueTracker: args.getLinearIssueTracker, - getLinearSyncService: args.getLinearSyncService, - issueInventoryService: args.issueInventoryService, - pathToMergeOrchestrator: args.pathToMergeOrchestrator, - queueLandingService: args.queueLandingService, - projectConfigService: args.projectConfigService, - processService: args.processService, - portAllocationService: args.portAllocationService, - laneEnvironmentService: args.laneEnvironmentService, - laneTemplateService: args.laneTemplateService, - rebaseSuggestionService: args.rebaseSuggestionService, - autoRebaseService: args.autoRebaseService, - logger: args.logger, - }); - const heartbeatIntervalMs = Math.max(5_000, Math.floor(args.heartbeatIntervalMs ?? DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS)); - const pollIntervalMs = Math.max(100, Math.floor(args.pollIntervalMs ?? DEFAULT_SYNC_POLL_INTERVAL_MS)); - const brainStatusIntervalMs = Math.max(1_000, Math.floor(args.brainStatusIntervalMs ?? DEFAULT_BRAIN_STATUS_INTERVAL_MS)); - const compressionThresholdBytes = Math.max(256, Math.floor(args.compressionThresholdBytes ?? DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES)); - const maxChangesetBatchBytes = 256 * 1024; - const maxChangesetBatchRows = 250; - const maxProjectCatalogEnvelopeBytes = 768 * 1024; - const maxProjectCatalogChunkBytes = 192 * 1024; - const localPresenceCommandDescriptors: SyncRemoteCommandDescriptor[] = [ - { - action: "lanes.presence.announce", - policy: { viewerAllowed: true }, - }, - { - action: "lanes.presence.release", - policy: { viewerAllowed: true }, - }, - ]; - - const readBrainMetadata = (): SyncPeerMetadata => { - const localDevice = args.deviceRegistryService?.ensureLocalDevice(); - return { - deviceId: localDevice?.deviceId ?? args.db.sync.getSiteId(), - deviceName: localDevice?.name ?? os.hostname(), - platform: localDevice?.platform ?? mapPlatform(process.platform), - deviceType: localDevice?.deviceType ?? "desktop", - siteId: localDevice?.siteId ?? args.db.sync.getSiteId(), - dbVersion: args.db.sync.getDbVersion(), - }; - }; - - const peers = new Set<PeerState>(); - const mobileCommandResultCache = new Map<string, CachedMobileCommand>(); - let commandReplayCount = 0; - let commandConflictCount = 0; - let lastCommandResultLatencyMs: number | null = null; - let lastChangesetAckLatencyMs: number | null = null; - - const pruneMobileCommandResultCache = (nowMs = Date.now()): void => { - for (const [key, record] of mobileCommandResultCache) { - if (record.completedAtMs == null) continue; - if (nowMs - record.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) { - mobileCommandResultCache.delete(key); - } - } - if (mobileCommandResultCache.size <= MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES) return; - - const completed = [...mobileCommandResultCache.entries()] - .filter(([, record]) => record.completedAtMs != null) - .sort(([, left], [, right]) => (left.completedAtMs ?? left.acceptedAtMs) - (right.completedAtMs ?? right.acceptedAtMs)); - for (const [key] of completed) { - if (mobileCommandResultCache.size <= MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES) break; - mobileCommandResultCache.delete(key); - } - }; - - const readPersistedCommandLedger = (): PersistedMobileCommand[] => { - try { - if (!fs.existsSync(commandLedgerPath)) return []; - const parsed = safeJsonParse<{ commands?: PersistedMobileCommand[] }>( - fs.readFileSync(commandLedgerPath, "utf8"), - { commands: [] }, - ); - return Array.isArray(parsed.commands) ? parsed.commands : []; - } catch (error) { - args.logger.warn("sync_host.command_ledger_read_failed", { - error: error instanceof Error ? error.message : String(error), - }); - return []; - } - }; - const writePersistedCommandLedger = (): void => { - const nowMs = Date.now(); - const commands: PersistedMobileCommand[] = []; - for (const [key, record] of mobileCommandResultCache) { - if (!record.result || record.completedAtMs == null) continue; - const persistedResult = persistedMobileCommandResult(record.action, record.result); - if (!persistedResult) continue; - if (!key.startsWith(`${args.projectRoot}:`)) continue; - if (nowMs - record.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) continue; - const deviceId = key.slice(`${args.projectRoot}:`.length).split(":")[0] ?? ""; - commands.push({ - key, - projectRoot: args.projectRoot, - deviceId, - commandId: record.commandId, - action: record.action, - argsFingerprint: record.argsFingerprint, - ack: record.ack, - result: persistedResult, - acceptedAtMs: record.acceptedAtMs, - completedAtMs: record.completedAtMs, - }); - } - commands.sort((left, right) => right.completedAtMs - left.completedAtMs); - writeTextAtomic(commandLedgerPath, `${JSON.stringify({ commands: commands.slice(0, MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES) }, null, 2)}\n`); - }; - const loadPersistedCommandLedger = (): void => { - const nowMs = Date.now(); - for (const command of readPersistedCommandLedger()) { - if (command.projectRoot !== args.projectRoot) continue; - if (nowMs - command.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) continue; - const replayResult = persistedMobileCommandResult(command.action, command.result); - if (!replayResult) continue; - const legacyArgsKey = (command as { argsKey?: unknown }).argsKey; - const argsFingerprint = typeof command.argsFingerprint === "string" - ? command.argsFingerprint - : typeof legacyArgsKey === "string" - ? mobileCommandArgsFingerprint(legacyArgsKey) - : null; - if (!argsFingerprint) continue; - mobileCommandResultCache.set(command.key, { - commandId: command.commandId, - action: command.action, - argsKey: argsFingerprint, - argsFingerprint, - ack: command.ack, - result: replayResult, - waiters: [], - acceptedAtMs: command.acceptedAtMs, - completedAtMs: command.completedAtMs, - }); - } - }; - const commandLedgerSizeForProject = (): number => - [...mobileCommandResultCache.keys()].filter((key) => key.startsWith(`${args.projectRoot}:`)).length; - const dropInFlightCommandRecordsForProject = (): void => { - for (const [key, record] of mobileCommandResultCache) { - if (!key.startsWith(`${args.projectRoot}:`)) continue; - if (record.result == null) mobileCommandResultCache.delete(key); - } - }; - loadPersistedCommandLedger(); - /** Notification preferences keyed by deviceId. The map is a hot cache; - * device metadata is the restart-safe source for offline push fan-out. */ - const notificationPrefsByDeviceId = new Map<string, NotificationPreferences>(); - const storeNotificationPrefsForDevice = (deviceId: string, prefs: NotificationPreferences): void => { - const normalizedPrefs = normalizeNotificationPreferences(prefs); - notificationPrefsByDeviceId.set(deviceId, normalizedPrefs); - args.deviceRegistryService?.setNotificationPreferences?.(deviceId, normalizedPrefs); - }; - const readNotificationPrefsForDevice = (deviceId: string): NotificationPreferences => { - return notificationPrefsByDeviceId.get(deviceId) - ?? args.deviceRegistryService?.getNotificationPreferences?.(deviceId) - ?? DEFAULT_NOTIFICATION_PREFERENCES; - }; - const lanePresenceByLaneId = new Map<string, Map<string, LanePresenceEntry>>(); - let localActiveLaneIds = new Set<string>(); - const PAIR_FAILURE_THRESHOLD = 5; - const PAIR_COOLDOWN_MS = 10 * 60_000; - const PAIR_FAILURE_WINDOW_MS = 10 * 60_000; - const pairFailures = new Map<string, { count: number; cooldownUntilMs: number; updatedAtMs: number }>(); - const pruneExpiredPairFailures = (now = Date.now()): boolean => { - let changed = false; - for (const [ip, entry] of pairFailures) { - const cooldownExpired = entry.cooldownUntilMs > 0 && entry.cooldownUntilMs <= now; - const failureWindowExpired = entry.updatedAtMs + PAIR_FAILURE_WINDOW_MS <= now; - if (cooldownExpired || failureWindowExpired) { - pairFailures.delete(ip); - changed = true; - } - } - return changed; - }; - const registerPairFailure = (ip: string | null): void => { - if (!ip) return; - const now = Date.now(); - pruneExpiredPairFailures(now); - const entry = pairFailures.get(ip) ?? { count: 0, cooldownUntilMs: 0, updatedAtMs: now }; - entry.count += 1; - entry.updatedAtMs = now; - if (entry.count >= PAIR_FAILURE_THRESHOLD) { - entry.cooldownUntilMs = now + PAIR_COOLDOWN_MS; - entry.count = 0; - } - pairFailures.set(ip, entry); - }; - const pairingCooldownMsRemaining = (ip: string | null): number => { - if (!ip) return 0; - const entry = pairFailures.get(ip); - if (!entry) return 0; - const now = Date.now(); - const remaining = entry.cooldownUntilMs - now; - if (remaining > 0) return remaining; - if ( - (entry.cooldownUntilMs > 0 && remaining <= 0) - || entry.updatedAtMs + PAIR_FAILURE_WINDOW_MS <= now - ) { - pairFailures.delete(ip); - } - return 0; - }; - - const normalizeLaneId = (laneId: string | null | undefined): string | null => { - const normalized = toOptionalString(laneId); - return normalized && normalized.length > 0 ? normalized : null; - }; - - const listLanePresenceMarkers = (laneId: string): DeviceMarker[] => { - const entries = lanePresenceByLaneId.get(laneId); - if (!entries) return []; - return [...entries.values()] - .map((entry) => entry.marker) - .sort((left, right) => left.displayName.localeCompare(right.displayName)); - }; - - const upsertLanePresence = (argsIn: { - laneId: string; - marker: DeviceMarker; - source: "local" | "remote"; - }): boolean => { - const laneId = normalizeLaneId(argsIn.laneId); - if (!laneId) return false; - const byDevice = lanePresenceByLaneId.get(laneId) ?? new Map<string, LanePresenceEntry>(); - const existing = byDevice.get(argsIn.marker.deviceId) ?? null; - const nextEntry: LanePresenceEntry = { - marker: argsIn.marker, - lastAnnouncedAtMs: Date.now(), - source: argsIn.source, - }; - byDevice.set(argsIn.marker.deviceId, nextEntry); - lanePresenceByLaneId.set(laneId, byDevice); - return ( - existing == null - || existing.source !== nextEntry.source - || existing.marker.displayName !== nextEntry.marker.displayName - || existing.marker.platform !== nextEntry.marker.platform - ); - }; - - const removeLanePresence = (laneId: string | null | undefined, deviceId: string | null | undefined): boolean => { - const normalizedLaneId = normalizeLaneId(laneId); - const normalizedDeviceId = toOptionalString(deviceId); - if (!normalizedLaneId || !normalizedDeviceId) return false; - const byDevice = lanePresenceByLaneId.get(normalizedLaneId); - if (!byDevice?.delete(normalizedDeviceId)) return false; - if (byDevice.size === 0) { - lanePresenceByLaneId.delete(normalizedLaneId); - } - return true; - }; - - const removeAllPresenceForDevice = ( - deviceId: string | null | undefined, - source?: LanePresenceEntry["source"], - ): boolean => { - const normalizedDeviceId = toOptionalString(deviceId); - if (!normalizedDeviceId) return false; - let changed = false; - for (const [laneId, byDevice] of lanePresenceByLaneId) { - const entry = byDevice.get(normalizedDeviceId); - if (!entry || (source && entry.source !== source)) continue; - byDevice.delete(normalizedDeviceId); - changed = true; - if (byDevice.size === 0) { - lanePresenceByLaneId.delete(laneId); - } - } - return changed; - }; - - const pruneExpiredLanePresence = (): boolean => { - const cutoff = Date.now() - LANE_PRESENCE_TTL_MS; - let changed = false; - for (const [laneId, byDevice] of lanePresenceByLaneId) { - for (const [deviceId, entry] of byDevice) { - if (entry.lastAnnouncedAtMs > cutoff) continue; - byDevice.delete(deviceId); - changed = true; - } - if (byDevice.size === 0) { - lanePresenceByLaneId.delete(laneId); - } - } - return changed; - }; - - const readLocalPresenceMarker = (): DeviceMarker | null => { - const localDevice = args.deviceRegistryService?.ensureLocalDevice() ?? null; - if (!localDevice) return null; - return { - deviceId: localDevice.deviceId, - displayName: localDevice.name, - platform: localDevice.platform, - }; - }; - - const refreshLocalLanePresence = (): boolean => { - if (localActiveLaneIds.size === 0) return false; - const marker = readLocalPresenceMarker(); - if (!marker) return false; - let changed = false; - for (const laneId of localActiveLaneIds) { - changed = upsertLanePresence({ - laneId, - marker, - source: "local", - }) || changed; - } - return changed; - }; - - const setLocalActiveLanePresence = (laneIds: string[]): void => { - const nextLaneIds = new Set( - laneIds - .map((laneId) => normalizeLaneId(laneId)) - .filter((laneId): laneId is string => laneId != null), - ); - const marker = readLocalPresenceMarker(); - let changed = false; - if (marker) { - for (const laneId of localActiveLaneIds) { - if (!nextLaneIds.has(laneId)) { - changed = removeLanePresence(laneId, marker.deviceId) || changed; - } - } - } - localActiveLaneIds = nextLaneIds; - if (marker) { - for (const laneId of localActiveLaneIds) { - changed = upsertLanePresence({ laneId, marker, source: "local" }) || changed; - } - } - if (changed) { - args.onStateChanged?.(); - broadcastBrainStatus(); - } - }; - - const buildRemotePresenceMarker = (peer: PeerState): DeviceMarker | null => { - if (!peer.metadata) return null; - return { - deviceId: peer.metadata.deviceId, - displayName: peer.metadata.deviceName, - platform: peer.metadata.platform, - }; - }; - - const decorateLaneSummary = (lane: LaneSummary): LaneSummary => { - const devicesOpen = listLanePresenceMarkers(lane.id); - return devicesOpen.length > 0 ? { ...lane, devicesOpen } : lane; - }; - - const decorateLaneSummaries = (lanes: LaneSummary[]): LaneSummary[] => - lanes.map((lane) => decorateLaneSummary(lane)); - - const decorateLaneListSnapshots = (snapshots: LaneListSnapshot[]): LaneListSnapshot[] => - snapshots.map((snapshot) => ({ - ...snapshot, - lane: decorateLaneSummary(snapshot.lane), - })); - - const decorateLaneDetailPayload = (detail: LaneDetailPayload): LaneDetailPayload => ({ - ...detail, - lane: decorateLaneSummary(detail.lane), - children: decorateLaneSummaries(detail.children), - }); - - const decorateCommandResult = ( - action: SyncCommandPayload["action"], - result: unknown, - ): unknown => { - pruneExpiredLanePresence(); - switch (action) { - case "lanes.list": - case "lanes.getChildren": - return Array.isArray(result) ? decorateLaneSummaries(result as LaneSummary[]) : result; - case "lanes.refreshSnapshots": { - const payload = result as - | { lanes?: LaneSummary[]; snapshots?: LaneListSnapshot[] } - | null - | undefined; - if (!payload || typeof payload !== "object") return result; - return { - ...payload, - ...(Array.isArray(payload.lanes) ? { lanes: decorateLaneSummaries(payload.lanes) } : {}), - ...(Array.isArray(payload.snapshots) - ? { snapshots: decorateLaneListSnapshots(payload.snapshots) } - : {}), - }; - } - case "lanes.getDetail": - return result && typeof result === "object" - ? decorateLaneDetailPayload(result as LaneDetailPayload) - : result; - case "lanes.create": - case "lanes.createChild": - case "lanes.createFromUnstaged": - case "lanes.importBranch": - case "lanes.attach": - case "lanes.adoptAttached": - return result && typeof result === "object" - ? decorateLaneSummary(result as LaneSummary) - : result; - default: - return result; - } - }; - const server = new WebSocketServer({ - host: "0.0.0.0", - port: args.port ?? DEFAULT_SYNC_HOST_PORT, - maxPayload: 25 * 1024 * 1024, - }); - - let disposed = false; - let startupError: Error | null = null; - let bonjourInstance: Bonjour | null = null; - let bonjourAnnouncement: BonjourService | null = null; - let bonjourPort: number | null = null; - let bonjourSignature: string | null = null; - let tailnetServeSignature: string | null = null; - let tailnetServeLastFailureSignature: string | null = null; - let tailnetServePublishSequence = 0; - let tailnetServeActivePublishToken = 0; - let discoveryEnabled = args.discoveryEnabled !== false; - let tailnetDiscoveryStatus: SyncTailnetDiscoveryStatus = { - state: !discoveryEnabled - ? "disabled" - : shouldAttemptTailnetServiceAdvertise() ? "disabled" : "unavailable", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: null, - error: !discoveryEnabled - ? "Tailnet discovery is disabled for this background project context." - : shouldAttemptTailnetServiceAdvertise() - ? "Tailnet discovery has not been published yet." - : "Tailscale Serve discovery is not available in this desktop process.", - stderr: null, - }; - let lastBroadcastAt: string | null = null; - const startedAtMs = Date.now(); - - server.on("error", (error: unknown) => { - const normalized = error instanceof Error ? error : new Error(String(error)); - if (!disposed && !server.address()) { - startupError = normalized; - } - args.logger.warn("sync_host.server_error", { - error: normalized.message, - code: (normalized as NodeJS.ErrnoException).code ?? null, - port: args.port ?? DEFAULT_SYNC_HOST_PORT, - }); - args.onStateChanged?.(); - }); - - const pollTimer = setInterval(() => { - void pumpChanges().catch((error) => { - args.logger.warn("sync_host.poll_failed", { error: error instanceof Error ? error.message : String(error) }); - }); - void pumpChatEvents().catch((error) => { - args.logger.warn("sync_host.chat_poll_failed", { error: error instanceof Error ? error.message : String(error) }); - }); - }, pollIntervalMs); - const heartbeatTimer = setInterval(() => { - pruneExpiredPairFailures(); - const refreshedLocalPresence = refreshLocalLanePresence(); - if (refreshedLocalPresence || pruneExpiredLanePresence()) { - args.onStateChanged?.(); - broadcastBrainStatus(); - } - const sentAt = nowIso(); - for (const peer of peers) { - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - if (isPeerBackpressured(peer)) { - args.logger.debug("sync_host.heartbeat_deferred_backpressure", { - peerDeviceId: peer.metadata?.deviceId ?? null, - bufferedAmount: peer.ws.bufferedAmount, - }); - continue; - } - if (peer.awaitingHeartbeatAt) { - peer.missedHeartbeatCount += 1; - if (peer.missedHeartbeatCount >= syncHeartbeatMissLimitForPeerMetadata(peer.metadata)) { - try { - peer.ws.close(4001, "Heartbeat timed out"); - } catch { - // ignore - } - continue; - } - } else { - peer.missedHeartbeatCount = 0; - } - peer.awaitingHeartbeatAt = sentAt; - send(peer.ws, "heartbeat", { kind: "ping", sentAt, dbVersion: args.db.sync.getDbVersion() }); - } - }, heartbeatIntervalMs); - const brainStatusTimer = setInterval(() => { - broadcastBrainStatus(); - }, brainStatusIntervalMs); - const chatEventSubscription = args.agentChatService?.subscribeToEvents( - (event) => { - broadcastChatEvent(event); - // Let the notification bus (mobile push fan-out) observe chat events. - // Failures here must never break chat delivery to the UI. - try { - args.notificationEventBus?.publishChatEvent(event); - } catch (error) { - args.logger.warn("sync_host.notification_publish_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } - }, - ) ?? null; - - server.on("connection", (ws, request) => { - const remoteAddress = sanitizeRemoteAddress(request.socket.remoteAddress); - const peer: PeerState = { - ws, - metadata: null, - authenticated: false, - authKind: null, - pairedDeviceId: null, - connectedAt: nowIso(), - lastSeenAt: nowIso(), - lastAppliedAt: null, - lastKnownServerDbVersion: 0, - latencyMs: null, - awaitingHeartbeatAt: null, - missedHeartbeatCount: 0, - remoteAddress, - remotePort: request.socket.remotePort ?? null, - subscribedSessionIds: new Set(), - subscribedChatSessionIds: new Set(), - chatTranscriptOffsets: new Map(), - chatEventIdsSent: new Map(), - pendingChangesetBatch: null, - }; - peers.add(peer); - ws.on("message", (raw) => { - void handleMessage(peer, raw).catch((error) => { - args.logger.warn("sync_host.message_failed", { - error: error instanceof Error ? error.message : String(error), - peerDeviceId: peer.metadata?.deviceId ?? null, - }); - }); - }); - ws.on("close", () => { - if (removeAllPresenceForDevice(peer.metadata?.deviceId, "remote")) { - broadcastBrainStatus(); - } - peers.delete(peer); - args.onStateChanged?.(); - broadcastBrainStatus(); - }); - ws.on("error", (error) => { - args.logger.warn("sync_host.socket_error", { - error: error instanceof Error ? error.message : String(error), - peerDeviceId: peer.metadata?.deviceId ?? null, - }); - }); - }); - - const publishLanDiscovery = (port: number): void => { - if (disposed) return; - if (!discoveryEnabled) { - unpublishLanDiscovery(); - return; - } - const localDevice = args.deviceRegistryService?.ensureLocalDevice() ?? null; - const hostName = localDevice?.name ?? os.hostname(); - const tailscaleDnsName = - typeof localDevice?.metadata?.tailscaleDnsName === "string" - ? localDevice.metadata.tailscaleDnsName.trim().replace(/\.$/, "").toLowerCase() - : ""; - const ipAddresses = uniqueStrings([ - ...(localDevice?.ipAddresses ?? []), - localDevice?.tailscaleIp ?? null, - ].filter((value): value is string => typeof value === "string" && value.trim().length > 0)); - const addressesCsv = ipAddresses.length > 0 ? ipAddresses.join(",") : "127.0.0.1"; - const preferredHost = ipAddresses[0] ?? localDevice?.lastHost ?? ""; - const txt = { - version: "1", - deviceId: localDevice?.deviceId ?? "", - siteId: localDevice?.siteId ?? "", - deviceName: hostName, - port: String(port), - host: preferredHost, - addresses: addressesCsv, - tailscaleIp: localDevice?.tailscaleIp ?? "", - tailscaleDnsName: tailscaleDnsName.endsWith(".ts.net") ? tailscaleDnsName : "", - }; - const signature = JSON.stringify({ hostName, port, txt }); - if (bonjourAnnouncement && bonjourPort === port && bonjourSignature === signature) return; - if (!bonjourInstance) { - bonjourInstance = new Bonjour(undefined, (error: unknown) => { - args.logger.warn("sync_host.discovery_error", { - error: error instanceof Error ? error.message : String(error), - }); - }); - } - if (bonjourAnnouncement) { - try { - bonjourAnnouncement.stop?.(); - } catch { - // ignore cleanup failures - } - bonjourAnnouncement = null; - } - bonjourPort = port; - bonjourSignature = signature; - bonjourAnnouncement = bonjourInstance.publish({ - name: `ADE Sync ${hostName} ${port}`, - type: SYNC_MDNS_SERVICE_TYPE, - protocol: "tcp", - port, - txt, - disableIPv6: true, - }); - bonjourAnnouncement.on("error", (error: unknown) => { - args.logger.warn("sync_host.discovery_publish_failed", { - error: error instanceof Error ? error.message : String(error), - }); - }); - }; - - const unpublishLanDiscovery = (): void => { - if (!bonjourAnnouncement) return; - try { - bonjourAnnouncement.stop?.(); - } catch { - // ignore cleanup failures - } - bonjourAnnouncement = null; - bonjourPort = null; - bonjourSignature = null; - }; - - const updateTailnetDiscoveryStatus = ( - next: SyncTailnetDiscoveryStatus, - ): void => { - tailnetDiscoveryStatus = next; - setTimeout(() => { - if (!disposed) args.onStateChanged?.(); - }, 0); - }; - - const publishTailnetDiscovery = ( - port: number, - options?: { force?: boolean }, - ): void => { - if (disposed) return; - if (!discoveryEnabled) { - void unpublishTailnetDiscovery(); - updateTailnetDiscoveryStatus({ - state: "disabled", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: nowIso(), - error: "Tailnet discovery is disabled for this background project context.", - stderr: null, - }); - return; - } - if (!shouldAttemptTailnetServiceAdvertise()) { - updateTailnetDiscoveryStatus({ - state: "unavailable", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: nowIso(), - error: "Tailscale Serve discovery is not available in this desktop process.", - stderr: null, - }); - return; - } - const cli = resolveTailscaleCliPath(); - const signature = `${SYNC_TAILNET_DISCOVERY_SERVICE_NAME}:${SYNC_TAILNET_DISCOVERY_SERVICE_PORT}->${port}`; - if (tailnetServeSignature === signature && !options?.force) return; - if (tailnetServeLastFailureSignature === signature && !options?.force) return; - const publishToken = ++tailnetServePublishSequence; - tailnetServeActivePublishToken = publishToken; - tailnetServeSignature = signature; - const target = `tcp://127.0.0.1:${port}`; - updateTailnetDiscoveryStatus({ - state: "publishing", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target, - updatedAt: nowIso(), - error: null, - stderr: null, - }); - const cliArgs = [ - "serve", - "--yes", - `--service=${SYNC_TAILNET_DISCOVERY_SERVICE_NAME}`, - `--tcp=${SYNC_TAILNET_DISCOVERY_SERVICE_PORT}`, - target, - ]; - void execFileAsync(cli, cliArgs, { timeout: 10_000 }) - .then(({ stdout, stderr }) => { - if (tailnetServeActivePublishToken !== publishToken) return; - tailnetServeLastFailureSignature = null; - const stdoutText = stdout.trim(); - const stderrText = stderr.trim(); - const outputText = [stdoutText, stderrText].filter(Boolean).join("\n"); - updateTailnetDiscoveryStatus({ - state: looksLikePendingTailnetApproval(outputText) ? "pending_approval" : "published", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target, - updatedAt: nowIso(), - error: null, - stderr: stderrText || null, - }); - args.logger.info("sync_host.tailnet_discovery_published", { - service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target, - stdout: stdoutText || null, - stderr: stderrText || null, - }); - }) - .catch((error: unknown) => { - if (tailnetServeActivePublishToken !== publishToken) return; - if (tailnetServeSignature === signature) { - tailnetServeSignature = null; - } - tailnetServeLastFailureSignature = signature; - const errorMessage = error instanceof Error ? error.message : String(error); - const code = (error as NodeJS.ErrnoException | null | undefined)?.code ?? null; - const stderr = typeof (error as { stderr?: unknown })?.stderr === "string" - ? String((error as { stderr?: string }).stderr).trim() - : null; - const errorText = [errorMessage, stderr].filter(Boolean).join("\n"); - updateTailnetDiscoveryStatus({ - state: code === "ENOENT" - ? "unavailable" - : looksLikePendingTailnetApproval(errorText) - ? "pending_approval" - : "failed", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target, - updatedAt: nowIso(), - error: code === "ENOENT" ? "Tailscale CLI was not found." : errorMessage, - stderr, - }); - const logPayload = { - service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target, - error: errorMessage, - code, - stderr, - }; - if (code === "ENOENT") { - args.logger.info("sync_host.tailnet_discovery_unavailable", logPayload); - } else { - args.logger.warn("sync_host.tailnet_discovery_failed", logPayload); - } - }); - }; - - const unpublishTailnetDiscovery = async (): Promise<void> => { - if (!tailnetServeSignature) return; - tailnetServeActivePublishToken = ++tailnetServePublishSequence; - tailnetServeSignature = null; - if (!shouldAttemptTailnetServiceAdvertise()) { - updateTailnetDiscoveryStatus({ - state: "unavailable", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: nowIso(), - error: null, - stderr: null, - }); - return; - } - const cli = resolveTailscaleCliPath(); - try { - await execFileAsync( - cli, - ["serve", "--yes", `--service=${SYNC_TAILNET_DISCOVERY_SERVICE_NAME}`, "off"], - { timeout: 10_000 }, - ); - updateTailnetDiscoveryStatus({ - state: "disabled", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: nowIso(), - error: null, - stderr: null, - }); - args.logger.info("sync_host.tailnet_discovery_unpublished", { - service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - }); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - const code = (error as NodeJS.ErrnoException | null | undefined)?.code ?? null; - updateTailnetDiscoveryStatus({ - state: code === "ENOENT" ? "unavailable" : "disabled", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: nowIso(), - error: code === "ENOENT" ? "Tailscale CLI was not found." : errorMessage, - stderr: null, - }); - args.logger.warn("sync_host.tailnet_discovery_unpublish_failed", { - service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - error: errorMessage, - code, - }); - } - }; - - function send<TPayload>(target: WebSocket | PeerState, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): boolean { - const ws = target instanceof WebSocket ? target : target.ws; - if (ws.readyState !== WebSocket.OPEN) return false; - // Drop sends to backpressured peers as the default — most envelopes are - // either replayable (chat events / changesets re-derived from db state) or - // tolerable to lose (acks, status pings). Routes that *must* deliver under - // backpressure should call ws.send / sendAndWait directly. - if (target instanceof WebSocket ? ws.bufferedAmount >= PEER_BACKPRESSURE_BYTES : isPeerBackpressured(target)) { - return false; - } - ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes })); - return true; - } - - function sendRequired<TPayload>(peer: PeerState, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): boolean { - const ws = peer.ws; - if (ws.readyState !== WebSocket.OPEN) return false; - ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), (error) => { - if (!error) return; - args.logger.warn("sync_host.required_send_failed", { - type, - requestId: requestId ?? null, - peerDeviceId: peer.metadata?.deviceId ?? peer.pairedDeviceId ?? null, - error: error.message, - }); - }); - return true; - } - - function isPeerBackpressured(peer: PeerState): boolean { - return peer.ws.bufferedAmount >= PEER_BACKPRESSURE_BYTES; - } - - function sendAndWait<TPayload>( - ws: WebSocket, - type: SyncEnvelope["type"], - payload: TPayload, - requestId?: string | null, - ): Promise<void> { - if (ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) { - return Promise.reject(new Error("Cannot send on closed WebSocket.")); - } - return new Promise<void>((resolve, reject) => { - ws.send( - encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), - (error) => { - if (error) reject(error); - else resolve(); - }, - ); - }); - } - - function encodedEnvelopeBytes<TPayload>( - type: SyncEnvelope["type"], - payload: TPayload, - requestId?: string | null, - ): number { - return Buffer.byteLength(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), "utf8"); - } - - function closeExistingPeersForDevice(deviceId: string, currentPeer: PeerState): void { - const normalized = toOptionalString(deviceId); - if (!normalized) return; - for (const peer of peers) { - if (peer === currentPeer) continue; - if (peer.metadata?.deviceId !== normalized && peer.pairedDeviceId !== normalized) continue; - peer.authenticated = false; - peer.metadata = null; - peer.authKind = null; - peer.pairedDeviceId = null; - try { - peer.ws.close(4000, "Superseded by a newer connection for this device"); - } catch { - // ignore close failures - } - } - } - - function makeChangesetBatchId(peer: PeerState, fromDbVersion: number, toDbVersion: number): string { - const deviceId = peer.metadata?.deviceId ?? peer.pairedDeviceId ?? "peer"; - return `changeset:${deviceId}:${fromDbVersion}:${toDbVersion}:${Date.now()}:${randomBytes(4).toString("hex")}`; - } - - function peerSupportsChangesetAck(peer: PeerState): boolean { - return Array.isArray(peer.metadata?.capabilities) && peer.metadata.capabilities.includes("changesetAck"); - } - - function sendNextChangesetBatch( - peer: PeerState, - reason: SyncChangesetBatchPayload["reason"], - fromDbVersion: number, - toDbVersion: number, - changes: CrsqlChangeRow[], - ): PendingChangesetBatch | null { - let chunk: CrsqlChangeRow[] = []; - let chunkBytes = 0; - - for (const change of changes) { - const changeBytes = Buffer.byteLength(JSON.stringify(change), "utf8"); - if ( - chunk.length > 0 - && (chunk.length >= maxChangesetBatchRows || chunkBytes + changeBytes > maxChangesetBatchBytes) - ) { - break; - } - chunk.push(change); - chunkBytes += changeBytes; - } - if (chunk.length === 0 && changes.length > 0) { - chunk = [changes[0]!]; - } - if (chunk.length === 0 && toDbVersion <= fromDbVersion) return null; - - const chunkToDbVersion = chunk.length > 0 - ? Math.max(...chunk.map((change) => Number(change.db_version ?? fromDbVersion))) - : toDbVersion; - const batch: PendingChangesetBatch = { - batchId: makeChangesetBatchId(peer, fromDbVersion, chunkToDbVersion), - reason, - fromDbVersion, - toDbVersion: chunkToDbVersion, - changes: chunk, - sentAtMs: Date.now(), - retryCount: 0, - }; - const sent = send(peer, "changeset_batch", { - batchId: batch.batchId, - reason, - fromDbVersion, - toDbVersion: chunkToDbVersion, - changes: chunk, - }); - return sent ? batch : null; - } - - function resendPendingChangesetBatch(peer: PeerState): boolean { - const batch = peer.pendingChangesetBatch; - if (!batch) return false; - batch.sentAtMs = Date.now(); - batch.retryCount += 1; - return send(peer, "changeset_batch", { - batchId: batch.batchId, - reason: batch.reason, - fromDbVersion: batch.fromDbVersion, - toDbVersion: batch.toDbVersion, - changes: batch.changes, - }); - } - - async function buildProjectCatalogPayload(): Promise<SyncProjectCatalogPayload> { - if (!args.projectCatalogProvider) { - return { projects: [] }; - } - try { - return await args.projectCatalogProvider.listProjects(); - } catch (error) { - args.logger.warn("sync_host.project_catalog_failed", { - error: error instanceof Error ? error.message : String(error), - }); - return { projects: [] }; - } - } - - function splitProjectCatalog(projects: SyncMobileProjectSummary[]): SyncMobileProjectSummary[][] { - const chunks: SyncMobileProjectSummary[][] = []; - let chunk: SyncMobileProjectSummary[] = []; - let chunkBytes = 0; - - const flush = (): void => { - if (chunk.length === 0) return; - chunks.push(chunk); - chunk = []; - chunkBytes = 0; - }; - - for (const project of projects) { - const projectBytes = Buffer.byteLength(JSON.stringify(project), "utf8"); - if (chunk.length > 0 && chunkBytes + projectBytes > maxProjectCatalogChunkBytes) { - flush(); - } - chunk.push(project); - chunkBytes += projectBytes; - } - flush(); - return chunks; - } - - function projectsForHello(projectCatalog: SyncProjectCatalogPayload): SyncMobileProjectSummary[] { - const payload = { - peer: readBrainMetadata(), - brain: readBrainMetadata(), - serverDbVersion: args.db.sync.getDbVersion(), - heartbeatIntervalMs, - pollIntervalMs, - projects: projectCatalog.projects, - features: {}, - }; - return encodedEnvelopeBytes("hello_ok", payload) <= maxProjectCatalogEnvelopeBytes - ? projectCatalog.projects - : []; - } - - function sendProjectCatalog( - peer: PeerState, - projectCatalog: SyncProjectCatalogPayload, - requestId?: string | null, - ): void { - if (encodedEnvelopeBytes("project_catalog", projectCatalog, requestId) <= maxProjectCatalogEnvelopeBytes) { - send(peer.ws, "project_catalog", projectCatalog, requestId); - return; - } - - const chunks = splitProjectCatalog(projectCatalog.projects); - const total = Math.max(1, chunks.length); - const catalogId = randomBytes(8).toString("hex"); - if (chunks.length === 0) { - send(peer.ws, "project_catalog_chunk", { - catalogId, - index: 0, - total, - done: true, - projects: [], - } satisfies SyncProjectCatalogChunkPayload, requestId); - return; - } - - chunks.forEach((projects, index) => { - send(peer.ws, "project_catalog_chunk", { - catalogId, - index, - total, - done: index === total - 1, - projects, - } satisfies SyncProjectCatalogChunkPayload, requestId); - }); - } - - async function handleProjectSwitchRequest( - peer: PeerState, - requestId: string | null | undefined, - payload: SyncProjectSwitchRequestPayload | null, - ): Promise<void> { - if (!args.projectCatalogProvider) { - sendRequired(peer, "project_switch_result", { - ok: false, - message: "Desktop project switching is not available.", - }, requestId); - return; - } - try { - const result = await args.projectCatalogProvider.prepareProjectConnection(payload ?? {}); - await sendAndWait(peer.ws, "project_switch_result", result, requestId); - try { - await args.projectCatalogProvider.completeProjectConnection?.(payload ?? {}, result); - } catch (completionError) { - args.logger.warn("sync_host.project_switch_completion_failed", { - message: completionError instanceof Error ? completionError.message : String(completionError), - }); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - args.logger.warn("sync_host.project_switch_failed", { message }); - sendRequired(peer, "project_switch_result", { - ok: false, - message, - }, requestId); - } - } - - function buildBrainStatus(): SyncBrainStatusPayload { - const brainMetadata = readBrainMetadata(); - if (disposed) { - return { - brain: brainMetadata, - connectedPeers: [], - metrics: { - connectedPeerCount: 0, - runningSessionCount: 0, - dbVersion: brainMetadata.dbVersion, - uptimeMs: Date.now() - startedAtMs, - lastBroadcastAt, - pendingChangesetPeerCount: 0, - commandLedgerSize: commandLedgerSizeForProject(), - commandReplayCount, - commandConflictCount, - lastCommandResultLatencyMs, - lastChangesetAckLatencyMs, - }, - }; - } - const dbVersion = args.db.sync.getDbVersion(); - const connectedPeers = [...peers] - .map((peer) => toSyncPeerConnectionState(peer, dbVersion)) - .filter((peer): peer is SyncPeerConnectionState => peer != null); - return { - brain: { - ...brainMetadata, - dbVersion, - }, - connectedPeers, - metrics: { - connectedPeerCount: connectedPeers.length, - runningSessionCount: args.sessionService.list({ status: "running", limit: 200 }).length, - dbVersion, - uptimeMs: Date.now() - startedAtMs, - lastBroadcastAt, - pendingChangesetPeerCount: [...peers].filter((peer) => peer.pendingChangesetBatch != null).length, - commandLedgerSize: commandLedgerSizeForProject(), - commandReplayCount, - commandConflictCount, - lastCommandResultLatencyMs, - lastChangesetAckLatencyMs, - }, - }; - } - - function broadcastBrainStatus(): void { - if (disposed) return; - const payload = buildBrainStatus(); - for (const peer of peers) { - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - send(peer.ws, "brain_status", payload); - } - } - - async function readChatTranscriptEventsSince( - transcriptPath: string, - startOffset: number, - ): Promise<{ events: AgentChatEventEnvelope[]; nextOffset: number }> { - let fh: fs.promises.FileHandle | null = null; - try { - fh = await fs.promises.open(transcriptPath, "r"); - const stat = await fh.stat(); - const size = stat.size; - const normalizedStart = Math.max(0, Math.min(startOffset, size)); - if (size <= normalizedStart) { - return { events: [], nextOffset: size }; - } - - const out = Buffer.alloc(size - normalizedStart); - await fh.read(out, 0, out.length, normalizedStart); - const lastNewline = out.lastIndexOf(0x0a); - if (lastNewline < 0) { - return { events: [], nextOffset: normalizedStart }; - } - - const completeSlice = out.subarray(0, lastNewline + 1); - const raw = completeSlice.toString("utf8"); - return { - events: parseAgentChatTranscript(raw), - nextOffset: normalizedStart + completeSlice.length, - }; - } catch { - return { events: [], nextOffset: Math.max(0, startOffset) }; - } finally { - await fh?.close().catch(() => {}); - } - } - - function chatEventDeliveryKey(event: AgentChatEventEnvelope): string { - return `${event.sessionId}:${event.sequence ?? -1}:${event.timestamp}:${event.event.type}`; - } - - function rememberChatEventSent(peer: PeerState, event: AgentChatEventEnvelope): boolean { - const key = chatEventDeliveryKey(event); - let sent = peer.chatEventIdsSent.get(event.sessionId); - if (!sent) { - sent = new Set(); - peer.chatEventIdsSent.set(event.sessionId, sent); - } - if (sent.has(key)) return false; - sent.add(key); - if (sent.size > 800) { - const overflow = sent.size - 800; - let removed = 0; - for (const existingKey of sent) { - sent.delete(existingKey); - removed += 1; - if (removed >= overflow) break; - } - } - return true; - } - - async function pumpChatEvents(): Promise<void> { - if (disposed) return; - - for (const peer of peers) { - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - if (isPeerBackpressured(peer)) continue; - for (const sessionId of peer.subscribedChatSessionIds) { - const session = args.sessionService.get(sessionId); - if (!session?.transcriptPath) continue; - - const startOffset = peer.chatTranscriptOffsets.get(sessionId) ?? 0; - const { events, nextOffset } = await readChatTranscriptEventsSince(session.transcriptPath, startOffset); - if (nextOffset !== startOffset) { - peer.chatTranscriptOffsets.set(sessionId, nextOffset); - } - for (const event of events) { - if (!rememberChatEventSent(peer, event)) continue; - send(peer.ws, "chat_event", event); - } - } - } - } - - function broadcastChatEvent(event: AgentChatEventEnvelope): void { - for (const peer of peers) { - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - if (isPeerBackpressured(peer)) continue; - if (!peer.subscribedChatSessionIds.has(event.sessionId)) continue; - if (!rememberChatEventSent(peer, event)) continue; - send(peer.ws, "chat_event", event); - } - } - - async function pumpChanges(): Promise<void> { - if (disposed) return; - const currentDbVersion = args.db.sync.getDbVersion(); - const nowMs = Date.now(); - for (const peer of peers) { - if (!peer.authenticated || !peer.metadata || peer.ws.readyState !== WebSocket.OPEN) continue; - if (isPeerBackpressured(peer)) continue; - if (peer.pendingChangesetBatch) { - if (nowMs - peer.pendingChangesetBatch.sentAtMs >= CHANGESET_ACK_TIMEOUT_MS) { - const pending = peer.pendingChangesetBatch; - if (pending.retryCount >= MAX_CHANGESET_ACK_RETRIES) { - args.logger.warn("sync_host.changeset_ack_timeout", { - peerDeviceId: peer.metadata.deviceId, - batchId: pending.batchId, - fromDbVersion: pending.fromDbVersion, - toDbVersion: pending.toDbVersion, - retryCount: pending.retryCount, - }); - try { - peer.ws.close(4000, "Changeset acknowledgement timed out"); - } catch { - // ignore close failures - } - continue; - } - const resent = resendPendingChangesetBatch(peer); - args.logger.debug("sync_host.changeset_ack_retry", { - peerDeviceId: peer.metadata.deviceId, - batchId: pending.batchId, - fromDbVersion: pending.fromDbVersion, - toDbVersion: pending.toDbVersion, - retryCount: pending.retryCount, - resent, - }); - } - continue; - } - if (currentDbVersion <= peer.lastKnownServerDbVersion) continue; - const changes = args.db.sync - .exportChangesSince(peer.lastKnownServerDbVersion) - .filter((change: CrsqlChangeRow) => change.site_id !== peer.metadata?.siteId); - const pending = sendNextChangesetBatch(peer, "broadcast", peer.lastKnownServerDbVersion, currentDbVersion, changes); - if (pending) { - if (peerSupportsChangesetAck(peer)) { - peer.pendingChangesetBatch = pending; - } else { - peer.lastKnownServerDbVersion = Math.max(peer.lastKnownServerDbVersion, pending.toDbVersion); - } - lastBroadcastAt = nowIso(); - } else { - args.logger.debug("sync_host.changeset_deferred_backpressure", { - peerDeviceId: peer.metadata?.deviceId ?? null, - fromDbVersion: peer.lastKnownServerDbVersion, - toDbVersion: currentDbVersion, - bufferedAmount: peer.ws.bufferedAmount, - }); - } - } - } - - function handleChangesetAck(peer: PeerState, payload: SyncChangesetAckPayload | null | undefined): void { - const pending = peer.pendingChangesetBatch; - if (!pending || !payload) return; - if (payload.batchId !== pending.batchId) { - args.logger.debug("sync_host.changeset_ack_ignored", { - peerDeviceId: peer.metadata?.deviceId ?? null, - expectedBatchId: pending.batchId, - receivedBatchId: payload.batchId, - }); - return; - } - if (!payload.ok) { - pending.retryCount += 1; - pending.sentAtMs = Date.now(); - args.logger.warn("sync_host.changeset_ack_failed", { - peerDeviceId: peer.metadata?.deviceId ?? null, - batchId: pending.batchId, - fromDbVersion: pending.fromDbVersion, - toDbVersion: pending.toDbVersion, - retryCount: pending.retryCount, - error: payload.error?.message ?? "Changeset apply failed.", - }); - if (pending.retryCount >= MAX_CHANGESET_ACK_RETRIES) { - try { - peer.ws.close(4000, "Changeset apply failed repeatedly"); - } catch { - // ignore close failures - } - } - return; - } - if (payload.toDbVersion < pending.toDbVersion) return; - peer.lastKnownServerDbVersion = Math.max(peer.lastKnownServerDbVersion, pending.toDbVersion); - peer.pendingChangesetBatch = null; - peer.lastAppliedAt = nowIso(); - lastChangesetAckLatencyMs = Math.max(0, Date.now() - pending.sentAtMs); - args.logger.debug("sync_host.changeset_ack_applied", { - peerDeviceId: peer.metadata?.deviceId ?? null, - batchId: pending.batchId, - fromDbVersion: pending.fromDbVersion, - toDbVersion: pending.toDbVersion, - latencyMs: lastChangesetAckLatencyMs, - }); - broadcastBrainStatus(); - } - - function resolveArtifactPath(request: Extract<SyncFileRequest, { action: "readArtifact" }>["args"]): string { - const artifactId = toOptionalString(request.artifactId); - const explicitUri = toOptionalString(request.uri) ?? toOptionalString(request.path); - let candidate = explicitUri; - if (artifactId) { - const artifact = args.computerUseArtifactBrokerService.listArtifacts({ artifactId })[0] ?? null; - candidate = artifact?.uri ?? candidate; - } - if (!candidate) { - throw new Error("Artifact request requires artifactId, uri, or path."); - } - if (/^https?:\/\//i.test(candidate)) { - throw new Error("Remote artifact URLs are not supported by the desktop sync host."); - } - if (/^file:\/\//i.test(candidate)) { - try { - candidate = fileURLToPath(candidate); - } catch { - throw new Error("Artifact file URL is invalid."); - } - } - const absolute = path.isAbsolute(candidate) - ? candidate - : path.resolve(args.projectRoot, candidate); - let resolvedArtifactPath: string; - try { - resolvedArtifactPath = resolvePathWithinRoot(layout.artifactsDir, absolute); - } catch { - throw new Error("Artifact path must resolve within .ade/artifacts."); - } - if (!fs.existsSync(resolvedArtifactPath) || !fs.statSync(resolvedArtifactPath).isFile()) { - throw new Error("Artifact file does not exist."); - } - return resolvedArtifactPath; - } - - function isMobilePeer(peer: PeerState): boolean { - return peer.metadata?.platform === "iOS" || peer.metadata?.deviceType === "phone"; - } - - function assertMobileFileMutationAllowed(peer: PeerState, payload: SyncFileRequest): void { - if (!MOBILE_MUTATING_FILE_ACTIONS.has(payload.action)) return; - if (!isMobilePeer(peer)) return; - - const workspaceId = toOptionalString((payload as { args?: { workspaceId?: unknown } }).args?.workspaceId); - if (!workspaceId) return; - const workspace = args.fileService.listWorkspaces({ includeArchived: true }) - .find((entry) => entry.id === workspaceId); - if (!workspace || workspace.mobileReadOnly === true || workspace.isReadOnlyByDefault) { - throw new Error("Mobile file access is read-only for this workspace."); - } - } - - function isMobileLaneFileMutationBlocked(payload: SyncCommandPayload): boolean { - const laneId = toOptionalString((payload.args as Record<string, unknown> | null | undefined)?.laneId); - if (!laneId) return false; - const workspace = args.fileService.listWorkspaces({ includeArchived: true }) - .find((entry) => entry.laneId === laneId); - return workspace ? workspace.mobileReadOnly === true || workspace.isReadOnlyByDefault : true; - } - - async function handleFileRequest(peer: PeerState, requestId: string | null, payload: SyncFileRequest): Promise<void> { - const respond = (response: SyncFileResponsePayload) => { - sendRequired(peer, "file_response", response, requestId); - }; - - try { - assertMobileFileMutationAllowed(peer, payload); - let result: - | FilesWorkspace[] - | FileTreeNode[] - | FileContent - | FilesQuickOpenItem[] - | FilesSearchTextMatch[] - | SyncFileBlob - | { ok: true } = { ok: true }; - - switch (payload.action) { - case "listWorkspaces": - result = args.fileService.listWorkspaces(payload.args ?? {}); - break; - case "listTree": - result = await args.fileService.listTree(payload.args); - break; - case "readFile": - result = fileContentToBlob(payload.args.path, args.fileService.readFile(payload.args)); - break; - case "writeText": - args.fileService.writeWorkspaceText(payload.args); - result = { ok: true }; - break; - case "createFile": - args.fileService.createFile(payload.args); - result = { ok: true }; - break; - case "createDirectory": - args.fileService.createDirectory(payload.args); - result = { ok: true }; - break; - case "rename": - args.fileService.rename(payload.args); - result = { ok: true }; - break; - case "deletePath": - args.fileService.deletePath(payload.args); - result = { ok: true }; - break; - case "quickOpen": - result = await args.fileService.quickOpen(payload.args); - break; - case "searchText": - result = await args.fileService.searchText(payload.args); - break; - case "readArtifact": { - const artifactPath = resolveArtifactPath(payload.args); - result = createBlobFromBuffer(normalizeRelative(path.relative(args.projectRoot, artifactPath)), fs.readFileSync(artifactPath)); - break; - } - default: - throw new Error(`Unsupported file action: ${(payload as { action?: string }).action ?? "unknown"}`); - } - - respond({ - ok: true, - action: payload.action, - result, - }); - } catch (error) { - respond({ - ok: false, - action: payload.action, - error: { - code: "file_request_failed", - message: error instanceof Error ? error.message : String(error), - }, - }); - } - } - - async function handleCommand(peer: PeerState, requestId: string | null, payload: SyncCommandPayload): Promise<void> { - const commandId = toOptionalString(payload.commandId) ?? requestId ?? `cmd-${Date.now()}`; - const commandCacheKey = mobileCommandCacheKey(args.projectRoot, peer, commandId); - const commandArgsKey = stableJsonKey(payload.args ?? {}); - const commandArgsFingerprint = mobileCommandArgsFingerprint(commandArgsKey); - pruneMobileCommandResultCache(); - - const sendResult = (record: CachedMobileCommand | null, result: SyncCommandResultPayload) => { - if (!record) { - sendRequired(peer, "command_result", result, requestId); - return; - } - record.result = result; - record.completedAtMs = Date.now(); - lastCommandResultLatencyMs = Math.max(0, record.completedAtMs - record.acceptedAtMs); - const waiters = record.waiters.splice(0); - for (const waiter of waiters) { - sendRequired(waiter.peer, "command_result", result, waiter.requestId); - } - pruneMobileCommandResultCache(); - try { - writePersistedCommandLedger(); - } catch (error) { - args.logger.warn("sync_host.command_ledger_write_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } - }; - const startCommandRecord = (ack: SyncCommandAckPayload): CachedMobileCommand | null => { - sendRequired(peer, "command_ack", ack, requestId); - if (!commandCacheKey) return null; - const record: CachedMobileCommand = { - commandId, - action: payload.action, - argsKey: commandArgsKey, - argsFingerprint: commandArgsFingerprint, - ack, - result: null, - waiters: [{ peer, requestId }], - acceptedAtMs: Date.now(), - completedAtMs: null, - }; - mobileCommandResultCache.set(commandCacheKey, record); - return record; - }; - const existingCommand = commandCacheKey ? mobileCommandResultCache.get(commandCacheKey) : null; - if (existingCommand) { - if (existingCommand.action !== payload.action || existingCommand.argsFingerprint !== commandArgsFingerprint) { - commandConflictCount += 1; - const mismatchResult: SyncCommandResultPayload = { - commandId, - ok: false, - error: { - code: "duplicate_command_mismatch", - message: "A command with this id already exists for a different action or payload.", - }, - }; - sendRequired(peer, "command_ack", { - commandId, - accepted: false, - status: "rejected", - message: mismatchResult.error?.message ?? null, - }, requestId); - sendRequired(peer, "command_result", mismatchResult, requestId); - return; - } - commandReplayCount += 1; - sendRequired(peer, "command_ack", existingCommand.ack, requestId); - if (existingCommand.result) { - sendRequired(peer, "command_result", existingCommand.result, requestId); - } else { - addMobileCommandWaiter(existingCommand, peer, requestId); - } - return; - } - - const reject = (message: string, code = "unsupported_command") => { - const ack: SyncCommandAckPayload = { - commandId, - accepted: false, - status: "rejected", - message, - }; - const result: SyncCommandResultPayload = { - commandId, - ok: false, - error: { - code, - message, - }, - }; - sendResult(startCommandRecord(ack), result); - }; - - const policy = remoteCommandService.getPolicy(payload.action); - if (payload.action === "notification_prefs") { - // iOS bridges `SyncService.setMutePush` through the command envelope - // rather than a second `notification_prefs` envelope. We translate by - // merging `{ muteUntil }` into the device's existing prefs (or the - // default prefs if none have been uploaded yet) so the notification - // bus starts gating immediately — the same `isAllowedByPrefs` path the - // envelope-based update feeds. - const deviceId = peer.metadata?.deviceId; - if (!deviceId) { - reject("notification_prefs requires an authenticated device.", "invalid_command"); - return; - } - const rawArgs = (payload.args as Record<string, unknown> | null | undefined) ?? {}; - const rawMute = rawArgs.muteUntil; - const muteUntil = typeof rawMute === "string" && rawMute.length > 0 ? rawMute : null; - const existing = readNotificationPrefsForDevice(deviceId); - storeNotificationPrefsForDevice(deviceId, { ...existing, muteUntil }); - const ack: SyncCommandAckPayload = { - commandId, - accepted: true, - status: "accepted", - message: muteUntil ? `Muted pushes until ${muteUntil}.` : "Cleared push mute.", - }; - sendResult(startCommandRecord(ack), { - commandId, - ok: true, - result: { ok: true, muteUntil }, - }); - return; - } - if (payload.action === "lanes.presence.announce" || payload.action === "lanes.presence.release") { - const laneId = normalizeLaneId((payload.args as Record<string, unknown> | null | undefined)?.laneId as string | null); - if (!laneId) { - reject(`${payload.action} requires laneId.`, "invalid_command"); - return; - } - const marker = buildRemotePresenceMarker(peer); - if (!marker) { - reject("Lane presence requires authenticated peer metadata.", "invalid_command"); - return; - } - const changed = payload.action === "lanes.presence.announce" - ? upsertLanePresence({ laneId, marker, source: "remote" }) - : removeLanePresence(laneId, marker.deviceId); - if (changed) { - args.onStateChanged?.(); - broadcastBrainStatus(); - } - const ack: SyncCommandAckPayload = { - commandId, - accepted: true, - status: "accepted", - message: payload.action === "lanes.presence.announce" - ? `Marked ${laneId} as open on ${marker.displayName}.` - : `Released ${laneId} on ${marker.displayName}.`, - }; - sendResult(startCommandRecord(ack), { - commandId, - ok: true, - result: { ok: true }, - }); - return; - } - if (!policy) { - reject(`Unsupported remote command: ${payload.action}.`); - return; - } - if (!policy.viewerAllowed) { - reject(`Remote command ${payload.action} is not available to paired controller devices.`, "forbidden_command"); - return; - } - if (payload.action === "files.writeTextAtomic" && isMobilePeer(peer) && isMobileLaneFileMutationBlocked(payload)) { - reject("Mobile file access is read-only for this workspace.", "mobile_read_only"); - return; - } - if (policy.localOnly || policy.requiresApproval) { - reject(`Remote command ${payload.action} requires approval on the desktop.`, "approval_required"); - return; - } - - const acceptedRecord = startCommandRecord({ - commandId, - accepted: true, - status: "accepted", - message: `Executing ${payload.action}.`, - }); - - try { - const created = await remoteCommandService.execute(payload); - sendResult(acceptedRecord, { - commandId, - ok: true, - result: decorateCommandResult(payload.action, created), - }); - } catch (error) { - sendResult(acceptedRecord, { - commandId, - ok: false, - error: { - code: "command_failed", - message: error instanceof Error ? error.message : String(error), - }, - }); - } - } - - async function handleMessage(peer: PeerState, raw: RawData): Promise<void> { - const rawText = wsDataToText(raw); - const envelope = parseSyncEnvelope(rawText); - const heartbeatAwaitedAt = peer.awaitingHeartbeatAt; - peer.lastSeenAt = nowIso(); - peer.awaitingHeartbeatAt = null; - peer.missedHeartbeatCount = 0; - - if (!peer.authenticated) { - if (envelope.type !== "hello" && envelope.type !== "pairing_request") { - send(peer.ws, "hello_error", { - code: "invalid_hello", - message: "Authenticate with hello or pairing_request before sending other messages.", - }, envelope.requestId); - try { - peer.ws.close(4003, "Authentication required"); - } catch { - // ignore - } - return; - } - if (envelope.type === "pairing_request") { - const pairing = parsePairingRequestPayload(envelope.payload); - if (!pairing) { - send(peer.ws, "pairing_result", { - ok: false, - error: { - code: "pairing_failed", - message: "Invalid pairing request payload.", - }, - }, envelope.requestId); - try { peer.ws.close(4003, "Pairing failed"); } catch { /* ignore */ } - return; - } - const cooldownMs = pairingCooldownMsRemaining(peer.remoteAddress); - if (cooldownMs > 0) { - const minutes = Math.ceil(cooldownMs / 60_000); - send(peer.ws, "pairing_result", { - ok: false, - error: { - code: "pairing_failed", - message: `Too many failed PIN attempts. Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.`, - }, - }, envelope.requestId); - try { peer.ws.close(4004, "Pairing cooldown"); } catch { /* ignore */ } - return; - } - try { - const result = pairingStore.pairPeer(pairing.peer, pairing.code); - if (peer.remoteAddress) { - pairFailures.delete(peer.remoteAddress); - } - args.deviceRegistryService?.upsertPeerMetadata(pairing.peer, { - lastSeenAt: nowIso(), - lastHost: peer.remoteAddress, - lastPort: peer.remotePort, - }); - send(peer.ws, "pairing_result", { - ok: true, - deviceId: result.deviceId, - secret: result.secret, - }, envelope.requestId); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const thrownCode = (error as { code?: string } | null)?.code ?? null; - const resultCode: "pin_not_set" | "invalid_pin" | "pairing_failed" = - thrownCode === "pin_not_set" || thrownCode === "invalid_pin" - ? thrownCode - : "pairing_failed"; - send(peer.ws, "pairing_result", { - ok: false, - error: { - code: resultCode, - message, - }, - }, envelope.requestId); - // Drop the socket after any failed pair so brute-forcing the 6-digit - // PIN requires a new TCP+WS handshake per attempt, and track per-IP - // failures so sustained guessers hit a cooldown. - if (resultCode === "invalid_pin" || resultCode === "pairing_failed") { - registerPairFailure(peer.remoteAddress); - } - try { peer.ws.close(4003, "Pairing failed"); } catch { /* ignore */ } - } - return; - } - const hello = parseHelloPayload(envelope.payload); - if (!hello) { - send(peer.ws, "hello_error", { - code: "invalid_hello", - message: "Invalid hello payload.", - }, envelope.requestId); - try { - peer.ws.close(4003, "Authentication failed"); - } catch { - // ignore - } - return; - } - const authFailed = (() => { - if (hello.auth?.kind === "bootstrap") { - return hello.auth.token !== bootstrapToken; - } - if (hello.auth?.kind === "paired") { - if (hello.auth.deviceId !== hello.peer.deviceId) return true; - return !pairingStore.authenticate(hello.auth.deviceId, hello.auth.secret); - } - return true; - })(); - if (authFailed) { - send(peer.ws, "hello_error", { - code: "auth_failed", - message: "Sync authentication failed.", - }, envelope.requestId); - try { - peer.ws.close(4003, "Authentication failed"); - } catch { - // ignore - } - return; - } - - closeExistingPeersForDevice(hello.peer.deviceId, peer); - peer.authenticated = true; - peer.metadata = hello.peer; - const auth = hello.auth ?? { kind: "bootstrap", token: "" }; - peer.authKind = auth.kind; - peer.pairedDeviceId = auth.kind === "paired" ? auth.deviceId : null; - peer.lastKnownServerDbVersion = Math.max(0, Math.floor(hello.peer.dbVersion)); - args.deviceRegistryService?.upsertPeerMetadata(hello.peer, { - lastSeenAt: nowIso(), - lastHost: peer.remoteAddress, - lastPort: peer.remotePort, - }); - const projectCatalog = await buildProjectCatalogPayload(); - send(peer.ws, "hello_ok", { - peer: hello.peer, - brain: readBrainMetadata(), - serverDbVersion: args.db.sync.getDbVersion(), - heartbeatIntervalMs, - pollIntervalMs, - projects: projectsForHello(projectCatalog), - features: { - fileAccess: true, - terminalStreaming: true, - chatStreaming: { - enabled: true, - }, - projectCatalog: { - enabled: Boolean(args.projectCatalogProvider), - }, - changesetAck: { - enabled: true, - }, - bootstrapAuth: true, - pairingAuth: { - enabled: true, - pinDigits: 6, - }, - commandRouting: { - mode: "allowlisted", - supportedActions: [ - ...remoteCommandService.getSupportedActions(), - ...localPresenceCommandDescriptors.map((entry) => entry.action), - ], - actions: [ - ...remoteCommandService.getDescriptors(), - ...localPresenceCommandDescriptors, - ], - }, - }, - }, envelope.requestId); - args.onStateChanged?.(); - await pumpChanges(); - broadcastBrainStatus(); - return; - } - - switch (envelope.type) { - case "project_catalog_request": { - sendProjectCatalog(peer, await buildProjectCatalogPayload(), envelope.requestId); - break; - } - case "project_switch_request": { - await handleProjectSwitchRequest(peer, envelope.requestId, envelope.payload as SyncProjectSwitchRequestPayload); - break; - } - case "heartbeat": { - const payload = envelope.payload as { kind?: string; sentAt?: string } | null; - if (payload?.kind === "ping") { - send(peer.ws, "heartbeat", { - kind: "pong", - sentAt: payload.sentAt ?? nowIso(), - dbVersion: args.db.sync.getDbVersion(), - }, envelope.requestId); - } else if (payload?.kind === "pong" && heartbeatAwaitedAt) { - const now = Date.now(); - const sentAtMs = Date.parse(heartbeatAwaitedAt); - peer.latencyMs = Number.isFinite(sentAtMs) ? Math.max(0, now - sentAtMs) : null; - peer.awaitingHeartbeatAt = null; - } - break; - } - case "changeset_batch": { - const payload = (envelope.payload ?? {}) as SyncChangesetBatchPayload; - const batchId = payload.batchId || envelope.requestId || ""; - const changes = Array.isArray(payload.changes) ? payload.changes as CrsqlChangeRow[] : []; - try { - let appliedCount = 0; - if (changes.length > 0) { - args.db.sync.applyChanges(changes); - appliedCount = changes.length; - peer.lastAppliedAt = nowIso(); - lastBroadcastAt = nowIso(); - args.onStateChanged?.(); - broadcastBrainStatus(); - } - sendRequired(peer, "changeset_ack", { - batchId, - fromDbVersion: Number(payload.fromDbVersion ?? 0), - toDbVersion: Number(payload.toDbVersion ?? 0), - appliedDbVersion: args.db.sync.getDbVersion(), - appliedCount, - ok: true, - } satisfies SyncChangesetAckPayload, envelope.requestId); - } catch (error) { - sendRequired(peer, "changeset_ack", { - batchId, - fromDbVersion: Number(payload.fromDbVersion ?? 0), - toDbVersion: Number(payload.toDbVersion ?? 0), - appliedDbVersion: args.db.sync.getDbVersion(), - appliedCount: 0, - ok: false, - error: { - code: "changeset_apply_failed", - message: error instanceof Error ? error.message : String(error), - }, - } satisfies SyncChangesetAckPayload, envelope.requestId); - throw error; - } - break; - } - case "changeset_ack": { - handleChangesetAck(peer, envelope.payload as SyncChangesetAckPayload); - break; - } - case "file_request": - await handleFileRequest(peer, envelope.requestId, envelope.payload as SyncFileRequest); - break; - case "terminal_subscribe": { - const payload = envelope.payload as { sessionId?: string; maxBytes?: number } | null; - const sessionId = toOptionalString(payload?.sessionId); - if (!sessionId) break; - peer.subscribedSessionIds.add(sessionId); - const session = args.sessionService.get(sessionId); - const transcript = session - ? await args.sessionService.readTranscriptTail( - session.transcriptPath, - Math.max(1_024, Math.min(2_000_000, Math.floor(payload?.maxBytes ?? DEFAULT_TERMINAL_SNAPSHOT_BYTES))), - { raw: true, alignToLineBoundary: true }, - ) - : ""; - const snapshot: SyncTerminalSnapshotPayload = { - sessionId, - transcript, - status: session?.status ?? null, - runtimeState: session?.runtimeState ?? null, - lastOutputPreview: session?.lastOutputPreview ?? null, - capturedAt: nowIso(), - }; - sendRequired(peer, "terminal_snapshot", snapshot, envelope.requestId); - break; - } - case "terminal_unsubscribe": { - const payload = envelope.payload as { sessionId?: string } | null; - const sessionId = toOptionalString(payload?.sessionId); - if (sessionId) { - peer.subscribedSessionIds.delete(sessionId); - } - break; - } - case "terminal_input": { - // Forward keystrokes / pasted text from a mobile client into the - // active PTY for the named session. We require a prior subscribe so - // only an attached peer can drive the shell — protects against an - // attacker who acquired a session id but is not actively viewing. - const payload = envelope.payload as { sessionId?: string; data?: string } | null; - const sessionId = toOptionalString(payload?.sessionId); - const data = typeof payload?.data === "string" ? payload.data : null; - if (!sessionId || data == null) break; - if (!peer.subscribedSessionIds.has(sessionId)) { - args.logger.warn("sync.terminal_input_unsubscribed_session", { sessionId }); - break; - } - const accepted = args.ptyService.writeBySessionId(sessionId, data); - if (!accepted) { - args.logger.info("sync.terminal_input_no_active_pty", { sessionId }); - } - break; - } - case "terminal_resize": { - // Mobile clients re-emit this whenever their visible viewport - // changes (rotation, split view, dynamic font). We forward to the - // active PTY so command-line apps re-flow correctly. Out-of-bound - // values are clamped inside ptyService. - const payload = envelope.payload as { sessionId?: string; cols?: number; rows?: number } | null; - const sessionId = toOptionalString(payload?.sessionId); - const cols = typeof payload?.cols === "number" ? Math.floor(payload.cols) : null; - const rows = typeof payload?.rows === "number" ? Math.floor(payload.rows) : null; - if (!sessionId || cols == null || rows == null) break; - if (!peer.subscribedSessionIds.has(sessionId)) break; - args.ptyService.resizeBySessionId(sessionId, cols, rows); - break; - } - case "chat_subscribe": { - const payload = envelope.payload as { sessionId?: string; maxBytes?: number } | null; - const sessionId = toOptionalString(payload?.sessionId); - if (!sessionId) break; - peer.subscribedChatSessionIds.add(sessionId); - - const session = args.sessionService.get(sessionId); - const maxBytes = Math.max( - 1_024, - Math.min(2_000_000, Math.floor(typeof payload?.maxBytes === "number" ? payload.maxBytes : DEFAULT_TERMINAL_SNAPSHOT_BYTES)), - ); - const raw = session?.transcriptPath - ? await args.sessionService.readTranscriptTail( - session.transcriptPath, - maxBytes, - { raw: true, alignToLineBoundary: true }, - ) - : ""; - const events = parseAgentChatTranscript(raw).filter((event) => event.sessionId === sessionId); - const transcriptSize = session?.transcriptPath && fs.existsSync(session.transcriptPath) - ? fs.statSync(session.transcriptPath).size - : 0; - peer.chatTranscriptOffsets.set(sessionId, transcriptSize); - const snapshot: SyncChatSubscribeSnapshotPayload = { - sessionId, - capturedAt: nowIso(), - truncated: transcriptSize > maxBytes, - events, - }; - sendRequired(peer, "chat_subscribe", snapshot, envelope.requestId); - break; - } - case "chat_unsubscribe": { - const payload = envelope.payload as SyncChatUnsubscribePayload | null; - const sessionId = toOptionalString(payload?.sessionId); - if (sessionId) { - peer.subscribedChatSessionIds.delete(sessionId); - peer.chatTranscriptOffsets.delete(sessionId); - peer.chatEventIdsSent.delete(sessionId); - } - break; - } - case "command": - await handleCommand(peer, envelope.requestId, envelope.payload as SyncCommandPayload); - break; - case "register_push_token": { - const payload = envelope.payload as SyncRegisterPushTokenPayload | null; - handleRegisterPushToken(peer, envelope.requestId, payload); - break; - } - case "notification_prefs": { - const payload = envelope.payload as SyncNotificationPrefsPayload | null; - handleNotificationPrefs(peer, payload); - break; - } - case "send_test_push": { - const payload = envelope.payload as SyncSendTestPushPayload | null; - await handleSendTestPush(peer, envelope.requestId, payload); - break; - } - default: - break; - } - } - - function handleRegisterPushToken( - peer: PeerState, - requestId: string | null | undefined, - payload: SyncRegisterPushTokenPayload | null, - ): void { - const deviceId = peer.metadata?.deviceId; - if (!deviceId) { - args.logger.warn("sync_host.push_token_missing_device", {}); - sendRequired(peer, "command_ack", { - commandId: "push-token:unknown", - accepted: false, - status: "missing_device_id", - message: "Cannot store push token before device registration completes.", - }, requestId ?? null); - return; - } - if (!payload || typeof payload.token !== "string" || payload.token.trim().length === 0) { - args.logger.warn("sync_host.push_token_missing", { deviceId }); - sendRequired(peer, "command_ack", { - commandId: `push-token:${deviceId}:unknown`, - accepted: false, - status: "invalid_payload", - message: "Push token registration did not include a token.", - }, requestId ?? null); - return; - } - const kind: ApnsPushTokenKind = - payload.kind === "alert" || payload.kind === "activity-start" || payload.kind === "activity-update" - ? payload.kind - : "alert"; - if (kind === "activity-update" && !payload.activityId?.trim()) { - args.logger.warn("sync_host.push_token_missing_activity_id", { deviceId }); - sendRequired(peer, "command_ack", { - commandId: `push-token:${deviceId}:${kind}`, - accepted: false, - status: "missing_activity_id", - message: "Live Activity update tokens require an activity id.", - }, requestId ?? null); - return; - } - const env: ApnsEnvironment = payload.env === "production" ? "production" : "sandbox"; - const stored = args.deviceRegistryService?.setApnsToken?.(deviceId, payload.token.trim(), kind, env, { - bundleId: payload.bundleId, - activityId: payload.activityId, - }); - if (!stored) { - sendRequired(peer, "command_ack", { - commandId: `push-token:${deviceId}:${kind}`, - accepted: false, - status: "device_not_found", - message: `Could not store ${kind} push token for ${deviceId}.`, - }, requestId ?? null); - return; - } - // Optional ack so the client can retry on failure. - sendRequired(peer, "command_ack", { - commandId: `push-token:${deviceId}:${kind}`, - accepted: true, - status: "accepted", - message: `Stored ${kind} push token for ${deviceId}.`, - }, requestId ?? null); - } - - function handleNotificationPrefs(peer: PeerState, payload: SyncNotificationPrefsPayload | null): void { - const deviceId = peer.metadata?.deviceId; - if (!deviceId || !payload || !payload.prefs) return; - storeNotificationPrefsForDevice(deviceId, normalizeNotificationPreferences(payload.prefs)); - } - - async function handleSendTestPush( - peer: PeerState, - requestId: string | null | undefined, - payload: SyncSendTestPushPayload | null, - ): Promise<void> { - const deviceId = peer.metadata?.deviceId; - if (!deviceId) return; - const kind = payload?.kind === "activity" ? "activity" : "alert"; - const result = args.notificationEventBus - ? await args.notificationEventBus.sendTestPush(deviceId, kind) - : { ok: false, reason: "notification_bus_unavailable" as const }; - sendRequired(peer, "command_result", { - commandId: `push-test:${deviceId}:${kind}`, - ok: result.ok, - ...(result.ok ? {} : { error: { code: "test_push_failed", message: result.reason ?? "unknown" } }), - }, requestId ?? null); - } - - /** - * Deliver a foreground-only notification to a specific iOS peer over the - * existing WebSocket. Used by the notification bus when the device is - * currently connected, in place of (or alongside) an APNs alert. - */ - function sendInAppNotification( - deviceId: string, - payload: Omit<SyncInAppNotificationPayload, "generatedAt">, - ): void { - const fullPayload: SyncInAppNotificationPayload = { - ...payload, - generatedAt: nowIso(), - }; - for (const peer of peers) { - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - if (peer.metadata?.deviceId !== deviceId) continue; - send(peer.ws, "in_app_notification", fullPayload); - } - } - - function getNotificationPrefsForDevice(deviceId: string): NotificationPreferences | null { - return readNotificationPrefsForDevice(deviceId); - } - - function isIosPeerConnected(deviceId: string): boolean { - for (const peer of peers) { - if (peer.metadata?.deviceId !== deviceId) continue; - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - return true; - } - return false; - } - - const getLanePresenceSnapshot = (): Array<{ laneId: string; devicesOpen: DeviceMarker[] }> => { - return [...lanePresenceByLaneId.keys()] - .sort((left, right) => left.localeCompare(right)) - .map((laneId) => ({ - laneId, - devicesOpen: listLanePresenceMarkers(laneId), - })) - .filter((entry) => entry.devicesOpen.length > 0); - }; - - return { - async waitUntilListening(): Promise<number> { - if (startupError) { - throw startupError; - } - if (server.address()) { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT; - publishLanDiscovery(port); - publishTailnetDiscovery(port); - return port; - } - await new Promise<void>((resolve, reject) => { - const onListening = () => { - cleanup(); - resolve(); - }; - const onError = (error: unknown) => { - cleanup(); - const normalized = error instanceof Error ? error : new Error(String(error)); - startupError = normalized; - reject(normalized); - }; - const cleanup = () => { - server.off("listening", onListening); - server.off("error", onError); - }; - server.on("listening", onListening); - server.on("error", onError); - if (startupError) { - cleanup(); - reject(startupError); - return; - } - if (server.address()) { - cleanup(); - resolve(); - } - }); - const address = server.address(); - const port = typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT; - publishLanDiscovery(port); - publishTailnetDiscovery(port); - return port; - }, - - getPort(): number | null { - const address = server.address(); - return typeof address === "object" && address ? address.port : null; - }, - - getBootstrapToken(): string { - return bootstrapToken; - }, - - setLocalActiveLanePresence(laneIds: string[]): void { - setLocalActiveLanePresence(laneIds); - }, - - refreshLanDiscovery(options?: { forceTailnet?: boolean }): void { - const address = server.address(); - if (typeof address === "object" && address) { - publishLanDiscovery(address.port); - publishTailnetDiscovery(address.port, { force: options?.forceTailnet }); - } - }, - - setDiscoveryEnabled(enabled: boolean): void { - if (discoveryEnabled === enabled) return; - discoveryEnabled = enabled; - const address = server.address(); - if (!enabled) { - unpublishLanDiscovery(); - void unpublishTailnetDiscovery(); - updateTailnetDiscoveryStatus({ - state: "disabled", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: nowIso(), - error: "Tailnet discovery is disabled for this background project context.", - stderr: null, - }); - return; - } - if (typeof address === "object" && address) { - publishLanDiscovery(address.port); - publishTailnetDiscovery(address.port, { force: true }); - } - }, - - revokePairedDevice(deviceId: string): void { - pairingStore.revoke(deviceId); - let revokedConnectedPeer = false; - for (const peer of peers) { - if (!peer.authenticated || peer.authKind !== "paired" || peer.pairedDeviceId !== deviceId) continue; - revokedConnectedPeer = true; - peer.authenticated = false; - peer.metadata = null; - peer.authKind = null; - peer.pairedDeviceId = null; - try { - peer.ws.close(4003, "Pairing revoked"); - } catch { - // ignore close failures - } - } - if (revokedConnectedPeer) { - args.onStateChanged?.(); - broadcastBrainStatus(); - } - }, - - getPeerStates(): SyncPeerConnectionState[] { - const dbVersion = args.db.sync.getDbVersion(); - const latestByDevice = new Map<string, SyncPeerConnectionState>(); - for (const peer of [...peers] - .map((peer) => toSyncPeerConnectionState(peer, dbVersion)) - .filter((peer): peer is SyncPeerConnectionState => peer != null)) { - const existing = latestByDevice.get(peer.deviceId); - if (!existing || peer.connectedAt > existing.connectedAt) { - latestByDevice.set(peer.deviceId, peer); - } - } - return [...latestByDevice.values()]; - }, - - getTailnetDiscoveryStatus(): SyncTailnetDiscoveryStatus { - return { ...tailnetDiscoveryStatus }; - }, - - getLanePresenceSnapshot(): Array<{ laneId: string; devicesOpen: DeviceMarker[] }> { - return getLanePresenceSnapshot(); - }, - - getChatSubscriptionSnapshot(): Array<{ deviceId: string; subscribedChatSessionIds: string[] }> { - return [...peers] - .map((peer) => { - if (!peer.metadata) return null; - return { - deviceId: peer.metadata.deviceId, - subscribedChatSessionIds: [...peer.subscribedChatSessionIds].sort(), - }; - }) - .filter((peer): peer is { deviceId: string; subscribedChatSessionIds: string[] } => peer != null); - }, - - getBrainStatusSnapshot(): SyncBrainStatusPayload { - return buildBrainStatus(); - }, - - async broadcastProjectCatalog(): Promise<void> { - const payload = await buildProjectCatalogPayload(); - for (const peer of peers) { - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - sendProjectCatalog(peer, payload); - } - }, - - /** - * Push an in-app notification to a specific iOS peer over the WebSocket. - * Used by the notification event bus as the foreground-delivery path. - */ - sendInAppNotification( - deviceId: string, - payload: Omit<SyncInAppNotificationPayload, "generatedAt">, - ): void { - sendInAppNotification(deviceId, payload); - }, - - /** Returns the latest announced notification prefs for a device, or null. */ - getNotificationPrefsForDevice(deviceId: string): NotificationPreferences | null { - return getNotificationPrefsForDevice(deviceId); - }, - - /** Whether a given device is currently connected + authenticated. */ - isIosPeerConnected(deviceId: string): boolean { - return isIosPeerConnected(deviceId); - }, - - handlePtyData(event: PtyDataEvent): void { - const payload = { - sessionId: event.sessionId, - ptyId: event.ptyId, - data: event.data, - at: nowIso(), - }; - for (const peer of peers) { - if (!peer.authenticated || !peer.subscribedSessionIds.has(event.sessionId) || peer.ws.readyState !== WebSocket.OPEN) continue; - if (isPeerBackpressured(peer)) continue; - send(peer.ws, "terminal_data", payload); - } - }, - - handlePtyExit(event: PtyExitEvent): void { - const payload = { - sessionId: event.sessionId, - ptyId: event.ptyId, - exitCode: event.exitCode, - at: nowIso(), - }; - for (const peer of peers) { - if (!peer.authenticated || !peer.subscribedSessionIds.has(event.sessionId) || peer.ws.readyState !== WebSocket.OPEN) continue; - if (isPeerBackpressured(peer)) continue; - send(peer.ws, "terminal_exit", payload); - } - }, - - async dispose(): Promise<void> { - if (disposed) return; - disposed = true; - localActiveLaneIds = new Set<string>(); - lanePresenceByLaneId.clear(); - dropInFlightCommandRecordsForProject(); - chatEventSubscription?.(); - clearInterval(pollTimer); - clearInterval(heartbeatTimer); - clearInterval(brainStatusTimer); - unpublishLanDiscovery(); - try { - await unpublishTailnetDiscovery(); - } catch { - // Never throw from dispose. - } - await new Promise<void>((resolve) => { - const finish = () => resolve(); - for (const peer of peers) { - try { - peer.ws.close(); - } catch { - // ignore - } - } - if (!server.address()) { - finish(); - return; - } - try { - server.close(() => finish()); - } catch { - finish(); - } - }); - if (bonjourAnnouncement) { - try { - bonjourAnnouncement.stop?.(); - } catch { - // ignore cleanup failures - } - bonjourAnnouncement = null; - } - bonjourPort = null; - bonjourSignature = null; - if (bonjourInstance) { - try { - bonjourInstance.destroy(); - } catch { - // ignore cleanup failures - } - bonjourInstance = null; - } - }, - }; -} - -export type SyncHostService = ReturnType<typeof createSyncHostService>; +export * from "../../../../../ade-cli/src/services/sync/syncHostService"; diff --git a/apps/desktop/src/main/services/sync/syncPairingStore.ts b/apps/desktop/src/main/services/sync/syncPairingStore.ts index f398ee351..d5a17f65f 100644 --- a/apps/desktop/src/main/services/sync/syncPairingStore.ts +++ b/apps/desktop/src/main/services/sync/syncPairingStore.ts @@ -1,93 +1 @@ -import fs from "node:fs"; -import path from "node:path"; -import { createHash, randomBytes } from "node:crypto"; -import type { SyncPeerMetadata } from "../../../shared/types"; -import { nowIso, safeJsonParse, writeTextAtomic } from "../shared/utils"; -import type { SyncPinStore } from "./syncPinStore"; - -type PairingRecord = { - secretHash: string; - createdAt: string; - lastUsedAt: string | null; - peerName: string; - peerPlatform: string; - peerDeviceType: string; -}; - -type PairingSecretsFile = Record<string, PairingRecord>; - -type SyncPairingStoreArgs = { - filePath: string; - pinStore: SyncPinStore; -}; - -function hashSecret(secret: string): string { - return createHash("sha256").update(secret).digest("hex"); -} - -function pairingError(code: "pin_not_set" | "invalid_pin", message: string): Error { - const err = new Error(message) as Error & { code?: string }; - err.code = code; - return err; -} - -export function createSyncPairingStore(args: SyncPairingStoreArgs) { - fs.mkdirSync(path.dirname(args.filePath), { recursive: true }); - - const readRecords = (): PairingSecretsFile => { - if (!fs.existsSync(args.filePath)) return {}; - return safeJsonParse<PairingSecretsFile>(fs.readFileSync(args.filePath, "utf8"), {}); - }; - - const writeRecords = (records: PairingSecretsFile): void => { - writeTextAtomic(args.filePath, `${JSON.stringify(records, null, 2)}\n`); - }; - - return { - pairPeer(peer: SyncPeerMetadata, pin: string): { deviceId: string; secret: string } { - if (!args.pinStore.hasPin()) { - throw pairingError("pin_not_set", "No pairing PIN is set on this computer."); - } - if (!args.pinStore.verifyPin(pin)) { - throw pairingError("invalid_pin", "Incorrect pairing PIN."); - } - const secret = randomBytes(24).toString("hex"); - const records = readRecords(); - const existing = records[peer.deviceId] ?? null; - records[peer.deviceId] = { - secretHash: hashSecret(secret), - createdAt: existing?.createdAt ?? nowIso(), - lastUsedAt: null, - peerName: peer.deviceName, - peerPlatform: peer.platform, - peerDeviceType: peer.deviceType, - }; - writeRecords(records); - return { - deviceId: peer.deviceId, - secret, - }; - }, - - authenticate(deviceId: string, secret: string): boolean { - const records = readRecords(); - const entry = records[deviceId]; - if (!entry) return false; - if (entry.secretHash !== hashSecret(secret)) return false; - entry.lastUsedAt = nowIso(); - writeRecords(records); - return true; - }, - - revoke(deviceId: string): void { - const normalized = deviceId.trim(); - if (!normalized) return; - const records = readRecords(); - if (!(normalized in records)) return; - delete records[normalized]; - writeRecords(records); - }, - }; -} - -export type SyncPairingStore = ReturnType<typeof createSyncPairingStore>; +export * from "../../../../../ade-cli/src/services/sync/syncPairingStore"; diff --git a/apps/desktop/src/main/services/sync/syncPeerService.ts b/apps/desktop/src/main/services/sync/syncPeerService.ts index b67af24ba..7b2c82d7b 100644 --- a/apps/desktop/src/main/services/sync/syncPeerService.ts +++ b/apps/desktop/src/main/services/sync/syncPeerService.ts @@ -1,579 +1 @@ -import { WebSocket, type RawData } from "ws"; -import type { - SyncBrainStatusPayload, - SyncChangesetAckPayload, - SyncChangesetBatchPayload, - SyncClientStatus, - SyncCommandAckPayload, - SyncCommandResultPayload, - SyncDesktopConnectionDraft, - SyncRemoteCommandAction, - SyncPeerMetadata, - SyncRunQuickCommandArgs, -} from "../../../shared/types"; -import type { Logger } from "../logging/logger"; -import type { AdeDb } from "../state/kvDb"; -import { nowIso } from "../shared/utils"; -import type { DeviceRegistryService } from "./deviceRegistryService"; -import { DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, encodeSyncEnvelope, parseSyncEnvelope, wsDataToText } from "./syncProtocol"; - -type SyncPeerServiceArgs = { - db: AdeDb; - logger: Logger; - deviceRegistryService: DeviceRegistryService; - onStatusChange?: (status: SyncClientStatus) => void; - onBrainStatus?: (payload: SyncBrainStatusPayload) => void; - onRemoteChangesApplied?: () => void; -}; - -type PendingRequest = { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - timer: ReturnType<typeof setTimeout>; -}; - -type InternalStatus = SyncClientStatus; -type PendingChangesetBatch = { - batchId: string; - payload: SyncChangesetBatchPayload; - sentAtMs: number; - retryCount: number; -}; - -const CHANGESET_ACK_TIMEOUT_MS = 10_000; -const MAX_CHANGESET_ACK_RETRIES = 6; - -export function createSyncPeerService(args: SyncPeerServiceArgs) { - let ws: WebSocket | null = null; - let disposed = false; - let relayTimer: NodeJS.Timeout | null = null; - let heartbeatTimer: NodeJS.Timeout | null = null; - let connectionDraft: SyncDesktopConnectionDraft | null = null; - let latestBrainStatus: SyncBrainStatusPayload | null = null; - let outboundLocalDbVersion = args.db.sync.getDbVersion(); - let latestRemoteDbVersion = 0; - let pendingOutboundChangeset: PendingChangesetBatch | null = null; - const pendingRequests = new Map<string, PendingRequest>(); - let pendingConnect: { resolve: () => void; reject: (error: Error) => void } | null = null; - - const status: InternalStatus = { - state: "disconnected", - host: null, - port: null, - connectedAt: null, - lastSeenAt: null, - latencyMs: null, - syncLag: null, - lastRemoteDbVersion: 0, - brainDeviceId: null, - hostName: null, - error: null, - message: null, - savedDraft: null, - }; - - const emitStatus = () => { - status.lastRemoteDbVersion = latestRemoteDbVersion; - status.savedDraft = connectionDraft - ? { - host: connectionDraft.host, - port: connectionDraft.port, - authKind: connectionDraft.authKind ?? "bootstrap", - pairedDeviceId: connectionDraft.pairedDeviceId ?? null, - lastRemoteDbVersion: connectionDraft.lastRemoteDbVersion ?? latestRemoteDbVersion, - } - : null; - args.onStatusChange?.({ ...status }); - }; - - const stopTimers = () => { - if (relayTimer) { - clearInterval(relayTimer); - relayTimer = null; - } - if (heartbeatTimer) { - clearInterval(heartbeatTimer); - heartbeatTimer = null; - } - }; - - const clearPendingRequests = (message: string) => { - for (const [requestId, pending] of pendingRequests) { - clearTimeout(pending.timer); - pending.reject(new Error(message)); - pendingRequests.delete(requestId); - } - }; - - const applyDraft = (draft: SyncDesktopConnectionDraft | null) => { - connectionDraft = draft - ? { - host: draft.host.trim(), - port: Math.max(1, Math.floor(draft.port)), - token: draft.token, - authKind: draft.authKind ?? "bootstrap", - pairedDeviceId: draft.pairedDeviceId ?? null, - lastRemoteDbVersion: Math.max(0, Math.floor(draft.lastRemoteDbVersion ?? 0)), - } - : null; - emitStatus(); - }; - - const currentLocalPeerMetadata = (): SyncPeerMetadata => { - const localDevice = args.deviceRegistryService.ensureLocalDevice(); - return { - deviceId: localDevice.deviceId, - deviceName: localDevice.name, - platform: localDevice.platform, - deviceType: localDevice.deviceType, - siteId: localDevice.siteId, - dbVersion: latestRemoteDbVersion, - capabilities: ["changesetAck"], - }; - }; - - const sendChangesetAck = ( - batch: SyncChangesetBatchPayload, - ok: boolean, - appliedDbVersion: number, - appliedCount: number, - error?: unknown, - ) => { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - const payload: SyncChangesetAckPayload = { - batchId: batch.batchId, - fromDbVersion: Number(batch.fromDbVersion ?? 0), - toDbVersion: Number(batch.toDbVersion ?? 0), - appliedDbVersion, - appliedCount, - ok, - ...(error - ? { error: { code: "changeset_apply_failed", message: error instanceof Error ? error.message : String(error) } } - : {}), - }; - ws.send( - encodeSyncEnvelope({ - type: "changeset_ack", - requestId: batch.batchId, - payload, - compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, - }), - ); - }; - - const sendOutboundChangeset = (pending: PendingChangesetBatch) => { - if (!ws || ws.readyState !== WebSocket.OPEN) return false; - ws.send( - encodeSyncEnvelope({ - type: "changeset_batch", - requestId: pending.batchId, - payload: pending.payload, - compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, - }), - ); - return true; - }; - - const sendLocalChanges = () => { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - const nowMs = Date.now(); - if (pendingOutboundChangeset) { - if (nowMs - pendingOutboundChangeset.sentAtMs >= CHANGESET_ACK_TIMEOUT_MS) { - if (pendingOutboundChangeset.retryCount >= MAX_CHANGESET_ACK_RETRIES) { - args.logger.warn("sync_peer.changeset_ack_timeout_exhausted", { - batchId: pendingOutboundChangeset.batchId, - retryCount: pendingOutboundChangeset.retryCount, - }); - disconnectInternal("error", null, "Changeset acknowledgement timed out."); - return; - } - pendingOutboundChangeset.sentAtMs = nowMs; - pendingOutboundChangeset.retryCount += 1; - sendOutboundChangeset(pendingOutboundChangeset); - } - return; - } - const currentDbVersion = args.db.sync.getDbVersion(); - if (currentDbVersion <= outboundLocalDbVersion) return; - const localSiteId = args.deviceRegistryService.getLocalSiteId(); - const changes = args.db.sync - .exportChangesSince(outboundLocalDbVersion) - .filter((change) => change.site_id === localSiteId); - const previousDbVersion = outboundLocalDbVersion; - if (!changes.length) { - outboundLocalDbVersion = currentDbVersion; - return; - } - const batchId = `changeset:${currentLocalPeerMetadata().deviceId}:${previousDbVersion}:${currentDbVersion}:${Date.now()}:${Math.random().toString(16).slice(2)}`; - pendingOutboundChangeset = { - batchId, - payload: { - batchId, - reason: "relay", - fromDbVersion: previousDbVersion, - toDbVersion: currentDbVersion, - changes, - }, - sentAtMs: nowMs, - retryCount: 0, - }; - sendOutboundChangeset(pendingOutboundChangeset); - }; - - const startRelay = () => { - stopTimers(); - relayTimer = setInterval(() => { - try { - sendLocalChanges(); - } catch (error) { - args.logger.warn("sync_peer.relay_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } - }, 400); - }; - - const startHeartbeatFallback = () => { - heartbeatTimer = setInterval(() => { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - ws.send( - encodeSyncEnvelope({ - type: "heartbeat", - payload: { - kind: "ping", - sentAt: nowIso(), - dbVersion: latestRemoteDbVersion, - }, - }), - ); - }, 30_000); - }; - - const disconnectInternal = (state: SyncClientStatus["state"], message: string | null, error: string | null) => { - stopTimers(); - if (ws) { - try { - ws.removeAllListeners(); - ws.close(); - } catch { - // ignore - } - } - ws = null; - pendingOutboundChangeset = null; - latestBrainStatus = null; - status.state = state; - status.connectedAt = null; - status.lastSeenAt = null; - status.latencyMs = null; - status.syncLag = null; - status.brainDeviceId = null; - status.hostName = null; - status.message = message; - status.error = error; - clearPendingRequests(error ?? message ?? "Sync peer disconnected."); - emitStatus(); - }; - - const handleMessage = (raw: RawData) => { - const envelope = parseSyncEnvelope(wsDataToText(raw)); - status.lastSeenAt = nowIso(); - switch (envelope.type) { - case "hello_ok": { - const payload = envelope.payload as { - brain: SyncPeerMetadata; - serverDbVersion: number; - }; - latestRemoteDbVersion = Math.max(0, Math.floor(payload.serverDbVersion ?? 0)); - status.state = "connected"; - status.connectedAt = nowIso(); - status.message = `Connected to host ${payload.brain.deviceName}.`; - status.error = null; - status.brainDeviceId = payload.brain.deviceId; - status.hostName = payload.brain.deviceName; - if (connectionDraft) { - connectionDraft.lastRemoteDbVersion = latestRemoteDbVersion; - } - outboundLocalDbVersion = Math.min(outboundLocalDbVersion, args.db.sync.getDbVersion()); - emitStatus(); - startRelay(); - startHeartbeatFallback(); - pendingConnect?.resolve(); - pendingConnect = null; - break; - } - case "hello_error": { - const payload = envelope.payload as { message?: string }; - pendingConnect?.reject(new Error(payload?.message ?? "Sync peer authentication failed.")); - pendingConnect = null; - disconnectInternal("error", null, payload?.message ?? "Sync peer authentication failed."); - break; - } - case "changeset_batch": { - const payload = (envelope.payload ?? {}) as SyncChangesetBatchPayload; - const changes = Array.isArray(payload.changes) ? payload.changes : []; - try { - if (changes.length) { - args.db.sync.applyChanges(changes); - args.onRemoteChangesApplied?.(); - } - latestRemoteDbVersion = Math.max(latestRemoteDbVersion, Math.floor(payload.toDbVersion ?? latestRemoteDbVersion)); - if (connectionDraft) connectionDraft.lastRemoteDbVersion = latestRemoteDbVersion; - sendChangesetAck(payload, true, args.db.sync.getDbVersion(), changes.length); - emitStatus(); - } catch (error) { - sendChangesetAck(payload, false, args.db.sync.getDbVersion(), 0, error); - throw error; - } - break; - } - case "changeset_ack": { - const payload = envelope.payload as SyncChangesetAckPayload; - if (!pendingOutboundChangeset || payload.batchId !== pendingOutboundChangeset.batchId) break; - if (!payload.ok) { - if (pendingOutboundChangeset.retryCount >= MAX_CHANGESET_ACK_RETRIES) { - const message = payload.error?.message ?? "Changeset apply failed repeatedly."; - args.logger.warn("sync_peer.changeset_ack_failed_exhausted", { - batchId: pendingOutboundChangeset.batchId, - retryCount: pendingOutboundChangeset.retryCount, - error: message, - }); - disconnectInternal("error", null, message); - break; - } - pendingOutboundChangeset.sentAtMs = Date.now(); - pendingOutboundChangeset.retryCount += 1; - args.logger.warn("sync_peer.changeset_ack_failed", { - batchId: pendingOutboundChangeset.batchId, - error: payload.error?.message ?? "Changeset apply failed.", - }); - break; - } - if (payload.toDbVersion < pendingOutboundChangeset.payload.toDbVersion) break; - const acknowledgedRemoteVersion = Math.max( - latestRemoteDbVersion, - pendingOutboundChangeset.payload.toDbVersion, - Math.floor(payload.toDbVersion ?? 0), - ); - latestRemoteDbVersion = acknowledgedRemoteVersion; - if (connectionDraft) { - connectionDraft.lastRemoteDbVersion = acknowledgedRemoteVersion; - } - outboundLocalDbVersion = Math.max(outboundLocalDbVersion, pendingOutboundChangeset.payload.toDbVersion); - pendingOutboundChangeset = null; - emitStatus(); - break; - } - case "brain_status": { - const payload = envelope.payload as SyncBrainStatusPayload; - latestBrainStatus = payload; - status.brainDeviceId = payload.brain.deviceId; - status.hostName = payload.brain.deviceName; - const localDeviceId = args.deviceRegistryService.getLocalDeviceId(); - const localPeer = payload.connectedPeers.find((peer) => peer.deviceId === localDeviceId) ?? null; - status.latencyMs = localPeer?.latencyMs ?? null; - status.syncLag = localPeer?.syncLag ?? 0; - args.onBrainStatus?.(payload); - emitStatus(); - break; - } - case "heartbeat": { - const payload = envelope.payload as { kind?: string; sentAt?: string }; - if (payload?.kind === "ping" && ws && ws.readyState === WebSocket.OPEN) { - ws.send( - encodeSyncEnvelope({ - type: "heartbeat", - requestId: envelope.requestId ?? null, - payload: { - kind: "pong", - sentAt: payload.sentAt ?? nowIso(), - dbVersion: latestRemoteDbVersion, - }, - }), - ); - } - break; - } - case "command_ack": - case "command_result": { - const requestId = envelope.requestId ?? null; - if (!requestId) break; - const pending = pendingRequests.get(requestId); - if (!pending) break; - if (envelope.type === "command_result") { - clearTimeout(pending.timer); - pendingRequests.delete(requestId); - const payload = envelope.payload as SyncCommandResultPayload; - if (payload.ok) { - pending.resolve(payload.result ?? null); - } else { - pending.reject(new Error(payload.error?.message ?? "Remote command failed.")); - } - } else { - const payload = envelope.payload as SyncCommandAckPayload; - if (!payload.accepted) { - clearTimeout(pending.timer); - pendingRequests.delete(requestId); - pending.reject(new Error(payload.message ?? "Remote command rejected.")); - } - } - break; - } - default: - break; - } - }; - - return { - setSavedDraft(draft: SyncDesktopConnectionDraft | null): void { - applyDraft(draft); - }, - - async connect(draft: SyncDesktopConnectionDraft): Promise<void> { - if (disposed) { - throw new Error("Sync peer service is disposed."); - } - this.disconnect({ preserveDraft: true }); - applyDraft(draft); - latestRemoteDbVersion = Math.max(0, Math.floor(draft.lastRemoteDbVersion ?? 0)); - status.state = "connecting"; - status.host = draft.host.trim(); - status.port = Math.max(1, Math.floor(draft.port)); - status.message = `Connecting to ${status.host}:${String(status.port)}...`; - status.error = null; - emitStatus(); - - await new Promise<void>((resolve, reject) => { - const socket = new WebSocket(`ws://${status.host}:${String(status.port)}`); - ws = socket; - pendingConnect = { resolve, reject }; - - const cleanup = () => { - socket.removeListener("open", onOpen); - socket.removeListener("error", onError); - }; - - const onOpen = () => { - cleanup(); - const peer = currentLocalPeerMetadata(); - const auth = draft.authKind === "paired" && draft.pairedDeviceId - ? { - kind: "paired" as const, - deviceId: draft.pairedDeviceId, - secret: draft.token, - } - : { - kind: "bootstrap" as const, - token: draft.token, - }; - socket.send( - encodeSyncEnvelope({ - type: "hello", - requestId: "hello", - payload: { - peer, - auth, - }, - compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, - }), - ); - }; - - const onError = (error: Error) => { - cleanup(); - pendingConnect?.reject(error); - pendingConnect = null; - disconnectInternal("error", null, error.message); - }; - - socket.once("open", onOpen); - socket.once("error", onError); - socket.on("message", (raw) => { - try { - handleMessage(raw); - } catch (error) { - args.logger.warn("sync_peer.message_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } - }); - socket.on("close", () => { - if (disposed) return; - if (pendingConnect) { - pendingConnect.reject(new Error("Connection closed before authentication completed.")); - pendingConnect = null; - } - disconnectInternal("disconnected", "Disconnected from host.", null); - }); - }); - }, - - disconnect(options: { preserveDraft?: boolean } = {}): void { - const nextDraft = options.preserveDraft ? connectionDraft : null; - disconnectInternal("disconnected", connectionDraft ? "Disconnected from host." : null, null); - if (!options.preserveDraft) { - applyDraft(null); - } else { - applyDraft(nextDraft); - } - }, - - getStatus(): SyncClientStatus { - return { ...status }; - }, - - getLatestBrainStatus(): SyncBrainStatusPayload | null { - return latestBrainStatus ? { ...latestBrainStatus, connectedPeers: [...latestBrainStatus.connectedPeers] } : null; - }, - - getConnectionDraft(): SyncDesktopConnectionDraft | null { - return connectionDraft ? { ...connectionDraft } : null; - }, - - isConnected(): boolean { - return status.state === "connected" && Boolean(ws) && ws?.readyState === WebSocket.OPEN; - }, - - flushLocalChanges(): void { - sendLocalChanges(); - }, - - async executeRemoteCommand(action: SyncRemoteCommandAction | (string & {}), commandArgs: Record<string, unknown>): Promise<unknown> { - if (!ws || ws.readyState !== WebSocket.OPEN) { - throw new Error("Not connected to a host device."); - } - const requestId = `sync-command-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const promise = new Promise<unknown>((resolve, reject) => { - const timer = setTimeout(() => { - pendingRequests.delete(requestId); - reject(new Error("Timed out waiting for remote command result.")); - }, 20_000); - pendingRequests.set(requestId, { resolve, reject, timer }); - }); - ws.send( - encodeSyncEnvelope({ - type: "command", - requestId, - payload: { - commandId: requestId, - action, - args: commandArgs, - }, - compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, - }), - ); - return await promise; - }, - - async runQuickCommand(argsIn: SyncRunQuickCommandArgs): Promise<unknown> { - return await this.executeRemoteCommand("work.runQuickCommand", argsIn); - }, - - async dispose(): Promise<void> { - disposed = true; - this.disconnect(); - }, - }; -} - -export type SyncPeerService = ReturnType<typeof createSyncPeerService>; +export * from "../../../../../ade-cli/src/services/sync/syncPeerService"; diff --git a/apps/desktop/src/main/services/sync/syncPinStore.ts b/apps/desktop/src/main/services/sync/syncPinStore.ts index 6401a31dc..a1720f95c 100644 --- a/apps/desktop/src/main/services/sync/syncPinStore.ts +++ b/apps/desktop/src/main/services/sync/syncPinStore.ts @@ -1,147 +1 @@ -import fs from "node:fs"; -import path from "node:path"; -import { pbkdf2Sync, randomBytes, timingSafeEqual } from "node:crypto"; -import { safeJsonParse, writeTextAtomic } from "../shared/utils"; - -type SyncPinStoreArgs = { - filePath: string; -}; - -type LegacySyncPinFile = { - pin: string; - updatedAt: string; -}; - -type HashedSyncPinFile = { - version: 2; - algorithm: "pbkdf2-sha256"; - iterations: number; - salt: string; - hash: string; - updatedAt: string; -}; - -type SyncPinFile = LegacySyncPinFile | HashedSyncPinFile; - -const PIN_PATTERN = /^\d{6}$/; -const PIN_HASH_ITERATIONS = 120_000; -const PIN_HASH_BYTES = 32; - -function derivePinHash(pin: string, salt: string, iterations: number): string { - return pbkdf2Sync(pin, salt, iterations, PIN_HASH_BYTES, "sha256").toString("hex"); -} - -function safeEqualHex(left: string, right: string): boolean { - const leftBuffer = Buffer.from(left, "hex"); - const rightBuffer = Buffer.from(right, "hex"); - return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); -} - -function createHashedPinFile(pin: string, updatedAt = new Date().toISOString()): HashedSyncPinFile { - const salt = randomBytes(16).toString("hex"); - return { - version: 2, - algorithm: "pbkdf2-sha256", - iterations: PIN_HASH_ITERATIONS, - salt, - hash: derivePinHash(pin, salt, PIN_HASH_ITERATIONS), - updatedAt, - }; -} - -function isHashedPinFile(value: SyncPinFile | null): value is HashedSyncPinFile { - if (!value || !("version" in value)) return false; - return value.version === 2 - && value.algorithm === "pbkdf2-sha256" - && Number.isInteger(value.iterations) - && value.iterations > 0 - && typeof value.salt === "string" - && /^[0-9a-f]+$/i.test(value.salt) - && typeof value.hash === "string" - && /^[0-9a-f]+$/i.test(value.hash); -} - -export function createSyncPinStore(args: SyncPinStoreArgs) { - fs.mkdirSync(path.dirname(args.filePath), { recursive: true }); - - let cachedPlainPin: string | null = null; - let cachedRecord: HashedSyncPinFile | null | undefined; - - const writeRecord = (record: HashedSyncPinFile): void => { - writeTextAtomic(args.filePath, `${JSON.stringify(record, null, 2)}\n`); - try { - fs.chmodSync(args.filePath, 0o600); - } catch { - // ignore chmod failures on platforms that don't support it - } - }; - - const readFromDisk = (): HashedSyncPinFile | null => { - if (!fs.existsSync(args.filePath)) return null; - const parsed = safeJsonParse<SyncPinFile | null>( - fs.readFileSync(args.filePath, "utf8"), - null, - ); - if (isHashedPinFile(parsed)) return parsed; - - const pin = typeof (parsed as LegacySyncPinFile | null)?.pin === "string" - ? (parsed as LegacySyncPinFile).pin.trim() - : ""; - if (!PIN_PATTERN.test(pin)) return null; - - const migrated = createHashedPinFile(pin, (parsed as LegacySyncPinFile).updatedAt); - writeRecord(migrated); - cachedPlainPin = pin; - return migrated; - }; - - const loadRecord = (): HashedSyncPinFile | null => { - if (cachedRecord !== undefined) return cachedRecord; - cachedRecord = readFromDisk(); - return cachedRecord; - }; - - return { - getPin(): string | null { - if (cachedPlainPin !== null) return cachedPlainPin; - loadRecord(); - return cachedPlainPin; - }, - - hasPin(): boolean { - return loadRecord() !== null; - }, - - verifyPin(pin: string): boolean { - const trimmed = pin.trim(); - if (!PIN_PATTERN.test(trimmed)) return false; - const record = loadRecord(); - if (!record) return false; - const hash = derivePinHash(trimmed, record.salt, record.iterations); - return safeEqualHex(hash, record.hash); - }, - - setPin(pin: string): void { - const trimmed = pin.trim(); - if (!PIN_PATTERN.test(trimmed)) { - throw new Error("PIN must be 6 digits."); - } - const payload = createHashedPinFile(trimmed); - writeRecord(payload); - cachedRecord = payload; - cachedPlainPin = trimmed; - }, - - clearPin(): void { - try { - fs.rmSync(args.filePath, { force: true }); - } catch { - // ignore cleanup failures - } - cachedRecord = null; - cachedPlainPin = null; - }, - }; -} - -export type SyncPinStore = ReturnType<typeof createSyncPinStore>; +export * from "../../../../../ade-cli/src/services/sync/syncPinStore"; diff --git a/apps/desktop/src/main/services/sync/syncProtocol.test.ts b/apps/desktop/src/main/services/sync/syncProtocol.test.ts index 380db8966..70b4d956d 100644 --- a/apps/desktop/src/main/services/sync/syncProtocol.test.ts +++ b/apps/desktop/src/main/services/sync/syncProtocol.test.ts @@ -5,6 +5,7 @@ describe("syncProtocol", () => { it("preserves request ids and leaves small payloads uncompressed", () => { const encoded = encodeSyncEnvelope({ type: "heartbeat", + projectId: " project-1 ", requestId: "req-1", payload: { kind: "ping", @@ -16,6 +17,7 @@ describe("syncProtocol", () => { const parsed = parseSyncEnvelope(encoded); expect(parsed.type).toBe("heartbeat"); + expect(parsed.projectId).toBe("project-1"); expect(parsed.requestId).toBe("req-1"); expect(parsed.compression).toBe("none"); expect(parsed.payload).toEqual({ @@ -55,6 +57,7 @@ describe("syncProtocol", () => { expect(wire.payloadEncoding).toBe("base64"); const parsed = parseSyncEnvelope(encoded); + expect(parsed.projectId).toBe(null); expect(parsed.requestId).toBe("req-large"); expect(parsed.compression).toBe("gzip"); expect(parsed.payload).toEqual(payload); diff --git a/apps/desktop/src/main/services/sync/syncProtocol.ts b/apps/desktop/src/main/services/sync/syncProtocol.ts index a1e9dcb32..6be409cc1 100644 --- a/apps/desktop/src/main/services/sync/syncProtocol.ts +++ b/apps/desktop/src/main/services/sync/syncProtocol.ts @@ -1,120 +1 @@ -import { gunzipSync, gzipSync } from "node:zlib"; -import type { SyncCompressionCodec, SyncEnvelope, SyncPeerPlatform, SyncProtocolVersion } from "../../../shared/types"; -import { safeJsonParse } from "../shared/utils"; - -export const SYNC_PROTOCOL_VERSION: SyncProtocolVersion = 1; -export const DEFAULT_SYNC_HOST_PORT = 8787; -export const DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES = 4 * 1024; - -export function mapPlatform(platform: NodeJS.Platform): SyncPeerPlatform { - switch (platform) { - case "darwin": - return "macOS"; - case "linux": - return "linux"; - case "win32": - return "windows"; - default: - return "unknown"; - } -} - -export function wsDataToText(data: unknown): string { - if (typeof data === "string") return data; - if (Buffer.isBuffer(data)) return data.toString("utf8"); - if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); - return String(data); -} - -export type ParsedSyncEnvelope = { - version: SyncProtocolVersion; - type: SyncEnvelope["type"]; - requestId: string | null; - compression: SyncCompressionCodec; - payload: unknown; - raw: SyncEnvelope; -}; - -type EncodeEnvelopeArgs = { - type: SyncEnvelope["type"]; - requestId?: string | null; - payload: unknown; - compressionThresholdBytes?: number; -}; - -function asSyncEnvelope(value: unknown): SyncEnvelope { - return value as SyncEnvelope; -} - -export function encodeSyncEnvelope(args: EncodeEnvelopeArgs): string { - const payloadJson = JSON.stringify(args.payload ?? null); - const payloadBytes = Buffer.byteLength(payloadJson, "utf8"); - const requestId = typeof args.requestId === "string" && args.requestId.trim().length > 0 - ? args.requestId.trim() - : null; - const threshold = Math.max(0, Math.floor(args.compressionThresholdBytes ?? DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES)); - - if (payloadBytes >= threshold) { - const compressed = gzipSync(Buffer.from(payloadJson, "utf8")); - return JSON.stringify(asSyncEnvelope({ - version: SYNC_PROTOCOL_VERSION, - type: args.type, - requestId, - compression: "gzip", - payloadEncoding: "base64", - payload: compressed.toString("base64"), - uncompressedBytes: payloadBytes, - })); - } - - return JSON.stringify(asSyncEnvelope({ - version: SYNC_PROTOCOL_VERSION, - type: args.type, - requestId, - compression: "none", - payloadEncoding: "json", - payload: args.payload ?? null, - })); -} - -export function parseSyncEnvelope(rawText: string): ParsedSyncEnvelope { - const decoded = safeJsonParse<SyncEnvelope | null>(rawText, null); - if (!decoded || typeof decoded !== "object") { - throw new Error("Invalid sync envelope JSON."); - } - if (decoded.version !== SYNC_PROTOCOL_VERSION) { - throw new Error(`Unsupported sync protocol version: ${String((decoded as { version?: unknown }).version ?? "unknown")}`); - } - - const requestId = typeof decoded.requestId === "string" && decoded.requestId.trim().length > 0 - ? decoded.requestId.trim() - : null; - - if (decoded.compression === "gzip") { - if (decoded.payloadEncoding !== "base64" || typeof decoded.payload !== "string") { - throw new Error("Compressed sync envelopes must use base64 payload encoding."); - } - const uncompressed = gunzipSync(Buffer.from(decoded.payload, "base64")).toString("utf8"); - return { - version: decoded.version, - type: decoded.type, - requestId, - compression: "gzip", - payload: safeJsonParse(uncompressed, null), - raw: decoded, - }; - } - - if (decoded.payloadEncoding !== "json") { - throw new Error("Uncompressed sync envelopes must use JSON payload encoding."); - } - - return { - version: decoded.version, - type: decoded.type, - requestId, - compression: "none", - payload: decoded.payload, - raw: decoded, - }; -} +export * from "../../../../../ade-cli/src/services/sync/syncProtocol"; diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 088a1a387..192c0aa08 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -609,6 +609,7 @@ describe("createSyncRemoteCommandService", () => { expect(descriptors).toHaveLength(actions.length); for (const desc of descriptors) { expect(desc).toHaveProperty("action"); + expect(desc.scope).toBe("project"); expect(desc).toHaveProperty("policy"); expect(desc.policy).toHaveProperty("viewerAllowed"); } @@ -641,6 +642,21 @@ describe("createSyncRemoteCommandService", () => { }); }); + describe("getDescriptor", () => { + it("returns scope and policy for a known action", () => { + const descriptor = service.getDescriptor("lanes.list"); + expect(descriptor).toEqual(expect.objectContaining({ + action: "lanes.list", + scope: "project", + policy: expect.objectContaining({ viewerAllowed: true }), + })); + }); + + it("returns null for an unknown action", () => { + expect(service.getDescriptor("totally.unknown.action")).toBeNull(); + }); + }); + // --------------------------------------------------------------- // execute: unknown action // --------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index e4b4c4452..2c2731ce2 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -1,2514 +1 @@ -import { randomUUID } from "node:crypto"; -import type { - AgentChatCreateArgs, - AgentChatArchiveArgs, - AgentChatApproveArgs, - AgentChatDisposeArgs, - AgentChatFileRef, - AgentChatGetSummaryArgs, - AgentChatListArgs, - AgentChatProvider, - AgentChatRespondToInputArgs, - AgentChatResumeArgs, - AgentChatSendArgs, - AgentChatSession, - AgentChatSessionSummary, - AgentChatSteerArgs, - AgentChatCancelSteerArgs, - AgentChatEditSteerArgs, - AgentChatDispatchSteerArgs, - AgentChatCancelDispatchedSteerArgs, - AgentChatInterruptArgs, - AgentChatUpdateSessionArgs, - AgentStatus, - AddPrCommentArgs, - AiReviewSummaryArgs, - ApplyLaneTemplateArgs, - ArchiveLaneArgs, - AttachLaneArgs, - ClosePrArgs, - CancelQueueAutomationArgs, - CtoCoreMemory, - CtoIdentity, - CtoTriggerAgentWakeupArgs, - CreateChildLaneArgs, - CreateLaneArgs, - CreateLaneFromUnstagedArgs, - CreatePrFromLaneArgs, - CreateIntegrationLaneForProposalArgs, - ConvergenceRuntimeState, - CleanupIntegrationWorkflowArgs, - DeleteLaneArgs, - DeleteIntegrationProposalArgs, - DismissIntegrationCleanupArgs, - DraftPrDescriptionArgs, - GetDiffChangesArgs, - GetFileDiffArgs, - GitBatchFileActionArgs, - GitCherryPickArgs, - GitCommitArgs, - GitFileActionArgs, - GitGenerateCommitMessageArgs, - GitGetCommitMessageArgs, - GitGetFileHistoryArgs, - GitCheckoutBranchArgs, - GitListBranchesArgs, - GitListCommitFilesArgs, - GitPushArgs, - GitRevertArgs, - GitStashPushArgs, - GitStashRefArgs, - GitSyncArgs, - ImportBranchLaneArgs, - LandPrArgs, - LandQueueNextArgs, - PauseQueueAutomationArgs, - PipelineSettings, - PrConvergenceStatePatch, - LaneEnvInitConfig, - LaneEnvInitProgress, - LaneDetailPayload, - LaneListSnapshot, - LaneOverlayOverrides, - LaneStateSnapshotSummary, - ListLanesArgs, - ListIntegrationWorkflowsArgs, - ListSessionsArgs, - LinkPrToLaneArgs, - RebasePushArgs, - RebaseStartArgs, - RenameLaneArgs, - ReopenPrArgs, - RecheckIntegrationStepArgs, - ReactToPrCommentArgs, - ReplyToPrReviewThreadArgs, - ReparentLaneArgs, - RequestPrReviewersArgs, - ReorderQueuePrsArgs, - ResumeQueueAutomationArgs, - RerunPrChecksArgs, - SetPrLabelsArgs, - SetPrReviewThreadResolvedArgs, - StartIntegrationResolutionArgs, - SubmitPrReviewArgs, - SyncCommandPayload, - SyncRemoteCommandAction, - SyncRemoteCommandDescriptor, - SyncRemoteCommandPolicy, - SyncStartCliSessionArgs, - SyncStartCliSessionResult, - SyncRunQuickCommandArgs, - TerminalSessionSummary, - UpdateSessionMetaArgs, - UpdateIntegrationProposalArgs, - TerminalToolType, - UpdateLaneAppearanceArgs, - UpdatePrBodyArgs, - UpdatePrTitleArgs, - WriteTextAtomicArgs, -} from "../../../shared/types"; -import { - buildTrackedCliLaunchCommand, - buildTrackedCliResumeCommand, - isLaunchProfile, - isTrackedCliPermissionMode, - LAUNCH_PROFILE_TITLE, - LAUNCH_PROFILE_TOOL_TYPE, - launchProfileForTerminalSession, - resolveTrackedCliResumeCommand, - validateLaunchProfilePermissionMode, -} from "../../../shared/cliLaunch"; -import { normalizePrCreationStrategy } from "../../../shared/prStrategy"; -import type { createAgentChatService } from "../chat/agentChatService"; -import type { createCtoStateService } from "../cto/ctoStateService"; -import type { createFlowPolicyService } from "../cto/flowPolicyService"; -import type { createLinearCredentialService } from "../cto/linearCredentialService"; -import type { createLinearIngressService } from "../cto/linearIngressService"; -import type { createLinearIssueTracker } from "../cto/linearIssueTracker"; -import type { createLinearSyncService } from "../cto/linearSyncService"; -import type { createWorkerAgentService } from "../cto/workerAgentService"; -import type { createWorkerBudgetService } from "../cto/workerBudgetService"; -import type { createWorkerHeartbeatService } from "../cto/workerHeartbeatService"; -import type { createWorkerRevisionService } from "../cto/workerRevisionService"; -import { matchLaneOverlayPolicies } from "../config/laneOverlayMatcher"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createConflictService } from "../conflicts/conflictService"; -import type { createDiffService } from "../diffs/diffService"; -import type { createFileService } from "../files/fileService"; -import type { createGitOperationsService } from "../git/gitOperationsService"; -import type { createAutoRebaseService } from "../lanes/autoRebaseService"; -import type { createLaneEnvironmentService } from "../lanes/laneEnvironmentService"; -import type { createLaneService } from "../lanes/laneService"; -import type { createLaneTemplateService } from "../lanes/laneTemplateService"; -import type { createPortAllocationService } from "../lanes/portAllocationService"; -import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionService"; -import type { createProcessService } from "../processes/processService"; -import type { Logger } from "../logging/logger"; -import type { createPrService } from "../prs/prService"; -import type { createIssueInventoryService } from "../prs/issueInventoryService"; -import type { PathToMergeOrchestrator } from "../prs/pathToMergeOrchestrator"; -import type { createQueueLandingService } from "../prs/queueLandingService"; -import type { createPtyService } from "../pty/ptyService"; -import type { createSessionService } from "../sessions/sessionService"; - -type SyncRemoteCommandServiceArgs = { - laneService: ReturnType<typeof createLaneService>; - prService: ReturnType<typeof createPrService>; - issueInventoryService?: ReturnType<typeof createIssueInventoryService> | null; - /** - * Optional Path-to-Merge orchestrator. When present, iOS callers can start - * and stop the convergence loop via the `prs.pathToMerge.start` / - * `prs.pathToMerge.stop` sync commands. Optional so older builds (without - * the orchestrator wired) keep compiling and degrade gracefully on iOS. - */ - pathToMergeOrchestrator?: PathToMergeOrchestrator | null; - queueLandingService?: ReturnType<typeof createQueueLandingService> | null; - ptyService: ReturnType<typeof createPtyService>; - sessionService: ReturnType<typeof createSessionService>; - fileService: ReturnType<typeof createFileService>; - gitService?: ReturnType<typeof createGitOperationsService>; - diffService?: ReturnType<typeof createDiffService>; - conflictService?: ReturnType<typeof createConflictService>; - agentChatService?: ReturnType<typeof createAgentChatService>; - workerAgentService?: ReturnType<typeof createWorkerAgentService> | null; - workerBudgetService?: ReturnType<typeof createWorkerBudgetService> | null; - workerHeartbeatService?: ReturnType<typeof createWorkerHeartbeatService> | null; - workerRevisionService?: ReturnType<typeof createWorkerRevisionService> | null; - ctoStateService?: ReturnType<typeof createCtoStateService> | null; - flowPolicyService?: ReturnType<typeof createFlowPolicyService> | null; - linearCredentialService?: ReturnType<typeof createLinearCredentialService> | null; - /** - * Resolvers for services created after createSyncService in main.ts. - * Router handlers read them lazily so init order is not load-bearing. - */ - getLinearIngressService?: () => ReturnType<typeof createLinearIngressService> | null; - getLinearIssueTracker?: () => ReturnType<typeof createLinearIssueTracker> | null; - getLinearSyncService?: () => ReturnType<typeof createLinearSyncService> | null; - projectConfigService?: ReturnType<typeof createProjectConfigService>; - processService?: ReturnType<typeof createProcessService> | null; - portAllocationService?: ReturnType<typeof createPortAllocationService> | null; - laneEnvironmentService?: ReturnType<typeof createLaneEnvironmentService> | null; - laneTemplateService?: ReturnType<typeof createLaneTemplateService> | null; - rebaseSuggestionService?: ReturnType<typeof createRebaseSuggestionService> | null; - autoRebaseService?: ReturnType<typeof createAutoRebaseService> | null; - logger: Logger; -}; - -type RegisteredRemoteCommand = { - descriptor: SyncRemoteCommandDescriptor; - handler: (args: Record<string, unknown>) => Promise<unknown>; -}; - -function isRecord(value: unknown): value is Record<string, unknown> { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function asTrimmedString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function asOptionalBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - -function asOptionalNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.map((entry) => asTrimmedString(entry)).filter((entry): entry is string => Boolean(entry)); -} - -function parseAgentChatFileRefs(value: unknown): AgentChatFileRef[] | undefined { - if (!Array.isArray(value)) return undefined; - const attachments: AgentChatFileRef[] = []; - for (const entry of value) { - if (!isRecord(entry)) continue; - const path = asTrimmedString(entry.path); - const type = entry.type === "image" ? "image" : entry.type === "file" ? "file" : null; - if (!path || !type) continue; - attachments.push({ path, type }); - } - return attachments; -} - -function parseCursorConfigValues( - value: unknown, -): AgentChatUpdateSessionArgs["cursorConfigValues"] | AgentChatCreateArgs["cursorConfigValues"] { - if (value == null) return null; - if (!isRecord(value)) return {}; - return Object.fromEntries( - Object.entries(value) - .filter((entry): entry is [string, string | boolean | number] => ( - typeof entry[1] === "string" - || typeof entry[1] === "boolean" - || (typeof entry[1] === "number" && Number.isFinite(entry[1])) - )) - .map(([key, entryValue]): [string, string | boolean | number] => [key.trim(), entryValue]) - .filter(([key]) => key.length > 0), - ); -} - -function requireString(value: unknown, message: string): string { - const parsed = asTrimmedString(value); - if (!parsed) throw new Error(message); - return parsed; -} - -function requireStringArray(value: unknown, message: string): string[] { - const parsed = asStringArray(value); - if (parsed.length === 0) throw new Error(message); - return parsed; -} - -function requireService<T>(value: T | null | undefined, message: string): T { - if (value == null) throw new Error(message); - return value; -} - -function parseProcessLaneArgs(payload: Record<string, unknown>, action: string): { laneId: string } { - return { - laneId: requireString(payload.laneId, `${action} requires laneId.`), - }; -} - -function parseProcessActionArgs(payload: Record<string, unknown>, action: string): { laneId: string; processId: string; runId?: string } { - const parsed = { - laneId: requireString(payload.laneId, `${action} requires laneId.`), - processId: requireString(payload.processId, `${action} requires processId.`), - }; - const runId = asTrimmedString(payload.runId); - return runId ? { ...parsed, runId } : parsed; -} - -async function summarizeChatSessionForRemote( - agentChatService: ReturnType<typeof createAgentChatService>, - session: AgentChatSession, -): Promise<AgentChatSessionSummary> { - const summary = await agentChatService.getSessionSummary(session.id); - if (summary) return summary; - - return { - sessionId: session.id, - laneId: session.laneId, - provider: session.provider, - model: session.model, - ...(session.modelId ? { modelId: session.modelId } : {}), - ...(session.sessionProfile ? { sessionProfile: session.sessionProfile } : {}), - reasoningEffort: session.reasoningEffort ?? null, - codexFastMode: session.codexFastMode === true, - executionMode: session.executionMode ?? null, - ...(session.permissionMode ? { permissionMode: session.permissionMode } : {}), - ...(session.interactionMode !== undefined ? { interactionMode: session.interactionMode } : {}), - ...(session.claudePermissionMode ? { claudePermissionMode: session.claudePermissionMode } : {}), - ...(session.codexApprovalPolicy ? { codexApprovalPolicy: session.codexApprovalPolicy } : {}), - ...(session.codexSandbox ? { codexSandbox: session.codexSandbox } : {}), - ...(session.codexConfigSource ? { codexConfigSource: session.codexConfigSource } : {}), - ...(session.opencodePermissionMode ? { opencodePermissionMode: session.opencodePermissionMode } : {}), - ...(session.droidPermissionMode ? { droidPermissionMode: session.droidPermissionMode } : {}), - ...(session.cursorModeSnapshot ? { cursorModeSnapshot: session.cursorModeSnapshot } : {}), - ...(session.cursorModeId !== undefined ? { cursorModeId: session.cursorModeId } : {}), - ...(session.cursorConfigValues ? { cursorConfigValues: session.cursorConfigValues } : {}), - ...(session.identityKey ? { identityKey: session.identityKey } : {}), - ...(session.surface ? { surface: session.surface } : {}), - automationId: session.automationId ?? null, - automationRunId: session.automationRunId ?? null, - ...(session.capabilityMode ? { capabilityMode: session.capabilityMode } : {}), - completion: session.completion ?? null, - status: session.status, - idleSinceAt: session.idleSinceAt ?? null, - startedAt: session.createdAt, - endedAt: null, - lastActivityAt: session.lastActivityAt, - lastOutputPreview: null, - summary: null, - ...(session.threadId ? { threadId: session.threadId } : {}), - ...(session.requestedCwd !== undefined ? { requestedCwd: session.requestedCwd } : {}), - }; -} - -function parseListLanesArgs(value: Record<string, unknown>): ListLanesArgs { - return { - includeArchived: asOptionalBoolean(value.includeArchived), - includeStatus: asOptionalBoolean(value.includeStatus), - }; -} - -function parseCreateLaneArgs(value: Record<string, unknown>): CreateLaneArgs { - return { - name: requireString(value.name, "lanes.create requires name."), - ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), - ...(asTrimmedString(value.parentLaneId) ? { parentLaneId: asTrimmedString(value.parentLaneId)! } : {}), - ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), - }; -} - -function parseCreateChildLaneArgs(value: Record<string, unknown>): CreateChildLaneArgs { - return { - name: requireString(value.name, "lanes.createChild requires name."), - parentLaneId: requireString(value.parentLaneId, "lanes.createChild requires parentLaneId."), - ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), - ...(asTrimmedString(value.folder) ? { folder: asTrimmedString(value.folder)! } : {}), - }; -} - -function parseCreateLaneFromUnstagedArgs(value: Record<string, unknown>): CreateLaneFromUnstagedArgs { - return { - name: requireString(value.name, "lanes.createFromUnstaged requires name."), - sourceLaneId: requireString(value.sourceLaneId, "lanes.createFromUnstaged requires sourceLaneId."), - }; -} - -function parseImportBranchArgs(value: Record<string, unknown>): ImportBranchLaneArgs { - return { - branchRef: requireString(value.branchRef, "lanes.importBranch requires branchRef."), - ...(asTrimmedString(value.name) ? { name: asTrimmedString(value.name)! } : {}), - ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), - ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), - }; -} - -function parseAttachLaneArgs(value: Record<string, unknown>): AttachLaneArgs { - return { - name: requireString(value.name, "lanes.attach requires name."), - attachedPath: requireString(value.attachedPath, "lanes.attach requires attachedPath."), - ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), - }; -} - -function parseArchiveLaneArgs(value: Record<string, unknown>, action: string): ArchiveLaneArgs { - return { - laneId: requireString(value.laneId, `${action} requires laneId.`), - }; -} - -function parseDeleteLaneArgs(value: Record<string, unknown>): DeleteLaneArgs { - return { - laneId: requireString(value.laneId, "lanes.delete requires laneId."), - deleteBranch: asOptionalBoolean(value.deleteBranch), - deleteRemoteBranch: asOptionalBoolean(value.deleteRemoteBranch), - ...(asTrimmedString(value.remoteName) ? { remoteName: asTrimmedString(value.remoteName)! } : {}), - force: asOptionalBoolean(value.force), - }; -} - -function parseRenameLaneArgs(value: Record<string, unknown>): RenameLaneArgs { - return { - laneId: requireString(value.laneId, "lanes.rename requires laneId."), - name: requireString(value.name, "lanes.rename requires name."), - }; -} - -function parseReparentLaneArgs(value: Record<string, unknown>): ReparentLaneArgs { - return { - laneId: requireString(value.laneId, "lanes.reparent requires laneId."), - newParentLaneId: requireString(value.newParentLaneId, "lanes.reparent requires newParentLaneId."), - }; -} - -function parseUpdateLaneAppearanceArgs(value: Record<string, unknown>): UpdateLaneAppearanceArgs { - const parsed: UpdateLaneAppearanceArgs = { - laneId: requireString(value.laneId, "lanes.updateAppearance requires laneId."), - }; - if ("color" in value) { - parsed.color = value.color == null ? null : asTrimmedString(value.color) ?? null; - } - if ("icon" in value) { - parsed.icon = value.icon == null ? null : (asTrimmedString(value.icon) as UpdateLaneAppearanceArgs["icon"]); - } - if ("tags" in value) { - parsed.tags = value.tags == null ? null : asStringArray(value.tags); - } - return parsed; -} - -function parseRebaseStartArgs(value: Record<string, unknown>): RebaseStartArgs { - return { - laneId: requireString(value.laneId, "lanes.rebaseStart requires laneId."), - ...(asTrimmedString(value.scope) ? { scope: value.scope as RebaseStartArgs["scope"] } : {}), - ...(asTrimmedString(value.pushMode) ? { pushMode: value.pushMode as RebaseStartArgs["pushMode"] } : {}), - ...(asTrimmedString(value.actor) ? { actor: asTrimmedString(value.actor)! } : {}), - ...(asTrimmedString(value.reason) ? { reason: asTrimmedString(value.reason)! } : {}), - ...(asTrimmedString(value.baseBranchOverride) ? { baseBranchOverride: asTrimmedString(value.baseBranchOverride)! } : {}), - }; -} - -function parseRebasePushArgs(value: Record<string, unknown>): RebasePushArgs { - return { - runId: requireString(value.runId, "lanes.rebasePush requires runId."), - laneIds: requireStringArray(value.laneIds, "lanes.rebasePush requires laneIds."), - }; -} - -function parseRunIdArgs(value: Record<string, unknown>, action: string): { runId: string } { - return { - runId: requireString(value.runId, `${action} requires runId.`), - }; -} - -function parseListSessionsArgs(value: Record<string, unknown>): ListSessionsArgs { - const laneId = asTrimmedString(value.laneId); - const status = asTrimmedString(value.status) as ListSessionsArgs["status"]; - const limit = asOptionalNumber(value.limit); - return { - ...(laneId ? { laneId } : {}), - ...(status ? { status } : {}), - ...(typeof limit === "number" ? { limit } : {}), - }; -} - -function parseUpdateSessionMetaArgs(value: Record<string, unknown>): UpdateSessionMetaArgs { - const parsed: UpdateSessionMetaArgs = { - sessionId: requireString(value.sessionId, "work.updateSessionMeta requires sessionId."), - }; - - if ("pinned" in value) parsed.pinned = value.pinned === true; - if ("manuallyNamed" in value) parsed.manuallyNamed = value.manuallyNamed === true; - if ("title" in value) parsed.title = value.title == null ? undefined : requireString(value.title, "work.updateSessionMeta requires a non-empty title when title is provided."); - if ("goal" in value) parsed.goal = value.goal == null ? null : asTrimmedString(value.goal) ?? null; - if ("toolType" in value) { - parsed.toolType = value.toolType == null - ? null - : asTrimmedString(value.toolType) as UpdateSessionMetaArgs["toolType"]; - } - if ("resumeCommand" in value) { - parsed.resumeCommand = value.resumeCommand == null ? null : asTrimmedString(value.resumeCommand) ?? null; - } - - return parsed; -} - -function parseQuickCommandArgs(value: Record<string, unknown>): SyncRunQuickCommandArgs { - const laneId = requireString(value.laneId, "work.runQuickCommand requires laneId."); - const title = requireString(value.title, "work.runQuickCommand requires title."); - const toolType = asTrimmedString(value.toolType); - const startupCommand = asTrimmedString(value.startupCommand); - if (!startupCommand && toolType !== "shell") { - throw new Error("work.runQuickCommand requires startupCommand unless toolType is shell."); - } - return { - laneId, - title, - ...(startupCommand ? { startupCommand } : {}), - cols: asOptionalNumber(value.cols), - rows: asOptionalNumber(value.rows), - toolType, - tracked: asOptionalBoolean(value.tracked), - }; -} - -const DEFAULT_CLI_COLS = 120; -const DEFAULT_CLI_ROWS = 36; - -function clampCliDimension(value: number | undefined, fallback: number, min: number, max: number): number { - return Math.max(min, Math.min(max, Math.floor(value ?? fallback))); -} - -function parseCliProvider(value: unknown): SyncStartCliSessionArgs["provider"] { - const provider = asTrimmedString(value)?.toLowerCase(); - if (!isLaunchProfile(provider)) throw new Error("work.startCliSession requires provider."); - return provider; -} - -function parseCliPermissionMode(value: unknown): SyncStartCliSessionArgs["permissionMode"] { - const mode = asTrimmedString(value); - return isTrackedCliPermissionMode(mode) ? mode : "default"; -} - -function parseStartCliSessionArgs(value: Record<string, unknown>): SyncStartCliSessionArgs { - const laneId = requireString(value.laneId, "work.startCliSession requires laneId."); - const provider = parseCliProvider(value.provider); - const initialInput = typeof value.initialInput === "string" && value.initialInput.trim().length > 0 - ? value.initialInput.slice(0, 20_000) - : null; - return { - laneId, - provider, - permissionMode: parseCliPermissionMode(value.permissionMode), - title: asTrimmedString(value.title), - initialInput, - cols: asOptionalNumber(value.cols), - rows: asOptionalNumber(value.rows), - resumeSessionId: asTrimmedString(value.resumeSessionId), - }; -} - -function requireResumeSessionForProvider( - sessionService: ReturnType<typeof createSessionService>, - sessionId: string, - provider: SyncStartCliSessionArgs["provider"], -): TerminalSessionSummary { - const session = sessionService.get(sessionId) as TerminalSessionSummary | null; - if (!session) throw new Error(`work.startCliSession resumeSessionId '${sessionId}' was not found.`); - const existingProvider = launchProfileForTerminalSession(session); - if (existingProvider && existingProvider !== provider) { - throw new Error(`work.startCliSession resumeSessionId '${sessionId}' belongs to ${existingProvider}, not ${provider}.`); - } - return session; -} - -function isChatToolType(toolType: string | null | undefined): boolean { - if (!toolType) return false; - const t = toolType.trim().toLowerCase(); - return t === "cursor" || t.endsWith("-chat"); -} - -async function listRemoteWorkSessions( - args: SyncRemoteCommandServiceArgs, - filters: ListSessionsArgs, -) { - const sessions = args.ptyService.enrichSessions(args.sessionService.list(filters)); - const laneId = typeof filters.laneId === "string" ? filters.laneId.trim() : ""; - const allChats = await args.agentChatService - ?.listSessions(laneId || undefined, { includeIdentity: true }) - .catch(() => [] as AgentChatSessionSummary[]) ?? []; - - const identitySessionIds = new Set( - allChats.filter((chat) => Boolean(chat.identityKey)).map((chat) => chat.sessionId), - ); - const visibleSessions = identitySessionIds.size > 0 - ? sessions.filter((session) => !identitySessionIds.has(session.id)) - : sessions; - - const chatSummaryBySessionId = new Map( - allChats.filter((chat) => !chat.identityKey).map((chat) => [chat.sessionId, chat] as const), - ); - if (chatSummaryBySessionId.size === 0) return visibleSessions; - - return visibleSessions.map((session) => { - if (!isChatToolType(session.toolType) || session.status !== "running") return session; - const chat = chatSummaryBySessionId.get(session.id); - if (!chat) return session; - if (chat.awaitingInput) return { ...session, runtimeState: "waiting-input" as const, chatIdleSinceAt: null }; - if (chat.status === "active") return { ...session, runtimeState: "running" as const, chatIdleSinceAt: null }; - if (chat.status === "idle") return { ...session, runtimeState: "idle" as const, chatIdleSinceAt: chat.idleSinceAt ?? null }; - return session; - }); -} - -function parseCloseSessionArgs(value: Record<string, unknown>): { sessionId: string } { - return { - sessionId: requireString(value.sessionId, "work.closeSession requires sessionId."), - }; -} - -function parseAgentChatListArgs(value: Record<string, unknown>): AgentChatListArgs { - return { - ...(asTrimmedString(value.laneId) ? { laneId: asTrimmedString(value.laneId)! } : {}), - includeAutomation: asOptionalBoolean(value.includeAutomation), - }; -} - -function parseAgentChatGetSummaryArgs(value: Record<string, unknown>): AgentChatGetSummaryArgs { - return { - sessionId: requireString(value.sessionId, "chat.getSummary requires sessionId."), - }; -} - -function parseAgentChatCreateArgs(value: Record<string, unknown>): AgentChatCreateArgs { - const parsed: AgentChatCreateArgs = { - laneId: requireString(value.laneId, "chat.create requires laneId."), - provider: (asTrimmedString(value.provider) ?? "codex") as AgentChatCreateArgs["provider"], - model: asTrimmedString(value.model) ?? "", - ...(asTrimmedString(value.modelId) ? { modelId: asTrimmedString(value.modelId)! } : {}), - ...(asTrimmedString(value.reasoningEffort) ? { reasoningEffort: asTrimmedString(value.reasoningEffort)! } : {}), - }; - - if ("sessionProfile" in value) parsed.sessionProfile = value.sessionProfile == null ? undefined : asTrimmedString(value.sessionProfile) as AgentChatCreateArgs["sessionProfile"]; - if ("permissionMode" in value) parsed.permissionMode = value.permissionMode == null ? undefined : asTrimmedString(value.permissionMode) as AgentChatCreateArgs["permissionMode"]; - if ("interactionMode" in value) parsed.interactionMode = value.interactionMode == null ? null : asTrimmedString(value.interactionMode) as AgentChatCreateArgs["interactionMode"]; - if ("claudePermissionMode" in value) parsed.claudePermissionMode = value.claudePermissionMode == null ? undefined : asTrimmedString(value.claudePermissionMode) as AgentChatCreateArgs["claudePermissionMode"]; - if ("codexApprovalPolicy" in value) parsed.codexApprovalPolicy = value.codexApprovalPolicy == null ? undefined : asTrimmedString(value.codexApprovalPolicy) as AgentChatCreateArgs["codexApprovalPolicy"]; - if ("codexSandbox" in value) parsed.codexSandbox = value.codexSandbox == null ? undefined : asTrimmedString(value.codexSandbox) as AgentChatCreateArgs["codexSandbox"]; - if ("codexConfigSource" in value) parsed.codexConfigSource = value.codexConfigSource == null ? undefined : asTrimmedString(value.codexConfigSource) as AgentChatCreateArgs["codexConfigSource"]; - if ("codexFastMode" in value) parsed.codexFastMode = asOptionalBoolean(value.codexFastMode); - if ("opencodePermissionMode" in value) parsed.opencodePermissionMode = value.opencodePermissionMode == null ? undefined : asTrimmedString(value.opencodePermissionMode) as AgentChatCreateArgs["opencodePermissionMode"]; - if ("droidPermissionMode" in value) parsed.droidPermissionMode = value.droidPermissionMode == null ? undefined : (asTrimmedString(value.droidPermissionMode) ?? undefined) as AgentChatCreateArgs["droidPermissionMode"]; - if ("cursorModeId" in value) parsed.cursorModeId = value.cursorModeId == null ? null : asTrimmedString(value.cursorModeId) ?? null; - if ("cursorConfigValues" in value) parsed.cursorConfigValues = parseCursorConfigValues(value.cursorConfigValues); - if ("requestedCwd" in value) parsed.requestedCwd = value.requestedCwd == null ? undefined : requireString(value.requestedCwd, "chat.create requires a non-empty requestedCwd when provided."); - - return parsed; -} - -function parseAgentChatSendArgs(value: Record<string, unknown>): AgentChatSendArgs { - const attachments = parseAgentChatFileRefs(value.attachments); - return { - sessionId: requireString(value.sessionId, "chat.send requires sessionId."), - text: requireString(value.text, "chat.send requires text."), - ...(asTrimmedString(value.displayText) ? { displayText: asTrimmedString(value.displayText)! } : {}), - ...(attachments?.length ? { attachments } : {}), - ...(asTrimmedString(value.reasoningEffort) ? { reasoningEffort: asTrimmedString(value.reasoningEffort)! } : {}), - ...(asTrimmedString(value.executionMode) ? { executionMode: asTrimmedString(value.executionMode)! as AgentChatSendArgs["executionMode"] } : {}), - ...(asTrimmedString(value.interactionMode) ? { interactionMode: asTrimmedString(value.interactionMode)! as AgentChatSendArgs["interactionMode"] } : {}), - }; -} - -function parseAgentChatSteerArgs(value: Record<string, unknown>): AgentChatSteerArgs { - const attachments = parseAgentChatFileRefs(value.attachments); - return { - sessionId: requireString(value.sessionId, "chat.steer requires sessionId."), - text: requireString(value.text, "chat.steer requires text."), - ...(attachments?.length ? { attachments } : {}), - }; -} - -function parseAgentChatCancelSteerArgs(value: Record<string, unknown>): AgentChatCancelSteerArgs { - return { - sessionId: requireString(value.sessionId, "chat.cancelSteer requires sessionId."), - steerId: requireString(value.steerId, "chat.cancelSteer requires steerId."), - }; -} - -function parseAgentChatEditSteerArgs(value: Record<string, unknown>): AgentChatEditSteerArgs { - return { - sessionId: requireString(value.sessionId, "chat.editSteer requires sessionId."), - steerId: requireString(value.steerId, "chat.editSteer requires steerId."), - text: requireString(value.text, "chat.editSteer requires text."), - }; -} - -function parseAgentChatDispatchSteerArgs(value: Record<string, unknown>): AgentChatDispatchSteerArgs { - const mode = value.mode; - if (mode !== "inline" && mode !== "interrupt") { - throw new Error("chat.dispatchSteer requires mode of 'inline' or 'interrupt'."); - } - return { - sessionId: requireString(value.sessionId, "chat.dispatchSteer requires sessionId."), - steerId: requireString(value.steerId, "chat.dispatchSteer requires steerId."), - mode, - }; -} - -function parseAgentChatCancelDispatchedSteerArgs(value: Record<string, unknown>): AgentChatCancelDispatchedSteerArgs { - return { - sessionId: requireString(value.sessionId, "chat.cancelDispatchedSteer requires sessionId."), - steerId: requireString(value.steerId, "chat.cancelDispatchedSteer requires steerId."), - }; -} - -function parseAgentChatInterruptArgs(value: Record<string, unknown>): AgentChatInterruptArgs { - return { - sessionId: requireString(value.sessionId, "chat.interrupt requires sessionId."), - }; -} - -function parseAgentChatResumeArgs(value: Record<string, unknown>): AgentChatResumeArgs { - return { - sessionId: requireString(value.sessionId, "chat.resume requires sessionId."), - }; -} - -function parseAgentChatApproveArgs(value: Record<string, unknown>): AgentChatApproveArgs { - return { - sessionId: requireString(value.sessionId, "chat.approve requires sessionId."), - itemId: requireString(value.itemId, "chat.approve requires itemId."), - decision: requireString(value.decision, "chat.approve requires decision.") as AgentChatApproveArgs["decision"], - ...(asTrimmedString(value.responseText) ? { responseText: asTrimmedString(value.responseText)! } : {}), - }; -} - -function parseAgentChatRespondToInputArgs(value: Record<string, unknown>): AgentChatRespondToInputArgs { - const parsed: AgentChatRespondToInputArgs = { - sessionId: requireString(value.sessionId, "chat.respondToInput requires sessionId."), - itemId: requireString(value.itemId, "chat.respondToInput requires itemId."), - }; - - if (typeof value.decision === "string" && value.decision.trim().length > 0) { - parsed.decision = value.decision.trim() as AgentChatRespondToInputArgs["decision"]; - } - if (isRecord(value.answers)) { - parsed.answers = Object.fromEntries( - Object.entries(value.answers).map(([key, entry]) => { - if (Array.isArray(entry)) { - return [key, entry.map((item) => String(item))]; - } - return [key, String(entry)]; - }), - ); - } - if (typeof value.responseText === "string" && value.responseText.trim().length > 0) { - parsed.responseText = value.responseText.trim(); - } - return parsed; -} - -function parseAgentChatUpdateSessionArgs(value: Record<string, unknown>): AgentChatUpdateSessionArgs { - const parsed: AgentChatUpdateSessionArgs = { - sessionId: requireString(value.sessionId, "chat.updateSession requires sessionId."), - }; - - if ("title" in value) parsed.title = value.title == null ? null : asTrimmedString(value.title) ?? null; - if ("modelId" in value) parsed.modelId = value.modelId == null ? undefined : asTrimmedString(value.modelId) as AgentChatUpdateSessionArgs["modelId"]; - if ("reasoningEffort" in value) parsed.reasoningEffort = value.reasoningEffort == null ? null : asTrimmedString(value.reasoningEffort) ?? null; - if ("permissionMode" in value) parsed.permissionMode = value.permissionMode == null ? undefined : asTrimmedString(value.permissionMode) as AgentChatUpdateSessionArgs["permissionMode"]; - if ("interactionMode" in value) parsed.interactionMode = value.interactionMode == null ? null : asTrimmedString(value.interactionMode) as AgentChatUpdateSessionArgs["interactionMode"]; - if ("claudePermissionMode" in value) parsed.claudePermissionMode = value.claudePermissionMode == null ? undefined : asTrimmedString(value.claudePermissionMode) as AgentChatUpdateSessionArgs["claudePermissionMode"]; - if ("codexApprovalPolicy" in value) parsed.codexApprovalPolicy = value.codexApprovalPolicy == null ? undefined : asTrimmedString(value.codexApprovalPolicy) as AgentChatUpdateSessionArgs["codexApprovalPolicy"]; - if ("codexSandbox" in value) parsed.codexSandbox = value.codexSandbox == null ? undefined : asTrimmedString(value.codexSandbox) as AgentChatUpdateSessionArgs["codexSandbox"]; - if ("codexConfigSource" in value) parsed.codexConfigSource = value.codexConfigSource == null ? undefined : asTrimmedString(value.codexConfigSource) as AgentChatUpdateSessionArgs["codexConfigSource"]; - if ("codexFastMode" in value) parsed.codexFastMode = asOptionalBoolean(value.codexFastMode); - if ("opencodePermissionMode" in value) parsed.opencodePermissionMode = value.opencodePermissionMode == null ? undefined : asTrimmedString(value.opencodePermissionMode) as AgentChatUpdateSessionArgs["opencodePermissionMode"]; - if ("droidPermissionMode" in value) parsed.droidPermissionMode = value.droidPermissionMode == null ? undefined : asTrimmedString(value.droidPermissionMode) as AgentChatUpdateSessionArgs["droidPermissionMode"]; - if ("cursorModeId" in value) parsed.cursorModeId = value.cursorModeId == null ? null : asTrimmedString(value.cursorModeId) ?? null; - if ("cursorConfigValues" in value) { - parsed.cursorConfigValues = parseCursorConfigValues(value.cursorConfigValues); - } - if ("manuallyNamed" in value) parsed.manuallyNamed = value.manuallyNamed === true; - return parsed; -} - -function parseAgentChatDisposeArgs(value: Record<string, unknown>): AgentChatDisposeArgs { - return { - sessionId: requireString(value.sessionId, "chat.dispose requires sessionId."), - }; -} - -function parseAgentChatArchiveArgs(value: Record<string, unknown>, action: string): AgentChatArchiveArgs { - return { - sessionId: requireString(value.sessionId, `${action} requires sessionId.`), - }; -} - -function parseGetTranscriptArgs(value: Record<string, unknown>): { - sessionId: string; - limit?: number; - maxChars?: number; -} { - return { - sessionId: requireString(value.sessionId, "chat.getTranscript requires sessionId."), - limit: asOptionalNumber(value.limit), - maxChars: asOptionalNumber(value.maxChars), - }; -} - -function parseGitFileActionArgs(value: Record<string, unknown>, action: string): GitFileActionArgs { - return { - laneId: requireString(value.laneId, `${action} requires laneId.`), - path: requireString(value.path, `${action} requires path.`), - }; -} - -function parseGitBatchFileActionArgs(value: Record<string, unknown>, action: string): GitBatchFileActionArgs { - return { - laneId: requireString(value.laneId, `${action} requires laneId.`), - paths: requireStringArray(value.paths, `${action} requires paths.`), - }; -} - -function parseWriteTextAtomicArgs(value: Record<string, unknown>): WriteTextAtomicArgs { - if (typeof value.text !== "string") { - throw new Error("files.writeTextAtomic requires text."); - } - return { - laneId: requireString(value.laneId, "files.writeTextAtomic requires laneId."), - path: requireString(value.path, "files.writeTextAtomic requires path."), - text: value.text, - }; -} - -function parseGitCommitArgs(value: Record<string, unknown>): GitCommitArgs { - return { - laneId: requireString(value.laneId, "git.commit requires laneId."), - message: requireString(value.message, "git.commit requires message."), - amend: asOptionalBoolean(value.amend), - }; -} - -function parseGitGenerateCommitMessageArgs(value: Record<string, unknown>): GitGenerateCommitMessageArgs { - return { - laneId: requireString(value.laneId, "git.generateCommitMessage requires laneId."), - amend: asOptionalBoolean(value.amend), - }; -} - -function parseGitListRecentCommitsArgs(value: Record<string, unknown>): { laneId: string; limit?: number } { - return { - laneId: requireString(value.laneId, "git.listRecentCommits requires laneId."), - limit: asOptionalNumber(value.limit), - }; -} - -function parseGitListCommitFilesArgs(value: Record<string, unknown>): GitListCommitFilesArgs { - return { - laneId: requireString(value.laneId, "git.listCommitFiles requires laneId."), - commitSha: requireString(value.commitSha, "git.listCommitFiles requires commitSha."), - }; -} - -function parseGitGetCommitMessageArgs(value: Record<string, unknown>): GitGetCommitMessageArgs { - return { - laneId: requireString(value.laneId, "git.getCommitMessage requires laneId."), - commitSha: requireString(value.commitSha, "git.getCommitMessage requires commitSha."), - }; -} - -function parseGitGetFileHistoryArgs(value: Record<string, unknown>): GitGetFileHistoryArgs { - return { - laneId: requireString(value.laneId, "git.getFileHistory requires laneId."), - path: requireString(value.path, "git.getFileHistory requires path."), - limit: asOptionalNumber(value.limit), - }; -} - -function parseGitRevertArgs(value: Record<string, unknown>): GitRevertArgs { - return { - laneId: requireString(value.laneId, "git.revertCommit requires laneId."), - commitSha: requireString(value.commitSha, "git.revertCommit requires commitSha."), - }; -} - -function parseGitCherryPickArgs(value: Record<string, unknown>): GitCherryPickArgs { - return { - laneId: requireString(value.laneId, "git.cherryPickCommit requires laneId."), - commitSha: requireString(value.commitSha, "git.cherryPickCommit requires commitSha."), - }; -} - -function parseGitStashPushArgs(value: Record<string, unknown>): GitStashPushArgs { - return { - laneId: requireString(value.laneId, "git.stashPush requires laneId."), - ...(asTrimmedString(value.message) ? { message: asTrimmedString(value.message)! } : {}), - includeUntracked: asOptionalBoolean(value.includeUntracked), - }; -} - -function parseGitStashRefArgs(value: Record<string, unknown>, action: string): GitStashRefArgs { - return { - laneId: requireString(value.laneId, `${action} requires laneId.`), - stashRef: requireString(value.stashRef, `${action} requires stashRef.`), - }; -} - -function parseGitSyncArgs(value: Record<string, unknown>): GitSyncArgs { - return { - laneId: requireString(value.laneId, "git.sync requires laneId."), - ...(asTrimmedString(value.mode) ? { mode: value.mode as GitSyncArgs["mode"] } : {}), - ...(asTrimmedString(value.baseRef) ? { baseRef: asTrimmedString(value.baseRef)! } : {}), - }; -} - -function parseGitPushArgs(value: Record<string, unknown>): GitPushArgs { - return { - laneId: requireString(value.laneId, "git.push requires laneId."), - forceWithLease: asOptionalBoolean(value.forceWithLease), - }; -} - -function parseGetDiffChangesArgs(value: Record<string, unknown>): GetDiffChangesArgs { - return { - laneId: requireString(value.laneId, "git.getChanges requires laneId."), - }; -} - -function parseGetFileDiffArgs(value: Record<string, unknown>): GetFileDiffArgs { - return { - laneId: requireString(value.laneId, "git.getFile requires laneId."), - path: requireString(value.path, "git.getFile requires path."), - mode: requireString(value.mode, "git.getFile requires mode.") as GetFileDiffArgs["mode"], - ...(asTrimmedString(value.compareRef) ? { compareRef: asTrimmedString(value.compareRef)! } : {}), - ...(asTrimmedString(value.compareTo) ? { compareTo: value.compareTo as GetFileDiffArgs["compareTo"] } : {}), - }; -} - -function parseGitListBranchesArgs(value: Record<string, unknown>): GitListBranchesArgs { - return { - laneId: requireString(value.laneId, "git.listBranches requires laneId."), - }; -} - -function parseGitCheckoutBranchArgs(value: Record<string, unknown>): GitCheckoutBranchArgs { - return { - laneId: requireString(value.laneId, "git.checkoutBranch requires laneId."), - branchName: requireString(value.branchName, "git.checkoutBranch requires branchName."), - ...(asTrimmedString(value.mode) ? { mode: value.mode as GitCheckoutBranchArgs["mode"] } : {}), - ...(asTrimmedString(value.startPoint) ? { startPoint: asTrimmedString(value.startPoint)! } : {}), - ...(asTrimmedString(value.baseRef) ? { baseRef: asTrimmedString(value.baseRef)! } : {}), - ...(asOptionalBoolean(value.acknowledgeActiveWork) !== undefined - ? { acknowledgeActiveWork: asOptionalBoolean(value.acknowledgeActiveWork) } - : {}), - }; -} - -function parseConflictLaneArgs(value: Record<string, unknown>, action: string): { laneId: string } { - return { - laneId: requireString(value.laneId, `${action} requires laneId.`), - }; -} - -function parseChatModelsArgs(value: Record<string, unknown>): { provider: AgentChatProvider; activateRuntime?: boolean } { - return { - provider: (asTrimmedString(value.provider) ?? "codex") as AgentChatProvider, - ...(value.activateRuntime === true ? { activateRuntime: true } : {}), - }; -} - -function requirePrId(value: Record<string, unknown>, action: string): string { - return requireString(value.prId, `${action} requires prId.`); -} - -function parseCreatePrArgs(value: Record<string, unknown>): CreatePrFromLaneArgs { - const laneId = asTrimmedString(value.laneId); - const title = asTrimmedString(value.title); - const body = typeof value.body === "string" ? value.body : ""; - if (!laneId || !title) throw new Error("prs.createFromLane requires laneId and title."); - const strategy: CreatePrFromLaneArgs["strategy"] = - normalizePrCreationStrategy(asTrimmedString(value.strategy)) ?? undefined; - return { - laneId, - title, - body, - draft: value.draft === true, - ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), - ...(asStringArray(value.labels).length ? { labels: asStringArray(value.labels) } : {}), - ...(asStringArray(value.reviewers).length ? { reviewers: asStringArray(value.reviewers) } : {}), - ...(typeof value.allowDirtyWorktree === "boolean" ? { allowDirtyWorktree: value.allowDirtyWorktree } : {}), - ...(typeof value.closeLinearIssueOnMerge === "boolean" ? { closeLinearIssueOnMerge: value.closeLinearIssueOnMerge } : {}), - ...(strategy ? { strategy } : {}), - }; -} - -function parseLinkPrToLaneArgs(value: Record<string, unknown>): LinkPrToLaneArgs { - return { - laneId: requireString(value.laneId, "prs.linkToLane requires laneId."), - prUrlOrNumber: requireString(value.prUrlOrNumber, "prs.linkToLane requires prUrlOrNumber."), - }; -} - -function parseDraftPrDescriptionArgs(value: Record<string, unknown>): DraftPrDescriptionArgs { - return { - laneId: requireString(value.laneId, "prs.draftDescription requires laneId."), - ...(asTrimmedString(value.model) ? { model: asTrimmedString(value.model)! } : {}), - ...("reasoningEffort" in value - ? { reasoningEffort: value.reasoningEffort == null ? null : asTrimmedString(value.reasoningEffort) ?? null } - : {}), - ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), - ...(typeof value.closeLinearIssueOnMerge === "boolean" ? { closeLinearIssueOnMerge: value.closeLinearIssueOnMerge } : {}), - }; -} - -function parseLandPrArgs(value: Record<string, unknown>): LandPrArgs { - const prId = requirePrId(value, "prs.land"); - const method = asTrimmedString(value.method) as LandPrArgs["method"]; - if (!method || !["merge", "squash", "rebase"].includes(method)) { - throw new Error("prs.land requires method to be merge, squash, or rebase."); - } - return { prId, method }; -} - -function parseClosePrArgs(value: Record<string, unknown>): ClosePrArgs { - return { - prId: requirePrId(value, "prs.close"), - ...(typeof value.comment === "string" ? { comment: value.comment } : {}), - }; -} - -function parseReopenPrArgs(value: Record<string, unknown>): ReopenPrArgs { - return { - prId: requirePrId(value, "prs.reopen"), - }; -} - -function parseRequestReviewersArgs(value: Record<string, unknown>): RequestPrReviewersArgs { - const prId = requirePrId(value, "prs.requestReviewers"); - const reviewers = asStringArray(value.reviewers); - if (reviewers.length === 0) throw new Error("prs.requestReviewers requires at least one reviewer."); - return { prId, reviewers }; -} - -function parseRerunPrChecksArgs(value: Record<string, unknown>): RerunPrChecksArgs { - const checkRunIds = (() => { - if (value.checkRunIds == null) return undefined; - if (!Array.isArray(value.checkRunIds)) { - throw new Error("prs.rerunChecks requires checkRunIds to be an array of numbers when provided."); - } - return value.checkRunIds.map((entry) => { - if (typeof entry !== "number" || !Number.isSafeInteger(entry) || entry <= 0) { - throw new Error("prs.rerunChecks requires checkRunIds to be an array of numbers when provided."); - } - return entry; - }); - })(); - return { - prId: requirePrId(value, "prs.rerunChecks"), - ...(checkRunIds?.length ? { checkRunIds } : {}), - }; -} - -function parseAddPrCommentArgs(value: Record<string, unknown>): AddPrCommentArgs { - return { - prId: requirePrId(value, "prs.addComment"), - body: requireString(value.body, "prs.addComment requires body."), - ...(asTrimmedString(value.inReplyToCommentId) ? { inReplyToCommentId: asTrimmedString(value.inReplyToCommentId)! } : {}), - }; -} - -function parseUpdatePrTitleArgs(value: Record<string, unknown>): UpdatePrTitleArgs { - return { - prId: requirePrId(value, "prs.updateTitle"), - title: requireString(value.title, "prs.updateTitle requires title."), - }; -} - -function parseUpdatePrBodyArgs(value: Record<string, unknown>): UpdatePrBodyArgs { - return { - prId: requirePrId(value, "prs.updateBody"), - body: typeof value.body === "string" ? value.body : "", - }; -} - -function parseSetPrLabelsArgs(value: Record<string, unknown>): SetPrLabelsArgs { - return { - prId: requirePrId(value, "prs.setLabels"), - labels: asStringArray(value.labels), - }; -} - -function parseSubmitPrReviewArgs(value: Record<string, unknown>): SubmitPrReviewArgs { - const event = asTrimmedString(value.event); - if (event !== "APPROVE" && event !== "REQUEST_CHANGES" && event !== "COMMENT") { - throw new Error("prs.submitReview requires event to be APPROVE, REQUEST_CHANGES, or COMMENT."); - } - return { - prId: requirePrId(value, "prs.submitReview"), - event, - ...(typeof value.body === "string" ? { body: value.body } : {}), - }; -} - -function parseReplyToReviewThreadArgs(value: Record<string, unknown>): ReplyToPrReviewThreadArgs { - return { - prId: requirePrId(value, "prs.replyToReviewThread"), - threadId: requireString(value.threadId, "prs.replyToReviewThread requires threadId."), - body: requireString(value.body, "prs.replyToReviewThread requires body."), - }; -} - -function parseSetReviewThreadResolvedArgs(value: Record<string, unknown>): SetPrReviewThreadResolvedArgs { - return { - prId: requirePrId(value, "prs.setReviewThreadResolved"), - threadId: requireString(value.threadId, "prs.setReviewThreadResolved requires threadId."), - resolved: value.resolved === true, - }; -} - -function parseReactToCommentArgs(value: Record<string, unknown>): ReactToPrCommentArgs { - const content = asTrimmedString(value.content); - if (!content) throw new Error("prs.reactToComment requires content."); - return { - prId: requirePrId(value, "prs.reactToComment"), - commentId: requireString(value.commentId, "prs.reactToComment requires commentId."), - content: content as ReactToPrCommentArgs["content"], - }; -} - -function parseAiReviewSummaryArgs(value: Record<string, unknown>): AiReviewSummaryArgs { - return { - prId: requirePrId(value, "prs.aiReviewSummary"), - ...(asTrimmedString(value.model) ? { model: asTrimmedString(value.model)! } : {}), - }; -} - -function parseListIntegrationWorkflowsArgs(value: Record<string, unknown>): ListIntegrationWorkflowsArgs { - const view = asTrimmedString(value.view); - return view ? { view: view as ListIntegrationWorkflowsArgs["view"] } : {}; -} - -function parseUpdateIntegrationProposalArgs(value: Record<string, unknown>): UpdateIntegrationProposalArgs { - return { - proposalId: requireString(value.proposalId, "prs.updateIntegrationProposal requires proposalId."), - ...(typeof value.title === "string" ? { title: value.title } : {}), - ...(typeof value.body === "string" ? { body: value.body } : {}), - ...(typeof value.draft === "boolean" ? { draft: value.draft } : {}), - ...(typeof value.integrationLaneName === "string" ? { integrationLaneName: value.integrationLaneName } : {}), - ...(typeof value.preferredIntegrationLaneId === "string" || value.preferredIntegrationLaneId === null - ? { preferredIntegrationLaneId: value.preferredIntegrationLaneId } - : {}), - ...(typeof value.mergeIntoHeadSha === "string" || value.mergeIntoHeadSha === null - ? { mergeIntoHeadSha: value.mergeIntoHeadSha } - : {}), - }; -} - -function parseDeleteIntegrationProposalArgs(value: Record<string, unknown>): DeleteIntegrationProposalArgs { - return { - proposalId: requireString(value.proposalId, "prs.deleteIntegrationProposal requires proposalId."), - ...(typeof value.deleteIntegrationLane === "boolean" ? { deleteIntegrationLane: value.deleteIntegrationLane } : {}), - }; -} - -function parseDismissIntegrationCleanupArgs(value: Record<string, unknown>): DismissIntegrationCleanupArgs { - return { - proposalId: requireString(value.proposalId, "prs.dismissIntegrationCleanup requires proposalId."), - }; -} - -function parseCleanupIntegrationWorkflowArgs(value: Record<string, unknown>): CleanupIntegrationWorkflowArgs { - const rawLaneIds = Array.isArray(value.archiveSourceLaneIds) ? value.archiveSourceLaneIds : []; - const archiveSourceLaneIds = rawLaneIds - .map((entry) => (typeof entry === "string" ? entry.trim() : "")) - .filter((entry) => entry.length > 0); - return { - proposalId: requireString(value.proposalId, "prs.cleanupIntegrationWorkflow requires proposalId."), - ...(typeof value.archiveIntegrationLane === "boolean" ? { archiveIntegrationLane: value.archiveIntegrationLane } : {}), - ...(archiveSourceLaneIds.length > 0 ? { archiveSourceLaneIds } : {}), - }; -} - -function parseCreateIntegrationLaneForProposalArgs(value: Record<string, unknown>): CreateIntegrationLaneForProposalArgs { - return { - proposalId: requireString(value.proposalId, "prs.createIntegrationLaneForProposal requires proposalId."), - }; -} - -function parseStartIntegrationResolutionArgs(value: Record<string, unknown>): StartIntegrationResolutionArgs { - return { - proposalId: requireString(value.proposalId, "prs.startIntegrationResolution requires proposalId."), - laneId: requireString(value.laneId, "prs.startIntegrationResolution requires laneId."), - }; -} - -function parseRecheckIntegrationStepArgs(value: Record<string, unknown>): RecheckIntegrationStepArgs { - return { - proposalId: requireString(value.proposalId, "prs.recheckIntegrationStep requires proposalId."), - laneId: requireString(value.laneId, "prs.recheckIntegrationStep requires laneId."), - }; -} - -function parseLandQueueNextArgs(value: Record<string, unknown>): LandQueueNextArgs { - const method = asTrimmedString(value.method) as LandQueueNextArgs["method"]; - if (!method || !["merge", "squash", "rebase"].includes(method)) { - throw new Error("prs.landQueueNext requires method to be merge, squash, or rebase."); - } - return { - groupId: requireString(value.groupId, "prs.landQueueNext requires groupId."), - method, - ...(typeof value.archiveLane === "boolean" ? { archiveLane: value.archiveLane } : {}), - ...(typeof value.autoResolve === "boolean" ? { autoResolve: value.autoResolve } : {}), - ...(asOptionalNumber(value.confidenceThreshold) != null ? { confidenceThreshold: asOptionalNumber(value.confidenceThreshold)! } : {}), - }; -} - -function parseReorderQueuePrsArgs(value: Record<string, unknown>): ReorderQueuePrsArgs { - return { - groupId: requireString(value.groupId, "prs.reorderQueue requires groupId."), - prIds: requireStringArray(value.prIds, "prs.reorderQueue requires prIds."), - }; -} - -function parsePauseQueueAutomationArgs(value: Record<string, unknown>): PauseQueueAutomationArgs { - return { - queueId: requireString(value.queueId, "prs.pauseQueueAutomation requires queueId."), - }; -} - -function parseResumeQueueAutomationArgs(value: Record<string, unknown>): ResumeQueueAutomationArgs { - const method = asTrimmedString(value.method); - if (method && !["merge", "squash", "rebase"].includes(method)) { - throw new Error("prs.resumeQueueAutomation requires method to be merge, squash, or rebase when provided."); - } - return { - queueId: requireString(value.queueId, "prs.resumeQueueAutomation requires queueId."), - ...(method ? { method: method as ResumeQueueAutomationArgs["method"] } : {}), - ...(typeof value.archiveLane === "boolean" ? { archiveLane: value.archiveLane } : {}), - ...(typeof value.autoResolve === "boolean" ? { autoResolve: value.autoResolve } : {}), - ...(typeof value.ciGating === "boolean" ? { ciGating: value.ciGating } : {}), - ...(asOptionalNumber(value.confidenceThreshold) != null ? { confidenceThreshold: asOptionalNumber(value.confidenceThreshold)! } : {}), - ...(asTrimmedString(value.originLabel) ? { originLabel: asTrimmedString(value.originLabel)! } : {}), - }; -} - -function parseCancelQueueAutomationArgs(value: Record<string, unknown>): CancelQueueAutomationArgs { - return { - queueId: requireString(value.queueId, "prs.cancelQueueAutomation requires queueId."), - }; -} - -function parseIssueInventoryPrArgs(value: Record<string, unknown>, action: string): { prId: string } { - return { - prId: requirePrId(value, action), - }; -} - -function parseIssueInventoryItemsArgs(value: Record<string, unknown>, action: string): { prId: string; itemIds: string[] } { - return { - prId: requirePrId(value, action), - itemIds: requireStringArray(value.itemIds, `${action} requires itemIds.`), - }; -} - -function parseIssueInventoryDismissArgs(value: Record<string, unknown>): { prId: string; itemIds: string[]; reason: string } { - return { - ...parseIssueInventoryItemsArgs(value, "prs.issueInventory.markDismissed"), - reason: typeof value.reason === "string" ? value.reason : "", - }; -} - -function parsePipelineSettingsPatch(value: Record<string, unknown>): { prId: string; settings: Partial<PipelineSettings> } { - const settings = isRecord(value.settings) ? value.settings : value; - const patch: Partial<PipelineSettings> = {}; - if (typeof settings.autoMerge === "boolean") patch.autoMerge = settings.autoMerge; - const mergeMethod = asTrimmedString(settings.mergeMethod); - if (mergeMethod && ["merge", "squash", "rebase", "repo_default"].includes(mergeMethod)) { - patch.mergeMethod = mergeMethod as PipelineSettings["mergeMethod"]; - } - const maxRounds = asOptionalNumber(settings.maxRounds); - if (maxRounds != null && maxRounds >= 1) patch.maxRounds = Math.floor(maxRounds); - const onRebaseNeeded = asTrimmedString(settings.onRebaseNeeded); - if (onRebaseNeeded === "pause" || onRebaseNeeded === "auto_rebase") { - patch.onRebaseNeeded = onRebaseNeeded; - } - const conflictStrategy = asTrimmedString(settings.conflictStrategy); - if (conflictStrategy && ["pause", "rebase", "merge", "auto"].includes(conflictStrategy)) { - patch.conflictStrategy = conflictStrategy as PipelineSettings["conflictStrategy"]; - } - const forceFinalizeMode = asTrimmedString(settings.forceFinalizeMode); - if (forceFinalizeMode && ["off", "conditional", "unconditional"].includes(forceFinalizeMode)) { - patch.forceFinalizeMode = forceFinalizeMode as PipelineSettings["forceFinalizeMode"]; - } - if (typeof settings.forceFinalizeRequireNoCiFailures === "boolean") { - patch.forceFinalizeRequireNoCiFailures = settings.forceFinalizeRequireNoCiFailures; - } - if (typeof settings.earlyMergeOnGreen === "boolean") { - patch.earlyMergeOnGreen = settings.earlyMergeOnGreen; - } - const atCapPolicy = asTrimmedString(settings.atCapPolicy); - if (atCapPolicy && ["stop", "wait_for_ci", "ci_retry_once", "ci_retry_loop", "force_merge"].includes(atCapPolicy)) { - patch.atCapPolicy = atCapPolicy as PipelineSettings["atCapPolicy"]; - } - const atCapWaitMinutes = asOptionalNumber(settings.atCapWaitMinutes); - if (atCapWaitMinutes != null && atCapWaitMinutes >= 1) patch.atCapWaitMinutes = Math.floor(atCapWaitMinutes); - const atCapCiRetryMax = asOptionalNumber(settings.atCapCiRetryMax); - if (atCapCiRetryMax != null && atCapCiRetryMax >= 1) patch.atCapCiRetryMax = Math.floor(atCapCiRetryMax); - if (typeof settings.forceMergeRequiresConfirmation === "boolean") { - patch.forceMergeRequiresConfirmation = settings.forceMergeRequiresConfirmation; - } - if (isRecord(settings.autoAgentSettings)) { - const autoAgentSettings: Partial<PipelineSettings["autoAgentSettings"]> = {}; - const provider = settings.autoAgentSettings.provider; - if (provider === null || provider === "claude" || provider === "codex") autoAgentSettings.provider = provider; - for (const key of ["model", "reasoningEffort"] as const) { - const value = settings.autoAgentSettings[key]; - if (value === null || typeof value === "string") autoAgentSettings[key] = value; - } - const permissionMode = settings.autoAgentSettings.permissionMode; - if ( - permissionMode === null || - permissionMode === "read_only" || - permissionMode === "guarded_edit" || - permissionMode === "full_edit" || - permissionMode === "default" || - permissionMode === "plan" || - permissionMode === "edit" || - permissionMode === "full-auto" || - permissionMode === "config-toml" - ) { - autoAgentSettings.permissionMode = permissionMode; - } - const confidenceThreshold = asOptionalNumber(settings.autoAgentSettings.confidenceThreshold); - if (settings.autoAgentSettings.confidenceThreshold === null || (confidenceThreshold != null && confidenceThreshold >= 0 && confidenceThreshold <= 1)) { - autoAgentSettings.confidenceThreshold = settings.autoAgentSettings.confidenceThreshold === null ? null : confidenceThreshold; - } - if (Object.keys(autoAgentSettings).length > 0) patch.autoAgentSettings = autoAgentSettings as PipelineSettings["autoAgentSettings"]; - } - return { - prId: requirePrId(value, "prs.pipelineSettings.save"), - settings: patch, - }; -} - -function parseConvergenceStatePatch(value: Record<string, unknown>): { prId: string; state: PrConvergenceStatePatch } { - const raw = isRecord(value.state) ? value.state : value; - const patch: PrConvergenceStatePatch = {}; - const statuses = new Set(["idle", "launching", "running", "polling", "paused", "converged", "merged", "failed", "cancelled", "stopped"]); - const pollerStatuses = new Set(["idle", "scheduled", "polling", "waiting_for_checks", "waiting_for_comments", "paused", "stopped"]); - if (typeof raw.autoConvergeEnabled === "boolean") patch.autoConvergeEnabled = raw.autoConvergeEnabled; - const status = asTrimmedString(raw.status); - if (status && statuses.has(status)) patch.status = status as ConvergenceRuntimeState["status"]; - const pollerStatus = asTrimmedString(raw.pollerStatus); - if (pollerStatus && pollerStatuses.has(pollerStatus)) patch.pollerStatus = pollerStatus as ConvergenceRuntimeState["pollerStatus"]; - const currentRound = asOptionalNumber(raw.currentRound); - if (currentRound != null && currentRound >= 0) patch.currentRound = Math.floor(currentRound); - if (typeof raw.forceFinalizeUsed === "boolean") patch.forceFinalizeUsed = raw.forceFinalizeUsed; - const ciRetryAttemptsUsed = asOptionalNumber(raw.ciRetryAttemptsUsed); - if (ciRetryAttemptsUsed != null && ciRetryAttemptsUsed >= 0) patch.ciRetryAttemptsUsed = Math.floor(ciRetryAttemptsUsed); - const pauseRepeatCount = asOptionalNumber(raw.pauseRepeatCount); - if (pauseRepeatCount != null && pauseRepeatCount >= 0) patch.pauseRepeatCount = Math.floor(pauseRepeatCount); - for (const key of [ - "activeSessionId", - "activeLaneId", - "activeHref", - "pauseReason", - "errorMessage", - "waitForCiStartedAt", - "lastDispatchHeadSha", - "lastPauseReasonHash", - "lastStartedAt", - "lastPolledAt", - "lastPausedAt", - "lastStoppedAt", - ] as const) { - const next = raw[key]; - if (next === null || typeof next === "string") { - (patch as Record<string, unknown>)[key] = next; - } - } - return { - prId: requirePrId(value, "prs.convergenceState.save"), - state: patch, - }; -} - -function mergeLaneDockerConfig( - current: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, - next: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, -) { - if (!current && !next) return undefined; - if (!current) return next ? { ...next, ...(next.services ? { services: [...next.services] } : {}) } : undefined; - if (!next) return { ...current, ...(current.services ? { services: [...current.services] } : {}) }; - return { - ...current, - ...next, - ...(next.services != null - ? { services: [...next.services] } - : current.services != null - ? { services: [...current.services] } - : {}), - }; -} - -function mergeLaneEnvInitConfig( - current: LaneEnvInitConfig | undefined, - next: LaneEnvInitConfig | undefined, -): LaneEnvInitConfig | undefined { - if (!current && !next) return undefined; - if (!current) { - return next - ? { - ...(next.envFiles ? { envFiles: [...next.envFiles] } : {}), - ...(mergeLaneDockerConfig(undefined, next.docker) ? { docker: mergeLaneDockerConfig(undefined, next.docker) } : {}), - ...(next.dependencies ? { dependencies: [...next.dependencies] } : {}), - ...(next.mountPoints ? { mountPoints: [...next.mountPoints] } : {}), - ...(next.copyPaths ? { copyPaths: [...next.copyPaths] } : {}), - } - : undefined; - } - if (!next) { - return { - ...(current.envFiles ? { envFiles: [...current.envFiles] } : {}), - ...(mergeLaneDockerConfig(undefined, current.docker) ? { docker: mergeLaneDockerConfig(undefined, current.docker) } : {}), - ...(current.dependencies ? { dependencies: [...current.dependencies] } : {}), - ...(current.mountPoints ? { mountPoints: [...current.mountPoints] } : {}), - ...(current.copyPaths ? { copyPaths: [...current.copyPaths] } : {}), - }; - } - return { - envFiles: [...(current.envFiles ?? []), ...(next.envFiles ?? [])], - ...(mergeLaneDockerConfig(current.docker, next.docker) ? { docker: mergeLaneDockerConfig(current.docker, next.docker) } : {}), - dependencies: [...(current.dependencies ?? []), ...(next.dependencies ?? [])], - mountPoints: [...(current.mountPoints ?? []), ...(next.mountPoints ?? [])], - copyPaths: [...(current.copyPaths ?? []), ...(next.copyPaths ?? [])], - }; -} - -function mergeLaneOverrides(base: LaneOverlayOverrides, next: Partial<LaneOverlayOverrides>): LaneOverlayOverrides { - return { - ...base, - ...next, - ...(base.env || next.env ? { env: { ...(base.env ?? {}), ...(next.env ?? {}) } } : {}), - ...(base.processIds || next.processIds ? { processIds: [...(next.processIds ?? base.processIds ?? [])] } : {}), - ...(base.testSuiteIds || next.testSuiteIds ? { testSuiteIds: [...(next.testSuiteIds ?? base.testSuiteIds ?? [])] } : {}), - ...(mergeLaneEnvInitConfig(base.envInit, next.envInit) ? { envInit: mergeLaneEnvInitConfig(base.envInit, next.envInit) } : {}), - }; -} - -function applyLeaseToOverrides( - overrides: LaneOverlayOverrides, - lease: { status: string; rangeStart: number; rangeEnd: number } | null, -): LaneOverlayOverrides { - if (!lease || lease.status !== "active" || overrides.portRange) { - return { ...overrides }; - } - return { - ...overrides, - portRange: { start: lease.rangeStart, end: lease.rangeEnd }, - }; -} - -/** - * Strict resolver for identity-pinned sessions (CTO + worker agents). Never - * slips a foreign lane through via a `lanes[0]` fallback — if no primary lane - * exists, the caller must error out rather than silently host the identity on - * a non-primary lane. - */ -async function resolvePrimaryLaneIdOnlyForSync(args: SyncRemoteCommandServiceArgs): Promise<string> { - await args.laneService.ensurePrimaryLane?.().catch(() => {}); - const lanes = await args.laneService.list({ includeArchived: false, includeStatus: false }); - return lanes.find((lane) => lane.laneType === "primary")?.id ?? ""; -} - -async function resolveLaneOverlayContext(args: SyncRemoteCommandServiceArgs, laneId: string) { - const projectConfigService = requireService(args.projectConfigService, "Project config service not available."); - const lanes = await args.laneService.list({ includeStatus: false }); - const lane = lanes.find((entry) => entry.id === laneId); - if (!lane) throw new Error(`Lane not found: ${laneId}`); - - const config = projectConfigService.getEffective(); - const overlayOverrides = matchLaneOverlayPolicies(lane, config.laneOverlayPolicies ?? []); - const lease = args.portAllocationService?.getLease(lane.id) ?? null; - const overrides = applyLeaseToOverrides(overlayOverrides, lease); - const envInitConfig = args.laneEnvironmentService?.resolveEnvInitConfig(config.laneEnvInit, overrides); - - return { - lane, - overrides, - envInitConfig, - }; -} - -async function resolveChatCreateArgs( - service: ReturnType<typeof createAgentChatService>, - payload: AgentChatCreateArgs, -): Promise<AgentChatCreateArgs> { - if (payload.model.trim().length > 0) return payload; - const available = await service.getAvailableModels({ - provider: payload.provider, - ...(payload.provider === "opencode" ? { activateRuntime: true } : {}), - }); - const chosen = available[0]; - if (!chosen) { - throw new Error(`No configured ${payload.provider} chat model is available on the host.`); - } - return { - ...payload, - model: chosen.id, - ...(!payload.modelId && chosen.modelId ? { modelId: chosen.modelId } : {}), - }; -} - -function sessionStatusBucket(argsIn: { - status: string; - lastOutputPreview: string | null | undefined; - runtimeState?: string | null; -}): "running" | "awaiting-input" | "ended" { - if (argsIn.status === "running") { - if (argsIn.runtimeState === "waiting-input") return "awaiting-input"; - const preview = argsIn.lastOutputPreview ?? ""; - if (/\b(?:waiting|awaiting)\b.{0,28}\b(?:input|confirmation|response|prompt)\b/i.test(preview)) { - return "awaiting-input"; - } - if (/\((?:y\/n|yes\/no)\)/i.test(preview) || /\[(?:y\/n|yes\/no)\]/i.test(preview)) { - return "awaiting-input"; - } - return "running"; - } - return "ended"; -} - -function summarizeLaneRuntime( - laneId: string, - sessions: Array<{ - laneId: string; - status: string; - lastOutputPreview: string | null; - runtimeState?: string | null; - }>, -): LaneListSnapshot["runtime"] { - let runningCount = 0; - let awaitingInputCount = 0; - let endedCount = 0; - let sessionCount = 0; - for (const session of sessions) { - if (session.laneId !== laneId) continue; - sessionCount += 1; - const bucket = sessionStatusBucket(session); - if (bucket === "running") runningCount += 1; - else if (bucket === "awaiting-input") awaitingInputCount += 1; - else endedCount += 1; - } - const bucket = runningCount > 0 - ? "running" - : awaitingInputCount > 0 - ? "awaiting-input" - : endedCount > 0 - ? "ended" - : "none"; - return { - bucket, - runningCount, - awaitingInputCount, - endedCount, - sessionCount, - }; -} - -async function buildLaneListSnapshots( - args: SyncRemoteCommandServiceArgs, - lanes: Awaited<ReturnType<ReturnType<typeof createLaneService>["list"]>>, -): Promise<LaneListSnapshot[]> { - const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ - Promise.resolve(args.sessionService.list({ limit: 500 })), - Promise.resolve(args.rebaseSuggestionService?.listSuggestions() ?? []), - Promise.resolve(args.autoRebaseService?.listStatuses() ?? []), - Promise.resolve(args.laneService.listStateSnapshots()), - args.conflictService?.getBatchAssessment({ lanes }).catch(() => null) ?? Promise.resolve(null), - ]); - - const rebaseByLaneId = new Map(rebaseSuggestions.map((entry) => [entry.laneId, entry] as const)); - const autoRebaseByLaneId = new Map(autoRebaseStatuses.map((entry) => [entry.laneId, entry] as const)); - const stateByLaneId = new Map(stateSnapshots.map((entry) => [entry.laneId, entry] as const)); - const conflictByLaneId = new Map((batchAssessment?.lanes ?? []).map((entry) => [entry.laneId, entry] as const)); - - return lanes.map((lane) => ({ - lane, - runtime: summarizeLaneRuntime(lane.id, sessions), - rebaseSuggestion: rebaseByLaneId.get(lane.id) ?? null, - autoRebaseStatus: autoRebaseByLaneId.get(lane.id) ?? null, - conflictStatus: conflictByLaneId.get(lane.id) ?? null, - stateSnapshot: stateByLaneId.get(lane.id) ?? null, - adoptableAttached: lane.laneType === "attached" && lane.archivedAt == null, - })); -} - -async function buildLaneDetailPayload(args: SyncRemoteCommandServiceArgs, laneId: string): Promise<LaneDetailPayload> { - const lane = (await args.laneService.list({ includeArchived: true, includeStatus: true })).find((entry) => entry.id === laneId) ?? null; - if (!lane) throw new Error(`Lane not found: ${laneId}`); - - const [ - stackChain, - children, - sessions, - chatSessions, - rebaseSuggestions, - autoRebaseStatuses, - stateSnapshot, - recentCommits, - diffChanges, - stashes, - syncStatus, - conflictState, - conflictStatus, - overlaps, - envInitProgress, - ] = await Promise.all([ - args.laneService.getStackChain(laneId), - args.laneService.getChildren(laneId), - Promise.resolve(args.sessionService.list({ laneId, limit: 200 })), - args.agentChatService?.listSessions(laneId, { includeAutomation: true }) ?? Promise.resolve([]), - Promise.resolve(args.rebaseSuggestionService?.listSuggestions() ?? []), - Promise.resolve(args.autoRebaseService?.listStatuses() ?? []), - Promise.resolve(args.laneService.getStateSnapshot(laneId)), - args.gitService?.listRecentCommits({ laneId, limit: 20 }) ?? Promise.resolve([]), - args.diffService?.getChanges(laneId).catch(() => null) ?? Promise.resolve(null), - args.gitService?.listStashes({ laneId }) ?? Promise.resolve([]), - args.gitService?.getSyncStatus({ laneId }).catch(() => null) ?? Promise.resolve(null), - args.gitService?.getConflictState({ laneId }).catch(() => null) ?? Promise.resolve(null), - args.conflictService?.getLaneStatus({ laneId }).catch(() => null) ?? Promise.resolve(null), - args.conflictService?.listOverlaps({ laneId }).catch(() => []) ?? Promise.resolve([]), - Promise.resolve(args.laneEnvironmentService?.getProgress(laneId) ?? null), - ]); - - return { - lane, - runtime: summarizeLaneRuntime(laneId, sessions), - stackChain, - children, - stateSnapshot: stateSnapshot as LaneStateSnapshotSummary | null, - rebaseSuggestion: rebaseSuggestions.find((entry) => entry.laneId === laneId) ?? null, - autoRebaseStatus: autoRebaseStatuses.find((entry) => entry.laneId === laneId) ?? null, - conflictStatus, - overlaps, - syncStatus, - conflictState, - recentCommits, - diffChanges, - stashes, - envInitProgress, - sessions, - chatSessions, - }; -} - -export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArgs) { - const registry = new Map<SyncRemoteCommandAction, RegisteredRemoteCommand>(); - - const register = ( - action: SyncRemoteCommandAction, - policy: SyncRemoteCommandPolicy, - handler: (payload: Record<string, unknown>) => Promise<unknown>, - ) => { - registry.set(action, { - descriptor: { action, policy }, - handler, - }); - }; - - register("lanes.list", { viewerAllowed: true }, async (payload) => args.laneService.list(parseListLanesArgs(payload))); - register("lanes.refreshSnapshots", { viewerAllowed: true }, async (payload) => { - const refreshed = await args.laneService.refreshSnapshots(parseListLanesArgs(payload)); - return { - ...refreshed, - snapshots: await buildLaneListSnapshots(args, refreshed.lanes), - }; - }); - register("lanes.getDetail", { viewerAllowed: true }, async (payload) => - buildLaneDetailPayload(args, requireString(payload.laneId, "lanes.getDetail requires laneId."))); - register("lanes.create", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.create(parseCreateLaneArgs(payload))); - register("lanes.createChild", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.createChild(parseCreateChildLaneArgs(payload))); - register("lanes.createFromUnstaged", { viewerAllowed: true, queueable: true }, async (payload) => - args.laneService.createFromUnstaged(parseCreateLaneFromUnstagedArgs(payload))); - register("lanes.importBranch", { viewerAllowed: true, queueable: true }, async (payload) => - args.laneService.importBranch(parseImportBranchArgs(payload))); - register("lanes.previewBranchSwitch", { viewerAllowed: true }, async (payload) => - args.laneService.previewBranchSwitch(parseGitCheckoutBranchArgs(payload))); - register("lanes.attach", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.attach(parseAttachLaneArgs(payload))); - register("lanes.adoptAttached", { viewerAllowed: true, queueable: true }, async (payload) => - args.laneService.adoptAttached({ laneId: requireString(payload.laneId, "lanes.adoptAttached requires laneId.") })); - register("lanes.rename", { viewerAllowed: true, queueable: true }, async (payload) => { - args.laneService.rename(parseRenameLaneArgs(payload)); - return { ok: true }; - }); - register("lanes.reparent", { viewerAllowed: true, queueable: true }, async (payload) => - args.laneService.reparent(parseReparentLaneArgs(payload))); - register("lanes.updateAppearance", { viewerAllowed: true, queueable: true }, async (payload) => { - args.laneService.updateAppearance(parseUpdateLaneAppearanceArgs(payload)); - return { ok: true }; - }); - register("lanes.archive", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.laneService.archive(parseArchiveLaneArgs(payload, "lanes.archive")); - return { ok: true }; - }); - register("lanes.unarchive", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.laneService.unarchive(parseArchiveLaneArgs(payload, "lanes.unarchive")); - return { ok: true }; - }); - register("lanes.delete", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.laneService.delete(parseDeleteLaneArgs(payload)); - return { ok: true }; - }); - register("lanes.getStackChain", { viewerAllowed: true }, async (payload) => - args.laneService.getStackChain(requireString(payload.laneId, "lanes.getStackChain requires laneId."))); - register("lanes.getChildren", { viewerAllowed: true }, async (payload) => - args.laneService.getChildren(requireString(payload.laneId, "lanes.getChildren requires laneId."))); - register("lanes.rebaseStart", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseStart(parseRebaseStartArgs(payload))); - register("lanes.rebasePush", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebasePush(parseRebasePushArgs(payload))); - register("lanes.rebaseRollback", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseRollback(parseRunIdArgs(payload, "lanes.rebaseRollback"))); - register("lanes.rebaseAbort", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseAbort(parseRunIdArgs(payload, "lanes.rebaseAbort"))); - register("lanes.listRebaseSuggestions", { viewerAllowed: true }, async () => args.rebaseSuggestionService?.listSuggestions() ?? []); - register("lanes.dismissRebaseSuggestion", { viewerAllowed: true, queueable: true }, async (payload) => { - const laneId = requireString(payload.laneId, "lanes.dismissRebaseSuggestion requires laneId."); - args.conflictService?.dismissRebase(laneId); - if (args.rebaseSuggestionService) { - await args.rebaseSuggestionService.dismiss({ laneId }); - } - return { ok: true }; - }); - register("lanes.deferRebaseSuggestion", { viewerAllowed: true, queueable: true }, async (payload) => { - const laneId = requireString(payload.laneId, "lanes.deferRebaseSuggestion requires laneId."); - const minutes = Math.max(5, Math.min(7 * 24 * 60, Math.floor(asOptionalNumber(payload.minutes) ?? 60))); - const until = new Date(Date.now() + minutes * 60_000).toISOString(); - args.conflictService?.deferRebase(laneId, until); - if (args.rebaseSuggestionService) { - await args.rebaseSuggestionService.defer({ - laneId, - minutes, - }); - } - return { ok: true }; - }); - register("lanes.listAutoRebaseStatuses", { viewerAllowed: true }, async () => args.autoRebaseService?.listStatuses() ?? []); - register("lanes.dismissAutoRebaseStatus", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.autoRebaseService) return { ok: true }; - await args.autoRebaseService.dismissStatus({ - laneId: requireString(payload.laneId, "lanes.dismissAutoRebaseStatus requires laneId."), - }); - return { ok: true }; - }); - register("lanes.listTemplates", { viewerAllowed: true }, async () => args.laneTemplateService?.listTemplates() ?? []); - register("lanes.getDefaultTemplate", { viewerAllowed: true }, async () => args.laneTemplateService?.getDefaultTemplateId() ?? null); - register("lanes.getEnvStatus", { viewerAllowed: true }, async (payload) => args.laneEnvironmentService?.getProgress(requireString(payload.laneId, "lanes.getEnvStatus requires laneId.")) ?? null); - register("lanes.initEnv", { viewerAllowed: true, queueable: true }, async (payload) => { - const laneEnvironmentService = requireService(args.laneEnvironmentService, "Lane environment service not available."); - const laneId = requireString(payload.laneId, "lanes.initEnv requires laneId."); - const context = await resolveLaneOverlayContext(args, laneId); - if (!context.envInitConfig) { - const now = new Date().toISOString(); - return { - laneId, - steps: [], - startedAt: now, - completedAt: now, - overallStatus: "completed", - } satisfies LaneEnvInitProgress; - } - return await laneEnvironmentService.initLaneEnvironment(context.lane, context.envInitConfig, context.overrides); - }); - register("lanes.applyTemplate", { viewerAllowed: true, queueable: true }, async (payload) => { - const laneTemplateService = requireService(args.laneTemplateService, "Lane template service not available."); - const laneEnvironmentService = requireService(args.laneEnvironmentService, "Lane environment service not available."); - const parsed = { - laneId: requireString(payload.laneId, "lanes.applyTemplate requires laneId."), - templateId: requireString(payload.templateId, "lanes.applyTemplate requires templateId."), - } satisfies ApplyLaneTemplateArgs; - const context = await resolveLaneOverlayContext(args, parsed.laneId); - const template = laneTemplateService.getTemplate(parsed.templateId); - if (!template) throw new Error(`Template not found: ${parsed.templateId}`); - const templateEnvInit = laneTemplateService.resolveTemplateAsEnvInit(template); - const mergedOverrides = mergeLaneOverrides(context.overrides, { - ...(template.envVars ? { env: template.envVars } : {}), - ...(!context.overrides.portRange && template.portRange ? { portRange: template.portRange } : {}), - envInit: templateEnvInit, - }); - const mergedEnvInitConfig = mergeLaneEnvInitConfig(context.envInitConfig, templateEnvInit) ?? templateEnvInit; - return await laneEnvironmentService.initLaneEnvironment(context.lane, mergedEnvInitConfig, mergedOverrides); - }); - - register("work.listSessions", { viewerAllowed: true }, async (payload) => listRemoteWorkSessions(args, parseListSessionsArgs(payload))); - register("work.updateSessionMeta", { viewerAllowed: true, queueable: true }, async (payload) => { - args.sessionService.updateMeta(parseUpdateSessionMetaArgs(payload)); - return { ok: true }; - }); - register("work.runQuickCommand", { viewerAllowed: true, queueable: true }, async (payload) => { - const parsed = parseQuickCommandArgs(payload); - return await args.ptyService.create({ - laneId: parsed.laneId, - title: parsed.title, - ...(parsed.toolType === "shell" || !parsed.startupCommand ? {} : { startupCommand: parsed.startupCommand }), - tracked: parsed.tracked ?? true, - cols: parsed.cols ?? 120, - rows: parsed.rows ?? 36, - toolType: (parsed.toolType ?? "run-shell") as TerminalToolType, - }); - }); - register("work.startCliSession", { viewerAllowed: true, queueable: true }, async (payload) => { - const parsed = parseStartCliSessionArgs(payload); - const cols = clampCliDimension(parsed.cols, DEFAULT_CLI_COLS, 20, 240); - const rows = clampCliDimension(parsed.rows, DEFAULT_CLI_ROWS, 4, 120); - const resumeSessionId = parsed.resumeSessionId?.trim() || undefined; - const { provider } = parsed; - const permissionMode = parsed.permissionMode ?? "default"; - validateLaunchProfilePermissionMode(provider, permissionMode); - const resumeSession = resumeSessionId - ? requireResumeSessionForProvider(args.sessionService, resumeSessionId, provider) - : null; - const toolType = LAUNCH_PROFILE_TOOL_TYPE[provider] as TerminalToolType; - const title = parsed.title?.trim() || LAUNCH_PROFILE_TITLE[provider]; - const preassignedSessionId = provider === "claude" && !resumeSessionId ? randomUUID() : undefined; - - function resolveLaunch(): { startupCommand?: string; command?: string; args?: string[]; env?: Record<string, string> } { - if (provider === "shell") return {}; - if (resumeSessionId) { - if (!resumeSession) throw new Error(`work.startCliSession resumeSessionId '${resumeSessionId}' was not found.`); - const startupCommand = resolveTrackedCliResumeCommand(resumeSession) - ?? buildTrackedCliResumeCommand({ - provider, - targetKind: "session", - targetId: null, - launch: { permissionMode }, - }); - return { startupCommand }; - } - return buildTrackedCliLaunchCommand({ provider, permissionMode, sessionId: preassignedSessionId }); - } - - const sessionId = resumeSessionId ?? preassignedSessionId; - const result = await args.ptyService.create({ - ...(sessionId ? { sessionId } : {}), - allowNewSessionId: Boolean(preassignedSessionId), - laneId: parsed.laneId, - title, - tracked: true, - toolType, - cols, - rows, - ...resolveLaunch(), - }); - - if (parsed.initialInput && provider !== "shell") { - const written = args.ptyService.writeBySessionId(result.sessionId, `${parsed.initialInput}\r`); - if (!written) { - try { - args.ptyService.dispose({ ptyId: result.ptyId, sessionId: result.sessionId }); - } catch (err) { - args.logger.warn("sync_remote.start_cli_session_initial_input_cleanup_failed", { - sessionId: result.sessionId, - err: String(err), - }); - } - throw new Error("work.startCliSession created a terminal session but could not write initialInput."); - } - } - - const session = args.sessionService.get(result.sessionId); - const enriched = session ? args.ptyService.enrichSessions([session])[0] ?? session : null; - return { - sessionId: result.sessionId, - ptyId: result.ptyId, - session: enriched, - } satisfies SyncStartCliSessionResult; - }); - register("work.closeSession", { viewerAllowed: true, queueable: true }, async (payload) => { - const { sessionId } = parseCloseSessionArgs(payload); - const session = args.sessionService.get(sessionId); - if (session?.ptyId) { - await args.ptyService.dispose({ ptyId: session.ptyId, sessionId }); - } - return { ok: true }; - }); - - register("processes.listDefinitions", { viewerAllowed: true }, async () => - requireService(args.processService, "Process service not available.").listDefinitions()); - register("processes.listRuntime", { viewerAllowed: true }, async (payload) => - requireService(args.processService, "Process service not available.").listRuntime( - parseProcessLaneArgs(payload, "processes.listRuntime").laneId, - )); - register("processes.start", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.processService, "Process service not available.").start( - parseProcessActionArgs(payload, "processes.start"), - )); - register("processes.stop", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.processService, "Process service not available.").stop( - parseProcessActionArgs(payload, "processes.stop"), - )); - register("processes.kill", { viewerAllowed: true, queueable: false }, async (payload) => - requireService(args.processService, "Process service not available.").kill( - parseProcessActionArgs(payload, "processes.kill"), - )); - - register("chat.listSessions", { viewerAllowed: true }, async (payload) => { - const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); - const parsed = parseAgentChatListArgs(payload); - return agentChatService.listSessions(parsed.laneId, { includeAutomation: parsed.includeAutomation }); - }); - register("chat.getSummary", { viewerAllowed: true }, async (payload) => - requireService(args.agentChatService, "Agent chat service not available.").getSessionSummary(parseAgentChatGetSummaryArgs(payload).sessionId)); - register("chat.getTranscript", { viewerAllowed: true }, async (payload) => - requireService(args.agentChatService, "Agent chat service not available.").getChatTranscript(parseGetTranscriptArgs(payload))); - register("chat.create", { viewerAllowed: true, queueable: true }, async (payload) => { - const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); - const parsed = parseAgentChatCreateArgs(payload); - const session = await agentChatService.createSession(await resolveChatCreateArgs(agentChatService, parsed)); - return summarizeChatSessionForRemote(agentChatService, session); - }); - register("chat.send", { viewerAllowed: true, queueable: true }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").sendMessage( - parseAgentChatSendArgs(payload), - { awaitDispatch: true }, - ); - return { ok: true }; - }); - register("chat.interrupt", { viewerAllowed: true, queueable: false }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").interrupt(parseAgentChatInterruptArgs(payload)); - return { ok: true }; - }); - register("chat.steer", { viewerAllowed: true, queueable: false }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").steer(parseAgentChatSteerArgs(payload)); - return { ok: true }; - }); - register("chat.cancelSteer", { viewerAllowed: true, queueable: false }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").cancelSteer(parseAgentChatCancelSteerArgs(payload)); - return { ok: true }; - }); - register("chat.editSteer", { viewerAllowed: true, queueable: false }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").editSteer(parseAgentChatEditSteerArgs(payload)); - return { ok: true }; - }); - register("chat.dispatchSteer", { viewerAllowed: true, queueable: false }, async (payload) => { - const result = await requireService(args.agentChatService, "Agent chat service not available.").dispatchSteer(parseAgentChatDispatchSteerArgs(payload)); - return { ok: true, dispatchedAt: result.dispatchedAt }; - }); - register("chat.cancelDispatchedSteer", { viewerAllowed: true, queueable: false }, async (payload) => { - const result = await requireService(args.agentChatService, "Agent chat service not available.").cancelDispatchedSteer(parseAgentChatCancelDispatchedSteerArgs(payload)); - return { ok: true, cancelled: result.cancelled }; - }); - register("chat.approve", { viewerAllowed: true, queueable: false }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").approveToolUse(parseAgentChatApproveArgs(payload)); - return { ok: true }; - }); - register("chat.respondToInput", { viewerAllowed: true, queueable: false }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").respondToInput(parseAgentChatRespondToInputArgs(payload)); - return { ok: true }; - }); - register("chat.resume", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.agentChatService, "Agent chat service not available.").resumeSession(parseAgentChatResumeArgs(payload))); - // Restart: fired by iOS Live Activity + Attention Drawer "Restart" pill on - // a failed agent. Alias to resumeSession — same runtime-rewire behaviour. - // Keep as a distinct action name so telemetry can distinguish explicit - // restart intent from ordinary resume. - register("chat.restart", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.agentChatService, "Agent chat service not available.").resumeSession(parseAgentChatResumeArgs(payload))); - register("chat.updateSession", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.agentChatService, "Agent chat service not available.").updateSession(parseAgentChatUpdateSessionArgs(payload))); - register("chat.dispose", { viewerAllowed: true, queueable: true }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").dispose(parseAgentChatDisposeArgs(payload)); - return { ok: true }; - }); - register("chat.archive", { viewerAllowed: true, queueable: true }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").archiveSession(parseAgentChatArchiveArgs(payload, "chat.archive")); - return { ok: true }; - }); - register("chat.unarchive", { viewerAllowed: true, queueable: true }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").unarchiveSession(parseAgentChatArchiveArgs(payload, "chat.unarchive")); - return { ok: true }; - }); - register("chat.delete", { viewerAllowed: true, queueable: true }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").deleteSession(parseAgentChatArchiveArgs(payload, "chat.delete")); - return { ok: true }; - }); - register("chat.models", { viewerAllowed: true }, async (payload) => - requireService(args.agentChatService, "Agent chat service not available.").getAvailableModels(parseChatModelsArgs(payload))); - register("chat.modelCatalog", { viewerAllowed: true }, async () => - requireService(args.agentChatService, "Agent chat service not available.").getModelCatalog()); - - register("cto.getRoster", { viewerAllowed: true }, async () => { - const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); - const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); - const sessions = await agentChatService.listSessions(undefined, { includeIdentity: true }); - const activityTimestamp = (value: string | null | undefined): number => { - if (!value) return 0; - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : 0; - }; - const sortedByRecency = [...sessions].sort( - (a, b) => activityTimestamp(b.lastActivityAt) - activityTimestamp(a.lastActivityAt), - ); - const ctoSummary = sortedByRecency.find((entry) => entry.identityKey === "cto") ?? null; - const agents = workerAgentService.listAgents(); - const knownAgentIds = new Set(agents.map((agent) => agent.id)); - const liveWorkers = agents.map((agent) => { - const sessionSummary = sortedByRecency.find( - (entry) => entry.identityKey === `agent:${agent.id}`, - ) ?? null; - return { - agentId: agent.id, - name: agent.name, - avatarSeed: agent.slug || null, - status: agent.status as string, - sessionSummary, - }; - }); - // Include agent:<id> sessions whose identity is no longer in the roster - // so mobile users can still see / resume orphan chats. These are marked - // with a synthetic "orphaned" status and no avatar seed. - const orphanPrefix = "agent:"; - const orphanWorkers: typeof liveWorkers = []; - const seenOrphanIds = new Set<string>(); - for (const entry of sortedByRecency) { - const key = entry.identityKey ?? ""; - if (!key.startsWith(orphanPrefix)) continue; - const agentId = key.slice(orphanPrefix.length); - if (!agentId.length) continue; - if (knownAgentIds.has(agentId)) continue; - if (seenOrphanIds.has(agentId)) continue; - seenOrphanIds.add(agentId); - orphanWorkers.push({ - agentId, - name: agentId, - avatarSeed: null, - status: "orphaned", - sessionSummary: entry, - }); - } - liveWorkers.sort((a, b) => a.name.localeCompare(b.name)); - orphanWorkers.sort((a, b) => a.name.localeCompare(b.name)); - const workers = [...liveWorkers, ...orphanWorkers]; - return { cto: ctoSummary, workers }; - }); - register("cto.ensureSession", { viewerAllowed: true }, async (payload) => { - const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); - const laneId = await resolvePrimaryLaneIdOnlyForSync(args); - if (!laneId) throw new Error("No primary lane is available to host the CTO chat session."); - const modelId = asTrimmedString(payload.modelId); - const reasoningEffort = asTrimmedString(payload.reasoningEffort); - const session = await agentChatService.ensureIdentitySession({ - identityKey: "cto", - laneId, - modelId: modelId ?? null, - reasoningEffort: reasoningEffort ?? null, - permissionMode: "full-auto", - }); - return summarizeChatSessionForRemote(agentChatService, session); - }); - register("cto.ensureAgentSession", { viewerAllowed: true }, async (payload) => { - const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); - const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); - const agentId = requireString(payload.agentId, "cto.ensureAgentSession requires agentId."); - // Reject unknown agentIds before we spin up an identity-bound session — - // otherwise clients could spawn orphan `agent:<id>` sessions for agents - // that don't exist. - const agent = typeof workerAgentService.getAgent === "function" - ? workerAgentService.getAgent(agentId) - : workerAgentService.listAgents().find((entry) => entry.id === agentId) ?? null; - if (!agent) { - throw new Error(`cto.ensureAgentSession: unknown agentId '${agentId}'`); - } - const laneId = await resolvePrimaryLaneIdOnlyForSync(args); - if (!laneId) throw new Error("No primary lane is available to host the agent chat session."); - const modelId = asTrimmedString(payload.modelId); - const reasoningEffort = asTrimmedString(payload.reasoningEffort); - const session = await agentChatService.ensureIdentitySession({ - identityKey: `agent:${agentId}`, - laneId, - modelId: modelId ?? null, - reasoningEffort: reasoningEffort ?? null, - permissionMode: "full-auto", - }); - return summarizeChatSessionForRemote(agentChatService, session); - }); - - register("cto.getState", { viewerAllowed: true }, async (payload) => { - const ctoStateService = requireService(args.ctoStateService, "CTO state service not available."); - const recentLimit = asOptionalNumber(payload.recentLimit); - return ctoStateService.getSnapshot(recentLimit ?? 20); - }); - register("cto.listAgents", { viewerAllowed: true }, async (payload) => { - const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); - const includeDeleted = asOptionalBoolean(payload.includeDeleted); - return workerAgentService.listAgents(includeDeleted === undefined ? {} : { includeDeleted }); - }); - register("cto.getBudgetSnapshot", { viewerAllowed: true }, async (payload) => { - const workerBudgetService = requireService(args.workerBudgetService, "Worker budget service not available."); - const monthKey = asTrimmedString(payload.monthKey); - return workerBudgetService.getBudgetSnapshot(monthKey ? { monthKey } : {}); - }); - register("cto.getAgentCoreMemory", { viewerAllowed: true }, async (payload) => { - const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); - const agentId = requireString(payload.agentId, "cto.getAgentCoreMemory requires agentId."); - return workerHeartbeatService.getAgentCoreMemory(agentId); - }); - register("cto.listAgentRuns", { viewerAllowed: true }, async (payload) => { - const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); - const agentId = requireString(payload.agentId, "cto.listAgentRuns requires agentId."); - const limit = asOptionalNumber(payload.limit); - return workerHeartbeatService.listRuns({ agentId, ...(typeof limit === "number" ? { limit } : {}) }); - }); - register("cto.listAgentSessionLogs", { viewerAllowed: true }, async (payload) => { - const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); - const agentId = requireString(payload.agentId, "cto.listAgentSessionLogs requires agentId."); - const limit = asOptionalNumber(payload.limit); - return workerHeartbeatService.listAgentSessionLogs(agentId, limit ?? 40); - }); - register("cto.listAgentRevisions", { viewerAllowed: true }, async (payload) => { - const workerRevisionService = requireService(args.workerRevisionService, "Worker revision service not available."); - const agentId = requireString(payload.agentId, "cto.listAgentRevisions requires agentId."); - const limit = asOptionalNumber(payload.limit); - return workerRevisionService.listAgentRevisions(agentId, limit ?? 20); - }); - register("cto.getFlowPolicy", { viewerAllowed: true }, async () => { - const flowPolicyService = requireService(args.flowPolicyService, "Flow policy service not available."); - return flowPolicyService.getPolicy(); - }); - register("cto.getLinearConnectionStatus", { viewerAllowed: true }, async () => { - const linearCredentialService = requireService(args.linearCredentialService, "Linear credential service not available."); - const credentialStatus = linearCredentialService.getStatus(); - const tokenStored = Boolean(credentialStatus.tokenStored); - const checkedAt = new Date().toISOString(); - const linearIssueTracker = args.getLinearIssueTracker?.() ?? null; - if (!linearIssueTracker || !tokenStored) { - return { - tokenStored, - connected: false, - viewerId: null, - viewerName: null, - checkedAt, - authMode: credentialStatus.authMode, - oauthAvailable: credentialStatus.oauthConfigured, - tokenExpiresAt: credentialStatus.tokenExpiresAt, - message: tokenStored ? "Linear tracker service unavailable." : "Linear token not configured.", - }; - } - const status = await linearIssueTracker.getConnectionStatus(); - return { - tokenStored, - connected: status.connected, - viewerId: status.viewerId, - viewerName: status.viewerName, - organizationId: status.organizationId, - organizationName: status.organizationName, - organizationUrlKey: status.organizationUrlKey, - organizationLogoUrl: status.organizationLogoUrl, - checkedAt, - authMode: credentialStatus.authMode, - oauthAvailable: credentialStatus.oauthConfigured, - tokenExpiresAt: credentialStatus.tokenExpiresAt, - message: status.message, - }; - }); - register("cto.getLinearSyncDashboard", { viewerAllowed: true }, async () => { - const linearSyncService = requireService(args.getLinearSyncService?.() ?? null, "Linear sync service not available."); - return linearSyncService.getDashboard(); - }); - register("cto.listLinearSyncQueue", { viewerAllowed: true }, async () => { - const linearSyncService = requireService(args.getLinearSyncService?.() ?? null, "Linear sync service not available."); - return linearSyncService.listQueue({ limit: 300 }); - }); - register("cto.listLinearIngressEvents", { viewerAllowed: true }, async (payload) => { - const linearIngressService = requireService(args.getLinearIngressService?.() ?? null, "Linear ingress service not available."); - const limit = asOptionalNumber(payload.limit); - return linearIngressService.listRecentEvents(limit ?? 20); - }); - register("cto.updateIdentity", { viewerAllowed: true, queueable: true }, async (payload) => { - const ctoStateService = requireService(args.ctoStateService, "CTO state service not available."); - const patch = isRecord(payload.patch) ? (payload.patch as Partial<CtoIdentity>) : {}; - return ctoStateService.updateIdentity(patch); - }); - register("cto.updateCoreMemory", { viewerAllowed: true, queueable: true }, async (payload) => { - const ctoStateService = requireService(args.ctoStateService, "CTO state service not available."); - const patch = isRecord(payload.patch) ? (payload.patch as Partial<CtoCoreMemory>) : {}; - return ctoStateService.updateCoreMemory(patch); - }); - register("cto.setAgentStatus", { viewerAllowed: true, queueable: true }, async (payload) => { - const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); - const agentId = requireString(payload.agentId, "cto.setAgentStatus requires agentId."); - const status = requireString(payload.status, "cto.setAgentStatus requires status.") as AgentStatus; - workerAgentService.setAgentStatus(agentId, status); - return {}; - }); - register("cto.triggerAgentWakeup", { viewerAllowed: true, queueable: true }, async (payload) => { - const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); - const agentId = requireString(payload.agentId, "cto.triggerAgentWakeup requires agentId."); - const reason = asTrimmedString(payload.reason); - const context = isRecord(payload.context) ? payload.context : undefined; - return workerHeartbeatService.triggerWakeup({ - agentId, - ...(reason ? { reason: reason as CtoTriggerAgentWakeupArgs["reason"] } : {}), - ...(context ? { context } : {}), - }); - }); - register("cto.rollbackAgentRevision", { viewerAllowed: true, queueable: true }, async (payload) => { - const workerRevisionService = requireService(args.workerRevisionService, "Worker revision service not available."); - const agentId = requireString(payload.agentId, "cto.rollbackAgentRevision requires agentId."); - const revisionId = requireString(payload.revisionId, "cto.rollbackAgentRevision requires revisionId."); - await workerRevisionService.rollbackAgentRevision(agentId, revisionId, "user"); - return {}; - }); - - register("git.getChanges", { viewerAllowed: true }, async (payload) => - requireService(args.diffService, "Diff service not available.").getChanges(parseGetDiffChangesArgs(payload).laneId)); - register("git.getFile", { viewerAllowed: true }, async (payload) => { - const diffService = requireService(args.diffService, "Diff service not available."); - const parsed = parseGetFileDiffArgs(payload); - return await diffService.getFileDiff({ - laneId: parsed.laneId, - filePath: parsed.path, - mode: parsed.mode, - compareRef: parsed.compareRef, - compareTo: parsed.compareTo, - }); - }); - register("files.writeTextAtomic", { viewerAllowed: true, queueable: true }, async (payload) => { - const parsed = parseWriteTextAtomicArgs(payload); - args.fileService.writeTextAtomic({ laneId: parsed.laneId, relPath: parsed.path, text: parsed.text }); - return { ok: true }; - }); - register("git.stageFile", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").stageFile(parseGitFileActionArgs(payload, "git.stageFile"))); - register("git.stageAll", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").stageAll(parseGitBatchFileActionArgs(payload, "git.stageAll"))); - register("git.unstageFile", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").unstageFile(parseGitFileActionArgs(payload, "git.unstageFile"))); - register("git.unstageAll", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").unstageAll(parseGitBatchFileActionArgs(payload, "git.unstageAll"))); - register("git.discardFile", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").discardFile(parseGitFileActionArgs(payload, "git.discardFile"))); - register("git.restoreStagedFile", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").restoreStagedFile(parseGitFileActionArgs(payload, "git.restoreStagedFile"))); - register("git.commit", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").commit(parseGitCommitArgs(payload))); - register("git.generateCommitMessage", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").generateCommitMessage(parseGitGenerateCommitMessageArgs(payload))); - register("git.listRecentCommits", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").listRecentCommits(parseGitListRecentCommitsArgs(payload))); - register("git.listCommitFiles", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").listCommitFiles(parseGitListCommitFilesArgs(payload))); - register("git.getFileHistory", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").getFileHistory(parseGitGetFileHistoryArgs(payload))); - register("git.getCommitMessage", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").getCommitMessage(parseGitGetCommitMessageArgs(payload))); - register("git.revertCommit", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").revertCommit(parseGitRevertArgs(payload))); - register("git.cherryPickCommit", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").cherryPickCommit(parseGitCherryPickArgs(payload))); - register("git.stashPush", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").stashPush(parseGitStashPushArgs(payload))); - register("git.stashList", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").listStashes(parseConflictLaneArgs(payload, "git.stashList"))); - register("git.stashApply", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").stashApply(parseGitStashRefArgs(payload, "git.stashApply"))); - register("git.stashPop", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").stashPop(parseGitStashRefArgs(payload, "git.stashPop"))); - register("git.stashDrop", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").stashDrop(parseGitStashRefArgs(payload, "git.stashDrop"))); - register("git.fetch", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").fetch(parseConflictLaneArgs(payload, "git.fetch"))); - register("git.pull", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").pull(parseConflictLaneArgs(payload, "git.pull"))); - register("git.getSyncStatus", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").getSyncStatus(parseConflictLaneArgs(payload, "git.getSyncStatus"))); - register("git.sync", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").sync(parseGitSyncArgs(payload))); - register("git.push", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").push(parseGitPushArgs(payload))); - register("git.getConflictState", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").getConflictState(parseConflictLaneArgs(payload, "git.getConflictState"))); - register("git.rebaseContinue", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").rebaseContinue(parseConflictLaneArgs(payload, "git.rebaseContinue"))); - register("git.rebaseAbort", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").rebaseAbort(parseConflictLaneArgs(payload, "git.rebaseAbort"))); - register("git.listBranches", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").listBranches(parseGitListBranchesArgs(payload))); - register("git.checkoutBranch", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").checkoutBranch(parseGitCheckoutBranchArgs(payload))); - - register("conflicts.getLaneStatus", { viewerAllowed: true }, async (payload) => - requireService(args.conflictService, "Conflict service not available.").getLaneStatus(parseConflictLaneArgs(payload, "conflicts.getLaneStatus"))); - register("conflicts.listOverlaps", { viewerAllowed: true }, async (payload) => - requireService(args.conflictService, "Conflict service not available.").listOverlaps(parseConflictLaneArgs(payload, "conflicts.listOverlaps"))); - register("conflicts.getBatchAssessment", { viewerAllowed: true }, async () => - requireService(args.conflictService, "Conflict service not available.").getBatchAssessment()); - - register("prs.list", { viewerAllowed: true }, async () => args.prService.listAll()); - register("prs.refresh", { viewerAllowed: true }, async (payload) => { - const prId = asTrimmedString(payload.prId); - const prIds = asStringArray(payload.prIds); - await args.prService.refresh(prId ? { prId } : prIds.length > 0 ? { prIds } : {}); - const prs = await args.prService.listAll(); - return { - refreshedCount: prId ? 1 : prIds.length > 0 ? prIds.length : prs.length, - prs, - snapshots: args.prService.listSnapshots(), - }; - }); - register("prs.getDetail", { viewerAllowed: true }, async (payload) => args.prService.getDetail(requirePrId(payload, "prs.getDetail"))); - register("prs.getStatus", { viewerAllowed: true }, async (payload) => args.prService.getStatus(requirePrId(payload, "prs.getStatus"))); - register("prs.getChecks", { viewerAllowed: true }, async (payload) => args.prService.getChecks(requirePrId(payload, "prs.getChecks"))); - register("prs.getReviews", { viewerAllowed: true }, async (payload) => args.prService.getReviews(requirePrId(payload, "prs.getReviews"))); - register("prs.getComments", { viewerAllowed: true }, async (payload) => args.prService.getComments(requirePrId(payload, "prs.getComments"))); - register("prs.getFiles", { viewerAllowed: true }, async (payload) => args.prService.getFiles(requirePrId(payload, "prs.getFiles"))); - register("prs.getGitHubSnapshot", { viewerAllowed: true }, async (payload) => - args.prService.getGithubSnapshot({ force: payload.force === true })); - register("prs.getReviewThreads", { viewerAllowed: true }, async (payload) => args.prService.getReviewThreads(requirePrId(payload, "prs.getReviewThreads"))); - register("prs.getActionRuns", { viewerAllowed: true }, async (payload) => args.prService.getActionRuns(requirePrId(payload, "prs.getActionRuns"))); - register("prs.getActivity", { viewerAllowed: true }, async (payload) => args.prService.getActivity(requirePrId(payload, "prs.getActivity"))); - register("prs.getDeployments", { viewerAllowed: true }, async (payload) => args.prService.getDeployments(requirePrId(payload, "prs.getDeployments"))); - register("prs.createFromLane", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.createFromLane(parseCreatePrArgs(payload))); - register("prs.linkToLane", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.linkToLane(parseLinkPrToLaneArgs(payload))); - register("prs.draftDescription", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.draftDescription(parseDraftPrDescriptionArgs(payload))); - register("prs.land", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.land(parseLandPrArgs(payload))); - register("prs.close", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.closePr(parseClosePrArgs(payload)); - return { ok: true }; - }); - register("prs.reopen", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.reopenPr(parseReopenPrArgs(payload)); - return { ok: true }; - }); - register("prs.requestReviewers", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.requestReviewers(parseRequestReviewersArgs(payload)); - return { ok: true }; - }); - register("prs.rerunChecks", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.rerunChecks(parseRerunPrChecksArgs(payload)); - return { ok: true }; - }); - register("prs.addComment", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.addComment(parseAddPrCommentArgs(payload))); - register("prs.updateTitle", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.updateTitle(parseUpdatePrTitleArgs(payload)); - return { ok: true }; - }); - register("prs.updateBody", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.updateBody(parseUpdatePrBodyArgs(payload)); - return { ok: true }; - }); - register("prs.setLabels", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.setLabels(parseSetPrLabelsArgs(payload)); - return { ok: true }; - }); - register("prs.submitReview", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.submitReview(parseSubmitPrReviewArgs(payload)); - return { ok: true }; - }); - register("prs.replyToReviewThread", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.replyToReviewThread(parseReplyToReviewThreadArgs(payload))); - register("prs.setReviewThreadResolved", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.setReviewThreadResolved(parseSetReviewThreadResolvedArgs(payload))); - register("prs.reactToComment", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.reactToComment(parseReactToCommentArgs(payload)); - return { ok: true }; - }); - register("prs.aiReviewSummary", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.aiReviewSummary(parseAiReviewSummaryArgs(payload))); - register("prs.listIntegrationWorkflows", { viewerAllowed: true }, async (payload) => - args.prService.listIntegrationWorkflows(parseListIntegrationWorkflowsArgs(payload))); - register("prs.updateIntegrationProposal", { viewerAllowed: true, queueable: true }, async (payload) => { - args.prService.updateIntegrationProposal(parseUpdateIntegrationProposalArgs(payload)); - return { ok: true }; - }); - register("prs.deleteIntegrationProposal", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.deleteIntegrationProposal(parseDeleteIntegrationProposalArgs(payload))); - register("prs.dismissIntegrationCleanup", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.dismissIntegrationCleanup(parseDismissIntegrationCleanupArgs(payload))); - register("prs.cleanupIntegrationWorkflow", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.cleanupIntegrationWorkflow(parseCleanupIntegrationWorkflowArgs(payload))); - register("prs.createIntegrationLaneForProposal", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.createIntegrationLaneForProposal(parseCreateIntegrationLaneForProposalArgs(payload))); - register("prs.startIntegrationResolution", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.startIntegrationResolution(parseStartIntegrationResolutionArgs(payload))); - register("prs.recheckIntegrationStep", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.recheckIntegrationStep(parseRecheckIntegrationStepArgs(payload))); - register("prs.landQueueNext", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.landQueueNext(parseLandQueueNextArgs(payload))); - register("prs.pauseQueueAutomation", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.queueLandingService) throw new Error("Queue automation is not available."); - return args.queueLandingService.pauseQueue(parsePauseQueueAutomationArgs(payload).queueId); - }); - register("prs.resumeQueueAutomation", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.queueLandingService) throw new Error("Queue automation is not available."); - return args.queueLandingService.resumeQueue(parseResumeQueueAutomationArgs(payload)); - }); - register("prs.cancelQueueAutomation", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.queueLandingService) throw new Error("Queue automation is not available."); - return args.queueLandingService.cancelQueue(parseCancelQueueAutomationArgs(payload).queueId); - }); - register("prs.reorderQueue", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.reorderQueuePrs(parseReorderQueuePrsArgs(payload)); - return { ok: true }; - }); - register("prs.issueInventory.sync", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - const { prId } = parseIssueInventoryPrArgs(payload, "prs.issueInventory.sync"); - const [checks, reviewThreads, comments] = await Promise.all([ - args.prService.getChecks(prId), - args.prService.getReviewThreads(prId), - args.prService.getComments(prId).catch(() => []), - ]); - return args.issueInventoryService.syncFromPrData(prId, checks, reviewThreads, comments); - }); - register("prs.issueInventory.get", { viewerAllowed: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - return args.issueInventoryService.getInventory(parseIssueInventoryPrArgs(payload, "prs.issueInventory.get").prId); - }); - register("prs.issueInventory.getNew", { viewerAllowed: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - return args.issueInventoryService.getNewItems(parseIssueInventoryPrArgs(payload, "prs.issueInventory.getNew").prId); - }); - register("prs.issueInventory.markFixed", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - const parsed = parseIssueInventoryItemsArgs(payload, "prs.issueInventory.markFixed"); - args.issueInventoryService.markFixed(parsed.prId, parsed.itemIds); - return { ok: true }; - }); - register("prs.issueInventory.markDismissed", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - const parsed = parseIssueInventoryDismissArgs(payload); - args.issueInventoryService.markDismissed(parsed.prId, parsed.itemIds, parsed.reason); - return { ok: true }; - }); - register("prs.issueInventory.markEscalated", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - const parsed = parseIssueInventoryItemsArgs(payload, "prs.issueInventory.markEscalated"); - args.issueInventoryService.markEscalated(parsed.prId, parsed.itemIds); - return { ok: true }; - }); - register("prs.issueInventory.getConvergence", { viewerAllowed: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - return args.issueInventoryService.getConvergenceStatus(parseIssueInventoryPrArgs(payload, "prs.issueInventory.getConvergence").prId); - }); - register("prs.issueInventory.reset", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - args.issueInventoryService.resetInventory(parseIssueInventoryPrArgs(payload, "prs.issueInventory.reset").prId); - return { ok: true }; - }); - register("prs.convergenceState.get", { viewerAllowed: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - return args.issueInventoryService.getConvergenceRuntime(parseIssueInventoryPrArgs(payload, "prs.convergenceState.get").prId); - }); - register("prs.convergenceState.save", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - const parsed = parseConvergenceStatePatch(payload); - return args.issueInventoryService.saveConvergenceRuntime(parsed.prId, parsed.state); - }); - register("prs.convergenceState.delete", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - args.issueInventoryService.resetConvergenceRuntime(parseIssueInventoryPrArgs(payload, "prs.convergenceState.delete").prId); - return { ok: true }; - }); - register("prs.pipelineSettings.get", { viewerAllowed: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - return args.issueInventoryService.getPipelineSettings(parseIssueInventoryPrArgs(payload, "prs.pipelineSettings.get").prId); - }); - register("prs.pipelineSettings.save", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - const parsed = parsePipelineSettingsPatch(payload); - args.issueInventoryService.savePipelineSettings(parsed.prId, parsed.settings); - return { ok: true }; - }); - register("prs.pipelineSettings.delete", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - args.issueInventoryService.deletePipelineSettings(parseIssueInventoryPrArgs(payload, "prs.pipelineSettings.delete").prId); - return { ok: true }; - }); - register("prs.pathToMerge.start", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.pathToMergeOrchestrator) { - throw new Error("Path to Merge orchestrator is not available in this build."); - } - const { prId } = parseIssueInventoryPrArgs(payload, "prs.pathToMerge.start"); - const modelId = typeof payload?.modelId === "string" ? payload.modelId : null; - const reasoning = typeof payload?.reasoning === "string" ? payload.reasoning : null; - const additionalInstructions = typeof payload?.additionalInstructions === "string" - ? payload.additionalInstructions - : null; - const rawScope = payload?.scope; - const scope = rawScope === "checks" || rawScope === "comments" || rawScope === "both" - ? rawScope - : undefined; - return args.pathToMergeOrchestrator.startPathToMerge({ - prId, - modelId, - reasoning, - scope, - additionalInstructions, - }); - }); - register("prs.pathToMerge.stop", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.pathToMergeOrchestrator) { - throw new Error("Path to Merge orchestrator is not available in this build."); - } - const { prId } = parseIssueInventoryPrArgs(payload, "prs.pathToMerge.stop"); - const reason = typeof payload?.reason === "string" ? payload.reason : null; - return args.pathToMergeOrchestrator.stopPathToMerge({ prId, reason }); - }); - register("prs.getMobileSnapshot", { viewerAllowed: true }, async () => args.prService.getMobileSnapshot()); - - return { - getSupportedActions(): SyncRemoteCommandAction[] { - return [...registry.keys()]; - }, - - getDescriptors(): SyncRemoteCommandDescriptor[] { - return [...registry.values()].map((entry) => entry.descriptor); - }, - - getPolicy(action: string): SyncRemoteCommandPolicy | null { - return registry.get(action as SyncRemoteCommandAction)?.descriptor.policy ?? null; - }, - - async execute(payload: SyncCommandPayload): Promise<unknown> { - const handler = registry.get(payload.action as SyncRemoteCommandAction); - if (!handler) { - throw new Error(`Unsupported remote command: ${payload.action}`); - } - const commandArgs = isRecord(payload.args) ? payload.args : {}; - args.logger.debug?.("sync.remote_command.execute", { - action: payload.action, - policy: handler.descriptor.policy, - }); - return await handler.handler(commandArgs); - }, - }; -} - -export type SyncRemoteCommandService = ReturnType<typeof createSyncRemoteCommandService>; +export * from "../../../../../ade-cli/src/services/sync/syncRemoteCommandService"; diff --git a/apps/desktop/src/main/services/sync/syncService.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index ff3481117..d0c41975f 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -44,7 +44,7 @@ const { createSyncHostServiceMock } = vi.hoisted(() => ({ // Prevent real WebSocket servers from binding to port 8787 during tests. // Tests only exercise role/transfer/pairing logic, not the sync transport. -vi.mock("./syncHostService", () => ({ +vi.mock("../../../../../ade-cli/src/services/sync/syncHostService", () => ({ createSyncHostService: createSyncHostServiceMock, SYNC_TAILNET_DISCOVERY_SERVICE_NAME: "svc:ade-sync", SYNC_TAILNET_DISCOVERY_SERVICE_PORT: 8787, @@ -175,6 +175,10 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { // serviceB sees the same on-disk pin file but only the host that performed // the migration retains the plaintext PIN in memory; serviceB should not. expect(serviceB.getPin()).toBeNull(); + + const generated = await serviceA.generatePin(); + expect(generated.pairingPin).toMatch(/^\d{6}$/); + expect(serviceA.getPin()).toBe(generated.pairingPin); }); it("reports W3 transfer blockers while keeping paused and idle state survivable", async () => { @@ -387,7 +391,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { expect(transferred.transferReadiness.ready).toBe(true); }, 30_000); - it("builds pairing QR payloads with LAN-first address candidates and tailscale fallback", async () => { + it("builds pairing runtime addresses with LAN-first address candidates and tailscale fallback", async () => { const projectRoot = makeProjectRoot("ade-sync-service-pairing-"); const db = await openKvDb( path.join(projectRoot, ".ade", "ade.db"), @@ -498,16 +502,8 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { expect(refreshedCandidates[0]?.kind).toBe("saved"); expect(refreshedCandidates[0]?.host).toBe(refreshedStatus.localDevice.lastHost); - const encodedPayload = - status.pairingConnectInfo?.qrPayloadText.split("payload=")[1] ?? ""; - const parsedPayload = JSON.parse(decodeURIComponent(encodedPayload)) as { - version: number; - hostIdentity: { deviceId: string }; - addressCandidates: Array<{ host: string; kind: string }>; - }; - expect(parsedPayload.version).toBe(2); - expect(parsedPayload.hostIdentity.deviceId).toBe(localDeviceId); - expect(parsedPayload.addressCandidates.some((c) => c.kind === "loopback" && c.host === "127.0.0.1")).toBe(true); + expect(status.pairingConnectInfo?.hostIdentity.deviceId).toBe(localDeviceId); + expect(addressCandidates.some((c) => c.kind === "loopback" && c.host === "127.0.0.1")).toBe(true); }, 30_000); it("does not start the sync host or expose pairing details when host startup is disabled", async () => { diff --git a/apps/desktop/src/main/services/sync/syncService.ts b/apps/desktop/src/main/services/sync/syncService.ts index 0c7e54d4c..f29ba5d05 100644 --- a/apps/desktop/src/main/services/sync/syncService.ts +++ b/apps/desktop/src/main/services/sync/syncService.ts @@ -1,1064 +1 @@ -import fs from "node:fs"; -import path from "node:path"; -import { resolveAdeLayout } from "../../../shared/adeLayout"; -import type { - SyncAddressCandidate, - SyncDesktopConnectionDraft, - SyncDeviceRuntimeState, - SyncGetStatusArgs, - SyncPairingConnectInfo, - SyncPairingQrPayload, - SyncProjectCatalogPayload, - SyncProjectSwitchRequestPayload, - SyncProjectSwitchResultPayload, - SyncRoleSnapshot, - SyncTailnetDiscoveryStatus, - SyncTransferBlocker, - SyncTransferReadiness, -} from "../../../shared/types"; -import type { Logger } from "../logging/logger"; -import type { createAgentChatService } from "../chat/agentChatService"; -import type { createCtoStateService } from "../cto/ctoStateService"; -import type { createFlowPolicyService } from "../cto/flowPolicyService"; -import type { createLinearCredentialService } from "../cto/linearCredentialService"; -import type { createLinearIngressService } from "../cto/linearIngressService"; -import type { createLinearIssueTracker } from "../cto/linearIssueTracker"; -import type { createLinearSyncService } from "../cto/linearSyncService"; -import type { createWorkerAgentService } from "../cto/workerAgentService"; -import type { createWorkerBudgetService } from "../cto/workerBudgetService"; -import type { createWorkerHeartbeatService } from "../cto/workerHeartbeatService"; -import type { createWorkerRevisionService } from "../cto/workerRevisionService"; -import type { createComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createFileService } from "../files/fileService"; -import type { createDiffService } from "../diffs/diffService"; -import type { createGitOperationsService } from "../git/gitOperationsService"; -import type { createConflictService } from "../conflicts/conflictService"; -import type { createLaneEnvironmentService } from "../lanes/laneEnvironmentService"; -import type { createLaneService } from "../lanes/laneService"; -import type { createLaneTemplateService } from "../lanes/laneTemplateService"; -import type { createAutoRebaseService } from "../lanes/autoRebaseService"; -import type { createPortAllocationService } from "../lanes/portAllocationService"; -import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionService"; -import type { createMissionService } from "../missions/missionService"; -import type { createProcessService } from "../processes/processService"; -import type { createIssueInventoryService } from "../prs/issueInventoryService"; -import type { PathToMergeOrchestrator } from "../prs/pathToMergeOrchestrator"; -import type { createPrService } from "../prs/prService"; -import type { createQueueLandingService } from "../prs/queueLandingService"; -import type { createPtyService } from "../pty/ptyService"; -import type { createSessionService } from "../sessions/sessionService"; -import type { NotificationEventBus } from "../notifications/notificationEventBus"; -import type { AdeDb } from "../state/kvDb"; -import { nowIso, safeJsonParse, sleep, writeTextAtomic } from "../shared/utils"; -import { createDeviceRegistryService } from "./deviceRegistryService"; -import { - createSyncHostService, - SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - type SyncHostService, -} from "./syncHostService"; -import { createSyncPeerService } from "./syncPeerService"; -import { createSyncPinStore } from "./syncPinStore"; -import { DEFAULT_SYNC_HOST_PORT } from "./syncProtocol"; - -type SyncServiceArgs = { - db: AdeDb; - logger: Logger; - projectRoot: string; - localDeviceIdPath?: string; - phonePairingStateDir?: string; - fileService: ReturnType<typeof createFileService>; - laneService: ReturnType<typeof createLaneService>; - gitService?: ReturnType<typeof createGitOperationsService>; - diffService?: ReturnType<typeof createDiffService>; - conflictService?: ReturnType<typeof createConflictService>; - prService: ReturnType<typeof createPrService>; - issueInventoryService?: ReturnType<typeof createIssueInventoryService> | null; - /** - * Optional Path-to-Merge orchestrator forwarded to the embedded sync host so - * iOS callers can drive the convergence loop via remote commands. - */ - pathToMergeOrchestrator?: PathToMergeOrchestrator | null; - queueLandingService?: ReturnType<typeof createQueueLandingService> | null; - sessionService: ReturnType<typeof createSessionService>; - ptyService: ReturnType<typeof createPtyService>; - projectConfigService?: ReturnType<typeof createProjectConfigService>; - portAllocationService?: ReturnType<typeof createPortAllocationService>; - laneEnvironmentService?: ReturnType<typeof createLaneEnvironmentService>; - laneTemplateService?: ReturnType<typeof createLaneTemplateService>; - rebaseSuggestionService?: ReturnType< - typeof createRebaseSuggestionService - > | null; - autoRebaseService?: ReturnType<typeof createAutoRebaseService> | null; - computerUseArtifactBrokerService: ReturnType< - typeof createComputerUseArtifactBrokerService - >; - missionService: ReturnType<typeof createMissionService>; - agentChatService: ReturnType<typeof createAgentChatService>; - workerAgentService?: ReturnType<typeof createWorkerAgentService> | null; - workerBudgetService?: ReturnType<typeof createWorkerBudgetService> | null; - workerHeartbeatService?: ReturnType<typeof createWorkerHeartbeatService> | null; - workerRevisionService?: ReturnType<typeof createWorkerRevisionService> | null; - ctoStateService?: ReturnType<typeof createCtoStateService> | null; - flowPolicyService?: ReturnType<typeof createFlowPolicyService> | null; - linearCredentialService?: ReturnType<typeof createLinearCredentialService> | null; - /** - * Resolvers for services that are constructed AFTER createSyncService in - * main.ts. Using lazy getters lets the sync router forward remote commands - * to them without requiring a specific init order. - */ - getLinearIngressService?: () => ReturnType<typeof createLinearIngressService> | null; - getLinearIssueTracker?: () => ReturnType<typeof createLinearIssueTracker> | null; - getLinearSyncService?: () => ReturnType<typeof createLinearSyncService> | null; - processService: ReturnType<typeof createProcessService>; - hostStartupEnabled?: boolean; - hostDiscoveryEnabled?: boolean; - /** - * Phone sync is hosted by the local desktop app. When enabled, legacy - * desktop-to-desktop viewer state stored in a project DB cannot demote the - * phone sync surface into viewer mode. - */ - forceHostRole?: boolean; - onStatusChanged?: (snapshot: SyncRoleSnapshot) => void; - /** - * Optional notification bus forwarded to the sync host. The host publishes - * chat/PR/mission/system events and invokes `sendInAppNotification` for - * connected iOS peers. - */ - notificationEventBus?: NotificationEventBus | null; - projectCatalogProvider?: { - listProjects: () => Promise<SyncProjectCatalogPayload>; - prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise<SyncProjectSwitchResultPayload>; - completeProjectConnection?: ( - args: SyncProjectSwitchRequestPayload, - result: SyncProjectSwitchResultPayload, - ) => Promise<void>; - }; -}; - -const DRAFT_FILE = "sync-peer-draft.json"; -const TOKEN_FILE = "sync-bootstrap-token"; -const PIN_FILE = "sync-pin.json"; -const PAIRED_DEVICES_FILE = "sync-paired-devices.json"; - -function migrateLegacySyncSecretFile(args: { - legacyPath: string; - appPath: string; - logger: Logger; - label: string; -}): void { - if (args.legacyPath === args.appPath) return; - if (fs.existsSync(args.appPath) || !fs.existsSync(args.legacyPath)) return; - try { - fs.mkdirSync(path.dirname(args.appPath), { recursive: true }); - fs.copyFileSync(args.legacyPath, args.appPath, fs.constants.COPYFILE_EXCL); - args.logger.info("sync.app_pairing_state_migrated", { - label: args.label, - legacyPath: args.legacyPath, - appPath: args.appPath, - }); - } catch (error) { - if ((error as NodeJS.ErrnoException | null | undefined)?.code === "EEXIST") return; - args.logger.warn("sync.app_pairing_state_migration_failed", { - label: args.label, - legacyPath: args.legacyPath, - appPath: args.appPath, - error: error instanceof Error ? error.message : String(error), - }); - } -} -const RUNNING_PROCESS_STATES = new Set(["starting", "running", "degraded"]); -const CHAT_TOOL_TYPES = new Set(["codex-chat", "claude-chat", "opencode-chat"]); -const SYNC_HOST_PORT_RETRY_WINDOW = 12; -const LOCAL_LANE_PRESENCE_HEARTBEAT_MS = 30_000; -const TRANSFER_READINESS_CACHE_MS = 15_000; - -function buildSkippedTransferReadiness(): SyncTransferReadiness { - return { - ready: false, - blockers: [], - survivableState: [ - "Transfer readiness was skipped for this lightweight sync status request.", - ], - }; -} - -function sanitizeDraft( - raw: unknown, - token: string | null, -): SyncDesktopConnectionDraft | null { - if (!raw || typeof raw !== "object" || !token) return null; - const row = raw as Record<string, unknown>; - const host = typeof row.host === "string" ? row.host.trim() : ""; - const port = Number(row.port ?? 0); - if (!host || !Number.isFinite(port) || port <= 0) return null; - return { - host, - port: Math.floor(port), - token, - authKind: row.authKind === "paired" ? "paired" : "bootstrap", - pairedDeviceId: - typeof row.pairedDeviceId === "string" ? row.pairedDeviceId : null, - lastRemoteDbVersion: Number.isFinite(row.lastRemoteDbVersion) - ? Number(row.lastRemoteDbVersion) - : 0, - }; -} - -function normalizeHost(host: string | null | undefined): string | null { - if (!host) return null; - const normalized = host.trim().toLowerCase(); - return normalized.length > 0 ? normalized : null; -} - -function tailscaleDnsNameFromDevice( - localDevice: SyncRoleSnapshot["localDevice"], -): string | null { - const value = localDevice.metadata?.tailscaleDnsName; - return typeof value === "string" && value.trim().toLowerCase().endsWith(".ts.net") - ? value.trim().replace(/\.$/, "").toLowerCase() - : null; -} - -function buildAddressCandidates( - localDevice: SyncRoleSnapshot["localDevice"], -): SyncAddressCandidate[] { - const candidates: SyncAddressCandidate[] = []; - const seen = new Set<string>(); - const append = ( - host: string | null | undefined, - kind: SyncAddressCandidate["kind"], - ) => { - const normalized = normalizeHost(host); - if (!normalized || seen.has(normalized)) return; - seen.add(normalized); - candidates.push({ host: normalized, kind }); - }; - const preferredSavedHost = normalizeHost(localDevice.lastHost); - const preferredSavedHostIsCurrent = preferredSavedHost != null && ( - localDevice.ipAddresses.some((host) => normalizeHost(host) === preferredSavedHost) - || normalizeHost(localDevice.tailscaleIp) === preferredSavedHost - || tailscaleDnsNameFromDevice(localDevice) === preferredSavedHost - ); - if (preferredSavedHostIsCurrent) { - append(localDevice.lastHost, "saved"); - } - for (const lanAddress of localDevice.ipAddresses) { - append(lanAddress, "lan"); - } - if (!preferredSavedHostIsCurrent) { - append(localDevice.lastHost, "saved"); - } - append(tailscaleDnsNameFromDevice(localDevice), "tailscale"); - append(localDevice.tailscaleIp, "tailscale"); - append("127.0.0.1", "loopback"); - return candidates; -} - -function buildPairingConnectInfo(argsIn: { - localDevice: SyncRoleSnapshot["localDevice"]; -}): SyncPairingConnectInfo { - const port = argsIn.localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT; - const addressCandidates = buildAddressCandidates(argsIn.localDevice); - const hostIdentity = { - deviceId: argsIn.localDevice.deviceId, - siteId: argsIn.localDevice.siteId, - name: argsIn.localDevice.name, - platform: argsIn.localDevice.platform, - deviceType: argsIn.localDevice.deviceType, - }; - const qrPayload: SyncPairingQrPayload = { - version: 2, - hostIdentity, - port, - addressCandidates, - }; - const qrPayloadText = `ade-sync://pair?payload=${encodeURIComponent(JSON.stringify(qrPayload))}`; - return { - hostIdentity, - port, - addressCandidates, - qrPayload, - qrPayloadText, - }; -} - -function isRetryableHostBindError(error: unknown): boolean { - const code = (error as NodeJS.ErrnoException | null | undefined)?.code ?? ""; - return code === "EADDRINUSE" || code === "EACCES"; -} - -function createInactiveTailnetDiscoveryStatus( - error: string, -): SyncTailnetDiscoveryStatus { - return { - state: "disabled", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: null, - error, - stderr: null, - }; -} - -function buildHostPortCandidates(preferredPort: number | null | undefined): number[] { - const preferred = Number.isFinite(preferredPort) - ? Math.max(0, Math.min(65_535, Math.floor(Number(preferredPort)))) - : DEFAULT_SYNC_HOST_PORT; - const candidates: number[] = []; - const seen = new Set<number>(); - const add = (port: number) => { - const normalized = Math.max(0, Math.min(65_535, Math.floor(port))); - if (seen.has(normalized)) return; - seen.add(normalized); - candidates.push(normalized); - }; - add(preferred); - if (preferred !== DEFAULT_SYNC_HOST_PORT) { - add(DEFAULT_SYNC_HOST_PORT); - } - for (let offset = 1; offset <= SYNC_HOST_PORT_RETRY_WINDOW; offset += 1) { - if (preferred + offset <= 65_535) { - add(preferred + offset); - } - } - if (preferred !== DEFAULT_SYNC_HOST_PORT) { - for (let offset = 1; offset <= Math.min(4, SYNC_HOST_PORT_RETRY_WINDOW); offset += 1) { - if (DEFAULT_SYNC_HOST_PORT + offset <= 65_535) { - add(DEFAULT_SYNC_HOST_PORT + offset); - } - } - } - add(0); - return candidates; -} - -export function createSyncService(args: SyncServiceArgs) { - const layout = resolveAdeLayout(args.projectRoot); - const pairingStateDir = args.phonePairingStateDir ?? layout.secretsDir; - const draftPath = path.join(pairingStateDir, DRAFT_FILE); - const tokenPath = path.join(pairingStateDir, TOKEN_FILE); - const pinPath = path.join(pairingStateDir, PIN_FILE); - const pairingSecretsPath = path.join(pairingStateDir, PAIRED_DEVICES_FILE); - migrateLegacySyncSecretFile({ - legacyPath: path.join(layout.secretsDir, DRAFT_FILE), - appPath: draftPath, - logger: args.logger, - label: DRAFT_FILE, - }); - migrateLegacySyncSecretFile({ - legacyPath: path.join(layout.secretsDir, TOKEN_FILE), - appPath: tokenPath, - logger: args.logger, - label: TOKEN_FILE, - }); - migrateLegacySyncSecretFile({ - legacyPath: path.join(layout.secretsDir, PIN_FILE), - appPath: pinPath, - logger: args.logger, - label: PIN_FILE, - }); - migrateLegacySyncSecretFile({ - legacyPath: path.join(layout.secretsDir, PAIRED_DEVICES_FILE), - appPath: pairingSecretsPath, - logger: args.logger, - label: PAIRED_DEVICES_FILE, - }); - fs.mkdirSync(path.dirname(draftPath), { recursive: true }); - - const pinStore = createSyncPinStore({ filePath: pinPath }); - - const deviceRegistryService = createDeviceRegistryService({ - db: args.db, - logger: args.logger, - projectRoot: args.projectRoot, - localDeviceIdPath: args.localDeviceIdPath, - }); - - let hostService: SyncHostService | null = null; - let refreshRunning = false; - let refreshQueued = false; - let disposed = false; - // Mobile project switch can fire `sync.initialize` as a background task and - // then immediately await `service.initialize()` from the dialog handler. - // Coalesce concurrent calls so the second await rides the first promise - // rather than re-running ensureLocalDevice/refreshRoleState in parallel. - let initializingPromise: Promise<void> | null = null; - let initialized = false; - let hostStartupEnabled = args.hostStartupEnabled !== false; - let hostDiscoveryEnabled = args.hostDiscoveryEnabled !== false; - let transferReadinessCache: { value: SyncTransferReadiness; expiresAtMs: number } | null = null; - let transferReadinessInFlight: Promise<SyncTransferReadiness> | null = null; - const forceHostRole = args.forceHostRole === true; - const isCrdtSyncAvailable = (): boolean => args.db.sync.isAvailable?.() !== false; - const assertPhonePairingAvailable = (): void => { - if (!hostStartupEnabled) { - throw new Error( - "Phone pairing is unavailable because the sync host is disabled for this ADE process.", - ); - } - if (!isCrdtSyncAvailable()) { - throw new Error( - "Phone pairing is unavailable because the CRDT database extension is unavailable on this platform.", - ); - } - }; - let activeLocalLanePresenceIds: string[] = []; - const localLanePresenceHeartbeatTimer = setInterval(() => { - if (disposed || !hostService || activeLocalLanePresenceIds.length === 0) return; - hostService.setLocalActiveLanePresence?.(activeLocalLanePresenceIds); - }, LOCAL_LANE_PRESENCE_HEARTBEAT_MS); - - const readToken = (): string | null => { - if (!fs.existsSync(tokenPath)) return null; - const value = fs.readFileSync(tokenPath, "utf8").trim(); - return value.length > 0 ? value : null; - }; - - const writeToken = (token: string): void => { - writeTextAtomic(tokenPath, `${token.trim()}\n`); - }; - - const readSavedDraft = (): SyncDesktopConnectionDraft | null => { - if (forceHostRole) return null; - if (!fs.existsSync(draftPath)) return null; - const token = readToken(); - return sanitizeDraft( - safeJsonParse(fs.readFileSync(draftPath, "utf8"), null), - token, - ); - }; - - const writeSavedDraft = (draft: SyncDesktopConnectionDraft | null): void => { - if (!draft) { - try { - fs.rmSync(draftPath, { force: true }); - } catch { - // ignore - } - return; - } - writeToken(draft.token); - writeTextAtomic( - draftPath, - `${JSON.stringify( - { - host: draft.host, - port: draft.port, - authKind: draft.authKind ?? "bootstrap", - pairedDeviceId: draft.pairedDeviceId ?? null, - lastRemoteDbVersion: draft.lastRemoteDbVersion ?? 0, - }, - null, - 2, - )}\n`, - ); - }; - - const syncPeerService = createSyncPeerService({ - db: args.db, - logger: args.logger, - deviceRegistryService, - onStatusChange: (status) => { - if (forceHostRole) return; - if (status.savedDraft) { - const token = readToken(); - if (token) { - writeSavedDraft({ - host: status.savedDraft.host, - port: status.savedDraft.port, - token, - authKind: status.savedDraft.authKind ?? "bootstrap", - pairedDeviceId: status.savedDraft.pairedDeviceId ?? null, - lastRemoteDbVersion: status.savedDraft.lastRemoteDbVersion ?? 0, - }); - } - } - void emitStatus(); - }, - onBrainStatus: (payload) => { - deviceRegistryService.applyBrainStatus(payload); - void emitStatus(); - }, - onRemoteChangesApplied: () => { - void refreshRoleState(); - }, - }); - - const emitStatus = async (): Promise<void> => { - if (disposed) return; - args.onStatusChanged?.(await service.getStatus()); - }; - - const startHostIfNeeded = async (): Promise<void> => { - if (!hostStartupEnabled || !isCrdtSyncAvailable()) { - if (hostService) { - await stopHostIfRunning(); - } - const currentLocalDevice = deviceRegistryService.ensureLocalDevice(); - deviceRegistryService.touchLocalDevice({ - lastSeenAt: nowIso(), - lastHost: currentLocalDevice.ipAddresses[0] ?? currentLocalDevice.tailscaleIp ?? currentLocalDevice.lastHost, - }); - return; - } - if (hostService) { - const currentLocalDevice = deviceRegistryService.ensureLocalDevice(); - deviceRegistryService.touchLocalDevice({ - lastSeenAt: nowIso(), - lastHost: currentLocalDevice.ipAddresses[0] ?? currentLocalDevice.tailscaleIp ?? currentLocalDevice.lastHost, - lastPort: hostService.getPort(), - }); - hostService.refreshLanDiscovery?.(); - return; - } - const localDevice = deviceRegistryService.ensureLocalDevice(); - const preferredPort = localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT; - let lastError: unknown = null; - for (const attemptedPort of buildHostPortCandidates(preferredPort)) { - const candidateHostService = createSyncHostService({ - db: args.db, - logger: args.logger, - projectRoot: args.projectRoot, - fileService: args.fileService, - laneService: args.laneService, - gitService: args.gitService, - diffService: args.diffService, - conflictService: args.conflictService, - prService: args.prService, - issueInventoryService: args.issueInventoryService, - pathToMergeOrchestrator: args.pathToMergeOrchestrator, - queueLandingService: args.queueLandingService, - sessionService: args.sessionService, - ptyService: args.ptyService, - processService: args.processService, - agentChatService: args.agentChatService, - workerAgentService: args.workerAgentService, - workerBudgetService: args.workerBudgetService, - workerHeartbeatService: args.workerHeartbeatService, - workerRevisionService: args.workerRevisionService, - ctoStateService: args.ctoStateService, - flowPolicyService: args.flowPolicyService, - linearCredentialService: args.linearCredentialService, - getLinearIngressService: args.getLinearIngressService, - getLinearIssueTracker: args.getLinearIssueTracker, - getLinearSyncService: args.getLinearSyncService, - projectConfigService: args.projectConfigService, - portAllocationService: args.portAllocationService, - laneEnvironmentService: args.laneEnvironmentService, - laneTemplateService: args.laneTemplateService, - rebaseSuggestionService: args.rebaseSuggestionService ?? undefined, - autoRebaseService: args.autoRebaseService ?? undefined, - computerUseArtifactBrokerService: args.computerUseArtifactBrokerService, - pinStore, - bootstrapTokenPath: tokenPath, - pairingSecretsPath, - port: attemptedPort, - discoveryEnabled: hostDiscoveryEnabled, - deviceRegistryService, - notificationEventBus: args.notificationEventBus ?? null, - projectCatalogProvider: args.projectCatalogProvider, - onStateChanged: () => { - void refreshRoleState(); - }, - }); - try { - const resolvedPort = await candidateHostService.waitUntilListening(); - hostService = candidateHostService; - hostService.setLocalActiveLanePresence?.(activeLocalLanePresenceIds); - deviceRegistryService.touchLocalDevice({ - lastSeenAt: nowIso(), - lastHost: localDevice.ipAddresses[0] ?? localDevice.tailscaleIp ?? localDevice.lastHost, - lastPort: resolvedPort, - }); - return; - } catch (error) { - lastError = error; - await candidateHostService.dispose().catch(() => {}); - const retryable = isRetryableHostBindError(error) && attemptedPort !== 0; - args.logger.warn( - retryable ? "sync.host_start_port_conflict" : "sync.host_start_failed", - { - preferredPort, - attemptedPort, - error: error instanceof Error ? error.message : String(error), - code: (error as NodeJS.ErrnoException | null | undefined)?.code ?? null, - }, - ); - if (!retryable) { - throw error; - } - } - } - throw lastError instanceof Error - ? lastError - : new Error("Unable to start the sync host."); - }; - - const stopHostIfRunning = async (): Promise<void> => { - if (!hostService) return; - const current = hostService; - hostService = null; - await current.dispose(); - }; - - const resolveViewerDraftFromRegistry = - (): SyncDesktopConnectionDraft | null => { - if (forceHostRole) return null; - const cluster = deviceRegistryService.getClusterState(); - const token = readToken(); - if (!cluster || !token) return null; - const brain = deviceRegistryService.getDevice(cluster.brainDeviceId); - const host = - brain != null ? buildAddressCandidates(brain)[0]?.host ?? null : null; - const port = brain?.lastPort ?? DEFAULT_SYNC_HOST_PORT; - if (!host) return null; - return { - host, - port, - token, - lastRemoteDbVersion: - syncPeerService.getStatus().lastRemoteDbVersion ?? 0, - }; - }; - - const refreshRoleState = async (): Promise<void> => { - if (disposed) return; - if (refreshRunning) { - refreshQueued = true; - return; - } - refreshRunning = true; - try { - do { - refreshQueued = false; - const savedDraft = readSavedDraft(); - syncPeerService.setSavedDraft(savedDraft); - const localDevice = deviceRegistryService.ensureLocalDevice(); - let cluster = deviceRegistryService.getClusterState(); - if (forceHostRole) { - if (!cluster || cluster.brainDeviceId !== localDevice.deviceId) { - cluster = deviceRegistryService.setClusterState({ - brainDeviceId: localDevice.deviceId, - brainEpoch: (cluster?.brainEpoch ?? 0) + 1, - updatedByDeviceId: localDevice.deviceId, - }); - } - } else if (!cluster && !savedDraft) { - cluster = deviceRegistryService.bootstrapLocalBrainIfNeeded(); - } - const isLocalBrain = forceHostRole || (cluster - ? cluster.brainDeviceId === localDevice.deviceId - : !savedDraft); - if (isLocalBrain) { - if (syncPeerService.isConnected()) { - syncPeerService.disconnect({ preserveDraft: true }); - } - await startHostIfNeeded(); - } else { - await stopHostIfRunning(); - if (!isCrdtSyncAvailable()) { - if (syncPeerService.isConnected()) { - syncPeerService.disconnect({ preserveDraft: true }); - } - continue; - } - const draft = savedDraft ?? resolveViewerDraftFromRegistry(); - if (draft && !syncPeerService.isConnected()) { - syncPeerService.setSavedDraft(draft); - try { - await syncPeerService.connect(draft); - deviceRegistryService.touchLocalDevice({ lastSeenAt: nowIso() }); - syncPeerService.flushLocalChanges(); - } catch (error) { - args.logger.warn("sync.role.viewer_connect_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } - } - } - } while (refreshQueued); - } finally { - refreshRunning = false; - await emitStatus(); - } - }; - - const listRuntimeDevices = async (): Promise<SyncDeviceRuntimeState[]> => { - const devices = deviceRegistryService.listDevices(); - const cluster = deviceRegistryService.getClusterState(); - const currentBrainId = cluster?.brainDeviceId ?? null; - const peerStates = hostService - ? hostService.getPeerStates() - : (syncPeerService.getLatestBrainStatus()?.connectedPeers ?? []); - const localDeviceId = deviceRegistryService.getLocalDeviceId(); - return devices.map((device) => { - const peer = - peerStates.find((entry) => entry.deviceId === device.deviceId) ?? null; - const isLocal = device.deviceId === localDeviceId; - return { - ...device, - isLocal, - isBrain: device.deviceId === currentBrainId, - connectionState: isLocal ? "self" : peer ? "connected" : "disconnected", - connectedAt: peer?.connectedAt ?? null, - lastAppliedAt: peer?.lastAppliedAt ?? null, - remoteAddress: peer?.remoteAddress ?? null, - remotePort: peer?.remotePort ?? null, - latencyMs: peer?.latencyMs ?? null, - syncLag: peer?.syncLag ?? null, - }; - }); - }; - - const computeTransferReadiness = async (): Promise<SyncTransferReadiness> => { - const blockers: SyncTransferBlocker[] = []; - - for (const mission of args.missionService.list({ - status: "active", - limit: 200, - })) { - blockers.push({ - kind: "mission_run", - id: mission.id, - label: mission.title || mission.id, - detail: `Mission is ${mission.status}. Paused missions can transfer, but active mission work cannot.`, - }); - } - - const chats = await args.agentChatService.listSessions(undefined, { - includeIdentity: true, - includeAutomation: true, - }); - const chatSummaries = new Map( - chats.map((chat) => [chat.sessionId, chat] as const), - ); - - for (const session of args.sessionService.list({ - status: "running", - limit: 500, - })) { - if (CHAT_TOOL_TYPES.has(session.toolType ?? "")) { - const chat = chatSummaries.get(session.id); - const isCto = chat?.identityKey === "cto"; - blockers.push({ - kind: "chat_runtime", - id: session.id, - label: chat?.title || (isCto ? "CTO thread" : session.title), - detail: isCto - ? "A running CTO turn must stop before handoff. CTO history and idle threads still transfer." - : "Live chat runtimes do not hot-transfer. Let the turn finish or interrupt it first.", - }); - continue; - } - blockers.push({ - kind: "terminal_session", - id: session.id, - label: session.title, - detail: - "Running terminal sessions must stop before the host role can move.", - }); - } - - const lanes = args.db.all<{ id: string }>( - "select id from lanes where status != 'archived'", - ); - for (const lane of lanes) { - for (const runtime of args.processService.listRuntime(lane.id)) { - if (!RUNNING_PROCESS_STATES.has(runtime.status)) continue; - blockers.push({ - kind: "managed_process", - id: `${lane.id}:${runtime.processId}`, - label: runtime.processId, - detail: - "Managed run processes must stop before the host role can move.", - }); - } - } - - return { - ready: blockers.length === 0, - blockers, - survivableState: [ - "Paused missions remain paused and can resume on the new host.", - "CTO history and idle threads remain available on the new host.", - "Idle and ended agent chats remain available and resumable on the new host.", - ], - }; - }; - - const getTransferReadiness = async (options?: { force?: boolean }): Promise<SyncTransferReadiness> => { - const now = Date.now(); - if (!options?.force && transferReadinessCache && transferReadinessCache.expiresAtMs > now) { - return transferReadinessCache.value; - } - // `force` should skip the cached value but still share the in-flight - // promise — otherwise overlapping forced callers each spawn their own - // computeTransferReadiness() run. - if (transferReadinessInFlight) return transferReadinessInFlight; - transferReadinessInFlight = computeTransferReadiness() - .then((value) => { - transferReadinessCache = { - value, - expiresAtMs: Date.now() + TRANSFER_READINESS_CACHE_MS, - }; - return value; - }) - .finally(() => { - transferReadinessInFlight = null; - }); - return transferReadinessInFlight; - }; - - const service = { - async initialize(): Promise<void> { - if (initialized) return; - if (initializingPromise) return initializingPromise; - initializingPromise = (async () => { - deviceRegistryService.ensureLocalDevice(); - await refreshRoleState(); - initialized = true; - })().finally(() => { - initializingPromise = null; - }); - return initializingPromise; - }, - - async getStatus(options?: SyncGetStatusArgs): Promise<SyncRoleSnapshot> { - const localDevice = deviceRegistryService.ensureLocalDevice(); - const cluster = deviceRegistryService.getClusterState(); - const savedDraft = readSavedDraft(); - const currentBrain = cluster - ? deviceRegistryService.getDevice(cluster.brainDeviceId) - : localDevice; - const isLocalBrain = forceHostRole || (cluster - ? cluster.brainDeviceId === localDevice.deviceId - : !savedDraft && !syncPeerService.isConnected()); - const role = isLocalBrain ? "brain" : "viewer"; - const crdtSyncAvailable = isCrdtSyncAvailable(); - const canHostPhonePairing = role === "brain" && hostStartupEnabled && crdtSyncAvailable; - const client = syncPeerService.getStatus(); - const mode = - role === "viewer" - ? "viewer" - : client.state === "connected" - ? "brain" - : "standalone"; - return { - mode, - role, - localDevice, - currentBrain, - clusterState: cluster, - bootstrapToken: - canHostPhonePairing ? readToken() : null, - pairingPin: canHostPhonePairing ? pinStore.getPin() : null, - pairingPinConfigured: canHostPhonePairing ? pinStore.hasPin() : false, - pairingConnectInfo: - canHostPhonePairing - ? buildPairingConnectInfo({ localDevice }) - : null, - connectedPeers: hostService - ? hostService.getPeerStates() - : (syncPeerService.getLatestBrainStatus()?.connectedPeers ?? []), - tailnetDiscovery: canHostPhonePairing && hostService - ? hostService.getTailnetDiscoveryStatus() - : createInactiveTailnetDiscoveryStatus( - canHostPhonePairing - ? "Tailnet discovery is waiting for the desktop sync host to start." - : "Tailnet discovery is only published by the host desktop.", - ), - client, - transferReadiness: options?.includeTransferReadiness === false - ? (transferReadinessCache?.value ?? buildSkippedTransferReadiness()) - : await getTransferReadiness({ force: options?.forceTransferReadiness === true }), - survivableStateText: - crdtSyncAvailable - ? "Paused and idle state will remain available on the new host." - : "Desktop sync is disabled because the CRDT database extension is unavailable on this platform.", - blockingStateText: - crdtSyncAvailable - ? "Live missions, chats, terminals, or run processes must stop first." - : "Install a Windows cr-sqlite runtime before pairing or syncing devices.", - }; - }, - - async listDevices(): Promise<SyncDeviceRuntimeState[]> { - return await listRuntimeDevices(); - }, - - async refreshDiscovery(): Promise<SyncRoleSnapshot> { - hostService?.refreshLanDiscovery?.({ forceTailnet: true }); - const snapshot = await this.getStatus(); - args.onStatusChanged?.(snapshot); - return snapshot; - }, - - setHostDiscoveryEnabled(enabled: boolean): void { - if (hostDiscoveryEnabled === enabled) return; - hostDiscoveryEnabled = enabled; - hostService?.setDiscoveryEnabled(enabled); - void emitStatus(); - }, - - async setHostStartupEnabled(enabled: boolean): Promise<void> { - if (hostStartupEnabled === enabled) return; - hostStartupEnabled = enabled; - await refreshRoleState(); - }, - - async updateLocalDevice(argsIn: { - name?: string; - deviceType?: "desktop" | "phone" | "vps" | "unknown"; - }) { - const updated = deviceRegistryService.updateLocalDevice(argsIn); - hostService?.setLocalActiveLanePresence(activeLocalLanePresenceIds); - await emitStatus(); - return updated; - }, - - async connectToBrain( - draft: SyncDesktopConnectionDraft, - ): Promise<SyncRoleSnapshot> { - if (!isCrdtSyncAvailable()) { - throw new Error("Desktop sync is unavailable because the CRDT database extension is not loaded."); - } - await stopHostIfRunning(); - deviceRegistryService.clearClusterRegistryForViewerJoin(); - writeSavedDraft(draft); - syncPeerService.setSavedDraft(draft); - try { - await syncPeerService.connect(draft); - deviceRegistryService.touchLocalDevice({ lastSeenAt: nowIso() }); - syncPeerService.flushLocalChanges(); - await sleep(150); - await refreshRoleState(); - return await this.getStatus(); - } catch (error) { - writeSavedDraft(null); - syncPeerService.setSavedDraft(null); - await refreshRoleState(); - throw error; - } - }, - - async disconnectFromBrain(): Promise<SyncRoleSnapshot> { - syncPeerService.disconnect(); - writeSavedDraft(null); - deviceRegistryService.clearClusterRegistryForViewerJoin(); - await refreshRoleState(); - return await this.getStatus(); - }, - - getPin(): string | null { - return pinStore.getPin(); - }, - - async setPin(pin: string): Promise<SyncRoleSnapshot> { - assertPhonePairingAvailable(); - const current = await service.getStatus(); - if (current.role !== "brain") { - throw new Error("Phone pairing PINs can only be managed on the host desktop."); - } - pinStore.setPin(pin); - const snapshot = await service.getStatus(); - args.onStatusChanged?.(snapshot); - return snapshot; - }, - - async clearPin(): Promise<SyncRoleSnapshot> { - assertPhonePairingAvailable(); - const current = await service.getStatus(); - if (current.role !== "brain") { - throw new Error("Phone pairing PINs can only be managed on the host desktop."); - } - pinStore.clearPin(); - const snapshot = await service.getStatus(); - args.onStatusChanged?.(snapshot); - return snapshot; - }, - - async setActiveLanePresence(laneIds: string[]): Promise<void> { - const normalized = Array.isArray(laneIds) - ? [...new Set( - laneIds - .map((laneId) => (typeof laneId === "string" ? laneId.trim() : "")) - .filter((laneId) => laneId.length > 0), - )] - : []; - activeLocalLanePresenceIds = normalized; - hostService?.setLocalActiveLanePresence(activeLocalLanePresenceIds); - }, - - async forgetDevice(deviceId: string): Promise<SyncRoleSnapshot> { - hostService?.revokePairedDevice(deviceId); - deviceRegistryService.forgetDevice(deviceId); - await emitStatus(); - return await this.getStatus(); - }, - - async getTransferReadiness(): Promise<SyncTransferReadiness> { - return await getTransferReadiness({ force: true }); - }, - - async transferBrainToLocal(): Promise<SyncRoleSnapshot> { - const current = await this.getStatus({ forceTransferReadiness: true }); - if (current.role === "brain") return current; - if (!current.transferReadiness.ready) { - throw new Error( - "Stop live missions, chats, terminals, and run processes before transferring the host role.", - ); - } - const localDevice = deviceRegistryService.ensureLocalDevice(); - const currentCluster = deviceRegistryService.getClusterState(); - deviceRegistryService.touchLocalDevice({ - lastSeenAt: nowIso(), - lastHost: localDevice.lastHost, - lastPort: localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT, - }); - deviceRegistryService.setClusterState({ - brainDeviceId: localDevice.deviceId, - brainEpoch: (currentCluster?.brainEpoch ?? 0) + 1, - updatedByDeviceId: localDevice.deviceId, - }); - syncPeerService.flushLocalChanges(); - await sleep(300); - await refreshRoleState(); - return await this.getStatus(); - }, - - handlePtyData( - event: Parameters<SyncHostService["handlePtyData"]>[0], - ): void { - hostService?.handlePtyData(event); - }, - - handlePtyExit( - event: Parameters<SyncHostService["handlePtyExit"]>[0], - ): void { - hostService?.handlePtyExit(event); - }, - - getHostService(): SyncHostService | null { - return hostService; - }, - - getDeviceRegistryService() { - return deviceRegistryService; - }, - - async dispose(): Promise<void> { - disposed = true; - syncPeerService.disconnect(); - clearInterval(localLanePresenceHeartbeatTimer); - await stopHostIfRunning(); - await syncPeerService.dispose(); - }, - }; - - return service; -} - -export type SyncService = ReturnType<typeof createSyncService>; +export * from "../../../../../ade-cli/src/services/sync/syncService"; diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 5ac55697d..4a7e3e717 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -12,6 +12,7 @@ import type { AdoptAttachedLaneArgs, UnregisteredLaneCandidate, AppInfo, + AppNavigationRequest, AutoUpdateSnapshot, ClearLocalAdeDataArgs, ClearLocalAdeDataResult, @@ -217,13 +218,6 @@ import type { CtoGetLinearOAuthSessionArgs, CtoGetLinearOAuthSessionResult, CtoRunProjectScanResult, - CtoGetOpenclawStateResult, - CtoUpdateOpenclawConfigArgs, - CtoTestOpenclawConnectionArgs, - CtoTestOpenclawConnectionResult, - CtoListOpenclawMessagesArgs, - CtoListOpenclawMessagesResult, - CtoSendOpenclawMessageArgs, LinearConnectionStatus, CtoSetLinearTokenArgs, CtoSaveFlowPolicyArgs, @@ -243,7 +237,6 @@ import type { CtoEnsureLinearWebhookArgs, CtoListLinearIngressEventsArgs, LinearWorkflowConfig, - OpenclawBridgeStatus, AddMissionArtifactArgs, AddMissionInterventionArgs, KeybindingOverride, @@ -412,6 +405,7 @@ import type { ProjectConfigTrust, ProjectConfigValidationResult, ProjectInfo, + OpenProjectBinding, CreateProjectInput, CreateProjectResult, CloneProjectInput, @@ -699,6 +693,17 @@ import type { MacosVmStopArgs, MacosVmTypeTextArgs, MacosVmWindowTarget, + RemoteRuntimeActionRequest, + RemoteRuntimeActionResult, + RemoteRuntimeConnectionSnapshot, + RemoteRuntimeConnectResult, + RemoteRuntimeDiscoveredMachine, + RemoteRuntimeLocalWorkCheckResult, + RemoteRuntimeProjectRecord, + RemoteRuntimeStreamEventsRequest, + RemoteRuntimeStreamEventsResult, + RemoteRuntimeTarget, + RemoteRuntimeTargetInput, ChatTerminalActiveForChatArgs, ChatTerminalListArgs, ChatTerminalReadArgs, @@ -722,15 +727,33 @@ declare global { ping: () => Promise<"pong">; getInfo: () => Promise<AppInfo>; getProject: () => Promise<ProjectInfo | null>; + getWindowSession: () => Promise<{ + windowId: number | null; + project: ProjectInfo | null; + binding: OpenProjectBinding | null; + }>; + newWindow: () => Promise<{ windowId: number | null }>; + openProjectInNewWindow: ( + rootPath: string, + ) => Promise<{ windowId: number | null; project: ProjectInfo | null }>; + closeWindow: (windowId?: number | null) => Promise<{ closed: boolean }>; onProjectChanged: ( cb: (project: ProjectInfo | null) => void, ) => () => void; + onProjectBindingChanged: ( + cb: (binding: OpenProjectBinding | null) => void, + ) => () => void; + onNavigate: (cb: (request: AppNavigationRequest) => void) => () => void; openExternal: (url: string) => Promise<void>; revealPath: (path: string) => Promise<void>; openPath: (path: string) => Promise<void>; writeClipboardText: (text: string) => Promise<void>; hasClipboardImage: () => Promise<boolean>; - readClipboardImage: () => Promise<{ data: string; filename: string; mimeType: string } | null>; + readClipboardImage: () => Promise<{ + data: string; + filename: string; + mimeType: string; + } | null>; getImageDataUrl: (path: string) => Promise<{ dataUrl: string }>; writeClipboardImage: (path: string) => Promise<void>; openPathInEditor: (args: { @@ -768,7 +791,9 @@ declare global { reorderRecent: ( orderedPaths: string[], ) => Promise<RecentProjectSummary[]>; - createLocal: (input: CreateProjectInput) => Promise<CreateProjectResult>; + createLocal: ( + input: CreateProjectInput, + ) => Promise<CreateProjectResult>; clone: (input: CloneProjectInput) => Promise<CloneProjectResult>; getDefaultParentDir: () => Promise<string>; getSnapshot: () => Promise<AdeProjectSnapshot>; @@ -777,12 +802,73 @@ declare global { onMissing: (cb: (data: { rootPath: string }) => void) => () => void; onStateEvent: (cb: (event: AdeProjectEvent) => void) => () => void; }; + remoteRuntime: { + listTargets: () => Promise<RemoteRuntimeTarget[]>; + getConnectionSnapshot: () => Promise<RemoteRuntimeConnectionSnapshot>; + onConnectionSnapshotChanged: ( + cb: (snapshot: RemoteRuntimeConnectionSnapshot) => void, + ) => () => void; + listDiscoveredMachines: () => Promise<RemoteRuntimeDiscoveredMachine[]>; + saveTarget: ( + input: RemoteRuntimeTargetInput, + ) => Promise<RemoteRuntimeTarget>; + removeTarget: (id: string) => Promise<{ removed: boolean }>; + connect: (id: string) => Promise<RemoteRuntimeConnectResult>; + listProjects: (id: string) => Promise<RemoteRuntimeProjectRecord[]>; + addProject: ( + id: string, + rootPath: string, + ) => Promise<RemoteRuntimeProjectRecord>; + browseDirectories: ( + id: string, + args?: ProjectBrowseInput, + ) => Promise<ProjectBrowseResult>; + getProjectDetail: ( + id: string, + rootPath: string, + ) => Promise<ProjectDetail>; + getDefaultParentDir: (id: string) => Promise<string>; + createProject: ( + id: string, + input: CreateProjectInput, + ) => Promise<RemoteRuntimeProjectRecord>; + cloneProject: ( + id: string, + input: CloneProjectInput, + ) => Promise<RemoteRuntimeProjectRecord>; + listMyGitHubRepos: ( + id: string, + input?: ListMyGitHubReposInput, + ) => Promise<ListMyGitHubReposResult>; + openProject: ( + id: string, + projectId: string, + ) => Promise<OpenProjectBinding>; + callAction: ( + id: string, + projectId: string, + request: RemoteRuntimeActionRequest, + ) => Promise<RemoteRuntimeActionResult>; + streamEvents: ( + id: string, + projectId: string, + request?: RemoteRuntimeStreamEventsRequest, + ) => Promise<RemoteRuntimeStreamEventsResult>; + checkLocalWork: ( + id: string, + project: RemoteRuntimeProjectRecord, + ) => Promise<RemoteRuntimeLocalWorkCheckResult>; + disconnect: (id: string) => Promise<{ disconnected: boolean }>; + }; keybindings: { get: () => Promise<KeybindingsSnapshot>; set: (overrides: KeybindingOverride[]) => Promise<KeybindingsSnapshot>; }; ai: { - getStatus: (args?: { force?: boolean; refreshOpenCodeInventory?: boolean }) => Promise<AiSettingsStatus>; + getStatus: (args?: { + force?: boolean; + refreshOpenCodeInventory?: boolean; + }) => Promise<AiSettingsStatus>; getOpenCodeRuntimeDiagnostics: () => Promise<OpenCodeRuntimeSnapshot>; storeApiKey: (provider: string, key: string) => Promise<void>; deleteApiKey: (provider: string) => Promise<void>; @@ -800,17 +886,35 @@ declare global { limit?: number; cursor?: string | null; }) => Promise<CursorCloudListRunsResult>; - cursorCloudCreateRun: (args: CursorCloudCreateRunRequest) => Promise<CursorCloudCreateRunResult>; + cursorCloudCreateRun: ( + args: CursorCloudCreateRunRequest, + ) => Promise<CursorCloudCreateRunResult>; cursorCloudArchiveAgent: (agentId: string) => Promise<void>; cursorCloudUnarchiveAgent: (agentId: string) => Promise<void>; cursorCloudDeleteAgent: (agentId: string) => Promise<void>; - cursorCloudGetAgent: (agentId: string) => Promise<CursorCloudAgentSummary | null>; - cursorCloudStreamRun: (args: CursorCloudStreamRunRequest) => Promise<CursorCloudStreamRunResult>; - cursorCloudCancelRun: (args: { agentId: string; runId: string }) => Promise<void>; - cursorCloudFollowUp: (args: CursorCloudFollowUpRequest) => Promise<CursorCloudFollowUpResult>; - cursorCloudListArtifacts: (agentId: string) => Promise<CursorCloudArtifactSummary[]>; - cursorCloudDownloadArtifact: (args: { agentId: string; path: string }) => Promise<CursorCloudArtifactDownload>; - cursorCloudOpenChat: (args: CursorCloudOpenChatRequest) => Promise<CursorCloudOpenChatResult>; + cursorCloudGetAgent: ( + agentId: string, + ) => Promise<CursorCloudAgentSummary | null>; + cursorCloudStreamRun: ( + args: CursorCloudStreamRunRequest, + ) => Promise<CursorCloudStreamRunResult>; + cursorCloudCancelRun: (args: { + agentId: string; + runId: string; + }) => Promise<void>; + cursorCloudFollowUp: ( + args: CursorCloudFollowUpRequest, + ) => Promise<CursorCloudFollowUpResult>; + cursorCloudListArtifacts: ( + agentId: string, + ) => Promise<CursorCloudArtifactSummary[]>; + cursorCloudDownloadArtifact: (args: { + agentId: string; + path: string; + }) => Promise<CursorCloudArtifactDownload>; + cursorCloudOpenChat: ( + args: CursorCloudOpenChatRequest, + ) => Promise<CursorCloudOpenChatResult>; }; sync: { getStatus: (args?: SyncGetStatusArgs) => Promise<SyncRoleSnapshot>; @@ -829,17 +933,20 @@ declare global { transferBrainToLocal: () => Promise<SyncRoleSnapshot>; getPin: () => Promise<{ pin: string | null }>; setPin: (pin: string) => Promise<SyncRoleSnapshot>; + generatePin: () => Promise<SyncRoleSnapshot>; clearPin: () => Promise<SyncRoleSnapshot>; - setActiveLanePresence: (args: { - laneIds: string[]; - }) => Promise<void>; + setActiveLanePresence: (args: { laneIds: string[] }) => Promise<void>; onEvent: (cb: (event: SyncStatusEventPayload) => void) => () => void; }; notifications: { apns: { getStatus: () => Promise<ApnsBridgeStatus>; - saveConfig: (args: ApnsBridgeSaveConfigArgs) => Promise<ApnsBridgeStatus>; - uploadKey: (args: ApnsBridgeUploadKeyArgs) => Promise<ApnsBridgeStatus>; + saveConfig: ( + args: ApnsBridgeSaveConfigArgs, + ) => Promise<ApnsBridgeStatus>; + uploadKey: ( + args: ApnsBridgeUploadKeyArgs, + ) => Promise<ApnsBridgeStatus>; clearKey: () => Promise<ApnsBridgeStatus>; sendTestPush: ( args: ApnsBridgeSendTestPushArgs, @@ -867,8 +974,13 @@ declare global { markWizardDismissed: () => Promise<OnboardingTourProgress>; markTourCompleted: (tourId: string) => Promise<OnboardingTourProgress>; markTourDismissed: (tourId: string) => Promise<OnboardingTourProgress>; - updateTourStep: (tourId: string, index: number) => Promise<OnboardingTourProgress>; - markGlossaryTermSeen: (termId: string) => Promise<OnboardingTourProgress>; + updateTourStep: ( + tourId: string, + index: number, + ) => Promise<OnboardingTourProgress>; + markGlossaryTermSeen: ( + termId: string, + ) => Promise<OnboardingTourProgress>; resetTourProgress: (tourId?: string) => Promise<OnboardingTourProgress>; markTourCompletedVariant: ( tourId: string, @@ -946,7 +1058,9 @@ declare global { args?: import("../shared/types").ReviewListSuppressionsArgs, ) => Promise<import("../shared/types").ReviewSuppression[]>; deleteSuppression: (suppressionId: string) => Promise<boolean>; - qualityReport: () => Promise<import("../shared/types").ReviewQualityReport>; + qualityReport: () => Promise< + import("../shared/types").ReviewQualityReport + >; onEvent: (cb: (ev: ReviewEventPayload) => void) => () => void; }; actions: { @@ -1192,7 +1306,9 @@ declare global { updateAppearance: (args: UpdateLaneAppearanceArgs) => Promise<void>; archive: (args: ArchiveLaneArgs) => Promise<void>; delete: (args: DeleteLaneArgs) => Promise<void>; - cancelDelete: (args: { laneId: string }) => Promise<{ cancelled: boolean; reason?: string }>; + cancelDelete: (args: { + laneId: string; + }) => Promise<{ cancelled: boolean; reason?: string }>; getDeleteRisk: (args: { laneId: string }) => Promise<LaneDeleteRisk>; onDeleteEvent: (cb: (ev: LaneDeleteEvent) => void) => () => void; getStackChain: (laneId: string) => Promise<StackChainItem[]>; @@ -1288,10 +1404,14 @@ declare global { list: (args?: ListSessionsArgs) => Promise<TerminalSessionSummary[]>; get: (sessionId: string) => Promise<TerminalSessionDetail | null>; delete: (args: DeleteSessionArgs) => Promise<void>; - updateMeta: (args: UpdateSessionMetaArgs) => Promise<TerminalSessionSummary | null>; + updateMeta: ( + args: UpdateSessionMetaArgs, + ) => Promise<TerminalSessionSummary | null>; readTranscriptTail: (args: ReadTranscriptTailArgs) => Promise<string>; getDelta: (sessionId: string) => Promise<SessionDeltaSummary | null>; - onChanged: (cb: (ev: TerminalSessionChangedEvent) => void) => () => void; + onChanged: ( + cb: (ev: TerminalSessionChangedEvent) => void, + ) => () => void; }; agentChat: { list: (args?: AgentChatListArgs) => Promise<AgentChatSessionSummary[]>; @@ -1299,9 +1419,13 @@ declare global { args: AgentChatGetSummaryArgs, ) => Promise<AgentChatSessionSummary | null>; create: (args: AgentChatCreateArgs) => Promise<AgentChatSession>; - suggestLaneName: (args: AgentChatSuggestLaneNameArgs) => Promise<string>; + suggestLaneName: ( + args: AgentChatSuggestLaneNameArgs, + ) => Promise<string>; parallelLaunchState: { - get: (args: AgentChatParallelLaunchStateArgs) => Promise<AgentChatParallelLaunchState | null>; + get: ( + args: AgentChatParallelLaunchStateArgs, + ) => Promise<AgentChatParallelLaunchState | null>; set: (args: AgentChatSetParallelLaunchStateArgs) => Promise<void>; }; handoff: ( @@ -1311,8 +1435,12 @@ declare global { steer: (args: AgentChatSteerArgs) => Promise<void>; cancelSteer: (args: AgentChatCancelSteerArgs) => Promise<void>; editSteer: (args: AgentChatEditSteerArgs) => Promise<void>; - dispatchSteer: (args: AgentChatDispatchSteerArgs) => Promise<AgentChatDispatchSteerResult>; - cancelDispatchedSteer: (args: AgentChatCancelDispatchedSteerArgs) => Promise<AgentChatCancelDispatchedSteerResult>; + dispatchSteer: ( + args: AgentChatDispatchSteerArgs, + ) => Promise<AgentChatDispatchSteerResult>; + cancelDispatchedSteer: ( + args: AgentChatCancelDispatchedSteerArgs, + ) => Promise<AgentChatCancelDispatchedSteerResult>; interrupt: (args: AgentChatInterruptArgs) => Promise<void>; resume: (args: AgentChatResumeArgs) => Promise<AgentChatSession>; approve: (args: AgentChatApproveArgs) => Promise<void>; @@ -1377,43 +1505,98 @@ declare global { iosSimulator: { getStatus: () => Promise<IosSimulatorStatus>; listDevices: () => Promise<IosSimulatorDevice[]>; - listLaunchTargets: (args?: IosSimulatorListLaunchTargetsArgs) => Promise<IosSimulatorLaunchTarget[]>; + listLaunchTargets: ( + args?: IosSimulatorListLaunchTargetsArgs, + ) => Promise<IosSimulatorLaunchTarget[]>; launch: (args?: IosSimulatorLaunchArgs) => Promise<IosSimulatorSession>; - attachToChatSession: (args: { chatSessionId: string | null; callerChatSessionId?: string | null }) => Promise<IosSimulatorSession | null>; - shutdown: (args?: IosSimulatorShutdownArgs) => Promise<IosSimulatorShutdownResult>; - screenshot: (args?: { deviceUdid?: string | null }) => Promise<IosSimulatorScreenshot>; - getScreenSnapshot: (args?: IosScreenSnapshotArgs) => Promise<IosScreenSnapshot>; - getInspectorSnapshot: (args?: { deviceUdid?: string | null }) => Promise<IosInspectorSnapshot | null>; - inspectPoint: (args: IosSimulatorInspectPointArgs) => Promise<IosSimulatorInspectResult>; - getPreviewCapability: (args?: IosSimulatorListPreviewsArgs) => Promise<IosSimulatorPreviewCapability>; - listPreviewTargets: (args?: IosSimulatorListPreviewsArgs) => Promise<IosSimulatorPreviewTarget[]>; - renderPreview: (args: IosSimulatorRenderPreviewArgs) => Promise<IosSimulatorRenderPreviewResult>; - openPreviewWorkspace: (args?: IosSimulatorOpenPreviewWorkspaceArgs) => Promise<{ ok: true; path: string }>; - startStream: (args?: IosSimulatorStartStreamArgs) => Promise<IosSimulatorStreamStatus>; + attachToChatSession: (args: { + chatSessionId: string | null; + callerChatSessionId?: string | null; + }) => Promise<IosSimulatorSession | null>; + shutdown: ( + args?: IosSimulatorShutdownArgs, + ) => Promise<IosSimulatorShutdownResult>; + screenshot: (args?: { + deviceUdid?: string | null; + }) => Promise<IosSimulatorScreenshot>; + getScreenSnapshot: ( + args?: IosScreenSnapshotArgs, + ) => Promise<IosScreenSnapshot>; + getInspectorSnapshot: (args?: { + deviceUdid?: string | null; + }) => Promise<IosInspectorSnapshot | null>; + inspectPoint: ( + args: IosSimulatorInspectPointArgs, + ) => Promise<IosSimulatorInspectResult>; + getPreviewCapability: ( + args?: IosSimulatorListPreviewsArgs, + ) => Promise<IosSimulatorPreviewCapability>; + listPreviewTargets: ( + args?: IosSimulatorListPreviewsArgs, + ) => Promise<IosSimulatorPreviewTarget[]>; + renderPreview: ( + args: IosSimulatorRenderPreviewArgs, + ) => Promise<IosSimulatorRenderPreviewResult>; + openPreviewWorkspace: ( + args?: IosSimulatorOpenPreviewWorkspaceArgs, + ) => Promise<{ ok: true; path: string }>; + startStream: ( + args?: IosSimulatorStartStreamArgs, + ) => Promise<IosSimulatorStreamStatus>; stopStream: () => Promise<IosSimulatorStreamStatus>; getStreamStatus: () => Promise<IosSimulatorStreamStatus>; getSimulatorWindowState: () => Promise<IosSimulatorWindowState>; listSimulatorWindowSources: () => Promise<IosSimulatorWindowSource[]>; - tap: (args: { deviceUdid?: string | null; projectRoot?: string | null; x: number; y: number }) => Promise<{ ok: true }>; - typeText: (args: { deviceUdid?: string | null; projectRoot?: string | null; text: string }) => Promise<{ ok: true }>; + tap: (args: { + deviceUdid?: string | null; + projectRoot?: string | null; + x: number; + y: number; + }) => Promise<{ ok: true }>; + typeText: (args: { + deviceUdid?: string | null; + projectRoot?: string | null; + text: string; + }) => Promise<{ ok: true }>; drag: (args: IosSimulatorDragArgs) => Promise<{ ok: true }>; swipe: (args: IosSimulatorDragArgs) => Promise<{ ok: true }>; - selectPoint: (args: { deviceUdid?: string | null; projectRoot?: string | null; x: number; y: number }) => Promise<IosSimulatorSelectResult>; + selectPoint: (args: { + deviceUdid?: string | null; + projectRoot?: string | null; + x: number; + y: number; + }) => Promise<IosSimulatorSelectResult>; onEvent: (cb: (ev: IosSimulatorEventPayload) => void) => () => void; }; appControl: { getStatus: () => Promise<AppControlStatus>; launch: (args?: AppControlLaunchArgs) => Promise<AppControlSession>; - launchInTerminal: (args?: AppControlLaunchArgs) => Promise<AppControlSession>; + launchInTerminal: ( + args?: AppControlLaunchArgs, + ) => Promise<AppControlSession>; connect: (args: AppControlConnectArgs) => Promise<AppControlSession>; - stop: (args?: AppControlStopArgs) => Promise<{ ok: true; previousSession: AppControlSession | null }>; + stop: ( + args?: AppControlStopArgs, + ) => Promise<{ ok: true; previousSession: AppControlSession | null }>; screenshot: () => Promise<AppControlScreenshot>; - getSnapshot: (args?: AppControlSnapshotArgs) => Promise<AppControlSnapshot>; - inspectPoint: (args: AppControlInspectPointArgs) => Promise<AppControlInspectResult>; - selectPoint: (args: AppControlInspectPointArgs) => Promise<AppControlSelectResult>; + getSnapshot: ( + args?: AppControlSnapshotArgs, + ) => Promise<AppControlSnapshot>; + inspectPoint: ( + args: AppControlInspectPointArgs, + ) => Promise<AppControlInspectResult>; + selectPoint: ( + args: AppControlInspectPointArgs, + ) => Promise<AppControlSelectResult>; click: (args: AppControlClickArgs) => Promise<{ ok: true }>; typeText: (args: AppControlTypeTextArgs) => Promise<{ ok: true }>; - scroll: (args: { x: number; y: number; deltaX: number; deltaY: number; scale?: number | null }) => Promise<{ ok: true }>; + scroll: (args: { + x: number; + y: number; + deltaX: number; + deltaY: number; + scale?: number | null; + }) => Promise<{ ok: true }>; dispatchKey: (args: { type: "keyDown" | "keyUp" | "rawKeyDown" | "char"; key?: string | null; @@ -1422,18 +1605,34 @@ declare global { modifiers?: number | null; }) => Promise<{ ok: true }>; listTargets: () => Promise<AppControlTarget[]>; - attachToTarget: (args: { targetId: string }) => Promise<AppControlSession>; + attachToTarget: (args: { + targetId: string; + }) => Promise<AppControlSession>; onEvent: (cb: (ev: AppControlEventPayload) => void) => () => void; }; builtInBrowser: { getStatus: () => Promise<BuiltInBrowserStatus>; - showPanel: (args?: BuiltInBrowserOpenPanelArgs) => Promise<BuiltInBrowserStatus>; - setBounds: (args: BuiltInBrowserBoundsArgs) => Promise<BuiltInBrowserStatus>; - attachWebview: (args: BuiltInBrowserAttachWebviewArgs) => Promise<BuiltInBrowserStatus>; - navigate: (args: BuiltInBrowserNavigateArgs) => Promise<BuiltInBrowserStatus>; - createTab: (args?: BuiltInBrowserCreateTabArgs) => Promise<BuiltInBrowserStatus>; - switchTab: (args: BuiltInBrowserTabArgs) => Promise<BuiltInBrowserStatus>; - closeTab: (args: BuiltInBrowserTabArgs) => Promise<BuiltInBrowserStatus>; + showPanel: ( + args?: BuiltInBrowserOpenPanelArgs, + ) => Promise<BuiltInBrowserStatus>; + setBounds: ( + args: BuiltInBrowserBoundsArgs, + ) => Promise<BuiltInBrowserStatus>; + attachWebview: ( + args: BuiltInBrowserAttachWebviewArgs, + ) => Promise<BuiltInBrowserStatus>; + navigate: ( + args: BuiltInBrowserNavigateArgs, + ) => Promise<BuiltInBrowserStatus>; + createTab: ( + args?: BuiltInBrowserCreateTabArgs, + ) => Promise<BuiltInBrowserStatus>; + switchTab: ( + args: BuiltInBrowserTabArgs, + ) => Promise<BuiltInBrowserStatus>; + closeTab: ( + args: BuiltInBrowserTabArgs, + ) => Promise<BuiltInBrowserStatus>; reload: () => Promise<BuiltInBrowserStatus>; goBack: () => Promise<BuiltInBrowserStatus>; goForward: () => Promise<BuiltInBrowserStatus>; @@ -1441,7 +1640,9 @@ declare global { startInspect: () => Promise<BuiltInBrowserStatus>; stopInspect: () => Promise<BuiltInBrowserStatus>; captureScreenshot: () => Promise<BuiltInBrowserScreenshot>; - selectPoint: (args: BuiltInBrowserSelectPointArgs) => Promise<BuiltInBrowserSelectResult>; + selectPoint: ( + args: BuiltInBrowserSelectPointArgs, + ) => Promise<BuiltInBrowserSelectResult>; selectCurrent: () => Promise<BuiltInBrowserSelectResult>; clearSelection: () => Promise<{ ok: true }>; onEvent: (cb: (ev: BuiltInBrowserEventPayload) => void) => () => void; @@ -1451,13 +1652,32 @@ declare global { provision: (args: MacosVmProvisionArgs) => Promise<MacosVmRecord>; start: (args: MacosVmStartArgs) => Promise<MacosVmRecord>; stop: (args: MacosVmStopArgs) => Promise<MacosVmRecord | null>; - delete: (args: MacosVmDeleteArgs) => Promise<{ deleted: boolean; previous: MacosVmRecord | null }>; - getAgentGuide: (args: MacosVmAgentGuideArgs) => Promise<MacosVmAgentGuide>; - focusWindow: (args: MacosVmFocusWindowArgs) => Promise<MacosVmWindowTarget>; - captureScreenshot: (args: MacosVmCaptureScreenshotArgs) => Promise<MacosVmCaptureScreenshotResult>; - selectPoint: (args: MacosVmSelectPointArgs) => Promise<MacosVmSelectPointResult>; - click: (args: MacosVmClickArgs) => Promise<{ ok: true; window: MacosVmWindowTarget; x: number; y: number }>; - typeText: (args: MacosVmTypeTextArgs) => Promise<{ ok: true; window: MacosVmWindowTarget }>; + delete: ( + args: MacosVmDeleteArgs, + ) => Promise<{ deleted: boolean; previous: MacosVmRecord | null }>; + getAgentGuide: ( + args: MacosVmAgentGuideArgs, + ) => Promise<MacosVmAgentGuide>; + focusWindow: ( + args: MacosVmFocusWindowArgs, + ) => Promise<MacosVmWindowTarget>; + captureScreenshot: ( + args: MacosVmCaptureScreenshotArgs, + ) => Promise<MacosVmCaptureScreenshotResult>; + selectPoint: ( + args: MacosVmSelectPointArgs, + ) => Promise<MacosVmSelectPointResult>; + click: ( + args: MacosVmClickArgs, + ) => Promise<{ + ok: true; + window: MacosVmWindowTarget; + x: number; + y: number; + }>; + typeText: ( + args: MacosVmTypeTextArgs, + ) => Promise<{ ok: true; window: MacosVmWindowTarget }>; onEvent: (cb: (ev: MacosVmEventPayload) => void) => () => void; }; terminal: { @@ -1465,7 +1685,9 @@ declare global { read: (args?: ChatTerminalReadArgs) => Promise<ChatTerminalReadResult>; write: (args: ChatTerminalWriteArgs) => Promise<{ ok: true }>; signal: (args: ChatTerminalSignalArgs) => Promise<{ ok: true }>; - activeForChat: (args: ChatTerminalActiveForChatArgs) => Promise<ChatTerminalSession | null>; + activeForChat: ( + args: ChatTerminalActiveForChatArgs, + ) => Promise<ChatTerminalSession | null>; }; pty: { create: (args: PtyCreateArgs) => Promise<PtyCreateResult>; @@ -1536,8 +1758,18 @@ declare global { getSyncStatus: (args: { laneId: string; }) => Promise<GitUpstreamSyncStatus>; - getOriginRemote: (args: { laneId: string }) => Promise<{ remoteUrl: string | null; branch: string | null }>; - getOpenPrForBranch: (args: { laneId: string; branch?: string }) => Promise<{ prUrl: string | null; prNumber: number | null; title: string | null; headRefName: string | null }>; + getOriginRemote: (args: { + laneId: string; + }) => Promise<{ remoteUrl: string | null; branch: string | null }>; + getOpenPrForBranch: (args: { + laneId: string; + branch?: string; + }) => Promise<{ + prUrl: string | null; + prNumber: number | null; + title: string | null; + headRefName: string | null; + }>; sync: (args: GitSyncArgs) => Promise<GitActionResult>; push: (args: GitPushArgs) => Promise<GitActionResult>; getConflictState: (laneId: string) => Promise<GitConflictState>; @@ -1608,8 +1840,12 @@ declare global { onEvent: (cb: (ev: ConflictEventPayload) => void) => () => void; }; feedback: { - prepareDraft: (args: FeedbackPrepareDraftArgs) => Promise<FeedbackPreparedDraft>; - submitDraft: (args: FeedbackSubmitDraftArgs) => Promise<FeedbackSubmission>; + prepareDraft: ( + args: FeedbackPrepareDraftArgs, + ) => Promise<FeedbackPreparedDraft>; + submitDraft: ( + args: FeedbackSubmitDraftArgs, + ) => Promise<FeedbackSubmission>; list: () => Promise<FeedbackSubmission[]>; onUpdate: (cb: (event: FeedbackSubmissionEvent) => void) => () => void; }; @@ -1618,10 +1854,20 @@ declare global { setToken: (token: string) => Promise<GitHubStatus>; clearToken: () => Promise<GitHubStatus>; detectRepo: () => Promise<{ owner: string; name: string } | null>; - listRepoLabels: (args: { owner: string; name: string }) => Promise<Array<{ name: string; color?: string }>>; - listRepoCollaborators: (args: { owner: string; name: string }) => Promise<Array<{ login: string; avatarUrl?: string }>>; - listMyRepos: (input?: ListMyGitHubReposInput) => Promise<ListMyGitHubReposResult>; - publishCurrentProject: (input: PublishProjectInput) => Promise<PublishProjectResult>; + listRepoLabels: (args: { + owner: string; + name: string; + }) => Promise<Array<{ name: string; color?: string }>>; + listRepoCollaborators: (args: { + owner: string; + name: string; + }) => Promise<Array<{ login: string; avatarUrl?: string }>>; + listMyRepos: ( + input?: ListMyGitHubReposInput, + ) => Promise<ListMyGitHubReposResult>; + publishCurrentProject: ( + input: PublishProjectInput, + ) => Promise<PublishProjectResult>; onStatusChanged: (cb: (status: GitHubStatus) => void) => () => void; }; prs: { @@ -1646,7 +1892,10 @@ declare global { ) => Promise<{ title: string; body: string }>; land: (args: LandPrArgs) => Promise<LandResult>; landStack: (args: LandStackArgs) => Promise<LandResult[]>; - retargetBase: (args: { prId: string; baseBranch: string }) => Promise<void>; + retargetBase: (args: { + prId: string; + baseBranch: string; + }) => Promise<void>; openInGitHub: (prId: string) => Promise<void>; createQueue: ( args: CreateQueuePrsArgs, @@ -1744,7 +1993,9 @@ declare global { updateBody: (args: UpdatePrBodyArgs) => Promise<void>; setLabels: (args: SetPrLabelsArgs) => Promise<void>; requestReviewers: (args: RequestPrReviewersArgs) => Promise<void>; - submitReview: (args: SubmitPrReviewArgs) => Promise<SubmitPrReviewResult>; + submitReview: ( + args: SubmitPrReviewArgs, + ) => Promise<SubmitPrReviewResult>; close: (args: ClosePrArgs) => Promise<void>; reopen: (args: ReopenPrArgs) => Promise<void>; rerunChecks: (args: RerunPrChecksArgs) => Promise<void>; @@ -1985,22 +2236,6 @@ declare global { args?: CtoListSessionLogsArgs, ) => Promise<CtoSessionLogEntry[]>; updateIdentity: (args: CtoUpdateIdentityArgs) => Promise<CtoSnapshot>; - getOpenclawState: () => Promise<CtoGetOpenclawStateResult>; - updateOpenclawConfig: ( - args: CtoUpdateOpenclawConfigArgs, - ) => Promise<CtoGetOpenclawStateResult>; - testOpenclawConnection: ( - args?: CtoTestOpenclawConnectionArgs, - ) => Promise<CtoTestOpenclawConnectionResult>; - listOpenclawMessages: ( - args?: CtoListOpenclawMessagesArgs, - ) => Promise<CtoListOpenclawMessagesResult>; - sendOpenclawMessage: ( - args: CtoSendOpenclawMessageArgs, - ) => Promise<CtoListOpenclawMessagesResult[number]>; - onOpenclawConnectionStatus: ( - cb: (status: OpenclawBridgeStatus) => void, - ) => () => void; listAgents: (args?: CtoListAgentsArgs) => Promise<AgentIdentity[]>; saveAgent: (args: CtoSaveAgentArgs) => Promise<AgentIdentity>; removeAgent: (args: CtoRemoveAgentArgs) => Promise<void>; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index dc5e1095c..ba4466305 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -235,4 +235,1038 @@ describe("preload OAuth bridge", () => { expect(invoke).toHaveBeenCalledWith(IPC.aiVerifyApiKey, { provider: "cursor" }); expect(invoke.mock.calls.filter(([channel]) => channel === IPC.aiGetStatus)).toHaveLength(2); }); + + it("rejects lane folder opens for remote project bindings before local lane IPC", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.lanes.openFolder({ laneId: "lane-1" })).rejects.toThrow(/remote lane folders/i); + + expect(invoke).toHaveBeenCalledWith(IPC.appGetWindowSession); + expect(invoke).not.toHaveBeenCalledWith(IPC.lanesOpenFolder, { laneId: "lane-1" }); + }); + + it("keeps lane folder opens on local project bindings routed to local lane IPC", async () => { + const binding = { + kind: "local", + key: "local:/repo", + rootPath: "/repo", + displayName: "Project", + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await bridge.lanes.openFolder({ laneId: "lane-1" }); + + expect(invoke).toHaveBeenCalledWith(IPC.appGetWindowSession); + expect(invoke).toHaveBeenCalledWith(IPC.lanesOpenFolder, { laneId: "lane-1" }); + }); + + it("routes project local-data cleanup through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const args = { packs: true, logs: true }; + const result = { + deletedPaths: ["/remote/project/.ade/artifacts", "/remote/project/.ade/transcripts/logs"], + clearedAt: "2026-05-10T12:00:00.000Z", + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + return { ok: true, domain: "ade_project", action: "clearLocalData", result, statusHints: {} }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.project.clearLocalData(args)).resolves.toEqual(result); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "ade_project", + action: "clearLocalData", + args, + }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.projectClearLocalData, args); + }); + + it("routes session deltas and artifact previews through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const delta = { sessionId: "session-1", filesChanged: 2 }; + const preview = "data:image/png;base64,AAAA"; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + const request = (payload as { request?: { domain?: string; action?: string } } | undefined)?.request; + if (request?.domain === "session" && request.action === "getDelta") { + return { ok: true, domain: "session", action: "getDelta", result: delta, statusHints: {} }; + } + if (request?.domain === "computer_use_artifacts" && request.action === "readArtifactPreview") { + return { + ok: true, + domain: "computer_use_artifacts", + action: "readArtifactPreview", + result: preview, + statusHints: {}, + }; + } + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.sessions.getDelta("session-1")).resolves.toEqual(delta); + await expect(bridge.computerUse.readArtifactPreview({ uri: ".ade/artifacts/proof.png" })).resolves.toBe(preview); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "session", + action: "getDelta", + args: { sessionId: "session-1" }, + }, + }); + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "computer_use_artifacts", + action: "readArtifactPreview", + args: { uri: ".ade/artifacts/proof.png" }, + }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.sessionsGetDelta, { sessionId: "session-1" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.computerUseReadArtifactPreview, { uri: ".ade/artifacts/proof.png" }); + }); + + it("routes Linear CTO read-model calls through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const catalog = { users: [{ id: "user-1" }], labels: [{ id: "label-1" }], states: [{ id: "state-1" }] }; + const ingressStatus = { configured: true, webhookUrl: "https://linear.example/webhook" }; + const projects = [{ id: "project-1", name: "ADE" }]; + const picker = { projects, users: catalog.users, states: catalog.states }; + const search = { issues: [{ id: "issue-1", title: "Fix routing" }], pageInfo: { hasNextPage: false, endCursor: null } }; + const connection = { tokenStored: true, connected: true, viewerId: "user-1", viewerName: "Arul", checkedAt: "2026-05-10T00:00:00.000Z", message: null }; + const quickView = { connection, organization: { id: "org-1" }, viewer: { id: "user-1" }, projects, teams: [], assignedIssues: [], recentIssues: [], fetchedAt: "2026-05-10T00:00:00.000Z", sdk: { packageName: "@linear/sdk", surfaces: [] } }; + const route = { workflowId: "workflow-1", reason: "matched" }; + const oauthStart = { sessionId: "linear-oauth-1", authUrl: "https://linear.app/oauth/authorize", redirectUri: "http://127.0.0.1:19836/oauth/callback" }; + const oauthSession = { status: "completed", connection }; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + const request = (payload as { request?: { domain?: string; action?: string } } | undefined)?.request; + if (request?.domain === "linear_issue_tracker" && request.action === "getWorkflowCatalog") { + return { ok: true, domain: request.domain, action: request.action, result: catalog, statusHints: {} }; + } + if ( + request?.domain === "linear_credentials" && + ( + request.action === "setToken" || + request.action === "clearToken" || + request.action === "setOAuthClientCredentials" || + request.action === "clearOAuthClientCredentials" + ) + ) { + return { ok: true, domain: request.domain, action: request.action, result: undefined, statusHints: {} }; + } + if (request?.domain === "linear_issue_tracker" && request.action === "getConnectionStatus") { + return { ok: true, domain: request.domain, action: request.action, result: connection, statusHints: {} }; + } + if (request?.domain === "linear_issue_tracker" && request.action === "getQuickView") { + return { ok: true, domain: request.domain, action: request.action, result: quickView, statusHints: {} }; + } + if (request?.domain === "linear_routing" && request.action === "simulateRoute") { + return { ok: true, domain: request.domain, action: request.action, result: route, statusHints: {} }; + } + if (request?.domain === "linear_oauth" && request.action === "startSession") { + return { ok: true, domain: request.domain, action: request.action, result: oauthStart, statusHints: {} }; + } + if (request?.domain === "linear_oauth" && request.action === "getSession") { + return { ok: true, domain: request.domain, action: request.action, result: oauthSession, statusHints: {} }; + } + if (request?.domain === "linear_ingress" && request.action === "ensureRelayWebhook") { + return { ok: true, domain: request.domain, action: request.action, result: undefined, statusHints: {} }; + } + if (request?.domain === "linear_ingress" && request.action === "getStatus") { + return { ok: true, domain: request.domain, action: request.action, result: ingressStatus, statusHints: {} }; + } + if (request?.domain === "linear_issue_tracker" && request.action === "listProjects") { + return { ok: true, domain: request.domain, action: request.action, result: projects, statusHints: {} }; + } + if (request?.domain === "linear_issue_tracker" && request.action === "getIssuePickerData") { + return { ok: true, domain: request.domain, action: request.action, result: picker, statusHints: {} }; + } + if (request?.domain === "linear_issue_tracker" && request.action === "searchIssues") { + return { ok: true, domain: request.domain, action: request.action, result: search, statusHints: {} }; + } + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.cto.getLinearWorkflowCatalog()).resolves.toEqual(catalog); + await expect(bridge.cto.getLinearConnectionStatus()).resolves.toEqual(connection); + await expect(bridge.cto.setLinearToken({ token: "lin-token" })).resolves.toEqual(connection); + await expect(bridge.cto.clearLinearToken()).resolves.toEqual(connection); + await expect(bridge.cto.setLinearOAuthClient({ clientId: "client-id", clientSecret: "secret" })).resolves.toEqual(connection); + await expect(bridge.cto.clearLinearOAuthClient()).resolves.toEqual(connection); + await expect(bridge.cto.getLinearQuickView()).resolves.toEqual(quickView); + await expect(bridge.cto.simulateFlowRoute({ issue: { title: "Fix routing" } })).resolves.toEqual(route); + await expect(bridge.cto.startLinearOAuth()).resolves.toEqual(oauthStart); + await expect(bridge.cto.getLinearOAuthSession({ sessionId: "linear-oauth-1" })).resolves.toEqual(oauthSession); + await expect(bridge.cto.ensureLinearWebhook({ force: true })).resolves.toEqual(ingressStatus); + await expect(bridge.cto.getLinearProjects()).resolves.toEqual(projects); + await expect(bridge.cto.getLinearIssuePickerData()).resolves.toEqual(picker); + await expect(bridge.cto.searchLinearIssues({ query: "routing" })).resolves.toEqual(search); + + const actions = invoke.mock.calls + .filter(([channel]) => channel === IPC.remoteRuntimeCallAction) + .map(([, payload]) => (payload as { request: { domain: string; action: string; args?: unknown; arg?: unknown } }).request); + expect(actions).toEqual([ + { domain: "linear_issue_tracker", action: "getWorkflowCatalog" }, + { domain: "linear_issue_tracker", action: "getConnectionStatus" }, + { domain: "linear_credentials", action: "setToken", arg: "lin-token" }, + { domain: "linear_issue_tracker", action: "getConnectionStatus" }, + { domain: "linear_credentials", action: "clearToken" }, + { domain: "linear_issue_tracker", action: "getConnectionStatus" }, + { domain: "linear_credentials", action: "setOAuthClientCredentials", args: { clientId: "client-id", clientSecret: "secret" } }, + { domain: "linear_issue_tracker", action: "getConnectionStatus" }, + { domain: "linear_credentials", action: "clearOAuthClientCredentials" }, + { domain: "linear_issue_tracker", action: "getConnectionStatus" }, + { domain: "linear_issue_tracker", action: "getQuickView" }, + { domain: "linear_routing", action: "simulateRoute", args: { issue: { title: "Fix routing" } } }, + { domain: "linear_oauth", action: "startSession" }, + { domain: "linear_oauth", action: "getSession", arg: "linear-oauth-1" }, + { domain: "linear_ingress", action: "ensureRelayWebhook", arg: true }, + { domain: "linear_ingress", action: "getStatus" }, + { domain: "linear_issue_tracker", action: "listProjects" }, + { domain: "linear_issue_tracker", action: "getIssuePickerData" }, + { domain: "linear_issue_tracker", action: "searchIssues", args: { query: "routing" } }, + ]); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoGetLinearWorkflowCatalog); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoGetLinearConnectionStatus); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoSetLinearToken, { token: "lin-token" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoClearLinearToken); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoSetLinearOAuthClient, { clientId: "client-id", clientSecret: "secret" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoClearLinearOAuthClient); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoGetLinearQuickView); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoSimulateFlowRoute, { issue: { title: "Fix routing" } }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoStartLinearOAuth); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoGetLinearOAuthSession, { sessionId: "linear-oauth-1" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoEnsureLinearWebhook, { force: true }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoGetLinearProjects); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoGetLinearIssuePickerData); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoSearchLinearIssues, { query: "routing" }); + }); + + it("routes CTO identity session and project scan calls through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const ctoSession = { id: "session-cto", identityKey: "cto" }; + const workerSession = { id: "session-worker", identityKey: "agent:worker-1" }; + const scan = { detection: null, coreMemoryPatch: { projectSummary: "Detected project setup." }, createdMemoryIds: ["mem-1"] }; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + const request = (payload as { request?: { domain?: string; action?: string } } | undefined)?.request; + if (request?.domain === "chat" && request.action === "ensureCtoSession") { + return { ok: true, domain: request.domain, action: request.action, result: ctoSession, statusHints: {} }; + } + if (request?.domain === "chat" && request.action === "ensureAgentIdentitySession") { + return { ok: true, domain: request.domain, action: request.action, result: workerSession, statusHints: {} }; + } + if (request?.domain === "cto_state" && request.action === "runProjectScan") { + return { ok: true, domain: request.domain, action: request.action, result: scan, statusHints: {} }; + } + } + return undefined; + }); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on: vi.fn(), removeListener: vi.fn() }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.cto.ensureSession({ modelId: "claude-sonnet", reasoningEffort: "high" })).resolves.toEqual(ctoSession); + await expect(bridge.cto.ensureAgentSession({ agentId: "worker-1", modelId: "gpt-5.4-mini" })).resolves.toEqual(workerSession); + await expect(bridge.cto.runProjectScan()).resolves.toEqual(scan); + + const actions = invoke.mock.calls + .filter(([channel]) => channel === IPC.remoteRuntimeCallAction) + .map(([, payload]) => (payload as { request: { domain: string; action: string; args?: unknown } }).request); + expect(actions).toEqual([ + { domain: "chat", action: "ensureCtoSession", args: { modelId: "claude-sonnet", reasoningEffort: "high" } }, + { domain: "chat", action: "ensureAgentIdentitySession", args: { agentId: "worker-1", modelId: "gpt-5.4-mini" } }, + { domain: "cto_state", action: "runProjectScan" }, + ]); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoEnsureSession, { modelId: "claude-sonnet", reasoningEffort: "high" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoEnsureAgentSession, { agentId: "worker-1", modelId: "gpt-5.4-mini" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoRunProjectScan); + }); + + it("routes history list operations through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const operation = { + id: "operation-1", + kind: "git", + status: "completed", + startedAt: "2026-05-10T12:00:00.000Z", + completedAt: "2026-05-10T12:00:01.000Z", + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + return { ok: true, domain: "operation", action: "list", result: [operation], statusHints: {} }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.history.listOperations({ limit: 10 })).resolves.toEqual([operation]); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "operation", + action: "list", + args: { limit: 10 }, + }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.historyListOperations, { limit: 10 }); + }); + + it("exports history using rows from a bound remote project runtime", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Remote Project", + }; + const operation = { + id: "operation-1", + laneId: "lane-1", + laneName: "Lane 1", + kind: "git_push", + status: "succeeded", + startedAt: "2026-05-10T12:00:00.000Z", + endedAt: "2026-05-10T12:00:01.000Z", + preHeadSha: "abc", + postHeadSha: "def", + metadataJson: "{}", + }; + const exportResult = { + cancelled: false, + savedPath: "/tmp/ade-history.json", + bytesWritten: 120, + exportedAt: "2026-05-10T12:00:02.000Z", + rowCount: 1, + format: "json", + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + return { ok: true, domain: "operation", action: "list", result: [operation], statusHints: {} }; + } + if (channel === IPC.historyExportOperations) { + return exportResult; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.history.exportOperations({ + format: "json", + status: "succeeded", + laneId: "lane-1", + limit: 25, + })).resolves.toEqual(exportResult); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "operation", + action: "list", + args: { + laneId: "lane-1", + limit: 25, + }, + }, + }); + expect(invoke).toHaveBeenCalledWith(IPC.historyExportOperations, { + format: "json", + status: "succeeded", + laneId: "lane-1", + limit: 25, + rows: [operation], + project: { + rootPath: "/remote/project", + displayName: "Remote Project", + }, + }); + }); + + it("routes Phase 3 acceptance actions through a bound remote runtime", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + const request = (payload as { request?: { domain?: string; action?: string } } | undefined)?.request; + return { + ok: true, + domain: request?.domain, + action: request?.action, + result: { ok: true }, + statusHints: {}, + }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await bridge.lanes.create({ name: "Remote lane" }); + await bridge.agentChat.create({ laneId: "lane-1", provider: "codex", model: "gpt-5.4" }); + await bridge.agentChat.send({ sessionId: "chat-1", text: "hello" }); + await bridge.agentChat.resume({ sessionId: "chat-1" }); + await bridge.git.commit({ laneId: "lane-1", message: "checkpoint" }); + await bridge.git.push({ laneId: "lane-1" }); + await bridge.prs.createFromLane({ laneId: "lane-1", title: "Remote PR", body: "Proof" }); + + const actions = invoke.mock.calls + .filter(([channel]) => channel === IPC.remoteRuntimeCallAction) + .map(([, payload]) => (payload as { request: { domain: string; action: string } }).request); + expect(actions.map((request) => `${request.domain}.${request.action}`)).toEqual([ + "lane.create", + "chat.createSession", + "chat.sendMessage", + "chat.resumeSession", + "git.commit", + "git.push", + "pr.createFromLane", + ]); + expect(invoke).not.toHaveBeenCalledWith(IPC.lanesCreate, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatCreate, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.gitCommit, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.gitPush, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.prsCreateFromLane, expect.anything()); + }); + + it("routes GitHub repo metadata through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const labels = [{ name: "bug", color: "d73a4a" }]; + const collaborators = [{ login: "octocat", avatarUrl: "https://example.test/octocat.png" }]; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + const request = (payload as { request?: { action?: string } } | undefined)?.request; + if (request?.action === "listRepoLabels") { + return { ok: true, domain: "github", action: "listRepoLabels", result: labels, statusHints: {} }; + } + if (request?.action === "listRepoCollaborators") { + return { + ok: true, + domain: "github", + action: "listRepoCollaborators", + result: collaborators, + statusHints: {}, + }; + } + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.github.listRepoLabels({ owner: "acme", name: "repo" })).resolves.toEqual(labels); + await expect(bridge.github.listRepoCollaborators({ owner: "acme", name: "repo" })).resolves.toEqual(collaborators); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "github", + action: "listRepoLabels", + args: { owner: "acme", name: "repo" }, + }, + }); + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "github", + action: "listRepoCollaborators", + args: { owner: "acme", name: "repo" }, + }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.githubListRepoLabels, { owner: "acme", name: "repo" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.githubListRepoCollaborators, { owner: "acme", name: "repo" }); + }); + + it("routes GitHub publish through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const result = { owner: "acme", name: "repo", url: "https://github.com/acme/repo" }; + const input = { name: "repo", private: true }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + return { ok: true, domain: "github", action: "publishCurrentProject", result, statusHints: {} }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.github.publishCurrentProject(input)).resolves.toEqual(result); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "github", + action: "publishCurrentProject", + args: input, + }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.githubPublishCurrentProject, input); + }); + + it("routes PTY creation through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const input = { + laneId: "lane-1", + startupCommand: "codex login", + tracked: true, + toolType: "shell", + }; + const result = { ptyId: "pty-1", sessionId: "session-1" }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + return { ok: true, domain: "pty", action: "create", result, statusHints: {} }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.pty.create(input)).resolves.toEqual(result); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "pty", + action: "create", + args: input, + }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ptyCreate, input); + }); + + it("fans out project state events from local IPC and remote runtime events", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const projectEvent = { + type: "config-changed", + at: "2026-05-10T12:00:00.000Z", + filePath: "/remote/project/.ade/ade.yaml", + snapshot: { + rootPath: "/remote/project", + adeDir: "/remote/project/.ade", + lastCheckedAt: "2026-05-10T12:00:00.000Z", + entries: [], + health: [], + cleanup: { changed: false, actions: [] }, + config: { + sharedPath: "/remote/project/.ade/ade.yaml", + localPath: "/remote/project/.ade/local.yaml", + secretPath: "/remote/project/.ade/local.secret.yaml", + trust: { + sharedHash: "shared", + localHash: "local", + approvedSharedHash: null, + requiresSharedTrust: false, + }, + }, + }, + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeStreamEvents) { + return { events: [], nextCursor: 0, hasMore: false }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await bridge.app.getWindowSession(); + + const callback = vi.fn(); + const unsubscribe = bridge.project.onStateEvent(callback); + + const projectStateListener = on.mock.calls.find(([channel]) => channel === IPC.projectStateEvent)?.[1]; + expect(typeof projectStateListener).toBe("function"); + projectStateListener({}, projectEvent); + expect(callback).toHaveBeenCalledWith(projectEvent); + + const runtimeListener = on.mock.calls.find(([channel]) => channel === IPC.runtimeEvent)?.[1]; + expect(typeof runtimeListener).toBe("function"); + runtimeListener({}, { + bindingKey: binding.key, + event: { + id: 1, + timestamp: "2026-05-10T12:00:01.000Z", + category: "runtime", + payload: { type: "project_state_event", event: projectEvent }, + }, + }); + expect(callback).toHaveBeenCalledTimes(2); + + unsubscribe(); + expect(removeListener).toHaveBeenCalledWith(IPC.projectStateEvent, projectStateListener); + + runtimeListener({}, { + bindingKey: binding.key, + event: { + id: 2, + timestamp: "2026-05-10T12:00:02.000Z", + category: "runtime", + payload: { type: "project_state_event", event: projectEvent }, + }, + }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it("fans out PR events from local IPC and remote runtime events", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const prEvent = { + type: "prs-updated", + polledAt: "2026-05-10T12:00:00.000Z", + prs: [], + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeStreamEvents) { + return { events: [], nextCursor: 0, hasMore: false }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await bridge.app.getWindowSession(); + + const callback = vi.fn(); + const unsubscribe = bridge.prs.onEvent(callback); + + const prListener = on.mock.calls.find(([channel]) => channel === IPC.prsEvent)?.[1]; + expect(typeof prListener).toBe("function"); + prListener({}, prEvent); + expect(callback).toHaveBeenCalledWith(prEvent); + + const runtimeListener = on.mock.calls.find(([channel]) => channel === IPC.runtimeEvent)?.[1]; + expect(typeof runtimeListener).toBe("function"); + runtimeListener({}, { + bindingKey: binding.key, + event: { + id: 1, + timestamp: "2026-05-10T12:00:01.000Z", + category: "runtime", + payload: { type: "pr_event", event: prEvent }, + }, + }); + expect(callback).toHaveBeenCalledTimes(2); + + unsubscribe(); + expect(removeListener).toHaveBeenCalledWith(IPC.prsEvent, prListener); + + runtimeListener({}, { + bindingKey: binding.key, + event: { + id: 2, + timestamp: "2026-05-10T12:00:02.000Z", + category: "runtime", + payload: { type: "pr_event", event: prEvent }, + }, + }); + expect(callback).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index e0bedaedc..d79ccadf8 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -16,6 +16,7 @@ import type { AdoptAttachedLaneArgs, UnregisteredLaneCandidate, AppInfo, + AppNavigationRequest, AutoUpdateSnapshot, ClearLocalAdeDataArgs, ClearLocalAdeDataResult, @@ -37,10 +38,15 @@ import type { AutomationSimulateRequest, AutomationSimulateResult, ReviewEventPayload, + ReviewFeedbackRecord, ReviewLaunchContext, ReviewListRunsArgs, + ReviewListSuppressionsArgs, + ReviewQualityReport, + ReviewRecordFeedbackArgs, ReviewRun, ReviewRunDetail, + ReviewSuppression, ReviewStartRunArgs, AdeActionRegistryEntry, AdeCliInstallResult, @@ -118,13 +124,6 @@ import type { CtoGetLinearOAuthSessionArgs, CtoGetLinearOAuthSessionResult, CtoRunProjectScanResult, - CtoGetOpenclawStateResult, - CtoUpdateOpenclawConfigArgs, - CtoTestOpenclawConnectionArgs, - CtoTestOpenclawConnectionResult, - CtoListOpenclawMessagesArgs, - CtoListOpenclawMessagesResult, - CtoSendOpenclawMessageArgs, LinearConnectionStatus, CtoSetLinearOAuthClientArgs, LinearIngressEventRecord, @@ -145,7 +144,6 @@ import type { CtoEnsureLinearWebhookArgs, CtoListLinearIngressEventsArgs, LinearWorkflowConfig, - OpenclawBridgeStatus, AddMissionArtifactArgs, AddMissionInterventionArgs, AutomationsEventPayload, @@ -348,6 +346,7 @@ import type { ProjectConfigTrust, ProjectConfigValidationResult, ProjectInfo, + OpenProjectBinding, CreateProjectInput, CreateProjectResult, CloneProjectInput, @@ -704,6 +703,19 @@ import type { MacosVmStopArgs, MacosVmTypeTextArgs, MacosVmWindowTarget, + RemoteRuntimeActionRequest, + RemoteRuntimeActionResult, + RemoteRuntimeBufferedEvent, + RemoteRuntimeConnectionSnapshot, + RemoteRuntimeConnectResult, + RemoteRuntimeDiscoveredMachine, + RemoteRuntimeEventNotificationPayload, + RemoteRuntimeLocalWorkCheckResult, + RemoteRuntimeProjectRecord, + RemoteRuntimeStreamEventsRequest, + RemoteRuntimeStreamEventsResult, + RemoteRuntimeTarget, + RemoteRuntimeTargetInput, ChatTerminalActiveForChatArgs, ChatTerminalListArgs, ChatTerminalReadArgs, @@ -723,7 +735,10 @@ type ShortIpcCache<T> = { get: (opts?: { force?: boolean }) => Promise<T>; }; -function createShortIpcCache<T>(loader: () => Promise<T>, ttlMs: number): ShortIpcCache<T> { +function createShortIpcCache<T>( + loader: () => Promise<T>, + ttlMs: number, +): ShortIpcCache<T> { let value: T | undefined; let promise: Promise<T> | null = null; let expiresAt = 0; @@ -841,39 +856,54 @@ const aiStatusCache = (() => { }; const get = async (key: string): Promise<AiSettingsStatus> => { - const args = parseIpcCacheArgs<{ refreshOpenCodeInventory?: boolean }>(key, {}); + const args = parseIpcCacheArgs<{ refreshOpenCodeInventory?: boolean }>( + key, + {}, + ); const wantsOpenCodeInventory = args.refreshOpenCodeInventory === true; const now = Date.now(); if ( - value !== undefined - && expiresAt > now - && (!wantsOpenCodeInventory || includesOpenCodeInventory) + value !== undefined && + expiresAt > now && + (!wantsOpenCodeInventory || includesOpenCodeInventory) ) { return value; } if ( - promise - && (!wantsOpenCodeInventory || promiseIncludesOpenCodeInventory) + promise && + (!wantsOpenCodeInventory || promiseIncludesOpenCodeInventory) ) { return promise; } promiseIncludesOpenCodeInventory = wantsOpenCodeInventory; - const request = ipcRenderer.invoke(IPC.aiGetStatus, { - refreshOpenCodeInventory: wantsOpenCodeInventory, - }).then((status: AiSettingsStatus) => { - if (promise === request) { - value = status; - expiresAt = Date.now() + 10_000; - includesOpenCodeInventory = wantsOpenCodeInventory; - } - return status; - }).finally(() => { - if (promise === request) { - promise = null; - promiseIncludesOpenCodeInventory = false; - } - }); + const request = callProjectRuntimeActionOr( + "ai", + "getStatus", + { + args: { + refreshOpenCodeInventory: wantsOpenCodeInventory, + }, + }, + () => + ipcRenderer.invoke(IPC.aiGetStatus, { + refreshOpenCodeInventory: wantsOpenCodeInventory, + }), + ) + .then((status: AiSettingsStatus) => { + if (promise === request) { + value = status; + expiresAt = Date.now() + 10_000; + includesOpenCodeInventory = wantsOpenCodeInventory; + } + return status; + }) + .finally(() => { + if (promise === request) { + promise = null; + promiseIncludesOpenCodeInventory = false; + } + }); promise = request; return request; }; @@ -887,37 +917,61 @@ const githubStatusCache = createShortIpcCache<GitHubStatus>( ); const lanesListCache = createKeyedShortIpcCache<LaneSummary[]>( - (key) => ipcRenderer.invoke(IPC.lanesList, parseIpcCacheArgs<ListLanesArgs>(key, {})), + (key) => + ipcRenderer.invoke( + IPC.lanesList, + parseIpcCacheArgs<ListLanesArgs>(key, {}), + ), 2_000, ); const lanesListSnapshotsCache = createKeyedShortIpcCache<LaneListSnapshot[]>( - (key) => ipcRenderer.invoke(IPC.lanesListSnapshots, parseIpcCacheArgs<ListLanesArgs>(key, {})), + (key) => + ipcRenderer.invoke( + IPC.lanesListSnapshots, + parseIpcCacheArgs<ListLanesArgs>(key, {}), + ), 2_000, ); const sessionDeltaCache = createKeyedShortIpcCache<SessionDeltaSummary | null>( - (sessionId) => ipcRenderer.invoke(IPC.sessionsGetDelta, { sessionId }), + (sessionId) => + callProjectRuntimeActionOr( + "session", + "getDelta", + { args: { sessionId } }, + () => ipcRenderer.invoke(IPC.sessionsGetDelta, { sessionId }), + ), 1_000, ); -const agentChatSummaryCache = createKeyedShortIpcCache<AgentChatSessionSummary | null>( - (sessionId) => ipcRenderer.invoke(IPC.agentChatGetSummary, { sessionId }), - 1_000, -); +const agentChatSummaryCache = + createKeyedShortIpcCache<AgentChatSessionSummary | null>( + (sessionId) => ipcRenderer.invoke(IPC.agentChatGetSummary, { sessionId }), + 1_000, + ); const iosSimulatorStatusCache = createShortIpcCache<IosSimulatorStatus>( - () => ipcRenderer.invoke(IPC.iosSimulatorGetStatus), + () => + callProjectRuntimeActionOr("ios_simulator", "getStatus", {}, () => + ipcRenderer.invoke(IPC.iosSimulatorGetStatus), + ), 2_000, ); const iosSimulatorDevicesCache = createShortIpcCache<IosSimulatorDevice[]>( - () => ipcRenderer.invoke(IPC.iosSimulatorListDevices), + () => + callProjectRuntimeActionOr("ios_simulator", "listDevices", {}, () => + ipcRenderer.invoke(IPC.iosSimulatorListDevices), + ), 2_000, ); const appControlStatusCache = createShortIpcCache<AppControlStatus>( - () => ipcRenderer.invoke(IPC.appControlGetStatus), + () => + callProjectRuntimeActionOr("app_control", "getStatus", {}, () => + ipcRenderer.invoke(IPC.appControlGetStatus), + ), 1_000, ); @@ -926,18 +980,26 @@ const builtInBrowserStatusCache = createShortIpcCache<BuiltInBrowserStatus>( 500, ); -const macosVmStatusCache = createKeyedShortIpcCache<MacosVmStatus>( - (key) => ipcRenderer.invoke(IPC.macosVmGetStatus, parseIpcCacheArgs<MacosVmStatusArgs>(key, {})), - 750, -); +const macosVmStatusCache = createKeyedShortIpcCache<MacosVmStatus>((key) => { + const args = parseIpcCacheArgs<MacosVmStatusArgs>(key, {}); + return callProjectRuntimeActionOr("macos_vm", "getStatus", { args }, () => + ipcRenderer.invoke(IPC.macosVmGetStatus, args), + ); +}, 750); -const computerUseOwnerSnapshotCache = createKeyedShortIpcCache<ComputerUseOwnerSnapshot>( - (key) => ipcRenderer.invoke( - IPC.computerUseGetOwnerSnapshot, - parseIpcCacheArgs<ComputerUseOwnerSnapshotArgs>(key, {} as ComputerUseOwnerSnapshotArgs), - ), - 2_000, -); +const computerUseOwnerSnapshotCache = + createKeyedShortIpcCache<ComputerUseOwnerSnapshot>((key) => { + const args = parseIpcCacheArgs<ComputerUseOwnerSnapshotArgs>( + key, + {} as ComputerUseOwnerSnapshotArgs, + ); + return callProjectRuntimeActionOr( + "computer_use_artifacts", + "getOwnerSnapshot", + { args }, + () => ipcRenderer.invoke(IPC.computerUseGetOwnerSnapshot, args), + ); + }, 2_000); const imageDataUrlCache = createKeyedShortIpcCache<{ dataUrl: string }>( (path) => ipcRenderer.invoke(IPC.appGetImageDataUrl, { path }), @@ -950,15 +1012,1211 @@ const projectIconCache = createKeyedShortIpcCache<ProjectIcon>( ); const diffChangesCache = createKeyedShortIpcCache<DiffChanges>( - (key) => ipcRenderer.invoke(IPC.diffGetChanges, parseIpcCacheArgs<GetDiffChangesArgs>(key, {} as GetDiffChangesArgs)), + (key) => + ipcRenderer.invoke( + IPC.diffGetChanges, + parseIpcCacheArgs<GetDiffChangesArgs>(key, {} as GetDiffChangesArgs), + ), 2_000, ); const gitBranchesCache = createKeyedShortIpcCache<GitBranchSummary[]>( - (key) => ipcRenderer.invoke(IPC.gitListBranches, parseIpcCacheArgs<GitListBranchesArgs>(key, {} as GitListBranchesArgs)), + (key) => + ipcRenderer.invoke( + IPC.gitListBranches, + parseIpcCacheArgs<GitListBranchesArgs>(key, {} as GitListBranchesArgs), + ), 2_000, ); +const allowLocalRuntimeFallback = + process.env.ADE_LOCAL_RUNTIME_FALLBACK !== "0" && + ( + process.env.ADE_LOCAL_RUNTIME_FALLBACK === "1" || + process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON === "1" || + process.env.ADE_PACKAGE_CHANNEL === "alpha" + ); + +function isSafeLocalRuntimeFallbackError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + /\b(ECONNREFUSED|ECONNRESET|EPIPE|ENOENT|ETIMEDOUT)\b/i.test(message) || + /Local runtime daemon is not available/i.test(message) || + /ADE service connection (?:closed|failed)/i.test(message) || + /Timed out connecting to ADE service socket/i.test(message) || + /Unsupported database value/i.test(message) || + /UNIQUE constraint failed: process_definitions\.id/i.test(message) || + /no such function: crsql_internal_sync_bit/i.test(message) || + /database is not open/i.test(message) + ); +} + +let currentProjectBinding: OpenProjectBinding | null = null; +let projectBindingGeneration = 0; + +function rememberProjectBinding(binding: OpenProjectBinding | null): void { + const previousKey = currentProjectBinding?.key ?? null; + const nextKey = binding?.key ?? null; + currentProjectBinding = binding; + if (previousKey !== nextKey) { + projectBindingGeneration += 1; + resetRemoteRuntimeEventDedup(nextKey); + } + if (binding?.kind === "remote" || binding?.kind === "local") { + ensureRemoteRuntimeEventPump(); + } +} + +async function getRemoteProjectBinding(): Promise<Extract< + OpenProjectBinding, + { kind: "remote" } +> | null> { + if (currentProjectBinding) { + return currentProjectBinding.kind === "remote" + ? currentProjectBinding + : null; + } + const session = (await ipcRenderer.invoke(IPC.appGetWindowSession)) as { + binding?: OpenProjectBinding | null; + } | null; + rememberProjectBinding(session?.binding ?? null); + return session?.binding?.kind === "remote" ? session.binding : null; +} + +async function getLocalProjectBinding(): Promise<Extract< + OpenProjectBinding, + { kind: "local" } +> | null> { + if (currentProjectBinding) { + return currentProjectBinding.kind === "local" + ? currentProjectBinding + : null; + } + const session = (await ipcRenderer.invoke(IPC.appGetWindowSession)) as { + binding?: OpenProjectBinding | null; + } | null; + rememberProjectBinding(session?.binding ?? null); + return session?.binding?.kind === "local" ? session.binding : null; +} + +async function getProjectRuntimeBinding(): Promise<OpenProjectBinding | null> { + if (currentProjectBinding) return currentProjectBinding; + const session = (await ipcRenderer.invoke(IPC.appGetWindowSession)) as { + binding?: OpenProjectBinding | null; + } | null; + rememberProjectBinding(session?.binding ?? null); + return session?.binding ?? null; +} + +async function callRemoteProjectActionIfBound<T>( + domain: string, + action: string, + request: Omit<RemoteRuntimeActionRequest, "domain" | "action"> = {}, +): Promise<{ handled: true; result: T } | { handled: false }> { + const binding = await getRemoteProjectBinding(); + if (!binding) return { handled: false }; + const response = (await ipcRenderer.invoke(IPC.remoteRuntimeCallAction, { + id: binding.targetId, + projectId: binding.projectId, + request: { domain, action, ...request }, + })) as RemoteRuntimeActionResult; + return { handled: true, result: response.result as T }; +} + +async function callLocalProjectActionIfBound<T>( + domain: string, + action: string, + request: Omit<RemoteRuntimeActionRequest, "domain" | "action"> = {}, +): Promise<{ handled: true; result: T } | { handled: false }> { + const binding = await getLocalProjectBinding(); + if (!binding) return { handled: false }; + try { + const response = (await ipcRenderer.invoke(IPC.localRuntimeCallAction, { + request: { domain, action, ...request }, + })) as RemoteRuntimeActionResult; + return { handled: true, result: response.result as T }; + } catch (error) { + if (!allowLocalRuntimeFallback || !isSafeLocalRuntimeFallbackError(error)) { + throw error; + } + console.warn( + "Local ADE service action failed; using in-process fallback.", + error, + ); + return { handled: false }; + } +} + +async function callProjectRuntimeActionIfBound<T>( + domain: string, + action: string, + request: Omit<RemoteRuntimeActionRequest, "domain" | "action"> = {}, +): Promise<{ handled: true; result: T } | { handled: false }> { + const remote = await callRemoteProjectActionIfBound<T>( + domain, + action, + request, + ); + if (remote.handled) return remote; + return callLocalProjectActionIfBound<T>(domain, action, request); +} + +async function callProjectRuntimeActionOr<T>( + domain: string, + action: string, + request: Omit<RemoteRuntimeActionRequest, "domain" | "action">, + local: () => Promise<T>, +): Promise<T> { + const runtime = await callProjectRuntimeActionIfBound<T>( + domain, + action, + request, + ); + return runtime.handled ? runtime.result : local(); +} + +async function callRemoteProjectSyncIfBound<T>( + method: string, + params: Record<string, unknown> = {}, +): Promise<{ handled: true; result: T } | { handled: false }> { + const binding = await getRemoteProjectBinding(); + if (!binding) return { handled: false }; + const result = (await ipcRenderer.invoke(IPC.remoteRuntimeCallSync, { + id: binding.targetId, + projectId: binding.projectId, + method, + params, + })) as T; + return { handled: true, result }; +} + +async function callLocalProjectSyncIfBound<T>( + method: string, + params: Record<string, unknown> = {}, +): Promise<{ handled: true; result: T } | { handled: false }> { + const binding = await getLocalProjectBinding(); + if (!binding) return { handled: false }; + try { + const result = (await ipcRenderer.invoke(IPC.localRuntimeCallSync, { + method, + params, + })) as T; + return { handled: true, result }; + } catch (error) { + if (!allowLocalRuntimeFallback || !isSafeLocalRuntimeFallbackError(error)) { + throw error; + } + console.warn( + "Local ADE service sync call failed; using in-process fallback.", + error, + ); + return { handled: false }; + } +} + +async function callProjectRuntimeSyncOr<T>( + method: string, + params: Record<string, unknown>, + local: () => Promise<T>, +): Promise<T> { + const remote = await callRemoteProjectSyncIfBound<T>(method, params); + if (remote.handled) return remote.result; + const localRuntime = await callLocalProjectSyncIfBound<T>(method, params); + return localRuntime.handled ? localRuntime.result : local(); +} + +const remoteAgentChatEventCallbacks = new Set< + (payload: AgentChatEventEnvelope) => void +>(); +const remoteSessionChangedCallbacks = new Set< + (payload: TerminalSessionChangedEvent) => void +>(); +const remoteLaneDeleteEventCallbacks = new Set< + (payload: LaneDeleteEvent) => void +>(); +const remoteLaneRebaseEventCallbacks = new Set< + (payload: RebaseRunEventPayload) => void +>(); +const remoteLaneRebaseSuggestionsEventCallbacks = new Set< + (payload: RebaseSuggestionsEventPayload) => void +>(); +const remoteLaneAutoRebaseEventCallbacks = new Set< + (payload: AutoRebaseEventPayload) => void +>(); +const remoteLaneEnvEventCallbacks = new Set< + (payload: LaneEnvInitEvent) => void +>(); +const remoteLanePortEventCallbacks = new Set< + (payload: PortAllocationEvent) => void +>(); +const remoteLaneProxyEventCallbacks = new Set< + (payload: LaneProxyEvent) => void +>(); +const remoteLaneOAuthEventCallbacks = new Set< + (payload: OAuthRedirectEvent) => void +>(); +const remoteLaneDiagnosticsEventCallbacks = new Set< + (payload: RuntimeDiagnosticsEvent) => void +>(); +const remotePtyDataEventCallbacks = new Set<(payload: PtyDataEvent) => void>(); +const remotePtyExitEventCallbacks = new Set<(payload: PtyExitEvent) => void>(); +const remoteProcessEventCallbacks = new Set<(payload: ProcessEvent) => void>(); +const remoteTestEventCallbacks = new Set<(payload: TestEvent) => void>(); +const remoteFileChangeEventCallbacks = new Set< + (payload: FileChangeEvent) => void +>(); +const remotePrEventCallbacks = new Set<(payload: PrEventPayload) => void>(); +const remotePrAiResolutionEventCallbacks = new Set< + (payload: PrAiResolutionEventPayload) => void +>(); +const remoteProjectStateEventCallbacks = new Set< + (payload: AdeProjectEvent) => void +>(); +const remoteMissionEventCallbacks = new Set< + (payload: MissionsEventPayload) => void +>(); +const remoteOrchestratorEventCallbacks = new Set< + (payload: OrchestratorRuntimeEvent) => void +>(); +const remoteOrchestratorThreadEventCallbacks = new Set< + (payload: OrchestratorThreadEvent) => void +>(); +const remoteDagMutationEventCallbacks = new Set< + (payload: DagMutationEvent) => void +>(); +const remoteSyncStatusEventCallbacks = new Set< + (payload: SyncStatusEventPayload) => void +>(); +const remoteReviewEventCallbacks = new Set< + (payload: ReviewEventPayload) => void +>(); +let remoteRuntimeEventTimer: ReturnType<typeof setTimeout> | null = null; +let remoteRuntimeEventInFlight = false; +let remoteRuntimeEventCursor = 0; +let remoteRuntimeEventBindingKey: string | null = null; +let remoteRuntimeEventGeneration = -1; +let remoteRuntimeEventStartedAtMs = 0; +let remoteRuntimeSeenEventBindingKey: string | null = null; +const remoteRuntimeSeenEventIds = new Set<number>(); + +function resetRemoteRuntimeEventDedup(bindingKey: string | null): void { + remoteRuntimeSeenEventBindingKey = bindingKey; + remoteRuntimeSeenEventIds.clear(); +} + +function shouldDispatchRemoteRuntimeEvent( + bindingKey: string, + event: RemoteRuntimeBufferedEvent, +): boolean { + if (remoteRuntimeSeenEventBindingKey !== bindingKey) { + resetRemoteRuntimeEventDedup(bindingKey); + } + if (remoteRuntimeSeenEventIds.has(event.id)) return false; + remoteRuntimeSeenEventIds.add(event.id); + while (remoteRuntimeSeenEventIds.size > 1_000) { + const oldest = remoteRuntimeSeenEventIds.values().next().value; + if (typeof oldest !== "number") break; + remoteRuntimeSeenEventIds.delete(oldest); + } + remoteRuntimeEventCursor = Math.max(remoteRuntimeEventCursor, event.id); + return true; +} + +function hasRemoteRuntimeEventSubscribers(): boolean { + return ( + remoteAgentChatEventCallbacks.size > 0 || + remoteMissionEventCallbacks.size > 0 || + remoteOrchestratorEventCallbacks.size > 0 || + remoteOrchestratorThreadEventCallbacks.size > 0 || + remoteDagMutationEventCallbacks.size > 0 || + remoteSyncStatusEventCallbacks.size > 0 || + remoteReviewEventCallbacks.size > 0 || + remoteSessionChangedCallbacks.size > 0 || + remoteLaneDeleteEventCallbacks.size > 0 || + remoteLaneRebaseEventCallbacks.size > 0 || + remoteLaneRebaseSuggestionsEventCallbacks.size > 0 || + remoteLaneAutoRebaseEventCallbacks.size > 0 || + remoteLaneEnvEventCallbacks.size > 0 || + remoteLanePortEventCallbacks.size > 0 || + remoteLaneProxyEventCallbacks.size > 0 || + remoteLaneOAuthEventCallbacks.size > 0 || + remoteLaneDiagnosticsEventCallbacks.size > 0 || + remotePtyDataEventCallbacks.size > 0 || + remotePtyExitEventCallbacks.size > 0 || + remoteProcessEventCallbacks.size > 0 || + remoteTestEventCallbacks.size > 0 || + remoteFileChangeEventCallbacks.size > 0 || + remotePrEventCallbacks.size > 0 || + remoteProjectStateEventCallbacks.size > 0 || + remotePrAiResolutionEventCallbacks.size > 0 + ); +} + +function ensureRemoteRuntimeEventPump(): void { + if (!hasRemoteRuntimeEventSubscribers()) return; + if (remoteRuntimeEventTimer || remoteRuntimeEventInFlight) return; + remoteRuntimeEventTimer = setTimeout(() => { + remoteRuntimeEventTimer = null; + void pollRemoteRuntimeEvents(); + }, 0); +} + +function scheduleRemoteRuntimeEventPoll(delayMs: number): void { + if (!hasRemoteRuntimeEventSubscribers()) return; + if (remoteRuntimeEventTimer || remoteRuntimeEventInFlight) return; + remoteRuntimeEventTimer = setTimeout(() => { + remoteRuntimeEventTimer = null; + void pollRemoteRuntimeEvents(); + }, delayMs); +} + +async function pollRemoteRuntimeEvents(): Promise<void> { + if (remoteRuntimeEventInFlight || !hasRemoteRuntimeEventSubscribers()) return; + remoteRuntimeEventInFlight = true; + let nextDelayMs: number | null = null; + try { + const binding = await getProjectRuntimeBinding(); + if (!binding || (binding.kind !== "remote" && binding.kind !== "local")) { + remoteRuntimeEventCursor = 0; + remoteRuntimeEventBindingKey = null; + remoteRuntimeEventGeneration = projectBindingGeneration; + remoteRuntimeEventStartedAtMs = 0; + resetRemoteRuntimeEventDedup(null); + return; + } + + if ( + remoteRuntimeEventBindingKey !== binding.key || + remoteRuntimeEventGeneration !== projectBindingGeneration + ) { + remoteRuntimeEventCursor = 0; + remoteRuntimeEventBindingKey = binding.key; + remoteRuntimeEventGeneration = projectBindingGeneration; + remoteRuntimeEventStartedAtMs = Date.now(); + resetRemoteRuntimeEventDedup(binding.key); + } + + const request = { + cursor: remoteRuntimeEventCursor, + limit: 100, + category: "runtime", + } satisfies RemoteRuntimeStreamEventsRequest; + const batch = + binding.kind === "remote" + ? ((await ipcRenderer.invoke(IPC.remoteRuntimeStreamEvents, { + id: binding.targetId, + projectId: binding.projectId, + request, + })) as RemoteRuntimeStreamEventsResult) + : ((await ipcRenderer.invoke(IPC.localRuntimeStreamEvents, { + request, + })) as RemoteRuntimeStreamEventsResult); + + remoteRuntimeEventCursor = Number.isFinite(batch.nextCursor) + ? Math.max(0, Math.floor(batch.nextCursor)) + : remoteRuntimeEventCursor; + + for (const event of batch.events) { + const eventTime = Date.parse(event.timestamp); + if ( + remoteRuntimeEventStartedAtMs > 0 && + Number.isFinite(eventTime) && + eventTime < remoteRuntimeEventStartedAtMs - 1_000 + ) { + continue; + } + if (!shouldDispatchRemoteRuntimeEvent(binding.key, event)) continue; + dispatchRemoteRuntimeEventPayload(event.payload); + } + nextDelayMs = batch.hasMore ? 50 : 750; + } catch (error) { + console.warn("Remote ADE service event polling failed", error); + nextDelayMs = 2_000; + } finally { + remoteRuntimeEventInFlight = false; + if ( + nextDelayMs != null && + hasRemoteRuntimeEventSubscribers() && + (currentProjectBinding?.kind === "remote" || + currentProjectBinding?.kind === "local") && + !remoteRuntimeEventTimer + ) { + scheduleRemoteRuntimeEventPoll(nextDelayMs); + } + } +} + +function handleRemoteRuntimeEventNotification(value: unknown): void { + const payload = toRemoteRuntimeEventNotificationPayload(value); + const binding = currentProjectBinding; + if (!payload || !binding || payload.bindingKey !== binding.key) return; + const eventTime = Date.parse(payload.event.timestamp); + if ( + remoteRuntimeEventStartedAtMs > 0 && + Number.isFinite(eventTime) && + eventTime < remoteRuntimeEventStartedAtMs - 1_000 + ) { + return; + } + if (!shouldDispatchRemoteRuntimeEvent(payload.bindingKey, payload.event)) + return; + dispatchRemoteRuntimeEventPayload(payload.event.payload); +} + +function toRemoteRuntimeEventNotificationPayload( + value: unknown, +): RemoteRuntimeEventNotificationPayload | null { + if (!isRecord(value)) return null; + const bindingKey = + typeof value.bindingKey === "string" ? value.bindingKey : ""; + const event = toRemoteRuntimeBufferedEvent(value.event); + if (!bindingKey || !event) return null; + return { bindingKey, event }; +} + +function toRemoteRuntimeBufferedEvent( + value: unknown, +): RemoteRuntimeBufferedEvent | null { + if (!isRecord(value)) return null; + if (typeof value.id !== "number" || !Number.isFinite(value.id)) return null; + if (typeof value.timestamp !== "string") return null; + const category = value.category; + if ( + category !== "orchestrator" && + category !== "dag_mutation" && + category !== "runtime" && + category !== "mission" + ) { + return null; + } + const payload = isRecord(value.payload) ? value.payload : {}; + return { + id: Math.max(0, Math.floor(value.id)), + timestamp: value.timestamp, + category, + payload, + }; +} + +ipcRenderer.on(IPC.runtimeEvent, (_event, payload: unknown) => { + handleRemoteRuntimeEventNotification(payload); +}); + +function dispatchRemoteRuntimeEventPayload( + payload: Record<string, unknown>, +): void { + if (payload.type === "missions-updated") { + for (const cb of [...remoteMissionEventCallbacks]) { + try { + cb(payload as MissionsEventPayload); + } catch (error) { + console.error("preload remote mission listener failed", error); + } + } + } + + if (payload.type === "sync-status" && isRecord(payload.snapshot)) { + for (const cb of [...remoteSyncStatusEventCallbacks]) { + try { + cb(payload as SyncStatusEventPayload); + } catch (error) { + console.error("preload remote sync listener failed", error); + } + } + } + + const reviewEvent = toWrappedEvent<ReviewEventPayload>( + payload, + "review_event", + ); + if (reviewEvent) { + for (const cb of [...remoteReviewEventCallbacks]) { + try { + cb(reviewEvent); + } catch (error) { + console.error("preload remote review listener failed", error); + } + } + } + + if ( + payload.type === "orchestrator-run-updated" || + payload.type === "orchestrator-step-updated" || + payload.type === "orchestrator-attempt-updated" || + payload.type === "orchestrator-claim-updated" + ) { + for (const cb of [...remoteOrchestratorEventCallbacks]) { + try { + cb(payload as OrchestratorRuntimeEvent); + } catch (error) { + console.error("preload remote orchestrator listener failed", error); + } + } + } + + if ( + payload.type === "thread_updated" || + payload.type === "message_appended" || + payload.type === "message_updated" || + payload.type === "metrics_updated" || + payload.type === "worker_digest_updated" || + payload.type === "worker_replay" + ) { + for (const cb of [...remoteOrchestratorThreadEventCallbacks]) { + try { + cb(payload as OrchestratorThreadEvent); + } catch (error) { + console.error( + "preload remote orchestrator thread listener failed", + error, + ); + } + } + } + + if ( + typeof payload.runId === "string" && + isRecord(payload.mutation) && + typeof payload.timestamp === "string" + ) { + for (const cb of [...remoteDagMutationEventCallbacks]) { + try { + cb(payload as DagMutationEvent); + } catch (error) { + console.error("preload remote DAG mutation listener failed", error); + } + } + } + + const chatEvent = toAgentChatEventEnvelope(payload); + if (chatEvent) { + agentChatSummaryCache.clear(); + for (const cb of [...remoteAgentChatEventCallbacks]) { + try { + cb(chatEvent); + } catch (error) { + console.error("preload remote agent chat listener failed", error); + } + } + } + + const sessionChanged = toTerminalSessionChangedEvent(payload); + if (sessionChanged) { + sessionDeltaCache.clear(); + for (const cb of [...remoteSessionChangedCallbacks]) { + try { + cb(sessionChanged); + } catch (error) { + console.error("preload remote session listener failed", error); + } + } + } + + const laneDeleteEvent = toWrappedEvent<LaneDeleteEvent>( + payload, + "lane_delete_event", + ); + if (laneDeleteEvent) { + clearGitReadCaches(); + for (const cb of [...remoteLaneDeleteEventCallbacks]) { + try { + cb(laneDeleteEvent); + } catch (error) { + console.error("preload remote lane delete listener failed", error); + } + } + } + + const laneRebaseEvent = toWrappedEvent<RebaseRunEventPayload>( + payload, + "lane_rebase_event", + ); + if (laneRebaseEvent) { + clearGitReadCaches(); + for (const cb of [...remoteLaneRebaseEventCallbacks]) { + try { + cb(laneRebaseEvent); + } catch (error) { + console.error("preload remote lane rebase listener failed", error); + } + } + } + + const rebaseSuggestionsEvent = toWrappedEvent<RebaseSuggestionsEventPayload>( + payload, + "lane_rebase_suggestions_event", + ); + if (rebaseSuggestionsEvent) { + for (const cb of [...remoteLaneRebaseSuggestionsEventCallbacks]) { + try { + cb(rebaseSuggestionsEvent); + } catch (error) { + console.error( + "preload remote rebase suggestions listener failed", + error, + ); + } + } + } + + const autoRebaseEvent = toWrappedEvent<AutoRebaseEventPayload>( + payload, + "lane_auto_rebase_event", + ); + if (autoRebaseEvent) { + for (const cb of [...remoteLaneAutoRebaseEventCallbacks]) { + try { + cb(autoRebaseEvent); + } catch (error) { + console.error("preload remote auto rebase listener failed", error); + } + } + } + + const envEvent = toWrappedEvent<LaneEnvInitEvent>(payload, "lane_env_event"); + if (envEvent) { + for (const cb of [...remoteLaneEnvEventCallbacks]) { + try { + cb(envEvent); + } catch (error) { + console.error("preload remote lane env listener failed", error); + } + } + } + + const portEvent = toWrappedEvent<PortAllocationEvent>( + payload, + "lane_port_event", + ); + if (portEvent) { + for (const cb of [...remoteLanePortEventCallbacks]) { + try { + cb(portEvent); + } catch (error) { + console.error("preload remote lane port listener failed", error); + } + } + } + + const proxyEvent = toWrappedEvent<LaneProxyEvent>( + payload, + "lane_proxy_event", + ); + if (proxyEvent) { + for (const cb of [...remoteLaneProxyEventCallbacks]) { + try { + cb(proxyEvent); + } catch (error) { + console.error("preload remote lane proxy listener failed", error); + } + } + } + + const oauthEvent = toWrappedEvent<OAuthRedirectEvent>( + payload, + "lane_oauth_event", + ); + if (oauthEvent) { + for (const cb of [...remoteLaneOAuthEventCallbacks]) { + try { + cb(oauthEvent); + } catch (error) { + console.error("preload remote lane OAuth listener failed", error); + } + } + } + + const diagnosticsEvent = toWrappedEvent<RuntimeDiagnosticsEvent>( + payload, + "lane_diagnostics_event", + ); + if (diagnosticsEvent) { + for (const cb of [...remoteLaneDiagnosticsEventCallbacks]) { + try { + cb(diagnosticsEvent); + } catch (error) { + console.error("preload remote lane diagnostics listener failed", error); + } + } + } + + if (isRecord(payload) && payload.type === "lane_head_changed") { + clearGitReadCaches(); + } + + const ptyDataEvent = toWrappedEvent<PtyDataEvent>(payload, "pty_data"); + if (ptyDataEvent) { + for (const cb of [...remotePtyDataEventCallbacks]) { + try { + cb(ptyDataEvent); + } catch (error) { + console.error("preload remote pty data listener failed", error); + } + } + } + + const ptyExitEvent = toWrappedEvent<PtyExitEvent>(payload, "pty_exit"); + if (ptyExitEvent) { + for (const cb of [...remotePtyExitEventCallbacks]) { + try { + cb(ptyExitEvent); + } catch (error) { + console.error("preload remote pty exit listener failed", error); + } + } + } + + const processEvent = toProcessEvent(payload); + if (processEvent) { + for (const cb of [...remoteProcessEventCallbacks]) { + try { + cb(processEvent); + } catch (error) { + console.error("preload remote process listener failed", error); + } + } + } + + const testEvent = toTestEvent(payload); + if (testEvent) { + for (const cb of [...remoteTestEventCallbacks]) { + try { + cb(testEvent); + } catch (error) { + console.error("preload remote test listener failed", error); + } + } + } + + const fileChangeEvent = toWrappedEvent<FileChangeEvent>( + payload, + "file_change", + ); + if (fileChangeEvent) { + clearGitReadCaches(); + for (const cb of [...remoteFileChangeEventCallbacks]) { + try { + cb(fileChangeEvent); + } catch (error) { + console.error("preload remote file change listener failed", error); + } + } + } + + const prAiResolutionEvent = toWrappedEvent<PrAiResolutionEventPayload>( + payload, + "pr_ai_resolution_event", + ); + if (prAiResolutionEvent) { + for (const cb of [...remotePrAiResolutionEventCallbacks]) { + try { + cb(prAiResolutionEvent); + } catch (error) { + console.error("preload remote PR AI resolution listener failed", error); + } + } + } + + const prEvent = toWrappedEvent<PrEventPayload>(payload, "pr_event"); + if (prEvent) { + for (const cb of [...remotePrEventCallbacks]) { + try { + cb(prEvent); + } catch (error) { + console.error("preload remote PR listener failed", error); + } + } + } + + const projectStateEvent = toWrappedEvent<AdeProjectEvent>( + payload, + "project_state_event", + ); + if (projectStateEvent) { + for (const cb of [...remoteProjectStateEventCallbacks]) { + try { + cb(projectStateEvent); + } catch (error) { + console.error("preload remote project state listener failed", error); + } + } + } +} + +function subscribeRemoteAgentChatEvents( + cb: (payload: AgentChatEventEnvelope) => void, +): () => void { + remoteAgentChatEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteAgentChatEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteMissionEvents( + cb: (payload: MissionsEventPayload) => void, +): () => void { + remoteMissionEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteMissionEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteOrchestratorEvents( + cb: (payload: OrchestratorRuntimeEvent) => void, +): () => void { + remoteOrchestratorEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteOrchestratorEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteOrchestratorThreadEvents( + cb: (payload: OrchestratorThreadEvent) => void, +): () => void { + remoteOrchestratorThreadEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteOrchestratorThreadEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteDagMutationEvents( + cb: (payload: DagMutationEvent) => void, +): () => void { + remoteDagMutationEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteDagMutationEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteSyncStatusEvents( + cb: (payload: SyncStatusEventPayload) => void, +): () => void { + remoteSyncStatusEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteSyncStatusEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteReviewEvents( + cb: (payload: ReviewEventPayload) => void, +): () => void { + remoteReviewEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteReviewEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteSessionChangedEvents( + cb: (payload: TerminalSessionChangedEvent) => void, +): () => void { + remoteSessionChangedCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteSessionChangedCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneDeleteEvents( + cb: (payload: LaneDeleteEvent) => void, +): () => void { + remoteLaneDeleteEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneDeleteEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneRebaseEvents( + cb: (payload: RebaseRunEventPayload) => void, +): () => void { + remoteLaneRebaseEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneRebaseEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneRebaseSuggestionsEvents( + cb: (payload: RebaseSuggestionsEventPayload) => void, +): () => void { + remoteLaneRebaseSuggestionsEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneRebaseSuggestionsEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneAutoRebaseEvents( + cb: (payload: AutoRebaseEventPayload) => void, +): () => void { + remoteLaneAutoRebaseEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneAutoRebaseEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneEnvEvents( + cb: (payload: LaneEnvInitEvent) => void, +): () => void { + remoteLaneEnvEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneEnvEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLanePortEvents( + cb: (payload: PortAllocationEvent) => void, +): () => void { + remoteLanePortEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLanePortEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneProxyEvents( + cb: (payload: LaneProxyEvent) => void, +): () => void { + remoteLaneProxyEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneProxyEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneOAuthEvents( + cb: (payload: OAuthRedirectEvent) => void, +): () => void { + remoteLaneOAuthEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneOAuthEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneDiagnosticsEvents( + cb: (payload: RuntimeDiagnosticsEvent) => void, +): () => void { + remoteLaneDiagnosticsEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneDiagnosticsEventCallbacks.delete(cb); + }; +} + +function subscribeRemotePtyDataEvents( + cb: (payload: PtyDataEvent) => void, +): () => void { + remotePtyDataEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remotePtyDataEventCallbacks.delete(cb); + }; +} + +function subscribeRemotePtyExitEvents( + cb: (payload: PtyExitEvent) => void, +): () => void { + remotePtyExitEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remotePtyExitEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteProcessEvents( + cb: (payload: ProcessEvent) => void, +): () => void { + remoteProcessEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteProcessEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteTestEvents( + cb: (payload: TestEvent) => void, +): () => void { + remoteTestEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteTestEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteFileChangeEvents( + cb: (payload: FileChangeEvent) => void, +): () => void { + remoteFileChangeEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteFileChangeEventCallbacks.delete(cb); + }; +} + +function subscribeRemotePrAiResolutionEvents( + cb: (payload: PrAiResolutionEventPayload) => void, +): () => void { + remotePrAiResolutionEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remotePrAiResolutionEventCallbacks.delete(cb); + }; +} + +function subscribeRemotePrEvents( + cb: (payload: PrEventPayload) => void, +): () => void { + remotePrEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remotePrEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteProjectStateEvents( + cb: (payload: AdeProjectEvent) => void, +): () => void { + remoteProjectStateEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteProjectStateEventCallbacks.delete(cb); + }; +} + +function subscribeAgentChatEvents( + cb: (payload: AgentChatEventEnvelope) => void, +): () => void { + const removeLocal = agentChatEventFanout(cb); + const removeRemote = subscribeRemoteAgentChatEvents(cb); + return () => { + removeRemote(); + removeLocal(); + }; +} + +function subscribePtyDataEvents( + cb: (payload: PtyDataEvent) => void, +): () => void { + const removeLocal = ptyDataEventFanout(cb); + const removeRemote = subscribeRemotePtyDataEvents(cb); + return () => { + removeRemote(); + removeLocal(); + }; +} + +function subscribePtyExitEvents( + cb: (payload: PtyExitEvent) => void, +): () => void { + const removeLocal = ptyExitEventFanout(cb); + const removeRemote = subscribeRemotePtyExitEvents(cb); + return () => { + removeRemote(); + removeLocal(); + }; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function toAgentChatEventEnvelope( + payload: unknown, +): AgentChatEventEnvelope | null { + if (!isRecord(payload)) return null; + if (typeof payload.sessionId !== "string") return null; + if (typeof payload.timestamp !== "string") return null; + if (!isRecord(payload.event) || typeof payload.event.type !== "string") + return null; + return payload as unknown as AgentChatEventEnvelope; +} + +function toTerminalSessionChangedEvent( + payload: unknown, +): TerminalSessionChangedEvent | null { + if (!isRecord(payload) || payload.type !== "terminal_session_changed") + return null; + const event = payload.event; + if (!isRecord(event)) return null; + if (typeof event.sessionId !== "string") return null; + if ( + event.reason !== "meta-updated" && + event.reason !== "deleted" && + event.reason !== "created" + ) + return null; + return { + sessionId: event.sessionId, + reason: event.reason, + }; +} + +function toWrappedEvent<T>(payload: unknown, type: string): T | null { + if (!isRecord(payload) || payload.type !== type || !isRecord(payload.event)) + return null; + return payload.event as T; +} + +function toProcessEvent(payload: unknown): ProcessEvent | null { + if (!isRecord(payload) || typeof payload.type !== "string") return null; + if (payload.type === "runtime") { + const runtime = payload.runtime; + if (!isRecord(runtime)) return null; + if ( + typeof runtime.laneId !== "string" || + typeof runtime.processId !== "string" + ) + return null; + return payload as unknown as ProcessEvent; + } + if (payload.type === "log") { + if (typeof payload.runId !== "string") return null; + if ( + typeof payload.laneId !== "string" || + typeof payload.processId !== "string" + ) + return null; + if (payload.stream !== "stdout" && payload.stream !== "stderr") return null; + if (typeof payload.chunk !== "string" || typeof payload.ts !== "string") + return null; + return payload as unknown as ProcessEvent; + } + return null; +} + +function toTestEvent(payload: unknown): TestEvent | null { + if (!isRecord(payload) || typeof payload.type !== "string") return null; + if (payload.type === "run") { + const run = payload.run; + if (!isRecord(run)) return null; + if (typeof run.id !== "string" || typeof run.suiteId !== "string") + return null; + return payload as unknown as TestEvent; + } + if (payload.type === "log") { + if ( + typeof payload.runId !== "string" || + typeof payload.suiteId !== "string" + ) + return null; + if (payload.stream !== "stdout" && payload.stream !== "stderr") return null; + if (typeof payload.chunk !== "string" || typeof payload.ts !== "string") + return null; + return payload as unknown as TestEvent; + } + return null; +} + function clearGitReadCaches(): void { diffChangesCache.clear(); gitBranchesCache.clear(); @@ -981,13 +2239,18 @@ function clearIosSimulatorStatusCaches(): void { iosSimulatorDevicesCache.clear(); } -function getAiStatusCacheKey(args?: { refreshOpenCodeInventory?: boolean }): string { +function getAiStatusCacheKey(args?: { + refreshOpenCodeInventory?: boolean; +}): string { return serializeIpcCacheArgs({ refreshOpenCodeInventory: args?.refreshOpenCodeInventory === true, }); } -async function clearAround<T>(clear: () => void, action: () => Promise<T>): Promise<T> { +async function clearAround<T>( + clear: () => void, + action: () => Promise<T>, +): Promise<T> { clear(); try { return await action(); @@ -1010,7 +2273,10 @@ function createIpcEventFanout<T>( try { cb(payload); } catch (error) { - console.error(`preload IPC fanout listener failed for ${channel}`, error); + console.error( + `preload IPC fanout listener failed for ${channel}`, + error, + ); } } }; @@ -1049,14 +2315,18 @@ const appControlEventFanout = createIpcEventFanout<AppControlEventPayload>( IPC.appControlEvent, () => appControlStatusCache.clear(), ); -const builtInBrowserEventFanout = createIpcEventFanout<BuiltInBrowserEventPayload>( - IPC.builtInBrowserEvent, - () => builtInBrowserStatusCache.clear(), -); +const builtInBrowserEventFanout = + createIpcEventFanout<BuiltInBrowserEventPayload>( + IPC.builtInBrowserEvent, + () => builtInBrowserStatusCache.clear(), + ); const macosVmEventFanout = createIpcEventFanout<MacosVmEventPayload>( IPC.macosVmEvent, () => macosVmStatusCache.clear(), ); +const projectStateEventFanout = createIpcEventFanout<AdeProjectEvent>( + IPC.projectStateEvent, +); const ptyDataEventFanout = createIpcEventFanout<PtyDataEvent>(IPC.ptyData); const ptyExitEventFanout = createIpcEventFanout<PtyExitEvent>(IPC.ptyExit); @@ -1066,6 +2336,29 @@ contextBridge.exposeInMainWorld("ade", { getInfo: async (): Promise<AppInfo> => ipcRenderer.invoke(IPC.appGetInfo), getProject: async (): Promise<ProjectInfo | null> => ipcRenderer.invoke(IPC.appGetProject), + getWindowSession: async (): Promise<{ + windowId: number | null; + project: ProjectInfo | null; + binding: OpenProjectBinding | null; + }> => { + const session = (await ipcRenderer.invoke(IPC.appGetWindowSession)) as { + windowId: number | null; + project: ProjectInfo | null; + binding: OpenProjectBinding | null; + }; + rememberProjectBinding(session.binding); + return session; + }, + newWindow: async (): Promise<{ windowId: number | null }> => + ipcRenderer.invoke(IPC.appNewWindow), + openProjectInNewWindow: async ( + rootPath: string, + ): Promise<{ windowId: number | null; project: ProjectInfo | null }> => + ipcRenderer.invoke(IPC.appOpenProjectInNewWindow, { rootPath }), + closeWindow: async ( + windowId?: number | null, + ): Promise<{ closed: boolean }> => + ipcRenderer.invoke(IPC.appCloseWindow, { windowId: windowId ?? null }), onProjectChanged: (cb: (project: ProjectInfo | null) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -1077,6 +2370,29 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.on(IPC.appProjectChanged, listener); return () => ipcRenderer.removeListener(IPC.appProjectChanged, listener); }, + onProjectBindingChanged: ( + cb: (binding: OpenProjectBinding | null) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: OpenProjectBinding | null, + ) => { + rememberProjectBinding(payload); + clearProjectScopedReadCaches(); + cb(payload); + }; + ipcRenderer.on(IPC.appProjectBindingChanged, listener); + return () => + ipcRenderer.removeListener(IPC.appProjectBindingChanged, listener); + }, + onNavigate: (cb: (request: AppNavigationRequest) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: AppNavigationRequest, + ) => cb(payload); + ipcRenderer.on(IPC.appNavigate, listener); + return () => ipcRenderer.removeListener(IPC.appNavigate, listener); + }, openExternal: async (url: string): Promise<void> => ipcRenderer.invoke(IPC.appOpenExternal, { url }), revealPath: async (path: string): Promise<void> => @@ -1087,8 +2403,11 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.appWriteClipboardText, { text }), hasClipboardImage: async (): Promise<boolean> => ipcRenderer.invoke(IPC.appHasClipboardImage), - readClipboardImage: async (): Promise<{ data: string; filename: string; mimeType: string } | null> => - ipcRenderer.invoke(IPC.appReadClipboardImage), + readClipboardImage: async (): Promise<{ + data: string; + filename: string; + mimeType: string; + } | null> => ipcRenderer.invoke(IPC.appReadClipboardImage), getImageDataUrl: async (path: string): Promise<{ dataUrl: string }> => imageDataUrlCache.get(path), writeClipboardImage: async (path: string): Promise<void> => @@ -1098,12 +2417,17 @@ contextBridge.exposeInMainWorld("ade", { relativePath?: string; target: "default" | "finder" | "vscode" | "cursor" | "zed"; }): Promise<void> => ipcRenderer.invoke(IPC.appOpenPathInEditor, args), - logDebugEvent: (event: string, payload: Record<string, unknown> = {}): void => - ipcRenderer.send(IPC.appLogDebugEvent, { event, payload }), + logDebugEvent: ( + event: string, + payload: Record<string, unknown> = {}, + ): void => ipcRenderer.send(IPC.appLogDebugEvent, { event, payload }), }, project: { openRepo: async (): Promise<ProjectInfo | null> => - clearAround(() => clearProjectScopedReadCaches(), () => ipcRenderer.invoke(IPC.projectOpenRepo)), + clearAround( + () => clearProjectScopedReadCaches(), + () => ipcRenderer.invoke(IPC.projectOpenRepo), + ), chooseDirectory: async ( args: { title?: string; defaultPath?: string } = {}, ): Promise<string | null> => @@ -1117,15 +2441,21 @@ contextBridge.exposeInMainWorld("ade", { resolveIcon: async (rootPath: string): Promise<ProjectIcon> => projectIconCache.get(rootPath), chooseIcon: async (rootPath: string): Promise<ProjectIcon | null> => - clearAround(() => { - imageDataUrlCache.clear(); - projectIconCache.clear(rootPath); - }, () => ipcRenderer.invoke(IPC.projectChooseIcon, { rootPath })), + clearAround( + () => { + imageDataUrlCache.clear(); + projectIconCache.clear(rootPath); + }, + () => ipcRenderer.invoke(IPC.projectChooseIcon, { rootPath }), + ), removeIcon: async (rootPath: string): Promise<ProjectIcon> => - clearAround(() => { - imageDataUrlCache.clear(); - projectIconCache.clear(rootPath); - }, () => ipcRenderer.invoke(IPC.projectRemoveIcon, { rootPath })), + clearAround( + () => { + imageDataUrlCache.clear(); + projectIconCache.clear(rootPath); + }, + () => ipcRenderer.invoke(IPC.projectRemoveIcon, { rootPath }), + ), getDroppedPath: (file: File): string => { try { return webUtils.getPathForFile(file); @@ -1138,31 +2468,60 @@ contextBridge.exposeInMainWorld("ade", { clearLocalData: async ( args: ClearLocalAdeDataArgs = {}, ): Promise<ClearLocalAdeDataResult> => - clearAround(() => clearProjectScopedReadCaches(), () => ipcRenderer.invoke(IPC.projectClearLocalData, args)), + clearAround( + () => clearProjectScopedReadCaches(), + () => + callProjectRuntimeActionOr( + "ade_project", + "clearLocalData", + { args }, + () => ipcRenderer.invoke(IPC.projectClearLocalData, args), + ), + ), listRecent: async (): Promise<RecentProjectSummary[]> => ipcRenderer.invoke(IPC.projectListRecent), closeCurrent: async (): Promise<void> => - clearAround(() => clearProjectScopedReadCaches(), () => ipcRenderer.invoke(IPC.projectCloseCurrent)), + clearAround( + () => { + rememberProjectBinding(null); + clearProjectScopedReadCaches(); + }, + () => ipcRenderer.invoke(IPC.projectCloseCurrent), + ), switchToPath: async (rootPath: string): Promise<ProjectInfo> => - clearAround(() => clearProjectScopedReadCaches(), () => ipcRenderer.invoke(IPC.projectSwitchToPath, { rootPath })), + clearAround( + () => { + rememberProjectBinding(null); + clearProjectScopedReadCaches(); + }, + () => ipcRenderer.invoke(IPC.projectSwitchToPath, { rootPath }), + ), forgetRecent: async (rootPath: string): Promise<RecentProjectSummary[]> => ipcRenderer.invoke(IPC.projectForgetRecent, { rootPath }), reorderRecent: async ( orderedPaths: string[], ): Promise<RecentProjectSummary[]> => ipcRenderer.invoke(IPC.projectReorderRecent, { orderedPaths }), - createLocal: async (input: CreateProjectInput): Promise<CreateProjectResult> => + createLocal: async ( + input: CreateProjectInput, + ): Promise<CreateProjectResult> => ipcRenderer.invoke(IPC.projectCreateLocal, input), clone: async (input: CloneProjectInput): Promise<CloneProjectResult> => ipcRenderer.invoke(IPC.projectClone, input), getDefaultParentDir: async (): Promise<string> => ipcRenderer.invoke(IPC.projectGetDefaultParentDir), getSnapshot: async (): Promise<AdeProjectSnapshot> => - ipcRenderer.invoke(IPC.projectStateGetSnapshot), + callProjectRuntimeActionOr("ade_project", "getSnapshot", {}, () => + ipcRenderer.invoke(IPC.projectStateGetSnapshot), + ), initializeOrRepair: async (): Promise<AdeCleanupResult> => - ipcRenderer.invoke(IPC.projectStateInitializeOrRepair), + callProjectRuntimeActionOr("ade_project", "initializeOrRepair", {}, () => + ipcRenderer.invoke(IPC.projectStateInitializeOrRepair), + ), runIntegrityCheck: async (): Promise<AdeCleanupResult> => - ipcRenderer.invoke(IPC.projectStateRunIntegrityCheck), + callProjectRuntimeActionOr("ade_project", "runIntegrityCheck", {}, () => + ipcRenderer.invoke(IPC.projectStateRunIntegrityCheck), + ), onMissing: (cb: (data: { rootPath: string }) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -1172,73 +2531,257 @@ contextBridge.exposeInMainWorld("ade", { return () => ipcRenderer.removeListener(IPC.projectMissing, listener); }, onStateEvent: (cb: (event: AdeProjectEvent) => void) => { + const removeLocal = projectStateEventFanout(cb); + const removeRemote = subscribeRemoteProjectStateEvents(cb); + return () => { + removeRemote(); + removeLocal(); + }; + }, + }, + remoteRuntime: { + listTargets: async (): Promise<RemoteRuntimeTarget[]> => + ipcRenderer.invoke(IPC.remoteRuntimeListTargets), + getConnectionSnapshot: async (): Promise<RemoteRuntimeConnectionSnapshot> => + ipcRenderer.invoke(IPC.remoteRuntimeGetConnectionSnapshot), + onConnectionSnapshotChanged: ( + cb: (snapshot: RemoteRuntimeConnectionSnapshot) => void, + ) => { const listener = ( _event: Electron.IpcRendererEvent, - payload: AdeProjectEvent, + payload: RemoteRuntimeConnectionSnapshot, ) => cb(payload); - ipcRenderer.on(IPC.projectStateEvent, listener); - return () => ipcRenderer.removeListener(IPC.projectStateEvent, listener); + ipcRenderer.on(IPC.remoteRuntimeConnectionSnapshotChanged, listener); + return () => + ipcRenderer.removeListener( + IPC.remoteRuntimeConnectionSnapshotChanged, + listener, + ); + }, + listDiscoveredMachines: async (): Promise< + RemoteRuntimeDiscoveredMachine[] + > => ipcRenderer.invoke(IPC.remoteRuntimeListDiscoveredMachines), + saveTarget: async ( + input: RemoteRuntimeTargetInput, + ): Promise<RemoteRuntimeTarget> => + ipcRenderer.invoke(IPC.remoteRuntimeSaveTarget, input), + removeTarget: async (id: string): Promise<{ removed: boolean }> => + ipcRenderer.invoke(IPC.remoteRuntimeRemoveTarget, { id }), + connect: async (id: string): Promise<RemoteRuntimeConnectResult> => + ipcRenderer.invoke(IPC.remoteRuntimeConnect, { id }), + listProjects: async (id: string): Promise<RemoteRuntimeProjectRecord[]> => + ipcRenderer.invoke(IPC.remoteRuntimeListProjects, { id }), + addProject: async ( + id: string, + rootPath: string, + ): Promise<RemoteRuntimeProjectRecord> => + ipcRenderer.invoke(IPC.remoteRuntimeAddProject, { id, rootPath }), + browseDirectories: async ( + id: string, + args: ProjectBrowseInput = {}, + ): Promise<ProjectBrowseResult> => + ipcRenderer.invoke(IPC.remoteRuntimeBrowseDirectories, { id, args }), + getProjectDetail: async ( + id: string, + rootPath: string, + ): Promise<ProjectDetail> => + ipcRenderer.invoke(IPC.remoteRuntimeGetProjectDetail, { id, rootPath }), + getDefaultParentDir: async (id: string): Promise<string> => + ipcRenderer.invoke(IPC.remoteRuntimeGetDefaultParentDir, { id }), + createProject: async ( + id: string, + input: CreateProjectInput, + ): Promise<RemoteRuntimeProjectRecord> => + ipcRenderer.invoke(IPC.remoteRuntimeCreateProject, { id, input }), + cloneProject: async ( + id: string, + input: CloneProjectInput, + ): Promise<RemoteRuntimeProjectRecord> => + ipcRenderer.invoke(IPC.remoteRuntimeCloneProject, { id, input }), + listMyGitHubRepos: async ( + id: string, + input: ListMyGitHubReposInput = {}, + ): Promise<ListMyGitHubReposResult> => + ipcRenderer.invoke(IPC.remoteRuntimeListMyGitHubRepos, { id, input }), + openProject: async ( + id: string, + projectId: string, + ): Promise<OpenProjectBinding> => { + const binding = (await ipcRenderer.invoke(IPC.remoteRuntimeOpenProject, { + id, + projectId, + })) as OpenProjectBinding; + rememberProjectBinding(binding); + return binding; }, + callAction: async ( + id: string, + projectId: string, + request: RemoteRuntimeActionRequest, + ): Promise<RemoteRuntimeActionResult> => + ipcRenderer.invoke(IPC.remoteRuntimeCallAction, { + id, + projectId, + request, + }), + streamEvents: async ( + id: string, + projectId: string, + request: RemoteRuntimeStreamEventsRequest = {}, + ): Promise<RemoteRuntimeStreamEventsResult> => + ipcRenderer.invoke(IPC.remoteRuntimeStreamEvents, { + id, + projectId, + request, + }), + checkLocalWork: async ( + id: string, + project: RemoteRuntimeProjectRecord, + ): Promise<RemoteRuntimeLocalWorkCheckResult> => + ipcRenderer.invoke(IPC.remoteRuntimeCheckLocalWork, { id, project }), + disconnect: async (id: string): Promise<{ disconnected: boolean }> => + ipcRenderer.invoke(IPC.remoteRuntimeDisconnect, { id }), }, keybindings: { get: async (): Promise<KeybindingsSnapshot> => - ipcRenderer.invoke(IPC.keybindingsGet), + callProjectRuntimeActionOr("keybindings", "get", {}, () => + ipcRenderer.invoke(IPC.keybindingsGet), + ), set: async ( overrides: KeybindingOverride[], ): Promise<KeybindingsSnapshot> => - ipcRenderer.invoke(IPC.keybindingsSet, { overrides }), + callProjectRuntimeActionOr( + "keybindings", + "set", + { args: { overrides } }, + () => ipcRenderer.invoke(IPC.keybindingsSet, { overrides }), + ), }, ai: { - getStatus: async (args?: { force?: boolean; refreshOpenCodeInventory?: boolean }): Promise<AiSettingsStatus> => { + getStatus: async (args?: { + force?: boolean; + refreshOpenCodeInventory?: boolean; + }): Promise<AiSettingsStatus> => { const cacheKey = getAiStatusCacheKey(args); if (args?.force === true) { aiStatusCache.clear(); - return ipcRenderer.invoke(IPC.aiGetStatus, args); + return callProjectRuntimeActionOr("ai", "getStatus", { args }, () => + ipcRenderer.invoke(IPC.aiGetStatus, args), + ); } return aiStatusCache.get(cacheKey); }, getOpenCodeRuntimeDiagnostics: async (): Promise<OpenCodeRuntimeSnapshot> => ipcRenderer.invoke(IPC.aiGetOpenCodeRuntimeDiagnostics), storeApiKey: async (provider: string, key: string): Promise<void> => - clearAround(() => aiStatusCache.clear(), () => ipcRenderer.invoke(IPC.aiStoreApiKey, { provider, key })), + clearAround( + () => aiStatusCache.clear(), + () => + callProjectRuntimeActionOr( + "ai", + "storeApiKey", + { args: { provider, key } }, + () => ipcRenderer.invoke(IPC.aiStoreApiKey, { provider, key }), + ), + ), deleteApiKey: async (provider: string): Promise<void> => - clearAround(() => aiStatusCache.clear(), () => ipcRenderer.invoke(IPC.aiDeleteApiKey, { provider })), + clearAround( + () => aiStatusCache.clear(), + () => + callProjectRuntimeActionOr( + "ai", + "deleteApiKey", + { args: { provider } }, + () => ipcRenderer.invoke(IPC.aiDeleteApiKey, { provider }), + ), + ), listApiKeys: async (): Promise<string[]> => - ipcRenderer.invoke(IPC.aiListApiKeys), + callProjectRuntimeActionOr("ai", "listApiKeys", {}, () => + ipcRenderer.invoke(IPC.aiListApiKeys), + ), verifyApiKey: async ( provider: string, ): Promise<AiApiKeyVerificationResult> => - clearAround(() => aiStatusCache.clear(), () => ipcRenderer.invoke(IPC.aiVerifyApiKey, { provider })), + clearAround( + () => aiStatusCache.clear(), + () => + callProjectRuntimeActionOr( + "ai", + "verifyApiKeyConnection", + { args: { provider } }, + () => ipcRenderer.invoke(IPC.aiVerifyApiKey, { provider }), + ), + ), updateConfig: async (config: Partial<AiConfig>): Promise<void> => - clearAround(() => aiStatusCache.clear(), () => ipcRenderer.invoke(IPC.aiUpdateConfig, config)), + clearAround( + () => aiStatusCache.clear(), + () => + callProjectRuntimeActionOr( + "ai", + "updateConfig", + { args: config }, + () => ipcRenderer.invoke(IPC.aiUpdateConfig, config), + ), + ), cursorCloudListRepositories: async (): Promise<CursorCloudRepository[]> => - ipcRenderer.invoke(IPC.aiCursorCloudListRepositories), + callProjectRuntimeActionOr("ai", "listCursorCloudRepositories", {}, () => + ipcRenderer.invoke(IPC.aiCursorCloudListRepositories), + ), cursorCloudListAgents: async (args?: { includeArchived?: boolean; limit?: number; cursor?: string | null; }): Promise<CursorCloudListAgentsResult> => - ipcRenderer.invoke(IPC.aiCursorCloudListAgents, args ?? {}), + callProjectRuntimeActionOr( + "ai", + "listCursorCloudAgents", + { args: args ?? {} }, + () => ipcRenderer.invoke(IPC.aiCursorCloudListAgents, args ?? {}), + ), cursorCloudListRuns: async (args: { agentId: string; limit?: number; cursor?: string | null; }): Promise<CursorCloudListRunsResult> => - ipcRenderer.invoke(IPC.aiCursorCloudListRuns, args), + callProjectRuntimeActionOr("ai", "listCursorCloudRuns", { args }, () => + ipcRenderer.invoke(IPC.aiCursorCloudListRuns, args), + ), cursorCloudCreateRun: async ( args: CursorCloudCreateRunRequest, ): Promise<CursorCloudCreateRunResult> => - ipcRenderer.invoke(IPC.aiCursorCloudCreateRun, args), + callProjectRuntimeActionOr("ai", "createCursorCloudRun", { args }, () => + ipcRenderer.invoke(IPC.aiCursorCloudCreateRun, args), + ), cursorCloudArchiveAgent: async (agentId: string): Promise<void> => - ipcRenderer.invoke(IPC.aiCursorCloudArchiveAgent, { agentId }), + callProjectRuntimeActionOr( + "ai", + "archiveCursorCloudAgent", + { args: { agentId } }, + () => ipcRenderer.invoke(IPC.aiCursorCloudArchiveAgent, { agentId }), + ), cursorCloudUnarchiveAgent: async (agentId: string): Promise<void> => - ipcRenderer.invoke(IPC.aiCursorCloudUnarchiveAgent, { agentId }), + callProjectRuntimeActionOr( + "ai", + "unarchiveCursorCloudAgent", + { args: { agentId } }, + () => ipcRenderer.invoke(IPC.aiCursorCloudUnarchiveAgent, { agentId }), + ), cursorCloudDeleteAgent: async (agentId: string): Promise<void> => - ipcRenderer.invoke(IPC.aiCursorCloudDeleteAgent, { agentId }), + callProjectRuntimeActionOr( + "ai", + "deleteCursorCloudAgent", + { args: { agentId } }, + () => ipcRenderer.invoke(IPC.aiCursorCloudDeleteAgent, { agentId }), + ), cursorCloudGetAgent: async ( agentId: string, ): Promise<CursorCloudAgentSummary | null> => - ipcRenderer.invoke(IPC.aiCursorCloudGetAgent, { agentId }), + callProjectRuntimeActionOr( + "ai", + "getCursorCloudAgent", + { args: { agentId } }, + () => ipcRenderer.invoke(IPC.aiCursorCloudGetAgent, { agentId }), + ), cursorCloudStreamRun: async ( args: CursorCloudStreamRunRequest, ): Promise<CursorCloudStreamRunResult> => @@ -1247,75 +2790,129 @@ contextBridge.exposeInMainWorld("ade", { agentId: string; runId: string; }): Promise<void> => - ipcRenderer.invoke(IPC.aiCursorCloudCancelRun, args), + callProjectRuntimeActionOr("ai", "cancelCursorCloudRun", { args }, () => + ipcRenderer.invoke(IPC.aiCursorCloudCancelRun, args), + ), cursorCloudFollowUp: async ( args: CursorCloudFollowUpRequest, ): Promise<CursorCloudFollowUpResult> => - ipcRenderer.invoke(IPC.aiCursorCloudFollowUp, args), + callProjectRuntimeActionOr("ai", "cursorCloudFollowUp", { args }, () => + ipcRenderer.invoke(IPC.aiCursorCloudFollowUp, args), + ), cursorCloudListArtifacts: async ( agentId: string, ): Promise<CursorCloudArtifactSummary[]> => - ipcRenderer.invoke(IPC.aiCursorCloudListArtifacts, { agentId }), + callProjectRuntimeActionOr( + "ai", + "listCursorCloudArtifacts", + { args: { agentId } }, + () => ipcRenderer.invoke(IPC.aiCursorCloudListArtifacts, { agentId }), + ), cursorCloudDownloadArtifact: async (args: { agentId: string; path: string; }): Promise<CursorCloudArtifactDownload> => - ipcRenderer.invoke(IPC.aiCursorCloudDownloadArtifact, args), + callProjectRuntimeActionOr( + "ai", + "downloadCursorCloudArtifact", + { args }, + () => ipcRenderer.invoke(IPC.aiCursorCloudDownloadArtifact, args), + ), cursorCloudOpenChat: async ( args: CursorCloudOpenChatRequest, ): Promise<CursorCloudOpenChatResult> => - ipcRenderer.invoke(IPC.aiCursorCloudOpenChat, args), + callProjectRuntimeActionOr("ai", "openCursorCloudChat", { args }, () => + ipcRenderer.invoke(IPC.aiCursorCloudOpenChat, args), + ), }, sync: { getStatus: async (args?: SyncGetStatusArgs): Promise<SyncRoleSnapshot> => - ipcRenderer.invoke(IPC.syncGetStatus, args), + callProjectRuntimeSyncOr("sync.getStatus", args ?? {}, () => + ipcRenderer.invoke(IPC.syncGetStatus, args), + ), refreshDiscovery: async (): Promise<SyncRoleSnapshot> => - ipcRenderer.invoke(IPC.syncRefreshDiscovery), + callProjectRuntimeSyncOr("sync.refreshDiscovery", {}, () => + ipcRenderer.invoke(IPC.syncRefreshDiscovery), + ), listDevices: async (): Promise<SyncDeviceRuntimeState[]> => - ipcRenderer.invoke(IPC.syncListDevices), + callProjectRuntimeSyncOr("sync.listDevices", {}, () => + ipcRenderer.invoke(IPC.syncListDevices), + ), updateLocalDevice: async (args: { name?: string; deviceType?: SyncPeerDeviceType; }): Promise<SyncDeviceRecord> => - ipcRenderer.invoke(IPC.syncUpdateLocalDevice, args), + callProjectRuntimeSyncOr("sync.updateLocalDevice", args, () => + ipcRenderer.invoke(IPC.syncUpdateLocalDevice, args), + ), connectToBrain: async ( draft: SyncDesktopConnectionDraft, ): Promise<SyncRoleSnapshot> => - ipcRenderer.invoke(IPC.syncConnectToBrain, draft), + callProjectRuntimeSyncOr( + "sync.connectToBrain", + draft as unknown as Record<string, unknown>, + () => ipcRenderer.invoke(IPC.syncConnectToBrain, draft), + ), disconnectFromBrain: async (): Promise<SyncRoleSnapshot> => - ipcRenderer.invoke(IPC.syncDisconnectFromBrain), + callProjectRuntimeSyncOr("sync.disconnectFromBrain", {}, () => + ipcRenderer.invoke(IPC.syncDisconnectFromBrain), + ), forgetDevice: async (deviceId: string): Promise<SyncRoleSnapshot> => - ipcRenderer.invoke(IPC.syncForgetDevice, { deviceId }), + callProjectRuntimeSyncOr("sync.forgetDevice", { deviceId }, () => + ipcRenderer.invoke(IPC.syncForgetDevice, { deviceId }), + ), getTransferReadiness: async (): Promise<SyncTransferReadiness> => - ipcRenderer.invoke(IPC.syncGetTransferReadiness), + callProjectRuntimeSyncOr("sync.getTransferReadiness", {}, () => + ipcRenderer.invoke(IPC.syncGetTransferReadiness), + ), transferBrainToLocal: async (): Promise<SyncRoleSnapshot> => - ipcRenderer.invoke(IPC.syncTransferBrainToLocal), + callProjectRuntimeSyncOr("sync.transferBrainToLocal", {}, () => + ipcRenderer.invoke(IPC.syncTransferBrainToLocal), + ), getPin: async (): Promise<{ pin: string | null }> => - ipcRenderer.invoke(IPC.syncGetPin), + callProjectRuntimeSyncOr("sync.getPin", {}, () => + ipcRenderer.invoke(IPC.syncGetPin), + ), setPin: async (pin: string): Promise<SyncRoleSnapshot> => - ipcRenderer.invoke(IPC.syncSetPin, pin), + callProjectRuntimeSyncOr("sync.setPin", { pin }, () => + ipcRenderer.invoke(IPC.syncSetPin, pin), + ), + generatePin: async (): Promise<SyncRoleSnapshot> => + callProjectRuntimeSyncOr("sync.generatePin", {}, () => + ipcRenderer.invoke(IPC.syncGeneratePin), + ), clearPin: async (): Promise<SyncRoleSnapshot> => - ipcRenderer.invoke(IPC.syncClearPin), - setActiveLanePresence: async (args: { - laneIds: string[]; - }): Promise<void> => - ipcRenderer.invoke(IPC.syncSetActiveLanePresence, args), + callProjectRuntimeSyncOr("sync.clearPin", {}, () => + ipcRenderer.invoke(IPC.syncClearPin), + ), + setActiveLanePresence: async (args: { laneIds: string[] }): Promise<void> => + callProjectRuntimeSyncOr("sync.setActiveLanePresence", args, () => + ipcRenderer.invoke(IPC.syncSetActiveLanePresence, args), + ), onEvent: (cb: (event: SyncStatusEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: SyncStatusEventPayload, ) => cb(payload); ipcRenderer.on(IPC.syncEvent, listener); - return () => ipcRenderer.removeListener(IPC.syncEvent, listener); + const removeRemote = subscribeRemoteSyncStatusEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.syncEvent, listener); + }; }, }, notifications: { apns: { getStatus: async (): Promise<ApnsBridgeStatus> => ipcRenderer.invoke(IPC.notificationsApnsGetStatus), - saveConfig: async (args: ApnsBridgeSaveConfigArgs): Promise<ApnsBridgeStatus> => + saveConfig: async ( + args: ApnsBridgeSaveConfigArgs, + ): Promise<ApnsBridgeStatus> => ipcRenderer.invoke(IPC.notificationsApnsSaveConfig, args), - uploadKey: async (args: ApnsBridgeUploadKeyArgs): Promise<ApnsBridgeStatus> => + uploadKey: async ( + args: ApnsBridgeUploadKeyArgs, + ): Promise<ApnsBridgeStatus> => ipcRenderer.invoke(IPC.notificationsApnsUploadKey, args), clearKey: async (): Promise<ApnsBridgeStatus> => ipcRenderer.invoke(IPC.notificationsApnsClearKey), @@ -1341,114 +2938,290 @@ contextBridge.exposeInMainWorld("ade", { }, onboarding: { getStatus: async (): Promise<OnboardingStatus> => - ipcRenderer.invoke(IPC.onboardingGetStatus), + callProjectRuntimeActionOr("onboarding", "getStatus", {}, () => + ipcRenderer.invoke(IPC.onboardingGetStatus), + ), detectDefaults: async (): Promise<OnboardingDetectionResult> => - ipcRenderer.invoke(IPC.onboardingDetectDefaults), + callProjectRuntimeActionOr("onboarding", "detectDefaults", {}, () => + ipcRenderer.invoke(IPC.onboardingDetectDefaults), + ), detectExistingLanes: async (): Promise<OnboardingExistingLaneCandidate[]> => - ipcRenderer.invoke(IPC.onboardingDetectExistingLanes), + callProjectRuntimeActionOr("onboarding", "detectExistingLanes", {}, () => + ipcRenderer.invoke(IPC.onboardingDetectExistingLanes), + ), setDismissed: async (dismissed: boolean): Promise<OnboardingStatus> => - ipcRenderer.invoke(IPC.onboardingSetDismissed, { dismissed }), + callProjectRuntimeActionOr( + "onboarding", + "setDismissed", + { arg: dismissed }, + () => ipcRenderer.invoke(IPC.onboardingSetDismissed, { dismissed }), + ), complete: async (): Promise<OnboardingStatus> => - ipcRenderer.invoke(IPC.onboardingComplete), + callProjectRuntimeActionOr("onboarding", "complete", {}, () => + ipcRenderer.invoke(IPC.onboardingComplete), + ), getTourProgress: async (): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingGetTourProgress), + callProjectRuntimeActionOr("onboarding", "getTourProgress", {}, () => + ipcRenderer.invoke(IPC.onboardingGetTourProgress), + ), markWizardCompleted: async (): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingMarkWizardCompleted), + callProjectRuntimeActionOr("onboarding", "markWizardCompleted", {}, () => + ipcRenderer.invoke(IPC.onboardingMarkWizardCompleted), + ), markWizardDismissed: async (): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingMarkWizardDismissed), - markTourCompleted: async (tourId: string): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingMarkTourCompleted, { tourId }), - markTourDismissed: async (tourId: string): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingMarkTourDismissed, { tourId }), - updateTourStep: async (tourId: string, index: number): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingUpdateTourStep, { tourId, index }), - markGlossaryTermSeen: async (termId: string): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingMarkGlossaryTermSeen, { termId }), - resetTourProgress: async (tourId?: string): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingResetTourProgress, { tourId }), + callProjectRuntimeActionOr("onboarding", "markWizardDismissed", {}, () => + ipcRenderer.invoke(IPC.onboardingMarkWizardDismissed), + ), + markTourCompleted: async ( + tourId: string, + ): Promise<OnboardingTourProgress> => + callProjectRuntimeActionOr( + "onboarding", + "markTourCompleted", + { arg: tourId }, + () => ipcRenderer.invoke(IPC.onboardingMarkTourCompleted, { tourId }), + ), + markTourDismissed: async ( + tourId: string, + ): Promise<OnboardingTourProgress> => + callProjectRuntimeActionOr( + "onboarding", + "markTourDismissed", + { arg: tourId }, + () => ipcRenderer.invoke(IPC.onboardingMarkTourDismissed, { tourId }), + ), + updateTourStep: async ( + tourId: string, + index: number, + ): Promise<OnboardingTourProgress> => + callProjectRuntimeActionOr( + "onboarding", + "updateTourStep", + { argsList: [tourId, index] }, + () => + ipcRenderer.invoke(IPC.onboardingUpdateTourStep, { tourId, index }), + ), + markGlossaryTermSeen: async ( + termId: string, + ): Promise<OnboardingTourProgress> => + callProjectRuntimeActionOr( + "onboarding", + "markGlossaryTermSeen", + { arg: termId }, + () => + ipcRenderer.invoke(IPC.onboardingMarkGlossaryTermSeen, { termId }), + ), + resetTourProgress: async ( + tourId?: string, + ): Promise<OnboardingTourProgress> => + callProjectRuntimeActionOr( + "onboarding", + "resetTourProgress", + { arg: tourId }, + () => ipcRenderer.invoke(IPC.onboardingResetTourProgress, { tourId }), + ), markTourCompletedVariant: async ( tourId: string, variant: OnboardingTourVariant, ): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingMarkTourCompletedVariant, { tourId, variant }), + callProjectRuntimeActionOr( + "onboarding", + "markTourCompleted", + { argsList: [tourId, variant] }, + () => + ipcRenderer.invoke(IPC.onboardingMarkTourCompletedVariant, { + tourId, + variant, + }), + ), markTourDismissedVariant: async ( tourId: string, variant: OnboardingTourVariant, ): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingMarkTourDismissedVariant, { tourId, variant }), + callProjectRuntimeActionOr( + "onboarding", + "markTourDismissed", + { argsList: [tourId, variant] }, + () => + ipcRenderer.invoke(IPC.onboardingMarkTourDismissedVariant, { + tourId, + variant, + }), + ), updateTourStepVariant: async ( tourId: string, variant: OnboardingTourVariant, index: number, ): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingUpdateTourStepVariant, { tourId, variant, index }), + callProjectRuntimeActionOr( + "onboarding", + "updateTourStep", + { argsList: [tourId, index, variant] }, + () => + ipcRenderer.invoke(IPC.onboardingUpdateTourStepVariant, { + tourId, + variant, + index, + }), + ), tutorial: { start: async (): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingTutorialStart), + callProjectRuntimeActionOr( + "onboarding", + "markTutorialStarted", + {}, + () => ipcRenderer.invoke(IPC.onboardingTutorialStart), + ), dismiss: async (permanent: boolean): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingTutorialDismiss, { permanent }), + callProjectRuntimeActionOr( + "onboarding", + "markTutorialDismissed", + { arg: permanent }, + () => + ipcRenderer.invoke(IPC.onboardingTutorialDismiss, { permanent }), + ), complete: async (): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingTutorialComplete), + callProjectRuntimeActionOr( + "onboarding", + "markTutorialCompleted", + {}, + () => ipcRenderer.invoke(IPC.onboardingTutorialComplete), + ), updateAct: async ( actIndex: number, ctxSnapshot?: Record<string, unknown>, ): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingTutorialUpdateAct, { actIndex, ctxSnapshot }), + callProjectRuntimeActionOr( + "onboarding", + "updateTutorialAct", + { argsList: [actIndex, ctxSnapshot] }, + () => + ipcRenderer.invoke(IPC.onboardingTutorialUpdateAct, { + actIndex, + ctxSnapshot, + }), + ), setSilenced: async (silenced: boolean): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingTutorialSetSilenced, { silenced }), + callProjectRuntimeActionOr( + "onboarding", + "setTutorialSilenced", + { arg: silenced }, + () => + ipcRenderer.invoke(IPC.onboardingTutorialSetSilenced, { silenced }), + ), clearSessionDismissal: async (): Promise<OnboardingTourProgress> => - ipcRenderer.invoke(IPC.onboardingTutorialClearSessionDismissal), + callProjectRuntimeActionOr( + "onboarding", + "clearTutorialSessionDismissal", + {}, + () => ipcRenderer.invoke(IPC.onboardingTutorialClearSessionDismissal), + ), shouldPrompt: async (): Promise<boolean> => - ipcRenderer.invoke(IPC.onboardingTutorialShouldPrompt), + callProjectRuntimeActionOr( + "onboarding", + "shouldPromptTutorial", + {}, + () => ipcRenderer.invoke(IPC.onboardingTutorialShouldPrompt), + ), }, }, automations: { list: async (): Promise<AutomationRuleSummary[]> => - ipcRenderer.invoke(IPC.automationsList), + callProjectRuntimeActionOr("automations", "list", {}, () => + ipcRenderer.invoke(IPC.automationsList), + ), toggle: async (args: { id: string; enabled: boolean; }): Promise<AutomationRuleSummary[]> => - ipcRenderer.invoke(IPC.automationsToggle, args), + callProjectRuntimeActionOr("automations", "toggleRule", { args }, () => + ipcRenderer.invoke(IPC.automationsToggle, args), + ), deleteRule: async ( args: AutomationDeleteRuleRequest, ): Promise<AutomationRuleSummary[]> => - ipcRenderer.invoke(IPC.automationsDeleteRule, args), + callProjectRuntimeActionOr("automations", "deleteRule", { args }, () => + ipcRenderer.invoke(IPC.automationsDeleteRule, args), + ), triggerManually: async ( args: AutomationManualTriggerRequest, ): Promise<AutomationRun> => - ipcRenderer.invoke(IPC.automationsTriggerManually, args), + callProjectRuntimeActionOr( + "automations", + "triggerManually", + { args }, + () => ipcRenderer.invoke(IPC.automationsTriggerManually, args), + ), getHistory: async (args: { id: string; limit?: number; }): Promise<AutomationRun[]> => - ipcRenderer.invoke(IPC.automationsGetHistory, args), + callProjectRuntimeActionOr("automations", "getHistory", { args }, () => + ipcRenderer.invoke(IPC.automationsGetHistory, args), + ), listRuns: async (args?: AutomationRunListArgs): Promise<AutomationRun[]> => - ipcRenderer.invoke(IPC.automationsListRuns, args ?? {}), + callProjectRuntimeActionOr( + "automations", + "listRuns", + { args: args ?? {} }, + () => ipcRenderer.invoke(IPC.automationsListRuns, args ?? {}), + ), getRunDetail: async (runId: string): Promise<AutomationRunDetail | null> => - ipcRenderer.invoke(IPC.automationsGetRunDetail, { runId }), + callProjectRuntimeActionOr( + "automations", + "getRunDetail", + { args: { runId } }, + () => ipcRenderer.invoke(IPC.automationsGetRunDetail, { runId }), + ), getIngressStatus: async (): Promise<AutomationIngressStatus> => - ipcRenderer.invoke(IPC.automationsGetIngressStatus), + callProjectRuntimeActionOr("automations", "getIngressStatus", {}, () => + ipcRenderer.invoke(IPC.automationsGetIngressStatus), + ), listIngressEvents: async (args?: { limit?: number; }): Promise<AutomationIngressEventRecord[]> => - ipcRenderer.invoke(IPC.automationsListIngressEvents, args ?? {}), + callProjectRuntimeActionOr( + "automations", + "listIngressEvents", + { args: args ?? {} }, + () => ipcRenderer.invoke(IPC.automationsListIngressEvents, args ?? {}), + ), parseNaturalLanguage: async ( req: AutomationParseNaturalLanguageRequest, ): Promise<AutomationParseNaturalLanguageResult> => - ipcRenderer.invoke(IPC.automationsParseNaturalLanguage, req), + callProjectRuntimeActionOr( + "automation_planner", + "parseNaturalLanguage", + { args: req }, + () => ipcRenderer.invoke(IPC.automationsParseNaturalLanguage, req), + ), validateDraft: async ( req: AutomationValidateDraftRequest, ): Promise<AutomationValidateDraftResult> => - ipcRenderer.invoke(IPC.automationsValidateDraft, req), + callProjectRuntimeActionOr( + "automation_planner", + "validateDraft", + { args: req }, + () => ipcRenderer.invoke(IPC.automationsValidateDraft, req), + ), saveDraft: async ( req: AutomationSaveDraftRequest, ): Promise<AutomationSaveDraftResult> => - ipcRenderer.invoke(IPC.automationsSaveDraft, req), + callProjectRuntimeActionOr( + "automation_planner", + "saveDraft", + { args: req }, + () => ipcRenderer.invoke(IPC.automationsSaveDraft, req), + ), simulate: async ( req: AutomationSimulateRequest, ): Promise<AutomationSimulateResult> => - ipcRenderer.invoke(IPC.automationsSimulate, req), + callProjectRuntimeActionOr( + "automation_planner", + "simulate", + { args: req }, + () => ipcRenderer.invoke(IPC.automationsSimulate, req), + ), onEvent: (cb: (ev: AutomationsEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -1460,33 +3233,70 @@ contextBridge.exposeInMainWorld("ade", { }, review: { listLaunchContext: async (): Promise<ReviewLaunchContext> => - ipcRenderer.invoke(IPC.reviewListLaunchContext), + callProjectRuntimeActionOr("review", "listLaunchContext", {}, () => + ipcRenderer.invoke(IPC.reviewListLaunchContext), + ), listRuns: async (args: ReviewListRunsArgs = {}): Promise<ReviewRun[]> => - ipcRenderer.invoke(IPC.reviewListRuns, args), + callProjectRuntimeActionOr("review", "listRuns", { args }, () => + ipcRenderer.invoke(IPC.reviewListRuns, args), + ), getRunDetail: async (runId: string): Promise<ReviewRunDetail | null> => - ipcRenderer.invoke(IPC.reviewGetRunDetail, { runId }), + callProjectRuntimeActionOr( + "review", + "getRunDetail", + { args: { runId } }, + () => ipcRenderer.invoke(IPC.reviewGetRunDetail, { runId }), + ), startRun: async (args: ReviewStartRunArgs): Promise<ReviewRun> => - ipcRenderer.invoke(IPC.reviewStartRun, args), + callProjectRuntimeActionOr("review", "startRun", { args }, () => + ipcRenderer.invoke(IPC.reviewStartRun, args), + ), rerun: async (runId: string): Promise<ReviewRun> => - ipcRenderer.invoke(IPC.reviewRerun, { runId }), + callProjectRuntimeActionOr("review", "rerun", { arg: runId }, () => + ipcRenderer.invoke(IPC.reviewRerun, { runId }), + ), cancelRun: async (runId: string): Promise<ReviewRun | null> => - ipcRenderer.invoke(IPC.reviewCancelRun, { runId }), + callProjectRuntimeActionOr( + "review", + "cancelRun", + { args: { runId } }, + () => ipcRenderer.invoke(IPC.reviewCancelRun, { runId }), + ), recordFeedback: async ( - args: import("../shared/types").ReviewRecordFeedbackArgs, - ): Promise<import("../shared/types").ReviewFeedbackRecord> => - ipcRenderer.invoke(IPC.reviewRecordFeedback, args), + args: ReviewRecordFeedbackArgs, + ): Promise<ReviewFeedbackRecord> => + callProjectRuntimeActionOr("review", "recordFeedback", { args }, () => + ipcRenderer.invoke(IPC.reviewRecordFeedback, args), + ), listSuppressions: async ( - args: import("../shared/types").ReviewListSuppressionsArgs = {}, - ): Promise<import("../shared/types").ReviewSuppression[]> => - ipcRenderer.invoke(IPC.reviewListSuppressions, args), + args: ReviewListSuppressionsArgs = {}, + ): Promise<ReviewSuppression[]> => + callProjectRuntimeActionOr("review", "listSuppressions", { args }, () => + ipcRenderer.invoke(IPC.reviewListSuppressions, args), + ), deleteSuppression: async (suppressionId: string): Promise<boolean> => - ipcRenderer.invoke(IPC.reviewDeleteSuppression, { suppressionId }), - qualityReport: async (): Promise<import("../shared/types").ReviewQualityReport> => - ipcRenderer.invoke(IPC.reviewQualityReport), + callProjectRuntimeActionOr( + "review", + "deleteSuppression", + { args: { suppressionId } }, + () => + ipcRenderer.invoke(IPC.reviewDeleteSuppression, { suppressionId }), + ), + qualityReport: async (): Promise<ReviewQualityReport> => + callProjectRuntimeActionOr("review", "qualityReport", {}, () => + ipcRenderer.invoke(IPC.reviewQualityReport), + ), onEvent: (cb: (ev: ReviewEventPayload) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: ReviewEventPayload) => cb(payload); + const listener = ( + _event: Electron.IpcRendererEvent, + payload: ReviewEventPayload, + ) => cb(payload); ipcRenderer.on(IPC.reviewEvent, listener); - return () => ipcRenderer.removeListener(IPC.reviewEvent, listener); + const removeRemote = subscribeRemoteReviewEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.reviewEvent, listener); + }; }, }, actions: { @@ -1495,15 +3305,21 @@ contextBridge.exposeInMainWorld("ade", { }, usage: { getSnapshot: async (): Promise<UsageSnapshot | null> => - ipcRenderer.invoke(IPC.usageGetSnapshot), + callProjectRuntimeActionOr("usage", "getUsageSnapshot", {}, () => + ipcRenderer.invoke(IPC.usageGetSnapshot), + ), refresh: async (): Promise<UsageSnapshot | null> => - ipcRenderer.invoke(IPC.usageRefresh), + callProjectRuntimeActionOr("usage", "forceRefresh", {}, () => + ipcRenderer.invoke(IPC.usageRefresh), + ), checkBudget: async (args: { scope: BudgetCapScope; scopeId?: string; provider: BudgetCapProvider; }): Promise<BudgetCheckResult> => - ipcRenderer.invoke(IPC.usageCheckBudget, args), + callProjectRuntimeActionOr("budget", "checkBudget", { args }, () => + ipcRenderer.invoke(IPC.usageCheckBudget, args), + ), getCumulativeUsage: async (args: { scope: BudgetCapScope; scopeId?: string; @@ -1512,13 +3328,23 @@ contextBridge.exposeInMainWorld("ade", { totalTokens: number; totalCostUsd: number; weekKey: string; - }> => ipcRenderer.invoke(IPC.usageGetCumulativeUsage, args), + }> => + callProjectRuntimeActionOr("budget", "getCumulativeUsage", { args }, () => + ipcRenderer.invoke(IPC.usageGetCumulativeUsage, args), + ), getBudgetConfig: async (): Promise<BudgetCapConfig> => - ipcRenderer.invoke(IPC.usageGetBudgetConfig), + callProjectRuntimeActionOr("budget", "getConfig", {}, () => + ipcRenderer.invoke(IPC.usageGetBudgetConfig), + ), saveBudgetConfig: async ( config: BudgetCapConfig, ): Promise<BudgetCapConfig> => - ipcRenderer.invoke(IPC.usageSaveBudgetConfig, config), + callProjectRuntimeActionOr( + "budget", + "updateConfig", + { args: config }, + () => ipcRenderer.invoke(IPC.usageSaveBudgetConfig, config), + ), onUpdate: (cb: (snapshot: UsageSnapshot) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -1530,87 +3356,158 @@ contextBridge.exposeInMainWorld("ade", { }, missions: { list: async (args: ListMissionsArgs = {}): Promise<MissionSummary[]> => - ipcRenderer.invoke(IPC.missionsList, args), + callProjectRuntimeActionOr("mission", "list", { args }, () => + ipcRenderer.invoke(IPC.missionsList, args), + ), get: async (missionId: string): Promise<MissionDetail | null> => - ipcRenderer.invoke(IPC.missionsGet, { missionId }), + callProjectRuntimeActionOr("mission", "get", { arg: missionId }, () => + ipcRenderer.invoke(IPC.missionsGet, { missionId }), + ), create: async (args: CreateMissionArgs): Promise<MissionDetail> => - ipcRenderer.invoke(IPC.missionsCreate, args), + callProjectRuntimeActionOr("mission", "create", { args }, () => + ipcRenderer.invoke(IPC.missionsCreate, args), + ), update: async (args: UpdateMissionArgs): Promise<MissionDetail> => - ipcRenderer.invoke(IPC.missionsUpdate, args), + callProjectRuntimeActionOr("mission", "update", { args }, () => + ipcRenderer.invoke(IPC.missionsUpdate, args), + ), archive: async (args: ArchiveMissionArgs): Promise<void> => - ipcRenderer.invoke(IPC.missionsArchive, args), + callProjectRuntimeActionOr("mission", "archive", { args }, () => + ipcRenderer.invoke(IPC.missionsArchive, args), + ), delete: async (args: DeleteMissionArgs): Promise<void> => - ipcRenderer.invoke(IPC.missionsDelete, args), + callProjectRuntimeActionOr("mission", "delete", { args }, () => + ipcRenderer.invoke(IPC.missionsDelete, args), + ), updateStep: async (args: UpdateMissionStepArgs): Promise<MissionStep> => - ipcRenderer.invoke(IPC.missionsUpdateStep, args), + callProjectRuntimeActionOr("mission", "updateStep", { args }, () => + ipcRenderer.invoke(IPC.missionsUpdateStep, args), + ), addArtifact: async ( args: AddMissionArtifactArgs, ): Promise<MissionArtifact> => - ipcRenderer.invoke(IPC.missionsAddArtifact, args), + callProjectRuntimeActionOr("mission", "addArtifact", { args }, () => + ipcRenderer.invoke(IPC.missionsAddArtifact, args), + ), addIntervention: async ( args: AddMissionInterventionArgs, ): Promise<MissionIntervention> => - ipcRenderer.invoke(IPC.missionsAddIntervention, args), + callProjectRuntimeActionOr("mission", "addIntervention", { args }, () => + ipcRenderer.invoke(IPC.missionsAddIntervention, args), + ), resolveIntervention: async ( args: ResolveMissionInterventionArgs, ): Promise<MissionIntervention> => - ipcRenderer.invoke(IPC.missionsResolveIntervention, args), + callProjectRuntimeActionOr( + "mission", + "resolveIntervention", + { args }, + () => ipcRenderer.invoke(IPC.missionsResolveIntervention, args), + ), listPhaseItems: async ( args: ListPhaseItemsArgs = {}, ): Promise<PhaseCard[]> => - ipcRenderer.invoke(IPC.missionsListPhaseItems, args), + callProjectRuntimeActionOr("mission", "listPhaseItems", { args }, () => + ipcRenderer.invoke(IPC.missionsListPhaseItems, args), + ), savePhaseItem: async (args: SavePhaseItemArgs): Promise<PhaseCard> => - ipcRenderer.invoke(IPC.missionsSavePhaseItem, args), + callProjectRuntimeActionOr("mission", "savePhaseItem", { args }, () => + ipcRenderer.invoke(IPC.missionsSavePhaseItem, args), + ), deletePhaseItem: async (args: DeletePhaseItemArgs): Promise<void> => - ipcRenderer.invoke(IPC.missionsDeletePhaseItem, args), + callProjectRuntimeActionOr("mission", "deletePhaseItem", { args }, () => + ipcRenderer.invoke(IPC.missionsDeletePhaseItem, args), + ).then(() => undefined), importPhaseItems: async ( args: ImportPhaseItemsArgs, ): Promise<PhaseCard[]> => - ipcRenderer.invoke(IPC.missionsImportPhaseItems, args), + callProjectRuntimeActionOr("mission", "importPhaseItems", { args }, () => + ipcRenderer.invoke(IPC.missionsImportPhaseItems, args), + ), exportPhaseItems: async ( args: ExportPhaseItemsArgs = {}, ): Promise<ExportPhaseItemsResult> => - ipcRenderer.invoke(IPC.missionsExportPhaseItems, args), + callProjectRuntimeActionOr("mission", "exportPhaseItems", { args }, () => + ipcRenderer.invoke(IPC.missionsExportPhaseItems, args), + ), listPhaseProfiles: async ( args: ListPhaseProfilesArgs = {}, ): Promise<PhaseProfile[]> => - ipcRenderer.invoke(IPC.missionsListPhaseProfiles, args), + callProjectRuntimeActionOr("mission", "listPhaseProfiles", { args }, () => + ipcRenderer.invoke(IPC.missionsListPhaseProfiles, args), + ), savePhaseProfile: async ( args: SavePhaseProfileArgs, ): Promise<PhaseProfile> => - ipcRenderer.invoke(IPC.missionsSavePhaseProfile, args), + callProjectRuntimeActionOr("mission", "savePhaseProfile", { args }, () => + ipcRenderer.invoke(IPC.missionsSavePhaseProfile, args), + ), deletePhaseProfile: async (args: DeletePhaseProfileArgs): Promise<void> => - ipcRenderer.invoke(IPC.missionsDeletePhaseProfile, args), + callProjectRuntimeActionOr( + "mission", + "deletePhaseProfile", + { args }, + () => ipcRenderer.invoke(IPC.missionsDeletePhaseProfile, args), + ).then(() => undefined), clonePhaseProfile: async ( args: ClonePhaseProfileArgs, ): Promise<PhaseProfile> => - ipcRenderer.invoke(IPC.missionsClonePhaseProfile, args), + callProjectRuntimeActionOr("mission", "clonePhaseProfile", { args }, () => + ipcRenderer.invoke(IPC.missionsClonePhaseProfile, args), + ), exportPhaseProfile: async ( args: ExportPhaseProfileArgs, ): Promise<ExportPhaseProfileResult> => - ipcRenderer.invoke(IPC.missionsExportPhaseProfile, args), + callProjectRuntimeActionOr( + "mission", + "exportPhaseProfile", + { args }, + () => ipcRenderer.invoke(IPC.missionsExportPhaseProfile, args), + ), importPhaseProfile: async ( args: ImportPhaseProfileArgs, ): Promise<PhaseProfile> => - ipcRenderer.invoke(IPC.missionsImportPhaseProfile, args), + callProjectRuntimeActionOr( + "mission", + "importPhaseProfile", + { args }, + () => ipcRenderer.invoke(IPC.missionsImportPhaseProfile, args), + ), getPhaseConfiguration: async ( missionId: string, ): Promise<MissionPhaseConfiguration | null> => - ipcRenderer.invoke(IPC.missionsGetPhaseConfiguration, { missionId }), + callProjectRuntimeActionOr( + "mission", + "getPhaseConfiguration", + { arg: missionId }, + () => + ipcRenderer.invoke(IPC.missionsGetPhaseConfiguration, { missionId }), + ), getDashboard: async (): Promise<MissionDashboardSnapshot> => - ipcRenderer.invoke(IPC.missionsGetDashboard), + callProjectRuntimeActionOr("mission", "getDashboard", {}, () => + ipcRenderer.invoke(IPC.missionsGetDashboard), + ), getFullMissionView: async ( args: GetFullMissionViewArgs, ): Promise<FullMissionViewResult> => - ipcRenderer.invoke(IPC.missionsGetFullMissionView, args), + callProjectRuntimeActionOr( + "mission", + "getFullMissionView", + { args }, + () => ipcRenderer.invoke(IPC.missionsGetFullMissionView, args), + ), preflight: async ( args: MissionPreflightRequest, ): Promise<MissionPreflightResult> => - ipcRenderer.invoke(IPC.missionsPreflight, args), + callProjectRuntimeActionOr("mission", "preflight", { args }, () => + ipcRenderer.invoke(IPC.missionsPreflight, args), + ), getRunView: async ( args: GetMissionRunViewArgs, ): Promise<MissionRunView | null> => - ipcRenderer.invoke(IPC.missionsGetRunView, args), + callProjectRuntimeActionOr("mission", "getRunView", { args }, () => + ipcRenderer.invoke(IPC.missionsGetRunView, args), + ), subscribeRunView: ( args: GetMissionRunViewArgs, cb: (view: MissionRunView | null) => void, @@ -1626,17 +3523,21 @@ contextBridge.exposeInMainWorld("ade", { return; } inFlight = true; - void ipcRenderer.invoke(IPC.missionsGetRunView, args).then( - (view: MissionRunView | null) => { - if (!disposed) cb(view); - }, - () => {}, - ).finally(() => { - inFlight = false; - if (disposed || !pending) return; - pending = false; - scheduleRefresh(350); - }); + void callProjectRuntimeActionOr("mission", "getRunView", { args }, () => + ipcRenderer.invoke(IPC.missionsGetRunView, args), + ) + .then( + (view: MissionRunView | null) => { + if (!disposed) cb(view); + }, + () => {}, + ) + .finally(() => { + inFlight = false; + if (disposed || !pending) return; + pending = false; + scheduleRefresh(350); + }); }; const scheduleRefresh = (delayMs = 650) => { if (disposed) return; @@ -1679,10 +3580,35 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.on(IPC.orchestratorEvent, runtimeListener); ipcRenderer.on(IPC.orchestratorThreadEvent, threadListener); ipcRenderer.on(IPC.orchestratorDagMutation, dagListener); + const removeRemoteMission = subscribeRemoteMissionEvents((payload) => { + if (payload.missionId !== args.missionId) return; + scheduleRefresh(); + }); + const removeRemoteOrchestrator = subscribeRemoteOrchestratorEvents( + (payload) => { + if (args.runId && payload.runId !== args.runId) return; + scheduleRefresh(); + }, + ); + const removeRemoteThread = subscribeRemoteOrchestratorThreadEvents( + (payload) => { + if (payload.missionId !== args.missionId) return; + if (args.runId && payload.runId !== args.runId) return; + scheduleRefresh(750); + }, + ); + const removeRemoteDag = subscribeRemoteDagMutationEvents((payload) => { + if (args.runId && payload.runId !== args.runId) return; + scheduleRefresh(750); + }); refresh(); return () => { disposed = true; if (refreshTimer) clearTimeout(refreshTimer); + removeRemoteMission(); + removeRemoteOrchestrator(); + removeRemoteThread(); + removeRemoteDag(); ipcRenderer.removeListener(IPC.missionsEvent, missionListener); ipcRenderer.removeListener(IPC.orchestratorEvent, runtimeListener); ipcRenderer.removeListener(IPC.orchestratorThreadEvent, threadListener); @@ -1695,202 +3621,461 @@ contextBridge.exposeInMainWorld("ade", { payload: MissionsEventPayload, ) => cb(payload); ipcRenderer.on(IPC.missionsEvent, listener); - return () => ipcRenderer.removeListener(IPC.missionsEvent, listener); + const removeRemote = subscribeRemoteMissionEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.missionsEvent, listener); + }; }, }, orchestrator: { listRuns: async ( args: ListOrchestratorRunsArgs = {}, ): Promise<OrchestratorRun[]> => - ipcRenderer.invoke(IPC.orchestratorListRuns, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "listRuns", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorListRuns, args), + ), getRunGraph: async ( args: GetOrchestratorRunGraphArgs, ): Promise<OrchestratorRunGraph> => - ipcRenderer.invoke(IPC.orchestratorGetRunGraph, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "getRunGraph", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetRunGraph, args), + ), startRun: async ( args: StartOrchestratorRunArgs, ): Promise<{ run: OrchestratorRun; steps: OrchestratorStep[] }> => - ipcRenderer.invoke(IPC.orchestratorStartRun, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "startRun", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorStartRun, args), + ), startRunFromMission: async ( args: StartOrchestratorRunFromMissionArgs, - ): Promise<{ run: OrchestratorRun; steps: OrchestratorStep[] }> => - ipcRenderer.invoke(IPC.orchestratorStartRunFromMission, args), + ): Promise<{ run: OrchestratorRun; steps: OrchestratorStep[] }> => { + const launch = + await callProjectRuntimeActionOr<StartMissionRunWithAIResult>( + "orchestrator", + "startMissionRun", + { + args: { + missionId: args.missionId, + runMode: args.runMode, + autopilotOwnerId: args.autopilotOwnerId, + defaultExecutorKind: args.defaultExecutorKind, + defaultRetryLimit: args.defaultRetryLimit, + metadata: args.metadata ?? null, + plannerProvider: args.plannerProvider ?? undefined, + }, + }, + () => + ipcRenderer.invoke(IPC.orchestratorStartMissionRun, { + missionId: args.missionId, + runMode: args.runMode, + autopilotOwnerId: args.autopilotOwnerId, + defaultExecutorKind: args.defaultExecutorKind, + defaultRetryLimit: args.defaultRetryLimit, + metadata: args.metadata ?? null, + plannerProvider: args.plannerProvider ?? undefined, + }), + ); + if (!launch.started) { + throw new Error("Mission run did not produce a runnable execution."); + } + return launch.started; + }, startAttempt: async ( args: StartOrchestratorAttemptArgs, ): Promise<OrchestratorAttempt> => - ipcRenderer.invoke(IPC.orchestratorStartAttempt, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "startAttempt", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorStartAttempt, args), + ), completeAttempt: async ( args: CompleteOrchestratorAttemptArgs, ): Promise<OrchestratorAttempt> => - ipcRenderer.invoke(IPC.orchestratorCompleteAttempt, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "completeAttempt", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorCompleteAttempt, args), + ), tickRun: async (args: TickOrchestratorRunArgs): Promise<OrchestratorRun> => - ipcRenderer.invoke(IPC.orchestratorTickRun, args), + callProjectRuntimeActionOr("orchestrator_core", "tick", { args }, () => + ipcRenderer.invoke(IPC.orchestratorTickRun, args), + ), pauseRun: async ( args: PauseOrchestratorRunArgs, ): Promise<OrchestratorRun> => - ipcRenderer.invoke(IPC.orchestratorPauseRun, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "pauseRun", + { + args: { + runId: args.runId, + reason: args.reason ?? "Paused from Missions UI.", + }, + }, + () => ipcRenderer.invoke(IPC.orchestratorPauseRun, args), + ), resumeRun: async ( args: ResumeOrchestratorRunArgs, ): Promise<OrchestratorRun> => - ipcRenderer.invoke(IPC.orchestratorResumeRun, args), + callProjectRuntimeActionOr("orchestrator", "resumeRun", { args }, () => + ipcRenderer.invoke(IPC.orchestratorResumeRun, args), + ), cancelRun: async ( args: CancelOrchestratorRunArgs, ): Promise<OrchestratorRun> => - ipcRenderer.invoke(IPC.orchestratorCancelRun, args), + callProjectRuntimeActionOr( + "orchestrator", + "cancelRunGracefully", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorCancelRun, args), + ), cleanupTeamResources: async ( args: CleanupOrchestratorTeamResourcesArgs, ): Promise<CleanupOrchestratorTeamResourcesResult> => - ipcRenderer.invoke(IPC.orchestratorCleanupTeamResources, args), + callProjectRuntimeActionOr( + "orchestrator", + "cleanupTeamResources", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorCleanupTeamResources, args), + ), heartbeatClaims: async ( args: HeartbeatOrchestratorClaimsArgs, ): Promise<number> => - ipcRenderer.invoke(IPC.orchestratorHeartbeatClaims, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "heartbeatClaims", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorHeartbeatClaims, args), + ), listTimeline: async ( args: ListOrchestratorTimelineArgs, ): Promise<OrchestratorTimelineEvent[]> => - ipcRenderer.invoke(IPC.orchestratorListTimeline, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "listTimeline", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorListTimeline, args), + ), getMissionLogs: async ( args: GetMissionLogsArgs, ): Promise<GetMissionLogsResult> => - ipcRenderer.invoke(IPC.orchestratorGetMissionLogs, args), + callProjectRuntimeActionOr( + "orchestrator", + "getMissionLogs", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetMissionLogs, args), + ), exportMissionLogs: async ( args: ExportMissionLogsArgs, ): Promise<ExportMissionLogsResult> => - ipcRenderer.invoke(IPC.orchestratorExportMissionLogs, args), + callProjectRuntimeActionOr( + "orchestrator", + "exportMissionLogs", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorExportMissionLogs, args), + ), getGateReport: async ( args: GetOrchestratorGateReportArgs = {}, ): Promise<OrchestratorGateReport> => - ipcRenderer.invoke(IPC.orchestratorGetGateReport, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "getLatestGateReport", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetGateReport, args), + ), getWorkerStates: async ( args: GetOrchestratorWorkerStatesArgs, ): Promise<OrchestratorWorkerState[]> => - ipcRenderer.invoke(IPC.orchestratorGetWorkerStates, args), + callProjectRuntimeActionOr( + "orchestrator", + "getWorkerStates", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetWorkerStates, args), + ), startMissionRun: async ( args: StartMissionRunWithAIArgs, ): Promise<StartMissionRunWithAIResult> => - ipcRenderer.invoke(IPC.orchestratorStartMissionRun, args), + callProjectRuntimeActionOr( + "orchestrator", + "startMissionRun", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorStartMissionRun, args), + ), steerMission: async (args: SteerMissionArgs): Promise<SteerMissionResult> => - ipcRenderer.invoke(IPC.orchestratorSteerMission, args), + callProjectRuntimeActionOr("orchestrator", "steerMission", { args }, () => + ipcRenderer.invoke(IPC.orchestratorSteerMission, args), + ), getModelCapabilities: async (): Promise<GetModelCapabilitiesResult> => - ipcRenderer.invoke(IPC.orchestratorGetModelCapabilities), + callProjectRuntimeActionOr( + "orchestrator", + "getModelCapabilities", + {}, + () => ipcRenderer.invoke(IPC.orchestratorGetModelCapabilities), + ), getTeamMembers: async ( args: GetTeamMembersArgs, ): Promise<OrchestratorTeamMember[]> => - ipcRenderer.invoke(IPC.orchestratorGetTeamMembers, args), + callProjectRuntimeActionOr( + "orchestrator", + "getTeamMembers", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetTeamMembers, args), + ), getTeamRuntimeState: async ( args: GetTeamRuntimeStateArgs, ): Promise<OrchestratorTeamRuntimeState | null> => - ipcRenderer.invoke(IPC.orchestratorGetTeamRuntimeState, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "getRunState", + { arg: args.runId }, + () => ipcRenderer.invoke(IPC.orchestratorGetTeamRuntimeState, args), + ), finalizeRun: async (args: FinalizeRunArgs): Promise<FinalizeRunResult> => - ipcRenderer.invoke(IPC.orchestratorFinalizeRun, args), + callProjectRuntimeActionOr("orchestrator", "finalizeRun", { args }, () => + ipcRenderer.invoke(IPC.orchestratorFinalizeRun, args), + ), sendChat: async ( args: SendOrchestratorChatArgs, ): Promise<OrchestratorChatMessage> => - ipcRenderer.invoke(IPC.orchestratorSendChat, args), + callProjectRuntimeActionOr("orchestrator", "sendChat", { args }, () => + ipcRenderer.invoke(IPC.orchestratorSendChat, args), + ), getChat: async ( args: GetOrchestratorChatArgs, ): Promise<OrchestratorChatMessage[]> => - ipcRenderer.invoke(IPC.orchestratorGetChat, args), + callProjectRuntimeActionOr("orchestrator", "getChat", { args }, () => + ipcRenderer.invoke(IPC.orchestratorGetChat, args), + ), listChatThreads: async ( args: ListOrchestratorChatThreadsArgs, ): Promise<OrchestratorChatThread[]> => - ipcRenderer.invoke(IPC.orchestratorListChatThreads, args), + callProjectRuntimeActionOr( + "orchestrator", + "listChatThreads", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorListChatThreads, args), + ), getThreadMessages: async ( args: GetOrchestratorThreadMessagesArgs, ): Promise<OrchestratorChatMessage[]> => - ipcRenderer.invoke(IPC.orchestratorGetThreadMessages, args), + callProjectRuntimeActionOr( + "orchestrator", + "getThreadMessages", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetThreadMessages, args), + ), sendThreadMessage: async ( args: SendOrchestratorThreadMessageArgs, ): Promise<OrchestratorChatMessage> => - ipcRenderer.invoke(IPC.orchestratorSendThreadMessage, args), + callProjectRuntimeActionOr( + "orchestrator", + "sendThreadMessage", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorSendThreadMessage, args), + ), getWorkerDigest: async ( args: GetOrchestratorWorkerDigestArgs, ): Promise<OrchestratorWorkerDigest | null> => - ipcRenderer.invoke(IPC.orchestratorGetWorkerDigest, args), + callProjectRuntimeActionOr( + "orchestrator", + "getWorkerDigest", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetWorkerDigest, args), + ), listWorkerDigests: async ( args: ListOrchestratorWorkerDigestsArgs, ): Promise<OrchestratorWorkerDigest[]> => - ipcRenderer.invoke(IPC.orchestratorListWorkerDigests, args), + callProjectRuntimeActionOr( + "orchestrator", + "listWorkerDigests", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorListWorkerDigests, args), + ), getContextCheckpoint: async ( args: GetOrchestratorContextCheckpointArgs, ): Promise<OrchestratorContextCheckpoint | null> => - ipcRenderer.invoke(IPC.orchestratorGetContextCheckpoint, args), + callProjectRuntimeActionOr( + "orchestrator", + "getContextCheckpoint", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetContextCheckpoint, args), + ), listLaneDecisions: async ( args: ListOrchestratorLaneDecisionsArgs, ): Promise<OrchestratorLaneDecision[]> => - ipcRenderer.invoke(IPC.orchestratorListLaneDecisions, args), + callProjectRuntimeActionOr( + "orchestrator", + "listLaneDecisions", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorListLaneDecisions, args), + ), getMissionMetrics: async ( args: GetMissionMetricsArgs, ): Promise<{ config: MissionMetricsConfig | null; samples: MissionMetricSample[]; - }> => ipcRenderer.invoke(IPC.orchestratorGetMissionMetrics, args), + }> => + callProjectRuntimeActionOr( + "orchestrator", + "getMissionMetrics", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetMissionMetrics, args), + ), setMissionMetricsConfig: async ( args: SetMissionMetricsConfigArgs, ): Promise<MissionMetricsConfig> => - ipcRenderer.invoke(IPC.orchestratorSetMissionMetricsConfig, args), + callProjectRuntimeActionOr( + "orchestrator", + "setMissionMetricsConfig", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorSetMissionMetricsConfig, args), + ), getExecutionPlanPreview: async (args: { runId: string; }): Promise<ExecutionPlanPreview | null> => - ipcRenderer.invoke(IPC.orchestratorGetExecutionPlanPreview, args), + callProjectRuntimeActionOr( + "orchestrator", + "getExecutionPlanPreview", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetExecutionPlanPreview, args), + ), getMissionStateDocument: async ( args: GetMissionStateDocumentArgs, ): Promise<MissionStateDocument | null> => - ipcRenderer.invoke(IPC.orchestratorGetMissionStateDocument, args), + callProjectRuntimeActionOr( + "orchestrator", + "getMissionStateDocument", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetMissionStateDocument, args), + ), listArtifacts: async ( args: ListOrchestratorArtifactsArgs, ): Promise<OrchestratorArtifact[]> => - ipcRenderer.invoke(IPC.orchestratorListArtifacts, args), + callProjectRuntimeActionOr( + "orchestrator", + "listArtifacts", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorListArtifacts, args), + ), listWorkerCheckpoints: async ( args: ListOrchestratorWorkerCheckpointsArgs, ): Promise<OrchestratorWorkerCheckpoint[]> => - ipcRenderer.invoke(IPC.orchestratorListWorkerCheckpoints, args), + callProjectRuntimeActionOr( + "orchestrator", + "listWorkerCheckpoints", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorListWorkerCheckpoints, args), + ), getPromptInspector: async ( args: GetOrchestratorPromptInspectorArgs, ): Promise<OrchestratorPromptInspector | null> => - ipcRenderer.invoke(IPC.orchestratorGetPromptInspector, args), + callProjectRuntimeActionOr( + "orchestrator", + "getPromptInspector", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetPromptInspector, args), + ), getPlanningPromptPreview: async ( args: GetPlanningPromptPreviewArgs, ): Promise<OrchestratorPromptInspector | null> => - ipcRenderer.invoke(IPC.orchestratorGetPlanningPromptPreview, args), + callProjectRuntimeActionOr( + "orchestrator", + "getPlanningPromptPreview", + { args }, + () => + ipcRenderer.invoke(IPC.orchestratorGetPlanningPromptPreview, args), + ), getCheckpointStatus: async (args: { runId: string; }): Promise<{ savedAt: string; turnCount: number; compactionCount: number; - } | null> => ipcRenderer.invoke(IPC.orchestratorGetCheckpointStatus, args), + } | null> => + callProjectRuntimeActionOr( + "orchestrator", + "getCheckpointStatus", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetCheckpointStatus, args), + ), getMissionBudgetStatus: async ( args: GetMissionBudgetStatusArgs, ): Promise<MissionBudgetSnapshot> => - ipcRenderer.invoke(IPC.orchestratorGetMissionBudgetStatus, args), + callProjectRuntimeActionOr( + "mission_budget", + "getMissionBudgetStatus", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetMissionBudgetStatus, args), + ), getMissionBudgetTelemetry: async ( args: GetMissionBudgetTelemetryArgs, ): Promise<MissionBudgetTelemetrySnapshot> => - ipcRenderer.invoke(IPC.orchestratorGetMissionBudgetTelemetry, args), + callProjectRuntimeActionOr( + "mission_budget", + "getMissionBudgetTelemetry", + { args }, + () => + ipcRenderer.invoke(IPC.orchestratorGetMissionBudgetTelemetry, args), + ), sendAgentMessage: async ( args: SendAgentMessageArgs, ): Promise<OrchestratorChatMessage> => - ipcRenderer.invoke(IPC.orchestratorSendAgentMessage, args), + callProjectRuntimeActionOr( + "orchestrator", + "sendAgentMessage", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorSendAgentMessage, args), + ), getGlobalChat: async ( args: GetGlobalChatArgs, ): Promise<OrchestratorChatMessage[]> => - ipcRenderer.invoke(IPC.orchestratorGetGlobalChat, args), + callProjectRuntimeActionOr( + "orchestrator", + "getGlobalChat", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetGlobalChat, args), + ), getActiveAgents: async ( args: GetActiveAgentsArgs, ): Promise<ActiveAgentInfo[]> => - ipcRenderer.invoke(IPC.orchestratorGetActiveAgents, args), + callProjectRuntimeActionOr( + "orchestrator", + "getActiveAgents", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetActiveAgents, args), + ), getAggregatedUsage: async ( args: GetAggregatedUsageArgs, ): Promise<AggregatedUsageStats> => - ipcRenderer.invoke(IPC.getAggregatedUsage, args), + callProjectRuntimeActionOr( + "orchestrator", + "getAggregatedUsage", + { args }, + () => ipcRenderer.invoke(IPC.getAggregatedUsage, args), + ), onEvent: (cb: (ev: OrchestratorRuntimeEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: OrchestratorRuntimeEvent, ) => cb(payload); ipcRenderer.on(IPC.orchestratorEvent, listener); - return () => ipcRenderer.removeListener(IPC.orchestratorEvent, listener); + const removeRemote = subscribeRemoteOrchestratorEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.orchestratorEvent, listener); + }; }, onThreadEvent: (cb: (ev: OrchestratorThreadEvent) => void) => { const listener = ( @@ -1898,8 +4083,11 @@ contextBridge.exposeInMainWorld("ade", { payload: OrchestratorThreadEvent, ) => cb(payload); ipcRenderer.on(IPC.orchestratorThreadEvent, listener); - return () => + const removeRemote = subscribeRemoteOrchestratorThreadEvents(cb); + return () => { + removeRemote(); ipcRenderer.removeListener(IPC.orchestratorThreadEvent, listener); + }; }, onDagMutation: (cb: (ev: DagMutationEvent) => void) => { const listener = ( @@ -1907,136 +4095,258 @@ contextBridge.exposeInMainWorld("ade", { payload: DagMutationEvent, ) => cb(payload); ipcRenderer.on(IPC.orchestratorDagMutation, listener); - return () => + const removeRemote = subscribeRemoteDagMutationEvents(cb); + return () => { + removeRemote(); ipcRenderer.removeListener(IPC.orchestratorDagMutation, listener); + }; }, }, lanes: { - list: async (args: ListLanesArgs = {}): Promise<LaneSummary[]> => - lanesListCache.get(serializeIpcCacheArgs(args)), + list: async (args: ListLanesArgs = {}): Promise<LaneSummary[]> => { + const runtime = await callProjectRuntimeActionIfBound<LaneSummary[]>( + "lane", + "list", + { args }, + ); + if (runtime.handled) return runtime.result; + return lanesListCache.get(serializeIpcCacheArgs(args)); + }, listSnapshots: async ( args: ListLanesArgs = {}, - ): Promise<LaneListSnapshot[]> => - lanesListSnapshotsCache.get(serializeIpcCacheArgs(args)), + ): Promise<LaneListSnapshot[]> => { + const runtime = await callProjectRuntimeActionIfBound<LaneListSnapshot[]>( + "lane", + "listSnapshots", + { args }, + ); + if (runtime.handled) return runtime.result; + return lanesListSnapshotsCache.get(serializeIpcCacheArgs(args)); + }, create: async (args: CreateLaneArgs): Promise<LaneSummary> => { clearGitReadCaches(); - const lane = await ipcRenderer.invoke(IPC.lanesCreate, args); + const lane = await callProjectRuntimeActionOr<LaneSummary>( + "lane", + "create", + { args }, + () => ipcRenderer.invoke(IPC.lanesCreate, args), + ); clearGitReadCaches(); - return lane; + return lane as LaneSummary; }, createChild: async (args: CreateChildLaneArgs): Promise<LaneSummary> => { clearGitReadCaches(); - const lane = await ipcRenderer.invoke(IPC.lanesCreateChild, args); + const lane = await callProjectRuntimeActionOr<LaneSummary>( + "lane", + "createChild", + { args }, + () => ipcRenderer.invoke(IPC.lanesCreateChild, args), + ); clearGitReadCaches(); - return lane; + return lane as LaneSummary; }, createFromUnstaged: async ( args: CreateLaneFromUnstagedArgs, ): Promise<LaneSummary> => { clearGitReadCaches(); - const lane = await ipcRenderer.invoke(IPC.lanesCreateFromUnstaged, args); + const lane = await callProjectRuntimeActionOr<LaneSummary>( + "lane", + "createFromUnstaged", + { args }, + () => ipcRenderer.invoke(IPC.lanesCreateFromUnstaged, args), + ); clearGitReadCaches(); - return lane; + return lane as LaneSummary; }, importBranch: async (args: ImportBranchLaneArgs): Promise<LaneSummary> => { clearGitReadCaches(); - const lane = await ipcRenderer.invoke(IPC.lanesImportBranch, args); + const lane = await callProjectRuntimeActionOr<LaneSummary>( + "lane", + "importBranch", + { args }, + () => ipcRenderer.invoke(IPC.lanesImportBranch, args), + ); clearGitReadCaches(); - return lane; + return lane as LaneSummary; }, previewBranchSwitch: async ( args: LaneBranchSwitchArgs, ): Promise<LaneBranchSwitchPreview> => - ipcRenderer.invoke(IPC.lanesPreviewBranchSwitch, args), + callProjectRuntimeActionOr("lane", "previewBranchSwitch", { args }, () => + ipcRenderer.invoke(IPC.lanesPreviewBranchSwitch, args), + ), switchBranch: async ( args: LaneBranchSwitchArgs, ): Promise<LaneBranchSwitchResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.lanesSwitchBranch, args); + const result = await callProjectRuntimeActionOr<LaneBranchSwitchResult>( + "lane", + "switchBranch", + { args }, + () => ipcRenderer.invoke(IPC.lanesSwitchBranch, args), + ); clearGitReadCaches(); - return result; + return result as LaneBranchSwitchResult; }, attach: async (args: AttachLaneArgs): Promise<LaneSummary> => { clearGitReadCaches(); - const lane = await ipcRenderer.invoke(IPC.lanesAttach, args); + const lane = await callProjectRuntimeActionOr<LaneSummary>( + "lane", + "attach", + { args }, + () => ipcRenderer.invoke(IPC.lanesAttach, args), + ); clearGitReadCaches(); - return lane; + return lane as LaneSummary; }, listUnregisteredWorktrees: async (): Promise<UnregisteredLaneCandidate[]> => - ipcRenderer.invoke(IPC.lanesListUnregisteredWorktrees), - adoptAttached: async (args: AdoptAttachedLaneArgs): Promise<LaneSummary> => { + callProjectRuntimeActionOr("lane", "listUnregisteredWorktrees", {}, () => + ipcRenderer.invoke(IPC.lanesListUnregisteredWorktrees), + ), + adoptAttached: async ( + args: AdoptAttachedLaneArgs, + ): Promise<LaneSummary> => { clearGitReadCaches(); - const lane = await ipcRenderer.invoke(IPC.lanesAdoptAttached, args); + const lane = await callProjectRuntimeActionOr<LaneSummary>( + "lane", + "adoptAttached", + { args }, + () => ipcRenderer.invoke(IPC.lanesAdoptAttached, args), + ); clearGitReadCaches(); - return lane; + return lane as LaneSummary; }, rename: async (args: RenameLaneArgs): Promise<void> => { clearGitReadCaches(); - await ipcRenderer.invoke(IPC.lanesRename, args); + await callProjectRuntimeActionOr("lane", "rename", { args }, () => + ipcRenderer.invoke(IPC.lanesRename, args), + ); clearGitReadCaches(); }, reparent: async (args: ReparentLaneArgs): Promise<ReparentLaneResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.lanesReparent, args); + const result = await callProjectRuntimeActionOr<ReparentLaneResult>( + "lane", + "reparent", + { args }, + () => ipcRenderer.invoke(IPC.lanesReparent, args), + ); clearGitReadCaches(); - return result; + return result as ReparentLaneResult; }, updateAppearance: async (args: UpdateLaneAppearanceArgs): Promise<void> => { clearGitReadCaches(); - await ipcRenderer.invoke(IPC.lanesUpdateAppearance, args); + await callProjectRuntimeActionOr( + "lane", + "updateAppearance", + { args }, + () => ipcRenderer.invoke(IPC.lanesUpdateAppearance, args), + ); clearGitReadCaches(); }, archive: async (args: ArchiveLaneArgs): Promise<void> => { clearGitReadCaches(); - await ipcRenderer.invoke(IPC.lanesArchive, args); + await callProjectRuntimeActionOr("lane", "archive", { args }, () => + ipcRenderer.invoke(IPC.lanesArchive, args), + ); clearGitReadCaches(); }, delete: async (args: DeleteLaneArgs): Promise<void> => { clearGitReadCaches(); - await ipcRenderer.invoke(IPC.lanesDelete, args); + await callProjectRuntimeActionOr("lane", "delete", { args }, () => + ipcRenderer.invoke(IPC.lanesDelete, args), + ); clearGitReadCaches(); }, - cancelDelete: async (args: { laneId: string }): Promise<{ cancelled: boolean; reason?: string }> => - ipcRenderer.invoke(IPC.lanesDeleteCancel, args), + cancelDelete: async (args: { + laneId: string; + }): Promise<{ cancelled: boolean; reason?: string }> => + callProjectRuntimeActionOr( + "lane", + "cancelDelete", + { arg: args.laneId }, + () => ipcRenderer.invoke(IPC.lanesDeleteCancel, args), + ), getDeleteRisk: async (args: { laneId: string }): Promise<LaneDeleteRisk> => - ipcRenderer.invoke(IPC.lanesGetDeleteRisk, args), + callProjectRuntimeActionOr( + "lane", + "getDeleteRisk", + { arg: args.laneId }, + () => ipcRenderer.invoke(IPC.lanesGetDeleteRisk, args), + ), onDeleteEvent: (cb: (ev: LaneDeleteEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: LaneDeleteEvent, ) => cb(payload); ipcRenderer.on(IPC.lanesDeleteEvent, listener); - return () => ipcRenderer.removeListener(IPC.lanesDeleteEvent, listener); + const removeRemote = subscribeRemoteLaneDeleteEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.lanesDeleteEvent, listener); + }; }, getStackChain: async (laneId: string): Promise<StackChainItem[]> => - ipcRenderer.invoke(IPC.lanesGetStackChain, { laneId }), + callProjectRuntimeActionOr("lane", "getStackChain", { arg: laneId }, () => + ipcRenderer.invoke(IPC.lanesGetStackChain, { laneId }), + ), getChildren: async (laneId: string): Promise<LaneSummary[]> => - ipcRenderer.invoke(IPC.lanesGetChildren, { laneId }), + callProjectRuntimeActionOr("lane", "getChildren", { arg: laneId }, () => + ipcRenderer.invoke(IPC.lanesGetChildren, { laneId }), + ), rebaseStart: async (args: RebaseStartArgs): Promise<RebaseStartResult> => - ipcRenderer.invoke(IPC.lanesRebaseStart, args), + callProjectRuntimeActionOr("lane", "rebaseStart", { args }, () => + ipcRenderer.invoke(IPC.lanesRebaseStart, args), + ), rebasePush: async (args: RebasePushArgs): Promise<RebaseRun> => - ipcRenderer.invoke(IPC.lanesRebasePush, args), + callProjectRuntimeActionOr("lane", "rebasePush", { args }, () => + ipcRenderer.invoke(IPC.lanesRebasePush, args), + ), rebaseRollback: async (args: RebaseRollbackArgs): Promise<RebaseRun> => - ipcRenderer.invoke(IPC.lanesRebaseRollback, args), + callProjectRuntimeActionOr("lane", "rebaseRollback", { args }, () => + ipcRenderer.invoke(IPC.lanesRebaseRollback, args), + ), rebaseAbort: async (args: RebaseAbortArgs): Promise<RebaseRun> => - ipcRenderer.invoke(IPC.lanesRebaseAbort, args), + callProjectRuntimeActionOr("lane", "rebaseAbort", { args }, () => + ipcRenderer.invoke(IPC.lanesRebaseAbort, args), + ), rebaseSubscribe: (cb: (ev: RebaseRunEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: RebaseRunEventPayload, ) => cb(payload); ipcRenderer.on(IPC.lanesRebaseEvent, listener); - return () => ipcRenderer.removeListener(IPC.lanesRebaseEvent, listener); + const removeRemote = subscribeRemoteLaneRebaseEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.lanesRebaseEvent, listener); + }; }, listRebaseSuggestions: async (): Promise<RebaseSuggestion[]> => - ipcRenderer.invoke(IPC.lanesListRebaseSuggestions), - dismissRebaseSuggestion: async (args: { laneId: string }): Promise<void> => - ipcRenderer.invoke(IPC.lanesDismissRebaseSuggestion, args), + callProjectRuntimeActionOr("lane", "listRebaseSuggestions", {}, () => + ipcRenderer.invoke(IPC.lanesListRebaseSuggestions), + ), + dismissRebaseSuggestion: async (args: { + laneId: string; + }): Promise<void> => { + await callProjectRuntimeActionOr( + "lane", + "dismissRebaseSuggestion", + { args }, + () => ipcRenderer.invoke(IPC.lanesDismissRebaseSuggestion, args), + ); + }, deferRebaseSuggestion: async (args: { laneId: string; minutes: number; - }): Promise<void> => - ipcRenderer.invoke(IPC.lanesDeferRebaseSuggestion, args), + }): Promise<void> => { + await callProjectRuntimeActionOr( + "lane", + "deferRebaseSuggestion", + { args }, + () => ipcRenderer.invoke(IPC.lanesDeferRebaseSuggestion, args), + ); + }, onRebaseSuggestionsEvent: ( cb: (ev: RebaseSuggestionsEventPayload) => void, ) => { @@ -2045,173 +4355,383 @@ contextBridge.exposeInMainWorld("ade", { payload: RebaseSuggestionsEventPayload, ) => cb(payload); ipcRenderer.on(IPC.lanesRebaseSuggestionsEvent, listener); - return () => + const removeRemote = subscribeRemoteLaneRebaseSuggestionsEvents(cb); + return () => { + removeRemote(); ipcRenderer.removeListener(IPC.lanesRebaseSuggestionsEvent, listener); + }; }, listAutoRebaseStatuses: async (): Promise<AutoRebaseLaneStatus[]> => - ipcRenderer.invoke(IPC.lanesListAutoRebaseStatuses), - dismissAutoRebaseStatus: async (args: { laneId: string }): Promise<void> => - ipcRenderer.invoke(IPC.lanesDismissAutoRebaseStatus, args), + callProjectRuntimeActionOr("lane", "listAutoRebaseStatuses", {}, () => + ipcRenderer.invoke(IPC.lanesListAutoRebaseStatuses), + ), + dismissAutoRebaseStatus: async (args: { + laneId: string; + }): Promise<void> => { + await callProjectRuntimeActionOr( + "lane", + "dismissAutoRebaseStatus", + { args }, + () => ipcRenderer.invoke(IPC.lanesDismissAutoRebaseStatus, args), + ); + }, onAutoRebaseEvent: (cb: (ev: AutoRebaseEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: AutoRebaseEventPayload, ) => cb(payload); ipcRenderer.on(IPC.lanesAutoRebaseEvent, listener); - return () => + const removeRemote = subscribeRemoteLaneAutoRebaseEvents(cb); + return () => { + removeRemote(); ipcRenderer.removeListener(IPC.lanesAutoRebaseEvent, listener); + }; + }, + openFolder: async (args: { laneId: string }): Promise<void> => { + const binding = await getRemoteProjectBinding(); + if (binding) { + throw new Error( + "Remote lane folders cannot be opened on this machine. Copy the remote path instead.", + ); + } + await ipcRenderer.invoke(IPC.lanesOpenFolder, args); }, - openFolder: async (args: { laneId: string }): Promise<void> => - ipcRenderer.invoke(IPC.lanesOpenFolder, args), initEnv: async (args: InitLaneEnvArgs): Promise<LaneEnvInitProgress> => - ipcRenderer.invoke(IPC.lanesInitEnv, args), + callProjectRuntimeActionOr("lane", "initEnv", { args }, () => + ipcRenderer.invoke(IPC.lanesInitEnv, args), + ), getEnvStatus: async ( args: GetLaneEnvStatusArgs, ): Promise<LaneEnvInitProgress | null> => - ipcRenderer.invoke(IPC.lanesGetEnvStatus, args), + callProjectRuntimeActionOr("lane", "getEnvStatus", { args }, () => + ipcRenderer.invoke(IPC.lanesGetEnvStatus, args), + ), getOverlay: async ( args: GetLaneOverlayArgs, ): Promise<LaneOverlayOverrides> => - ipcRenderer.invoke(IPC.lanesGetOverlay, args), + callProjectRuntimeActionOr("lane", "getOverlay", { args }, () => + ipcRenderer.invoke(IPC.lanesGetOverlay, args), + ), onEnvEvent: (cb: (ev: LaneEnvInitEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: LaneEnvInitEvent, ) => cb(payload); ipcRenderer.on(IPC.lanesEnvEvent, listener); - return () => ipcRenderer.removeListener(IPC.lanesEnvEvent, listener); + const removeRemote = subscribeRemoteLaneEnvEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.lanesEnvEvent, listener); + }; }, listTemplates: async (): Promise<LaneTemplate[]> => - ipcRenderer.invoke(IPC.lanesListTemplates), + callProjectRuntimeActionOr("lane", "listTemplates", {}, () => + ipcRenderer.invoke(IPC.lanesListTemplates), + ), getTemplate: async ( args: GetLaneTemplateArgs, ): Promise<LaneTemplate | null> => - ipcRenderer.invoke(IPC.lanesGetTemplate, args), + callProjectRuntimeActionOr("lane", "getTemplate", { args }, () => + ipcRenderer.invoke(IPC.lanesGetTemplate, args), + ), getDefaultTemplate: async (): Promise<string | null> => - ipcRenderer.invoke(IPC.lanesGetDefaultTemplate), + callProjectRuntimeActionOr("lane", "getDefaultTemplate", {}, () => + ipcRenderer.invoke(IPC.lanesGetDefaultTemplate), + ), setDefaultTemplate: async ( args: SetDefaultLaneTemplateArgs, - ): Promise<void> => ipcRenderer.invoke(IPC.lanesSetDefaultTemplate, args), + ): Promise<void> => { + await callProjectRuntimeActionOr( + "lane", + "setDefaultTemplate", + { args }, + () => ipcRenderer.invoke(IPC.lanesSetDefaultTemplate, args), + ); + }, applyTemplate: async ( args: ApplyLaneTemplateArgs, ): Promise<LaneEnvInitProgress> => - ipcRenderer.invoke(IPC.lanesApplyTemplate, args), - saveTemplate: async (args: SaveLaneTemplateArgs): Promise<void> => - ipcRenderer.invoke(IPC.lanesSaveTemplate, args), - deleteTemplate: async (args: DeleteLaneTemplateArgs): Promise<void> => - ipcRenderer.invoke(IPC.lanesDeleteTemplate, args), + callProjectRuntimeActionOr("lane", "applyTemplate", { args }, () => + ipcRenderer.invoke(IPC.lanesApplyTemplate, args), + ), + saveTemplate: async (args: SaveLaneTemplateArgs): Promise<void> => { + await callProjectRuntimeActionOr("lane", "saveTemplate", { args }, () => + ipcRenderer.invoke(IPC.lanesSaveTemplate, args), + ); + }, + deleteTemplate: async (args: DeleteLaneTemplateArgs): Promise<void> => { + await callProjectRuntimeActionOr("lane", "deleteTemplate", { args }, () => + ipcRenderer.invoke(IPC.lanesDeleteTemplate, args), + ); + }, portGetLease: async (args: GetPortLeaseArgs): Promise<PortLease | null> => - ipcRenderer.invoke(IPC.lanesPortGetLease, args), + callProjectRuntimeActionOr("lane", "portGetLease", { args }, () => + ipcRenderer.invoke(IPC.lanesPortGetLease, args), + ), portListLeases: async (): Promise<PortLease[]> => - ipcRenderer.invoke(IPC.lanesPortListLeases), + callProjectRuntimeActionOr("lane", "portListLeases", {}, () => + ipcRenderer.invoke(IPC.lanesPortListLeases), + ), portAcquire: async (args: AcquirePortLeaseArgs): Promise<PortLease> => - ipcRenderer.invoke(IPC.lanesPortAcquire, args), - portRelease: async (args: ReleasePortLeaseArgs): Promise<void> => - ipcRenderer.invoke(IPC.lanesPortRelease, args), + callProjectRuntimeActionOr("lane", "portAcquire", { args }, () => + ipcRenderer.invoke(IPC.lanesPortAcquire, args), + ), + portRelease: async (args: ReleasePortLeaseArgs): Promise<void> => { + await callProjectRuntimeActionOr("lane", "portRelease", { args }, () => + ipcRenderer.invoke(IPC.lanesPortRelease, args), + ); + }, portListConflicts: async (): Promise<PortConflict[]> => - ipcRenderer.invoke(IPC.lanesPortListConflicts), + callProjectRuntimeActionOr("lane", "portListConflicts", {}, () => + ipcRenderer.invoke(IPC.lanesPortListConflicts), + ), portRecoverOrphans: async (): Promise<PortLease[]> => - ipcRenderer.invoke(IPC.lanesPortRecoverOrphans), + callProjectRuntimeActionOr("lane", "portRecoverOrphans", {}, () => + ipcRenderer.invoke(IPC.lanesPortRecoverOrphans), + ), onPortEvent: (cb: (ev: PortAllocationEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: PortAllocationEvent, ) => cb(payload); ipcRenderer.on(IPC.lanesPortEvent, listener); - return () => ipcRenderer.removeListener(IPC.lanesPortEvent, listener); + const removeRemote = subscribeRemoteLanePortEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.lanesPortEvent, listener); + }; }, proxyGetStatus: async (): Promise<ProxyStatus> => - ipcRenderer.invoke(IPC.lanesProxyGetStatus), + callProjectRuntimeActionOr("lane", "proxyGetStatus", {}, () => + ipcRenderer.invoke(IPC.lanesProxyGetStatus), + ), proxyStart: async (args?: StartProxyArgs): Promise<ProxyStatus> => - ipcRenderer.invoke(IPC.lanesProxyStart, args), - proxyStop: async (): Promise<void> => - ipcRenderer.invoke(IPC.lanesProxyStop), + callProjectRuntimeActionOr("lane", "proxyStart", { args }, () => + ipcRenderer.invoke(IPC.lanesProxyStart, args), + ), + proxyStop: async (): Promise<void> => { + await callProjectRuntimeActionOr("lane", "proxyStop", {}, () => + ipcRenderer.invoke(IPC.lanesProxyStop), + ); + }, proxyAddRoute: async (args: AddProxyRouteArgs): Promise<ProxyRoute> => - ipcRenderer.invoke(IPC.lanesProxyAddRoute, args), - proxyRemoveRoute: async (args: RemoveProxyRouteArgs): Promise<void> => - ipcRenderer.invoke(IPC.lanesProxyRemoveRoute, args), + callProjectRuntimeActionOr("lane", "proxyAddRoute", { args }, () => + ipcRenderer.invoke(IPC.lanesProxyAddRoute, args), + ), + proxyRemoveRoute: async (args: RemoveProxyRouteArgs): Promise<void> => { + await callProjectRuntimeActionOr( + "lane", + "proxyRemoveRoute", + { args }, + () => ipcRenderer.invoke(IPC.lanesProxyRemoveRoute, args), + ); + }, proxyGetPreviewInfo: async ( args: GetPreviewInfoArgs, ): Promise<LanePreviewInfo | null> => - ipcRenderer.invoke(IPC.lanesProxyGetPreviewInfo, args), - proxyOpenPreview: async (args: OpenPreviewArgs): Promise<void> => - ipcRenderer.invoke(IPC.lanesProxyOpenPreview, args), + callProjectRuntimeActionOr("lane", "proxyGetPreviewInfo", { args }, () => + ipcRenderer.invoke(IPC.lanesProxyGetPreviewInfo, args), + ), + proxyOpenPreview: async (args: OpenPreviewArgs): Promise<void> => { + const binding = await getProjectRuntimeBinding(); + if (binding) { + const runtime = + await callProjectRuntimeActionIfBound<LanePreviewInfo | null>( + "lane", + "proxyGetPreviewInfo", + { args }, + ); + if (!runtime.handled) { + await ipcRenderer.invoke(IPC.lanesProxyOpenPreview, args); + return; + } + const info = runtime.result; + if (!info) throw new Error(`No preview route for lane: ${args.laneId}`); + await ipcRenderer.invoke(IPC.appOpenExternal, { url: info.previewUrl }); + return; + } + await ipcRenderer.invoke(IPC.lanesProxyOpenPreview, args); + }, onProxyEvent: (cb: (ev: LaneProxyEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: LaneProxyEvent, ) => cb(payload); ipcRenderer.on(IPC.lanesProxyEvent, listener); - return () => ipcRenderer.removeListener(IPC.lanesProxyEvent, listener); + const removeRemote = subscribeRemoteLaneProxyEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.lanesProxyEvent, listener); + }; }, oauthGetStatus: async (): Promise<OAuthRedirectStatus> => - ipcRenderer.invoke(IPC.lanesOAuthGetStatus), + callProjectRuntimeActionOr("lane", "oauthGetStatus", {}, () => + ipcRenderer.invoke(IPC.lanesOAuthGetStatus), + ), oauthUpdateConfig: async ( args: UpdateOAuthRedirectConfigArgs, - ): Promise<void> => ipcRenderer.invoke(IPC.lanesOAuthUpdateConfig, args), + ): Promise<void> => { + await callProjectRuntimeActionOr( + "lane", + "oauthUpdateConfig", + { args }, + () => ipcRenderer.invoke(IPC.lanesOAuthUpdateConfig, args), + ); + }, oauthGenerateRedirectUris: async ( args: GenerateRedirectUrisArgs, ): Promise<RedirectUriInfo[]> => - ipcRenderer.invoke(IPC.lanesOAuthGenerateRedirectUris, args), + callProjectRuntimeActionOr( + "lane", + "oauthGenerateRedirectUris", + { args }, + () => ipcRenderer.invoke(IPC.lanesOAuthGenerateRedirectUris, args), + ), oauthEncodeState: async (args: EncodeOAuthStateArgs): Promise<string> => - ipcRenderer.invoke(IPC.lanesOAuthEncodeState, args), + callProjectRuntimeActionOr("lane", "oauthEncodeState", { args }, () => + ipcRenderer.invoke(IPC.lanesOAuthEncodeState, args), + ), oauthDecodeState: async ( args: DecodeOAuthStateArgs, ): Promise<DecodeOAuthStateResult> => - ipcRenderer.invoke(IPC.lanesOAuthDecodeState, args), + callProjectRuntimeActionOr("lane", "oauthDecodeState", { args }, () => + ipcRenderer.invoke(IPC.lanesOAuthDecodeState, args), + ), oauthListSessions: async (): Promise<OAuthSession[]> => - ipcRenderer.invoke(IPC.lanesOAuthListSessions), + callProjectRuntimeActionOr("lane", "oauthListSessions", {}, () => + ipcRenderer.invoke(IPC.lanesOAuthListSessions), + ), onOAuthEvent: (cb: (ev: OAuthRedirectEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: OAuthRedirectEvent, ) => cb(payload); ipcRenderer.on(IPC.lanesOAuthEvent, listener); - return () => ipcRenderer.removeListener(IPC.lanesOAuthEvent, listener); + const removeRemote = subscribeRemoteLaneOAuthEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.lanesOAuthEvent, listener); + }; }, diagnosticsGetStatus: async (): Promise<RuntimeDiagnosticsStatus> => - ipcRenderer.invoke(IPC.lanesDiagnosticsGetStatus), + callProjectRuntimeActionOr("lane", "diagnosticsGetStatus", {}, () => + ipcRenderer.invoke(IPC.lanesDiagnosticsGetStatus), + ), diagnosticsGetLaneHealth: async ( args: GetLaneHealthArgs, ): Promise<LaneHealthCheck | null> => - ipcRenderer.invoke(IPC.lanesDiagnosticsGetLaneHealth, args), + callProjectRuntimeActionOr( + "lane", + "diagnosticsGetLaneHealth", + { args }, + () => ipcRenderer.invoke(IPC.lanesDiagnosticsGetLaneHealth, args), + ), diagnosticsRunHealthCheck: async ( args: RunHealthCheckArgs, ): Promise<LaneHealthCheck> => - ipcRenderer.invoke(IPC.lanesDiagnosticsRunHealthCheck, args), + callProjectRuntimeActionOr( + "lane", + "diagnosticsRunHealthCheck", + { args }, + () => ipcRenderer.invoke(IPC.lanesDiagnosticsRunHealthCheck, args), + ), diagnosticsRunFullCheck: async (): Promise<LaneHealthCheck[]> => - ipcRenderer.invoke(IPC.lanesDiagnosticsRunFullCheck), + callProjectRuntimeActionOr("lane", "diagnosticsRunFullCheck", {}, () => + ipcRenderer.invoke(IPC.lanesDiagnosticsRunFullCheck), + ), diagnosticsActivateFallback: async ( args: ActivateFallbackArgs, - ): Promise<void> => - ipcRenderer.invoke(IPC.lanesDiagnosticsActivateFallback, args), + ): Promise<void> => { + await callProjectRuntimeActionOr( + "lane", + "diagnosticsActivateFallback", + { args }, + () => ipcRenderer.invoke(IPC.lanesDiagnosticsActivateFallback, args), + ); + }, diagnosticsDeactivateFallback: async ( args: DeactivateFallbackArgs, - ): Promise<void> => - ipcRenderer.invoke(IPC.lanesDiagnosticsDeactivateFallback, args), + ): Promise<void> => { + await callProjectRuntimeActionOr( + "lane", + "diagnosticsDeactivateFallback", + { args }, + () => ipcRenderer.invoke(IPC.lanesDiagnosticsDeactivateFallback, args), + ); + }, onDiagnosticsEvent: (cb: (ev: RuntimeDiagnosticsEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: RuntimeDiagnosticsEvent, ) => cb(payload); ipcRenderer.on(IPC.lanesDiagnosticsEvent, listener); - return () => + const removeRemote = subscribeRemoteLaneDiagnosticsEvents(cb); + return () => { + removeRemote(); ipcRenderer.removeListener(IPC.lanesDiagnosticsEvent, listener); + }; }, }, sessions: { list: async ( args: ListSessionsArgs = {}, - ): Promise<TerminalSessionSummary[]> => - ipcRenderer.invoke(IPC.sessionsList, args), - get: async (sessionId: string): Promise<TerminalSessionDetail | null> => - ipcRenderer.invoke(IPC.sessionsGet, { sessionId }), - delete: async (args: DeleteSessionArgs): Promise<void> => - ipcRenderer.invoke(IPC.sessionsDelete, args), - updateMeta: async (args: UpdateSessionMetaArgs): Promise<TerminalSessionSummary | null> => - ipcRenderer.invoke(IPC.sessionsUpdateMeta, args), - readTranscriptTail: async (args: ReadTranscriptTailArgs): Promise<string> => - ipcRenderer.invoke(IPC.sessionsReadTranscriptTail, args), + ): Promise<TerminalSessionSummary[]> => { + const runtime = await callProjectRuntimeActionIfBound< + TerminalSessionSummary[] + >("session", "list", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.sessionsList, args); + }, + get: async (sessionId: string): Promise<TerminalSessionDetail | null> => { + const runtime = + await callProjectRuntimeActionIfBound<TerminalSessionDetail | null>( + "session", + "get", + { arg: sessionId }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.sessionsGet, { sessionId }); + }, + delete: async (args: DeleteSessionArgs): Promise<void> => { + sessionDeltaCache.clear(); + const runtime = await callProjectRuntimeActionIfBound<boolean>( + "session", + "deleteSession", + { arg: args.sessionId }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.sessionsDelete, args); + sessionDeltaCache.clear(); + }, + updateMeta: async ( + args: UpdateSessionMetaArgs, + ): Promise<TerminalSessionSummary | null> => { + sessionDeltaCache.clear(); + const runtime = + await callProjectRuntimeActionIfBound<TerminalSessionSummary | null>( + "session", + "updateMeta", + { args }, + ); + const updated = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.sessionsUpdateMeta, args); + sessionDeltaCache.clear(); + return updated as TerminalSessionSummary | null; + }, + readTranscriptTail: async ( + args: ReadTranscriptTailArgs, + ): Promise<string> => { + const runtime = await callProjectRuntimeActionIfBound<string>( + "session", + "readTranscriptTail", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.sessionsReadTranscriptTail, args); + }, getDelta: async (sessionId: string): Promise<SessionDeltaSummary | null> => sessionDeltaCache.get(sessionId), onChanged: (cb: (ev: TerminalSessionChangedEvent) => void) => { @@ -2220,161 +4740,346 @@ contextBridge.exposeInMainWorld("ade", { payload: TerminalSessionChangedEvent, ) => cb(payload); ipcRenderer.on(IPC.sessionsChanged, listener); - return () => ipcRenderer.removeListener(IPC.sessionsChanged, listener); + const removeRemote = subscribeRemoteSessionChangedEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.sessionsChanged, listener); + }; }, }, agentChat: { list: async ( args: AgentChatListArgs = {}, - ): Promise<AgentChatSessionSummary[]> => - ipcRenderer.invoke(IPC.agentChatList, args), + ): Promise<AgentChatSessionSummary[]> => { + const runtime = await callProjectRuntimeActionIfBound< + AgentChatSessionSummary[] + >("chat", "listSessions", { + argsList: [ + args.laneId, + { includeAutomation: args.includeAutomation === true }, + ], + }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.agentChatList, args); + }, getSummary: async ( args: AgentChatGetSummaryArgs, ): Promise<AgentChatSessionSummary | null> => { - const sessionId = typeof args?.sessionId === "string" ? args.sessionId.trim() : ""; + const sessionId = + typeof args?.sessionId === "string" ? args.sessionId.trim() : ""; if (!sessionId) return ipcRenderer.invoke(IPC.agentChatGetSummary, args); - return agentChatSummaryCache.get(sessionId); + const runtime = + await callProjectRuntimeActionIfBound<AgentChatSessionSummary | null>( + "chat", + "getSessionSummary", + { arg: sessionId }, + ); + return runtime.handled + ? runtime.result + : agentChatSummaryCache.get(sessionId); }, create: async (args: AgentChatCreateArgs): Promise<AgentChatSession> => { agentChatSummaryCache.clear(); - return ipcRenderer.invoke(IPC.agentChatCreate, args); + const runtime = await callProjectRuntimeActionIfBound<AgentChatSession>( + "chat", + "createSession", + { args }, + ); + const session = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.agentChatCreate, args); + agentChatSummaryCache.clear(); + return session as AgentChatSession; }, - suggestLaneName: async (args: AgentChatSuggestLaneNameArgs): Promise<string> => - ipcRenderer.invoke(IPC.agentChatSuggestLaneName, args), + suggestLaneName: async ( + args: AgentChatSuggestLaneNameArgs, + ): Promise<string> => + callProjectRuntimeActionOr( + "chat", + "suggestLaneNameFromPrompt", + { args }, + () => ipcRenderer.invoke(IPC.agentChatSuggestLaneName, args), + ), parallelLaunchState: { - get: async (args: AgentChatParallelLaunchStateArgs): Promise<AgentChatParallelLaunchState | null> => - ipcRenderer.invoke(IPC.agentChatParallelLaunchStateGet, args), + get: async ( + args: AgentChatParallelLaunchStateArgs, + ): Promise<AgentChatParallelLaunchState | null> => + callProjectRuntimeActionOr( + "chat", + "getParallelLaunchState", + { args }, + () => ipcRenderer.invoke(IPC.agentChatParallelLaunchStateGet, args), + ), set: async (args: AgentChatSetParallelLaunchStateArgs): Promise<void> => - ipcRenderer.invoke(IPC.agentChatParallelLaunchStateSet, args), + callProjectRuntimeActionOr( + "chat", + "setParallelLaunchState", + { args }, + () => ipcRenderer.invoke(IPC.agentChatParallelLaunchStateSet, args), + ), }, handoff: async ( args: AgentChatHandoffArgs, ): Promise<AgentChatHandoffResult> => - ipcRenderer.invoke(IPC.agentChatHandoff, args), + callProjectRuntimeActionOr("chat", "handoffSession", { args }, () => + ipcRenderer.invoke(IPC.agentChatHandoff, args), + ), send: async (args: AgentChatSendArgs): Promise<void> => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatSend, args); + const runtime = await callProjectRuntimeActionIfBound<void>( + "chat", + "sendMessage", + { args }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.agentChatSend, args); agentChatSummaryCache.clear(); }, steer: async (args: AgentChatSteerArgs): Promise<void> => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatSteer, args); + await callProjectRuntimeActionOr("chat", "steer", { args }, () => + ipcRenderer.invoke(IPC.agentChatSteer, args), + ); agentChatSummaryCache.clear(); }, cancelSteer: async (args: AgentChatCancelSteerArgs): Promise<void> => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatCancelSteer, args); + await callProjectRuntimeActionOr("chat", "cancelSteer", { args }, () => + ipcRenderer.invoke(IPC.agentChatCancelSteer, args), + ); agentChatSummaryCache.clear(); }, editSteer: async (args: AgentChatEditSteerArgs): Promise<void> => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatEditSteer, args); + await callProjectRuntimeActionOr("chat", "editSteer", { args }, () => + ipcRenderer.invoke(IPC.agentChatEditSteer, args), + ); agentChatSummaryCache.clear(); }, - dispatchSteer: async (args: AgentChatDispatchSteerArgs): Promise<AgentChatDispatchSteerResult> => { + dispatchSteer: async ( + args: AgentChatDispatchSteerArgs, + ): Promise<AgentChatDispatchSteerResult> => { agentChatSummaryCache.clear(); - const result = await ipcRenderer.invoke(IPC.agentChatDispatchSteer, args); + const result = await callProjectRuntimeActionOr( + "chat", + "dispatchSteer", + { args }, + () => ipcRenderer.invoke(IPC.agentChatDispatchSteer, args), + ); agentChatSummaryCache.clear(); return result; }, - cancelDispatchedSteer: async (args: AgentChatCancelDispatchedSteerArgs): Promise<AgentChatCancelDispatchedSteerResult> => { + cancelDispatchedSteer: async ( + args: AgentChatCancelDispatchedSteerArgs, + ): Promise<AgentChatCancelDispatchedSteerResult> => { agentChatSummaryCache.clear(); - const result = await ipcRenderer.invoke(IPC.agentChatCancelDispatchedSteer, args); + const result = await callProjectRuntimeActionOr( + "chat", + "cancelDispatchedSteer", + { args }, + () => ipcRenderer.invoke(IPC.agentChatCancelDispatchedSteer, args), + ); agentChatSummaryCache.clear(); return result; }, interrupt: async (args: AgentChatInterruptArgs): Promise<void> => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatInterrupt, args); + const runtime = await callProjectRuntimeActionIfBound<void>( + "chat", + "interrupt", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.agentChatInterrupt, args); agentChatSummaryCache.clear(); }, resume: async (args: AgentChatResumeArgs): Promise<AgentChatSession> => { agentChatSummaryCache.clear(); - const session = await ipcRenderer.invoke(IPC.agentChatResume, args); + const runtime = await callProjectRuntimeActionIfBound<AgentChatSession>( + "chat", + "resumeSession", + { args }, + ); + const session = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.agentChatResume, args); agentChatSummaryCache.clear(); - return session; + return session as AgentChatSession; }, approve: async (args: AgentChatApproveArgs): Promise<void> => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatApprove, args); + const runtime = await callProjectRuntimeActionIfBound<void>( + "chat", + "approveToolUse", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.agentChatApprove, args); agentChatSummaryCache.clear(); }, - respondToInput: async (args: AgentChatRespondToInputArgs): Promise<void> => { + respondToInput: async ( + args: AgentChatRespondToInputArgs, + ): Promise<void> => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatRespondToInput, args); + const runtime = await callProjectRuntimeActionIfBound<void>( + "chat", + "respondToInput", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.agentChatRespondToInput, args); agentChatSummaryCache.clear(); }, - models: async (args: AgentChatModelsArgs): Promise<AgentChatModelInfo[]> => - ipcRenderer.invoke(IPC.agentChatModels, args), + models: async ( + args: AgentChatModelsArgs, + ): Promise<AgentChatModelInfo[]> => { + const runtime = await callProjectRuntimeActionIfBound< + AgentChatModelInfo[] + >("chat", "getAvailableModels", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.agentChatModels, args); + }, dispose: async (args: AgentChatDisposeArgs): Promise<void> => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatDispose, args); + const runtime = await callProjectRuntimeActionIfBound<void>( + "chat", + "dispose", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.agentChatDispose, args); agentChatSummaryCache.clear(); }, archive: async (args: AgentChatArchiveArgs): Promise<void> => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatArchive, args); + const runtime = await callProjectRuntimeActionIfBound<void>( + "chat", + "archiveSession", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.agentChatArchive, args); agentChatSummaryCache.clear(); }, unarchive: async (args: AgentChatArchiveArgs): Promise<void> => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatUnarchive, args); + const runtime = await callProjectRuntimeActionIfBound<void>( + "chat", + "unarchiveSession", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.agentChatUnarchive, args); agentChatSummaryCache.clear(); }, delete: async (args: AgentChatDeleteArgs): Promise<void> => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatDelete, args); + const runtime = await callProjectRuntimeActionIfBound<void>( + "chat", + "deleteSession", + { args }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.agentChatDelete, args); agentChatSummaryCache.clear(); }, updateSession: async ( args: AgentChatUpdateSessionArgs, ): Promise<AgentChatSession> => { agentChatSummaryCache.clear(); - const session = await ipcRenderer.invoke(IPC.agentChatUpdateSession, args); + const runtime = await callProjectRuntimeActionIfBound<AgentChatSession>( + "chat", + "updateSession", + { args }, + ); + const session = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.agentChatUpdateSession, args); agentChatSummaryCache.clear(); - return session; + return session as AgentChatSession; }, warmupModel: async (args: { sessionId: string; modelId: string; - }): Promise<void> => ipcRenderer.invoke(IPC.agentChatWarmupModel, args), - onEvent: agentChatEventFanout, + }): Promise<void> => + callProjectRuntimeActionOr("chat", "warmupModel", { args }, () => + ipcRenderer.invoke(IPC.agentChatWarmupModel, args), + ), + onEvent: subscribeAgentChatEvents, slashCommands: async ( args: AgentChatSlashCommandsArgs, - ): Promise<AgentChatSlashCommand[]> => - ipcRenderer.invoke(IPC.agentChatSlashCommands, args), + ): Promise<AgentChatSlashCommand[]> => { + const runtime = await callProjectRuntimeActionIfBound< + AgentChatSlashCommand[] + >("chat", "getSlashCommands", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.agentChatSlashCommands, args); + }, fileSearch: async ( args: AgentChatFileSearchArgs, ): Promise<AgentChatFileSearchResult[]> => - ipcRenderer.invoke(IPC.agentChatFileSearch, args), + callProjectRuntimeActionOr("chat", "fileSearch", { args }, () => + ipcRenderer.invoke(IPC.agentChatFileSearch, args), + ), getTurnFileDiff: async ( args: AgentChatGetTurnFileDiffArgs, ): Promise<AgentChatTurnFileDiff | null> => - ipcRenderer.invoke(IPC.agentChatGetTurnFileDiff, args), + callProjectRuntimeActionOr("chat", "getTurnFileDiff", { args }, () => + ipcRenderer.invoke(IPC.agentChatGetTurnFileDiff, args), + ), listSubagents: async ( args: AgentChatSubagentListArgs, ): Promise<AgentChatSubagentSnapshot[]> => - ipcRenderer.invoke(IPC.agentChatListSubagents, args), + callProjectRuntimeActionOr("chat", "listSubagents", { args }, () => + ipcRenderer.invoke(IPC.agentChatListSubagents, args), + ), getSessionCapabilities: async ( args: AgentChatSessionCapabilitiesArgs, ): Promise<AgentChatSessionCapabilities> => - ipcRenderer.invoke(IPC.agentChatGetSessionCapabilities, args), + callProjectRuntimeActionOr( + "chat", + "getSessionCapabilities", + { args }, + () => ipcRenderer.invoke(IPC.agentChatGetSessionCapabilities, args), + ), saveTempAttachment: async (args: { data: string; filename: string; }): Promise<{ path: string }> => - ipcRenderer.invoke(IPC.agentChatSaveTempAttachment, args), + callProjectRuntimeActionOr("chat", "saveTempAttachment", { args }, () => + ipcRenderer.invoke(IPC.agentChatSaveTempAttachment, args), + ), getEventHistory: async (args: { sessionId: string; maxEvents?: number; - }): Promise<{ sessionId: string; events: AgentChatEventEnvelope[]; truncated: boolean }> => - ipcRenderer.invoke(IPC.agentChatGetEventHistory, args), + }): Promise<{ + sessionId: string; + events: AgentChatEventEnvelope[]; + truncated: boolean; + }> => { + const runtime = await callProjectRuntimeActionIfBound<{ + sessionId: string; + events: AgentChatEventEnvelope[]; + truncated: boolean; + }>("chat", "getChatEventHistory", { + argsList: [args.sessionId, { maxEvents: args.maxEvents }], + }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.agentChatGetEventHistory, args); + }, }, computerUse: { listArtifacts: async ( args: ComputerUseArtifactListArgs = {}, ): Promise<ComputerUseArtifactView[]> => - ipcRenderer.invoke(IPC.computerUseListArtifacts, args), + callProjectRuntimeActionOr( + "computer_use_artifacts", + "listArtifacts", + { args }, + () => ipcRenderer.invoke(IPC.computerUseListArtifacts, args), + ), getOwnerSnapshot: async ( args: ComputerUseOwnerSnapshotArgs, ): Promise<ComputerUseOwnerSnapshot> => @@ -2384,19 +5089,36 @@ contextBridge.exposeInMainWorld("ade", { ): Promise<ComputerUseArtifactView> => clearAround( () => computerUseOwnerSnapshotCache.clear(), - () => ipcRenderer.invoke(IPC.computerUseRouteArtifact, args), + () => + callProjectRuntimeActionOr( + "computer_use_artifacts", + "routeArtifact", + { args }, + () => ipcRenderer.invoke(IPC.computerUseRouteArtifact, args), + ), ), updateArtifactReview: async ( args: ComputerUseArtifactReviewArgs, ): Promise<ComputerUseArtifactView> => clearAround( () => computerUseOwnerSnapshotCache.clear(), - () => ipcRenderer.invoke(IPC.computerUseUpdateArtifactReview, args), + () => + callProjectRuntimeActionOr( + "computer_use_artifacts", + "updateArtifactReview", + { args }, + () => ipcRenderer.invoke(IPC.computerUseUpdateArtifactReview, args), + ), ), readArtifactPreview: async (args: { uri: string; }): Promise<string | null> => - ipcRenderer.invoke(IPC.computerUseReadArtifactPreview, args), + callProjectRuntimeActionOr( + "computer_use_artifacts", + "readArtifactPreview", + { args }, + () => ipcRenderer.invoke(IPC.computerUseReadArtifactPreview, args), + ), onEvent: computerUseEventFanout, }, iosSimulator: { @@ -2404,52 +5126,141 @@ contextBridge.exposeInMainWorld("ade", { iosSimulatorStatusCache.get(), listDevices: async (): Promise<IosSimulatorDevice[]> => iosSimulatorDevicesCache.get(), - listLaunchTargets: async (args: IosSimulatorListLaunchTargetsArgs = {}): Promise<IosSimulatorLaunchTarget[]> => - ipcRenderer.invoke(IPC.iosSimulatorListLaunchTargets, args), - launch: async (args: IosSimulatorLaunchArgs = {}): Promise<IosSimulatorSession> => { + listLaunchTargets: async ( + args: IosSimulatorListLaunchTargetsArgs = {}, + ): Promise<IosSimulatorLaunchTarget[]> => + callProjectRuntimeActionOr( + "ios_simulator", + "listLaunchTargets", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorListLaunchTargets, args), + ), + launch: async ( + args: IosSimulatorLaunchArgs = {}, + ): Promise<IosSimulatorSession> => { clearIosSimulatorStatusCaches(); try { - return await ipcRenderer.invoke(IPC.iosSimulatorLaunch, args); + return await callProjectRuntimeActionOr( + "ios_simulator", + "launch", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorLaunch, args), + ); } finally { clearIosSimulatorStatusCaches(); } }, - attachToChatSession: async (args: { chatSessionId: string | null; callerChatSessionId?: string | null }): Promise<IosSimulatorSession | null> => { + attachToChatSession: async (args: { + chatSessionId: string | null; + callerChatSessionId?: string | null; + }): Promise<IosSimulatorSession | null> => { clearIosSimulatorStatusCaches(); try { - return await ipcRenderer.invoke(IPC.iosSimulatorAttachToChatSession, args); + return await callProjectRuntimeActionOr( + "ios_simulator", + "attachToChatSession", + { argsList: [args.chatSessionId, args.callerChatSessionId] }, + () => ipcRenderer.invoke(IPC.iosSimulatorAttachToChatSession, args), + ); } finally { clearIosSimulatorStatusCaches(); } }, - shutdown: async (args: IosSimulatorShutdownArgs = {}): Promise<IosSimulatorShutdownResult> => { + shutdown: async ( + args: IosSimulatorShutdownArgs = {}, + ): Promise<IosSimulatorShutdownResult> => { clearIosSimulatorStatusCaches(); try { - return await ipcRenderer.invoke(IPC.iosSimulatorShutdown, args); + return await callProjectRuntimeActionOr( + "ios_simulator", + "shutdown", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorShutdown, args), + ); } finally { clearIosSimulatorStatusCaches(); } }, - screenshot: async (args: { deviceUdid?: string | null } = {}): Promise<IosSimulatorScreenshot> => - ipcRenderer.invoke(IPC.iosSimulatorScreenshot, args), - getScreenSnapshot: async (args: IosScreenSnapshotArgs = {}): Promise<IosScreenSnapshot> => - ipcRenderer.invoke(IPC.iosSimulatorGetScreenSnapshot, args), - getInspectorSnapshot: async (args: { deviceUdid?: string | null } = {}): Promise<IosInspectorSnapshot | null> => - ipcRenderer.invoke(IPC.iosSimulatorGetInspectorSnapshot, args), - inspectPoint: async (args: IosSimulatorInspectPointArgs): Promise<IosSimulatorInspectResult> => - ipcRenderer.invoke(IPC.iosSimulatorInspectPoint, args), - getPreviewCapability: async (args: IosSimulatorListPreviewsArgs = {}): Promise<IosSimulatorPreviewCapability> => - ipcRenderer.invoke(IPC.iosSimulatorGetPreviewCapability, args), - listPreviewTargets: async (args: IosSimulatorListPreviewsArgs = {}): Promise<IosSimulatorPreviewTarget[]> => - ipcRenderer.invoke(IPC.iosSimulatorListPreviewTargets, args), - renderPreview: async (args: IosSimulatorRenderPreviewArgs): Promise<IosSimulatorRenderPreviewResult> => - ipcRenderer.invoke(IPC.iosSimulatorRenderPreview, args), - openPreviewWorkspace: async (args: IosSimulatorOpenPreviewWorkspaceArgs = {}): Promise<{ ok: true; path: string }> => - ipcRenderer.invoke(IPC.iosSimulatorOpenPreviewWorkspace, args), - startStream: async (args: IosSimulatorStartStreamArgs = {}): Promise<IosSimulatorStreamStatus> => { + screenshot: async ( + args: { deviceUdid?: string | null } = {}, + ): Promise<IosSimulatorScreenshot> => + callProjectRuntimeActionOr("ios_simulator", "screenshot", { args }, () => + ipcRenderer.invoke(IPC.iosSimulatorScreenshot, args), + ), + getScreenSnapshot: async ( + args: IosScreenSnapshotArgs = {}, + ): Promise<IosScreenSnapshot> => + callProjectRuntimeActionOr( + "ios_simulator", + "getScreenSnapshot", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorGetScreenSnapshot, args), + ), + getInspectorSnapshot: async ( + args: { deviceUdid?: string | null } = {}, + ): Promise<IosInspectorSnapshot | null> => + callProjectRuntimeActionOr( + "ios_simulator", + "getInspectorSnapshot", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorGetInspectorSnapshot, args), + ), + inspectPoint: async ( + args: IosSimulatorInspectPointArgs, + ): Promise<IosSimulatorInspectResult> => + callProjectRuntimeActionOr( + "ios_simulator", + "inspectPoint", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorInspectPoint, args), + ), + getPreviewCapability: async ( + args: IosSimulatorListPreviewsArgs = {}, + ): Promise<IosSimulatorPreviewCapability> => + callProjectRuntimeActionOr( + "ios_simulator", + "getPreviewCapability", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorGetPreviewCapability, args), + ), + listPreviewTargets: async ( + args: IosSimulatorListPreviewsArgs = {}, + ): Promise<IosSimulatorPreviewTarget[]> => + callProjectRuntimeActionOr( + "ios_simulator", + "listPreviewTargets", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorListPreviewTargets, args), + ), + renderPreview: async ( + args: IosSimulatorRenderPreviewArgs, + ): Promise<IosSimulatorRenderPreviewResult> => + callProjectRuntimeActionOr( + "ios_simulator", + "renderPreview", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorRenderPreview, args), + ), + openPreviewWorkspace: async ( + args: IosSimulatorOpenPreviewWorkspaceArgs = {}, + ): Promise<{ ok: true; path: string }> => + callProjectRuntimeActionOr( + "ios_simulator", + "openPreviewWorkspace", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorOpenPreviewWorkspace, args), + ), + startStream: async ( + args: IosSimulatorStartStreamArgs = {}, + ): Promise<IosSimulatorStreamStatus> => { clearIosSimulatorStatusCaches(); try { - return await ipcRenderer.invoke(IPC.iosSimulatorStartStream, args); + return await callProjectRuntimeActionOr( + "ios_simulator", + "startStream", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorStartStream, args), + ); } finally { clearIosSimulatorStatusCaches(); } @@ -2457,441 +5268,1209 @@ contextBridge.exposeInMainWorld("ade", { stopStream: async (): Promise<IosSimulatorStreamStatus> => { clearIosSimulatorStatusCaches(); try { - return await ipcRenderer.invoke(IPC.iosSimulatorStopStream); + return await callProjectRuntimeActionOr( + "ios_simulator", + "stopStream", + {}, + () => ipcRenderer.invoke(IPC.iosSimulatorStopStream), + ); } finally { clearIosSimulatorStatusCaches(); } }, getStreamStatus: async (): Promise<IosSimulatorStreamStatus> => - ipcRenderer.invoke(IPC.iosSimulatorGetStreamStatus), + callProjectRuntimeActionOr("ios_simulator", "getStreamStatus", {}, () => + ipcRenderer.invoke(IPC.iosSimulatorGetStreamStatus), + ), getSimulatorWindowState: async (): Promise<IosSimulatorWindowState> => ipcRenderer.invoke(IPC.iosSimulatorGetWindowState), - listSimulatorWindowSources: async (): Promise<IosSimulatorWindowSource[]> => { + listSimulatorWindowSources: async (): Promise< + IosSimulatorWindowSource[] + > => { return ipcRenderer.invoke(IPC.iosSimulatorListWindowSources); }, - tap: async (args: { deviceUdid?: string | null; projectRoot?: string | null; x: number; y: number }): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.iosSimulatorTap, args), - typeText: async (args: { deviceUdid?: string | null; projectRoot?: string | null; text: string }): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.iosSimulatorTypeText, args), + tap: async (args: { + deviceUdid?: string | null; + projectRoot?: string | null; + x: number; + y: number; + }): Promise<{ ok: true }> => + callProjectRuntimeActionOr("ios_simulator", "tap", { args }, () => + ipcRenderer.invoke(IPC.iosSimulatorTap, args), + ), + typeText: async (args: { + deviceUdid?: string | null; + projectRoot?: string | null; + text: string; + }): Promise<{ ok: true }> => + callProjectRuntimeActionOr("ios_simulator", "typeText", { args }, () => + ipcRenderer.invoke(IPC.iosSimulatorTypeText, args), + ), drag: async (args: IosSimulatorDragArgs): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.iosSimulatorDrag, args), + callProjectRuntimeActionOr("ios_simulator", "drag", { args }, () => + ipcRenderer.invoke(IPC.iosSimulatorDrag, args), + ), swipe: async (args: IosSimulatorDragArgs): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.iosSimulatorSwipe, args), - selectPoint: async (args: { deviceUdid?: string | null; projectRoot?: string | null; x: number; y: number }): Promise<IosSimulatorSelectResult> => - ipcRenderer.invoke(IPC.iosSimulatorSelectPoint, args), + callProjectRuntimeActionOr("ios_simulator", "swipe", { args }, () => + ipcRenderer.invoke(IPC.iosSimulatorSwipe, args), + ), + selectPoint: async (args: { + deviceUdid?: string | null; + projectRoot?: string | null; + x: number; + y: number; + }): Promise<IosSimulatorSelectResult> => + callProjectRuntimeActionOr("ios_simulator", "selectPoint", { args }, () => + ipcRenderer.invoke(IPC.iosSimulatorSelectPoint, args), + ), onEvent: iosSimulatorEventFanout, }, appControl: { getStatus: async (): Promise<AppControlStatus> => appControlStatusCache.get(), - launch: async (args: AppControlLaunchArgs = {}): Promise<AppControlSession> => - clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlLaunch, args)), - launchInTerminal: async (args: AppControlLaunchArgs = {}): Promise<AppControlSession> => - clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlLaunchInTerminal, args)), + launch: async ( + args: AppControlLaunchArgs = {}, + ): Promise<AppControlSession> => + clearAround( + () => appControlStatusCache.clear(), + () => + callProjectRuntimeActionOr("app_control", "launch", { args }, () => + ipcRenderer.invoke(IPC.appControlLaunch, args), + ), + ), + launchInTerminal: async ( + args: AppControlLaunchArgs = {}, + ): Promise<AppControlSession> => + clearAround( + () => appControlStatusCache.clear(), + () => + callProjectRuntimeActionOr( + "app_control", + "launchInTerminal", + { args }, + () => ipcRenderer.invoke(IPC.appControlLaunchInTerminal, args), + ), + ), connect: async (args: AppControlConnectArgs): Promise<AppControlSession> => - clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlConnect, args)), - stop: async (args: AppControlStopArgs = {}): Promise<{ ok: true; previousSession: AppControlSession | null }> => - clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlStop, args)), + clearAround( + () => appControlStatusCache.clear(), + () => + callProjectRuntimeActionOr("app_control", "connect", { args }, () => + ipcRenderer.invoke(IPC.appControlConnect, args), + ), + ), + stop: async ( + args: AppControlStopArgs = {}, + ): Promise<{ ok: true; previousSession: AppControlSession | null }> => + clearAround( + () => appControlStatusCache.clear(), + () => + callProjectRuntimeActionOr("app_control", "stop", { args }, () => + ipcRenderer.invoke(IPC.appControlStop, args), + ), + ), screenshot: async (): Promise<AppControlScreenshot> => - ipcRenderer.invoke(IPC.appControlScreenshot), - getSnapshot: async (args: AppControlSnapshotArgs = {}): Promise<AppControlSnapshot> => - ipcRenderer.invoke(IPC.appControlGetSnapshot, args), - inspectPoint: async (args: AppControlInspectPointArgs): Promise<AppControlInspectResult> => - ipcRenderer.invoke(IPC.appControlInspectPoint, args), - selectPoint: async (args: AppControlInspectPointArgs): Promise<AppControlSelectResult> => - ipcRenderer.invoke(IPC.appControlSelectPoint, args), + callProjectRuntimeActionOr("app_control", "screenshot", {}, () => + ipcRenderer.invoke(IPC.appControlScreenshot), + ), + getSnapshot: async ( + args: AppControlSnapshotArgs = {}, + ): Promise<AppControlSnapshot> => + callProjectRuntimeActionOr("app_control", "getSnapshot", { args }, () => + ipcRenderer.invoke(IPC.appControlGetSnapshot, args), + ), + inspectPoint: async ( + args: AppControlInspectPointArgs, + ): Promise<AppControlInspectResult> => + callProjectRuntimeActionOr("app_control", "inspectPoint", { args }, () => + ipcRenderer.invoke(IPC.appControlInspectPoint, args), + ), + selectPoint: async ( + args: AppControlInspectPointArgs, + ): Promise<AppControlSelectResult> => + callProjectRuntimeActionOr("app_control", "selectPoint", { args }, () => + ipcRenderer.invoke(IPC.appControlSelectPoint, args), + ), click: async (args: AppControlClickArgs): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.appControlClick, args), + callProjectRuntimeActionOr("app_control", "click", { args }, () => + ipcRenderer.invoke(IPC.appControlClick, args), + ), typeText: async (args: AppControlTypeTextArgs): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.appControlTypeText, args), - scroll: async (args: { x: number; y: number; deltaX: number; deltaY: number; scale?: number | null }): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.appControlScroll, args), + callProjectRuntimeActionOr("app_control", "typeText", { args }, () => + ipcRenderer.invoke(IPC.appControlTypeText, args), + ), + scroll: async (args: { + x: number; + y: number; + deltaX: number; + deltaY: number; + scale?: number | null; + }): Promise<{ ok: true }> => + callProjectRuntimeActionOr("app_control", "scroll", { args }, () => + ipcRenderer.invoke(IPC.appControlScroll, args), + ), dispatchKey: async (args: { type: "keyDown" | "keyUp" | "rawKeyDown" | "char"; key?: string | null; code?: string | null; text?: string | null; modifiers?: number | null; - }): Promise<{ ok: true }> => ipcRenderer.invoke(IPC.appControlDispatchKey, args), + }): Promise<{ ok: true }> => + callProjectRuntimeActionOr("app_control", "dispatchKey", { args }, () => + ipcRenderer.invoke(IPC.appControlDispatchKey, args), + ), listTargets: async (): Promise<AppControlTarget[]> => - ipcRenderer.invoke(IPC.appControlListTargets), - attachToTarget: async (args: { targetId: string }): Promise<AppControlSession> => - clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlAttachToTarget, args)), + callProjectRuntimeActionOr("app_control", "listTargets", {}, () => + ipcRenderer.invoke(IPC.appControlListTargets), + ), + attachToTarget: async (args: { + targetId: string; + }): Promise<AppControlSession> => + clearAround( + () => appControlStatusCache.clear(), + () => + callProjectRuntimeActionOr( + "app_control", + "attachToTarget", + { args }, + () => ipcRenderer.invoke(IPC.appControlAttachToTarget, args), + ), + ), onEvent: appControlEventFanout, }, builtInBrowser: { getStatus: async (): Promise<BuiltInBrowserStatus> => builtInBrowserStatusCache.get(), - showPanel: async (args: BuiltInBrowserOpenPanelArgs = {}): Promise<BuiltInBrowserStatus> => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserShowPanel, args)), - setBounds: async (args: BuiltInBrowserBoundsArgs): Promise<BuiltInBrowserStatus> => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserSetBounds, args)), - attachWebview: async (args: BuiltInBrowserAttachWebviewArgs): Promise<BuiltInBrowserStatus> => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserAttachWebview, args)), - navigate: async (args: BuiltInBrowserNavigateArgs): Promise<BuiltInBrowserStatus> => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserNavigate, args)), - createTab: async (args: BuiltInBrowserCreateTabArgs = {}): Promise<BuiltInBrowserStatus> => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserCreateTab, args)), - switchTab: async (args: BuiltInBrowserTabArgs): Promise<BuiltInBrowserStatus> => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserSwitchTab, args)), - closeTab: async (args: BuiltInBrowserTabArgs): Promise<BuiltInBrowserStatus> => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserCloseTab, args)), + showPanel: async ( + args: BuiltInBrowserOpenPanelArgs = {}, + ): Promise<BuiltInBrowserStatus> => + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserShowPanel, args), + ), + setBounds: async ( + args: BuiltInBrowserBoundsArgs, + ): Promise<BuiltInBrowserStatus> => + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserSetBounds, args), + ), + attachWebview: async ( + args: BuiltInBrowserAttachWebviewArgs, + ): Promise<BuiltInBrowserStatus> => + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserAttachWebview, args), + ), + navigate: async ( + args: BuiltInBrowserNavigateArgs, + ): Promise<BuiltInBrowserStatus> => + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserNavigate, args), + ), + createTab: async ( + args: BuiltInBrowserCreateTabArgs = {}, + ): Promise<BuiltInBrowserStatus> => + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserCreateTab, args), + ), + switchTab: async ( + args: BuiltInBrowserTabArgs, + ): Promise<BuiltInBrowserStatus> => + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserSwitchTab, args), + ), + closeTab: async ( + args: BuiltInBrowserTabArgs, + ): Promise<BuiltInBrowserStatus> => + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserCloseTab, args), + ), reload: async (): Promise<BuiltInBrowserStatus> => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserReload)), + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserReload), + ), goBack: async (): Promise<BuiltInBrowserStatus> => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserGoBack)), + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserGoBack), + ), goForward: async (): Promise<BuiltInBrowserStatus> => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserGoForward)), + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserGoForward), + ), stop: async (): Promise<BuiltInBrowserStatus> => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserStop)), + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserStop), + ), startInspect: async (): Promise<BuiltInBrowserStatus> => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserStartInspect)), + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserStartInspect), + ), stopInspect: async (): Promise<BuiltInBrowserStatus> => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserStopInspect)), + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserStopInspect), + ), captureScreenshot: async (): Promise<BuiltInBrowserScreenshot> => ipcRenderer.invoke(IPC.builtInBrowserCaptureScreenshot), - selectPoint: async (args: BuiltInBrowserSelectPointArgs): Promise<BuiltInBrowserSelectResult> => + selectPoint: async ( + args: BuiltInBrowserSelectPointArgs, + ): Promise<BuiltInBrowserSelectResult> => ipcRenderer.invoke(IPC.builtInBrowserSelectPoint, args), selectCurrent: async (): Promise<BuiltInBrowserSelectResult> => ipcRenderer.invoke(IPC.builtInBrowserSelectCurrent), clearSelection: async (): Promise<{ ok: true }> => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserClearSelection)), + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserClearSelection), + ), onEvent: builtInBrowserEventFanout, }, macosVm: { getStatus: async (args: MacosVmStatusArgs = {}): Promise<MacosVmStatus> => macosVmStatusCache.get(serializeIpcCacheArgs(args)), provision: async (args: MacosVmProvisionArgs): Promise<MacosVmRecord> => - clearAround(() => macosVmStatusCache.clear(), () => ipcRenderer.invoke(IPC.macosVmProvision, args)), + clearAround( + () => macosVmStatusCache.clear(), + () => + callProjectRuntimeActionOr("macos_vm", "provision", { args }, () => + ipcRenderer.invoke(IPC.macosVmProvision, args), + ), + ), start: async (args: MacosVmStartArgs): Promise<MacosVmRecord> => - clearAround(() => macosVmStatusCache.clear(), () => ipcRenderer.invoke(IPC.macosVmStart, args)), + clearAround( + () => macosVmStatusCache.clear(), + () => + callProjectRuntimeActionOr("macos_vm", "start", { args }, () => + ipcRenderer.invoke(IPC.macosVmStart, args), + ), + ), stop: async (args: MacosVmStopArgs): Promise<MacosVmRecord | null> => - clearAround(() => macosVmStatusCache.clear(), () => ipcRenderer.invoke(IPC.macosVmStop, args)), - delete: async (args: MacosVmDeleteArgs): Promise<{ deleted: boolean; previous: MacosVmRecord | null }> => - clearAround(() => macosVmStatusCache.clear(), () => ipcRenderer.invoke(IPC.macosVmDelete, args)), - getAgentGuide: async (args: MacosVmAgentGuideArgs): Promise<MacosVmAgentGuide> => - ipcRenderer.invoke(IPC.macosVmGetAgentGuide, args), - focusWindow: async (args: MacosVmFocusWindowArgs): Promise<MacosVmWindowTarget> => - ipcRenderer.invoke(IPC.macosVmFocusWindow, args), - captureScreenshot: async (args: MacosVmCaptureScreenshotArgs): Promise<MacosVmCaptureScreenshotResult> => - ipcRenderer.invoke(IPC.macosVmCaptureScreenshot, args), - selectPoint: async (args: MacosVmSelectPointArgs): Promise<MacosVmSelectPointResult> => - ipcRenderer.invoke(IPC.macosVmSelectPoint, args), - click: async (args: MacosVmClickArgs): Promise<{ ok: true; window: MacosVmWindowTarget; x: number; y: number }> => - ipcRenderer.invoke(IPC.macosVmClick, args), - typeText: async (args: MacosVmTypeTextArgs): Promise<{ ok: true; window: MacosVmWindowTarget }> => - ipcRenderer.invoke(IPC.macosVmTypeText, args), + clearAround( + () => macosVmStatusCache.clear(), + () => + callProjectRuntimeActionOr("macos_vm", "stop", { args }, () => + ipcRenderer.invoke(IPC.macosVmStop, args), + ), + ), + delete: async ( + args: MacosVmDeleteArgs, + ): Promise<{ deleted: boolean; previous: MacosVmRecord | null }> => + clearAround( + () => macosVmStatusCache.clear(), + () => + callProjectRuntimeActionOr("macos_vm", "delete", { args }, () => + ipcRenderer.invoke(IPC.macosVmDelete, args), + ), + ), + getAgentGuide: async ( + args: MacosVmAgentGuideArgs, + ): Promise<MacosVmAgentGuide> => + callProjectRuntimeActionOr("macos_vm", "getAgentGuide", { args }, () => + ipcRenderer.invoke(IPC.macosVmGetAgentGuide, args), + ), + focusWindow: async ( + args: MacosVmFocusWindowArgs, + ): Promise<MacosVmWindowTarget> => + callProjectRuntimeActionOr("macos_vm", "focusWindow", { args }, () => + ipcRenderer.invoke(IPC.macosVmFocusWindow, args), + ), + captureScreenshot: async ( + args: MacosVmCaptureScreenshotArgs, + ): Promise<MacosVmCaptureScreenshotResult> => + callProjectRuntimeActionOr( + "macos_vm", + "captureScreenshot", + { args }, + () => ipcRenderer.invoke(IPC.macosVmCaptureScreenshot, args), + ), + selectPoint: async ( + args: MacosVmSelectPointArgs, + ): Promise<MacosVmSelectPointResult> => + callProjectRuntimeActionOr("macos_vm", "selectPoint", { args }, () => + ipcRenderer.invoke(IPC.macosVmSelectPoint, args), + ), + click: async ( + args: MacosVmClickArgs, + ): Promise<{ + ok: true; + window: MacosVmWindowTarget; + x: number; + y: number; + }> => + callProjectRuntimeActionOr("macos_vm", "click", { args }, () => + ipcRenderer.invoke(IPC.macosVmClick, args), + ), + typeText: async ( + args: MacosVmTypeTextArgs, + ): Promise<{ ok: true; window: MacosVmWindowTarget }> => + callProjectRuntimeActionOr("macos_vm", "typeText", { args }, () => + ipcRenderer.invoke(IPC.macosVmTypeText, args), + ), onEvent: macosVmEventFanout, }, terminal: { - list: async (args: ChatTerminalListArgs = {}): Promise<ChatTerminalSession[]> => - ipcRenderer.invoke(IPC.terminalList, args), - read: async (args: ChatTerminalReadArgs = {}): Promise<ChatTerminalReadResult> => - ipcRenderer.invoke(IPC.terminalRead, args), - write: async (args: ChatTerminalWriteArgs): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.terminalWrite, args), - signal: async (args: ChatTerminalSignalArgs): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.terminalSignal, args), - activeForChat: async (args: ChatTerminalActiveForChatArgs): Promise<ChatTerminalSession | null> => - ipcRenderer.invoke(IPC.terminalActiveForChat, args), + list: async ( + args: ChatTerminalListArgs = {}, + ): Promise<ChatTerminalSession[]> => { + const runtime = await callProjectRuntimeActionIfBound< + ChatTerminalSession[] + >("terminal", "list", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.terminalList, args); + }, + read: async ( + args: ChatTerminalReadArgs = {}, + ): Promise<ChatTerminalReadResult> => { + const runtime = + await callProjectRuntimeActionIfBound<ChatTerminalReadResult>( + "terminal", + "read", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.terminalRead, args); + }, + write: async (args: ChatTerminalWriteArgs): Promise<{ ok: true }> => { + const runtime = await callProjectRuntimeActionIfBound<{ ok: true }>( + "terminal", + "write", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.terminalWrite, args); + }, + signal: async (args: ChatTerminalSignalArgs): Promise<{ ok: true }> => { + const runtime = await callProjectRuntimeActionIfBound<{ ok: true }>( + "terminal", + "signal", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.terminalSignal, args); + }, + activeForChat: async ( + args: ChatTerminalActiveForChatArgs, + ): Promise<ChatTerminalSession | null> => { + const runtime = + await callProjectRuntimeActionIfBound<ChatTerminalSession | null>( + "terminal", + "activeForChat", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.terminalActiveForChat, args); + }, }, pty: { - create: async (args: PtyCreateArgs): Promise<PtyCreateResult> => - ipcRenderer.invoke(IPC.ptyCreate, args), - write: async (arg: { ptyId: string; data: string }): Promise<void> => - ipcRenderer.invoke(IPC.ptyWrite, arg), + create: async (args: PtyCreateArgs): Promise<PtyCreateResult> => { + const runtime = await callProjectRuntimeActionIfBound<PtyCreateResult>( + "pty", + "create", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.ptyCreate, args); + }, + write: async (arg: { ptyId: string; data: string }): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "pty", + "write", + { args: arg }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.ptyWrite, arg); + }, resize: async (arg: { ptyId: string; cols: number; rows: number; - }): Promise<void> => ipcRenderer.invoke(IPC.ptyResize, arg), + }): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "pty", + "resize", + { args: arg }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.ptyResize, arg); + }, dispose: async (arg: { ptyId: string; sessionId?: string; - }): Promise<void> => ipcRenderer.invoke(IPC.ptyDispose, arg), - onData: ptyDataEventFanout, - onExit: ptyExitEventFanout, + }): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "pty", + "dispose", + { args: arg }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.ptyDispose, arg); + }, + onData: subscribePtyDataEvents, + onExit: subscribePtyExitEvents, }, diff: { - getChanges: async (args: GetDiffChangesArgs): Promise<DiffChanges> => - diffChangesCache.get(serializeIpcCacheArgs(args)), - getFile: async (args: GetFileDiffArgs): Promise<FileDiff> => - ipcRenderer.invoke(IPC.diffGetFile, args), - getFilePatch: async (args: GetFilePatchArgs): Promise<FilePatch> => - ipcRenderer.invoke(IPC.diffGetFilePatch, args), + getChanges: async (args: GetDiffChangesArgs): Promise<DiffChanges> => { + const runtime = await callProjectRuntimeActionIfBound<DiffChanges>( + "diff", + "getChanges", + { arg: args.laneId }, + ); + if (runtime.handled) return runtime.result; + return diffChangesCache.get(serializeIpcCacheArgs(args)); + }, + getFile: async (args: GetFileDiffArgs): Promise<FileDiff> => { + const runtime = await callProjectRuntimeActionIfBound<FileDiff>( + "diff", + "getFileDiff", + { + args: { + laneId: args.laneId, + filePath: args.path, + mode: args.mode, + compareRef: args.compareRef, + compareTo: args.compareTo, + }, + }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.diffGetFile, args); + }, + getFilePatch: async (args: GetFilePatchArgs): Promise<FilePatch> => { + const runtime = await callProjectRuntimeActionIfBound<FilePatch>( + "diff", + "getFilePatch", + { + args: { + laneId: args.laneId, + filePath: args.path, + mode: args.mode, + compareRef: args.compareRef, + compareTo: args.compareTo, + }, + }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.diffGetFilePatch, args); + }, }, files: { - writeTextAtomic: async (args: WriteTextAtomicArgs): Promise<void> => - ipcRenderer.invoke(IPC.filesWriteTextAtomic, args), + writeTextAtomic: async (args: WriteTextAtomicArgs): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "file", + "writeTextAtomic", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.filesWriteTextAtomic, args); + }, listWorkspaces: async ( args: FilesListWorkspacesArgs = {}, - ): Promise<FilesWorkspace[]> => - ipcRenderer.invoke(IPC.filesListWorkspaces, args), - listTree: async (args: FilesListTreeArgs): Promise<FileTreeNode[]> => - ipcRenderer.invoke(IPC.filesListTree, args), - readFile: async (args: FilesReadFileArgs): Promise<FileContent> => - ipcRenderer.invoke(IPC.filesReadFile, args), - writeText: async (args: FilesWriteTextArgs): Promise<void> => - ipcRenderer.invoke(IPC.filesWriteText, args), - createFile: async (args: FilesCreateFileArgs): Promise<void> => - ipcRenderer.invoke(IPC.filesCreateFile, args), - createDirectory: async (args: FilesCreateDirectoryArgs): Promise<void> => - ipcRenderer.invoke(IPC.filesCreateDirectory, args), - rename: async (args: FilesRenameArgs): Promise<void> => - ipcRenderer.invoke(IPC.filesRename, args), - delete: async (args: FilesDeleteArgs): Promise<void> => - ipcRenderer.invoke(IPC.filesDelete, args), - watchChanges: async (args: FilesWatchArgs): Promise<void> => - ipcRenderer.invoke(IPC.filesWatchChanges, args), - stopWatching: async (args: FilesWatchArgs): Promise<void> => - ipcRenderer.invoke(IPC.filesStopWatching, args), + ): Promise<FilesWorkspace[]> => { + const runtime = await callProjectRuntimeActionIfBound<FilesWorkspace[]>( + "file", + "listWorkspaces", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.filesListWorkspaces, args); + }, + listTree: async (args: FilesListTreeArgs): Promise<FileTreeNode[]> => { + const runtime = await callProjectRuntimeActionIfBound<FileTreeNode[]>( + "file", + "listTree", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.filesListTree, args); + }, + readFile: async (args: FilesReadFileArgs): Promise<FileContent> => { + const runtime = await callProjectRuntimeActionIfBound<FileContent>( + "file", + "readFile", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.filesReadFile, args); + }, + writeText: async (args: FilesWriteTextArgs): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "file", + "writeWorkspaceText", + { args }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.filesWriteText, args); + }, + createFile: async (args: FilesCreateFileArgs): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "file", + "createFile", + { args }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.filesCreateFile, args); + }, + createDirectory: async (args: FilesCreateDirectoryArgs): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "file", + "createDirectory", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.filesCreateDirectory, args); + }, + rename: async (args: FilesRenameArgs): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "file", + "rename", + { args }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.filesRename, args); + }, + delete: async (args: FilesDeleteArgs): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "file", + "deletePath", + { args }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.filesDelete, args); + }, + watchChanges: async (args: FilesWatchArgs): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "file", + "watchWorkspace", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.filesWatchChanges, args); + }, + stopWatching: async (args: FilesWatchArgs): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "file", + "stopWatching", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.filesStopWatching, args); + }, quickOpen: async ( args: FilesQuickOpenArgs, - ): Promise<FilesQuickOpenItem[]> => - ipcRenderer.invoke(IPC.filesQuickOpen, args), + ): Promise<FilesQuickOpenItem[]> => { + const runtime = await callProjectRuntimeActionIfBound< + FilesQuickOpenItem[] + >("file", "quickOpen", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.filesQuickOpen, args); + }, searchText: async ( args: FilesSearchTextArgs, - ): Promise<FilesSearchTextMatch[]> => - ipcRenderer.invoke(IPC.filesSearchText, args), + ): Promise<FilesSearchTextMatch[]> => { + const runtime = await callProjectRuntimeActionIfBound< + FilesSearchTextMatch[] + >("file", "searchText", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.filesSearchText, args); + }, onChange: (cb: (ev: FileChangeEvent) => void) => { + const unsubscribeRuntime = subscribeRemoteFileChangeEvents(cb); const listener = ( _event: Electron.IpcRendererEvent, payload: FileChangeEvent, ) => cb(payload); ipcRenderer.on(IPC.filesChange, listener); - return () => ipcRenderer.removeListener(IPC.filesChange, listener); + return () => { + unsubscribeRuntime(); + ipcRenderer.removeListener(IPC.filesChange, listener); + }; }, }, git: { stageFile: async (args: GitFileActionArgs): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitStageFile, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "stageFile", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitStageFile, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, - stageAll: async (args: GitBatchFileActionArgs): Promise<GitActionResult> => { + stageAll: async ( + args: GitBatchFileActionArgs, + ): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitStageAll, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "stageAll", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitStageAll, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, unstageFile: async (args: GitFileActionArgs): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitUnstageFile, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "unstageFile", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitUnstageFile, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, unstageAll: async ( args: GitBatchFileActionArgs, ): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitUnstageAll, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "unstageAll", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitUnstageAll, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, discardFile: async (args: GitFileActionArgs): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitDiscardFile, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "discardFile", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitDiscardFile, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, restoreStagedFile: async ( args: GitFileActionArgs, ): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitRestoreStagedFile, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "restoreStagedFile", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitRestoreStagedFile, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, commit: async (args: GitCommitArgs): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitCommit, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "commit", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitCommit, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, generateCommitMessage: async ( args: GitGenerateCommitMessageArgs, - ): Promise<GitGenerateCommitMessageResult> => - ipcRenderer.invoke(IPC.gitGenerateCommitMessage, args), + ): Promise<GitGenerateCommitMessageResult> => { + const runtime = + await callProjectRuntimeActionIfBound<GitGenerateCommitMessageResult>( + "git", + "generateCommitMessage", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitGenerateCommitMessage, args); + }, listRecentCommits: async (args: { laneId: string; limit?: number; - }): Promise<GitCommitSummary[]> => - ipcRenderer.invoke(IPC.gitListRecentCommits, args), - listCommitFiles: async (args: GitListCommitFilesArgs): Promise<string[]> => - ipcRenderer.invoke(IPC.gitListCommitFiles, args), - getCommitMessage: async (args: GitGetCommitMessageArgs): Promise<string> => - ipcRenderer.invoke(IPC.gitGetCommitMessage, args), + }): Promise<GitCommitSummary[]> => { + const runtime = await callProjectRuntimeActionIfBound<GitCommitSummary[]>( + "git", + "listRecentCommits", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitListRecentCommits, args); + }, + listCommitFiles: async ( + args: GitListCommitFilesArgs, + ): Promise<string[]> => { + const runtime = await callProjectRuntimeActionIfBound<string[]>( + "git", + "listCommitFiles", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitListCommitFiles, args); + }, + getCommitMessage: async ( + args: GitGetCommitMessageArgs, + ): Promise<string> => { + const runtime = await callProjectRuntimeActionIfBound<string>( + "git", + "getCommitMessage", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitGetCommitMessage, args); + }, revertCommit: async (args: GitRevertArgs): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitRevertCommit, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "revertCommit", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitRevertCommit, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, cherryPickCommit: async ( args: GitCherryPickArgs, ): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitCherryPickCommit, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "cherryPickCommit", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitCherryPickCommit, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, stashPush: async (args: GitStashPushArgs): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitStashPush, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "stashPush", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitStashPush, args); clearGitReadCaches(); - return result; + return result as GitActionResult; + }, + stashList: async (args: { laneId: string }): Promise<GitStashSummary[]> => { + const runtime = await callProjectRuntimeActionIfBound<GitStashSummary[]>( + "git", + "listStashes", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitStashList, args); }, - stashList: async (args: { laneId: string }): Promise<GitStashSummary[]> => - ipcRenderer.invoke(IPC.gitStashList, args), stashApply: async (args: GitStashRefArgs): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitStashApply, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "stashApply", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitStashApply, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, stashPop: async (args: GitStashRefArgs): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitStashPop, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "stashPop", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitStashPop, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, stashDrop: async (args: GitStashRefArgs): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitStashDrop, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "stashDrop", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitStashDrop, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, stashClear: async (args: { laneId: string }): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitStashClear, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "stashClear", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitStashClear, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, fetch: async (args: { laneId: string }): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitFetch, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "fetch", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitFetch, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, pull: async (args: { laneId: string }): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitPull, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "pull", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitPull, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, getSyncStatus: async (args: { laneId: string; - }): Promise<GitUpstreamSyncStatus> => - ipcRenderer.invoke(IPC.gitGetSyncStatus, args), - getOriginRemote: async (args: { laneId: string }): Promise<{ remoteUrl: string | null; branch: string | null }> => - ipcRenderer.invoke(IPC.gitGetOriginRemote, args), - getOpenPrForBranch: async (args: { laneId: string; branch?: string }): Promise<{ prUrl: string | null; prNumber: number | null; title: string | null; headRefName: string | null }> => - ipcRenderer.invoke(IPC.gitGetOpenPrForBranch, args), + }): Promise<GitUpstreamSyncStatus> => { + const runtime = + await callProjectRuntimeActionIfBound<GitUpstreamSyncStatus>( + "git", + "getSyncStatus", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitGetSyncStatus, args); + }, + getOriginRemote: async (args: { + laneId: string; + }): Promise<{ remoteUrl: string | null; branch: string | null }> => { + const runtime = await callProjectRuntimeActionIfBound<{ + remoteUrl: string | null; + branch: string | null; + }>("git", "getOriginRemote", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitGetOriginRemote, args); + }, + getOpenPrForBranch: async (args: { + laneId: string; + branch?: string; + }): Promise<{ + prUrl: string | null; + prNumber: number | null; + title: string | null; + headRefName: string | null; + }> => { + const runtime = await callProjectRuntimeActionIfBound<{ + prUrl: string | null; + prNumber: number | null; + title: string | null; + headRefName: string | null; + }>("git", "getOpenPrForBranch", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitGetOpenPrForBranch, args); + }, sync: async (args: GitSyncArgs): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitSync, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "sync", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitSync, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, push: async (args: GitPushArgs): Promise<GitActionResult> => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitPush, args); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "push", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitPush, args); clearGitReadCaches(); - return result; + return result as GitActionResult; + }, + getConflictState: async (laneId: string): Promise<GitConflictState> => { + const runtime = await callProjectRuntimeActionIfBound<GitConflictState>( + "git", + "getConflictState", + { args: { laneId } }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitGetConflictState, { laneId }); + }, + rebaseContinue: async (laneId: string): Promise<GitActionResult> => { + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "rebaseContinue", + { args: { laneId } }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitRebaseContinue, { laneId }); + }, + rebaseAbort: async (laneId: string): Promise<GitActionResult> => { + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "rebaseAbort", + { args: { laneId } }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitRebaseAbort, { laneId }); + }, + mergeContinue: async (laneId: string): Promise<GitActionResult> => { + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "mergeContinue", + { args: { laneId } }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitMergeContinue, { laneId }); + }, + mergeAbort: async (laneId: string): Promise<GitActionResult> => { + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "mergeAbort", + { args: { laneId } }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitMergeAbort, { laneId }); }, - getConflictState: async (laneId: string): Promise<GitConflictState> => - ipcRenderer.invoke(IPC.gitGetConflictState, { laneId }), - rebaseContinue: async (laneId: string): Promise<GitActionResult> => - ipcRenderer.invoke(IPC.gitRebaseContinue, { laneId }), - rebaseAbort: async (laneId: string): Promise<GitActionResult> => - ipcRenderer.invoke(IPC.gitRebaseAbort, { laneId }), - mergeContinue: async (laneId: string): Promise<GitActionResult> => - ipcRenderer.invoke(IPC.gitMergeContinue, { laneId }), - mergeAbort: async (laneId: string): Promise<GitActionResult> => - ipcRenderer.invoke(IPC.gitMergeAbort, { laneId }), listBranches: async ( args: GitListBranchesArgs, - ): Promise<GitBranchSummary[]> => - gitBranchesCache.get(serializeIpcCacheArgs(args)), + ): Promise<GitBranchSummary[]> => { + const runtime = await callProjectRuntimeActionIfBound<GitBranchSummary[]>( + "git", + "listBranches", + { args }, + ); + if (runtime.handled) return runtime.result; + return gitBranchesCache.get(serializeIpcCacheArgs(args)); + }, getUserIdentity: async ( args: GitGetUserIdentityArgs, - ): Promise<GitUserIdentity> => - ipcRenderer.invoke(IPC.gitGetUserIdentity, args), + ): Promise<GitUserIdentity> => { + const runtime = await callProjectRuntimeActionIfBound<GitUserIdentity>( + "git", + "getUserIdentity", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitGetUserIdentity, args); + }, checkoutBranch: async ( args: GitCheckoutBranchArgs, - ): Promise<GitActionResult> => - ipcRenderer.invoke(IPC.gitCheckoutBranch, args), + ): Promise<GitActionResult> => { + clearGitReadCaches(); + const runtime = await callProjectRuntimeActionIfBound<GitActionResult>( + "git", + "checkoutBranch", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitCheckoutBranch, args); + clearGitReadCaches(); + return result as GitActionResult; + }, }, conflicts: { getLaneStatus: async ( args: GetLaneConflictStatusArgs, ): Promise<ConflictStatus> => - ipcRenderer.invoke(IPC.conflictsGetLaneStatus, args), + callProjectRuntimeActionOr("conflicts", "getLaneStatus", { args }, () => + ipcRenderer.invoke(IPC.conflictsGetLaneStatus, args), + ), listOverlaps: async (args: ListOverlapsArgs): Promise<ConflictOverlap[]> => - ipcRenderer.invoke(IPC.conflictsListOverlaps, args), + callProjectRuntimeActionOr("conflicts", "listOverlaps", { args }, () => + ipcRenderer.invoke(IPC.conflictsListOverlaps, args), + ), getRiskMatrix: async (): Promise<RiskMatrixEntry[]> => - ipcRenderer.invoke(IPC.conflictsGetRiskMatrix), + callProjectRuntimeActionOr("conflicts", "getRiskMatrix", {}, () => + ipcRenderer.invoke(IPC.conflictsGetRiskMatrix), + ), simulateMerge: async ( args: MergeSimulationArgs, ): Promise<MergeSimulationResult> => - ipcRenderer.invoke(IPC.conflictsSimulateMerge, args), + callProjectRuntimeActionOr("conflicts", "simulateMerge", { args }, () => + ipcRenderer.invoke(IPC.conflictsSimulateMerge, args), + ), runPrediction: async ( args: RunConflictPredictionArgs = {}, ): Promise<BatchAssessmentResult> => - ipcRenderer.invoke(IPC.conflictsRunPrediction, args), + callProjectRuntimeActionOr("conflicts", "runPrediction", { args }, () => + ipcRenderer.invoke(IPC.conflictsRunPrediction, args), + ), getBatchAssessment: async (): Promise<BatchAssessmentResult> => - ipcRenderer.invoke(IPC.conflictsGetBatchAssessment), + callProjectRuntimeActionOr("conflicts", "getBatchAssessment", {}, () => + ipcRenderer.invoke(IPC.conflictsGetBatchAssessment), + ), listProposals: async (laneId: string): Promise<ConflictProposal[]> => - ipcRenderer.invoke(IPC.conflictsListProposals, { laneId }), + callProjectRuntimeActionOr( + "conflicts", + "listProposals", + { args: { laneId } }, + () => ipcRenderer.invoke(IPC.conflictsListProposals, { laneId }), + ), prepareProposal: async ( args: PrepareConflictProposalArgs, ): Promise<ConflictProposalPreview> => - ipcRenderer.invoke(IPC.conflictsPrepareProposal, args), + callProjectRuntimeActionOr("conflicts", "prepareProposal", { args }, () => + ipcRenderer.invoke(IPC.conflictsPrepareProposal, args), + ), requestProposal: async ( args: RequestConflictProposalArgs, ): Promise<ConflictProposal> => - ipcRenderer.invoke(IPC.conflictsRequestProposal, args), + callProjectRuntimeActionOr("conflicts", "requestProposal", { args }, () => + ipcRenderer.invoke(IPC.conflictsRequestProposal, args), + ), applyProposal: async ( args: ApplyConflictProposalArgs, ): Promise<ConflictProposal> => - ipcRenderer.invoke(IPC.conflictsApplyProposal, args), + callProjectRuntimeActionOr("conflicts", "applyProposal", { args }, () => + ipcRenderer.invoke(IPC.conflictsApplyProposal, args), + ), undoProposal: async ( args: UndoConflictProposalArgs, ): Promise<ConflictProposal> => - ipcRenderer.invoke(IPC.conflictsUndoProposal, args), + callProjectRuntimeActionOr("conflicts", "undoProposal", { args }, () => + ipcRenderer.invoke(IPC.conflictsUndoProposal, args), + ), runExternalResolver: async ( args: RunExternalConflictResolverArgs, ): Promise<ConflictExternalResolverRunSummary> => - ipcRenderer.invoke(IPC.conflictsRunExternalResolver, args), + callProjectRuntimeActionOr( + "conflicts", + "runExternalResolver", + { args }, + () => ipcRenderer.invoke(IPC.conflictsRunExternalResolver, args), + ), listExternalResolverRuns: async ( args: ListExternalConflictResolverRunsArgs = {}, ): Promise<ConflictExternalResolverRunSummary[]> => - ipcRenderer.invoke(IPC.conflictsListExternalResolverRuns, args), + callProjectRuntimeActionOr( + "conflicts", + "listExternalResolverRuns", + { args }, + () => ipcRenderer.invoke(IPC.conflictsListExternalResolverRuns, args), + ), commitExternalResolverRun: async ( args: CommitExternalConflictResolverRunArgs, ): Promise<CommitExternalConflictResolverRunResult> => - ipcRenderer.invoke(IPC.conflictsCommitExternalResolverRun, args), - prepareResolverSession: ( + callProjectRuntimeActionOr( + "conflicts", + "commitExternalResolverRun", + { args }, + () => ipcRenderer.invoke(IPC.conflictsCommitExternalResolverRun, args), + ), + prepareResolverSession: async ( args: PrepareResolverSessionArgs, ): Promise<PrepareResolverSessionResult> => - ipcRenderer.invoke(IPC.conflictsPrepareResolverSession, args), - attachResolverSession: ( + callProjectRuntimeActionOr( + "conflicts", + "prepareResolverSession", + { args }, + () => ipcRenderer.invoke(IPC.conflictsPrepareResolverSession, args), + ), + attachResolverSession: async ( args: AttachResolverSessionArgs, ): Promise<ConflictExternalResolverRunSummary> => - ipcRenderer.invoke(IPC.conflictsAttachResolverSession, args), - finalizeResolverSession: ( + callProjectRuntimeActionOr( + "conflicts", + "attachResolverSession", + { args }, + () => ipcRenderer.invoke(IPC.conflictsAttachResolverSession, args), + ), + finalizeResolverSession: async ( args: FinalizeResolverSessionArgs, ): Promise<ConflictExternalResolverRunSummary> => - ipcRenderer.invoke(IPC.conflictsFinalizeResolverSession, args), - cancelResolverSession: ( + callProjectRuntimeActionOr( + "conflicts", + "finalizeResolverSession", + { args }, + () => ipcRenderer.invoke(IPC.conflictsFinalizeResolverSession, args), + ), + cancelResolverSession: async ( args: CancelResolverSessionArgs, ): Promise<ConflictExternalResolverRunSummary> => - ipcRenderer.invoke(IPC.conflictsCancelResolverSession, args), - suggestResolverTarget: ( + callProjectRuntimeActionOr( + "conflicts", + "cancelResolverSession", + { args }, + () => ipcRenderer.invoke(IPC.conflictsCancelResolverSession, args), + ), + suggestResolverTarget: async ( args: SuggestResolverTargetArgs, ): Promise<SuggestResolverTargetResult> => - ipcRenderer.invoke(IPC.conflictsSuggestResolverTarget, args), + callProjectRuntimeActionOr( + "conflicts", + "suggestResolverTarget", + { args }, + () => ipcRenderer.invoke(IPC.conflictsSuggestResolverTarget, args), + ), onEvent: (cb: (ev: ConflictEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -2902,12 +6481,25 @@ contextBridge.exposeInMainWorld("ade", { }, }, feedback: { - prepareDraft: async (args: FeedbackPrepareDraftArgs): Promise<FeedbackPreparedDraft> => - ipcRenderer.invoke(IPC.feedbackPrepareDraft, args), - submitDraft: async (args: FeedbackSubmitDraftArgs): Promise<FeedbackSubmission> => - ipcRenderer.invoke(IPC.feedbackSubmitDraft, args), + prepareDraft: async ( + args: FeedbackPrepareDraftArgs, + ): Promise<FeedbackPreparedDraft> => + callProjectRuntimeActionOr("feedback", "prepareDraft", { args }, () => + ipcRenderer.invoke(IPC.feedbackPrepareDraft, args), + ), + submitDraft: async ( + args: FeedbackSubmitDraftArgs, + ): Promise<FeedbackSubmission> => + callProjectRuntimeActionOr( + "feedback", + "submitPreparedDraft", + { args }, + () => ipcRenderer.invoke(IPC.feedbackSubmitDraft, args), + ), list: async (): Promise<FeedbackSubmission[]> => - ipcRenderer.invoke(IPC.feedbackList), + callProjectRuntimeActionOr("feedback", "list", {}, () => + ipcRenderer.invoke(IPC.feedbackList), + ), onUpdate: (cb: (event: FeedbackSubmissionEvent) => void): (() => void) => { const handler = ( _event: Electron.IpcRendererEvent, @@ -2918,190 +6510,412 @@ contextBridge.exposeInMainWorld("ade", { }, }, github: { - getStatus: async (opts?: { forceRefresh?: boolean }): Promise<GitHubStatus> => - opts?.forceRefresh - ? clearAround(() => githubStatusCache.clear(), () => ipcRenderer.invoke(IPC.githubGetStatus, opts ?? {})) - : githubStatusCache.get(), + getStatus: async (opts?: { + forceRefresh?: boolean; + }): Promise<GitHubStatus> => { + if (opts?.forceRefresh) githubStatusCache.clear(); + return callProjectRuntimeActionOr( + "github", + "getStatus", + { args: opts ?? {} }, + () => + opts?.forceRefresh + ? clearAround( + () => githubStatusCache.clear(), + () => ipcRenderer.invoke(IPC.githubGetStatus, opts ?? {}), + ) + : githubStatusCache.get(), + ); + }, setToken: async (token: string): Promise<GitHubStatus> => - clearAround(() => githubStatusCache.clear(), () => ipcRenderer.invoke(IPC.githubSetToken, { token })), + clearAround( + () => githubStatusCache.clear(), + () => + callProjectRuntimeActionOr("github", "setToken", { arg: token }, () => + ipcRenderer.invoke(IPC.githubSetToken, { token }), + ), + ), clearToken: async (): Promise<GitHubStatus> => - clearAround(() => githubStatusCache.clear(), () => ipcRenderer.invoke(IPC.githubClearToken)), + clearAround( + () => githubStatusCache.clear(), + () => + callProjectRuntimeActionOr("github", "clearToken", {}, () => + ipcRenderer.invoke(IPC.githubClearToken), + ), + ), detectRepo: async (): Promise<{ owner: string; name: string } | null> => { + const runtime = await callProjectRuntimeActionIfBound<{ + owner: string; + name: string; + } | null>("github", "detectRepo", {}); + if (runtime.handled) return runtime.result; const status = await githubStatusCache.get(); return status.repo; }, - listRepoLabels: async (args: { owner: string; name: string }): Promise<Array<{ name: string; color?: string }>> => - ipcRenderer.invoke(IPC.githubListRepoLabels, args), - listRepoCollaborators: async (args: { owner: string; name: string }): Promise<Array<{ login: string; avatarUrl?: string }>> => - ipcRenderer.invoke(IPC.githubListRepoCollaborators, args), - listMyRepos: async (input: ListMyGitHubReposInput = {}): Promise<ListMyGitHubReposResult> => + listRepoLabels: async (args: { + owner: string; + name: string; + }): Promise<Array<{ name: string; color?: string }>> => + callProjectRuntimeActionOr("github", "listRepoLabels", { args }, () => + ipcRenderer.invoke(IPC.githubListRepoLabels, args), + ), + listRepoCollaborators: async (args: { + owner: string; + name: string; + }): Promise<Array<{ login: string; avatarUrl?: string }>> => + callProjectRuntimeActionOr( + "github", + "listRepoCollaborators", + { args }, + () => ipcRenderer.invoke(IPC.githubListRepoCollaborators, args), + ), + listMyRepos: async ( + input: ListMyGitHubReposInput = {}, + ): Promise<ListMyGitHubReposResult> => ipcRenderer.invoke(IPC.githubListMyRepos, input), - publishCurrentProject: async (input: PublishProjectInput): Promise<PublishProjectResult> => - clearAround(() => githubStatusCache.clear(), () => ipcRenderer.invoke(IPC.githubPublishCurrentProject, input)), + publishCurrentProject: async ( + input: PublishProjectInput, + ): Promise<PublishProjectResult> => + clearAround( + () => githubStatusCache.clear(), + () => + callProjectRuntimeActionOr( + "github", + "publishCurrentProject", + { args: input }, + () => ipcRenderer.invoke(IPC.githubPublishCurrentProject, input), + ), + ), onStatusChanged: (cb: (status: GitHubStatus) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: GitHubStatus) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: GitHubStatus, + ) => { githubStatusCache.clear(); cb(payload); }; ipcRenderer.on(IPC.githubStatusChanged, listener); - return () => ipcRenderer.removeListener(IPC.githubStatusChanged, listener); + return () => + ipcRenderer.removeListener(IPC.githubStatusChanged, listener); }, }, prs: { createFromLane: async (args: CreatePrFromLaneArgs): Promise<PrSummary> => - ipcRenderer.invoke(IPC.prsCreateFromLane, args), + callProjectRuntimeActionOr("pr", "createFromLane", { args }, () => + ipcRenderer.invoke(IPC.prsCreateFromLane, args), + ), linkToLane: async (args: LinkPrToLaneArgs): Promise<PrSummary> => - ipcRenderer.invoke(IPC.prsLinkToLane, args), + callProjectRuntimeActionOr("pr", "linkToLane", { args }, () => + ipcRenderer.invoke(IPC.prsLinkToLane, args), + ), getForLane: async (laneId: string): Promise<PrSummary | null> => - ipcRenderer.invoke(IPC.prsGetForLane, { laneId }), + callProjectRuntimeActionOr("pr", "getForLane", { arg: laneId }, () => + ipcRenderer.invoke(IPC.prsGetForLane, { laneId }), + ), listAll: async (): Promise<PrSummary[]> => - ipcRenderer.invoke(IPC.prsListAll), + callProjectRuntimeActionOr("pr", "listAll", { args: {} }, () => + ipcRenderer.invoke(IPC.prsListAll), + ), listOpenForRepo: async (): Promise<BranchPullRequest[]> => - ipcRenderer.invoke(IPC.prsListOpenForRepo), + callProjectRuntimeActionOr("pr", "listOpenPullRequests", {}, () => + ipcRenderer.invoke(IPC.prsListOpenForRepo), + ), refresh: async ( args: { prId?: string; prIds?: string[] } = {}, - ): Promise<PrSummary[]> => ipcRenderer.invoke(IPC.prsRefresh, args), + ): Promise<PrSummary[]> => + callProjectRuntimeActionOr("pr", "refresh", { args }, () => + ipcRenderer.invoke(IPC.prsRefresh, args), + ), getStatus: async (prId: string): Promise<PrStatus | null> => - ipcRenderer.invoke(IPC.prsGetStatus, { prId }), + callProjectRuntimeActionOr("pr", "getStatus", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetStatus, { prId }), + ), getChecks: async (prId: string): Promise<PrCheck[]> => - ipcRenderer.invoke(IPC.prsGetChecks, { prId }), + callProjectRuntimeActionOr("pr", "getChecks", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetChecks, { prId }), + ), getComments: async (prId: string): Promise<PrComment[]> => - ipcRenderer.invoke(IPC.prsGetComments, { prId }), + callProjectRuntimeActionOr("pr", "getComments", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetComments, { prId }), + ), getReviews: async (prId: string): Promise<PrReview[]> => - ipcRenderer.invoke(IPC.prsGetReviews, { prId }), + callProjectRuntimeActionOr("pr", "getReviews", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetReviews, { prId }), + ), getReviewThreads: async (prId: string): Promise<PrReviewThread[]> => - ipcRenderer.invoke(IPC.prsGetReviewThreads, { prId }), + callProjectRuntimeActionOr("pr", "getReviewThreads", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetReviewThreads, { prId }), + ), updateDescription: async (args: UpdatePrDescriptionArgs): Promise<void> => - ipcRenderer.invoke(IPC.prsUpdateDescription, args), + callProjectRuntimeActionOr("pr", "updateDescription", { args }, () => + ipcRenderer.invoke(IPC.prsUpdateDescription, args), + ), delete: async (args: DeletePrArgs): Promise<DeletePrResult> => - ipcRenderer.invoke(IPC.prsDelete, args), + callProjectRuntimeActionOr("pr", "delete", { args }, () => + ipcRenderer.invoke(IPC.prsDelete, args), + ), draftDescription: async ( args: DraftPrDescriptionArgs, ): Promise<{ title: string; body: string }> => - ipcRenderer.invoke(IPC.prsDraftDescription, args), + callProjectRuntimeActionOr("pr", "draftDescription", { args }, () => + ipcRenderer.invoke(IPC.prsDraftDescription, args), + ), land: async (args: LandPrArgs): Promise<LandResult> => - ipcRenderer.invoke(IPC.prsLand, args), + callProjectRuntimeActionOr("pr", "land", { args }, () => + ipcRenderer.invoke(IPC.prsLand, args), + ), landStack: async (args: LandStackArgs): Promise<LandResult[]> => - ipcRenderer.invoke(IPC.prsLandStack, args), - retargetBase: async (args: { prId: string; baseBranch: string }): Promise<void> => - ipcRenderer.invoke(IPC.prsRetargetBase, args), - openInGitHub: async (prId: string): Promise<void> => - ipcRenderer.invoke(IPC.prsOpenInGitHub, { prId }), + callProjectRuntimeActionOr("pr", "landStack", { args }, () => + ipcRenderer.invoke(IPC.prsLandStack, args), + ), + retargetBase: async (args: { + prId: string; + baseBranch: string; + }): Promise<void> => + callProjectRuntimeActionOr( + "pr", + "retargetBase", + { argsList: [args.prId, args.baseBranch] }, + () => ipcRenderer.invoke(IPC.prsRetargetBase, args), + ), + openInGitHub: async (prId: string): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<PrSummary[]>( + "pr", + "listAll", + { args: {} }, + ); + if (runtime.handled) { + const pr = runtime.result.find((entry) => entry.id === prId); + if (pr?.githubUrl) { + await ipcRenderer.invoke(IPC.appOpenExternal, { url: pr.githubUrl }); + return; + } + } + await ipcRenderer.invoke(IPC.prsOpenInGitHub, { prId }); + }, createQueue: (args: CreateQueuePrsArgs): Promise<CreateQueuePrsResult> => - ipcRenderer.invoke(IPC.prsCreateQueue, args), + callProjectRuntimeActionOr("pr", "createQueuePrs", { args }, () => + ipcRenderer.invoke(IPC.prsCreateQueue, args), + ), createIntegration: ( args: CreateIntegrationPrArgs, ): Promise<CreateIntegrationPrResult> => - ipcRenderer.invoke(IPC.prsCreateIntegration, args), + callProjectRuntimeActionOr("pr", "createIntegrationPr", { args }, () => + ipcRenderer.invoke(IPC.prsCreateIntegration, args), + ), simulateIntegration: ( args: SimulateIntegrationArgs, ): Promise<IntegrationProposal> => - ipcRenderer.invoke(IPC.prsSimulateIntegration, args), + callProjectRuntimeActionOr("pr", "simulateIntegration", { args }, () => + ipcRenderer.invoke(IPC.prsSimulateIntegration, args), + ), commitIntegration: ( args: CommitIntegrationArgs, ): Promise<CreateIntegrationPrResult> => - ipcRenderer.invoke(IPC.prsCommitIntegration, args), + callProjectRuntimeActionOr("pr", "commitIntegration", { args }, () => + ipcRenderer.invoke(IPC.prsCommitIntegration, args), + ), listProposals: (): Promise<IntegrationProposal[]> => - ipcRenderer.invoke(IPC.prsListProposals), + callProjectRuntimeActionOr("pr", "listIntegrationProposals", {}, () => + ipcRenderer.invoke(IPC.prsListProposals), + ), updateProposal: (args: UpdateIntegrationProposalArgs): Promise<void> => - ipcRenderer.invoke(IPC.prsUpdateProposal, args), + callProjectRuntimeActionOr( + "pr", + "updateIntegrationProposal", + { args }, + () => ipcRenderer.invoke(IPC.prsUpdateProposal, args), + ), deleteProposal: ( args: DeleteIntegrationProposalArgs, ): Promise<DeleteIntegrationProposalResult> => - ipcRenderer.invoke(IPC.prsDeleteProposal, args), + callProjectRuntimeActionOr( + "pr", + "deleteIntegrationProposal", + { args }, + () => ipcRenderer.invoke(IPC.prsDeleteProposal, args), + ), landStackEnhanced: (args: LandStackEnhancedArgs): Promise<LandResult[]> => - ipcRenderer.invoke(IPC.prsLandStackEnhanced, args), + callProjectRuntimeActionOr("pr", "landStackEnhanced", { args }, () => + ipcRenderer.invoke(IPC.prsLandStackEnhanced, args), + ), landQueueNext: (args: LandQueueNextArgs): Promise<LandResult> => - ipcRenderer.invoke(IPC.prsLandQueueNext, args), + callProjectRuntimeActionOr("pr", "landQueueNext", { args }, () => + ipcRenderer.invoke(IPC.prsLandQueueNext, args), + ), startQueueAutomation: ( args: StartQueueAutomationArgs, ): Promise<QueueLandingState> => - ipcRenderer.invoke(IPC.prsStartQueueAutomation, args), + callProjectRuntimeActionOr("pr", "startQueueAutomation", { args }, () => + ipcRenderer.invoke(IPC.prsStartQueueAutomation, args), + ), pauseQueueAutomation: ( queueId: string, ): Promise<QueueLandingState | null> => - ipcRenderer.invoke(IPC.prsPauseQueueAutomation, { queueId }), + callProjectRuntimeActionOr( + "pr", + "pauseQueueAutomation", + { arg: queueId }, + () => ipcRenderer.invoke(IPC.prsPauseQueueAutomation, { queueId }), + ), resumeQueueAutomation: ( args: ResumeQueueAutomationArgs, ): Promise<QueueLandingState | null> => - ipcRenderer.invoke(IPC.prsResumeQueueAutomation, args), + callProjectRuntimeActionOr("pr", "resumeQueueAutomation", { args }, () => + ipcRenderer.invoke(IPC.prsResumeQueueAutomation, args), + ), cancelQueueAutomation: ( queueId: string, ): Promise<QueueLandingState | null> => - ipcRenderer.invoke(IPC.prsCancelQueueAutomation, { queueId }), + callProjectRuntimeActionOr( + "pr", + "cancelQueueAutomation", + { arg: queueId }, + () => ipcRenderer.invoke(IPC.prsCancelQueueAutomation, { queueId }), + ), reorderQueuePrs: (args: ReorderQueuePrsArgs): Promise<void> => - ipcRenderer.invoke(IPC.prsReorderQueue, args), + callProjectRuntimeActionOr("pr", "reorderQueuePrs", { args }, () => + ipcRenderer.invoke(IPC.prsReorderQueue, args), + ), getHealth: (prId: string): Promise<PrHealth> => - ipcRenderer.invoke(IPC.prsGetHealth, { prId }), + callProjectRuntimeActionOr("pr", "getPrHealth", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetHealth, { prId }), + ), getQueueState: (groupId: string): Promise<QueueLandingState | null> => - ipcRenderer.invoke(IPC.prsGetQueueState, { groupId }), + callProjectRuntimeActionOr("pr", "getQueueState", { arg: groupId }, () => + ipcRenderer.invoke(IPC.prsGetQueueState, { groupId }), + ), listQueueStates: (args?: { includeCompleted?: boolean; limit?: number; }): Promise<QueueLandingState[]> => - ipcRenderer.invoke(IPC.prsListQueueStates, args ?? {}), + callProjectRuntimeActionOr( + "pr", + "listQueueStates", + { args: args ?? {} }, + () => ipcRenderer.invoke(IPC.prsListQueueStates, args ?? {}), + ), getConflictAnalysis: (prId: string): Promise<PrConflictAnalysis> => - ipcRenderer.invoke(IPC.prsGetConflictAnalysis, { prId }), + callProjectRuntimeActionOr( + "pr", + "getConflictAnalysis", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsGetConflictAnalysis, { prId }), + ), getMergeContext: (prId: string): Promise<PrMergeContext> => - ipcRenderer.invoke(IPC.prsGetMergeContext, { prId }), + callProjectRuntimeActionOr("pr", "getMergeContext", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetMergeContext, { prId }), + ), listWithConflicts: (): Promise<PrWithConflicts[]> => - ipcRenderer.invoke(IPC.prsListWithConflicts), + callProjectRuntimeActionOr("pr", "listWithConflicts", {}, () => + ipcRenderer.invoke(IPC.prsListWithConflicts), + ), getGitHubSnapshot: (args?: { force?: boolean; }): Promise<GitHubPrSnapshot> => - ipcRenderer.invoke(IPC.prsGetGitHubSnapshot, args ?? {}), + callProjectRuntimeActionOr( + "pr", + "getGithubSnapshot", + { args: args ?? {} }, + () => ipcRenderer.invoke(IPC.prsGetGitHubSnapshot, args ?? {}), + ), listIntegrationWorkflows: ( args: ListIntegrationWorkflowsArgs = {}, ): Promise<IntegrationProposal[]> => - ipcRenderer.invoke(IPC.prsListIntegrationWorkflows, args), + callProjectRuntimeActionOr( + "pr", + "listIntegrationWorkflows", + { args }, + () => ipcRenderer.invoke(IPC.prsListIntegrationWorkflows, args), + ), createIntegrationLaneForProposal: ( args: CreateIntegrationLaneForProposalArgs, ): Promise<CreateIntegrationLaneForProposalResult> => - ipcRenderer.invoke(IPC.prsCreateIntegrationLaneForProposal, args), + callProjectRuntimeActionOr( + "pr", + "createIntegrationLaneForProposal", + { args }, + () => ipcRenderer.invoke(IPC.prsCreateIntegrationLaneForProposal, args), + ), startIntegrationResolution: ( args: StartIntegrationResolutionArgs, ): Promise<StartIntegrationResolutionResult> => - ipcRenderer.invoke(IPC.prsStartIntegrationResolution, args), + callProjectRuntimeActionOr( + "pr", + "startIntegrationResolution", + { args }, + () => ipcRenderer.invoke(IPC.prsStartIntegrationResolution, args), + ), getIntegrationResolutionState: ( proposalId: string, ): Promise<IntegrationResolutionState | null> => - ipcRenderer.invoke(IPC.prsGetIntegrationResolutionState, { proposalId }), + callProjectRuntimeActionOr( + "pr", + "getIntegrationResolutionState", + { arg: proposalId }, + () => + ipcRenderer.invoke(IPC.prsGetIntegrationResolutionState, { + proposalId, + }), + ), recheckIntegrationStep: ( args: RecheckIntegrationStepArgs, ): Promise<RecheckIntegrationStepResult> => - ipcRenderer.invoke(IPC.prsRecheckIntegrationStep, args), + callProjectRuntimeActionOr("pr", "recheckIntegrationStep", { args }, () => + ipcRenderer.invoke(IPC.prsRecheckIntegrationStep, args), + ), aiResolutionStart: ( args: PrAiResolutionStartArgs, ): Promise<PrAiResolutionStartResult> => - ipcRenderer.invoke(IPC.prsAiResolutionStart, args), + callProjectRuntimeActionOr("pr", "aiResolutionStart", { args }, () => + ipcRenderer.invoke(IPC.prsAiResolutionStart, args), + ), aiResolutionGetSession: ( args: PrAiResolutionGetSessionArgs, ): Promise<PrAiResolutionGetSessionResult> => - ipcRenderer.invoke(IPC.prsAiResolutionGetSession, args), + callProjectRuntimeActionOr("pr", "aiResolutionGetSession", { args }, () => + ipcRenderer.invoke(IPC.prsAiResolutionGetSession, args), + ), aiResolutionInput: (args: PrAiResolutionInputArgs): Promise<void> => - ipcRenderer.invoke(IPC.prsAiResolutionInput, args), + callProjectRuntimeActionOr("pr", "aiResolutionInput", { args }, () => + ipcRenderer.invoke(IPC.prsAiResolutionInput, args), + ), aiResolutionStop: (args: PrAiResolutionStopArgs): Promise<void> => - ipcRenderer.invoke(IPC.prsAiResolutionStop, args), + callProjectRuntimeActionOr("pr", "aiResolutionStop", { args }, () => + ipcRenderer.invoke(IPC.prsAiResolutionStop, args), + ), issueResolutionStart: ( args: PrIssueResolutionStartArgs, ): Promise<PrIssueResolutionStartResult> => - ipcRenderer.invoke(IPC.prsIssueResolutionStart, args), + callProjectRuntimeActionOr("pr", "issueResolutionStart", { args }, () => + ipcRenderer.invoke(IPC.prsIssueResolutionStart, args), + ), issueResolutionPreviewPrompt: ( args: PrIssueResolutionPromptPreviewArgs, ): Promise<PrIssueResolutionPromptPreviewResult> => - ipcRenderer.invoke(IPC.prsIssueResolutionPreviewPrompt, args), + callProjectRuntimeActionOr( + "pr", + "issueResolutionPreviewPrompt", + { args }, + () => ipcRenderer.invoke(IPC.prsIssueResolutionPreviewPrompt, args), + ), rebaseResolutionStart: ( args: RebaseResolutionStartArgs, ): Promise<RebaseResolutionStartResult> => - ipcRenderer.invoke(IPC.prsRebaseResolutionStart, args), + callProjectRuntimeActionOr("pr", "rebaseResolutionStart", { args }, () => + ipcRenderer.invoke(IPC.prsRebaseResolutionStart, args), + ), onAiResolutionEvent: (cb: (ev: PrAiResolutionEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: PrAiResolutionEventPayload, ) => cb(payload); ipcRenderer.on(IPC.prsAiResolutionEvent, listener); - return () => + const unsubscribeRemote = subscribeRemotePrAiResolutionEvents(cb); + return () => { + unsubscribeRemote(); ipcRenderer.removeListener(IPC.prsAiResolutionEvent, listener); + }; }, onEvent: (cb: (ev: PrEventPayload) => void) => { const listener = ( @@ -3109,76 +6923,224 @@ contextBridge.exposeInMainWorld("ade", { payload: PrEventPayload, ) => cb(payload); ipcRenderer.on(IPC.prsEvent, listener); - return () => ipcRenderer.removeListener(IPC.prsEvent, listener); + const unsubscribeRemote = subscribeRemotePrEvents(cb); + return () => { + unsubscribeRemote(); + ipcRenderer.removeListener(IPC.prsEvent, listener); + }; }, getDetail: async (prId: string): Promise<PrDetail> => - ipcRenderer.invoke(IPC.prsGetDetail, { prId }), + callProjectRuntimeActionOr("pr", "getDetail", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetDetail, { prId }), + ), getFiles: async (prId: string): Promise<PrFile[]> => - ipcRenderer.invoke(IPC.prsGetFiles, { prId }), + callProjectRuntimeActionOr("pr", "getFiles", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetFiles, { prId }), + ), getCommits: async (prId: string): Promise<PrCommit[]> => - ipcRenderer.invoke(IPC.prsGetCommits, { prId }), + callProjectRuntimeActionOr("pr", "getCommits", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetCommits, { prId }), + ), getActionRuns: async (prId: string): Promise<PrActionRun[]> => - ipcRenderer.invoke(IPC.prsGetActionRuns, { prId }), + callProjectRuntimeActionOr("pr", "getActionRuns", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetActionRuns, { prId }), + ), getActivity: async (prId: string): Promise<PrActivityEvent[]> => - ipcRenderer.invoke(IPC.prsGetActivity, { prId }), + callProjectRuntimeActionOr("pr", "getActivity", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetActivity, { prId }), + ), addComment: async (args: AddPrCommentArgs): Promise<PrComment> => - ipcRenderer.invoke(IPC.prsAddComment, args), + callProjectRuntimeActionOr("pr", "addComment", { args }, () => + ipcRenderer.invoke(IPC.prsAddComment, args), + ), replyToReviewThread: async ( args: ReplyToPrReviewThreadArgs, ): Promise<PrReviewThreadComment> => - ipcRenderer.invoke(IPC.prsReplyToReviewThread, args), - resolveReviewThread: async (args: ResolvePrReviewThreadArgs): Promise<void> => - ipcRenderer.invoke(IPC.prsResolveReviewThread, args), - updateTitle: async (args: UpdatePrTitleArgs): Promise<void> => ipcRenderer.invoke(IPC.prsUpdateTitle, args), - updateBody: async (args: UpdatePrBodyArgs): Promise<void> => ipcRenderer.invoke(IPC.prsUpdateBody, args), - setLabels: async (args: SetPrLabelsArgs): Promise<void> => ipcRenderer.invoke(IPC.prsSetLabels, args), - requestReviewers: async (args: RequestPrReviewersArgs): Promise<void> => ipcRenderer.invoke(IPC.prsRequestReviewers, args), - submitReview: async (args: SubmitPrReviewArgs): Promise<SubmitPrReviewResult> => ipcRenderer.invoke(IPC.prsSubmitReview, args), - close: async (args: ClosePrArgs): Promise<void> => ipcRenderer.invoke(IPC.prsClose, args), - reopen: async (args: ReopenPrArgs): Promise<void> => ipcRenderer.invoke(IPC.prsReopen, args), - rerunChecks: async (args: RerunPrChecksArgs): Promise<void> => ipcRenderer.invoke(IPC.prsRerunChecks, args), - aiReviewSummary: async (args: AiReviewSummaryArgs): Promise<AiReviewSummary> => ipcRenderer.invoke(IPC.prsAiReviewSummary, args), - issueInventorySync: async (prId: string): Promise<IssueInventorySnapshot> => - ipcRenderer.invoke(IPC.prsIssueInventorySync, { prId }), + callProjectRuntimeActionOr("pr", "replyToReviewThread", { args }, () => + ipcRenderer.invoke(IPC.prsReplyToReviewThread, args), + ), + resolveReviewThread: async ( + args: ResolvePrReviewThreadArgs, + ): Promise<void> => + callProjectRuntimeActionOr("pr", "resolveReviewThread", { args }, () => + ipcRenderer.invoke(IPC.prsResolveReviewThread, args), + ), + updateTitle: async (args: UpdatePrTitleArgs): Promise<void> => + callProjectRuntimeActionOr("pr", "updateTitle", { args }, () => + ipcRenderer.invoke(IPC.prsUpdateTitle, args), + ), + updateBody: async (args: UpdatePrBodyArgs): Promise<void> => + callProjectRuntimeActionOr("pr", "updateBody", { args }, () => + ipcRenderer.invoke(IPC.prsUpdateBody, args), + ), + setLabels: async (args: SetPrLabelsArgs): Promise<void> => + callProjectRuntimeActionOr("pr", "setLabels", { args }, () => + ipcRenderer.invoke(IPC.prsSetLabels, args), + ), + requestReviewers: async (args: RequestPrReviewersArgs): Promise<void> => + callProjectRuntimeActionOr("pr", "requestReviewers", { args }, () => + ipcRenderer.invoke(IPC.prsRequestReviewers, args), + ), + submitReview: async ( + args: SubmitPrReviewArgs, + ): Promise<SubmitPrReviewResult> => + callProjectRuntimeActionOr("pr", "submitReview", { args }, () => + ipcRenderer.invoke(IPC.prsSubmitReview, args), + ), + close: async (args: ClosePrArgs): Promise<void> => + callProjectRuntimeActionOr("pr", "closePr", { args }, () => + ipcRenderer.invoke(IPC.prsClose, args), + ), + reopen: async (args: ReopenPrArgs): Promise<void> => + callProjectRuntimeActionOr("pr", "reopenPr", { args }, () => + ipcRenderer.invoke(IPC.prsReopen, args), + ), + rerunChecks: async (args: RerunPrChecksArgs): Promise<void> => + callProjectRuntimeActionOr("pr", "rerunChecks", { args }, () => + ipcRenderer.invoke(IPC.prsRerunChecks, args), + ), + aiReviewSummary: async ( + args: AiReviewSummaryArgs, + ): Promise<AiReviewSummary> => + callProjectRuntimeActionOr("pr", "aiReviewSummary", { args }, () => + ipcRenderer.invoke(IPC.prsAiReviewSummary, args), + ), + issueInventorySync: async ( + prId: string, + ): Promise<IssueInventorySnapshot> => { + const checks = await callProjectRuntimeActionIfBound<PrCheck[]>( + "pr", + "getChecks", + { arg: prId }, + ); + const reviewThreads = checks.handled + ? await callProjectRuntimeActionIfBound<PrReviewThread[]>( + "pr", + "getReviewThreads", + { arg: prId }, + ) + : ({ handled: false } as const); + const comments = + checks.handled && reviewThreads.handled + ? await callProjectRuntimeActionIfBound<PrComment[]>( + "pr", + "getComments", + { arg: prId }, + ) + : ({ handled: false } as const); + if (checks.handled && reviewThreads.handled && comments.handled) { + const runtime = + await callProjectRuntimeActionIfBound<IssueInventorySnapshot>( + "issue_inventory", + "syncFromPrData", + { + argsList: [ + prId, + checks.result, + reviewThreads.result, + comments.result, + ], + }, + ); + if (runtime.handled) return runtime.result; + } + return ipcRenderer.invoke(IPC.prsIssueInventorySync, { prId }); + }, issueInventoryGet: async (prId: string): Promise<IssueInventorySnapshot> => - ipcRenderer.invoke(IPC.prsIssueInventoryGet, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "getInventory", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsIssueInventoryGet, { prId }), + ), issueInventoryGetNew: async (prId: string): Promise<IssueInventoryItem[]> => - ipcRenderer.invoke(IPC.prsIssueInventoryGetNew, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "getNewItems", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsIssueInventoryGetNew, { prId }), + ), issueInventoryMarkFixed: async ( prId: string, itemIds: string[], ): Promise<void> => - ipcRenderer.invoke(IPC.prsIssueInventoryMarkFixed, { prId, itemIds }), + callProjectRuntimeActionOr( + "issue_inventory", + "markFixed", + { argsList: [prId, itemIds] }, + () => + ipcRenderer.invoke(IPC.prsIssueInventoryMarkFixed, { prId, itemIds }), + ), issueInventoryMarkDismissed: async ( prId: string, itemIds: string[], reason: string, ): Promise<void> => - ipcRenderer.invoke(IPC.prsIssueInventoryMarkDismissed, { - prId, - itemIds, - reason, - }), + callProjectRuntimeActionOr( + "issue_inventory", + "markDismissed", + { argsList: [prId, itemIds, reason] }, + () => + ipcRenderer.invoke(IPC.prsIssueInventoryMarkDismissed, { + prId, + itemIds, + reason, + }), + ), issueInventoryMarkEscalated: async ( prId: string, itemIds: string[], ): Promise<void> => - ipcRenderer.invoke(IPC.prsIssueInventoryMarkEscalated, { prId, itemIds }), + callProjectRuntimeActionOr( + "issue_inventory", + "markEscalated", + { argsList: [prId, itemIds] }, + () => + ipcRenderer.invoke(IPC.prsIssueInventoryMarkEscalated, { + prId, + itemIds, + }), + ), issueInventoryGetConvergence: async ( prId: string, ): Promise<ConvergenceStatus> => - ipcRenderer.invoke(IPC.prsIssueInventoryGetConvergence, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "getConvergenceStatus", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsIssueInventoryGetConvergence, { prId }), + ), issueInventoryReset: async (prId: string): Promise<void> => - ipcRenderer.invoke(IPC.prsIssueInventoryReset, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "resetInventory", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsIssueInventoryReset, { prId }), + ), convergenceStateGet: async (prId: string): Promise<PrConvergenceState> => - ipcRenderer.invoke(IPC.prsConvergenceStateGet, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "getConvergenceRuntime", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsConvergenceStateGet, { prId }), + ), convergenceStateSave: async ( prId: string, state: PrConvergenceStatePatch, ): Promise<PrConvergenceState> => - ipcRenderer.invoke(IPC.prsConvergenceStateSave, { prId, state }), + callProjectRuntimeActionOr( + "issue_inventory", + "saveConvergenceRuntime", + { argsList: [prId, state] }, + () => ipcRenderer.invoke(IPC.prsConvergenceStateSave, { prId, state }), + ), convergenceStateDelete: async (prId: string): Promise<void> => - ipcRenderer.invoke(IPC.prsConvergenceStateDelete, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "resetConvergenceRuntime", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsConvergenceStateDelete, { prId }), + ), pathToMergeStart: async (args: { prId: string; modelId?: string | null; @@ -3187,65 +7149,145 @@ contextBridge.exposeInMainWorld("ade", { scope?: "checks" | "comments" | "both"; additionalInstructions?: string | null; }): Promise<PathToMergeStartResult> => - ipcRenderer.invoke(IPC.prsPathToMergeStart, args), + callProjectRuntimeActionOr( + "path_to_merge", + "startPathToMerge", + { args }, + () => ipcRenderer.invoke(IPC.prsPathToMergeStart, args), + ), pathToMergeStop: async (args: { prId: string; reason?: string | null; }): Promise<PathToMergeStopResult> => - ipcRenderer.invoke(IPC.prsPathToMergeStop, args), + callProjectRuntimeActionOr( + "path_to_merge", + "stopPathToMerge", + { args }, + () => ipcRenderer.invoke(IPC.prsPathToMergeStop, args), + ), pipelineSettingsGet: async (prId: string): Promise<PipelineSettings> => - ipcRenderer.invoke(IPC.prsPipelineSettingsGet, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "getPipelineSettings", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsPipelineSettingsGet, { prId }), + ), pipelineSettingsSave: async ( prId: string, settings: Partial<PipelineSettings>, ): Promise<void> => - ipcRenderer.invoke(IPC.prsPipelineSettingsSave, { prId, settings }), + callProjectRuntimeActionOr( + "issue_inventory", + "savePipelineSettings", + { argsList: [prId, settings] }, + () => + ipcRenderer.invoke(IPC.prsPipelineSettingsSave, { prId, settings }), + ), pipelineSettingsDelete: async (prId: string): Promise<void> => - ipcRenderer.invoke(IPC.prsPipelineSettingsDelete, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "deletePipelineSettings", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsPipelineSettingsDelete, { prId }), + ), dismissIntegrationCleanup: async ( args: DismissIntegrationCleanupArgs, ): Promise<IntegrationProposal> => - ipcRenderer.invoke(IPC.prsDismissIntegrationCleanup, args), + callProjectRuntimeActionOr( + "pr", + "dismissIntegrationCleanup", + { args }, + () => ipcRenderer.invoke(IPC.prsDismissIntegrationCleanup, args), + ), cleanupIntegrationWorkflow: async ( args: CleanupIntegrationWorkflowArgs, ): Promise<CleanupIntegrationWorkflowResult> => - ipcRenderer.invoke(IPC.prsCleanupIntegrationWorkflow, args), + callProjectRuntimeActionOr( + "pr", + "cleanupIntegrationWorkflow", + { args }, + () => ipcRenderer.invoke(IPC.prsCleanupIntegrationWorkflow, args), + ), getDeployments: async (prId: string): Promise<PrDeployment[]> => - ipcRenderer.invoke(IPC.prsGetDeployments, { prId }), + callProjectRuntimeActionOr("pr", "getDeployments", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetDeployments, { prId }), + ), getAiSummary: async (prId: string): Promise<PrAiSummary | null> => - ipcRenderer.invoke(IPC.prsGetAiSummary, { prId }), + callProjectRuntimeActionOr("pr", "getAiSummary", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetAiSummary, { prId }), + ), regenerateAiSummary: async (prId: string): Promise<PrAiSummary> => - ipcRenderer.invoke(IPC.prsRegenerateAiSummary, { prId }), + callProjectRuntimeActionOr( + "pr", + "regenerateAiSummary", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsRegenerateAiSummary, { prId }), + ), postReviewComment: async ( args: PostPrReviewCommentArgs, ): Promise<PrReviewThreadComment> => - ipcRenderer.invoke(IPC.prsPostReviewComment, args), + callProjectRuntimeActionOr("pr", "postReviewComment", { args }, () => + ipcRenderer.invoke(IPC.prsPostReviewComment, args), + ), setReviewThreadResolved: async ( args: SetPrReviewThreadResolvedArgs, ): Promise<SetPrReviewThreadResolvedResult> => - ipcRenderer.invoke(IPC.prsSetReviewThreadResolved, args), + callProjectRuntimeActionOr( + "pr", + "setReviewThreadResolved", + { args }, + () => ipcRenderer.invoke(IPC.prsSetReviewThreadResolved, args), + ), reactToComment: async (args: ReactToPrCommentArgs): Promise<void> => - ipcRenderer.invoke(IPC.prsReactToComment, args), + callProjectRuntimeActionOr("pr", "reactToComment", { args }, () => + ipcRenderer.invoke(IPC.prsReactToComment, args), + ), launchIssueResolutionFromThread: async ( args: LaunchPrIssueResolutionFromThreadArgs, ): Promise<LaunchPrIssueResolutionFromThreadResult> => - ipcRenderer.invoke(IPC.prsLaunchIssueResolutionFromThread, args), + callProjectRuntimeActionOr( + "pr", + "launchIssueResolutionFromThread", + { args }, + () => ipcRenderer.invoke(IPC.prsLaunchIssueResolutionFromThread, args), + ), cleanupBranch: async ( args: CleanupPrBranchArgs, ): Promise<CleanupPrBranchResult> => - ipcRenderer.invoke(IPC.prsCleanupBranch, args), + callProjectRuntimeActionOr("pr", "cleanupBranch", { args }, () => + ipcRenderer.invoke(IPC.prsCleanupBranch, args), + ), }, rebase: { scanNeeds: async (): Promise<RebaseNeed[]> => - ipcRenderer.invoke(IPC.rebaseScanNeeds), + callProjectRuntimeActionOr("conflicts", "scanRebaseNeeds", {}, () => + ipcRenderer.invoke(IPC.rebaseScanNeeds), + ), getNeed: async (laneId: string): Promise<RebaseNeed | null> => - ipcRenderer.invoke(IPC.rebaseGetNeed, { laneId }), + callProjectRuntimeActionOr( + "conflicts", + "getRebaseNeed", + { arg: laneId }, + () => ipcRenderer.invoke(IPC.rebaseGetNeed, { laneId }), + ), dismiss: async (laneId: string): Promise<void> => - ipcRenderer.invoke(IPC.rebaseDismiss, { laneId }), + callProjectRuntimeActionOr( + "conflicts", + "dismissRebase", + { arg: laneId }, + () => ipcRenderer.invoke(IPC.rebaseDismiss, { laneId }), + ).then(() => undefined), defer: async (laneId: string, until: string): Promise<void> => - ipcRenderer.invoke(IPC.rebaseDefer, { laneId, until }), + callProjectRuntimeActionOr( + "conflicts", + "deferRebase", + { argsList: [laneId, until] }, + () => ipcRenderer.invoke(IPC.rebaseDefer, { laneId, until }), + ).then(() => undefined), execute: async (args: RebaseLaneArgs): Promise<RebaseResult> => - ipcRenderer.invoke(IPC.rebaseExecute, args), + callProjectRuntimeActionOr("conflicts", "rebaseLane", { args }, () => + ipcRenderer.invoke(IPC.rebaseExecute, args), + ), onEvent: (cb: (ev: RebaseEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -3259,103 +7301,346 @@ contextBridge.exposeInMainWorld("ade", { listOperations: async ( args: ListOperationsArgs = {}, ): Promise<OperationRecord[]> => - ipcRenderer.invoke(IPC.historyListOperations, args), + callProjectRuntimeActionOr("operation", "list", { args }, () => + ipcRenderer.invoke(IPC.historyListOperations, args), + ), exportOperations: async ( args: ExportHistoryArgs, - ): Promise<ExportHistoryResult> => - ipcRenderer.invoke(IPC.historyExportOperations, args), + ): Promise<ExportHistoryResult> => { + const listArgs: ListOperationsArgs = { + ...(typeof args?.laneId === "string" ? { laneId: args.laneId } : {}), + ...(typeof args?.kind === "string" ? { kind: args.kind } : {}), + limit: typeof args?.limit === "number" ? args.limit : 1000, + }; + const runtime = await callProjectRuntimeActionIfBound<OperationRecord[]>( + "operation", + "list", + { args: listArgs }, + ); + if (!runtime.handled) { + return ipcRenderer.invoke(IPC.historyExportOperations, args); + } + const binding = await getProjectRuntimeBinding(); + return ipcRenderer.invoke(IPC.historyExportOperations, { + ...args, + rows: runtime.result, + project: binding + ? { + rootPath: binding.rootPath, + displayName: binding.displayName, + } + : null, + }); + }, }, layout: { get: async (layoutId: string): Promise<DockLayout | null> => - ipcRenderer.invoke(IPC.layoutGet, { layoutId }), + callProjectRuntimeActionOr("layout", "get", { args: { layoutId } }, () => + ipcRenderer.invoke(IPC.layoutGet, { layoutId }), + ), set: async (layoutId: string, layout: DockLayout): Promise<void> => - ipcRenderer.invoke(IPC.layoutSet, { layoutId, layout }), + callProjectRuntimeActionOr( + "layout", + "set", + { args: { layoutId, layout } }, + () => ipcRenderer.invoke(IPC.layoutSet, { layoutId, layout }), + ).then(() => undefined), }, tilingTree: { get: async (layoutId: string): Promise<unknown> => - ipcRenderer.invoke(IPC.tilingTreeGet, { layoutId }), + callProjectRuntimeActionOr( + "tiling_tree", + "get", + { args: { layoutId } }, + () => ipcRenderer.invoke(IPC.tilingTreeGet, { layoutId }), + ), set: async (layoutId: string, tree: unknown): Promise<void> => - ipcRenderer.invoke(IPC.tilingTreeSet, { layoutId, tree }), + callProjectRuntimeActionOr( + "tiling_tree", + "set", + { args: { layoutId, tree } }, + () => ipcRenderer.invoke(IPC.tilingTreeSet, { layoutId, tree }), + ).then(() => undefined), }, graphState: { get: async (projectId: string): Promise<GraphPersistedState | null> => - ipcRenderer.invoke(IPC.graphStateGet, { projectId }), + callProjectRuntimeActionOr("graph_state", "get", {}, () => + ipcRenderer.invoke(IPC.graphStateGet, { projectId }), + ), set: async (projectId: string, state: GraphPersistedState): Promise<void> => - ipcRenderer.invoke(IPC.graphStateSet, { projectId, state }), + callProjectRuntimeActionOr( + "graph_state", + "set", + { args: { state } }, + () => ipcRenderer.invoke(IPC.graphStateSet, { projectId, state }), + ).then(() => undefined), }, processes: { - listDefinitions: async (): Promise<ProcessDefinition[]> => - ipcRenderer.invoke(IPC.processesListDefinitions), - listRuntime: async (laneId: string): Promise<ProcessRuntime[]> => - ipcRenderer.invoke(IPC.processesListRuntime, { laneId }), - start: async (args: ProcessActionArgs): Promise<ProcessRuntime> => - ipcRenderer.invoke(IPC.processesStart, args), - stop: async (args: ProcessActionArgs): Promise<ProcessRuntime | null> => - ipcRenderer.invoke(IPC.processesStop, args), - restart: async (args: ProcessActionArgs): Promise<ProcessRuntime> => - ipcRenderer.invoke(IPC.processesRestart, args), - kill: async (args: ProcessActionArgs): Promise<ProcessRuntime | null> => - ipcRenderer.invoke(IPC.processesKill, args), - startStack: async (args: ProcessStackArgs): Promise<void> => - ipcRenderer.invoke(IPC.processesStartStack, args), - stopStack: async (args: ProcessStackArgs): Promise<void> => - ipcRenderer.invoke(IPC.processesStopStack, args), - restartStack: async (args: ProcessStackArgs): Promise<void> => - ipcRenderer.invoke(IPC.processesRestartStack, args), - startGroup: async (args: ProcessGroupArgs): Promise<void> => - ipcRenderer.invoke(IPC.processesStartGroup, args), - stopGroup: async (args: ProcessGroupArgs): Promise<void> => - ipcRenderer.invoke(IPC.processesStopGroup, args), - restartGroup: async (args: ProcessGroupArgs): Promise<void> => - ipcRenderer.invoke(IPC.processesRestartGroup, args), - startAll: async (args: { laneId: string }): Promise<void> => - ipcRenderer.invoke(IPC.processesStartAll, args), - stopAll: async (args: { laneId: string }): Promise<void> => - ipcRenderer.invoke(IPC.processesStopAll, args), - getLogTail: async (args: GetProcessLogTailArgs): Promise<string> => - ipcRenderer.invoke(IPC.processesGetLogTail, args), + listDefinitions: async (): Promise<ProcessDefinition[]> => { + const runtime = await callProjectRuntimeActionIfBound< + ProcessDefinition[] + >("process", "listDefinitions"); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesListDefinitions); + }, + listRuntime: async (laneId: string): Promise<ProcessRuntime[]> => { + const runtime = await callProjectRuntimeActionIfBound<ProcessRuntime[]>( + "process", + "listRuntime", + { arg: laneId }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesListRuntime, { laneId }); + }, + start: async (args: ProcessActionArgs): Promise<ProcessRuntime> => { + const runtime = await callProjectRuntimeActionIfBound<ProcessRuntime>( + "process", + "start", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStart, args); + }, + stop: async (args: ProcessActionArgs): Promise<ProcessRuntime | null> => { + const runtime = + await callProjectRuntimeActionIfBound<ProcessRuntime | null>( + "process", + "stop", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStop, args); + }, + restart: async (args: ProcessActionArgs): Promise<ProcessRuntime> => { + const runtime = await callProjectRuntimeActionIfBound<ProcessRuntime>( + "process", + "restart", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesRestart, args); + }, + kill: async (args: ProcessActionArgs): Promise<ProcessRuntime | null> => { + const runtime = + await callProjectRuntimeActionIfBound<ProcessRuntime | null>( + "process", + "kill", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesKill, args); + }, + startStack: async (args: ProcessStackArgs): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "process", + "startStack", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStartStack, args); + }, + stopStack: async (args: ProcessStackArgs): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "process", + "stopStack", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStopStack, args); + }, + restartStack: async (args: ProcessStackArgs): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "process", + "restartStack", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesRestartStack, args); + }, + startGroup: async (args: ProcessGroupArgs): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "process", + "startGroup", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStartGroup, args); + }, + stopGroup: async (args: ProcessGroupArgs): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "process", + "stopGroup", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStopGroup, args); + }, + restartGroup: async (args: ProcessGroupArgs): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "process", + "restartGroup", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesRestartGroup, args); + }, + startAll: async (args: { laneId: string }): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "process", + "startAll", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStartAll, args); + }, + stopAll: async (args: { laneId: string }): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "process", + "stopAll", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStopAll, args); + }, + getLogTail: async (args: GetProcessLogTailArgs): Promise<string> => { + const runtime = await callProjectRuntimeActionIfBound<string>( + "process", + "getLogTail", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesGetLogTail, args); + }, onEvent: (cb: (ev: ProcessEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: ProcessEvent, ) => cb(payload); ipcRenderer.on(IPC.processesEvent, listener); - return () => ipcRenderer.removeListener(IPC.processesEvent, listener); + const unsubscribeRemote = subscribeRemoteProcessEvents(cb); + return () => { + unsubscribeRemote(); + ipcRenderer.removeListener(IPC.processesEvent, listener); + }; }, }, tests: { - listSuites: async (): Promise<TestSuiteDefinition[]> => - ipcRenderer.invoke(IPC.testsListSuites), - run: async (args: RunTestSuiteArgs): Promise<TestRunSummary> => - ipcRenderer.invoke(IPC.testsRun, args), - stop: async (args: StopTestRunArgs): Promise<void> => - ipcRenderer.invoke(IPC.testsStop, args), - listRuns: async (args: ListTestRunsArgs = {}): Promise<TestRunSummary[]> => - ipcRenderer.invoke(IPC.testsListRuns, args), - getLogTail: async (args: GetTestLogTailArgs): Promise<string> => - ipcRenderer.invoke(IPC.testsGetLogTail, args), + listSuites: async (): Promise<TestSuiteDefinition[]> => { + const runtime = await callProjectRuntimeActionIfBound< + TestSuiteDefinition[] + >("tests", "listSuites"); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.testsListSuites); + }, + run: async (args: RunTestSuiteArgs): Promise<TestRunSummary> => { + const runtime = await callProjectRuntimeActionIfBound<TestRunSummary>( + "tests", + "run", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.testsRun, args); + }, + stop: async (args: StopTestRunArgs): Promise<void> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "tests", + "stop", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.testsStop, args); + }, + listRuns: async ( + args: ListTestRunsArgs = {}, + ): Promise<TestRunSummary[]> => { + const runtime = await callProjectRuntimeActionIfBound<TestRunSummary[]>( + "tests", + "listRuns", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.testsListRuns, args); + }, + getLogTail: async (args: GetTestLogTailArgs): Promise<string> => { + const runtime = await callProjectRuntimeActionIfBound<string>( + "tests", + "getLogTail", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.testsGetLogTail, args); + }, onEvent: (cb: (ev: TestEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: TestEvent, ) => cb(payload); ipcRenderer.on(IPC.testsEvent, listener); - return () => ipcRenderer.removeListener(IPC.testsEvent, listener); + const unsubscribeRemote = subscribeRemoteTestEvents(cb); + return () => { + unsubscribeRemote(); + ipcRenderer.removeListener(IPC.testsEvent, listener); + }; }, }, projectConfig: { - get: async (): Promise<ProjectConfigSnapshot> => - projectConfigSnapshotCache.get(), + get: async (): Promise<ProjectConfigSnapshot> => { + const runtime = + await callProjectRuntimeActionIfBound<ProjectConfigSnapshot>( + "project_config", + "get", + ); + return runtime.handled + ? runtime.result + : projectConfigSnapshotCache.get(); + }, validate: async ( candidate: ProjectConfigCandidate, - ): Promise<ProjectConfigValidationResult> => - ipcRenderer.invoke(IPC.projectConfigValidate, { candidate }), + ): Promise<ProjectConfigValidationResult> => { + const runtime = + await callProjectRuntimeActionIfBound<ProjectConfigValidationResult>( + "project_config", + "validate", + { args: candidate }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.projectConfigValidate, { candidate }); + }, save: async ( candidate: ProjectConfigCandidate, ): Promise<ProjectConfigSnapshot> => { projectConfigSnapshotCache.clear(); try { - const snapshot = await ipcRenderer.invoke(IPC.projectConfigSave, { candidate }); + const runtime = + await callProjectRuntimeActionIfBound<ProjectConfigSnapshot>( + "project_config", + "save", + { args: candidate }, + ); + const snapshot = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.projectConfigSave, { candidate }); projectConfigSnapshotCache.clear(); return snapshot; } catch (error) { @@ -3363,14 +7648,29 @@ contextBridge.exposeInMainWorld("ade", { throw error; } }, - diffAgainstDisk: async (): Promise<ProjectConfigDiff> => - ipcRenderer.invoke(IPC.projectConfigDiffAgainstDisk), + diffAgainstDisk: async (): Promise<ProjectConfigDiff> => { + const runtime = await callProjectRuntimeActionIfBound<ProjectConfigDiff>( + "project_config", + "diffAgainstDisk", + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.projectConfigDiffAgainstDisk); + }, confirmTrust: async ( arg: { sharedHash?: string } = {}, ): Promise<ProjectConfigTrust> => { projectConfigSnapshotCache.clear(); try { - return await ipcRenderer.invoke(IPC.projectConfigConfirmTrust, arg); + const runtime = + await callProjectRuntimeActionIfBound<ProjectConfigTrust>( + "project_config", + "confirmTrust", + { args: arg }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.projectConfigConfirmTrust, arg); } finally { projectConfigSnapshotCache.clear(); } @@ -3396,11 +7696,21 @@ contextBridge.exposeInMainWorld("ade", { content: string; importance?: "low" | "medium" | "high"; sourceRunId?: string; - }): Promise<unknown> => ipcRenderer.invoke(IPC.memoryAdd, args), + }): Promise<unknown> => + callProjectRuntimeActionOr("memory", "add", { args }, () => + ipcRenderer.invoke(IPC.memoryAdd, args), + ), pin: async (args: { id: string }): Promise<void> => - ipcRenderer.invoke(IPC.memoryPin, args), + callProjectRuntimeActionOr("memory", "pin", { args }, () => + ipcRenderer.invoke(IPC.memoryPin, args), + ), updateCore: async (args: CtoUpdateCoreMemoryArgs): Promise<CtoSnapshot> => - ipcRenderer.invoke(IPC.memoryUpdateCore, args), + callProjectRuntimeActionOr( + "cto_state", + "updateCoreMemory", + { args: args.patch ?? {} }, + () => ipcRenderer.invoke(IPC.memoryUpdateCore, args), + ), getBudget: async ( args: { projectId?: string; @@ -3408,19 +7718,34 @@ contextBridge.exposeInMainWorld("ade", { scope?: "user" | "project" | "lane" | "mission" | "agent"; scopeOwnerId?: string; } = {}, - ): Promise<unknown[]> => ipcRenderer.invoke(IPC.memoryGetBudget, args), + ): Promise<unknown[]> => + callProjectRuntimeActionOr("memory", "getBudget", { args }, () => + ipcRenderer.invoke(IPC.memoryGetBudget, args), + ), getCandidates: async ( args: { projectId?: string; limit?: number } = {}, - ): Promise<unknown[]> => ipcRenderer.invoke(IPC.memoryGetCandidates, args), + ): Promise<unknown[]> => + callProjectRuntimeActionOr("memory", "getCandidates", { args }, () => + ipcRenderer.invoke(IPC.memoryGetCandidates, args), + ), promote: async (args: { id: string }): Promise<void> => - ipcRenderer.invoke(IPC.memoryPromote, args), + callProjectRuntimeActionOr("memory", "promote", { args }, () => + ipcRenderer.invoke(IPC.memoryPromote, args), + ), promoteMissionEntry: async (args: { id: string; missionId: string; }): Promise<MemoryEntryDto | null> => - ipcRenderer.invoke(IPC.memoryPromoteMissionEntry, args), + callProjectRuntimeActionOr( + "memory", + "promoteMissionEntry", + { args }, + () => ipcRenderer.invoke(IPC.memoryPromoteMissionEntry, args), + ), archive: async (args: { id: string }): Promise<void> => - ipcRenderer.invoke(IPC.memoryArchive, args), + callProjectRuntimeActionOr("memory", "archive", { args }, () => + ipcRenderer.invoke(IPC.memoryArchive, args), + ), search: async (args: { query: string; projectId?: string; @@ -3429,7 +7754,10 @@ contextBridge.exposeInMainWorld("ade", { limit?: number; mode?: "lexical" | "hybrid"; status?: "promoted" | "candidate" | "archived" | "all"; - }): Promise<unknown[]> => ipcRenderer.invoke(IPC.memorySearch, args), + }): Promise<unknown[]> => + callProjectRuntimeActionOr("memory", "search", { args }, () => + ipcRenderer.invoke(IPC.memorySearch, args), + ), list: async ( args: { scope?: "project" | "agent" | "mission"; @@ -3437,13 +7765,18 @@ contextBridge.exposeInMainWorld("ade", { status?: "promoted" | "candidate" | "archived" | "all"; limit?: number; } = {}, - ): Promise<MemoryEntryDto[]> => ipcRenderer.invoke(IPC.memoryList, args), + ): Promise<MemoryEntryDto[]> => + callProjectRuntimeActionOr("memory", "list", { args }, () => + ipcRenderer.invoke(IPC.memoryList, args), + ), listMissionEntries: async (args: { missionId: string; runId?: string | null; status?: "promoted" | "candidate" | "archived" | "all"; }): Promise<MemoryEntryDto[]> => - ipcRenderer.invoke(IPC.memoryListMissionEntries, args), + callProjectRuntimeActionOr("memory", "listMissionEntries", { args }, () => + ipcRenderer.invoke(IPC.memoryListMissionEntries, args), + ), listProcedures: async ( args: { status?: "promoted" | "candidate" | "archived" | "all"; @@ -3451,32 +7784,55 @@ contextBridge.exposeInMainWorld("ade", { query?: string; } = {}, ): Promise<ProcedureListItem[]> => - ipcRenderer.invoke(IPC.memoryListProcedures, args), + callProjectRuntimeActionOr("memory", "listProcedures", { args }, () => + ipcRenderer.invoke(IPC.memoryListProcedures, args), + ), getProcedureDetail: async (args: { id: string; }): Promise<ProcedureDetail | null> => - ipcRenderer.invoke(IPC.memoryGetProcedureDetail, args), + callProjectRuntimeActionOr("memory", "getProcedureDetail", { args }, () => + ipcRenderer.invoke(IPC.memoryGetProcedureDetail, args), + ), exportProcedureSkill: async (args: { id: string; name?: string; }): Promise<{ path: string; skill: SkillIndexEntry | null } | null> => - ipcRenderer.invoke(IPC.memoryExportProcedureSkill, args), + callProjectRuntimeActionOr( + "memory", + "exportProcedureSkill", + { args }, + () => ipcRenderer.invoke(IPC.memoryExportProcedureSkill, args), + ), listIndexedSkills: async (): Promise<SkillIndexEntry[]> => - ipcRenderer.invoke(IPC.memoryListIndexedSkills), + callProjectRuntimeActionOr("memory", "listIndexedSkills", {}, () => + ipcRenderer.invoke(IPC.memoryListIndexedSkills), + ), reindexSkills: async ( args: { paths?: string[] } = {}, ): Promise<SkillIndexEntry[]> => - ipcRenderer.invoke(IPC.memoryReindexSkills, args), + callProjectRuntimeActionOr("memory", "reindexSkills", { args }, () => + ipcRenderer.invoke(IPC.memoryReindexSkills, args), + ), syncKnowledge: async (): Promise<ChangeDigest | null> => - ipcRenderer.invoke(IPC.memorySyncKnowledge), + callProjectRuntimeActionOr("memory", "syncKnowledge", {}, () => + ipcRenderer.invoke(IPC.memorySyncKnowledge), + ), getKnowledgeSyncStatus: async (): Promise<KnowledgeSyncStatus> => - ipcRenderer.invoke(IPC.memoryGetKnowledgeSyncStatus), + callProjectRuntimeActionOr("memory", "getKnowledgeSyncStatus", {}, () => + ipcRenderer.invoke(IPC.memoryGetKnowledgeSyncStatus), + ), getHealthStats: async (): Promise<MemoryHealthStats> => - ipcRenderer.invoke(IPC.memoryHealthStats), + callProjectRuntimeActionOr("memory", "getHealthStats", {}, () => + ipcRenderer.invoke(IPC.memoryHealthStats), + ), downloadEmbeddingModel: async (): Promise<MemoryHealthStats> => - ipcRenderer.invoke(IPC.memoryDownloadEmbeddingModel), + callProjectRuntimeActionOr("memory", "downloadEmbeddingModel", {}, () => + ipcRenderer.invoke(IPC.memoryDownloadEmbeddingModel), + ), runSweep: async (): Promise<MemoryLifecycleSweepResult> => - ipcRenderer.invoke(IPC.memoryRunSweep), + callProjectRuntimeActionOr("memory", "runSweep", {}, () => + ipcRenderer.invoke(IPC.memoryRunSweep), + ), onSweepStatus: (cb: (payload: MemorySweepStatusEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -3486,7 +7842,9 @@ contextBridge.exposeInMainWorld("ade", { return () => ipcRenderer.removeListener(IPC.memorySweepStatus, listener); }, runConsolidation: async (): Promise<MemoryConsolidationResult> => - ipcRenderer.invoke(IPC.memoryRunConsolidation), + callProjectRuntimeActionOr("memory", "runConsolidation", {}, () => + ipcRenderer.invoke(IPC.memoryRunConsolidation), + ), onConsolidationStatus: ( cb: (payload: MemoryConsolidationStatusEventPayload) => void, ) => { @@ -3501,145 +7859,292 @@ contextBridge.exposeInMainWorld("ade", { }, cto: { getState: async (args: CtoGetStateArgs = {}): Promise<CtoSnapshot> => - ipcRenderer.invoke(IPC.ctoGetState, args), + callProjectRuntimeActionOr( + "cto_state", + "getSnapshot", + { arg: args.recentLimit ?? 20 }, + () => ipcRenderer.invoke(IPC.ctoGetState, args), + ), ensureSession: async ( args: CtoEnsureSessionArgs = {}, ): Promise<AgentChatSession> => - ipcRenderer.invoke(IPC.ctoEnsureSession, args), + callProjectRuntimeActionOr("chat", "ensureCtoSession", { args }, () => + ipcRenderer.invoke(IPC.ctoEnsureSession, args), + ), updateCoreMemory: async ( args: CtoUpdateCoreMemoryArgs, ): Promise<CtoSnapshot> => - ipcRenderer.invoke(IPC.ctoUpdateCoreMemory, args), + callProjectRuntimeActionOr( + "cto_state", + "updateCoreMemory", + { arg: args.patch ?? {} }, + () => ipcRenderer.invoke(IPC.ctoUpdateCoreMemory, args), + ), listSessionLogs: async ( args: CtoListSessionLogsArgs = {}, ): Promise<CtoSessionLogEntry[]> => - ipcRenderer.invoke(IPC.ctoListSessionLogs, args), + callProjectRuntimeActionOr( + "cto_state", + "getSessionLogs", + { arg: args.limit ?? 40 }, + () => ipcRenderer.invoke(IPC.ctoListSessionLogs, args), + ), updateIdentity: async (args: CtoUpdateIdentityArgs): Promise<CtoSnapshot> => - ipcRenderer.invoke(IPC.ctoUpdateIdentity, args), - getOpenclawState: async (): Promise<CtoGetOpenclawStateResult> => - ipcRenderer.invoke(IPC.ctoGetOpenclawState), - updateOpenclawConfig: async ( - args: CtoUpdateOpenclawConfigArgs, - ): Promise<CtoGetOpenclawStateResult> => - ipcRenderer.invoke(IPC.ctoUpdateOpenclawConfig, args), - testOpenclawConnection: async ( - args: CtoTestOpenclawConnectionArgs = {}, - ): Promise<CtoTestOpenclawConnectionResult> => - ipcRenderer.invoke(IPC.ctoTestOpenclawConnection, args), - listOpenclawMessages: async ( - args: CtoListOpenclawMessagesArgs = {}, - ): Promise<CtoListOpenclawMessagesResult> => - ipcRenderer.invoke(IPC.ctoListOpenclawMessages, args), - sendOpenclawMessage: async ( - args: CtoSendOpenclawMessageArgs, - ): Promise<CtoListOpenclawMessagesResult[number]> => - ipcRenderer.invoke(IPC.ctoSendOpenclawMessage, args), - onOpenclawConnectionStatus: ( - cb: (status: OpenclawBridgeStatus) => void, - ) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: OpenclawBridgeStatus, - ) => cb(payload); - ipcRenderer.on(IPC.openclawConnectionStatus, listener); - return () => - ipcRenderer.removeListener(IPC.openclawConnectionStatus, listener); - }, + callProjectRuntimeActionOr( + "cto_state", + "updateIdentity", + { arg: args.patch ?? {} }, + () => ipcRenderer.invoke(IPC.ctoUpdateIdentity, args), + ), listAgents: async ( args: CtoListAgentsArgs = {}, - ): Promise<AgentIdentity[]> => ipcRenderer.invoke(IPC.ctoListAgents, args), + ): Promise<AgentIdentity[]> => + callProjectRuntimeActionOr("worker_agent", "listAgents", { args }, () => + ipcRenderer.invoke(IPC.ctoListAgents, args), + ), saveAgent: async (args: CtoSaveAgentArgs): Promise<AgentIdentity> => - ipcRenderer.invoke(IPC.ctoSaveAgent, args), + callProjectRuntimeActionOr("worker_agent", "saveAgent", { args }, () => + ipcRenderer.invoke(IPC.ctoSaveAgent, args), + ), removeAgent: async (args: CtoRemoveAgentArgs): Promise<void> => - ipcRenderer.invoke(IPC.ctoRemoveAgent, args), + callProjectRuntimeActionOr("worker_agent", "removeAgent", { args }, () => + ipcRenderer.invoke(IPC.ctoRemoveAgent, args), + ), setAgentStatus: async (args: CtoSetAgentStatusArgs): Promise<void> => - ipcRenderer.invoke(IPC.ctoSetAgentStatus, args), + callProjectRuntimeActionOr( + "worker_agent", + "setAgentStatus", + { args }, + () => ipcRenderer.invoke(IPC.ctoSetAgentStatus, args), + ), listAgentRevisions: async ( args: CtoListAgentRevisionsArgs, ): Promise<AgentConfigRevision[]> => - ipcRenderer.invoke(IPC.ctoListAgentRevisions, args), + callProjectRuntimeActionOr( + "worker_agent", + "listAgentRevisions", + { args }, + () => ipcRenderer.invoke(IPC.ctoListAgentRevisions, args), + ), rollbackAgentRevision: async ( args: CtoRollbackAgentRevisionArgs, ): Promise<AgentIdentity> => - ipcRenderer.invoke(IPC.ctoRollbackAgentRevision, args), + callProjectRuntimeActionOr( + "worker_agent", + "rollbackAgentRevision", + { args }, + () => ipcRenderer.invoke(IPC.ctoRollbackAgentRevision, args), + ), ensureAgentSession: async ( args: CtoEnsureAgentSessionArgs, ): Promise<AgentChatSession> => - ipcRenderer.invoke(IPC.ctoEnsureAgentSession, args), + callProjectRuntimeActionOr( + "chat", + "ensureAgentIdentitySession", + { args }, + () => ipcRenderer.invoke(IPC.ctoEnsureAgentSession, args), + ), getBudgetSnapshot: async ( args: CtoGetBudgetSnapshotArgs = {}, ): Promise<AgentBudgetSnapshot> => - ipcRenderer.invoke(IPC.ctoGetBudgetSnapshot, args), + callProjectRuntimeActionOr( + "worker_agent", + "getBudgetSnapshot", + { args }, + () => ipcRenderer.invoke(IPC.ctoGetBudgetSnapshot, args), + ), triggerAgentWakeup: async ( args: CtoTriggerAgentWakeupArgs, ): Promise<CtoTriggerAgentWakeupResult> => - ipcRenderer.invoke(IPC.ctoTriggerAgentWakeup, args), + callProjectRuntimeActionOr( + "worker_agent", + "triggerWakeup", + { args }, + () => ipcRenderer.invoke(IPC.ctoTriggerAgentWakeup, args), + ), listAgentRuns: async ( args: CtoListAgentRunsArgs = {}, ): Promise<WorkerAgentRun[]> => - ipcRenderer.invoke(IPC.ctoListAgentRuns, args), + callProjectRuntimeActionOr( + "worker_agent", + "listAgentRuns", + { args }, + () => ipcRenderer.invoke(IPC.ctoListAgentRuns, args), + ), getAgentCoreMemory: async ( args: CtoGetAgentCoreMemoryArgs, ): Promise<AgentCoreMemory> => - ipcRenderer.invoke(IPC.ctoGetAgentCoreMemory, args), + callProjectRuntimeActionOr( + "worker_agent", + "getCoreMemory", + { arg: args.agentId }, + () => ipcRenderer.invoke(IPC.ctoGetAgentCoreMemory, args), + ), updateAgentCoreMemory: async ( args: CtoUpdateAgentCoreMemoryArgs, ): Promise<AgentCoreMemory> => - ipcRenderer.invoke(IPC.ctoUpdateAgentCoreMemory, args), + callProjectRuntimeActionOr( + "worker_agent", + "updateCoreMemory", + { argsList: [args.agentId, args.patch ?? {}] }, + () => ipcRenderer.invoke(IPC.ctoUpdateAgentCoreMemory, args), + ), listAgentSessionLogs: async ( args: CtoListAgentSessionLogsArgs, ): Promise<AgentSessionLogEntry[]> => - ipcRenderer.invoke(IPC.ctoListAgentSessionLogs, args), + callProjectRuntimeActionOr( + "worker_agent", + "listSessionLogs", + { argsList: [args.agentId, args.limit ?? 40] }, + () => ipcRenderer.invoke(IPC.ctoListAgentSessionLogs, args), + ), getLinearConnectionStatus: async (): Promise<LinearConnectionStatus> => - ipcRenderer.invoke(IPC.ctoGetLinearConnectionStatus), + callProjectRuntimeActionOr( + "linear_issue_tracker", + "getConnectionStatus", + {}, + () => ipcRenderer.invoke(IPC.ctoGetLinearConnectionStatus), + ), setLinearToken: async ( args: CtoSetLinearTokenArgs, - ): Promise<LinearConnectionStatus> => - ipcRenderer.invoke(IPC.ctoSetLinearToken, args), - clearLinearToken: async (): Promise<LinearConnectionStatus> => - ipcRenderer.invoke(IPC.ctoClearLinearToken), + ): Promise<LinearConnectionStatus> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "linear_credentials", + "setToken", + { arg: args.token }, + ); + if (runtime.handled) { + return callProjectRuntimeActionOr( + "linear_issue_tracker", + "getConnectionStatus", + {}, + () => ipcRenderer.invoke(IPC.ctoSetLinearToken, args), + ); + } + return ipcRenderer.invoke(IPC.ctoSetLinearToken, args); + }, + clearLinearToken: async (): Promise<LinearConnectionStatus> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "linear_credentials", + "clearToken", + {}, + ); + if (runtime.handled) { + return callProjectRuntimeActionOr( + "linear_issue_tracker", + "getConnectionStatus", + {}, + () => ipcRenderer.invoke(IPC.ctoClearLinearToken), + ); + } + return ipcRenderer.invoke(IPC.ctoClearLinearToken); + }, getFlowPolicy: async (): Promise<LinearWorkflowConfig> => - ipcRenderer.invoke(IPC.ctoGetFlowPolicy), + callProjectRuntimeActionOr("flow_policy", "getPolicy", {}, () => + ipcRenderer.invoke(IPC.ctoGetFlowPolicy), + ), saveFlowPolicy: async ( args: CtoSaveFlowPolicyArgs, ): Promise<LinearWorkflowConfig> => - ipcRenderer.invoke(IPC.ctoSaveFlowPolicy, args), + callProjectRuntimeActionOr( + "flow_policy", + "savePolicy", + { argsList: [args.policy, args.actor ?? "user"] }, + () => ipcRenderer.invoke(IPC.ctoSaveFlowPolicy, args), + ), listFlowPolicyRevisions: async (): Promise<CtoFlowPolicyRevision[]> => - ipcRenderer.invoke(IPC.ctoListFlowPolicyRevisions), + callProjectRuntimeActionOr( + "flow_policy", + "listRevisions", + { arg: 50 }, + () => ipcRenderer.invoke(IPC.ctoListFlowPolicyRevisions), + ), rollbackFlowPolicyRevision: async ( args: CtoRollbackFlowPolicyRevisionArgs, ): Promise<LinearWorkflowConfig> => - ipcRenderer.invoke(IPC.ctoRollbackFlowPolicyRevision, args), + callProjectRuntimeActionOr( + "flow_policy", + "rollbackRevision", + { argsList: [args.revisionId, args.actor ?? "user"] }, + () => ipcRenderer.invoke(IPC.ctoRollbackFlowPolicyRevision, args), + ), simulateFlowRoute: async ( args: CtoSimulateFlowRouteArgs, ): Promise<LinearRouteDecision> => - ipcRenderer.invoke(IPC.ctoSimulateFlowRoute, args), + callProjectRuntimeActionOr( + "linear_routing", + "simulateRoute", + { args }, + () => ipcRenderer.invoke(IPC.ctoSimulateFlowRoute, args), + ), getLinearWorkflowCatalog: async (): Promise<LinearWorkflowCatalog> => - ipcRenderer.invoke(IPC.ctoGetLinearWorkflowCatalog), + callProjectRuntimeActionOr( + "linear_issue_tracker", + "getWorkflowCatalog", + {}, + () => ipcRenderer.invoke(IPC.ctoGetLinearWorkflowCatalog), + ), getLinearSyncDashboard: async (): Promise<LinearSyncDashboard> => - ipcRenderer.invoke(IPC.ctoGetLinearSyncDashboard), + callProjectRuntimeActionOr("linear_sync", "getDashboard", {}, () => + ipcRenderer.invoke(IPC.ctoGetLinearSyncDashboard), + ), runLinearSyncNow: async (): Promise<LinearSyncDashboard> => - ipcRenderer.invoke(IPC.ctoRunLinearSyncNow), + callProjectRuntimeActionOr("linear_sync", "runSyncNow", {}, () => + ipcRenderer.invoke(IPC.ctoRunLinearSyncNow), + ), listLinearSyncQueue: async (): Promise<LinearSyncQueueItem[]> => - ipcRenderer.invoke(IPC.ctoListLinearSyncQueue), + callProjectRuntimeActionOr( + "linear_sync", + "listQueue", + { args: { limit: 300 } }, + () => ipcRenderer.invoke(IPC.ctoListLinearSyncQueue), + ), getLinearWorkflowRunDetail: async ( args: CtoGetLinearWorkflowRunDetailArgs, ): Promise<LinearWorkflowRunDetail | null> => - ipcRenderer.invoke(IPC.ctoGetLinearWorkflowRunDetail, args), + callProjectRuntimeActionOr("linear_sync", "getRunDetail", { args }, () => + ipcRenderer.invoke(IPC.ctoGetLinearWorkflowRunDetail, args), + ), resolveLinearSyncQueueItem: async ( args: CtoResolveLinearSyncQueueItemArgs, ): Promise<LinearSyncQueueItem | null> => - ipcRenderer.invoke(IPC.ctoResolveLinearSyncQueueItem, args), + callProjectRuntimeActionOr( + "linear_sync", + "resolveQueueItem", + { args }, + () => ipcRenderer.invoke(IPC.ctoResolveLinearSyncQueueItem, args), + ), getLinearIngressStatus: async (): Promise<LinearIngressStatus> => - ipcRenderer.invoke(IPC.ctoGetLinearIngressStatus), + callProjectRuntimeActionOr("linear_ingress", "getStatus", {}, () => + ipcRenderer.invoke(IPC.ctoGetLinearIngressStatus), + ), listLinearIngressEvents: async ( args: CtoListLinearIngressEventsArgs = {}, ): Promise<LinearIngressEventRecord[]> => - ipcRenderer.invoke(IPC.ctoListLinearIngressEvents, args), + callProjectRuntimeActionOr( + "linear_ingress", + "listRecentEvents", + { arg: args.limit ?? 20 }, + () => ipcRenderer.invoke(IPC.ctoListLinearIngressEvents, args), + ), ensureLinearWebhook: async ( args: CtoEnsureLinearWebhookArgs = {}, - ): Promise<LinearIngressStatus> => - ipcRenderer.invoke(IPC.ctoEnsureLinearWebhook, args), + ): Promise<LinearIngressStatus> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "linear_ingress", + "ensureRelayWebhook", + { arg: args.force === true }, + ); + if (runtime.handled) { + return callProjectRuntimeActionOr( + "linear_ingress", + "getStatus", + {}, + () => ipcRenderer.invoke(IPC.ctoEnsureLinearWebhook, args), + ); + } + return ipcRenderer.invoke(IPC.ctoEnsureLinearWebhook, args); + }, onLinearWorkflowEvent: ( cb: (event: LinearWorkflowEventPayload) => void, ) => { @@ -3654,53 +8159,139 @@ contextBridge.exposeInMainWorld("ade", { listAgentTaskSessions: async ( args: CtoListAgentTaskSessionsArgs, ): Promise<AgentTaskSession[]> => - ipcRenderer.invoke(IPC.ctoListAgentTaskSessions, args), + callProjectRuntimeActionOr( + "worker_agent", + "listAgentTaskSessions", + { args }, + () => ipcRenderer.invoke(IPC.ctoListAgentTaskSessions, args), + ), clearAgentTaskSession: async ( args: CtoClearAgentTaskSessionArgs, - ): Promise<void> => ipcRenderer.invoke(IPC.ctoClearAgentTaskSession, args), + ): Promise<void> => + callProjectRuntimeActionOr( + "worker_agent", + "clearAgentTaskSession", + { args }, + () => ipcRenderer.invoke(IPC.ctoClearAgentTaskSession, args), + ), getOnboardingState: async (): Promise<CtoOnboardingState> => - ipcRenderer.invoke(IPC.ctoGetOnboardingState), + callProjectRuntimeActionOr("cto_state", "getOnboardingState", {}, () => + ipcRenderer.invoke(IPC.ctoGetOnboardingState), + ), completeOnboardingStep: async (args: { stepId: string; }): Promise<CtoOnboardingState> => - ipcRenderer.invoke(IPC.ctoCompleteOnboardingStep, args), + callProjectRuntimeActionOr( + "cto_state", + "completeOnboardingStep", + { arg: args.stepId }, + () => ipcRenderer.invoke(IPC.ctoCompleteOnboardingStep, args), + ), dismissOnboarding: async (): Promise<CtoOnboardingState> => - ipcRenderer.invoke(IPC.ctoDismissOnboarding), + callProjectRuntimeActionOr("cto_state", "dismissOnboarding", {}, () => + ipcRenderer.invoke(IPC.ctoDismissOnboarding), + ), resetOnboarding: async (): Promise<CtoOnboardingState> => - ipcRenderer.invoke(IPC.ctoResetOnboarding), + callProjectRuntimeActionOr("cto_state", "resetOnboarding", {}, () => + ipcRenderer.invoke(IPC.ctoResetOnboarding), + ), previewSystemPrompt: async ( args: { identityOverride?: Record<string, unknown> } = {}, ): Promise<CtoSystemPromptPreview> => - ipcRenderer.invoke(IPC.ctoPreviewSystemPrompt, args), + callProjectRuntimeActionOr( + "cto_state", + "previewSystemPrompt", + { arg: args.identityOverride }, + () => ipcRenderer.invoke(IPC.ctoPreviewSystemPrompt, args), + ), getLinearProjects: async (): Promise<CtoLinearProject[]> => - ipcRenderer.invoke(IPC.ctoGetLinearProjects), + callProjectRuntimeActionOr( + "linear_issue_tracker", + "listProjects", + {}, + () => ipcRenderer.invoke(IPC.ctoGetLinearProjects), + ), getLinearQuickView: async (): Promise<CtoLinearQuickView> => - ipcRenderer.invoke(IPC.ctoGetLinearQuickView), - getLinearIssuePickerData: async (): Promise<CtoGetLinearIssuePickerDataResult> => - ipcRenderer.invoke(IPC.ctoGetLinearIssuePickerData), + callProjectRuntimeActionOr( + "linear_issue_tracker", + "getQuickView", + {}, + () => ipcRenderer.invoke(IPC.ctoGetLinearQuickView), + ), + getLinearIssuePickerData: + async (): Promise<CtoGetLinearIssuePickerDataResult> => + callProjectRuntimeActionOr( + "linear_issue_tracker", + "getIssuePickerData", + {}, + () => ipcRenderer.invoke(IPC.ctoGetLinearIssuePickerData), + ), searchLinearIssues: async ( args: CtoSearchLinearIssuesArgs = {}, ): Promise<CtoSearchLinearIssuesResult> => - ipcRenderer.invoke(IPC.ctoSearchLinearIssues, args), + callProjectRuntimeActionOr( + "linear_issue_tracker", + "searchIssues", + { args }, + () => ipcRenderer.invoke(IPC.ctoSearchLinearIssues, args), + ), setLinearOAuthClient: async ( args: CtoSetLinearOAuthClientArgs, - ): Promise<LinearConnectionStatus> => - ipcRenderer.invoke(IPC.ctoSetLinearOAuthClient, args), - clearLinearOAuthClient: async (): Promise<LinearConnectionStatus> => - ipcRenderer.invoke(IPC.ctoClearLinearOAuthClient), + ): Promise<LinearConnectionStatus> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "linear_credentials", + "setOAuthClientCredentials", + { args }, + ); + if (runtime.handled) { + return callProjectRuntimeActionOr( + "linear_issue_tracker", + "getConnectionStatus", + {}, + () => ipcRenderer.invoke(IPC.ctoSetLinearOAuthClient, args), + ); + } + return ipcRenderer.invoke(IPC.ctoSetLinearOAuthClient, args); + }, + clearLinearOAuthClient: async (): Promise<LinearConnectionStatus> => { + const runtime = await callProjectRuntimeActionIfBound<void>( + "linear_credentials", + "clearOAuthClientCredentials", + {}, + ); + if (runtime.handled) { + return callProjectRuntimeActionOr( + "linear_issue_tracker", + "getConnectionStatus", + {}, + () => ipcRenderer.invoke(IPC.ctoClearLinearOAuthClient), + ); + } + return ipcRenderer.invoke(IPC.ctoClearLinearOAuthClient); + }, startLinearOAuth: async (): Promise<CtoStartLinearOAuthResult> => - ipcRenderer.invoke(IPC.ctoStartLinearOAuth), + callProjectRuntimeActionOr("linear_oauth", "startSession", {}, () => + ipcRenderer.invoke(IPC.ctoStartLinearOAuth), + ), getLinearOAuthSession: async ( args: CtoGetLinearOAuthSessionArgs, ): Promise<CtoGetLinearOAuthSessionResult> => - ipcRenderer.invoke(IPC.ctoGetLinearOAuthSession, args), + callProjectRuntimeActionOr( + "linear_oauth", + "getSession", + { arg: args.sessionId }, + () => ipcRenderer.invoke(IPC.ctoGetLinearOAuthSession, args), + ), runProjectScan: async (): Promise<CtoRunProjectScanResult> => - ipcRenderer.invoke(IPC.ctoRunProjectScan), + callProjectRuntimeActionOr("cto_state", "runProjectScan", {}, () => + ipcRenderer.invoke(IPC.ctoRunProjectScan), + ), }, updateCheckForUpdates: () => ipcRenderer.invoke(IPC.updateCheckForUpdates), updateGetState: (): Promise<AutoUpdateSnapshot> => ipcRenderer.invoke(IPC.updateGetState), - updateQuitAndInstall: (): Promise<boolean> => ipcRenderer.invoke(IPC.updateQuitAndInstall), + updateQuitAndInstall: (): Promise<boolean> => + ipcRenderer.invoke(IPC.updateQuitAndInstall), updateDismissInstalledNotice: () => ipcRenderer.invoke(IPC.updateDismissInstalledNotice), onUpdateEvent: (cb: (snapshot: AutoUpdateSnapshot) => void) => { diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index df1fffbfe..2125915a3 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -58,24 +58,32 @@ const BUILTIN_MOCK_PROJECT = { createdAt: new Date().toISOString(), }; -const adeDbSnapshotByPath = import.meta.glob<any>("./browser-mock-ade-snapshot.generated.json", { - eager: true, - import: "default", -}); +const adeDbSnapshotByPath = import.meta.glob<any>( + "./browser-mock-ade-snapshot.generated.json", + { + eager: true, + import: "default", + }, +); -const ADE_DB_SNAPSHOT = adeDbSnapshotByPath["./browser-mock-ade-snapshot.generated.json"] ?? null; +const ADE_DB_SNAPSHOT = + adeDbSnapshotByPath["./browser-mock-ade-snapshot.generated.json"] ?? null; const USE_ADE_DB_SNAPSHOT = Boolean(ADE_DB_SNAPSHOT?.project); -const MOCK_PROJECT = USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.project - ? { - ...BUILTIN_MOCK_PROJECT, - id: ADE_DB_SNAPSHOT.project.id, - name: ADE_DB_SNAPSHOT.project.name, - rootPath: ADE_DB_SNAPSHOT.project.rootPath, - gitDefaultBranch: ADE_DB_SNAPSHOT.project.gitDefaultBranch ?? BUILTIN_MOCK_PROJECT.gitDefaultBranch, - createdAt: ADE_DB_SNAPSHOT.project.createdAt ?? BUILTIN_MOCK_PROJECT.createdAt, - } - : BUILTIN_MOCK_PROJECT; +const MOCK_PROJECT = + USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.project + ? { + ...BUILTIN_MOCK_PROJECT, + id: ADE_DB_SNAPSHOT.project.id, + name: ADE_DB_SNAPSHOT.project.name, + rootPath: ADE_DB_SNAPSHOT.project.rootPath, + gitDefaultBranch: + ADE_DB_SNAPSHOT.project.gitDefaultBranch ?? + BUILTIN_MOCK_PROJECT.gitDefaultBranch, + createdAt: + ADE_DB_SNAPSHOT.project.createdAt ?? BUILTIN_MOCK_PROJECT.createdAt, + } + : BUILTIN_MOCK_PROJECT; // ── Timestamps ──────────────────────────────────────────────── const now = new Date().toISOString(); @@ -92,10 +100,20 @@ function mockBrowserLaneHealth(laneId: string) { fallbackMode: false, lastCheckedAt: now, issues: [] as Array<{ - type: "process-dead" | "port-unresponsive" | "proxy-route-missing" | "port-conflict" | "env-init-failed"; + type: + | "process-dead" + | "port-unresponsive" + | "proxy-route-missing" + | "port-conflict" + | "env-init-failed"; message: string; actionLabel?: string; - actionType?: "reassign-port" | "restart-proxy" | "reinit-env" | "enable-fallback" | "refresh-preview"; + actionType?: + | "reassign-port" + | "restart-proxy" + | "reinit-env" + | "enable-fallback" + | "refresh-preview"; }>, }; } @@ -433,7 +451,10 @@ const BUILTIN_RUN_PROCESS_DEFINITIONS: any[] = [ restart: "never", gracefulShutdownMs: 10000, dependsOn: ["mock-dev"], - readiness: { type: "logRegex", pattern: "Local:\\s+http://localhost:[0-9]+" }, + readiness: { + type: "logRegex", + pattern: "Local:\\s+http://localhost:[0-9]+", + }, }, ]; @@ -521,7 +542,9 @@ function buildMockLanesFromAdeSnapshot(laneRows: any[]): any[] { name: String(raw.name ?? "lane"), description: raw.description ?? null, laneType: - raw.laneType === "primary" || raw.laneType === "worktree" || raw.laneType === "attached" + raw.laneType === "primary" || + raw.laneType === "worktree" || + raw.laneType === "attached" ? raw.laneType : "worktree", baseRef: String(raw.baseRef ?? "main"), @@ -553,52 +576,79 @@ function buildMockLanesFromAdeSnapshot(laneRows: any[]): any[] { } const MOCK_LANES: any[] = USE_ADE_DB_SNAPSHOT - ? buildMockLanesFromAdeSnapshot(Array.isArray(ADE_DB_SNAPSHOT?.lanes) ? ADE_DB_SNAPSHOT.lanes : []) + ? buildMockLanesFromAdeSnapshot( + Array.isArray(ADE_DB_SNAPSHOT?.lanes) ? ADE_DB_SNAPSHOT.lanes : [], + ) : BUILTIN_MOCK_LANES; -const ADE_DB_PR_SNAPSHOTS: any[] = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.prSnapshots) - ? ADE_DB_SNAPSHOT.prSnapshots - : []; +const ADE_DB_PR_SNAPSHOTS: any[] = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.prSnapshots) + ? ADE_DB_SNAPSHOT.prSnapshots + : []; const ADE_DB_PR_SNAPSHOT_BY_ID = new Map<string, any>( ADE_DB_PR_SNAPSHOTS.map((snapshot) => [String(snapshot.prId), snapshot]), ); -const ADE_DB_OPERATIONS: any[] = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.operations) - ? ADE_DB_SNAPSHOT.operations - : []; -const ADE_DB_SESSIONS: any[] = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.sessions) - ? ADE_DB_SNAPSHOT.sessions - : []; +const ADE_DB_OPERATIONS: any[] = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.operations) + ? ADE_DB_SNAPSHOT.operations + : []; +const ADE_DB_SESSIONS: any[] = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.sessions) + ? ADE_DB_SNAPSHOT.sessions + : []; /** Prefer exported DB rows when present; otherwise built-ins so Work is usable without a snapshot file. */ -const MOCK_SESSIONS: any[] = ADE_DB_SESSIONS.length > 0 ? ADE_DB_SESSIONS : BUILTIN_MOCK_SESSIONS; -const ADE_DB_CHAT_TRANSCRIPTS: Record<string, { events?: any[]; path?: string | null }> = - USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.chatTranscripts && typeof ADE_DB_SNAPSHOT.chatTranscripts === "object" +const MOCK_SESSIONS: any[] = + ADE_DB_SESSIONS.length > 0 ? ADE_DB_SESSIONS : BUILTIN_MOCK_SESSIONS; +const ADE_DB_CHAT_TRANSCRIPTS: Record< + string, + { events?: any[]; path?: string | null } +> = + USE_ADE_DB_SNAPSHOT && + ADE_DB_SNAPSHOT?.chatTranscripts && + typeof ADE_DB_SNAPSHOT.chatTranscripts === "object" ? ADE_DB_SNAPSHOT.chatTranscripts : {}; -const ADE_DB_PROCESS_DEFINITIONS: any[] = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.processDefinitions) - ? ADE_DB_SNAPSHOT.processDefinitions - : []; -const ADE_DB_PROCESS_RUNTIME: any[] = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.processRuntime) - ? ADE_DB_SNAPSHOT.processRuntime - : []; -const ADE_DB_STACK_BUTTONS: any[] = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.stackButtons) - ? ADE_DB_SNAPSHOT.stackButtons - : []; -const ADE_DB_PROCESS_GROUPS: any[] = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.processGroups) - ? ADE_DB_SNAPSHOT.processGroups - : []; +const ADE_DB_PROCESS_DEFINITIONS: any[] = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.processDefinitions) + ? ADE_DB_SNAPSHOT.processDefinitions + : []; +const ADE_DB_PROCESS_RUNTIME: any[] = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.processRuntime) + ? ADE_DB_SNAPSHOT.processRuntime + : []; +const ADE_DB_STACK_BUTTONS: any[] = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.stackButtons) + ? ADE_DB_SNAPSHOT.stackButtons + : []; +const ADE_DB_PROCESS_GROUPS: any[] = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.processGroups) + ? ADE_DB_SNAPSHOT.processGroups + : []; -const usingBuiltinRunDemo = !USE_ADE_DB_SNAPSHOT || ADE_DB_PROCESS_DEFINITIONS.length === 0; -const MOCK_PROCESS_DEFINITIONS: any[] = usingBuiltinRunDemo ? BUILTIN_RUN_PROCESS_DEFINITIONS : ADE_DB_PROCESS_DEFINITIONS; -const MOCK_PROCESS_RUNTIME: any[] = usingBuiltinRunDemo ? BUILTIN_RUN_PROCESS_RUNTIME : ADE_DB_PROCESS_RUNTIME; -const MOCK_STACK_BUTTONS: any[] = usingBuiltinRunDemo ? [] : ADE_DB_STACK_BUTTONS; -const MOCK_PROCESS_GROUPS: any[] = usingBuiltinRunDemo ? BUILTIN_RUN_PROCESS_GROUPS : ADE_DB_PROCESS_GROUPS; +const usingBuiltinRunDemo = + !USE_ADE_DB_SNAPSHOT || ADE_DB_PROCESS_DEFINITIONS.length === 0; +const MOCK_PROCESS_DEFINITIONS: any[] = usingBuiltinRunDemo + ? BUILTIN_RUN_PROCESS_DEFINITIONS + : ADE_DB_PROCESS_DEFINITIONS; +const MOCK_PROCESS_RUNTIME: any[] = usingBuiltinRunDemo + ? BUILTIN_RUN_PROCESS_RUNTIME + : ADE_DB_PROCESS_RUNTIME; +const MOCK_STACK_BUTTONS: any[] = usingBuiltinRunDemo + ? [] + : ADE_DB_STACK_BUTTONS; +const MOCK_PROCESS_GROUPS: any[] = usingBuiltinRunDemo + ? BUILTIN_RUN_PROCESS_GROUPS + : ADE_DB_PROCESS_GROUPS; -const ADE_DB_AUTOMATIONS = USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.automations - ? ADE_DB_SNAPSHOT.automations - : null; +const ADE_DB_AUTOMATIONS = + USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.automations + ? ADE_DB_SNAPSHOT.automations + : null; function normalizeBrowserMockRelPath(rel: unknown): string { - let s = String(rel ?? "").trim().replace(/\\/g, "/"); + let s = String(rel ?? "") + .trim() + .replace(/\\/g, "/"); while (s.startsWith("./")) s = s.slice(2); if (s === "." || s === "/") return ""; return s.replace(/\/+$/, ""); @@ -609,7 +659,8 @@ function languageIdForBrowserMockPath(relPath: string): string { const dot = lower.lastIndexOf("."); const ext = dot >= 0 ? lower.slice(dot) : ""; if (ext === ".ts" || ext === ".tsx") return "typescript"; - if (ext === ".js" || ext === ".jsx" || ext === ".mjs" || ext === ".cjs") return "javascript"; + if (ext === ".js" || ext === ".jsx" || ext === ".mjs" || ext === ".cjs") + return "javascript"; if (ext === ".json") return "json"; if (ext === ".yml" || ext === ".yaml") return "yaml"; if (ext === ".md") return "markdown"; @@ -621,19 +672,23 @@ function languageIdForBrowserMockPath(relPath: string): string { } /** Depth-1 listTree rows keyed by parent path ("" = workspace root), from `export-browser-mock-ade-snapshot.mjs`. */ -const ADE_DB_FILES_TREE_BY_WORKSPACE: Record<string, Record<string, any[]>> = - USE_ADE_DB_SNAPSHOT - && ADE_DB_SNAPSHOT?.filesTreeByWorkspace - && typeof ADE_DB_SNAPSHOT.filesTreeByWorkspace === "object" - ? ADE_DB_SNAPSHOT.filesTreeByWorkspace - : {}; +const ADE_DB_FILES_TREE_BY_WORKSPACE: Record< + string, + Record<string, any[]> +> = USE_ADE_DB_SNAPSHOT && +ADE_DB_SNAPSHOT?.filesTreeByWorkspace && +typeof ADE_DB_SNAPSHOT.filesTreeByWorkspace === "object" + ? ADE_DB_SNAPSHOT.filesTreeByWorkspace + : {}; -const ADE_DB_FILES_CONTENTS_BY_WORKSPACE: Record<string, Record<string, any>> = - USE_ADE_DB_SNAPSHOT - && ADE_DB_SNAPSHOT?.filesContentsByWorkspace - && typeof ADE_DB_SNAPSHOT.filesContentsByWorkspace === "object" - ? ADE_DB_SNAPSHOT.filesContentsByWorkspace - : {}; +const ADE_DB_FILES_CONTENTS_BY_WORKSPACE: Record< + string, + Record<string, any> +> = USE_ADE_DB_SNAPSHOT && +ADE_DB_SNAPSHOT?.filesContentsByWorkspace && +typeof ADE_DB_SNAPSHOT.filesContentsByWorkspace === "object" + ? ADE_DB_SNAPSHOT.filesContentsByWorkspace + : {}; function makeBuiltinSyntheticFilesTreeIndex(): Record<string, any[]> { return { @@ -654,8 +709,18 @@ function makeBuiltinSyntheticFilesTreeIndex(): Record<string, any[]> { }, ], apps: [ - { name: "desktop", path: "apps/desktop", type: "directory", changeStatus: null }, - { name: "ade-cli", path: "apps/ade-cli", type: "directory", changeStatus: null }, + { + name: "desktop", + path: "apps/desktop", + type: "directory", + changeStatus: null, + }, + { + name: "ade-cli", + path: "apps/ade-cli", + type: "directory", + changeStatus: null, + }, ], "apps/desktop": [ { @@ -664,7 +729,12 @@ function makeBuiltinSyntheticFilesTreeIndex(): Record<string, any[]> { type: "file", changeStatus: null, }, - { name: "src", path: "apps/desktop/src", type: "directory", changeStatus: null }, + { + name: "src", + path: "apps/desktop/src", + type: "directory", + changeStatus: null, + }, ], "apps/desktop/src": [ { @@ -693,22 +763,32 @@ function makeBuiltinSyntheticFilesTreeIndex(): Record<string, any[]> { }; } -const BUILTIN_FILES_TREE_BY_WORKSPACE: Record<string, Record<string, any[]>> = Object.fromEntries( - MOCK_LANES.map((lane) => [String(lane.id), makeBuiltinSyntheticFilesTreeIndex()]), +const BUILTIN_FILES_TREE_BY_WORKSPACE: Record< + string, + Record<string, any[]> +> = Object.fromEntries( + MOCK_LANES.map((lane) => [ + String(lane.id), + makeBuiltinSyntheticFilesTreeIndex(), + ]), ); function getBrowserMockFilesWorkspaces(): any[] { return [...MOCK_LANES] .map((lane) => { - const laneType = lane.laneType === "primary" || lane.laneType === "attached" || lane.laneType === "worktree" - ? lane.laneType - : "worktree"; + const laneType = + lane.laneType === "primary" || + lane.laneType === "attached" || + lane.laneType === "worktree" + ? lane.laneType + : "worktree"; return { id: String(lane.id), kind: laneType, laneId: String(lane.id), name: String(lane.name ?? lane.id), - branchRef: typeof lane.branchRef === "string" ? lane.branchRef : undefined, + branchRef: + typeof lane.branchRef === "string" ? lane.branchRef : undefined, rootPath: String(lane.worktreePath ?? MOCK_PROJECT.rootPath), isReadOnlyByDefault: Boolean(lane.isEditProtected), mobileReadOnly: true, @@ -722,7 +802,10 @@ function getBrowserMockFilesWorkspaces(): any[] { }); } -function getBrowserMockListTreeNodes(workspaceId: string, parentPath: string): any[] { +function getBrowserMockListTreeNodes( + workspaceId: string, + parentPath: string, +): any[] { const parentKey = normalizeBrowserMockRelPath(parentPath); const snapTree = ADE_DB_FILES_TREE_BY_WORKSPACE[workspaceId]; if (snapTree && Object.prototype.hasOwnProperty.call(snapTree, parentKey)) { @@ -737,15 +820,20 @@ function getBrowserMockListTreeNodes(workspaceId: string, parentPath: string): a return []; } -function getBrowserMockReadFilePayload(workspaceId: string, relPath: string): any { +function getBrowserMockReadFilePayload( + workspaceId: string, + relPath: string, +): any { const normalized = normalizeBrowserMockRelPath(relPath); - const fromSnapshot = ADE_DB_FILES_CONTENTS_BY_WORKSPACE[workspaceId]?.[normalized]; + const fromSnapshot = + ADE_DB_FILES_CONTENTS_BY_WORKSPACE[workspaceId]?.[normalized]; if (fromSnapshot && typeof fromSnapshot.content === "string") { return { content: fromSnapshot.content, encoding: fromSnapshot.encoding ?? "utf-8", size: Number(fromSnapshot.size ?? fromSnapshot.content.length), - languageId: fromSnapshot.languageId ?? languageIdForBrowserMockPath(normalized), + languageId: + fromSnapshot.languageId ?? languageIdForBrowserMockPath(normalized), isBinary: Boolean(fromSnapshot.isBinary), }; } @@ -760,27 +848,39 @@ function getBrowserMockReadFilePayload(workspaceId: string, relPath: string): an } function isMockChatToolType(toolType: unknown): boolean { - const normalized = String(toolType ?? "").trim().toLowerCase(); + const normalized = String(toolType ?? "") + .trim() + .toLowerCase(); return Boolean( - normalized - && ( - normalized === "codex-chat" - || normalized === "claude-chat" - || normalized === "opencode-chat" - || normalized === "cursor" - || normalized === "droid" - || normalized === "droid-chat" - || normalized.endsWith("-chat") - ), + normalized && + (normalized === "codex-chat" || + normalized === "claude-chat" || + normalized === "opencode-chat" || + normalized === "cursor" || + normalized === "droid" || + normalized === "droid-chat" || + normalized.endsWith("-chat")), ); } -function inferMockChatProvider(session: any): "claude" | "codex" | "cursor" | "droid" | "opencode" { - const metadataProvider = String(session?.resumeMetadata?.provider ?? "").trim().toLowerCase(); - if (metadataProvider === "claude" || metadataProvider === "codex" || metadataProvider === "cursor" || metadataProvider === "droid" || metadataProvider === "opencode") { +function inferMockChatProvider( + session: any, +): "claude" | "codex" | "cursor" | "droid" | "opencode" { + const metadataProvider = String(session?.resumeMetadata?.provider ?? "") + .trim() + .toLowerCase(); + if ( + metadataProvider === "claude" || + metadataProvider === "codex" || + metadataProvider === "cursor" || + metadataProvider === "droid" || + metadataProvider === "opencode" + ) { return metadataProvider; } - const toolType = String(session?.toolType ?? "").trim().toLowerCase(); + const toolType = String(session?.toolType ?? "") + .trim() + .toLowerCase(); if (toolType.startsWith("claude")) return "claude"; if (toolType.startsWith("codex")) return "codex"; if (toolType === "cursor" || toolType.startsWith("cursor")) return "cursor"; @@ -790,7 +890,9 @@ function inferMockChatProvider(session: any): "claude" | "codex" | "cursor" | "d function getMockChatTranscriptEvents(sessionId: string): any[] { const events = ADE_DB_CHAT_TRANSCRIPTS[sessionId]?.events; - return Array.isArray(events) ? events.filter((entry) => entry?.sessionId === sessionId && entry?.event) : []; + return Array.isArray(events) + ? events.filter((entry) => entry?.sessionId === sessionId && entry?.event) + : []; } function latestMockDoneEvent(events: any[]): any | null { @@ -801,7 +903,9 @@ function latestMockDoneEvent(events: any[]): any | null { return null; } -function fallbackMockModelForProvider(provider: "claude" | "codex" | "cursor" | "droid" | "opencode"): string { +function fallbackMockModelForProvider( + provider: "claude" | "codex" | "cursor" | "droid" | "opencode", +): string { if (provider === "claude") return "sonnet"; if (provider === "codex") return DEFAULT_BROWSER_MOCK_CODEX_MODEL; if (provider === "cursor") return "auto"; @@ -809,7 +913,9 @@ function fallbackMockModelForProvider(provider: "claude" | "codex" | "cursor" | return "opencode/mock"; } -function fallbackMockModelIdForProvider(provider: "claude" | "codex" | "cursor" | "droid" | "opencode"): string { +function fallbackMockModelIdForProvider( + provider: "claude" | "codex" | "cursor" | "droid" | "opencode", +): string { if (provider === "claude") return DEFAULT_BROWSER_MOCK_CLAUDE_MODEL; if (provider === "codex") return DEFAULT_BROWSER_MOCK_CODEX_MODEL; if (provider === "cursor") return "cursor/auto"; @@ -823,19 +929,20 @@ function mockAgentChatSummaryFromSession(session: any): any | null { const events = getMockChatTranscriptEvents(String(session.id)); const done = latestMockDoneEvent(events); const modelId = String( - session.resumeMetadata?.modelId - ?? session.resumeMetadata?.launch?.modelId - ?? done?.modelId - ?? fallbackMockModelIdForProvider(provider), + session.resumeMetadata?.modelId ?? + session.resumeMetadata?.launch?.modelId ?? + done?.modelId ?? + fallbackMockModelIdForProvider(provider), ); const model = String( - session.resumeMetadata?.model - ?? session.resumeMetadata?.launch?.model - ?? done?.model - ?? fallbackMockModelForProvider(provider), + session.resumeMetadata?.model ?? + session.resumeMetadata?.launch?.model ?? + done?.model ?? + fallbackMockModelForProvider(provider), ); const endedAt = session.endedAt ?? null; - const lastActivityAt = session.lastActivityAt ?? session.endedAt ?? session.startedAt ?? now; + const lastActivityAt = + session.lastActivityAt ?? session.endedAt ?? session.startedAt ?? now; const status = session.status === "running" ? "idle" : "ended"; return { sessionId: String(session.id), @@ -851,12 +958,16 @@ function mockAgentChatSummaryFromSession(session: any): any | null { executionMode: session.resumeMetadata?.executionMode ?? null, permissionMode: session.resumeMetadata?.permissionMode ?? null, interactionMode: session.resumeMetadata?.interactionMode ?? null, - claudePermissionMode: session.resumeMetadata?.claudePermissionMode ?? undefined, - codexApprovalPolicy: session.resumeMetadata?.codexApprovalPolicy ?? undefined, + claudePermissionMode: + session.resumeMetadata?.claudePermissionMode ?? undefined, + codexApprovalPolicy: + session.resumeMetadata?.codexApprovalPolicy ?? undefined, codexSandbox: session.resumeMetadata?.codexSandbox ?? undefined, codexConfigSource: session.resumeMetadata?.codexConfigSource ?? undefined, - opencodePermissionMode: session.resumeMetadata?.opencodePermissionMode ?? undefined, - droidPermissionMode: session.resumeMetadata?.droidPermissionMode ?? undefined, + opencodePermissionMode: + session.resumeMetadata?.opencodePermissionMode ?? undefined, + droidPermissionMode: + session.resumeMetadata?.droidPermissionMode ?? undefined, cursorModeSnapshot: session.resumeMetadata?.cursorModeSnapshot ?? undefined, cursorModeId: session.resumeMetadata?.cursorModeId ?? null, cursorConfigValues: session.resumeMetadata?.cursorConfigValues ?? null, @@ -880,9 +991,9 @@ function mockAgentChatSummaryFromSession(session: any): any | null { } function listMockAgentChatSummaries(args: any = {}): any[] { - let rows = MOCK_SESSIONS - .map(mockAgentChatSummaryFromSession) - .filter((session): session is any => Boolean(session)); + let rows = MOCK_SESSIONS.map(mockAgentChatSummaryFromSession).filter( + (session): session is any => Boolean(session), + ); if (typeof args?.laneId === "string" && args.laneId.trim()) { rows = rows.filter((session) => session.laneId === args.laneId.trim()); } @@ -1215,7 +1326,9 @@ const INTEGRATION_PRS: any[] = [ // ── All PRs combined ────────────────────────────────────────── const ALL_PRS = USE_ADE_DB_SNAPSHOT - ? (Array.isArray(ADE_DB_SNAPSHOT?.prs) ? ADE_DB_SNAPSHOT.prs : []) + ? Array.isArray(ADE_DB_SNAPSHOT?.prs) + ? ADE_DB_SNAPSHOT.prs + : [] : [...NORMAL_PRS, ...QUEUE_PRS, ...INTEGRATION_PRS]; // ── Merge Contexts ──────────────────────────────────────────── @@ -1931,7 +2044,9 @@ const BUILTIN_MOCK_REBASE_NEEDS: any[] = [ ]; const MOCK_REBASE_NEEDS: any[] = USE_ADE_DB_SNAPSHOT - ? (Array.isArray(ADE_DB_SNAPSHOT?.rebaseNeeds) ? ADE_DB_SNAPSHOT.rebaseNeeds : []) + ? Array.isArray(ADE_DB_SNAPSHOT?.rebaseNeeds) + ? ADE_DB_SNAPSHOT.rebaseNeeds + : [] : BUILTIN_MOCK_REBASE_NEEDS; // ── Queue Landing State ─────────────────────────────────────── @@ -2044,12 +2159,15 @@ const BUILTIN_MOCK_QUEUE_STATE: Record<string, any> = { const MOCK_QUEUE_STATE: Record<string, any> = USE_ADE_DB_SNAPSHOT ? Object.fromEntries( - (Array.isArray(ADE_DB_SNAPSHOT?.queueStates) ? ADE_DB_SNAPSHOT.queueStates : []).flatMap( - (state: any) => { - const keys = [state?.groupId, state?.queueId].filter(Boolean).map(String); - return keys.map((key) => [key, state]); - }, - ), + (Array.isArray(ADE_DB_SNAPSHOT?.queueStates) + ? ADE_DB_SNAPSHOT.queueStates + : [] + ).flatMap((state: any) => { + const keys = [state?.groupId, state?.queueId] + .filter(Boolean) + .map(String); + return keys.map((key) => [key, state]); + }), ) : BUILTIN_MOCK_QUEUE_STATE; @@ -2221,7 +2339,9 @@ const BUILTIN_MOCK_INTEGRATION_WORKFLOWS: any[] = [ ]; const MOCK_INTEGRATION_WORKFLOWS: any[] = USE_ADE_DB_SNAPSHOT - ? (Array.isArray(ADE_DB_SNAPSHOT?.integrationWorkflows) ? ADE_DB_SNAPSHOT.integrationWorkflows : []) + ? Array.isArray(ADE_DB_SNAPSHOT?.integrationWorkflows) + ? ADE_DB_SNAPSHOT.integrationWorkflows + : [] : BUILTIN_MOCK_INTEGRATION_WORKFLOWS; const BUILTIN_MOCK_GITHUB_SNAPSHOT: any = { @@ -2311,9 +2431,10 @@ const BUILTIN_MOCK_GITHUB_SNAPSHOT: any = { ], }; -const MOCK_GITHUB_SNAPSHOT: any = USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.githubSnapshot - ? ADE_DB_SNAPSHOT.githubSnapshot - : BUILTIN_MOCK_GITHUB_SNAPSHOT; +const MOCK_GITHUB_SNAPSHOT: any = + USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.githubSnapshot + ? ADE_DB_SNAPSHOT.githubSnapshot + : BUILTIN_MOCK_GITHUB_SNAPSHOT; // ═══════════════════════════════════════════════════════════════ // Wire it up @@ -2329,13 +2450,19 @@ const MOCK_GITHUB_SNAPSHOT: any = USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.github */ function shouldInstallBrowserMock(target: Window): boolean { const w = target as any; - return !(w.ade && !w.__adeBrowserMock && typeof w.ade.sync?.getStatus === "function"); + return !( + w.ade && + !w.__adeBrowserMock && + typeof w.ade.sync?.getStatus === "function" + ); } if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { const w = window as any; if (w.ade) { - console.warn("[ADE] Re-applying full window.ade browser mock (e.g. Vite HMR)."); + console.warn( + "[ADE] Re-applying full window.ade browser mock (e.g. Vite HMR).", + ); } else { console.warn( "[ADE] Running outside Electron — injecting browser mock for window.ade", @@ -2437,7 +2564,12 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { const BROWSER_MOCK_AI_STATUS: any = { mode: "guest", - availableProviders: { claude: false, codex: false, cursor: false, droid: false }, + availableProviders: { + claude: false, + codex: false, + cursor: false, + droid: false, + }, models: { claude: [], codex: [], cursor: [], droid: [] }, features: [], providerConnections: { @@ -2481,9 +2613,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { lastPolledAt: now, errors: [], }; - const BROWSER_USAGE_SNAPSHOT: any = USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.usageSnapshot - ? ADE_DB_SNAPSHOT.usageSnapshot - : BROWSER_MOCK_USAGE_SNAPSHOT; + const BROWSER_USAGE_SNAPSHOT: any = + USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.usageSnapshot + ? ADE_DB_SNAPSHOT.usageSnapshot + : BROWSER_MOCK_USAGE_SNAPSHOT; const BROWSER_MOCK_BUDGET_CONFIG: any = { refreshIntervalMin: 15, @@ -2578,9 +2711,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { totalCostUsd: 0, }, }; - const BROWSER_MISSION_DASHBOARD: any = USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.missionDashboard - ? ADE_DB_SNAPSHOT.missionDashboard - : BROWSER_MOCK_MISSION_DASHBOARD; + const BROWSER_MISSION_DASHBOARD: any = + USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.missionDashboard + ? ADE_DB_SNAPSHOT.missionDashboard + : BROWSER_MOCK_MISSION_DASHBOARD; const BROWSER_MOCK_EMPTY_FULL_MISSION_VIEW: any = { mission: null, @@ -2641,9 +2775,48 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { v8: "0.0.0-browser", }, env: {}, + localRuntime: { + connectionState: "idle", + serviceInstall: { + state: "skipped", + attempted: false, + path: null, + message: + "Background service installation is not available in the browser mock.", + exitCode: null, + updatedAt: null, + }, + serviceHealth: { + state: "unsupported", + installed: null, + running: null, + path: null, + message: + "Background service status is not available in the browser mock.", + checkedAt: null, + }, + }, }), getProject: resolved(MOCK_PROJECT), + getWindowSession: resolved({ + windowId: 1, + project: MOCK_PROJECT, + binding: { + kind: "local", + key: `local:${MOCK_PROJECT.rootPath}`, + rootPath: MOCK_PROJECT.rootPath, + displayName: MOCK_PROJECT.name, + }, + }), + newWindow: resolved({ windowId: 2 }), + openProjectInNewWindow: resolvedArg({ + windowId: 2, + project: MOCK_PROJECT, + }), + closeWindow: resolvedArg({ closed: false }), onProjectChanged: () => () => {}, + onProjectBindingChanged: () => () => {}, + onNavigate: () => () => {}, openExternal: resolvedArg(undefined), revealPath: resolvedArg(undefined), writeClipboardText: resolvedArg(undefined), @@ -2660,7 +2833,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { chooseDirectory: resolvedArg(null), browseDirectories: async (args?: { inputPath?: string }) => { const inputPath = - typeof args?.inputPath === "string" && args.inputPath.trim().length > 0 + typeof args?.inputPath === "string" && + args.inputPath.trim().length > 0 ? args.inputPath : "~/"; return { @@ -2694,9 +2868,17 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }), listRecent: resolved([]), closeCurrent: resolved(undefined), - resolveIcon: resolvedArg({ dataUrl: null, sourcePath: null, mimeType: null }), + resolveIcon: resolvedArg({ + dataUrl: null, + sourcePath: null, + mimeType: null, + }), chooseIcon: resolvedArg(null), - removeIcon: resolvedArg({ dataUrl: null, sourcePath: null, mimeType: null }), + removeIcon: resolvedArg({ + dataUrl: null, + sourcePath: null, + mimeType: null, + }), switchToPath: resolvedArg(MOCK_PROJECT), forgetRecent: resolvedArg([]), reorderRecent: resolvedArg([]), @@ -2724,6 +2906,160 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { onMissing: noop, onStateEvent: noop, }, + remoteRuntime: { + listTargets: resolved([]), + getConnectionSnapshot: resolved({ + connections: [], + connectedCount: 0, + updatedAt: Date.now(), + }), + onConnectionSnapshotChanged: noop, + listDiscoveredMachines: resolved([]), + saveTarget: resolvedArg({ + id: "mock-remote", + name: "Mock remote", + hostname: "mock.local", + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, + }), + removeTarget: resolvedArg({ removed: true }), + connect: resolvedArg({ + target: { + id: "mock-remote", + name: "Mock remote", + hostname: "mock.local", + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: "darwin-arm64", + runtimeBinaryVersion: "0.0.0-browser", + lastConnectedAt: Date.now(), + }, + arch: "darwin-arm64", + version: "0.0.0-browser", + projects: [], + }), + listProjects: resolvedArg([]), + addProject: async (_id: string, rootPath: string) => ({ + projectId: `mock-${ + rootPath + .replace(/[^a-z0-9]+/gi, "-") + .replace(/^-|-$/g, "") + .toLowerCase() || "project" + }`, + rootPath, + displayName: + rootPath.split(/[\\/]/).filter(Boolean).at(-1) || "Mock project", + addedAt: Date.now(), + lastOpenedAt: Date.now(), + gitOriginUrl: null, + }), + browseDirectories: resolvedArg2({ + inputPath: "", + resolvedPath: "/Users/ade", + directoryPath: "/Users/ade", + parentPath: "/Users", + exactDirectoryPath: "/Users/ade", + openableProjectRoot: null, + entries: [], + }), + getProjectDetail: async (_id: string, rootPath: string) => ({ + rootPath, + isGitRepo: true, + branchName: "main", + dirtyCount: 0, + aheadBehind: { ahead: 0, behind: 0 }, + lastCommit: null, + readmeExcerpt: null, + languages: [], + laneCount: 0, + lastOpenedAt: null, + subdirectoryCount: 0, + }), + getDefaultParentDir: resolved("/Users/ade/Projects"), + createProject: async ( + _id: string, + input: { name: string; parentDir: string }, + ) => { + const rootPath = `${input.parentDir.replace(/\/+$/g, "")}/${input.name}`; + return { + projectId: `mock-${input.name}`, + rootPath, + displayName: input.name, + addedAt: Date.now(), + lastOpenedAt: Date.now(), + gitOriginUrl: null, + }; + }, + cloneProject: async ( + _id: string, + input: { url: string; parentDir: string; name?: string }, + ) => { + const name = + input.name || + input.url + .split(/[/:]/) + .pop() + ?.replace(/\.git$/i, "") || + "repo"; + const rootPath = `${input.parentDir.replace(/\/+$/g, "")}/${name}`; + return { + projectId: `mock-${name}`, + rootPath, + displayName: name, + addedAt: Date.now(), + lastOpenedAt: Date.now(), + gitOriginUrl: input.url, + }; + }, + listMyGitHubRepos: resolvedArg2({ repos: [] }), + openProject: async (id: string, projectId: string) => ({ + kind: "remote" as const, + key: `remote:${id}:${projectId}`, + targetId: id, + runtimeName: "Mock remote", + projectId, + rootPath: "/Users/ade/mock-project", + displayName: "mock-project", + }), + callAction: async ( + _id: string, + _projectId: string, + request: { domain: string; action: string }, + ) => ({ + domain: request.domain, + action: request.action, + result: + request.domain === "lane" && request.action === "list" + ? [ + { + id: "lane-main", + name: "Main", + branchName: "main", + laneType: "primary", + }, + ] + : null, + statusHints: {}, + }), + streamEvents: resolvedArg({ events: [], nextCursor: 0, hasMore: false }), + checkLocalWork: async (_id: string, project: { + projectId: string; + displayName: string; + gitOriginUrl: string | null; + }) => ({ + remoteProjectId: project.projectId, + remoteDisplayName: project.displayName, + remoteGitOriginUrl: project.gitOriginUrl, + matches: [], + hasDirtyWork: false, + }), + disconnect: resolvedArg({ disconnected: true }), + }, keybindings: { get: resolved({ definitions: [], overrides: [] }), set: resolvedArg({ definitions: [], overrides: [] }), @@ -2744,6 +3080,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { transferBrainToLocal: resolved(BROWSER_MOCK_SYNC_SNAPSHOT), getPin: resolved({ pin: null }), setPin: resolvedArg(BROWSER_MOCK_SYNC_SNAPSHOT), + generatePin: resolved(BROWSER_MOCK_SYNC_SNAPSHOT), clearPin: resolved(BROWSER_MOCK_SYNC_SNAPSHOT), setActiveLanePresence: resolvedArg(undefined), onEvent: () => () => {}, @@ -2821,14 +3158,17 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { available: false, version: null, detail: "Browser preview does not run local VM providers.", - docsUrl: "https://cua.ai/docs/lume/guide/getting-started/installation", + docsUrl: + "https://cua.ai/docs/lume/guide/getting-started/installation", }, tools: [], laneVm: null, vms: [], docs: { - appleVirtualization: "https://developer.apple.com/documentation/virtualization", - appleSharedDirectories: "https://developer.apple.com/documentation/virtualization/vzvirtiofilesystemdeviceconfiguration", + appleVirtualization: + "https://developer.apple.com/documentation/virtualization", + appleSharedDirectories: + "https://developer.apple.com/documentation/virtualization/vzvirtiofilesystemdeviceconfiguration", lume: "https://cua.ai/docs/lume/guide/fundamentals/vm-management", }, }), @@ -2963,7 +3303,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { resetTourProgress: resolvedArg(BROWSER_MOCK_TOUR_PROGRESS), markTourCompletedVariant: resolvedArg2(BROWSER_MOCK_TOUR_PROGRESS), markTourDismissedVariant: resolvedArg2(BROWSER_MOCK_TOUR_PROGRESS), - updateTourStepVariant: async (_a: any, _b: any, _c: any) => BROWSER_MOCK_TOUR_PROGRESS, + updateTourStepVariant: async (_a: any, _b: any, _c: any) => + BROWSER_MOCK_TOUR_PROGRESS, tutorial: { start: resolved(BROWSER_MOCK_TOUR_PROGRESS), dismiss: resolvedArg(BROWSER_MOCK_TOUR_PROGRESS), @@ -2975,60 +3316,67 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }, }, automations: { - list: resolved(USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.rules) ? ADE_DB_AUTOMATIONS.rules : [ - { - id: "auto-session-review", - name: "PR follow-up thread", - description: - "When a pull request changes, send a focused follow-up prompt to an automation-owned chat thread.", - enabled: true, - mode: "review", - triggers: [{ type: "git.pr_updated", branch: "main" }], - trigger: { type: "git.pr_updated", branch: "main" }, - execution: { - kind: "agent-session", - session: { title: "PR follow-up thread" }, - }, - executor: { mode: "automation-bot" }, - modelConfig: { - orchestratorModel: { - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", - }, - }, - permissionConfig: { - providers: { - opencode: "edit", - claude: "plan", - codexSandbox: "workspace-write", - allowedTools: ["git", "github"], - }, - }, - prompt: - "Review the latest PR update and leave a concise follow-up summary with any high-signal next steps.", - reviewProfile: "incremental", - toolPalette: ["repo", "git", "github", "memory", "mission"], - contextSources: [ - { type: "project-memory" }, - { type: "automation-memory" }, - ], - memory: { mode: "automation-plus-project" }, - guardrails: {}, - outputs: { disposition: "comment-only", createArtifact: true }, - verification: { verifyBeforePublish: false, mode: "intervention" }, - billingCode: "auto:session-review", - actions: [], - running: false, - lastRunAt: now, - lastRunStatus: "succeeded", - confidence: { - value: 0.84, - label: "high", - reason: - "Recent runs consistently produced concise PR follow-up notes.", - }, - }, - ]), + list: resolved( + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.rules) + ? ADE_DB_AUTOMATIONS.rules + : [ + { + id: "auto-session-review", + name: "PR follow-up thread", + description: + "When a pull request changes, send a focused follow-up prompt to an automation-owned chat thread.", + enabled: true, + mode: "review", + triggers: [{ type: "git.pr_updated", branch: "main" }], + trigger: { type: "git.pr_updated", branch: "main" }, + execution: { + kind: "agent-session", + session: { title: "PR follow-up thread" }, + }, + executor: { mode: "automation-bot" }, + modelConfig: { + orchestratorModel: { + modelId: "anthropic/claude-sonnet-4-6", + thinkingLevel: "medium", + }, + }, + permissionConfig: { + providers: { + opencode: "edit", + claude: "plan", + codexSandbox: "workspace-write", + allowedTools: ["git", "github"], + }, + }, + prompt: + "Review the latest PR update and leave a concise follow-up summary with any high-signal next steps.", + reviewProfile: "incremental", + toolPalette: ["repo", "git", "github", "memory", "mission"], + contextSources: [ + { type: "project-memory" }, + { type: "automation-memory" }, + ], + memory: { mode: "automation-plus-project" }, + guardrails: {}, + outputs: { disposition: "comment-only", createArtifact: true }, + verification: { + verifyBeforePublish: false, + mode: "intervention", + }, + billingCode: "auto:session-review", + actions: [], + running: false, + lastRunAt: now, + lastRunStatus: "succeeded", + confidence: { + value: 0.84, + label: "high", + reason: + "Recent runs consistently produced concise PR follow-up notes.", + }, + }, + ], + ), toggle: resolvedArg([]), triggerManually: resolvedArg({ id: "run-1", @@ -3049,32 +3397,36 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { summary: "Manual run completed.", billingCode: "auto:session-review", }), - getHistory: resolvedArg(USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.runs) ? ADE_DB_AUTOMATIONS.runs : [ - { - id: "run-1", - automationId: "auto-session-review", - chatSessionId: "chat-auto-1", - missionId: null, - triggerType: "git.pr_updated", - startedAt: now, - endedAt: now, - status: "succeeded", - executionKind: "agent-session", - actionsCompleted: 1, - actionsTotal: 1, - errorMessage: null, - spendUsd: 1.32, - confidence: { - value: 0.81, - label: "high", - reason: "Automation summarized the latest PR update clearly.", - }, - triggerMetadata: { repository: "ADE", branch: "main" }, - summary: - "Summarized the latest PR update and suggested next review points.", - billingCode: "auto:session-review", - }, - ]), + getHistory: resolvedArg( + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.runs) + ? ADE_DB_AUTOMATIONS.runs + : [ + { + id: "run-1", + automationId: "auto-session-review", + chatSessionId: "chat-auto-1", + missionId: null, + triggerType: "git.pr_updated", + startedAt: now, + endedAt: now, + status: "succeeded", + executionKind: "agent-session", + actionsCompleted: 1, + actionsTotal: 1, + errorMessage: null, + spendUsd: 1.32, + confidence: { + value: 0.81, + label: "high", + reason: "Automation summarized the latest PR update clearly.", + }, + triggerMetadata: { repository: "ADE", branch: "main" }, + summary: + "Summarized the latest PR update and suggested next review points.", + billingCode: "auto:session-review", + }, + ], + ), getRunDetail: resolvedArg({ run: { id: "run-1", @@ -3145,32 +3497,36 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { receivedAt: now, }, }), - listRuns: resolvedArg(USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.runs) ? ADE_DB_AUTOMATIONS.runs : [ - { - id: "run-1", - automationId: "auto-session-review", - chatSessionId: "chat-auto-1", - missionId: null, - triggerType: "git.pr_updated", - startedAt: now, - endedAt: now, - status: "succeeded", - executionKind: "agent-session", - actionsCompleted: 1, - actionsTotal: 1, - errorMessage: null, - spendUsd: 1.32, - confidence: { - value: 0.81, - label: "high", - reason: "Automation summarized the latest PR update clearly.", - }, - triggerMetadata: { repository: "ADE", branch: "main" }, - summary: - "Summarized the latest PR update and suggested next review points.", - billingCode: "auto:session-review", - }, - ]), + listRuns: resolvedArg( + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.runs) + ? ADE_DB_AUTOMATIONS.runs + : [ + { + id: "run-1", + automationId: "auto-session-review", + chatSessionId: "chat-auto-1", + missionId: null, + triggerType: "git.pr_updated", + startedAt: now, + endedAt: now, + status: "succeeded", + executionKind: "agent-session", + actionsCompleted: 1, + actionsTotal: 1, + errorMessage: null, + spendUsd: 1.32, + confidence: { + value: 0.81, + label: "high", + reason: "Automation summarized the latest PR update clearly.", + }, + triggerMetadata: { repository: "ADE", branch: "main" }, + summary: + "Summarized the latest PR update and suggested next review points.", + billingCode: "auto:session-review", + }, + ], + ), getIngressStatus: resolved({ githubRelay: { configured: true, @@ -3193,21 +3549,25 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { lastError: null, }, }), - listIngressEvents: resolvedArg(USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.ingressEvents) ? ADE_DB_AUTOMATIONS.ingressEvents : [ - { - id: "ingress-1", - source: "github-relay", - eventKey: "delivery-1", - automationIds: ["auto-session-review"], - triggerType: "git.pr_updated", - eventName: "pull_request", - status: "dispatched", - summary: "PR synchronize event dispatched to matching rules.", - errorMessage: null, - cursor: "cursor-1", - receivedAt: now, - }, - ]), + listIngressEvents: resolvedArg( + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.ingressEvents) + ? ADE_DB_AUTOMATIONS.ingressEvents + : [ + { + id: "ingress-1", + source: "github-relay", + eventKey: "delivery-1", + automationIds: ["auto-session-review"], + triggerType: "git.pr_updated", + eventName: "pull_request", + status: "dispatched", + summary: "PR synchronize event dispatched to matching rules.", + errorMessage: null, + cursor: "cursor-1", + receivedAt: now, + }, + ], + ), parseNaturalLanguage: resolvedArg({ draft: { name: "Mock automation", @@ -3265,22 +3625,25 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { color: lane.color ?? null, })), recentCommitsByLane: Object.fromEntries( - MOCK_LANES.map((lane) => [lane.id, [ - { - sha: "abc1234567890", - shortSha: "abc1234", - subject: `Recent work on ${lane.name}`, - authoredAt: now, - pushed: false, - }, - { - sha: "def4567890123", - shortSha: "def4567", - subject: `Follow-up fix on ${lane.name}`, - authoredAt: yesterday, - pushed: true, - }, - ]]), + MOCK_LANES.map((lane) => [ + lane.id, + [ + { + sha: "abc1234567890", + shortSha: "abc1234", + subject: `Recent work on ${lane.name}`, + authoredAt: now, + pushed: false, + }, + { + sha: "def4567890123", + shortSha: "def4567", + subject: `Follow-up fix on ${lane.name}`, + authoredAt: yesterday, + pushed: true, + }, + ], + ]), ), recommendedModelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, }), @@ -3289,18 +3652,32 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { id: "review-run-1", projectId: MOCK_PROJECT.id, laneId: MOCK_LANES[1]?.id ?? "lane-auth", - target: { mode: "lane_diff", laneId: MOCK_LANES[1]?.id ?? "lane-auth" }, + target: { + mode: "lane_diff", + laneId: MOCK_LANES[1]?.id ?? "lane-auth", + }, config: { compareAgainst: { kind: "default_branch" }, selectionMode: "full_diff", dirtyOnly: false, modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, reasoningEffort: "medium", - budgets: { maxFiles: 60, maxDiffChars: 180000, maxPromptChars: 220000, maxFindings: 12 }, + budgets: { + maxFiles: 60, + maxDiffChars: 180000, + maxPromptChars: 220000, + maxFindings: 12, + }, publishBehavior: "local_only", }, targetLabel: "feature/auth-flow vs main", - compareTarget: { kind: "default_branch", label: "main", ref: "main", laneId: null, branchRef: "main" }, + compareTarget: { + kind: "default_branch", + label: "main", + ref: "main", + laneId: null, + branchRef: "main", + }, status: "completed", summary: "Found two actionable risks in the auth flow changes.", errorMessage: null, @@ -3324,11 +3701,22 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { dirtyOnly: false, modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, reasoningEffort: "medium", - budgets: { maxFiles: 60, maxDiffChars: 180000, maxPromptChars: 220000, maxFindings: 12 }, + budgets: { + maxFiles: 60, + maxDiffChars: 180000, + maxPromptChars: 220000, + maxFindings: 12, + }, publishBehavior: "local_only", }, targetLabel: "feature/auth-flow vs main", - compareTarget: { kind: "default_branch", label: "main", ref: "main", laneId: null, branchRef: "main" }, + compareTarget: { + kind: "default_branch", + label: "main", + ref: "main", + laneId: null, + branchRef: "main", + }, status: "completed", summary: "Found two actionable risks in the auth flow changes.", errorMessage: null, @@ -3350,7 +3738,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { evidence: [ { kind: "diff_hunk", - summary: "Session write happens before token exchange success is confirmed.", + summary: + "Session write happens before token exchange success is confirmed.", filePath: "src/auth/oauth.ts", line: 128, quote: "saveSession(session);", @@ -3385,7 +3774,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { artifactType: "diff_bundle", title: "Diff bundle", mimeType: "text/plain", - contentText: "diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts\n@@ ...", + contentText: + "diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts\n@@ ...", metadata: null, createdAt: now, }, @@ -3405,7 +3795,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { startedAt: yesterday, endedAt: now, lastActivityAt: now, - lastOutputPreview: "Found two actionable risks in the auth flow changes.", + lastOutputPreview: + "Found two actionable risks in the auth flow changes.", summary: "Saved review transcript for local diff review.", }, }), @@ -3420,7 +3811,12 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { dirtyOnly: false, modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, reasoningEffort: "medium", - budgets: { maxFiles: 60, maxDiffChars: 180000, maxPromptChars: 220000, maxFindings: 12 }, + budgets: { + maxFiles: 60, + maxDiffChars: 180000, + maxPromptChars: 220000, + maxFindings: 12, + }, publishBehavior: "local_only", }, targetLabel: "feature/auth-flow review", @@ -3447,7 +3843,12 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { dirtyOnly: false, modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, reasoningEffort: "medium", - budgets: { maxFiles: 60, maxDiffChars: 180000, maxPromptChars: 220000, maxFindings: 12 }, + budgets: { + maxFiles: 60, + maxDiffChars: 180000, + maxPromptChars: 220000, + maxFindings: 12, + }, publishBehavior: "local_only", }, targetLabel: "feature/auth-flow review", @@ -3489,8 +3890,16 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { recentFeedback: [], byClass: [ { findingClass: "intent_drift" as const, total: 4, addressed: 2 }, - { findingClass: "incomplete_rollout" as const, total: 5, addressed: 3 }, - { findingClass: "late_stage_regression" as const, total: 2, addressed: 1 }, + { + findingClass: "incomplete_rollout" as const, + total: 5, + addressed: 3, + }, + { + findingClass: "late_stage_regression" as const, + total: 2, + addressed: 1, + }, ], }), onEvent: noop, @@ -3500,30 +3909,52 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }, missions: { list: async (args: any = {}) => { - const rows = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.missions) - ? ADE_DB_SNAPSHOT.missions - : []; + const rows = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.missions) + ? ADE_DB_SNAPSHOT.missions + : []; const status = typeof args?.status === "string" ? args.status : null; const laneId = typeof args?.laneId === "string" ? args.laneId : null; const includeArchived = args?.includeArchived === true; - const activeStatuses = new Set(["queued", "planning", "plan_review", "in_progress", "intervention_required"]); + const activeStatuses = new Set([ + "queued", + "planning", + "plan_review", + "in_progress", + "intervention_required", + ]); let filtered = rows; - if (!includeArchived) filtered = filtered.filter((mission: any) => !mission.archivedAt); - if (laneId) filtered = filtered.filter((mission: any) => mission.laneId === laneId); + if (!includeArchived) + filtered = filtered.filter((mission: any) => !mission.archivedAt); + if (laneId) + filtered = filtered.filter( + (mission: any) => mission.laneId === laneId, + ); if (status === "active") { - filtered = filtered.filter((mission: any) => activeStatuses.has(mission.status)); + filtered = filtered.filter((mission: any) => + activeStatuses.has(mission.status), + ); } else if (status === "in_progress") { - filtered = filtered.filter((mission: any) => mission.status === "in_progress" || mission.status === "plan_review"); + filtered = filtered.filter( + (mission: any) => + mission.status === "in_progress" || + mission.status === "plan_review", + ); } else if (status) { - filtered = filtered.filter((mission: any) => mission.status === status); + filtered = filtered.filter( + (mission: any) => mission.status === status, + ); } - const limit = Number.isFinite(args?.limit) ? Math.max(1, Math.floor(args.limit)) : filtered.length; + const limit = Number.isFinite(args?.limit) + ? Math.max(1, Math.floor(args.limit)) + : filtered.length; return filtered.slice(0, limit); }, get: async (missionId: string) => { - const rows = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.missions) - ? ADE_DB_SNAPSHOT.missions - : []; + const rows = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.missions) + ? ADE_DB_SNAPSHOT.missions + : []; return rows.find((mission: any) => mission.id === missionId) ?? null; }, create: resolvedArg({ id: "mock" }), @@ -3567,7 +3998,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { getPhaseConfiguration: resolvedArg(null), getDashboard: resolved(BROWSER_MISSION_DASHBOARD), getFullMissionView: async (missionId: string) => - (USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.missionFullViews?.[missionId]) + USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.missionFullViews?.[missionId] ? ADE_DB_SNAPSHOT.missionFullViews[missionId] : BROWSER_MOCK_EMPTY_FULL_MISSION_VIEW, preflight: resolvedArg({ @@ -3769,7 +4200,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { updateAppearance: resolvedArg(undefined), archive: resolvedArg(undefined), delete: resolvedArg(undefined), - cancelDelete: resolvedArg({ cancelled: false, reason: "no active delete" }), + cancelDelete: resolvedArg({ + cancelled: false, + reason: "no active delete", + }), getDeleteRisk: resolvedArg({ laneId: "mock", branchRef: null, @@ -3935,7 +4369,9 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { callbackPaths: [], }), oauthUpdateConfig: resolvedArg(undefined), - oauthGenerateRedirectUris: resolvedArg([{ provider: "google", uris: [] as string[], instructions: "" }]), + oauthGenerateRedirectUris: resolvedArg([ + { provider: "google", uris: [] as string[], instructions: "" }, + ]), oauthEncodeState: resolvedArg("ade:mock"), oauthDecodeState: resolvedArg(null), oauthListSessions: resolved([]), @@ -3949,9 +4385,13 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { fallbackLanes: [] as string[], }), diagnosticsGetLaneHealth: async (args: { laneId: string }) => - typeof args?.laneId === "string" ? mockBrowserLaneHealth(args.laneId) : null, + typeof args?.laneId === "string" + ? mockBrowserLaneHealth(args.laneId) + : null, diagnosticsRunHealthCheck: async (args: { laneId: string }) => - mockBrowserLaneHealth(typeof args?.laneId === "string" ? args.laneId : "mock"), + mockBrowserLaneHealth( + typeof args?.laneId === "string" ? args.laneId : "mock", + ), diagnosticsRunFullCheck: resolved([]), diagnosticsActivateFallback: resolvedArg(undefined), diagnosticsDeactivateFallback: resolvedArg(undefined), @@ -3962,12 +4402,18 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { list: async (args: any = {}) => { let rows = MOCK_SESSIONS; if (typeof args?.laneId === "string" && args.laneId.trim()) { - rows = rows.filter((session) => session.laneId === args.laneId.trim()); + rows = rows.filter( + (session) => session.laneId === args.laneId.trim(), + ); } if (typeof args?.status === "string" && args.status.trim()) { - rows = rows.filter((session) => session.status === args.status.trim()); + rows = rows.filter( + (session) => session.status === args.status.trim(), + ); } - const limit = Number.isFinite(args?.limit) ? Math.max(1, Math.floor(args.limit)) : rows.length; + const limit = Number.isFinite(args?.limit) + ? Math.max(1, Math.floor(args.limit)) + : rows.length; return rows.slice(0, limit); }, get: async (sessionId: string) => @@ -3976,10 +4422,16 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { updateMeta: resolvedArg(null), readTranscriptTail: async (args: any = {}) => { const sessionId = String(args?.sessionId ?? "").trim(); - const lines = getMockChatTranscriptEvents(sessionId).map((entry) => JSON.stringify(entry)); + const lines = getMockChatTranscriptEvents(sessionId).map((entry) => + JSON.stringify(entry), + ); const raw = lines.join("\n"); - const maxBytes = Number.isFinite(args?.maxBytes) ? Math.max(0, Math.floor(args.maxBytes)) : raw.length; - return raw.length > maxBytes ? raw.slice(Math.max(0, raw.length - maxBytes)) : raw; + const maxBytes = Number.isFinite(args?.maxBytes) + ? Math.max(0, Math.floor(args.maxBytes)) + : raw.length; + return raw.length > maxBytes + ? raw.slice(Math.max(0, raw.length - maxBytes)) + : raw; }, getDelta: resolvedArg(null), onChanged: noop, @@ -4002,7 +4454,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { steer: resolvedArg(undefined), cancelSteer: resolvedArg(undefined), editSteer: resolvedArg(undefined), - dispatchSteer: resolvedArg({ delivered: false, reason: "Browser mock does not run chat sessions." }), + dispatchSteer: resolvedArg({ + delivered: false, + reason: "Browser mock does not run chat sessions.", + }), cancelDispatchedSteer: resolvedArg({ cancelled: false }), interrupt: resolvedArg(undefined), resume: resolvedArg({ id: "mock" }), @@ -4026,10 +4481,14 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { supportsInterrupt: false, }), saveTempAttachment: resolvedArg({ path: "/tmp/browser-mock-attachment" }), - getEventHistory: async (arg: { sessionId: string; maxEvents?: number }) => ({ + getEventHistory: async (arg: { + sessionId: string; + maxEvents?: number; + }) => ({ sessionId: typeof arg?.sessionId === "string" ? arg.sessionId : "", events: (() => { - const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId : ""; + const sessionId = + typeof arg?.sessionId === "string" ? arg.sessionId : ""; const events = getMockChatTranscriptEvents(sessionId); const maxEvents = Number.isFinite(arg?.maxEvents) ? Math.max(1, Math.floor(arg.maxEvents!)) @@ -4037,7 +4496,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { return events.length > maxEvents ? events.slice(-maxEvents) : events; })(), truncated: (() => { - const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId : ""; + const sessionId = + typeof arg?.sessionId === "string" ? arg.sessionId : ""; const events = getMockChatTranscriptEvents(sessionId); const maxEvents = Number.isFinite(arg?.maxEvents) ? Math.max(1, Math.floor(arg.maxEvents!)) @@ -4337,7 +4797,14 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { fetchedAt: now, sdk: { packageName: "@linear/sdk", - surfaces: ["viewer", "organization", "projects", "teams", "assignedIssues", "issues"], + surfaces: [ + "viewer", + "organization", + "projects", + "teams", + "assignedIssues", + "issues", + ], }, }), getLinearIssuePickerData: resolvedArg({ @@ -4403,7 +4870,11 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }), }, pty: { - create: resolvedArg({ ptyId: "mock", sessionId: "mock-session", pid: 1234 }), + create: resolvedArg({ + ptyId: "mock", + sessionId: "mock-session", + pid: 1234, + }), write: resolvedArg(undefined), resize: resolvedArg(undefined), dispose: resolvedArg(undefined), @@ -4441,8 +4912,12 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { stopWatching: resolvedArg(undefined), quickOpen: async (args: any) => { const workspaceId = String(args?.workspaceId ?? ""); - const q = String(args?.query ?? "").trim().toLowerCase(); - const limit = Number.isFinite(args?.limit) ? Math.max(1, Math.floor(args.limit)) : 25; + const q = String(args?.query ?? "") + .trim() + .toLowerCase(); + const limit = Number.isFinite(args?.limit) + ? Math.max(1, Math.floor(args.limit)) + : 25; const rootNodes = getBrowserMockListTreeNodes(workspaceId, ""); const flat: { path: string; score: number }[] = []; const maxCollect = 400; @@ -4452,7 +4927,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { if (!node?.path) continue; const hay = String(node.path).toLowerCase(); if (!q || hay.includes(q)) { - flat.push({ path: node.path, score: prefixScore + (node.name?.length ?? 0) }); + flat.push({ + path: node.path, + score: prefixScore + (node.name?.length ?? 0), + }); } if (node.type === "directory") { const kids = getBrowserMockListTreeNodes(workspaceId, node.path); @@ -4554,7 +5032,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { body: "## Description\n\nMock feedback", labels: ["bug"], generationMode: "deterministic", - generationWarning: "ADE used a deterministic draft because no AI model was selected.", + generationWarning: + "ADE used a deterministic draft because no AI model was selected.", }), submitDraft: resolvedArg({ id: "mock-feedback-1", @@ -4623,17 +5102,18 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }, prs: { createFromLane: resolvedArg( - USE_ADE_DB_SNAPSHOT ? null : NORMAL_PRS[0] ?? null, + USE_ADE_DB_SNAPSHOT ? null : (NORMAL_PRS[0] ?? null), ), linkToLane: resolvedArg( - USE_ADE_DB_SNAPSHOT ? null : NORMAL_PRS[0] ?? null, + USE_ADE_DB_SNAPSHOT ? null : (NORMAL_PRS[0] ?? null), ), getForLane: async (laneId: string) => ALL_PRS.find((pr: any) => pr.laneId === laneId) ?? null, listAll: resolved(ALL_PRS), refresh: resolved(ALL_PRS), getStatus: async (prId: string) => - ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.status ?? MOCK_STATUS_BY_PR[prId] ?? { + ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.status ?? + MOCK_STATUS_BY_PR[prId] ?? { prId, state: "open", checksStatus: "passing", @@ -4643,11 +5123,17 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { behindBaseBy: 0, }, getChecks: async (prId: string) => - ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.checks ?? MOCK_CHECKS_BY_PR[prId] ?? [], + ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.checks ?? + MOCK_CHECKS_BY_PR[prId] ?? + [], getComments: async (prId: string) => - ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.comments ?? MOCK_COMMENTS_BY_PR[prId] ?? [], + ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.comments ?? + MOCK_COMMENTS_BY_PR[prId] ?? + [], getReviews: async (prId: string) => - ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.reviews ?? MOCK_REVIEWS_BY_PR[prId] ?? [], + ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.reviews ?? + MOCK_REVIEWS_BY_PR[prId] ?? + [], getReviewThreads: resolvedArg([]), updateDescription: resolvedArg(undefined), delete: resolvedArg({ deleted: true }), @@ -4665,7 +5151,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { commitIntegration: resolvedArg({ groupId: "group-int-mock", integrationLaneId: "lane-search", - pr: USE_ADE_DB_SNAPSHOT ? null : INTEGRATION_PRS[0] ?? null, + pr: USE_ADE_DB_SNAPSHOT ? null : (INTEGRATION_PRS[0] ?? null), mergeResults: [], }), landStackEnhanced: resolvedArg([]), @@ -4806,7 +5292,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { convergenceStateDelete: async (prId: string) => { delete MOCK_CONVERGENCE_RUNTIME[prId]; }, - pathToMergeStart: async (args: { prId: string; permissionMode?: string | null }) => { + pathToMergeStart: async (args: { + prId: string; + permissionMode?: string | null; + }) => { const runtime = MOCK_CONVERGENCE_RUNTIME[args.prId] ?? createDefaultConvergenceRuntime(args.prId); @@ -4820,7 +5309,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { MOCK_CONVERGENCE_RUNTIME[args.prId] = runtime; return { prId: args.prId, scheduled: true, runtime: { ...runtime } }; }, - pathToMergeStop: async (args: { prId: string; reason?: string | null }) => { + pathToMergeStop: async (args: { + prId: string; + reason?: string | null; + }) => { const runtime = MOCK_CONVERGENCE_RUNTIME[args.prId] ?? null; if (runtime) { runtime.autoConvergeEnabled = false; @@ -4920,7 +5412,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { dismissIntegrationCleanup: resolvedArg( USE_ADE_DB_SNAPSHOT ? undefined - : BUILTIN_MOCK_INTEGRATION_WORKFLOWS[1] ?? undefined, + : (BUILTIN_MOCK_INTEGRATION_WORKFLOWS[1] ?? undefined), ), cleanupIntegrationWorkflow: resolvedArg({ proposalId: "workflow-int-active", @@ -4952,15 +5444,21 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { listOperations: async (args: any = {}) => { let rows = ADE_DB_OPERATIONS; if (typeof args?.laneId === "string" && args.laneId.trim()) { - rows = rows.filter((operation) => operation.laneId === args.laneId.trim()); + rows = rows.filter( + (operation) => operation.laneId === args.laneId.trim(), + ); } if (typeof args?.kind === "string" && args.kind.trim()) { - rows = rows.filter((operation) => operation.kind === args.kind.trim()); + rows = rows.filter( + (operation) => operation.kind === args.kind.trim(), + ); } if (typeof args?.status === "string" && args.status !== "all") { rows = rows.filter((operation) => operation.status === args.status); } - const limit = Number.isFinite(args?.limit) ? Math.max(1, Math.floor(args.limit)) : rows.length; + const limit = Number.isFinite(args?.limit) + ? Math.max(1, Math.floor(args.limit)) + : rows.length; return rows.slice(0, limit); }, exportOperations: async (args: any = {}) => ({ @@ -5033,7 +5531,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { installAvailable: false, installTargetPath: "~/.local/bin/ade", installTargetDirOnPath: false, - message: "ADE-launched agents can use ade. Terminal access is not installed yet.", + message: + "ADE-launched agents can use ade. Terminal access is not installed yet.", nextAction: "Run npm link in apps/ade-cli for local development.", }), installForUser: resolved({ @@ -5053,7 +5552,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { installAvailable: false, installTargetPath: "~/.local/bin/ade", installTargetDirOnPath: false, - message: "ADE-launched agents can use ade. Terminal access is not installed yet.", + message: + "ADE-launched agents can use ade. Terminal access is not installed yet.", nextAction: "Run npm link in apps/ade-cli for local development.", }, }), diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index b0e5e70f3..005bc28aa 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -68,6 +68,7 @@ import { useAppStore } from "../../state/appStore"; import { getDirtyFileTextForWindow } from "../../lib/dirtyWorkspaceBuffers"; import { getAiStatusCached } from "../../lib/aiDiscoveryCache"; import { dispatchWorkSurfaceRevealed } from "../terminals/workSurfaceVisibility"; +import type { AppNavigationRequest } from "../../../shared/types"; const StartupSplashScreen = ( <div className="flex h-full w-full flex-col items-center justify-center relative overflow-hidden" style={{ background: "var(--color-bg)" }}> @@ -290,6 +291,43 @@ function ShellLayout() { ); } +function AppNavigationBridge() { + const navigate = useNavigate(); + React.useEffect(() => { + const onNavigate = window.ade?.app?.onNavigate; + if (!onNavigate) return; + return onNavigate((request: AppNavigationRequest) => { + const target = request.target; + if (target.kind === "chat" || target.kind === "work") { + const params = new URLSearchParams(); + if (target.sessionId) params.set("sessionId", target.sessionId); + if (target.laneId) params.set("laneId", target.laneId); + navigate(`/work${params.toString() ? `?${params.toString()}` : ""}`); + return; + } + if (target.kind === "lane") { + const params = new URLSearchParams(); + params.set("laneId", target.laneId); + if (target.sessionId) params.set("sessionId", target.sessionId); + navigate(`/lanes?${params.toString()}`); + return; + } + if (target.kind === "pr") { + const params = new URLSearchParams(); + if (target.prId) params.set("prId", target.prId); + if (target.prNumber != null) params.set("pr", String(target.prNumber)); + if (target.laneId) params.set("laneId", target.laneId); + navigate(`/prs${params.toString() ? `?${params.toString()}` : ""}`); + return; + } + if (target.kind === "route") { + navigate(target.route.startsWith("/") ? target.route : `/${target.route}`); + } + }); + }, [navigate]); + return null; +} + export function App() { const theme = useAppStore((s) => s.theme); const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); @@ -317,6 +355,7 @@ export function App() { <Router> <div data-theme={theme} className="h-full bg-bg text-fg font-sans antialiased selection:bg-accent/30"> <OnboardingBootstrap /> + <AppNavigationBridge /> <Routes> <Route path="/startup" element={<Navigate to="/work" replace />} /> <Route element={<ShellLayout />}> diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 7825b89c1..09b781a69 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -31,6 +31,7 @@ import type { OnboardingStatus, PrEventPayload, ProjectInfo, + OpenProjectBinding, TerminalSessionSummary, } from "../../../shared/types"; import { @@ -238,6 +239,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const navigate = useNavigate(); const setProject = useAppStore((s) => s.setProject); const setProjectHydrated = useAppStore((s) => s.setProjectHydrated); + const setProjectBinding = useAppStore((s) => s.setProjectBinding); const refreshLanes = useAppStore((s) => s.refreshLanes); const refreshProviderMode = useAppStore((s) => s.refreshProviderMode); const refreshKeybindings = useAppStore((s) => s.refreshKeybindings); @@ -365,19 +367,36 @@ export function AppShell({ children }: { children: React.ReactNode }) { } }; - const applyProjectState = (nextProject: ProjectInfo | null) => { - const nextProjectRoot = nextProject?.rootPath ?? null; + const applyProjectState = (nextProject: ProjectInfo | null, nextBinding?: OpenProjectBinding | null) => { + const remoteBinding = nextBinding?.kind === "remote" ? nextBinding : null; + const nextProjectRoot = remoteBinding?.rootPath ?? nextProject?.rootPath ?? null; const currentProjectRoot = useAppStore.getState().project?.rootPath ?? null; const currentShowWelcome = useAppStore.getState().showWelcome; const currentIsNewTabOpen = useAppStore.getState().isNewTabOpen; - const hasStoredProject = Boolean(nextProject); + const hasStoredProject = Boolean(nextProject || remoteBinding); const projectChanged = nextProjectRoot !== currentProjectRoot; const welcomeChanged = currentShowWelcome === hasStoredProject; + if (remoteBinding) { + setProject({ + rootPath: remoteBinding.rootPath, + displayName: remoteBinding.displayName, + baseRef: "main", + }); + setProjectBinding(remoteBinding); + setShowWelcome(false); + clearScheduledRefreshes(); + void refreshLanes({ includeStatus: false }); + return; + } + if (currentIsNewTabOpen && nextProject && !projectChanged) { setProject(nextProject); - if (currentShowWelcome) setShowWelcome(false); + setProjectBinding(nextBinding ?? null); + // Leave showWelcome alone — the user explicitly opened the new-tab + // UI; a stale project-changed event for the same root must not kick + // them back to the project content. return; } @@ -386,6 +405,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { setShowWelcome(false); } else { setProject(null); + setProjectBinding(null); setShowWelcome(true); } @@ -422,9 +442,9 @@ export function AppShell({ children }: { children: React.ReactNode }) { const initializeProjectState = async () => { setProjectHydrated(false); try { - const nextProject = await window.ade.app.getProject(); + const session = await window.ade.app.getWindowSession(); if (cancelled) return; - applyProjectState(nextProject); + applyProjectState(session.project, session.binding); } catch { if (cancelled) return; setProject(null); @@ -458,15 +478,24 @@ export function AppShell({ children }: { children: React.ReactNode }) { applyProjectState(nextProject); setProjectHydrated(true); }); + const disposeProjectBindingChanged = window.ade.app.onProjectBindingChanged((binding) => { + const state = useAppStore.getState(); + if (state.projectTransition) return; + setProjectHydrated(false); + applyProjectState(binding?.kind === "local" ? state.project : null, binding); + setProjectHydrated(true); + }); void initializeProjectState(); return () => { cancelled = true; clearScheduledRefreshes(); disposeProjectChanged(); + disposeProjectBindingChanged(); }; }, [ setProject, + setProjectBinding, setProjectHydrated, refreshLanes, refreshProviderMode, diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx index 7bf29ce5a..f2f132393 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx @@ -91,12 +91,8 @@ describe("CommandPalette", () => { render( <MemoryRouter> - <CommandPalette - open - intent="project-browse" - onOpenChange={vi.fn()} - /> - </MemoryRouter> + <CommandPalette open intent="project-browse" onOpenChange={vi.fn()} /> + </MemoryRouter>, ); await waitFor(() => { @@ -107,7 +103,9 @@ describe("CommandPalette", () => { }); }); - expect(await screen.findByRole("button", { name: /open directory/i })).toBeTruthy(); + expect( + await screen.findByRole("button", { name: /open directory/i }), + ).toBeTruthy(); expect(screen.getByText("Versic")).toBeTruthy(); }); @@ -127,12 +125,8 @@ describe("CommandPalette", () => { render( <MemoryRouter> - <CommandPalette - open - intent="project-browse" - onOpenChange={vi.fn()} - /> - </MemoryRouter> + <CommandPalette open intent="project-browse" onOpenChange={vi.fn()} /> + </MemoryRouter>, ); await waitFor(() => { @@ -142,7 +136,9 @@ describe("CommandPalette", () => { limit: 200, }); }); - const button = await screen.findByRole("button", { name: /open directory/i }); + const button = await screen.findByRole("button", { + name: /open directory/i, + }); fireEvent.click(button); await waitFor(() => { @@ -151,7 +147,7 @@ describe("CommandPalette", () => { defaultPath: "/Users/admin/Projects", }); expect(switchProjectToPath).toHaveBeenCalledWith( - "/Users/admin/Projects/Versic" + "/Users/admin/Projects/Versic", ); }); }); @@ -175,11 +171,13 @@ describe("CommandPalette", () => { intent="project-browse" onOpenChange={onOpenChange} /> - </MemoryRouter> + </MemoryRouter>, ); await waitFor(() => { - expect(document.querySelector('[data-tour="project.browser"]')).toBeTruthy(); + expect( + document.querySelector('[data-tour="project.browser"]'), + ).toBeTruthy(); }); window.dispatchEvent(new CustomEvent(PROJECT_BROWSER_CLOSE_EVENT)); @@ -222,12 +220,8 @@ describe("CommandPalette", () => { render( <MemoryRouter> - <CommandPalette - open - intent="project-browse" - onOpenChange={vi.fn()} - /> - </MemoryRouter> + <CommandPalette open intent="project-browse" onOpenChange={vi.fn()} /> + </MemoryRouter>, ); await waitFor(() => { @@ -237,7 +231,9 @@ describe("CommandPalette", () => { limit: 200, }); }); - const inputs = await screen.findAllByPlaceholderText(/paste a path, type to filter, or drop a folder anywhere/i); + const inputs = await screen.findAllByPlaceholderText( + /paste a path, type to filter, or drop a folder anywhere/i, + ); const input = inputs.at(-1) as HTMLInputElement; fireEvent.drop(input, { dataTransfer: { files: [new File(["stale"], "stale")] }, @@ -266,9 +262,155 @@ describe("CommandPalette", () => { }); await waitFor(() => { - expect(switchProjectToPath).toHaveBeenCalledWith("/Users/admin/Projects/FreshFolder"); + expect(switchProjectToPath).toHaveBeenCalledWith( + "/Users/admin/Projects/FreshFolder", + ); expect(switchProjectToPath).toHaveBeenCalledTimes(1); expect(browseDirectories).toHaveBeenCalledTimes(3); }); }); + + it("warns before opening a remote project when matching local work is dirty", async () => { + const switchRemoteProject = vi.fn(async () => {}); + seedStore({ + projectBinding: null, + switchRemoteProject, + }); + const remoteProject = { + projectId: "project-remote-ade", + rootPath: "/remote/ADE", + displayName: "ADE", + addedAt: 1, + lastOpenedAt: 2, + gitOriginUrl: "git@github.com:example/ade.git", + }; + const remoteRuntime = { + getConnectionSnapshot: vi.fn(async () => ({ + connectedCount: 1, + updatedAt: Date.now(), + connections: [ + { + target: { + id: "target-1", + name: "Mac Studio", + hostname: "studio.tailnet.ts.net", + sshUser: "admin", + port: 22, + sshKeyPath: null, + lastSeenArch: "darwin-arm64", + runtimeBinaryVersion: "1.0.0", + lastConnectedAt: Date.now(), + }, + state: "connected", + arch: "darwin-arm64", + version: "1.0.0", + projects: [], + lastError: null, + lastAttemptedAt: Date.now(), + connectedAt: Date.now(), + }, + ], + })), + onConnectionSnapshotChanged: vi.fn(() => () => {}), + browseDirectories: vi.fn(async () => ({ + inputPath: "~/", + resolvedPath: "/remote/ADE", + directoryPath: "/remote/ADE", + parentPath: "/remote", + exactDirectoryPath: "/remote/ADE", + openableProjectRoot: "/remote/ADE", + entries: [], + })), + getProjectDetail: vi.fn(async () => ({ + rootPath: "/remote/ADE", + isGitRepo: true, + branchName: "main", + dirtyCount: 0, + aheadBehind: null, + lastCommit: null, + readmeExcerpt: null, + languages: [], + laneCount: null, + lastOpenedAt: null, + subdirectoryCount: null, + })), + addProject: vi.fn(async () => remoteProject), + checkLocalWork: vi.fn(async () => ({ + remoteProjectId: remoteProject.projectId, + remoteDisplayName: remoteProject.displayName, + remoteGitOriginUrl: remoteProject.gitOriginUrl, + hasDirtyWork: true, + matches: [ + { + rootPath: "/Users/admin/Projects/ADE", + displayName: "ADE", + gitOriginUrl: "git@github.com:example/ade.git", + dirtyCount: 3, + workSummary: { + rootPath: "/Users/admin/Projects/ADE", + laneCount: 1, + checkedLaneCount: 1, + dirtyLaneCount: 1, + dirtyFileCount: 3, + primaryDirtyCount: 3, + lanes: [ + { + rootPath: "/Users/admin/Projects/ADE", + name: "main", + branchName: "main", + dirtyCount: 3, + isPrimary: true, + }, + ], + }, + }, + ], + })), + }; + globalThis.window.ade = { + ...globalThis.window.ade, + remoteRuntime, + } as any; + + render( + <MemoryRouter> + <CommandPalette open intent="project-add" onOpenChange={vi.fn()} /> + </MemoryRouter>, + ); + + const machineButton = await screen.findByRole("button", { + name: /Mac Studio/i, + }); + fireEvent.click(machineButton); + fireEvent.click(await screen.findByRole("button", { name: /OPEN/i })); + + await waitFor(() => + expect(remoteRuntime.browseDirectories).toHaveBeenCalledWith("target-1", { + partialPath: "~/", + cwd: null, + limit: 200, + }), + ); + fireEvent.click(await screen.findByRole("button", { name: /Open ADE/i })); + + await waitFor(() => + expect( + screen.getByRole("dialog", { + name: "You already work on this repo locally", + }), + ).toBeTruthy(), + ); + expect(screen.getAllByText("Changes").length).toBeGreaterThan(0); + expect(screen.getByTitle(/Primary.*3 files/)).toBeTruthy(); + expect(screen.getAllByTitle("/Users/admin/Projects/ADE").length).toBeGreaterThan(0); + expect(switchRemoteProject).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Open on Mac Studio" })); + await waitFor(() => + expect(switchRemoteProject).toHaveBeenCalledWith( + "target-1", + "project-remote-ade", + ), + ); + }); }); diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index f226e3b1a..ec4634e16 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import * as Dialog from "@radix-ui/react-dialog"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -7,6 +13,7 @@ import { ArrowRight, CircleNotch, Clock, + DesktopTower, Folder, FolderOpen, GitBranch, @@ -17,7 +24,15 @@ import { } from "@phosphor-icons/react"; import { motion, AnimatePresence } from "motion/react"; import { useNavigate } from "react-router-dom"; -import type { ProjectBrowseResult, ProjectDetail } from "../../../shared/types"; +import type { + ProjectBrowseInput, + ProjectBrowseResult, + ProjectDetail, + RemoteRuntimeConnectionSnapshot, + RemoteRuntimeConnectionStatus, + RemoteRuntimeLocalWorkCheckResult, + RemoteRuntimeProjectRecord, +} from "../../../shared/types"; import { extractError } from "../../lib/format"; import { fadeScale } from "../../lib/motion"; import { PROJECT_BROWSER_CLOSE_EVENT } from "../../lib/projectBrowserEvents"; @@ -28,13 +43,16 @@ import { AddProjectChooser } from "../projects/AddProjectChooser"; import { CloneProjectForm } from "../projects/CloneProjectForm"; import { CreateProjectForm } from "../projects/CreateProjectForm"; import { ProjectActionSuccess } from "../projects/ProjectActionSuccess"; +import { RemoteProjectOpenDialog } from "../projects/RemoteProjectOpenDialog"; +import { RemoteTargetList } from "../remoteTargets/RemoteTargetList"; export type CommandPaletteIntent = | "default" | "project-browse" | "project-add" | "project-create" - | "project-clone"; + | "project-clone" + | "project-remote"; type CommandPaletteMode = CommandPaletteIntent | "project-success"; @@ -42,6 +60,15 @@ type ProjectActionOutcome = { verb: "Created" | "Cloned"; displayName: string; rootPath: string; + location: ProjectLocation; + projectId?: string; +}; + +type PendingRemoteProjectOpen = { + targetId: string; + runtimeName: string; + project: RemoteRuntimeProjectRecord; + localWork: RemoteRuntimeLocalWorkCheckResult; }; type Command = { @@ -63,11 +90,23 @@ type BrowseRow = { isGitRepo: boolean; }; +type ProjectLocation = + | { kind: "local"; id: "local"; name: string } + | { kind: "remote"; targetId: string; name: string }; + +const LOCAL_PROJECT_LOCATION: ProjectLocation = { + kind: "local", + id: "local", + name: "This Mac", +}; + function stripTrailingSeparator(input: string): string { if (input.length <= 1) return input; if (/^[a-z]:[\\/]$/i.test(input)) return input; if (/^[/\\]{2}[^/\\]+[/\\][^/\\]+[/\\]?$/i.test(input)) return input; - return input.endsWith("/") || input.endsWith("\\") ? input.slice(0, -1) : input; + return input.endsWith("/") || input.endsWith("\\") + ? input.slice(0, -1) + : input; } function relativeFromNow(iso: string | null | undefined): string | null { @@ -158,16 +197,23 @@ export function CommandPalette({ const lanes = useAppStore((s) => s.lanes); const selectedLaneId = useAppStore((s) => s.selectedLaneId); const project = useAppStore((s) => s.project); + const projectBinding = useAppStore((s) => s.projectBinding); const selectLane = useAppStore((s) => s.selectLane); const switchProjectToPath = useAppStore((s) => s.switchProjectToPath); + const switchRemoteProject = useAppStore((s) => s.switchRemoteProject); const hasActiveProject = Boolean(project?.rootPath); const [mode, setMode] = useState<CommandPaletteMode>("default"); - const [actionOutcome, setActionOutcome] = useState<ProjectActionOutcome | null>(null); + const [actionOutcome, setActionOutcome] = + useState<ProjectActionOutcome | null>(null); const [q, setQ] = useState(""); const [selectedIdx, setSelectedIdx] = useState(0); - const [browseInput, setBrowseInput] = useState(defaultBrowseInput(project?.rootPath)); - const [browseResult, setBrowseResult] = useState<ProjectBrowseResult | null>(null); + const [browseInput, setBrowseInput] = useState( + defaultBrowseInput(project?.rootPath), + ); + const [browseResult, setBrowseResult] = useState<ProjectBrowseResult | null>( + null, + ); const [browseSelectedIdx, setBrowseSelectedIdx] = useState(0); const [browseLoading, setBrowseLoading] = useState(false); const [browseError, setBrowseError] = useState<string | null>(null); @@ -177,26 +223,114 @@ export function CommandPalette({ const [detailLoading, setDetailLoading] = useState(false); const [detailPath, setDetailPath] = useState<string | null>(null); const [isDragging, setIsDragging] = useState(false); + const [selectedProjectLocation, setSelectedProjectLocation] = + useState<ProjectLocation | null>(null); + const [remoteSnapshot, setRemoteSnapshot] = + useState<RemoteRuntimeConnectionSnapshot | null>(null); + const [pendingRemoteOpen, setPendingRemoteOpen] = + useState<PendingRemoteProjectOpen | null>(null); + const [openingPendingRemote, setOpeningPendingRemote] = useState(false); const listRef = useRef<HTMLUListElement>(null); const browseRequestRef = useRef(0); const detailRequestRef = useRef(0); const dragCounterRef = useRef(0); + const openIntentRef = useRef<{ + open: boolean; + intent: CommandPaletteIntent; + } | null>(null); + + const remoteLocations = useMemo( + () => + (remoteSnapshot?.connections ?? []) + .filter((connection) => connection.state === "connected") + .map( + ( + connection, + ): ProjectLocation & { status: RemoteRuntimeConnectionStatus } => ({ + kind: "remote", + targetId: connection.target.id, + name: connection.target.name, + status: connection, + }), + ), + [remoteSnapshot], + ); + + const activeProjectLocation = + selectedProjectLocation ?? LOCAL_PROJECT_LOCATION; + const activeRemoteTargetId = + activeProjectLocation.kind === "remote" + ? activeProjectLocation.targetId + : null; + const activeBrowseRoot = activeRemoteTargetId + ? projectBinding?.kind === "remote" && + projectBinding.targetId === activeRemoteTargetId + ? projectBinding.rootPath + : null + : (project?.rootPath ?? null); + const browseMachineName = activeProjectLocation.name; + + const browseDirectoriesForActiveLocation = useCallback( + (input: ProjectBrowseInput) => + activeRemoteTargetId + ? window.ade.remoteRuntime.browseDirectories( + activeRemoteTargetId, + input, + ) + : window.ade.project.browseDirectories(input), + [activeRemoteTargetId], + ); + + const getProjectDetailForActiveLocation = useCallback( + (rootPath: string) => + activeRemoteTargetId + ? window.ade.remoteRuntime.getProjectDetail( + activeRemoteTargetId, + rootPath, + ) + : window.ade.project.getDetail(rootPath), + [activeRemoteTargetId], + ); + + useEffect(() => { + if (!open) return; + const remoteRuntime = window.ade.remoteRuntime; + if (!remoteRuntime?.getConnectionSnapshot) return; + let cancelled = false; + void remoteRuntime + .getConnectionSnapshot() + .then((snapshot) => { + if (!cancelled) setRemoteSnapshot(snapshot); + }) + .catch(() => { + if (!cancelled) setRemoteSnapshot(null); + }); + const unsubscribe = + remoteRuntime.onConnectionSnapshotChanged?.((snapshot) => { + if (!cancelled) setRemoteSnapshot(snapshot); + }) ?? (() => {}); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [open]); const startProjectBrowse = useCallback(() => { setMode("project-browse"); setQ(""); setSelectedIdx(0); - setBrowseInput(defaultBrowseInput(project?.rootPath)); + setBrowseInput(defaultBrowseInput(activeBrowseRoot)); setBrowseResult(null); setBrowseError(null); setBrowseSelectedIdx(0); - }, [project?.rootPath]); + }, [activeBrowseRoot]); const startProjectAdd = useCallback(() => { setMode("project-add"); setQ(""); setActionOutcome(null); + setSelectedProjectLocation(null); }, []); const startProjectCreate = useCallback(() => { @@ -209,7 +343,18 @@ export function CommandPalette({ setActionOutcome(null); }, []); + const startProjectRemote = useCallback(() => { + setMode("project-remote"); + setActionOutcome(null); + }, []); + useEffect(() => { + const previous = openIntentRef.current; + const changed = + previous == null || previous.open !== open || previous.intent !== intent; + openIntentRef.current = { open, intent }; + if (!changed) return; + if (!open) { setMode("default"); setQ(""); @@ -219,6 +364,9 @@ export function CommandPalette({ setOpenProjectPending(false); setSystemPickerPending(false); setActionOutcome(null); + setSelectedProjectLocation(null); + setPendingRemoteOpen(null); + setOpeningPendingRemote(false); return; } @@ -242,11 +390,24 @@ export function CommandPalette({ return; } + if (intent === "project-remote") { + startProjectRemote(); + return; + } + setMode("default"); setQ(""); setSelectedIdx(0); setBrowseError(null); - }, [intent, open, startProjectAdd, startProjectBrowse, startProjectClone, startProjectCreate]); + }, [ + intent, + open, + startProjectAdd, + startProjectBrowse, + startProjectClone, + startProjectCreate, + startProjectRemote, + ]); useEffect(() => { if (!open || mode !== "project-browse") return; @@ -254,7 +415,8 @@ export function CommandPalette({ onOpenChange(false); }; window.addEventListener(PROJECT_BROWSER_CLOSE_EVENT, closeBrowser); - return () => window.removeEventListener(PROJECT_BROWSER_CLOSE_EVENT, closeBrowser); + return () => + window.removeEventListener(PROJECT_BROWSER_CLOSE_EVENT, closeBrowser); }, [mode, onOpenChange, open]); const commands: Command[] = useMemo(() => { @@ -283,21 +445,126 @@ export function CommandPalette({ closeOnRun: false, run: startProjectClone, }, - { id: "go-project", title: "Go to Run", shortcut: "G 1", group: "Navigation", run: () => navigate("/project") }, - { id: "go-lanes", title: "Go to Lanes", shortcut: "G L", group: "Navigation", run: () => navigate("/lanes") }, - { id: "go-files", title: "Go to Files", shortcut: "G F", group: "Navigation", run: () => navigate("/files") }, - { id: "go-work", title: "Go to Work", shortcut: "G T", group: "Navigation", run: () => navigate("/work") }, - { id: "go-graph", title: "Go to Graph", shortcut: "G G", group: "Navigation", run: () => navigate("/graph") }, - { id: "go-prs", title: "Go to PRs", shortcut: "G R", group: "Navigation", run: () => navigate(readStoredPrsRoute(project?.rootPath) ?? "/prs") }, - { id: "go-history", title: "Go to History", shortcut: "G H", group: "Navigation", run: () => navigate("/history") }, - { id: "go-missions", title: "Go to Missions", shortcut: "G M", group: "Navigation", run: () => navigate("/missions") }, - { id: "go-automations", title: "Go to Automations", hint: "Automation rules and agent workflows", group: "Navigation", run: () => navigate("/automations") }, - { id: "go-settings", title: "Go to Settings", shortcut: "G S", group: "Navigation", run: () => navigate("/settings") }, - { id: "go-settings-general", title: "Go to General Settings", hint: "Setup reminder, app info", group: "Settings", run: () => navigate("/settings?tab=general") }, - { id: "go-settings-appearance", title: "Go to Appearance", hint: "Theme, chat font size, chat notifications", group: "Settings", run: () => navigate("/settings?tab=appearance") }, - { id: "go-settings-ai", title: "Go to AI Settings", hint: "Providers, models, AI defaults", group: "Settings", run: () => navigate("/settings?tab=ai") }, - { id: "go-settings-integrations", title: "Go to Integrations", hint: "GitHub, Linear, computer use", group: "Settings", run: () => navigate("/settings?tab=integrations") }, - { id: "go-settings-workspace", title: "Go to Workspace Settings", hint: "Project health and docs generation", group: "Settings", run: () => navigate("/settings?tab=workspace") }, + { + id: "project-remote", + title: "Connect to remote machine", + hint: "Register an SSH target and list its ADE projects", + group: "Projects", + closeOnRun: false, + run: startProjectRemote, + }, + { + id: "go-project", + title: "Go to Run", + shortcut: "G 1", + group: "Navigation", + run: () => navigate("/project"), + }, + { + id: "go-lanes", + title: "Go to Lanes", + shortcut: "G L", + group: "Navigation", + run: () => navigate("/lanes"), + }, + { + id: "go-files", + title: "Go to Files", + shortcut: "G F", + group: "Navigation", + run: () => navigate("/files"), + }, + { + id: "go-work", + title: "Go to Work", + shortcut: "G T", + group: "Navigation", + run: () => navigate("/work"), + }, + { + id: "go-graph", + title: "Go to Graph", + shortcut: "G G", + group: "Navigation", + run: () => navigate("/graph"), + }, + { + id: "go-prs", + title: "Go to PRs", + shortcut: "G R", + group: "Navigation", + run: () => navigate(readStoredPrsRoute(project?.rootPath) ?? "/prs"), + }, + { + id: "go-history", + title: "Go to History", + shortcut: "G H", + group: "Navigation", + run: () => navigate("/history"), + }, + { + id: "go-missions", + title: "Go to Missions", + shortcut: "G M", + group: "Navigation", + run: () => navigate("/missions"), + }, + { + id: "go-automations", + title: "Go to Automations", + hint: "Automation rules and agent workflows", + group: "Navigation", + run: () => navigate("/automations"), + }, + { + id: "go-settings", + title: "Go to Settings", + shortcut: "G S", + group: "Navigation", + run: () => navigate("/settings"), + }, + { + id: "go-settings-general", + title: "Go to General Settings", + hint: "Setup reminder, app info", + group: "Settings", + run: () => navigate("/settings?tab=general"), + }, + { + id: "go-settings-appearance", + title: "Go to Appearance", + hint: "Theme, chat font size, chat notifications", + group: "Settings", + run: () => navigate("/settings?tab=appearance"), + }, + { + id: "go-settings-ai", + title: "Go to AI Settings", + hint: "Providers, models, AI defaults", + group: "Settings", + run: () => navigate("/settings?tab=ai"), + }, + { + id: "go-settings-integrations", + title: "Go to Integrations", + hint: "GitHub, Linear, computer use", + group: "Settings", + run: () => navigate("/settings?tab=integrations"), + }, + { + id: "go-settings-workspace", + title: "Go to Workspace Settings", + hint: "Project health and docs generation", + group: "Settings", + run: () => navigate("/settings?tab=workspace"), + }, + { + id: "go-settings-usage", + title: "Go to Usage", + hint: "Token usage, cost breakdown", + group: "Settings", + run: () => navigate("/settings?tab=usage"), + }, { id: "action-create-lane", title: "Create Lane", @@ -333,8 +600,11 @@ export function CommandPalette({ group: "Lanes", run: () => { if (!lanes.length) return; - const currentIdx = lanes.findIndex((lane) => lane.id === selectedLaneId); - const nextLane = lanes[(currentIdx + 1 + lanes.length) % lanes.length]; + const currentIdx = lanes.findIndex( + (lane) => lane.id === selectedLaneId, + ); + const nextLane = + lanes[(currentIdx + 1 + lanes.length) % lanes.length]; if (!nextLane) return; selectLane(nextLane.id); navigate(`/lanes?laneId=${encodeURIComponent(nextLane.id)}`); @@ -347,8 +617,11 @@ export function CommandPalette({ group: "Lanes", run: () => { if (!lanes.length) return; - const currentIdx = lanes.findIndex((lane) => lane.id === selectedLaneId); - const nextLane = lanes[(currentIdx - 1 + lanes.length) % lanes.length]; + const currentIdx = lanes.findIndex( + (lane) => lane.id === selectedLaneId, + ); + const nextLane = + lanes[(currentIdx - 1 + lanes.length) % lanes.length]; if (!nextLane) return; selectLane(nextLane.id); navigate(`/lanes?laneId=${encodeURIComponent(nextLane.id)}`); @@ -373,7 +646,7 @@ export function CommandPalette({ { id: "ping", title: "Ping preload bridge", - hint: "Expect \"pong\"", + hint: 'Expect "pong"', group: "Debug", run: async () => { await window.ade.app.ping(); @@ -387,6 +660,7 @@ export function CommandPalette({ command.id === "project-browse" || command.id === "project-create" || command.id === "project-clone" || + command.id === "project-remote" || command.id === "go-project" || command.id === "ping", ); @@ -403,13 +677,16 @@ export function CommandPalette({ startProjectBrowse, startProjectClone, startProjectCreate, + startProjectRemote, ]); const filtered = useMemo(() => { const needle = q.trim().toLowerCase(); if (!needle) return commands; - return commands.filter((command) => - command.title.toLowerCase().includes(needle) || (command.hint ?? "").toLowerCase().includes(needle) + return commands.filter( + (command) => + command.title.toLowerCase().includes(needle) || + (command.hint ?? "").toLowerCase().includes(needle), ); }, [commands, q]); @@ -455,31 +732,44 @@ export function CommandPalette({ }, [browseResult]); const openableProjectRoot = browseResult?.openableProjectRoot ?? null; - const isCurrentProjectTarget = Boolean(openableProjectRoot && project?.rootPath === openableProjectRoot); - const canOpenProject = Boolean(openableProjectRoot) && !isCurrentProjectTarget; + const isCurrentProjectTarget = Boolean( + openableProjectRoot && activeBrowseRoot === openableProjectRoot, + ); + const canOpenProject = + Boolean(openableProjectRoot) && !isCurrentProjectTarget; const openProjectLabel = isCurrentProjectTarget ? "Already open" : "Open"; - const highlightedRow = browseSelectedIdx >= 0 ? (browseRows[browseSelectedIdx] ?? null) : null; + const highlightedRow = + browseSelectedIdx >= 0 ? (browseRows[browseSelectedIdx] ?? null) : null; const highlightedPath = useMemo(() => { if (highlightedRow && highlightedRow.kind === "directory") { return stripTrailingSeparator(highlightedRow.path); } if (openableProjectRoot) return openableProjectRoot; - if (browseResult?.exactDirectoryPath) return browseResult.exactDirectoryPath; + if (browseResult?.exactDirectoryPath) + return browseResult.exactDirectoryPath; return null; }, [browseResult?.exactDirectoryPath, highlightedRow, openableProjectRoot]); - const highlightedIsRepo = highlightedRow?.kind === "directory" - ? highlightedRow.isGitRepo - : Boolean(openableProjectRoot && highlightedPath && highlightedPath === openableProjectRoot); + const highlightedIsRepo = + highlightedRow?.kind === "directory" + ? highlightedRow.isGitRepo + : Boolean( + openableProjectRoot && + highlightedPath && + highlightedPath === openableProjectRoot, + ); const detailTarget = highlightedPath; - const openTarget = highlightedIsRepo && highlightedRow?.kind === "directory" && highlightedPath - ? highlightedPath - : openableProjectRoot; + const openTarget = + highlightedIsRepo && highlightedRow?.kind === "directory" && highlightedPath + ? highlightedPath + : openableProjectRoot; const openTargetLabel = openTarget ? pathLabel(openTarget) : null; - const canOpenHighlighted = Boolean(openTarget) && openTarget !== project?.rootPath; - const isMac = typeof navigator !== "undefined" && /mac/i.test(navigator.platform); + const canOpenHighlighted = + Boolean(openTarget) && openTarget !== activeBrowseRoot; + const isMac = + typeof navigator !== "undefined" && /mac/i.test(navigator.platform); const openShortcutLabel = `${isMac ? "⌘" : "Ctrl"}↵`; useEffect(() => { @@ -488,16 +778,26 @@ export function CommandPalette({ setBrowseLoading(true); setBrowseError(null); const timeout = globalThis.setTimeout(() => { - void window.ade.project - .browseDirectories({ - partialPath: browseInput, - cwd: project?.rootPath ?? null, - limit: 200, - }) + void Promise.resolve() + .then(() => + browseDirectoriesForActiveLocation({ + partialPath: browseInput, + cwd: activeBrowseRoot, + limit: 200, + }), + ) .then((result) => { if (browseRequestRef.current !== requestId) return; + if (!result) + throw new Error("Project browser did not return a result."); setBrowseResult(result); - setBrowseSelectedIdx(result.openableProjectRoot ? -1 : (result.parentPath || result.entries.length > 0 ? 0 : -1)); + setBrowseSelectedIdx( + result.openableProjectRoot + ? -1 + : result.parentPath || result.entries.length > 0 + ? 0 + : -1, + ); }) .catch((error) => { if (browseRequestRef.current !== requestId) return; @@ -513,7 +813,13 @@ export function CommandPalette({ return () => { globalThis.clearTimeout(timeout); }; - }, [browseInput, mode, open, project?.rootPath]); + }, [ + activeBrowseRoot, + browseDirectoriesForActiveLocation, + browseInput, + mode, + open, + ]); useEffect(() => { if (mode !== "default") return; @@ -544,7 +850,7 @@ export function CommandPalette({ setDetailPath(detailTarget); const timeout = globalThis.setTimeout(() => { void Promise.resolve() - .then(() => window.ade.project.getDetail(detailTarget)) + .then(() => getProjectDetailForActiveLocation(detailTarget)) .then((result) => { if (detailRequestRef.current !== requestId) return; setDetail(result); @@ -561,7 +867,14 @@ export function CommandPalette({ return () => { globalThis.clearTimeout(timeout); }; - }, [detail, detailTarget, highlightedIsRepo, mode, open]); + }, [ + detail, + detailTarget, + getProjectDetailForActiveLocation, + highlightedIsRepo, + mode, + open, + ]); useEffect(() => { if (mode !== "project-browse") return; @@ -573,12 +886,18 @@ export function CommandPalette({ setBrowseSelectedIdx(-1); return; } - if (!openableProjectRoot && browseSelectedIdx < 0 && browseRows.length > 0) { + if ( + !openableProjectRoot && + browseSelectedIdx < 0 && + browseRows.length > 0 + ) { setBrowseSelectedIdx(0); return; } if (browseSelectedIdx >= browseRows.length) { - setBrowseSelectedIdx(openableProjectRoot ? -1 : Math.max(0, browseRows.length - 1)); + setBrowseSelectedIdx( + openableProjectRoot ? -1 : Math.max(0, browseRows.length - 1), + ); } }, [browseRows.length, browseSelectedIdx, mode, openableProjectRoot]); @@ -586,7 +905,10 @@ export function CommandPalette({ if (!listRef.current || idx < 0) return; const items = listRef.current.querySelectorAll("[data-cmd-item]"); const target = items[idx]; - if (target instanceof HTMLElement && typeof target.scrollIntoView === "function") { + if ( + target instanceof HTMLElement && + typeof target.scrollIntoView === "function" + ) { target.scrollIntoView({ block: "nearest" }); } }, []); @@ -610,7 +932,7 @@ export function CommandPalette({ console.error("Command palette command failed", error); }); }, - [onOpenChange] + [onOpenChange], ); const activateBrowseRow = useCallback((row: BrowseRow) => { @@ -620,12 +942,38 @@ export function CommandPalette({ const handleOpenProject = useCallback( async (targetPath: string | null | undefined) => { - const nextTarget = typeof targetPath === "string" ? targetPath.trim() : ""; + const nextTarget = + typeof targetPath === "string" ? targetPath.trim() : ""; if (!nextTarget) return; setBrowseError(null); setOpenProjectPending(true); try { - await switchProjectToPath(nextTarget); + if (activeRemoteTargetId) { + const remoteProject = await window.ade.remoteRuntime.addProject( + activeRemoteTargetId, + nextTarget, + ); + const localWork = + await window.ade.remoteRuntime.checkLocalWork( + activeRemoteTargetId, + remoteProject, + ); + if (localWork.hasDirtyWork) { + setPendingRemoteOpen({ + targetId: activeRemoteTargetId, + runtimeName: browseMachineName, + project: remoteProject, + localWork, + }); + return; + } + await switchRemoteProject( + activeRemoteTargetId, + remoteProject.projectId, + ); + } else { + await switchProjectToPath(nextTarget); + } onOpenChange(false); } catch (error) { setBrowseError(extractError(error)); @@ -633,16 +981,43 @@ export function CommandPalette({ setOpenProjectPending(false); } }, - [onOpenChange, switchProjectToPath] + [ + activeRemoteTargetId, + browseMachineName, + onOpenChange, + switchProjectToPath, + switchRemoteProject, + ], ); + const confirmPendingRemoteOpen = useCallback(async () => { + if (!pendingRemoteOpen) return; + setOpeningPendingRemote(true); + setBrowseError(null); + try { + await switchRemoteProject( + pendingRemoteOpen.targetId, + pendingRemoteOpen.project.projectId, + ); + setPendingRemoteOpen(null); + onOpenChange(false); + } catch (error) { + setBrowseError(extractError(error)); + } finally { + setOpeningPendingRemote(false); + } + }, [onOpenChange, pendingRemoteOpen, switchRemoteProject]); + const handleChooseInSystemPicker = useCallback(async () => { setBrowseError(null); setSystemPickerPending(true); try { const selected = await window.ade.project.chooseDirectory({ title: "Open project", - defaultPath: browseResult?.exactDirectoryPath ?? browseResult?.directoryPath ?? undefined, + defaultPath: + browseResult?.exactDirectoryPath ?? + browseResult?.directoryPath ?? + undefined, }); if (!selected) return; await handleOpenProject(selected); @@ -651,7 +1026,11 @@ export function CommandPalette({ } finally { setSystemPickerPending(false); } - }, [browseResult?.directoryPath, browseResult?.exactDirectoryPath, handleOpenProject]); + }, [ + browseResult?.directoryPath, + browseResult?.exactDirectoryPath, + handleOpenProject, + ]); const handleDefaultKeyDown = useCallback( (event: React.KeyboardEvent) => { @@ -664,7 +1043,9 @@ export function CommandPalette({ if (event.key === "ArrowUp") { if (filtered.length === 0) return; event.preventDefault(); - setSelectedIdx((prev) => (prev - 1 + filtered.length) % filtered.length); + setSelectedIdx( + (prev) => (prev - 1 + filtered.length) % filtered.length, + ); return; } if (event.key === "Enter") { @@ -674,7 +1055,7 @@ export function CommandPalette({ runCommand(command); } }, - [filtered, runCommand, selectedIdx] + [filtered, runCommand, selectedIdx], ); const handleBrowseKeyDown = useCallback( @@ -714,7 +1095,15 @@ export function CommandPalette({ } } }, - [activateBrowseRow, browseRows, browseSelectedIdx, canOpenProject, handleOpenProject, openTarget, openableProjectRoot] + [ + activateBrowseRow, + browseRows, + browseSelectedIdx, + canOpenProject, + handleOpenProject, + openTarget, + openableProjectRoot, + ], ); const handleDragEnter = useCallback((event: React.DragEvent) => { @@ -752,19 +1141,23 @@ export function CommandPalette({ const requestId = ++browseRequestRef.current; setBrowseLoading(true); setBrowseError(null); - void window.ade.project - .browseDirectories({ - partialPath: nextBrowseInput, - cwd: project?.rootPath ?? null, - limit: 200, - }) + void Promise.resolve() + .then(() => + browseDirectoriesForActiveLocation({ + partialPath: nextBrowseInput, + cwd: activeBrowseRoot, + limit: 200, + }), + ) .then((result) => { if (browseRequestRef.current !== requestId) return; + if (!result) + throw new Error("Project browser did not return a result."); const nextTarget = - result.openableProjectRoot - ?? result.exactDirectoryPath - ?? result.directoryPath - ?? droppedPath; + result.openableProjectRoot ?? + result.exactDirectoryPath ?? + result.directoryPath ?? + droppedPath; if (nextTarget) { void handleOpenProject(nextTarget); return; @@ -780,7 +1173,7 @@ export function CommandPalette({ setBrowseLoading(false); }); }, - [handleOpenProject, project?.rootPath] + [activeBrowseRoot, browseDirectoriesForActiveLocation, handleOpenProject], ); const isBrowsing = mode === "project-browse"; @@ -788,35 +1181,47 @@ export function CommandPalette({ mode === "project-add" || mode === "project-create" || mode === "project-clone" || + mode === "project-remote" || mode === "project-success"; - const isWideAddFlow = mode === "project-clone"; + const isWideAddFlow = mode === "project-clone" || mode === "project-remote"; const resultHeightClass = isBrowsing ? "h-[620px] max-h-[86vh]" : isAddFlow - ? "max-h-[86vh]" - : "max-h-[400px]"; + ? "max-h-[86vh]" + : "max-h-[400px]"; const widthClass = isBrowsing ? "w-[1080px]" : isWideAddFlow - ? "w-[820px]" - : isAddFlow - ? "w-[640px]" - : "w-[680px]"; + ? "w-[820px]" + : isAddFlow + ? "w-[640px]" + : "w-[680px]"; const positionClass = isBrowsing ? "fixed inset-0 z-[130] m-auto" : isAddFlow - ? "fixed inset-0 z-[130] m-auto h-fit" - : "fixed left-1/2 top-[12%] z-[130] -translate-x-1/2"; + ? "fixed inset-0 z-[130] m-auto h-fit" + : "fixed left-1/2 top-[12%] z-[130] -translate-x-1/2"; const inputPlaceholder = isBrowsing - ? "Paste a path, type to filter, or drop a folder anywhere…" + ? activeRemoteTargetId + ? `Browse ${browseMachineName} by path…` + : "Paste a path, type to filter, or drop a folder anywhere…" : "Search commands..."; const handleProjectActionSuccess = useCallback( - (verb: "Created" | "Cloned", result: { rootPath: string; displayName: string }) => { - setActionOutcome({ verb, displayName: result.displayName, rootPath: result.rootPath }); + ( + verb: "Created" | "Cloned", + result: { rootPath: string; displayName: string; projectId?: string }, + ) => { + setActionOutcome({ + verb, + displayName: result.displayName, + rootPath: result.rootPath, + projectId: result.projectId, + location: activeProjectLocation, + }); setMode("project-success"); }, - [], + [activeProjectLocation], ); const handleSuccessOpen = useCallback(async () => { @@ -825,30 +1230,50 @@ export function CommandPalette({ return; } try { - await switchProjectToPath(actionOutcome.rootPath); + if (actionOutcome.location.kind === "remote" && actionOutcome.projectId) { + await switchRemoteProject( + actionOutcome.location.targetId, + actionOutcome.projectId, + ); + } else { + await switchProjectToPath(actionOutcome.rootPath); + } } catch (error) { console.error("Failed to open new project", error); } onOpenChange(false); - }, [actionOutcome, onOpenChange, switchProjectToPath]); + }, [actionOutcome, onOpenChange, switchProjectToPath, switchRemoteProject]); const handleSuccessStay = useCallback(() => { onOpenChange(false); }, [onOpenChange]); - const addFlowTitle = - mode === "project-add" - ? "Add a project" - : mode === "project-create" - ? "Create a new project" - : mode === "project-clone" - ? "Clone from GitHub" - : actionOutcome - ? `${actionOutcome.verb}!` - : ""; + let addFlowTitle = ""; + switch (mode) { + case "project-add": + addFlowTitle = selectedProjectLocation + ? `Add a project on ${browseMachineName}` + : "Add a project"; + break; + case "project-create": + addFlowTitle = `Create a new project${activeRemoteTargetId ? ` on ${browseMachineName}` : ""}`; + break; + case "project-clone": + addFlowTitle = `Clone from GitHub${activeRemoteTargetId ? ` on ${browseMachineName}` : ""}`; + break; + case "project-remote": + addFlowTitle = "Connect to a machine"; + break; + default: + if (actionOutcome) addFlowTitle = `${actionOutcome.verb}!`; + } const showAddFlowBack = - mode === "project-create" || mode === "project-clone" || mode === "project-success"; + (mode === "project-add" && selectedProjectLocation !== null) || + mode === "project-create" || + mode === "project-clone" || + mode === "project-remote" || + mode === "project-success"; return ( <Dialog.Root open={open} onOpenChange={onOpenChange}> @@ -886,7 +1311,7 @@ export function CommandPalette({ "max-w-[96vw]", resultHeightClass, "overflow-hidden rounded-2xl", - "flex flex-col focus:outline-none" + "flex flex-col focus:outline-none", )} style={{ background: @@ -904,10 +1329,24 @@ export function CommandPalette({ initial="initial" animate="animate" exit="exit" - onDragEnter={isBrowsing ? handleDragEnter : undefined} - onDragOver={isBrowsing ? handleDragOver : undefined} - onDragLeave={isBrowsing ? handleDragLeave : undefined} - onDrop={isBrowsing ? handleDrop : undefined} + onDragEnter={ + isBrowsing && !activeRemoteTargetId + ? handleDragEnter + : undefined + } + onDragOver={ + isBrowsing && !activeRemoteTargetId + ? handleDragOver + : undefined + } + onDragLeave={ + isBrowsing && !activeRemoteTargetId + ? handleDragLeave + : undefined + } + onDrop={ + isBrowsing && !activeRemoteTargetId ? handleDrop : undefined + } > {isBrowsing && ( <div @@ -918,7 +1357,8 @@ export function CommandPalette({ background: "linear-gradient(135deg, rgba(167,139,250,0.55), rgba(167,139,250,0.08) 55%, rgba(167,139,250,0.45))", mask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)", - WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)", + WebkitMask: + "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)", maskComposite: "exclude", WebkitMaskComposite: "xor", }} @@ -928,15 +1368,15 @@ export function CommandPalette({ {mode === "project-browse" ? "Project browser" : isAddFlow - ? addFlowTitle - : "Command palette"} + ? addFlowTitle + : "Command palette"} </Dialog.Title> <Dialog.Description className="sr-only"> {mode === "project-browse" ? "Browse folders in ADE and open a Git repository without leaving the app." : isAddFlow - ? "Open, create, or clone a project." - : "Search ADE commands and jump to actions quickly."} + ? "Open, create, clone, or connect to a project." + : "Search ADE commands and jump to actions quickly."} </Dialog.Description> {isAddFlow ? ( @@ -954,7 +1394,11 @@ export function CommandPalette({ type="button" onClick={() => { setActionOutcome(null); - setMode("project-add"); + if (mode === "project-add") { + setSelectedProjectLocation(null); + } else { + setMode("project-add"); + } }} className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 text-xs font-medium text-[var(--color-muted-fg)] transition-colors hover:border-[var(--color-border)] hover:bg-[var(--color-muted)] hover:text-[var(--color-fg)]" aria-label="Back to chooser" @@ -982,13 +1426,21 @@ export function CommandPalette({ <div className="relative flex items-center gap-3 border-b px-4" style={{ - background: "color-mix(in srgb, var(--color-surface-recessed) 92%, rgba(167,139,250,0.08))", - borderColor: "color-mix(in srgb, var(--color-accent) 14%, var(--color-border))", + background: + "color-mix(in srgb, var(--color-surface-recessed) 92%, rgba(167,139,250,0.08))", + borderColor: + "color-mix(in srgb, var(--color-accent) 14%, var(--color-border))", }} > - <MagnifyingGlass size={18} weight="regular" className="shrink-0 text-[var(--color-muted-fg)]" /> + <MagnifyingGlass + size={18} + weight="regular" + className="shrink-0 text-[var(--color-muted-fg)]" + /> <input - data-tour={isBrowsing ? "project.browserInput" : undefined} + data-tour={ + isBrowsing ? "project.browserInput" : undefined + } value={isBrowsing ? browseInput : q} onChange={(event) => { if (isBrowsing) { @@ -999,11 +1451,13 @@ export function CommandPalette({ setQ(event.target.value); setSelectedIdx(0); }} - onKeyDown={isBrowsing ? handleBrowseKeyDown : handleDefaultKeyDown} + onKeyDown={ + isBrowsing ? handleBrowseKeyDown : handleDefaultKeyDown + } placeholder={inputPlaceholder} className={cn( "h-[56px] w-full bg-transparent text-[15px] text-[var(--color-fg)] outline-none placeholder:text-[var(--color-muted-fg)]", - !isBrowsing && "font-mono" + !isBrowsing && "font-mono", )} autoFocus /> @@ -1016,27 +1470,108 @@ export function CommandPalette({ {isAddFlow ? ( <div className="flex-1 overflow-auto p-6"> {mode === "project-add" ? ( - <AddProjectChooser - onChoose={(choice) => { - if (choice === "open") { - startProjectBrowse(); - } else if (choice === "create") { - startProjectCreate(); - } else { - startProjectClone(); - } - }} - /> + selectedProjectLocation === null && + remoteLocations.length > 0 ? ( + <ProjectLocationChooser + remoteLocations={remoteLocations} + onChoose={(location) => { + setSelectedProjectLocation(location); + }} + /> + ) : ( + <AddProjectChooser + onChoose={(choice) => { + if (choice === "open") { + startProjectBrowse(); + } else if (choice === "create") { + startProjectCreate(); + } else { + startProjectClone(); + } + }} + /> + ) ) : mode === "project-create" ? ( <CreateProjectForm + machineName={ + activeRemoteTargetId ? browseMachineName : undefined + } + getDefaultParentDir={ + activeRemoteTargetId + ? () => + window.ade.remoteRuntime.getDefaultParentDir( + activeRemoteTargetId, + ) + : undefined + } + browseDirectories={ + activeRemoteTargetId + ? (input) => + window.ade.remoteRuntime.browseDirectories( + activeRemoteTargetId, + input, + ) + : undefined + } + chooseDirectory={ + activeRemoteTargetId ? null : undefined + } + createProject={ + activeRemoteTargetId + ? (input) => + window.ade.remoteRuntime.createProject( + activeRemoteTargetId, + input, + ) + : undefined + } onCancel={() => setMode("project-add")} - onCreated={(result) => handleProjectActionSuccess("Created", result)} + onCreated={(result) => + handleProjectActionSuccess("Created", result) + } /> ) : mode === "project-clone" ? ( <CloneProjectForm + machineName={ + activeRemoteTargetId ? browseMachineName : undefined + } + getDefaultParentDir={ + activeRemoteTargetId + ? () => + window.ade.remoteRuntime.getDefaultParentDir( + activeRemoteTargetId, + ) + : undefined + } + browseDirectories={ + activeRemoteTargetId + ? (input) => + window.ade.remoteRuntime.browseDirectories( + activeRemoteTargetId, + input, + ) + : undefined + } + chooseDirectory={ + activeRemoteTargetId ? null : undefined + } + cloneProject={ + activeRemoteTargetId + ? (input) => + window.ade.remoteRuntime.cloneProject( + activeRemoteTargetId, + input, + ) + : undefined + } + allowTokenSetup={true} onCancel={() => setMode("project-add")} - onCloned={(result) => handleProjectActionSuccess("Cloned", result)} + onCloned={(result) => + handleProjectActionSuccess("Cloned", result) + } /> + ) : mode === "project-remote" ? ( + <RemoteTargetList /> ) : mode === "project-success" && actionOutcome ? ( <ProjectActionSuccess verb={actionOutcome.verb} @@ -1058,7 +1593,11 @@ export function CommandPalette({ > {browseLoading && !browseResult ? ( <div className="flex items-center gap-2 px-4 py-6 text-sm text-[var(--color-muted-fg)]"> - <CircleNotch size={14} weight="bold" className="animate-spin" /> + <CircleNotch + size={14} + weight="bold" + className="animate-spin" + /> Scanning folders… </div> ) : browseRows.length === 0 ? ( @@ -1078,7 +1617,7 @@ export function CommandPalette({ "mx-2 flex w-[calc(100%-1rem)] items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150", isSelected ? "border-[var(--color-accent)] bg-[color-mix(in_srgb,var(--color-accent)_14%,transparent)] -translate-y-[0.5px]" - : "border-transparent hover:border-[color-mix(in_srgb,var(--color-accent)_20%,var(--color-border))] hover:bg-[color-mix(in_srgb,var(--color-accent)_5%,transparent)]" + : "border-transparent hover:border-[color-mix(in_srgb,var(--color-accent)_20%,var(--color-border))] hover:bg-[color-mix(in_srgb,var(--color-accent)_5%,transparent)]", )} style={ isSelected @@ -1088,7 +1627,9 @@ export function CommandPalette({ } : undefined } - onMouseEnter={() => setBrowseSelectedIdx(index)} + onMouseEnter={() => + setBrowseSelectedIdx(index) + } onClick={() => activateBrowseRow(row)} > <div className="flex min-w-0 items-center gap-2.5"> @@ -1104,18 +1645,29 @@ export function CommandPalette({ style={{ background: "linear-gradient(135deg, rgba(167,139,250,0.30), rgba(167,139,250,0.08))", - boxShadow: "0 0 0 1px rgba(167,139,250,0.30) inset", + boxShadow: + "0 0 0 1px rgba(167,139,250,0.30) inset", }} > - <GitBranch size={12} weight="bold" className="text-[var(--color-accent)]" /> + <GitBranch + size={12} + weight="bold" + className="text-[var(--color-accent)]" + /> </span> ) : ( <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-[var(--color-border)]"> - <Folder size={12} weight="regular" className="text-[var(--color-muted-fg)]" /> + <Folder + size={12} + weight="regular" + className="text-[var(--color-muted-fg)]" + /> </span> )} <div className="min-w-0"> - <div className="truncate text-sm font-medium text-[var(--color-fg)]">{row.title}</div> + <div className="truncate text-sm font-medium text-[var(--color-fg)]"> + {row.title} + </div> <div className="mt-0.5 truncate font-mono text-[11px] text-[var(--color-muted-fg)]"> {row.hint} </div> @@ -1126,7 +1678,9 @@ export function CommandPalette({ weight="regular" className={cn( "shrink-0 transition-opacity", - isSelected ? "opacity-100 text-[var(--color-accent)]" : "opacity-40 text-[var(--color-muted-fg)]" + isSelected + ? "opacity-100 text-[var(--color-accent)]" + : "opacity-40 text-[var(--color-muted-fg)]", )} /> </button> @@ -1144,7 +1698,7 @@ export function CommandPalette({ highlightedPath={highlightedPath} highlightedIsRepo={highlightedIsRepo} browseResult={browseResult} - activeProjectPath={project?.rootPath ?? null} + activeProjectPath={activeBrowseRoot} /> </div> @@ -1157,7 +1711,11 @@ export function CommandPalette({ }} > <div className="flex items-center gap-3 rounded-full border border-[var(--color-accent)] bg-[var(--color-popup-bg)]/90 px-5 py-2.5 text-sm font-medium text-[var(--color-fg)] shadow-lg"> - <FolderOpen size={18} weight="fill" className="text-[var(--color-accent)]" /> + <FolderOpen + size={18} + weight="fill" + className="text-[var(--color-accent)]" + /> Drop to open </div> </div> @@ -1168,7 +1726,8 @@ export function CommandPalette({ style={{ background: "linear-gradient(180deg, color-mix(in srgb, var(--color-surface-recessed) 92%, rgba(167,139,250,0.06)), var(--color-surface-recessed))", - borderColor: "color-mix(in srgb, var(--color-accent) 12%, var(--color-border))", + borderColor: + "color-mix(in srgb, var(--color-accent) 12%, var(--color-border))", }} > <div className="flex min-w-0 flex-1 items-center gap-2 text-[11px] text-[var(--color-muted-fg)]"> @@ -1181,33 +1740,45 @@ export function CommandPalette({ <span>Already open.</span> ) : ( <> - <kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 py-0.5 font-mono text-[10px]">↑↓</kbd> + <kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 py-0.5 font-mono text-[10px]"> + ↑↓ + </kbd> <span>navigate</span> - <kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 py-0.5 font-mono text-[10px]">↵</kbd> + <kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 py-0.5 font-mono text-[10px]"> + ↵ + </kbd> <span>step in</span> - <kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 py-0.5 font-mono text-[10px]">{openShortcutLabel}</kbd> + <kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 py-0.5 font-mono text-[10px]"> + {openShortcutLabel} + </kbd> <span>open directory</span> </> )} </div> <div className="flex shrink-0 items-center gap-2"> - <button - type="button" - data-tour="project.browserSystemPicker" - className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-[var(--color-border)] bg-transparent px-3 text-xs font-medium text-[var(--color-muted-fg)] transition-colors hover:bg-[var(--color-muted)] hover:text-[var(--color-fg)] disabled:cursor-not-allowed disabled:opacity-50" - disabled={systemPickerPending || openProjectPending} - onClick={() => { - void handleChooseInSystemPicker(); - }} - > - {systemPickerPending ? ( - <CircleNotch size={14} weight="bold" className="animate-spin" /> - ) : ( - <FolderOpen size={14} weight="regular" /> - )} - Open directory… - </button> + {!activeRemoteTargetId ? ( + <button + type="button" + data-tour="project.browserSystemPicker" + className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-[var(--color-border)] bg-transparent px-3 text-xs font-medium text-[var(--color-muted-fg)] transition-colors hover:bg-[var(--color-muted)] hover:text-[var(--color-fg)] disabled:cursor-not-allowed disabled:opacity-50" + disabled={systemPickerPending || openProjectPending} + onClick={() => { + void handleChooseInSystemPicker(); + }} + > + {systemPickerPending ? ( + <CircleNotch + size={14} + weight="bold" + className="animate-spin" + /> + ) : ( + <FolderOpen size={14} weight="regular" /> + )} + Open directory… + </button> + ) : null} <button type="button" data-tour="project.browserOpenButton" @@ -1218,17 +1789,27 @@ export function CommandPalette({ ? "0 10px 24px -12px rgba(167,139,250,0.8), 0 0 0 1px rgba(167,139,250,0.35)" : undefined, }} - disabled={!canOpenHighlighted || openProjectPending || systemPickerPending} + disabled={ + !canOpenHighlighted || + openProjectPending || + systemPickerPending + } onClick={() => { void handleOpenProject(openTarget); }} > {openProjectPending ? ( - <CircleNotch size={14} weight="bold" className="animate-spin" /> + <CircleNotch + size={14} + weight="bold" + className="animate-spin" + /> ) : ( <ArrowRight size={14} weight="bold" /> )} - {openTargetLabel ? `${openProjectLabel} ${openTargetLabel}` : openProjectLabel} + {openTargetLabel + ? `${openProjectLabel} ${openTargetLabel}` + : openProjectLabel} </button> </div> </div> @@ -1236,7 +1817,9 @@ export function CommandPalette({ ) : ( <div className="flex-1 overflow-auto"> {filtered.length === 0 ? ( - <div className="px-4 py-6 text-sm text-[var(--color-muted-fg)]">No matches.</div> + <div className="px-4 py-6 text-sm text-[var(--color-muted-fg)]"> + No matches. + </div> ) : ( <ul ref={listRef} className="py-2"> {(() => { @@ -1259,9 +1842,11 @@ export function CommandPalette({ "mx-2 flex w-[calc(100%-1rem)] items-center justify-between gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors", isSelected ? "border-[var(--color-accent)] bg-[var(--color-accent-muted)]" - : "border-transparent hover:border-[var(--color-border)] hover:bg-[var(--color-muted)]" + : "border-transparent hover:border-[var(--color-border)] hover:bg-[var(--color-muted)]", )} - onMouseEnter={() => setSelectedIdx(index)} + onMouseEnter={() => + setSelectedIdx(index) + } onClick={() => runCommand(command)} > <div className="min-w-0"> @@ -1269,7 +1854,9 @@ export function CommandPalette({ {command.title} </div> {command.hint ? ( - <div className="mt-0.5 truncate text-xs text-[var(--color-muted-fg)]">{command.hint}</div> + <div className="mt-0.5 truncate text-xs text-[var(--color-muted-fg)]"> + {command.hint} + </div> ) : null} </div> <div className="flex items-center gap-2"> @@ -1278,7 +1865,11 @@ export function CommandPalette({ {command.shortcut} </span> ) : null} - <ArrowRight size={14} weight="regular" className="text-[var(--color-muted-fg)]" /> + <ArrowRight + size={14} + weight="regular" + className="text-[var(--color-muted-fg)]" + /> </div> </button> </li> @@ -1292,6 +1883,18 @@ export function CommandPalette({ )} </div> )} + {pendingRemoteOpen ? ( + <RemoteProjectOpenDialog + project={pendingRemoteOpen.project} + localWork={pendingRemoteOpen.localWork} + runtimeName={pendingRemoteOpen.runtimeName} + busy={openingPendingRemote} + onCancel={() => setPendingRemoteOpen(null)} + onContinue={() => { + void confirmPendingRemoteOpen(); + }} + /> + ) : null} </motion.div> </Dialog.Content> </Dialog.Portal> @@ -1301,6 +1904,76 @@ export function CommandPalette({ ); } +function ProjectLocationChooser({ + remoteLocations, + onChoose, +}: { + remoteLocations: Array< + ProjectLocation & { status: RemoteRuntimeConnectionStatus } + >; + onChoose: (location: ProjectLocation) => void; +}) { + const locations: Array< + ProjectLocation & { status?: RemoteRuntimeConnectionStatus } + > = [LOCAL_PROJECT_LOCATION, ...remoteLocations]; + return ( + <div className="grid w-full grid-cols-1 gap-3 sm:grid-cols-2"> + {locations.map((location) => { + const isRemote = location.kind === "remote"; + const key = isRemote ? location.targetId : location.id; + const status = isRemote ? location.status : null; + return ( + <button + key={key} + type="button" + className="group flex min-h-[118px] items-center gap-4 rounded-xl border border-[var(--color-border)] bg-[color-mix(in_srgb,var(--color-card)_92%,transparent)] p-4 text-left transition-all hover:-translate-y-0.5 hover:border-[var(--color-accent)] hover:bg-[color-mix(in_srgb,var(--color-accent)_6%,var(--color-card))]" + onClick={() => onChoose(location)} + > + <span + className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg border" + style={{ + borderColor: isRemote + ? "color-mix(in srgb, #F59E0B 45%, var(--color-border))" + : "color-mix(in srgb, var(--color-accent) 45%, var(--color-border))", + background: isRemote + ? "color-mix(in srgb, #F59E0B 12%, transparent)" + : "color-mix(in srgb, var(--color-accent) 12%, transparent)", + color: isRemote ? "#F59E0B" : "var(--color-accent)", + }} + > + {isRemote ? ( + <DesktopTower size={22} weight="duotone" /> + ) : ( + <FolderOpen size={22} weight="duotone" /> + )} + </span> + <span className="min-w-0 flex-1"> + <span className="block truncate text-sm font-semibold text-[var(--color-fg)]"> + {location.name} + </span> + <span className="mt-1 block truncate font-mono text-[11px] text-[var(--color-muted-fg)]"> + {isRemote + ? `${status?.target.hostname ?? "remote"}${status?.version ? ` · ADE ${status.version}` : ""}` + : "Local filesystem"} + </span> + {isRemote ? ( + <span className="mt-2 inline-flex rounded-full border border-[#F59E0B66] bg-[#F59E0B1A] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[#FBBF24]"> + Connected + </span> + ) : null} + </span> + <ArrowRight + size={15} + weight="bold" + className="shrink-0 text-[var(--color-muted-fg)] transition-transform group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)]" + /> + </button> + ); + })} + </div> + ); +} + type BrowsePreviewProps = { detail: ProjectDetail | null; detailLoading: boolean; @@ -1321,14 +1994,19 @@ function BrowsePreview({ activeProjectPath, }: BrowsePreviewProps) { const showingDetailForPath = detailPath === highlightedPath ? detail : null; - const isLoading = detailLoading && detailPath === highlightedPath && !showingDetailForPath; + const isLoading = + detailLoading && detailPath === highlightedPath && !showingDetailForPath; if (!highlightedPath) { return ( <div className="relative flex min-h-0 flex-1 items-center justify-center p-8"> <div className="max-w-[300px] text-center text-sm text-[var(--color-muted-fg)]"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]/60"> - <Folder size={24} weight="regular" className="text-[var(--color-muted-fg)]" /> + <Folder + size={24} + weight="regular" + className="text-[var(--color-muted-fg)]" + /> </div> <p>Pick a folder to see its repo details, or drop one here.</p> </div> @@ -1362,21 +2040,33 @@ function BrowsePreview({ boxShadow: "0 0 0 1px rgba(167,139,250,0.35) inset", }} > - <GitBranch size={16} weight="bold" className="text-[var(--color-accent)]" /> + <GitBranch + size={16} + weight="bold" + className="text-[var(--color-accent)]" + /> </span> ) : ( <span className="flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--color-border)]"> - <Folder size={16} weight="regular" className="text-[var(--color-muted-fg)]" /> + <Folder + size={16} + weight="regular" + className="text-[var(--color-muted-fg)]" + /> </span> )} - <h2 className="truncate text-xl font-semibold text-[var(--color-fg)]">{displayName}</h2> + <h2 className="truncate text-xl font-semibold text-[var(--color-fg)]"> + {displayName} + </h2> {isActiveProject && ( <span className="ml-auto rounded-full border border-[var(--color-accent)]/40 bg-[var(--color-accent)]/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-accent)]"> Open now </span> )} </div> - <div className="truncate font-mono text-[11px] text-[var(--color-muted-fg)]">{highlightedPath}</div> + <div className="truncate font-mono text-[11px] text-[var(--color-muted-fg)]"> + {highlightedPath} + </div> </div> {isLoading ? ( @@ -1396,29 +2086,39 @@ function BrowsePreview({ } function RepoDetailBlocks({ detail }: { detail: ProjectDetail }) { - const lastCommitRelative = detail.lastCommit ? relativeFromNow(detail.lastCommit.isoDate) : null; + const lastCommitRelative = detail.lastCommit + ? relativeFromNow(detail.lastCommit.isoDate) + : null; const lastOpenedRelative = relativeFromNow(detail.lastOpenedAt); return ( <> <div className="flex flex-wrap items-center gap-2"> {detail.branchName && ( - <StatusChip icon={<GitBranch size={11} weight="bold" />} tone="accent"> + <StatusChip + icon={<GitBranch size={11} weight="bold" />} + tone="accent" + > {detail.branchName} </StatusChip> )} - {detail.aheadBehind && (detail.aheadBehind.ahead > 0 || detail.aheadBehind.behind > 0) && ( - <StatusChip tone="muted"> - {detail.aheadBehind.ahead > 0 ? `↑${detail.aheadBehind.ahead} ` : ""} - {detail.aheadBehind.behind > 0 ? `↓${detail.aheadBehind.behind}` : ""} - </StatusChip> - )} + {detail.aheadBehind && + (detail.aheadBehind.ahead > 0 || detail.aheadBehind.behind > 0) && ( + <StatusChip tone="muted"> + {detail.aheadBehind.ahead > 0 + ? `↑${detail.aheadBehind.ahead} ` + : ""} + {detail.aheadBehind.behind > 0 + ? `↓${detail.aheadBehind.behind}` + : ""} + </StatusChip> + )} {typeof detail.dirtyCount === "number" && detail.dirtyCount > 0 && ( <StatusChip tone="warn">{detail.dirtyCount} uncommitted</StatusChip> )} - {typeof detail.dirtyCount === "number" && detail.dirtyCount === 0 && detail.branchName && ( - <StatusChip tone="muted">clean</StatusChip> - )} + {typeof detail.dirtyCount === "number" && + detail.dirtyCount === 0 && + detail.branchName && <StatusChip tone="muted">clean</StatusChip>} {typeof detail.laneCount === "number" && detail.laneCount > 0 && ( <StatusChip icon={<Stack size={11} weight="bold" />} tone="muted"> {detail.laneCount} lane{detail.laneCount === 1 ? "" : "s"} @@ -1436,7 +2136,9 @@ function RepoDetailBlocks({ detail }: { detail: ProjectDetail }) { <div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-muted-fg)]"> Last commit </div> - <div className="truncate text-sm text-[var(--color-fg)]">{detail.lastCommit.subject}</div> + <div className="truncate text-sm text-[var(--color-fg)]"> + {detail.lastCommit.subject} + </div> <div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--color-muted-fg)]"> <span className="font-mono">{detail.lastCommit.shortSha}</span> {lastCommitRelative && <span>· {lastCommitRelative}</span>} @@ -1460,13 +2162,17 @@ function RepoDetailBlocks({ detail }: { detail: ProjectDetail }) { </div> <div className="flex items-center gap-2"> {detail.languages.map((lang) => { - const color = LANGUAGE_SWATCHES[lang.name] ?? "var(--color-accent)"; + const color = + LANGUAGE_SWATCHES[lang.name] ?? "var(--color-accent)"; return ( <span key={lang.name} className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)]/50 px-2.5 py-1 text-[11px] text-[var(--color-fg)]" > - <span className="h-2 w-2 rounded-full" style={{ backgroundColor: color }} /> + <span + className="h-2 w-2 rounded-full" + style={{ backgroundColor: color }} + /> {lang.name} <span className="text-[var(--color-muted-fg)]"> {Math.round(lang.fraction * 100)}% @@ -1490,19 +2196,24 @@ function PlainDirectoryBlock({ highlightedPath: string; detail: ProjectDetail | null; }) { - const subCount = detail?.subdirectoryCount ?? (browseResult?.exactDirectoryPath === highlightedPath - ? browseResult.entries.length - : null); + const subCount = + detail?.subdirectoryCount ?? + (browseResult?.exactDirectoryPath === highlightedPath + ? browseResult.entries.length + : null); return ( <div className="space-y-3"> <div className="flex flex-wrap items-center gap-2"> <StatusChip tone="muted">Plain folder</StatusChip> {typeof subCount === "number" && ( - <StatusChip tone="muted">{subCount} subfolder{subCount === 1 ? "" : "s"}</StatusChip> + <StatusChip tone="muted"> + {subCount} subfolder{subCount === 1 ? "" : "s"} + </StatusChip> )} </div> <p className="text-[13px] leading-relaxed text-[var(--color-muted-fg)]"> - No git repository here. Step into a subfolder, paste a path, or drop a folder to force-open. + No git repository here. Step into a subfolder, paste a path, or drop a + folder to force-open. </p> </div> ); @@ -1510,20 +2221,36 @@ function PlainDirectoryBlock({ const README_COMPONENTS: Components = { h1: ({ children }) => ( - <h3 className="mt-3 mb-1.5 text-[13px] font-semibold text-[var(--color-fg)] first:mt-0">{children}</h3> + <h3 className="mt-3 mb-1.5 text-[13px] font-semibold text-[var(--color-fg)] first:mt-0"> + {children} + </h3> ), h2: ({ children }) => ( - <h4 className="mt-3 mb-1.5 text-[12px] font-semibold text-[var(--color-fg)] first:mt-0">{children}</h4> + <h4 className="mt-3 mb-1.5 text-[12px] font-semibold text-[var(--color-fg)] first:mt-0"> + {children} + </h4> ), h3: ({ children }) => ( - <h5 className="mt-2.5 mb-1 text-[11px] font-semibold uppercase tracking-wide text-[var(--color-muted-fg)] first:mt-0">{children}</h5> + <h5 className="mt-2.5 mb-1 text-[11px] font-semibold uppercase tracking-wide text-[var(--color-muted-fg)] first:mt-0"> + {children} + </h5> ), h4: ({ children }) => ( - <h6 className="mt-2 mb-1 text-[11px] font-semibold text-[var(--color-muted-fg)] first:mt-0">{children}</h6> + <h6 className="mt-2 mb-1 text-[11px] font-semibold text-[var(--color-muted-fg)] first:mt-0"> + {children} + </h6> ), p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>, - ul: ({ children }) => <ul className="mb-2 list-disc pl-5 last:mb-0 marker:text-[var(--color-muted-fg)]">{children}</ul>, - ol: ({ children }) => <ol className="mb-2 list-decimal pl-5 last:mb-0 marker:text-[var(--color-muted-fg)]">{children}</ol>, + ul: ({ children }) => ( + <ul className="mb-2 list-disc pl-5 last:mb-0 marker:text-[var(--color-muted-fg)]"> + {children} + </ul> + ), + ol: ({ children }) => ( + <ol className="mb-2 list-decimal pl-5 last:mb-0 marker:text-[var(--color-muted-fg)]"> + {children} + </ol> + ), li: ({ children }) => <li className="mb-0.5">{children}</li>, a: ({ children, href }) => ( <a @@ -1564,9 +2291,15 @@ const README_COMPONENTS: Components = { </div> ), th: ({ children }) => ( - <th className="border-b border-[var(--color-border)] bg-black/20 px-2 py-1 font-semibold">{children}</th> + <th className="border-b border-[var(--color-border)] bg-black/20 px-2 py-1 font-semibold"> + {children} + </th> + ), + td: ({ children }) => ( + <td className="border-b border-[var(--color-border)] px-2 py-1 align-top"> + {children} + </td> ), - td: ({ children }) => <td className="border-b border-[var(--color-border)] px-2 py-1 align-top">{children}</td>, img: () => null, }; @@ -1610,8 +2343,10 @@ function StatusChip({ const toneStyle = tone === "accent" ? { - background: "color-mix(in srgb, var(--color-accent) 14%, transparent)", - borderColor: "color-mix(in srgb, var(--color-accent) 40%, var(--color-border))", + background: + "color-mix(in srgb, var(--color-accent) 14%, transparent)", + borderColor: + "color-mix(in srgb, var(--color-accent) 40%, var(--color-border))", color: "var(--color-accent)", } : tone === "warn" diff --git a/apps/desktop/src/renderer/components/app/SettingsPage.tsx b/apps/desktop/src/renderer/components/app/SettingsPage.tsx index 9c159deaa..a133ec802 100644 --- a/apps/desktop/src/renderer/components/app/SettingsPage.tsx +++ b/apps/desktop/src/renderer/components/app/SettingsPage.tsx @@ -14,6 +14,7 @@ import { COLORS, MONO_FONT, SANS_FONT, LABEL_STYLE, cardStyle, outlineButton, pr import { ConfirmDialog, PromptDialog, useConfirmDialog, usePromptDialog } from "../shared/InlineDialogs"; import type { PhaseProfile, PhaseCard } from "../../../shared/types"; import { PhaseCardEditor } from "../missions/PhaseCardEditor"; +import { useAppStore } from "../../state/appStore"; const SECTIONS = [ { id: "general", label: "General", icon: GearSix }, @@ -447,10 +448,16 @@ function PhaseProfilesSection() { export function SettingsPage() { const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); + const projectBinding = useAppStore((s) => s.projectBinding); + const memoryAvailable = projectBinding?.kind !== "remote"; + const visibleSections = memoryAvailable + ? SECTIONS + : SECTIONS.filter((entry) => entry.id !== "memory"); const tabParam = searchParams.get("tab"); - const canonicalTab = tabParam && SECTIONS.some((s) => s.id === tabParam) + const canonicalTab = tabParam && visibleSections.some((s) => s.id === tabParam) ? (tabParam as SectionId) : tabParam && TAB_ALIASES[tabParam] + && (TAB_ALIASES[tabParam] !== "memory" || memoryAvailable) ? TAB_ALIASES[tabParam] : null; const validTab = canonicalTab; @@ -462,7 +469,10 @@ export function SettingsPage() { if (validTab && validTab !== section) { setSection(validTab); } - }, [validTab, section]); + if (!memoryAvailable && section === "memory") { + setSection("general"); + } + }, [memoryAvailable, validTab, section]); useEffect(() => { if (!tabParam || !canonicalTab || tabParam === canonicalTab) return; @@ -507,7 +517,7 @@ export function SettingsPage() { SETTINGS </div> - {SECTIONS.map((s, i) => { + {visibleSections.map((s, i) => { const isActive = section === s.id; const isHovered = hoveredId === s.id; diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index f6854d4d5..65cc0efde 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { TopBar } from "./TopBar"; import { useAppStore } from "../../state/appStore"; @@ -53,6 +53,8 @@ function makeSyncSnapshot(overrides: Record<string, unknown> = {}) { ipAddresses: [], metadata: {}, }, + projectHydrated: true, + showWelcome: false, currentBrain: null, clusterState: null, bootstrapToken: "bootstrap-token", @@ -82,6 +84,12 @@ function makeSyncSnapshot(overrides: Record<string, unknown> = {}) { function resetStore() { useAppStore.setState({ project: { rootPath: "/Users/arul/ADE", name: "ADE" } as any, + projectBinding: { + kind: "local", + key: "local:/Users/arul/ADE", + rootPath: "/Users/arul/ADE", + displayName: "ADE", + }, terminalAttention: { runningCount: 0, activeCount: 0, @@ -98,15 +106,54 @@ function resetStore() { projectTransitionError: null, clearProjectTransitionError: vi.fn(), switchProjectToPath: vi.fn(async () => undefined), + switchRemoteProject: vi.fn(async (targetId: string, projectId: string) => ({ + kind: "remote", + key: `remote:${targetId}:${projectId}`, + targetId, + runtimeName: "Mac Studio", + projectId, + rootPath: "/srv/ade/remote-app", + displayName: "Remote App", + })), } as any); } +function makeDataTransfer(data: Record<string, string>, dropEffect = "move") { + return { + dropEffect, + effectAllowed: "move", + types: Object.keys(data), + getData: vi.fn((type: string) => data[type] ?? ""), + setData: vi.fn(), + }; +} + +function fireProjectTabDragEnd( + element: HTMLElement, + dataTransfer: ReturnType<typeof makeDataTransfer>, +) { + const event = createEvent.dragEnd(element, { dataTransfer }); + Object.defineProperty(event, "clientX", { value: -1 }); + Object.defineProperty(event, "clientY", { value: 12 }); + Object.defineProperty(event, "dataTransfer", { value: dataTransfer }); + fireEvent(element, event); +} + describe("TopBar", () => { const originalAde = globalThis.window.ade; beforeEach(() => { resetStore(); globalThis.window.ade = { + app: { + getWindowSession: vi.fn(async () => ({ windowId: 1, project: useAppStore.getState().project })), + newWindow: vi.fn(async () => ({ windowId: 2 })), + openProjectInNewWindow: vi.fn(async (rootPath: string) => ({ + windowId: 2, + project: { rootPath, name: rootPath.split("/").pop() ?? rootPath }, + })), + closeWindow: vi.fn(async () => ({ closed: true })), + }, project: { listRecent: vi.fn(async () => [ { @@ -128,6 +175,22 @@ describe("TopBar", () => { getStatus: vi.fn(async () => makeSyncSnapshot()), onEvent: vi.fn(() => () => {}), }, + github: { + getStatus: vi.fn(async () => ({ + tokenStored: false, + tokenDecryptionFailed: false, + storageScope: "app", + repo: { owner: "acme", name: "ade", url: "https://github.com/acme/ade" }, + hasOrigin: true, + userLogin: null, + scopes: [], + checkedAt: "2026-04-22T00:00:00.000Z", + repoAccessOk: true, + repoAccessError: null, + connected: false, + })), + onStatusChanged: vi.fn(() => () => {}), + }, zoom: { setLevel: vi.fn(), }, @@ -157,31 +220,136 @@ describe("TopBar", () => { await waitFor(() => { expect(globalThis.window.ade.project.listRecent).toHaveBeenCalled(); }); - expect(screen.queryByText("1 phone connected")).toBeNull(); + expect(screen.queryByText("1 phone connected to ADE Desktop")).toBeNull(); expect(globalThis.window.ade.sync.getStatus).not.toHaveBeenCalled(); }); - it("does not eagerly resolve icons for non-current recent projects", async () => { + it("does not render recent projects as tabs before a project is open", async () => { useAppStore.setState({ project: null } as any); render(<TopBar />); - expect(await screen.findByText("ADE")).toBeTruthy(); + await waitFor(() => { + expect(globalThis.window.ade.project.listRecent).toHaveBeenCalled(); + }); + expect(screen.queryByTitle("/Users/arul/ADE")).toBeNull(); await new Promise((resolve) => setTimeout(resolve, 850)); expect(globalThis.window.ade.project.resolveIcon).not.toHaveBeenCalled(); }); + it("renders a remote project tab without local sync polling", async () => { + useAppStore.setState({ + project: { rootPath: "/srv/ade/remote-app", displayName: "Remote App", baseRef: "main" }, + projectBinding: { + kind: "remote", + key: "remote:studio:project-1", + targetId: "studio", + runtimeName: "Mac Studio", + projectId: "project-1", + rootPath: "/srv/ade/remote-app", + displayName: "Remote App", + }, + projectHydrated: true, + showWelcome: false, + } as any); + + render(<TopBar />); + + expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app")).toBeTruthy(); + expect(screen.getByText("Remote App")).toBeTruthy(); + expect(screen.getByLabelText("Remote: Mac Studio")).toBeTruthy(); + expect(globalThis.window.ade.sync.getStatus).not.toHaveBeenCalled(); + expect(screen.queryByTitle("Connect a phone to this machine")).toBeNull(); + }); + + it("keeps local tabs visible when a remote project is active", async () => { + render(<TopBar />); + + const localTab = await screen.findByTitle("/Users/arul/ADE"); + + await act(async () => { + useAppStore.setState({ + project: { rootPath: "/srv/ade/remote-app", displayName: "Remote App", baseRef: "main" }, + projectBinding: { + kind: "remote", + key: "remote:studio:project-1", + targetId: "studio", + runtimeName: "Mac Studio", + projectId: "project-1", + rootPath: "/srv/ade/remote-app", + displayName: "Remote App", + }, + projectHydrated: true, + showWelcome: false, + } as any); + }); + + expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app")).toBeTruthy(); + expect(screen.getByTitle("/Users/arul/ADE")).toBeTruthy(); + + fireEvent.click(localTab); + + expect(useAppStore.getState().switchProjectToPath).toHaveBeenCalledWith("/Users/arul/ADE"); + }); + + it("opens a blank ADE window from the top bar", async () => { + render(<TopBar />); + + fireEvent.click(await screen.findByTitle("New window")); + + expect(globalThis.window.ade.app.newWindow).toHaveBeenCalledTimes(1); + }); + + it("consolidates a cross-window project tab dropped onto the same project", async () => { + render(<TopBar />); + + const tab = await screen.findByTitle("/Users/arul/ADE"); + await waitFor(() => { + expect(globalThis.window.ade.app.getWindowSession).toHaveBeenCalled(); + }); + + fireEvent.drop(tab, { + dataTransfer: makeDataTransfer({ + "application/x-ade-project-root": "/Users/arul/ADE", + "application/x-ade-window-id": "2", + }), + }); + + expect(globalThis.window.ade.app.closeWindow).toHaveBeenCalledWith(2); + expect(useAppStore.getState().switchProjectToPath).not.toHaveBeenCalled(); + }); + + it("does not detach again after a project tab is dropped onto an ADE target", async () => { + render(<TopBar />); + + const tab = await screen.findByTitle("/Users/arul/ADE"); + + fireProjectTabDragEnd(tab, makeDataTransfer({}, "move")); + + expect(globalThis.window.ade.app.openProjectInNewWindow).not.toHaveBeenCalled(); + }); + + it("detaches a project tab when it is dragged outside without an ADE drop target", async () => { + render(<TopBar />); + + const tab = await screen.findByTitle("/Users/arul/ADE"); + + fireProjectTabDragEnd(tab, makeDataTransfer({}, "none")); + + expect(globalThis.window.ade.app.openProjectInNewWindow).toHaveBeenCalledWith("/Users/arul/ADE"); + }); + it("opens the phone sync drawer from the host status control", async () => { render(<TopBar />); - expect(await screen.findByText("1 phone connected")).toBeTruthy(); + expect(await screen.findByText("1 phone connected to ADE Desktop")).toBeTruthy(); - fireEvent.click(screen.getByTitle("Connect a phone to this computer")); + fireEvent.click(screen.getByTitle("Connect a phone to this machine")); expect(screen.getByText("Connect to the ADE mobile app")).toBeTruthy(); expect(screen.getByTestId("sync-devices-section")).toBeTruthy(); - expect(screen.getByTitle("Connect a phone to this computer").getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByTitle("Connect a phone to this machine").getAttribute("aria-expanded")).toBe("true"); fireEvent.click(screen.getByTitle("Close phone sync")); @@ -217,7 +385,7 @@ describe("TopBar", () => { }); }); - expect(await screen.findByText("1 phone connected")).toBeTruthy(); + expect(await screen.findByText("1 phone connected to ADE Desktop")).toBeTruthy(); }); it("does not refresh phone sync status on an idle interval", async () => { @@ -259,7 +427,7 @@ describe("TopBar", () => { window.dispatchEvent(new Event("focus")); }); - expect(await screen.findByText("1 phone connected")).toBeTruthy(); + expect(await screen.findByText("1 phone connected to ADE Desktop")).toBeTruthy(); expect(getStatus).toHaveBeenCalledTimes(2); }); @@ -396,7 +564,7 @@ describe("TopBar", () => { expect((await screen.findByRole("alert")).textContent).toContain("Project icon must be 10 MB or smaller."); }); - it("confirms before removing a project tab", async () => { + it("confirms before closing a project tab", async () => { const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); render(<TopBar />); @@ -404,11 +572,12 @@ describe("TopBar", () => { await screen.findByText("ADE"); fireEvent.click(screen.getByTitle("Remove project")); - expect(confirm).toHaveBeenCalledWith(expect.stringContaining("Close \"ADE\" and remove it from project tabs?")); + expect(confirm).toHaveBeenCalledWith(expect.stringContaining("Close \"ADE\" project tab?")); expect(globalThis.window.ade.project.forgetRecent).not.toHaveBeenCalled(); + expect(useAppStore.getState().closeProject).not.toHaveBeenCalled(); }); - it("removes the project tab after confirmation", async () => { + it("closes the active project tab after confirmation without removing it from recents", async () => { vi.spyOn(window, "confirm").mockReturnValue(true); render(<TopBar />); @@ -417,7 +586,8 @@ describe("TopBar", () => { fireEvent.click(screen.getByTitle("Remove project")); await waitFor(() => { - expect(globalThis.window.ade.project.forgetRecent).toHaveBeenCalledWith("/Users/arul/ADE"); + expect(useAppStore.getState().closeProject).toHaveBeenCalledTimes(1); }); + expect(globalThis.window.ade.project.forgetRecent).not.toHaveBeenCalled(); }); }); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 88a8e755a..b40892277 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -1,5 +1,24 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ChatCircleDots, CircleNotch, DeviceMobile, Folder, FolderOpen, Plus, Minus, Trash, UploadSimple, X } from "@phosphor-icons/react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + ArrowSquareOut, + ChatCircleDots, + CircleNotch, + DesktopTower, + DeviceMobile, + Folder, + FolderOpen, + Plus, + Minus, + Trash, + UploadSimple, + X, +} from "@phosphor-icons/react"; import * as Dialog from "@radix-ui/react-dialog"; import { useAppStore } from "../../state/appStore"; @@ -14,16 +33,30 @@ import { } from "../../lib/zoom"; import { cn } from "../ui/cn"; import { SmartTooltip } from "../ui/SmartTooltip"; -import type { ProcessRuntime, ProjectIcon, RecentProjectSummary, SyncRoleSnapshot } from "../../../shared/types"; +import type { + ProcessRuntime, + ProjectIcon, + OpenProjectBinding, + RecentProjectSummary, + RemoteRuntimeConnectionSnapshot, + SyncRoleSnapshot, +} from "../../../shared/types"; import { AutoUpdateControl } from "./AutoUpdateControl"; import { FeedbackReporterModal } from "./FeedbackReporterModal"; import { HelpMenu } from "../onboarding/HelpMenu"; import { LinearQuickViewButton } from "./LinearQuickViewButton"; import { PublishToGitHubDialog } from "../projects/PublishToGitHubDialog"; +import { RemoteTargetList } from "../remoteTargets/RemoteTargetList"; import { SyncDevicesSection } from "../settings/SyncDevicesSection"; import { HeaderUsageControl } from "../usage/HeaderUsageControl"; -const RUNNING_LANE_PROCESS_STATES: ProcessRuntime["status"][] = ["starting", "running", "degraded"]; +const RUNNING_LANE_PROCESS_STATES: ProcessRuntime["status"][] = [ + "starting", + "running", + "degraded", +]; +const ADE_PROJECT_TAB_ROOT_MIME = "application/x-ade-project-root"; +const ADE_PROJECT_TAB_WINDOW_MIME = "application/x-ade-window-id"; // Bounded LRU so we don't accumulate icons for every project ever opened in // long-lived sessions. 24 entries keeps the working set hot for typical usage @@ -32,6 +65,7 @@ const PROJECT_ICON_CACHE_MAX = 24; const projectIconCache = new Map<string, ProjectIcon>(); const PROJECT_ICON_ACCENT_CACHE_MAX = 48; const projectIconAccentCache = new Map<string, string | null>(); +type RemoteProjectTab = Extract<OpenProjectBinding, { kind: "remote" }>; function getProjectIconFromCache(rootPath: string): ProjectIcon | undefined { const cached = projectIconCache.get(rootPath); if (cached === undefined) return undefined; @@ -52,7 +86,10 @@ function setProjectIconCache(rootPath: string, icon: ProjectIcon): void { } projectIconCache.set(rootPath, icon); } -function setProjectIconAccentCache(cacheKey: string, color: string | null): void { +function setProjectIconAccentCache( + cacheKey: string, + color: string | null, +): void { if (projectIconAccentCache.has(cacheKey)) { projectIconAccentCache.delete(cacheKey); } else if (projectIconAccentCache.size >= PROJECT_ICON_ACCENT_CACHE_MAX) { @@ -63,7 +100,9 @@ function setProjectIconAccentCache(cacheKey: string, color: string | null): void } function toHexByte(value: number): string { - return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0"); + return Math.max(0, Math.min(255, Math.round(value))) + .toString(16) + .padStart(2, "0"); } function balancedAccentColor(red: number, green: number, blue: number): string { @@ -82,8 +121,10 @@ function balancedAccentColor(red: number, green: number, blue: number): string { } async function deriveIconAccentColor(dataUrl: string): Promise<string | null> { - if (projectIconAccentCache.has(dataUrl)) return projectIconAccentCache.get(dataUrl) ?? null; - if (typeof document === "undefined" || typeof Image === "undefined") return null; + if (projectIconAccentCache.has(dataUrl)) + return projectIconAccentCache.get(dataUrl) ?? null; + if (typeof document === "undefined" || typeof Image === "undefined") + return null; const color = await new Promise<string | null>((resolve) => { const image = new Image(); @@ -91,8 +132,14 @@ async function deriveIconAccentColor(dataUrl: string): Promise<string | null> { image.onload = () => { try { const canvas = document.createElement("canvas"); - const width = Math.max(1, Math.min(24, image.naturalWidth || image.width || 24)); - const height = Math.max(1, Math.min(24, image.naturalHeight || image.height || 24)); + const width = Math.max( + 1, + Math.min(24, image.naturalWidth || image.width || 24), + ); + const height = Math.max( + 1, + Math.min(24, image.naturalHeight || image.height || 24), + ); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d", { willReadFrequently: true }); @@ -117,7 +164,8 @@ async function deriveIconAccentColor(dataUrl: string): Promise<string | null> { const min = Math.min(red, green, blue); const saturation = max === 0 ? 0 : (max - min) / max; const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; - if (saturation < 0.08 && (luminance < 28 || luminance > 230)) continue; + if (saturation < 0.08 && (luminance < 28 || luminance > 230)) + continue; const weight = alpha * (0.18 + saturation * 1.65); redTotal += red * weight; greenTotal += green * weight; @@ -128,7 +176,13 @@ async function deriveIconAccentColor(dataUrl: string): Promise<string | null> { resolve(null); return; } - resolve(balancedAccentColor(redTotal / weightTotal, greenTotal / weightTotal, blueTotal / weightTotal)); + resolve( + balancedAccentColor( + redTotal / weightTotal, + greenTotal / weightTotal, + blueTotal / weightTotal, + ), + ); } catch { resolve(null); } @@ -146,21 +200,24 @@ const PHONE_SYNC_FOCUSABLE_SELECTOR = [ "textarea:not([disabled])", "input:not([disabled])", "select:not([disabled])", - "[tabindex]:not([tabindex=\"-1\"])", + '[tabindex]:not([tabindex="-1"])', ].join(","); function getFocusableElements(root: HTMLElement): HTMLElement[] { - return Array.from(root.querySelectorAll<HTMLElement>(PHONE_SYNC_FOCUSABLE_SELECTOR)) - .filter((element) => - element.getAttribute("aria-hidden") !== "true" - && !element.hasAttribute("disabled") - && element.tabIndex >= 0 - ); + return Array.from( + root.querySelectorAll<HTMLElement>(PHONE_SYNC_FOCUSABLE_SELECTOR), + ).filter( + (element) => + element.getAttribute("aria-hidden") !== "true" && + !element.hasAttribute("disabled") && + element.tabIndex >= 0, + ); } function syncDotClass(snapshot: SyncRoleSnapshot): string { if (snapshot.client.state === "error") return "ade-status-dot-error"; - if (snapshot.client.state === "connected" || snapshot.role === "brain") return "ade-status-dot-active"; + if (snapshot.client.state === "connected" || snapshot.role === "brain") + return "ade-status-dot-active"; return "ade-status-dot-warning"; } @@ -173,12 +230,15 @@ function projectIconErrorMessage(error: unknown): string { return cleaned || "Failed to update project icon."; } -function confirmProjectTabRemoval(projectName: string, isCurrent: boolean, isMissing: boolean): boolean { +function fallbackProjectName(rootPath: string): string { + return rootPath.split(/[\\/]/).filter(Boolean).pop() ?? rootPath; +} + +function confirmProjectTabRemoval(projectName: string): boolean { const label = projectName.trim() || "this project"; - const action = isCurrent && !isMissing - ? `Close "${label}" and remove it from project tabs?` - : `Remove "${label}" from project tabs?`; - return window.confirm(`${action}\n\nThis does not delete any files on disk.`); + return window.confirm( + `Close "${label}" project tab?\n\nThis does not remove it from Recent Projects or delete any files on disk.`, + ); } function deriveSyncLabel(snapshot: SyncRoleSnapshot | null): string | null { @@ -187,7 +247,8 @@ function deriveSyncLabel(snapshot: SyncRoleSnapshot | null): string | null { if (snapshot.role === "brain") { const count = snapshot.connectedPeers.length; if (count > 0) { - return `${count} phone${count === 1 ? "" : "s"} connected`; + const machineName = snapshot.localDevice.name.trim() || "this machine"; + return `${count} phone${count === 1 ? "" : "s"} connected to ${machineName}`; } return "Phone sync ready"; } @@ -207,16 +268,18 @@ function ProjectTabIcon({ isCurrent, animate, disabled, + readOnly = false, onAccentColorChange, }: { rootPath: string; isCurrent: boolean; animate: boolean; disabled: boolean; + readOnly?: boolean; onAccentColorChange?: (rootPath: string, color: string | null) => void; }) { const [icon, setIcon] = useState<ProjectIcon | null>(() => - disabled ? null : getProjectIconFromCache(rootPath) ?? null + disabled ? null : (getProjectIconFromCache(rootPath) ?? null), ); const [failed, setFailed] = useState(false); const [iconDialogOpen, setIconDialogOpen] = useState(false); @@ -245,13 +308,16 @@ function ProjectTabIcon({ let cancelled = false; const timer = window.setTimeout(() => { - window.ade.project.resolveIcon(rootPath).then((nextIcon) => { - if (cancelled) return; - setProjectIconCache(rootPath, nextIcon); - setIcon(nextIcon); - }).catch(() => { - if (!cancelled) setIcon(null); - }); + window.ade.project + .resolveIcon(rootPath) + .then((nextIcon) => { + if (cancelled) return; + setProjectIconCache(rootPath, nextIcon); + setIcon(nextIcon); + }) + .catch(() => { + if (!cancelled) setIcon(null); + }); }, 100); return () => { cancelled = true; @@ -268,11 +334,13 @@ function ProjectTabIcon({ cancelled = true; }; } - deriveIconAccentColor(dataUrl).then((color) => { - if (!cancelled) onAccentColorChange?.(rootPath, color); - }).catch(() => { - if (!cancelled) onAccentColorChange?.(rootPath, null); - }); + deriveIconAccentColor(dataUrl) + .then((color) => { + if (!cancelled) onAccentColorChange?.(rootPath, color); + }) + .catch(() => { + if (!cancelled) onAccentColorChange?.(rootPath, null); + }); return () => { cancelled = true; }; @@ -290,19 +358,22 @@ function ProjectTabIcon({ /> ); - const iconNode = !icon?.dataUrl || failed ? fallbackIcon : ( - <img - src={icon.dataUrl} - alt="" - className={cn( - "h-[18px] w-[18px] shrink-0 rounded-[4px] object-contain transition-opacity duration-150", - isCurrent ? "opacity-95" : "opacity-75", - animate && "animate-pulse", - )} - draggable={false} - onError={() => setFailed(true)} - /> - ); + const iconNode = + !icon?.dataUrl || failed ? ( + fallbackIcon + ) : ( + <img + src={icon.dataUrl} + alt="" + className={cn( + "h-[18px] w-[18px] shrink-0 rounded-[4px] object-contain transition-opacity duration-150", + isCurrent ? "opacity-95" : "opacity-75", + animate && "animate-pulse", + )} + draggable={false} + onError={() => setFailed(true)} + /> + ); const handleChooseIcon = useCallback(async () => { if (disabled || choosing) return; @@ -317,7 +388,9 @@ function ProjectTabIcon({ if (nextIcon.dataUrl) { setIconDialogOpen(false); } else { - setIconError("ADE saved the path, but the image could not be rendered as a project icon."); + setIconError( + "ADE saved the path, but the image could not be rendered as a project icon.", + ); } } } catch (error) { @@ -348,6 +421,23 @@ function ProjectTabIcon({ if (disabled) return iconNode; + if (readOnly) { + return ( + <span + aria-label="Project icon" + title="Project icon" + className={cn( + "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-[5px] text-current", + )} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + > + {iconNode} + </span> + ); + } + return ( <Dialog.Root open={iconDialogOpen} @@ -369,7 +459,15 @@ function ProjectTabIcon({ onKeyDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} > - {choosing || removing ? <CircleNotch size={15} weight="bold" className="animate-spin opacity-80" /> : iconNode} + {choosing || removing ? ( + <CircleNotch + size={15} + weight="bold" + className="animate-spin opacity-80" + /> + ) : ( + iconNode + )} </button> </Dialog.Trigger> <Dialog.Portal> @@ -383,7 +481,9 @@ function ProjectTabIcon({ > <div className="flex items-start justify-between gap-3"> <div> - <Dialog.Title className="text-sm font-semibold">Project icon</Dialog.Title> + <Dialog.Title className="text-sm font-semibold"> + Project icon + </Dialog.Title> <Dialog.Description className="sr-only"> Preview and manage this project's shared icon. </Dialog.Description> @@ -428,7 +528,13 @@ function ProjectTabIcon({ disabled={choosing || removing} onClick={handleRemoveIcon} > - {removing ? <CircleNotch size={13} weight="bold" className="mr-1.5 animate-spin" /> : null} + {removing ? ( + <CircleNotch + size={13} + weight="bold" + className="mr-1.5 animate-spin" + /> + ) : null} Remove </button> <button @@ -437,7 +543,13 @@ function ProjectTabIcon({ disabled={choosing || removing} onClick={handleChooseIcon} > - {choosing ? <CircleNotch size={13} weight="bold" className="mr-1.5 animate-spin" /> : null} + {choosing ? ( + <CircleNotch + size={13} + weight="bold" + className="mr-1.5 animate-spin" + /> + ) : null} Replace </button> </div> @@ -449,6 +561,7 @@ function ProjectTabIcon({ export function TopBar() { const project = useAppStore((s) => s.project); + const projectBinding = useAppStore((s) => s.projectBinding); const projectHydrated = useAppStore((s) => s.projectHydrated); const showWelcome = useAppStore((s) => s.showWelcome); const closeProject = useAppStore((s) => s.closeProject); @@ -459,30 +572,56 @@ export function TopBar() { const cancelNewTab = useAppStore((s) => s.cancelNewTab); const projectTransition = useAppStore((s) => s.projectTransition); const projectTransitionError = useAppStore((s) => s.projectTransitionError); - const clearProjectTransitionError = useAppStore((s) => s.clearProjectTransitionError); + const clearProjectTransitionError = useAppStore( + (s) => s.clearProjectTransitionError, + ); const switchProjectToPath = useAppStore((s) => s.switchProjectToPath); - const [recentProjects, setRecentProjects] = useState<RecentProjectSummary[]>([]); - const [projectAccentColors, setProjectAccentColors] = useState<Record<string, string | null>>({}); + const switchRemoteProject = useAppStore((s) => s.switchRemoteProject); + const [recentProjects, setRecentProjects] = useState<RecentProjectSummary[]>( + [], + ); + const [projectAccentColors, setProjectAccentColors] = useState< + Record<string, string | null> + >({}); const [relocatingPath, setRelocatingPath] = useState<string | null>(null); const [zoom, setZoom] = useState(getStoredZoomLevel); - const [syncSnapshot, setSyncSnapshot] = useState<SyncRoleSnapshot | null>(null); + const [syncSnapshot, setSyncSnapshot] = useState<SyncRoleSnapshot | null>( + null, + ); const [phoneSyncOpen, setPhoneSyncOpen] = useState(false); + const [remotePanelOpen, setRemotePanelOpen] = useState(false); + const [remoteSnapshot, setRemoteSnapshot] = + useState<RemoteRuntimeConnectionSnapshot | null>(null); const [feedbackOpen, setFeedbackOpen] = useState(false); const [publishOpen, setPublishOpen] = useState(false); + const [openProjectTabRoots, setOpenProjectTabRoots] = useState<string[]>([]); + const [openRemoteProjectTabs, setOpenRemoteProjectTabs] = useState< + RemoteProjectTab[] + >([]); const [dragIdx, setDragIdx] = useState<number | null>(null); const [dropIdx, setDropIdx] = useState<number | null>(null); + const [windowId, setWindowId] = useState<number | null>(null); const phoneSyncPanelRef = useRef<HTMLDivElement | null>(null); + const remotePanelRef = useRef<HTMLDivElement | null>(null); const dragCounterRef = useRef(0); const isProjectBusy = projectTransition != null || relocatingPath != null; + const remoteBinding = + projectBinding?.kind === "remote" ? projectBinding : null; const workspaceProjectOpen = projectHydrated === true && showWelcome !== true && isNewTabOpen !== true && - Boolean(project?.rootPath); - - const projectRootForRemote = workspaceProjectOpen ? project?.rootPath ?? null : null; - const { hasGitHubRemote, hasOrigin, refresh: refreshRemote } = - useGithubProjectRemote(projectRootForRemote); + Boolean(project?.rootPath) && + !remoteBinding; + + const projectRootForRemote = workspaceProjectOpen + ? (project?.rootPath ?? null) + : null; + const { + hasGitHubRemote, + hasOrigin, + refresh: refreshRemote, + } = useGithubProjectRemote(projectRootForRemote); const publishDefaultName = useMemo(() => { const root = project?.rootPath; if (!root) return ""; @@ -497,6 +636,9 @@ export function TopBar() { Boolean(project?.rootPath) && hasGitHubRemote === false && hasOrigin === false; + const connectedRemoteCount = remoteSnapshot?.connectedCount ?? 0; + const remoteButtonLabel = + connectedRemoteCount > 0 ? `Remote ${connectedRemoteCount}` : "Remote"; const applyZoom = useCallback((pct: number) => { const clamped = Math.max(MIN_ZOOM_LEVEL, Math.min(MAX_ZOOM_LEVEL, pct)); @@ -512,13 +654,100 @@ export function TopBar() { window.ade.project .listRecent() .then((rows) => setRecentProjects(rows)) - .catch(() => { }); + .catch(() => {}); }, []); useEffect(() => { fetchRecent(); }, [project?.rootPath, fetchRecent]); + useEffect(() => { + const rootPath = project?.rootPath ?? null; + if (!rootPath) { + // Only wipe local tabs when the user has explicitly closed the project + // (welcome screen visible, no remote binding, no transition in flight). + // Otherwise we'd nuke other tabs whenever `project` is briefly null mid + // open/switch/close. + if ( + !remoteBinding && + projectTransition == null && + showWelcome === true + ) { + setOpenProjectTabRoots([]); + } + return; + } + if (remoteBinding) { + return; + } + // Skip while a transition targeting a *different* root is in flight. + // During switch/close, `project` briefly points at the OLD root before + // the await resolves; re-adding it here would resurrect a tab the user + // just removed via handleRemoveTab. + if (projectTransition != null && projectTransition.rootPath !== rootPath) { + return; + } + setOpenProjectTabRoots((prev) => + prev.includes(rootPath) ? prev : [...prev, rootPath], + ); + }, [project?.rootPath, remoteBinding, projectTransition, showWelcome]); + + useEffect(() => { + if (!remoteBinding) return; + setOpenRemoteProjectTabs((prev) => { + const existingIndex = prev.findIndex( + (entry) => entry.key === remoteBinding.key, + ); + if (existingIndex === -1) return [...prev, remoteBinding]; + const next = [...prev]; + next[existingIndex] = remoteBinding; + return next; + }); + }, [remoteBinding]); + + useEffect(() => { + if (project || remoteBinding) return; + // Same guard as above: only wipe remote tabs on a true close, not while a + // transition is in flight or before the welcome screen is shown. + if (projectTransition != null || showWelcome !== true) return; + setOpenRemoteProjectTabs([]); + }, [project, remoteBinding, projectTransition, showWelcome]); + + const projectTabs = useMemo<RecentProjectSummary[]>( + () => + openProjectTabRoots.map((rootPath) => { + const recent = recentProjects.find( + (entry) => entry.rootPath === rootPath, + ); + if (recent) return recent; + return { + rootPath, + displayName: + project?.rootPath === rootPath + ? (project.displayName ?? fallbackProjectName(rootPath)) + : fallbackProjectName(rootPath), + exists: true, + lastOpenedAt: "", + }; + }), + [openProjectTabRoots, project, recentProjects], + ); + + useEffect(() => { + let cancelled = false; + window.ade.app + .getWindowSession() + .then((session) => { + if (!cancelled) setWindowId(session.windowId); + }) + .catch(() => { + if (!cancelled) setWindowId(null); + }); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { if (!phoneSyncOpen) return; const frame = window.requestAnimationFrame(() => { @@ -527,6 +756,36 @@ export function TopBar() { return () => window.cancelAnimationFrame(frame); }, [phoneSyncOpen]); + useEffect(() => { + const remoteRuntime = window.ade.remoteRuntime; + if (!remoteRuntime?.getConnectionSnapshot) return; + let cancelled = false; + void remoteRuntime + .getConnectionSnapshot() + .then((snapshot) => { + if (!cancelled) setRemoteSnapshot(snapshot); + }) + .catch(() => { + if (!cancelled) setRemoteSnapshot(null); + }); + const unsubscribe = + remoteRuntime.onConnectionSnapshotChanged?.((snapshot) => { + if (!cancelled) setRemoteSnapshot(snapshot); + }) ?? (() => {}); + return () => { + cancelled = true; + unsubscribe(); + }; + }, []); + + useEffect(() => { + if (!remotePanelOpen) return; + const frame = window.requestAnimationFrame(() => { + remotePanelRef.current?.focus(); + }); + return () => window.cancelAnimationFrame(frame); + }, [remotePanelOpen]); + // Re-fetch when app regains focus (catches external deletions). useEffect(() => { const onFocus = () => fetchRecent(); @@ -543,7 +802,7 @@ export function TopBar() { useEffect(() => { let cancelled = false; let statusRequestVersion = 0; - if (!project?.rootPath) { + if (!project?.rootPath || remoteBinding) { setSyncSnapshot(null); setPhoneSyncOpen(false); return () => { @@ -552,11 +811,16 @@ export function TopBar() { } const refreshSyncStatus = () => { const requestVersion = ++statusRequestVersion; - void window.ade.sync.getStatus({ includeTransferReadiness: false }).then((snapshot) => { - if (!cancelled && requestVersion === statusRequestVersion) setSyncSnapshot(snapshot); - }).catch(() => { - if (!cancelled && requestVersion === statusRequestVersion) setSyncSnapshot(null); - }); + void window.ade.sync + .getStatus({ includeTransferReadiness: false }) + .then((snapshot) => { + if (!cancelled && requestVersion === statusRequestVersion) + setSyncSnapshot(snapshot); + }) + .catch(() => { + if (!cancelled && requestVersion === statusRequestVersion) + setSyncSnapshot(null); + }); }; setSyncSnapshot(null); refreshSyncStatus(); @@ -576,121 +840,243 @@ export function TopBar() { // them to the active project), so we re-run this effect on rootPath change // to force an immediate refetch. Focus refresh covers state changes that // happen while ADE is not active. - }, [project?.rootPath]); + }, [project?.rootPath, remoteBinding]); - const checkForActiveWorkloads = useCallback(async (projectRootPath: string): Promise<boolean> => { - if (project?.rootPath !== projectRootPath) return true; - - try { - const [lanes, runningSessions, agentChats, activeMissions] = await Promise.all([ - window.ade.lanes.list({ includeArchived: false }), - window.ade.sessions.list({ status: "running" }), - window.ade.agentChat.list(), - window.ade.missions.list({ status: "active" }) - ]); - - const laneRuntimes = await Promise.all( - lanes.map((lane) => window.ade.processes.listRuntime(lane.id).catch(() => [] as ProcessRuntime[])) - ); + const checkForActiveWorkloads = useCallback( + async (projectRootPath: string): Promise<boolean> => { + if (project?.rootPath !== projectRootPath) return true; - const activeProcesses = laneRuntimes - .flat() - .filter((runtime) => RUNNING_LANE_PROCESS_STATES.includes(runtime.status)); - const activeSessionCount = runningSessions.filter( - (session) => session.status === "running" && !isRunOwnedSession(session), - ).length; - const activeChatCount = agentChats.filter((chat) => chat.status === "active").length; - - const warnings: string[] = []; - if (activeProcesses.length > 0) { - warnings.push(`${activeProcesses.length} running lane process${activeProcesses.length === 1 ? "" : "es"}`); - } - if (activeSessionCount > 0) { - warnings.push(`${activeSessionCount} running terminal session${activeSessionCount === 1 ? "" : "s"}`); - } - if (activeChatCount > 0) { - warnings.push(`${activeChatCount} active chat${activeChatCount === 1 ? "" : "s"}`); - } - if (activeMissions.length > 0) { - warnings.push(`${activeMissions.length} active mission${activeMissions.length === 1 ? "" : "s"}`); - } + try { + const [lanes, runningSessions, agentChats, activeMissions] = + await Promise.all([ + window.ade.lanes.list({ includeArchived: false }), + window.ade.sessions.list({ status: "running" }), + window.ade.agentChat.list(), + window.ade.missions.list({ status: "active" }), + ]); + + const laneRuntimes = await Promise.all( + lanes.map((lane) => + window.ade.processes + .listRuntime(lane.id) + .catch(() => [] as ProcessRuntime[]), + ), + ); + + const activeProcesses = laneRuntimes + .flat() + .filter((runtime) => + RUNNING_LANE_PROCESS_STATES.includes(runtime.status), + ); + const activeSessionCount = runningSessions.filter( + (session) => + session.status === "running" && !isRunOwnedSession(session), + ).length; + const activeChatCount = agentChats.filter( + (chat) => chat.status === "active", + ).length; + + const warnings: string[] = []; + if (activeProcesses.length > 0) { + warnings.push( + `${activeProcesses.length} running lane process${activeProcesses.length === 1 ? "" : "es"}`, + ); + } + if (activeSessionCount > 0) { + warnings.push( + `${activeSessionCount} running terminal session${activeSessionCount === 1 ? "" : "s"}`, + ); + } + if (activeChatCount > 0) { + warnings.push( + `${activeChatCount} active chat${activeChatCount === 1 ? "" : "s"}`, + ); + } + if (activeMissions.length > 0) { + warnings.push( + `${activeMissions.length} active mission${activeMissions.length === 1 ? "" : "s"}`, + ); + } - if (warnings.length === 0) return true; + if (warnings.length === 0) return true; - const message = [ - "You are about to close this project.", - "The following active work items will be terminated:", - ...warnings.map((line) => `- ${line}`), - "", - "Do you want to continue?" - ].join("\n"); + const message = [ + "You are about to close this project.", + "The following active work items will be terminated:", + ...warnings.map((line) => `- ${line}`), + "", + "Do you want to continue?", + ].join("\n"); - return window.confirm(message); - } catch { - return true; - } - }, [project?.rootPath]); + return window.confirm(message); + } catch { + return true; + } + }, + [project?.rootPath], + ); const handleOpenNew = useCallback(() => { if (isProjectBusy) return; openNewTab(); }, [isProjectBusy, openNewTab]); - const handleSwitchProject = useCallback((rootPath: string) => { + const handleOpenNewWindow = useCallback(() => { if (isProjectBusy) return; - if (project?.rootPath === rootPath) { - cancelNewTab(); + window.ade.app.newWindow().catch(() => {}); + }, [isProjectBusy]); + + const handleSwitchProject = useCallback( + (rootPath: string) => { + if (isProjectBusy) return; + if (!remoteBinding && project?.rootPath === rootPath) { + cancelNewTab(); + return; + } + switchProjectToPath(rootPath).catch(() => {}); + }, + [ + cancelNewTab, + isProjectBusy, + project?.rootPath, + remoteBinding, + switchProjectToPath, + ], + ); + + const handleSwitchRemoteProject = useCallback( + (binding: RemoteProjectTab) => { + if (isProjectBusy) return; + if (remoteBinding?.key === binding.key) { + cancelNewTab(); + return; + } + switchRemoteProject(binding.targetId, binding.projectId).catch(() => {}); + }, + [ + cancelNewTab, + isProjectBusy, + remoteBinding?.key, + switchRemoteProject, + ], + ); + + const handleRemoveTab = useCallback( + (rootPath: string) => { + void (async () => { + const target = projectTabs.find((entry) => entry.rootPath === rootPath); + const fallbackName = fallbackProjectName(rootPath); + const confirmed = confirmProjectTabRemoval( + target?.displayName ?? fallbackName, + ); + if (!confirmed) return; + + const shouldClose = await checkForActiveWorkloads(rootPath); + if (!shouldClose) return; + + const currentIndex = openProjectTabRoots.indexOf(rootPath); + const nextTabRoots = openProjectTabRoots.filter( + (entry) => entry !== rootPath, + ); + setOpenProjectTabRoots(nextTabRoots); + if (!remoteBinding && project?.rootPath === rootPath) { + const nextRoot = + nextTabRoots[currentIndex] ?? + nextTabRoots[currentIndex - 1] ?? + null; + if (nextRoot) { + switchProjectToPath(nextRoot).catch(() => {}); + } else if (openRemoteProjectTabs[0]) { + switchRemoteProject( + openRemoteProjectTabs[0].targetId, + openRemoteProjectTabs[0].projectId, + ).catch(() => {}); + } else { + closeProject().catch(() => {}); + } + } + })().catch(() => {}); + }, + [ + checkForActiveWorkloads, + closeProject, + openProjectTabRoots, + openRemoteProjectTabs, + project?.rootPath, + projectTabs, + remoteBinding, + switchProjectToPath, + switchRemoteProject, + ], + ); + + const handleCloseRemoteTab = useCallback((binding: RemoteProjectTab) => { + if (isProjectBusy) return; + const closedIndex = openRemoteProjectTabs.findIndex( + (entry) => entry.key === binding.key, + ); + const nextRemoteTabs = openRemoteProjectTabs.filter( + (entry) => entry.key !== binding.key, + ); + setOpenRemoteProjectTabs(nextRemoteTabs); + if (remoteBinding?.key !== binding.key) return; + + const nextRemoteTab = + nextRemoteTabs[closedIndex] ?? nextRemoteTabs[closedIndex - 1] ?? null; + if (nextRemoteTab) { + switchRemoteProject(nextRemoteTab.targetId, nextRemoteTab.projectId).catch( + () => {}, + ); return; } - switchProjectToPath(rootPath).catch(() => { }); - }, [cancelNewTab, isProjectBusy, project?.rootPath, switchProjectToPath]); - - const handleRemoveTab = useCallback((rootPath: string) => { - void (async () => { - const target = recentProjects.find((entry) => entry.rootPath === rootPath); - const fallbackName = rootPath.split(/[\\/]/).filter(Boolean).pop() ?? rootPath; - const confirmed = confirmProjectTabRemoval( - target?.displayName ?? fallbackName, - project?.rootPath === rootPath, - target?.exists === false, - ); - if (!confirmed) return; - - const shouldClose = await checkForActiveWorkloads(rootPath); - if (!shouldClose) return; - const rows = await window.ade.project.forgetRecent(rootPath).catch(() => null); - if (!rows) return; + const nextLocalRoot = + openProjectTabRoots[openProjectTabRoots.length - 1] ?? null; + if (nextLocalRoot) { + switchProjectToPath(nextLocalRoot).catch(() => {}); + } else { + closeProject().catch(() => {}); + } + }, [ + closeProject, + isProjectBusy, + openProjectTabRoots, + openRemoteProjectTabs, + remoteBinding?.key, + switchProjectToPath, + switchRemoteProject, + ]); + + const handleRelocate = useCallback( + (oldPath: string) => { + setRelocatingPath(oldPath); + void (async () => { + const newProject = await openRepo().catch(() => null); + if (!newProject) return; + const nextRows = await window.ade.project + .forgetRecent(oldPath) + .catch(() => null); + if (nextRows) setRecentProjects(nextRows); + })() + .catch(() => {}) + .finally(() => setRelocatingPath(null)); + }, + [openRepo], + ); - setRecentProjects(rows); - // If we just removed the active project, switch to the next available or show welcome. - if (project?.rootPath === rootPath) { - const next = rows.find((r) => r.exists && r.rootPath !== rootPath); - if (next) { - switchProjectToPath(next.rootPath).catch(() => { }); - } else { - closeProject().catch(() => { }); - } + const handleDragStart = useCallback( + (e: React.DragEvent, idx: number, rootPath: string) => { + setDragIdx(idx); + dragCounterRef.current = 0; + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(idx)); + e.dataTransfer.setData(ADE_PROJECT_TAB_ROOT_MIME, rootPath); + if (windowId != null) { + e.dataTransfer.setData(ADE_PROJECT_TAB_WINDOW_MIME, String(windowId)); } - })().catch(() => { }); - }, [checkForActiveWorkloads, project?.rootPath, recentProjects, closeProject, switchProjectToPath]); - - const handleRelocate = useCallback((oldPath: string) => { - setRelocatingPath(oldPath); - void (async () => { - const newProject = await openRepo().catch(() => null); - if (!newProject) return; - const nextRows = await window.ade.project.forgetRecent(oldPath).catch(() => null); - if (nextRows) setRecentProjects(nextRows); - })().catch(() => { }).finally(() => setRelocatingPath(null)); - }, [openRepo]); - - const handleDragStart = useCallback((e: React.DragEvent, idx: number) => { - setDragIdx(idx); - dragCounterRef.current = 0; - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", String(idx)); - }, []); + }, + [windowId], + ); const handleDragOver = useCallback((e: React.DragEvent, idx: number) => { e.preventDefault(); @@ -702,79 +1088,224 @@ export function TopBar() { setDropIdx(null); }, []); - const handleDrop = useCallback((e: React.DragEvent, targetIdx: number) => { - e.preventDefault(); - setDropIdx(null); - if (dragIdx === null || dragIdx === targetIdx) { + const handleDrop = useCallback( + (e: React.DragEvent, targetIdx: number) => { + if ( + dragIdx === null && + Array.from(e.dataTransfer.types).includes(ADE_PROJECT_TAB_ROOT_MIME) + ) { + return; + } + e.preventDefault(); + e.stopPropagation(); + setDropIdx(null); + if (dragIdx === null || dragIdx === targetIdx) { + setDragIdx(null); + return; + } + const items = [...openProjectTabRoots]; + const [moved] = items.splice(dragIdx, 1); + items.splice(targetIdx, 0, moved); + setOpenProjectTabRoots(items); + setDragIdx(null); + }, + [dragIdx, openProjectTabRoots], + ); + + const handleProjectTabDrop = useCallback( + (e: React.DragEvent) => { + const rootPath = e.dataTransfer.getData(ADE_PROJECT_TAB_ROOT_MIME); + if (!rootPath) return; + e.preventDefault(); + setDropIdx(null); setDragIdx(null); + + const sourceWindowIdRaw = e.dataTransfer.getData( + ADE_PROJECT_TAB_WINDOW_MIME, + ); + const parsedSourceWindowId = sourceWindowIdRaw + ? Number(sourceWindowIdRaw) + : null; + const sourceWindowId = + parsedSourceWindowId != null && Number.isFinite(parsedSourceWindowId) + ? parsedSourceWindowId + : null; + if (sourceWindowId != null && sourceWindowId === windowId) return; + + if (project?.rootPath === rootPath) { + if (sourceWindowId != null) { + window.ade.app.closeWindow(sourceWindowId).catch(() => {}); + } + return; + } + switchProjectToPath(rootPath).catch(() => {}); + }, + [project?.rootPath, switchProjectToPath, windowId], + ); + + const handleProjectTabDragOver = useCallback((e: React.DragEvent) => { + if (!Array.from(e.dataTransfer.types).includes(ADE_PROJECT_TAB_ROOT_MIME)) return; - } - const items = [...recentProjects]; - const [moved] = items.splice(dragIdx, 1); - items.splice(targetIdx, 0, moved); - setRecentProjects(items); - setDragIdx(null); - window.ade.project.reorderRecent(items.map((r) => r.rootPath)).catch(() => {}); - }, [dragIdx, recentProjects]); - - const handleDragEnd = useCallback(() => { - setDragIdx(null); - setDropIdx(null); + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; }, []); - const handleProjectAccentColorChange = useCallback((rootPath: string, color: string | null) => { - setProjectAccentColors((prev) => { - if ((prev[rootPath] ?? null) === color) return prev; - return { ...prev, [rootPath]: color }; - }); - }, []); + const handleDragEnd = useCallback( + (e: React.DragEvent, rootPath?: string) => { + const draggedOutside = + rootPath && + (e.clientX < 0 || + e.clientY < 0 || + e.clientX > window.innerWidth || + e.clientY > window.innerHeight); + const droppedOnAdeTarget = + e.dataTransfer.dropEffect && e.dataTransfer.dropEffect !== "none"; + setDragIdx(null); + setDropIdx(null); + if (!draggedOutside || droppedOnAdeTarget || !rootPath) return; + + // Fire IPC immediately so the new window starts spawning while we + // optimistically clean up the source window's tab state. + window.ade.app.openProjectInNewWindow(rootPath).catch(() => {}); + + // Detach skips the confirmation + active workload checks intentionally: + // the user already committed to detaching by dragging the tab out, and + // the work is moving to a new window rather than terminating. + const currentIndex = openProjectTabRoots.indexOf(rootPath); + if (currentIndex === -1) return; + const nextTabRoots = openProjectTabRoots.filter( + (entry) => entry !== rootPath, + ); + setOpenProjectTabRoots(nextTabRoots); + if (!remoteBinding && project?.rootPath === rootPath) { + const nextRoot = + nextTabRoots[currentIndex] ?? nextTabRoots[currentIndex - 1] ?? null; + if (nextRoot) { + switchProjectToPath(nextRoot).catch(() => {}); + } else if (openRemoteProjectTabs[0]) { + switchRemoteProject( + openRemoteProjectTabs[0].targetId, + openRemoteProjectTabs[0].projectId, + ).catch(() => {}); + } else { + closeProject().catch(() => {}); + } + } + }, + [ + closeProject, + openProjectTabRoots, + openRemoteProjectTabs, + project?.rootPath, + remoteBinding, + switchProjectToPath, + switchRemoteProject, + ], + ); - const handlePhoneSyncDialogKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => { - if (event.key === "Escape") { - event.preventDefault(); - setPhoneSyncOpen(false); - return; - } - if (event.key !== "Tab") return; - - const panel = phoneSyncPanelRef.current; - if (!panel) return; - const focusable = getFocusableElements(panel); - if (focusable.length === 0) { - event.preventDefault(); - panel.focus(); - return; - } + const handleProjectAccentColorChange = useCallback( + (rootPath: string, color: string | null) => { + setProjectAccentColors((prev) => { + if ((prev[rootPath] ?? null) === color) return prev; + return { ...prev, [rootPath]: color }; + }); + }, + [], + ); - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if (document.activeElement === panel) { - event.preventDefault(); - (event.shiftKey ? last : first).focus(); - } else if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } - }, []); + const handlePhoneSyncDialogKeyDown = useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.key === "Escape") { + event.preventDefault(); + setPhoneSyncOpen(false); + return; + } + if (event.key !== "Tab") return; + + const panel = phoneSyncPanelRef.current; + if (!panel) return; + const focusable = getFocusableElements(panel); + if (focusable.length === 0) { + event.preventDefault(); + panel.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (document.activeElement === panel) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } else if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }, + [], + ); + + const handleRemotePanelKeyDown = useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.key === "Escape") { + event.preventDefault(); + setRemotePanelOpen(false); + return; + } + if (event.key !== "Tab") return; + + const panel = remotePanelRef.current; + if (!panel) return; + const focusable = getFocusableElements(panel); + if (focusable.length === 0) { + event.preventDefault(); + panel.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (document.activeElement === panel) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } else if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }, + [], + ); const syncLabel = deriveSyncLabel(syncSnapshot); - const transitionTargetName = - projectTransition?.rootPath - ? (recentProjects.find((entry) => entry.rootPath === projectTransition.rootPath)?.displayName - ?? projectTransition.rootPath.split(/[\\/]/).filter(Boolean).pop() - ?? "project") - : "project"; - const projectTransitionLabel = - projectTransition == null - ? null - : projectTransition.kind === "opening" - ? "Opening project…" - : projectTransition.kind === "switching" - ? `Switching to ${transitionTargetName}…` - : "Closing project…"; + const transitionTargetName = projectTransition?.rootPath + ? (projectTabs.find( + (entry) => entry.rootPath === projectTransition.rootPath, + )?.displayName ?? + recentProjects.find( + (entry) => entry.rootPath === projectTransition.rootPath, + )?.displayName ?? + fallbackProjectName(projectTransition.rootPath) ?? + "project") + : "project"; + let projectTransitionLabel: string | null = null; + if (projectTransition != null) { + switch (projectTransition.kind) { + case "opening": + projectTransitionLabel = "Opening project…"; + break; + case "switching": + projectTransitionLabel = `Switching to ${transitionTargetName}…`; + break; + case "closing": + projectTransitionLabel = "Closing project…"; + break; + } + } return ( <header @@ -796,36 +1327,93 @@ export function TopBar() { {/* Project tabs — the container stays draggable, only interactive elements opt out */} <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto scrollbar-none" + onDragOver={handleProjectTabDragOver} + onDrop={handleProjectTabDrop} > - {recentProjects.length === 0 && !project ? ( - <button - type="button" - className={cn( - "ade-shell-project-tab inline-flex items-center gap-2 px-3 py-0.5", - "transition-[background-color,color,border-color,box-shadow] duration-150" - )} - style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} - onClick={handleOpenNew} - > - <Folder size={14} weight="regular" /> - Open a project - </button> - ) : ( + {openRemoteProjectTabs.length > 0 || + projectTabs.length > 0 || + isNewTabOpen ? ( <> - {recentProjects.map((rp, idx) => { - const isCurrent = project?.rootPath === rp.rootPath; + {openRemoteProjectTabs.map((remoteTab) => { + const isCurrentRemote = remoteBinding?.key === remoteTab.key; + return ( + <div + key={remoteTab.key} + role="button" + tabIndex={0} + data-state={isCurrentRemote ? "active" : undefined} + aria-current={isCurrentRemote ? "true" : undefined} + className={cn( + "ade-shell-project-tab group inline-flex w-[clamp(128px,16vw,220px)] max-w-[220px] min-w-0 shrink-0 items-center gap-2 px-3 py-0.5", + "font-semibold transition-[background-color,color,border-color,box-shadow,opacity] duration-150", + "cursor-pointer border border-warning/40", + )} + style={ + { WebkitAppRegion: "no-drag" } as React.CSSProperties + } + title={`${remoteTab.runtimeName}: ${remoteTab.rootPath}`} + onClick={() => handleSwitchRemoteProject(remoteTab)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleSwitchRemoteProject(remoteTab); + } + }} + > + <ProjectTabIcon + rootPath={remoteTab.rootPath} + isCurrent={isCurrentRemote} + animate={false} + disabled={false} + readOnly={true} + /> + <span className="min-w-0 flex-1 truncate text-center text-[12px]"> + {remoteTab.displayName} + </span> + <DesktopTower + size={11} + weight="duotone" + className="shrink-0 text-warning" + aria-label={`Remote: ${remoteTab.runtimeName}`} + /> + <button + type="button" + className={cn( + "ade-shell-control ml-auto inline-flex h-5 w-5 shrink-0 items-center justify-center text-current", + "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-150", + )} + data-variant="ghost" + disabled={isProjectBusy} + onClick={(e) => { + e.stopPropagation(); + handleCloseRemoteTab(remoteTab); + }} + title="Close remote project" + > + <X size={13} weight="regular" /> + </button> + </div> + ); + })} + {projectTabs.map((rp, idx) => { + const isCurrent = + !remoteBinding && project?.rootPath === rp.rootPath; const isMissing = !rp.exists; const isRelocating = relocatingPath === rp.rootPath; const isSwitchTarget = - projectTransition?.kind === "switching" && projectTransition.rootPath === rp.rootPath; + projectTransition?.kind === "switching" && + projectTransition.rootPath === rp.rootPath; const isClosingTarget = projectTransition?.kind === "closing" && isCurrent; const isDragging = dragIdx === idx; const isDropTarget = dropIdx === idx && dragIdx !== idx; - const projectAccentColor = projectAccentColors[rp.rootPath] ?? null; + const projectAccentColor = + projectAccentColors[rp.rootPath] ?? null; const projectTabStyle = { WebkitAppRegion: "no-drag", - ...(projectAccentColor ? { "--project-tab-accent": projectAccentColor } : {}), + ...(projectAccentColor + ? { "--project-tab-accent": projectAccentColor } + : {}), } as React.CSSProperties; let projectTabState: string | undefined; if (isRelocating) projectTabState = "open"; @@ -838,24 +1426,31 @@ export function TopBar() { role={isMissing ? undefined : "button"} tabIndex={isMissing ? -1 : 0} data-state={projectTabState} - data-tour={isCurrent && workspaceProjectOpen ? "project.activeTab" : undefined} + data-tour={ + isCurrent && workspaceProjectOpen + ? "project.activeTab" + : undefined + } aria-current={isCurrent ? "true" : undefined} - aria-disabled={isRelocating || isProjectBusy ? true : undefined} + aria-disabled={ + isRelocating || isProjectBusy ? true : undefined + } draggable={!isMissing && !isRelocating && !isProjectBusy} - onDragStart={(e) => handleDragStart(e, idx)} + onDragStart={(e) => handleDragStart(e, idx, rp.rootPath)} onDragOver={(e) => handleDragOver(e, idx)} onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, idx)} - onDragEnd={handleDragEnd} + onDragEnd={(e) => handleDragEnd(e, rp.rootPath)} className={cn( "ade-shell-project-tab group inline-flex w-[clamp(128px,16vw,220px)] max-w-[220px] min-w-0 shrink-0 items-center gap-2 px-3 py-0.5", "transition-[background-color,color,border-color,box-shadow,opacity] duration-150", !isMissing && "cursor-pointer", isCurrent && "font-semibold", isRelocating && "pointer-events-none opacity-80", - (isSwitchTarget || isClosingTarget) && "pointer-events-none opacity-80", + (isSwitchTarget || isClosingTarget) && + "pointer-events-none opacity-80", isDragging && "opacity-40", - isDropTarget && "ring-1 ring-accent/50" + isDropTarget && "ring-1 ring-accent/50", )} style={projectTabStyle} onClick={() => { @@ -878,7 +1473,11 @@ export function TopBar() { onAccentColorChange={handleProjectAccentColorChange} /> {isSwitchTarget || isClosingTarget ? ( - <CircleNotch size={12} weight="bold" className="shrink-0 animate-spin opacity-80" /> + <CircleNotch + size={12} + weight="bold" + className="shrink-0 animate-spin opacity-80" + /> ) : null} {isCurrent && indicator != null && indicator !== "none" ? ( <span @@ -891,14 +1490,14 @@ export function TopBar() { "ade-status-dot h-1.5 w-1.5 shrink-0", indicator === "running-needs-attention" ? "ade-status-dot-warning" - : "ade-status-dot-active" + : "ade-status-dot-active", )} /> ) : null} <span className={cn( - "min-w-0 flex-1 truncate", - isMissing && "line-through" + "min-w-0 flex-1 truncate text-center", + isMissing && "line-through", )} > {rp.displayName} @@ -918,7 +1517,11 @@ export function TopBar() { }} title="Relocate project" > - <FolderOpen size={13} weight="regular" className={cn(isRelocating && "animate-pulse")} /> + <FolderOpen + size={13} + weight="regular" + className={cn(isRelocating && "animate-pulse")} + /> </button> <button type="button" @@ -940,7 +1543,7 @@ export function TopBar() { type="button" className={cn( "ade-shell-control ml-auto inline-flex h-5 w-5 shrink-0 items-center justify-center text-current", - "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-150" + "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-150", )} data-variant="ghost" disabled={isProjectBusy} @@ -962,24 +1565,35 @@ export function TopBar() { className={cn( "ade-shell-project-tab group inline-flex w-[clamp(128px,16vw,220px)] max-w-[220px] min-w-0 items-center gap-2 px-3 py-0.5", "transition-[background-color,color,border-color,box-shadow] duration-150", - "font-semibold" + "font-semibold", )} data-state="active" style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} > {projectTransition?.kind === "opening" ? ( - <CircleNotch size={13} weight="bold" className="animate-spin" /> + <CircleNotch + size={13} + weight="bold" + className="animate-spin" + /> ) : ( - <img src="./logo.png" alt="" style={{ height: 16, width: 34, objectFit: "contain" }} draggable={false} /> + <img + src="./logo.png" + alt="" + style={{ height: 16, width: 34, objectFit: "contain" }} + draggable={false} + /> )} <span className="min-w-0 flex-1 truncate text-[12px]"> - {projectTransition?.kind === "opening" ? "Opening…" : "New Tab"} + {projectTransition?.kind === "opening" + ? "Opening…" + : "New Tab"} </span> <button type="button" className={cn( "ade-shell-control ml-auto inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-sm", - "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-150" + "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-150", )} data-variant="ghost" disabled={isProjectBusy} @@ -995,7 +1609,7 @@ export function TopBar() { </div> )} </> - )} + ) : null} {/* Add project button */} <button @@ -1003,16 +1617,46 @@ export function TopBar() { data-tour="project.addProject" className={cn( "ade-shell-control inline-flex h-5.5 w-5.5 shrink-0 items-center justify-center", - "transition-[background-color,color,border-color,box-shadow] duration-150" + "transition-[background-color,color,border-color,box-shadow] duration-150", )} data-variant="ghost" onClick={handleOpenNew} disabled={isProjectBusy} - title="Open another project" - style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} + title={ + connectedRemoteCount > 0 + ? `${connectedRemoteCount} remote device${connectedRemoteCount === 1 ? "" : "s"} available` + : "Open another project" + } + style={ + { + WebkitAppRegion: "no-drag", + ...(connectedRemoteCount > 0 + ? { + color: "#FBBF24", + borderColor: "rgba(245,158,11,0.58)", + boxShadow: + "0 0 0 1px rgba(245,158,11,0.20), 0 0 16px -8px rgba(245,158,11,0.9)", + } + : {}), + } as React.CSSProperties + } > <Plus size={12} weight="regular" /> </button> + <button + type="button" + className={cn( + "ade-shell-control inline-flex h-5.5 w-5.5 shrink-0 items-center justify-center", + "transition-[background-color,color,border-color,box-shadow] duration-150", + )} + data-variant="ghost" + onClick={handleOpenNewWindow} + disabled={isProjectBusy} + title="New window" + style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} + > + <ArrowSquareOut size={12} weight="regular" /> + </button> </div> {showPublishPill ? ( @@ -1037,8 +1681,10 @@ export function TopBar() { letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-accent)", - background: "color-mix(in srgb, var(--color-accent) 18%, transparent)", - border: "1px solid color-mix(in srgb, var(--color-accent) 36%, transparent)", + background: + "color-mix(in srgb, var(--color-accent) 18%, transparent)", + border: + "1px solid color-mix(in srgb, var(--color-accent) 36%, transparent)", borderRadius: 6, cursor: isProjectBusy ? "not-allowed" : "pointer", opacity: isProjectBusy ? 0.55 : 1, @@ -1054,13 +1700,15 @@ export function TopBar() { <div className={cn( "ade-shell-control shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1", - "text-[11px] font-medium" + "text-[11px] font-medium", )} style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} title={projectTransitionLabel} > <CircleNotch size={12} weight="bold" className="animate-spin" /> - <span className="max-w-[240px] truncate">{projectTransitionLabel}</span> + <span className="max-w-[240px] truncate"> + {projectTransitionLabel} + </span> </div> ) : null} @@ -1068,12 +1716,14 @@ export function TopBar() { <div className={cn( "ade-shell-control shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1", - "text-[11px] font-medium text-red-300" + "text-[11px] font-medium text-red-300", )} style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} title={projectTransitionError} > - <span className="max-w-[320px] truncate">{projectTransitionError}</span> + <span className="max-w-[320px] truncate"> + {projectTransitionError} + </span> <button type="button" className="inline-flex h-4 w-4 items-center justify-center rounded-sm text-current opacity-80 transition-opacity hover:opacity-100" @@ -1087,20 +1737,63 @@ export function TopBar() { <LinearQuickViewButton /> + <button + type="button" + className={cn( + "ade-shell-control shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1", + "text-[11px] font-medium transition-colors duration-150", + )} + style={ + { + WebkitAppRegion: "no-drag", + color: "#FBBF24", + background: + connectedRemoteCount > 0 + ? "rgba(245,158,11,0.16)" + : "rgba(245,158,11,0.08)", + border: "1px solid rgba(245,158,11,0.34)", + boxShadow: + connectedRemoteCount > 0 + ? "0 0 18px -8px rgba(245,158,11,0.9)" + : undefined, + } as React.CSSProperties + } + title="Manage remote machines" + aria-expanded={remotePanelOpen} + onClick={() => setRemotePanelOpen((open) => !open)} + > + <DesktopTower + size={12} + weight="regular" + className="shrink-0 opacity-90" + /> + <span + className={cn( + "ade-status-dot h-1.5 w-1.5 shrink-0", + connectedRemoteCount > 0 ? "bg-emerald-400" : "bg-amber-400/65", + )} + /> + {remoteButtonLabel} + </button> + {syncSnapshot && syncLabel ? ( <button type="button" className={cn( "ade-shell-control shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1", - "text-[11px] font-medium transition-colors duration-150" + "text-[11px] font-medium transition-colors duration-150", )} data-variant="ghost" style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} - title="Connect a phone to this computer" + title="Connect a phone to this machine" aria-expanded={phoneSyncOpen} onClick={() => setPhoneSyncOpen((open) => !open)} > - <DeviceMobile size={12} weight="regular" className="shrink-0 opacity-85" /> + <DeviceMobile + size={12} + weight="regular" + className="shrink-0 opacity-85" + /> <span className={cn( "ade-status-dot h-1.5 w-1.5 shrink-0", @@ -1111,6 +1804,61 @@ export function TopBar() { </button> ) : null} + {remotePanelOpen ? ( + <div + className="fixed inset-0 z-[80]" + style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} + onClick={() => setRemotePanelOpen(false)} + > + <div + ref={remotePanelRef} + className={cn( + "absolute right-3 top-10 max-h-[calc(100vh-72px)] w-[min(820px,calc(100vw-24px))] overflow-y-auto", + "rounded-xl border border-white/10 bg-[color:var(--ade-shell-surface,#121019)] shadow-2xl shadow-black/45", + )} + role="dialog" + aria-modal="true" + aria-labelledby="remote-connections-title" + tabIndex={-1} + onClick={(event) => event.stopPropagation()} + onKeyDown={handleRemotePanelKeyDown} + > + <div className="sticky top-0 z-10 flex items-center justify-between border-b border-white/10 bg-[color:var(--ade-shell-surface,#121019)] px-4 py-3"> + <div className="flex min-w-0 items-center gap-2"> + <DesktopTower + size={16} + weight="regular" + className="shrink-0 text-[#FBBF24]" + /> + <div className="min-w-0"> + <div + id="remote-connections-title" + className="truncate text-[13px] font-semibold" + > + Remote machines + </div> + <div className="truncate text-[11px] text-white/55"> + {connectedRemoteCount} connected + </div> + </div> + </div> + <button + type="button" + className="ade-shell-control inline-flex h-7 w-7 items-center justify-center rounded-md" + data-variant="ghost" + onClick={() => setRemotePanelOpen(false)} + title="Close remote machines" + > + <X size={13} weight="regular" /> + </button> + </div> + <div className="p-4"> + <RemoteTargetList /> + </div> + </div> + </div> + ) : null} + {phoneSyncOpen ? ( <div className="fixed inset-0 z-[80]" @@ -1121,7 +1869,7 @@ export function TopBar() { ref={phoneSyncPanelRef} className={cn( "absolute right-3 top-10 max-h-[calc(100vh-72px)] w-[min(620px,calc(100vw-24px))] overflow-y-auto", - "rounded-xl border border-white/10 bg-[color:var(--ade-shell-surface,#121019)] shadow-2xl shadow-black/45" + "rounded-xl border border-white/10 bg-[color:var(--ade-shell-surface,#121019)] shadow-2xl shadow-black/45", )} role="dialog" aria-modal="true" @@ -1132,12 +1880,21 @@ export function TopBar() { > <div className="sticky top-0 z-10 flex items-center justify-between border-b border-white/10 bg-[color:var(--ade-shell-surface,#121019)] px-4 py-3"> <div className="flex min-w-0 items-center gap-2"> - <DeviceMobile size={16} weight="regular" className="shrink-0 opacity-85" /> + <DeviceMobile + size={16} + weight="regular" + className="shrink-0 opacity-85" + /> <div className="min-w-0"> - <div id="phone-sync-title" className="truncate text-[13px] font-semibold"> + <div + id="phone-sync-title" + className="truncate text-[13px] font-semibold" + > Connect to the ADE mobile app </div> - <div className="truncate text-[11px] text-white/55">{syncLabel}</div> + <div className="truncate text-[11px] text-white/55"> + {syncLabel} + </div> </div> </div> <button @@ -1167,7 +1924,7 @@ export function TopBar() { type="button" className={cn( "ade-shell-control inline-flex h-[20px] w-[20px] items-center justify-center", - "transition-[background-color,color,border-color,box-shadow] duration-150" + "transition-[background-color,color,border-color,box-shadow] duration-150", )} onClick={() => setFeedbackOpen(true)} title="Report bug or suggest feature" @@ -1176,7 +1933,10 @@ export function TopBar() { <ChatCircleDots size={12} weight="regular" /> </button> - <FeedbackReporterModal open={feedbackOpen} onOpenChange={setFeedbackOpen} /> + <FeedbackReporterModal + open={feedbackOpen} + onOpenChange={setFeedbackOpen} + /> <PublishToGitHubDialog open={publishOpen} @@ -1196,7 +1956,7 @@ export function TopBar() { type="button" className={cn( "ade-shell-control inline-flex h-[20px] w-[20px] items-center justify-center", - "transition-[background-color,color,border-color,box-shadow] duration-150" + "transition-[background-color,color,border-color,box-shadow] duration-150", )} onClick={zoomOut} title="Zoom out" @@ -1207,7 +1967,7 @@ export function TopBar() { className={cn( "ade-shell-control-kbd inline-flex h-[20px] items-center justify-center border-x-0 px-1.5", "text-[10px] font-mono select-none", - "min-w-[36px] text-center" + "min-w-[36px] text-center", )} > {zoom}% @@ -1216,7 +1976,7 @@ export function TopBar() { type="button" className={cn( "ade-shell-control inline-flex h-[20px] w-[20px] items-center justify-center", - "transition-[background-color,color,border-color,box-shadow] duration-150" + "transition-[background-color,color,border-color,box-shadow] duration-150", )} onClick={zoomIn} title="Zoom in" diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 70f373cea..6080829d5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -78,6 +78,7 @@ import { type ChatTranscriptRenderEnvelope as TranscriptRenderEnvelope, } from "./chatTranscriptRows"; import { ChatUserMinimap } from "./ChatUserMinimap"; +import { AgentCliAuthCard, type AgentCliAuthCardInfo } from "./AgentCliAuthCard"; import { CHAT_TIMELINE_ROW_GAP_PX, buildMinimapDisplayEntries, @@ -1920,7 +1921,10 @@ function renderEvent( respondingApprovalIds?: Set<string>; pendingApprovalIds?: Set<string>; resolvedInputStates?: Map<string, PendingInputResolution>; + laneId?: string | null; sessionId?: string | null; + runtimeName?: string | null; + onRevealChatTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; } ) { const event = envelope.event; @@ -2764,6 +2768,10 @@ function renderEvent( /* ── Error ── */ if (event.type === "error") { + const agentCliInfo: AgentCliAuthCardInfo | null = + typeof event.errorInfo === "object" && event.errorInfo?.agentCli + ? event.errorInfo.agentCli + : null; const errorCopyValue = event.detail?.trim().length ? `${event.message}\n\n${event.detail}` : event.message; @@ -2791,7 +2799,16 @@ function renderEvent( {event.detail} </div> ) : null} - {event.errorInfo ? ( + {agentCliInfo ? ( + <AgentCliAuthCard + agentCli={agentCliInfo} + laneId={options?.laneId} + chatSessionId={options?.sessionId} + runtimeName={options?.runtimeName} + onRevealTerminal={options?.onRevealChatTerminal} + /> + ) : null} + {event.errorInfo && !agentCliInfo ? ( <div className="mt-2 font-mono text-[length:calc(var(--chat-font-size)*10/14)] text-muted-fg/40"> {typeof event.errorInfo === "string" ? event.errorInfo : `${event.errorInfo.provider ? `${event.errorInfo.provider}` : ""}${event.errorInfo.model ? ` / ${event.errorInfo.model}` : ""}`} </div> @@ -3314,7 +3331,9 @@ type EventRowProps = { respondingApprovalIds?: Set<string>; pendingApprovalIds?: Set<string>; resolvedInputStates?: Map<string, PendingInputResolution>; + laneId?: string | null; sessionId?: string | null; + runtimeName?: string | null; }; const EventRow = React.memo(function EventRow({ @@ -3337,7 +3356,9 @@ const EventRow = React.memo(function EventRow({ respondingApprovalIds, pendingApprovalIds, resolvedInputStates, + laneId, sessionId, + runtimeName, }: EventRowProps) { const workLogAnimate = Boolean(turnActive) && !sessionEnded @@ -3385,7 +3406,10 @@ const EventRow = React.memo(function EventRow({ respondingApprovalIds, pendingApprovalIds, resolvedInputStates, + laneId, sessionId, + runtimeName, + onRevealChatTerminal, })} </div> ); @@ -3542,6 +3566,7 @@ export function AgentChatMessageList({ onOpenWorkspacePath, respondingApprovalIds, pendingApprovalIds, + laneId, sessionId, onInsertDraft, onRevealChatTerminal, @@ -3559,10 +3584,12 @@ export function AgentChatMessageList({ onRevealChatTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; respondingApprovalIds?: Set<string>; pendingApprovalIds?: Set<string>; + laneId?: string | null; sessionId?: string | null; sessionEnded?: boolean; }) { const chatTranscriptDensity = useAppStore((s) => s.chatTranscriptDensity); + const runtimeName = useAppStore((s) => s.projectBinding?.kind === "remote" ? s.projectBinding.runtimeName : null); const timelineRowGapPx = useMemo(() => transcriptRowGapPx(chatTranscriptDensity), [chatTranscriptDensity]); const scrollRef = useRef<HTMLDivElement | null>(null); const contentWrapperRef = useRef<HTMLDivElement | null>(null); @@ -3971,7 +3998,9 @@ export function AgentChatMessageList({ respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} resolvedInputStates={resolvedInputStates} + laneId={laneId} sessionId={sessionId} + runtimeName={runtimeName} /> ); } @@ -3998,10 +4027,12 @@ export function AgentChatMessageList({ respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} resolvedInputStates={resolvedInputStates} + laneId={laneId} sessionId={sessionId} + runtimeName={runtimeName} /> ); - }, [activeTurnId, assistantLabel, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onInsertDraft, onRevealChatTerminal, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, sessionId, sessionEnded]); + }, [activeTurnId, assistantLabel, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onInsertDraft, onRevealChatTerminal, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionEnded, runtimeName]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 69503a85b..be8ceda41 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -6150,6 +6150,7 @@ export function AgentChatPane({ assistantLabel={assistantLabel} respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} + laneId={laneId} sessionId={selectedSessionId} onInsertDraft={insertComposerDraft} onRevealChatTerminal={(terminal) => { diff --git a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.test.tsx b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.test.tsx new file mode 100644 index 000000000..6b8d0b028 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.test.tsx @@ -0,0 +1,142 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { AgentCliAuthCard, type AgentCliAuthCardInfo } from "./AgentCliAuthCard"; + +const originalAde = globalThis.window.ade; + +const missingCli: AgentCliAuthCardInfo = { + agent: "codex", + displayName: "Codex", + category: "missing", + installCommand: "npm install -g @openai/codex", + authCommand: "codex login", +}; + +const unauthenticatedCli: AgentCliAuthCardInfo = { + ...missingCli, + category: "unauthenticated", +}; + +function installAdeStub() { + globalThis.window.ade = { + pty: { + create: vi.fn().mockResolvedValue({ + sessionId: "terminal-auth-1", + ptyId: "pty-auth-1", + pid: 1234, + }), + }, + lanes: { + list: vi.fn().mockResolvedValue([{ id: "lane-default", name: "Main" }]), + }, + } as any; +} + +describe("AgentCliAuthCard", () => { + beforeEach(() => { + installAdeStub(); + }); + + afterEach(() => { + cleanup(); + if (originalAde === undefined) { + delete (globalThis.window as any).ade; + } else { + globalThis.window.ade = originalAde; + } + }); + + it("opens install commands in the chat PTY context", async () => { + const onRevealTerminal = vi.fn(); + + render( + <AgentCliAuthCard + agentCli={missingCli} + laneId="lane-1" + chatSessionId="chat-1" + onRevealTerminal={onRevealTerminal} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: /run install/i })); + + await waitFor(() => { + expect(window.ade.pty.create).toHaveBeenCalledWith({ + laneId: "lane-1", + chatSessionId: "chat-1", + cols: 100, + rows: 28, + title: "install", + tracked: true, + toolType: "shell", + startupCommand: "npm install -g @openai/codex", + }); + }); + expect(onRevealTerminal).toHaveBeenCalledWith({ + terminalId: "terminal-auth-1", + ptyId: "pty-auth-1", + label: "install", + }); + }); + + it("opens auth commands in the chat PTY context", async () => { + const onRevealTerminal = vi.fn(); + + render( + <AgentCliAuthCard + agentCli={unauthenticatedCli} + laneId="lane-1" + chatSessionId="chat-1" + onRevealTerminal={onRevealTerminal} + />, + ); + + expect(screen.queryByText("npm install -g @openai/codex")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /run auth/i })); + + await waitFor(() => { + expect(window.ade.pty.create).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-1", + chatSessionId: "chat-1", + title: "auth", + toolType: "shell", + startupCommand: "codex login", + })); + }); + expect(onRevealTerminal).toHaveBeenCalledWith({ + terminalId: "terminal-auth-1", + ptyId: "pty-auth-1", + label: "auth", + }); + }); + + it("names the remote runtime when the auth flow runs away from the local machine", () => { + render(<AgentCliAuthCard agentCli={missingCli} runtimeName="Mac Studio" />); + + expect(screen.getByText(/Install the CLI on Mac Studio/i)).toBeTruthy(); + }); + + it("uses the project default lane when no chat context is available", async () => { + render(<AgentCliAuthCard agentCli={missingCli} />); + + const runInstall = screen.getByRole("button", { name: /run install/i }); + expect(runInstall).toHaveProperty("disabled", false); + + fireEvent.click(runInstall); + + await waitFor(() => { + expect(window.ade.lanes.list).toHaveBeenCalledWith({ + includeArchived: false, + includeStatus: false, + }); + expect(window.ade.pty.create).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-default", + startupCommand: "npm install -g @openai/codex", + })); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx new file mode 100644 index 000000000..fd2b84ed8 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx @@ -0,0 +1,193 @@ +import { useCallback, useState } from "react"; +import { CopySimple, Play, Terminal, Warning } from "@phosphor-icons/react"; +import { cn } from "../ui/cn"; + +export type AgentCliAuthCardInfo = { + agent: string; + displayName: string; + category: "missing" | "unauthenticated"; + installCommand: string; + authCommand: string; +}; + +function CommandCopyButton({ command, label }: { command: string; label: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) return; + void navigator.clipboard.writeText(command) + .then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1_500); + }) + .catch(() => setCopied(false)); + }, [command]); + + return ( + <button + type="button" + onClick={handleCopy} + className="inline-flex items-center gap-1.5 rounded-md border border-white/[0.08] bg-white/[0.04] px-2 py-1 font-mono text-[length:calc(var(--chat-font-size)*9/14)] font-bold uppercase tracking-[0.14em] text-fg/58 transition-colors hover:border-amber-300/25 hover:bg-amber-300/[0.07] hover:text-amber-100" + title={copied ? "Copied" : `Copy ${label}`} + > + <CopySimple size={12} weight={copied ? "fill" : "regular"} aria-hidden /> + {copied ? "Copied" : label} + </button> + ); +} + +function ShellRunButton({ + command, + label, + laneId, + chatSessionId, + onRevealTerminal, +}: { + command: string; + label: string; + laneId?: string | null; + chatSessionId?: string | null; + onRevealTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; +}) { + const [running, setRunning] = useState(false); + const [error, setError] = useState<string | null>(null); + const disabled = running || !window.ade?.pty?.create || (!laneId && !window.ade?.lanes?.list); + + const handleRun = useCallback(() => { + if (disabled) return; + setRunning(true); + setError(null); + const terminalLabel = label.replace(/^Run\s+/i, ""); + void (async () => { + const resolvedLaneId = laneId ?? (await window.ade.lanes.list({ + includeArchived: false, + includeStatus: false, + }))[0]?.id ?? null; + if (!resolvedLaneId) { + throw new Error("No active lane is available for this project."); + } + return window.ade.pty.create({ + laneId: resolvedLaneId, + ...(chatSessionId ? { chatSessionId } : {}), + cols: 100, + rows: 28, + title: terminalLabel, + tracked: true, + toolType: "shell", + startupCommand: command, + }); + })() + .then((created) => { + onRevealTerminal?.({ + terminalId: created.sessionId, + ptyId: created.ptyId, + label: terminalLabel, + }); + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => setRunning(false)); + }, [chatSessionId, command, disabled, label, laneId, onRevealTerminal]); + + return ( + <div className="flex flex-col items-end gap-1"> + <button + type="button" + onClick={handleRun} + disabled={disabled} + className="inline-flex items-center gap-1.5 rounded-md border border-amber-300/20 bg-amber-300/[0.08] px-2 py-1 font-mono text-[length:calc(var(--chat-font-size)*9/14)] font-bold uppercase tracking-[0.14em] text-amber-100/82 transition-colors hover:border-amber-300/35 hover:bg-amber-300/[0.12] disabled:pointer-events-none disabled:opacity-45" + title={!laneId && !window.ade?.lanes?.list ? "Open a project to run this command" : label} + > + <Play size={12} weight={running ? "fill" : "bold"} aria-hidden /> + {running ? "Opening" : label} + </button> + {error ? ( + <div className="max-w-[22rem] text-right font-mono text-[length:calc(var(--chat-font-size)*9/14)] text-red-200/70"> + {error} + </div> + ) : null} + </div> + ); +} + +export function AgentCliAuthCard({ + agentCli, + laneId, + chatSessionId, + runtimeName, + onRevealTerminal, +}: { + agentCli: AgentCliAuthCardInfo; + laneId?: string | null; + chatSessionId?: string | null; + runtimeName?: string | null; + onRevealTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; +}) { + const missing = agentCli.category === "missing"; + const installLocation = runtimeName?.trim() ? runtimeName.trim() : "this machine"; + const title = missing + ? `${agentCli.displayName} is not installed` + : `${agentCli.displayName} needs authentication`; + const body = missing + ? `Install the CLI on ${installLocation}, authenticate it, then retry the chat.` + : `Authenticate the CLI on ${installLocation}, then retry the chat.`; + + return ( + <div className="mt-3 overflow-hidden rounded-[calc(var(--chat-radius-card)-6px)] border border-amber-300/14 bg-amber-300/[0.045]"> + <div className="flex items-start gap-3 p-3"> + <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-amber-300/15 bg-amber-300/[0.08] text-amber-200"> + {missing ? <Terminal size={15} weight="bold" aria-hidden /> : <Warning size={15} weight="bold" aria-hidden />} + </div> + <div className="min-w-0 flex-1"> + <div className="font-sans text-[length:calc(var(--chat-font-size)*12/14)] font-semibold text-amber-100/90"> + {title} + </div> + <div className="mt-1 text-[length:calc(var(--chat-font-size)*11/14)] leading-relaxed text-fg/66"> + {body} + </div> + <div className="mt-3 grid gap-2"> + {missing ? ( + <div className="grid gap-1.5"> + <div className="font-mono text-[length:calc(var(--chat-font-size)*9/14)] font-bold uppercase tracking-[0.16em] text-muted-fg/45"> + Install + </div> + <div className="flex flex-wrap items-center gap-2 rounded-lg border border-white/[0.06] bg-black/20 px-2.5 py-2"> + <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[length:calc(var(--chat-font-size)*11/14)] text-fg/78"> + {agentCli.installCommand} + </code> + <ShellRunButton + command={agentCli.installCommand} + label="Run install" + laneId={laneId} + chatSessionId={chatSessionId} + onRevealTerminal={onRevealTerminal} + /> + <CommandCopyButton command={agentCli.installCommand} label="Copy install" /> + </div> + </div> + ) : null} + <div className={cn("grid gap-1.5", missing ? "" : "mt-0")}> + <div className="font-mono text-[length:calc(var(--chat-font-size)*9/14)] font-bold uppercase tracking-[0.16em] text-muted-fg/45"> + Authenticate + </div> + <div className="flex flex-wrap items-center gap-2 rounded-lg border border-white/[0.06] bg-black/20 px-2.5 py-2"> + <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[length:calc(var(--chat-font-size)*11/14)] text-fg/78"> + {agentCli.authCommand} + </code> + <ShellRunButton + command={agentCli.authCommand} + label="Run auth" + laneId={laneId} + chatSessionId={chatSessionId} + onRevealTerminal={onRevealTerminal} + /> + <CommandCopyButton command={agentCli.authCommand} label="Copy auth" /> + </div> + </div> + </div> + </div> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/components/cto/CtoPage.tsx b/apps/desktop/src/renderer/components/cto/CtoPage.tsx index e9314b949..294320a75 100644 --- a/apps/desktop/src/renderer/components/cto/CtoPage.tsx +++ b/apps/desktop/src/renderer/components/cto/CtoPage.tsx @@ -20,7 +20,6 @@ import type { AgentChatSessionSummary, ChatSurfacePresentation, HeartbeatPolicy, - OpenclawBridgeStatus, WorkerAgentRun, } from "../../../shared/types"; import { AgentChatPane } from "../chat/AgentChatPane"; @@ -85,7 +84,6 @@ export function CtoPage() { const [ctoIdentity, setCtoIdentity] = useState<CtoIdentity | null>(null); const [coreMemory, setCoreMemory] = useState<CtoCoreMemory | null>(null); const [sessionLogs, setSessionLogs] = useState<CtoSessionLogEntry[]>([]); - const [openclawStatus, setOpenclawStatus] = useState<OpenclawBridgeStatus | null>(null); useEffect(() => { const onTourTab = (event: Event) => { @@ -236,13 +234,6 @@ export function CtoPage() { void loadCtoHistory(); }, [activeTab, loadCtoHistory]); - useEffect(() => { - const unsubscribe = window.ade?.cto?.onOpenclawConnectionStatus?.((status) => { - setOpenclawStatus(status); - }); - return () => unsubscribe?.(); - }, []); - // Load revisions when worker selected useEffect(() => { if (!window.ade?.cto || !selectedAgentId) { setRevisions([]); return; } @@ -367,9 +358,7 @@ export function CtoPage() { try { const at = workerDraft.adapterType; const adapterConfig: Record<string, unknown> = - at === "openclaw-webhook" - ? { url: workerDraft.webhookUrl, ...(workerDraft.authHeader.trim() ? { headers: { Authorization: workerDraft.authHeader.trim() } } : {}) } - : at === "process" + at === "process" ? { command: workerDraft.processCommand } : { ...(workerDraft.model.trim() ? { model: workerDraft.model.trim() } : {}) }; diff --git a/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.tsx b/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.tsx index 72081a67a..bd6119659 100644 --- a/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.tsx +++ b/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.tsx @@ -7,7 +7,6 @@ import { Button } from "../ui/Button"; import { cn } from "../ui/cn"; import { inputCls, labelCls, textareaCls } from "./shared/designTokens"; import { SmartTooltip } from "../ui/SmartTooltip"; -import { OpenclawConnectionPanel } from "./OpenclawConnectionPanel"; import { getCtoPersonalityPreset } from "./identityPresets"; import { CtoPromptPreview } from "./CtoPromptPreview"; @@ -82,12 +81,11 @@ export function CtoSettingsPanel({ finally { setMemorySaving(false); } }; - const [settingsTab, setSettingsTab] = useState<"identity" | "brief" | "integrations">("identity"); + const [settingsTab, setSettingsTab] = useState<"identity" | "brief">("identity"); const SUB_TABS = [ { id: "identity" as const, label: "Identity", tooltip: "CTO personality, model, and reasoning configuration." }, { id: "brief" as const, label: "Brief", tooltip: "Project summary, conventions, and focus areas that persist across sessions." }, - { id: "integrations" as const, label: "Integrations", tooltip: "OpenClaw bridge configuration." }, ]; return ( @@ -246,17 +244,6 @@ export function CtoSettingsPanel({ </div> </> )} - - {/* ── Integrations sub-tab ── */} - {settingsTab === "integrations" && ( - <div className="space-y-4"> - {/* OpenClaw Bridge card */} - <div className="rounded-xl border border-white/[0.07] bg-[linear-gradient(180deg,rgba(26,24,48,0.7),rgba(18,16,34,0.8))] backdrop-blur-[20px] shadow-card p-4" style={{ borderLeft: "3px solid #FB7185" }}> - <div className="text-xs font-semibold text-fg mb-3">OpenClaw Bridge</div> - <OpenclawConnectionPanel identity={identity} onSaveIdentity={onSaveIdentity} /> - </div> - </div> - )} </div> </div> ); diff --git a/apps/desktop/src/renderer/components/cto/OpenclawConnectionPanel.tsx b/apps/desktop/src/renderer/components/cto/OpenclawConnectionPanel.tsx deleted file mode 100644 index 38c7d0f1c..000000000 --- a/apps/desktop/src/renderer/components/cto/OpenclawConnectionPanel.tsx +++ /dev/null @@ -1,626 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - ArrowCounterClockwise, - CheckCircle, - CircleNotch, - WarningCircle, -} from "@phosphor-icons/react"; -import type { - CtoIdentity, - OpenclawMessageRecord, - OpenclawBridgeState, - OpenclawBridgeStatus, - OpenclawNotificationRoute, - OpenclawNotificationType, -} from "../../../shared/types"; -import { Button } from "../ui/Button"; -import { cn } from "../ui/cn"; -import { ConnectionStatusDot } from "./shared/ConnectionStatusDot"; -import { cardCls, inputCls, labelCls } from "./shared/designTokens"; - -const NOTIFICATION_TYPES: OpenclawNotificationType[] = [ - "mission_complete", - "ci_broken", - "blocked_run", -]; - -const CONNECTION_STATUS_LABEL: Record<"connected" | "degraded" | "disconnected", string> = { - connected: "Connected", - degraded: "Connecting", - disconnected: "Disconnected", -}; - -type DraftState = { - enabled: boolean; - bridgePort: string; - gatewayUrl: string; - gatewayToken: string; - hooksToken: string; - allowedAgentIds: string; - defaultTarget: string; - allowEmployeeTargets: boolean; - notificationRoutes: Record<OpenclawNotificationType, { agentId: string; sessionKey: string; enabled: boolean }>; -}; - -type ManualDraftState = { - agentId: string; - sessionKey: string; - message: string; -}; - -function routesToDraft(routes: OpenclawNotificationRoute[]): DraftState["notificationRoutes"] { - const base = Object.fromEntries( - NOTIFICATION_TYPES.map((type) => [ - type, - { - agentId: "", - sessionKey: "", - enabled: false, - }, - ]), - ) as DraftState["notificationRoutes"]; - - for (const route of routes) { - base[route.notificationType] = { - agentId: route.agentId ?? "", - sessionKey: route.sessionKey ?? "", - enabled: route.enabled !== false, - }; - } - return base; -} - -function stateToDraft(state: OpenclawBridgeState | null): DraftState { - return { - enabled: state?.config.enabled === true, - bridgePort: String(state?.config.bridgePort ?? 18791), - gatewayUrl: state?.config.gatewayUrl ?? "", - gatewayToken: state?.config.gatewayToken ?? "", - hooksToken: state?.config.hooksToken ?? "", - allowedAgentIds: (state?.config.allowedAgentIds ?? []).join(", "), - defaultTarget: state?.config.defaultTarget ?? "cto", - allowEmployeeTargets: state?.config.allowEmployeeTargets !== false, - notificationRoutes: routesToDraft(state?.config.notificationRoutes ?? []), - }; -} - -function normalizeRoutes( - routes: DraftState["notificationRoutes"], -): OpenclawNotificationRoute[] { - return NOTIFICATION_TYPES - .map((notificationType) => ({ - notificationType, - agentId: routes[notificationType].agentId.trim() || null, - sessionKey: routes[notificationType].sessionKey.trim() || null, - enabled: routes[notificationType].enabled, - })) - .filter((route) => route.enabled || route.agentId || route.sessionKey); -} - -export function OpenclawConnectionPanel({ - compact = false, - showConfig = true, - showRecentTraffic = !compact, - identity, - onSaveIdentity, - onStateChange, -}: { - compact?: boolean; - showConfig?: boolean; - showRecentTraffic?: boolean; - identity?: CtoIdentity | null; - onSaveIdentity?: (patch: Record<string, unknown>) => Promise<void>; - onStateChange?: (state: OpenclawBridgeState | null) => void; -}) { - const [state, setState] = useState<OpenclawBridgeState | null>(null); - const [draft, setDraft] = useState<DraftState>(stateToDraft(null)); - const [messages, setMessages] = useState<OpenclawMessageRecord[]>([]); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); - const [error, setError] = useState<string | null>(null); - const [contextSaving, setContextSaving] = useState(false); - const [contextError, setContextError] = useState<string | null>(null); - const [manualDraft, setManualDraft] = useState<ManualDraftState>({ - agentId: "", - sessionKey: "", - message: "", - }); - const [manualSending, setManualSending] = useState(false); - const [manualError, setManualError] = useState<string | null>(null); - const [manualSuccess, setManualSuccess] = useState<string | null>(null); - const [contextDraft, setContextDraft] = useState({ - shareMode: identity?.openclawContextPolicy?.shareMode ?? "filtered", - blockedCategories: (identity?.openclawContextPolicy?.blockedCategories ?? []).join(", "), - }); - const onStateChangeRef = useRef(onStateChange); - - useEffect(() => { - onStateChangeRef.current = onStateChange; - }, [onStateChange]); - - const connectionStatus: "connected" | "degraded" | "disconnected" = useMemo(() => { - if (state?.status.state === "connected") return "connected"; - if (state?.status.state === "reconnecting" || state?.status.state === "connecting") return "degraded"; - return "disconnected"; - }, [state?.status.state]); - - const load = useCallback(async () => { - if (!window.ade?.cto) return; - try { - const [nextState, nextMessages] = await Promise.all([ - window.ade.cto.getOpenclawState(), - window.ade.cto.listOpenclawMessages({ limit: compact ? 6 : 12 }), - ]); - setState(nextState); - setDraft(stateToDraft(nextState)); - setMessages(nextMessages); - setError(null); - onStateChangeRef.current?.(nextState); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load OpenClaw state."); - setState(null); - setMessages([]); - onStateChangeRef.current?.(null); - } - }, [compact]); - - useEffect(() => { - void load(); - }, [load]); - - useEffect(() => { - const unsubscribe = window.ade?.cto?.onOpenclawConnectionStatus?.((nextStatus) => { - setState((current) => current ? { ...current, status: nextStatus } : current); - }); - return () => unsubscribe?.(); - }, []); - - useEffect(() => { - setContextDraft({ - shareMode: identity?.openclawContextPolicy?.shareMode ?? "filtered", - blockedCategories: (identity?.openclawContextPolicy?.blockedCategories ?? []).join(", "), - }); - }, [identity]); - - const saveConfig = useCallback(async () => { - if (!window.ade?.cto) return; - setSaving(true); - setError(null); - try { - const nextState = await window.ade.cto.updateOpenclawConfig({ - patch: { - enabled: draft.enabled, - bridgePort: Number(draft.bridgePort) || 18791, - gatewayUrl: draft.gatewayUrl.trim() || null, - gatewayToken: draft.gatewayToken.trim() || null, - hooksToken: draft.hooksToken.trim() || null, - allowedAgentIds: draft.allowedAgentIds - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean), - defaultTarget: (draft.defaultTarget.trim() || "cto") as "cto" | `agent:${string}`, - allowEmployeeTargets: draft.allowEmployeeTargets, - notificationRoutes: normalizeRoutes(draft.notificationRoutes), - }, - }); - setState(nextState); - onStateChange?.(nextState); - await load(); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to save OpenClaw settings."); - } finally { - setSaving(false); - } - }, [draft, load, onStateChange]); - - const testConnection = useCallback(async () => { - if (!window.ade?.cto) return; - setTesting(true); - setError(null); - try { - await saveConfig(); - const nextStatus = await window.ade.cto.testOpenclawConnection({}); - setState((current) => current ? { ...current, status: nextStatus } : current); - await load(); - } catch (err) { - setError(err instanceof Error ? err.message : "OpenClaw connection test failed."); - } finally { - setTesting(false); - } - }, [load, saveConfig]); - - const saveContextPolicy = useCallback(async () => { - if (!onSaveIdentity) return; - setContextSaving(true); - setContextError(null); - try { - await onSaveIdentity({ - openclawContextPolicy: { - shareMode: contextDraft.shareMode, - blockedCategories: contextDraft.blockedCategories - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean), - }, - }); - } catch (err) { - setContextError(err instanceof Error ? err.message : "Failed to save context policy."); - } finally { - setContextSaving(false); - } - }, [contextDraft, onSaveIdentity]); - - const sendManualMessage = useCallback(async () => { - if (!window.ade?.cto) return; - const message = manualDraft.message.trim(); - const sessionKey = manualDraft.sessionKey.trim(); - const agentId = manualDraft.agentId.trim(); - if (!message.length) { - setManualError("Enter a message before sending."); - return; - } - if (!sessionKey && !agentId) { - setManualError("Provide either a session key or an agent ID."); - return; - } - setManualSending(true); - setManualError(null); - setManualSuccess(null); - try { - await window.ade.cto.sendOpenclawMessage({ - sessionKey: sessionKey || null, - agentId: agentId || null, - message, - }); - setManualDraft((current) => ({ ...current, message: "" })); - setManualSuccess("Message queued for delivery."); - await load(); - } catch (err) { - setManualError(err instanceof Error ? err.message : "Failed to send OpenClaw message."); - } finally { - setManualSending(false); - } - }, [load, manualDraft]); - - return ( - <div className={cn("space-y-4", compact && "space-y-3")} data-testid="openclaw-connection-panel"> - <div className="flex items-center justify-between gap-3"> - <div className="flex items-center gap-3"> - <span className={labelCls}>Connection Status</span> - <ConnectionStatusDot - status={connectionStatus} - label={CONNECTION_STATUS_LABEL[connectionStatus]} - /> - </div> - <div className="flex items-center gap-2"> - <Button variant="outline" size="sm" disabled={saving || testing} onClick={() => void load()}> - <ArrowCounterClockwise size={10} /> - Refresh - </Button> - <Button variant="primary" size="sm" disabled={saving || testing} onClick={() => void testConnection()}> - {testing ? <CircleNotch size={10} className="animate-spin" /> : "Test"} - </Button> - </div> - </div> - - {showConfig && ( - <div className={cn(cardCls, compact ? "p-3" : "p-4")}> - <div className="grid gap-3 md:grid-cols-2"> - <label className="space-y-1"> - <div className={labelCls}>Enable Bridge</div> - <label className="flex items-center gap-2 font-sans text-[10px] text-fg"> - <input - type="checkbox" - checked={draft.enabled} - onChange={(event) => setDraft((current) => ({ ...current, enabled: event.target.checked }))} - /> - Accept incoming hooks/queries and attempt operator pairing - </label> - </label> - - <label className="space-y-1"> - <div className={labelCls}>Bridge Port</div> - <input - className={inputCls} - value={draft.bridgePort} - onChange={(event) => setDraft((current) => ({ ...current, bridgePort: event.target.value }))} - /> - </label> - - <label className="space-y-1 md:col-span-2"> - <div className={labelCls}>Gateway URL</div> - <input - className={inputCls} - placeholder="ws://127.0.0.1:18789" - value={draft.gatewayUrl} - onChange={(event) => setDraft((current) => ({ ...current, gatewayUrl: event.target.value }))} - /> - </label> - - <label className="space-y-1"> - <div className={labelCls}>Gateway Token</div> - <input - className={inputCls} - type="password" - placeholder="optional gateway/operator token" - value={draft.gatewayToken} - onChange={(event) => setDraft((current) => ({ ...current, gatewayToken: event.target.value }))} - /> - </label> - - <label className="space-y-1"> - <div className={labelCls}>Hook Token</div> - <input - className={inputCls} - type="password" - placeholder="token for /openclaw/hook and /openclaw/query" - value={draft.hooksToken} - onChange={(event) => setDraft((current) => ({ ...current, hooksToken: event.target.value }))} - /> - </label> - - <label className="space-y-1"> - <div className={labelCls}>Default Target</div> - <input - className={inputCls} - placeholder="cto or agent:frontend" - value={draft.defaultTarget} - onChange={(event) => setDraft((current) => ({ ...current, defaultTarget: event.target.value }))} - /> - </label> - - <label className="space-y-1"> - <div className={labelCls}>Allowed OpenClaw Agents</div> - <input - className={inputCls} - placeholder="cto-bot, qa-bot" - value={draft.allowedAgentIds} - onChange={(event) => setDraft((current) => ({ ...current, allowedAgentIds: event.target.value }))} - /> - </label> - </div> - - <label className="mt-3 flex items-center gap-2 font-sans text-[10px] text-fg"> - <input - type="checkbox" - checked={draft.allowEmployeeTargets} - onChange={(event) => setDraft((current) => ({ ...current, allowEmployeeTargets: event.target.checked }))} - /> - Allow `targetHint` values like `agent:worker-slug` - </label> - - {!compact && ( - <div className="mt-4 space-y-2"> - <div className={labelCls}>Notification Routes</div> - {NOTIFICATION_TYPES.map((notificationType) => ( - <div key={notificationType} className="grid gap-2 rounded border border-border/10 bg-surface-recessed p-3 md:grid-cols-[150px_minmax(0,1fr)_minmax(0,1fr)]"> - <label className="flex items-center gap-2 font-sans text-[10px] text-fg"> - <input - type="checkbox" - checked={draft.notificationRoutes[notificationType].enabled} - onChange={(event) => setDraft((current) => ({ - ...current, - notificationRoutes: { - ...current.notificationRoutes, - [notificationType]: { - ...current.notificationRoutes[notificationType], - enabled: event.target.checked, - }, - }, - }))} - /> - {notificationType} - </label> - <input - className={inputCls} - placeholder="agentId" - value={draft.notificationRoutes[notificationType].agentId} - onChange={(event) => setDraft((current) => ({ - ...current, - notificationRoutes: { - ...current.notificationRoutes, - [notificationType]: { - ...current.notificationRoutes[notificationType], - agentId: event.target.value, - }, - }, - }))} - /> - <input - className={inputCls} - placeholder="sessionKey (optional)" - value={draft.notificationRoutes[notificationType].sessionKey} - onChange={(event) => setDraft((current) => ({ - ...current, - notificationRoutes: { - ...current.notificationRoutes, - [notificationType]: { - ...current.notificationRoutes[notificationType], - sessionKey: event.target.value, - }, - }, - }))} - /> - </div> - ))} - </div> - )} - - <div className="mt-4 flex items-center justify-between gap-3"> - <div className="font-sans text-[9px] text-muted-fg/50"> - {state?.endpoints.healthUrl ? ( - <> - Health: {state.endpoints.healthUrl} - <br /> - Hook: {state.endpoints.hookUrl} - <br /> - Query: {state.endpoints.queryUrl} - </> - ) : ( - "Health, hook, and query endpoints appear once the local bridge listener starts." - )} - </div> - <Button variant="outline" size="sm" disabled={saving} onClick={() => void saveConfig()}> - {saving ? "Saving..." : "Save Settings"} - </Button> - </div> - </div> - )} - - {state?.status.lastError && ( - <div className="flex items-start gap-2 rounded border border-error/20 bg-error/5 px-3 py-2"> - <WarningCircle size={14} className="mt-0.5 shrink-0 text-error" /> - <div className="font-sans text-[10px] text-fg/80">{state.status.lastError}</div> - </div> - )} - - {error && ( - <div className="flex items-start gap-2 rounded border border-error/20 bg-error/5 px-3 py-2"> - <WarningCircle size={14} className="mt-0.5 shrink-0 text-error" /> - <div className="font-sans text-[10px] text-fg/80">{error}</div> - </div> - )} - - {state?.status.state === "connected" && ( - <div className={cn(cardCls, compact ? "p-2.5" : "p-3")}> - <div className="flex items-center gap-2"> - <CheckCircle size={14} weight="fill" className="text-success" /> - <span className="font-sans text-[10px] text-fg"> - Paired device <span className="font-bold">{state.status.deviceId ?? "unknown"}</span> - </span> - </div> - <div className="mt-1 font-sans text-[9px] text-muted-fg/45"> - Last connected: {state.status.lastConnectedAt ? new Date(state.status.lastConnectedAt).toLocaleString() : "n/a"} - </div> - </div> - )} - - {!compact && onSaveIdentity && ( - <div className={cn(cardCls, "p-4")}> - <div className="mb-3"> - <div className="font-sans text-xs font-bold text-fg">OpenClaw Context Policy</div> - <div className="mt-1 font-sans text-[10px] text-muted-fg/55"> - Controls which metadata ADE includes when it sends notifications or bridge replies back into OpenClaw. - </div> - </div> - - <div className="grid gap-3 md:grid-cols-2"> - <label className="space-y-1"> - <div className={labelCls}>Share Mode</div> - <select - className={inputCls} - value={contextDraft.shareMode} - onChange={(event) => setContextDraft((current) => ({ ...current, shareMode: event.target.value as "full" | "filtered" }))} - > - <option value="filtered">Filtered</option> - <option value="full">Full</option> - </select> - </label> - - <label className="space-y-1"> - <div className={labelCls}>Blocked Categories</div> - <input - className={inputCls} - placeholder="secret, token, system_prompt" - value={contextDraft.blockedCategories} - onChange={(event) => setContextDraft((current) => ({ ...current, blockedCategories: event.target.value }))} - /> - </label> - </div> - - {contextError && <div className="mt-3 font-sans text-[10px] text-error">{contextError}</div>} - - <div className="mt-4 flex justify-end"> - <Button variant="outline" size="sm" disabled={contextSaving} onClick={() => void saveContextPolicy()}> - {contextSaving ? "Saving..." : "Save Context Policy"} - </Button> - </div> - </div> - )} - - {!compact && showConfig && ( - <div className={cn(cardCls, "p-4")}> - <div className="mb-3"> - <div className="font-sans text-xs font-bold text-fg">Manual Outbound Message</div> - <div className="mt-1 font-sans text-[10px] text-muted-fg/55"> - Send a direct bridge message to a known OpenClaw session or agent to validate routing end to end. - </div> - </div> - - <div className="grid gap-3 md:grid-cols-2"> - <label className="space-y-1"> - <div className={labelCls}>Session Key</div> - <input - className={inputCls} - placeholder="chat:discord:thread:123" - value={manualDraft.sessionKey} - onChange={(event) => setManualDraft((current) => ({ ...current, sessionKey: event.target.value }))} - /> - </label> - - <label className="space-y-1"> - <div className={labelCls}>Agent ID</div> - <input - className={inputCls} - placeholder="main" - value={manualDraft.agentId} - onChange={(event) => setManualDraft((current) => ({ ...current, agentId: event.target.value }))} - /> - </label> - - <label className="space-y-1 md:col-span-2"> - <div className={labelCls}>Message</div> - <textarea - className={cn(inputCls, "min-h-[84px] resize-y py-2")} - placeholder="Bridge health check from ADE." - value={manualDraft.message} - onChange={(event) => setManualDraft((current) => ({ ...current, message: event.target.value }))} - /> - </label> - </div> - - {manualError && <div className="mt-3 font-sans text-[10px] text-error">{manualError}</div>} - {manualSuccess && <div className="mt-3 font-sans text-[10px] text-success">{manualSuccess}</div>} - - <div className="mt-4 flex justify-end"> - <Button variant="outline" size="sm" disabled={manualSending} onClick={() => void sendManualMessage()}> - {manualSending ? "Sending..." : "Send Message"} - </Button> - </div> - </div> - )} - - {showRecentTraffic && ( - <div className={cn(cardCls, "p-4")}> - <div className="mb-3 flex items-center justify-between gap-3"> - <div> - <div className="font-sans text-xs font-bold text-fg">Recent Bridge Traffic</div> - <div className="mt-1 font-sans text-[10px] text-muted-fg/55"> - Last inbound and outbound bridge records persisted under `.ade/cto/`. - </div> - </div> - </div> - - <div className="space-y-2"> - {messages.map((message) => ( - <div key={message.id} className="rounded border border-border/10 bg-surface-recessed px-3 py-2"> - <div className="flex items-center justify-between gap-3"> - <div className="font-sans text-[10px] text-fg"> - {message.direction} · {message.mode} · {message.status} - </div> - <div className="font-sans text-[9px] text-muted-fg/45"> - {new Date(message.createdAt).toLocaleString()} - </div> - </div> - <div className="mt-1 font-sans text-[10px] text-muted-fg/80">{message.summary}</div> - </div> - ))} - {messages.length === 0 && ( - <div className="font-sans text-[10px] text-muted-fg/50">No OpenClaw traffic has been recorded yet.</div> - )} - </div> - </div> - )} - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/cto/TeamPanel.tsx b/apps/desktop/src/renderer/components/cto/TeamPanel.tsx index 5ea020490..0a82bf06b 100644 --- a/apps/desktop/src/renderer/components/cto/TeamPanel.tsx +++ b/apps/desktop/src/renderer/components/cto/TeamPanel.tsx @@ -51,8 +51,6 @@ export type WorkerEditorDraft = { linearAliases: string; adapterType: AdapterType; model: string; - webhookUrl: string; - authHeader: string; processCommand: string; budgetDollars: number; heartbeatEnabled: boolean; @@ -82,11 +80,6 @@ export function workerDraftFromAgent(agent?: AgentIdentity | null): WorkerEditor linearAliases: (agent?.linearIdentity?.aliases ?? []).join(", "), adapterType: agent?.adapterType ?? "claude-local", model: typeof adapterConfig.model === "string" ? adapterConfig.model : "", - webhookUrl: typeof adapterConfig.url === "string" ? adapterConfig.url : "", - authHeader: - typeof (adapterConfig.headers as Record<string, unknown> | undefined)?.Authorization === "string" - ? String((adapterConfig.headers as Record<string, unknown>).Authorization) - : "", processCommand: typeof adapterConfig.command === "string" ? adapterConfig.command : "", budgetDollars: (agent?.budgetMonthlyCents ?? 0) / 100, heartbeatEnabled: heartbeat?.enabled === true, @@ -213,7 +206,6 @@ export function WorkerEditorPanel({ <select className={selectCls} value={draft.adapterType} onChange={(e) => setDraft((d) => ({ ...d, adapterType: e.target.value as AdapterType }))}> <option value="claude-local">claude-local</option> <option value="codex-local">codex-local</option> - <option value="openclaw-webhook">openclaw-webhook</option> <option value="process">process</option> </select> </label> @@ -223,18 +215,6 @@ export function WorkerEditorPanel({ <input className={inputCls} placeholder="claude-sonnet-4-6" value={draft.model} onChange={(e) => setDraft((d) => ({ ...d, model: e.target.value }))} /> </label> )} - {draft.adapterType === "openclaw-webhook" && ( - <> - <label className="space-y-1"> - <div className={labelCls}>Webhook URL</div> - <input className={inputCls} value={draft.webhookUrl} onChange={(e) => setDraft((d) => ({ ...d, webhookUrl: e.target.value }))} /> - </label> - <label className="space-y-1 col-span-2"> - <div className={labelCls}>Auth header</div> - <input className={inputCls} placeholder="${env:TOKEN}" value={draft.authHeader} onChange={(e) => setDraft((d) => ({ ...d, authHeader: e.target.value }))} /> - </label> - </> - )} {draft.adapterType === "process" && ( <label className="space-y-1"> <div className={labelCls}>Command</div> diff --git a/apps/desktop/src/renderer/components/cto/ctoUi.test.tsx b/apps/desktop/src/renderer/components/cto/ctoUi.test.tsx index b98d423d3..352752787 100644 --- a/apps/desktop/src/renderer/components/cto/ctoUi.test.tsx +++ b/apps/desktop/src/renderer/components/cto/ctoUi.test.tsx @@ -28,10 +28,6 @@ describe("CtoSettingsPanel (file group)", () => { )), })); - vi.mock("./OpenclawConnectionPanel", () => ({ - OpenclawConnectionPanel: vi.fn(() => <div data-testid="openclaw-panel" />), - })); - vi.mock("./CtoPromptPreview", () => ({ CtoPromptPreview: vi.fn(() => <div data-testid="prompt-preview" />), })); @@ -270,7 +266,6 @@ describe("CtoSettingsPanel (file group)", () => { ); expect(screen.getByRole("button", { name: "Identity" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Brief" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Integrations" })).toBeTruthy(); }); it("shows session history timeline entries in the Brief tab", () => { diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx index b94af7904..506ec7c06 100644 --- a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -124,6 +124,7 @@ function GraphInner() { const [searchParams, setSearchParams] = useSearchParams(); const reactFlow = useReactFlow<Node<GraphNodeData>, Edge<GraphEdgeData>>(); const project = useAppStore((s) => s.project); + const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); const lanes = useAppStore((s) => s.lanes); const lanesKey = React.useMemo(() => lanes.map((l) => l.id).join(","), [lanes]); const refreshLanes = useAppStore((s) => s.refreshLanes); @@ -2439,6 +2440,8 @@ function GraphInner() { navigate(`/lanes?laneId=${encodeURIComponent(lane.id)}&focus=single`); } else if (action === "open-folder") { await window.ade.lanes.openFolder({ laneId: lane.id }); + } else if (action === "copy-remote-path") { + await window.ade.app.writeClipboardText(lane.worktreePath); } else if (action === "view-pr") { const overlay = buildGraphOverlayForLane(lane.id, lanePr); if (!overlay) return; @@ -3450,7 +3453,9 @@ function GraphInner() { title: "Navigate", items: [ { key: "open-lane", label: "Open Lane" }, - { key: "open-folder", label: "Open Folder" }, + isRemoteProject + ? { key: "copy-remote-path", label: "Copy Remote Path" } + : { key: "open-folder", label: "Open Folder" }, { key: "view-pr", label: "Open PR", disabled: !hasPr, reason: "No linked PR for this lane." }, { key: "create-pr", label: hasPr ? "Open PR Workflow" : "Create PR", disabled: !canCreatePr, reason: "Primary lanes cannot open PRs." }, ] diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx index ec2b7287d..7d297677b 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx @@ -489,112 +489,112 @@ export function CreateLaneDialog({ {/* Advanced — Linear issue + template */} <details open className="group rounded-xl border border-white/[0.06] bg-white/[0.02] open:bg-white/[0.03]"> - <summary className="flex cursor-pointer select-none items-center justify-between gap-3 rounded-xl px-4 py-2.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-fg/70 transition-colors hover:text-fg [&::-webkit-details-marker]:hidden"> - <span className="flex items-center gap-2"> - <CaretDown size={10} weight="bold" className="transition-transform group-open:rotate-0 -rotate-90" /> - Advanced - </span> - {onNavigateToTemplates ? ( + <summary className="flex cursor-pointer select-none items-center justify-between gap-3 rounded-xl px-4 py-2.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-fg/70 transition-colors hover:text-fg [&::-webkit-details-marker]:hidden"> + <span className="flex items-center gap-2"> + <CaretDown size={10} weight="bold" className="transition-transform group-open:rotate-0 -rotate-90" /> + Advanced + </span> + {onNavigateToTemplates ? ( + <button + type="button" + className="text-[10px] font-medium normal-case tracking-normal text-muted-fg/60 transition-colors hover:text-accent" + disabled={busy || laneCreated} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onOpenChange(false); + onNavigateToTemplates(); + }} + > + {templates.length > 0 ? "Manage templates" : "Create template"} + </button> + ) : null} + </summary> + <div className="space-y-3 px-4 pb-4 pt-1"> + <div> + <span className={LABEL_CLASS_NAME}>Linear issue</span> + {selectedLinearIssue ? ( + <> + <LinearIssueSummaryCard + issue={selectedLinearIssue} + branchName={selectedLinearBranchName} + branchConflict={selectedLinearBranchConflict} + onClear={() => setSelectedLinearIssue(null)} + /> + <div className="mt-2 flex justify-end"> + <button + type="button" + disabled={busy || laneCreated} + onClick={() => setIssuePickerOpen(true)} + className="inline-flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors disabled:opacity-50" + style={{ + borderColor: LINEAR_BRAND.borderSubtle, + background: LINEAR_BRAND.surface, + color: LINEAR_BRAND.text, + }} + > + <LinearMark size={11} /> + Change issue + </button> + </div> + </> + ) : ( <button type="button" - className="text-[10px] font-medium normal-case tracking-normal text-muted-fg/60 transition-colors hover:text-accent" + onClick={() => setIssuePickerOpen(true)} disabled={busy || laneCreated} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onOpenChange(false); - onNavigateToTemplates(); + className="mt-2 flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60" + style={{ + borderColor: LINEAR_BRAND.borderSubtle, + background: LINEAR_BRAND.surface, }} > - {templates.length > 0 ? "Manage templates" : "Create template"} + <span + className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md" + style={{ background: LINEAR_BRAND.surfaceHover, color: LINEAR_BRAND.primaryBright }} + > + <LinearMark size={15} /> + </span> + <span className="min-w-0 flex-1"> + <span className="block text-sm font-semibold text-fg">Connect a Linear issue</span> + <span className="mt-0.5 block text-[11px] text-muted-fg/65"> + Auto-names the branch and links the lane to your ticket. + </span> + </span> + <CaretRight size={14} className="shrink-0" style={{ color: LINEAR_BRAND.textMuted }} /> </button> - ) : null} - </summary> - <div className="space-y-3 px-4 pb-4 pt-1"> - <div> - <span className={LABEL_CLASS_NAME}>Linear issue</span> - {selectedLinearIssue ? ( - <> - <LinearIssueSummaryCard - issue={selectedLinearIssue} - branchName={selectedLinearBranchName} - branchConflict={selectedLinearBranchConflict} - onClear={() => setSelectedLinearIssue(null)} - /> - <div className="mt-2 flex justify-end"> - <button - type="button" - disabled={busy || laneCreated} - onClick={() => setIssuePickerOpen(true)} - className="inline-flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors disabled:opacity-50" - style={{ - borderColor: LINEAR_BRAND.borderSubtle, - background: LINEAR_BRAND.surface, - color: LINEAR_BRAND.text, - }} - > - <LinearMark size={11} /> - Change issue - </button> - </div> - </> - ) : ( - <button - type="button" - onClick={() => setIssuePickerOpen(true)} + )} + </div> + <div> + <span className={LABEL_CLASS_NAME}>Template</span> + {templates.length > 0 ? ( + <> + <select + value={selectedTemplateId} + onChange={(e) => setSelectedTemplateId(e.target.value)} + className={SELECT_CLASS_NAME} disabled={busy || laneCreated} - className="mt-2 flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60" - style={{ - borderColor: LINEAR_BRAND.borderSubtle, - background: LINEAR_BRAND.surface, - }} + aria-label="Template" > - <span - className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md" - style={{ background: LINEAR_BRAND.surfaceHover, color: LINEAR_BRAND.primaryBright }} - > - <LinearMark size={15} /> - </span> - <span className="min-w-0 flex-1"> - <span className="block text-sm font-semibold text-fg">Connect a Linear issue</span> - <span className="mt-0.5 block text-[11px] text-muted-fg/65"> - Auto-names the branch and links the lane to your ticket. - </span> - </span> - <CaretRight size={14} className="shrink-0" style={{ color: LINEAR_BRAND.textMuted }} /> - </button> - )} - </div> - <div> - <span className={LABEL_CLASS_NAME}>Template</span> - {templates.length > 0 ? ( - <> - <select - value={selectedTemplateId} - onChange={(e) => setSelectedTemplateId(e.target.value)} - className={SELECT_CLASS_NAME} - disabled={busy || laneCreated} - aria-label="Template" - > - <option value="">None</option> - {templates.map((t) => ( - <option key={t.id} value={t.id}> - {t.name}{t.description ? ` — ${t.description}` : ""} - </option> - ))} - </select> - {selectedTemplate?.description ? ( - <div className="mt-1.5 text-[11px] text-muted-fg/60">{selectedTemplate.description}</div> - ) : null} - </> - ) : ( - <div className="mt-2 text-xs text-muted-fg/50"> - No templates yet. - </div> - )} - </div> + <option value="">None</option> + {templates.map((t) => ( + <option key={t.id} value={t.id}> + {t.name}{t.description ? ` — ${t.description}` : ""} + </option> + ))} + </select> + {selectedTemplate?.description ? ( + <div className="mt-1.5 text-[11px] text-muted-fg/60">{selectedTemplate.description}</div> + ) : null} + </> + ) : ( + <div className="mt-2 text-xs text-muted-fg/50"> + No templates yet. + </div> + )} </div> - </details> + </div> + </details> {error ? ( <div className="rounded-xl border border-red-500/25 bg-red-500/10 px-3 py-2 text-sm text-red-200"> diff --git a/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx b/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx index 1c6a29577..d3a239ed4 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx @@ -1,6 +1,7 @@ import React from "react"; import type { LaneSummary } from "../../../shared/types"; import { revealLabel } from "../../lib/platform"; +import { useAppStore } from "../../state/appStore"; import { COLORS, MONO_FONT } from "./laneDesignTokens"; import { LANE_COLOR_PALETTE, colorsInUse } from "./laneColorPalette"; @@ -82,6 +83,7 @@ export function LaneContextMenu({ onBatchManage: (laneIds: string[]) => void; onAppearanceChanged?: () => void | Promise<void>; }) { + const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); const ctxLane = lanesById.get(laneContextMenu.laneId) ?? null; const isInSplit = visibleLaneIds.includes(laneContextMenu.laneId); const splitCount = visibleLaneIds.length; @@ -134,20 +136,26 @@ export function LaneContextMenu({ dataTour="lanes.manageLane" onClick={() => { onClose(); + if (isRemoteProject) { + window.ade.app.writeClipboardText(ctxLane.worktreePath).catch(() => {}); + return; + } window.ade.app.revealPath(ctxLane.worktreePath).catch(() => {}); }} > - {revealLabel} - </HoverButton> - <HoverButton - style={menuItemStyle} - onClick={() => { - onClose(); - navigator.clipboard.writeText(ctxLane.worktreePath).catch(() => {}); - }} - > - Copy Path + {isRemoteProject ? "Copy Remote Path" : revealLabel} </HoverButton> + {!isRemoteProject ? ( + <HoverButton + style={menuItemStyle} + onClick={() => { + onClose(); + window.ade.app.writeClipboardText(ctxLane.worktreePath).catch(() => {}); + }} + > + Copy Path + </HoverButton> + ) : null} </> ) : null} diff --git a/apps/desktop/src/renderer/components/lanes/LinearIssueBadge.tsx b/apps/desktop/src/renderer/components/lanes/LinearIssueBadge.tsx index c27bb7057..25283c90b 100644 --- a/apps/desktop/src/renderer/components/lanes/LinearIssueBadge.tsx +++ b/apps/desktop/src/renderer/components/lanes/LinearIssueBadge.tsx @@ -19,6 +19,32 @@ function isSafeExternalUrl(value: string | null | undefined): value is string { } } +type CopyState = "idle" | "copied" | "error"; + +function copyButtonColor(state: CopyState): string { + switch (state) { + case "error": return "#FCA5A5"; + case "copied": return "#86EFAC"; + default: return "rgba(199,205,245,0.85)"; + } +} + +function copyButtonIcon(state: CopyState): React.ReactNode { + switch (state) { + case "copied": return <Check size={11} weight="bold" />; + case "error": return <WarningCircle size={11} weight="bold" />; + default: return <Clipboard size={11} />; + } +} + +function copyButtonLabel(state: CopyState): string { + switch (state) { + case "copied": return "Copied"; + case "error": return "Copy failed"; + default: return "Copy link"; + } +} + export function LinearIssueBadge({ issue, compact = false, @@ -28,7 +54,7 @@ export function LinearIssueBadge({ compact?: boolean; onStartChatWithIssue?: () => void; }) { - const [copyState, setCopyState] = React.useState<"idle" | "copied" | "error">("idle"); + const [copyState, setCopyState] = React.useState<CopyState>("idle"); const project = issue.projectName?.trim() || issue.projectSlug; React.useEffect(() => { @@ -201,7 +227,7 @@ export function LinearIssueBadge({ type="button" className="inline-flex h-6 items-center gap-1 rounded px-2 text-[10.5px] font-medium transition-colors hover:bg-white/[0.06]" style={{ - color: copyState === "error" ? "#FCA5A5" : copyState === "copied" ? "#86EFAC" : "rgba(199,205,245,0.85)", + color: copyButtonColor(copyState), background: copyState === "copied" ? "rgba(34,197,94,0.10)" : "transparent", }} title="Copy Linear issue link" @@ -211,8 +237,8 @@ export function LinearIssueBadge({ }} onClick={handleCopyIssueLink} > - {copyState === "copied" ? <Check size={11} weight="bold" /> : copyState === "error" ? <WarningCircle size={11} weight="bold" /> : <Clipboard size={11} />} - {copyState === "copied" ? "Copied" : copyState === "error" ? "Copy failed" : "Copy link"} + {copyButtonIcon(copyState)} + {copyButtonLabel(copyState)} </button> <button type="button" diff --git a/apps/desktop/src/renderer/components/projects/AddProjectChooser.tsx b/apps/desktop/src/renderer/components/projects/AddProjectChooser.tsx index 2ee2283c9..d6a8039e0 100644 --- a/apps/desktop/src/renderer/components/projects/AddProjectChooser.tsx +++ b/apps/desktop/src/renderer/components/projects/AddProjectChooser.tsx @@ -63,7 +63,7 @@ export function AddProjectChooser({ onChoose }: AddProjectChooserProps) { <div style={{ display: "grid", - gridTemplateColumns: "repeat(3, 1fr)", + gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 16, width: "100%", }} @@ -104,7 +104,8 @@ function ChooserTile({ ? `color-mix(in srgb, ${tile.hue} 65%, transparent)` : "color-mix(in srgb, var(--color-border) 80%, transparent)" }`, - transition: "transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease, background 180ms ease", + transition: + "transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease, background 180ms ease", transform: hover ? "translateY(-3px)" : "translateY(0)", boxShadow: hover ? `0 22px 48px -18px color-mix(in srgb, ${tile.hue} 55%, transparent), 0 0 0 1px color-mix(in srgb, ${tile.hue} 35%, transparent), inset 0 1px 0 0 color-mix(in srgb, ${tile.hue} 30%, transparent)` diff --git a/apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx b/apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx index fbd8b1911..5ec135275 100644 --- a/apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx +++ b/apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx @@ -18,7 +18,16 @@ import { } from "@phosphor-icons/react"; import { motion, AnimatePresence } from "motion/react"; import { extractError } from "../../lib/format"; -import type { GitHubStatus, MyGitHubRepoSummary } from "../../../shared/types"; +import type { + CloneProjectInput, + CloneProjectResult, + GitHubStatus, + ListMyGitHubReposInput, + ListMyGitHubReposResult, + MyGitHubRepoSummary, + ProjectBrowseInput, + ProjectBrowseResult, +} from "../../../shared/types"; import { COLORS, LABEL_STYLE, @@ -32,7 +41,29 @@ import { export type CloneProjectFormProps = { onCancel: () => void; - onCloned: (result: { rootPath: string; displayName: string }) => void; + onCloned: (result: { + rootPath: string; + displayName: string; + projectId?: string; + }) => void; + machineName?: string; + getDefaultParentDir?: () => Promise<string>; + browseDirectories?: ( + input: ProjectBrowseInput, + ) => Promise<ProjectBrowseResult>; + chooseDirectory?: + | ((args: { + title: string; + defaultPath?: string; + }) => Promise<string | null>) + | null; + cloneProject?: ( + input: CloneProjectInput, + ) => Promise<CloneProjectResult & { projectId?: string }>; + listMyRepos?: ( + input?: ListMyGitHubReposInput, + ) => Promise<ListMyGitHubReposResult>; + allowTokenSetup?: boolean; }; type Tab = "url" | "my-repos"; @@ -55,7 +86,10 @@ function isGitHubRepoUrl(raw: string): boolean { try { const url = new URL(trimmed); if (!/github\.com$/i.test(url.hostname)) return false; - const parts = url.pathname.replace(/^\/+/, "").replace(/\.git$/i, "").split("/"); + const parts = url.pathname + .replace(/^\/+/, "") + .replace(/\.git$/i, "") + .split("/"); return Boolean(parts[0]?.trim() && parts[1]?.trim()); } catch { return false; @@ -88,7 +122,10 @@ function deriveSlug(url: string): string { function joinPath(parent: string, name: string): string { if (!parent) return name; const sep = parent.includes("\\") ? "\\" : "/"; - const trimmed = parent.endsWith("/") || parent.endsWith("\\") ? parent.slice(0, -1) : parent; + const trimmed = + parent.endsWith("/") || parent.endsWith("\\") + ? parent.slice(0, -1) + : parent; if (!name) return trimmed; return `${trimmed}${sep}${name}`; } @@ -112,14 +149,60 @@ function relativeFromNow(iso: string | null | undefined): string { return `${years}y ago`; } -export function CloneProjectForm({ onCancel, onCloned }: CloneProjectFormProps) { +function withRepoListDeadline( + promise: Promise<ListMyGitHubReposResult>, +): Promise<ListMyGitHubReposResult> { + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + reject( + new Error( + "Timed out loading repositories. Check GitHub auth and network access on the selected machine.", + ), + ); + }, 15_000); + promise.then( + (value) => { + window.clearTimeout(timer); + resolve(value); + }, + (error) => { + window.clearTimeout(timer); + reject(error); + }, + ); + }); +} + +type ChooseDirectory = + | ((args: { title: string; defaultPath?: string }) => Promise<string | null>) + | null; + +export function CloneProjectForm({ + onCancel, + onCloned, + machineName, + getDefaultParentDir, + browseDirectories, + chooseDirectory, + cloneProject, + listMyRepos, + allowTokenSetup = true, +}: CloneProjectFormProps) { const [tab, setTab] = useState<Tab>("url"); const [defaultParentDir, setDefaultParentDir] = useState<string>(""); + const loadDefaultParentDir = + getDefaultParentDir ?? window.ade.project.getDefaultParentDir; + const browse = browseDirectories ?? window.ade.project.browseDirectories; + const pickDirectory: ChooseDirectory = + chooseDirectory === undefined + ? window.ade.project.chooseDirectory + : chooseDirectory; + const clone = cloneProject ?? window.ade.project.clone; + const loadRepos = listMyRepos ?? window.ade.github.listMyRepos; useEffect(() => { let cancelled = false; - void window.ade.project - .getDefaultParentDir() + void loadDefaultParentDir() .then((value) => { if (cancelled) return; setDefaultParentDir(value); @@ -128,20 +211,38 @@ export function CloneProjectForm({ onCancel, onCloned }: CloneProjectFormProps) return () => { cancelled = true; }; - }, []); + }, [loadDefaultParentDir]); return ( - <div style={{ display: "flex", flexDirection: "column", gap: 14, width: "100%" }}> + <div + style={{ + display: "flex", + flexDirection: "column", + gap: 14, + width: "100%", + }} + > <TabBar tab={tab} onChange={setTab} /> + {machineName ? ( + <InlineHint tone="muted">Target: {machineName}</InlineHint> + ) : null} {tab === "url" ? ( <UrlTab defaultParentDir={defaultParentDir} + browseDirectories={browse} + chooseDirectory={pickDirectory} + cloneProject={clone} onCancel={onCancel} onCloned={onCloned} /> ) : ( <MyReposTab defaultParentDir={defaultParentDir} + browseDirectories={browse} + chooseDirectory={pickDirectory} + cloneProject={clone} + listMyRepos={loadRepos} + allowTokenSetup={allowTokenSetup} onCancel={onCancel} onCloned={onCloned} /> @@ -166,7 +267,10 @@ function TabBar({ tab, onChange }: { tab: Tab; onChange: (tab: Tab) => void }) { <TabButton active={tab === "url"} onClick={() => onChange("url")}> URL </TabButton> - <TabButton active={tab === "my-repos"} onClick={() => onChange("my-repos")}> + <TabButton + active={tab === "my-repos"} + onClick={() => onChange("my-repos")} + > My repos </TabButton> </div> @@ -209,12 +313,26 @@ function TabButton({ function UrlTab({ defaultParentDir, + browseDirectories, + chooseDirectory, + cloneProject, onCancel, onCloned, }: { defaultParentDir: string; + browseDirectories: ( + input: ProjectBrowseInput, + ) => Promise<ProjectBrowseResult>; + chooseDirectory: ChooseDirectory; + cloneProject: ( + input: CloneProjectInput, + ) => Promise<CloneProjectResult & { projectId?: string }>; onCancel: () => void; - onCloned: (result: { rootPath: string; displayName: string }) => void; + onCloned: (result: { + rootPath: string; + displayName: string; + projectId?: string; + }) => void; }) { const [url, setUrl] = useState(""); const [name, setName] = useState(""); @@ -246,8 +364,7 @@ function UrlTab({ } const requestId = ++checkRequestRef.current; const timeout = window.setTimeout(() => { - void window.ade.project - .browseDirectories({ partialPath: previewPath }) + void browseDirectories({ partialPath: previewPath }) .then((result) => { if (checkRequestRef.current !== requestId) return; setPathExists(result.exactDirectoryPath === previewPath); @@ -258,7 +375,7 @@ function UrlTab({ }); }, 220); return () => window.clearTimeout(timeout); - }, [previewPath]); + }, [browseDirectories, previewPath]); const handleUrlBlur = useCallback(() => { if (nameTouched) return; @@ -267,10 +384,11 @@ function UrlTab({ }, [nameTouched, trimmedUrl]); const handleChooseParent = useCallback(async () => { + if (!chooseDirectory) return; setPickerPending(true); setError(null); try { - const selected = await window.ade.project.chooseDirectory({ + const selected = await chooseDirectory({ title: "Choose parent directory", defaultPath: parentDir || undefined, }); @@ -280,28 +398,36 @@ function UrlTab({ } finally { setPickerPending(false); } - }, [parentDir]); + }, [chooseDirectory, parentDir]); const canSubmit = - urlValid && trimmedName.length > 0 && parentDir.length > 0 && !pathExists && !pending; + urlValid && + trimmedName.length > 0 && + parentDir.length > 0 && + !pathExists && + !pending; const handleSubmit = useCallback(async () => { if (!canSubmit) return; setPending(true); setError(null); try { - const result = await window.ade.project.clone({ + const result = await cloneProject({ url: trimmedUrl, parentDir, name: trimmedName, }); - onCloned({ rootPath: result.rootPath, displayName: trimmedName }); + onCloned({ + rootPath: result.rootPath, + displayName: trimmedName, + projectId: result.projectId, + }); } catch (err) { setError(extractError(err)); } finally { setPending(false); } - }, [canSubmit, onCloned, parentDir, trimmedName, trimmedUrl]); + }, [canSubmit, cloneProject, onCloned, parentDir, trimmedName, trimmedUrl]); return ( <div style={{ display: "flex", flexDirection: "column", gap: 14 }}> @@ -318,7 +444,8 @@ function UrlTab({ /> {url.length > 0 && !urlValid ? ( <InlineHint tone="danger"> - Enter a GitHub URL like https://github.com/owner/repo or git@github.com:owner/repo.git + Enter a GitHub URL like https://github.com/owner/repo or + git@github.com:owner/repo.git </InlineHint> ) : null} </Field> @@ -339,38 +466,38 @@ function UrlTab({ <Field label="PARENT DIRECTORY"> <div style={{ display: "flex", gap: 8, alignItems: "center" }}> - <div + <input + type="text" + value={parentDir} + onChange={(event) => setParentDir(event.target.value)} + placeholder="Parent directory" style={{ ...inputStyle, - display: "flex", - alignItems: "center", fontFamily: MONO_FONT, fontSize: 12, color: parentDir ? COLORS.textPrimary : COLORS.textMuted, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", flex: 1, }} title={parentDir} - > - {parentDir || "No parent directory selected"} - </div> - <button - type="button" - style={outlineButton({ minWidth: 44 })} - disabled={pickerPending || pending} - onClick={() => { - void handleChooseParent(); - }} - aria-label="Choose parent directory" - > - {pickerPending ? ( - <CircleNotch size={12} weight="bold" className="animate-spin" /> - ) : ( - <FolderOpen size={12} weight="regular" /> - )} - </button> + disabled={pending} + /> + {chooseDirectory ? ( + <button + type="button" + style={outlineButton({ minWidth: 44 })} + disabled={pickerPending || pending} + onClick={() => { + void handleChooseParent(); + }} + aria-label="Choose parent directory" + > + {pickerPending ? ( + <CircleNotch size={12} weight="bold" className="animate-spin" /> + ) : ( + <FolderOpen size={12} weight="regular" /> + )} + </button> + ) : null} </div> </Field> @@ -378,8 +505,20 @@ function UrlTab({ {error ? <InlineHint tone="danger">{error}</InlineHint> : null} - <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}> - <button type="button" style={outlineButton()} onClick={onCancel} disabled={pending}> + <div + style={{ + display: "flex", + gap: 8, + justifyContent: "flex-end", + marginTop: 4, + }} + > + <button + type="button" + style={outlineButton()} + onClick={onCancel} + disabled={pending} + > Cancel </button> <button @@ -410,12 +549,32 @@ function UrlTab({ function MyReposTab({ defaultParentDir, + browseDirectories, + chooseDirectory, + cloneProject, + listMyRepos, + allowTokenSetup, onCancel, onCloned, }: { defaultParentDir: string; + browseDirectories: ( + input: ProjectBrowseInput, + ) => Promise<ProjectBrowseResult>; + chooseDirectory: ChooseDirectory; + cloneProject: ( + input: CloneProjectInput, + ) => Promise<CloneProjectResult & { projectId?: string }>; + listMyRepos: ( + input?: ListMyGitHubReposInput, + ) => Promise<ListMyGitHubReposResult>; + allowTokenSetup: boolean; onCancel: () => void; - onCloned: (result: { rootPath: string; displayName: string }) => void; + onCloned: (result: { + rootPath: string; + displayName: string; + projectId?: string; + }) => void; }) { const [status, setStatus] = useState<GitHubStatus | null>(null); const [statusLoading, setStatusLoading] = useState(true); @@ -435,10 +594,30 @@ function MyReposTab({ }, []); useEffect(() => { + if (!allowTokenSetup) { + setStatusLoading(false); + return; + } void loadStatus(); - }, [loadStatus]); + }, [allowTokenSetup, loadStatus]); + + if (!allowTokenSetup) { + return ( + <ConnectedRepoBrowser + defaultParentDir={defaultParentDir} + browseDirectories={browseDirectories} + chooseDirectory={chooseDirectory} + cloneProject={cloneProject} + listMyRepos={listMyRepos} + onCancel={onCancel} + onCloned={onCloned} + /> + ); + } - const isConnected = Boolean(status?.tokenStored && !status?.tokenDecryptionFailed); + const isConnected = Boolean( + status?.tokenStored && !status?.tokenDecryptionFailed, + ); if (statusLoading && !status) { return ( @@ -474,6 +653,10 @@ function MyReposTab({ return ( <ConnectedRepoBrowser defaultParentDir={defaultParentDir} + browseDirectories={browseDirectories} + chooseDirectory={chooseDirectory} + cloneProject={cloneProject} + listMyRepos={listMyRepos} onCancel={onCancel} onCloned={onCloned} /> @@ -529,7 +712,8 @@ function ConnectGithubPrompt({ width: 32, height: 32, borderRadius: 8, - background: "color-mix(in srgb, var(--color-accent) 15%, transparent)", + background: + "color-mix(in srgb, var(--color-accent) 15%, transparent)", color: COLORS.accent, }} > @@ -560,7 +744,9 @@ function ConnectGithubPrompt({ </div> <label style={{ display: "flex", flexDirection: "column", gap: 6 }}> - <span style={{ ...LABEL_STYLE, letterSpacing: "0.08em" }}>PERSONAL ACCESS TOKEN</span> + <span style={{ ...LABEL_STYLE, letterSpacing: "0.08em" }}> + PERSONAL ACCESS TOKEN + </span> <input type="password" value={token} @@ -577,12 +763,17 @@ function ConnectGithubPrompt({ /> </label> - {(error || statusError) ? ( + {error || statusError ? ( <InlineHint tone="danger">{error ?? statusError}</InlineHint> ) : null} <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> - <button type="button" style={outlineButton()} onClick={onCancel} disabled={pending}> + <button + type="button" + style={outlineButton()} + onClick={onCancel} + disabled={pending} + > Cancel </button> <button @@ -612,12 +803,30 @@ function ConnectGithubPrompt({ function ConnectedRepoBrowser({ defaultParentDir, + browseDirectories, + chooseDirectory, + cloneProject, + listMyRepos, onCancel, onCloned, }: { defaultParentDir: string; + browseDirectories: ( + input: ProjectBrowseInput, + ) => Promise<ProjectBrowseResult>; + chooseDirectory: ChooseDirectory; + cloneProject: ( + input: CloneProjectInput, + ) => Promise<CloneProjectResult & { projectId?: string }>; + listMyRepos: ( + input?: ListMyGitHubReposInput, + ) => Promise<ListMyGitHubReposResult>; onCancel: () => void; - onCloned: (result: { rootPath: string; displayName: string }) => void; + onCloned: (result: { + rootPath: string; + displayName: string; + projectId?: string; + }) => void; }) { const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); @@ -627,6 +836,11 @@ function ConnectedRepoBrowser({ const [expandedFullName, setExpandedFullName] = useState<string | null>(null); const requestRef = useRef(0); + const listMyReposRef = useRef(listMyRepos); + + useEffect(() => { + listMyReposRef.current = listMyRepos; + }, [listMyRepos]); useEffect(() => { const timeout = window.setTimeout(() => { @@ -639,8 +853,9 @@ function ConnectedRepoBrowser({ const requestId = ++requestRef.current; setLoading(true); setError(null); - void window.ade.github - .listMyRepos({ search: debouncedSearch || undefined }) + void withRepoListDeadline( + listMyReposRef.current({ search: debouncedSearch || undefined }), + ) .then((result) => { if (requestRef.current !== requestId) return; const sorted = [...result.repos].sort((a, b) => { @@ -696,7 +911,9 @@ function ConnectedRepoBrowser({ fontSize: 13, }} /> - {loading ? <CircleNotch size={12} weight="bold" className="animate-spin" /> : null} + {loading ? ( + <CircleNotch size={12} weight="bold" className="animate-spin" /> + ) : null} </div> {error ? <InlineHint tone="danger">{error}</InlineHint> : null} @@ -723,7 +940,9 @@ function ConnectedRepoBrowser({ textAlign: "center", }} > - {debouncedSearch ? "No repositories match." : "No repositories found."} + {debouncedSearch + ? "No repositories match." + : "No repositories found."} </div> ) : ( repos.map((repo) => ( @@ -732,16 +951,28 @@ function ConnectedRepoBrowser({ repo={repo} expanded={expandedFullName === repo.fullName} onToggle={() => - setExpandedFullName((prev) => (prev === repo.fullName ? null : repo.fullName)) + setExpandedFullName((prev) => + prev === repo.fullName ? null : repo.fullName, + ) } defaultParentDir={defaultParentDir} + browseDirectories={browseDirectories} + chooseDirectory={chooseDirectory} + cloneProject={cloneProject} onCloned={onCloned} /> )) )} </div> - <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 2 }}> + <div + style={{ + display: "flex", + gap: 8, + justifyContent: "flex-end", + marginTop: 2, + }} + > <button type="button" style={outlineButton()} onClick={onCancel}> Cancel </button> @@ -755,13 +986,27 @@ function RepoRow({ expanded, onToggle, defaultParentDir, + browseDirectories, + chooseDirectory, + cloneProject, onCloned, }: { repo: MyGitHubRepoSummary; expanded: boolean; onToggle: () => void; defaultParentDir: string; - onCloned: (result: { rootPath: string; displayName: string }) => void; + browseDirectories: ( + input: ProjectBrowseInput, + ) => Promise<ProjectBrowseResult>; + chooseDirectory: ChooseDirectory; + cloneProject: ( + input: CloneProjectInput, + ) => Promise<CloneProjectResult & { projectId?: string }>; + onCloned: (result: { + rootPath: string; + displayName: string; + projectId?: string; + }) => void; }) { const [parentDir, setParentDir] = useState(defaultParentDir); const [name, setName] = useState(repo.name); @@ -776,7 +1021,8 @@ function RepoRow({ const checkRequestRef = useRef(0); const trimmedName = name.trim(); - const previewPath = parentDir && trimmedName ? joinPath(parentDir, trimmedName) : ""; + const previewPath = + parentDir && trimmedName ? joinPath(parentDir, trimmedName) : ""; useEffect(() => { if (!expanded || !previewPath) { @@ -785,8 +1031,7 @@ function RepoRow({ } const requestId = ++checkRequestRef.current; const timeout = window.setTimeout(() => { - void window.ade.project - .browseDirectories({ partialPath: previewPath }) + void browseDirectories({ partialPath: previewPath }) .then((result) => { if (checkRequestRef.current !== requestId) return; setPathExists(result.exactDirectoryPath === previewPath); @@ -797,13 +1042,14 @@ function RepoRow({ }); }, 220); return () => window.clearTimeout(timeout); - }, [expanded, previewPath]); + }, [browseDirectories, expanded, previewPath]); const handleChooseParent = useCallback(async () => { + if (!chooseDirectory) return; setPickerPending(true); setError(null); try { - const selected = await window.ade.project.chooseDirectory({ + const selected = await chooseDirectory({ title: "Choose parent directory", defaultPath: parentDir || undefined, }); @@ -813,7 +1059,7 @@ function RepoRow({ } finally { setPickerPending(false); } - }, [parentDir]); + }, [chooseDirectory, parentDir]); const canClone = trimmedName.length > 0 && parentDir.length > 0 && !pathExists && !pending; @@ -823,18 +1069,22 @@ function RepoRow({ setPending(true); setError(null); try { - const result = await window.ade.project.clone({ + const result = await cloneProject({ url: repo.cloneUrl, parentDir, name: trimmedName, }); - onCloned({ rootPath: result.rootPath, displayName: trimmedName }); + onCloned({ + rootPath: result.rootPath, + displayName: trimmedName, + projectId: result.projectId, + }); } catch (err) { setError(extractError(err)); } finally { setPending(false); } - }, [canClone, onCloned, parentDir, repo.cloneUrl, trimmedName]); + }, [canClone, cloneProject, onCloned, parentDir, repo.cloneUrl, trimmedName]); const visibilityChip: CSSProperties = repo.isPrivate ? inlineBadge(COLORS.accent, { padding: "2px 6px", fontSize: 10 }) @@ -875,7 +1125,15 @@ function RepoRow({ minHeight: 56, }} > - <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 2 }}> + <div + style={{ + flex: 1, + minWidth: 0, + display: "flex", + flexDirection: "column", + gap: 2, + }} + > <div style={{ fontFamily: MONO_FONT, @@ -901,7 +1159,11 @@ function RepoRow({ <span style={visibilityChip}> {repo.isPrivate ? ( <> - <LockSimple size={9} weight="fill" style={{ marginRight: 3 }} /> + <LockSimple + size={9} + weight="fill" + style={{ marginRight: 3 }} + /> Private </> ) : ( @@ -930,7 +1192,8 @@ function RepoRow({ gap: 10, padding: "10px 12px 12px", borderTop: `1px solid ${COLORS.border}`, - background: "color-mix(in srgb, var(--color-bg) 40%, transparent)", + background: + "color-mix(in srgb, var(--color-bg) 40%, transparent)", }} > <Field label="FOLDER NAME"> @@ -945,38 +1208,42 @@ function RepoRow({ <Field label="PARENT DIRECTORY"> <div style={{ display: "flex", gap: 8, alignItems: "center" }}> - <div + <input + type="text" + value={parentDir} + onChange={(event) => setParentDir(event.target.value)} + placeholder="Parent directory" style={{ ...inputStyle, - display: "flex", - alignItems: "center", fontFamily: MONO_FONT, fontSize: 12, color: parentDir ? COLORS.textPrimary : COLORS.textMuted, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", flex: 1, }} title={parentDir} - > - {parentDir || "Choose a parent directory"} - </div> - <button - type="button" - style={outlineButton({ minWidth: 44 })} - disabled={pickerPending || pending} - onClick={() => { - void handleChooseParent(); - }} - aria-label="Choose parent directory" - > - {pickerPending ? ( - <CircleNotch size={12} weight="bold" className="animate-spin" /> - ) : ( - <FolderOpen size={12} weight="regular" /> - )} - </button> + disabled={pending} + /> + {chooseDirectory ? ( + <button + type="button" + style={outlineButton({ minWidth: 44 })} + disabled={pickerPending || pending} + onClick={() => { + void handleChooseParent(); + }} + aria-label="Choose parent directory" + > + {pickerPending ? ( + <CircleNotch + size={12} + weight="bold" + className="animate-spin" + /> + ) : ( + <FolderOpen size={12} weight="regular" /> + )} + </button> + ) : null} </div> </Field> @@ -984,7 +1251,9 @@ function RepoRow({ {error ? <InlineHint tone="danger">{error}</InlineHint> : null} - <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> + <div + style={{ display: "flex", gap: 8, justifyContent: "flex-end" }} + > <button type="button" style={outlineButton()} @@ -1007,7 +1276,11 @@ function RepoRow({ > {pending ? ( <> - <CircleNotch size={12} weight="bold" className="animate-spin" /> + <CircleNotch + size={12} + weight="bold" + className="animate-spin" + /> Cloning… </> ) : ( @@ -1041,15 +1314,29 @@ function RepoSkeleton() { aria-live="polite" > <CircleNotch size={22} weight="bold" className="animate-spin" /> - <div style={{ fontFamily: SANS_FONT, fontSize: 12 }}>Loading your repositories…</div> + <div style={{ fontFamily: SANS_FONT, fontSize: 12 }}> + Loading your repositories… + </div> </div> ); } -function Field({ label, children }: { label: string; children: React.ReactNode }) { +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { return ( <label style={{ display: "flex", flexDirection: "column", gap: 6 }}> - <span style={{ ...LABEL_STYLE, letterSpacing: "0.08em", textTransform: "uppercase" }}> + <span + style={{ + ...LABEL_STYLE, + letterSpacing: "0.08em", + textTransform: "uppercase", + }} + > {label} </span> {children} @@ -1068,7 +1355,9 @@ function PathPreview({ path, exists }: { path: string; exists: boolean }) { ? "color-mix(in srgb, var(--color-error) 8%, transparent)" : "color-mix(in srgb, var(--color-fg) 3%, transparent)", border: `1px solid ${ - exists ? "color-mix(in srgb, var(--color-error) 40%, var(--color-border))" : COLORS.border + exists + ? "color-mix(in srgb, var(--color-error) 40%, var(--color-border))" + : COLORS.border }`, }} > diff --git a/apps/desktop/src/renderer/components/projects/CreateProjectForm.tsx b/apps/desktop/src/renderer/components/projects/CreateProjectForm.tsx index d4a62c7ba..62b61b17d 100644 --- a/apps/desktop/src/renderer/components/projects/CreateProjectForm.tsx +++ b/apps/desktop/src/renderer/components/projects/CreateProjectForm.tsx @@ -7,6 +7,12 @@ import React, { type CSSProperties, } from "react"; import { CircleNotch, FolderOpen, Warning } from "@phosphor-icons/react"; +import type { + CreateProjectInput, + CreateProjectResult, + ProjectBrowseInput, + ProjectBrowseResult, +} from "../../../shared/types"; import { extractError } from "../../lib/format"; import { COLORS, @@ -20,7 +26,25 @@ import { export type CreateProjectFormProps = { onCancel: () => void; - onCreated: (result: { rootPath: string; displayName: string }) => void; + onCreated: (result: { + rootPath: string; + displayName: string; + projectId?: string; + }) => void; + machineName?: string; + getDefaultParentDir?: () => Promise<string>; + browseDirectories?: ( + input: ProjectBrowseInput, + ) => Promise<ProjectBrowseResult>; + chooseDirectory?: + | ((args: { + title: string; + defaultPath?: string; + }) => Promise<string | null>) + | null; + createProject?: ( + input: CreateProjectInput, + ) => Promise<CreateProjectResult & { projectId?: string }>; }; type NameValidation = { ok: true } | { ok: false; reason: string }; @@ -28,16 +52,22 @@ type NameValidation = { ok: true } | { ok: false; reason: string }; function validateName(rawName: string): NameValidation { const name = rawName.trim(); if (name.length === 0) return { ok: false, reason: "Enter a project name" }; - if (name.length > 100) return { ok: false, reason: "Name must be 100 characters or fewer" }; - if (name.startsWith(".")) return { ok: false, reason: "Name cannot start with a dot" }; - if (/[/\\]/.test(name)) return { ok: false, reason: "Name cannot contain / or \\" }; + if (name.length > 100) + return { ok: false, reason: "Name must be 100 characters or fewer" }; + if (name.startsWith(".")) + return { ok: false, reason: "Name cannot start with a dot" }; + if (/[/\\]/.test(name)) + return { ok: false, reason: "Name cannot contain / or \\" }; return { ok: true }; } function joinPath(parent: string, name: string): string { if (!parent) return name; const sep = parent.includes("\\") ? "\\" : "/"; - const trimmed = parent.endsWith("/") || parent.endsWith("\\") ? parent.slice(0, -1) : parent; + const trimmed = + parent.endsWith("/") || parent.endsWith("\\") + ? parent.slice(0, -1) + : parent; if (!name) return trimmed; return `${trimmed}${sep}${name}`; } @@ -56,7 +86,15 @@ const inputStyle: CSSProperties = { boxSizing: "border-box", }; -export function CreateProjectForm({ onCancel, onCreated }: CreateProjectFormProps) { +export function CreateProjectForm({ + onCancel, + onCreated, + machineName, + getDefaultParentDir, + browseDirectories, + chooseDirectory, + createProject, +}: CreateProjectFormProps) { const [name, setName] = useState(""); const [parentDir, setParentDir] = useState<string>(""); const [parentDirLoading, setParentDirLoading] = useState(true); @@ -67,11 +105,18 @@ export function CreateProjectForm({ onCancel, onCreated }: CreateProjectFormProp const [submitAttempted, setSubmitAttempted] = useState(false); const checkRequestRef = useRef(0); + const loadDefaultParentDir = + getDefaultParentDir ?? window.ade.project.getDefaultParentDir; + const browse = browseDirectories ?? window.ade.project.browseDirectories; + const pickDirectory = + chooseDirectory === undefined + ? window.ade.project.chooseDirectory + : chooseDirectory; + const create = createProject ?? window.ade.project.createLocal; useEffect(() => { let cancelled = false; - void window.ade.project - .getDefaultParentDir() + void loadDefaultParentDir() .then((value) => { if (cancelled) return; setParentDir(value); @@ -86,7 +131,7 @@ export function CreateProjectForm({ onCancel, onCreated }: CreateProjectFormProp return () => { cancelled = true; }; - }, []); + }, [loadDefaultParentDir]); const validation = useMemo(() => validateName(name), [name]); const trimmedName = name.trim(); @@ -102,8 +147,7 @@ export function CreateProjectForm({ onCancel, onCreated }: CreateProjectFormProp } const requestId = ++checkRequestRef.current; const timeout = window.setTimeout(() => { - void window.ade.project - .browseDirectories({ partialPath: previewPath }) + void browse({ partialPath: previewPath }) .then((result) => { if (checkRequestRef.current !== requestId) return; setPathExists(Boolean(result.exactDirectoryPath === previewPath)); @@ -116,18 +160,22 @@ export function CreateProjectForm({ onCancel, onCreated }: CreateProjectFormProp return () => { window.clearTimeout(timeout); }; - }, [previewPath, validation.ok]); + }, [browse, previewPath, validation.ok]); - const showNameError = - !validation.ok && (submitAttempted || name.length > 0); + const showNameError = !validation.ok && (submitAttempted || name.length > 0); const canSubmit = - validation.ok && parentDir.length > 0 && !pathExists && !pending && !parentDirLoading; + validation.ok && + parentDir.length > 0 && + !pathExists && + !pending && + !parentDirLoading; const handleChooseParent = useCallback(async () => { + if (!pickDirectory) return; setPickerPending(true); setError(null); try { - const selected = await window.ade.project.chooseDirectory({ + const selected = await pickDirectory({ title: "Choose parent directory", defaultPath: parentDir || undefined, }); @@ -139,7 +187,7 @@ export function CreateProjectForm({ onCancel, onCreated }: CreateProjectFormProp } finally { setPickerPending(false); } - }, [parentDir]); + }, [parentDir, pickDirectory]); const handleSubmit = useCallback(async () => { setSubmitAttempted(true); @@ -147,17 +195,25 @@ export function CreateProjectForm({ onCancel, onCreated }: CreateProjectFormProp setPending(true); setError(null); try { - const result = await window.ade.project.createLocal({ + const result = await create({ name: trimmedName, parentDir, }); - onCreated({ rootPath: result.rootPath, displayName: trimmedName }); + const projectId = + "projectId" in result && typeof result.projectId === "string" + ? result.projectId + : undefined; + onCreated({ + rootPath: result.rootPath, + displayName: trimmedName, + projectId, + }); } catch (err) { setError(extractError(err)); } finally { setPending(false); } - }, [onCreated, parentDir, pathExists, trimmedName, validation.ok]); + }, [create, onCreated, parentDir, pathExists, trimmedName, validation.ok]); return ( <div @@ -189,40 +245,44 @@ export function CreateProjectForm({ onCancel, onCreated }: CreateProjectFormProp ) : null} </Field> + {machineName ? ( + <InlineHint tone="muted">Target: {machineName}</InlineHint> + ) : null} + <Field label="PARENT DIRECTORY"> <div style={{ display: "flex", gap: 8, alignItems: "center" }}> - <div + <input + type="text" + value={parentDir} + onChange={(event) => setParentDir(event.target.value)} + placeholder={parentDirLoading ? "Loading…" : "Parent directory"} style={{ ...inputStyle, - display: "flex", - alignItems: "center", fontFamily: MONO_FONT, fontSize: 12, color: parentDir ? COLORS.textPrimary : COLORS.textMuted, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", flex: 1, }} title={parentDir} - > - {parentDirLoading ? "Loading…" : parentDir || "No parent directory selected"} - </div> - <button - type="button" - style={outlineButton({ minWidth: 44 })} - disabled={pickerPending || pending} - onClick={() => { - void handleChooseParent(); - }} - aria-label="Choose parent directory" - > - {pickerPending ? ( - <CircleNotch size={12} weight="bold" className="animate-spin" /> - ) : ( - <FolderOpen size={12} weight="regular" /> - )} - </button> + disabled={pending || parentDirLoading} + /> + {pickDirectory ? ( + <button + type="button" + style={outlineButton({ minWidth: 44 })} + disabled={pickerPending || pending} + onClick={() => { + void handleChooseParent(); + }} + aria-label="Choose parent directory" + > + {pickerPending ? ( + <CircleNotch size={12} weight="bold" className="animate-spin" /> + ) : ( + <FolderOpen size={12} weight="regular" /> + )} + </button> + ) : null} </div> </Field> @@ -248,7 +308,10 @@ export function CreateProjectForm({ onCancel, onCreated }: CreateProjectFormProp </button> <button type="button" - style={primaryButton({ opacity: canSubmit ? 1 : 0.55, cursor: canSubmit ? "pointer" : "not-allowed" })} + style={primaryButton({ + opacity: canSubmit ? 1 : 0.55, + cursor: canSubmit ? "pointer" : "not-allowed", + })} onClick={() => { void handleSubmit(); }} @@ -268,10 +331,22 @@ export function CreateProjectForm({ onCancel, onCreated }: CreateProjectFormProp ); } -function Field({ label, children }: { label: string; children: React.ReactNode }) { +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { return ( <label style={{ display: "flex", flexDirection: "column", gap: 6 }}> - <span style={{ ...LABEL_STYLE, letterSpacing: "0.08em", textTransform: "uppercase" }}> + <span + style={{ + ...LABEL_STYLE, + letterSpacing: "0.08em", + textTransform: "uppercase", + }} + > {label} </span> {children} diff --git a/apps/desktop/src/renderer/components/projects/RemoteProjectOpenDialog.tsx b/apps/desktop/src/renderer/components/projects/RemoteProjectOpenDialog.tsx new file mode 100644 index 000000000..0c64446f2 --- /dev/null +++ b/apps/desktop/src/renderer/components/projects/RemoteProjectOpenDialog.tsx @@ -0,0 +1,548 @@ +import { type CSSProperties, useEffect } from "react"; +import { CloudArrowUp, Desktop, GitBranch, Warning } from "@phosphor-icons/react"; +import type { + RemoteRuntimeLocalWorkCheckResult, + RemoteRuntimeLocalWorkMatch, + RemoteRuntimeProjectRecord, + RemoteRuntimeProjectWorkSummary, + RemoteRuntimeProjectWorktreeSummary, +} from "../../../shared/types"; +import { COLORS, MONO_FONT, SANS_FONT, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; + +type RemoteProjectOpenDialogProps = { + project: RemoteRuntimeProjectRecord; + localWork: RemoteRuntimeLocalWorkCheckResult; + runtimeName: string; + busy?: boolean; + onCancel: () => void; + onContinue: () => void; +}; + +const overlayStyle: CSSProperties = { + position: "fixed", + inset: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 24, + background: "rgba(3, 4, 10, 0.76)", + backdropFilter: "blur(10px)", + zIndex: 160, +}; + +const dialogStyle: CSSProperties = { + width: "min(820px, calc(100vw - 32px))", + maxHeight: "calc(100vh - 48px)", + display: "grid", + gridTemplateRows: "auto minmax(0, 1fr) auto", + background: COLORS.cardBgSolid, + border: `1px solid ${COLORS.outlineBorder}`, + borderRadius: 16, + boxShadow: "0 30px 100px rgba(0,0,0,0.55)", + overflow: "hidden", +}; + +function projectLabel(project: RemoteRuntimeProjectRecord): string { + return project.displayName || project.rootPath.split(/[\\/]/).filter(Boolean).at(-1) || project.projectId; +} + +function shortenPath(path: string): string { + if (!path) return path; + const home = path.match(/^\/Users\/([^/]+)/); + if (home) return path.replace(`/Users/${home[1]}`, "~"); + return path; +} + +function shortenOrigin(origin: string | null | undefined): string { + if (!origin) return "No Git remote"; + return origin + .replace(/^https?:\/\//, "") + .replace(/^git@([^:]+):/, "$1/") + .replace(/\.git$/, ""); +} + +function pluralize(count: number, singular: string, plural?: string): string { + return count === 1 ? singular : plural ?? `${singular}s`; +} + +type Tone = "neutral" | "warn" | "accent"; + +function chipStyle(tone: Tone, overrides?: CSSProperties): CSSProperties { + const color = + tone === "warn" + ? "var(--color-warning)" + : tone === "accent" + ? "var(--color-accent)" + : "var(--color-fg)"; + const textColor = + tone === "warn" ? COLORS.warning : tone === "accent" ? COLORS.accent : COLORS.textMuted; + return { + display: "inline-flex", + alignItems: "center", + gap: 4, + padding: "2px 7px", + borderRadius: 999, + fontFamily: MONO_FONT, + fontSize: 10, + fontWeight: 500, + color: textColor, + background: `color-mix(in srgb, ${color} ${tone === "neutral" ? 7 : 12}%, transparent)`, + border: `1px solid color-mix(in srgb, ${color} ${tone === "neutral" ? 18 : 26}%, transparent)`, + whiteSpace: "nowrap", + ...overrides, + }; +} + +function StatBlock({ + value, + label, + tone = "neutral", +}: { + value: number | string; + label: string; + tone?: Tone; +}) { + const accent = + tone === "warn" ? COLORS.warning : tone === "accent" ? COLORS.accent : COLORS.textPrimary; + return ( + <div + style={{ + display: "grid", + gap: 2, + padding: "8px 10px", + borderRadius: 8, + background: "rgba(255,255,255,0.03)", + border: `1px solid color-mix(in srgb, ${accent} ${tone === "neutral" ? 8 : 22}%, transparent)`, + minWidth: 0, + }} + > + <div + style={{ + color: accent, + fontFamily: MONO_FONT, + fontSize: 18, + fontWeight: 700, + lineHeight: 1.1, + letterSpacing: "-0.01em", + }} + > + {value} + </div> + <div + style={{ + color: COLORS.textMuted, + fontFamily: SANS_FONT, + fontSize: 10, + textTransform: "uppercase", + letterSpacing: "0.06em", + }} + > + {label} + </div> + </div> + ); +} + +function laneChipLabel(lane: RemoteRuntimeProjectWorktreeSummary): string { + if (lane.isPrimary) return lane.branchName ? `Primary · ${lane.branchName}` : "Primary"; + return lane.branchName ?? lane.name; +} + +function ComparisonCard({ + icon, + kicker, + title, + path, + origin, + summary, + tone, +}: { + icon: React.ReactNode; + kicker: string; + title: string; + path: string; + origin: string | null; + summary: RemoteRuntimeProjectWorkSummary | null | undefined; + tone: "local" | "remote"; +}) { + const accentColor = tone === "local" ? "var(--color-warning)" : "var(--color-accent)"; + const accentText = tone === "local" ? COLORS.warning : COLORS.accent; + const dirtyLanes = (summary?.lanes ?? []).filter((l) => l.dirtyCount > 0); + const sortedLanes = [...dirtyLanes].sort((a, b) => { + if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; + return b.dirtyCount - a.dirtyCount; + }); + const visibleLanes = sortedLanes.slice(0, 3); + const overflow = sortedLanes.length - visibleLanes.length; + const hasDirty = (summary?.dirtyFileCount ?? 0) > 0; + + return ( + <div + style={{ + display: "grid", + gap: 12, + padding: 14, + borderRadius: 12, + border: `1px solid color-mix(in srgb, ${accentColor} 22%, ${COLORS.border})`, + background: `linear-gradient(180deg, color-mix(in srgb, ${accentColor} 6%, rgba(255,255,255,0.02)) 0%, rgba(255,255,255,0.015) 100%)`, + minWidth: 0, + }} + > + <div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}> + <div + aria-hidden="true" + style={{ + width: 30, + height: 30, + borderRadius: 8, + display: "grid", + placeItems: "center", + background: `color-mix(in srgb, ${accentColor} 14%, transparent)`, + border: `1px solid color-mix(in srgb, ${accentColor} 30%, transparent)`, + color: accentText, + flexShrink: 0, + }} + > + {icon} + </div> + <div style={{ display: "grid", gap: 1, minWidth: 0 }}> + <div + style={{ + color: accentText, + fontFamily: SANS_FONT, + fontSize: 10, + fontWeight: 600, + textTransform: "uppercase", + letterSpacing: "0.08em", + }} + > + {kicker} + </div> + <div + style={{ + color: COLORS.textPrimary, + fontFamily: SANS_FONT, + fontSize: 14, + fontWeight: 600, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > + {title} + </div> + </div> + </div> + + <div style={{ display: "grid", gap: 3, minWidth: 0 }}> + <div + title={path} + style={{ + color: COLORS.textMuted, + fontFamily: MONO_FONT, + fontSize: 11, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > + {shortenPath(path)} + </div> + <div + title={origin ?? undefined} + style={{ + color: COLORS.textDim, + fontFamily: MONO_FONT, + fontSize: 11, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > + {shortenOrigin(origin)} + </div> + </div> + + <div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 6 }}> + <StatBlock value={summary?.laneCount ?? "—"} label="Lanes" /> + <StatBlock + value={summary?.dirtyFileCount ?? "—"} + label={pluralize(summary?.dirtyFileCount ?? 0, "Change", "Changes")} + tone={hasDirty ? "warn" : "neutral"} + /> + <StatBlock + value={summary?.dirtyLaneCount ?? "—"} + label={pluralize(summary?.dirtyLaneCount ?? 0, "Dirty lane", "Dirty lanes")} + tone={(summary?.dirtyLaneCount ?? 0) > 0 ? "warn" : "neutral"} + /> + </div> + + {visibleLanes.length > 0 ? ( + <div style={{ display: "flex", flexWrap: "wrap", gap: 6, minWidth: 0 }}> + {visibleLanes.map((lane) => ( + <span + key={`${lane.rootPath}:${lane.name}`} + title={`${laneChipLabel(lane)} — ${lane.dirtyCount} ${pluralize(lane.dirtyCount, "file")}`} + style={chipStyle(lane.isPrimary ? "warn" : "neutral", { + maxWidth: "100%", + overflow: "hidden", + })} + > + <GitBranch size={10} weight="bold" style={{ flexShrink: 0 }} /> + <span + style={{ + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + minWidth: 0, + }} + > + {laneChipLabel(lane)} + </span> + <span style={{ color: COLORS.warning, flexShrink: 0, fontWeight: 600 }}> + {lane.dirtyCount} + </span> + </span> + ))} + {overflow > 0 ? ( + <span style={chipStyle("neutral")}>+{overflow} more</span> + ) : null} + </div> + ) : summary ? ( + <div + style={{ + color: COLORS.textMuted, + fontFamily: SANS_FONT, + fontSize: 11, + fontStyle: "italic", + }} + > + All lanes clean + </div> + ) : null} + </div> + ); +} + +function ExtraLocalRow({ match }: { match: RemoteRuntimeLocalWorkMatch }) { + const dirtyCount = match.workSummary?.dirtyFileCount ?? match.dirtyCount; + return ( + <div + style={{ + display: "flex", + alignItems: "center", + gap: 10, + padding: "8px 12px", + borderRadius: 8, + border: `1px solid ${COLORS.border}`, + background: "rgba(255,255,255,0.02)", + minWidth: 0, + }} + > + <Desktop size={14} weight="duotone" style={{ color: COLORS.textMuted, flexShrink: 0 }} /> + <div style={{ display: "grid", gap: 1, minWidth: 0, flex: 1 }}> + <div + style={{ + color: COLORS.textPrimary, + fontFamily: SANS_FONT, + fontSize: 12, + fontWeight: 600, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > + {match.displayName} + </div> + <div + title={match.rootPath} + style={{ + color: COLORS.textMuted, + fontFamily: MONO_FONT, + fontSize: 11, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > + {shortenPath(match.rootPath)} + </div> + </div> + {dirtyCount > 0 ? ( + <span style={chipStyle("warn")}> + {dirtyCount} {pluralize(dirtyCount, "change")} + </span> + ) : ( + <span style={chipStyle("neutral")}>clean</span> + )} + </div> + ); +} + +export function RemoteProjectOpenDialog({ + project, + localWork, + runtimeName, + busy = false, + onCancel, + onContinue, +}: RemoteProjectOpenDialogProps) { + const titleId = "remote-project-open-dialog-title"; + const descriptionId = "remote-project-open-dialog-description"; + const label = projectLabel(project); + const primaryLocal = localWork.matches[0] ?? null; + const extraLocal = localWork.matches.slice(1); + + useEffect(() => { + if (busy) return undefined; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onCancel(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [busy, onCancel]); + + return ( + <div + role="presentation" + style={overlayStyle} + onClick={() => { + if (!busy) onCancel(); + }} + > + <div + role="dialog" + aria-modal="true" + aria-labelledby={titleId} + aria-describedby={descriptionId} + onClick={(event) => event.stopPropagation()} + style={dialogStyle} + > + <div + style={{ + display: "flex", + alignItems: "flex-start", + gap: 12, + padding: 20, + borderBottom: `1px solid ${COLORS.border}`, + background: "linear-gradient(180deg, rgba(24,20,35,0.98) 0%, rgba(24,20,35,0.96) 100%)", + }} + > + <div + aria-hidden="true" + style={{ + width: 34, + height: 34, + borderRadius: 8, + display: "grid", + placeItems: "center", + background: "color-mix(in srgb, var(--color-warning) 14%, transparent)", + border: "1px solid color-mix(in srgb, var(--color-warning) 30%, transparent)", + color: COLORS.warning, + flexShrink: 0, + }} + > + <Warning size={18} weight="fill" /> + </div> + <div style={{ display: "grid", gap: 6, minWidth: 0 }}> + <div + id={titleId} + style={{ color: COLORS.textPrimary, fontFamily: SANS_FONT, fontSize: 18, fontWeight: 700 }} + > + You already work on this repo locally + </div> + <div + id={descriptionId} + style={{ color: COLORS.textMuted, fontFamily: SANS_FONT, fontSize: 13, lineHeight: 1.5 }} + > + Opening it on <strong style={{ color: COLORS.textPrimary, fontWeight: 600 }}>{runtimeName}</strong>{" "} + creates a separate remote tab. Your local files and lanes stay exactly where they are. + </div> + </div> + </div> + + <div style={{ display: "grid", gap: 16, padding: 20, overflow: "auto", minWidth: 0 }}> + <div + style={{ + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", + gap: 12, + minWidth: 0, + }} + > + {primaryLocal ? ( + <ComparisonCard + icon={<Desktop size={16} weight="duotone" />} + kicker="On this Mac" + title={primaryLocal.displayName} + path={primaryLocal.rootPath} + origin={primaryLocal.gitOriginUrl} + summary={primaryLocal.workSummary} + tone="local" + /> + ) : null} + <ComparisonCard + icon={<CloudArrowUp size={16} weight="duotone" />} + kicker={`On ${runtimeName}`} + title={label} + path={project.rootPath} + origin={localWork.remoteGitOriginUrl} + summary={localWork.remoteWorkSummary} + tone="remote" + /> + </div> + + {extraLocal.length > 0 ? ( + <div style={{ display: "grid", gap: 8, minWidth: 0 }}> + <div + style={{ + color: COLORS.textMuted, + fontFamily: SANS_FONT, + fontSize: 11, + fontWeight: 500, + textTransform: "uppercase", + letterSpacing: "0.06em", + }} + > + {extraLocal.length} more local {pluralize(extraLocal.length, "copy", "copies")} of this repo + </div> + <div style={{ display: "grid", gap: 6 }}> + {extraLocal.map((match) => ( + <ExtraLocalRow key={`${match.rootPath}:${match.gitOriginUrl}`} match={match} /> + ))} + </div> + </div> + ) : null} + </div> + + <div + style={{ + display: "flex", + justifyContent: "flex-end", + gap: 10, + padding: 16, + borderTop: `1px solid ${COLORS.border}`, + background: "rgba(255,255,255,0.02)", + }} + > + <button + type="button" + disabled={busy} + onClick={onCancel} + style={{ ...outlineButton({ height: 34 }), opacity: busy ? 0.55 : 1 }} + > + Cancel + </button> + <button + type="button" + disabled={busy} + onClick={onContinue} + style={{ ...primaryButton({ height: 34 }), opacity: busy ? 0.7 : 1 }} + > + {busy ? "Opening..." : `Open on ${runtimeName}`} + </button> + </div> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/components/prs/PRsPage.tsx b/apps/desktop/src/renderer/components/prs/PRsPage.tsx index a449a1383..62af7acbc 100644 --- a/apps/desktop/src/renderer/components/prs/PRsPage.tsx +++ b/apps/desktop/src/renderer/components/prs/PRsPage.tsx @@ -139,7 +139,10 @@ function PRsPageInner() { setActiveTab(resolved.activeTab); if (!resolved.isWorkflowRoute) { - setSelectedPrId(routeState.prId ?? null); + const prNumberMatch = routeState.prNumber == null + ? null + : prs.find((pr) => pr.githubPrNumber === routeState.prNumber)?.id ?? null; + setSelectedPrId(routeState.prId ?? prNumberMatch); setSelectedDetailTab(routeState.detailTab); } if (resolved.effectiveWorkflow === "queue") { @@ -160,7 +163,7 @@ function PRsPageInner() { window.removeEventListener("popstate", syncFromLocation); window.removeEventListener("hashchange", syncFromLocation); }; - }, [location.search, rebaseNeeds, setActiveTab, setSelectedPrId, setSelectedQueueGroupId, setSelectedRebaseItemId]); + }, [location.search, prs, rebaseNeeds, setActiveTab, setSelectedPrId, setSelectedQueueGroupId, setSelectedRebaseItemId]); React.useEffect(() => { const current = parsePrsRouteState({ search: location.search }); diff --git a/apps/desktop/src/renderer/components/prs/prsRouteState.test.ts b/apps/desktop/src/renderer/components/prs/prsRouteState.test.ts index 670fc9bad..1b628e518 100644 --- a/apps/desktop/src/renderer/components/prs/prsRouteState.test.ts +++ b/apps/desktop/src/renderer/components/prs/prsRouteState.test.ts @@ -28,6 +28,7 @@ describe("prsRouteState", () => { workflowTab: "queue", laneId: null, prId: null, + prNumber: null, queueGroupId: "group-hash", eventId: null, threadId: null, @@ -47,6 +48,7 @@ describe("prsRouteState", () => { workflowTab: "rebase", laneId: "lane-456", prId: "pr-789", + prNumber: null, queueGroupId: "group-1", eventId: null, threadId: null, @@ -65,6 +67,7 @@ describe("prsRouteState", () => { workflowTab: null, laneId: null, prId: "pr-1", + prNumber: null, queueGroupId: null, eventId: "evt-99", threadId: "thr-12", @@ -88,6 +91,13 @@ describe("prsRouteState", () => { ).toBe("?tab=normal&prId=pr-1&eventId=evt-5&threadId=thr-3&commitSha=abc&detailTab=checks"); }); + it("parses PR number handoff routes", () => { + const parsed = parsePrsRouteState({ search: "?tab=normal&pr=123&laneId=lane-1" }); + expect(parsed.prNumber).toBe(123); + expect(parsed.prId).toBeNull(); + expect(parsed.laneId).toBe("lane-1"); + }); + it("builds normal and workflow route searches with the expected ids", () => { expect( buildPrsRouteSearch({ diff --git a/apps/desktop/src/renderer/components/prs/prsRouteState.ts b/apps/desktop/src/renderer/components/prs/prsRouteState.ts index 44fd73453..278d14d09 100644 --- a/apps/desktop/src/renderer/components/prs/prsRouteState.ts +++ b/apps/desktop/src/renderer/components/prs/prsRouteState.ts @@ -14,6 +14,7 @@ export type ParsedPrsRouteState = { workflowTab: PrWorkflowTab | null; laneId: string | null; prId: string | null; + prNumber: number | null; queueGroupId: string | null; eventId: string | null; threadId: string | null; @@ -56,6 +57,13 @@ function parseOptionalId(value: string | null): string | null { return trimmed.length > 0 ? trimmed : null; } +function parseOptionalNumber(value: string | null): number | null { + const trimmed = parseOptionalId(value); + if (!trimmed) return null; + const parsed = Number.parseInt(trimmed, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + export function parsePrsRouteState(args: { search?: string | null; hash?: string | null }): ParsedPrsRouteState { const searchParams = parseSearch(args.search ?? ""); const hashParams = parseHashParams(args.hash ?? ""); @@ -75,6 +83,7 @@ export function parsePrsRouteState(args: { search?: string | null; hash?: string workflowTab, laneId: pick("laneId"), prId: pick("prId"), + prNumber: parseOptionalNumber(routeParams.get("pr")), queueGroupId: pick("queueGroupId"), eventId: pick("eventId"), threadId: pick("threadId"), diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx new file mode 100644 index 000000000..71625f0ef --- /dev/null +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx @@ -0,0 +1,162 @@ +import { useEffect, useMemo, useState, type CSSProperties, type FormEvent } from "react"; +import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; +import type { RemoteRuntimeTargetInput } from "../../../shared/types"; + +export type RemoteTargetFormPrefill = Partial<RemoteRuntimeTargetInput> & { + key: string; + targetId?: string | null; +}; + +type RemoteTargetFormProps = { + busy?: boolean; + busyLabel?: string; + prefill?: RemoteTargetFormPrefill | null; + submitLabel?: string; + onSubmit: (input: RemoteRuntimeTargetInput) => void | Promise<void>; +}; + +const fieldStyle: CSSProperties = { + width: "100%", + height: 38, + borderRadius: 8, + border: `1px solid ${COLORS.border}`, + background: "rgba(255,255,255,0.03)", + color: COLORS.textPrimary, + fontFamily: MONO_FONT, + fontSize: 12, + padding: "0 10px", + outline: "none", +}; + +export function RemoteTargetForm({ + busy = false, + busyLabel = "Connecting...", + onSubmit, + prefill = null, + submitLabel = "Connect", +}: RemoteTargetFormProps) { + const [name, setName] = useState(""); + const [hostname, setHostname] = useState(""); + const [sshUser, setSshUser] = useState(""); + const [port, setPort] = useState(""); + const [sshKeyPath, setSshKeyPath] = useState(""); + const prefillKey = prefill?.key ?? null; + const prefillName = prefill?.name; + const prefillHostname = prefill?.hostname; + const prefillSshUser = prefill?.sshUser; + const prefillPort = prefill?.port; + const prefillSshKeyPath = prefill?.sshKeyPath; + + useEffect(() => { + if (!prefill) return; + if (prefillName !== undefined) setName(prefillName ?? ""); + if (prefillHostname !== undefined) setHostname(prefillHostname); + if (prefillSshUser !== undefined) setSshUser(prefillSshUser ?? ""); + if (prefillPort !== undefined) setPort(prefillPort == null ? "" : String(prefillPort)); + if (prefillSshKeyPath !== undefined) setSshKeyPath(prefillSshKeyPath ?? ""); + }, [prefill, prefillHostname, prefillKey, prefillName, prefillPort, prefillSshKeyPath, prefillSshUser]); + + const canSubmit = useMemo(() => { + if (!hostname.trim()) return false; + if (!port.trim()) return true; + if (!/^\d+$/.test(port.trim())) return false; + const parsedPort = Number.parseInt(port, 10); + return Number.isInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65_535; + }, [hostname, port]); + + async function handleSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + if (!canSubmit || busy) return; + await onSubmit({ + name: name.trim() || null, + hostname: hostname.trim(), + sshUser: sshUser.trim() || null, + port: port.trim() ? Number.parseInt(port, 10) : null, + sshKeyPath: sshKeyPath.trim() || null, + }); + } + + return ( + <form onSubmit={(event) => void handleSubmit(event)} style={{ display: "grid", gap: 12 }}> + <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}> + <label style={{ display: "grid", gap: 6 }}> + <span style={LABEL_STYLE}>Name</span> + <input + value={name} + onChange={(event) => setName(event.target.value)} + placeholder="Mac Studio" + style={fieldStyle} + disabled={busy} + /> + </label> + <label style={{ display: "grid", gap: 6 }}> + <span style={LABEL_STYLE}>Host</span> + <input + value={hostname} + onChange={(event) => setHostname(event.target.value)} + placeholder="studio.local" + style={fieldStyle} + disabled={busy} + required + /> + </label> + </div> + <div style={{ display: "grid", gridTemplateColumns: "1fr 104px", gap: 12 }}> + <label style={{ display: "grid", gap: 6 }}> + <span style={LABEL_STYLE}>SSH user</span> + <input + value={sshUser} + onChange={(event) => setSshUser(event.target.value)} + placeholder="From SSH config" + style={fieldStyle} + disabled={busy} + /> + </label> + <label style={{ display: "grid", gap: 6 }}> + <span style={LABEL_STYLE}>Port</span> + <input + value={port} + onChange={(event) => setPort(event.target.value)} + inputMode="numeric" + placeholder="From SSH config" + style={fieldStyle} + disabled={busy} + /> + </label> + </div> + <label style={{ display: "grid", gap: 6 }}> + <span style={LABEL_STYLE}>SSH key path</span> + <input + value={sshKeyPath} + onChange={(event) => setSshKeyPath(event.target.value)} + placeholder="Optional, defaults to ssh-agent or your SSH config" + style={fieldStyle} + disabled={busy} + /> + </label> + <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}> + <div style={{ color: COLORS.textMuted, fontFamily: SANS_FONT, fontSize: 12 }}> + ADE connects over SSH and starts `ade rpc --stdio` on the target. + </div> + <button + type="submit" + disabled={!canSubmit || busy} + style={{ + ...primaryButton({ height: 36, padding: "0 16px", fontSize: 12 }), + opacity: canSubmit && !busy ? 1 : 0.55, + }} + > + {busy ? busyLabel : submitLabel} + </button> + </div> + </form> + ); +} + +export function RemoteTargetEmptyAction({ onClick }: { onClick: () => void }) { + return ( + <button type="button" onClick={onClick} style={outlineButton({ height: 32, padding: "0 12px", fontSize: 12 })}> + Add machine + </button> + ); +} diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx new file mode 100644 index 000000000..641a5626c --- /dev/null +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx @@ -0,0 +1,141 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RemoteTargetList } from "./RemoteTargetList"; + +const remoteRuntimeMock = { + listTargets: vi.fn(), + listDiscoveredMachines: vi.fn(), + saveTarget: vi.fn(), + removeTarget: vi.fn(), + connect: vi.fn(), + listProjects: vi.fn(), + addProject: vi.fn(), + openProject: vi.fn(), + callAction: vi.fn(), + streamEvents: vi.fn(), + checkLocalWork: vi.fn(), + disconnect: vi.fn(), +}; + +const lanesMock = { + list: vi.fn(), + listSnapshots: vi.fn(), +}; + +function installAdeMock(): void { + Object.defineProperty(window, "ade", { + configurable: true, + value: { + remoteRuntime: remoteRuntimeMock, + lanes: lanesMock, + }, + }); +} + +describe("RemoteTargetList", () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.clearAllMocks(); + Reflect.deleteProperty(window, "ade"); + }); + + it("shows LAN-discovered machines and uses their route to prefill the SSH form", async () => { + remoteRuntimeMock.listTargets.mockResolvedValue([]); + remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue([ + { + id: "device-1::service", + serviceName: "ADE Sync Studio", + machineName: "Studio", + hostIdentity: "device-1", + hostName: "studio.local", + port: 8787, + addresses: ["192.168.1.42"], + primaryRoute: "192.168.1.42", + tailscaleAddress: "studio.tailnet.ts.net", + runtimeKind: "daemon", + runtimeVersion: "0.0.0", + projectIds: ["project-1", "project-2"], + projectCount: 2, + lastSeenAt: 1234, + }, + ]); + installAdeMock(); + + render(<RemoteTargetList />); + + await waitFor(() => expect(screen.getByText("Studio")).toBeTruthy()); + expect(screen.getByText("192.168.1.42:8787")).toBeTruthy(); + expect( + screen.getByText("Background ADE 0.0.0 | 2 projects advertised"), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Use host" })); + + expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe( + "Studio", + ); + expect((screen.getByLabelText("Host") as HTMLInputElement).value).toBe( + "192.168.1.42", + ); + expect((screen.getByLabelText("Port") as HTMLInputElement).value).toBe(""); + }); + + it("connects a saved machine without listing remote projects in the connection manager", async () => { + const target = { + id: "target-1", + name: "Mac Studio", + hostname: "studio.local", + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: "darwin-arm64", + runtimeBinaryVersion: "1.0.0", + lastConnectedAt: null, + }; + const project = { + projectId: "project-1", + rootPath: "/remote/ADE", + displayName: "ADE", + addedAt: 1, + lastOpenedAt: 2, + gitOriginUrl: "git@github.com:example/ade.git", + }; + remoteRuntimeMock.listTargets.mockResolvedValue([target]); + remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue([]); + remoteRuntimeMock.connect.mockResolvedValue({ + target, + arch: "darwin-arm64", + version: "1.0.0", + projects: [project], + }); + lanesMock.list.mockResolvedValue([]); + installAdeMock(); + + render(<RemoteTargetList />); + + await waitFor(() => + expect(screen.getAllByText("Mac Studio").length).toBeGreaterThan(0), + ); + const connectButton = screen + .getAllByRole("button", { name: "Connect" }) + .find((button) => !button.hasAttribute("disabled")); + expect(connectButton).toBeTruthy(); + fireEvent.click(connectButton!); + await waitFor(() => + expect(remoteRuntimeMock.connect).toHaveBeenCalledWith("target-1"), + ); + expect(screen.getByText("Connected")).toBeTruthy(); + expect(screen.getByText("ADE service 1.0.0 on darwin-arm64.")).toBeTruthy(); + expect(screen.queryByText("/remote/ADE")).toBeNull(); + expect(screen.queryByRole("button", { name: "Open" })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx new file mode 100644 index 000000000..868cb8483 --- /dev/null +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx @@ -0,0 +1,902 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type CSSProperties, +} from "react"; +import { + CheckCircle, + DesktopTower, + PlugsConnected, + Trash, + Warning, +} from "@phosphor-icons/react"; +import { extractError } from "../../lib/format"; +import { + COLORS, + LABEL_STYLE, + MONO_FONT, + SANS_FONT, + outlineButton, + primaryButton, +} from "../lanes/laneDesignTokens"; +import type { + RemoteRuntimeConnectionSnapshot, + RemoteRuntimeConnectionStatus, + RemoteRuntimeConnectResult, + RemoteRuntimeDiscoveredMachine, + RemoteRuntimeTarget, + RemoteRuntimeTargetInput, +} from "../../../shared/types"; +import { + RemoteTargetForm, + type RemoteTargetFormPrefill, +} from "./RemoteTargetForm"; + +type RemoteTargetListProps = { + onConnected?: (result: RemoteRuntimeConnectResult) => void; +}; + +const panelStyle: CSSProperties = { + display: "grid", + gap: 14, +}; + +const sectionStyle: CSSProperties = { + display: "grid", + gap: 12, + borderRadius: 10, + border: `1px solid ${COLORS.border}`, + background: "rgba(255,255,255,0.025)", + padding: 14, +}; + +function formatLastSeen(value: number | null): string { + if (!value) return "Never connected"; + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return "Last connection unknown"; + return `Last connected ${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; +} + +function discoveredRuntimeLabel( + machine: RemoteRuntimeDiscoveredMachine, +): string { + const kind = (machine.runtimeKind ?? "").toLowerCase(); + let label: string; + switch (kind) { + case "tailscale-peer": + label = "Tailscale SSH target"; + break; + case "tailscale-peer-offline": + label = "Tailscale SSH target offline"; + break; + case "daemon": + case "headless": + label = "Background ADE"; + break; + case "desktop": + case "desktop-embedded": + label = "ADE app"; + break; + default: + label = "ADE service"; + } + return machine.runtimeVersion ? `${label} ${machine.runtimeVersion}` : label; +} + +function discoveredProjectLabel( + machine: RemoteRuntimeDiscoveredMachine, +): string { + if ((machine.runtimeKind ?? "").startsWith("tailscale-peer")) + return "Use host to add this SSH target"; + const count = machine.projectCount ?? machine.projectIds.length; + if (count <= 0) return "No projects advertised"; + return `${count} project${count === 1 ? "" : "s"} advertised`; +} + +function discoveredRoute( + machine: RemoteRuntimeDiscoveredMachine, +): string | null { + return ( + machine.primaryRoute ?? + machine.tailscaleAddress ?? + machine.hostName ?? + machine.addresses[0] ?? + null + ); +} + +function targetFormPrefill( + target: RemoteRuntimeTarget, +): RemoteTargetFormPrefill { + return { + key: `target:${target.id}:${target.lastConnectedAt ?? "never"}:${target.sshUser ?? ""}:${target.port ?? ""}:${target.sshKeyPath ?? ""}`, + targetId: target.id, + name: target.name, + hostname: target.hostname, + sshUser: target.sshUser, + port: target.port, + sshKeyPath: target.sshKeyPath, + }; +} + +function targetConnectionLabel(target: RemoteRuntimeTarget): string { + const userPrefix = target.sshUser ? `${target.sshUser}@` : ""; + const portSuffix = target.port ? `:${target.port}` : ""; + let defaultHint = ""; + if (!target.sshUser && !target.port) { + defaultHint = " (SSH defaults)"; + } else if (!target.sshUser) { + defaultHint = " (default SSH user)"; + } else if (!target.port) { + defaultHint = " (default port)"; + } + return `${userPrefix}${target.hostname}${portSuffix}${defaultHint}`; +} + +function connectionStateLabel( + connection: RemoteRuntimeConnectionStatus | null, + connected: RemoteRuntimeConnectResult | null, +): string { + if (connection?.state === "connected" || (!connection && connected)) + return "Connected"; + if (connection?.state === "connecting") return "Connecting"; + if (connection?.state === "error") return "Connection failed"; + return "Not connected"; +} + +export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { + const [targets, setTargets] = useState<RemoteRuntimeTarget[]>([]); + const [connectionSnapshot, setConnectionSnapshot] = + useState<RemoteRuntimeConnectionSnapshot | null>(null); + const [discoveredMachines, setDiscoveredMachines] = useState< + RemoteRuntimeDiscoveredMachine[] + >([]); + const [selectedId, setSelectedId] = useState<string | null>(null); + const [connected, setConnected] = useState<RemoteRuntimeConnectResult | null>( + null, + ); + const [loading, setLoading] = useState(true); + const [loadingDiscovered, setLoadingDiscovered] = useState(true); + const [busyId, setBusyId] = useState<string | null>(null); + const [saving, setSaving] = useState(false); + const [formPrefill, setFormPrefill] = + useState<RemoteTargetFormPrefill | null>(null); + const [error, setError] = useState<string | null>(null); + const [discoveryError, setDiscoveryError] = useState<string | null>(null); + + const selectedTarget = useMemo( + () => targets.find((target) => target.id === selectedId) ?? null, + [selectedId, targets], + ); + const selectedConnection = useMemo( + () => + connectionSnapshot?.connections.find( + (entry) => entry.target.id === selectedId, + ) ?? null, + [connectionSnapshot, selectedId], + ); + const editingSavedTarget = formPrefill?.targetId + ? (targets.find((target) => target.id === formPrefill.targetId) ?? null) + : null; + const selectedConnectionLabel = connectionStateLabel( + selectedConnection, + connected?.target.id === selectedId ? connected : null, + ); + const selectedConnectionError = + selectedConnection?.state === "error" ? selectedConnection.lastError : null; + + const loadTargets = useCallback(async () => { + setLoading(true); + try { + const snapshot = window.ade.remoteRuntime.getConnectionSnapshot + ? await window.ade.remoteRuntime.getConnectionSnapshot() + : null; + const next = snapshot + ? snapshot.connections.map((entry) => entry.target) + : await window.ade.remoteRuntime.listTargets(); + if (snapshot) setConnectionSnapshot(snapshot); + setTargets(next); + setSelectedId((current) => current ?? next[0]?.id ?? null); + setError(null); + } catch (err) { + setError(extractError(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadTargets(); + }, [loadTargets]); + + useEffect(() => { + if (!window.ade.remoteRuntime.onConnectionSnapshotChanged) return; + const unsubscribe = window.ade.remoteRuntime.onConnectionSnapshotChanged( + (snapshot) => { + setConnectionSnapshot(snapshot); + setTargets(snapshot.connections.map((entry) => entry.target)); + setSelectedId( + (current) => current ?? snapshot.connections[0]?.target.id ?? null, + ); + }, + ); + return unsubscribe; + }, []); + + useEffect(() => { + if (!selectedConnection) return; + if (selectedConnection.state !== "connected") { + setConnected((current) => + current?.target.id === selectedConnection.target.id ? null : current, + ); + return; + } + setConnected({ + target: selectedConnection.target, + arch: + selectedConnection.arch ?? + selectedConnection.target.lastSeenArch ?? + "unknown", + version: + selectedConnection.version ?? + selectedConnection.target.runtimeBinaryVersion, + projects: selectedConnection.projects, + }); + }, [selectedConnection]); + + useEffect(() => { + if (!selectedTarget) return; + setFormPrefill(targetFormPrefill(selectedTarget)); + }, [selectedTarget]); + + const loadDiscoveredMachines = useCallback(async () => { + setLoadingDiscovered(true); + try { + const next = await window.ade.remoteRuntime.listDiscoveredMachines(); + setDiscoveredMachines(next); + setDiscoveryError(null); + } catch (err) { + setDiscoveryError(extractError(err)); + } finally { + setLoadingDiscovered(false); + } + }, []); + + useEffect(() => { + void loadDiscoveredMachines(); + }, [loadDiscoveredMachines]); + + const applyDiscoveredRoute = useCallback( + (machine: RemoteRuntimeDiscoveredMachine) => { + const route = discoveredRoute(machine); + if (!route) return; + setFormPrefill({ + key: `${machine.id}:${machine.lastSeenAt}`, + targetId: null, + name: machine.machineName, + hostname: route.replace(/\.$/, ""), + sshUser: null, + port: null, + sshKeyPath: null, + }); + }, + [], + ); + + const connectTarget = useCallback( + async (targetId: string) => { + setBusyId(targetId); + try { + const result = await window.ade.remoteRuntime.connect(targetId); + setConnected(result); + setTargets((current) => + current.map((target) => + target.id === result.target.id ? result.target : target, + ), + ); + setConnectionSnapshot((current) => { + const fallbackConnections = targets.map((target) => ({ + target, + state: "idle" as const, + arch: target.lastSeenArch, + version: target.runtimeBinaryVersion, + projects: [], + lastError: null, + lastAttemptedAt: null, + connectedAt: target.lastConnectedAt, + })); + const existing = current?.connections ?? fallbackConnections; + const connections = existing.some( + (entry) => entry.target.id === result.target.id, + ) + ? existing.map((entry) => + entry.target.id === result.target.id + ? { + target: result.target, + state: "connected" as const, + arch: result.arch, + version: result.version, + projects: result.projects, + lastError: null, + lastAttemptedAt: Date.now(), + connectedAt: result.target.lastConnectedAt ?? Date.now(), + } + : entry, + ) + : [ + ...existing, + { + target: result.target, + state: "connected" as const, + arch: result.arch, + version: result.version, + projects: result.projects, + lastError: null, + lastAttemptedAt: Date.now(), + connectedAt: result.target.lastConnectedAt ?? Date.now(), + }, + ]; + return { + connections, + connectedCount: connections.filter( + (entry) => entry.state === "connected", + ).length, + updatedAt: Date.now(), + }; + }); + setSelectedId(result.target.id); + setError(null); + onConnected?.(result); + } catch (err) { + setError(extractError(err)); + } finally { + setBusyId(null); + } + }, + [onConnected, targets], + ); + + const saveAndConnect = useCallback( + async (input: RemoteRuntimeTargetInput) => { + setSaving(true); + try { + const replacedTargetId = formPrefill?.targetId ?? null; + const target = await window.ade.remoteRuntime.saveTarget(input); + if (replacedTargetId && replacedTargetId !== target.id) { + await window.ade.remoteRuntime.removeTarget(replacedTargetId); + } + setTargets((current) => [ + target, + ...current.filter( + (entry) => entry.id !== target.id && entry.id !== replacedTargetId, + ), + ]); + setSelectedId(target.id); + setFormPrefill(targetFormPrefill(target)); + setError(null); + await connectTarget(target.id); + } catch (err) { + setError(extractError(err)); + } finally { + setSaving(false); + } + }, + [connectTarget, formPrefill?.targetId], + ); + + const removeTarget = useCallback( + async (targetId: string) => { + setBusyId(targetId); + try { + await window.ade.remoteRuntime.removeTarget(targetId); + setTargets((current) => + current.filter((target) => target.id !== targetId), + ); + if (selectedId === targetId) { + setSelectedId(null); + setConnected(null); + } + if (formPrefill?.targetId === targetId) setFormPrefill(null); + setError(null); + } catch (err) { + setError(extractError(err)); + } finally { + setBusyId(null); + } + }, + [formPrefill?.targetId, selectedId], + ); + + return ( + <div style={panelStyle}> + <div + style={{ + display: "grid", + gridTemplateColumns: "minmax(0,1fr) minmax(300px,0.8fr)", + gap: 16, + alignItems: "start", + }} + > + <div style={{ display: "grid", gap: 16 }}> + <div style={sectionStyle}> + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + }} + > + <div> + <div style={{ ...LABEL_STYLE, color: COLORS.textMuted }}> + REMOTE MACHINES + </div> + <div + style={{ + color: COLORS.textPrimary, + fontFamily: SANS_FONT, + fontSize: 14, + fontWeight: 600, + }} + > + Connect over SSH + </div> + </div> + <DesktopTower size={22} weight="duotone" color={COLORS.accent} /> + </div> + {loading ? ( + <div + style={{ + color: COLORS.textMuted, + fontFamily: MONO_FONT, + fontSize: 12, + }} + > + Loading machines... + </div> + ) : targets.length === 0 ? ( + <div + style={{ + color: COLORS.textMuted, + fontFamily: SANS_FONT, + fontSize: 13, + }} + > + No remote machines saved yet. + </div> + ) : ( + <div style={{ display: "grid", gap: 8 }}> + {targets.map((target) => { + const active = selectedId === target.id; + const targetStatus = + connectionSnapshot?.connections.find( + (entry) => entry.target.id === target.id, + ) ?? null; + const isConnected = targetStatus + ? targetStatus.state === "connected" + : connected?.target.id === target.id; + return ( + <button + key={target.id} + type="button" + onClick={() => { + setSelectedId(target.id); + setFormPrefill(targetFormPrefill(target)); + if (selectedId !== target.id) setConnected(null); + }} + style={{ + display: "grid", + gap: 6, + padding: "10px 12px", + borderRadius: 8, + border: `1px solid ${active ? COLORS.accent : COLORS.border}`, + background: active + ? "color-mix(in srgb, var(--color-accent) 12%, transparent)" + : "rgba(255,255,255,0.02)", + color: COLORS.textPrimary, + textAlign: "left", + cursor: "pointer", + }} + > + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 8, + }} + > + <span + style={{ + fontFamily: MONO_FONT, + fontSize: 12, + fontWeight: 700, + }} + > + {target.name} + </span> + {isConnected ? ( + <CheckCircle + size={16} + weight="fill" + color={COLORS.success} + /> + ) : null} + </div> + <span + style={{ + color: COLORS.textMuted, + fontFamily: MONO_FONT, + fontSize: 11, + }} + > + {targetConnectionLabel(target)} + </span> + <span + style={{ + color: COLORS.textDim, + fontFamily: SANS_FONT, + fontSize: 11, + }} + > + {formatLastSeen(target.lastConnectedAt)} + </span> + </button> + ); + })} + </div> + )} + </div> + + <div style={sectionStyle}> + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + }} + > + <div> + <div style={{ ...LABEL_STYLE, color: COLORS.textMuted }}> + NEARBY MACHINES + </div> + <div + style={{ + color: COLORS.textPrimary, + fontFamily: SANS_FONT, + fontSize: 14, + fontWeight: 600, + }} + > + LAN and Tailscale discovery + </div> + </div> + <button + type="button" + disabled={loadingDiscovered} + onClick={() => void loadDiscoveredMachines()} + style={{ + ...outlineButton({ + height: 30, + padding: "0 10px", + fontSize: 11, + }), + opacity: loadingDiscovered ? 0.6 : 1, + }} + > + Refresh + </button> + </div> + {discoveryError ? ( + <div + style={{ + display: "flex", + alignItems: "center", + gap: 8, + color: COLORS.danger, + fontFamily: SANS_FONT, + fontSize: 12, + }} + > + <Warning size={15} weight="fill" /> + {discoveryError} + </div> + ) : null} + {loadingDiscovered ? ( + <div + style={{ + color: COLORS.textMuted, + fontFamily: MONO_FONT, + fontSize: 12, + }} + > + Scanning nearby machines... + </div> + ) : discoveredMachines.length === 0 ? ( + <div + style={{ + color: COLORS.textMuted, + fontFamily: SANS_FONT, + fontSize: 13, + }} + > + No LAN ADE services or Tailscale peers found. + </div> + ) : ( + <div style={{ display: "grid", gap: 8 }}> + {discoveredMachines.map((machine) => { + const route = discoveredRoute(machine); + return ( + <div + key={machine.id} + style={{ + display: "grid", + gap: 6, + padding: "10px 12px", + borderRadius: 8, + border: `1px solid ${COLORS.border}`, + background: "rgba(255,255,255,0.02)", + }} + > + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 10, + }} + > + <div style={{ minWidth: 0 }}> + <div + style={{ + color: COLORS.textPrimary, + fontFamily: MONO_FONT, + fontSize: 12, + fontWeight: 700, + }} + > + {machine.machineName} + </div> + <div + style={{ + color: COLORS.textMuted, + fontFamily: MONO_FONT, + fontSize: 11, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > + {route + ? `${route}:${machine.port}` + : "No route advertised"} + </div> + </div> + <button + type="button" + disabled={!route} + onClick={() => applyDiscoveredRoute(machine)} + style={{ + ...outlineButton({ + height: 28, + padding: "0 10px", + fontSize: 11, + }), + opacity: route ? 1 : 0.55, + flexShrink: 0, + }} + > + Use host + </button> + </div> + <div + style={{ + color: COLORS.textDim, + fontFamily: SANS_FONT, + fontSize: 11, + }} + > + {discoveredRuntimeLabel(machine)} |{" "} + {discoveredProjectLabel(machine)} + </div> + </div> + ); + })} + </div> + )} + </div> + </div> + + <div style={sectionStyle}> + <div> + <div style={{ ...LABEL_STYLE, color: COLORS.textMuted }}> + {editingSavedTarget ? "EDIT MACHINE" : "ADD MACHINE"} + </div> + <div + style={{ + color: COLORS.textPrimary, + fontFamily: SANS_FONT, + fontSize: 14, + fontWeight: 600, + }} + > + {editingSavedTarget ? editingSavedTarget.name : "SSH target"} + </div> + </div> + <RemoteTargetForm + busy={saving || busyId != null} + prefill={formPrefill} + submitLabel={editingSavedTarget ? "Save and connect" : "Connect"} + onSubmit={saveAndConnect} + /> + </div> + </div> + + <div style={sectionStyle}> + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + }} + > + <div> + <div style={{ ...LABEL_STYLE, color: COLORS.textMuted }}> + CONNECTION + </div> + <div + style={{ + color: COLORS.textPrimary, + fontFamily: SANS_FONT, + fontSize: 14, + fontWeight: 600, + }} + > + {selectedTarget ? selectedTarget.name : "Select a machine"} + </div> + </div> + {selectedTarget ? ( + <div style={{ display: "flex", gap: 8 }}> + <button + type="button" + style={primaryButton({ + height: 32, + padding: "0 12px", + fontSize: 12, + })} + disabled={busyId != null} + onClick={() => void connectTarget(selectedTarget.id)} + > + <PlugsConnected size={15} weight="bold" /> + {selectedConnection?.state === "connected" + ? "Reconnect" + : "Connect"} + </button> + <button + type="button" + aria-label="Remove remote machine" + style={outlineButton({ + height: 32, + padding: "0 10px", + fontSize: 12, + })} + disabled={busyId != null} + onClick={() => void removeTarget(selectedTarget.id)} + > + <Trash size={15} /> + </button> + </div> + ) : null} + </div> + + {error || selectedConnectionError ? ( + <div + style={{ + display: "flex", + alignItems: "center", + gap: 8, + color: COLORS.danger, + fontFamily: SANS_FONT, + fontSize: 12, + }} + > + <Warning size={15} weight="fill" /> + {error ?? selectedConnectionError} + </div> + ) : null} + + {selectedTarget ? ( + <div + style={{ + display: "grid", + gap: 8, + borderRadius: 8, + border: `1px solid ${COLORS.border}`, + background: "rgba(255,255,255,0.02)", + padding: "10px 12px", + }} + > + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 10, + }} + > + <div style={{ minWidth: 0 }}> + <div + style={{ + color: COLORS.textPrimary, + fontFamily: MONO_FONT, + fontSize: 12, + fontWeight: 700, + }} + > + {selectedConnectionLabel} + </div> + <div + style={{ + color: COLORS.textMuted, + fontFamily: MONO_FONT, + fontSize: 11, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > + {targetConnectionLabel(selectedTarget)} + </div> + </div> + {selectedConnection?.state === "connected" || + (!selectedConnection && + connected?.target.id === selectedTarget.id) ? ( + <CheckCircle size={17} weight="fill" color={COLORS.success} /> + ) : null} + </div> + {selectedConnection?.state === "connected" || + (!selectedConnection && + connected?.target.id === selectedTarget.id) ? ( + <div + style={{ + color: COLORS.textMuted, + fontFamily: SANS_FONT, + fontSize: 12, + }} + > + ADE service{" "} + {selectedConnection?.version ?? connected?.version ?? "unknown"}{" "} + on {selectedConnection?.arch ?? connected?.arch ?? "unknown"}. + </div> + ) : ( + <div + style={{ + color: COLORS.textMuted, + fontFamily: SANS_FONT, + fontSize: 12, + }} + > + Remote projects are opened from Add Project after this machine + is connected. + </div> + )} + </div> + ) : ( + <div + style={{ + color: COLORS.textMuted, + fontFamily: SANS_FONT, + fontSize: 12, + }} + > + Save a machine to keep ADE connected in the background. + </div> + )} + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/components/run/RunPage.advancedDrawer.test.tsx b/apps/desktop/src/renderer/components/run/RunPage.advancedDrawer.test.tsx index 28058a665..d3f7b4517 100644 --- a/apps/desktop/src/renderer/components/run/RunPage.advancedDrawer.test.tsx +++ b/apps/desktop/src/renderer/components/run/RunPage.advancedDrawer.test.tsx @@ -3,6 +3,7 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import { RunPage } from "./RunPage"; import { useAppStore } from "../../state/appStore"; import type { LaneSummary, ProjectInfo } from "../../../shared/types"; @@ -87,6 +88,7 @@ function installAdeStub() { }, project: { listRecent: vi.fn().mockResolvedValue([]), + resolveIcon: vi.fn().mockResolvedValue({ dataUrl: null, sourcePath: null, mimeType: null }), }, }; } @@ -112,6 +114,44 @@ afterEach(() => { }); describe("RunPage Advanced lane runtime drawer", () => { + it("renders saved project icons in the recent projects list", async () => { + const ade = (window as unknown as { + ade: { + project: { + listRecent: ReturnType<typeof vi.fn>; + resolveIcon: ReturnType<typeof vi.fn>; + }; + }; + }).ade; + ade.project.listRecent.mockResolvedValueOnce([ + { + rootPath: "/tmp/icon-project", + displayName: "Icon project", + exists: true, + lastOpenedAt: "2026-05-08T00:00:00.000Z", + laneCount: 1, + }, + ]); + ade.project.resolveIcon.mockResolvedValueOnce({ + dataUrl: "data:image/png;base64,icon", + sourcePath: "/tmp/icon-project/.ade/icon.png", + mimeType: "image/png", + }); + useAppStore.setState({ showWelcome: true, project: null }); + + const { container } = render( + <MemoryRouter> + <RunPage /> + </MemoryRouter>, + ); + + expect(await screen.findByText("Icon project")).toBeTruthy(); + await waitFor(() => { + expect(ade.project.resolveIcon).toHaveBeenCalledWith("/tmp/icon-project"); + expect(container.querySelector('img[src="data:image/png;base64,icon"]')).toBeTruthy(); + }); + }); + it("keeps LaneRuntimeBar collapsed by default with aria-expanded on the toggle", async () => { render(<RunPage />); const toggle = screen.getByRole("button", { name: /^advanced$/i }); diff --git a/apps/desktop/src/renderer/components/run/RunPage.tsx b/apps/desktop/src/renderer/components/run/RunPage.tsx index 635c7d7ff..1ea40aa9e 100644 --- a/apps/desktop/src/renderer/components/run/RunPage.tsx +++ b/apps/desktop/src/renderer/components/run/RunPage.tsx @@ -1,17 +1,39 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { CaretDown, CaretUp, Folder, FolderOpen, Play, Plus, Stop, Terminal } from "@phosphor-icons/react"; +import { + CaretDown, + CaretUp, + Folder, + Play, + Plus, + Stop, + Terminal, +} from "@phosphor-icons/react"; import { useAppStore } from "../../state/appStore"; -import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; +import { + COLORS, + LABEL_STYLE, + MONO_FONT, + SANS_FONT, + outlineButton, + primaryButton, +} from "../lanes/laneDesignTokens"; import { CommandCard } from "./CommandCard"; import { CommandPalette } from "../app/CommandPalette"; import { LaneRuntimeBar } from "./LaneRuntimeBar"; -import { AddCommandDialog, type AddCommandInitialValues, type AddCommandSubmitPayload } from "./AddCommandDialog"; +import { + AddCommandDialog, + type AddCommandInitialValues, + type AddCommandSubmitPayload, +} from "./AddCommandDialog"; import { RunNetworkPanel } from "./RunNetworkPanel"; import { commandArrayToLine, parseCommandLine } from "../../lib/shell"; import { logRendererDebugEvent } from "../../lib/debugLog"; import { toRelativeTime } from "../graph/graphHelpers"; import { isActiveProcessStatus } from "./processUtils"; -import { ChatTerminalDrawer, ChatTerminalToggle } from "../chat/ChatTerminalDrawer"; +import { + ChatTerminalDrawer, + ChatTerminalToggle, +} from "../chat/ChatTerminalDrawer"; import type { ConfigProcessDefinition, ProcessDefinition, @@ -21,6 +43,8 @@ import type { ProcessRuntime, ProjectConfigSnapshot, ConfigProcessGroupDefinition, + ProjectIcon, + RemoteRuntimeConnectionSnapshot, } from "../../../shared/types"; function generateId(): string { @@ -43,7 +67,9 @@ function parseEnvText(text: string): Record<string, string> | undefined { function envToText(env: Record<string, string> | undefined): string { if (!env) return ""; - return Object.entries(env).map(([key, value]) => `${key}=${value}`).join("\n"); + return Object.entries(env) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); } function parseGracefulShutdownMs(value: string): number | undefined { @@ -64,7 +90,8 @@ function normalizeRelativePath(value: string): string { const trimmed = value.trim().replace(/\\/g, "/"); if (!trimmed || trimmed === "." || trimmed === "./") return "."; const normalized = trimmed.replace(/\/+$/, ""); - if (normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized)) return normalized || "."; + if (normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized)) + return normalized || "."; return normalized.replace(/^\.\/+/, "") || "."; } @@ -80,11 +107,15 @@ function trimTrailingSlash(value: string): string { return normalized.replace(/\/+$/, ""); } -function projectRelativeFromAbsolute(projectRoot: string | null, value: string): string | null { +function projectRelativeFromAbsolute( + projectRoot: string | null, + value: string, +): string | null { if (!projectRoot || !isAbsoluteConfigPath(value)) return null; const root = trimTrailingSlash(projectRoot); const candidate = trimTrailingSlash(value); - const windowsPath = /^[A-Za-z]:\//.test(root) || /^[A-Za-z]:\//.test(candidate); + const windowsPath = + /^[A-Za-z]:\//.test(root) || /^[A-Za-z]:\//.test(candidate); const rootKey = windowsPath ? root.toLowerCase() : root; const candidateKey = windowsPath ? candidate.toLowerCase() : candidate; if (candidateKey === rootKey) return "."; @@ -93,45 +124,70 @@ function projectRelativeFromAbsolute(projectRoot: string | null, value: string): } function relativePathFromProjectDir(fromDir: string, toPath: string): string { - const fromParts = normalizeRelativePath(fromDir).split("/").filter((part) => part && part !== "."); - const toParts = normalizeRelativePath(toPath).split("/").filter((part) => part && part !== "."); + const fromParts = normalizeRelativePath(fromDir) + .split("/") + .filter((part) => part && part !== "."); + const toParts = normalizeRelativePath(toPath) + .split("/") + .filter((part) => part && part !== "."); let idx = 0; - while (idx < fromParts.length && idx < toParts.length && fromParts[idx] === toParts[idx]) idx += 1; + while ( + idx < fromParts.length && + idx < toParts.length && + fromParts[idx] === toParts[idx] + ) + idx += 1; const up = fromParts.slice(idx).map(() => ".."); const down = toParts.slice(idx); const relative = [...up, ...down].join("/"); return relative || "."; } -function normalizeCwdForConfig(cwd: string, projectRoot: string | null): string | undefined { +function normalizeCwdForConfig( + cwd: string, + projectRoot: string | null, +): string | undefined { const normalized = normalizeRelativePath(cwd); if (normalized === ".") return undefined; return projectRelativeFromAbsolute(projectRoot, normalized) ?? normalized; } -function normalizeCommandForConfig(commandLine: string, cwd: string | undefined, projectRoot: string | null): { +function normalizeCommandForConfig( + commandLine: string, + cwd: string | undefined, + projectRoot: string | null, +): { command: string[]; localOnly: boolean; } { const command = parseCommandLine(commandLine); const normalizedCwd = cwd ?? "."; - const hasOutsideProjectAbsolutePath = command.some((part) => - isAbsoluteConfigPath(part) && projectRelativeFromAbsolute(projectRoot, part) == null + const hasOutsideProjectAbsolutePath = command.some( + (part) => + isAbsoluteConfigPath(part) && + projectRelativeFromAbsolute(projectRoot, part) == null, ); if (!command[0]) return { command, localOnly: hasOutsideProjectAbsolutePath }; - const executableProjectPath = projectRelativeFromAbsolute(projectRoot, command[0]); + const executableProjectPath = projectRelativeFromAbsolute( + projectRoot, + command[0], + ); if (executableProjectPath == null) { return { command, localOnly: hasOutsideProjectAbsolutePath }; } - const executableFromCwd = relativePathFromProjectDir(normalizedCwd, executableProjectPath); - const executable = executableFromCwd.includes("/") || executableFromCwd.startsWith(".") - ? executableFromCwd - : `./${executableFromCwd}`; + const executableFromCwd = relativePathFromProjectDir( + normalizedCwd, + executableProjectPath, + ); + const executable = + executableFromCwd.includes("/") || executableFromCwd.startsWith(".") + ? executableFromCwd + : `./${executableFromCwd}`; return { command: [executable, ...command.slice(1)], - localOnly: hasOutsideProjectAbsolutePath + localOnly: hasOutsideProjectAbsolutePath, }; } @@ -143,7 +199,9 @@ function buildProcessConfigDefinition( ): { process: ConfigProcessDefinition; localOnly: boolean } { const cwd = normalizeCwdForConfig(cmd.cwd, projectRoot); const command = normalizeCommandForConfig(cmd.command, cwd, projectRoot); - const cwdLocalOnly = isAbsoluteConfigPath(cmd.cwd) && projectRelativeFromAbsolute(projectRoot, cmd.cwd) == null; + const cwdLocalOnly = + isAbsoluteConfigPath(cmd.cwd) && + projectRelativeFromAbsolute(projectRoot, cmd.cwd) == null; return { process: { id: processId, @@ -152,24 +210,35 @@ function buildProcessConfigDefinition( cwd, env: parseEnvText(cmd.env), autostart: cmd.autostart ? true : undefined, - restart: cmd.restart == null || cmd.restart === "never" ? undefined : cmd.restart, + restart: + cmd.restart == null || cmd.restart === "never" + ? undefined + : cmd.restart, gracefulShutdownMs: parseGracefulShutdownMs(cmd.gracefulShutdownMs), dependsOn: parseDependsOnCsv(cmd.dependsOn), readiness: { type: "none" }, groupIds: allGroupIds.length > 0 ? allGroupIds : undefined, }, - localOnly: command.localOnly || cwdLocalOnly + localOnly: command.localOnly || cwdLocalOnly, }; } -function upsertProcess(processes: ConfigProcessDefinition[] | undefined, processEntry: ConfigProcessDefinition): ConfigProcessDefinition[] { +function upsertProcess( + processes: ConfigProcessDefinition[] | undefined, + processEntry: ConfigProcessDefinition, +): ConfigProcessDefinition[] { const existing = processes ?? []; return existing.some((entry) => entry.id === processEntry.id) - ? existing.map((entry) => (entry.id === processEntry.id ? processEntry : entry)) + ? existing.map((entry) => + entry.id === processEntry.id ? processEntry : entry, + ) : [...existing, processEntry]; } -function removeProcess(processes: ConfigProcessDefinition[] | undefined, processId: string): ConfigProcessDefinition[] { +function removeProcess( + processes: ConfigProcessDefinition[] | undefined, + processId: string, +): ConfigProcessDefinition[] { return (processes ?? []).filter((entry) => entry.id !== processId); } @@ -186,7 +255,10 @@ function readLaneRuntimeBarOpenFromStorage(): boolean { function writeLaneRuntimeBarOpenToStorage(open: boolean) { try { - window.localStorage.setItem(LANE_RUNTIME_BAR_OPEN_KEY, open ? "true" : "false"); + window.localStorage.setItem( + LANE_RUNTIME_BAR_OPEN_KEY, + open ? "true" : "false", + ); } catch { // ignore persistence failures } @@ -196,7 +268,9 @@ type PersistedRunPageLaneState = { commandLaneIds: Record<string, string>; }; -function readRunPageLaneState(projectRoot: string | null): PersistedRunPageLaneState { +function readRunPageLaneState( + projectRoot: string | null, +): PersistedRunPageLaneState { if (!projectRoot) return { commandLaneIds: {} }; try { const raw = window.localStorage.getItem(RUN_PAGE_LANE_STORAGE_KEY); @@ -207,7 +281,9 @@ function readRunPageLaneState(projectRoot: string | null): PersistedRunPageLaneS const record = state as Record<string, unknown>; return { commandLaneIds: Object.fromEntries( - Object.entries((record.commandLaneIds as Record<string, unknown>) ?? {}).filter( + Object.entries( + (record.commandLaneIds as Record<string, unknown>) ?? {}, + ).filter( (entry): entry is [string, string] => typeof entry[1] === "string", ), ), @@ -217,37 +293,127 @@ function readRunPageLaneState(projectRoot: string | null): PersistedRunPageLaneS } } -function writeRunPageLaneState(projectRoot: string | null, state: PersistedRunPageLaneState) { +function writeRunPageLaneState( + projectRoot: string | null, + state: PersistedRunPageLaneState, +) { if (!projectRoot) return; try { const raw = window.localStorage.getItem(RUN_PAGE_LANE_STORAGE_KEY); const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : {}; parsed[projectRoot] = { commandLaneIds: state.commandLaneIds }; - window.localStorage.setItem(RUN_PAGE_LANE_STORAGE_KEY, JSON.stringify(parsed)); + window.localStorage.setItem( + RUN_PAGE_LANE_STORAGE_KEY, + JSON.stringify(parsed), + ); } catch { // ignore persistence failures } } -function runPageLaneStateEqual(left: PersistedRunPageLaneState, right: PersistedRunPageLaneState): boolean { +function runPageLaneStateEqual( + left: PersistedRunPageLaneState, + right: PersistedRunPageLaneState, +): boolean { const leftEntries = Object.entries(left.commandLaneIds); const rightEntries = Object.entries(right.commandLaneIds); if (leftEntries.length !== rightEntries.length) return false; - return leftEntries.every(([processId, laneId]) => right.commandLaneIds[processId] === laneId); + return leftEntries.every( + ([processId, laneId]) => right.commandLaneIds[processId] === laneId, + ); +} + +function RecentProjectIcon({ rootPath }: { rootPath: string }) { + const [icon, setIcon] = useState<ProjectIcon | null>(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + setIcon(null); + setFailed(false); + window.ade.project + .resolveIcon(rootPath) + .then((nextIcon) => { + if (!cancelled) setIcon(nextIcon); + }) + .catch(() => { + if (!cancelled) setIcon(null); + }); + return () => { + cancelled = true; + }; + }, [rootPath]); + + if (icon?.dataUrl && !failed) { + return ( + <img + src={icon.dataUrl} + alt="" + draggable={false} + onError={() => setFailed(true)} + style={{ + width: 22, + height: 22, + borderRadius: 6, + objectFit: "contain", + }} + /> + ); + } + + return <Folder size={16} weight="regular" />; } function WelcomeScreen() { const switchProjectToPath = useAppStore((s) => s.switchProjectToPath); const project = useAppStore((s) => s.project); const cancelNewTab = useAppStore((s) => s.cancelNewTab); - const [recentProjects, setRecentProjects] = useState<Array<{ rootPath: string; displayName: string; exists: boolean; lastOpenedAt?: string; laneCount?: number }>>([]); + const [recentProjects, setRecentProjects] = useState< + Array<{ + rootPath: string; + displayName: string; + exists: boolean; + lastOpenedAt?: string; + laneCount?: number; + }> + >([]); const [projectBrowserOpen, setProjectBrowserOpen] = useState(false); + const [remoteSnapshot, setRemoteSnapshot] = + useState<RemoteRuntimeConnectionSnapshot | null>(null); useEffect(() => { - window.ade.project.listRecent().then(setRecentProjects).catch(() => {}); + window.ade.project + .listRecent() + .then(setRecentProjects) + .catch(() => {}); + }, []); + + useEffect(() => { + const remoteRuntime = window.ade.remoteRuntime; + if (!remoteRuntime?.getConnectionSnapshot) return; + let cancelled = false; + void remoteRuntime + .getConnectionSnapshot() + .then((snapshot) => { + if (!cancelled) setRemoteSnapshot(snapshot); + }) + .catch(() => { + if (!cancelled) setRemoteSnapshot(null); + }); + const unsubscribe = + remoteRuntime.onConnectionSnapshotChanged?.((snapshot) => { + if (!cancelled) setRemoteSnapshot(snapshot); + }) ?? (() => {}); + return () => { + cancelled = true; + unsubscribe(); + }; }, []); - const realProjects = recentProjects.filter((rp) => rp.exists && !rp.rootPath.includes("ade-project")); + const realProjects = recentProjects.filter( + (rp) => rp.exists && !rp.rootPath.includes("ade-project"), + ); + const connectedRemoteCount = remoteSnapshot?.connectedCount ?? 0; return ( <div @@ -269,10 +435,20 @@ function WelcomeScreen() { alignItems: "center", justifyContent: "center", marginBottom: 16, - filter: "drop-shadow(0 0 22px color-mix(in srgb, var(--color-accent) 45%, transparent))", + filter: + "drop-shadow(0 0 22px color-mix(in srgb, var(--color-accent) 45%, transparent))", }} > - <img src="./logo.png" alt="ADE Logo" style={{ width: 420, height: 240, objectFit: "contain", maxWidth: "72vw" }} /> + <img + src="./logo.png" + alt="ADE Logo" + style={{ + width: 420, + height: 240, + objectFit: "contain", + maxWidth: "72vw", + }} + /> </div> </div> @@ -283,26 +459,64 @@ function WelcomeScreen() { style={{ ...primaryButton({ height: 48, padding: "0 32px", fontSize: 14 }), gap: 12, - boxShadow: `0 4px 20px color-mix(in srgb, var(--color-accent) 40%, transparent)`, + border: + connectedRemoteCount > 0 + ? "1px solid rgba(245,158,11,0.72)" + : undefined, + boxShadow: + connectedRemoteCount > 0 + ? "0 0 0 1px rgba(245,158,11,0.24), 0 6px 28px rgba(245,158,11,0.24)" + : `0 4px 20px color-mix(in srgb, var(--color-accent) 40%, transparent)`, transition: "transform 0.2s ease, box-shadow 0.2s ease", marginTop: -16, }} onMouseEnter={(event) => { event.currentTarget.style.transform = "translateY(-2px)"; - event.currentTarget.style.boxShadow = `0 6px 24px color-mix(in srgb, var(--color-accent) 60%, transparent)`; + event.currentTarget.style.boxShadow = + connectedRemoteCount > 0 + ? "0 0 0 1px rgba(245,158,11,0.38), 0 8px 34px rgba(245,158,11,0.34)" + : `0 6px 24px color-mix(in srgb, var(--color-accent) 60%, transparent)`; }} onMouseLeave={(event) => { event.currentTarget.style.transform = "none"; - event.currentTarget.style.boxShadow = `0 4px 20px color-mix(in srgb, var(--color-accent) 40%, transparent)`; + event.currentTarget.style.boxShadow = + connectedRemoteCount > 0 + ? "0 0 0 1px rgba(245,158,11,0.24), 0 6px 28px rgba(245,158,11,0.24)" + : `0 4px 20px color-mix(in srgb, var(--color-accent) 40%, transparent)`; }} > <Plus size={20} weight="bold" /> ADD PROJECT </button> + {connectedRemoteCount > 0 ? ( + <div + style={{ + marginTop: -22, + fontFamily: MONO_FONT, + fontSize: 11, + fontWeight: 700, + letterSpacing: "0.08em", + textTransform: "uppercase", + color: "#FBBF24", + }} + > + {connectedRemoteCount} remote device + {connectedRemoteCount === 1 ? "" : "s"} available + </div> + ) : null} {realProjects.length > 0 ? ( <div style={{ width: "100%", maxWidth: 440, marginTop: 8 }}> - <div style={{ ...LABEL_STYLE, marginBottom: 12, textAlign: "center", color: COLORS.textMuted }}>RECENT PROJECTS</div> + <div + style={{ + ...LABEL_STYLE, + marginBottom: 12, + textAlign: "center", + color: COLORS.textMuted, + }} + > + RECENT PROJECTS + </div> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}> {realProjects.map((rp) => ( <button @@ -341,25 +555,47 @@ function WelcomeScreen() { width: 32, height: 32, borderRadius: 8, - background: "color-mix(in srgb, var(--color-accent) 15%, transparent)", + background: + "color-mix(in srgb, var(--color-accent) 15%, transparent)", color: COLORS.accent, flexShrink: 0, }} > - <Folder size={16} weight="regular" /> + <RecentProjectIcon rootPath={rp.rootPath} /> </div> <div style={{ overflow: "hidden", flex: 1 }}> - <div style={{ fontWeight: 600, fontSize: 13, marginBottom: 2 }}>{rp.displayName}</div> - <div style={{ fontSize: 10, color: COLORS.textDim, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> + <div + style={{ fontWeight: 600, fontSize: 13, marginBottom: 2 }} + > + {rp.displayName} + </div> + <div + style={{ + fontSize: 10, + color: COLORS.textDim, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > {rp.rootPath} </div> </div> - <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4, flexShrink: 0 }}> + <div + style={{ + display: "flex", + flexDirection: "column", + alignItems: "flex-end", + gap: 4, + flexShrink: 0, + }} + > {rp.laneCount !== undefined ? ( <span style={{ fontSize: 10, - background: "color-mix(in srgb, var(--color-accent) 20%, transparent)", + background: + "color-mix(in srgb, var(--color-accent) 20%, transparent)", color: COLORS.accent, padding: "2px 6px", borderRadius: 10, @@ -370,7 +606,9 @@ function WelcomeScreen() { </span> ) : null} {rp.lastOpenedAt ? ( - <span style={{ fontSize: 9, color: COLORS.textDim }}>{toRelativeTime(rp.lastOpenedAt)}</span> + <span style={{ fontSize: 9, color: COLORS.textDim }}> + {toRelativeTime(rp.lastOpenedAt)} + </span> ) : null} </div> </button> @@ -379,7 +617,11 @@ function WelcomeScreen() { </div> ) : null} - <CommandPalette open={projectBrowserOpen} onOpenChange={setProjectBrowserOpen} intent="project-add" /> + <CommandPalette + open={projectBrowserOpen} + onOpenChange={setProjectBrowserOpen} + intent="project-add" + /> </div> ); } @@ -390,7 +632,10 @@ export function RunPage() { const showWelcome = useAppStore((s) => s.showWelcome); const projectRoot = project?.rootPath ?? null; - const [persistedLaneState, setPersistedLaneState] = useState<PersistedRunPageLaneState>(() => readRunPageLaneState(projectRoot)); + const [persistedLaneState, setPersistedLaneState] = + useState<PersistedRunPageLaneState>(() => + readRunPageLaneState(projectRoot), + ); const [config, setConfig] = useState<ProjectConfigSnapshot | null>(null); const [definitions, setDefinitions] = useState<ProcessDefinition[]>([]); const [runtime, setRuntime] = useState<ProcessRuntime[]>([]); @@ -400,12 +645,18 @@ export function RunPage() { const newGroupInputRef = useRef<HTMLInputElement>(null); const [addDialogOpen, setAddDialogOpen] = useState(false); const [loading, setLoading] = useState(false); - const [editingProcess, setEditingProcess] = useState<{ id: string; values: AddCommandInitialValues } | null>(null); + const [editingProcess, setEditingProcess] = useState<{ + id: string; + values: AddCommandInitialValues; + } | null>(null); const [actionError, setActionError] = useState<string | null>(null); const [networkDrawerOpen, setNetworkDrawerOpen] = useState(false); - const [laneRuntimeBarOpen, setLaneRuntimeBarOpen] = useState(readLaneRuntimeBarOpenFromStorage); + const [laneRuntimeBarOpen, setLaneRuntimeBarOpen] = useState( + readLaneRuntimeBarOpenFromStorage, + ); const [terminalDrawerOpen, setTerminalDrawerOpen] = useState(false); - const [terminalCreateRequestNonce, setTerminalCreateRequestNonce] = useState(0); + const [terminalCreateRequestNonce, setTerminalCreateRequestNonce] = + useState(0); const [terminalRevealRequest, setTerminalRevealRequest] = useState<{ terminalId: string; ptyId: string; @@ -413,14 +664,23 @@ export function RunPage() { nonce: number; } | null>(null); const runtimeRefreshTimerRef = useRef<number | null>(null); - const pendingRunLaunchRef = useRef<{ laneId: string; processId: string } | null>(null); + const pendingRunLaunchRef = useRef<{ + laneId: string; + processId: string; + } | null>(null); const terminalRevealNonceRef = useRef(0); const fallbackRunLaneId = useMemo( - () => lanes.find((lane) => lane.laneType === "primary")?.id ?? lanes[0]?.id ?? null, + () => + lanes.find((lane) => lane.laneType === "primary")?.id ?? + lanes[0]?.id ?? + null, [lanes], ); - const groups = useMemo<ProcessGroupDefinition[]>(() => config?.effective.processGroups ?? [], [config?.effective.processGroups]); + const groups = useMemo<ProcessGroupDefinition[]>( + () => config?.effective.processGroups ?? [], + [config?.effective.processGroups], + ); const selectedGroup = useMemo( () => groups.find((group) => group.id === selectedGroupId) ?? null, @@ -432,22 +692,35 @@ export function RunPage() { const map: Record<string, string> = {}; for (const definition of definitions) { const persistedLaneId = persistedLaneState.commandLaneIds[definition.id]; - const laneId = persistedLaneId && allowed.has(persistedLaneId) - ? persistedLaneId - : fallbackRunLaneId; + const laneId = + persistedLaneId && allowed.has(persistedLaneId) + ? persistedLaneId + : fallbackRunLaneId; if (laneId) map[definition.id] = laneId; } return map; - }, [definitions, fallbackRunLaneId, lanes, persistedLaneState.commandLaneIds]); - - const refreshLanePersistence = useCallback((updater: (current: PersistedRunPageLaneState) => PersistedRunPageLaneState) => { - setPersistedLaneState((current) => { - const next = updater(current); - if (runPageLaneStateEqual(current, next)) return current; - writeRunPageLaneState(projectRoot, next); - return next; - }); - }, [projectRoot]); + }, [ + definitions, + fallbackRunLaneId, + lanes, + persistedLaneState.commandLaneIds, + ]); + + const refreshLanePersistence = useCallback( + ( + updater: ( + current: PersistedRunPageLaneState, + ) => PersistedRunPageLaneState, + ) => { + setPersistedLaneState((current) => { + const next = updater(current); + if (runPageLaneStateEqual(current, next)) return current; + writeRunPageLaneState(projectRoot, next); + return next; + }); + }, + [projectRoot], + ); useEffect(() => { setPersistedLaneState(readRunPageLaneState(projectRoot)); @@ -506,9 +779,11 @@ export function RunPage() { return; } const laneIds = Array.from( - new Set([ - ...Object.values(commandLaneMap), - ].filter((value): value is string => Boolean(value))), + new Set( + [...Object.values(commandLaneMap)].filter((value): value is string => + Boolean(value), + ), + ), ); if (laneIds.length === 0) { setRuntime([]); @@ -516,7 +791,11 @@ export function RunPage() { } try { const snapshots = await Promise.all( - laneIds.map((laneId) => window.ade.processes.listRuntime(laneId).catch(() => [] as ProcessRuntime[])), + laneIds.map((laneId) => + window.ade.processes + .listRuntime(laneId) + .catch(() => [] as ProcessRuntime[]), + ), ); const next = snapshots.flat(); setRuntime(next); @@ -535,7 +814,10 @@ export function RunPage() { if (selectedGroupId !== null) setSelectedGroupId(null); return; } - if (selectedGroupId && !groups.some((group) => group.id === selectedGroupId)) { + if ( + selectedGroupId && + !groups.some((group) => group.id === selectedGroupId) + ) { setSelectedGroupId(null); } }, [groups, selectedGroupId]); @@ -564,7 +846,9 @@ export function RunPage() { const upsertRuntime = useCallback((nextRuntime: ProcessRuntime) => { setRuntime((current) => { const next = [...current]; - const index = next.findIndex((runtimeItem) => runtimeItem.runId === nextRuntime.runId); + const index = next.findIndex( + (runtimeItem) => runtimeItem.runId === nextRuntime.runId, + ); if (index >= 0) { next[index] = nextRuntime; } else { @@ -574,28 +858,39 @@ export function RunPage() { }); }, []); - const revealRuntimeTerminal = useCallback((runtimeItem: ProcessRuntime): boolean => { - if (!runtimeItem.sessionId || !runtimeItem.ptyId) return false; - const definition = definitions.find((item) => item.id === runtimeItem.processId); - const lane = lanes.find((item) => item.id === runtimeItem.laneId); - terminalRevealNonceRef.current += 1; - setTerminalDrawerOpen(true); - setTerminalRevealRequest({ - terminalId: runtimeItem.sessionId, - ptyId: runtimeItem.ptyId, - label: definition?.name ?? lane?.name ?? "Run command", - nonce: terminalRevealNonceRef.current, - }); - return true; - }, [definitions, lanes]); + const revealRuntimeTerminal = useCallback( + (runtimeItem: ProcessRuntime): boolean => { + if (!runtimeItem.sessionId || !runtimeItem.ptyId) return false; + const definition = definitions.find( + (item) => item.id === runtimeItem.processId, + ); + const lane = lanes.find((item) => item.id === runtimeItem.laneId); + terminalRevealNonceRef.current += 1; + setTerminalDrawerOpen(true); + setTerminalRevealRequest({ + terminalId: runtimeItem.sessionId, + ptyId: runtimeItem.ptyId, + label: definition?.name ?? lane?.name ?? "Run command", + nonce: terminalRevealNonceRef.current, + }); + return true; + }, + [definitions, lanes], + ); useEffect(() => { const unsubscribe = window.ade.processes.onEvent((event: ProcessEvent) => { if (event.type !== "runtime") return; upsertRuntime(event.runtime); const pending = pendingRunLaunchRef.current; - if (pending?.laneId === event.runtime.laneId && pending.processId === event.runtime.processId) { - if (revealRuntimeTerminal(event.runtime) || !isActiveProcessStatus(event.runtime.status)) { + if ( + pending?.laneId === event.runtime.laneId && + pending.processId === event.runtime.processId + ) { + if ( + revealRuntimeTerminal(event.runtime) || + !isActiveProcessStatus(event.runtime.status) + ) { pendingRunLaunchRef.current = null; } } @@ -603,55 +898,71 @@ export function RunPage() { return unsubscribe; }, [revealRuntimeTerminal, upsertRuntime]); - const resolveProcessLaneId = useCallback((processId: string): string | null => { - return commandLaneMap[processId] ?? fallbackRunLaneId ?? null; - }, [commandLaneMap, fallbackRunLaneId]); + const resolveProcessLaneId = useCallback( + (processId: string): string | null => { + return commandLaneMap[processId] ?? fallbackRunLaneId ?? null; + }, + [commandLaneMap, fallbackRunLaneId], + ); - const selectProcessLane = useCallback((processId: string, laneId: string) => { - refreshLanePersistence((current) => ({ - commandLaneIds: { - ...current.commandLaneIds, - [processId]: laneId, - }, - })); - }, [refreshLanePersistence]); + const selectProcessLane = useCallback( + (processId: string, laneId: string) => { + refreshLanePersistence((current) => ({ + commandLaneIds: { + ...current.commandLaneIds, + [processId]: laneId, + }, + })); + }, + [refreshLanePersistence], + ); - const startProcess = useCallback(async (processId: string, laneId: string, allowTrustRetry = true): Promise<ProcessRuntime> => { - try { - return await window.ade.processes.start({ laneId, processId }); - } catch (error) { - if ( - allowTrustRetry - && error instanceof Error - && error.message.includes("ADE_TRUST_REQUIRED") - ) { - await window.ade.projectConfig.confirmTrust(); + const startProcess = useCallback( + async ( + processId: string, + laneId: string, + allowTrustRetry = true, + ): Promise<ProcessRuntime> => { + try { return await window.ade.processes.start({ laneId, processId }); + } catch (error) { + if ( + allowTrustRetry && + error instanceof Error && + error.message.includes("ADE_TRUST_REQUIRED") + ) { + await window.ade.projectConfig.confirmTrust(); + return await window.ade.processes.start({ laneId, processId }); + } + throw error; } - throw error; - } - }, []); + }, + [], + ); - const handleRun = useCallback(async (processId: string) => { - const laneId = resolveProcessLaneId(processId); - if (!laneId) return; - pendingRunLaunchRef.current = { laneId, processId }; - try { - setActionError(null); - const started = await startProcess(processId, laneId); - upsertRuntime(started); - if (revealRuntimeTerminal(started)) { - pendingRunLaunchRef.current = null; - } - } catch (error) { - const pending = pendingRunLaunchRef.current; - if (pending?.laneId === laneId && pending.processId === processId) { - pendingRunLaunchRef.current = null; + const handleRun = useCallback( + async (processId: string) => { + const laneId = resolveProcessLaneId(processId); + if (!laneId) return; + pendingRunLaunchRef.current = { laneId, processId }; + try { + setActionError(null); + const started = await startProcess(processId, laneId); + upsertRuntime(started); + if (revealRuntimeTerminal(started)) { + pendingRunLaunchRef.current = null; + } + } catch (error) { + const pending = pendingRunLaunchRef.current; + if (pending?.laneId === laneId && pending.processId === processId) { + pendingRunLaunchRef.current = null; + } + setActionError(error instanceof Error ? error.message : String(error)); + console.error("[RunPage] handleRun failed:", error); } - setActionError(error instanceof Error ? error.message : String(error)); - console.error("[RunPage] handleRun failed:", error); - } - }, [resolveProcessLaneId, revealRuntimeTerminal, startProcess, upsertRuntime]); + }, + [resolveProcessLaneId, revealRuntimeTerminal, startProcess, upsertRuntime], + ); const handleKillRuntime = useCallback(async (runtimeItem: ProcessRuntime) => { try { @@ -667,13 +978,19 @@ export function RunPage() { } }, []); - const handleOpenRuntimeTerminal = useCallback((runtimeItem: ProcessRuntime) => { - setActionError(null); - if (revealRuntimeTerminal(runtimeItem)) return; - setActionError("This run no longer has a live terminal attached."); - }, [revealRuntimeTerminal]); + const handleOpenRuntimeTerminal = useCallback( + (runtimeItem: ProcessRuntime) => { + setActionError(null); + if (revealRuntimeTerminal(runtimeItem)) return; + setActionError("This run no longer has a live terminal attached."); + }, + [revealRuntimeTerminal], + ); - const buildLaneMapForSelectedGroup = useCallback((): Record<string, string> | null => { + const buildLaneMapForSelectedGroup = useCallback((): Record< + string, + string + > | null => { if (!selectedGroupId) return null; const laneByProcessId: Record<string, string> = {}; for (const definition of definitions) { @@ -693,13 +1010,20 @@ export function RunPage() { setActionError(null); await window.ade.processes.startGroup(args); } catch (error) { - if (error instanceof Error && error.message.includes("ADE_TRUST_REQUIRED")) { + if ( + error instanceof Error && + error.message.includes("ADE_TRUST_REQUIRED") + ) { try { await window.ade.projectConfig.confirmTrust(); await window.ade.processes.startGroup(args); return; } catch (retryError) { - setActionError(retryError instanceof Error ? retryError.message : String(retryError)); + setActionError( + retryError instanceof Error + ? retryError.message + : String(retryError), + ); return; } } @@ -713,7 +1037,10 @@ export function RunPage() { if (!laneByProcessId || Object.keys(laneByProcessId).length === 0) return; try { setActionError(null); - await window.ade.processes.stopGroup({ groupId: selectedGroupId, laneByProcessId }); + await window.ade.processes.stopGroup({ + groupId: selectedGroupId, + laneByProcessId, + }); } catch (error) { setActionError(error instanceof Error ? error.message : String(error)); } @@ -728,7 +1055,10 @@ export function RunPage() { if (!trimmed || !config) return; try { setActionError(null); - const newGroup: ConfigProcessGroupDefinition = { id: generateId(), name: trimmed }; + const newGroup: ConfigProcessGroupDefinition = { + id: generateId(), + name: trimmed, + }; const shared = { ...config.shared }; shared.processGroups = [...(shared.processGroups ?? []), newGroup]; await window.ade.projectConfig.save({ shared, local: config.local }); @@ -748,116 +1078,183 @@ export function RunPage() { setTerminalDrawerOpen(true); }, [fallbackRunLaneId]); - const saveProcessToConfig = useCallback(async (cmd: AddCommandSubmitPayload) => { - if (!config) { - throw new Error("Run configuration is still loading. Try again in a moment."); - } - const processId = generateId(); - const createdGroups: ConfigProcessGroupDefinition[] = cmd.newGroupNames.map((name) => ({ - id: generateId(), - name, - })); - const allGroupIds = [...cmd.groupIds, ...createdGroups.map((group) => group.id)]; - const { process: newProcess, localOnly } = buildProcessConfigDefinition(processId, cmd, allGroupIds, projectRoot); - - const shared = { ...config.shared }; - const local = { ...config.local }; - if (localOnly) { - local.processes = upsertProcess(local.processes, newProcess); - local.processGroups = [...(local.processGroups ?? []), ...createdGroups]; - } else { - shared.processes = upsertProcess(shared.processes, newProcess); - shared.processGroups = [...(shared.processGroups ?? []), ...createdGroups]; - } - - await window.ade.projectConfig.save({ shared, local }); - await Promise.all([refreshDefinitions(), refreshRuntime()]); - }, [config, projectRoot, refreshDefinitions, refreshRuntime]); + const saveProcessToConfig = useCallback( + async (cmd: AddCommandSubmitPayload) => { + if (!config) { + throw new Error( + "Run configuration is still loading. Try again in a moment.", + ); + } + const processId = generateId(); + const createdGroups: ConfigProcessGroupDefinition[] = + cmd.newGroupNames.map((name) => ({ + id: generateId(), + name, + })); + const allGroupIds = [ + ...cmd.groupIds, + ...createdGroups.map((group) => group.id), + ]; + const { process: newProcess, localOnly } = buildProcessConfigDefinition( + processId, + cmd, + allGroupIds, + projectRoot, + ); - const updateProcessInConfig = useCallback(async (processId: string, cmd: AddCommandSubmitPayload & { restart?: ProcessRestartPolicy }) => { - if (!config) { - throw new Error("Run configuration is still loading. Try again in a moment."); - } - const shared = { ...config.shared }; - const local = { ...config.local }; - const createdGroups: ConfigProcessGroupDefinition[] = cmd.newGroupNames.map((name) => ({ - id: generateId(), - name, - })); - const allGroupIds = [...cmd.groupIds, ...createdGroups.map((group) => group.id)]; - const existingProcess = - (config.local.processes ?? []).find((entry) => entry.id === processId) ?? - (config.shared.processes ?? []).find((entry) => entry.id === processId); - const cmdForBuild = { ...cmd, restart: cmd.restart ?? existingProcess?.restart }; - const { process: nextProcess, localOnly } = buildProcessConfigDefinition(processId, cmdForBuild, allGroupIds, projectRoot); - const existingLocal = (config.local.processes ?? []).some((entry) => entry.id === processId); - const targetLocal = existingLocal || localOnly; - - if (targetLocal) { - local.processes = upsertProcess(local.processes, nextProcess); - local.processGroups = [...(local.processGroups ?? []), ...createdGroups]; + const shared = { ...config.shared }; + const local = { ...config.local }; if (localOnly) { - shared.processes = removeProcess(shared.processes, processId); + local.processes = upsertProcess(local.processes, newProcess); + local.processGroups = [ + ...(local.processGroups ?? []), + ...createdGroups, + ]; + } else { + shared.processes = upsertProcess(shared.processes, newProcess); + shared.processGroups = [ + ...(shared.processGroups ?? []), + ...createdGroups, + ]; } - } else { - shared.processes = upsertProcess(shared.processes, nextProcess); - shared.processGroups = [...(shared.processGroups ?? []), ...createdGroups]; - local.processes = removeProcess(local.processes, processId); - } - await window.ade.projectConfig.save({ shared, local }); - await Promise.all([refreshDefinitions(), refreshRuntime()]); - }, [config, projectRoot, refreshDefinitions, refreshRuntime]); - - const handleAddProcessToGroup = useCallback(async (processId: string, groupId: string) => { - const definition = definitions.find((entry) => entry.id === processId); - if (!definition || (definition.groupIds ?? []).includes(groupId)) return; - const nextGroupIds = [...new Set([...(definition.groupIds ?? []), groupId])]; - await updateProcessInConfig(processId, { - name: definition.name, - command: commandArrayToLine(definition.command), - cwd: definition.cwd || ".", - env: envToText(definition.env), - autostart: definition.autostart, - restart: definition.restart, - gracefulShutdownMs: String(definition.gracefulShutdownMs ?? 7000), - dependsOn: (definition.dependsOn ?? []).join(", "), - groupIds: nextGroupIds, - newGroupNames: [], - }); - }, [definitions, updateProcessInConfig]); - - const handleDeleteProcess = useCallback(async (processId: string) => { - if (!config) return; - const shared = { ...config.shared }; - const local = { ...config.local }; - shared.processes = (shared.processes ?? []).filter((processEntry) => processEntry.id !== processId); - local.processes = (local.processes ?? []).filter((processEntry) => processEntry.id !== processId); - await window.ade.projectConfig.save({ shared, local }); - await Promise.all([refreshDefinitions(), refreshRuntime()]); - }, [config, refreshDefinitions, refreshRuntime]); - - const handleEditProcess = useCallback((processId: string) => { - const definition = definitions.find((entry) => entry.id === processId); - if (!definition) return; - setEditingProcess({ - id: processId, - values: { + await window.ade.projectConfig.save({ shared, local }); + await Promise.all([refreshDefinitions(), refreshRuntime()]); + }, + [config, projectRoot, refreshDefinitions, refreshRuntime], + ); + + const updateProcessInConfig = useCallback( + async ( + processId: string, + cmd: AddCommandSubmitPayload & { restart?: ProcessRestartPolicy }, + ) => { + if (!config) { + throw new Error( + "Run configuration is still loading. Try again in a moment.", + ); + } + const shared = { ...config.shared }; + const local = { ...config.local }; + const createdGroups: ConfigProcessGroupDefinition[] = + cmd.newGroupNames.map((name) => ({ + id: generateId(), + name, + })); + const allGroupIds = [ + ...cmd.groupIds, + ...createdGroups.map((group) => group.id), + ]; + const existingProcess = + (config.local.processes ?? []).find( + (entry) => entry.id === processId, + ) ?? + (config.shared.processes ?? []).find((entry) => entry.id === processId); + const cmdForBuild = { + ...cmd, + restart: cmd.restart ?? existingProcess?.restart, + }; + const { process: nextProcess, localOnly } = buildProcessConfigDefinition( + processId, + cmdForBuild, + allGroupIds, + projectRoot, + ); + const existingLocal = (config.local.processes ?? []).some( + (entry) => entry.id === processId, + ); + const targetLocal = existingLocal || localOnly; + + if (targetLocal) { + local.processes = upsertProcess(local.processes, nextProcess); + local.processGroups = [ + ...(local.processGroups ?? []), + ...createdGroups, + ]; + if (localOnly) { + shared.processes = removeProcess(shared.processes, processId); + } + } else { + shared.processes = upsertProcess(shared.processes, nextProcess); + shared.processGroups = [ + ...(shared.processGroups ?? []), + ...createdGroups, + ]; + local.processes = removeProcess(local.processes, processId); + } + + await window.ade.projectConfig.save({ shared, local }); + await Promise.all([refreshDefinitions(), refreshRuntime()]); + }, + [config, projectRoot, refreshDefinitions, refreshRuntime], + ); + + const handleAddProcessToGroup = useCallback( + async (processId: string, groupId: string) => { + const definition = definitions.find((entry) => entry.id === processId); + if (!definition || (definition.groupIds ?? []).includes(groupId)) return; + const nextGroupIds = [ + ...new Set([...(definition.groupIds ?? []), groupId]), + ]; + await updateProcessInConfig(processId, { name: definition.name, command: commandArrayToLine(definition.command), cwd: definition.cwd || ".", env: envToText(definition.env), autostart: definition.autostart, + restart: definition.restart, gracefulShutdownMs: String(definition.gracefulShutdownMs ?? 7000), dependsOn: (definition.dependsOn ?? []).join(", "), - groupIds: definition.groupIds ?? [], - }, - }); - }, [definitions]); + groupIds: nextGroupIds, + newGroupNames: [], + }); + }, + [definitions, updateProcessInConfig], + ); + + const handleDeleteProcess = useCallback( + async (processId: string) => { + if (!config) return; + const shared = { ...config.shared }; + const local = { ...config.local }; + shared.processes = (shared.processes ?? []).filter( + (processEntry) => processEntry.id !== processId, + ); + local.processes = (local.processes ?? []).filter( + (processEntry) => processEntry.id !== processId, + ); + await window.ade.projectConfig.save({ shared, local }); + await Promise.all([refreshDefinitions(), refreshRuntime()]); + }, + [config, refreshDefinitions, refreshRuntime], + ); + + const handleEditProcess = useCallback( + (processId: string) => { + const definition = definitions.find((entry) => entry.id === processId); + if (!definition) return; + setEditingProcess({ + id: processId, + values: { + name: definition.name, + command: commandArrayToLine(definition.command), + cwd: definition.cwd || ".", + env: envToText(definition.env), + autostart: definition.autostart, + gracefulShutdownMs: String(definition.gracefulShutdownMs ?? 7000), + dependsOn: (definition.dependsOn ?? []).join(", "), + groupIds: definition.groupIds ?? [], + }, + }); + }, + [definitions], + ); const filteredDefinitions = useMemo(() => { if (!selectedGroupId) return definitions; - return definitions.filter((definition) => (definition.groupIds ?? []).includes(selectedGroupId)); + return definitions.filter((definition) => + (definition.groupIds ?? []).includes(selectedGroupId), + ); }, [definitions, selectedGroupId]); const groupCounts = useMemo(() => { @@ -875,7 +1272,14 @@ export function RunPage() { } return ( - <div style={{ display: "flex", flexDirection: "column", height: "100%", background: COLORS.pageBg }}> + <div + style={{ + display: "flex", + flexDirection: "column", + height: "100%", + background: COLORS.pageBg, + }} + > <div style={{ display: "flex", @@ -901,10 +1305,23 @@ export function RunPage() { {filteredDefinitions.length > 0 ? ( <div style={{ display: "flex", alignItems: "center", gap: 6 }}> - <span style={{ fontFamily: MONO_FONT, fontSize: 12, fontWeight: 700, color: COLORS.textPrimary }}> + <span + style={{ + fontFamily: MONO_FONT, + fontSize: 12, + fontWeight: 700, + color: COLORS.textPrimary, + }} + > {selectedGroup?.name ?? "All commands"} </span> - <span style={{ fontFamily: MONO_FONT, fontSize: 10, color: COLORS.textDim }}> + <span + style={{ + fontFamily: MONO_FONT, + fontSize: 10, + color: COLORS.textDim, + }} + > ({filteredDefinitions.length}) </span> </div> @@ -930,12 +1347,19 @@ export function RunPage() { gap: 6, }} > - {laneRuntimeBarOpen ? <CaretUp size={14} weight="bold" /> : <CaretDown size={14} weight="bold" />} + {laneRuntimeBarOpen ? ( + <CaretUp size={14} weight="bold" /> + ) : ( + <CaretDown size={14} weight="bold" /> + )} Advanced </button> {fallbackRunLaneId ? ( - <ChatTerminalToggle open={terminalDrawerOpen} onToggle={() => setTerminalDrawerOpen((open) => !open)} /> + <ChatTerminalToggle + open={terminalDrawerOpen} + onToggle={() => setTerminalDrawerOpen((open) => !open)} + /> ) : null} <button @@ -986,7 +1410,12 @@ export function RunPage() { </> ) : null} - <button type="button" data-tour="run.addCommand" onClick={() => setAddDialogOpen(true)} style={outlineButton()}> + <button + type="button" + data-tour="run.addCommand" + onClick={() => setAddDialogOpen(true)} + style={outlineButton()} + > <Plus size={14} weight="bold" /> Add command </button> @@ -1000,7 +1429,10 @@ export function RunPage() { style={{ flexShrink: 0 }} > {laneRuntimeBarOpen ? ( - <LaneRuntimeBar laneId={fallbackRunLaneId} onOpenPreviewRouting={() => setNetworkDrawerOpen(true)} /> + <LaneRuntimeBar + laneId={fallbackRunLaneId} + onOpenPreviewRouting={() => setNetworkDrawerOpen(true)} + /> ) : null} </div> @@ -1022,9 +1454,15 @@ export function RunPage() { style={{ height: 28, padding: "0 10px", - background: selectedGroupId === null ? COLORS.accentSubtle : COLORS.recessedBg, + background: + selectedGroupId === null + ? COLORS.accentSubtle + : COLORS.recessedBg, border: `1px solid ${selectedGroupId === null ? COLORS.accentBorder : COLORS.outlineBorder}`, - color: selectedGroupId === null ? COLORS.textPrimary : COLORS.textSecondary, + color: + selectedGroupId === null + ? COLORS.textPrimary + : COLORS.textSecondary, cursor: "pointer", fontFamily: MONO_FONT, fontSize: 10, @@ -1051,13 +1489,23 @@ export function RunPage() { <button key={group.id} type="button" - onClick={() => setSelectedGroupId((current) => (current === group.id ? null : group.id))} + onClick={() => + setSelectedGroupId((current) => + current === group.id ? null : group.id, + ) + } style={{ height: 28, padding: "0 10px", - background: selectedGroupId === group.id ? COLORS.accentSubtle : COLORS.recessedBg, + background: + selectedGroupId === group.id + ? COLORS.accentSubtle + : COLORS.recessedBg, border: `1px solid ${selectedGroupId === group.id ? COLORS.accentBorder : COLORS.outlineBorder}`, - color: selectedGroupId === group.id ? COLORS.textPrimary : COLORS.textSecondary, + color: + selectedGroupId === group.id + ? COLORS.textPrimary + : COLORS.textSecondary, cursor: "pointer", fontFamily: MONO_FONT, fontSize: 10, @@ -1069,11 +1517,20 @@ export function RunPage() { }} > {group.name} - <span style={{ marginLeft: 6, color: COLORS.textDim }}>{groupCounts[group.id] ?? 0}</span> + <span style={{ marginLeft: 6, color: COLORS.textDim }}> + {groupCounts[group.id] ?? 0} + </span> </button> ))} {creatingGroup ? ( - <div style={{ display: "flex", alignItems: "center", gap: 8, flexShrink: 0 }}> + <div + style={{ + display: "flex", + alignItems: "center", + gap: 8, + flexShrink: 0, + }} + > <input ref={newGroupInputRef} value={newGroupName} @@ -1103,7 +1560,11 @@ export function RunPage() { onClick={() => void handleCreateGroup()} disabled={!newGroupName.trim()} style={{ - ...outlineButton({ height: 28, padding: "0 10px", fontSize: 10 }), + ...outlineButton({ + height: 28, + padding: "0 10px", + fontSize: 10, + }), opacity: newGroupName.trim() ? 1 : 0.45, }} > @@ -1115,7 +1576,11 @@ export function RunPage() { setCreatingGroup(false); setNewGroupName(""); }} - style={outlineButton({ height: 28, padding: "0 10px", fontSize: 10 })} + style={outlineButton({ + height: 28, + padding: "0 10px", + fontSize: 10, + })} > Cancel </button> @@ -1138,15 +1603,26 @@ export function RunPage() { )} </div> - <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", overflow: "hidden", position: "relative" }}> + <div + style={{ + flex: 1, + minWidth: 0, + display: "flex", + flexDirection: "column", + overflow: "hidden", + position: "relative", + }} + > {actionError ? ( <div style={{ margin: "20px 20px 0", padding: "10px 12px", - border: "1px solid color-mix(in srgb, var(--color-error) 40%, transparent)", + border: + "1px solid color-mix(in srgb, var(--color-error) 40%, transparent)", borderLeft: `3px solid ${COLORS.danger}`, - background: "color-mix(in srgb, var(--color-error) 12%, transparent)", + background: + "color-mix(in srgb, var(--color-error) 12%, transparent)", color: COLORS.textPrimary, fontFamily: MONO_FONT, fontSize: 11, @@ -1159,28 +1635,74 @@ export function RunPage() { <div style={{ flex: 1, overflowY: "auto", padding: 20 }}> {loading && filteredDefinitions.length === 0 ? ( - <div style={{ fontFamily: MONO_FONT, fontSize: 11, color: COLORS.textDim, textAlign: "center", padding: "40px 0" }}> + <div + style={{ + fontFamily: MONO_FONT, + fontSize: 11, + color: COLORS.textDim, + textAlign: "center", + padding: "40px 0", + }} + > Loading... </div> ) : filteredDefinitions.length === 0 ? ( - <div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 12, padding: "60px 20px" }}> - <div style={{ fontFamily: MONO_FONT, fontSize: 12, color: COLORS.textMuted, textAlign: "center" }}> + <div + style={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 12, + padding: "60px 20px", + }} + > + <div + style={{ + fontFamily: MONO_FONT, + fontSize: 12, + color: COLORS.textMuted, + textAlign: "center", + }} + > No commands in this view </div> - <div style={{ fontFamily: MONO_FONT, fontSize: 11, color: COLORS.textDim, textAlign: "center", maxWidth: 340 }}> - Add a command or assign groups. Every Run click opens a fresh terminal session. + <div + style={{ + fontFamily: MONO_FONT, + fontSize: 11, + color: COLORS.textDim, + textAlign: "center", + maxWidth: 340, + }} + > + Add a command or assign groups. Every Run click opens a fresh + terminal session. </div> - <button type="button" onClick={() => setAddDialogOpen(true)} style={primaryButton()}> + <button + type="button" + onClick={() => setAddDialogOpen(true)} + style={primaryButton()} + > <Plus size={14} weight="bold" /> Add command </button> </div> ) : ( - <div data-tour="run.commandCards" style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 12 }}> + <div + data-tour="run.commandCards" + style={{ + display: "grid", + gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", + gap: 12, + }} + > {filteredDefinitions.map((definition) => { const laneId = resolveProcessLaneId(definition.id); const laneRuntimes = runtime.filter( - (runtimeItem) => runtimeItem.processId === definition.id && runtimeItem.laneId === laneId, + (runtimeItem) => + runtimeItem.processId === definition.id && + runtimeItem.laneId === laneId, ); return ( <CommandCard @@ -1215,7 +1737,15 @@ export function RunPage() { }} onClick={() => setNetworkDrawerOpen(false)} /> - <div style={{ position: "absolute", top: 0, right: 0, bottom: 0, zIndex: 91 }}> + <div + style={{ + position: "absolute", + top: 0, + right: 0, + bottom: 0, + zIndex: 91, + }} + > <RunNetworkPanel onClose={() => setNetworkDrawerOpen(false)} /> </div> </> diff --git a/apps/desktop/src/renderer/components/settings/GeneralSection.tsx b/apps/desktop/src/renderer/components/settings/GeneralSection.tsx index b186c6b30..d5513ff7f 100644 --- a/apps/desktop/src/renderer/components/settings/GeneralSection.tsx +++ b/apps/desktop/src/renderer/components/settings/GeneralSection.tsx @@ -17,6 +17,58 @@ const sectionLabelStyle: React.CSSProperties = { marginBottom: 16, }; +type RuntimeServiceInstallState = NonNullable<AppInfo["localRuntime"]>["serviceInstall"]["state"]; +type RuntimeServiceHealthState = NonNullable<AppInfo["localRuntime"]>["serviceHealth"]["state"]; + +function runtimeServiceLabel(state: RuntimeServiceInstallState): string { + switch (state) { + case "installed": return "Installed"; + case "installing": return "Installing"; + case "failed": return "Needs attention"; + case "skipped": return "Skipped"; + default: return "Not checked"; + } +} + +function runtimeServiceColor(state: RuntimeServiceInstallState): string { + switch (state) { + case "installed": return COLORS.success; + case "installing": return COLORS.accent; + case "failed": return COLORS.danger; + case "skipped": return COLORS.warning; + default: return COLORS.textMuted; + } +} + +function runtimeServiceHealthLabel(state: RuntimeServiceHealthState): string { + switch (state) { + case "running": return "Running"; + case "installed": return "Installed"; + case "not_installed": return "Not installed"; + case "error": return "Status error"; + case "unsupported": return "Unsupported"; + default: return "Unknown"; + } +} + +function runtimeServiceHealthColor(state: RuntimeServiceHealthState): string { + switch (state) { + case "running": return COLORS.success; + case "installed": return COLORS.warning; + case "not_installed": return COLORS.textMuted; + case "error": return COLORS.danger; + case "unsupported": return COLORS.warning; + default: return COLORS.textMuted; + } +} + +function formatRuntimeTimestamp(value: string | null): string | null { + if (!value) return null; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return value; + return new Date(timestamp).toLocaleString(); +} + export function GeneralSection() { const navigate = useNavigate(); const [info, setInfo] = useState<AppInfo | null>(null); @@ -44,6 +96,22 @@ export function GeneralSection() { cancelled = true; }; }, []); + useEffect(() => { + if (info?.localRuntime?.serviceInstall.state !== "installing") return; + let cancelled = false; + const timer = window.setInterval(() => { + window.ade.app + .getInfo() + .then((value) => { + if (!cancelled) setInfo(value); + }) + .catch(() => {}); + }, 1_000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [info?.localRuntime?.serviceInstall.state]); if (loadError) { return <EmptyState title="General" description={`Failed to load: ${loadError}`} />; @@ -105,6 +173,77 @@ export function GeneralSection() { <AdeCliSection compact /> </section> + {info.localRuntime ? ( + <section> + <div style={sectionLabelStyle}>BACKGROUND RUNTIME</div> + <div style={{ ...cardStyle(), display: "flex", flexDirection: "column", gap: 12 }}> + <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}> + <div> + <div style={{ fontSize: 14, fontWeight: 700, color: COLORS.textPrimary }}> + Runtime daemon service + </div> + <div style={{ marginTop: 6, fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textMuted, lineHeight: 1.6 }}> + Connection: {info.localRuntime.connectionState}. Install: {info.localRuntime.serviceInstall.message ?? "No install status."} + </div> + <div style={{ marginTop: 4, fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textMuted, lineHeight: 1.6 }}> + Login service: {info.localRuntime.serviceHealth.message ?? "No service health status."} + </div> + </div> + <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 6 }}> + <span + style={{ + display: "inline-flex", + alignItems: "center", + padding: "4px 8px", + fontSize: 10, + fontWeight: 700, + fontFamily: MONO_FONT, + textTransform: "uppercase", + letterSpacing: "1px", + color: runtimeServiceColor(info.localRuntime.serviceInstall.state), + background: `color-mix(in srgb, ${runtimeServiceColor(info.localRuntime.serviceInstall.state)} 18%, transparent)`, + border: `1px solid color-mix(in srgb, ${runtimeServiceColor(info.localRuntime.serviceInstall.state)} 30%, transparent)`, + }} + > + {runtimeServiceLabel(info.localRuntime.serviceInstall.state)} + </span> + <span + style={{ + display: "inline-flex", + alignItems: "center", + padding: "4px 8px", + fontSize: 10, + fontWeight: 700, + fontFamily: MONO_FONT, + textTransform: "uppercase", + letterSpacing: "1px", + color: runtimeServiceHealthColor(info.localRuntime.serviceHealth.state), + background: `color-mix(in srgb, ${runtimeServiceHealthColor(info.localRuntime.serviceHealth.state)} 18%, transparent)`, + border: `1px solid color-mix(in srgb, ${runtimeServiceHealthColor(info.localRuntime.serviceHealth.state)} 30%, transparent)`, + }} + > + {runtimeServiceHealthLabel(info.localRuntime.serviceHealth.state)} + </span> + </div> + </div> + <div style={{ display: "flex", flexWrap: "wrap", gap: 12, fontSize: 10, fontFamily: MONO_FONT, color: COLORS.textDim }}> + {info.localRuntime.serviceHealth.path ?? info.localRuntime.serviceInstall.path ? ( + <span>Path: {info.localRuntime.serviceHealth.path ?? info.localRuntime.serviceInstall.path}</span> + ) : null} + {info.localRuntime.serviceInstall.exitCode != null ? ( + <span>Exit code: {info.localRuntime.serviceInstall.exitCode}</span> + ) : null} + {info.localRuntime.serviceHealth.checkedAt ? ( + <span>Service checked: {formatRuntimeTimestamp(info.localRuntime.serviceHealth.checkedAt)}</span> + ) : null} + {formatRuntimeTimestamp(info.localRuntime.serviceInstall.updatedAt) ? ( + <span>Updated: {formatRuntimeTimestamp(info.localRuntime.serviceInstall.updatedAt)}</span> + ) : null} + </div> + </div> + </section> + ) : null} + <section style={{ paddingTop: 20, diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index 99fababd7..c35faede2 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -51,8 +51,8 @@ const CLI_TOOLS: Array<{ { cli: "claude", label: "Claude Code", - description: "Anthropic CLI subscription", - loginCmd: "claude auth login", + description: "Claude Agent SDK runtime", + loginCmd: "claude auth login or set ANTHROPIC_API_KEY", installHint: "npm install -g @anthropic-ai/claude-code", }, { diff --git a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx index f588caff5..41bc3bd98 100644 --- a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx @@ -1,8 +1,9 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import QRCode from "qrcode"; import type { + SyncAddressCandidate, SyncDeviceRecord, SyncDeviceRuntimeState, + SyncPairingConnectInfo, SyncRoleSnapshot, SyncTailnetDiscoveryStatus, } from "../../../shared/types"; @@ -93,6 +94,27 @@ function formatEndpoint(host: string | null | undefined, port: number | null | u return port ? `${host}:${port}` : host; } +function addressKindLabel(kind: SyncAddressCandidate["kind"]): string { + switch (kind) { + case "tailscale": + return "Tailscale"; + case "lan": + return "LAN"; + case "loopback": + return "Loopback"; + default: + return "Manual"; + } +} + +function primaryPairingCandidate(connectInfo: SyncPairingConnectInfo | null): SyncAddressCandidate | null { + const candidates = connectInfo?.addressCandidates ?? []; + return candidates.find((candidate) => candidate.kind === "tailscale") + ?? candidates.find((candidate) => candidate.kind === "lan") + ?? candidates[0] + ?? null; +} + function formatTimestamp(value: string | null | undefined): string { if (!value) return "Never"; try { @@ -121,7 +143,7 @@ function connectionColor(state: SyncDeviceRuntimeState["connectionState"]): stri function deviceConnectionLabel(device: SyncDeviceRuntimeState): string { switch (device.connectionState) { case "self": - return "This desktop"; + return "This machine"; case "connected": return "Connected"; default: @@ -206,6 +228,11 @@ export function SyncDevicesSection() { setNotice("PIN removed. Phones can no longer pair."); }), [runAction]); + const handleGeneratePin = useCallback(() => runAction(async () => { + await window.ade.sync.generatePin(); + setNotice("PIN generated."); + }), [runAction]); + const handleRenameLocal = useCallback((name: string) => runAction(async () => { if (!name.trim()) throw new Error("Name cannot be empty."); await window.ade.sync.updateLocalDevice({ name: name.trim() }); @@ -247,11 +274,12 @@ export function SyncDevicesSection() { {isLocalHost ? ( <PairPhoneCard - qrPayloadText={status.pairingConnectInfo?.qrPayloadText ?? null} + connectInfo={status.pairingConnectInfo} pin={status.pairingPin} pinConfigured={status.pairingPinConfigured} busy={busy} onSavePin={handleSetPin} + onGeneratePin={handleGeneratePin} onClearPin={handleClearPin} /> ) : ( @@ -372,7 +400,7 @@ function tailnetStatusCopy(status: SyncTailnetDiscoveryStatus, args: { color: COLORS.success, title: "Phones can connect through Tailscale", detail: [ - "The QR code includes this Mac's normal Tailscale address, so pairing can work without extra setup.", + "The runtime address list includes this machine's normal Tailscale address, so pairing can work without extra setup.", "Only the optional stable shortcut is blocked by Tailscale policy.", ].join(" "), canRetry: false, @@ -385,7 +413,7 @@ function tailnetStatusCopy(status: SyncTailnetDiscoveryStatus, args: { detail: [ "ADE tried to create a stable Tailscale address that phones can find automatically.", "Tailscale only allows that on computers configured by a tailnet admin for service hosting.", - "Local pairing still works; retry after service hosting is enabled for this Mac.", + "Manual address pairing still works; retry after service hosting is enabled for this machine.", ].join(" "), canRetry: true, }; @@ -420,7 +448,7 @@ function tailnetStatusCopy(status: SyncTailnetDiscoveryStatus, args: { label: "Tailscale not available", color: COLORS.warning, title: `Cannot publish ${host}`, - detail: status.stderr || status.error || "Install or open Tailscale on this desktop, then retry.", + detail: status.stderr || status.error || "Install or open Tailscale on this machine, then retry.", canRetry: true, }; case "failed": @@ -435,7 +463,7 @@ function tailnetStatusCopy(status: SyncTailnetDiscoveryStatus, args: { return { label: "Not active", color: COLORS.textMuted, - title: args.isLocalHost ? `Not published as ${host}` : "Only the host desktop publishes tailnet discovery", + title: args.isLocalHost ? `Not published as ${host}` : "Only the host machine publishes tailnet discovery", detail: status.error || "Start phone sync hosting to publish tailnet discovery.", canRetry: false, }; @@ -493,47 +521,28 @@ function TailnetDiscoveryPanel({ } function PairPhoneCard({ - qrPayloadText, + connectInfo, pin, pinConfigured, busy, onSavePin, + onGeneratePin, onClearPin, }: { - qrPayloadText: string | null; + connectInfo: SyncPairingConnectInfo | null; pin: string | null; pinConfigured: boolean; busy: boolean; onSavePin: (pin: string) => Promise<void>; + onGeneratePin: () => Promise<void>; onClearPin: () => Promise<void>; }) { const [editing, setEditing] = useState(false); const [pinError, setPinError] = useState<string | null>(null); - const [qrDataUrl, setQrDataUrl] = useState<string | null>(null); - - useEffect(() => { - let cancelled = false; - if (!qrPayloadText) { - setQrDataUrl(null); - return; - } - void QRCode.toDataURL(qrPayloadText, { - width: 240, - margin: 1, - errorCorrectionLevel: "M", - color: { dark: "#F4F7FB", light: "#11151A" }, - }).then((url) => { - if (!cancelled) setQrDataUrl(url); - }).catch(() => { - if (!cancelled) setQrDataUrl(null); - }); - return () => { - cancelled = true; - }; - }, [qrPayloadText]); const pinMissing = !pinConfigured; - const qrDimmed = pinMissing; + const primaryCandidate = primaryPairingCandidate(connectInfo); + const primaryEndpoint = formatEndpoint(primaryCandidate?.host, connectInfo?.port ?? 8787); const handleSave = async (value: string) => { setPinError(null); @@ -551,48 +560,16 @@ function PairPhoneCard({ Pair a phone </div> - <div style={{ display: "grid", gridTemplateColumns: "240px minmax(220px, 1fr)", gap: 20, alignItems: "center" }}> - <div - style={{ - position: "relative", - width: 240, - height: 240, - borderRadius: 12, - overflow: "hidden", - border: `1px solid ${COLORS.border}`, - background: "#11151A", - }} - > - {qrDataUrl ? ( - <img - src={qrDataUrl} - alt="Phone pairing QR code" - style={{ display: "block", width: "100%", height: "100%", opacity: qrDimmed ? 0.25 : 1 }} - /> - ) : ( - <div style={{ ...helperTextStyle, display: "grid", placeItems: "center", height: "100%" }}> - Generating QR... - </div> - )} - {qrDimmed ? ( - <div - style={{ - position: "absolute", - inset: 0, - display: "grid", - placeItems: "center", - padding: 12, - textAlign: "center", - color: COLORS.textSecondary, - fontFamily: SANS_FONT, - fontSize: 12, - fontWeight: 500, - background: "rgba(10,10,14,0.55)", - }} - > - Set a PIN to enable pairing - </div> - ) : null} + <div style={{ display: "grid", gridTemplateColumns: "minmax(240px, 1.15fr) minmax(220px, 0.85fr)", gap: 18, alignItems: "start" }}> + <div style={{ ...panelStyle, gap: 10 }}> + <div style={LABEL_STYLE}>Runtime address</div> + <div style={{ color: COLORS.textPrimary, fontFamily: MONO_FONT, fontSize: 15, overflowWrap: "anywhere" }}> + {primaryEndpoint} + </div> + <div style={helperTextStyle}> + Choose this machine in ADE mobile discovery. If it does not appear, enter one of these addresses manually. + </div> + <EndpointList connectInfo={connectInfo} /> </div> <div style={{ display: "grid", gap: 10 }}> @@ -605,18 +582,24 @@ function PairPhoneCard({ error={pinError} /> ) : pinMissing ? ( - <EmptyPinBlock onSet={() => { setPinError(null); setEditing(true); }} /> + <EmptyPinBlock + busy={busy} + onSet={() => { setPinError(null); setEditing(true); }} + onGenerate={() => { void onGeneratePin(); }} + /> ) : pin ? ( <PinDisplay pin={pin} busy={busy} onChange={() => { setPinError(null); setEditing(true); }} + onGenerate={() => { void onGeneratePin(); }} onRemove={() => { void onClearPin(); }} /> ) : ( <SavedPinBlock busy={busy} onChange={() => { setPinError(null); setEditing(true); }} + onGenerate={() => { void onGeneratePin(); }} onRemove={() => { void onClearPin(); }} /> )} @@ -627,13 +610,43 @@ function PairPhoneCard({ {pinMissing ? "No PIN set. Phones cannot pair." : pin - ? "Scan on your phone and enter this PIN to pair." - : "Scan on your phone and enter the saved PIN, or set a new one."} + ? "Select this runtime on your phone and enter this PIN to pair." + : "Select this runtime on your phone and enter the saved PIN, or set a new one."} </div> </div> ); } +function EndpointList({ connectInfo }: { connectInfo: SyncPairingConnectInfo | null }) { + const candidates = connectInfo?.addressCandidates ?? []; + if (candidates.length === 0) { + return <div style={helperTextStyle}>No runtime addresses are published yet.</div>; + } + return ( + <div style={{ display: "grid", gap: 6 }}> + {candidates.map((candidate) => ( + <div + key={`${candidate.kind}:${candidate.host}`} + style={{ + display: "grid", + gridTemplateColumns: "76px minmax(0, 1fr)", + gap: 8, + alignItems: "baseline", + minWidth: 0, + }} + > + <span style={tagStyle(candidate.kind === "tailscale" ? COLORS.success : COLORS.textMuted)}> + {addressKindLabel(candidate.kind)} + </span> + <span style={{ ...codeValueStyle, overflowWrap: "anywhere" }}> + {formatEndpoint(candidate.host, connectInfo?.port ?? 8787)} + </span> + </div> + ))} + </div> + ); +} + function ViewerPairingNotice() { return ( <div style={cardStyle({ display: "grid", gap: 10 })}> @@ -641,7 +654,7 @@ function ViewerPairingNotice() { Phone pairing lives on the host </div> <div style={helperTextStyle}> - Open Sync settings on the host desktop to set the phone PIN and show the QR code. + Open Sync settings on the host machine to set the phone PIN and copy a runtime address. </div> </div> ); @@ -651,11 +664,13 @@ function PinDisplay({ pin, busy, onChange, + onGenerate, onRemove, }: { pin: string; busy: boolean; onChange: () => void; + onGenerate: () => void; onRemove: () => void; }) { const digits = pin.padEnd(6, " ").slice(0, 6).split(""); @@ -689,6 +704,9 @@ function PinDisplay({ <button type="button" style={outlineButton()} disabled={busy} onClick={onChange}> Change </button> + <button type="button" style={outlineButton()} disabled={busy} onClick={onGenerate}> + Generate + </button> <button type="button" style={dangerButton()} disabled={busy} onClick={onRemove}> Remove </button> @@ -697,16 +715,27 @@ function PinDisplay({ ); } -function EmptyPinBlock({ onSet }: { onSet: () => void }) { +function EmptyPinBlock({ + busy, + onSet, + onGenerate, +}: { + busy: boolean; + onSet: () => void; + onGenerate: () => void; +}) { return ( <div style={{ display: "grid", gap: 12 }}> <div style={LABEL_STYLE}>PIN</div> <div style={{ color: COLORS.textSecondary, fontFamily: SANS_FONT, fontSize: 13 }}> No PIN set yet. </div> - <div> - <button type="button" style={primaryButton()} onClick={onSet}> - Set a 6-digit PIN + <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}> + <button type="button" style={primaryButton()} disabled={busy} onClick={onGenerate}> + Generate PIN + </button> + <button type="button" style={outlineButton()} disabled={busy} onClick={onSet}> + Set manually </button> </div> </div> @@ -716,10 +745,12 @@ function EmptyPinBlock({ onSet }: { onSet: () => void }) { function SavedPinBlock({ busy, onChange, + onGenerate, onRemove, }: { busy: boolean; onChange: () => void; + onGenerate: () => void; onRemove: () => void; }) { return ( @@ -732,6 +763,9 @@ function SavedPinBlock({ <button type="button" style={outlineButton()} disabled={busy} onClick={onChange}> Set new PIN </button> + <button type="button" style={outlineButton()} disabled={busy} onClick={onGenerate}> + Generate + </button> <button type="button" style={dangerButton()} disabled={busy} onClick={onRemove}> Remove </button> @@ -920,7 +954,7 @@ function ThisComputerDetails({ value={name} onChange={(event) => setName(event.target.value)} style={inputStyle} - placeholder="This Mac" + placeholder="This machine" /> </label> <button diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 6ed14714e..ba9c3e1ab 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { KeybindingsSnapshot, LaneListSnapshot, LaneSummary, ProjectInfo, ProviderMode } from "../../shared/types"; +import type { KeybindingsSnapshot, LaneListSnapshot, LaneSummary, OpenProjectBinding, ProjectInfo, ProviderMode } from "../../shared/types"; import { MODEL_REGISTRY, type ModelDescriptor } from "../../shared/modelRegistry"; import { extractError } from "../lib/format"; import { getAiStatusCached, invalidateAiDiscoveryCache } from "../lib/aiDiscoveryCache"; @@ -519,6 +519,7 @@ export type SessionDismissMap = Record<string, true>; type AppState = { project: ProjectInfo | null; + projectBinding: OpenProjectBinding | null; projectHydrated: boolean; /** True when the user removed all projects — forces welcome screen even though backend still has a project loaded. */ showWelcome: boolean; @@ -562,6 +563,7 @@ type AppState = { dismissedGithubBannerRoots: SessionDismissMap; setProject: (project: ProjectInfo | null) => void; + setProjectBinding: (binding: OpenProjectBinding | null) => void; setProjectHydrated: (hydrated: boolean) => void; setShowWelcome: (show: boolean) => void; clearProjectTransitionError: () => void; @@ -622,6 +624,7 @@ type AppState = { }) => Promise<void>; openRepo: () => Promise<ProjectInfo | null>; switchProjectToPath: (rootPath: string) => Promise<void>; + switchRemoteProject: (targetId: string, projectId: string) => Promise<OpenProjectBinding>; closeProject: () => Promise<void>; }; @@ -704,6 +707,7 @@ function formatProjectTransitionError( export const useAppStore = create<AppState>((set, get) => ({ project: null, + projectBinding: null, projectHydrated: false, showWelcome: true, projectTransition: null, @@ -744,10 +748,19 @@ export const useAppStore = create<AppState>((set, get) => ({ const nextProjectRoot = project?.rootPath ?? null; return { project, + projectBinding: project + ? { + kind: "local", + key: `local:${project.rootPath}`, + rootPath: project.rootPath, + displayName: project.displayName, + } + : null, projectRevision: previousProjectRoot !== nextProjectRoot ? prev.projectRevision + 1 : prev.projectRevision, }; }), + setProjectBinding: (projectBinding) => set({ projectBinding }), setProjectHydrated: (projectHydrated) => set({ projectHydrated }), setShowWelcome: (showWelcome) => set({ showWelcome }), clearProjectTransitionError: () => set({ projectTransitionError: null }), @@ -1198,6 +1211,50 @@ export const useAppStore = create<AppState>((set, get) => ({ } }, + switchRemoteProject: async (targetId: string, projectId: string) => { + ++laneRefreshVersion; + set({ + projectTransition: { + kind: "switching", + rootPath: null, + startedAtMs: Date.now(), + }, + projectTransitionError: null, + }); + try { + const binding = await window.ade.remoteRuntime.openProject(targetId, projectId); + set({ + project: { + rootPath: binding.rootPath, + displayName: binding.displayName, + baseRef: "main", + }, + projectBinding: binding, + projectRevision: get().projectRevision + 1, + projectHydrated: true, + showWelcome: false, + projectTransition: null, + projectTransitionError: null, + isNewTabOpen: false, + laneSnapshots: [], + lanes: [], + selectedLaneId: null, + focusedSessionId: null, + laneInspectorTabs: {}, + keybindings: null, + terminalAttention: EMPTY_TERMINAL_ATTENTION, + }); + void get().refreshLanes({ includeStatus: false }); + return binding; + } catch (error) { + set({ + projectTransition: null, + projectTransitionError: formatProjectTransitionError("switching", error), + }); + throw error; + } + }, + closeProject: async () => { const closingProjectRoot = get().project?.rootPath ?? null; set({ diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 7c79575f0..7ce53391f 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -2,7 +2,13 @@ export const IPC = { appPing: "ade.app.ping", appGetInfo: "ade.app.getInfo", appGetProject: "ade.app.getProject", + appGetWindowSession: "ade.app.getWindowSession", + appNewWindow: "ade.app.newWindow", + appOpenProjectInNewWindow: "ade.app.openProjectInNewWindow", + appCloseWindow: "ade.app.closeWindow", + appNavigate: "ade.app.navigate", appProjectChanged: "ade.app.projectChanged", + appProjectBindingChanged: "ade.app.projectBindingChanged", appOpenExternal: "ade.app.openExternal", appRevealPath: "ade.app.revealPath", appOpenPath: "ade.app.openPath", @@ -31,6 +37,33 @@ export const IPC = { projectForgetRecent: "ade.project.forgetRecent", projectReorderRecent: "ade.project.reorderRecent", projectMissing: "ade.project.missing", + remoteRuntimeListTargets: "ade.remoteRuntime.listTargets", + remoteRuntimeListDiscoveredMachines: + "ade.remoteRuntime.listDiscoveredMachines", + remoteRuntimeGetConnectionSnapshot: "ade.remoteRuntime.getConnectionSnapshot", + remoteRuntimeConnectionSnapshotChanged: + "ade.remoteRuntime.connectionSnapshotChanged", + remoteRuntimeSaveTarget: "ade.remoteRuntime.saveTarget", + remoteRuntimeRemoveTarget: "ade.remoteRuntime.removeTarget", + remoteRuntimeConnect: "ade.remoteRuntime.connect", + remoteRuntimeListProjects: "ade.remoteRuntime.listProjects", + remoteRuntimeAddProject: "ade.remoteRuntime.addProject", + remoteRuntimeBrowseDirectories: "ade.remoteRuntime.browseDirectories", + remoteRuntimeGetProjectDetail: "ade.remoteRuntime.getProjectDetail", + remoteRuntimeGetDefaultParentDir: "ade.remoteRuntime.getDefaultParentDir", + remoteRuntimeCreateProject: "ade.remoteRuntime.createProject", + remoteRuntimeCloneProject: "ade.remoteRuntime.cloneProject", + remoteRuntimeListMyGitHubRepos: "ade.remoteRuntime.listMyGitHubRepos", + remoteRuntimeOpenProject: "ade.remoteRuntime.openProject", + remoteRuntimeCallAction: "ade.remoteRuntime.callAction", + remoteRuntimeCallSync: "ade.remoteRuntime.callSync", + remoteRuntimeStreamEvents: "ade.remoteRuntime.streamEvents", + remoteRuntimeCheckLocalWork: "ade.remoteRuntime.checkLocalWork", + remoteRuntimeDisconnect: "ade.remoteRuntime.disconnect", + localRuntimeCallAction: "ade.localRuntime.callAction", + localRuntimeCallSync: "ade.localRuntime.callSync", + localRuntimeStreamEvents: "ade.localRuntime.streamEvents", + runtimeEvent: "ade.runtime.event", projectStateGetSnapshot: "ade.project.state.getSnapshot", projectStateInitializeOrRepair: "ade.project.state.initializeOrRepair", projectStateRunIntegrityCheck: "ade.project.state.runIntegrityCheck", @@ -56,7 +89,8 @@ export const IPC = { onboardingTutorialComplete: "ade.onboarding.tutorial.complete", onboardingTutorialUpdateAct: "ade.onboarding.tutorial.updateAct", onboardingTutorialSetSilenced: "ade.onboarding.tutorial.setSilenced", - onboardingTutorialClearSessionDismissal: "ade.onboarding.tutorial.clearSessionDismissal", + onboardingTutorialClearSessionDismissal: + "ade.onboarding.tutorial.clearSessionDismissal", onboardingTutorialShouldPrompt: "ade.onboarding.tutorial.shouldPrompt", lanesList: "ade.lanes.list", lanesListSnapshots: "ade.lanes.listSnapshots", @@ -437,6 +471,7 @@ export const IPC = { syncTransferBrainToLocal: "ade.sync.transferBrainToLocal", syncGetPin: "ade.sync.getPin", syncSetPin: "ade.sync.setPin", + syncGeneratePin: "ade.sync.generatePin", syncClearPin: "ade.sync.clearPin", syncSetActiveLanePresence: "ade.sync.setActiveLanePresence", syncEvent: "ade.sync.event", @@ -660,17 +695,11 @@ export const IPC = { memorySweepStatus: "ade.memory.sweepStatus", memoryRunConsolidation: "ade.memory.runConsolidation", memoryConsolidationStatus: "ade.memory.consolidationStatus", - openclawConnectionStatus: "ade.openclaw.connectionStatus", ctoGetState: "ade.cto.getState", ctoEnsureSession: "ade.cto.ensureSession", ctoUpdateCoreMemory: "ade.cto.updateCoreMemory", ctoListSessionLogs: "ade.cto.listSessionLogs", ctoUpdateIdentity: "ade.cto.updateIdentity", - ctoGetOpenclawState: "ade.cto.getOpenclawState", - ctoUpdateOpenclawConfig: "ade.cto.updateOpenclawConfig", - ctoTestOpenclawConnection: "ade.cto.testOpenclawConnection", - ctoListOpenclawMessages: "ade.cto.listOpenclawMessages", - ctoSendOpenclawMessage: "ade.cto.sendOpenclawMessage", ctoListAgents: "ade.cto.listAgents", ctoSaveAgent: "ade.cto.saveAgent", ctoRemoveAgent: "ade.cto.removeAgent", diff --git a/apps/desktop/src/shared/types/adeCli.ts b/apps/desktop/src/shared/types/adeCli.ts index 9771a2ea9..0a7260093 100644 --- a/apps/desktop/src/shared/types/adeCli.ts +++ b/apps/desktop/src/shared/types/adeCli.ts @@ -1,5 +1,5 @@ export type AdeCliStatus = { - command: "ade"; + command: string; platform: NodeJS.Platform; isPackaged: boolean; bundledAvailable: boolean; diff --git a/apps/desktop/src/shared/types/agents.ts b/apps/desktop/src/shared/types/agents.ts index 8724b4f6e..dcafdec10 100644 --- a/apps/desktop/src/shared/types/agents.ts +++ b/apps/desktop/src/shared/types/agents.ts @@ -12,7 +12,7 @@ export type AgentRole = export type AgentStatus = "idle" | "active" | "paused" | "running"; -export type AdapterType = "claude-local" | "codex-local" | "openclaw-webhook" | "process"; +export type AdapterType = "claude-local" | "codex-local" | "process"; export type HeartbeatPolicy = { enabled: boolean; @@ -43,14 +43,6 @@ export type CodexLocalAdapterConfig = { timeoutMs?: number; }; -export type OpenclawWebhookAdapterConfig = { - url: string; - method?: "POST"; - headers?: Record<string, string>; - timeoutMs?: number; - bodyTemplate?: string; -}; - export type ProcessAdapterConfig = { command: string; args?: string[]; @@ -63,7 +55,6 @@ export type ProcessAdapterConfig = { export type AgentAdapterConfig = | ClaudeLocalAdapterConfig | CodexLocalAdapterConfig - | OpenclawWebhookAdapterConfig | ProcessAdapterConfig | Record<string, unknown>; @@ -207,8 +198,7 @@ export type WorkerRuntimeSurface = | "claude_sdk" | "codex_app_server" | "unified_chat" - | "process" - | "openclaw_webhook"; + | "process"; export type WorkerContinuationHandle = { surface: WorkerRuntimeSurface; diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 594f7be58..ea08447ee 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -260,9 +260,16 @@ export type AgentChatEvent = turnId?: string; itemId?: string; errorInfo?: string | { - category: "auth" | "rate_limit" | "budget" | "network" | "unknown"; + category: "auth" | "rate_limit" | "budget" | "network" | "unknown" | "agent_cli_missing" | "agent_cli_auth"; provider?: string; model?: string; + agentCli?: { + agent: string; + displayName: string; + category: "missing" | "unauthenticated"; + installCommand: string; + authCommand: string; + }; }; runtime?: AgentChatRuntime; } @@ -300,6 +307,7 @@ export type AgentChatEvent = outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; + contextWindow?: number; } | { type: "cloud_artifact"; diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts index 160a2acc4..e3ad7d45a 100644 --- a/apps/desktop/src/shared/types/core.ts +++ b/apps/desktop/src/shared/types/core.ts @@ -2,6 +2,46 @@ // Core / project-wide types // --------------------------------------------------------------------------- +export type LocalRuntimeServiceInstallState = + | "not_attempted" + | "installing" + | "installed" + | "failed" + | "skipped"; + +export type LocalRuntimeServiceHealthState = + | "unknown" + | "not_installed" + | "installed" + | "running" + | "error" + | "unsupported"; + +export type LocalRuntimeConnectionState = + | "idle" + | "connecting" + | "connected"; + +export type LocalRuntimeStatus = { + connectionState: LocalRuntimeConnectionState; + serviceInstall: { + state: LocalRuntimeServiceInstallState; + attempted: boolean; + path: string | null; + message: string | null; + exitCode: number | null; + updatedAt: string | null; + }; + serviceHealth: { + state: LocalRuntimeServiceHealthState; + installed: boolean | null; + running: boolean | null; + path: string | null; + message: string | null; + checkedAt: string | null; + }; +}; + export type AppInfo = { appVersion: string; isPackaged: boolean; @@ -17,6 +57,7 @@ export type AppInfo = { nodeEnv?: string; viteDevServerUrl?: string; }; + localRuntime: LocalRuntimeStatus | null; }; export type RecentlyInstalledUpdate = { @@ -45,6 +86,58 @@ export type ProjectInfo = { baseRef: string; }; +export type OpenProjectBinding = + | { + kind: "local"; + key: string; + rootPath: string; + displayName: string; + } + | { + kind: "remote"; + key: string; + targetId: string; + runtimeName: string; + projectId: string; + rootPath: string; + displayName: string; + }; + +export type AppNavigationTarget = + | { + kind: "work" | "chat"; + sessionId?: string | null; + laneId?: string | null; + } + | { + kind: "lane"; + laneId: string; + sessionId?: string | null; + } + | { + kind: "pr"; + prId?: string | null; + prNumber?: number | null; + laneId?: string | null; + } + | { + kind: "route"; + route: string; + }; + +export type AppNavigationRequest = { + target: AppNavigationTarget; + source?: "ade-code" | "desktop" | "cli" | string; +}; + +export type AppNavigationResult = { + ok: boolean; + mode: "desktop" | "unavailable"; + windowId?: number | null; + route?: string; + message?: string; +}; + export type ProjectBrowseInput = { partialPath?: string; cwd?: string | null; @@ -124,6 +217,7 @@ export type CloneProjectInput = { url: string; parentDir: string; name?: string; + githubAuthHeader?: string; }; export type CloneProjectResult = { diff --git a/apps/desktop/src/shared/types/cto.ts b/apps/desktop/src/shared/types/cto.ts index f53845357..b2a8f1c0b 100644 --- a/apps/desktop/src/shared/types/cto.ts +++ b/apps/desktop/src/shared/types/cto.ts @@ -5,14 +5,6 @@ import type { LinearConnectionStatus, NormalizedLinearIssue, } from "./linearSync"; -import type { - OpenclawBridgeConfig, - OpenclawBridgeState, - OpenclawBridgeStatus, - OpenclawContextPolicy, - OpenclawMessageRecord, - OpenclawOutboundEnvelope, -} from "./openclaw"; export type CtoCapabilityMode = "full_tooling" | "fallback"; @@ -33,7 +25,6 @@ export type CtoIdentity = { communicationStyle?: CtoCommunicationStyle; constraints?: string[]; systemPromptExtension?: string; - openclawContextPolicy?: OpenclawContextPolicy; onboardingState?: CtoOnboardingState; modelPreferences: { provider: string; @@ -283,23 +274,3 @@ export type CtoRunProjectScanResult = { coreMemoryPatch: Partial<Omit<CtoCoreMemory, "version" | "updatedAt">>; createdMemoryIds: string[]; }; - -export type CtoGetOpenclawStateArgs = Record<string, never>; - -export type CtoUpdateOpenclawConfigArgs = { - patch: Partial<OpenclawBridgeConfig>; -}; - -export type CtoTestOpenclawConnectionArgs = { - reconnect?: boolean; -}; - -export type CtoListOpenclawMessagesArgs = { - limit?: number; -}; - -export type CtoSendOpenclawMessageArgs = OpenclawOutboundEnvelope; - -export type CtoGetOpenclawStateResult = OpenclawBridgeState; -export type CtoTestOpenclawConnectionResult = OpenclawBridgeStatus; -export type CtoListOpenclawMessagesResult = OpenclawMessageRecord[]; diff --git a/apps/desktop/src/shared/types/index.ts b/apps/desktop/src/shared/types/index.ts index bf9bc5310..dfc33ca24 100644 --- a/apps/desktop/src/shared/types/index.ts +++ b/apps/desktop/src/shared/types/index.ts @@ -13,7 +13,6 @@ export * from "./files"; export * from "./sessions"; export * from "./chat"; export * from "./cto"; -export * from "./openclaw"; export * from "./computerUseArtifacts"; export * from "./iosSimulator"; export * from "./appControl"; @@ -33,6 +32,7 @@ export * from "./projectState"; export * from "./sync"; export * from "./devTools"; export * from "./adeCli"; +export * from "./remoteRuntime"; export * from "./linearSync"; export * from "./feedback"; diff --git a/apps/desktop/src/shared/types/openclaw.ts b/apps/desktop/src/shared/types/openclaw.ts deleted file mode 100644 index 3029942f4..000000000 --- a/apps/desktop/src/shared/types/openclaw.ts +++ /dev/null @@ -1,107 +0,0 @@ -export type OpenclawTargetHint = "cto" | `agent:${string}`; - -export type OpenclawNotificationType = "mission_complete" | "ci_broken" | "blocked_run"; - -export type OpenclawContextPolicy = { - shareMode: "full" | "filtered"; - blockedCategories: string[]; -}; - -export type OpenclawNotificationRoute = { - notificationType: OpenclawNotificationType; - agentId?: string | null; - sessionKey?: string | null; - enabled?: boolean; -}; - -export type OpenclawBridgeConfig = { - enabled: boolean; - bridgePort: number; - gatewayUrl?: string | null; - gatewayToken?: string | null; - deviceToken?: string | null; - hooksToken?: string | null; - allowedAgentIds: string[]; - defaultTarget: OpenclawTargetHint; - allowEmployeeTargets: boolean; - notificationRoutes: OpenclawNotificationRoute[]; -}; - -export type OpenclawBridgeStatus = { - state: "disabled" | "disconnected" | "connecting" | "connected" | "reconnecting" | "error"; - enabled: boolean; - fallbackMode: boolean; - httpListening: boolean; - bridgePort: number; - gatewayUrl?: string | null; - deviceId?: string | null; - paired: boolean; - deviceTokenStored: boolean; - lastConnectedAt?: string | null; - lastEventAt?: string | null; - lastMessageAt?: string | null; - lastError?: string | null; - queuedMessages: number; -}; - -export type OpenclawInboundEnvelope = { - requestId?: string; - idempotencyKey?: string; - agentId?: string | null; - sessionKey?: string | null; - channel?: string | null; - replyChannel?: string | null; - accountId?: string | null; - replyAccountId?: string | null; - threadId?: string | null; - message: string; - targetHint?: OpenclawTargetHint | null; - context?: Record<string, unknown> | null; - timeoutMs?: number | null; -}; - -export type OpenclawOutboundEnvelope = { - requestId?: string; - sessionKey?: string | null; - agentId?: string | null; - channel?: string | null; - replyChannel?: string | null; - accountId?: string | null; - replyAccountId?: string | null; - threadId?: string | null; - message: string; - context?: Record<string, unknown> | null; - notificationType?: OpenclawNotificationType | null; - label?: string | null; - timeoutMs?: number | null; - deliver?: boolean; - bestEffort?: boolean; -}; - -export type OpenclawMessageRecord = { - id: string; - requestId: string; - direction: "inbound" | "outbound"; - mode: "hook" | "query" | "reply" | "notification" | "manual"; - status: "received" | "queued" | "sent" | "failed" | "duplicate"; - agentId?: string | null; - sessionKey?: string | null; - targetHint?: OpenclawTargetHint | null; - resolvedTarget?: OpenclawTargetHint | null; - body: string; - summary: string; - context?: Record<string, unknown> | null; - createdAt: string; - error?: string | null; - metadata?: Record<string, unknown> | null; -}; - -export type OpenclawBridgeState = { - config: OpenclawBridgeConfig; - status: OpenclawBridgeStatus; - endpoints: { - healthUrl: string | null; - hookUrl: string | null; - queryUrl: string | null; - }; -}; diff --git a/apps/desktop/src/shared/types/remoteRuntime.ts b/apps/desktop/src/shared/types/remoteRuntime.ts new file mode 100644 index 000000000..e7d674187 --- /dev/null +++ b/apps/desktop/src/shared/types/remoteRuntime.ts @@ -0,0 +1,155 @@ +export type RemoteRuntimeTarget = { + id: string; + name: string; + hostname: string; + sshUser: string | null; + port: number | null; + sshKeyPath: string | null; + lastSeenArch: string | null; + runtimeBinaryVersion: string | null; + lastConnectedAt: number | null; +}; + +export type RemoteRuntimeTargetInput = { + name?: string | null; + hostname: string; + sshUser?: string | null; + port?: number | null; + sshKeyPath?: string | null; +}; + +export type RemoteRuntimeDiscoveredMachine = { + id: string; + serviceName: string; + machineName: string; + hostIdentity: string | null; + hostName: string | null; + port: number; + addresses: string[]; + primaryRoute: string | null; + tailscaleAddress: string | null; + runtimeKind: string | null; + runtimeVersion: string | null; + projectIds: string[]; + projectCount: number | null; + lastSeenAt: number; +}; + +export type RemoteRuntimeProjectRecord = { + projectId: string; + rootPath: string; + displayName: string; + addedAt: number; + lastOpenedAt: number; + gitOriginUrl: string | null; +}; + +export type RemoteRuntimeConnectResult = { + target: RemoteRuntimeTarget; + arch: string; + version: string | null; + projects: RemoteRuntimeProjectRecord[]; +}; + +export type RemoteRuntimeConnectionState = + | "idle" + | "connecting" + | "connected" + | "error"; + +export type RemoteRuntimeConnectionStatus = { + target: RemoteRuntimeTarget; + state: RemoteRuntimeConnectionState; + arch: string | null; + version: string | null; + projects: RemoteRuntimeProjectRecord[]; + lastError: string | null; + lastAttemptedAt: number | null; + connectedAt: number | null; +}; + +export type RemoteRuntimeConnectionSnapshot = { + connections: RemoteRuntimeConnectionStatus[]; + connectedCount: number; + updatedAt: number; +}; + +export type RemoteRuntimeActionRequest = { + domain: string; + action: string; + args?: Record<string, unknown>; + arg?: unknown; + argsList?: unknown[]; +}; + +export type RemoteRuntimeActionResult = { + domain: string; + action: string; + result: unknown; + statusHints: Record<string, unknown>; +}; + +export type RemoteRuntimeEventCategory = + | "orchestrator" + | "dag_mutation" + | "runtime" + | "mission"; + +export type RemoteRuntimeBufferedEvent = { + id: number; + timestamp: string; + category: RemoteRuntimeEventCategory; + payload: Record<string, unknown>; +}; + +export type RemoteRuntimeStreamEventsRequest = { + cursor?: number; + limit?: number; + category?: RemoteRuntimeEventCategory; +}; + +export type RemoteRuntimeStreamEventsResult = { + events: RemoteRuntimeBufferedEvent[]; + nextCursor: number; + hasMore: boolean; +}; + +export type RemoteRuntimeEventNotificationPayload = { + bindingKey: string; + event: RemoteRuntimeBufferedEvent; +}; + +export type RemoteRuntimeLocalWorkMatch = { + rootPath: string; + displayName: string; + gitOriginUrl: string; + dirtyCount: number; + workSummary?: RemoteRuntimeProjectWorkSummary | null; +}; + +export type RemoteRuntimeProjectWorktreeSummary = { + rootPath: string; + name: string; + branchName: string | null; + dirtyCount: number; + isPrimary: boolean; +}; + +export type RemoteRuntimeProjectWorkSummary = { + rootPath: string; + laneCount: number; + checkedLaneCount: number; + dirtyLaneCount: number; + dirtyFileCount: number; + primaryDirtyCount: number; + lanes: RemoteRuntimeProjectWorktreeSummary[]; +}; + +export type RemoteRuntimeLocalWorkCheckResult = { + remoteProjectId: string; + remoteDisplayName: string; + remoteGitOriginUrl: string | null; + remoteWorkSummary?: RemoteRuntimeProjectWorkSummary | null; + matches: RemoteRuntimeLocalWorkMatch[]; + hasDirtyWork: boolean; +}; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index f48d5ac37..45de33c16 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -267,7 +267,7 @@ export type SyncProjectConnectionPayload = { authKind: "bootstrap" | "paired"; token?: string | null; pairedDeviceId?: string | null; - hostIdentity: SyncPairingQrPayload["hostIdentity"]; + hostIdentity: SyncPairingHostIdentity; port: number; addressCandidates: SyncAddressCandidate[]; }; @@ -305,25 +305,18 @@ export type SyncAddressCandidate = { kind: SyncAddressCandidateKind; }; -export type SyncPairingQrPayload = { - version: 2; - hostIdentity: { - deviceId: string; - siteId: string; - name: string; - platform: SyncPeerPlatform; - deviceType: SyncPeerDeviceType; - }; - port: number; - addressCandidates: SyncAddressCandidate[]; +export type SyncPairingHostIdentity = { + deviceId: string; + siteId: string; + name: string; + platform: SyncPeerPlatform; + deviceType: SyncPeerDeviceType; }; export type SyncPairingConnectInfo = { - hostIdentity: SyncPairingQrPayload["hostIdentity"]; + hostIdentity: SyncPairingHostIdentity; port: number; addressCandidates: SyncAddressCandidate[]; - qrPayload: SyncPairingQrPayload; - qrPayloadText: string; }; export type SyncPairingRequestPayload = { @@ -712,11 +705,14 @@ export type SyncRemoteCommandPolicy = { export type SyncRemoteCommandDescriptor = { action: SyncRemoteCommandAction | (string & {}); + scope: "runtime" | "project"; policy: SyncRemoteCommandPolicy; }; export type SyncCommandPayload = { commandId: string; + projectId?: string | null; + projectRootPath?: string | null; action: SyncRemoteCommandAction | (string & {}); args: Record<string, unknown>; }; @@ -901,6 +897,7 @@ export type SyncInAppNotificationPayload = { type SyncEnvelopeBase<TType extends string> = { version: SyncProtocolVersion; type: TType; + projectId?: string | null; requestId?: string | null; }; diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index 5f72cc3ce..747c07bb3 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -123,13 +123,13 @@ struct ContentView: View { private struct ProjectHomeView: View { @EnvironmentObject private var syncService: SyncService - private var attachedComputerLabel: String { + private var attachedMachineLabel: String { let trimmedHost = syncService.hostName?.trimmingCharacters(in: .whitespacesAndNewlines) let host = (trimmedHost?.isEmpty == false) ? trimmedHost! : nil switch syncService.connectionState { case .connected, .syncing: if let host { return "Attached to \(host)" } - return "Attached to computer" + return "Attached to machine" case .connecting: if let host { return "Connecting to \(host)…" } return "Connecting…" @@ -137,11 +137,11 @@ private struct ProjectHomeView: View { if let host { return "Cannot reach \(host)" } return "Connection error" case .disconnected: - return "No computer attached" + return "No machine attached" } } - private var attachedComputerTint: Color { + private var attachedMachineTint: Color { let health = syncService.connectionHealth switch health.transport { case .connected: @@ -165,7 +165,7 @@ private struct ProjectHomeView: View { ScrollView { VStack(spacing: 30) { welcomeHero - attachedComputerBanner + attachedMachineBanner projectSection } .frame(maxWidth: 520) @@ -215,18 +215,18 @@ private struct ProjectHomeView: View { .accessibilityLabel("ADE") } - private var attachedComputerBanner: some View { + private var attachedMachineBanner: some View { Button { syncService.settingsPresented = true } label: { HStack(spacing: 10) { Circle() - .fill(attachedComputerTint) + .fill(attachedMachineTint) .frame(width: 8, height: 8) Image(systemName: "desktopcomputer") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(ADEColor.textSecondary) - Text(attachedComputerLabel) + Text(attachedMachineLabel) .font(.system(.footnote, design: .rounded).weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) .lineLimit(1) @@ -242,13 +242,13 @@ private struct ProjectHomeView: View { ) } .buttonStyle(.plain) - .accessibilityLabel(attachedComputerLabel) - .accessibilityHint("Opens computer connection settings.") + .accessibilityLabel(attachedMachineLabel) + .accessibilityHint("Opens machine connection settings.") } -private var projectSection: some View { + private var projectSection: some View { VStack(spacing: 14) { - Text("DESKTOP TABS") + Text("PROJECTS") .font(.system(.caption, design: .rounded).weight(.semibold)) .foregroundStyle(ADEColor.textMuted) .tracking(0.8) @@ -305,18 +305,18 @@ private var projectSection: some View { private var emptyProjectsTitle: String { switch syncService.connectionState { - case .connected, .syncing: return "No projects on desktop" - case .connecting: return "Connecting to desktop" - case .error, .disconnected: return "Connect ADE desktop" + case .connected, .syncing: return "No projects on machine" + case .connecting: return "Connecting to machine" + case .error, .disconnected: return "Connect ADE machine" } } private var emptyProjectsSubtitle: String { switch syncService.connectionState { case .connected, .syncing: - return "Open a project on \(syncService.hostName ?? "your computer")" + return "Open a project on \(syncService.hostName ?? "your machine")" case .connecting, .error, .disconnected: - return syncService.hostName ?? "Pair a computer to see your tabs" + return syncService.hostName ?? "Pair a machine to see your projects" } } } diff --git a/apps/ios/ADE/Info.plist b/apps/ios/ADE/Info.plist index 3463177fc..041054209 100644 --- a/apps/ios/ADE/Info.plist +++ b/apps/ios/ADE/Info.plist @@ -73,8 +73,6 @@ <array> <string>_ade-sync._tcp</string> </array> - <key>NSCameraUsageDescription</key> - <string>Scan the ADE host QR code to pair this iPhone with your project host.</string> <key>NSLocalNetworkUsageDescription</key> <string>Discover ADE hosts on your local network and reconnect without rescanning.</string> <key>NSSupportsLiveActivities</key> diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 1b3703462..e18b4d100 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -140,6 +140,11 @@ struct DiscoveredSyncHost: Codable, Equatable, Identifiable { var port: Int var addresses: [String] var tailscaleAddress: String? + var runtimeKind: String? = nil + var runtimeVersion: String? = nil + var projectIds: [String] = [] + var projectNames: [String] = [] + var projectCount: Int? = nil var lastResolvedAt: String } @@ -157,13 +162,6 @@ struct SyncPairingHostIdentity: Codable, Equatable { var deviceType: String } -struct SyncPairingQrPayload: Codable, Equatable { - var version: Int - var hostIdentity: SyncPairingHostIdentity - var port: Int - var addressCandidates: [SyncAddressCandidate] -} - enum SyncDomain: String, CaseIterable, Hashable { case lanes case files @@ -173,8 +171,8 @@ enum SyncDomain: String, CaseIterable, Hashable { enum SyncHydrationMessaging { static let initialData = "Syncing initial data..." - static let waitingForProjectData = "Waiting for host to sync project data..." - static let projectDataTimeout = "Timed out waiting for host to sync project data. Try reconnecting." + static let waitingForProjectData = "Waiting for the machine to sync project data..." + static let projectDataTimeout = "Timed out waiting for the machine to sync project data. Try reconnecting." } enum SyncDomainPhase: String, Codable, Equatable { @@ -889,6 +887,10 @@ struct LinearConnectionStatus: Codable, Hashable { var connected: Bool var viewerId: String? var viewerName: String? + var organizationId: String? + var organizationName: String? + var organizationUrlKey: String? + var organizationLogoUrl: String? var projectCount: Int? var projectPreview: [String]? var checkedAt: String? diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 631598560..f16bdf728 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -130,7 +130,7 @@ func decodeHydrationPayload<T: Decodable>(_ raw: Any, as type: T.Type, domainLab domain: "ADE", code: 18, userInfo: [ - NSLocalizedDescriptionKey: "The host returned incomplete \(domainLabel) data. Pull to retry or reconnect the host.", + NSLocalizedDescriptionKey: "The machine returned incomplete \(domainLabel) data. Pull to retry or reconnect the machine.", NSUnderlyingErrorKey: error, ] ) @@ -210,14 +210,14 @@ enum InitialHydrationGate { throw NSError( domain: "ADE", code: 22, - userInfo: [NSLocalizedDescriptionKey: SyncHydrationMessaging.projectDataTimeout] + userInfo: [NSLocalizedDescriptionKey: "Timed out waiting for the machine to sync project data. Try reconnecting."] ) } } enum SyncRequestTimeout { static let defaultTimeoutNanoseconds: UInt64 = 30_000_000_000 - static let message = "The host took too long to respond. Reconnecting now." + static let message = "The machine took too long to respond. Reconnecting now." static func error(message: String = Self.message, underlyingError: Error? = nil) -> NSError { var userInfo: [String: Any] = [NSLocalizedDescriptionKey: message] @@ -555,29 +555,29 @@ enum SyncUserFacingError { static func message(for error: Error) -> String { let nsError = error as NSError if nsError.userInfo[syncAmbiguousRouteAuthFailureKey] as? Bool == true { - return "Reached an ADE host over Tailnet, but it did not match this saved computer. ADE kept the pairing and will keep trying other routes." + return "Reached an ADE machine over Tailscale, but it did not match this saved machine. ADE kept the pairing and will keep trying other routes." } if let code = nsError.userInfo["ADEErrorCode"] as? String, code == "auth_failed" { - return "This phone is no longer paired with the host. Pair again from Settings." + return "This phone is no longer paired with this machine. Pair again from Settings." } let rawMessage = nsError.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) guard !rawMessage.isEmpty else { - return "Something interrupted sync. Reconnect to the host and try again." + return "Something interrupted sync. Reconnect to the machine and try again." } let lowered = rawMessage.lowercased() if lowered.contains("timed out waiting for host to sync project data") { - return SyncHydrationMessaging.projectDataTimeout + return "Timed out waiting for the machine to sync project data. Try reconnecting." } if lowered.contains("no project row") || lowered.contains("project data") { - return SyncHydrationMessaging.waitingForProjectData + return "Waiting for the machine to sync project data..." } if lowered.contains("host took too long to respond") { return SyncRequestTimeout.message } if lowered.contains("heartbeat") && lowered.contains("reconnect") { - return "The host stopped responding. Reconnecting now." + return "The machine stopped responding. Reconnecting now." } if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorAppTransportSecurityRequiresSecureConnection || lowered.contains("app transport security") || @@ -588,31 +588,31 @@ enum SyncUserFacingError { lowered.contains("socket is not connected") || lowered.contains("network connection was lost") || lowered.contains("cancelled") { - return "The connection to the host was interrupted. Reconnecting now." + return "The connection to the machine was interrupted. Reconnecting now." } if lowered.contains("unable to reach the saved ade host") || lowered.contains("could not connect to the server") || lowered.contains("network is unreachable") || lowered.contains("cannot connect to host") { - return "Can't reach the saved host right now. Make sure ADE is running on the host, then retry." + return "Can't reach the saved machine right now. Make sure ADE is running there, then retry. Away from the LAN, connect both devices through Tailscale or your VPN." } if lowered.contains("no saved address is available for this host") { - return "This phone no longer has a saved address for the host. Open Settings to rediscover it or pair again." + return "This phone no longer has a saved address for this machine. Open Settings to rediscover it or pair again." } if lowered.contains("the host is offline") || lowered.contains("requires a live connection to the host") { - return "The host is offline. Reconnect, then try again." + return "The machine is offline. Reconnect, then try again." } if lowered.contains("the host returned incomplete") { - return "The host sent incomplete sync data. Retry the affected area or reconnect the host." + return "The machine sent incomplete sync data. Retry the affected area or reconnect the machine." } if lowered.contains("pairing secret missing from response") || lowered.contains("invalid hello response") { - return "The host replied with unexpected pairing data. Reconnect and try again." + return "The machine replied with unexpected pairing data. Reconnect and try again." } if lowered.contains("authentication failed") { - return "This phone is no longer paired with the host. Pair again from Settings." + return "This phone is no longer paired with this machine. Pair again from Settings." } if lowered.contains("invalid host address") { - return "The host address looks invalid. Check it and try again." + return "The machine address looks invalid. Check it and try again." } if lowered.contains("invalid queued operation payload") || lowered.contains("queued operation payload is invalid") || @@ -620,16 +620,16 @@ enum SyncUserFacingError { return "Queued sync work on this phone became unreadable. Reconnect and try the action again." } if lowered.contains("remote command rejected") { - return "The host couldn't accept that request right now. Try again in a moment." + return "The machine couldn't accept that request right now. Try again in a moment." } if lowered.contains("file request failed") { - return "The host couldn't finish that file request. Try again." + return "The machine couldn't finish that file request. Try again." } if lowered.contains("unable to start gzip decoder") || lowered.contains("unable to decode compressed sync payload") { - return "The host sent unreadable sync data. Reconnect and try again." + return "The machine sent unreadable sync data. Reconnect and try again." } if lowered.contains("message too long") { - return "The desktop sent too much sync data in one message. Update ADE on the desktop, then reconnect." + return "The machine sent too much sync data in one message. Update ADE on the machine, then reconnect." } return rawMessage @@ -721,10 +721,63 @@ struct QueuedRemoteCommandError: LocalizedError { let action: String var errorDescription: String? { - "That action is queued on the host and will run when the desktop reconnects." + "That action is queued for this project and will run when the machine reconnects." } } +private func syncNormalizedCommandScopeValue(_ value: String?) -> String? { + guard let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !normalized.isEmpty + else { return nil } + return normalized +} + +func syncNormalizedProjectRootScope(_ rootPath: String?) -> String? { + guard var root = syncNormalizedCommandScopeValue(rootPath) else { return nil } + while root.count > 1, root.hasSuffix("/") { + root.removeLast() + } + return root +} + +func syncCommandEnvelopePayload( + commandId: String, + action: String, + args: [String: Any], + projectId: String?, + projectRootPath: String? +) -> [String: Any] { + var payload: [String: Any] = [ + "commandId": commandId, + "action": action, + "args": args, + ] + if let projectId = syncNormalizedCommandScopeValue(projectId) { + payload["projectId"] = projectId + } + if let projectRootPath = syncNormalizedProjectRootScope(projectRootPath) { + payload["projectRootPath"] = projectRootPath + } + return payload +} + +func syncOutboundEnvelopeProjectId(type: String, activeProjectId: String?) -> String? { + let projectScopedTypes: Set<String> = [ + "changeset_batch", + "changeset_ack", + "command", + "file_request", + "terminal_subscribe", + "terminal_unsubscribe", + "terminal_input", + "terminal_resize", + "chat_subscribe", + "chat_unsubscribe", + ] + guard projectScopedTypes.contains(type) else { return nil } + return syncNormalizedCommandScopeValue(activeProjectId) +} + @MainActor final class SyncService: ObservableObject { @Published private(set) var connectionState: RemoteConnectionState = .disconnected @@ -733,6 +786,7 @@ final class SyncService: ObservableObject { @Published private(set) var projects: [MobileProjectSummary] = [] @Published private(set) var activeProjectId: String? @Published private(set) var activeProjectRootPath: String? + private var activeProjectHostIdentity: String? @Published private(set) var projectSwitchInFlightRootPath: String? @Published private(set) var discoveredHosts: [DiscoveredSyncHost] = [] @Published private(set) var domainStatuses: [SyncDomain: SyncDomainStatus] = Dictionary( @@ -791,6 +845,7 @@ final class SyncService: ObservableObject { private let autoReconnectPausedKey = "ade.sync.autoReconnectPausedByUser" private let activeProjectIdKey = "ade.sync.activeProjectId" private let activeProjectRootPathKey = "ade.sync.activeProjectRootPath" + private let activeProjectHostIdentityKey = "ade.sync.activeProjectHostIdentity" private let pendingOperationsKey = "ade.sync.pendingOperations" private let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" private let outboundSyncCursorsKey = "ade.sync.outboundSyncCursors" @@ -1023,13 +1078,13 @@ final class SyncService: ObservableObject { } guard project.isCached || database.hasProject(id: project.id) else { - lastError = "That project has not been cached on this phone yet. Connect to the ADE desktop app before opening it." + lastError = "That project has not been cached on this phone yet. Connect to the ADE machine before opening it." setDomainStatus(SyncDomain.allCases, phase: .failed, error: lastError) return } guard connectionState != .connected && connectionState != .syncing else { - lastError = "This computer connection does not support project switching. Reconnect to a current ADE desktop app before opening another project." + lastError = "This machine connection does not support project switching. Reconnect to a current ADE machine before opening another project." setDomainStatus(SyncDomain.allCases, phase: .failed, error: lastError) return } @@ -1171,7 +1226,7 @@ final class SyncService: ObservableObject { private func applyRemoteProjectCatalog(_ catalog: MobileProjectCatalogPayload) { remoteProjectCatalog = catalog.projects - refreshProjectCatalog(preferRemoteSelection: true) + refreshProjectCatalog() } private func applyRemoteProjectCatalogChunk( @@ -1200,7 +1255,7 @@ final class SyncService: ObservableObject { let raw = try await awaitResponse( requestId: requestId, disconnectOnTimeout: false, - timeoutMessage: "Timed out waiting for the desktop project list." + timeoutMessage: "Timed out waiting for the machine project list." ) { self.sendEnvelope(type: "project_catalog_request", requestId: requestId, payload: [:]) } @@ -1226,7 +1281,7 @@ final class SyncService: ObservableObject { let result = try decode(raw, as: MobileProjectSwitchResultPayload.self) guard result.ok else { throw NSError(domain: "ADE", code: 24, userInfo: [ - NSLocalizedDescriptionKey: result.message ?? "The desktop could not open that project for phone sync." + NSLocalizedDescriptionKey: result.message ?? "The machine could not open that project for phone sync." ]) } guard isCurrentProjectSelection(selectionGeneration) else { @@ -1266,7 +1321,7 @@ final class SyncService: ObservableObject { lastError = nil let hadLiveSocket = connectionState == .connected || connectionState == .syncing if hadLiveSocket { - teardownSocket(reason: "Switching desktop project.") + teardownSocket(reason: "Switching project.") } connectionState = .connecting setDomainStatus(SyncDomain.allCases, phase: .syncingInitialData) @@ -1283,7 +1338,7 @@ final class SyncService: ObservableObject { ) guard !addressCandidates.isEmpty else { throw NSError(domain: "ADE", code: 25, userInfo: [ - NSLocalizedDescriptionKey: "The desktop did not provide an address for that project." + NSLocalizedDescriptionKey: "The machine did not provide an address for that project." ]) } @@ -1292,7 +1347,7 @@ final class SyncService: ObservableObject { let resolvedToken = hasBundledToken ? bundledToken : previousToken guard let resolvedToken else { throw NSError(domain: "ADE", code: 26, userInfo: [ - NSLocalizedDescriptionKey: "The desktop did not provide credentials for that project, and this phone has no saved pairing for the host." + NSLocalizedDescriptionKey: "The machine did not provide credentials for that project, and this phone has no saved pairing for that machine." ]) } let resolvedAuthKind = hasBundledToken ? connection.authKind : (previousProfile?.authKind ?? connection.authKind) @@ -1324,7 +1379,7 @@ final class SyncService: ObservableObject { keychain.saveToken(resolvedToken) keychain.saveToken(resolvedToken, hostKey: profileStorageKey(profile)) saveProfile(profile) - teardownSocket(reason: "Switching desktop project.") + teardownSocket(reason: "Switching project.") let connectedEndpoint = try await connectUsingProfile( profile, token: resolvedToken, @@ -1407,6 +1462,19 @@ final class SyncService: ObservableObject { } else { UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) } + if projectId != nil { + let hostIdentity = syncNormalizedCommandScopeValue(activeHostProfile?.hostIdentity) + ?? syncNormalizedCommandScopeValue(activeHostProfile?.lastHostDeviceId) + activeProjectHostIdentity = hostIdentity + if let hostIdentity { + UserDefaults.standard.set(hostIdentity, forKey: activeProjectHostIdentityKey) + } else { + UserDefaults.standard.removeObject(forKey: activeProjectHostIdentityKey) + } + } else { + activeProjectHostIdentity = nil + UserDefaults.standard.removeObject(forKey: activeProjectHostIdentityKey) + } if scopeChanged { resetOutboundCursorStateForActiveProject() } @@ -1441,13 +1509,7 @@ final class SyncService: ObservableObject { } private func normalizedProjectRoot(_ rootPath: String?) -> String? { - guard var root = rootPath?.trimmingCharacters(in: .whitespacesAndNewlines), - !root.isEmpty - else { return nil } - while root.count > 1, root.hasSuffix("/") { - root.removeLast() - } - return root + syncNormalizedProjectRootScope(rootPath) } private let queueableFileActions: Set<String> = [ @@ -1495,6 +1557,7 @@ final class SyncService: ObservableObject { } activeProjectId = UserDefaults.standard.string(forKey: activeProjectIdKey) activeProjectRootPath = normalizedProjectRoot(UserDefaults.standard.string(forKey: activeProjectRootPathKey)) + activeProjectHostIdentity = UserDefaults.standard.string(forKey: activeProjectHostIdentityKey) database.setActiveProjectId(activeProjectId) projects = database.listMobileProjects() outboundLocalDbVersion = loadOutboundCursorVersionForActiveProject(defaultVersion: database.currentDbVersion()) @@ -1638,14 +1701,20 @@ final class SyncService: ObservableObject { migrateTokenIfNeeded(for: profile) return profile } - guard let data = UserDefaults.standard.data(forKey: legacyDraftKey), - let draft = try? decoder.decode(ConnectionDraft.self, from: data) else { - return nil + if let data = UserDefaults.standard.data(forKey: legacyDraftKey), + let draft = try? decoder.decode(ConnectionDraft.self, from: data) { + let migrated = HostConnectionProfile(legacy: draft) + saveProfile(migrated) + UserDefaults.standard.removeObject(forKey: legacyDraftKey) + return migrated + } + + if let fallback = mostRecentSavedProfileWithCredentials() { + saveProfile(fallback) + syncConnectLog.info("Selected most recent saved ADE machine for automatic reconnect: \(syncLogProfileSummary(fallback), privacy: .public)") + return fallback } - let migrated = HostConnectionProfile(legacy: draft) - saveProfile(migrated) - UserDefaults.standard.removeObject(forKey: legacyDraftKey) - return migrated + return nil } private func profileStorageKey(_ profile: HostConnectionProfile) -> String? { @@ -1772,6 +1841,18 @@ final class SyncService: ObservableObject { keychain.saveToken(legacyToken, hostKey: key) } + private func storedTokenForSavedProfile(_ profile: HostConnectionProfile) -> String? { + guard let key = profileStorageKey(profile) else { return nil } + return keychain.loadToken(hostKey: key) + } + + private func mostRecentSavedProfileWithCredentials() -> HostConnectionProfile? { + loadSavedProfilesRaw().values + .filter { storedTokenForSavedProfile($0) != nil } + .sorted { shouldPreferProfile($0, over: $1) } + .first + } + private func tokenForProfile(_ profile: HostConnectionProfile?) -> String? { guard let profile else { return nil } if let key = profileStorageKey(profile), let token = keychain.loadToken(hostKey: key) { @@ -1824,12 +1905,17 @@ final class SyncService: ObservableObject { let routeId = tailscaleAddress ?? addresses.first ?? "saved" return DiscoveredSyncHost( id: "saved-\(identity?.isEmpty == false ? identity! : routeId)", - serviceName: "Saved ADE host", + serviceName: "Saved ADE machine", hostName: displayName?.isEmpty == false ? displayName! : routeId, hostIdentity: identity?.isEmpty == false ? identity : nil, port: profile.port, addresses: addresses, tailscaleAddress: tailscaleAddress, + runtimeKind: nil, + runtimeVersion: nil, + projectIds: [], + projectNames: [], + projectCount: nil, lastResolvedAt: profile.updatedAt ) } @@ -1847,7 +1933,7 @@ final class SyncService: ObservableObject { } guard let profile = candidates.sorted(by: { $0.updatedAt > $1.updatedAt }).first, tokenForProfile(profile) != nil else { - lastError = "This saved computer no longer has pairing credentials. Pair again from Settings." + lastError = "This saved machine no longer has pairing credentials. Pair again from Settings." connectionState = .error return } @@ -2212,7 +2298,7 @@ final class SyncService: ObservableObject { } } guard let preferredAddress = openedAddress, let preferredPort = openedPort else { - throw lastOpenError ?? NSError(domain: "ADE", code: 19, userInfo: [NSLocalizedDescriptionKey: "Unable to reach the host."]) + throw lastOpenError ?? NSError(domain: "ADE", code: 19, userInfo: [NSLocalizedDescriptionKey: "Unable to reach the machine. Check LAN, Tailscale, or VPN, then try again."]) } let requestId = makeRequestId() let raw = try await awaitResponse(requestId: requestId) { @@ -2276,31 +2362,6 @@ final class SyncService: ObservableObject { } } - func decodePairingQrPayload(from rawValue: String) throws -> SyncPairingQrPayload { - let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - if let url = URL(string: trimmed), let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let payloadValue = components.queryItems?.first(where: { $0.name == "payload" })?.value { - let json = payloadValue.removingPercentEncoding ?? payloadValue - if let data = json.data(using: .utf8), let payload = try? decodeCurrentPairingQrPayload(from: data) { - return payload - } - } - - throw NSError(domain: "ADE", code: 22, userInfo: [NSLocalizedDescriptionKey: "That QR code is not a valid ADE pairing payload."]) - } - - private func decodeCurrentPairingQrPayload(from data: Data) throws -> SyncPairingQrPayload { - let payload = try decoder.decode(SyncPairingQrPayload.self, from: data) - guard payload.version == 2 else { - throw NSError( - domain: "ADE", - code: 22, - userInfo: [NSLocalizedDescriptionKey: "That QR code uses an unsupported ADE pairing format."] - ) - } - return payload - } - private func friendlyPairingFailureMessage(_ raw: Any) -> String { let error = (raw as? [String: Any])?["error"] as? [String: Any] let code = error?["code"] as? String @@ -2310,7 +2371,7 @@ final class SyncService: ObservableObject { case "invalid_pin": return "Incorrect PIN." case "pin_not_set": - return "No PIN set on that computer. Set one in the desktop app's Sync settings." + return "No PIN set on that machine. Set one in ADE's Sync settings on the machine." default: return message ?? "Pairing failed." } @@ -4287,11 +4348,23 @@ final class SyncService: ObservableObject { } activeHostProfile = profile hostName = profile.hostName + if activeProjectId != nil { + let hostIdentity = syncNormalizedCommandScopeValue(profile.hostIdentity) + ?? syncNormalizedCommandScopeValue(profile.lastHostDeviceId) + activeProjectHostIdentity = hostIdentity + if let hostIdentity { + UserDefaults.standard.set(hostIdentity, forKey: activeProjectHostIdentityKey) + } else { + UserDefaults.standard.removeObject(forKey: activeProjectHostIdentityKey) + } + } } else { UserDefaults.standard.removeObject(forKey: profileKey) UserDefaults.standard.removeObject(forKey: legacyDraftKey) activeHostProfile = nil hostName = nil + activeProjectHostIdentity = nil + UserDefaults.standard.removeObject(forKey: activeProjectHostIdentityKey) } } @@ -4734,7 +4807,7 @@ final class SyncService: ObservableObject { action: action, args: ["laneId": laneId], disconnectOnTimeout: false, - timeoutMessage: "The host did not acknowledge lane presence in time." + timeoutMessage: "The machine did not acknowledge lane presence in time." ) if refreshSnapshots { try? await refreshLaneSnapshots() @@ -4748,8 +4821,12 @@ final class SyncService: ObservableObject { } private func deduplicatedAddresses(_ addresses: [String]) -> [String] { + deduplicatedStrings(addresses) + } + + private func deduplicatedStrings(_ values: [String]) -> [String] { var seen = Set<String>() - return addresses + return values .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } .filter { seen.insert($0).inserted } @@ -4813,7 +4890,7 @@ final class SyncService: ObservableObject { domain: "ADE", code: 24, userInfo: [ - NSLocalizedDescriptionKey: "No ADE host address is available. Scan the pairing QR again or enter the host address manually.", + NSLocalizedDescriptionKey: "No ADE machine address is available. Choose a discovered machine or enter the runtime address manually.", ] ) } @@ -5036,7 +5113,7 @@ final class SyncService: ObservableObject { ) guard !addresses.isEmpty else { if rawAddresses.isEmpty { - throw NSError(domain: "ADE", code: 18, userInfo: [NSLocalizedDescriptionKey: "No saved address is available for this host."]) + throw NSError(domain: "ADE", code: 18, userInfo: [NSLocalizedDescriptionKey: "No saved address is available for this machine."]) } throw noConnectableAddressError() } @@ -5086,7 +5163,7 @@ final class SyncService: ObservableObject { } } - throw lastFailure ?? NSError(domain: "ADE", code: 19, userInfo: [NSLocalizedDescriptionKey: "Unable to reach the saved ADE host."]) + throw lastFailure ?? NSError(domain: "ADE", code: 19, userInfo: [NSLocalizedDescriptionKey: "Unable to reach the saved ADE machine."]) } private func handleReconnectFailure( @@ -5154,6 +5231,32 @@ final class SyncService: ObservableObject { return NSError(domain: nsError.domain, code: nsError.code, userInfo: userInfo) } + private func profileByApplyingDiscoveredRoutes( + _ profile: HostConnectionProfile, + matching: [DiscoveredSyncHost] + ) -> HostConnectionProfile { + var next = profile + let liveLanAddresses = deduplicatedAddresses(matching.flatMap(\.addresses)) + let liveTailscaleAddress = matching.compactMap(\.tailscaleAddress).first ?? profile.tailscaleAddress + next.discoveredLanAddresses = liveLanAddresses + next.tailscaleAddress = liveTailscaleAddress + next.savedAddressCandidates = Array( + deduplicatedAddresses( + (profile.lastSuccessfulAddress.map { [$0] } ?? []) + + liveLanAddresses + + (liveTailscaleAddress.map { [$0] } ?? []) + + profile.savedAddressCandidates + ).prefix(6) + ) + if next.hostIdentity == nil { + next.hostIdentity = matching.compactMap(\.hostIdentity).first + } + if next.hostName == nil { + next.hostName = matching.first?.hostName + } + return next + } + private func applyDiscoveredHosts(_ hosts: [DiscoveredSyncHost]) { var mergedByIdentity: [String: DiscoveredSyncHost] = [:] var noIdentity: [DiscoveredSyncHost] = [] @@ -5177,6 +5280,11 @@ final class SyncService: ObservableObject { port: port, addresses: addresses, tailscaleAddress: tailscale, + runtimeKind: preferred.runtimeKind ?? fallback.runtimeKind, + runtimeVersion: preferred.runtimeVersion ?? fallback.runtimeVersion, + projectIds: deduplicatedStrings(preferred.projectIds + fallback.projectIds), + projectNames: deduplicatedStrings(preferred.projectNames + fallback.projectNames), + projectCount: preferred.projectCount ?? fallback.projectCount, lastResolvedAt: host.lastResolvedAt > existing.lastResolvedAt ? host.lastResolvedAt : existing.lastResolvedAt ) } else { @@ -5191,29 +5299,14 @@ final class SyncService: ObservableObject { } let merged = identifiedHosts + filteredNoIdentity discoveredHosts = merged.sorted { $0.hostName.localizedCaseInsensitiveCompare($1.hostName) == .orderedAscending } + refreshSavedProfilesFromDiscovery() guard let profile = activeHostProfile else { return } let matching = discoveredHosts.filter { discovered in matchesDiscoveredHost(discovered, profile: profile) } guard !matching.isEmpty else { return } updateProfile { profile in - let liveLanAddresses = deduplicatedAddresses(matching.flatMap(\.addresses)) - let liveTailscaleAddress = matching.compactMap(\.tailscaleAddress).first ?? profile.tailscaleAddress - profile.discoveredLanAddresses = liveLanAddresses - profile.tailscaleAddress = liveTailscaleAddress - profile.savedAddressCandidates = Array( - deduplicatedAddresses( - (profile.lastSuccessfulAddress.map { [$0] } ?? []) - + liveLanAddresses - + (liveTailscaleAddress.map { [$0] } ?? []) - ).prefix(6) - ) - if profile.hostIdentity == nil { - profile.hostIdentity = matching.compactMap(\.hostIdentity).first - } - if profile.hostName == nil { - profile.hostName = matching.first?.hostName - } + profile = profileByApplyingDiscoveredRoutes(profile, matching: matching) } guard autoReconnectAwaitingLiveDiscovery, allowAutoReconnect, @@ -5231,6 +5324,26 @@ final class SyncService: ObservableObject { } } + private func refreshSavedProfilesFromDiscovery() { + var profiles = loadSavedProfilesRaw() + guard !profiles.isEmpty, !discoveredHosts.isEmpty else { return } + var changed = false + for (key, profile) in profiles { + let matching = discoveredHosts.filter { discovered in + matchesDiscoveredHost(discovered, profile: profile) + } + guard !matching.isEmpty else { continue } + let updated = profileByApplyingDiscoveredRoutes(profile, matching: matching) + guard updated != profile else { continue } + profiles.removeValue(forKey: key) + profiles[profileStorageKey(updated) ?? key] = updated + changed = true + } + if changed { + saveSavedProfiles(profiles) + } + } + private func shouldSuppressAnonymousTailnetHost( _ host: DiscoveredSyncHost, identifiedHosts: [DiscoveredSyncHost] @@ -5280,7 +5393,7 @@ final class SyncService: ObservableObject { guard let urlString = syncWebSocketURLString(host: socketHost, port: socketPort), let url = URL(string: urlString) else { - throw NSError(domain: "ADE", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid host address."]) + throw NSError(domain: "ADE", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid machine address."]) } let task = socketSession.webSocketTask(with: url) socket = task @@ -5427,6 +5540,18 @@ final class SyncService: ObservableObject { let brain = payload["brain"] as? [String: Any] let remoteHostIdentity = brain?["deviceId"] as? String let remoteHostName = brain?["deviceName"] as? String + let incomingHostIdentity = syncNormalizedCommandScopeValue(remoteHostIdentity) + ?? syncNormalizedCommandScopeValue(expectedHostIdentity) + if activeProjectId != nil, + let incomingHostIdentity, + ( + syncNormalizedCommandScopeValue(activeProjectHostIdentity) + ?? syncNormalizedCommandScopeValue(activeHostProfile?.hostIdentity) + ?? syncNormalizedCommandScopeValue(activeHostProfile?.lastHostDeviceId) + ) != incomingHostIdentity { + setActiveProjectId(nil) + projectHomePresented = true + } if let expectedHostIdentity, let remoteHostIdentity, expectedHostIdentity != remoteHostIdentity { disconnect(clearCredentials: false, suspendAutoReconnect: false) remoteProjectCatalog = [] @@ -5434,7 +5559,7 @@ final class SyncService: ObservableObject { throw NSError( domain: "ADE", code: 20, - userInfo: [NSLocalizedDescriptionKey: "The saved pairing belongs to a different ADE host. Pair again with the current host."] + userInfo: [NSLocalizedDescriptionKey: "The saved pairing belongs to a different ADE machine. Pair again with the current machine."] ) } @@ -5621,7 +5746,7 @@ final class SyncService: ObservableObject { failure = NSError( domain: "ADE", code: 24, - userInfo: [NSLocalizedDescriptionKey: "The host stopped responding. Reconnecting now."] + userInfo: [NSLocalizedDescriptionKey: "The machine stopped responding. Reconnecting now."] ) } else { failure = error @@ -5886,14 +6011,14 @@ final class SyncService: ObservableObject { return } guard pending.retryCount < maxChangesetAckRetries else { - failPendingOutboundChangeset("The desktop stopped accepting phone changes. Reconnecting now.") + failPendingOutboundChangeset("The machine stopped accepting phone changes. Reconnecting now.") return } pending.retryCount += 1 pending.sentAt = ProcessInfo.processInfo.systemUptime pendingOutboundChangeset = pending persistPendingOutboundChangesetForActiveProject(pending) - lastError = ack.error?.message ?? "The desktop could not apply the latest phone changes." + lastError = ack.error?.message ?? "The machine could not apply the latest phone changes." } private func sendOutboundChangeset(_ pending: PendingOutboundChangeset) { @@ -5915,7 +6040,7 @@ final class SyncService: ObservableObject { } if now - pending.sentAt >= 10 { guard pending.retryCount < maxChangesetAckRetries else { - failPendingOutboundChangeset("The desktop did not acknowledge phone changes in time. Reconnecting now.") + failPendingOutboundChangeset("The machine did not acknowledge phone changes in time. Reconnecting now.") return } pending.sentAt = now @@ -6036,6 +6161,9 @@ final class SyncService: ObservableObject { if let requestId, !requestId.isEmpty { envelope["requestId"] = requestId } + if let projectId = syncOutboundEnvelopeProjectId(type: type, activeProjectId: activeProjectId) { + envelope["projectId"] = projectId + } guard let data = try? adeJSONData(withJSONObject: envelope), let text = String(data: data, encoding: .utf8) @@ -6067,7 +6195,7 @@ final class SyncService: ObservableObject { NSError( domain: "ADE", code: 25, - userInfo: [NSLocalizedDescriptionKey: "Timed out waiting for the host connection to open."] + userInfo: [NSLocalizedDescriptionKey: "Timed out waiting for the machine connection to open."] ) ) ) @@ -6177,7 +6305,7 @@ final class SyncService: ObservableObject { let resolvedError: NSError if disconnectOnTimeout { resolvedError = SyncRequestTimeout.error( - message: "The host took too long to respond. Try again." + message: "The machine took too long to respond. Try again." ) } else { resolvedError = timeoutError @@ -6323,7 +6451,7 @@ final class SyncService: ObservableObject { switch operation.kind { case "command": guard commandPolicy(for: operation.action) != nil else { - throw NSError(domain: "ADE", code: 16, userInfo: [NSLocalizedDescriptionKey: "Queued action \(operation.action) is no longer available on this host."]) + throw NSError(domain: "ADE", code: 16, userInfo: [NSLocalizedDescriptionKey: "Queued action \(operation.action) is no longer available on this machine."]) } _ = try await performCommandRequest(action: operation.action, args: args, commandId: operation.id) case "file": @@ -6358,7 +6486,7 @@ final class SyncService: ObservableObject { timeoutMessage: String = SyncRequestTimeout.message ) async throws -> Any { guard canSendLiveRequests() else { - throw NSError(domain: "ADE", code: 14, userInfo: [NSLocalizedDescriptionKey: "The host is offline."]) + throw NSError(domain: "ADE", code: 14, userInfo: [NSLocalizedDescriptionKey: "The machine is offline."]) } let requestId = commandId ?? makeRequestId() let raw = try await awaitResponse( @@ -6366,11 +6494,17 @@ final class SyncService: ObservableObject { disconnectOnTimeout: disconnectOnTimeout, timeoutMessage: timeoutMessage ) { - self.sendEnvelope(type: "command", requestId: requestId, payload: [ - "commandId": requestId, - "action": action, - "args": args, - ]) + self.sendEnvelope( + type: "command", + requestId: requestId, + payload: syncCommandEnvelopePayload( + commandId: requestId, + action: action, + args: args, + projectId: self.activeProjectId, + projectRootPath: self.activeProjectRootPath + ) + ) } return try unwrapSyncCommandResponse(raw) } @@ -6391,10 +6525,10 @@ final class SyncService: ObservableObject { } } guard let policy = commandPolicy(for: action) else { - throw NSError(domain: "ADE", code: 15, userInfo: [NSLocalizedDescriptionKey: "This action is not available for the current host. Reconnect to refresh lane capabilities."]) + throw NSError(domain: "ADE", code: 15, userInfo: [NSLocalizedDescriptionKey: "This action is not available for the current machine. Reconnect to refresh lane capabilities."]) } guard policy.queueable == true else { - throw NSError(domain: "ADE", code: 15, userInfo: [NSLocalizedDescriptionKey: "This action requires a live connection to the host."]) + throw NSError(domain: "ADE", code: 15, userInfo: [NSLocalizedDescriptionKey: "This action requires a live connection to the machine."]) } try enqueueOperation(kind: "command", action: action, args: args) return ["queued": true] @@ -6581,7 +6715,16 @@ final class SyncService: ObservableObject { currentProjectId: { guard let activeProjectId = self.activeProjectId else { let cachedProjects = self.database.listMobileProjects() - return cachedProjects.count == 1 ? cachedProjects[0].id : nil + guard cachedProjects.count == 1, let onlyProject = cachedProjects.first else { + return nil + } + if self.supportsProjectCatalog { + guard self.remoteProjectCatalog.count == 1, + self.remoteProjectCatalog.first?.id == onlyProject.id else { + return nil + } + } + return onlyProject.id } return self.database.hasProject(id: activeProjectId) ? activeProjectId : nil }, @@ -6605,6 +6748,13 @@ final class SyncService: ObservableObject { if activeProjectId == nil { let cachedProjects = database.listMobileProjects() if cachedProjects.count == 1, let onlyProject = cachedProjects.first { + if supportsProjectCatalog { + guard remoteProjectCatalog.count == 1, + remoteProjectCatalog.first?.id == onlyProject.id else { + refreshProjectCatalog() + return + } + } setActiveProjectId(onlyProject.id, rootPath: onlyProject.rootPath) } else { refreshProjectCatalog() @@ -6651,7 +6801,7 @@ final class SyncService: ObservableObject { private func performFileRequest(action: String, args: [String: Any]) async throws -> Any { guard canSendLiveRequests() else { - throw NSError(domain: "ADE", code: 16, userInfo: [NSLocalizedDescriptionKey: "The host is offline."]) + throw NSError(domain: "ADE", code: 16, userInfo: [NSLocalizedDescriptionKey: "The machine is offline."]) } let requestId = makeRequestId() let raw = try await awaitResponse(requestId: requestId) { @@ -6675,7 +6825,7 @@ final class SyncService: ObservableObject { return try await performFileRequest(action: action, args: args) } guard queueableFileActions.contains(action) else { - throw NSError(domain: "ADE", code: 17, userInfo: [NSLocalizedDescriptionKey: "This file action requires a live connection to the host."]) + throw NSError(domain: "ADE", code: 17, userInfo: [NSLocalizedDescriptionKey: "This file action requires a live connection to the machine."]) } try enqueueOperation(kind: "file", action: action, args: args) return ["queued": true] @@ -7171,6 +7321,11 @@ private final class SyncTailnetProbe { port: port, addresses: isTailnetRoute ? [] : [routeHost], tailscaleAddress: isTailnetRoute ? routeHost : nil, + runtimeKind: nil, + runtimeVersion: nil, + projectIds: [], + projectNames: [], + projectCount: nil, lastResolvedAt: ISO8601DateFormatter().string(from: Date()) ) break @@ -7214,6 +7369,81 @@ private final class SyncTailnetProbe { } } +func syncDiscoveredHostFromBonjour( + serviceKey: String, + serviceName: String, + serviceHostName: String?, + servicePort: Int, + txtRecord: [String: String], + resolvedAddresses: [String], + lastResolvedAt: String = ISO8601DateFormatter().string(from: Date()) +) -> DiscoveredSyncHost { + let preferredHost = txtRecord["host"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let announcedAddresses = txtRecord["addresses"]? + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } ?? [] + let addresses = ([preferredHost] + .compactMap { $0 } + .filter { !$0.isEmpty }) + + resolvedAddresses.filter { !$0.isEmpty } + + announcedAddresses + let port = servicePort > 0 ? servicePort : Int(txtRecord["port"] ?? "") ?? 8787 + let hostName = [txtRecord["deviceName"], serviceHostName, serviceName] + .compactMap(syncNormalizedCommandScopeValue) + .first ?? serviceName + let hostIdentity = syncNormalizedCommandScopeValue(txtRecord["deviceId"]) + let runtimeKind = txtRecord["runtimeKind"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let runtimeVersion = txtRecord["runtimeVersion"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let projectIds = txtRecord["projects"]? + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } ?? [] + let projectNames = txtRecord["projectNames"]? + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } ?? [] + let projectCount = txtRecord["projectCount"].flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) } + let tailscaleDnsName = txtRecord["tailscaleDnsName"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let tailscaleIp = txtRecord["tailscaleIp"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let tailscaleAddress = [tailscaleDnsName, tailscaleIp] + .compactMap { value -> String? in + guard let value, !value.isEmpty, syncIsTailscaleRoute(value) else { return nil } + return value + } + .first + let id: String + if let hostIdentity, !hostIdentity.isEmpty { + id = "\(hostIdentity)::\(serviceKey)" + } else { + id = serviceKey + } + var seen = Set<String>() + var ordered: [String] = [] + for host in addresses where seen.insert(host).inserted { + ordered.append(host) + } + let isLoopback = { (host: String) -> Bool in host == "127.0.0.1" || host == "::1" } + let nonLoopback = ordered.filter { !isLoopback($0) } + let loopback = ordered.filter(isLoopback) + return DiscoveredSyncHost( + id: id, + serviceName: serviceName, + hostName: hostName, + hostIdentity: hostIdentity, + port: port, + addresses: nonLoopback + loopback, + tailscaleAddress: tailscaleAddress, + runtimeKind: runtimeKind?.isEmpty == false ? runtimeKind : nil, + runtimeVersion: runtimeVersion?.isEmpty == false ? runtimeVersion : nil, + projectIds: projectIds, + projectNames: projectNames, + projectCount: projectCount, + lastResolvedAt: lastResolvedAt + ) +} + private final class SyncBonjourBrowser: NSObject, NetServiceBrowserDelegate, NetServiceDelegate { var onHostsChanged: (([DiscoveredSyncHost]) -> Void)? @@ -7415,59 +7645,17 @@ private final class SyncBonjourBrowser: NSObject, NetServiceBrowserDelegate, Net private func makeHost(from service: NetService) -> DiscoveredSyncHost? { let txtRecord = decodedTxtRecord(from: service) - let preferredHost = txtRecord["host"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - let announcedAddresses = txtRecord["addresses"]? - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? [] let resolvedAddresses = service.addresses? .compactMap(parseHost(from:)) .filter { !$0.isEmpty } ?? [] - let addresses = ([preferredHost] - .compactMap { $0 } - .filter { !$0.isEmpty }) - + resolvedAddresses - + announcedAddresses - let port = service.port > 0 ? service.port : Int(txtRecord["port"] ?? "") ?? 8787 - let hostName = txtRecord["deviceName"] ?? service.hostName ?? service.name - let hostIdentity = txtRecord["deviceId"] - let tailscaleDnsName = txtRecord["tailscaleDnsName"]?.trimmingCharacters(in: .whitespacesAndNewlines) - let tailscaleIp = txtRecord["tailscaleIp"]?.trimmingCharacters(in: .whitespacesAndNewlines) - let tailscaleAddress = [tailscaleDnsName, tailscaleIp] - .compactMap { value -> String? in - guard let value, !value.isEmpty, syncIsTailscaleRoute(value) else { return nil } - return value - } - .first let sk = serviceKey(for: service) - // Stable unique row id for SwiftUI: same `deviceId` can appear on multiple Bonjour rows. - let id: String - if let hostIdentity, !hostIdentity.isEmpty { - id = "\(hostIdentity)::\(sk)" - } else { - id = sk - } - // Preserve source order (TXT-preferred first, resolved next), dedup, and - // force any loopback candidate to the tail — a simulator sharing the host's - // loopback can use it, but a physical device would waste a roundtrip if it - // tried 127.0.0.1 first. - var seen = Set<String>() - var ordered: [String] = [] - for host in addresses where seen.insert(host).inserted { - ordered.append(host) - } - let isLoopback = { (host: String) -> Bool in host == "127.0.0.1" || host == "::1" } - let nonLoopback = ordered.filter { !isLoopback($0) } - let loopback = ordered.filter(isLoopback) - return DiscoveredSyncHost( - id: id, + return syncDiscoveredHostFromBonjour( + serviceKey: sk, serviceName: service.name, - hostName: hostName, - hostIdentity: hostIdentity, - port: port, - addresses: nonLoopback + loopback, - tailscaleAddress: tailscaleAddress, - lastResolvedAt: ISO8601DateFormatter().string(from: Date()) + serviceHostName: service.hostName, + servicePort: service.port, + txtRecord: txtRecord, + resolvedAddresses: resolvedAddresses ) } diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index a3bc6aa68..548c80a85 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -375,7 +375,7 @@ final class ADEImageCache { throw NSError( domain: "ADE", code: 301, - userInfo: [NSLocalizedDescriptionKey: "The host returned an unreadable image preview."] + userInfo: [NSLocalizedDescriptionKey: "The machine returned an unreadable image preview."] ) } @@ -447,7 +447,7 @@ struct ADEStatusPill: View { } } -/// Single source of truth for the "computer connection" presentation +/// Single source of truth for the "machine connection" presentation /// (status-dot tint, glow, accessibility label, truncated host name). /// /// The view-model is computed from the same inputs the underlying views read @@ -520,7 +520,7 @@ struct ConnectionHealthPresentation { case .connected: if let name = truncatedHostName { if health.load == .strained { - return "Connected to \(name). Host is responding slowly" + return "Connected to \(name). Machine is responding slowly" } if connectionState == .syncing { return "Connected to \(name). Syncing changes" @@ -528,18 +528,18 @@ struct ConnectionHealthPresentation { return "Connected to \(name)" } if health.load == .strained { - return "Connected. Host is responding slowly" + return "Connected. Machine is responding slowly" } if connectionState == .syncing { return "Connected. Syncing changes" } return "Connected" case .connecting: - return "Connecting to host" + return "Connecting to machine" case .unreachable: return "Connection error\(errorSuffix)" case .disconnected: - return "Disconnected from host" + return "Disconnected from machine" } } } @@ -565,7 +565,7 @@ struct ADEConnectionDot: View { var body: some View { Button(action: openSettings) { Label { - Text("Computer connection") + Text("Machine connection") } icon: { PrsGlassDisc(tint: tint, isAlive: showsConnectedGlow) { Image(systemName: "laptopcomputer") @@ -578,13 +578,13 @@ struct ADEConnectionDot: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel("Computer connection · \(accessibilityLabel)") - .accessibilityHint("Opens computer connection settings.") + .accessibilityLabel("Machine connection · \(accessibilityLabel)") + .accessibilityHint("Opens machine connection settings.") .accessibilityShowsLargeContentViewer() .adeInspectable( "Root.Toolbar.ConnectionButton", metadata: [ - "label": "Computer connection · \(accessibilityLabel)", + "label": "Machine connection · \(accessibilityLabel)", "role": "button" ] ) @@ -596,7 +596,7 @@ struct ADEConnectionDot: View { fileprivate var iconTint: Color { tint } fileprivate var isAlive: Bool { showsConnectedGlow } - fileprivate var a11yLabel: String { "Computer connection · \(accessibilityLabel)" } + fileprivate var a11yLabel: String { "Machine connection · \(accessibilityLabel)" } } struct ADEProjectHomeButton: View { @@ -635,7 +635,7 @@ struct ADEProjectHomeButton: View { } } -/// Root toolbar control cluster: computer connection, project switching, and +/// Root toolbar control cluster: machine connection, project switching, and /// attention bell collapsed into one floating liquid-glass capsule so the PRs /// (and every root tab) top-bar reads as a single glass chip rather than three /// disjointed discs. @@ -669,7 +669,7 @@ struct ADERootToolbarControls: View { private var connectionTint: Color { presentation.tint } private var connectionIsAlive: Bool { presentation.showsConnectedGlow } private var connectionAccessibilityLabel: String { - "Computer connection · \(presentation.accessibilityLabel)" + "Machine connection · \(presentation.accessibilityLabel)" } private var hasUnread: Bool { drawer.unreadCount > 0 } diff --git a/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift b/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift index 444dcb189..40dc3a394 100644 --- a/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift @@ -84,8 +84,8 @@ struct CtoSessionDestinationView: View { if syncService.connectionState.isHostUnreachable { ADEEmptyStateView( symbol: "wifi.slash", - title: "Connect your Mac to open this chat", - message: "Tap the settings gear in the top right to reconnect to your desktop host." + title: "Connect your ADE machine to open this chat", + message: "Tap the settings gear in the top right to reconnect to your ADE machine." ) .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) diff --git a/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift b/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift index c4bd8a438..9b19914ba 100644 --- a/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift @@ -12,8 +12,8 @@ struct CtoSettingsScreen: View { @State private var syncNotice: String? @State private var showingIdentityEditor = false @State private var showingBriefEditor = false - @State private var showingDesktopOnlySheet = false - @State private var desktopOnlyTitle: String = "" + @State private var showingMachineOnlySheet = false + @State private var machineOnlyTitle: String = "" var body: some View { ScrollView { @@ -69,8 +69,8 @@ struct CtoSettingsScreen: View { self.snapshot = updated } } - .sheet(isPresented: $showingDesktopOnlySheet) { - DesktopOnlySheet(title: desktopOnlyTitle) + .sheet(isPresented: $showingMachineOnlySheet) { + MachineOnlySheet(title: machineOnlyTitle) .presentationDetents([.fraction(0.3), .medium]) } } @@ -271,8 +271,6 @@ struct CtoSettingsScreen: View { .accessibilityLabel("Sync Linear now") } Sep() - IntegrationRow(name: "OpenClaw", subtitle: "—", connected: false) - Sep() IntegrationRow(name: "External MCP", subtitle: "off", connected: false) } .adeListCard(padding: 0) @@ -303,18 +301,18 @@ struct CtoSettingsScreen: View { SectionHeader(title: "Advanced") VStack(spacing: 0) { RowItem(label: "Re-run onboarding", value: "") { - desktopOnlyTitle = "Re-run onboarding" - showingDesktopOnlySheet = true + machineOnlyTitle = "Re-run onboarding" + showingMachineOnlySheet = true } Sep() RowItem(label: "Re-scan project", value: "") { - desktopOnlyTitle = "Re-scan project" - showingDesktopOnlySheet = true + machineOnlyTitle = "Re-scan project" + showingMachineOnlySheet = true } Sep() RowItem(label: "Reset memory", value: "", danger: true) { - desktopOnlyTitle = "Reset memory" - showingDesktopOnlySheet = true + machineOnlyTitle = "Reset memory" + showingMachineOnlySheet = true } } .adeListCard(padding: 0) @@ -322,7 +320,7 @@ struct CtoSettingsScreen: View { } private var linearSubtitle: String { - guard let linearStatus else { return "Manage from desktop" } + guard let linearStatus else { return "Manage in ADE" } if linearStatus.connected { if let name = linearStatus.viewerName, !name.isEmpty { return "Connected · \(name)" } return "Connected" @@ -512,7 +510,7 @@ enum CtoPresetSummary { case "minimal": return "Minimal voice. Short, surgical replies with no filler." case "custom": - return "Custom identity. Configure the system prompt extension from the desktop app." + return "Custom identity. Configure the system prompt extension from ADE on your machine." default: return "Pragmatic senior engineer who holds the mental model so workers don't have to." } @@ -665,9 +663,9 @@ private struct Sep: View { } } -// MARK: - Desktop-only sheet +// MARK: - Machine-only sheet -private struct DesktopOnlySheet: View { +private struct MachineOnlySheet: View { @Environment(\.dismiss) private var dismiss let title: String @@ -677,10 +675,10 @@ private struct DesktopOnlySheet: View { .font(.system(size: 36, weight: .semibold)) .foregroundStyle(ADEColor.accent) .padding(.top, 24) - Text(title.isEmpty ? "Desktop only" : title) + Text(title.isEmpty ? "Manage in ADE" : title) .font(.headline) .foregroundStyle(ADEColor.textPrimary) - Text("Manage from desktop for now.") + Text("Manage this from ADE on your machine for now.") .font(.subheadline) .foregroundStyle(ADEColor.textSecondary) .multilineTextAlignment(.center) diff --git a/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift b/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift index 8af422fd0..695163b9e 100644 --- a/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift @@ -53,7 +53,7 @@ struct CtoTeamScreen: View { ADEEmptyStateView( symbol: "person.crop.circle.badge.questionmark", title: "No workers hired yet", - message: "The persistent CTO is available above. Hire specialized workers from the desktop CTO tab." + message: "The persistent CTO is available above. Hire specialized workers from ADE on your machine." ) .padding(.horizontal, 16) } else { @@ -95,9 +95,9 @@ struct CtoTeamScreen: View { await load(force: true) } .sheet(isPresented: $showHireSheet) { - CtoDesktopOnlyNotice( + CtoMachineOnlyNotice( title: "Hire worker", - message: "Hire worker on the desktop CTO tab — mobile support is coming soon." + message: "Hire workers from ADE on your machine. Mobile support is coming soon." ) .presentationDetents([.fraction(0.3), .medium]) } @@ -131,7 +131,7 @@ struct CtoTeamScreen: View { } .buttonStyle(.plain) .accessibilityLabel("Hire worker") - .accessibilityHint("Opens a sheet explaining hire is desktop-only for now.") + .accessibilityHint("Opens a sheet explaining hire is available from ADE on your machine for now.") } } @@ -591,7 +591,7 @@ private func CtoTeamAsyncResult<T>(_ body: @escaping () async throws -> T) async catch { return .failure(error) } } -struct CtoDesktopOnlyNotice: View { +struct CtoMachineOnlyNotice: View { let title: String let message: String @Environment(\.dismiss) private var dismiss diff --git a/apps/ios/ADE/Views/Cto/CtoWorkerDetailScreen.swift b/apps/ios/ADE/Views/Cto/CtoWorkerDetailScreen.swift index 7e6723c33..36f0afc52 100644 --- a/apps/ios/ADE/Views/Cto/CtoWorkerDetailScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoWorkerDetailScreen.swift @@ -913,10 +913,10 @@ struct CtoWorkerQuickEditSheet: View { Image(systemName: "desktopcomputer") .foregroundStyle(ADEColor.textMuted) VStack(alignment: .leading, spacing: 2) { - Text("More settings on desktop") + Text("More settings in ADE") .font(.subheadline.weight(.medium)) .foregroundStyle(ADEColor.textPrimary) - Text("Budget cap, adapter config, Linear identity, and heartbeat policy are managed from the desktop CTO tab.") + Text("Budget cap, adapter config, Linear identity, and heartbeat policy are managed from ADE on your machine.") .font(.caption) .foregroundStyle(ADEColor.textSecondary) } diff --git a/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift b/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift index 5c68c5a5e..76c20d4dd 100644 --- a/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift @@ -11,7 +11,7 @@ struct CtoWorkflowsScreen: View { @State private var isSyncing = false @State private var errorMessage: String? @State private var syncNotice: String? - @State private var showEditOnDesktop = false + @State private var showEditOnMachine = false var body: some View { List { @@ -70,8 +70,8 @@ struct CtoWorkflowsScreen: View { guard connection == nil else { return } await reload() } - .sheet(isPresented: $showEditOnDesktop) { - EditOnDesktopSheet() + .sheet(isPresented: $showEditOnMachine) { + EditOnMachineSheet() .presentationDetents([.fraction(0.3), .medium]) } } @@ -136,7 +136,7 @@ struct CtoWorkflowsScreen: View { if let policy, !policy.workflows.isEmpty { ForEach(policy.workflows) { workflow in - WorkflowCard(workflow: workflow) { showEditOnDesktop = true } + WorkflowCard(workflow: workflow) { showEditOnMachine = true } .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) @@ -215,7 +215,7 @@ struct CtoWorkflowsScreen: View { .foregroundStyle(ADEColor.textPrimary) Spacer() } - Text("Connect from the desktop CTO Workflows tab.") + Text("Connect from the ADE machine CTO Workflows tab.") .font(.subheadline) .foregroundStyle(ADEColor.textSecondary) .fixedSize(horizontal: false, vertical: true) @@ -531,9 +531,9 @@ private struct EventRow: View { } } -// MARK: - Edit on desktop sheet +// MARK: - Edit on machine sheet -private struct EditOnDesktopSheet: View { +private struct EditOnMachineSheet: View { @Environment(\.dismiss) private var dismiss var body: some View { @@ -542,10 +542,10 @@ private struct EditOnDesktopSheet: View { .font(.system(size: 36, weight: .semibold)) .foregroundStyle(ADEColor.accent) .padding(.top, 24) - Text("Edit on desktop") + Text("Edit on machine") .font(.headline) .foregroundStyle(ADEColor.textPrimary) - Text("Linear workflow authoring is desktop-only for now.") + Text("Linear workflow authoring is available from ADE on your machine.") .font(.subheadline) .foregroundStyle(ADEColor.textSecondary) .multilineTextAlignment(.center) diff --git a/apps/ios/ADE/Views/Files/FilesDetailComponents.swift b/apps/ios/ADE/Views/Files/FilesDetailComponents.swift index 2d031da9b..6430fd920 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailComponents.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailComponents.swift @@ -635,7 +635,7 @@ struct FilesProofArtifactSheet: View { FilesContentFallback( symbol: "doc.badge.gearshape", title: "Preview unavailable", - message: "The host returned proof metadata, but iPhone could not render this artifact inline." + message: "The machine returned proof metadata, but iPhone could not render this artifact inline." ) } } diff --git a/apps/ios/ADE/Views/Files/FilesDetailScreen.swift b/apps/ios/ADE/Views/Files/FilesDetailScreen.swift index 52d226bc6..69193ca55 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailScreen.swift @@ -55,9 +55,9 @@ struct FilesDetailScreen: View { var readOnlyTagline: String { if workspace.laneId != nil { - return "Read-only on iPhone. Preview, diff, and metadata are available here; edit on desktop." + return "Read-only on iPhone. Preview, diff, and metadata are available here; edit on the machine." } - return "Read-only on iPhone. Preview and metadata are available here; edit on desktop." + return "Read-only on iPhone. Preview and metadata are available here; edit on the machine." } var body: some View { @@ -238,13 +238,13 @@ struct FilesDetailScreen: View { FilesContentFallback( symbol: "photo", title: "Image preview pending", - message: "The host returned metadata only. Reconnect to stream the full bytes." + message: "The machine returned metadata only. Reconnect to stream the full bytes." ) } else { FilesContentFallback( symbol: "doc.fill", title: "Binary file", - message: "iPhone keeps this read-only. Use desktop ADE to open with a local tool." + message: "iPhone keeps this read-only. Use ADE on the machine to open with a local tool." ) } } else { @@ -287,7 +287,7 @@ struct FilesDetailScreen: View { FilesContentFallback( symbol: "doc.badge.gearshape", title: "Binary diff", - message: "The host reported a binary diff that cannot be rendered inline." + message: "The machine reported a binary diff that cannot be rendered inline." ) } else if let diff, !filesDiffHasChanges(diff) { FilesContentFallback( diff --git a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift index 4addc21ae..f0a97f6d7 100644 --- a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift +++ b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift @@ -39,7 +39,7 @@ struct FilesDirectoryContentsView: View { ADEEmptyStateView( symbol: parentPath.isEmpty ? "folder" : "folder.badge.minus", title: parentPath.isEmpty ? "Workspace is empty" : "Folder is empty", - message: isLive ? "This directory does not have any files to preview on iPhone yet." : "Reconnect to refresh files from the host." + message: isLive ? "This directory does not have any files to preview on iPhone yet." : "Reconnect to refresh files from the machine." ) } else { ForEach(filesSortedNodes(nodes)) { node in diff --git a/apps/ios/ADE/Views/Files/FilesModels.swift b/apps/ios/ADE/Views/Files/FilesModels.swift index 35e6af874..49ed6f326 100644 --- a/apps/ios/ADE/Views/Files/FilesModels.swift +++ b/apps/ios/ADE/Views/Files/FilesModels.swift @@ -76,7 +76,7 @@ func filesHistoryFallback( if entries.isEmpty { return FilesSectionFallback( title: "No recent history", - message: "The host did not return recent commits for this file yet. Reconnect or refresh to try again." + message: "The machine did not return recent commits for this file yet. Reconnect or refresh to try again." ) } return nil @@ -121,7 +121,7 @@ func filesTextPreviewLimit(blob: SyncFileBlob) -> FilesPreviewLimit? { lineLimit: filesTextPreviewLineLimit, byteLimit: filesTextPreviewByteLimit, title: "Preview paused", - action: "Use desktop ADE or narrow the file before previewing it on iPhone." + action: "Use ADE on your machine or narrow the file before previewing it on iPhone." ) } @@ -133,7 +133,7 @@ func filesDiffPreviewLimit(diff: FileDiff) -> FilesPreviewLimit? { lineLimit: filesDiffPreviewLineLimit, byteLimit: filesDiffPreviewByteLimit, title: "Diff preview paused", - action: "Open the file on desktop or inspect a smaller diff before rendering it on iPhone." + action: "Open the file from ADE on your machine or inspect a smaller diff before rendering it on iPhone." ) } @@ -188,7 +188,7 @@ func filesSearchEmptyMessage(kind: FilesSearchKind, isLive: Bool, needsRepairing if !isLive { return needsRepairing ? "Pair again before using \(label.lowercased())." - : "\(label) needs a live host connection." + : "\(label) needs a live machine connection." } if trimmed.isEmpty { switch kind { diff --git a/apps/ios/ADE/Views/Files/FilesRootScreen.swift b/apps/ios/ADE/Views/Files/FilesRootScreen.swift index 4c92fa20a..5d4786ccb 100644 --- a/apps/ios/ADE/Views/Files/FilesRootScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesRootScreen.swift @@ -103,8 +103,8 @@ struct FilesRootScreen: View { symbol: isDisconnected ? "wifi.slash" : "folder.badge.questionmark", title: isDisconnected ? "Files unavailable" : "No workspaces available", message: isDisconnected - ? "Files need a connected host. Reconnect or pair a host in Settings to browse workspaces." - : "This host does not currently expose any lane-backed workspaces for the mobile Files browser." + ? "Files need a connected machine. Reconnect or pair a machine in Settings to browse workspaces." + : "This machine does not currently expose any lane-backed workspaces for the mobile Files browser." ) { Button(syncService.activeHostProfile == nil ? "Open Settings" : "Refresh Files") { if syncService.activeHostProfile == nil { diff --git a/apps/ios/ADE/Views/Lanes/LaneCommitSheet.swift b/apps/ios/ADE/Views/Lanes/LaneCommitSheet.swift index a0cb3a53c..89d96157b 100644 --- a/apps/ios/ADE/Views/Lanes/LaneCommitSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneCommitSheet.swift @@ -203,9 +203,9 @@ struct LaneCommitSheet: View { .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() if lower.contains("are off") || lower.contains("turned off") { - return "AI commit messages are turned off on the desktop. Open desktop Settings → AI → Commit Messages to enable it." + return "AI commit messages are turned off on your ADE machine. Open ADE Settings → AI → Commit Messages to enable it." } - return "Pick a Commit Messages model on the desktop in Settings → AI → Commit Messages." + return "Pick a Commit Messages model in ADE Settings → AI → Commit Messages." } @ViewBuilder diff --git a/apps/ios/ADE/Views/Lanes/LaneConnectionPresentation.swift b/apps/ios/ADE/Views/Lanes/LaneConnectionPresentation.swift index 2b0fe4900..82fccf57f 100644 --- a/apps/ios/ADE/Views/Lanes/LaneConnectionPresentation.swift +++ b/apps/ios/ADE/Views/Lanes/LaneConnectionPresentation.swift @@ -35,7 +35,7 @@ func laneRootEmptyState( return LaneEmptyStatePresentation( symbol: "exclamationmark.triangle.fill", title: "Lane hydration unavailable", - message: laneStatus.lastError ?? "Retry lane sync or reconnect the host.", + message: laneStatus.lastError ?? "Retry lane sync or reconnect the machine.", actionTitle: "Retry", action: .retry ) @@ -47,8 +47,8 @@ func laneRootEmptyState( symbol: "square.stack.3d.up", title: hasHostProfile ? "Reconnect to load lanes" : "Pair to load lanes", message: hasHostProfile - ? "Reconnect to the host before triaging or creating lanes from iPhone." - : "Pair with a host from Settings to load the current lane graph.", + ? "Reconnect to the machine before triaging or creating lanes from iPhone." + : "Pair with a machine from Settings to load the current lane graph.", actionTitle: offlineAction?.title, action: offlineAction?.action ) @@ -79,7 +79,7 @@ func laneDetailEmptyState( title: hasHostProfile ? "Reconnect for live lane detail" : "Pair to load lane detail", message: hasHostProfile ? "No cached lane detail is available yet. Reconnect to load git status, conflicts, and stack context." - : "Pair with a host from Settings to load lane detail on iPhone.", + : "Pair with a machine from Settings to load lane detail on iPhone.", actionTitle: offlineAction?.title, action: offlineAction?.action ) @@ -107,8 +107,8 @@ func laneLiveActionNotice( symbol: hasHostProfile ? "wifi.slash" : "link.badge.plus", title: hasHostProfile ? "Cached lanes shown" : "Pair to run lane actions", message: hasHostProfile - ? "Reconnect to desktop before creating, editing, rebasing, pushing, or archiving lanes from iPhone." - : "Pair with a desktop host before creating, editing, rebasing, pushing, or archiving lanes from iPhone.", + ? "Reconnect to machine before creating, editing, rebasing, pushing, or archiving lanes from iPhone." + : "Pair with a machine before creating, editing, rebasing, pushing, or archiving lanes from iPhone.", actionTitle: offlineAction?.title, action: offlineAction?.action ) @@ -118,7 +118,7 @@ func laneLiveActionNotice( return LaneEmptyStatePresentation( symbol: "arrow.triangle.2.circlepath", title: "Waiting for live lane actions", - message: "Cached lanes are visible now. Lane actions unlock after desktop connection and lane sync are ready.", + message: "Cached lanes are visible now. Lane actions unlock after machine connection and lane sync are ready.", actionTitle: "Retry", action: .retry ) @@ -137,5 +137,5 @@ private func laneOfflineAction( if hasHostProfile { return ("Reconnect", .reconnect) } - return ("Pair with host", .openSettings) + return ("Pair with machine", .openSettings) } diff --git a/apps/ios/ADE/Views/Lanes/LaneCreateSheet.swift b/apps/ios/ADE/Views/Lanes/LaneCreateSheet.swift index 1f6ae2eaa..eab3e5b9c 100644 --- a/apps/ios/ADE/Views/Lanes/LaneCreateSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneCreateSheet.swift @@ -232,7 +232,7 @@ struct LaneCreateSheet: View { if let notice = queuedNotice { ADENoticeCard( - title: "Queued on host", + title: "Queued on machine", message: notice, icon: "arrow.trianglehead.2.clockwise.rotate.90", tint: ADEColor.warning, diff --git a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift index 5ca755b5a..2f9edd6f4 100644 --- a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift +++ b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift @@ -113,9 +113,9 @@ func laneListEmptyStateMessage(scope: LaneListScope, searchText: String, hasFilt return "Try clearing the current filters." } switch scope { - case .active: return "Create a new lane or connect to a host." + case .active: return "Create a new lane or connect to a machine." case .archived: return "Archived lanes will appear here." - case .all: return "No lanes yet. Create a lane or connect to a host." + case .all: return "No lanes yet. Create a lane or connect to a machine." } } @@ -340,6 +340,6 @@ func conflictSummary(_ status: ConflictStatus) -> String { case "merge-ready": return "Conflict prediction clear. Merge-ready." default: - return "Conflict status available from host." + return "Conflict status available from machine." } } diff --git a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift index cfa919eb9..5498bc6b7 100644 --- a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift @@ -273,7 +273,7 @@ struct LaneManageSheet: View { private func performAction(_ label: String, operation: () async throws -> Void) async { guard canRunLiveActions else { ADEHaptics.warning() - errorMessage = "Reconnect to desktop before you \(label)." + errorMessage = "Reconnect to machine before you \(label)." return } do { diff --git a/apps/ios/ADE/Views/Lanes/LanesOfflineEmptyState.swift b/apps/ios/ADE/Views/Lanes/LanesOfflineEmptyState.swift index 323efa185..a43f2a3ac 100644 --- a/apps/ios/ADE/Views/Lanes/LanesOfflineEmptyState.swift +++ b/apps/ios/ADE/Views/Lanes/LanesOfflineEmptyState.swift @@ -18,7 +18,7 @@ struct LanesOfflineEmptyState: View { Text("Not connected") .font(.title3.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) - Text("Connect to a host to see your lanes") + Text("Connect to a machine to see your lanes") .font(.subheadline) .foregroundStyle(ADEColor.textSecondary) .multilineTextAlignment(.center) @@ -27,7 +27,7 @@ struct LanesOfflineEmptyState: View { Button { syncService.settingsPresented = true } label: { - Text("Connect to host") + Text("Connect to machine") .font(.subheadline.weight(.semibold)) .padding(.horizontal, 18) .padding(.vertical, 10) @@ -42,6 +42,6 @@ struct LanesOfflineEmptyState: View { .padding(.horizontal, 32) .adeScreenBackground() .accessibilityElement(children: .combine) - .accessibilityLabel("Not connected. Tap Connect to host to open settings.") + .accessibilityLabel("Not connected. Tap Connect to machine to open settings.") } } diff --git a/apps/ios/ADE/Views/LanesTabView.swift b/apps/ios/ADE/Views/LanesTabView.swift index b18f193e1..a5fb240d2 100644 --- a/apps/ios/ADE/Views/LanesTabView.swift +++ b/apps/ios/ADE/Views/LanesTabView.swift @@ -249,7 +249,7 @@ struct LanesTabView: View { .buttonStyle(.plain) .opacity(canRunLiveActions ? 1 : 0.55) .accessibilityLabel("Add lane") - .accessibilityHint(canRunLiveActions ? "Opens lane creation options" : "Reconnect to desktop before creating lanes") + .accessibilityHint(canRunLiveActions ? "Opens lane creation options" : "Reconnect to machine before creating lanes") } @ViewBuilder diff --git a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift index 148181132..191b8b850 100644 --- a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift +++ b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift @@ -789,7 +789,7 @@ struct CreatePrWizardView: View { private var finalReviewSection: some View { VStack(spacing: 0) { PrSectionHdr(title: "Final review") { - PrMonoText(text: "host action", color: ADEColor.warning, size: 10) + PrMonoText(text: "machine action", color: ADEColor.warning, size: 10) } VStack(spacing: 0) { let steps = buildNextSteps() diff --git a/apps/ios/ADE/Views/PRs/PrDetailActivityTab.swift b/apps/ios/ADE/Views/PRs/PrDetailActivityTab.swift index 2323e60a4..1f20c69be 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailActivityTab.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailActivityTab.swift @@ -226,7 +226,7 @@ struct PrActivityTab: View { ADEEmptyStateView( symbol: "bubble.left.and.bubble.right", title: "No reviews yet", - message: "Review threads and reviewer responses will appear here once the host syncs them." + message: "Review threads and reviewer responses will appear here once the machine syncs them." ) } @@ -246,7 +246,7 @@ struct PrActivityTab: View { ) if !canAddComment { - Text("Posting comments requires a host that exposes PR comment actions to mobile.") + Text("Posting comments requires a machine that exposes PR comment actions to mobile.") .font(.caption) .foregroundStyle(ADEColor.textSecondary) } diff --git a/apps/ios/ADE/Views/PRs/PrDetailChecksTab.swift b/apps/ios/ADE/Views/PRs/PrDetailChecksTab.swift index b334dc906..57fa154c7 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailChecksTab.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailChecksTab.swift @@ -121,7 +121,7 @@ struct PrChecksTab: View { } if !canRerunChecks { - Text("This host has not exposed PR check reruns to the mobile sync channel yet.") + Text("This machine has not exposed PR check reruns to the mobile sync channel yet.") .font(.caption) .foregroundStyle(ADEColor.textSecondary) } diff --git a/apps/ios/ADE/Views/PRs/PrDetailFilesTab.swift b/apps/ios/ADE/Views/PRs/PrDetailFilesTab.swift index 7d732001a..4bec1d65b 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailFilesTab.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailFilesTab.swift @@ -37,7 +37,7 @@ struct PrFilesTab: View { ADEEmptyStateView( symbol: "doc.text.magnifyingglass", title: "No changed files", - message: "The host has not synced any file diff data for this PR yet." + message: "The machine has not synced any file diff data for this PR yet." ) } else { LazyVStack(spacing: 14) { diff --git a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift index 1d75e33de..60c3945f6 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift @@ -1771,7 +1771,7 @@ private struct PrMergeStrategySheet: View { .tracking(-0.2) Text(canAttemptBlockedMerge ? "ADE sees merge blockers, but this will still ask GitHub to merge. GitHub may reject unless your account can bypass requirements." - : "Host rules may override your choice. All checks will be verified before merging.") + : "Machine rules may override your choice. All checks will be verified before merging.") .font(.system(size: 11)) .foregroundStyle(Color(red: 0xA8 / 255, green: 0xA8 / 255, blue: 0xB4 / 255)) .fixedSize(horizontal: false, vertical: true) @@ -1860,7 +1860,7 @@ private struct PrCleanupConfirmationSheet: View { private var message: String { choice == .archive ? "This keeps the lane for history but removes it from the active stack." - : "This removes the lane from ADE and asks the host to delete the branch as part of cleanup. This cannot be undone." + : "This removes the lane from ADE and asks the machine to delete the branch as part of cleanup. This cannot be undone." } private var confirmTitle: String { choice == .archive ? "Archive" : "Delete" } diff --git a/apps/ios/ADE/Views/PRs/PrMergeGateCard.swift b/apps/ios/ADE/Views/PRs/PrMergeGateCard.swift index 7e0105bd8..af031455c 100644 --- a/apps/ios/ADE/Views/PRs/PrMergeGateCard.swift +++ b/apps/ios/ADE/Views/PRs/PrMergeGateCard.swift @@ -216,7 +216,7 @@ func prComputeMergeGate( if let blockedReason, !blockedReason.isEmpty, parts.isEmpty { parts.append(blockedReason) } - let subline = parts.isEmpty ? (blockedReason ?? "Merge blocked by host") : parts.joined(separator: " · ") + let subline = parts.isEmpty ? (blockedReason ?? "Merge blocked by machine") : parts.joined(separator: " · ") let target: PrMergeGateTarget = (failing > 0 || conflicts) ? .checks : .reviews return PrMergeGateInfo(tone: .red, subline: subline, target: target) } diff --git a/apps/ios/ADE/Views/PRs/PrRebaseScreen.swift b/apps/ios/ADE/Views/PRs/PrRebaseScreen.swift index 0d32f560b..656182953 100644 --- a/apps/ios/ADE/Views/PRs/PrRebaseScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrRebaseScreen.swift @@ -292,7 +292,7 @@ struct PrRebaseScreen: View { } } } else { - Text("Commit details unavailable on this host.") + Text("Commit details unavailable on this machine.") .font(.system(size: 11, design: .monospaced)) .foregroundStyle(ADEColor.textMuted) .padding(14) diff --git a/apps/ios/ADE/Views/PRs/PrStackSheet.swift b/apps/ios/ADE/Views/PRs/PrStackSheet.swift index e971aa52c..dc7331ee6 100644 --- a/apps/ios/ADE/Views/PRs/PrStackSheet.swift +++ b/apps/ios/ADE/Views/PRs/PrStackSheet.swift @@ -85,8 +85,8 @@ struct PrStackSheet: View { symbol: syncService.connectionState.isHostUnreachable ? "wifi.exclamationmark" : "list.number", title: syncService.connectionState.isHostUnreachable ? "Offline" : "No stack members", message: syncService.connectionState.isHostUnreachable - ? "Reconnect to the desktop host to load this PR stack." - : "The host did not sync any PR chain members for this workflow yet." + ? "Reconnect to the machine to load this PR stack." + : "The machine did not sync any PR chain members for this workflow yet." ) .padding(16) } diff --git a/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift b/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift index a0639893f..5eb19f2e2 100644 --- a/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift +++ b/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift @@ -25,9 +25,9 @@ private enum WorkflowLandingConfirmation { var message: String { switch self { case .activePr: - return "This asks the host to merge the active queue pull request using the selected strategy. GitHub may merge into the target branch if checks pass." + return "This asks the machine to merge the active queue pull request using the selected strategy. GitHub may merge into the target branch if checks pass." case .queueNext: - return "This asks the host to merge the next queued pull request using the selected strategy. GitHub may merge into the target branch if checks pass." + return "This asks the machine to merge the next queued pull request using the selected strategy. GitHub may merge into the target branch if checks pass." } } } @@ -201,7 +201,7 @@ struct QueueWorkflowCard: View { landingConfirmation = nil } } message: { - Text(landingConfirmation?.message ?? "This will ask the host to merge the selected pull request.") + Text(landingConfirmation?.message ?? "This will ask the machine to merge the selected pull request.") } } @@ -327,7 +327,7 @@ struct PrMobileWorkflowCardView: View { landingConfirmation = nil } } message: { - Text(landingConfirmation?.message ?? "This will ask the host to merge the selected pull request.") + Text(landingConfirmation?.message ?? "This will ask the machine to merge the selected pull request.") } } diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index 8cb2f5db3..35fd56544 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift @@ -722,7 +722,7 @@ struct PRsTabView: View { symbol: searchText.isEmpty ? "arrow.triangle.pull" : "magnifyingglass", title: searchText.isEmpty ? "No pull requests for these filters" : "No PRs match this search", message: searchText.isEmpty - ? "Try a different status or scope, or refresh GitHub state from the host." + ? "Try a different status or scope, or refresh GitHub state from the machine." : "Try a broader query or switch the status and scope filters." ) .prListRow() @@ -867,7 +867,7 @@ struct PRsTabView: View { ADEEmptyStateView( symbol: "point.3.filled.connected.trianglepath.dotted", title: "No active PR workflows", - message: "Queue, integration, and rebase work appears here once the host syncs workflow state." + message: "Queue, integration, and rebase work appears here once the machine syncs workflow state." ) .prListRow() } else { @@ -1709,7 +1709,7 @@ private struct PrLaneLinkSheet: View { Image(systemName: "wifi.exclamationmark") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(PrGlassPalette.warning) - Text("Reconnect to a host that supports PR lane linking.") + Text("Reconnect to a machine that supports PR lane linking.") .font(.system(size: 11)) .foregroundStyle(PrGlassPalette.warning) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift index 8f85868fa..02c94513a 100644 --- a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift +++ b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift @@ -20,6 +20,9 @@ struct ConnectionSettingsView: View { .environmentObject(syncService) .padding(.horizontal, 16) + SettingsTailscaleHelpSection() + .padding(.horizontal, 16) + SettingsNotificationsSection( onPreferencesChanged: { prefs in syncService.uploadNotificationPrefs(prefs) @@ -76,14 +79,6 @@ struct ConnectionSettingsView: View { .environmentObject(syncService) .presentationDetents([.medium, .large]) - case .qr: - ScanQRSheet { payload in - presentedSheet = nil - pinPreset = .qr(payload) - } - .environmentObject(syncService) - .presentationDetents([.large]) - case .manual: ManualEntrySheet { host, port in presentedSheet = nil @@ -94,6 +89,39 @@ struct ConnectionSettingsView: View { } } +private struct SettingsTailscaleHelpSection: View { + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 10) { + Image(systemName: "network") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(ADEColor.purpleAccent) + .frame(width: 28, height: 28) + .background(ADEColor.purpleAccent.opacity(0.14), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + VStack(alignment: .leading, spacing: 2) { + Text("Away from home") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("Install Tailscale on this iPhone and your ADE machine. Once both are on the same tailnet, the machine appears here like it does on local Wi-Fi.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.white.opacity(0.045)) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.8) + ) + } +} + private struct SettingsAuroraBackground: View { var body: some View { ZStack { diff --git a/apps/ios/ADE/Views/Settings/NotificationsCenterView.swift b/apps/ios/ADE/Views/Settings/NotificationsCenterView.swift index c343090aa..f95be9b24 100644 --- a/apps/ios/ADE/Views/Settings/NotificationsCenterView.swift +++ b/apps/ios/ADE/Views/Settings/NotificationsCenterView.swift @@ -141,7 +141,7 @@ struct NotificationsCenterView: View { .padding(.top, 20) .padding(.bottom, 24) - Text("Preferences are stored in the shared container and mirrored to your paired Mac.") + Text("Preferences are stored in the shared container and mirrored to your paired machine.") .font(.caption) .foregroundStyle(ADEColor.textSecondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -397,7 +397,7 @@ struct NotificationsCenterView: View { .opacity(canSendTestPush ? 1 : 0.45) .accessibilityHint( canSendTestPush - ? "Ask the paired host to send a test notification to this device" + ? "Ask the paired machine to send a test notification to this device" : "Enable notifications and register this device first" ) } diff --git a/apps/ios/ADE/Views/Settings/PerSessionOverrideView.swift b/apps/ios/ADE/Views/Settings/PerSessionOverrideView.swift index e9d180dee..9bc8ab261 100644 --- a/apps/ios/ADE/Views/Settings/PerSessionOverrideView.swift +++ b/apps/ios/ADE/Views/Settings/PerSessionOverrideView.swift @@ -87,7 +87,7 @@ struct PerSessionOverrideView: View { Text("No active sessions") .font(.subheadline.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) - Text("Sessions appear here once your Mac starts syncing them.") + Text("Sessions appear here once your paired machine starts syncing them.") .font(.caption) .foregroundStyle(ADEColor.textSecondary) .multilineTextAlignment(.center) diff --git a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift index 535b26037..c605c251f 100644 --- a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift +++ b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift @@ -42,7 +42,7 @@ struct SettingsConnectionHeader: View { .foregroundStyle(ADEColor.textSecondary) .fixedSize(horizontal: false, vertical: true) } else { - Text("Pair a computer to start syncing lanes, work, and files.") + Text("Pair a machine to start syncing lanes, work, and files.") .font(.subheadline) .foregroundStyle(ADEColor.textSecondary) .fixedSize(horizontal: false, vertical: true) @@ -136,24 +136,24 @@ struct SettingsConnectionHeader: View { switch health.transport { case .connected: if health.load == .strained { - return "Live · host responding slowly" + return "Live · machine responding slowly" } if syncService.connectionState == .syncing { return "Live · syncing changes" } return "Live · ready to sync" case .connecting: - return "Connecting to saved host" + return "Connecting to saved machine" case .unreachable: - return "Unable to reach your Mac" + return "Unable to reach your machine" case .disconnected: if syncService.savedReconnectHost?.tailscaleAddress != nil { - return "Saved host · Tailscale route ready" + return "Saved machine · Tailscale route ready" } if syncService.canReconnectToSavedHost { - return "Saved host · not connected" + return "Saved machine · not connected" } - return "No paired host" + return "No paired machine" } } @@ -162,7 +162,7 @@ struct SettingsConnectionHeader: View { case .connecting: return "Reaching \(hostName)..." case .unreachable: - return "Tap reconnect to try \(hostName) again, or pair a different host below." + return "Tap reconnect to try \(hostName) again, or pair a different machine below." default: return "Reaching \(hostName)..." } @@ -222,7 +222,7 @@ private struct SettingsConnectionQuickAction: View { ) { syncService.disconnect() } - .accessibilityLabel("Disconnect from host") + .accessibilityLabel("Disconnect from machine") case .connecting: HStack(spacing: 6) { @@ -250,7 +250,7 @@ private struct SettingsConnectionQuickAction: View { ) } } - .accessibilityLabel("Reconnect to saved host") + .accessibilityLabel("Reconnect to saved machine") } } } diff --git a/apps/ios/ADE/Views/Settings/SettingsDiagnosticsSection.swift b/apps/ios/ADE/Views/Settings/SettingsDiagnosticsSection.swift index 8035bf84a..6aff9ef5a 100644 --- a/apps/ios/ADE/Views/Settings/SettingsDiagnosticsSection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsDiagnosticsSection.swift @@ -17,7 +17,7 @@ struct SettingsDiagnosticsSection: View { if let identity = syncService.activeHostProfile?.hostIdentity { SettingsDetailRow( symbol: "desktopcomputer.and.arrow.down", - label: "Paired host", + label: "Paired machine", value: Self.shortIdentity(identity) ) } diff --git a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift index 3f0add102..b5146d74b 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift @@ -1,6 +1,4 @@ import SwiftUI -import UIKit -import VisionKit struct SettingsPairingSection: View { @EnvironmentObject private var syncService: SyncService @@ -9,7 +7,7 @@ struct SettingsPairingSection: View { var body: some View { VStack(alignment: .leading, spacing: 10) { SettingsSectionHeader( - label: "PAIR A COMPUTER", + label: "PAIR A MACHINE", hint: pairingHint ) @@ -23,18 +21,10 @@ struct SettingsPairingSection: View { presentedSheet = .discover } - SettingsPairActionRow( - icon: "qrcode.viewfinder", - title: "Scan pairing QR", - subtitle: "Show on your Mac under Settings → Sync" - ) { - presentedSheet = .qr - } - SettingsPairActionRow( icon: "keyboard", - title: "Enter host details", - subtitle: "Host address and port" + title: "Enter machine details", + subtitle: "Runtime address and port" ) { presentedSheet = .manual } @@ -47,19 +37,19 @@ struct SettingsPairingSection: View { let count = syncService.discoveredHosts.count let savedCount = syncService.savedReconnectHosts.count if count == 0, savedCount > 0 { - return savedCount == 1 ? "1 saved host" : "\(savedCount) saved hosts" + return savedCount == 1 ? "1 saved machine" : "\(savedCount) saved machines" } if count == 0 { return "Looking nearby" } - return count == 1 ? "1 nearby host found" : "\(count) nearby hosts found" + return count == 1 ? "1 nearby machine found" : "\(count) nearby machines found" } private var pairingHint: String? { guard !syncService.savedReconnectHosts.isEmpty else { - return "Pick how to reach your Mac" + return "Pick how to reach your machine" } - return "Add another Mac or switch saved hosts" + return "Add another machine or switch saved machines" } } @@ -212,6 +202,137 @@ struct SettingsPairActionRow: View { // MARK: - Discover hosts sheet +func syncDiscoveredHostsForDisplay( + savedHosts: [DiscoveredSyncHost], + liveHosts: [DiscoveredSyncHost] +) -> (savedHosts: [DiscoveredSyncHost], liveHosts: [DiscoveredSyncHost]) { + let saved = savedHosts.map { savedHost in + guard let liveHost = liveHosts.first(where: { syncDiscoveredHostsReferToSameMachine(savedHost, $0) }) else { + return savedHost + } + return syncMergeSavedDiscoveredHost(savedHost, withLiveHost: liveHost) + } + let live = liveHosts.filter { liveHost in + !savedHosts.contains { savedHost in + syncDiscoveredHostsReferToSameMachine(savedHost, liveHost) + } + } + return (savedHosts: saved, liveHosts: live) +} + +func syncDiscoveredHostDetailText(host: DiscoveredSyncHost, detailPrefix: String?) -> String { + let route = syncDiscoveredHostPrimaryRoute(host: host, detailPrefix: detailPrefix) + let prefix = detailPrefix ?? syncDiscoveredHostInferredRoutePrefix(host: host, route: route) + let routeText = prefix.map { "\($0): \(route)" } ?? route + let projectList = syncDiscoveredHostProjectListText(host: host) + var parts: [String] = [] + if let runtimeText = syncRuntimeText(kind: host.runtimeKind, version: host.runtimeVersion) { + parts.append(runtimeText) + } + if let projectList { + parts.append(projectList) + } else if let projectCount = host.projectCount { + parts.append(projectCount == 1 ? "1 project" : "\(projectCount) projects") + } + parts.append(routeText) + return parts.joined(separator: " · ") +} + +private func syncDiscoveredHostProjectListText(host: DiscoveredSyncHost) -> String? { + let labels = syncUniqueNonEmptyStrings(host.projectNames.isEmpty ? host.projectIds : host.projectNames) + guard !labels.isEmpty else { return nil } + let visible = labels.prefix(3).joined(separator: ", ") + let remaining = labels.count - min(labels.count, 3) + let count = host.projectCount ?? labels.count + let countText = count == 1 ? "1 project" : "\(count) projects" + return remaining > 0 ? "\(countText): \(visible), +\(remaining)" : "\(countText): \(visible)" +} + +private func syncDiscoveredHostsReferToSameMachine( + _ left: DiscoveredSyncHost, + _ right: DiscoveredSyncHost +) -> Bool { + if let leftIdentity = syncTrimmedNonEmpty(left.hostIdentity), + let rightIdentity = syncTrimmedNonEmpty(right.hostIdentity) { + return leftIdentity == rightIdentity + } + return left.id == right.id +} + +private func syncMergeSavedDiscoveredHost( + _ savedHost: DiscoveredSyncHost, + withLiveHost liveHost: DiscoveredSyncHost +) -> DiscoveredSyncHost { + DiscoveredSyncHost( + id: savedHost.id, + serviceName: syncTrimmedNonEmpty(savedHost.serviceName) ?? liveHost.serviceName, + hostName: syncTrimmedNonEmpty(savedHost.hostName) ?? liveHost.hostName, + hostIdentity: syncTrimmedNonEmpty(savedHost.hostIdentity) ?? syncTrimmedNonEmpty(liveHost.hostIdentity), + port: savedHost.port > 0 ? savedHost.port : liveHost.port, + addresses: syncUniqueNonEmptyStrings(savedHost.addresses + liveHost.addresses), + tailscaleAddress: syncTrimmedNonEmpty(savedHost.tailscaleAddress) ?? syncTrimmedNonEmpty(liveHost.tailscaleAddress), + runtimeKind: syncTrimmedNonEmpty(savedHost.runtimeKind) ?? syncTrimmedNonEmpty(liveHost.runtimeKind), + runtimeVersion: syncTrimmedNonEmpty(savedHost.runtimeVersion) ?? syncTrimmedNonEmpty(liveHost.runtimeVersion), + projectIds: syncUniqueNonEmptyStrings(savedHost.projectIds + liveHost.projectIds), + projectNames: syncUniqueNonEmptyStrings(savedHost.projectNames + liveHost.projectNames), + projectCount: savedHost.projectCount ?? liveHost.projectCount, + lastResolvedAt: max(savedHost.lastResolvedAt, liveHost.lastResolvedAt) + ) +} + +private func syncRuntimeText(kind: String?, version: String?) -> String? { + guard let kind = syncTrimmedNonEmpty(kind) else { return nil } + let label: String + switch kind.lowercased() { + case "daemon", "headless": + label = "Background ADE" + case "desktop", "desktop-embedded": + label = "ADE app" + default: + label = "ADE service" + } + guard let version = syncTrimmedNonEmpty(version) else { return label } + return "\(label) \(version)" +} + +private func syncDiscoveredHostPrimaryRoute(host: DiscoveredSyncHost, detailPrefix: String?) -> String { + if let tailscaleAddress = syncTrimmedNonEmpty(host.tailscaleAddress), + detailPrefix?.localizedCaseInsensitiveContains("tailscale") == true { + return tailscaleAddress + } + return host.addresses.first { address in + !syncIsLoopbackAddress(address) && !syncIsTailscaleRoute(address) + } ?? syncTrimmedNonEmpty(host.tailscaleAddress) ?? host.addresses.first ?? "No route" +} + +private func syncDiscoveredHostInferredRoutePrefix(host: DiscoveredSyncHost, route: String) -> String? { + if syncIsTailscaleRoute(route) { + return "Tailscale" + } + if host.tailscaleAddress.map(syncIsTailscaleRoute) == true { + return "LAN + Tailscale" + } + return nil +} + +private func syncTrimmedNonEmpty(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value +} + +private func syncUniqueNonEmptyStrings(_ values: [String]) -> [String] { + var seen = Set<String>() + return values + .compactMap(syncTrimmedNonEmpty) + .filter { seen.insert($0).inserted } +} + +private func syncIsLoopbackAddress(_ address: String) -> Bool { + address == "127.0.0.1" || address == "::1" +} + struct DiscoverHostsSheet: View { @EnvironmentObject private var syncService: SyncService @Environment(\.dismiss) private var dismiss @@ -222,21 +343,18 @@ struct DiscoverHostsSheet: View { NavigationStack { ScrollView { LazyVStack(spacing: 10) { - let savedHosts = syncService.savedReconnectHosts - let liveHosts = syncService.discoveredHosts.filter { host in - !savedHosts.contains { savedHost in - if let hostIdentity = host.hostIdentity, let savedIdentity = savedHost.hostIdentity { - return hostIdentity == savedIdentity - } - return host.id == savedHost.id - } - } + let displayedHosts = syncDiscoveredHostsForDisplay( + savedHosts: syncService.savedReconnectHosts, + liveHosts: syncService.discoveredHosts + ) + let savedHosts = displayedHosts.savedHosts + let liveHosts = displayedHosts.liveHosts if savedHosts.isEmpty && liveHosts.isEmpty { VStack(spacing: 14) { ADESkeletonView(height: 56, cornerRadius: 14) ADESkeletonView(height: 56, cornerRadius: 14) - Text("Looking for ADE hosts on your network...") + Text("Looking for ADE machines on your network...") .font(.caption) .foregroundStyle(ADEColor.textSecondary) .padding(.top, 4) @@ -277,7 +395,7 @@ struct DiscoverHostsSheet: View { } .adeScreenBackground() .adeNavigationGlass() - .navigationTitle("Nearby hosts") + .navigationTitle("Nearby machines") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -308,7 +426,7 @@ private struct DiscoveredHostRow: View { Text(host.hostName) .font(.body.weight(.medium)) .foregroundStyle(ADEColor.textPrimary) - Text(routeText) + Text(detailText) .font(.caption.monospaced()) .foregroundStyle(ADEColor.textSecondary) .lineLimit(1) @@ -341,96 +459,8 @@ private struct DiscoveredHostRow: View { ) } - private var routeText: String { - let route = primaryRoute - let prefix = detailPrefix ?? inferredRoutePrefix(for: route) - guard let prefix else { return route } - return "\(prefix): \(route)" - } - - private var primaryRoute: String { - if let tailscaleAddress = host.tailscaleAddress, - detailPrefix?.localizedCaseInsensitiveContains("tailscale") == true { - return tailscaleAddress - } - return host.addresses.first { address in - !isLoopback(address) && !syncIsTailscaleRoute(address) - } ?? host.tailscaleAddress ?? host.addresses.first ?? "No route" - } - - private func inferredRoutePrefix(for route: String) -> String? { - if syncIsTailscaleRoute(route) { - return "Tailscale" - } - if host.tailscaleAddress.map(syncIsTailscaleRoute) == true { - return "LAN + Tailscale" - } - return nil - } - - private func isLoopback(_ address: String) -> Bool { - address == "127.0.0.1" || address == "::1" - } -} - -// MARK: - Scan QR sheet - -struct ScanQRSheet: View { - @EnvironmentObject private var syncService: SyncService - @Environment(\.dismiss) private var dismiss - - let onDecoded: (SyncPairingQrPayload) -> Void - - @State private var scanError: String? - - var body: some View { - NavigationStack { - Group { - if DataScannerViewController.isSupported && DataScannerViewController.isAvailable { - ZStack(alignment: .bottom) { - PairingQrScannerRepresentable { scannedValue in - handle(scannedValue: scannedValue) - } - .ignoresSafeArea() - - if let scanError { - Text(scanError) - .font(.footnote) - .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(ADEColor.danger.opacity(0.85), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .padding(.horizontal, 24) - .padding(.bottom, 48) - } - } - } else { - ContentUnavailableView( - "Camera scanning unavailable", - systemImage: "camera.metering.unknown", - description: Text("Use Discover or Enter details to pair from this device.") - ) - } - } - .navigationTitle("Scan QR code") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Close") { dismiss() } - } - } - .adeNavigationGlass() - } - } - - private func handle(scannedValue: String) { - do { - let payload = try syncService.decodePairingQrPayload(from: scannedValue) - scanError = nil - onDecoded(payload) - } catch { - scanError = error.localizedDescription - } + private var detailText: String { + syncDiscoveredHostDetailText(host: host, detailPrefix: detailPrefix) } } @@ -448,14 +478,14 @@ struct ManualEntrySheet: View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 14) { - Text("Reach your Mac directly") + Text("Reach your machine directly") .font(.headline) .foregroundStyle(ADEColor.textPrimary) - Text("Use this when your network blocks Bonjour discovery.") + Text("Use a runtime address from ADE sync status or Tailscale.") .font(.caption) .foregroundStyle(ADEColor.textSecondary) - TextField("Host or IP address", text: $host) + TextField("Machine address or IP", text: $host) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.asciiCapable) @@ -488,7 +518,7 @@ struct ManualEntrySheet: View { } .adeScreenBackground() .adeNavigationGlass() - .navigationTitle("Enter host details") + .navigationTitle("Enter machine details") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -517,51 +547,3 @@ private extension View { modifier(ManualEntryFieldModifier()) } } - -// MARK: - QR scanner bridge - -private struct PairingQrScannerRepresentable: UIViewControllerRepresentable { - let onScan: (String) -> Void - - func makeCoordinator() -> Coordinator { - Coordinator(onScan: onScan) - } - - func makeUIViewController(context: Context) -> DataScannerViewController { - let controller = DataScannerViewController( - recognizedDataTypes: [.barcode(symbologies: [.qr])], - qualityLevel: .fast, - recognizesMultipleItems: false, - isHighFrameRateTrackingEnabled: false, - isPinchToZoomEnabled: true, - isGuidanceEnabled: true, - isHighlightingEnabled: false - ) - controller.delegate = context.coordinator - try? controller.startScanning() - return controller - } - - func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {} - - final class Coordinator: NSObject, DataScannerViewControllerDelegate { - private let onScan: (String) -> Void - private var didEmit = false - - init(onScan: @escaping (String) -> Void) { - self.onScan = onScan - } - - func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) { - guard !didEmit else { return } - for item in addedItems { - if case .barcode(let barcode) = item, let payload = barcode.payloadStringValue { - didEmit = true - onScan(payload) - dataScanner.stopScanning() - break - } - } - } - } -} diff --git a/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift b/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift index 5cdbdcdac..2de604fd0 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift @@ -1,5 +1,4 @@ import SwiftUI -import UIKit struct SettingsPinSheet: View { @Environment(\.dismiss) private var dismiss @@ -36,7 +35,7 @@ struct SettingsPinSheet: View { .accessibilityLabel("Pairing PIN") .accessibilityValue(pin.isEmpty ? "No digits entered" : "\(pin.count) of 6 digits entered") - Text("Shown on your Mac under Settings → Sync.") + Text("Shown in ADE Sync settings or by `ade sync pin get`.") .font(.footnote) .foregroundStyle(ADEColor.textSecondary) @@ -137,18 +136,6 @@ struct SettingsPinSheet: View { tailscaleAddress: host.tailscaleAddress ) - case .qr(let payload): - let candidateAddresses = payload.addressCandidates.map(\.host) - await syncService.pairAndConnect( - host: candidateAddresses.first ?? "127.0.0.1", - port: payload.port, - code: code, - hostIdentity: payload.hostIdentity.deviceId, - hostName: payload.hostIdentity.name, - candidateAddresses: candidateAddresses, - tailscaleAddress: payload.addressCandidates.first(where: { $0.kind == "tailscale" })?.host - ) - case .manual(let host, let port): let tailscaleAddress = syncIsTailscaleRoute(host) ? host : nil await syncService.pairAndConnect( diff --git a/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift b/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift index a3e755d6d..4adb1920e 100644 --- a/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift +++ b/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift @@ -2,13 +2,11 @@ import SwiftUI enum SettingsPairSheetRoute: Identifiable { case discover - case qr case manual var id: String { switch self { case .discover: return "discover" - case .qr: return "qr" case .manual: return "manual" } } @@ -16,15 +14,12 @@ enum SettingsPairSheetRoute: Identifiable { enum PinPreset: Identifiable { case discover(DiscoveredSyncHost) - case qr(SyncPairingQrPayload) case manual(host: String, port: Int) var id: String { switch self { case .discover(let host): return "discover-\(host.id)" - case .qr(let payload): - return "qr-\(payload.hostIdentity.deviceId)" case .manual(let host, let port): return "manual-\(host)-\(port)" } @@ -34,8 +29,6 @@ enum PinPreset: Identifiable { switch self { case .discover(let host): return host.hostName - case .qr(let payload): - return payload.hostIdentity.name case .manual(let host, _): return host } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 94a9c2d82..51fb762f5 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -143,10 +143,10 @@ struct WorkChatSessionView: View { var composerFeedback: String? { if sending { - return sendWillQueue ? "Queueing message for desktop..." : "Sending message to host..." + return sendWillQueue ? "Queueing message for machine..." : "Sending message to machine..." } if sendWillQueue { - return "Desktop is reconnecting. Send will queue until it is back." + return "Machine is reconnecting. Send will queue until it is back." } if !canSendMessages { return "Reconnect to send messages." @@ -204,7 +204,7 @@ struct WorkChatSessionView: View { ADEEmptyStateView( symbol: "bubble.left.and.bubble.right", title: "No chat messages yet", - message: isLive ? "Send a message to start streaming the transcript." : "Reconnect to load the latest chat history from the host." + message: isLive ? "Send a message to start streaming the transcript." : "Reconnect to load the latest chat history from the machine." ) } else { if hiddenTimelineCount > 0 { diff --git a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift index 7bbc904b1..36cfa8978 100644 --- a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift +++ b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift @@ -280,14 +280,14 @@ private func workCuratedModelCatalogGroups() -> [WorkModelCatalogGroup] { key: "lmstudio", displayName: "LM Studio", models: [ - WorkModelOption(id: "opencode/lmstudio/auto", displayName: "LM Studio · Auto", tier: .fast, tagline: "Local LM Studio runtime", provider: "lmstudio"), + WorkModelOption(id: "opencode/lmstudio/auto", displayName: "LM Studio · Auto", tier: .fast, tagline: "Local LM Studio provider", provider: "lmstudio"), ] ), WorkModelProvider( key: "ollama", displayName: "Ollama", models: [ - WorkModelOption(id: "opencode/ollama/auto", displayName: "Ollama · Auto", tier: .fast, tagline: "Local Ollama runtime", provider: "ollama"), + WorkModelOption(id: "opencode/ollama/auto", displayName: "Ollama · Auto", tier: .fast, tagline: "Local Ollama provider", provider: "ollama"), ] ) ] @@ -420,7 +420,7 @@ private func workCatalogModelOption( } else { var parts: [String] = [] if model.isDefault { - parts.append("Default on the paired host") + parts.append("Default on the paired machine") } if model.supportsReasoning == true { parts.append("Reasoning") @@ -428,7 +428,7 @@ private func workCatalogModelOption( if model.supportsTools == true { parts.append("Tools") } - tagline = parts.isEmpty ? "Available on the paired host" : parts.joined(separator: " · ") + tagline = parts.isEmpty ? "Available on the paired machine" : parts.joined(separator: " · ") } return WorkModelOption( @@ -754,7 +754,7 @@ private func workDynamicModelOption( } else { var parts: [String] = [] if model.isDefault { - parts.append("Default on the paired host") + parts.append("Default on the paired machine") } if model.supportsReasoning == true { parts.append("Reasoning") @@ -762,7 +762,7 @@ private func workDynamicModelOption( if model.supportsTools == true { parts.append("Tools") } - tagline = parts.isEmpty ? "Available on the paired host" : parts.joined(separator: " · ") + tagline = parts.isEmpty ? "Available on the paired machine" : parts.joined(separator: " · ") } return WorkModelOption( @@ -831,7 +831,7 @@ private func injectCurrentWorkModelIfNeeded( id: currentModelId, displayName: currentModelId, tier: .balanced, - tagline: "In use on the paired host", + tagline: "In use on the paired machine", provider: workModelBrandKey(topLevelProvider: targetGroupKey, providerKey: providerKey) ) if let groupIndex = groups.firstIndex(where: { $0.key == targetGroupKey }) { diff --git a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift index 5f3808856..b692f2d1c 100644 --- a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift @@ -288,7 +288,7 @@ struct WorkModelPickerSheet: View { Spacer(minLength: 24) ProgressView() .tint(ADEColor.accent) - Text("Loading models from the paired host…") + Text("Loading models from the paired machine…") .font(.footnote) .foregroundStyle(ADEColor.textSecondary) Spacer(minLength: 24) @@ -306,7 +306,7 @@ struct WorkModelPickerSheet: View { Text("No models are currently available.") .font(.subheadline.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) - Text("Connect a provider on the host or load a local runtime, then reopen the picker.") + Text("Connect a provider on the paired machine or load a local model provider, then reopen the picker.") .font(.footnote) .foregroundStyle(ADEColor.textSecondary) .multilineTextAlignment(.center) diff --git a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift index 48c17b6f3..1b50723be 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift @@ -78,7 +78,7 @@ struct WorkNewChatSheet: View { WorkProviderOption( id: "opencode", title: "OpenCode", - subtitle: "Open runtime workflows and tools", + subtitle: "Open workflows and tools", icon: providerIcon("opencode"), tint: providerTint("opencode") ), @@ -232,12 +232,12 @@ struct WorkNewChatSheet: View { ADEEmptyStateView( symbol: "arrow.triangle.branch", title: "No lanes on this phone yet", - message: "Lanes are created on the ADE host. After the host syncs lane metadata, pull to refresh on Work or tap below." + message: "Lanes are created on the paired machine. After the machine syncs lane metadata, pull to refresh on Work or tap below." ) Button { Task { await onRefreshLanes() } } label: { - Label("Refresh lanes from host", systemImage: "arrow.trianglehead.2.clockwise.rotate.90") + Label("Refresh lanes from machine", systemImage: "arrow.trianglehead.2.clockwise.rotate.90") .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 10) diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index 244278702..47da7d953 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -394,7 +394,7 @@ extension WorkRootScreen { } await reload(refreshRemote: true) if let refreshed = mergedSessions.first(where: { $0.id == session.id }), refreshed.status == session.status, isChatSession(session) { - errorMessage = "This host keeps chat runtimes alive until the turn finishes. Reconnect and try again if the status does not update." + errorMessage = "This machine keeps chat sessions alive until the turn finishes. Reconnect and try again if the status does not update." } } catch { errorMessage = error.localizedDescription diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index 1632bc9b9..3ed1ddf0b 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -531,7 +531,7 @@ struct WorkRootScreen: View { } } message: { session in Text(isChatSession(session) - ? "ADE will ask the host to stop this chat and keep the transcript available for review." + ? "ADE will ask the machine to stop this chat and keep the transcript available for review." : "ADE will stop streaming new terminal output for this session.") } } diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift index d9aa22ba6..8f106cd09 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift @@ -336,7 +336,7 @@ extension WorkSessionDestinationView { } else if let image = try? await ADEImageCache.shared.loadRemoteImage(from: directURL, cacheKey: cacheKey) { artifactContent[artifact.id] = .image(image) } else { - artifactContent[artifact.id] = .error("The host returned an unreadable image preview.") + artifactContent[artifact.id] = .error("The machine returned an unreadable image preview.") } return } @@ -351,7 +351,7 @@ extension WorkSessionDestinationView { } guard let data else { - artifactContent[artifact.id] = .error("The host returned an artifact payload that could not be decoded.") + artifactContent[artifact.id] = .error("The machine returned an artifact payload that could not be decoded.") return } diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 6ab40b626..a835ac310 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -448,7 +448,7 @@ struct WorkSessionDestinationView: View { } catch { ADEHaptics.error() localEchoMessages.removeAll { $0.id == echo.id } - errorMessage = "Opening message did not reach the host. The chat exists; tap Send to retry. \(error.localizedDescription)" + errorMessage = "Opening message did not reach the machine. The chat exists; tap Send to retry. \(error.localizedDescription)" } sending = false } diff --git a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift index 30fe79b51..de9b9d760 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift @@ -78,13 +78,13 @@ struct WorkSessionSettingsSheet: View { WorkRuntimeOption(id: "default", title: "Default permissions", subtitle: "Workspace-write with approval on request."), WorkRuntimeOption(id: "plan", title: "Plan mode", subtitle: "Read-only browsing with approval on request."), WorkRuntimeOption(id: "full-auto", title: "Full access", subtitle: "No sandbox and no approval prompts."), - WorkRuntimeOption(id: "config-toml", title: "Custom (config.toml)", subtitle: "Use the Codex config on the host."), + WorkRuntimeOption(id: "config-toml", title: "Custom (config.toml)", subtitle: "Use the Codex config on the machine."), ] case "opencode": return [ - WorkRuntimeOption(id: "plan", title: "Plan", subtitle: "Read-first runtime mode."), + WorkRuntimeOption(id: "plan", title: "Plan", subtitle: "Read-first access mode."), WorkRuntimeOption(id: "edit", title: "Edit", subtitle: "Normal edit loop."), - WorkRuntimeOption(id: "full-auto", title: "Full auto", subtitle: "Let the runtime operate freely."), + WorkRuntimeOption(id: "full-auto", title: "Full auto", subtitle: "Let the agent operate freely."), ] default: return [] @@ -273,7 +273,7 @@ struct WorkSessionSettingsSheet: View { } } } else if !runtimeOptions.isEmpty { - GlassSection(title: "Runtime mode") { + GlassSection(title: "Access mode") { VStack(alignment: .leading, spacing: 12) { ForEach(runtimeOptions) { option in Button { diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index e25698efe..75b15aeb6 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -114,6 +114,80 @@ final class ADETests: XCTestCase { } } + func testCommandEnvelopePayloadIncludesProjectScope() throws { + let payload = syncCommandEnvelopePayload( + commandId: "cmd-1", + action: "lanes.create", + args: ["name": "Feature lane"], + projectId: " project-1 ", + projectRootPath: " /tmp/project-one/ " + ) + + XCTAssertEqual(payload["commandId"] as? String, "cmd-1") + XCTAssertEqual(payload["action"] as? String, "lanes.create") + XCTAssertEqual(payload["projectId"] as? String, "project-1") + XCTAssertEqual(payload["projectRootPath"] as? String, "/tmp/project-one") + let args = try XCTUnwrap(payload["args"] as? [String: Any]) + XCTAssertEqual(args["name"] as? String, "Feature lane") + } + + func testCommandEnvelopePayloadOmitsBlankProjectScope() { + let payload = syncCommandEnvelopePayload( + commandId: "cmd-1", + action: "lanes.list", + args: [:], + projectId: " ", + projectRootPath: " " + ) + + XCTAssertNil(payload["projectId"]) + XCTAssertNil(payload["projectRootPath"]) + } + + func testProjectScopedOutboundEnvelopeTypesIncludeActiveProjectId() { + let projectScopedTypes = [ + "changeset_batch", + "changeset_ack", + "command", + "file_request", + "terminal_subscribe", + "terminal_unsubscribe", + "terminal_input", + "terminal_resize", + "chat_subscribe", + "chat_unsubscribe", + ] + + for type in projectScopedTypes { + XCTAssertEqual( + syncOutboundEnvelopeProjectId(type: type, activeProjectId: " project-1 "), + "project-1", + "\(type) should carry the active project id" + ) + } + } + + func testRuntimeScopedOutboundEnvelopeTypesRemainProjectless() { + let runtimeScopedTypes = [ + "hello", + "pairing_request", + "project_catalog_request", + "project_switch_request", + "heartbeat", + "register_push_token", + "notification_prefs", + "send_test_push", + ] + + for type in runtimeScopedTypes { + XCTAssertNil( + syncOutboundEnvelopeProjectId(type: type, activeProjectId: "project-1"), + "\(type) should not inherit the active project id" + ) + } + XCTAssertNil(syncOutboundEnvelopeProjectId(type: "file_request", activeProjectId: " ")) + } + func testDecodeHydrationPayloadWrapsMalformedHostData() { XCTAssertThrowsError( try decodeHydrationPayload( @@ -123,7 +197,7 @@ final class ADETests: XCTestCase { decoder: JSONDecoder() ) ) { error in - XCTAssertEqual((error as NSError).localizedDescription, "The host returned incomplete lane data. Pull to retry or reconnect the host.") + XCTAssertEqual((error as NSError).localizedDescription, "The machine returned incomplete lane data. Pull to retry or reconnect the machine.") } } @@ -185,7 +259,7 @@ final class ADETests: XCTestCase { func testSyncRequestTimeoutUsesThirtySecondFriendlyReconnectMessage() { XCTAssertEqual(SyncRequestTimeout.defaultTimeoutNanoseconds, 30_000_000_000) - XCTAssertEqual(SyncRequestTimeout.error().localizedDescription, "The host took too long to respond. Reconnecting now.") + XCTAssertEqual(SyncRequestTimeout.error().localizedDescription, "The machine took too long to respond. Reconnecting now.") } func testSyncRequestTimeoutOnlyReconnectsAfterSocketSilence() { @@ -301,6 +375,169 @@ final class ADETests: XCTestCase { XCTAssertFalse(syncIsTailscaleRoute("not-ts.net.example.com")) } + func testBonjourHostParsesHeadlessRuntimeProjectTxtFields() { + let host = syncDiscoveredHostFromBonjour( + serviceKey: "local|_ade-sync._tcp.|ADE Sync studio", + serviceName: "ADE Sync studio", + serviceHostName: "studio.local.", + servicePort: 0, + txtRecord: [ + "host": "192.168.1.240", + "addresses": "127.0.0.1, 100.75.20.63", + "deviceName": "studio", + "deviceId": "device-1", + "runtimeKind": "headless", + "runtimeVersion": "0.0.0", + "projects": "project-a, project-b", + "projectNames": "ADE, Website", + "projectCount": "2", + "tailscaleDnsName": "macbook.tailnet.ts.net", + "tailscaleIp": "100.75.20.63", + "port": "8787", + ], + resolvedAddresses: ["127.0.0.1", "192.168.1.240"], + lastResolvedAt: "2026-05-10T10:00:00.000Z" + ) + + XCTAssertEqual(host.id, "device-1::local|_ade-sync._tcp.|ADE Sync studio") + XCTAssertEqual(host.hostName, "studio") + XCTAssertEqual(host.hostIdentity, "device-1") + XCTAssertEqual(host.port, 8787) + XCTAssertEqual(host.runtimeKind, "headless") + XCTAssertEqual(host.runtimeVersion, "0.0.0") + XCTAssertEqual(host.projectIds, ["project-a", "project-b"]) + XCTAssertEqual(host.projectNames, ["ADE", "Website"]) + XCTAssertEqual(host.projectCount, 2) + XCTAssertEqual(host.tailscaleAddress, "macbook.tailnet.ts.net") + XCTAssertEqual(host.addresses, ["192.168.1.240", "100.75.20.63", "127.0.0.1"]) + } + + func testBonjourHostFallsBackForOlderDesktopTxtRecords() { + let host = syncDiscoveredHostFromBonjour( + serviceKey: "local|_ade-sync._tcp.|ADE Sync legacy", + serviceName: "ADE Sync legacy", + serviceHostName: nil, + servicePort: 0, + txtRecord: [ + "deviceName": " ", + "deviceId": " ", + "runtimeKind": " ", + "runtimeVersion": " ", + "projects": " ", + "projectNames": " ", + "projectCount": "unknown", + "addresses": " ", + ], + resolvedAddresses: [], + lastResolvedAt: "2026-05-10T10:00:00.000Z" + ) + + XCTAssertEqual(host.id, "local|_ade-sync._tcp.|ADE Sync legacy") + XCTAssertEqual(host.hostName, "ADE Sync legacy") + XCTAssertEqual(host.port, 8787) + XCTAssertNil(host.hostIdentity) + XCTAssertNil(host.runtimeKind) + XCTAssertNil(host.runtimeVersion) + XCTAssertTrue(host.projectIds.isEmpty) + XCTAssertTrue(host.projectNames.isEmpty) + XCTAssertNil(host.projectCount) + XCTAssertTrue(host.addresses.isEmpty) + } + + func testSavedDiscoveredHostsDisplayLiveRuntimeMetadata() { + let savedHost = DiscoveredSyncHost( + id: "saved-device-1", + serviceName: "Saved ADE", + hostName: "Mac Studio", + hostIdentity: "device-1", + port: 8787, + addresses: ["192.168.1.240"], + tailscaleAddress: nil, + lastResolvedAt: "2026-05-10T09:59:00.000Z" + ) + let liveHost = DiscoveredSyncHost( + id: "device-1", + serviceName: "ADE Sync studio", + hostName: "Mac Studio", + hostIdentity: "device-1", + port: 8787, + addresses: ["192.168.1.240", "127.0.0.1"], + tailscaleAddress: "macbook.tailnet.ts.net", + runtimeKind: "headless", + runtimeVersion: "0.0.0", + projectIds: ["project-a", "project-b"], + projectNames: ["ADE", "Website"], + projectCount: 2, + lastResolvedAt: "2026-05-10T10:00:00.000Z" + ) + + let displayed = syncDiscoveredHostsForDisplay(savedHosts: [savedHost], liveHosts: [liveHost]) + + XCTAssertTrue(displayed.liveHosts.isEmpty) + XCTAssertEqual(displayed.savedHosts.count, 1) + XCTAssertEqual(displayed.savedHosts[0].runtimeKind, "headless") + XCTAssertEqual(displayed.savedHosts[0].runtimeVersion, "0.0.0") + XCTAssertEqual(displayed.savedHosts[0].projectIds, ["project-a", "project-b"]) + XCTAssertEqual(displayed.savedHosts[0].projectNames, ["ADE", "Website"]) + XCTAssertEqual(displayed.savedHosts[0].projectCount, 2) + XCTAssertEqual(displayed.savedHosts[0].tailscaleAddress, "macbook.tailnet.ts.net") + XCTAssertEqual( + syncDiscoveredHostDetailText(host: displayed.savedHosts[0], detailPrefix: "Saved"), + "Background ADE 0.0.0 · 2 projects: ADE, Website · Saved: 192.168.1.240" + ) + } + + @MainActor + func testSyncMergesDuplicateBonjourHostsByDeviceIdentityWithProjectMetadata() { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + let olderHost = DiscoveredSyncHost( + id: "device-1::local|_ade-sync._tcp.|ADE Sync studio 8787", + serviceName: "ADE Sync studio 8787", + hostName: "Studio", + hostIdentity: "device-1", + port: 8787, + addresses: ["192.168.1.240"], + tailscaleAddress: nil, + runtimeKind: "daemon", + runtimeVersion: "1.0.0", + projectIds: ["project-a"], + projectNames: ["ADE"], + projectCount: 1, + lastResolvedAt: "2026-05-10T10:00:00.000Z" + ) + let newerHost = DiscoveredSyncHost( + id: "device-1::local|_ade-sync._tcp.|ADE Sync studio 8788", + serviceName: "ADE Sync studio 8788", + hostName: "Studio", + hostIdentity: "device-1", + port: 8788, + addresses: ["10.0.0.8", "192.168.1.240"], + tailscaleAddress: "macbook.tailnet.ts.net", + runtimeKind: "headless", + runtimeVersion: "2.0.0", + projectIds: ["project-b", "project-a"], + projectNames: ["Website", "ADE"], + projectCount: 2, + lastResolvedAt: "2026-05-10T10:00:01.000Z" + ) + + service.applyDiscoveredHostsForTesting([olderHost, newerHost]) + + XCTAssertEqual(service.discoveredHosts.count, 1) + let merged = service.discoveredHosts[0] + XCTAssertEqual(merged.id, "device-1") + XCTAssertEqual(merged.hostIdentity, "device-1") + XCTAssertEqual(merged.port, 8788) + XCTAssertEqual(merged.addresses, ["10.0.0.8", "192.168.1.240"]) + XCTAssertEqual(merged.tailscaleAddress, "macbook.tailnet.ts.net") + XCTAssertEqual(merged.runtimeKind, "headless") + XCTAssertEqual(merged.runtimeVersion, "2.0.0") + XCTAssertEqual(merged.projectIds, ["project-b", "project-a"]) + XCTAssertEqual(merged.projectNames, ["Website", "ADE"]) + XCTAssertEqual(merged.projectCount, 2) + XCTAssertEqual(merged.lastResolvedAt, "2026-05-10T10:00:01.000Z") + } + func testSyncParsesManualRouteEndpointInputs() throws { XCTAssertEqual( syncParseRouteEndpoint("100.75.20.63:8788"), @@ -726,14 +963,14 @@ final class ADETests: XCTestCase { code: 2, userInfo: [NSLocalizedDescriptionKey: "The host is offline."] ) - XCTAssertEqual(SyncUserFacingError.message(for: offlineError), "The host is offline. Reconnect, then try again.") + XCTAssertEqual(SyncUserFacingError.message(for: offlineError), "The machine is offline. Reconnect, then try again.") let authError = NSError( domain: "ADE", code: 3, userInfo: [NSLocalizedDescriptionKey: "Authentication failed.", "ADEErrorCode": "auth_failed"] ) - XCTAssertEqual(SyncUserFacingError.message(for: authError), "This phone is no longer paired with the host. Pair again from Settings.") + XCTAssertEqual(SyncUserFacingError.message(for: authError), "This phone is no longer paired with this machine. Pair again from Settings.") let ambiguousTailnetAuthError = NSError( domain: "ADE", @@ -746,7 +983,7 @@ final class ADETests: XCTestCase { ) XCTAssertEqual( SyncUserFacingError.message(for: ambiguousTailnetAuthError), - "Reached an ADE host over Tailnet, but it did not match this saved computer. ADE kept the pairing and will keep trying other routes." + "Reached an ADE machine over Tailscale, but it did not match this saved machine. ADE kept the pairing and will keep trying other routes." ) let invalidHelloError = NSError( @@ -754,7 +991,7 @@ final class ADETests: XCTestCase { code: 4, userInfo: [NSLocalizedDescriptionKey: "Invalid hello response."] ) - XCTAssertEqual(SyncUserFacingError.message(for: invalidHelloError), "The host replied with unexpected pairing data. Reconnect and try again.") + XCTAssertEqual(SyncUserFacingError.message(for: invalidHelloError), "The machine replied with unexpected pairing data. Reconnect and try again.") let queuedOperationError = NSError( domain: "ADE", @@ -768,7 +1005,7 @@ final class ADETests: XCTestCase { code: 6, userInfo: [NSLocalizedDescriptionKey: "Unable to decode compressed sync payload."] ) - XCTAssertEqual(SyncUserFacingError.message(for: compressedPayloadError), "The host sent unreadable sync data. Reconnect and try again.") + XCTAssertEqual(SyncUserFacingError.message(for: compressedPayloadError), "The machine sent unreadable sync data. Reconnect and try again.") } @MainActor @@ -1520,7 +1757,7 @@ final class ADETests: XCTestCase { XCTAssertTrue(service.shouldShowProjectHome) XCTAssertEqual( service.lastError, - "That project has not been cached on this phone yet. Connect to the ADE desktop app before opening it." + "That project has not been cached on this phone yet. Connect to the ADE machine before opening it." ) database.close() @@ -1609,14 +1846,17 @@ final class ADETests: XCTestCase { } @MainActor - func testSyncServicePrefersRemoteCatalogProjectOverStaleCachedSelection() throws { + func testSyncServiceClearsStaleCachedSelectionUntilUserChoosesRemoteProject() throws { let activeProjectIdKey = "ade.sync.activeProjectId" let activeProjectRootPathKey = "ade.sync.activeProjectRootPath" + let activeProjectHostIdentityKey = "ade.sync.activeProjectHostIdentity" UserDefaults.standard.set("old-project", forKey: activeProjectIdKey) UserDefaults.standard.set("/tmp/old-project", forKey: activeProjectRootPathKey) + UserDefaults.standard.set("host-old", forKey: activeProjectHostIdentityKey) defer { UserDefaults.standard.removeObject(forKey: activeProjectIdKey) UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + UserDefaults.standard.removeObject(forKey: activeProjectHostIdentityKey) } let database = makeControllerHydrationDatabase(baseURL: makeTemporaryDirectory()) @@ -1649,38 +1889,65 @@ final class ADETests: XCTestCase { ]], ]) - XCTAssertEqual(service.activeProjectId, "new-project") - XCTAssertEqual(service.activeProjectRootPath, "/tmp/new-project") - XCTAssertEqual(database.currentProjectId(), "new-project") + XCTAssertNil(service.activeProjectId) + XCTAssertNil(service.activeProjectRootPath) + XCTAssertNotEqual(database.currentProjectId(), "new-project") + XCTAssertTrue(service.shouldShowProjectHome) + XCTAssertTrue(service.projects.contains { $0.id == "new-project" }) database.close() } @MainActor - func testSyncPairingQrPayloadRoundTripFromDesktopLink() throws { - let payload = """ - {"version":2,"hostIdentity":{"deviceId":"host-1","siteId":"site-1","name":"Mac Studio","platform":"macOS","deviceType":"desktop"},"port":8787,"addressCandidates":[{"host":"192.168.1.8","kind":"lan"},{"host":"100.101.102.103","kind":"tailscale"}]} - """ - let url = "ade-sync://pair?payload=\(payload.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? payload)" + func testSyncServiceClearsMatchingProjectIdWhenMachineIdentityChanges() throws { + let activeProjectIdKey = "ade.sync.activeProjectId" + let activeProjectRootPathKey = "ade.sync.activeProjectRootPath" + let activeProjectHostIdentityKey = "ade.sync.activeProjectHostIdentity" + UserDefaults.standard.set("project-1", forKey: activeProjectIdKey) + UserDefaults.standard.set("/tmp/project-one", forKey: activeProjectRootPathKey) + UserDefaults.standard.set("host-old", forKey: activeProjectHostIdentityKey) + defer { + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + UserDefaults.standard.removeObject(forKey: activeProjectHostIdentityKey) + } - let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) - let decoded = try service.decodePairingQrPayload(from: url) + let database = makeControllerHydrationDatabase(baseURL: makeTemporaryDirectory()) + try database.executeSqlForTesting(""" + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values + ('project-1', '/tmp/project-one', 'Project One', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T01:00:00.000Z'); + """) + let service = SyncService(database: database) + XCTAssertEqual(service.activeProjectId, "project-1") - XCTAssertEqual(decoded.hostIdentity.deviceId, "host-1") - XCTAssertEqual(decoded.hostIdentity.name, "Mac Studio") - XCTAssertEqual(decoded.version, 2) - XCTAssertEqual(decoded.addressCandidates.map(\.host), ["192.168.1.8", "100.101.102.103"]) - } + try service.applyHelloPayloadForTesting([ + "brain": [ + "deviceId": "host-new", + "deviceName": "New Mac", + ], + "features": [ + "projectCatalog": true, + ], + "projects": [[ + "id": "project-1", + "displayName": "Project One", + "rootPath": "/tmp/project-one", + "defaultBaseRef": "main", + "lastOpenedAt": "2026-04-22T02:00:00.000Z", + "laneCount": 2, + "isAvailable": true, + "isCached": false, + ]], + ]) - @MainActor - func testSyncPairingQrPayloadRejectsUnsupportedVersion() throws { - let payload = """ - {"version":3,"hostIdentity":{"deviceId":"host-1","siteId":"site-1","name":"Mac Studio","platform":"macOS","deviceType":"desktop"},"port":8787,"addressCandidates":[{"host":"192.168.1.8","kind":"lan"}]} - """ - let url = "ade-sync://pair?payload=\(payload.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? payload)" + XCTAssertNil(service.activeProjectId) + XCTAssertNil(service.activeProjectRootPath) + XCTAssertTrue(service.shouldShowProjectHome) + XCTAssertTrue(service.projects.contains { $0.id == "project-1" }) - let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) - XCTAssertThrowsError(try service.decodePairingQrPayload(from: url)) + database.close() } func testDatabasePersistsStableSiteIdAcrossReopen() throws { @@ -3217,7 +3484,7 @@ final class ADETests: XCTestCase { _ = try await service.refreshLaneDetail(laneId: "lane-child") XCTFail("Expected live-only lane detail refresh to fail while offline.") } catch { - XCTAssertEqual((error as NSError).localizedDescription, "This action requires a live connection to the host.") + XCTAssertEqual((error as NSError).localizedDescription, "This action requires a live connection to the machine.") } } @@ -3636,7 +3903,7 @@ final class ADETests: XCTestCase { ) XCTAssertEqual(emptyState?.title, "Pair to load lanes") - XCTAssertEqual(emptyState?.actionTitle, "Pair with host") + XCTAssertEqual(emptyState?.actionTitle, "Pair with machine") XCTAssertEqual(emptyState?.action, .openSettings) } @@ -3705,9 +3972,9 @@ final class ADETests: XCTestCase { XCTAssertTrue(discard.id.hasPrefix("discard:")) let restore = LaneFileConfirmation.restoreStaged(file) - XCTAssertEqual(restore.title, "Restore staged file?") - XCTAssertEqual(restore.confirmTitle, "Restore") - XCTAssertEqual(restore.actionLabel, "restore staged file") + XCTAssertEqual(restore.title, "Discard staged changes?") + XCTAssertEqual(restore.confirmTitle, "Discard staged") + XCTAssertEqual(restore.actionLabel, "discard staged file") XCTAssertEqual(restore.file?.path, file.path) XCTAssertTrue(restore.id.hasPrefix("restore:")) } @@ -7876,6 +8143,53 @@ final class ADETests: XCTestCase { XCTAssertEqual(model.questionId, "only") XCTAssertEqual(model.options.count, 1) } + + // MARK: - LinearConnectionStatus contract parity + + func testLinearConnectionStatusDecodesNewOrganizationFields() throws { + let json = """ + { + "connected": true, + "viewerId": "vw_1", + "viewerName": "Ada", + "organizationId": "org_1", + "organizationName": "Acme", + "organizationUrlKey": "acme", + "organizationLogoUrl": "https://example.invalid/logo.png", + "projectCount": 3, + "checkedAt": "2026-05-10T00:00:00Z", + "authMode": "oauth" + } + """.data(using: .utf8)! + + let status = try JSONDecoder().decode(LinearConnectionStatus.self, from: json) + + XCTAssertTrue(status.connected) + XCTAssertEqual(status.viewerName, "Ada") + XCTAssertEqual(status.organizationId, "org_1") + XCTAssertEqual(status.organizationName, "Acme") + XCTAssertEqual(status.organizationUrlKey, "acme") + XCTAssertEqual(status.organizationLogoUrl, "https://example.invalid/logo.png") + } + + /// Older hosts won't return the organization fields. The mirror must still + /// decode without throwing, leaving them nil. + func testLinearConnectionStatusDecodesWithoutOrganizationFields() throws { + let json = """ + { + "connected": false, + "checkedAt": null + } + """.data(using: .utf8)! + + let status = try JSONDecoder().decode(LinearConnectionStatus.self, from: json) + + XCTAssertFalse(status.connected) + XCTAssertNil(status.organizationId) + XCTAssertNil(status.organizationName) + XCTAssertNil(status.organizationUrlKey) + XCTAssertNil(status.organizationLogoUrl) + } } private extension Collection { diff --git a/apps/web/public/images/competitors/openclaw.png b/apps/web/public/images/competitors/openclaw.png deleted file mode 100644 index 85a2f2117..000000000 Binary files a/apps/web/public/images/competitors/openclaw.png and /dev/null differ diff --git a/apps/web/public/mockup.html b/apps/web/public/mockup.html index f95664b77..d1450fee3 100644 --- a/apps/web/public/mockup.html +++ b/apps/web/public/mockup.html @@ -779,11 +779,6 @@ <div class="name">Paperc.</div> </div> <span class="plus">+</span> - <div class="logo-cell"> - <div class="chip"><img src="/images/competitors/openclaw.png" alt="OpenClaw logo" /></div> - <div class="name">OpenClaw</div> - </div> - <span class="plus">+</span> <div class="logo-cell"> <div class="chip"><img src="/images/competitors/github.png" alt="GitHub logo" /></div> <div class="name">GitHub</div> diff --git a/apps/web/scripts/og-image-combo.html b/apps/web/scripts/og-image-combo.html index d89e3c8e9..b8144339a 100644 --- a/apps/web/scripts/og-image-combo.html +++ b/apps/web/scripts/og-image-combo.html @@ -368,8 +368,6 @@ <span class="plus">+</span> <div class="chip"><img src="../public/images/competitors/paperclip.png" alt="" /></div> <span class="plus">+</span> - <div class="chip"><img src="../public/images/competitors/openclaw.png" alt="" /></div> - <span class="plus">+</span> <div class="chip"><img src="../public/images/competitors/github.png" alt="" /></div> <span class="eq">=</span> <div class="ade-chip"> diff --git a/apps/web/scripts/og-image.html b/apps/web/scripts/og-image.html index f236cb4c2..60115ce04 100644 --- a/apps/web/scripts/og-image.html +++ b/apps/web/scripts/og-image.html @@ -336,11 +336,6 @@ <h1 class="headline"> <span class="label">Paperc.</span> </div> <span class="plus">+</span> - <div class="chip"> - <div class="logo"><img src="../public/images/competitors/openclaw.png" alt="" /></div> - <span class="label">OpenClaw</span> - </div> - <span class="plus">+</span> <div class="chip"> <div class="logo"><img src="../public/images/competitors/github.png" alt="" /></div> <span class="label">GitHub</span> diff --git a/apps/web/src/components/editorial/CompetitorEquation.tsx b/apps/web/src/components/editorial/CompetitorEquation.tsx index 5ef731697..27bf792bc 100644 --- a/apps/web/src/components/editorial/CompetitorEquation.tsx +++ b/apps/web/src/components/editorial/CompetitorEquation.tsx @@ -11,7 +11,6 @@ const COMPETITORS = [ { name: "Conductor", short: "Cond.", logo: "/images/competitors/conductor.png" }, { name: "Factory", short: "Factory", logo: "/images/competitors/factory.png" }, { name: "Paperclip", short: "Paperc.", logo: "/images/competitors/paperclip.png" }, - { name: "OpenClaw", short: "OpenClaw", logo: "/images/competitors/openclaw.png" }, { name: "GitHub", short: "GitHub", logo: "/images/competitors/github.png" }, ] as const; diff --git a/changelog/v1.0.10.mdx b/changelog/v1.0.10.mdx index 367a03bd7..3b650dd1c 100644 --- a/changelog/v1.0.10.mdx +++ b/changelog/v1.0.10.mdx @@ -40,5 +40,5 @@ Version 1.0.9 was skipped. ## Removed -- **Stale OpenClaw JSON artifacts** — Removed `openclaw-history.json`, `openclaw-idempotency.json`, `openclaw-outbox.json`, `openclaw-routes.json` from `.ade/cto/` +- **Stale CTO JSON artifacts** — Removed obsolete bridge runtime artifacts from `.ade/cto/` - **Chat hooks consolidated** — `useAgentChatComposerState`, `useAgentChatSessions`, `useChatDraft`, `useChatDraftStore` removed (logic absorbed into parent components) diff --git a/changelog/v1.1.6.mdx b/changelog/v1.1.6.mdx index a57a015b5..b32b7ec14 100644 --- a/changelog/v1.1.6.mdx +++ b/changelog/v1.1.6.mdx @@ -24,7 +24,7 @@ v1.1.6 makes automation rules first-class: per-action targeting and overrides, a After the recent feature ripouts, the test tree was carrying a lot of dead and fragmented files. This release collapses them. - **Orchestrator tests: 17 → 12 suites.** `orchestratorAdapters.test.ts` absorbs `baseOrchestratorAdapter` + `providerOrchestratorAdapter` + `permissionMapping` + `modelConfigResolver`. `mission.test.ts` absorbs `missionLifecycle` + `missionBudgetService` + `missionStateDoc`. `orchestratorPlanning.test.ts` absorbs `orchestratorContext` + `delegationContracts`. All 463 cases preserved. -- **CTO tests: 26 → 7 suites.** Linear OAuth/credential/client into `linearAuth`; sync/dispatcher/outbound/template/workflow-file into `linearSync`; intake/ingress/routing/closeout into `linearIntake`; worker-{heartbeat,adapterRuntime,agent,budget,revision,taskSession,openclawBridge} into `ctoWorkerLifecycle`; pipelineHelpers + pipelineLabels into `pipeline`; settings panel + Linear sync panel + session view state into `ctoUi`. 226 cases preserved verbatim, scoped per-source via outer `describe(...)` blocks. +- **CTO tests: 26 → 7 suites.** Linear OAuth/credential/client into `linearAuth`; sync/dispatcher/outbound/template/workflow-file into `linearSync`; intake/ingress/routing/closeout into `linearIntake`; worker lifecycle tests into `ctoWorkerLifecycle`; pipelineHelpers + pipelineLabels into `pipeline`; settings panel + Linear sync panel + session view state into `ctoUi`. 226 cases preserved verbatim, scoped per-source via outer `describe(...)` blocks. - **PR-service tests: 10 → 5 feature suites.** `prMergeQueue` (queueLandingService + integrationPlanning + integrationValidation), `prRebase` (prRebaseResolver + resolverUtils via `vi.importActual`), `prAsync` (prPollingService + prSummaryService), `prIssueResolution` (issueInventoryService + prIssueResolver). 180 cases preserved. - **Dead tests removed.** Orphaned tests in `orchestrator/`, `prs/`, `missions/`, and a handful of others where the source files no longer exist. A no-op `expect(true).toBe(true)` in `usageTrackingService.test.ts` is rewritten to a real `not.toThrow()` check. diff --git a/cto/workers.mdx b/cto/workers.mdx index 4dbea1015..8138879a0 100644 --- a/cto/workers.mdx +++ b/cto/workers.mdx @@ -214,16 +214,3 @@ cto: - Click **Dissolve** to terminate the worker and return the task to the CTO queue </Accordion> </AccordionGroup> - ---- - -## OpenClaw Bridge - -If you have an OpenClaw account or a compatible external agent platform, you can connect it to ADE. The CTO acts as the designated router for inbound requests from OpenClaw: - -1. In **Settings > CTO > External Connections**, enable **OpenClaw Bridge** -2. ADE generates a local RPC endpoint URL for OpenClaw to connect to -3. OpenClaw sends development requests to ADE by addressing them to the CTO via the `cto.request` RPC action - -The CTO receives these requests in its chat thread (tagged as external), evaluates them, and routes them to the appropriate internal workflow: mission launch, worker delegation, or human escalation. - diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0bf53328a..e0130f445 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,49 +6,61 @@ Consolidated technical reference for the ADE (Agentic Development Environment) s ## 1. System at a Glance -ADE is a local-first development control plane that orchestrates AI-assisted software engineering across parallel worktrees. It combines worktree-per-lane git isolation, a multi-provider AI runtime, a deterministic orchestrator for multi-step missions, a Linear-integrated CTO agent acting as a team lead, a pipeline builder for visual automations, stacked pull requests with conflict simulation, computer-use proofs, a SQLite-backed memory system, and multi-device sync via cr-sqlite CRDTs. Nothing leaves the user's machine by default: AI work runs through user-authenticated CLIs (Claude Code, Codex), local API-key routes (OpenCode server), or local model endpoints (Ollama, LM Studio, vLLM). +ADE is a local-first development control plane that orchestrates AI-assisted software engineering across parallel worktrees. The center of the system is a **per-machine ADE runtime daemon** (`apps/ade-cli/`, started with `ade serve`). The daemon hosts every project on that machine through a project registry and exposes a multi-project JSON-RPC surface on a Unix socket / Windows named pipe at `~/.ade/sock/ade.sock`. Desktop, the terminal `ade code` client, the iOS app, and SSH-attached desktop windows are all peer **clients** that bind to a runtime — local or remote — and invoke runtime-owned actions through that one surface. -ADE ships as four coordinated apps: +The runtime owns everything that needs to survive a client closing: worktree-per-lane git isolation, a multi-provider AI runtime, a deterministic orchestrator for multi-step missions, a Linear-integrated CTO agent acting as a team lead, a pipeline builder for visual automations, stacked pull requests with conflict simulation, computer-use proofs, a SQLite-backed memory system, the sync host that replicates projects to other devices, and the per-machine credential store and agent registry. Nothing leaves the user's machine by default: AI work runs through user-authenticated CLIs (Claude Code, Codex), local API-key routes (OpenCode server), or local model endpoints (Ollama, LM Studio, vLLM). + +ADE ships as four runtime/client packages plus the marketing site: ``` - ┌─────────────────────────┐ - │ apps/web (marketing + │ - │ download landing page) │ - └─────────────────────────┘ - ▲ - │ static hosting - │ -┌──────────────────────────┐ │ ┌──────────────────────────┐ -│ │ │ │ │ -│ apps/desktop (Electron) │──────┴───────▶│ apps/ios (SwiftUI) │ -│ │ WebSocket │ │ -│ main ─── preload ─── renderer │ SwiftUI tabs + local │ -│ │ │ cr-sqlite CRR emulation │ -│ │ └── IPC bridge `window.ade` │ (never runs agents) │ -│ │ │ │ -│ SQLite + cr-sqlite (ade.db) │ │ -│ │ │ │ -│ │─── spawns ─────────────────────┐ │ │ -│ │ ▼ │ │ -│ │ ┌──────────────────────┐ │ -│ │ │ apps/ade-cli │ │ -│ │ │ (JSON-RPC over stdio │◀──── headless mode ──────┤ -│ │ │ or .ade/ade.sock) │ │ -│ │ └──────────────────────┘ │ -│ │ │ -│ └── spawns CLI runtimes: │ -│ claude (Claude Agent SDK) · codex CLI · opencode server │ -│ │ -└──────────────────────────────────────────────────────────────────────┘ - │ - ▼ - ┌─────────────────────────┐ - │ User code: git worktrees │ - │ under .ade/worktrees/ │ - └─────────────────────────┘ + ┌───────────────────────────────┐ + │ apps/web (marketing + DL page)│ + └───────────────────────────────┘ + + ┌───────────────────────────────────────────────┐ + │ apps/ade-cli (RUNTIME) │ + │ ─────────────────────────────────────────────│ + │ `ade serve` daemon │ + │ - listens on ~/.ade/sock/ade.sock │ + │ - login service (launchd / systemd / Win) │ + │ - multi-project RPC + project registry │ + │ - sync host (cr-sqlite over WebSocket) │ + │ - credential store, agent registry │ + │ - dispatches CLI runtimes: │ + │ claude · codex · opencode · cursor │ + │ - SQLite + cr-sqlite per project (.ade/ade.db)│ + │ ─────────────────────────────────────────────│ + │ Also exposes: │ + │ - `ade rpc --stdio` single-session over SSH │ + │ - `ade <command>` typed CLI surface │ + │ - `ade code` terminal Work client (Ink+React)│ + └───────────────────────────────────────────────┘ + ▲ ▲ ▲ ▲ + │ local │ local │ WebSocket │ stdio over + │ socket │ socket │ │ SSH + │ │ │ │ + ┌──────────────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────────────┐ + │ apps/desktop │ │ ade code TUI │ │ apps/ios │ │ apps/desktop │ + │ (Electron, multi-│ │ (apps/ade-cli│ │ SwiftUI │ │ window bound to a│ + │ window — one │ │ /tuiClient) │ │ controller│ │ remote runtime │ + │ window/project) │ │ │ │ (never │ │ (RemoteConnection│ + │ LocalRuntime- │ │ defaults to │ │ runs │ │ Pool, bootstrap- │ + │ ConnectionPool │ │ machine sock │ │ agents) │ │ uploads bundled │ + │ │ │ │ │ │ │ runtime binary) │ + └──────────────────┘ └──────────────┘ └──────────┘ └──────────────────┘ + All clients share the runtime's view of + projects, lanes, chats, processes, sync. + │ + ▼ + ┌─────────────────────────┐ + │ User code: git worktrees│ + │ under .ade/worktrees/ │ + └─────────────────────────┘ ``` -Live runtime state is replicated between connected devices through cr-sqlite changesets carried over WebSocket. Source code crosses desktops through plain git. The iOS app is always a controller attached to a desktop host. +Live runtime state is replicated between paired devices through cr-sqlite changesets carried over WebSocket; the **sync host runs inside the runtime daemon**, not in the desktop app. The iOS app pairs with a runtime — typically the user's primary desktop-class machine. A second desktop on the same network is also a client of that runtime, not a peer host. A desktop window can be re-pointed at a runtime on a remote machine over SSH; the binding is per-window, so the same Electron process can drive a local project in one window and an SSH-bound project in another. The remote path starts `ade rpc --stdio` on the remote and routes runtime actions through the same multi-project JSON-RPC surface. See [features/remote-runtime/README.md](./features/remote-runtime/README.md). + +Source code crosses machines through plain git. ADE does not own a git server. Product positioning and workflows live in [`docs/PRD.md`](../docs/PRD.md). This document is strictly technical. @@ -56,20 +68,71 @@ Product positioning and workflows live in [`docs/PRD.md`](../docs/PRD.md). This ## 2. Apps & Processes -### 2.1 Electron desktop (`apps/desktop/`) +### 2.1 ADE runtime daemon (`apps/ade-cli/`) + +`apps/ade-cli/` is the runtime — the per-machine source of truth — and the `ade` CLI surface. It ships as one Node binary that runs in several modes. + +**Run modes:** + +- **Daemon (`ade serve`)** — the normal mode. Boots the multi-project JSON-RPC server, hosts the per-project services on demand, and listens on `~/.ade/sock/ade.sock` (Windows: a named pipe under `\\.\pipe\ade-<hash>`, with the hash derived in `apps/desktop/src/shared/adeMcpIpc.ts`). Installable / removable as a login service with `ade serve --install-service` / `--uninstall-service` (per-platform installers in `apps/ade-cli/src/serviceManager/`). +- **Single-session CLI** — `ade <command>` connects to the local daemon over the machine socket, dispatches one project-scoped action, and exits. With `--headless`, the CLI bootstraps a project's services directly from the repository instead of going through a daemon — used in CI and for one-off scripts. +- **SSH stdio bridge (`ade rpc --stdio`)** — runs a single-session JSON-RPC runtime over stdin/stdout. This is what desktop's `RemoteConnectionPool` execs over SSH after `bootstrapRemoteRuntime` has uploaded a matching `ade-<platform-arch>` binary. Exits when the SSH channel closes; does not expose remote memory features. +- **Terminal client (`ade code`)** — launches the Ink + React Work chat (`apps/ade-cli/src/tuiClient/`). Defaults to attaching to `~/.ade/sock/ade.sock` and will start `ade serve` if the socket is missing. `ade --socket /path code` requires a specific socket; `ade code --embedded` keeps the legacy in-process fallback explicit. -The desktop app is the execution host. It owns the trusted main process, a narrow typed preload bridge, the React renderer, and shared contracts. +**Multi-project RPC.** The daemon exposes runtime-scoped methods (`projects.list/add/remove/touch`, `sync.*`, `runtime/info`, `machineInfo.get`, `runtimeEvents.subscribe/unsubscribe`) directly. Project-scoped operations dispatch through `ade/actions/call` with a `projectId`. Per-project services are spun up lazily by `ProjectScopeRegistry` (`apps/ade-cli/src/services/projects/projectScope.ts`) which calls `createAdeRuntime({ projectRoot, ... })` the first time a project is touched. The project registry (`projectRegistry.ts`) is the durable list of known projects; `machineLayout.ts` resolves machine-wide paths under `~/.ade/`. Wire formats live in `apps/ade-cli/src/multiProjectRpcServer.ts`. + +**Runtime-side services** (under `apps/ade-cli/src/services/`): | Directory | Role | |-----------|------| -| `apps/desktop/src/main/` | Node process with full OS access. Bootstraps project context, registers IPC handlers, owns SQLite, spawns child processes and CLI runtimes. Entry: `main.ts`. | -| `apps/desktop/src/preload/` | Typed bridge. Entry: `preload.ts`. Uses `contextBridge.exposeInMainWorld("ade", { ... })` and is the only code that crosses the isolated-world boundary. | +| `projects/` | Project registry, project scope (per-project runtime), machine layout. | +| `sync/` | Sync host, peer client, device registry, pairing store, PIN store, sync protocol, remote command service, Tailscale CLI resolver. The sync host now lives here; desktop's old in-process host is disabled by default (env-gated `ADE_ENABLE_DESKTOP_SYNC_HOST=1` for diagnostics only). | +| `credentials/` | Per-machine credential store. | +| `agentRegistry.ts` | Per-machine agent registry. | + +**Service managers.** `apps/ade-cli/src/serviceManager/installLaunchd.ts` (macOS), `installSystemd.ts` (Linux), `installWindows.ts` (Windows) register `ade serve` as a login-time service. `index.ts` is the platform router; `common.ts` carries shared types (`ServiceManagerResult`, `ServiceManagerStatusResult`). + +**Session identity.** The runtime resolves caller role from ADE context env vars and command flags. Role vocabulary: `cto`, `orchestrator`, `agent`, `external`, `evaluator`. + +**Action surface.** First-class command families cover lanes, git, diffs, files, PRs, path-to-merge, runs, shells, chats, agents, CTO, Linear, tests, proof, memory, settings, the iOS Simulator (`ade ios-sim` / `ade ios` / `ade simulator` — see [features/ios-simulator/README.md](./features/ios-simulator/README.md)), the Cursor Cloud bridge (`ade cursor cloud agents | runs | artifacts | repos | models | me` — talks directly to `@cursor/sdk` without going through the ADE socket), the App Control bridge for Electron apps (`ade app-control` / `ade app` / `ade electron` — `launch`, `connect`, `stop`, `status`, `screenshot`, `snapshot`, `inspect`, `select`, `click`, `type`, `scroll`, `key`, `targets`, `attach`, `logs`, `terminal write`, `terminal signal` — see [features/computer-use/app-control.md](./features/computer-use/app-control.md)), the chat-scoped terminal (`ade terminal list` / `read` / `write` / `signal` / `active`), and a generic `ade actions run <domain.action>` escape hatch for every registered ADE service action. The action allow-list adds two domains for these surfaces: `app_control` (every public method on `AppControlService`) and `terminal` (`list`, `read`, `write`, `signal`, `activeForChat` against `ptyService`). + +**Proof subcommands** — `ade proof capture` (alias of `screenshot`), `ade proof attach <path>`, `ade proof record`, `ade proof launch`, `ade proof interact`, `ade proof list/status/environment/ingest`. `attach` infers the artifact kind from the file extension and routes through `ingest_computer_use_artifacts` with `backendStyle: "manual"`. Capture-style commands set `preferHeadless: true` on the plan so the connection layer drops to headless mode unless `--socket` is explicitly requested. All proof subcommands accept `--owner-kind` / `--owner-id` (with `chat` and `pr` aliases) to layer an explicit owner on top of the inferred session identity. + +**Bundled runtime artifacts.** Per-platform `ade-<platform-arch>` binaries plus their native dep tarballs live under `apps/desktop/resources/runtime/`. `release-core.yml` builds the cross-platform set; `bootstrapRemoteRuntime` uploads them on first SSH connect from the desktop client. + +**Headless install.** A standalone runtime can be installed on a headless machine without going through the desktop installer: + +```bash +curl -fsSL https://github.com/arul28/ADE/releases/latest/download/install.sh | sh +``` + +Use `ADE_VERSION=vX.Y.Z` for a pinned release or `ADE_INSTALL_DIR` to choose the destination directory. + +**Install + PATH wiring (when the desktop ships `ade`).** On macOS / Linux the desktop installer drops the launcher at `$HOME/.local/bin/ade`; on Windows it lands at `%LOCALAPPDATA%\ADE\bin\ade.cmd`. After a successful install on Windows, the packaged `.cmd` installer adds the target directory to HKCU `Environment\Path` when needed and broadcasts an environment-change notification. After a successful install on POSIX, `ensureUserBinOnShellPath` appends a marked `export PATH="$HOME/.local/bin:$PATH"` block to the user's shell rc (`.zshrc` for zsh, `.bashrc` for bash, `.profile` otherwise) iff (a) the install dir isn't already on the inherited `PATH` and (b) the file doesn't already contain the marker / line / target dir. The install IPC reply tells the renderer which profile was edited so the Settings/Onboarding UI can prompt the user to open a new terminal or `source` it. + +**Windows packaging.** The installer lays down `ade-cli-windows-wrapper.cmd` plus an `ade-cli-install-path.cmd` helper alongside the bundled Electron Node runtime. The helper installs `%LOCALAPPDATA%\ADE\bin\ade.cmd`, updates the user PATH when needed, and then `ade` works from a new normal Windows shell without a global Node install. See §14.4 for the packaging flow. + +### 2.2 Electron desktop client (`apps/desktop/`) + +The desktop app is a **client of the runtime**. It owns a trusted main process, a narrow typed preload bridge, the React renderer, and the shared TypeScript contracts that the whole monorepo (including the ADE CLI runtime) consumes — but the data plane it operates on lives in the runtime daemon. + +| Directory | Role | +|-----------|------| +| `apps/desktop/src/main/` | Node process with full OS access. Hosts windows, registers IPC handlers, routes runtime-backed APIs through local/remote runtime pools, spawns the local runtime daemon when needed, and runs the legacy in-process services that have not yet been migrated to the runtime. Entry: `main.ts`. | +| `apps/desktop/src/preload/` | Typed bridge. Entry: `preload.ts`. Uses `contextBridge.exposeInMainWorld("ade", { ... })`. Runtime-backed APIs route through `LocalRuntimeConnectionPool` (local) or `RemoteConnectionPool` (SSH-bound window). | | `apps/desktop/src/renderer/` | React 18 SPA. No Node access, no filesystem access, no direct process/network. Everything goes through `window.ade`. Entry: `main.tsx`. | -| `apps/desktop/src/shared/` | Types, IPC channel constants (`ipc.ts`), model registry (`modelRegistry.ts`), keybindings, and other DTOs shared between main and renderer. | +| `apps/desktop/src/shared/` | Types, IPC channel constants (`ipc.ts`), model registry (`modelRegistry.ts`), keybindings, and other DTOs. Imported by both desktop and `apps/ade-cli`. New runtime-facing types live in `shared/types/remoteRuntime.ts` and `shared/types/core.ts`. | | `apps/desktop/src/generated/` | Build-time generated code (e.g., bootstrap SQL snapshots). | | `apps/desktop/src/test/` | Shared vitest setup and fixtures. | | `apps/desktop/src/types/` | Ambient type declarations. | +**Multi-window shell.** `main.ts` hosts multiple `BrowserWindow` instances; opening another project opens it in a dedicated window. Each window has its own runtime binding (local pool or a specific remote target). External controllers — for example a `ade code` TUI — can drive desktop window navigation via the `app/navigate` JSON-RPC method against the runtime; the desktop's IPC tracing carries window ID so logs distinguish which renderer surface invoked a channel. + +**Runtime binding pools.** + +- `apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts` — desktop-side client for the local `ade serve` daemon. Spawns or attaches to the machine socket, registers local projects with `projects.add`, dispatches local runtime actions, and best-effort installs the background service in packaged builds. +- `apps/desktop/src/main/services/remoteRuntime/` — SSH-bound runtime pool. `remoteTargetRegistry.ts` stores saved machines under `~/.ade/secrets/remote-machines.json`; `sshTransport.ts` handles ssh-agent / key based transport; `remoteBootstrap.ts` does first-connect runtime upload + version negotiation against the bundled `ade-<platform-arch>` binary; `remoteConnectionPool.ts` keeps the per-window remote runtime binding alive with reconnect / eviction; `runtimeRpcClient.ts` is the JSON-RPC client; `runtimeDiscovery.ts` discovers reachable runtimes on the network. + Build outputs (configured in `apps/desktop/tsup.config.ts`): | Entry | Source | Purpose | @@ -78,93 +141,31 @@ Build outputs (configured in `apps/desktop/tsup.config.ts`): | `main/packagedRuntimeSmoke.cjs` | `src/main/packagedRuntimeSmoke.ts` | Post-package smoke test for PTY spawn, Claude SDK init, Codex availability, and ADE CLI readiness. | | `preload/preload.cjs` | `src/preload/preload.ts` | Renderer bridge. | -### 2.2 ADE CLI (`apps/ade-cli/`) - -A standalone Node CLI that exposes ADE actions over a private JSON-RPC -bridge. - -- **Socket mode** — when ADE desktop is running, `ade` connects to the - project IPC endpoint. On macOS/Linux that is `.ade/ade.sock`; on - Windows it is a named pipe under `\\.\pipe\ade-<hash>` where `<hash>` - is a SHA-256 prefix of the lowercased absolute project root - (`apps/desktop/src/shared/adeMcpIpc.ts`). Both platforms share the - same JSON-RPC framing. -- **Headless mode** — with `--headless`, the CLI bootstraps the same - project services directly from the repository. -- **Windows packaging** — the installer lays down `ade-cli-windows-wrapper.cmd` - plus an `ade-cli-install-path.cmd` helper alongside the bundled Electron - Node runtime. The helper installs `%LOCALAPPDATA%\ADE\bin\ade.cmd`, updates - the user PATH when needed, and then `ade` works from a new normal Windows - shell without a global Node install. See §14.4 for the packaging flow. -- **Install + PATH wiring (`adeCliService`)** — on macOS / Linux the - desktop installer drops the launcher at `$HOME/.local/bin/ade`; on - Windows it lands at `%LOCALAPPDATA%\ADE\bin\ade.cmd`. After a - successful install on Windows, the packaged `.cmd` installer adds the - target directory to HKCU `Environment\Path` when needed and broadcasts an - environment-change notification. After a successful install on POSIX, - `ensureUserBinOnShellPath` appends a - marked `export PATH="$HOME/.local/bin:$PATH"` block to the user's - shell rc (`.zshrc` for zsh, `.bashrc` for bash, `.profile` otherwise) - iff (a) the install dir isn't already on the inherited `PATH` and - (b) the file doesn't already contain the marker / line / target dir. - The install IPC reply tells the renderer which profile was edited - so the Settings/Onboarding UI can prompt the user to open a new - terminal or `source` it. -- **Session identity** — the CLI resolves caller role from ADE context - environment variables and command flags. Role vocabulary: `cto`, - `orchestrator`, `agent`, `external`, `evaluator`. -- **Action surface** — first-class command families cover lanes, git, - diffs, files, PRs, path-to-merge, runs, shells, chats, agents, CTO, - Linear, tests, proof, memory, settings, the iOS Simulator (`ade - ios-sim` / `ade ios` / `ade simulator` — see - [features/ios-simulator/README.md](./features/ios-simulator/README.md)), - the Cursor Cloud bridge (`ade cursor cloud agents | runs | - artifacts | repos | models | me` — talks directly to `@cursor/sdk` - without going through the ADE socket), - the App Control bridge for Electron apps (`ade app-control` / `ade - app` / `ade electron` — `launch`, `connect`, `stop`, `status`, - `screenshot`, `snapshot`, `inspect`, `select`, `click`, `type`, - `scroll`, `key`, `targets`, `attach`, `logs`, `terminal write`, - `terminal signal` — see - [features/computer-use/app-control.md](./features/computer-use/app-control.md)), - the chat-scoped terminal (`ade terminal list` / `read` / `write` / - `signal` / `active`), and a generic `ade actions run - <domain.action>` escape hatch for every registered ADE service - action. The action allow-list adds two domains for these surfaces: - `app_control` (every public method on `AppControlService`) and - `terminal` (`list`, `read`, `write`, `signal`, `activeForChat` - against `ptyService`). -- **Proof subcommands** — `ade proof capture` (alias of `screenshot`), - `ade proof attach <path>`, `ade proof record`, `ade proof launch`, - `ade proof interact`, `ade proof list/status/environment/ingest`. - `attach` infers the artifact kind from the file extension and routes - through `ingest_computer_use_artifacts` with `backendStyle: "manual"`. - Capture-style commands set `preferHeadless: true` on the plan so the - connection layer drops to headless mode unless `--socket` is - explicitly requested. All proof subcommands accept `--owner-kind` / - `--owner-id` (with `chat` and `pr` aliases) to layer an explicit - owner on top of the inferred session identity. - -### 2.3 Web app (`apps/web/`) +### 2.3 ADE Code terminal client (`ade code`) -A Vite/React SPA that serves the public marketing site and download page. Four pages: `HomePage`, `DownloadPage`, `PrivacyPage`, `TermsPage`. Independent package (`ade-web`), deployed via Vercel (`apps/web/vercel.json`). Not a runtime dependency of the desktop app. Shared-origin with the Mintlify docs site (`docs.json` at repo root). +Terminal-native **Work** chat client (Ink + React) for agents and power users who live in a shell, built into `apps/ade-cli/src/tuiClient/`. It is a peer of the desktop client, not a wrapper around it: it speaks the same multi-project JSON-RPC surface and binds to a runtime daemon the same way. + +- **Attached mode** (default): connects to `~/.ade/sock/ade.sock`, or to an explicit socket passed on the parent `ade` invocation. Starts `ade serve` if the socket is missing. +- **Embedded mode**: `--embedded` / `--headless` runs the shared `apps/ade-cli` services in-process without going through a daemon. Used when no daemon is reachable. + +Shared chat DTOs are imported from `apps/desktop/src/shared/types/*` (never the renderer barrel) so `npm run typecheck` in `apps/ade-cli` covers both typed commands and the TUI. Entry: `apps/ade-cli/src/tuiClient/cli.tsx` → `apps/ade-cli/dist/tuiClient/cli.mjs`, loaded by `ade code`. The TUI can hand off to a desktop window via the `app/navigate` JSON-RPC method when a desktop client is attached to the same runtime. -### 2.4 iOS companion (`apps/ios/`) +### 2.4 iOS client (`apps/ios/`) -Native SwiftUI app acting as a controller for an ADE host. It reads live desktop state from a local cr-sqlite-backed SQLite database and sends commands to the host for execution. The phone never runs agents. +Native SwiftUI app acting as a controller. It pairs with a runtime daemon over WebSocket and reads live state from a local cr-sqlite-backed SQLite database that mirrors the project's `ade.db`. The phone never runs agents. - Stack: native SwiftUI + `SQLite3` C API + iOS system SQLite. - CRDT: pure-SQL CRR emulation layer (trigger-based change tracking) since iOS blocks `sqlite3_load_extension()`/`sqlite3_auto_extension()`. Changesets are wire-compatible with desktop cr-sqlite. -- Core services: `Database.swift`, `SyncService.swift`, `KeychainService.swift`, - `LiveActivityCoordinator.swift`. +- Core services: `Database.swift`, `SyncService.swift`, `KeychainService.swift`, `LiveActivityCoordinator.swift`. - Shipped tabs: Lanes, Files, Work, PRs, CTO, Settings. -- Shipped: APNs push pipeline (desktop `apnsService` + `notificationEventBus` → - iOS `AppDelegate` + `NotificationCategories` + Notification Service Extension), - workspace Live Activity (Lock Screen + Dynamic Island), Home Screen / Lock - Screen / Control Center widgets. +- Shipped: APNs push pipeline (runtime-side `apnsService` + `notificationEventBus` → iOS `AppDelegate` + `NotificationCategories` + Notification Service Extension), workspace Live Activity (Lock Screen + Dynamic Island), Home Screen / Lock Screen / Control Center widgets. - Planned: Missions, Automations, Graph, History tabs; iPad layout; Spotlight. - Target: iOS 26+, iPhone + iPad. +### 2.5 Web app (`apps/web/`) + +A Vite/React SPA that serves the public marketing site and download page. Four pages: `HomePage`, `DownloadPage`, `PrivacyPage`, `TermsPage`. Independent package (`ade-web`), deployed via Vercel (`apps/web/vercel.json`). Not a runtime dependency of the desktop app. Shared-origin with the Mintlify docs site (`docs.json` at repo root). + --- ## 3. Data Plane @@ -173,7 +174,7 @@ Native SwiftUI app acting as a controller for an ADE host. It reads live desktop ADE uses Node's native `node:sqlite` driver (no better-sqlite3 dependency) with a vendored cr-sqlite loadable extension: -- **Engine source**: `apps/desktop/src/main/services/state/kvDb.ts` (schema bootstrap, CRR enablement, sync API) and `crsqliteExtension.ts` (extension loader). +- **Engine source**: `apps/desktop/src/main/services/state/kvDb.ts` (schema bootstrap, CRR enablement, sync API) and `crsqliteExtension.ts` (extension loader). Both the desktop main process and the ADE CLI runtime import the same engine module from here; they do not maintain parallel schemas. The database is owned by whichever process opened it first for a given project — in normal operation that is the runtime daemon, with desktop's in-process services acting as legacy fallbacks. - **Database file**: `<project_root>/.ade/ade.db`. - **WAL mode** handles durability; `flushNow()` is a no-op. - **CRRs**: eligible tables are marked via `SELECT crsql_as_crr('table_name')` at startup. Virtual/internal tables (`sqlite_%`, `crsql_%`, `unified_memories_fts%`) are excluded. Marking is dynamic — new tables are picked up automatically unless excluded. @@ -284,6 +285,10 @@ Service entry points live under `apps/desktop/src/main/services/ai/`. The subsys - `providerRuntimeHealth.ts` — per-provider health (`ready`, `auth-failed`, `runtime-failed`). - `claudeRuntimeProbe.ts` — lightweight SDK probe on force-refresh to confirm the Claude CLI + ADE CLI path can actually start. - `modelsDevService.ts` — non-blocking 6-hour refresh that enriches pricing and context-window metadata in the registry from `models.dev`. +- **ADE action status surface**: `ai.getStatus`, `ai.listApiKeys`, and + `ai.getOpenCodeRuntimeDiagnostics` expose the same provider readiness, + stored-key, and OpenCode runtime health data to renderer settings and + `ade code` model setup through the shared ADE action registry. - **Fallback**: if no usable provider is present, ADE runs in **guest mode** — deterministic features (packs, diffs, conflicts) continue; AI surfaces are disabled with explanatory UI. ### 4.2 Permission modes (provider-native + ADE) @@ -408,6 +413,7 @@ ade.updates.* - Every handler is wrapped with a **30-second timeout** — if it does not resolve, the call rejects with a timeout error rather than hanging the renderer. - Every handler emits structured tracing: `ipc.invoke.begin`, `ipc.invoke.done`, `ipc.invoke.failed` with call ID, channel, window ID, duration, and summarized args/results. - `AppContext` indirection: handlers close over a context pointer that swaps atomically on project switch, so IPC channels remain registered across project transitions. +- **Multi-window shell** — the app can host multiple `BrowserWindow` instances (for example when opening another project in a dedicated window). Handler tracing already carries **window ID** so logs and diagnostics distinguish which renderer surface invoked a channel; `main.ts` ties each window to its project context before routing into services. ### 5.4 Event subscriptions (push, not poll) @@ -435,9 +441,9 @@ Renderer telemetry events flow back to main: `renderer.route_change`, `renderer. --- -## 6. Services Catalog (Main Process) +## 6. Services Catalog (Desktop Client Main Process) -Every service lives under `apps/desktop/src/main/services/<domain>/`. Summary: +Most services described here live under `apps/desktop/src/main/services/<domain>/` in the desktop client's main process. Some are runtime delegations: they front a runtime-owned subsystem (project registry, sync host, agent registry, credential store, multi-project RPC) through a thin local pool plus, where applicable, a legacy in-process fallback. The runtime-side equivalents live under `apps/ade-cli/src/services/`. Summary: | Domain | Key files | Role | |--------|-----------|------| @@ -450,7 +456,7 @@ Every service lives under `apps/desktop/src/main/services/<domain>/`. Summary: | `computerUse/` | `computerUseArtifactBrokerService.ts`, `controlPlane.ts`, `localComputerUse.ts`, `agentBrowserArtifactAdapter.ts`, `syntheticToolResult.ts` | Proof-artifact broker (ingests, owner links, review state, routing), control-plane snapshot helpers, macOS capture capability descriptor, agent-browser payload parser, and the synthetic-tool-result helper used by the Claude compaction path. `proofObserver.ts` was removed in the rebuild — there is no passive auto-ingest. | | `config/` | `projectConfigService.ts`, `laneOverlayMatcher.ts` | Load/save `.ade/ade.yaml` + `local.yaml`; trust enforcement; lane overlays. | | `conflicts/` | `conflictService.ts` | Pairwise dry-merge simulation, risk matrix, proposal generation. | -| `cto/` | `ctoStateService.ts`, `workerAgentService.ts`, `workerBudgetService.ts`, `workerHeartbeatService.ts`, `linearSyncService.ts`, `linearIngressService.ts`, `linearOAuthService.ts`, `linearRoutingService.ts`, `linearDispatcherService.ts`, `linearCloseoutService.ts`, `openclawBridgeService.ts`, `flowPolicyService.ts` | CTO identity + core memory; worker agents; Linear sync/ingress/OAuth/routing/dispatcher/closeout; OpenClaw bridge. | +| `cto/` | `ctoStateService.ts`, `workerAgentService.ts`, `workerBudgetService.ts`, `workerHeartbeatService.ts`, `linearSyncService.ts`, `linearIngressService.ts`, `linearOAuthService.ts`, `linearRoutingService.ts`, `linearDispatcherService.ts`, `linearCloseoutService.ts`, `flowPolicyService.ts` | CTO identity + core memory; worker agents; Linear sync/ingress/OAuth/routing/dispatcher/closeout. | | `devTools/` | `devToolsService.ts` | Probe for git + `gh` CLI availability. | | `diffs/` | `diffService.ts` | Diff computation for file panes. | | `feedback/` | `feedbackReporterService.ts` | In-app feedback reporting. Two-stage: `prepareDraft` generates a structured issue title + labels (AI-assisted when a model is selected, deterministic fallback otherwise) so the user can review before posting; `submitPreparedDraft` files the GitHub issue. Each submission records `generationMode` and a `generationWarning` so the UI can flag deterministic drafts. | @@ -459,11 +465,12 @@ Every service lives under `apps/desktop/src/main/services/<domain>/`. Summary: | `github/` | `githubService.ts` | GitHub REST/GraphQL access; PR CRUD; checks; reviewers. | | `history/` | `operationService.ts` | Operation audit records (one row per mutation). | | `ios/` | `iosSimulatorService.ts` | macOS-only iOS Simulator backend: tool readiness probes, simctl device + app discovery, build/install/launch with progress events (hardened with `simctl bootstatus` and `simctl install` timeouts), screenshot + ADEInspector + accessibility hit-test, IOSurface/Indigo primary streaming and input with idb/simctl/window-capture fallbacks, recovery-only H.264+ffmpeg after idb MJPEG failure, and single-owner chat session locking. The macOS Simulator window placement / capture state probe (`getSimulatorWindowState`, `prepareSimulatorWindowForCapture`) lives next to the IPC handlers in `ipc/registerIpc.ts` because it depends on the active `BrowserWindow`. See [features/ios-simulator/README.md](./features/ios-simulator/README.md). | -| `ipc/` | `registerIpc.ts` | Single registration point for all IPC handlers. | +| `ipc/` | `registerIpc.ts`, `runtimeBridge.ts`, `ipcTimeouts.ts` | Single registration point for all IPC handlers. `runtimeBridge.ts` owns the runtime-facing channels (remote target registry, remote-runtime connect / project list / action dispatch / event stream, local-work checks, LAN discovery) and routes runtime calls through `LocalRuntimeConnectionPool` or `RemoteConnectionPool` based on the active window binding. `ipcTimeouts.ts` carries the shared 30-second handler timeout wrapper. | | `jobs/` | `jobEngine.ts` | Event-driven background scheduler for lane refresh + conflict prediction. Coalesced, debounced. | | `keybindings/` | `keybindingsService.ts` | User keybindings read/write. | | `lanes/` | `laneService.ts`, `laneEnvironmentService.ts`, `laneTemplateService.ts`, `laneProxyService.ts`, `portAllocationService.ts`, `autoRebaseService.ts`, `rebaseSuggestionService.ts`, `laneLaunchContext.ts`, `oauthRedirectService.ts`, `runtimeDiagnosticsService.ts` | Worktree lifecycle, env bootstrap, templates, reverse proxy, port leases, auto-rebase, suggestions, OAuth redirect, diagnostics. | | `logging/` | `logger.ts` | File-backed structured logger. | +| `localRuntime/` | `localRuntimeConnectionPool.ts` | Desktop-side client for the local `ade serve` daemon. Spawns or attaches to the machine socket, registers local projects with `projects.add`, dispatches local runtime actions, and installs the background service best-effort in packaged builds. | | `macosVm/` | `macosVmService.ts`, `rfbDirectClient.ts` | Lane-tied macOS VM lifecycle and GUI control. Uses Lume, stores VM records in `.ade/cache`, stores VNC credentials in `.ade/secrets`, mounts direct lane roots when safe, otherwise keeps a sanitized rsync mirror, and exposes screenshot/click/type/select through headless VNC or visible-window fallbacks. | | `memory/` | `unifiedMemoryService.ts` (canonical; listed under `memory/memoryService.ts`), `memoryBriefingService.ts`, `memoryLifecycleService.ts`, `batchConsolidationService.ts`, `embeddingService.ts`, `embeddingWorkerService.ts`, `hybridSearchService.ts`, `episodicSummaryService.ts`, `knowledgeCaptureService.ts`, `humanWorkDigestService.ts`, `proceduralLearningService.ts`, `compactionFlushPrompt.ts`, `skillRegistryService.ts`, `memoryFilesService.ts`, `memoryRepairService.ts`, `missionMemoryLifecycleService.ts` | Unified memory subsystem — see §10. | | `missions/` | `missionService.ts`, `missionPreflightService.ts`, `phaseEngine.ts` | Mission CRUD, preflight validation, phase lifecycle. | @@ -474,11 +481,12 @@ Every service lives under `apps/desktop/src/main/services/<domain>/`. Summary: | `projects/` | `adeProjectService.ts`, `configReloadService.ts`, `projectService.ts`, `logIntegrityService.ts`, `recentProjectSummary.ts`, `projectBrowserService.ts`, `projectDetailService.ts` | Project detection + `.ade` repair/bootstrap, reload on config change, recent-project metadata. `projectBrowserService` is the in-app directory autocomplete used by the Command Palette project browser (typed-path completion, `.git` detection, home expansion, system-picker fallback); `projectDetailService` returns repo metadata (branch, dirty count, ahead/behind, last commit, README excerpt, language mix, lane count, last-opened) for the palette's preview pane. | | `prs/` | `prService.ts`, `prPollingService.ts`, `prSummaryService.ts`, `queueLandingService.ts`, `issueInventoryService.ts`, `prIssueResolver.ts`, `prRebaseResolver.ts`, `integrationPlanning.ts`, `integrationValidation.ts` | PR CRUD, polling (with per-PR `last_polled_at` cursor), AI summary cache keyed by `(prId, head_sha)`, stacked-queue landing, issue inventory, AI-assisted resolution, integration planning, and merge-into-existing-lane proposal adoption. | | `pty/` | `ptyService.ts` | `node-pty` spawn, PTY I/O bridging, transcript writing. | +| `remoteRuntime/` | `remoteTargetRegistry.ts`, `sshTransport.ts`, `remoteBootstrap.ts`, `remoteConnectionPool.ts`, `runtimeRpcClient.ts` | Saved SSH machines, ssh-agent/key based transport, first-connect runtime upload/version verification, remote project catalog, action dispatch, and reconnect/eviction for remote runtime bindings. | | `runtime/` | `tempCleanupService.ts` | Runtime temp cleanup. | | `sessions/` | `sessionService.ts`, `sessionDeltaService.ts` | Terminal session CRUD, post-session delta computation. | | `shared/` | `utils.ts`, `queueRebase.ts`, `packLegacyUtils.ts`, `transcriptInsights.ts` | Cross-domain utilities. | | `state/` | `kvDb.ts`, `crsqliteExtension.ts`, `globalState.ts`, `projectState.ts`, `onConflictAudit.ts` | SQLite schema + open, CRR extension loader, global state file, per-project state init. `globalState.upsertRecentProject` accepts `preserveRecentOrder` so reactivating an already-known project (by app focus, deep link, etc.) refreshes its `lastOpenedAt` in place instead of jumping it to the front of the recents list. | -| `sync/` | `syncService.ts`, `syncHostService.ts`, `syncPeerService.ts`, `syncRemoteCommandService.ts`, `syncProtocol.ts`, `deviceRegistryService.ts`, `syncPairingStore.ts` | WebSocket host, peer client, remote command routing, protocol framing, device registry, pairing secrets. | +| `sync/` | `syncService.ts`, `syncHostService.ts`, `syncPeerService.ts`, `syncRemoteCommandService.ts`, `syncProtocol.ts`, `deviceRegistryService.ts`, `syncPairingStore.ts` | **Thin delegation to the runtime daemon's sync host plus a legacy in-process fallback.** The authoritative sync host now lives in `apps/ade-cli/src/services/sync/`; the desktop main-process instances default to a non-host viewer role for legacy state. The old in-process host is disabled unless `ADE_ENABLE_DESKTOP_SYNC_HOST=1` (diagnostics only). Wire formats — WebSocket envelope, remote command routing, device registry, pairing secrets — are the same across both implementations. | | `notifications/` | `apnsService.ts`, `notificationMapper.ts`, `notificationEventBus.ts` | APNs HTTP/2 client (ES256 JWT, encrypted `.p8`), pure domain-event → `MappedNotification` mapping (13 categories / 4 families), event bus routing to APNs alert pushes + Live Activity update pushes + in-app WS delivery, filtered by per-device `NotificationPreferences`. | | `tests/` | `testService.ts` | Test-suite execution + run history. | | `updates/` | `autoUpdateService.ts` | Electron auto-update wrapper around `electron-updater`. Owns the renderer-visible `AutoUpdateSnapshot` (`idle | checking | downloading | ready | installing | error`), uses `compareUpdateVersions` (SemVer-aware) to dedupe / supersede staged installers and to reconcile `pendingInstallUpdate` against the running version on next boot. `quitAndInstall()` is async: it re-runs `checkForUpdates({ allowReady: true })` to confirm the staged build is still latest, and only then flips to `installing` and calls `updater.quitAndInstall(false, true)`. | @@ -847,19 +855,21 @@ Renderer surfaces: ## 13. Multi-Device Sync +The sync subsystem is **owned by the ADE runtime daemon** (`apps/ade-cli/src/services/sync/`). When a project is opened, its scope creates a sync service inside the runtime; that runtime is the host. The desktop client and iOS client both connect to the same host. Desktop's old in-process host code path is disabled by default and only re-enabled with `ADE_ENABLE_DESKTOP_SYNC_HOST=1` for diagnostics. + ### 13.1 cr-sqlite CRDT + WebSocket -- **Desktop**: native cr-sqlite loadable extension (`.dylib`) loaded via `openKvDb(...)` in `kvDb.ts`. -- **iOS**: pure-SQL CRR emulation in `apps/ios/ADE/Services/Database.swift` — `crsql_master`, `crsql_site_id`, `crsql_changes`, per-table `<table>__crsql_clock` tables replicated as plain SQLite, with INSERT/UPDATE/DELETE triggers writing Lamport-versioned rows to `crsql_changes`. Custom SQLite functions (`ade_next_db_version()`, `ade_local_site_id()`, `ade_capture_local_changes()`) provide trigger context. Changesets are wire-compatible with desktop cr-sqlite. +- **Runtime / desktop**: native cr-sqlite loadable extension (`.dylib` / `.dll`) loaded via `openKvDb(...)` in `kvDb.ts`. +- **iOS**: pure-SQL CRR emulation in `apps/ios/ADE/Services/Database.swift` — `crsql_master`, `crsql_site_id`, `crsql_changes`, per-table `<table>__crsql_clock` tables replicated as plain SQLite, with INSERT/UPDATE/DELETE triggers writing Lamport-versioned rows to `crsql_changes`. Custom SQLite functions (`ade_next_db_version()`, `ade_local_site_id()`, `ade_capture_local_changes()`) provide trigger context. Changesets are wire-compatible with the runtime's cr-sqlite. - **Merge**: last-writer-wins per column. Each device has a unique site ID; Lamport timestamps per column. - **Sync API** (`AdeDb.sync`): `getSiteId`, `getDbVersion`, `exportChangesSince(version)`, `applyChanges(changes)`. - **Transport**: WebSocket on port 8787 (configurable); JSON-framed changesets + zlib compression for large batches; 30s ping/pong. The same envelope channel carries project catalog and project-switch handoff messages before the phone reconnects to a project-specific sync host. ### 13.2 Device model -- **Host**: one reachable desktop-class machine owns live execution side effects (agents, missions, PTYs, processes). Stored in the synced `sync_cluster_state` singleton row (`brain_device_id` is the legacy internal column name; user-facing language is "host"). Transfer requires a clean preflight (no active missions, running turns, live PTYs, running processes). Paused missions, CTO history, and idle chats are durable and survive handoff. -- **Controllers**: other connected devices (phones always; a second desktop optionally). Controllers read synced state and send commands to the host. -- **Independent desktops**: a second Mac can work independently through git without joining an ADE sync session. The tracked `.ade/` scaffold/config layer makes a clone look like an ADE project immediately. +- **Host**: a runtime daemon on one reachable machine owns live execution side effects (agents, missions, PTYs, processes) for a given project. Stored in the synced `sync_cluster_state` singleton row (`brain_device_id` is the legacy internal column name; user-facing language is "host"). Transfer requires a clean preflight (no active missions, running turns, live PTYs, running processes). Paused missions, CTO history, and idle chats are durable and survive handoff. +- **Controllers**: other connected devices (phones always; a second desktop optionally). Controllers read synced state and send commands to the host runtime. +- **Independent desktops**: a second Mac can run its own runtime daemon and work independently through git without joining an ADE sync session. The tracked `.ade/` scaffold/config layer makes a clone look like an ADE project immediately. ### 13.3 iOS companion sync model @@ -906,16 +916,15 @@ Full detail: [`docs/architecture/MULTI_DEVICE_SYNC.md`](../docs/architecture/MUL ``` ADE/ ├── apps/ -│ ├── desktop/ # Electron main/preload/renderer (primary product) -│ ├── ade-cli/ # Headless ADE CLI (Node, JSON-RPC over stdio) -│ ├── web/ # Marketing + download landing (Vite + React) -│ └── ios/ # Native SwiftUI controller +│ ├── ade-cli/ # ADE runtime daemon (`ade serve`), `ade` CLI, `ade code` terminal client +│ ├── desktop/ # Electron client (multi-window; local + SSH-bound runtime bindings) +│ ├── ios/ # Native SwiftUI controller (WebSocket to runtime daemon) +│ └── web/ # Marketing + download landing (Vite + React) ├── docs/ │ ├── PRD.md │ ├── architecture/ # Deep subsystem docs (source for this file) │ ├── features/ │ └── final-plan/ -├── new-docs/ # This file + feature docs ├── scripts/ # Release, validate, notarize, after-pack (per-platform) │ # Platform-specific: validate-mac-artifacts.mjs, │ # validate-win-artifacts.mjs, ade-cli-windows-wrapper.cmd, etc. @@ -939,7 +948,7 @@ Per-app scripts: | App | Key scripts | |-----|-------------| | `apps/desktop` | `dev`, `build` (tsup + vite), `typecheck`, `test` (vitest), `lint` (ESLint), `dist:mac`, `dist:mac:universal:signed:zip`, `notarize:mac:dmg`, `validate:mac:artifacts`, `rebuild:native`, `version:ci`, `version:release`, `ade:dev`, `ade:build`, `ade:test`. | -| `apps/ade-cli` | `dev`, `build`, `typecheck`, `test`. | +| `apps/ade-cli` | `dev`, `build`, `typecheck`, `test` (typed CLI commands, headless runtime, and Ink Work chat TUI). | | `apps/web` | `dev`, `build`, `preview`, `typecheck`. | | `apps/ios` | Xcode project; tests via `xcodebuild test` / Xcode. | @@ -947,7 +956,7 @@ Per-app scripts: Stages: -1. **Install** (`install` job) — checkout, setup Node 22, parallel `npm ci` across desktop/ade-cli/web with shared cache keyed on all three lockfiles. +1. **Install** (`install` job) — checkout, setup Node 22, parallel `npm ci` across desktop, ade-cli, and web with a shared cache keyed on those lockfiles. 2. **Parallel checks**: - `secret-scan` — gitleaks on full history. - `typecheck-desktop` — `cd apps/desktop && npm run typecheck`. @@ -956,7 +965,7 @@ Stages: - `lint-desktop` — ESLint on `src/**/*.{ts,tsx}`. - `test-desktop` — **8-way shard matrix**: `npx vitest run --shard=${{ matrix.shard }}/8` across shards 1–8. - `test-ade-cli` — full ade-cli vitest. - - `build` — all three apps built sequentially after install. + - `build` — desktop, ade-cli, and web built sequentially after install. - `validate-docs` — `node scripts/validate-docs.mjs`. 3. **Gate** (`ci-pass`) — all required jobs must pass (`if: always()` with failure/cancelled detection). @@ -1062,5 +1071,5 @@ Post-packaging hardening (`apps/desktop/scripts/`): - UI framework · [`docs/architecture/UI_FRAMEWORK.md`](../docs/architecture/UI_FRAMEWORK.md) - Multi-device sync · [`docs/architecture/MULTI_DEVICE_SYNC.md`](../docs/architecture/MULTI_DEVICE_SYNC.md) - iOS app · [`docs/architecture/IOS_APP.md`](../docs/architecture/IOS_APP.md) -- Feature docs (this directory) · [`new-docs/features/`](./features/) +- Feature docs · [`docs/features/`](./features/) - Product spec · [`docs/PRD.md`](../docs/PRD.md) diff --git a/docs/PRD.md b/docs/PRD.md index 5902ea3f0..8f8589b3e 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,6 +1,6 @@ # ADE — Product Requirements -ADE is an Electron desktop app for AI-assisted software engineering. It orchestrates lanes of work (git-worktree isolation), multi-provider AI chat, multi-agent missions, a persistent CTO agent, pipeline automations, PR stacking, conflict simulation, computer-use proofs, a cross-scope memory system, and optional iOS companion sync. +ADE is a **per-machine local-first runtime daemon** for AI-assisted software engineering. The runtime owns projects, git-worktree lanes of work, multi-provider AI chat, multi-agent missions, a persistent CTO agent, pipeline automations, PR stacking, conflict simulation, computer-use proofs, a cross-scope memory system, and multi-device sync. Three first-party clients attach to it as peers: the **Electron desktop app** (multi-window, one window per project, optionally bound to a remote runtime over SSH), the **`ade code` terminal client**, and the **iOS app**. The same `ade` CLI is also used directly from any shell. This doc is the entry point. Every major feature and concept is linked to its detailed breakdown in [`features/`](./features/). For how the pieces fit together, read [ARCHITECTURE.md](./ARCHITECTURE.md) next. @@ -8,15 +8,25 @@ This doc is the entry point. Every major feature and concept is linked to its de ## What ADE Is -ADE is a single-user, project-local workbench that runs AI agents against your codebase without them stepping on each other. The primary unit is a **lane**: an isolated git worktree + runtime + agent session. You can run many lanes concurrently — each with its own chat, its own processes, its own PR. Lanes compose into **stacks** (dependency chains) and graduate into **missions** (multi-agent, multi-step orchestrated runs) when the work is bigger than a single session. +ADE is a single-user development control plane that runs as a **runtime daemon on each machine** (`apps/ade-cli/`, started with `ade serve`, listening on `~/.ade/sock/ade.sock`, installable as a login service via launchd / systemd / Windows). The daemon hosts **multiple projects** through a project registry; project-scoped operations dispatch through the multi-project JSON-RPC surface (`projects.*`, `sync.*`, `ade/actions/call` with a `projectId`). -Layered on top: -- **Agents** — chat, CTO operator, workers. Multi-provider (Anthropic, OpenAI, Claude Code CLI, Codex, OpenCode, Cursor). Tool-aware. -- **Memory** — persistent knowledge across sessions. Scoped to global/project/session/agent. +The clients of that runtime are equal: + +- **Electron desktop** (`apps/desktop/`) — multi-window UI. Local windows attach to the local runtime through `LocalRuntimeConnectionPool`. Windows can also be bound to a remote machine over SSH; that path runs `ade rpc --stdio` on the remote and routes runtime-backed APIs through `RemoteConnectionPool`. Some legacy in-process services remain as fallbacks. +- **ADE Code (`ade code`)** — terminal-native Work chat (Ink + React) in `apps/ade-cli/src/tuiClient/`. Defaults to attaching to the machine socket; starts `ade serve` if missing. `--embedded` keeps the legacy in-process fallback explicit. +- **iOS app** (`apps/ios/`) — SwiftUI controller; connects to the runtime over WebSocket. The phone never runs agents. +- **SSH-attached desktop** — a desktop window pointed at a remote runtime is the same client as a local window; the runtime daemon is what differs. + +The primary unit of work inside any project is a **lane**: an isolated git worktree + per-lane process pool + agent session. Many lanes run concurrently — each with its own chat, its own processes, its own PR. Lanes compose into **stacks** (dependency chains) and graduate into **missions** (multi-agent, multi-step orchestrated runs) when the work is bigger than a single session. + +Layered on top, all owned by the runtime: +- **Agents** — chat, CTO operator, workers. Multi-provider (Anthropic, OpenAI, Claude Code CLI, Codex, OpenCode, Cursor). Tool-aware. CTO worker adapter types: `claude-local`, `codex-local`, `process`. +- **Memory** — persistent knowledge across sessions. Scoped to project/agent/mission. - **Automations** — rule-based background workflows triggered by events, cron, webhooks. - **Computer use** — control plane that fans out to Ghost OS, agent-browser, or local fallback for UI automation proofs. - **Linear** — first-class two-way integration owned by the CTO agent. -- **Multi-device sync** — cr-sqlite CRDT replication between desktop and iOS companion. +- **Multi-device sync** — cr-sqlite CRDT replication, owned by the runtime daemon. The iOS app and any controller desktops connect through the same sync host. +- **Remote runtime** — the desktop ships per-platform `ade-<platform-arch>` binaries plus native deps under `apps/desktop/resources/runtime/`; `bootstrapRemoteRuntime` uploads them on first SSH connect. Headless installs use `curl … install.sh | sh`. ADE is the control plane. It does not execute browser automation or computer-use itself — it dispatches to backends and normalizes their artifacts. @@ -26,12 +36,14 @@ ADE is the control plane. It does not execute browser automation or computer-use | Concept | Summary | Doc | | --- | --- | --- | -| Lane | Isolated git worktree + runtime + agent session for one task. | [lanes/README.md](./features/lanes/README.md) | +| Runtime | The per-machine ADE daemon (`ade serve`, `~/.ade/sock/ade.sock`). Hosts every project; desktop, `ade code`, and iOS attach as clients. Installable as a launchd / systemd / Windows login service. | [remote-runtime/README.md](./features/remote-runtime/README.md) | +| Project | One repo entry in the runtime's project registry. Identified by stable hash of root path; addressed in the multi-project RPC by `projectId`. | [remote-runtime/README.md](./features/remote-runtime/README.md) | +| Lane | Isolated git worktree + per-lane process pool + agent session for one task. | [lanes/README.md](./features/lanes/README.md) | | Stack | Dependency chain of lanes → stacked PRs. | [lanes/stacking.md](./features/lanes/stacking.md) | | Mission | Multi-step orchestrated run with a coordinator agent, sub-workers, validation gates, and a result lane. | [missions/README.md](./features/missions/README.md) | | Agent | Typed persona with identity, tool tier, budget, and session log. CTO + workers + chat agents. | [agents/README.md](./features/agents/README.md) | | Worktree | Git clone dir under `.ade/worktrees/<lane-id>/`, one per lane. | [lanes/worktree-isolation.md](./features/lanes/worktree-isolation.md) | -| Runtime | Per-lane process pool + env + ports + proxy + diagnostics. | [lanes/runtime.md](./features/lanes/runtime.md) | +| Lane runtime | Per-lane process pool + env + ports + proxy + diagnostics. | [lanes/runtime.md](./features/lanes/runtime.md) | | Session | PTY-backed terminal session pinned to a lane. | [terminals-and-sessions/README.md](./features/terminals-and-sessions/README.md) | | Memory | Structured, searchable, compaction-aware knowledge entries. | [memory/README.md](./features/memory/README.md) | | Proof | Normalized computer-use artifact (screenshot, recording, network log). | [computer-use/artifact-broker.md](./features/computer-use/artifact-broker.md) | @@ -40,9 +52,14 @@ ADE is the control plane. It does not execute browser automation or computer-use ## Feature Index +### Runtime and clients + +- [**Remote Runtime**](./features/remote-runtime/README.md) — The per-machine ADE daemon. Multi-project registry, machine socket, login-service install, SSH bootstrap of the cross-platform `ade-<platform-arch>` runtime binaries shipped under `apps/desktop/resources/runtime/`. Owns sync. +- [**ADE Code**](./features/ade-code/README.md) — Terminal-native Work chat (Ink + React) inside `apps/ade-cli`. Default attaches to the machine socket and starts `ade serve` if missing. Same JSON-RPC surface as the desktop app and the iOS controller. + ### Work execution -- [**Lanes**](./features/lanes/README.md) — Worktree isolation, stacking, runtime, OAuth redirect, diagnostics. Each lane is a sandbox. Stacks are dependency chains. Runtime covers ports, env, proxy, processes. +- [**Lanes**](./features/lanes/README.md) — Worktree isolation, stacking, lane runtime, OAuth redirect, diagnostics. Each lane is a sandbox. Stacks are dependency chains. Lane runtime covers ports, env, proxy, processes. - [**Pull Requests**](./features/pull-requests/README.md) — Stacked PRs, merge queue, conflict simulation, integration merge plans, and merge-into-lane workflows. Backed by lanes; dependencies rebase automatically. - [**Conflicts**](./features/conflicts/README.md) — Pre-flight detection (full pairwise matrix up to 15 lanes, prefilter above), live simulation via `git merge-tree`, AI-assisted resolution, external CLI resolver flow. - [**Workspace Graph**](./features/workspace-graph/README.md) — React Flow canvas projecting lanes/PRs/conflicts/sessions into a single view. Staged hydration (topology first, then activity/risk/sync). @@ -50,7 +67,7 @@ ADE is the control plane. It does not execute browser automation or computer-use ### Agents and chat - [**Agents**](./features/agents/README.md) — Three surfaces: chat, CTO operator, workers. Identity, capability modes, tool tiers, heartbeats. -- [**Chat**](./features/chat/README.md) — Multi-provider, streaming, tool-aware. Transcript and turns, tool system (universal/workflow/coordinator), agent routing, composer + derived panels, and parallel multi-model lane launch. +- [**Chat**](./features/chat/README.md) — Multi-provider, streaming, tool-aware. Transcript and turns, tool system (universal/workflow/coordinator), agent routing, composer + derived panels, and parallel multi-model lane launch. Terminal client: [ADE Code](./features/ade-code/README.md). - [**Memory**](./features/memory/README.md) — Unified SQLite + FTS + embeddings. Write gate, compaction, procedural learning, daily sweep, hybrid retrieval (BM25+cosine+MMR). - [**History**](./features/history/README.md) — Operations timeline + chat transcripts + exports. Every service follows the same `runTrackedOperation` recording pattern. @@ -78,15 +95,17 @@ ADE is the control plane. It does not execute browser automation or computer-use ## Cross-Cutting Architecture -For the system-wide picture — apps, processes, data plane, IPC, security, build/test/deploy — read [**ARCHITECTURE.md**](./ARCHITECTURE.md). +For the system-wide picture — runtime + clients, processes, data plane, IPC, security, build/test/deploy — read [**ARCHITECTURE.md**](./ARCHITECTURE.md). Quick pointers: -- **Apps**: `apps/desktop/` (Electron main + preload + renderer), `apps/ade-cli/` (headless ADE CLI action server), `apps/web/` (marketing), `apps/ios/` (companion). -- **Main-process services**: `apps/desktop/src/main/services/<domain>/` — one directory per capability. +- **Runtime daemon**: `apps/ade-cli/` — `ade serve` is the per-machine source of truth for projects, lanes, chats, processes, sync, and proof. Socket: `~/.ade/sock/ade.sock`. Login-service installers: `apps/ade-cli/src/serviceManager/installLaunchd.ts` (macOS), `installSystemd.ts` (Linux), `installWindows.ts` (Windows). Multi-project RPC: `apps/ade-cli/src/multiProjectRpcServer.ts`. Project registry/scope: `apps/ade-cli/src/services/projects/`. Sync host: `apps/ade-cli/src/services/sync/`. Credentials, agent registry, runtime-side service surfaces: `apps/ade-cli/src/services/`. +- **Desktop client**: `apps/desktop/` — Electron main + preload + renderer. Multi-window. `LocalRuntimeConnectionPool` (`apps/desktop/src/main/services/localRuntime/`) speaks to the local runtime; `RemoteConnectionPool` (`apps/desktop/src/main/services/remoteRuntime/`) speaks to a runtime over SSH after `bootstrapRemoteRuntime` uploads the bundled `ade-<platform-arch>` binary. `preload.ts` routes runtime-backed APIs through those pools. Some legacy in-process services remain as fallback. +- **Terminal client**: `apps/ade-cli/src/tuiClient/` — `ade code` Ink + React Work chat. +- **iOS client**: `apps/ios/` — SwiftUI controller over WebSocket to the runtime daemon. - **Renderer components**: `apps/desktop/src/renderer/components/<feature>/`. -- **Shared types + IPC contract**: `apps/desktop/src/shared/`. -- **Data**: SQLite + cr-sqlite. `.ade/` per project, `~/.ade/` global. +- **Shared types + IPC contract**: `apps/desktop/src/shared/` (consumed by the desktop client and re-imported by the ADE CLI runtime). New runtime-facing types: `apps/desktop/src/shared/types/remoteRuntime.ts`, `core.ts`. +- **Data**: SQLite + cr-sqlite. `.ade/` per project (the runtime owns these files regardless of which client is attached), `~/.ade/` global. --- @@ -102,6 +121,10 @@ If you are an AI agent working on ADE, read in this order: The source of truth is always the code. Docs may lag on specific code paths — cross-check `git log` and the referenced files when in doubt. Fragile areas flagged across the docs (read docs before editing): +- Multi-project RPC + project scope/registry (`apps/ade-cli/src/multiProjectRpcServer.ts`, `services/projects/`) — every runtime call lives or dies here; getting `projectId` routing wrong silently corrupts cross-project state. +- Local vs. remote runtime pools (`apps/desktop/src/main/services/localRuntime/`, `remoteRuntime/`) — desktop binding switching, SSH bootstrap upload, version negotiation against bundled `ade-<platform-arch>` binaries. +- Sync host inside the runtime daemon (`apps/ade-cli/src/services/sync/`) — desktop's old in-process sync host is disabled by default and only re-enabled with `ADE_ENABLE_DESKTOP_SYNC_HOST=1` for diagnostics; do not assume desktop owns sync. +- Multi-window shell + `app/navigate` JSON-RPC handoff (desktop main `main.ts`, runtime side in `apps/ade-cli/src/adeRpcServer.ts`) — TUI/external controllers can drive desktop window navigation. - CTO pipeline builder — recent work, custom flat/nested target-chain translation. - PTY / sessions / processes services — rewritten this branch. - OAuth redirect service — complex three-state machine with HMAC signing. @@ -114,5 +137,5 @@ Fragile areas flagged across the docs (read docs before editing): - ADE does not run browser automation or accessibility-based UI control itself. It is a control plane; executors run elsewhere (Ghost OS, agent-browser CLI). - ADE does not host remote git servers. It operates on local worktrees against a GitHub remote. -- ADE does not multiplex multiple users. Single-user, project-local. +- ADE does not multiplex multiple users. Single-user, per-machine. - ADE does not ship a server-side web app. The `apps/web/` is marketing/docs-site only. diff --git a/docs/README.md b/docs/README.md index 5831ffc16..94f6e0c32 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,17 +2,19 @@ Navigation map for the internal docs. **Start with [PRD.md](./PRD.md).** +The mental model up front: ADE is a **per-machine runtime daemon** (`apps/ade-cli/`, run as `ade serve`) that owns projects, lanes, chats, processes, sync, and proof. The desktop app, the terminal `ade code` client, the iOS app, and SSH-attached desktop windows are all peer **clients** of that runtime. Read the entry-point docs in that order: + ## Reading order -1. [**PRD.md**](./PRD.md) — product scope, concepts, feature index (links to everything). -2. [**ARCHITECTURE.md**](./ARCHITECTURE.md) — apps, data plane, IPC, services catalog, security, build/test/deploy. -3. [**features/**](./features/) — per-feature subfolders, each with a `README.md` + detail docs. +1. [**PRD.md**](./PRD.md) — product scope, runtime + clients model, concepts, feature index. +2. [**ARCHITECTURE.md**](./ARCHITECTURE.md) — apps, runtime/client topology, data plane, IPC, services catalog, security, build/test/deploy. +3. [**features/**](./features/) — per-feature subfolders, each with a `README.md` + detail docs. Start with `remote-runtime/`, `ade-code/`, and `sync-and-multi-device/` for the runtime+clients picture. 4. [**playbooks/**](./playbooks/) — operational workflows agents can follow directly. ## Layout ``` -new-docs/ +docs/ ├── README.md # this file ├── PRD.md # product entry point ├── ARCHITECTURE.md # system architecture @@ -21,6 +23,7 @@ new-docs/ │ └── ship-lane.md # autonomous PR-to-merge driver └── features/ ├── agents/ # agent identity, tools, personas + ├── ade-code/ # terminal Work chat docs; source lives in apps/ade-cli/src/tuiClient ├── automations/ # rule triggers + actions + guardrails ├── chat/ # multi-provider agent chat ├── computer-use/ # proof control plane, backends, broker @@ -36,6 +39,7 @@ new-docs/ ├── onboarding-and-settings/ # first-run, schema, settings tabs ├── project-home/ # welcome + per-lane dashboard ├── pull-requests/ # stacking, queue, conflict simulation + ├── remote-runtime/ # local daemon + SSH remote machines ├── sync-and-multi-device/ # cr-sqlite CRDT, iOS, remote commands ├── terminals-and-sessions/ # PTY, sessions, processes, UI surfaces └── workspace-graph/ # React Flow canvas + data sources @@ -53,4 +57,4 @@ new-docs/ `docs.json` at the repo root configures the public-facing Mintlify docs site (`.mdx` files under `./chat/`, `./tools/`, `./missions/`, etc.). That site is user-facing and separate. -**This folder (`new-docs/`) is internal-only** — for engineers and AI agents working on ADE itself. +**This folder (`docs/`) is internal-only** — for engineers and AI agents working on ADE itself. diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md new file mode 100644 index 000000000..caa14d7a0 --- /dev/null +++ b/docs/features/ade-code/README.md @@ -0,0 +1,164 @@ +# ADE Code (terminal Work chat) + +`ade code` is a terminal-native client for the same **Work** agent chat surface the Electron app exposes in `AgentChatPane`. It targets agents and operators who prefer a shell-first workflow: Ink + React render the TUI, while chat transcripts, slash commands, lane navigation, model picks, and ADE actions all flow through the same JSON-RPC contracts the desktop uses. + +It is a client. The runtime, lanes, chats, transcripts, PRs, processes, and proof artifacts live in the per-machine `ade serve` daemon. `ade code` attaches to that daemon, drives a single project scope, and renders incoming events. + +## Source file map + +| Path | Role | +|------|------| +| `apps/ade-cli/src/cli.ts` | Resolves the built or source TUI entry and forwards the parsed launch context to `runAdeCodeCli`. | +| `apps/ade-cli/src/tuiClient/cli.tsx` | TUI entry: argv parsing, project discovery, connection bootstrap, Ink mount. Built to `apps/ade-cli/dist/tuiClient/cli.mjs`. | +| `apps/ade-cli/src/tuiClient/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle, slash command dispatch. | +| `apps/ade-cli/src/tuiClient/connection.ts` | Resolves attached vs embedded mode, runs the `ade/initialize` handshake, registers the project with `projects.add`, wraps subsequent requests with `projectId`. | +| `apps/ade-cli/src/tuiClient/jsonRpcClient.ts` | Socket client: connect, request/response, `chat/event` notifications. | +| `apps/ade-cli/src/tuiClient/adeApi.ts` | Typed wrappers over `AdeCodeConnection.action` / `actionList` for lanes, chat, models, navigation, provider readiness, API-key status, OpenCode diagnostics, and project slash-command discovery. | +| `apps/ade-cli/src/tuiClient/commands.ts` / `linearCommands.ts` | Slash command catalog and routing. | +| `apps/ade-cli/src/tuiClient/format.ts` | Transcript rendering helpers for the TUI. | +| `apps/ade-cli/src/tuiClient/state.ts` | Persists terminal-client state such as the last selected chat per lane under the project `.ade/cache` layout. | +| `apps/ade-cli/src/tuiClient/theme.ts` | Shared Ink color and status tokens used by the header, model setup pane, transcript, and controls. | +| `apps/ade-cli/src/tuiClient/types.ts` | `AdeCodeConnection`, `ProjectLaunchContext`, navigation DTOs aligned with `apps/desktop/src/shared/types`. | +| `apps/ade-cli/src/tuiClient/components/` | `AdeWordmark`, `Drawer`, `ChatView`, `Header`, `RightPane`, `SlashPalette`, `MentionPalette`, `ApprovalPrompt`, `ModelStatus`, `FooterControls`. | +| `apps/desktop/src/shared/types/chat.ts` | Canonical chat DTOs (`AgentChatEventEnvelope`, sessions, pending input). Imported per-module so ade-cli typecheck stays scoped. | +| `apps/desktop/src/shared/modelRegistry.ts` | Default model selection for new sessions (`getDefaultModelDescriptor`). | +| `apps/desktop/src/shared/adeLayout.ts` | Resolves project-scoped `.ade` paths. | + +## Modes + +### Attached (default) + +`ade code` opens a Unix-domain or named-pipe socket connection to the runtime daemon. Resolution order in `connectToAde`: + +1. `--socket /path/to/sock` on the parent `ade` process (also reads `ADE_RPC_SOCKET_PATH`). +2. The machine socket from `resolveMachineAdeLayout()` (`~/.ade/sock/ade.sock` or `\\.\pipe\ade-runtime`). +3. If the machine socket is not listening, `connection.ts` calls `spawnDaemon(socketPath)` — a detached `ade serve --socket <socketPath>` — and retries up to 25 times with a 200 ms delay. +4. As a final fallback, the legacy project-scoped socket from `resolveAdeLayout(projectRoot)` if the user passed `--require-socket` and the machine socket is unavailable. + +`ade code --print-state` exercises that whole path, prints the chosen mode and socket path, and exits. + +### Embedded + +`ade code --embedded` (or `ade --headless code`) skips the daemon and builds an `AdeRuntime` in-process via `loadEmbeddedAdeCli()`, which dynamic-imports `bootstrap` and `adeRpcServer` from the `ade-cli` package itself. Used for headless or development environments where Electron / `ade serve` is not present. This mode is single-project, single-process: closing the TUI tears the runtime down. + +`forceEmbedded` and `requireSocket` are mutually exclusive — `connectToAde` rejects the combination. + +## Initialize handshake + +Both modes run the same handshake before the TUI mounts: + +```text +-> ade/initialize { + protocolVersion: "2025-06-18", + clientName: "ade-code", + identity: { role: "cto", callerId: "ade-code:<pid>" } + } +<- { runtimeInfo: { multiProject: true, version, ... }, capabilities: { projects: true, ... } } +-> ade/initialized +``` + +If the response advertises `runtimeInfo.multiProject === true` or `capabilities.projects === true`, `connection.ts` calls `projects.add { rootPath: <project-root> }`, captures the returned `projectId`, and from then on every project-scoped request is rewritten to include `projectId`. The runtime-scoped methods (the set in `MULTI_PROJECT_RUNTIME_METHODS`: `ade/initialize`, `projects.*`, `ping`, `runtime/info`, etc.) pass through unchanged. + +For the embedded runtime there is no `projects.add` step — the in-process runtime is already bound to one project root. + +## TUI surface + +`apps/ade-cli/src/tuiClient/app.tsx` is the Ink root. Layout: + +- **Header** — project name, active lane, branch, and the terminal client frame. +- **Drawer** (toggled with the configured shortcut) — two sections: Lanes and Chats. Selecting a lane in the Lanes pane switches the active lane and filters the Chats pane to that lane's sessions. Lane and chat selection drive the right pane's context. +- **ChatView** — the main transcript. Renders user, assistant, tool, and system events from `chat/event` notifications. Tool calls collapse into expandable blocks; the most recent expandable failure id is tracked so `Enter` can drill into it. +- **Composer** — multi-line input with mention completion (`@…`) sourced from `MentionPalette` and slash command completion from `SlashPalette`. Pending tool approvals surface as `ApprovalPrompt`. +- **RightPane** — context-sensitive drawer for slash command output. The "right" placement commands (see below) render their results here as forms, lists, diffs, help text, or rendered objects. + +Heartbeats are kept alive with `startTuiHeartbeat` so the runtime knows the chat client is still attached. + +## Slash commands + +`commands.ts` exports the built-in slash command catalog. `placement` decides whether the command runs inline in the chat or opens the right pane. The TUI also discovers project command files and Codex prompts before a chat exists, then refreshes against server-provided `AgentChatSlashCommand`s from the active runtime via `getSlashCommands`. Provider/runtime commands win over same-named built-ins except for local terminal controls such as `/login`, `/quit`, `/clear`, and `/end`. + +Inline (acts on chat or shell): + +| Command | Effect | +| --- | --- | +| `/commit [message]` | Commit lane changes through `git.commit`. | +| `/push` | Push the active lane branch. | +| `/clear` | Clear the local TUI transcript view. | +| `/end` | End the active chat runtime. | +| `/open` | Hand the current ADE context off to desktop via `app/navigate`. | +| `/quit` | Exit `ade code`. | +| `/remember <fact>` | Write a durable ADE memory entry. | + +Right pane (open the contextual drawer): + +| Command | Pane | +| --- | --- | +| `/new lane` | Lane creation form. | +| `/new chat [title]` | New chat in the active lane. | +| `/rename [title]` | Rename the active chat. | +| `/status` | Project, lane, runtime state summary. | +| `/diff` | Active lane diff (file list with summarized hunks). | +| `/log` | Recent commits. | +| `/pr`, `/pr open`, `/pr review`, `/pr checks` | PR state, create/open PR, reviews, checks. | +| `/linear …` (`list`, `workflows`, `run`, `route`, `sync`, `ingress`, `pull`, `comment`, `status`, `assign`) | Linear sub-router; backed by `linearCommands.ts`. | +| `/memory [query]`, `/forget` | Search and manage ADE memory. | +| `/chats` | Sessions in the active lane. | +| `/switch [lane\|chat]` | Switcher palette. | +| `/resume` | Resume the active ended chat. | +| `/help` | Keymap and command help. | +| `/model`, `/effort` | Model and reasoning-effort pickers. | +| `/system` | System and runtime details. | +| `/ade <domain.action> [json]` | Run an allowlisted ADE action; shows result in RightPane. | + +Several slash commands forward to a desktop route when issued from `ade code`: + +```text +/app-control -> /app-control +/browser -> /browser +/computer -> /proof +/computer-use -> /proof +/ios, /ios-sim -> /ios-sim +/macos-vm -> /macos-vm +/mission, /missions -> /missions +/pencil -> /pencil +/proof -> /proof +``` + +`navigateDesktop` posts an `app/navigate` request to the same runtime, which the multi-window desktop shell uses to open or focus the appropriate window. The TUI does not host these surfaces itself; it points the desktop at them. + +## Project / lane resolution + +`chooseInitialLane` (in `tuiClient/project.ts`) picks the active lane on launch: + +1. The lane the user passed via `--lane` (if any). +2. The most recently active lane reported by `lanes.list`. +3. The first lane in the project, falling back to "no lane" when the project has none yet. + +Lane selection updates the daemon's session state so the same lane is reflected in desktop and iOS clients attached to the same runtime. + +## Launch + +```bash +ade code # attached to the machine daemon for the current project +ade code --print-state # smoke-test: print mode + socket and exit +ade code --embedded # in-process runtime fallback +ade --project-root /repo code # bind to a different project +ade --socket /tmp/ade-runtime-dev.sock code + # attach to a specific socket (dev runtime, peer machine, etc.) +``` + +After local changes, run `npm run build` inside `apps/ade-cli` so both `dist/cli.cjs` and `dist/tuiClient/cli.mjs` exist for packaged and linked use. During repo development, `npm run dev:code` runs the source TUI against the shared dev runtime at `/tmp/ade-runtime-dev.sock`. + +## Chat setup + +- `+ new chat` opens a draft setup view in the details pane; it does not create a backend chat until the first prompt is sent from the middle composer. +- `/model` opens the model setup view. It can switch provider, model, reasoning, and permission settings, refresh provider readiness through `ai.getStatus`, and open desktop Settings > AI Providers for full configuration. +- `/login` delegates only to provider CLIs that can authenticate in the current terminal: Claude (`claude auth login`), Codex (`codex login`), and OpenCode (`opencode auth login`). Cursor chat is `@cursor/sdk` and needs `CURSOR_API_KEY` or desktop Settings > AI Providers. Droid chat runs Factory Droid over ACP and needs `FACTORY_API_KEY` or Factory's interactive `droid` login. +- The middle composer shows the selected provider, model, reasoning, and permission mode under the prompt so draft changes on the right are visible before the chat starts. + +## Related docs + +- [ADE CLI](../../../apps/ade-cli/README.md) — runtime daemon, install paths, service manager, full CLI surface. +- [Chat feature](../chat/README.md) — in-app Work chat architecture (service + renderer); same agent chat backend. +- [Remote runtime](../remote-runtime/README.md) — how the same runtime daemon is reached over SSH. +- [System overview](../../ARCHITECTURE.md) — CLI / terminal client placement in the system diagram. diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index ffe6be636..7d4b6de12 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -13,12 +13,11 @@ registry / ADE CLI integration that all three share. | Path | Role | |---|---| | `apps/desktop/src/main/services/cto/ctoStateService.ts` | CTO identity, core memory, session logs, subordinate activity, immutable doctrine, personality overlays. The CTO's everything. | -| `apps/desktop/src/main/services/cto/workerAgentService.ts` | Worker identity and core memory CRUD. Persists `agent_identities` rows and `.ade/agents/<slug>/` files. | -| `apps/desktop/src/main/services/cto/workerAgentService.ts` (same file) | Also owns heartbeat, budget, and runtime policy hooks. | +| `apps/desktop/src/main/services/cto/workerAgentService.ts` | Worker identity and core memory CRUD; validates `adapterType` against the three-entry allowlist (`claude-local`, `codex-local`, `process`). Persists `agent_identities` rows and `.ade/agents/<slug>/` files. | | `apps/desktop/src/main/services/cto/workerHeartbeatService.ts` | Heartbeat scheduling for workers (wake-on-demand + periodic intervals). | | `apps/desktop/src/main/services/cto/workerBudgetService.ts` | Monthly budget tracking (`budgetMonthlyCents`, `spentMonthlyCents`). | | `apps/desktop/src/main/services/cto/flowPolicyService.ts` | Worker flow policies (guardrails, approval requirements). | -| `apps/desktop/src/main/services/cto/workerAgentService.ts` | Worker adapter configs: Claude-local, Codex-local, OpenClaw webhook, raw process. | +| `apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts` | Adapter lifecycle for the three supported worker adapter types. | | `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` | CTO-only tools (spawnChat, mission control, worker management, Linear dispatch). | | `apps/desktop/src/main/services/agentTools/agentToolsService.ts` | Detects external CLI tools (Claude Code, Codex, Cursor, Aider, Continue) on PATH. | | `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. Includes the `ade ios-sim` (alias `ade ios`, `ade simulator`) family — see [iOS Simulator feature](../ios-simulator/README.md), the `ade --socket app-control ...` driver for live Electron apps, and the `ade --socket browser ...` driver for the in-app browser (`browser panel`, `browser open <url> [--no-panel]`, `browser new-tab --background`, `browser switch`, `browser close`, plus selection / inspect commands). `ade chat create --provider codex --model <id> --fast` opts a new Codex session into the fast service tier; `ade shell start --lane <id> --chat-session <chatId>` (or `ADE_CHAT_SESSION_ID` from the env) attaches a tracked shell to an existing chat so `ade --socket terminal read --chat-session "$ADE_CHAT_SESSION_ID" --text` resolves to it. | @@ -64,6 +63,40 @@ resumes across restarts but has no long-term persona. The CTO and workers are just identity sessions layered on top of the same chat runtime. +## Agent CLI install / auth from chat + +When a chat session targets a provider whose CLI (Claude, Codex, +Cursor, Droid) is missing or not authenticated on the active runtime, +the chat surfaces an inline **AgentCliAuthCard** +(`apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx`). +The card carries an `AgentCliAuthCardInfo` payload built by the chat +service via `classifyAgentCliError` from +`apps/ade-cli/src/services/agentRegistry.ts` — the same registry the +runtime uses to recognise "binary not on PATH" vs "needs login" +patterns from a CLI's stderr. + +The card renders two action rows: an Install row when the CLI is +missing, and an Authenticate row in either case. Each row has: + +- A copy-to-clipboard chip for the canonical install / auth command + (e.g. `claude /login`, `codex login`, `cursor-agent login`). +- A **Run** button that opens a tracked PTY in the active lane + (`window.ade.pty.create`) with `startupCommand` set to that command + and `tracked: true` so the new terminal lands in the chat's + terminal drawer. + +The crucial property is that the install/login command runs in the +**active runtime** — the runtime the chat session is bound to. A +desktop window bound to a remote `ade serve` daemon launches the +install/auth in a PTY on that remote machine, not locally. So a user +pairing with a remote runtime sets up `claude` / `codex` / `cursor` on +the remote host without leaving the chat or SSHing in. + +The card also shows the runtime name (when known) in its body copy +("Authenticate the CLI on `darwin-mini`, then retry the chat.") so the +operator can see which machine the install will land on before +clicking Run. + ## Agent identity (CTO) ```ts @@ -80,7 +113,6 @@ type CtoIdentity = { }; constraints?: string[]; systemPromptExtension?: string; - openclawContextPolicy?: OpenclawContextPolicy; onboardingState?: CtoOnboardingState; modelPreferences: { provider: string; @@ -135,7 +167,7 @@ type AgentIdentity = { reportsTo: string | null; capabilities: string[]; status: "idle" | "active" | "paused" | "running"; - adapterType: "claude-local" | "codex-local" | "openclaw-webhook" | "process"; + adapterType: "claude-local" | "codex-local" | "process"; adapterConfig: AgentAdapterConfig; // adapter-specific runtimeConfig: { heartbeat?: HeartbeatPolicy; @@ -175,14 +207,15 @@ Persisted at: ## Adapter types -Workers dispatch through one of four adapter types: +Workers dispatch through one of three adapter types — there are no +others, and `apps/desktop/src/shared/types/agents.ts` types +`AdapterType` as exactly `"claude-local" | "codex-local" | "process"`: | Adapter | Config | Purpose | |---|---|---| | `claude-local` | `ClaudeLocalAdapterConfig` (model, cwd, cliArgs, instructions, timeout) | Spawns `claude` CLI locally. | | `codex-local` | `CodexLocalAdapterConfig` (model, cwd, cliArgs, reasoningEffort, timeout) | Spawns `codex` CLI locally. | -| `openclaw-webhook` | `OpenclawWebhookAdapterConfig` (URL, method, headers, bodyTemplate, timeout) | POSTs to an external service and waits for a response. | -| `process` | `ProcessAdapterConfig` (command, args, cwd, env, timeout, shell) | Generic subprocess. | +| `process` | `ProcessAdapterConfig` (command, args, cwd, env, timeout, shell) | Generic managed subprocess; the catch-all for wrapping anything that isn't `claude` / `codex`. | The worker service forwards the correct adapter config to the orchestrator when the worker is activated. diff --git a/docs/features/agents/identity-and-personas.md b/docs/features/agents/identity-and-personas.md index 5a8b55560..9c8b4ef69 100644 --- a/docs/features/agents/identity-and-personas.md +++ b/docs/features/agents/identity-and-personas.md @@ -172,9 +172,8 @@ configuration surface: - **Role** (`AgentRole`) -- `engineer`, `qa`, `designer`, `devops`, `researcher`, `general`. Used for prompt context, Linear routing, and UI grouping. `cto` is reserved. -- **Adapter** -- one of `claude-local`, `codex-local`, - `openclaw-webhook`, `process`. Determines how the worker is - activated. +- **Adapter** -- one of `claude-local`, `codex-local`, `process`. + Determines how the worker is activated. - **Runtime config** -- heartbeat policy, max concurrent runs. - **Budget** -- monthly cents cap + current spend. - **Linear identity** -- optional mapping to Linear user ids, @@ -410,4 +409,4 @@ setup before enabling the full CTO experience. - [Chat Agent Routing](../chat/agent-routing.md) -- provider selection and model preferences. </content> -</invoke> \ No newline at end of file +</invoke> diff --git a/docs/features/automations/README.md b/docs/features/automations/README.md index 6a751f5ee..2251e6110 100644 --- a/docs/features/automations/README.md +++ b/docs/features/automations/README.md @@ -2,12 +2,20 @@ Automations are rule-based background workflows. Each rule has a trigger, a target execution surface, a prompt/mission template, an optional tool palette, an optional output contract, and guardrails. Automations sit between the CTO (heavy, stateful, chat-driven) and raw cron (deterministic, no AI). The execution surface choice is the key control point. -Automations never duplicate Linear issue intake — the CTO owns that. Automations can consume Linear as context or write to it as an action, but the canonical intake and routing logic lives in CTO services. +Automations never duplicate Linear issue intake — the CTO owns that. Automations can consume Linear as context or write to it as an action, but the canonical intake and routing logic lives in the CTO/Linear services hosted by the runtime daemon. + +## Runtime ownership + +The automation rule engine, cron scheduler, file watcher, ingress endpoints (webhook listener, GitHub relay/polling, Linear relay), and built-in action runner all execute inside the runtime daemon (`ade serve`) that owns the project. For local project bindings the local daemon hosts them; for remote project bindings the remote runtime hosts them. The desktop renderer is a view: it edits rules, watches run history, and triggers manual fires through `window.ade.automations`, but it does not own scheduling, ingress, or dispatch state. + +Caveat: GitHub-polling and webhook ingress only work on a runtime that can reach the public internet (or your relay). A remote runtime behind a firewall may need the relay path even if the local desktop is internet-reachable. ## Source file map ### Services (apps/desktop/src/main/services/automations/) +These services are loaded by the runtime daemon's project scope (and by the desktop main process when it hosts a local project) — the path reflects the source tree, not where the code "runs". + - `automationService.ts` — main service. Rule CRUD, execution dispatch (`mission`, `agent-session`, `built-in`), cron scheduling (via `node-cron`), file-change watching (via `chokidar`), queue management, run history, confidence scoring, billing codes, ingress cursor storage. - `automationPlannerService.ts` — natural-language rule authoring. `parseNaturalLanguage`, `validateDraft`, `saveDraft`, `simulate`. Runs a planner subprocess (Claude or Codex) to turn a free-text brief into an `AutomationRuleDraft`. - `automationIngressService.ts` — HTTP webhook ingress (GitHub, custom webhooks) and polling-relay ingress (GitHub relay API). Signature verification for webhooks. `AutomationIngressEventRecord` is the normalized event shape. @@ -33,10 +41,11 @@ Automations never duplicate Linear issue intake — the CTO owns that. Automatio - `apps/desktop/src/renderer/components/usage/` — header Usage popup (`HeaderUsageControl`, `UsageQuotaPanel`) that hosts live provider quotas + the collapsible automation guardrails. `BudgetCapEditor`, `UsageMeter`, `UsagePacingBadge`, and `CostSummaryCard` continue to live under `components/settings/` but are rendered from the popup; Settings no longer has a Usage tab. - `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` — agent-session execution surfaces as a chat thread filtered by automation owner. -### IPC +### IPC and runtime RPC - `apps/desktop/src/preload/global.d.ts` — `window.ade.automations` surface (now includes `pollGithubNow`). -- `apps/desktop/src/main/services/ipc/registerIpc.ts` — registers `automations:*` channels including the ADE Actions registry read, GitHub polling trigger, and the registry-backed `runAdeAction` dispatch. +- `apps/desktop/src/main/services/ipc/registerIpc.ts` — registers `automations:*` channels including the ADE Actions registry read, GitHub polling trigger, and the registry-backed `runAdeAction` dispatch. Each call routes through the active project binding's runtime connection (local daemon for local projects, SSH-tunneled JSON-RPC for remote projects) so the same automation rule edits or run-history reads apply to whichever runtime owns the project. +- `apps/ade-cli/src/multiProjectRpcServer.ts` — exposes the same automation surface as JSON-RPC actions so the headless ADE CLI can manage rules, fire manual runs, and read run history without the desktop UI. ## Core model diff --git a/docs/features/automations/guardrails.md b/docs/features/automations/guardrails.md index 5374a952f..f41640117 100644 --- a/docs/features/automations/guardrails.md +++ b/docs/features/automations/guardrails.md @@ -2,10 +2,12 @@ Automations publish effects — comments, PRs, Linear updates, external webhooks. Guardrails gate publishing so a low-confidence or unreviewed run doesn't write to external systems silently. This doc covers the review/verification path, confidence scoring, the queue that holds runs needing a human, and the permission/scope knobs that constrain what an automation can touch. +Confidence scoring, queue evaluation, sandbox cwd checks, and secret resolution all run in the runtime daemon that owns the project — the renderer just edits and observes the gates. + ## Source file map - `apps/desktop/src/main/services/automations/automationService.ts` — review queue, confidence scoring, verification gating, publish disposition, status mapping. -- `apps/desktop/src/main/services/automations/automationSecretService.ts` — secret policy (env-ref only, same as CTO workers). +- `apps/desktop/src/main/services/automations/automationSecretService.ts` — secret policy (env-ref only, same as CTO worker adapters). The runtime daemon resolves `${env:VAR}` at dispatch time from the runtime process environment, not from the desktop renderer. - `apps/desktop/src/main/services/automations/automationPlannerService.ts` — rule validation before persistence. ## Guardrail structure on a rule diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 247eb4a68..336689d04 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -12,10 +12,11 @@ machinery layered on top. | Path | Role | |---|---| | `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, and prompt-derived lane-name suggestions for parallel launch. Tracks Codex Fast Mode (`codexFastMode: boolean`) per session and forwards it as `serviceTier: "fast" \| null` on every Codex `thread/start` and `turn/start` JSON-RPC call (see [Agent Routing](agent-routing.md#codex-service-tiers-fast-mode)). Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). Large orchestrator file. | +| `apps/ade-cli/src/tuiClient/` | Terminal **Work** chat TUI (Ink + React): same action/RPC contracts as desktop, **attached** (socket) or **embedded** (headless runtime via `ade-cli`). See [ADE Code](../ade-code/README.md). | | `apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts` | Main-process broker for the in-app web browser. Owns a single `persist:ade-browser` partition, multiple `WebContentsView` tabs (cap 10), bounds + visibility against the renderer-supplied frame, debugger-protocol attachment for inspect-mode hit tests, screenshot capture, and emission of `BuiltInBrowserContextItem`s for selected page elements. Spoofs a desktop Chrome `User-Agent` and the matching `Sec-CH-UA*` client hints on every request through `webRequest.onBeforeSendHeaders` so external sign-in flows (Google, etc.) treat the embedded view as a normal desktop Chrome instead of refusing to load — the previous "open Google sign-in in the system browser" branch was removed because the spoofed UA stops Google from blocking the page in the first place. Window-open requests are forwarded into a fresh tab with `openPanel: true` so the Work sidebar Browser tab pops automatically. Backs the `ade.builtInBrowser.*` IPC surface and is consumed by both `ChatBuiltInBrowserPanel` (sidebar Browser tab) and `openExternal.ts` (links inside the renderer route through the built-in browser when the protocol is `http`/`https`/`about:blank`). | | `apps/desktop/src/shared/types/builtInBrowser.ts` | Cross-process types for the built-in browser: `BuiltInBrowserStatus`, `BuiltInBrowserTab`, `BuiltInBrowserContextItem` (`kind: "built_in_browser_element" | "built_in_browser_capture"`), `BuiltInBrowserSelectResult`, `BuiltInBrowserScreenshot`, `BuiltInBrowserOpenPanelArgs`, and the `BuiltInBrowserEventPayload` union (`status`, `open-request`, `selection`, `selection-cleared`, `error`). Navigate / create-tab / switch-tab args carry an optional `openPanel: boolean` so callers can ask for the Work sidebar Browser tab to flip open atomically with the navigation. | | `apps/desktop/src/main/services/chat/buildClaudeV2Message.ts` | Builds the message payload the Claude Agent SDK V2 session consumes. Handles base64 image content blocks and MIME inference. | -| `apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts` | Discovers per-project (`.claude/commands/**`) and per-user (`~/.claude/commands/**`) slash commands, including `.md` command files and `.skill` user-invocable skills, parsing YAML frontmatter for description and argument hints. Consumed by `agentChatService` to enrich the `chat.slashCommands` response so the composer's picker lists local Claude commands alongside SDK-provided ones. | +| `apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts` | Discovers project and user Claude slash surfaces by walking ancestor `.claude` roots, reading `.claude/commands/**/*.md`, `~/.claude/commands/**/*.md`, and `.claude/skills/*/SKILL.md` / `~/.claude/skills/*/SKILL.md` entries with command frontmatter. Consumed by `agentChatService` to enrich both the `chat.slashCommands` response and Claude system prompt with local command/skill metadata. | | `apps/desktop/src/main/services/chat/chatTextBatching.ts` | Batches streaming assistant text fragments (100 ms) before emission to reduce renderer re-renders. | | `apps/desktop/src/main/services/chat/sessionRecovery.ts` | Version-2 persisted-state reconstruction when sessions resume from disk. | | `apps/desktop/src/main/services/chat/cursorSdkPool.ts` | Cursor SDK adapter: spawns and pools `cursorSdkWorker.ts` Node workers per session, sends turns, brokers permission/hook callbacks, maps SDK events to chat events, and handles teardown. | @@ -40,6 +41,25 @@ machinery layered on top. | `apps/desktop/src/main/services/ipc/registerIpc.ts` | Validates chat IPC args, exposes `agentChat.*` handlers, and persists/retrieves parallel launch recovery state in `kv`. | | `apps/desktop/src/shared/ipc.ts` | `ade.agentChat.*` IPC channel constants. | +## Where the chat service runs + +The chat service is constructed once per project, inside whichever +runtime owns that project. The desktop renderer talks to it through +the runtime IPC bridge — never directly. When a window is bound to the +local machine, that means the Electron main process's chat service; +when bound to a remote runtime, the **remote `ade serve` daemon** +constructs its own `agentChatService` and the renderer is just a +client. The headless `ade serve` bootstrap in +`apps/ade-cli/src/bootstrap.ts` wires the same `createAgentChatService` +the desktop main process uses, so the surface is identical whether +the host is local Electron or a remote daemon. The iOS app also +reaches the chat service over the same channel (via the sync command +surface), again as a client. + +This is the framing to internalise: chat sessions are runtime-owned, +not desktop-owned. The renderer can render them, and the iOS app can +render them, but neither one *runs* them. + ## Key concepts - **Provider-agnostic sessions.** `AgentChatProvider` is one of `claude`, @@ -77,6 +97,17 @@ machinery layered on top. `"agent:<id>"`) are filtered out of the Work tab list and rendered by dedicated surfaces (CTO tab, worker detail). See [Agent Routing and Identity](agent-routing.md). +- **Inline agent CLI install / auth.** When a chat targets a provider + whose CLI (Claude, Codex, Cursor, Droid) is missing or + unauthenticated, the service decorates the resulting error envelope + with an `agentCli` payload (built via `classifyAgentCliError` from + `apps/ade-cli/src/services/agentRegistry.ts`). The renderer renders + that as an `AgentCliAuthCard` inline in the transcript: a copy chip + for the install / auth command and a Run button that opens a + tracked PTY in the active lane via `window.ade.pty.create`. The + command runs in the **active runtime** — a remote-bound desktop + window installs / logs in on the remote machine. See + [Agents](../agents/README.md#agent-cli-install--auth-from-chat). - **Parallel multi-model launch.** From an empty embedded Work composer, the user can enable parallel mode, select two or more model/control slots, and send one prompt. ADE creates child lanes, starts one chat diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 994cfdb2b..f0525b2e5 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -97,13 +97,15 @@ and a footer that contains the composer. - **File attach picker** opened with the `@` key. Runs a debounced `ade.agentChat.fileSearch` and discards stale results. - **Slash commands.** Local commands (`/clear`, `/login`) are always - available and resolved renderer-side. SDK commands and project-local - Claude commands discovered by `claudeSlashCommandDiscovery` (from - `.claude/commands/**` and `~/.claude/commands/**`, including - `user-invocable: true` skills) merge in through - `ade.agentChat.slashCommands`. Only `/clear` with `source: "local"` is - intercepted client-side — every other command is sent to the agent - verbatim so provider-native commands still flow. The composer also + available and resolved renderer-side. SDK commands and project/user + Claude commands discovered by `claudeSlashCommandDiscovery` merge in + through `ade.agentChat.slashCommands`; discovery walks ancestor + `.claude` roots and reads `.claude/commands`, `~/.claude/commands`, + `.claude/skills/*/SKILL.md`, and `~/.claude/skills/*/SKILL.md` command + metadata so both command files and local skills can appear in the + picker. Only `/clear` with `source: "local"` is intercepted client-side + — every other command is sent to the agent verbatim so provider-native + commands still flow. The composer also decides whether a leading-slash draft is a command or just a sentence via `isProviderSlashCommandInput` (heuristics in `shared/chatSlashCommands.ts`): `"/rebase the lane?"` is treated as diff --git a/docs/features/computer-use/README.md b/docs/features/computer-use/README.md index fbe8f4e46..1ee4e2df9 100644 --- a/docs/features/computer-use/README.md +++ b/docs/features/computer-use/README.md @@ -6,6 +6,15 @@ The previous control-plane model — policy modes (`off`/`auto`/`enabled`), read See [`../proof.md`](../proof.md) for the user-facing CLI surface (`ade proof capture` / `attach` / `list`) and the drawer UI contract. +## Runtime ownership + +The artifact broker is owned by the runtime daemon that owns the project. Ingest, link, list, review, route, backend status, and event emission all happen inside `ade serve` for that project. Artifacts live under that runtime's `.ade/artifacts/computer-use/` directory: + +- **Local runtime:** artifacts on the user's machine, under the local project root. +- **Remote runtime:** artifacts on the remote host, under the remote project root. The desktop renderer reads previews through `ade.proof.readArtifactPreview` over the same SSH-tunneled JSON-RPC that backs the rest of the remote project surface; raw artifact bytes are not synced back to the desktop machine. + +The desktop renderer is a viewer: it edits review state, navigates owners, and displays previews. It does not own storage. The headless ADE CLI (`ade proof capture` / `attach` / `list`) writes through the same broker via JSON-RPC, so a CLI invocation from a Mac targeting a remote runtime stores artifacts on the remote host. + ## Source file map ### Services (apps/desktop/src/main/services/computerUse/) @@ -21,7 +30,7 @@ Computer-use services that used to exist and were deleted on this branch: - `proofObserver.ts` — the passive observer that auto-ingested screenshots from `tool_result` events. Captures are always intentional now. - Ghost OS status shelling (`ghost status` / `ghost doctor` probes). The broker no longer shells out to external backend binaries. -### IPC +### IPC and runtime RPC Channel constants live under `ade.proof.*` (renamed from the old `ade.computerUse.*`): @@ -32,6 +41,10 @@ Channel constants live under `ade.proof.*` (renamed from the old `ade.computerUs - `ade.proof.readArtifactPreview` - `ade.proof.event` (push) +Each channel routes renderer → preload → runtime daemon → broker. For local projects the preload bridge talks to the local `ade serve`; for remote projects it tunnels the same JSON-RPC payload over the SSH connection in `apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts`. The broker on the receiving runtime executes the action and emits `ade.proof.event` back along the same channel. + +The `ade-cli` headless surface registers the same broker and exposes the equivalent JSON-RPC tools (`screenshot_environment`, `record_environment`, `ingest_computer_use_artifacts`, `list_computer_use_artifacts`) via `apps/ade-cli/src/adeRpcServer.ts`, so a chat agent's `ade proof capture` and the desktop renderer's review drawer go through the same broker instance. + ### Renderer - `apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx` — proof drawer mounted under the chat composer. Shows the `ComputerUseOwnerSnapshot` scoped to the active chat session. diff --git a/docs/features/computer-use/app-control.md b/docs/features/computer-use/app-control.md index 1e3f46960..028a00f9e 100644 --- a/docs/features/computer-use/app-control.md +++ b/docs/features/computer-use/app-control.md @@ -4,6 +4,8 @@ App Control is ADE's bridge for driving developer-owned app sessions from inside App Control is intentionally a *bridge*. Other automation stacks — Playwright, agent-browser, browser-use, Claude's `computer_use` — can attach to the same Electron app and continue to be useful. ADE's job is to keep the launch state, the visible launch terminal, screenshots, DOM/selector packets, source candidates, and chat-attached context coherent across those tools. +App Control runs on the runtime that owns the project. The launch terminal, CDP attachment, screencast frames, screenshots, and source-matching all execute on the runtime host; the renderer just streams the resulting frames and chips. Because Electron apps under inspection live on the runtime host's filesystem, App Control naturally runs on whichever machine has the source tree. + ## Source file map ### Service (apps/desktop/src/main/services/appControl/) diff --git a/docs/features/computer-use/artifact-broker.md b/docs/features/computer-use/artifact-broker.md index 98fe09f6f..af831c4a0 100644 --- a/docs/features/computer-use/artifact-broker.md +++ b/docs/features/computer-use/artifact-broker.md @@ -2,15 +2,18 @@ The broker is the normalization layer after external computer-use execution has happened. External tools perform the actual clicks, keystrokes, and captures. The broker ingests their output, stores it canonically, links it to owners (missions, chats, PRs, Linear issues), and tracks review and publication state. +The broker runs inside the runtime daemon (`ade serve`) that owns the project. Artifacts are written to that runtime host's `.ade/artifacts/computer-use/` directory; database rows live in that runtime's `.ade/ade.db`. Renderer reads/writes flow through `window.ade.proof.*` → preload → runtime JSON-RPC → broker; the desktop main process is no longer the owner of this state. + ## Source file map -- `apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts` — the service. `createComputerUseArtifactBrokerService(args)` is the entry point. ~2000 LOC. -- `apps/desktop/src/main/services/computerUse/proofObserver.ts` — passive observer that auto-ingests artifacts from chat tool results. +- `apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts` — the service. `createComputerUseArtifactBrokerService(args)` is the entry point. Loaded by both the runtime daemon's project scope and the desktop's local-project services. - `apps/desktop/src/main/services/computerUse/agentBrowserArtifactAdapter.ts` — payload parser for agent-browser output. - `apps/desktop/src/main/services/computerUse/localComputerUse.ts` — storage helpers (`createComputerUseArtifactPath`, `toProjectArtifactUri`). +- `apps/desktop/src/main/services/computerUse/syntheticToolResult.ts` — Claude-compaction tool-result stubs. - `apps/desktop/src/shared/types/computerUseArtifacts.ts` (via `shared/types`) — `ComputerUseArtifactRecord`, `ComputerUseArtifactLink`, `ComputerUseArtifactInput`, `ComputerUseArtifactOwner`, `ComputerUseArtifactReviewState`, `ComputerUseArtifactWorkflowState`, `ComputerUseEventPayload`. - `apps/desktop/src/shared/proofArtifacts.ts` — `normalizeComputerUseArtifactKind`, `resolveReportArtifactKind`. -- `docs/architecture/COMPUTER_USE_ARTIFACT_BROKER.md` — the architectural boundary document. + +The passive `proofObserver.ts` was deleted with the rebuild; nothing watches tool results to auto-ingest captures any more. Captures are intentional: an agent or operator runs `ade proof capture/attach` (or the corresponding RPC tool) and the broker ingests once. ## Canonical record model diff --git a/docs/features/computer-use/backends.md b/docs/features/computer-use/backends.md index 944368b84..037924860 100644 --- a/docs/features/computer-use/backends.md +++ b/docs/features/computer-use/backends.md @@ -1,13 +1,15 @@ # Computer-Use Backends -Three supported backend styles. ADE's job is to discover them, report their readiness, and ingest their output. ADE does not wrap or replace the backends themselves. +Backend discovery runs on the runtime host that owns the project (the runtime daemon's `commandExists("ghost")` / `commandExists("agent-browser")` checks reflect that machine's `PATH`). ADE's job is to discover backends locally to that runtime, report their readiness, and ingest their output. ADE does not wrap or replace the backends themselves. + +This doc describes the historical Ghost OS / agent-browser / local-fallback model. The current shipping broker (`computerUseArtifactBrokerService.getBackendStatus`) reports the same backend names but the policy + readiness machinery (`buildComputerUseSettingsSnapshot`, `buildGhostOsCheck`, capability matrix) was retired with the proof rebuild and the Settings > Computer Use panel was folded into Integrations. Use this doc for context on backend semantics, not for the current operator UI. ## Source file map -- `apps/desktop/src/main/services/computerUse/controlPlane.ts` — `buildGhostOsCheck`, `buildCapabilityMatrix`, `selectPreferredBackend`, `buildComputerUseSettingsSnapshot`. Ghost OS detection via `ghost status` / `ghost doctor`. -- `apps/desktop/src/main/services/computerUse/localComputerUse.ts` — `getLocalComputerUseCapabilities`, `getGhostDoctorProcessHealth`, `parseGhostDoctorProcessHealth`. CLI detection (`screencapture`, `open`, `swift`, `osascript`). -- `apps/desktop/src/main/services/computerUse/agentBrowserArtifactAdapter.ts` — `parseAgentBrowserArtifactPayload`, `loadAgentBrowserArtifactPayloadFromFile`. Parses agent-browser output manifests. -- `apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts` — `getBackendStatus` (emits `ComputerUseBackendStatus`), backend registration, `inferSupportedKindsFromExternalTool`. +- `apps/desktop/src/main/services/computerUse/controlPlane.ts` — pre-rebuild `buildComputerUseOwnerSnapshot` + capability/Ghost-OS helpers. The current build keeps the snapshot assembly path; the policy/Ghost-OS readiness helpers are vestigial. +- `apps/desktop/src/main/services/computerUse/localComputerUse.ts` — `getLocalComputerUseCapabilities`, `createComputerUseArtifactPath`, `toProjectArtifactUri`. Capability detection (`screencapture`, `open`, `swift`, `osascript`) reflects the runtime host's environment. +- `apps/desktop/src/main/services/computerUse/agentBrowserArtifactAdapter.ts` — `parseAgentBrowserArtifactPayload`, `loadAgentBrowserArtifactPayloadFromFile`. Parses agent-browser output manifests on the runtime host. +- `apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts` — `getBackendStatus` (emits `ComputerUseBackendStatus`), `secureCopyFromDescriptor` (symlink-safe path-based ingest), backend enumeration. ## Ghost OS **Transport:** external CLI. ADE detects `ghost` on `PATH` and reads `ghost status` / `ghost doctor` for readiness. @@ -33,7 +35,7 @@ Three supported backend styles. ADE's job is to discover them, report their read - `"stale"` when more than one process is reported (stale instances remaining) or `[FAIL] Processes:` matches. - `"unknown"` when the pattern isn't matchable. -**Tool scope:** Ghost OS exposes a large perception + interaction tool set (see `proofObserver.ts` `GHOST_ARTIFACT_TOOLS` for the perception subset ADE auto-ingests). All tools run over ADE CLI — ADE calls them via the ADE CLI service. +**Tool scope:** Ghost OS exposes a large perception + interaction tool set. The pre-rebuild `proofObserver` auto-ingested a curated `GHOST_ARTIFACT_TOOLS` subset on tool-result events; the observer has been deleted, so today an agent capturing Ghost OS evidence must call `ade proof capture/attach` (or the broker's `ingest_computer_use_artifacts` RPC tool) to file it. **Shell-out constraints:** @@ -134,7 +136,7 @@ To register a new external backend: 1. Add it to the ADE CLI list (if ADE CLI) or define a CLI-detection check. 2. Extend `buildComputerUseSettingsSnapshot` or the broker's backend enumeration to include it. 3. Register supported proof kinds — via explicit declaration or by letting `inferSupportedKindsFromExternalTool` match from the tool descriptions. -4. Update `proofObserver.ts` if the backend's tool names should be auto-observed. +4. (Pre-rebuild only.) The historical `proofObserver` auto-ingested specific tool names; since the observer was deleted, new backends ingest exclusively through explicit `ade proof attach` / `ingest_computer_use_artifacts` calls. 5. Add the backend's output root to the broker's `allowedImportRoots` if it writes files outside existing trusted locations. 6. Document the setup flow in Settings > Computer Use guidance. diff --git a/docs/features/computer-use/settings-and-readiness.md b/docs/features/computer-use/settings-and-readiness.md index 5cc18cf17..e6688d8b7 100644 --- a/docs/features/computer-use/settings-and-readiness.md +++ b/docs/features/computer-use/settings-and-readiness.md @@ -1,14 +1,16 @@ # Settings and Readiness -The `Settings > Computer Use` panel is the operator's entry point for configuring and monitoring the computer-use control plane. It shows backend readiness, policy, and a capability matrix mapping proof kinds to backends. Readiness detection runs on demand and is cached in the broker's backend status. +This doc describes the pre-rebuild `Settings > Computer Use` panel and its policy/readiness model. That panel was removed with the proof rebuild; readiness now appears inside the broader `IntegrationsSettingsSection`, and `ComputerUsePolicy` (with its `off`/`auto`/`enabled` modes, `allowLocalFallback`, etc.) is gone. Use this doc for historical context on what the matrix used to express. + +The active broker still runs inside the runtime daemon that owns the project (`computerUseArtifactBrokerService.getBackendStatus` reflects backends installed on the runtime host's `PATH`). ## Source file map -- `apps/desktop/src/main/services/computerUse/controlPlane.ts` — `buildComputerUseSettingsSnapshot`, `buildGhostOsCheck`, `buildCapabilityMatrix`, `selectPreferredBackend`, `summarizePolicy`, `buildComputerUseOwnerSnapshot`. -- `apps/desktop/src/main/services/computerUse/localComputerUse.ts` — `getLocalComputerUseCapabilities`, `getGhostDoctorProcessHealth`, `parseGhostDoctorProcessHealth`. +- `apps/desktop/src/main/services/computerUse/controlPlane.ts` — pre-rebuild `buildComputerUseSettingsSnapshot`, `buildGhostOsCheck`, `buildCapabilityMatrix`, `selectPreferredBackend`, `summarizePolicy`. Only `buildComputerUseOwnerSnapshot` is still wired into the live UI. +- `apps/desktop/src/main/services/computerUse/localComputerUse.ts` — `getLocalComputerUseCapabilities`, `createComputerUseArtifactPath`. - `apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts` — `getBackendStatus`. -- `apps/desktop/src/main/services/ipc/registerIpc.ts` — IPC surface for `computerUse:*` channels. -- Renderer Settings surface — `apps/desktop/src/renderer/components/settings/` (look for `ComputerUsePanel.tsx` or similar). +- `apps/desktop/src/main/services/ipc/registerIpc.ts` — IPC surface; channels live under `ade.proof.*` today (the `computerUse:*` namespace was renamed during the rebuild). +- Renderer Settings surface — `apps/desktop/src/renderer/components/settings/IntegrationsSettingsSection.tsx` (the dedicated `ComputerUsePanel.tsx` was deleted). ## Settings snapshot diff --git a/docs/features/conflicts/README.md b/docs/features/conflicts/README.md index 6b44317da..ffa4ff2fa 100644 --- a/docs/features/conflicts/README.md +++ b/docs/features/conflicts/README.md @@ -14,9 +14,30 @@ is projected into the surfaces where it matters: - **PRs**: blocked/manual rebase UIs, integration (merge-plan) pairwise simulation, issue resolution. +## Where this runs + +Conflict prediction (`git merge-tree` runs), pairwise risk +computation, the prediction job engine, AI proposal preparation / +dispatch / apply, and external CLI resolver runs all execute inside +the **active ADE runtime** for the window's project binding — the +local daemon for local-bound windows or the SSH-attached remote +runtime for remote-bound windows. The renderer routes +`window.ade.conflicts.*` calls through +`callProjectRuntimeActionOr("conflicts", …)` in +`apps/desktop/src/preload/preload.ts` and only falls back to the +desktop's in-process IPC handlers when no runtime is bound. Remote- +bound windows therefore predict conflicts, run merge simulations, +and execute external CLI resolvers on the remote machine — the +worktrees and pack artifacts they read are on the remote host. The +`ConflictPanel` and `RiskMatrix` renderer components only hold view +state; they call out to the runtime for every prediction or +proposal action. + ## Source file map -Main-process: +Service files (canonical implementations live in the runtime daemon; +the paths below are the desktop fallback targets that share the +behavior): | File | Responsibility | |------|---------------| diff --git a/docs/features/conflicts/detection.md b/docs/features/conflicts/detection.md index 047a7ba19..8d598cc53 100644 --- a/docs/features/conflicts/detection.md +++ b/docs/features/conflicts/detection.md @@ -1,11 +1,16 @@ # Conflict detection -The conflict prediction engine lives in -`apps/desktop/src/main/services/conflicts/conflictService.ts`. It -runs `git merge-tree` to predict whether a merge or rebase would -produce conflicts — without actually performing the merge. Results -are cached in `conflict_predictions` and surfaced as lane status -badges, risk matrix cells, overlap chips, and rebase needs. +The conflict prediction engine runs inside the **active ADE runtime** +(local daemon or SSH-attached remote runtime) using the implementation +in `apps/desktop/src/main/services/conflicts/conflictService.ts` (the +desktop fallback target also points at the same source). It runs +`git merge-tree` on the host that owns the worktrees to predict +whether a merge or rebase would produce conflicts — without actually +performing the merge. Results are cached in `conflict_predictions` +and surfaced as lane status badges, risk matrix cells, overlap chips, +and rebase needs. For remote-bound windows the entire prediction loop +runs on the remote host; the desktop renderer subscribes to events +through preload's runtime event pump and never spawns git itself. ## `git merge-tree` primer diff --git a/docs/features/cto/README.md b/docs/features/cto/README.md index d1e03d0de..aadea72a0 100644 --- a/docs/features/cto/README.md +++ b/docs/features/cto/README.md @@ -2,7 +2,7 @@ The CTO is ADE's persistent, project-level operator identity. One identity per project, not a family of rotating chats or a constantly running daemon. It owns persistent identity, shared project understanding, worker management, Linear dispatch and sync, and the operator-facing chat surface. -The runtime is organized around one contract: the CTO tab should be usable as a daily chat surface without forcing every optional subsystem (Linear, OpenClaw, realtime ingress, budget telemetry) to fully hydrate on mount. +The runtime is organized around one contract: the CTO tab should be usable as a daily chat surface without forcing every optional subsystem (Linear, realtime ingress, budget telemetry) to fully hydrate on mount. ## Source file map @@ -14,7 +14,7 @@ The runtime is organized around one contract: the CTO tab should be usable as a - `workerBudgetService.ts` — budget snapshots per worker and CTO org. - `workerRevisionService.ts` — worker config revision history. - `workerTaskSessionService.ts` — task-scoped worker sessions. -- `workerAdapterRuntimeService.ts` — adapter lifecycle for claude-local / codex-local / process / openclaw-webhook. +- `workerAdapterRuntimeService.ts` — adapter lifecycle for the three supported worker adapters: `claude-local`, `codex-local`, and `process`. - `linearCredentialService.ts` — personal API key storage, token status. - `linearOAuthService.ts` — PKCE loopback OAuth flow on port 19836. - `linearClient.ts` — Linear GraphQL client (shared by desktop and headless ADE CLI). @@ -29,26 +29,24 @@ The runtime is organized around one contract: the CTO tab should be usable as a - `linearDispatcherService.ts` — launches target runs (employee_session, worker_run, mission, pr_resolution, review_gate), tracks run state, emits events. - `linearCloseoutService.ts` — success/failure Linear state transitions, comments, proof attachment. - `linearOutboundService.ts` — outbound Linear writes (state, comments, assignees). -- `openclawBridgeService.ts` — optional OpenClaw device pairing and bridge runtime state. -### Headless parity +### Runtime daemon parity -- `apps/ade-cli/src/headlessLinearServices.ts` — wires the same CTO Linear services (client, tracker, template, workflow file, flow policy, routing, intake, outbound, closeout, dispatcher, sync, ingress) into the headless ADE CLI so `ADE CLI` acts as a drop-in Linear-capable runtime, not a read-only stub. +- `apps/ade-cli/src/headlessLinearServices.ts` — wires the same CTO Linear services (client, tracker, template, workflow file, flow policy, routing, intake, outbound, closeout, dispatcher, sync, ingress) into the `ade serve` runtime daemon, plus a headless `workerHeartbeatService`, `workerTaskSessionService`, and the supporting `fileService` / `processService` / `prService` / `automationSecretService` instances the dispatcher needs to actually launch targets. The CTO is no longer "desktop-only" — every Linear capability runs identically inside the daemon, so a headless host can intake issues, dispatch worker runs, and close out tickets with the same code path the desktop renderer drives. ### Renderer (apps/desktop/src/renderer/components/cto/) -- `CtoPage.tsx` — the `/cto` shell. Four tabs: Chat, Team, Workflows, Settings. Lazy-loads history, budget, and external-ADE CLI registry. +- `CtoPage.tsx` — the `/cto` shell. Four tabs: Chat, Team, Workflows, Settings. Lazy-loads history and budget data. - `AgentSidebar.tsx` — memoized worker tree; budget footer isolated so budget refresh does not rerender siblings. - `OnboardingBanner.tsx` / `OnboardingWizard.tsx` — minimal first-run flow: personality preset only. - `IdentityEditor.tsx` — editable identity surface (personality preset + custom overlay + model). No longer a full identity-prompt editor. -- `CtoSettingsPanel.tsx` — identity, core memory (project summary / conventions / preferences / focus / notes), external-ADE CLI access policy, onboarding reset. +- `CtoSettingsPanel.tsx` — identity, core memory (project summary / conventions / preferences / focus / notes), onboarding reset. - `CtoPromptPreview.tsx` — three-section prompt preview: doctrine, personality overlay, memory model. - `TeamPanel.tsx` — worker editor and detail view. - `WorkerCreationWizard.tsx` — two-step wizard: template selection then configure. - `WorkerActivityFeed.tsx` — recent worker sessions and runs. - `LinearConnectionPanel.tsx` — API key and OAuth connect surface. - `LinearSyncPanel.tsx` / `LinearSyncPanel.test.ts` — workflow list, sync dashboard, run timeline, "Watch It Live" monitor. -- `OpenclawConnectionPanel.tsx` — advanced-only OpenClaw pairing. - `identityPresets.ts` — re-exports from `shared/ctoPersonalityPresets`. - `shared/designTokens.ts` — CTO-wide class patterns (`cardCls`, `stageCardCls`, `pipelineCanvasCls`, ACCENT palette, `WORKER_TEMPLATES`). - `shared/AgentStatusBadge.tsx`, `shared/ConnectionStatusDot.tsx`, `shared/StepWizard.tsx`, `shared/TimelineEntry.tsx` — shared visual building blocks. @@ -91,12 +89,6 @@ On disk under `.ade/cto/`: - `MEMORY.md` — long-term CTO brief (summary, conventions, preferences, active focus, notes). - `CURRENT.md` — current working context (recent sessions, worker activity). - `daily/YYYY-MM-DD.md` — append-only daily logs via `appendDailyLog`, `readDailyLog`, `listDailyLogs`. -- `openclaw-device.json` — durable paired-device identity (if OpenClaw connected). - -Under `.ade/cache/openclaw/` (runtime, not git-tracked): - -- bridge history, outbox, route cache, idempotency data. - Portability rule (Phase 6 W3): identity YAML and the project memory schema are git-tracked; runtime memory files, daily logs, and session state are local or ADE-sync only. ### Tab model (`CtoPage.tsx`) @@ -106,7 +98,7 @@ Portability rule (Phase 6 W3): identity YAML and the project memory schema are g | Chat | CTO session, subordinate activity summary | Immediate | | Team | Agents, revisions, worker core memory, worker runs | On tab activation | | Workflows | `LinearSyncPanel` (dashboard + run detail + pipeline) | On tab activation; refresh debounced | -| Settings | Identity, core memory, session logs, external-ADE CLI registry, OpenClaw | On tab activation | +| Settings | Identity, core memory, session logs | On tab activation | The sidebar worker tree is precomputed and memoized. The budget footer is isolated so a budget refresh does not rerender the tree. @@ -152,8 +144,8 @@ The environment knowledge block inside the system prompt teaches intent-to-tool - Linear sync short-circuits when no workflows are enabled and no runs are active. - Ingress only auto-starts when realtime config is actually present. - Management surfaces (Team, Workflows, Settings) hydrate lazily without weakening persistent identity. -- OpenClaw is advanced config, not first-run. -- Headless ADE CLI uses the same Linear services, not a read-only fake. +- The `ade serve` runtime daemon uses the same Linear services as the desktop renderer; the CTO is not a desktop-only feature. +- Worker adapter type is one of `claude-local`, `codex-local`, or `process`. There are no other adapter types — anything that needs to wrap an external service does so as a `process` adapter. ## Gotchas and fragile areas @@ -161,4 +153,3 @@ The environment knowledge block inside the system prompt teaches intent-to-tool - **Identity re-injection after compaction** happens inside `refreshReconstructionContext()` — changes to the doctrine / personality / memory model or capability manifest must keep the preview and runtime in sync. The capability manifest is the single place to keep aligned with tool registrations. - **Workflow match precedence** runs by `priority` descending; values inside a trigger group are OR-ed, populated groups are AND-ed. A `watchOnly` route logs a match without launching. - **Dynamic employee delegation** — when routing resolves no employee, runs enter `awaiting_delegation` instead of dispatching to an invalid target. Do not assume dispatch always happens. -- **OpenClaw runtime migration** — legacy repo-visible runtime files are migrated into `.ade/cache/openclaw/` on startup. Keep the bridge service tolerant of missing-but-migratable files. diff --git a/docs/features/cto/identity-and-memory.md b/docs/features/cto/identity-and-memory.md index c75ab9c7a..3fa1b033e 100644 --- a/docs/features/cto/identity-and-memory.md +++ b/docs/features/cto/identity-and-memory.md @@ -104,7 +104,7 @@ When a CTO or worker session undergoes context compaction, `refreshReconstructio - Identity YAML (`identity.yaml` layout) is part of the shared ADE scaffold and intended to survive a clone/pull. - Core memory schema is git-tracked; the live content in `MEMORY.md` / `CURRENT.md` is local/ADE-sync. - Daily logs and session logs are operational history — local/ADE-sync only. -- Runtime memory files, openclaw bridge cache, generated docs remain local. +- Runtime memory files and generated docs remain local. This split is why a fresh clone recovers the CTO identity layer but not recent subordinate activity or session logs. diff --git a/docs/features/cto/linear-integration.md b/docs/features/cto/linear-integration.md index 88d5c9573..9cfe8974a 100644 --- a/docs/features/cto/linear-integration.md +++ b/docs/features/cto/linear-integration.md @@ -45,9 +45,9 @@ Detailed wiring lives in [`../linear-integration/README.md`](../linear-integrati - `apps/desktop/src/shared/types/linearSync.ts` — `LinearWorkflowDefinition`, `LinearWorkflowTarget`, `LinearWorkflowTrigger`, `LinearWorkflowStep`, run status, closeout types. - `apps/desktop/src/shared/linearWorkflowPresets.ts` — visual plan translation. -### Headless +### Runtime daemon -- `apps/ade-cli/src/headlessLinearServices.ts` — wires the same set of Linear services into the ADE CLI so `ADE CLI` is first-class for Linear, not a read-only stub. +- `apps/ade-cli/src/headlessLinearServices.ts` — instantiates the full Linear service stack inside the `ade serve` runtime daemon. The daemon is first-class for Linear, not a read-only stub: it can intake issues, dispatch worker runs / missions / employee sessions, and close out tickets with the same code path the desktop renderer drives. ## Connection model @@ -170,16 +170,20 @@ The LinearSyncPanel debounces follow-up refreshes so active sync stays observabl - From absolute paths to external files (temporary screenshots, e.g. Ghost OS captures). - From broker-managed computer-use artifacts (see `../computer-use/README.md`). -## Headless parity +## Runtime daemon parity -`headlessLinearServices.ts` instantiates the same services in the ADE CLI: +`headlessLinearServices.ts` instantiates the same services inside the +`ade serve` runtime daemon: - `linearClient`, `linearIssueTracker`, `linearTemplateService`, `linearWorkflowFileService`. - `flowPolicyService`, `linearRoutingService`, `linearIntakeService`, `linearOutboundService`, `linearCloseoutService`. - `linearDispatcherService`, `linearSyncService`, `linearIngressService`. -- Plus `workerTaskSessionService`, `fileService`, `processService`, `prService`, `automationSecretService` so the dispatcher's target launches actually work. +- Plus `workerHeartbeatService`, `workerTaskSessionService`, `fileService`, `processService`, `prService`, and `automationSecretService` so the dispatcher's target launches actually run. -Headless employee-session targets create reusable continuity chats but are manual shells unless a live agent runtime is attached. Worker-backed headless targets fail fast with explicit errors when no worker runtime is available, instead of stalling in a queued state. +Daemon-side employee-session targets create reusable continuity chats +but are manual shells unless a live agent runtime is attached. +Worker-backed daemon targets fail fast with explicit errors when no +worker runtime is available, instead of stalling in a queued state. ## Simulation diff --git a/docs/features/cto/onboarding.md b/docs/features/cto/onboarding.md index daa22c53e..167c34c7b 100644 --- a/docs/features/cto/onboarding.md +++ b/docs/features/cto/onboarding.md @@ -1,6 +1,6 @@ # CTO Onboarding -The first-run flow for a project. Intentionally short: the CTO is usable before Linear is connected, before workers are hired, and before OpenClaw is paired. The wizard exists to pick a personality overlay — everything else is deferred. +The first-run flow for a project. Intentionally short: the CTO is usable before Linear is connected and before workers are hired. The wizard exists to pick a personality overlay — everything else is deferred. ## Source file map @@ -56,7 +56,7 @@ The first-run flow for a project. Intentionally short: the CTO is usable before 5. On "Finish" the wizard calls `window.ade.cto.updateIdentity({ patch: { name: "CTO", personality, customPersonality, persona } })` where `persona` is either the custom text or the preset-derived sentence `"Persistent project CTO with <label> personality."`. 6. On success, the wizard calls `onComplete()` which the container wires to `updateOnboardingState({ completedSteps: [...existing, "identity"] })`. -There is no separate step for model selection, Linear connection, worker hiring, or OpenClaw pairing. Those happen lazily from Settings and the relevant tabs. +There is no separate step for model selection, Linear connection, or worker hiring. Those happen lazily from Settings and the relevant tabs. ## Identity editor (post-onboarding) diff --git a/docs/features/cto/pipeline-builder.md b/docs/features/cto/pipeline-builder.md index a4965a203..757c4b678 100644 --- a/docs/features/cto/pipeline-builder.md +++ b/docs/features/cto/pipeline-builder.md @@ -119,7 +119,7 @@ Every field in `FIELD_LABELS` carries a `tier` (`essential`, `advanced`, `expert - **`visualManagedStepTypes` is the contract boundary.** Adding a new step type that should be rebuilt from the visual plan requires adding it to the set in `linearWorkflowPresets.ts` or it will be preserved-but-not-regenerated (often leading to stale steps after a plan change). - **Tests in `pipelineHelpers.test.ts` and `linearWorkflowPresets.test.ts`** cover the translation invariants. Keep them green — they are the regression net for this surface. - **PR strategy kind polymorphism.** `target.prStrategy` is a union (`{kind: "per-lane"}`, `{kind: "manual"}`, etc.). `StageCard.tsx` branches on `kind`; new kinds must update the card summary and the config panel. -- **Headless parity.** `apps/ade-cli/src/headlessLinearServices.ts` instantiates the same flow policy and dispatcher — any YAML schema change must pass through the headless code path as well, otherwise `ADE CLI` diverges. +- **Daemon parity.** `apps/ade-cli/src/headlessLinearServices.ts` instantiates the same flow policy and dispatcher inside `ade serve` — any YAML schema change must pass through the daemon code path as well, otherwise the runtime daemon diverges from the desktop renderer. ## Cross-links diff --git a/docs/features/cto/workers.md b/docs/features/cto/workers.md index 3ec181872..c38019dde 100644 --- a/docs/features/cto/workers.md +++ b/docs/features/cto/workers.md @@ -11,7 +11,7 @@ Workers are named agent identities that ADE can wake for delegated work. The CTO - `workerHeartbeatService.ts` — heartbeat policy (interval, pause threshold), liveness reporting, activity feed updates. - `workerRevisionService.ts` — config revision history; every identity change lands as a new `AgentConfigRevision`. - `workerTaskSessionService.ts` — short-lived task session records that tie a worker to a lane/issue/run. -- `workerAdapterRuntimeService.ts` — adapter lifecycle management for `claude-local`, `codex-local`, `process`, `openclaw-webhook`. +- `workerAdapterRuntimeService.ts` — adapter lifecycle management for the three worker adapter types: `claude-local`, `codex-local`, and `process`. ### Renderer (apps/desktop/src/renderer/components/cto/) @@ -29,7 +29,7 @@ Workers are named agent identities that ADE can wake for delegated work. The CTO - `title` (display), `reportsTo` (parent worker id or null). - `capabilities` (deduplicated string list). - `status` (`idle` | `active` | `paused` | `running`). -- `adapterType` (`claude-local` | `codex-local` | `process` | `openclaw-webhook`). +- `adapterType` (`claude-local` | `codex-local` | `process`). - `linearIdentity` (`AgentLinearIdentity` — user ids, display names, aliases). - Secret-policy fields pass through `assertEnvRefSecretPolicy`: raw secret-like values are rejected; only `${env:VAR}` references are allowed. Applies recursively to any object/array under an adapter config. @@ -71,14 +71,19 @@ The heartbeat policy lets the operator toggle "always running" vs idle-on-demand ## Adapter types -`workerAdapterRuntimeService.ts` owns the lifecycle for each adapter: +`workerAdapterRuntimeService.ts` owns the lifecycle for the three +supported adapters: - `claude-local` — Claude CLI subprocess. Uses `resolveClaudeCliModel`. - `codex-local` — Codex CLI subprocess. Uses `resolveCodexCliModel`. -- `process` — generic managed process (e.g. for running a long-lived worker bin). -- `openclaw-webhook` — routes through an OpenClaw webhook adapter. - -Adapter config is validated for env-reference-only secrets — the service refuses to persist raw API keys in config fields and requires `${env:VAR}` references instead. +- `process` — generic managed process (e.g. for running a long-lived worker binary or wrapping an out-of-tree agent runtime). + +There are no other adapter types. `AdapterType` is `"claude-local" | +"codex-local" | "process"` in `apps/desktop/src/shared/types/agents.ts`, +and `workerAgentService` enforces that allowlist when persisting an +identity (`ALLOWED_ADAPTER_TYPES`). Adapter config is validated for +env-reference-only secrets — the service refuses to persist raw API +keys in config fields and requires `${env:VAR}` references instead. ## Budgets diff --git a/docs/features/files-and-editor/README.md b/docs/features/files-and-editor/README.md index 6e728a7a3..ef35a4bf6 100644 --- a/docs/features/files-and-editor/README.md +++ b/docs/features/files-and-editor/README.md @@ -9,9 +9,29 @@ This feature sits at the boundary between the filesystem and everything else: context packs use it to discover docs, the chat surface links back to it for "open this file", and lanes surface files by worktree. +## Where this runs + +File listing, atomic writes, the cross-file search index, and the +chokidar-backed file watcher all run inside the **active runtime** +for the window's project binding — the local ADE daemon for +local-bound windows and the SSH-attached remote runtime for +remote-bound windows. The Monaco editor in the renderer is purely +client-side; every byte it reads or writes flows through +`window.ade.files.*` in `apps/desktop/src/preload/preload.ts`, which +calls `callProjectRuntimeActionIfBound("file", …)` first and only +falls through to the legacy in-process IPC handlers when no runtime +is bound. Watcher events arrive over the runtime's event stream +(category `"runtime"`) and are dispatched into renderer subscribers +through the same preload pump that powers lane / pty / process +events. Remote-bound desktop windows therefore browse and edit files +on the remote machine; the file tree, search results, and watcher +events all reflect the remote worktree. + ## Source file map -Main process: +Runtime services back the canonical implementation. The desktop +`apps/desktop/src/main/services/files/` files below stay as fallback +targets for the legacy IPC path. - `apps/desktop/src/main/services/files/fileService.ts` — directory listing, atomic writes, quick open, cross-file search, path safety. @@ -209,9 +229,13 @@ whether a directory should show the "has changes" dot. The preload bridge (`apps/desktop/src/preload/preload.ts`) exposes `window.ade.files` and `window.ade.diff`; nothing from `node:fs` or `node:path` leaks into the renderer. All path resolution for file -writes and workspace roots happens in the main process through -`resolvePathWithinRoot`, which refuses `..` escapes, null bytes, and -`.git` internals. +writes and workspace roots happens server-side — inside the active +runtime daemon for runtime-routed calls and inside the desktop main +process for the fallback IPC path — through `resolvePathWithinRoot`, +which refuses `..` escapes, null bytes, and `.git` internals. Remote +runtimes apply the same path-safety primitives on the remote host, so +the trust boundary still holds when the renderer is browsing files on +a remote machine. For deeper detail on the watcher + trust boundary, see [file-watcher-and-trust.md](./file-watcher-and-trust.md). diff --git a/docs/features/files-and-editor/editor-surfaces.md b/docs/features/files-and-editor/editor-surfaces.md index b3a81f212..f78cde451 100644 --- a/docs/features/files-and-editor/editor-surfaces.md +++ b/docs/features/files-and-editor/editor-surfaces.md @@ -134,8 +134,9 @@ Protection rails: banner above the editor: "You have active lanes. Saving here writes to main." The user must click "I understand" to dismiss for the session. -- Saving a file marked read-only fails at the main-process boundary - and the tab displays the error. +- Saving a file marked read-only fails at the runtime boundary (or + the main-process boundary on the fallback path) and the tab displays + the error. ## Diff mode diff --git a/docs/features/files-and-editor/file-watcher-and-trust.md b/docs/features/files-and-editor/file-watcher-and-trust.md index 31c114510..27d73403e 100644 --- a/docs/features/files-and-editor/file-watcher-and-trust.md +++ b/docs/features/files-and-editor/file-watcher-and-trust.md @@ -1,21 +1,33 @@ # File Watcher and Trust Boundary -Detail reference for the main-process file services — how filesystem -access is gated, how `chokidar` is shared across subscriptions, and -how external changes propagate to open editor tabs without racing -against user edits. +Detail reference for the file services — how filesystem access is +gated, how `chokidar` is shared across subscriptions, and how external +changes propagate to open editor tabs without racing against user +edits. + +The canonical file services run inside the **active ADE runtime** +(local daemon for local-bound windows, SSH-attached remote runtime for +remote-bound windows). The desktop main process also hosts the same +services as fallback targets for the legacy IPC path; both code paths +share the same source files and behavior. Remote-bound windows +therefore execute every file read, atomic write, watcher subscription, +and search index update on the remote machine. ## Trust boundary -The file services run exclusively in the main process. The renderer -has no direct `node:fs` or `node:path` access (those come from the -node runtime, not Electron). All filesystem operations go through: +The renderer never touches `node:fs` or `node:path` directly (those +come from the node runtime, not Electron). All filesystem operations +go through: 1. `window.ade.files.*` from the preload bridge - (`apps/desktop/src/preload/preload.ts`) + (`apps/desktop/src/preload/preload.ts`), which calls + `callProjectRuntimeActionIfBound("file", …)` first for the active + runtime route, then falls through to the in-process IPC handler. 2. `ade.files.*` IPC channels registered in - `apps/desktop/src/main/services/ipc/registerIpc.ts` -3. `fileService` methods, which: + `apps/desktop/src/main/services/ipc/registerIpc.ts` (fallback + path for the desktop's local in-process implementation). +3. `fileService` methods (run on the runtime host; in fallback mode + they run inside the desktop main process), which: - resolve every path against the workspace root via `resolvePathWithinRoot` - refuse any path that contains `.git` at any segment @@ -24,10 +36,10 @@ node runtime, not Electron). All filesystem operations go through: - refuse paths that are not inside the workspace root after normalization -The renderer never sees an absolute host path until the main process -has validated it. `FileContent.languageId` is a Monaco hint; it is -derived from the extension by `languageIdFromPath`, not from any path -metadata. +The renderer never sees an absolute host path until the active runtime +(or, on the fallback path, the desktop main process) has validated it. +`FileContent.languageId` is a Monaco hint; it is derived from the +extension by `languageIdFromPath`, not from any path metadata. ### Path safety invariants @@ -199,9 +211,13 @@ Rename detection on the renderer side: because watcher events come as renderer inspects the modified timestamp and file size to correlate them when possible. -## IPC surface (main-process handlers) +## IPC surface -All registered in `registerIpc.ts`: +The primary route is the runtime daemon's `file` action domain. +`preload.ts` calls `callProjectRuntimeActionIfBound("file", …)` first +and only falls back to the in-process IPC handler when no runtime is +bound. Both paths share the same handler shapes; the desktop fallback +handlers are registered in `registerIpc.ts`: | Channel | Handler behavior | |---|---| diff --git a/docs/features/history/README.md b/docs/features/history/README.md index 704f690e2..f4302a86e 100644 --- a/docs/features/history/README.md +++ b/docs/features/history/README.md @@ -7,11 +7,30 @@ checkpoints). The goal is traceability and debuggability, not just recorded in parallel tables owned by their respective features; history is the operations-level view that ties them together. +## Where this runs + +Operation recording, the `operations` SQLite table, and the export +pipeline all live inside the **active ADE runtime** (local daemon for +local-bound windows, SSH-attached remote runtime for remote-bound +windows). Every git operation runs through the runtime's +`gitOperationsService` which brackets the command with +`operationService.start` / `finish`, so the timeline records work +performed on whichever host owns the lane's worktree. The renderer's +`window.ade.history.listOperations` and `exportOperations` go through +preload's `callProjectRuntimeActionOr("operation", …)` first and fall +back to the legacy in-process IPC handlers when no runtime is bound. +For remote-bound windows the operations database lives on the remote +machine; the desktop simply renders rows it pulled through the +runtime. The export-to-disk dialog itself still runs on the desktop +because the file is saved on the user's local machine — the runtime +returns the rows, then the desktop's IPC handler writes the CSV/JSON +to disk through the native save dialog. + ## Source file map | Path | Role | |---|---| -| `apps/desktop/src/main/services/history/operationService.ts` | CRUD for `operations` rows; the canonical entry point for `record`, `start`, `finish`, `list`. | +| `apps/desktop/src/main/services/history/operationService.ts` | CRUD for `operations` rows; the canonical entry point for `record`, `start`, `finish`, `list`. Same source backs the runtime daemon and the desktop fallback path. | | `apps/desktop/src/main/services/state/kvDb.ts` | Schema for `operations`, `checkpoints`, `pack_events`, `pack_versions`, `pack_heads`, `terminal_sessions`, `orchestrator_chat_threads`, `orchestrator_chat_messages`. | | `apps/desktop/src/main/services/git/gitOperationsService.ts` | Brackets every git operation with `operationService.start` / `finish`, capturing pre/post HEAD SHAs. | | `apps/desktop/src/main/services/prs/prService.ts` | Records PR creation as an operation. | diff --git a/docs/features/history/recording-and-export.md b/docs/features/history/recording-and-export.md index fe19ce082..e6e2b9a3e 100644 --- a/docs/features/history/recording-and-export.md +++ b/docs/features/history/recording-and-export.md @@ -6,6 +6,15 @@ through the recording paths, how transcripts are serialised for history-adjacent features, and how the export flow converts rows to CSV/JSON. +The operationService and gitOperationsService both run inside the +**active ADE runtime** (local daemon for local-bound windows, +SSH-attached remote runtime for remote-bound windows). The same source +files are also loaded by the desktop main process for the legacy +in-process IPC fallback path. Export-to-disk is split: the runtime +returns the row payload, then the desktop main process writes the +file through the native save dialog (the file always lands on the +user's local machine). + ## Source file map | Path | Role | diff --git a/docs/features/ios-simulator/README.md b/docs/features/ios-simulator/README.md index f973bf69e..c8ab087c5 100644 --- a/docs/features/ios-simulator/README.md +++ b/docs/features/ios-simulator/README.md @@ -16,6 +16,30 @@ is hidden on non-darwin platforms, and `iosSimulatorService.launch` throws `"iOS Simulator control is only available on macOS."` when called from a non-darwin host. +## Runtime ownership + +The iOS Simulator service runs on the runtime host that owns the project, +because every operation it performs (`xcrun simctl`, `xcodebuild`, +Simulator.app window control, IOSurface/Indigo helper compilation, idb +companion lifecycle) needs the macOS toolchain on that machine. + +- A **local Mac runtime** (the desktop's local `ade serve`, or + `ade serve` started directly on a Mac) can drive the simulator. The + desktop renderer mounts the panel and streams MJPEG frames from the + runtime over IPC. +- A **remote Mac runtime** (another Mac reached over SSH) can drive + its own simulator. The desktop renderer streams frames through the + SSH-tunneled JSON-RPC channel. +- A **remote Linux runtime** cannot launch the simulator — the + service's tool-readiness check (`getStatus().supported`) returns + `false` on non-darwin hosts, the renderer hides the toggle, and + `ade ios-sim launch` rejects with the macOS-only error message. + +The MJPEG stream URL the renderer renders is allocated and bound on the +runtime host. For remote bindings, frame bytes flow over the same +runtime RPC channel as everything else; there is no direct browser → +remote-localhost connection. + ## Source file map | Path | Role | diff --git a/docs/features/ios-simulator/inspector.md b/docs/features/ios-simulator/inspector.md index 4943ed7f1..825b2fd19 100644 --- a/docs/features/ios-simulator/inspector.md +++ b/docs/features/ios-simulator/inspector.md @@ -1,14 +1,19 @@ # ADE Inspector (Swift inspector kit) -ADE Inspector is the bridge that lets the Electron iOS Simulator panel -say "the user just tapped *that* SwiftUI view, defined at this file +ADE Inspector is the bridge that lets the iOS Simulator panel say +"the user just tapped *that* SwiftUI view, defined at this file and line, with these accessibility tags." It runs entirely inside the debug build of the iOS app under inspection, publishes a JSON snapshot of every annotated view's frame to a known path inside the app's data -container, and the Electron service correlates that snapshot with a -fresh simctl screenshot to produce `IosScreenSnapshot` and +container, and the iOS Simulator service correlates that snapshot with +a fresh simctl screenshot to produce `IosScreenSnapshot` and `IosElementContextItem` values. +Snapshot reads happen inside whichever runtime daemon owns the active +simulator session. Because the simulator is macOS-only, that runtime +is always a Mac (local or remote-Mac); the renderer is purely a viewer +over the resulting elements. + The kit is **DEBUG-only**: under `#if DEBUG` the modifiers attach preference values and the snapshot host emits JSON; under release builds both modifiers compile to a no-op so production binaries do diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index a88936852..2b0842215 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -11,9 +11,43 @@ This folder documents the Lanes feature: data model, worktree mechanics, stack dependency graphs, the runtime isolation subsystem, and the OAuth redirect service that makes multi-lane auth practical. +## Where this runs + +Lane lifecycle (create / attach / rename / archive / delete / rebase / +branch-switch / port + proxy + OAuth + diagnostics) is owned by the **ADE +runtime daemon** (`ade serve` listening on `~/.ade/sock/ade.sock`), not by +the Electron main process. The renderer's `window.ade.lanes.*` calls go +through `apps/desktop/src/preload/preload.ts`, which routes every +runtime-backed method through `LocalRuntimeConnectionPool` for +local-bound windows or through `RemoteConnectionPool` (SSH-attached) for +remote-bound windows. The legacy in-process `laneService.ts` still exists +on the desktop main process as a fallback target so older callers and +tests keep working — preload calls the runtime first via +`callProjectRuntimeActionOr("lane", …)` and only invokes the local IPC +handler if no runtime is bound. For remote-bound windows the worktree is +created on the remote machine; the desktop renders the same UX but the +git operations, file watchers, PTYs, and processes execute on the remote +host. The desktop main process keeps a thin `laneListSnapshotService.ts` +helper for assembling per-window lane snapshots that overlay sync +presence on top of runtime-supplied lane summaries. Multi-window: each +desktop window has its own project binding, so a lane-creation request +in window A targets window A's runtime (local or remote) regardless of +what window B is bound to. + ## Source file map -Core services (`apps/desktop/src/main/services/lanes/`): +Core services. The canonical lane lifecycle now runs in the **ADE +runtime daemon**; the desktop main-process services below remain as +either fallback targets or thin desktop-side helpers. + +Runtime services (`apps/ade-cli/src/services/lanes/` and friends): + +- `apps/ade-cli/src/services/projects/projectRuntime.ts` exposes the + `lane` action domain (CRUD, runtime isolation, branch switching, + templates, diagnostics) over JSON-RPC; remote runtimes are reached + over the SSH-tunneled equivalent. + +Desktop fallback services (`apps/desktop/src/main/services/lanes/`): | File | Responsibility | |------|---------------| @@ -27,6 +61,7 @@ Core services (`apps/desktop/src/main/services/lanes/`): | `oauthRedirectService.ts` | OAuth callback routing for multi-lane (Phase 5 W5) | | `runtimeDiagnosticsService.ts` | Aggregate lane health checks, fallback mode (Phase 5 W6) | | `laneLaunchContext.ts` | Pure helper: resolves launch cwd/env for terminals and tools | +| `laneListSnapshotService.ts` | Desktop-side snapshot assembly: takes runtime-supplied lane summaries and decorates them with sync presence (`devicesOpen`), conflict status, rebase suggestions, auto-rebase status, and runtime session bucket counts. Used to build the lane list for the renderer without round-tripping every overlay separately. | Renderer components: @@ -327,8 +362,13 @@ refusal, duplicate-owner refusal, stale-PR cleanup). ## IPC surface -Registered in `apps/desktop/src/main/services/ipc/registerIpc.ts` and -exposed through `apps/desktop/src/preload/preload.ts`. +Registered as runtime actions on the `lane` domain (served by the local +or remote ADE runtime daemon) and as legacy in-process IPC handlers in +`apps/desktop/src/main/services/ipc/registerIpc.ts` for the fallback +path. Exposed through `apps/desktop/src/preload/preload.ts`, which +prefers the runtime route. Remote-bound desktop windows execute every +lane action on the remote machine — including `git worktree add`, the +delete teardown pipeline, env init, and template apply. Lane management (selected): diff --git a/docs/features/lanes/oauth-redirect.md b/docs/features/lanes/oauth-redirect.md index 87a8056ce..d23b70d5d 100644 --- a/docs/features/lanes/oauth-redirect.md +++ b/docs/features/lanes/oauth-redirect.md @@ -1,11 +1,21 @@ # OAuth redirect service -`apps/desktop/src/main/services/lanes/oauthRedirectService.ts` routes -OAuth callbacks back to the correct lane when many lanes share an -OAuth provider configuration. This is a fragile subsystem: it sits -inline on the proxy request path, owns three state machines, and has -recently been hardened in ways tests now pin directly. Treat it with -care. +The OAuth redirect service routes OAuth callbacks back to the correct +lane when many lanes share an OAuth provider configuration. It runs +inside the **active runtime** (local ADE daemon for local-bound windows, +SSH-attached remote runtime for remote-bound windows) and sits inline on +the lane proxy request path on whichever host owns the proxy. This is a +fragile subsystem: it owns three state machines and has recently been +hardened in ways tests now pin directly. Treat it with care. + +Source files: + +- Canonical implementation lives alongside the runtime daemon's lane + isolation services and is exercised through the same code that + `apps/desktop/src/main/services/lanes/oauthRedirectService.ts` + retains as a fallback target. The desktop file (and its companion + `oauthRedirectService.test.ts`) is the file you edit when making + changes — both runtime and fallback consume it. > **This branch touches this service heavily.** The current branch > changes include direct modifications to `oauthRedirectService.ts` diff --git a/docs/features/lanes/runtime.md b/docs/features/lanes/runtime.md index aefdfc047..201511f72 100644 --- a/docs/features/lanes/runtime.md +++ b/docs/features/lanes/runtime.md @@ -5,9 +5,31 @@ parallel dev environment: its own port range, its own `.localhost` hostname, its own OAuth callback routing, its own health signals, and optional per-lane env init. Shipped as Phase 5 workstreams W1–W6. +## Where this runs + +Every service below executes inside the **active runtime** for the +window's project binding — the local ADE daemon (`ade serve`) for +local-bound windows or the SSH-attached remote runtime for +remote-bound windows. Port leases, proxy hostname routing, OAuth +callback handling, env init, and runtime diagnostics all run on the +machine that owns the lane's worktree. The desktop main-process copies +under `apps/desktop/src/main/services/lanes/` are kept as fallback +implementations only; the canonical ones now live alongside the runtime +services in `apps/ade-cli/`. The renderer's `window.ade.lanes.*` APIs +that touch this subsystem (`initEnv`, `getEnvStatus`, `port.*`, +`proxy.*`, `oauth.*`, `diagnostics.*`) are routed through preload's +`callProjectRuntimeActionOr("lane", …)` helper, which prefers the +runtime daemon and only falls back to in-process handlers when no +runtime is bound. + +For remote-bound windows the listening sockets, the `*.localhost` +proxy, and the OAuth callback URL all live on the remote host. Preview +URLs reflect that hostname. + ## Services -Main process services in `apps/desktop/src/main/services/lanes/`: +Services keyed by workstream. Code paths shown for the desktop +fallback target; the runtime daemon hosts the canonical instances. | Service | Workstream | Responsibility | |---------|-----------|----------------| diff --git a/docs/features/lanes/worktree-isolation.md b/docs/features/lanes/worktree-isolation.md index e320bd7ee..1195fffbc 100644 --- a/docs/features/lanes/worktree-isolation.md +++ b/docs/features/lanes/worktree-isolation.md @@ -4,7 +4,17 @@ Every non-primary lane lives in its own git worktree. This is the mechanism that lets ADE hold dozens of branches checked out simultaneously without thrashing a single working directory. -Source: `apps/desktop/src/main/services/lanes/laneService.ts`. +Worktree creation, removal, and the `git worktree …` shell-outs that +back them are owned by the **active ADE runtime** — the local daemon +(`ade serve`) for local-bound windows, or the SSH-attached remote +runtime for remote-bound windows. The desktop main process exposes +`apps/desktop/src/main/services/lanes/laneService.ts` as a fallback +target with the same interface so older callers and tests keep +working, but the canonical lifecycle lives in the runtime daemon. +Remote-bound windows therefore create worktrees on the remote +machine: the desktop UX is identical, but the worktree directory, +the per-lane state, and every git command for the lane all live on +the remote host. ## Worktree placement @@ -100,9 +110,14 @@ persisted in the SQLite KV/tables, not on disk. ## Worktree interactions with git operations -All git commands are routed through -`apps/desktop/src/main/services/git/git.ts` with `cwd` pinned to the -lane's `worktree_path`. This matters because: +All git commands run inside the active runtime — not in the Electron +main process — with `cwd` pinned to the lane's `worktree_path`. The +runtime spawns `git` directly on the host that owns the worktree +(local daemon spawns on the desktop machine; the remote runtime spawns +on the remote machine over SSH). The desktop fallback path uses +`apps/desktop/src/main/services/git/git.ts` (same shell-out shape) so +the legacy IPC handlers behave identically when the runtime is not +present. This matters because: - Stashes, rebases, merges, and cherry-picks are worktree-local — nothing bleeds into other lanes. diff --git a/docs/features/linear-integration/README.md b/docs/features/linear-integration/README.md index d56ceb598..ef77831dc 100644 --- a/docs/features/linear-integration/README.md +++ b/docs/features/linear-integration/README.md @@ -11,6 +11,27 @@ This document describes the shape of the integration: who participates, which services own what, which tables store state, and how the desktop app and the headless ADE CLI run the same pipeline. +## Runtime ownership + +The full Linear stack — credential service, GraphQL client, issue tracker, +template service, workflow file loader, flow policy, routing, intake, +outbound, dispatcher, sync, ingress, and closeout — runs inside the +runtime daemon that owns the project. The desktop renderer is a viewer +over `window.ade.cto.linear*` IPC channels, and the headless ADE CLI +hosts the same services through `apps/ade-cli/src/headlessLinearServices.ts` +so Linear-driven workflows can run in `ade serve` without the desktop +app open. + +Both the desktop main process (for local projects) and the standalone +`ade serve` daemon load the same service modules out of +`apps/desktop/src/main/services/cto/`; the path reflects the source +tree, not where execution happens. + +The webhook HTTP listener (`linearIngressService`), the relay poller, +and the reconciliation timer (`linearSyncService`) all bind on the +runtime host. A remote runtime behind a NAT therefore needs the relay +path even if the desktop machine has a public webhook URL. + ## Who uses it The integration is used by four distinct consumers: diff --git a/docs/features/linear-integration/dispatch-and-sync.md b/docs/features/linear-integration/dispatch-and-sync.md index 77c3201f7..0c0e6423d 100644 --- a/docs/features/linear-integration/dispatch-and-sync.md +++ b/docs/features/linear-integration/dispatch-and-sync.md @@ -5,6 +5,16 @@ the sync loop, how the dispatcher walks a run through its steps, and how the closeout service pushes the terminal outcome back to Linear. Workflow authoring and presets are covered in `workflow-presets.md`. +Every component below — ingress HTTP server, relay long-poller, +reconciliation timer, dispatcher loop, closeout service, retry queue, +delegation queue, and outbound API client — runs inside the runtime +daemon (`ade serve`) that owns the project. For local projects the +local daemon hosts them; for remote projects the remote runtime hosts +them. The headless ADE CLI in `headlessLinearServices.ts` constructs +the same services so Linear can drive missions / chats / PRs without +the desktop UI running. PR creation paths are local-by-default because +they shell out to git; they still execute on the runtime host. + ## Overview Three independent loops drive issue state into the dispatcher: diff --git a/docs/features/memory/README.md b/docs/features/memory/README.md index 4cac883a9..b47a4f041 100644 --- a/docs/features/memory/README.md +++ b/docs/features/memory/README.md @@ -6,6 +6,37 @@ a unified store with three scopes (project, agent, mission), three tiers and promotes entries over time. Memory operates automatically in the background; agents and the user rarely touch the raw store. +## Runtime ownership + +Memory is owned by the runtime daemon that owns the project. The unified +store, embedding worker, hybrid search, lifecycle sweep, batch +consolidation, knowledge capture, episodic summaries, procedural +learning, and skill export all run inside `ade serve` for that project. +Writes from the desktop renderer and ADE CLI go through the runtime's +JSON-RPC surface; the renderer is a viewer over `ade.memory.*` +channels. + +**Remote runtimes have memory and embeddings disabled in v1.** The +static remote runtime build does not bundle `onnxruntime-node`, so the +embedding model cannot load and the hybrid search service refuses to +run. Remote-bound projects therefore: + +- Have no `unified_memories` writes from agents on the remote host — + `memoryAdd` returns rejected, and the turn-level memory guard treats + `required` turns as if no embeddings exist. +- Cannot consolidate, decay, or promote — lifecycle and consolidation + jobs are no-ops. +- Cannot run procedural learning or compaction-flush hooks for memory. +- Skip `.ade/memory/MEMORY.md` regeneration. + +For projects that round-trip between a local Mac runtime and a remote +Linux runtime, all memory state lives in the local runtime's database. +The remote runtime's `unified_memories` table is empty by design. + +Memory shown in Settings -> Memory always reflects the runtime that +owns the active project binding. Switching to a remote project hides +embedding-dependent surfaces. + ## Source file map | Path | Role | diff --git a/docs/features/memory/compaction.md b/docs/features/memory/compaction.md index 7af10f5c8..9294b29ce 100644 --- a/docs/features/memory/compaction.md +++ b/docs/features/memory/compaction.md @@ -4,6 +4,14 @@ Memory is not write-only: ADE actively synthesises new entries, merges similar ones, and invalidates stale state. This doc covers the services that keep the memory store healthy. +All of these services run inside the runtime daemon that owns the +project (local `ade serve` for local bindings). On remote bindings the +embedding-backed paths (consolidation, procedural learning, +embedding-dependent capture) are disabled along with embeddings; +intervention/PR-feedback capture and the daily sweep can still write +rows, but they have no semantic-search target and no consolidation +pass. + ## Source file map | Path | Role | diff --git a/docs/features/memory/embeddings.md b/docs/features/memory/embeddings.md index 5802d580f..1ce3997e9 100644 --- a/docs/features/memory/embeddings.md +++ b/docs/features/memory/embeddings.md @@ -5,6 +5,28 @@ meaning-based memory search and the consolidation clustering pipeline. Embeddings are produced in-process by a Transformers.js runtime; no memory data is sent externally for vectorisation. +## Runtime availability + +Embeddings run inside the runtime daemon that owns the project. + +- **Local runtime (`ade serve` on the user's machine):** the embedding + service loads `Xenova/all-MiniLM-L6-v2` lazily and the worker drains + pending memories on schedule. This is the default path for local + project bindings. +- **Remote runtime (static build deployed over SSH):** embeddings are + **disabled in v1**. The static remote build does not bundle + `onnxruntime-node`, so the Transformers.js pipeline cannot load. The + service reports `state: "unavailable"`, the worker stays idle, and + `hybridSearchService` throws `HybridSearchUnavailableError` on every + query (which `memoryService.search` catches and falls back to lexical + FTS — but with an empty `unified_memories` table on the remote host, + even FTS returns nothing). + +Practically: when a project is bound to a remote runtime, expect no +semantic memory retrieval and no consolidation. The Settings -> Memory +panel hides the model-download affordance and surfaces an +"unavailable" state for the active binding. + ## Source file map | Path | Role | diff --git a/docs/features/memory/storage.md b/docs/features/memory/storage.md index c1975b049..e2188971d 100644 --- a/docs/features/memory/storage.md +++ b/docs/features/memory/storage.md @@ -4,6 +4,12 @@ Memory lives in SQLite (`kvDb.ts`), with a sidecar folder under `.ade/memory/` for bootstrap and topic files. This doc captures the schema, how entries move through it, and the key integrity rules. +The schema is owned by the runtime daemon that owns the project. The +local runtime hosts the canonical `unified_memories` table for local +projects; for remote projects the same schema exists on the remote host +but stays empty in v1 because the static remote runtime cannot load the +embedding pipeline (see `embeddings.md`). + ## Source file map | Path | Role | diff --git a/docs/features/missions/README.md b/docs/features/missions/README.md index 28d27ed2d..0637acd1e 100644 --- a/docs/features/missions/README.md +++ b/docs/features/missions/README.md @@ -4,6 +4,16 @@ A mission is ADE's structured, multi-step execution primitive. It wraps a user g The runtime is feature-rich but the mission launcher and page shell now follow a staged-load model so the surface stays responsive even while orchestrator metadata warms up. +## Runtime ownership + +Missions live in whichever runtime daemon owns the project. The coordinator agent loop, planner workers, implementation/testing/validation workers, intervention queue, recovery loop, and result-lane finalization all execute inside `ade serve`. For local projects that is the local daemon; for remote projects it is the remote runtime over SSH-tunneled JSON-RPC. The desktop renderer's mission UI (`MissionsPage`, `MissionDetailView`, chat channels, plan editor) is purely a view: it reads runs/steps/attempts/events through the active runtime binding, sends control RPCs (start, cancel, steer, intervene, approve), and renders coordinator/worker chat threads. + +Caveats that follow from "runtime owns missions": + +- Worker provider availability follows the runtime host. A remote Linux runtime cannot launch a worker that requires the macOS-only iOS Simulator; that worker has to run on a Mac runtime. +- Mission artifacts (including computer-use proof) write to the runtime host's project artifacts directory. Remote runs store proof on the remote machine. +- Memory and embedding-backed retrieval are disabled on remote bindings (the static remote build does not bundle `onnxruntime-node`); mission preflight knowledge-sync degrades to lexical fallbacks for remote-hosted runs. + ## Source file map ### Core services (apps/desktop/src/main/services/) @@ -35,7 +45,8 @@ The runtime is feature-rich but the mission launcher and page shell now follow a - `orchestrator/teamRuntimeConfig.ts` / `teamRuntimeState.ts` — team manifest and runtime state. - `orchestrator/permissionMapping.ts` — mission permission config to provider-specific tool permissions. - `orchestrator/orchestratorQueries.ts` — row types, helpers for mapping DB rows to typed objects, normalization. -- `apps/ade-cli/src/cli.ts` — typed `ade missions` command group (`list`, `create`, `launch`, `start`, `resume`, `show`, `runs`, `graph`, `watch`) plus phase/planned-step JSON payload options for headless or socket-backed mission operations. +- `apps/ade-cli/src/cli.ts` — typed `ade missions` command group (`list`, `create`, `launch`, `start`, `resume`, `show`, `runs`, `graph`, `watch`) plus phase/planned-step JSON payload options for headless or socket-backed mission operations. Routes through the active runtime daemon; with `--socket` it talks to the desktop's local daemon, otherwise it spins up a headless project scope. +- `apps/ade-cli/src/multiProjectRpcServer.ts` — exposes mission lifecycle and run-graph reads as project-scoped JSON-RPC actions consumed by both the desktop preload bridge (for remote bindings) and the CLI. ### Renderer diff --git a/docs/features/missions/orchestration.md b/docs/features/missions/orchestration.md index 9eaefb37a..7b1c1926d 100644 --- a/docs/features/missions/orchestration.md +++ b/docs/features/missions/orchestration.md @@ -1,10 +1,12 @@ # Orchestration -The orchestrator is the runtime that drives missions. It owns runs, steps, attempts, claims, artifacts, gate reports, timeline events, and the coordinator-agent session that turns a natural-language goal into a multi-step plan and execution DAG. +The orchestrator is the in-runtime engine that drives missions. It owns runs, steps, attempts, claims, artifacts, gate reports, timeline events, and the coordinator-agent session that turns a natural-language goal into a multi-step plan and execution DAG. Every part of the orchestrator runs inside whichever runtime daemon owns the project (local `ade serve` for local projects, the remote runtime over SSH for remote projects); the desktop UI is a viewer over the runtime's RPC surface. + +Worker spawn paths use whatever provider CLIs are installed on the runtime host. A remote Linux runtime can spawn `claude-local` and `codex-local` workers but cannot spawn anything that needs macOS-only tooling (e.g. iOS simulator drivers); plan accordingly when authoring missions for remote-hosted projects. ## Source file map -All in `apps/desktop/src/main/services/orchestrator/`. +All in `apps/desktop/src/main/services/orchestrator/`. Files in this directory are loaded by the runtime daemon's project scope (and by the desktop main process for local projects); the path reflects the source tree, not the host process. - `orchestratorService.ts` — row-level persistence and the low-level run state machine. `tickRun`, `completeAttempt`, claim acquisition, gate reports. ~8000 LOC. The most delicate file in the service layer. - `aiOrchestratorService.ts` — the façade used by the rest of the app and by the AI surfaces. Wires mission + orchestrator + AI integration + memory + budget + conflict services. Owns top-level flows: `pauseMissionWithIntervention`, `steerMission`, run finalization, recovery. diff --git a/docs/features/missions/validation-gates.md b/docs/features/missions/validation-gates.md index 5f52101e0..298d7cf7a 100644 --- a/docs/features/missions/validation-gates.md +++ b/docs/features/missions/validation-gates.md @@ -4,6 +4,8 @@ Missions have a dedicated validation contract that lives outside the normal unit This document indexes the assertions by area and notes where the backing tests live. It is a pointer to the contract, not a replacement for it. +The orchestrator that enforces these invariants runs inside the runtime daemon (local `ade serve` or remote runtime) — every VAL-XXX assertion is a runtime-side guarantee. + ## Source file map - `docs/validation-contract-m1-m2.md` — the contract (366 lines, 19 assertions). diff --git a/docs/features/missions/workers.md b/docs/features/missions/workers.md index c24942b97..be2b551d1 100644 --- a/docs/features/missions/workers.md +++ b/docs/features/missions/workers.md @@ -2,6 +2,8 @@ Mission workers are transient, role-scoped agents spawned by the coordinator to execute specific phases of a mission. They are distinct from CTO "Team" workers (see `../cto/workers.md`) — Team workers are stable identities the operator configures; mission workers are ephemeral, spawned by `spawn_worker` with a role and a delegation contract that applies only to the current run. +Workers always launch on the runtime that owns the mission. Provider availability follows the runtime host: a remote runtime that lacks the Claude CLI cannot spawn `claude-local` workers, and macOS-only capabilities (iOS Simulator, native screencapture) are only reachable when the runtime itself is on macOS. + ## Source file map - `apps/desktop/src/main/services/orchestrator/coordinatorTools.ts` — `spawn_worker` tool, `classifyPlannerLaunchFailure`. @@ -104,9 +106,9 @@ The coordinator respects: - **Claude CLI** — `resolveClaudeCliModel`, spawns the Claude Code CLI binary with `--model`, `--append-system-prompt`, and a tailored tool allowlist. - **Codex CLI** — `resolveCodexCliModel`, spawns the Codex CLI with a similar config. -- **ADE CLI** — workers inherit ADE context env vars and can call the `ade` command for ADE operator actions. +- **ADE CLI** — workers inherit ADE context env vars and can call the `ade` command for ADE operator actions against the same runtime daemon they were spawned by. -Each launcher reads the `classifyWorkerExecutionPath(model)` classification from the model registry to decide between provider CLI and managed OpenCode execution. +Each launcher reads the `classifyWorkerExecutionPath(model)` classification from the model registry to decide between provider CLI and managed in-runtime execution. CLI binaries are resolved on the runtime host's `PATH`; missing binaries surface as `startup_failure` rather than a silent fallback. ## File-claim scope diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 5307d4e3a..fc041b5c3 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -2,10 +2,13 @@ Two related but distinct flows: -- **Onboarding** — the fastest path to a usable project. Detects dev - tools and stack signals, suggests a project config, optionally - imports existing git branches as lanes, and runs a short wizard for - AI providers, GitHub, and optional integrations. +- **Onboarding** — the fastest path to a usable installation and a usable + project. Covers installing the per-machine ADE runtime daemon as a login + service, putting `ade` on `PATH`, registering the project with the runtime + so every client (desktop, `ade code`, iOS) sees it, then detecting dev tools + and stack signals, suggesting a project config, optionally importing + existing git branches as lanes, and walking the user through AI providers, + GitHub, and optional integrations. - **Settings** — long-lived configuration organized by tab. Persists to `.ade/ade.yaml` (shared) and `.ade/local.yaml` (local) through `projectConfigService`. @@ -15,6 +18,24 @@ service. Project open favors a cheap first pass; secondary hydration (full lane status, provider modes, semantic indexing) happens after the app is interactive. +## Where state lives + +ADE state is split between the per-machine runtime root and per-project +directories. Onboarding writes to both. + +| Scope | Location | Owner | Contents | +|---|---|---|---| +| Machine | `~/.ade/` (`ADE_HOME` overrides; channel builds use `~/.ade-alpha/` / `~/.ade-beta/`) | `ade serve` runtime daemon | Runtime socket (`sock/ade.sock`), project registry (`projects.json`), encrypted credential store (`secrets/`), bundled binary (`bin/ade`), native runtime deps (`runtime/<arch>/`), service log files. | +| Project (shared) | `<project>/.ade/ade.yaml` | `projectConfigService` | Version-controlled team config: processes, stacks, tests, automations, lane templates, AI mode, providers, Linear sync. | +| Project (local) | `<project>/.ade/local.yaml` | `projectConfigService` | Per-user, gitignored: ports, env vars, local-only processes. | +| Project (data) | `<project>/.ade/` | various services | Lanes, attachments, kvDb, generated assets. The shared `.ade/.gitignore` whitelists only authored files. | + +The runtime daemon is the seam that ties machine and project scope +together: it owns `~/.ade/projects.json`, lazily builds an `AdeRuntime` +per project root on first project-scoped JSON-RPC call, and is the +single host through which desktop, `ade code`, and SSH-attached +desktops see live lanes / chats / processes. + ## Source file map Main process: @@ -153,11 +174,16 @@ Renderer — settings: Visual chat / theme controls now live in the dedicated Appearance tab (`AppearanceSection.tsx`). - `apps/desktop/src/renderer/components/settings/AdeCliSection.tsx` - — surfaces `ade.cli.getStatus` / `ade.cli.install` / `ade.cli.uninstall`. - In compact form (used by `GeneralSection` and the onboarding - `DevToolsSection`) it shows the current install path, an - Install / Repair button, and a "Add to PATH" hint when the install - target isn't on the user's `$PATH`. + — surfaces `window.ade.adeCli.getStatus()` / `installForUser()`. + Status carries `terminalInstalled`, `agentPathReady`, + `bundledAvailable`, and the resolved `installTargetPath` for the + bundled `ade` binary. In compact form (used by `GeneralSection` and + the onboarding `DevToolsSection`) it shows the current install + path, an Install / Repair button that runs the platform + install-path helper, and an "Add to PATH" hint when the install + target isn't on the user's `$PATH`. Agents launched by ADE always + get the bundled CLI automatically; this surface is what makes + `ade` available to the user's own terminals. - `apps/desktop/src/renderer/components/settings/WorkspaceSettingsSection.tsx` + `ProjectSection.tsx` — project identity, base ref, paths. - `apps/desktop/src/renderer/components/settings/AiSettingsSection.tsx` @@ -245,6 +271,41 @@ Auto-update (top-bar control, not a settings tab): ## Onboarding responsibilities +Onboarding covers two layers. + +### Machine layer (one-time per machine) + +Driven by `LocalRuntimeConnectionPool` on desktop launch and surfaced in +the General settings tab via `AdeCliSection`: + +1. Bring up the runtime daemon. The pool tries to attach to + `~/.ade/sock/ade.sock`; if that fails it spawns + `ade serve --socket <path>` from the bundled CLI and waits for the + socket. A version mismatch between the running daemon and the desktop + build forces a clean restart. +2. Register the runtime as a per-user login service so it survives + reboots. `installServiceBestEffort()` runs `ade serve --install-service` + once per session; the implementation lives in + `apps/ade-cli/src/serviceManager/` (launchd / systemd / schtasks). + The result is exposed as `LocalRuntimeStatus.serviceInstall` and + `serviceHealth` (`unsupported | not_installed | installed | running | + error | unknown`). +3. Install the `ade` command on `PATH`. The `AdeCliSection` "ADE + command" card calls `window.ade.adeCli.installForUser()`, which + delegates to the platform helper script bundled with the desktop + (`/Applications/ADE.app/Contents/Resources/ade-cli/install-path.sh` + on macOS, equivalents on other platforms). The compact form embedded + in `GeneralSection` and the onboarding `DevToolsSection` shows the + current install path, an Install / Repair button, and an "Add to + PATH" hint when the install target is not on the user's `$PATH`. +4. Register projects with the runtime. Opening a project on desktop + calls `LocalRuntimeConnectionPool.ensureProject(rootPath)`, which + issues `projects.add { rootPath }` against the daemon. The project + then appears in `projects.list` to every other client (`ade code`, + iOS, SSH-attached desktops) without an extra step. + +### Project layer (per project) + Repository onboarding covers five things: 1. detect dev tools (git, gh CLI) and report availability @@ -262,6 +323,17 @@ Current behavior: - expensive background work is no longer gated on "must finish before the app feels usable" +### Headless install + +For machines without a desktop install (CI workers, remote +SSH-attached runtimes), the runtime daemon and `ade` CLI install via +`curl -fsSL .../install.sh | sh`. The script downloads the static +`ade-<platform-arch>` binary plus its native dependency archive, drops +the binary in `$ADE_INSTALL_DIR` (or `~/.local/bin`), extracts native +modules under `~/.ade/runtime/<arch>/`, and best-effort registers the +login service. See [`apps/ade-cli/README.md`](../../../apps/ade-cli/README.md) +for the full flow and environment overrides. + ### CTO first-run setup CTO (the agent identity used in the Chat tab) has its own lightweight @@ -275,8 +347,6 @@ wizard: onboarding with or without Linear. Fastest path is a personal API key; OAuth is available but not the default recommendation. -OpenClaw is intentionally excluded from first-run setup. - ## Settings responsibilities Top-level tabs, organized to match the kind of thing the user is @@ -284,10 +354,10 @@ changing rather than which service backs it: | Tab | Section file | What lives here | |---|---|---| -| General | `GeneralSection.tsx` (embeds `AdeCliSection` in compact form) | AI mode, task routing, terminal preferences (font size, line height, scrollback), keybindings link, and the `ade` CLI install / status surface. Receives the legacy `?tab=onboarding`, `?tab=help`, `?tab=tours`, and `?tab=keybindings` deep links via `TAB_ALIASES`. | +| General | `GeneralSection.tsx` (embeds `AdeCliSection` in compact form) | AI mode, task routing, terminal preferences (font size, line height, scrollback), keybindings link, and the `ade` CLI install / status surface. The CLI card reports whether the bundled `ade-<platform-arch>` binary is on `PATH`, the resolved install target, and exposes one-click Install / Repair backed by the platform install-path helper. Receives the legacy `?tab=onboarding`, `?tab=help`, `?tab=tours`, and `?tab=keybindings` deep links via `TAB_ALIASES`. | | Appearance | `AppearanceSection.tsx` (renders `ChatAppearancePreview`) | Theme, code-block copy-button position, agent-turn completion sound + volume + quiet-when-focused, chat font size (`chatFontSizePx`), chat transcript density (`chatTranscriptDensity` — `compact` / `comfortable` / `spacious`), chat chrome tint (`chatChromeTint` — `colored` default vs `neutral` for monochrome chrome; the legacy `chatLaneAccentEmphasis` preset slug is still read so older user-pref blobs migrate cleanly), chat shell geometry (`chatShellGeometry` — `soft` / `default` / `sharp` corners), and the user-message minimap toggle (`chatUserMinimapEnabled` — drives the inline `ChatUserMinimap`). Persisted to `localStorage` under `ade.userPreferences.v1`. | | Workspace | `WorkspaceSettingsSection.tsx`, `ProjectSection.tsx` | Project identity, paths, skill files. (`SyncDevicesSection.tsx` — multi-device sync, host transfer, peer status, pairing PIN, Tailscale discovery — is mounted from the top bar's Sync popover, not as a Settings tab.) | -| AI | `AiSettingsSection.tsx`, `AiFeaturesSection.tsx`, `ProvidersSection.tsx` | Provider CLIs, models, AI feature flags | +| AI | `AiSettingsSection.tsx`, `AiFeaturesSection.tsx`, `ProvidersSection.tsx` | Provider CLIs, models, API-key status, provider readiness, OpenCode runtime diagnostics, and AI feature flags. The same status surface is exposed through ADE actions for `ade code` model setup. | | Mobile Push | `MobilePushPanel.tsx` | APNs registration, paired-device push tokens, per-category preferences | | Integrations | `IntegrationsSettingsSection.tsx`, `GitHubSection.tsx`, `LinearSection.tsx` | GitHub, Linear, and computer-use backend readiness. The GitHub section reads `status.connected` (the backend's single "GitHub is usable" gate) to decide between CONNECTED / LIMITED ACCESS / NOT CONNECTED, surfaces a dedicated repo-probe error when a fine-grained token authenticates as a user but cannot access the active repo, and the REFRESH button calls `getStatus({ forceRefresh: true })` so users who fix permissions on github.com see the change immediately. See [`pull-requests/README.md`](../pull-requests/README.md#github-connectivity-model) for the full status-shape and `connected` derivation. | | Memory | `MemoryHealthTab.tsx` | Memory health, browser, embedding health | diff --git a/docs/features/project-home/README.md b/docs/features/project-home/README.md index ac84d73c6..92b3c5945 100644 --- a/docs/features/project-home/README.md +++ b/docs/features/project-home/README.md @@ -7,6 +7,30 @@ loaded projects. The same surface (`RunPage`) is also the Run tab, because "the project's home" and "the project's execution substrate" have converged. +## Where this runs + +Project metadata reads (`window.ade.project.*`), process/test +definitions, runtime queries, and command lifecycle (`start`, `stop`, +`restart`, `startStack`, `startGroup`, `getLogTail`) all flow through +`apps/desktop/src/preload/preload.ts`, which calls +`callProjectRuntimeActionIfBound("process", …)` / +`callProjectRuntimeActionOr("ade_project", …)` / +`callProjectRuntimeActionOr("ai", …)` first for the **active runtime** +(local ADE daemon for local-bound windows, SSH-attached remote runtime +for remote-bound windows) and falls through to the legacy in-process +IPC handlers when no runtime is bound. Managed processes therefore +spawn on whichever machine owns the lane's worktree: the local +machine for local bindings, the remote host for remote bindings. The +welcome screen, project icons, recent project list, project browse / +create / clone flows, and the Add Project chooser still talk to the +desktop main process directly because they precede a project binding +(no runtime is connected yet) — they live under `window.ade.project.*` +and are handled by the desktop's `projectBrowserService`, +`projectScaffoldService`, `projectDetailService`, and +`projectIconResolver`. Multi-window: each desktop window has its own +project context, so the per-lane dashboard for window A reflects +window A's binding regardless of what is open in window B. + ## Source file map Renderer: @@ -49,7 +73,13 @@ Related pages for the broader "home" experience: `RunPage` becomes meaningful. See [../onboarding-and-settings/first-run.md](../onboarding-and-settings/first-run.md). -Main process (the substrate): +Backing services. The canonical lifecycle services run inside the +**active runtime** (local daemon or SSH-attached remote runtime); the +desktop main process keeps the same files as fallback targets for the +in-process IPC path. The pre-binding scaffold services +(`projectBrowserService`, `projectScaffoldService`, +`projectDetailService`, `projectIconResolver`) only run in the desktop +main process because they execute before a runtime binding exists. - `apps/desktop/src/main/services/processes/processService.ts` — lifecycle, readiness, restart. See @@ -65,7 +95,9 @@ Main process (the substrate): - `apps/desktop/src/main/services/agentTools/` — detects installed agent CLI tools (Claude Code, Codex, Cursor, Aider, Continue). - `apps/desktop/src/main/services/projects/projectBrowserService.ts` - — serves the Command Palette project browser: expands `~`, handles + — desktop-only (runs before any project binding so it stays on the + Electron main process). Serves the Command Palette project browser: + expands `~`, handles platform-appropriate relative / absolute paths, lists matching subdirectories with `.git` detection (concurrency-limited, capped at `limit` with 500 max), and resolves any exact-directory match up to diff --git a/docs/features/proof.md b/docs/features/proof.md index 26b2d71a7..061ad6e2f 100644 --- a/docs/features/proof.md +++ b/docs/features/proof.md @@ -8,6 +8,12 @@ The old system sat upstream of the agent and tried to normalize every backend. I The result: one interface for all models, no backend matrix, no coverage math. A proof set is a handful of captioned screenshots a reviewer can skim in under a minute. +## Runtime ownership + +Proof storage and the broker are owned by the runtime daemon (`ade serve`) that owns the project. Artifacts on disk live under the runtime host's `.ade/artifacts/computer-use/` directory; the SQLite rows live in that runtime's `.ade/ade.db`. For local projects that is the user's machine; for remote projects it is the remote host. The desktop renderer and the headless ADE CLI both call into the broker over JSON-RPC; nothing about the proof pipeline lives in the renderer or in a separate host process. + +That means: proof captured during a remote-runtime session lives on the remote host. The desktop drawer fetches preview bytes through the same SSH-tunneled JSON-RPC channel as the rest of the remote project surface; raw artifact files are not synced back to the desktop machine, and proof is only viewable while the runtime that captured it is reachable. + --- ## CLI reference @@ -98,17 +104,17 @@ Explicit owners are added in addition to the session identity inferred from `ADE ## Storage -Images live on disk under the project's `.ade/` scaffold: +Images live on disk under the project's `.ade/` scaffold on the runtime host: ``` -.ade/artifacts/computer-use/<uuid>.<ext> +<runtime host>/<project root>/.ade/artifacts/computer-use/<uuid>.<ext> ``` (Path will move to `.ade/artifacts/proof/` in a future phase.) Metadata is a single SQLite row per capture in `computer_use_artifacts`, with ownership links in `computer_use_artifact_links`. The columns relevant to the new system are a small subset of what the table carries today: `id`, `kind` (always `screenshot` for captures; `image` for attaches), `uri`, `mime_type`, `caption`, `created_at`, plus the owner link row. -There is no retention policy — captures persist until the project is cleaned up. Disk is the budget; nothing ages out automatically. +There is no retention policy — captures persist until the project is cleaned up. Disk is the budget; nothing ages out automatically. For remote-runtime projects, the disk being filled is the remote host's, not the desktop machine's. --- @@ -149,32 +155,34 @@ Headless-browser screenshots *are* supported — use `ade proof attach` with the ## Architecture ``` - agent (any model) + agent (any model, any runtime host) │ │ shell invocation ▼ ade proof capture --caption "…" │ - │ JSON-RPC over .ade/ade.sock + │ JSON-RPC over .ade/ade.sock (runtime daemon) ▼ - proof action (main-process) + proof action (runtime: ade serve) │ - ├── screencapture ─► .ade/artifacts/computer-use/<uuid>.png + ├── screencapture ─► <runtime host>/.ade/artifacts/computer-use/<uuid>.png │ └── computerUseArtifactBrokerService │ - │ SQLite insert + │ SQLite insert into <runtime host>/.ade/ade.db ▼ computer_use_artifacts + …_artifact_links │ ▼ - drawer UI (chat / mission) + drawer UI (renderer reads via + window.ade.proof.* → preload → + local or remote runtime RPC) ``` -The broker (`apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts`) is the only ingest path — both the `ade proof` CLI and any in-process call go through it. Supporting modules in the same directory: +The broker (`apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts`) is the only ingest path — both the `ade proof` CLI and any in-process call go through it. The same module is loaded by the desktop main process for local projects and by the standalone `ade serve` runtime for headless / remote use. Supporting modules in the same directory: - `controlPlane.ts` builds owner snapshots + backend status for the UI. -- `localComputerUse.ts` reports macOS-only proof-capture capabilities (`screencapture`, app launch, GUI interaction). +- `localComputerUse.ts` reports macOS-only proof-capture capabilities (`screencapture`, app launch, GUI interaction). Reflects the runtime host's environment, not the desktop machine's. - `agentBrowserArtifactAdapter.ts` parses agent-browser output into `ComputerUseArtifactInput[]`. - `syntheticToolResult.ts` produces tool-result stubs for the Claude compaction path. diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index 73f44e7ff..406f186bc 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -13,9 +13,34 @@ This folder documents: - [`conflict-simulation.md`](./conflict-simulation.md) — how ADE predicts PR merge conflicts before the user hits Merge. - [`path-to-merge.md`](./path-to-merge.md) — the Path-to-Merge orchestrator: phase delays, terminal-state gate, conflict strategy switch, force-finalize, merge ladder, and Queue Automate Merging. +## Where this runs + +PR CRUD, GitHub polling, queue landing, integration proposal +simulation, the Path-to-Merge orchestrator, and the issue/rebase +resolver agent dispatch all run inside the **active ADE runtime** +(local daemon for local-bound windows, SSH-attached remote runtime +for remote-bound windows). The renderer's `window.ade.prs.*` surface +in `apps/desktop/src/preload/preload.ts` routes every PR call through +`callProjectRuntimeActionOr("pr", …)` and falls back to the legacy +in-process IPC handlers only when no runtime is bound. PR polling +fingerprints, the `prsRouteState.ts` URL-state helper, and the +PR detail panes are renderer-only — they hold no service state. + +For remote-bound windows, GitHub polling, the queue automation loop, +and the Path-to-Merge orchestrator all execute on the remote machine. +The git operations that back PR merges, rebases, and conflict +resolution use the worktrees on the remote host. Stop / start / +status reads work exactly the same as local; the desktop window just +sends every action through the SSH-tunneled JSON-RPC instead of the +local socket. + ## Source file map -Main-process services (`apps/desktop/src/main/services/prs/`): +Services. The canonical implementations run inside the runtime +daemon; the desktop main-process files below stay as fallback targets +for the legacy in-process IPC path. + +Service files (`apps/desktop/src/main/services/prs/`): | File | Responsibility | |------|---------------| @@ -164,8 +189,9 @@ involving the current user, sorted by creation date. A scope filter Caching layers: -1. **Main process cache** — GitHub snapshot is cached for a short TTL - inside `prService`; repeated in-flight snapshot requests are +1. **Runtime cache** — GitHub snapshot is cached for a short TTL + inside `prService` on the active runtime (local daemon or + remote-attached); repeated in-flight snapshot requests are deduplicated. 2. **Renderer cache** — `PrsContext` holds the last snapshot so revisiting the tab renders immediately. diff --git a/docs/features/pull-requests/path-to-merge.md b/docs/features/pull-requests/path-to-merge.md index c1b50aefd..15c9d8677 100644 --- a/docs/features/pull-requests/path-to-merge.md +++ b/docs/features/pull-requests/path-to-merge.md @@ -6,19 +6,27 @@ It is a native TypeScript port of the `/shipLane` Claude skill state machine — `apps/.claude/commands/shipLane.md` is the source of truth for the phase delays, terminal-state gate, conflict-strategy switch, and force-finalize semantics; this implementation mirrors them in-process so -the Electron host can run several PtM loops in parallel without spawning -agents per phase. +the **active ADE runtime** (local daemon for local-bound windows, +SSH-attached remote runtime for remote-bound windows) can run several +PtM loops in parallel without spawning agents per phase. For +remote-bound windows the loop runs on the remote machine — the merge +ladder, gh CLI invocations, and resolver agent dispatches all execute +on the remote host. -Source: `apps/desktop/src/main/services/prs/pathToMergeOrchestrator.ts`. +Source: `apps/desktop/src/main/services/prs/pathToMergeOrchestrator.ts` +(used by the runtime daemon and the desktop fallback IPC path alike). ## Wiring and lifecycle -`createPathToMergeOrchestrator(deps)` is built once during main-process -boot in `main.ts` alongside the rest of the PR services. Right after -construction, `setImmediate(() => resumeFromPersistedState())` rearms any -loops that were live when the desktop last shut down. The orchestrator is -exposed to renderer code through two IPCs (registered in -`services/ipc/registerIpc.ts` and bridged via `preload.ts`): +`createPathToMergeOrchestrator(deps)` is built once during runtime +daemon boot (and during desktop main-process boot for the fallback +path) alongside the rest of the PR services. Right after construction, +`setImmediate(() => resumeFromPersistedState())` rearms any loops that +were live when the runtime last shut down. The orchestrator is exposed +to renderer code through two IPCs — preload's `window.ade.prs.pathToMerge.*` +routes through `callProjectRuntimeActionOr("pr", …)` first and falls +back to the legacy `services/ipc/registerIpc.ts` handlers when no +runtime is bound: | Channel | Purpose | |---------|---------| @@ -183,9 +191,10 @@ otherwise. ## Persistence and resume -`resumeFromPersistedState()` runs on boot from `main.ts`. It iterates -every PR via `prService.listAll()` and rearms a `warming`-phase wake-up -for any whose convergence runtime is still flagged as live +`resumeFromPersistedState()` runs on runtime daemon boot (and on +desktop main-process boot for the fallback path). It iterates every PR +via `prService.listAll()` and rearms a `warming`-phase wake-up for any +whose convergence runtime is still flagged as live (`autoConvergeEnabled === true`, `pollerStatus !== "stopped"`, `status !∈ {merged, stopped, cancelled}`). The warming delay is chosen diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md new file mode 100644 index 000000000..ca41ee3c6 --- /dev/null +++ b/docs/features/remote-runtime/README.md @@ -0,0 +1,117 @@ +# Remote Runtime + +The desktop app connects to an `ade serve` daemon running on a remote machine over SSH. The remote project lives on that machine; lanes, PTYs, git, agent chat, and PR actions all run there. The local desktop is the controller — it spawns no project services of its own for a remote binding. + +The wire transport is the same JSON-RPC the local daemon answers. The remote-runtime layer just wraps it in an SSH `exec` channel running `ade rpc --stdio`. + +## Source file map + +- `apps/desktop/src/main/services/remoteRuntime/` — SSH transport, runtime + bootstrap, target registry, runtime RPC client, remote connection pool. +- `apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts` — + the local daemon connection used by desktop IPC, event streaming, sync + Settings, and local-work checks. Spawns `ade serve` if the machine socket is + not listening; tracks the per-user login service install/health state. +- `apps/desktop/src/renderer/components/remoteTargets/` — remote machine form, + target list, project picker, dirty-local-work warning. +- `apps/desktop/src/renderer/components/projects/RemoteProjectOpenDialog.tsx` — + confirmation dialog before opening a remote project, surfaces local matches + with uncommitted changes. +- `apps/desktop/src/preload/preload.ts` — routes runtime-backed renderer APIs to + local or remote JSON-RPC actions based on the active project binding. +- `apps/ade-cli/src/multiProjectRpcServer.ts` — runtime-level project catalog + and sync methods plus project-scoped action dispatch. +- `apps/ade-cli/src/services/projects/` — machine project registry and + per-project service scope cache. +- `apps/ade-cli/scripts/build-static.mjs` — produces the static + `ade-<platform-arch>` SEA binary and the `.native.tar.gz` of native modules. +- `apps/ade-cli/scripts/install-runtime.sh` — standalone installer that + downloads `ade-<platform-arch>` and the matching native deps from a release. +- `apps/desktop/scripts/materialize-runtime-resources.mjs` and + `validate-runtime-resources.mjs` — populate and validate + `apps/desktop/resources/runtime/` for packaging. + +## User model + +A **remote target** is a machine reachable by SSH. A **remote project** is a path on that machine that has been registered with that machine's ADE runtime (via `projects.add`). Opening a remote project does not copy local files or move a local lane; ADE controls the remote runtime and expects normal git workflow to move code between local and remote clones. + +When opening a remote project, ADE checks local projects with the same git origin. If a matching local copy has uncommitted changes, ADE shows a confirmation dialog (`RemoteProjectOpenDialog`) before switching so the user can push, stash, or keep the divergent local work intentionally. + +## Connect flow + +1. Add a machine from the remote machines panel or command palette. +2. Enter a display name, hostname, SSH user, port, and optionally a private key path. If no key path is provided, ADE uses the user's local ssh-agent when `SSH_AUTH_SOCK` is available and reads matching `HostName` / `IdentityFile` entries from `~/.ssh/config`. +3. Connect. ADE opens an SSH session, detects the remote platform with `uname -sm`, and starts `ade rpc --stdio`. +4. If the bundled ADE runtime for that platform is present and the remote ADE binary is missing or stale, ADE uploads `ade-<platform-arch>` to `~/.ade/bin/ade`, uploads native dependencies to `~/.ade/runtime/<platform-arch>/`, and verifies `~/.ade/bin/ade --version`. +5. Pick an existing remote project or register a new remote path; the desktop calls `projects.add { rootPath }` against the remote runtime to bind it. + +Per-channel layout: builds with `ADE_PACKAGE_CHANNEL=alpha|beta` upload to `~/.ade-alpha/` or `~/.ade-beta/` instead of `~/.ade/` so a remote machine can host stable, beta, and alpha runtimes side by side, and they pass `ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1` so the channel build doesn't fight the stable login service for the socket. + +## Runtime artifact layout + +Desktop distributable builds require `apps/desktop/resources/runtime/` to contain every supported `ade-<platform-arch>` binary and matching `.native.tar.gz` archive. The supported targets are `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`. + +`apps/desktop/scripts/validate-runtime-resources.mjs` is the preflight that fails the package step when artifacts are missing. Release builds populate the resource directory from the runtime-binary CI workflow's artifacts via `materialize-runtime-resources.mjs`. For local same-platform packaging, build into the resource directory directly: + +```bash +npm --prefix apps/ade-cli run build:static -- --target <target> --out-dir ../desktop/resources/runtime +``` + +…or set `ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY=1` to validate only the host target during local channel builds (release builds always require the full set). + +`materialize-runtime-resources.mjs` searches `ADE_RUNTIME_ARTIFACTS_DIR`, then `apps/ade-cli/dist-static/`, copies any matching artifacts into the resource directory, and falls back to invoking `npm run build:static` for the host target when a missing artifact is the host build (downloading the official Node SEA helper if `ADE_STATIC_NODE_BINARY` isn't set and `ADE_RUNTIME_DISABLE_NODE_DOWNLOAD` isn't `1`). + +## Standalone runtime install + +For headless macOS / Linux machines that can run an SSH server but have no desktop, install the runtime directly from a release: + +```bash +curl -fsSL https://github.com/arul28/ADE/releases/latest/download/install.sh | sh +``` + +`install.sh` (lives at `apps/ade-cli/scripts/install-runtime.sh`): + +- detects platform / arch with `uname -sm`, +- downloads `ade-<platform-arch>` and `ade-<platform-arch>.native.tar.gz` from the release, +- installs the binary to `$ADE_INSTALL_DIR` (default `/usr/local/bin` if writable, else `~/.local/bin`), +- extracts the native modules to `~/.ade/runtime/<platform-arch>/`, +- verifies with `ade --version`, +- best-effort registers the per-user login service via `ade serve --install-service` on macOS and systemd Linux. + +Environment overrides: + +- `ADE_VERSION=vX.Y.Z` — pin a specific release; default `latest`. +- `ADE_INSTALL_DIR=/usr/local/bin` — destination directory. +- `ADE_RELEASE_REPO=owner/repo` — fetch from a fork. +- `ADE_HOME=/path/to/.ade` — alternate per-machine state root. + +After install, the headless machine can already serve clients. Desktop ADE on a developer laptop adds it as a remote target; `ade code` works on the headless machine itself. + +## What works remotely + +Remote project bindings route lanes, agent chat, PTYs, terminal IO, file operations, file-watch notifications, git actions, PR actions, PR queue automation, PR AI conflict-resolution sessions, PR issue-resolution launch flows, Path to Merge orchestration, AI PR summaries, issue inventory, and event streaming through the remote runtime. Agent CLI failures (Claude / Codex / Cursor / Droid not installed or not authenticated) surface as inline `AgentCliAuthCard` cards in chat; the install / login buttons open a tracked terminal in the active runtime, so a remote project runs the install or login command on the remote machine. + +Local project bindings prefer the local `ade serve` daemon for the same surfaces — agent chat, session history, PTYs, terminal reads/writes, file operations and watchers, diffs, lanes, PRs, PR queues, PR issue-resolution launch flows, Path to Merge, PR AI conflict-resolution sessions, issue inventory, tests, processes, project config, and most git operations. The legacy in-process Electron services remain only as a guarded fallback while the last IPC surfaces are migrated. + +Memory and embedding features are disabled for remote runtimes in v1. The static remote runtime does not bundle `onnxruntime-node`. + +## Mobile reachability + +iOS does not SSH into a machine. The phone connects to the runtime daemon's sync WebSocket advertised on the LAN or over a Tailscale tailnet. Install Tailscale on the phone and the ADE machine when they are not on the same local network. + +On desktop, phone pairing and sync status are managed by the local `ade serve` daemon. The legacy in-process desktop sync host is disabled by default and can be re-enabled only for diagnostics with `ADE_ENABLE_DESKTOP_SYNC_HOST=1`. + +## Troubleshooting + +- `Remote target was not found` — the saved target was removed or the UI has a stale selection. Refresh the target list. +- `ADE service is not installed ... no bundled ADE service is available` — install or build `ade` on the remote, or use a release build that includes runtime resources for the remote architecture. +- `Uploaded ADE service version mismatch: expected X, got Y` — the uploaded binary did not report the expected runtime version. Rebuild the static runtime artifacts for the current desktop version. +- `Remote ADE service does not support multi-project mode` — the remote is running an older ADE before multi-project RPC. Re-bootstrap from a current desktop build. +- Agent provider missing or unauthenticated — use the inline `AgentCliAuthCard` to install or authenticate that provider on the active runtime machine. + +## Related docs + +- [Internal architecture](./internal-architecture.md) — protocol shape, bootstrap sequence, sync command scoping. +- [ADE CLI](../../../apps/ade-cli/README.md) — runtime modes, service manager, machine layout. +- [ADE Code](../ade-code/README.md) — terminal client that uses the same runtime. +- [Sync and Multi-Device](../sync-and-multi-device/README.md) — phone pairing and multi-device sync (hosted by the same daemon). diff --git a/docs/features/remote-runtime/internal-architecture.md b/docs/features/remote-runtime/internal-architecture.md new file mode 100644 index 000000000..3edff1d12 --- /dev/null +++ b/docs/features/remote-runtime/internal-architecture.md @@ -0,0 +1,102 @@ +# Remote Runtime Internal Architecture + +Remote runtime support is built on the same JSON-RPC runtime the local `ade serve` daemon answers. The desktop chooses a runtime binding for each window; the renderer APIs stay stable while preload decides whether to call the local runtime daemon or a remote SSH-backed runtime. Both bindings speak the same wire protocol. + +## Runtime bindings + +`OpenProjectBinding` records the active runtime for a window: + +- `kind: "local"` — actions go through `LocalRuntimeConnectionPool`, which connects to the machine socket (`~/.ade/sock/ade.sock`) and spawns `ade serve` if it is not running. +- `kind: "remote"` — actions go through `RemoteConnectionPool` keyed by `{ targetId, projectId }`. + +The binding is established when a project is opened. Local bindings are created from the current desktop project (the desktop calls `LocalRuntimeConnectionPool.ensureProject(rootPath)` to register the project with the daemon and capture its `projectId`). Remote bindings are created by `remoteRuntimeOpenProject` after the selected target is connected and the remote project record is confirmed. + +## Protocol shape + +Runtime-level methods do not require a project and operate on the daemon as a whole: + +```text +ade/initialize ade/initialized ping shutdown exit +runtime/info machineInfo.get +projects.list projects.add projects.remove projects.touch +runtimeEvents.subscribe runtimeEvents.unsubscribe +sync.getStatus sync.refreshDiscovery +sync.listDevices sync.updateLocalDevice +sync.connectToBrain sync.disconnectFromBrain +sync.forgetDevice +sync.getTransferReadiness sync.transferBrainToLocal +sync.getPin sync.setPin sync.clearPin +sync.setActiveLanePresence +``` + +Project-scoped operations are routed through `ade/actions/call` and carry `params.projectId`. The ade-cli multi-project RPC handler (`createMultiProjectRpcRequestHandler`) looks up the per-project service scope via `ProjectScopeRegistry.get(projectId)` and forwards the request to the cached single-project handler created from `createAdeRpcRequestHandler({ runtime, … })`. + +`ade/initialize` advertises `runtimeInfo.multiProject: true` and `capabilities.projects: true`. Clients use that to decide whether to send `projectId` per request (multi-project runtime) or treat the runtime as already bound to one project (embedded `ade code --embedded`). `validateRemoteRuntimeInitializeResult` enforces both flags on the remote side and rejects mismatched runtime versions. + +Runtime event streaming uses `ade/actions/call` with `name: "stream_events"` for one-shot pulls, and `runtimeEvents.subscribe` (with `runtime/event` notifications) for live streaming. For remote bindings the desktop reconnects the SSH transport before re-subscribing, matching normal remote action behavior after disconnects. For local bindings, preload polls the local daemon through `localRuntimeStreamEvents` so daemon-owned chat, terminal, pty, lane, file-watch, process, and test events are delivered through the same renderer fanout used by remote projects. + +## SSH transport + +`sshTransport.ts` creates an `ssh2` client config from the saved target: + +- host, port, and username come from the remote target registry. +- `sshKeyPath` loads a private key from disk when supplied. +- if no explicit key path is saved, matching `HostName` and `IdentityFile` entries in `~/.ssh/config` are applied so aliases like `Host studio` work. +- `SSH_AUTH_SOCK` is passed through as `agent` when available. + +The runtime transport itself is an SSH `exec` channel running `ade rpc --stdio` (with the channel-aware environment prefix from `buildRemoteRuntimeEnvironmentPrefix`). The channel implements the `RuntimeRpcTransport` interface used by `RuntimeRpcClient`, the same client `LocalRuntimeConnectionPool` uses against a Unix socket. + +## Bootstrap sequence + +`bootstrapRemoteRuntime` performs first-connect setup: + +1. Connect over SSH. +2. Detect platform and architecture with `uname -sm` (`normalizeRemoteArch` accepts darwin/linux × arm64/x64). +3. Read `~/.ade/bin/ade.version` and `~/.ade/bin/ade --version` when present. +4. Locate the bundled `ade-<platform-arch>` binary and `ade-<platform-arch>.native.tar.gz` archive in desktop resources. +5. If the local bundle is present and `executableVersion !== appVersion`, upload the binary to `~/.ade/bin/ade` (mode 700 dir, +x file, write `~/.ade/bin/ade.version`). +6. If the native deps archive is present and either the runtime was just uploaded or the remote `~/.ade/runtime/<arch>/.ade-version` doesn't match, upload and extract it to `~/.ade/runtime/<platform-arch>/`. +7. Verify the uploaded runtime by running `~/.ade/bin/ade --version` with the channel/arch environment prefix; abort with `Uploaded ADE service version mismatch` if the reported version doesn't match. +8. Start `ade rpc --stdio`, initialize the JSON-RPC client, validate `multiProject` + `projects` capabilities and version, and read `projects.list`. +9. Update the target registry with architecture, runtime version, and last-connected timestamp. + +If no bundled runtime exists locally and the remote does not already expose `ade` on `PATH`, bootstrap fails with an explicit install/build error rather than silently shipping the wrong version. + +Channel layout: `resolveRemoteRuntimeLayout` reads `ADE_PACKAGE_CHANNEL`. Stable uploads to `~/.ade/`; alpha to `~/.ade-alpha/`; beta to `~/.ade-beta/`. Channel builds also pass `ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1` in the environment prefix so the channel binary doesn't fight a stable login service for the socket. + +## Local-vs-remote work warning + +Before opening a remote project, `remoteRuntimeCheckLocalWork` compares the remote project's git origin with local projects. It checks both recent desktop projects and projects known to the local runtime daemon's project registry, then runs `git status --porcelain` on matches. Dirty matches produce the `RemoteProjectOpenDialog` confirmation in the remote target UI, listing the matching local clones and their changed file counts. + +## Sync command scoping + +The sync WebSocket host is owned by the `ade serve` daemon in normal desktop operation. `ProjectScopeRegistry.ensureSyncHost` elects the most-recently-opened registered project as the active sync host and re-elects when projects are added or removed. + +Desktop sync Settings IPC first talks to the local runtime daemon for status, discovery, device registry, and PIN operations, then falls back to the legacy in-process sync service only when the daemon route is unavailable. The old desktop-host path is guarded by `ADE_ENABLE_DESKTOP_SYNC_HOST=1` for diagnostics and migration debugging. + +The sync command registry labels descriptors as `runtime` or `project` scope. Project-bound hosts reject project-scoped commands that arrive without a matching `projectId`, while runtime-scoped commands operate on the daemon as a whole. This keeps mobile/controller commands explicit in the multi-project runtime. + +## Local daemon routing + +Local desktop windows go through the runtime binding before falling back to legacy Electron-hosted handlers. `callProjectRuntimeActionOr` and `callProjectRuntimeSyncOr` in `apps/desktop/src/preload/preload.ts` try the runtime path first and fall back to the in-process IPC only on a safe local-runtime fallback error. + +The runtime path covers: + +- agent chat actions and chat event history +- terminal session list / detail / update / delete and transcript tails +- pty create / write / resize / dispose plus streamed data and exit events +- file reads / writes / search / quick-open / tree listing and file-watch subscriptions +- diff reads and most git operations +- lanes, PRs, PR queue automation, PR issue-resolution launch flows, Path to Merge, PR AI conflict-resolution sessions, issue inventory, tests, processes, and project config + +Operations with desktop-only side effects, such as some automation hooks and UI-native flows, still use the in-process IPC handlers until their side effects are moved into ade-cli services. + +## Local runtime connection lifecycle + +`LocalRuntimeConnectionPool` handles the desktop side of the local runtime binding: + +- `connect()` first tries an existing `~/.ade/sock/ade.sock`. If that fails, it spawns `ade serve --socket <path>` detached (using the bundled CLI from `process.resourcesPath/ade-cli/cli.cjs` or the dev path), waits for the socket, and reconnects. +- `initialize` is called immediately after connect; if `runtimeInfo.version` does not match the desktop app version, the pool shuts the connection down and lets the next call respawn the daemon at the right version. +- `installServiceBestEffort()` runs `ade serve --install-service` once per session to register the per-user login service; the result feeds `LocalRuntimeStatus.serviceInstall`. +- `getStatus()` periodically refreshes `serviceHealth` (`unsupported | not_installed | installed | running | error | unknown`) by calling `getRuntimeServiceStatus()` from the service manager. +- The pool exposes typed entry points for action calls (`callActionForRoot`), sync calls (`callSyncForRoot`), event polling (`streamEventsForRoot`), and event subscription (`subscribeEventsForRoot`). All of them register the project with `projects.add` once and then carry `projectId` on every project-scoped request. diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index b3d97797d..c97dac126 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -1,7 +1,7 @@ # Sync and Multi-Device -ADE syncs live runtime state across a host desktop and any connected -controllers (other desktops, iPhones) using **cr-sqlite** as a CRDT-backed +ADE syncs live runtime state across a host ADE machine and any connected +controllers (other Macs, iPhones) using **cr-sqlite** as a CRDT-backed replication layer over a **WebSocket** transport. The design is local-first, peer-to-peer, and has zero cloud dependency — two machines on the same LAN (or Tailscale tailnet) converge their application state directly. @@ -16,24 +16,46 @@ does and does not travel, and the layers that implement it. Deep-dives: - `remote-commands.md` — the `syncRemoteCommandService` registry that turns controller actions into host-executed mutations. +## Where the host actually runs + +The sync host is owned by the **ADE runtime daemon** in `apps/ade-cli/` +(the `ade serve` process). The desktop renderer is just another client +of that daemon — it talks to it through the local runtime connection +pool, exactly the same way `ade code` and the iOS app do. + +This is the inversion to internalise: the desktop is no longer the +host. A desktop window that is bound to a remote runtime is therefore +not the host either; the remote `ade serve` on that machine owns the +host role for projects opened on it. + +The legacy in-process desktop sync host still exists in source for +diagnostics. It is **disabled by default** and only activates when +`ADE_ENABLE_DESKTOP_SYNC_HOST=1` is set (and the kill-switch +`ADE_DISABLE_SYNC_HOST=1` is not set). Production builds and dev +sessions both leave it off; everything below describes the daemon-hosted +path unless explicitly noted. + ## Who participates -- **Host** — a desktop-class machine running ADE's full Electron main - process. It owns agent execution, PTYs, worktrees, worker heartbeats, - and the orchestrator. There is **one** host per live sync cluster at a - time. -- **Controllers** — other connected devices. Phones are always - controllers (they cannot be hosts). A second Mac can either be - independent (Git-only, its own local ADE runtime) or deliberately - attach to an existing host as a controller. +- **Host** — the per-machine `ade serve` runtime daemon. It owns agent + execution, PTYs, worktrees, worker heartbeats, the orchestrator, and + the sync WebSocket server. There is one daemon per machine; it can + hold **multiple** open projects at once and a phone picks which one to + bind to via the project catalog. +- **Desktop renderer** — a controller of the local daemon over the + runtime IPC bridge. The same renderer can also bind to a remote + daemon (the remote-runtime feature), in which case sync state lives + on the remote machine. +- **iOS app** — controller-only, always. Connects to a daemon over + WebSocket using the same `SyncEnvelope` protocol the desktop uses + internally. - **Cluster state** — a singleton `sync_cluster_state` row with `brain_device_id` and `brain_epoch` tracks which device currently - owns execution. Handoff bumps `brain_epoch` and rewrites - `brain_device_id`. + owns execution within a cluster. The name `brain_*` remains in the database and protocol as a legacy -internal identifier; it is not user-facing. All UI and current docs -use "host". +internal identifier; it is not user-facing. UI and current docs all +say "host". ## What syncs, what does not @@ -53,106 +75,145 @@ directories. Two disconnected desktops do **not** have a shared live session. They converge code through Git and they converge the narrow tracked ADE scaffold through Git, but live mission/chat/process state converges -only when they join the same sync cluster. +only when they join the same sync cluster (i.e. point at the same +running daemon). ## Architecture layers ``` -┌────────────────────────────────────────────────────────────────┐ -│ Renderer / iOS SwiftUI │ -│ - reads local SQLite (instant, offline) │ -│ - writes: state-only → local, execution → remote command │ -└────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────┐ -│ Sync transport (ws) │ -│ - SyncEnvelope: hello, pairing, changeset_batch, │ -│ changeset_ack, heartbeat, file_request/response, │ -│ terminal_*, chat_*, brain_status, │ -│ project_catalog/project_switch, │ -│ command / command_ack / command_result │ -│ - JSON payloads; gzip+base64 above threshold (4KB default) │ -└────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────┐ ┌──────────────────────────┐ -│ Host side │ │ Controller side │ -│ - syncHostService (WS server) │ │ - syncPeerService (WS │ -│ - syncRemoteCommandService │ │ client, auto-reconn) │ -│ - deviceRegistryService │ │ - local AdeDb │ -│ - AdeDb.sync │ │ - command queue │ -└──────────────────────────────────┘ └──────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────┐ -│ cr-sqlite CRDT layer │ -│ - desktop: loadable .dylib extension, crsql_as_crr() │ -│ - iOS: pure-SQL emulation in Database.swift │ -│ - AdeDb.sync: getSiteId, getDbVersion, │ -│ exportChangesSince, applyChanges │ -└────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────┐ +│ Renderer (Electron) / iOS SwiftUI │ +│ - reads local SQLite (instant, offline) │ +│ - writes: state-only → local; execution → remote command │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Desktop runtime IPC bridge (renderer → main → daemon) │ +│ - sync.* preload calls route through │ +│ callProjectRuntimeSyncOr(method, params, fallback) │ +│ - prefers the remote runtime if the window is bound, │ +│ then the local runtime daemon, only then the in-process │ +│ fallback (ADE_ENABLE_DESKTOP_SYNC_HOST=1) │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ ade-cli runtime daemon (`ade serve`) │ +│ - syncService — orchestrator, draft persistence, pin store │ +│ - syncHostService — WebSocket server, peers, project catalog │ +│ - syncRemoteCommandService — registry of executable actions │ +│ - deviceRegistryService — devices + cluster_state singleton │ +│ - hosts MULTIPLE projects per machine │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Sync transport (ws) │ +│ - SyncEnvelope: hello, pairing, changeset_batch, │ +│ changeset_ack, heartbeat, file_request/response, │ +│ terminal_*, chat_*, brain_status, │ +│ project_catalog/project_switch, │ +│ command / command_ack / command_result │ +│ - JSON payloads; gzip+base64 above threshold (4 KB default) │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ cr-sqlite CRDT layer │ +│ - desktop/daemon: loadable .dylib extension, crsql_as_crr() │ +│ - iOS: pure-SQL emulation in Database.swift │ +│ - AdeDb.sync: getSiteId, getDbVersion, │ +│ exportChangesSince, applyChanges │ +└──────────────────────────────────────────────────────────────────┘ ``` ## Source file map -Host-side service files -(`apps/desktop/src/main/services/sync/`): - -- `syncHostService.ts` (~2,170 lines) — WebSocket server, connection - acceptance, hello/pairing handling, per-peer state, changeset fan-out, - terminal/chat subscription bridging, mobile terminal input/resize - forwarding into subscribed PTYs, lane presence decoration, project - catalog/switch envelopes, per-IP pairing rate limiter. -- `syncPeerService.ts` (~460 lines) — WebSocket **client**. The host - can run this too when it is a peer of a different host during a - handoff rehearsal or controller-to-host role swap. On iOS, an - equivalent Swift implementation lives in `apps/ios/ADE/Services/SyncService.swift`. -- `syncProtocol.ts` (~120 lines) — envelope encode/decode with gzip +The canonical sync implementation lives in the **ade-cli** runtime +package. The desktop tree only contains thin re-export proxies plus the +legacy fallback; do not edit the desktop copies expecting the daemon to +see your change. + +Canonical files (`apps/ade-cli/src/services/sync/`): + +- `syncService.ts` (~1,160 lines) — orchestrator that wires the host, + peer client, device registry, draft persistence, pin store, and the + per-project / per-runtime configuration. Builds the + `projectCatalogProvider` so a daemon hosting multiple projects can + hand a phone a catalog and react to `project_switch_request`. Accepts + `forceHostRole: true` for the phone-sync surface so legacy + desktop-to-desktop viewer state cannot demote the daemon's host role. +- `syncHostService.ts` (~3,260 lines) — the WebSocket server. Owns + connection acceptance, hello/pairing handshakes, per-peer state, + changeset fan-out + ack tracking, terminal/chat subscription + bridging, mobile terminal input/resize forwarding into subscribed + PTYs, lane presence decoration, project catalog/switch envelopes, + per-IP pairing rate limiter, and the Tailscale Serve / mDNS + publication paths. Runtime kind is one of `desktop-embedded`, + `headless`, `remote-stdio`, `desktop`, `daemon`, or `remote`. +- `syncPeerService.ts` (~580 lines) — WebSocket **client**. The host + can run this too when it joins another host as a peer (handoff + rehearsal, controller-to-host swap). On iOS, an equivalent Swift + implementation lives in `apps/ios/ADE/Services/SyncService.swift`. +- `syncProtocol.ts` (~150 lines) — envelope encode/decode with gzip threshold (`DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES = 4 * 1024`). Protocol version is `1`. Default host port is `8787`. -- `apps/desktop/src/shared/types/sync.ts` — typed protocol DTOs for - `SyncEnvelope`, including controller-originated `terminal_input` and - `terminal_resize` envelopes, plus the mobile CLI launcher payload - (`SyncCliLaunchProvider`, `SyncStartCliSessionArgs`, - `SyncStartCliSessionResult`) consumed by the - `work.startCliSession` remote command. -- `syncService.ts` (~875 lines) — orchestrator that wires host, - peer, device registry, draft persistence, pin store, and exposes - the IPC entry points used by the renderer Settings > Sync surface - (`ade.sync.getPin` / `setPin` / `clearPin`, `setActiveLanePresence`, - QR payload). Project-switch hosting receives a catalog provider from - `main.ts` so a phone can request a warm connection for another recent - desktop project without making that project the visible desktop tab. - Constructed with `forceHostRole: true` for the phone-sync surface: - in that mode the saved viewer draft is ignored, the cluster row is - rewritten so the local device is the brain on every refresh, and any - legacy desktop-to-desktop viewer state cannot demote phone hosting - back into viewer mode. `setHostStartupEnabled` is async so callers - can await the role-state refresh before broadcasting. -- `deviceRegistryService.ts` (~430 lines) — reads/writes the synced - `devices` table and `sync_cluster_state` singleton. -- `syncPairingStore.ts` (~90 lines) — thin wrapper that validates - incoming `pairing_request` envelopes against `syncPinStore`, - mints the durable per-device secret, and persists it into the - `paired_devices` row (SQLite). -- `syncPinStore.ts` (~65 lines) — on-disk storage for the user-set - 6-digit pairing PIN at `.ade/secrets/sync-pin.json`, chmodded `0600`. - Host never rotates the PIN; the user sets or clears it from Settings - > Sync. -- `syncRemoteCommandService.ts` (~2,030 lines) — command action - registry (lanes, chat, git, PR, sessions, conflicts, files, - `prs.getMobileSnapshot`, `lanes.presence.*`, - `work.runQuickCommand`, `work.startCliSession`). The CLI launch - registry shares its provider-to-argv translation with the desktop - Work tab through `apps/desktop/src/shared/cliLaunch.ts` - (`buildTrackedCliLaunchCommand`, `buildTrackedCliResumeCommand`, - `LAUNCH_PROFILE_TOOL_TYPE`, `LAUNCH_PROFILE_TITLE`) so a phone - starting Claude/Codex/Cursor/Droid/OpenCode/shell hits the same - permission-mode flags, ADE guidance, and provider preambles the - desktop sends. Documented separately in `remote-commands.md`. - -Client-side (iOS) service files (`apps/ios/ADE/Services/`): +- `syncRemoteCommandService.ts` (~2,520 lines) — command registry + (lanes, chat, git, PR, sessions, conflicts, files, + `prs.getMobileSnapshot`, `lanes.presence.*`, `work.runQuickCommand`, + `work.startCliSession`, …). Each registration carries a + `SyncRemoteCommandDescriptor` with a **scope** label of + `"runtime"` or `"project"`. The host rejects a `project`-scoped + command when no project is open or when the caller did not bundle a + matching `projectId` (see *Scope enforcement* below). +- `deviceRegistryService.ts` (~670 lines) — synced `devices` table and + `sync_cluster_state` singleton. +- `syncPairingStore.ts` — validates `pairing_request` envelopes + against `syncPinStore`, mints the durable per-device secret, and + persists it into the `paired_devices` row (SQLite). +- `syncPinStore.ts` — on-disk storage for the user-set 6-digit + pairing PIN at `~/.ade/secrets/sync-pin.json`, chmodded `0600`. The + host never rotates the PIN; the operator sets or clears it from + Settings > Sync. +- `resolveTailscaleCliPath.ts` — Tailscale CLI discovery used for the + tailnet `tailscale serve` publication path. + +Desktop client adapter (`apps/desktop/src/main/services/sync/`): + +Every file in this directory is a one-line re-export of the canonical +ade-cli module, e.g. `syncHostService.ts` reads `export * from +"../../../../../ade-cli/src/services/sync/syncHostService";`. They exist +so the desktop's internal imports keep resolving while the canonical +implementation lives in the runtime daemon. The legacy in-process host +path in `apps/desktop/src/main/main.ts` (gated by +`ADE_ENABLE_DESKTOP_SYNC_HOST=1`) calls these re-exports and runs an +embedded host *inside* the Electron main process — kept only for +diagnostics. The unit tests next to the proxies still exercise the same +canonical code through the re-export. + +Sync IPC routing in the renderer +(`apps/desktop/src/preload/preload.ts`): every `window.ade.sync.*` call +goes through `callProjectRuntimeSyncOr(method, params, localFallback)`, +which: + +1. Resolves the active project binding. If the window is bound to a + remote runtime, the call goes over `IPC.remoteRuntimeCallSync` to + the remote daemon. +2. Otherwise, it calls `IPC.localRuntimeCallSync` against the local + daemon. Local-daemon failures that look safe to retry fall through. +3. Only as a final fallback (and only when the desktop in-process host + was actually started) does the call hit the in-process IPC handler. + +The shared protocol DTOs (`SyncEnvelope`, controller-originated +`terminal_input` / `terminal_resize`, the mobile CLI launcher payload — +`SyncCliLaunchProvider`, `SyncStartCliSessionArgs`, +`SyncStartCliSessionResult` — and so on) live in +`apps/desktop/src/shared/types/sync.ts`. The CLI launcher's +provider-to-argv translation is shared with the desktop Work tab +through `apps/desktop/src/shared/cliLaunch.ts`. + +iOS service files (`apps/ios/ADE/Services/`): - `Database.swift` — native SQLite3 + pure-SQL CRR emulation (triggers + custom SQLite functions). Offline caches for files workspaces, @@ -167,10 +228,10 @@ Client-side (iOS) service files (`apps/ios/ADE/Services/`): home/catalog state, active-project scoping, unregistered-worktree discovery, and APNs push-token registration to the host. - `KeychainService.swift` — iOS Keychain Services for paired device - secrets. + secrets (per-host token shelf included). - `LiveActivityCoordinator.swift` — owns the single workspace - `Activity<ADESessionAttributes>` lifecycle; collects push-to-start - and per-activity update tokens and forwards them to the host. + `Activity<ADESessionAttributes>` lifecycle and forwards + push-to-start / per-activity update tokens to the host. Notification services (`apps/desktop/src/main/services/notifications/`): @@ -180,43 +241,65 @@ Notification services (`apps/desktop/src/main/services/notifications/`): - `notificationMapper.ts` — pure domain-event → `MappedNotification` mapping across 13 categories in 4 families (chat, cto, pr, system). - `notificationEventBus.ts` — `publishChatEvent`, `publishPrEvent`, - `publishMissionEvent`, `publishSystemEvent`, `sendTestPush`. - Routes to APNs (alert + Live Activity update pushes) and/or in-app - WS delivery, filtered by per-device `NotificationPreferences`. - -iOS notification files: - -- `apps/ios/ADE/App/AppDelegate.swift` — APNs registration, category - setup, notification-action response routing, deep-link dispatch. -- `apps/ios/ADE/App/NotificationCategories.swift` — ten - `UNNotificationCategory` / `UNNotificationAction` constants matching - the desktop `NotificationCategory` identifiers. -- `apps/ios/ADE/App/DeepLinkRouter.swift` — `ade://session/<id>` and - `ade://pr/<n>` URL routing via `NotificationCenter`. -- `apps/ios/ADE/Models/NotificationPreferences.swift` — 13-toggle - prefs, quiet hours, per-session overrides (`SessionNotificationOverride`). -- `apps/ios/ADENotificationService/NotificationService.swift` — + `publishMissionEvent`, `publishSystemEvent`, `sendTestPush`. Routes + to APNs (alert + Live Activity update pushes) and/or in-app WS + delivery, filtered by per-device `NotificationPreferences`. + +iOS notification / widget files (under `apps/ios/`): + +- `ADE/App/AppDelegate.swift`, `ADE/App/NotificationCategories.swift`, + `ADE/App/DeepLinkRouter.swift`, `ADE/Models/NotificationPreferences.swift`. +- `ADENotificationService/NotificationService.swift` — `UNNotificationServiceExtension` (brand prefix, `threadIdentifier`, `interruptionLevel` / `relevanceScore`). -- `apps/ios/ADEWidgets/ADELiveActivity.swift` — `ADESessionAttributes` - (ActivityKit attributes + `ContentState`) + `ADELiveActivity` widget. -- `apps/ios/ADEWidgets/ADEWorkspaceWidget.swift` — Home Screen widget - (small / medium / large). -- `apps/ios/ADEWidgets/ADELockScreenWidget.swift` — Lock Screen - accessory widget. -- `apps/ios/ADEWidgets/ADEControlWidget.swift` — Control Center - "Open ADE" + "Mute ADE" widgets (iOS 18+). -- `apps/ios/ADE/Shared/ADESharedModels.swift` — `AgentSnapshot`, - `PrSnapshot` shared with widget and notification service extensions. -- `apps/ios/ADE/Models/RemoteModels.swift` — Codable models used by - sync/mobile snapshots; carries `StartCliSessionResult` for - `work.startCliSession` and `IntegrationProposal` fields that mirror - desktop merge targets such as `preferredIntegrationLaneId` and - `mergeIntoHeadSha`. -- `apps/ios/ADE/Resources/DatabaseBootstrap.sql` — generated bootstrap - schema copied from desktop `kvDb.ts`; includes - `integration_proposals.preferred_integration_lane_id` and - `merge_into_head_sha` for merge-into-lane PR workflows. +- `ADEWidgets/ADELiveActivity.swift`, `ADEWorkspaceWidget.swift`, + `ADELockScreenWidget.swift`, `ADEControlWidget.swift` (Control + Center widgets, iOS 18+). +- `ADE/Shared/ADESharedModels.swift`, `ADE/Models/RemoteModels.swift`, + `ADE/Resources/DatabaseBootstrap.sql` (generated from desktop + `kvDb.ts`). + +## Multi-project hosts and project switching + +A daemon hosts **every** project the user has opened on that machine +(within retention) and exposes them as a single catalog. The phone +flow: + +1. Phone connects and sends `hello`. The host responds with + `hello_ok` containing the current project catalog (when supported). +2. The phone renders the catalog as a project home — recent projects + marked available/cached/unavailable, with `MobileProjectSummary` + metadata (icon, lane snippets) supplied by the daemon. +3. The user taps a project → phone sends `project_switch_request`. + The daemon's `prepareProjectConnection` runs, the daemon activates + that project locally, and returns a `project_switch_result` with + either a fresh `connection` payload or `connection: null` (the + phone should reuse its existing pairing credentials and reconnect + against the now-active host). +4. After the host acknowledges the switch, `completeProjectConnection` + runs so the daemon can persist the new active project. + +Project catalog snapshots are also chunked +(`MAX_PROJECT_CATALOG_ENVELOPE_BYTES = 768 KB`, +`maxProjectCatalogChunkBytes = 192 KB`) so a daemon with many projects +streams the catalog in `project_catalog_chunk` envelopes. + +## Scope enforcement + +`syncRemoteCommandService.register(action, policy, handler, scope)` +labels every command as `"runtime"` (machine-wide; doesn't need a +project binding) or `"project"` (must run inside an open project). +At dispatch time: + +- If the command is `project`-scoped and the host has a `hostProjectId` + but the caller did not include `requestedProjectId`, the host rejects + the command with `"requires projectId"` (`code: missing_project`). +- If the command is `project`-scoped and the host has no project open, + the host rejects it with `"requires an open project on this ADE + machine"` (`code: project_not_open`). + +A phone bound to a daemon-hosted catalog therefore must complete the +`project_switch` handshake before invoking project-scoped commands. ## Device registry and cluster state @@ -264,11 +347,12 @@ is not supported. ## Device discovery -- **Desktop-to-desktop**: manual host/port/bootstrap-token entry in - Settings > Sync. The bootstrap token lives at - `.ade/secrets/sync-bootstrap-token`. +- **Machine-to-machine**: manual host/port/bootstrap-token entry in + Settings > Sync. The machine bootstrap token lives under + `~/.ade/secrets` and legacy project-local tokens are migrated there + on startup. - **Project switch handoff carries auth.** `SyncProjectConnectionPayload` - now distinguishes `authKind: "bootstrap" | "paired"` and may carry a + distinguishes `authKind: "bootstrap" | "paired"` and may carry a `pairedDeviceId` instead of a raw `token`. When a phone follows a desktop project switch, `prepareProjectConnection` returns the payload, `completeProjectConnection` runs after the host has @@ -277,7 +361,7 @@ is not supported. `KeychainService.tokenAccount`) when the desktop did not bundle a fresh credential. - **Phone pairing**: user-set **6-digit PIN** stored on the host at - `.ade/secrets/sync-pin.json`. The PIN is owned by the human + `~/.ade/secrets/sync-pin.json`. The PIN is owned by the human operator — the host does not rotate it, does not time-expire it, and does not mint a one-shot code. The phone enters the same digits the user typed on the host's Settings > Sync > Phone pairing sheet. @@ -289,29 +373,35 @@ is not supported. host identity, port, and address candidates only — it no longer embeds a pairing code or expiry. The phone still needs the PIN manually. -- **Address candidates**: the host advertises LAN IPs, - the saved `lastHost` (when it matches the current set), the - Tailscale IP, and `127.0.0.1` (`SyncAddressCandidateKind` now - includes `loopback`). +- **Address candidates**: the host advertises LAN IPs, the saved + `lastHost` (when it matches the current set), the Tailscale IP, and + `127.0.0.1` (`SyncAddressCandidateKind` includes `loopback`). - **mDNS**: `publishLanDiscovery` builds a TXT record whose - `addresses` CSV includes the Tailscale IP alongside LAN IPs. The - host keeps a signature of `{ hostName, port, txt }` and re-publishes - the announcement only when the signature changes, to avoid churn - while IP addresses fluctuate. -- **Tailscale Serve tailnet discovery**: when the host sees a usable - `tailscale` CLI (via the `ADE_TAILSCALE_CLI` env override or the - default macOS path `/Applications/Tailscale.app/Contents/MacOS/Tailscale`), - it publishes the sync WebSocket port on the tailnet under the - service name `svc:ade-sync` (`SYNC_TAILNET_DISCOVERY_SERVICE_NAME`) - at the default port `8787` (`SYNC_TAILNET_DISCOVERY_SERVICE_PORT`). - Status flows out through `SyncRoleSnapshot.tailnetDiscovery` - (type `SyncTailnetDiscoveryStatus`) with states `disabled | - publishing | published | pending_approval | unavailable | failed` - plus `error` / `stderr` tails for debugging. The host tracks a - `tailnetServeSignature` so re-publishing is a no-op when the - `(serviceName, port, target)` tuple hasn't changed; Settings > - Sync surfaces the status so the user can see whether MagicDNS - resolution from an iPhone or a peer desktop is ready. + `addresses` CSV includes the Tailscale IP alongside LAN IPs. It also + advertises `runtimeKind`, `runtimeVersion`, `projects`, and + `projectCount`, so mobile can show a machine-first picker before it + hydrates the full project catalog over the paired WebSocket. The host + keeps a signature of `{ hostName, port, txt }` and re-publishes the + announcement only when the signature changes, to avoid churn while IP + addresses fluctuate. +- **Machine-scoped pairing state**: phone pairing files live under the + machine ADE home (`~/.ade/secrets/`): `sync-device-id`, + `sync-bootstrap-token`, `sync-pin.json`, and + `sync-paired-devices.json`. On upgrade, legacy per-project copies + under `<project>/.ade/secrets/` are copied or merged into the machine + store, with paired devices deduped by `deviceId`. +- **Tailscale Serve tailnet discovery**: when the daemon sees a usable + `tailscale` CLI (via `ADE_TAILSCALE_CLI` or the macOS default + `/Applications/Tailscale.app/Contents/MacOS/Tailscale`), it publishes + the sync WebSocket port on the tailnet under the service name + `svc:ade-sync` (`SYNC_TAILNET_DISCOVERY_SERVICE_NAME`) at the + default port `8787` (`SYNC_TAILNET_DISCOVERY_SERVICE_PORT`). Status + flows out through `SyncRoleSnapshot.tailnetDiscovery` + (`SyncTailnetDiscoveryStatus`: `disabled | publishing | published | + pending_approval | unavailable | failed`) plus `error` / `stderr` + tails. The host tracks a `tailnetServeSignature` so re-publishing + is a no-op when the `(serviceName, port, target)` tuple hasn't + changed. ## Sync protocol (summary) @@ -327,7 +417,12 @@ Envelopes are JSON with fields: "terminal_snapshot" | "terminal_data" | "terminal_exit" | "terminal_input" | "terminal_resize" | "chat_subscribe" | "chat_unsubscribe" | "chat_event" | - "brain_status" | "command" | "command_ack" | "command_result", + "brain_status" | + "project_catalog_request" | "project_catalog" | + "project_catalog_chunk" | + "project_switch_request" | "project_switch_result" | + "command" | "command_ack" | "command_result", + projectId?: string | null, // present on project-scoped envelopes requestId: string | null, compression: "none" | "gzip", payloadEncoding: "json" | "base64", @@ -346,10 +441,10 @@ invalid_hello`. `SyncPairingResultPayload.error.code` is one of `invalid_pin | pin_not_set | pairing_failed`. Heartbeat interval is 30 seconds. Desktop peers close after **two** -consecutive missed heartbeats, while mobile peers get a wider grace -window because iOS can briefly suspend foreground networking during app -and route transitions. Reconnection resumes from the last-known -`db_version` so no changesets are lost. +consecutive missed heartbeats; mobile peers get a wider grace window +(`MOBILE_SYNC_HEARTBEAT_MISS_LIMIT = 6`) because iOS can briefly suspend +foreground networking during app and route transitions. Reconnection +resumes from the last-known `db_version` so no changesets are lost. `changeset_batch` envelopes carry a `batchId`; legacy batches without one are decoded with a deterministic fallback so older desktops can @@ -380,6 +475,7 @@ payload. | Terminal stream/control | Subscribe to PTY output from host; send input bytes and viewport resize events back to the subscribed PTY | iOS Work tab | | Chat stream | Agent chat transcript events (subscribe snapshot + live `chat_event` push from the host's `agentChatService.subscribeToEvents` fan-out; polling survives as the reconnect-catchup path) | iOS Work tab, controller chat | | Command routing | Send named actions (`chat.send`, `lanes.create`, `git.push`, `prs.getMobileSnapshot`, etc.) | All non-host devices | +| Project switching | `project_catalog` + `project_switch_request/result` for multi-project daemons | iOS project home | | Brain status | Host broadcasts cluster/version status | All devices | | Lane presence | Controllers call `lanes.presence.announce` / `lanes.presence.release`; the host decorates `LaneSummary.devicesOpen` for 60 s TTL | iOS Lanes tab; desktop host presence heartbeat | @@ -387,7 +483,7 @@ payload. Controllers never run agent processes. CTO heartbeats, worker activations, mission orchestration, and the embedding worker are -host-exclusive. +host-exclusive (host = the daemon). Two categories of controller write: @@ -410,20 +506,21 @@ Every command action has a `SyncRemoteCommandPolicy`: } ``` -The host-declared policy is the authority: the iOS app reads -descriptors via `chat.models`, `lanes.list`, etc. and gates UI -actions accordingly. Hardcoded mobile assumptions would be stale -after a host-side policy change, so the phone trusts the host. +Plus a scope (`runtime` or `project`) on the descriptor. The +host-declared policy and scope are the authority: the iOS app reads +descriptors over the wire and gates UI actions accordingly. Hardcoded +mobile assumptions would be stale after a host-side policy change, so +the phone trusts the host. -See `remote-commands.md` for the full action set and a note on the -current branch modifications to `syncRemoteCommandService.ts`. +See `remote-commands.md` for the full action set and the runtime / +project scope split. ## Security model -- **Pairing**: two independent paths. Desktop-to-desktop uses the - shared bootstrap token from `.ade/secrets/sync-bootstrap-token`. +- **Pairing**: two independent paths. Machine-to-machine pairing uses + the shared bootstrap token from the machine secrets directory. Phone pairing uses a **user-set 6-digit PIN** stored in - `.ade/secrets/sync-pin.json` on the host. The host never auto-rotates + `~/.ade/secrets/sync-pin.json` on the host. The host never auto-rotates or TTLs the PIN; the user sets it through Settings > Sync and clears it when they want to stop accepting new pairings. The PIN unlocks generation of a durable per-device secret that the phone stores in @@ -443,17 +540,21 @@ current branch modifications to `syncRemoteCommandService.ts`. interfaces (intended for trusted LAN and tailnets). - **Secret isolation**: each device stores its own pairing secret in its OS keychain. -- **Execution isolation**: the host runs agents; controllers do not. +- **Execution isolation**: the daemon runs agents; controllers do not. ## Current implementation status | Component | Status | |---|---| -| cr-sqlite extension loading (desktop) | Implemented | +| Sync host owned by `ade serve` runtime daemon | Implemented | +| Desktop in-process sync host | Disabled by default (`ADE_ENABLE_DESKTOP_SYNC_HOST=1` for diagnostics) | +| Multi-project host + `project_switch` handshake | Implemented | +| `SyncRemoteCommandDescriptor.scope` (`runtime` / `project`) gating | Implemented | +| cr-sqlite extension loading (desktop/daemon) | Implemented | | Pure-SQL CRR emulation (iOS) | Implemented | | CRR marking for eligible tables | Implemented (dynamic startup) | | Changeset extraction/application | Implemented | -| WebSocket sync server | Implemented (desktop) | +| WebSocket sync server | Implemented | | Sync protocol (JSON + zlib) | Implemented | | File access sub-protocol | Implemented | | Terminal stream sub-protocol | Implemented | @@ -475,6 +576,20 @@ current branch modifications to `syncRemoteCommandService.ts`. ## Gotchas +- **The daemon owns sync. Desktop is a client.** A desktop window bound + to a remote runtime is *not* the host for that project; the remote + daemon is. Code that wants the sync host must reach into the + runtime IPC bridge, not into the renderer or the Electron main + process. +- **`ADE_ENABLE_DESKTOP_SYNC_HOST` is a diagnostics escape hatch.** If + you turn it on, both an in-process host and the daemon's host can be + alive simultaneously on the same machine — that's intentional for + comparing behaviors, but production builds should never run with + that flag set. +- **Project-scoped commands need `projectId`.** A daemon hosting + multiple projects has no implicit "current project". Forward the + active `projectId` on every project-scoped command or the host + rejects with `code: missing_project`. - **CRR retrofit strips non-PK UNIQUE constraints.** Upserts on synced tables must target the primary key only. Use explicit select-then-update for non-PK merge cases. diff --git a/docs/features/sync-and-multi-device/crdt-model.md b/docs/features/sync-and-multi-device/crdt-model.md index 0ca9f6b8a..fa27ba306 100644 --- a/docs/features/sync-and-multi-device/crdt-model.md +++ b/docs/features/sync-and-multi-device/crdt-model.md @@ -10,8 +10,13 @@ the schema implications that fall out of the CRR retrofit. The entire CRDT layer lives inside the shared DB adapter: `apps/desktop/src/main/services/state/kvDb.ts` exposes an `AdeDb` with -an `AdeDb.sync` object. Every other desktop service talks to plain -SQLite (`run`, `get`, `all`, `prepare`); `AdeDb.sync` exposes: +an `AdeDb.sync` object. The same module is consumed both by the +Electron main process and by the **ade-cli runtime daemon** (`ade +serve`); both open the same `.ade/ade.db` and use the same `AdeDb.sync` +surface, so a change in either place is wire-compatible with the other. + +Every other service talks to plain SQLite (`run`, `get`, `all`, +`prepare`); `AdeDb.sync` exposes: - `getSiteId(): string` — the local cr-sqlite site identifier. - `getDbVersion(): number` — the monotonic replication version. @@ -20,16 +25,19 @@ SQLite (`run`, `get`, `all`, `prepare`); `AdeDb.sync` exposes: - `applyChanges(rows: CrsqlChangeRow[]): ApplyRemoteChangesResult` — apply remote changes locally. -`syncHostService` and `syncPeerService` use those four primitives -plus `syncProtocol.ts` envelope encoding to do the actual wire -exchange. +The canonical `syncHostService` and `syncPeerService` +(`apps/ade-cli/src/services/sync/`) use those four primitives plus +`syncProtocol.ts` envelope encoding to do the actual wire exchange. +The desktop tree's matching files are one-line re-exports of the +ade-cli modules — there is no second implementation to keep in sync. -## Desktop: native loadable extension +## Desktop / daemon: native loadable extension -Desktop opens SQLite through `node:sqlite` and loads a vendored -`crsqlite.dylib` (macOS) / `.so` (linux) as a loadable extension. A -fresh connection runs `SELECT load_extension(...)` once, then `AdeDb` -marks every eligible non-virtual table as a CRR at startup: +Both the Electron main process and the `ade serve` daemon open SQLite +through `node:sqlite` and load a vendored `crsqlite.dylib` (macOS) / +`.so` (linux) as a loadable extension. A fresh connection runs +`SELECT load_extension(...)` once, then `AdeDb` marks every eligible +non-virtual table as a CRR at startup: ```sql SELECT crsql_as_crr('table_name'); @@ -266,7 +274,7 @@ After apply, ADE runs post-hooks: | Piece | Status | |---|---| -| Desktop extension loading + CRR marking | Implemented | +| Desktop / daemon extension loading + CRR marking | Implemented | | iOS pure-SQL emulation | Implemented, wire-compatible | | Dynamic CRR discovery | Implemented | | `ALTER TABLE ADD COLUMN` support | Implemented (wrapped) | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 43496b71c..232bb173e 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -1,9 +1,12 @@ # iOS Companion -The ADE iOS app is a native SwiftUI companion that acts as a **controller** -for a desktop or VPS host running the full ADE Electron app. The phone -never runs agents; it reads synced state from a local SQLite DB and sends -execution commands to the host over WebSocket. +The ADE iOS app is a native SwiftUI companion that acts as a +**controller** for an ADE runtime daemon (`ade serve`). The daemon may +be running on a Mac that also has the desktop app open or on a headless +host — the phone does not care, and the desktop renderer is just +another controller of the same daemon. The phone never runs agents; it +reads synced state from a local SQLite DB and sends execution commands +to the daemon over WebSocket. This doc summarises the architecture at a level useful for understanding the sync surface. For the full roadmap, see Phase 6 and Phase 7 plans in @@ -170,13 +173,13 @@ to the same Settings sheet the dot opens. `SettingsConnectionHeader` distinguishes the four states explicitly: - Connected, normal load → "Live · ready to sync". -- Connected, strained load → "Live · host responding slowly". +- Connected, strained load → "Live · machine responding slowly". - Connected with `connectionState == .syncing` → "Live · syncing changes". -- `connecting` → "Connecting to saved host". -- `unreachable` → "Unable to reach your Mac" plus the +- `connecting` → "Connecting to saved machine". +- `unreachable` → "Unable to reach your machine" plus the `lastFailureMessage` banner. -- `disconnected` → reconnect / pair-different-host CTA depending on +- `disconnected` → reconnect / pair-different-machine CTA depending on whether a saved Tailscale address candidate is present. `SettingsConnectionPresentation.statusLabel` returns "Connected, slow" @@ -286,8 +289,8 @@ Implemented envelope types on iOS: |---|---|---| | `hello` / `hello_ok` / `hello_error` | Bidirectional | Handshake | | `pairing_request` / `pairing_result` | Phone → host / host → phone | 6-digit PIN pairing | -| `project_catalog_request` / `project_catalog` | Phone → host / host → phone | Refresh recent/available desktop projects | -| `project_switch_request` / `project_switch_result` | Phone → host / host → phone | Prepare a sync connection for a selected desktop project | +| `project_catalog_request` / `project_catalog` | Phone → host / host → phone | Refresh recent/available machine projects | +| `project_switch_request` / `project_switch_result` | Phone → host / host → phone | Prepare a sync connection for a selected machine project | | `changeset_batch` | Bidirectional | cr-sqlite changeset batch | | `changeset_ack` | Bidirectional | Per-batch apply confirmation (or error code); the sender retransmits on timeout | | `command` | Phone → host | Execution request | @@ -355,19 +358,19 @@ yet arrived in the catchup batch. `device:<hostIdentity>`, `route:<host>:<port>`, or `name:<hostName>:<port>`. `SyncService` keeps a parallel `ade.sync.hostProfiles` `UserDefaults` blob so a phone that has - paired with multiple desktops can re-resolve the right token when - the desktop initiates a project switch without re-bundling + paired with multiple machines can re-resolve the right token when + the host initiates a project switch without re-bundling credentials. - Uses iOS Keychain Services API (`SecItemAdd` / `SecItemCopyMatching` / `SecItemUpdate` / `SecItemDelete`). ### PIN pairing flow -1. User opens Settings > Sync on the host desktop and sets a 6-digit - PIN. The desktop writes `.ade/secrets/sync-pin.json` (chmod `0600`) +1. User opens Settings > Sync on the host machine and sets a 6-digit + PIN. The host writes the PIN under `~/.ade/secrets` (chmod `0600`) and surfaces it on the Settings > Sync sheet for the duration the user wants to accept pairings. -2. Phone opens Settings > Pairing, either scans the desktop QR (which +2. Phone opens Settings > Pairing, either scans the machine QR (which carries address candidates + port only) or enters host/port manually, then types the same PIN the user set. 3. Phone sends a `pairing_request` envelope with the PIN. The host's @@ -585,7 +588,7 @@ Before the tabs render, `ProjectHomeView` can take over the root screen when no active project is selected or the user taps the Projects toolbar button. It merges the host-provided catalog with projects already present in the local replicated DB, marks cached/unavailable rows, and requests a -fresh bootstrap connection for the selected desktop project through +fresh bootstrap connection for the selected machine project through `project_switch_request`. Each tile renders `MobileProjectSummary.iconDataUrl` when the host's `projectIconResolver` found a favicon for the project, falling back to the brand glyph otherwise. The host pre-renders icons @@ -615,13 +618,13 @@ All lane, file, Work, and PR projections are scoped through `Database.currentProjectId()`. The iOS app stores the active project id in `UserDefaults`, mirrors it into `DatabaseService`, and falls back to the project home if no selected project row has arrived yet. Project -switches reset the remote DB version. The desktop runs at most one sync -host at a time — pinned to the active project — so when the phone asks -the desktop to switch projects, the desktop activates the requested +switches reset the remote DB version. The host machine runs at most one +sync host at a time — pinned to the active project — so when the phone +asks the host to switch projects, the host activates the requested project locally, returns `connection: null`, and the phone reuses its existing pairing credentials to reconnect against the now-active host. -If the desktop is offline at switch time, it still records the requested -project as active and the phone reconnects when the desktop returns. +If the host is offline at switch time, it still records the requested +project as active and the phone reconnects when the host returns. Rather than reconstructing lane detail surfaces client-side from primitive rows, the iOS app persists richer projections the host @@ -718,8 +721,8 @@ reflected in the phone's UI on the next descriptor read. | WebSocket client | Implemented | | PIN pairing flow | Implemented | | QR pairing payload (v2, address candidates + port) | Implemented | -| Project home + desktop project switching | Implemented | -| Lanes tab | Implemented to live desktop parity (with `devicesOpen`, multi-attach, stack canvas, and template environment progress) | +| Project home + machine project switching | Implemented | +| Lanes tab | Implemented to live machine parity (with `devicesOpen`, multi-attach, stack canvas, and template environment progress) | | Files tab | Implemented with `mobileReadOnly` workspace gate and capped search/quick-open result rendering | | Work tab | Implemented; live chat-event push from host, subscribed terminal input/resize control with `terminal_unsubscribe` on view disappear, in-app CLI session launcher (`work.startCliSession`), tap-to-resume on ended PTY rows | | PRs tab | Implemented; driven by `prs.getMobileSnapshot` | @@ -744,8 +747,8 @@ reflected in the phone's UI on the next descriptor read. is a CRR, make sure writes land in a table the phone reads), not on the phone. Avoid adding host-only caches that the phone has no way to observe. -- **Project selection gates hydration.** A phone paired to a host can - know about multiple desktop projects, but lane/file/Work/PR reads must +- **Project selection gates hydration.** A phone paired to a machine can + know about multiple machine projects, but lane/file/Work/PR reads must stay scoped to the active project id. If a switch fails, roll back the active project id, host profile, token, and remote DB version together. - **Keychain items survive app uninstall on some iOS builds.** diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index 5e198a870..e63be4817 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -2,13 +2,15 @@ Remote commands are the execution channel for controllers. A controller (another desktop acting as a peer, or the iOS app) sends a `command` -envelope to the host; the host resolves it through -`syncRemoteCommandService`, runs the underlying action against the -host-side services, and replies with `command_ack` and then -`command_result`. +envelope to the host (the `ade serve` runtime daemon); the host +resolves it through `syncRemoteCommandService`, runs the underlying +action against its in-process services, and replies with `command_ack` +and then `command_result`. -Source file: `apps/desktop/src/main/services/sync/syncRemoteCommandService.ts` -(~2,030 lines). +Source file: `apps/ade-cli/src/services/sync/syncRemoteCommandService.ts` +(~2,520 lines). The desktop tree's +`apps/desktop/src/main/services/sync/syncRemoteCommandService.ts` is a +one-line re-export of the canonical module. ## Shape @@ -55,11 +57,18 @@ The host responds in two envelopes: } ``` -### Per-action policy +### Per-action descriptor -Every action carries a `SyncRemoteCommandPolicy`: +Every action carries a `SyncRemoteCommandDescriptor` with both a +**scope** and a **policy**: ```ts +type SyncRemoteCommandDescriptor = { + action: SyncRemoteCommandAction; + scope: "runtime" | "project"; + policy: SyncRemoteCommandPolicy; +}; + type SyncRemoteCommandPolicy = { viewerAllowed: boolean; // can a read-only controller invoke? requiresApproval?: boolean; // host prompts operator before executing @@ -68,18 +77,34 @@ type SyncRemoteCommandPolicy = { }; ``` -Controllers read `SyncRemoteCommandDescriptor` from the host (via a -metadata channel or cached descriptor bundle) and gate UI accordingly -— the host policy is always authoritative. +The scope label matters because the daemon hosts **multiple projects** +at once. `runtime`-scoped commands (machine-wide diagnostics, project +catalog reads, settings) run without a project binding. `project`-scoped +commands (everything that mutates lane / chat / PR state inside a +project) require the host to have an active project AND the caller to +have bundled a matching `projectId` on the envelope. The host enforces +this with explicit error codes: + +- `code: missing_project` — host has a project open but the command did + not include `projectId`. Re-select the project on the controller and + retry. +- `code: project_not_open` — caller asked for a project the host does + not currently have open. Drive a `project_switch_request` first. + +Controllers read `SyncRemoteCommandDescriptor`s from the host (via the +`getSupportedActions` / `getDescriptors` surface) and gate UI +accordingly — the host policy and scope are always authoritative. ## Registry -Commands are registered by calling `register(action, policy, handler)` -inside `createSyncRemoteCommandService`. The registry is a `Map<string, -RegisteredRemoteCommand>` built at service construction. Handlers -receive parsed-and-validated args and either return a result or -throw; thrown errors are wrapped into the `command_result.error` -envelope. +Commands are registered by calling `register(action, policy, handler, +scope = "project")` inside `createSyncRemoteCommandService`. The +registry is a `Map<SyncRemoteCommandAction, RegisteredRemoteCommand>` +built at service construction. Handlers receive parsed-and-validated +args and either return a result or throw; thrown errors are wrapped +into the `command_result.error` envelope. The default scope is +`"project"` because most actions need an open project to make sense; +runtime-scoped registrations are explicit. ### Action categories @@ -284,9 +309,10 @@ services: Optional services that are missing cause their dependent actions to throw `"<service> not available."` at call time. The `requireService` -helper centralises that check. This pattern lets the headless ADE CLI -server construct a narrower service set without crashing at command -registration. +helper centralises that check. This pattern lets a narrower runtime +construct only the services it can actually back without crashing at +command registration — useful for headless `ade serve` setups that, for +example, intentionally skip the chat service. ## Supported-action discovery @@ -383,15 +409,15 @@ see the chat README for the passive/active contract. reconnect. Be aware when reasoning about "why did this lane disappear" — check the command queue, not just the local DB. - **`prs.createFromLane` requires the host's GitHub token.** On a - headless ADE CLI host with no `ADE_GITHUB_TOKEN` / + headless `ade serve` host with no `ADE_GITHUB_TOKEN` / `GITHUB_TOKEN` / `GH_TOKEN`, the command fails with a clear error before reaching GitHub. This is deliberate fail-fast behavior. - **`work.runQuickCommand` always creates a PTY.** There is no "run a command, give me just the output" variant; the controller must subscribe to the terminal stream and tear down with - `work.closeSession`. This is why headless ADE CLI mode provides a - stub PTY service that throws on `.create` — the action is not - supported there. + `work.closeSession`. A daemon configured without a real PTY service + (rare; only used in some headless test harnesses) will surface + `pty service not available` for this command. - **`work.startCliSession` provider list is host-controlled.** The controller cannot pass `command` / `args` / `startupCommand` overrides — the host derives those from the provider name through diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 519663824..3a8d5e7f6 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -5,9 +5,25 @@ single `terminal_sessions` row and surfaced in the Work view, lane panels, and the Sessions sidebar. The session model is the backbone for transcripts, deltas, lane association, and resume flows. -The main-process services for this feature are large and have been repeatedly -rewritten: `ptyService.ts`, `sessionService.ts`, and `processService.ts`. -Treat them as fragile and re-read whenever wiring changes. +PTYs are owned by the **active ADE runtime** for the window's project +binding. Local-bound windows spawn PTYs through the local ADE daemon +(`ade serve`); remote-bound windows spawn PTYs on the remote host via +the SSH-attached runtime, with stdin/stdout bytes streaming over the +SSH-backed RPC. The renderer's `window.ade.pty.*`, `window.ade.sessions.*`, +`window.ade.processes.*`, and `window.ade.terminal.*` calls in +`apps/desktop/src/preload/preload.ts` route through +`callProjectRuntimeActionIfBound("pty", …)` / +`callProjectRuntimeActionIfBound("session", …)` / +`callProjectRuntimeActionIfBound("process", …)` first and fall back to +the legacy in-process IPC handlers (the desktop's `ptyService.ts`, +`sessionService.ts`, `processService.ts`) only when no runtime is +bound. The same source files run on both paths. The macOS VM controls +(`window.ade.macosVm.*`) are local-only — they require local hardware +access and are intentionally disabled for remote-bound windows. + +These services are large and have been repeatedly rewritten: +`ptyService.ts`, `sessionService.ts`, and `processService.ts`. Treat +them as fragile and re-read whenever wiring changes. `processService` keeps one runtime record per *invocation*, not per (lane, process) pair. A single `ProcessDefinition` can have many concurrent @@ -17,7 +33,8 @@ snapshot (the most recent run) is what lives in the `process_runtime` table. ## Source file map -Main process: +Service files. Same sources back both the runtime daemon and the +desktop fallback IPC path. - `apps/desktop/src/main/services/pty/ptyService.ts` — PTY lifecycle, transcript capture (capped at `MAX_TRANSCRIPT_BYTES = 64 MB`), runtime diff --git a/docs/features/terminals-and-sessions/pty-and-processes.md b/docs/features/terminals-and-sessions/pty-and-processes.md index a730816c2..2b48a442e 100644 --- a/docs/features/terminals-and-sessions/pty-and-processes.md +++ b/docs/features/terminals-and-sessions/pty-and-processes.md @@ -1,17 +1,35 @@ # PTY, Sessions, and Managed Processes -Lifecycle and wiring for the three main-process services that back the +Lifecycle and wiring for the three services that back the terminal/session system: - `apps/desktop/src/main/services/pty/ptyService.ts` - `apps/desktop/src/main/services/sessions/sessionService.ts` - `apps/desktop/src/main/services/processes/processService.ts` -All three are large and carry a lot of cross-wiring through `main.ts` -and `registerIpc.ts`. Re-read them before any non-trivial change. -The most recent structural shift was in `processService`: runtime -entries are now keyed by `runId` so a single `(laneId, processId)` -pair can have multiple concurrent and historical runs simultaneously. +These services run inside the **active ADE runtime** (local daemon for +local-bound windows, SSH-attached remote runtime for remote-bound +windows). The same source files are also loaded by the desktop main +process for the legacy in-process IPC fallback path; both paths share +identical behavior. PTY data and exit events flow over the runtime's +event stream and the renderer subscribes via the preload runtime event +pump. Remote-bound windows therefore have their PTYs spawn on the +remote machine — `node-pty` runs on the remote host, the bytes stream +back over SSH, and per-process readiness checks (TCP port probes) hit +ports on the remote host as well. + +All three are large and carry a lot of cross-wiring through the +runtime daemon's project boot and `registerIpc.ts`. Re-read them before +any non-trivial change. The most recent structural shift was in +`processService`: runtime entries are now keyed by `runId` so a single +`(laneId, processId)` pair can have multiple concurrent and historical +runs simultaneously. + +Adjacent: the `apps/desktop/src/main/services/computerUse/` +directory hosts the computer-use control plane and its broker +service (`computerUseArtifactBrokerService.ts`, with companion +test). It is local-only — controlling a real desktop is gated to the +local ADE runtime. --- diff --git a/docs/features/terminals-and-sessions/runtime-isolation.md b/docs/features/terminals-and-sessions/runtime-isolation.md index d44beb912..4e3c12587 100644 --- a/docs/features/terminals-and-sessions/runtime-isolation.md +++ b/docs/features/terminals-and-sessions/runtime-isolation.md @@ -6,6 +6,14 @@ system encodes that as a hard invariant: `laneId` is required on `PtyCreateArgs`, the lane's worktree directory is the only legal spawn cwd, and resume flows will not cross lanes. +The lane gate runs inside the **active ADE runtime** for the window's +project binding (local daemon for local-bound windows, SSH-attached +remote runtime for remote-bound windows). For remote-bound windows +the lane gate executes on the remote host — `resolveLaneLaunchContext` +calls `fs.realpathSync` against the remote filesystem and refuses to +spawn outside the remote worktree. The desktop renderer never bypasses +the runtime to spawn directly. + This document covers the gating, fallback behavior, and per-mission scoping that makes "which work runs where" a deterministic answer. diff --git a/docs/features/workspace-graph/README.md b/docs/features/workspace-graph/README.md index f98fa6d26..910d665fa 100644 --- a/docs/features/workspace-graph/README.md +++ b/docs/features/workspace-graph/README.md @@ -11,6 +11,27 @@ conflict, PR, and git service state the rest of the app uses into a spatial view. Data flows in staged layers so the canvas becomes usable before every overlay finishes loading. +## Where this runs + +Every backing data feed (lane list, conflict batch assessment, sync +status, auto-rebase status, PR list, integration proposals, +operations) is served by the **active ADE runtime** for the window's +project binding — the local ADE daemon for local-bound windows or the +SSH-attached remote runtime for remote-bound windows. The renderer +calls into the runtime through preload's +`callProjectRuntimeActionOr(...)` helpers and falls back to the legacy +in-process IPC handlers when no runtime is bound. Persisted graph +preferences (node positions, view mode, filters) are stored through +the runtime's `graph_state` action domain — they live in the runtime's +state store so the layout follows the project binding (and survives +across desktop windows pointed at the same project). The renderer +itself owns no service state; it is purely a projection of runtime +data. + +For remote-bound windows the entire data graph is computed on the +remote machine; the desktop renderer just receives the snapshots and +event deltas. + ## Source file map Core renderer files (`apps/desktop/src/renderer/components/graph/`): diff --git a/docs/features/workspace-graph/data-sources.md b/docs/features/workspace-graph/data-sources.md index 0f0c6d62d..93cb0410d 100644 --- a/docs/features/workspace-graph/data-sources.md +++ b/docs/features/workspace-graph/data-sources.md @@ -5,20 +5,31 @@ session, and operation state into `GraphNodeData` / `GraphEdgeData`. The renderer stages data loading in layers so the canvas is interactive before every overlay finishes. +Every data feed below is served by the **active ADE runtime** for the +window's project binding (local daemon for local-bound windows, +SSH-attached remote runtime for remote-bound windows). The renderer +goes through `apps/desktop/src/preload/preload.ts`, which prefers the +runtime route via `callProjectRuntimeActionOr(...)` and falls back to +the legacy in-process IPC handler when no runtime is bound. The IPC +channel names below are the renderer-facing API; the runtime serves +each one through its corresponding action domain +(`lane`, `conflicts`, `pr`, `operation`, `process`, `session`, +`graph_state`). + Source: `apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx`. ## Data feeds -| Source | Feeds | IPC / store path | -|--------|-------|------------------| -| Lane list | Node positions, node data (`lane`) | `appStore.lanes`, `appStore.refreshLanes()` | -| Conflict status + risk matrix | Node `status`, edge `riskLevel`, matrix | `ade.conflicts.getBatchAssessment` | +| Source | Feeds | Renderer call (runtime-routed) | +|--------|-------|--------------------------------| +| Lane list | Node positions, node data (`lane`) | `appStore.lanes`, `appStore.refreshLanes()` (→ `lane` action) | +| Conflict status + risk matrix | Node `status`, edge `riskLevel`, matrix | `ade.conflicts.getBatchAssessment` (→ `conflicts` action) | | Sync status | Node `remoteSync` badge | `ade.git.getLaneUpstreamSync` (batched) | -| Auto-rebase status | Node `autoRebaseStatus` badge | `ade.lanes.listAutoRebaseStatuses` | -| Sessions | Active session counts, activity score, last-activity timestamps | `renderer/lib/sessionListCache.ts` (cached list + PTY event stream) | -| Operations | Activity score (git commits) | `ade.history.listOperations` | -| PRs | Node `pr` overlay, PR edges | `ade.prs.listWithConflicts` | -| Integration proposals | Proposal nodes | `ade.prs.listProposals` | +| Auto-rebase status | Node `autoRebaseStatus` badge | `ade.lanes.listAutoRebaseStatuses` (→ `lane` action) | +| Sessions | Active session counts, activity score, last-activity timestamps | `renderer/lib/sessionListCache.ts` (cached list + runtime event stream) | +| Operations | Activity score (git commits) | `ade.history.listOperations` (→ `operation` action) | +| PRs | Node `pr` overlay, PR edges | `ade.prs.listWithConflicts` (→ `pr` action) | +| Integration proposals | Proposal nodes | `ade.prs.listProposals` (→ `pr` action) | | Environment mappings | Environment coloring per lane | `ade.project.listEnvironmentMappings` | ## Initial hydration sequence @@ -122,8 +133,9 @@ on every chunk. ## Event-driven refreshes -The page subscribes to several main-process event streams and -schedules refreshes accordingly: +The page subscribes to several runtime event streams (delivered +through the preload runtime event pump for both local and remote +runtimes) and schedules refreshes accordingly: - `ade.prs.onEvent(event)` — when `event.type === "prs-updated"` → `scheduleRefreshPrs()`. @@ -217,14 +229,16 @@ PR edges: ## Persistence -Two storage paths: +Two storage paths, both routed to the active runtime via preload's +`graph_state` action domain (with the legacy in-process IPC handler +as fallback): - **Per-view session snapshot** (`GraphSessionState`): node positions, collapsed state, filters. Persisted per view mode so switching modes preserves the user's layout. - **Global preferences** (`GraphPersistedState`): `lastViewMode` - only. Written via `ade.workspace.saveGraphPreferences` (or - similar IPC name — consult `preload.ts`). + only. Written via `window.ade.graphState.set(projectId, state)` + → runtime `graph_state.set` → fallback `ade.graphState.set` IPC. `normalizeGraphPreferences(state)` reads either the current or legacy (`presets: […]`) format. If `migrated: true`, the caller diff --git a/package.json b/package.json index fbf9f6b5a..140e5e3ac 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,24 @@ "name": "ade", "private": true, "scripts": { + "dev": "npm run dev:desktop", + "dev:all": "node scripts/dev-all.mjs", + "dev:code": "node scripts/dev-code.mjs --auto", + "dev:code:attach": "node scripts/dev-code.mjs --attach", + "dev:code:auto": "node scripts/dev-code.mjs --auto", + "dev:desktop": "node scripts/dev-desktop.mjs --auto", + "dev:desktop:attach": "node scripts/dev-desktop.mjs --attach", + "dev:desktop:auto": "node scripts/dev-desktop.mjs --auto", + "dev:desktop:clean": "node scripts/dev-desktop.mjs --auto --clean", + "dev:runtime": "node scripts/dev-runtime.mjs", + "dev:stop": "node scripts/dev-runtime-stop.mjs", + "dev:runtime:stop": "node scripts/dev-runtime-stop.mjs", + "install:apps": "npm --prefix apps/ade-cli install && npm --prefix apps/desktop install && npm --prefix apps/web install", + "package:alpha": "node scripts/package-channel.mjs alpha", + "package:beta": "node scripts/package-channel.mjs beta", + "runtime:build": "npm --prefix apps/ade-cli run build", + "setup": "npm run install:apps", + "stop": "node scripts/dev-runtime-stop.mjs", "test": "npm run test --prefix apps/desktop && npm run test --prefix apps/ade-cli", "test:ci": "npm run test:coverage --prefix apps/desktop && npm run test --prefix apps/ade-cli" } diff --git a/plans/remote-runtime-architecture.md b/plans/remote-runtime-architecture.md new file mode 100644 index 000000000..395fdafea --- /dev/null +++ b/plans/remote-runtime-architecture.md @@ -0,0 +1,887 @@ +# ADE Remote Runtime Architecture — Implementation Specification + +## 0. Purpose of This Document + +This is the engineering spec for the next major architecture shift in ADE: extracting the runtime from the desktop process, making it multi-project, and adding remote-machine support over SSH. It captures every decision we have made, the rationale behind each, the audit findings of the current codebase that the decisions are grounded in, and a concrete phased implementation plan with file-level detail. + +It is intended to be sufficient for a dev team to execute without further architectural debate. Where decisions were made, they are stated as decisions, not options. Where decisions were deferred, they are explicitly listed in the Non-Goals section. + +No timelines are included; sequencing is captured as phase ordering and parallelization tracks. + +--- + +## 1. Executive Summary + +### What we are building + +A unified, always-on ADE runtime ("ade") that: +- Runs as a single per-machine background daemon, managing N projects on that machine. +- Can run on the user's local machine, a Mac Studio, an AWS VPS, a Cloudflare VM, or any always-on Unix host accessible via SSH. +- Is connected to by all three UI surfaces (Desktop, Mobile, TUI), each treated as a thin client. +- Allows the desktop and TUI to address remote runtimes via SSH-tunneled JSON-RPC, with the runtime binary auto-uploaded to the remote on first connect (the "Cursor Server" / VS Code Remote-SSH model). + +### Why + +Three motivations: + +1. **Always-on agents.** Long-running agent runs should not die because the user closed their laptop or the desktop app crashed. +2. **Heterogeneous compute.** Users want to run agents on a beefy Mac Studio at home or a cloud VPS while controlling them from a thin laptop client. Cursor's Background Agents demonstrate the demand. +3. **Mobile parity.** The mobile app today is conceptually tied to "the desktop app." Once the runtime is a separable thing, mobile becomes a peer client of any runtime — local or remote — without architectural change. + +### The three big shifts + +1. **Runtime extraction.** The Electron desktop app no longer hosts the runtime in-process. The runtime is `apps/ade-cli`, run as a separate process. Desktop becomes a thin client of its own local runtime. +2. **Multi-project unified runtime.** A single runtime instance manages all projects on its host machine. The protocol envelope carries a `projectId`. The user mental model becomes "I have one machine, on which I have many projects," not "I have many runtimes, one per project." +3. **SSH-tunneled remote runtime.** Desktop (and TUI) can connect to a runtime running on a different machine over SSH stdio, using the same JSON-RPC protocol as for the local runtime. Static binaries are auto-uploaded to remotes on first connect. + +--- + +## 2. Current State (Audit Findings) + +These are the facts about today's codebase that the design is grounded in. They were established by three parallel investigation agents and are referenced throughout the rest of this spec. + +### 2.1 Repo structure + +`/home/user/ADE/apps/`: +- `apps/desktop` — Electron app. Main process currently *is* the runtime. Renderer is a normal React app. +- `apps/ade-cli` — Standalone Node.js runtime + JSON-RPC server (~550 KB compiled). No Electron deps. +- `apps/ade-code` — React Ink TUI. Separate package today; connects to a desktop's RPC socket OR embeds ade-cli in-process. +- `apps/ios` — Swift/iOS app. Connects to desktop's WebSocket sync server over mDNS+QR pairing. +- `apps/web` — Minimal Vite/React surface. Limited integration. + +No top-level monorepo manager (no `pnpm-workspace.yaml`). Cross-package imports use relative paths. + +### 2.2 What's already in place that helps us + +- **`apps/ade-cli/src/bootstrap.ts` exposes `createAdeRuntime()`** that instantiates ~40 of the ~88 services the desktop has. This is the existing "core" we are formalizing. +- **`apps/ade-cli/src/jsonrpc.ts` defines `JsonRpcTransport`** as a 3-method interface (`onData`, `write`, `close`). Already pluggable — works with Unix socket, TCP, and is trivial to extend to stdio for SSH. +- **`apps/desktop/src/renderer/` is fully Electron-agnostic.** Zero `ipcRenderer` or `window.electron` references. Talks through a typed `window.ade` bridge that is wired by preload script in Electron and stubbed by `browserMock.ts` outside it. Renderer requires zero changes when we move the backend. +- **Sync layer (`apps/desktop/src/main/services/sync/*`) has zero `electron` imports.** Already headless-compatible. Can move to `ade-cli` mechanically. +- **Bonjour/mDNS uses `bonjour-service` (pure Node).** Works in headless processes. Tailscale `serve` fallback already exists. +- **Mobile pairing protocol is host-agnostic.** Multiple runtimes coexist on a network as distinct mDNS instances distinguished by `deviceId` in TXT records. +- **Desktop already bundles `ade-cli` via electron-builder `extraResources`.** Wrapper scripts (`apps/desktop/scripts/ade-cli-{macos,windows}-wrapper.{sh,cmd}`) put `ade` on the user's PATH. + +### 2.3 What's tangled today + +- The desktop main process instantiates ~88 services; ade-cli's `bootstrap.ts` instantiates ~40. The 40-45 service gap is the runtime services that haven't yet been pulled into the shared runtime. +- Only **2-3 services use Electron APIs directly** — `linearCredentialService` and `apiKeyStore` use `safeStorage`; `feedbackReporterService` uses `BrowserWindow`; `builtInBrowserService` is wholly Electron. Everything else uses plain Node. +- **IPC surface is 687 channels** in `apps/desktop/src/main/services/ipc/registerIpc.ts` (~10,240 LOC). The JSON-RPC surface is ~60-80 methods. **They are not isomorphic.** IPC includes window management, clipboard, dialogs, keybindings, progress event subscriptions — UI concerns that have no place on a wire protocol. RPC is a strict subset focused on runtime operations. +- Today every ade-cli is bound to one `projectRoot` at construction time. `.ade/` directories are project-scoped. Services hold `projectRoot` for their lifetime. Multi-project support requires reorganizing service ownership inside the runtime. + +### 2.4 Native dependencies in ade-cli + +- `node-pty` ^1.1.0 (native, prebuilds for darwin-{arm64,x64}, linux-{arm64,x64}) +- `sql.js` ^1.13.0 (pure JS + WASM) +- `@cursor/sdk` ^1.0.9 (has platform-specific variants — see desktop's electron-builder `asarUnpack`) +- `node-cron`, `yaml` (pure JS) + +`onnxruntime-node` is desktop-only (used for embeddings). It is **not** in `ade-cli/package.json` and will **not** be bundled into the static remote binary in v1. See Non-Goals. + +### 2.5 Sync layer specifics + +- `apps/desktop/src/main/services/sync/syncHostService.ts` (~3,000 LOC): WebSocket on `0.0.0.0:8787` (auto-bumps to 8788, 8789 on collision). Raw WS, max 25 MB payload. +- `syncRemoteCommandService.ts` (~2,500 LOC): registry of 181 remote command actions across categories (lanes, work/chat, git, prs, cto, files/processes). +- Pairing: bootstrap token (`.ade/secrets/sync-bootstrap-token`), QR + PIN flow, paired device registry (`sync-paired-devices.json`). +- Message envelope: JSON wrapper, gzip when payload ≥ 4 KB. +- CRDT changesets streamed via cr-sqlite `db.sync.exportChangesSince()` polling at ~400 ms. + +--- + +## 3. Target Architecture + +### 3.1 Conceptual model + +> **Every UI is a thin client. The only thing that holds state is the runtime. The runtime can live on your laptop, your Mac Studio, or a VPS — the same binary, the same protocol.** + +A "remote target" in the desktop UI is just a registered location where a runtime lives. Lanes, worktrees, agent processes, sync servers all live where the runtime lives. The desktop / TUI / mobile UI is a *view* over that runtime's state. + +### 3.2 Process model on a single machine + +Per host: + +``` + ┌───────────────────────────┐ + │ ade (runtime daemon) │ + │ — managed by launchd / │ + │ systemd user unit │ + │ — listens on Unix sock │ + │ — listens on WS 8787 │ + │ — broadcasts mDNS │ + │ — manages N projects │ + └─────────────┬─────────────┘ + │ + ┌───────────────┬───────┴───────┬────────────────┐ + │ │ │ │ + ┌────▼────┐ ┌─────▼────┐ ┌─────▼────┐ ┌──────▼─────┐ + │ Desktop │ │ TUI │ │ Mobile │ │ External │ + │ (UNIX │ │ (UNIX │ │ (WS over │ │ JSON-RPC │ + │ sock) │ │ sock) │ │ LAN / │ │ clients │ + │ │ │ │ │ Tailsc.)│ │ │ + └─────────┘ └──────────┘ └──────────┘ └────────────┘ +``` + +### 3.3 Process model with a remote target + +When the desktop targets a remote runtime: + +``` + Local machine Remote machine (Mac Studio / VPS) + ┌───────────────┐ ┌──────────────────────────────┐ + │ Desktop UI │ │ ade (runtime daemon) │ + │ │ SSH stdio │ — spawned by SSH on demand │ + │ JSON-RPC ─────┼───────────────────┼─→ JSON-RPC handler │ + │ client │ │ — same protocol, same code │ + └───────────────┘ └──────────────────────────────┘ +``` + +The local runtime daemon and the remote runtime daemon are the **same binary running with different invocations**: +- Local: `ade serve` (managed by launchd/systemd, Unix socket + WS) +- Remote: `ade rpc --stdio` (spawned over SSH, stdio JSON-RPC only) + +### 3.4 Project model + +A runtime maintains a **project registry**: a list of `(projectId, projectRoot)` pairs known to that runtime. Each project has its own `.ade/` directory inside its root. Service trees are instantiated lazily per-project on first reference. + +Every JSON-RPC request and every sync WS message carries a `projectId` (or omits it for runtime-level operations like "list projects"). Clients pick which project they are operating on; the runtime routes accordingly. + +### 3.5 Tab / window model on the desktop + +Each desktop window or tab holds a single `(runtime, projectId)` binding established at the moment the user opens or connects. Switching projects or runtimes within an existing tab is **not supported**; the user opens a new tab. Multiple tabs can independently target the same project on different runtimes (e.g. local + Mac Studio copies of `myapp`). They are unrelated from ADE's perspective; reconciliation happens via normal git. + +--- + +## 4. Architectural Decisions (Numbered, with Rationale) + +These are the decisions made during design. They are not up for re-debate during implementation; if a constraint surfaces that requires revisiting one, escalate. + +| # | Decision | Rationale | +|---|---|---| +| D1 | **Unified per-machine runtime managing multiple projects.** Single ade-cli process per host. Project-id in protocol envelope. Lazy per-project service trees. | Matches user mental model ("I have one Mac Studio, not five"). One pairing per machine for mobile. Lower process overhead. | +| D2 | **Desktop becomes a thin client.** Electron main process spawns or attaches to a local runtime daemon via Unix socket. Renderer unchanged. | Renderer is already Electron-agnostic. The IPC façade can route runtime calls to the daemon transparently. Avoids future divergence between local and remote behaviour. | +| D3 | **SSH-tunneled JSON-RPC for remote runtime.** Desktop opens an `ssh user@host ade rpc --stdio` channel; speaks existing JSON-RPC over the SSH stdio. | Reuses pluggable transport. No new server. Auth piggy-backs on SSH. Works for any always-on host accepting SSH. Cursor / VS Code Remote-SSH model. | +| D4 | **Static binary, auto-uploaded on first connect.** Per-platform `ade` binaries built via Node SEA. Desktop detects remote arch via `uname -sm`, scp's the matching binary to `~/.ade/bin/` on first connect. | "Cursor Server" UX. User installs nothing on the remote. We pin the runtime version. Upgrade = replace one file. | +| D5 | **Run-as-SSH-user identity model.** Agent on the remote runs as the user that SSH'd in. No dedicated `ade` user, no sandboxing in v1. | Same authority as if the user SSH'd in by hand. Predictable blast radius. Sandboxing delegated to standard Unix permissions. | +| D6 | **Auto-start runtime on user login as a system service** (launchd user agent / systemd user unit / Windows equivalent). Setting to disable, default ON. | Required for "phone connects any time without desktop open" and "agents survive desktop crashes." Standard pattern (Docker Desktop, Tailscale). | +| D7 | **Any UI spawns a runtime if none is running.** Desktop, TUI, etc. detect missing daemon and start one transparently. | Robustness when the user has disabled auto-start or killed the daemon. No user-facing "runtime offline" errors. | +| D8 | **One installer ships everything.** Desktop installer registers the launchd/systemd service and puts `ade` on PATH. Standalone CLI installer (`brew`, `curl \| sh`) ships the same `ade` binary for headless / VPS users. | Same binary, two install paths. Required for SSH bootstrap (we need standalone binaries to upload). | +| D9 | **Single CLI surface — `ade` with subcommands.** `ade code` launches TUI; `ade serve` runs daemon foreground; `ade rpc --stdio` is SSH transport mode; existing `ade lanes`/`ade prs` etc. unchanged. The `ade-code` package is merged into `ade-cli`. | One command surface, less user confusion. Mirrors `git`, `cargo`, `gh`, OpenCode. Lazy-load Ink/React only when `ade code` runs. | +| D10 | **Silent runtime updates.** Desktop update brings a newer bundled binary. On launch, desktop signals running daemon to shut down, daemon exits, desktop spawns new daemon. No user prompt. Same for remote: on connect, if remote binary < bundled, upload + restart silently with a small status pill in connection UI. | User updates the app expecting everything to update. No "do you want to update?" interruptions during deep work. | +| D11 | **Multi-project: project-id in protocol envelope from day one.** Don't ship per-project runtime first and migrate later. | Migrating mid-flight breaks mobile clients. The protocol decision is foundational, not optional. | +| D12 | **Model A only in v1: project lives where the runtime lives.** Remote target = project lives on the remote machine. No "send this chat to remote, run on my exact local state" flow. | Per-chat dispatch (Cursor Background Agents) requires either ephemeral branches (which the user explicitly rejected) or real-time file sync (huge feature). Out of scope for this spec. | +| D13 | **Detect-and-surface for agent CLI auth.** Don't proxy OAuth. When `claude` / `codex` / etc. is missing or unauthenticated on a remote, render an inline error card with "Install" and "Authenticate" buttons. Auth opens a terminal pane that runs the CLI's own login command over SSH; user completes the device-code flow in their local browser. | Agent CLI auth is the CLI's problem, not ours. Trying to proxy OAuth is an indefinite project. v1 surfaces the error well; that's enough. | +| D14 | **Mobile sees only network-reachable runtimes (LAN + Tailscale-extended).** No SSH transport on mobile. NAT traversal is a documentation problem (Tailscale recommendation), not infrastructure we operate. | SSH from a phone is bad UX. The actual underlying need is reachability, which Tailscale solves. | +| D15 | **Branch-name collision: not our problem.** Two runtimes pushing lanes targeting the same upstream branch is treated like two devs collaborating on the same branch — git handles it. | Avoids inventing a new naming convention or coordination protocol. | +| D16 | **No memory/embedding features on remote runtimes in v1.** `onnxruntime-node` not bundled in the static remote binary. Memory tab features unavailable when the active runtime is remote. | onnxruntime is ~100 MB and the largest single packaging cost. v1 ships smaller, faster. Reintroduce later if demand justifies. | +| D17 | **Local-vs-remote uncommitted work warning.** When opening a project on a remote runtime, if the local runtime has the same project (matched by `git remote get-url origin`) with uncommitted changes, show a small dialog: *"Your local copy has uncommitted work. Push first, or your remote work will be on different code."* | Cheap to implement, real value, prevents confusion. | +| D18 | **One-time migration on the next release.** No backwards-compat shims for old behaviour beyond that. The first release after this lands installs the daemon, migrates state, and from then on the new architecture is the only architecture. | We have few enough users that we don't need long-tail compatibility. Subsequent releases are normal updates. | + +--- + +## 5. Implementation Phases + +Phases are ordered by dependency. Within a phase, tasks can be parallelized along the tracks listed in section 13. + +### Phase 1 — Runtime extraction + multi-project foundation + +**Goal:** A single `ade-cli` process can serve multiple projects and exposes the full runtime feature surface. The desktop continues to embed it for now (the process split happens in Phase 2). + +### Phase 2 — Desktop and sync become clients of the runtime + +**Goal:** The desktop runs `ade serve` as a child or attached daemon and routes runtime IPC through JSON-RPC. The sync WebSocket lives in the runtime, not the Electron process. The launchd/systemd service is registered. Mobile sees runtimes regardless of whether the desktop is open. + +### Phase 3 — SSH transport + remote machine support + +**Goal:** Users can register remote machines, the desktop auto-uploads the runtime binary on first connect, and lanes can be opened on remote runtimes. + +### Phase 4 — Mobile UI updates for remote runtimes + +**Goal:** Mobile UX reflects the multi-runtime, machine-first model. (Most of the protocol work is already in place from Phase 1+2; this is mostly UI/copy.) + +--- + +## 6. Phase Details + +### Phase 1 — Runtime extraction + multi-project + +#### 1.1 Move the missing services into ade-cli + +**Files to move (or import into ade-cli's bootstrap):** + +The 40-45 services currently in `apps/desktop/src/main/services/` that are not yet in `apps/ade-cli/src/bootstrap.ts`. Notable ones: + +- `services/lanes/laneEnvironmentService.ts`, `laneTemplateService.ts`, `laneWorktreeLockService.ts` (last is partially shared) +- `services/lanes/portAllocationService.ts`, `laneProxyService.ts`, `oauthRedirectService.ts`, `runtimeDiagnosticsService.ts` +- `services/git/rebaseSuggestionService.ts`, `autoRebaseService.ts` +- `services/prs/prPollingService.ts`, `pathToMergeOrchestrator.ts` (consolidate; both ade-cli and desktop have versions) +- `services/automation/*` (automationSecretService, automationIngressService — bring into ade-cli) +- `services/missions/missionPreflightService.ts`, `sessionDeltaService.ts` +- `services/memory/embeddingService.ts`, `embeddingWorkerService.ts`, `hybridSearchService.ts`, `memoryLifecycleService.ts`, `memoryBriefingService.ts`, `missionMemoryLifecycleService.ts`, `episodicSummaryService.ts`, `humanWorkDigestService.ts`, `proceduralLearningService.ts`, `knowledgeCaptureService.ts`, `skillRegistryService.ts` — desktop-only in v1 (see D16); not moved, but their interfaces should be defined so the desktop can keep them while remote runtimes simply don't expose memory RPC methods. +- `services/github/githubPollingService.ts` +- `services/usage/usageTrackingService.ts`, `services/budget/budgetCapService.ts` +- `services/agents/agentToolsService.ts` +- `services/projects/projectScaffoldService.ts` +- `services/feedback/feedbackReporterService.ts` — split into runtime-side "submit feedback" and desktop-side "focus window after submit" + +For each: move the file (or, if the file imports anything Electron-only, refactor to remove the import and inject the dependency from the desktop shell instead), update `apps/ade-cli/src/bootstrap.ts` to instantiate it, expose the relevant RPC methods in `apps/ade-cli/src/adeRpcServer.ts`. + +**Services that stay in `apps/desktop/src/main/`:** +- `services/updates/autoUpdateService.ts` +- `services/builtInBrowser/*` +- `services/onboarding/onboardingService.ts` +- `services/keybindings/keybindingsService.ts` +- `services/devtools/devToolsService.ts` +- `services/notifications/apnsService.ts`, `apnsKeyStore.ts`, `notificationEventBus.ts` (mostly — runtime side handled by sync; APNs key management stays desktop-side) +- Native menu / tray / deep-link handlers in `apps/desktop/src/main/main.ts` + +#### 1.2 Abstract Electron API usage + +Three known sites: + +- `apps/desktop/src/main/services/cto/linearCredentialService.ts:4` and `apps/desktop/src/main/services/ai/apiKeyStore.ts` use Electron `safeStorage`. +- `apps/desktop/src/main/services/feedback/feedbackReporterService.ts:2` imports `BrowserWindow`. +- `apps/desktop/src/main/services/builtInBrowser/*` — wholly Electron, stays. + +Introduce a credential-store interface in `apps/ade-cli/src/services/credentials/`: + +``` +interface CredentialStore { + get(key: string): Promise<string | null>; + set(key: string, value: string): Promise<void>; + delete(key: string): Promise<void>; +} +``` + +Implementations: +- `KeytarCredentialStore` — uses `keytar` package; works on macOS/Windows/Linux with a keyring daemon present. +- `EncryptedFileCredentialStore` — `~/.ade/secrets/credentials.json.enc`, AES-GCM with a per-machine key stored mode-600 in `~/.ade/secrets/.machine-key`. Used on headless Linux servers / VPSes without a keyring. +- `ElectronSafeStorageCredentialStore` — desktop-only wrapper around Electron `safeStorage`. Constructed from inside the Electron main process and either passed into the local runtime via IPC or replaced once the runtime is split out. + +The credential interface is owned by the runtime. The desktop hands it the `safeStorage` impl while embedded; after Phase 2 the runtime picks keytar or encrypted-file based on platform detection. + +`feedbackReporterService` is split: the runtime exposes a `feedback.submit` RPC method; the desktop adds a small wrapper that calls it and then handles the post-submit Electron focus. + +#### 1.3 Project registry inside the runtime + +New module: `apps/ade-cli/src/services/projects/projectRegistry.ts`. + +Schema: + +``` +type ProjectId = string; // stable, derived from absolute path hash + +interface ProjectRecord { + projectId: ProjectId; + rootPath: string; + displayName: string; // last path segment, editable + addedAt: number; + lastOpenedAt: number; + gitOriginUrl: string | null; // for D17 matching +} +``` + +Persistence: `~/.ade/projects.json` (machine-scoped, NOT per-project). Atomic writes. + +Operations exposed via JSON-RPC: +- `projects.list()` → `ProjectRecord[]` +- `projects.add({ rootPath })` → creates `.ade/` if missing, registers, returns record. +- `projects.remove({ projectId })` — does NOT delete `.ade/` from disk; just deregisters. +- `projects.touch({ projectId })` — updates `lastOpenedAt`. + +#### 1.4 Per-project service-tree caching + +New module: `apps/ade-cli/src/services/projects/projectScope.ts`. + +Pattern: + +``` +class ProjectScope { + // lazy-init per project + readonly laneService: LaneService; + readonly prService: PrService; + readonly orchestratorService: OrchestratorService; + readonly chatService: AgentChatService; + // ... etc +} + +class ProjectScopeRegistry { + private scopes = new Map<ProjectId, ProjectScope>(); + get(projectId: ProjectId): ProjectScope { /* lazy create */ } + async dispose(projectId: ProjectId): Promise<void> { /* drain + close */ } +} +``` + +Service constructors that currently take `projectRoot` get refactored to take it from the `ProjectScope` they belong to. Truly cross-project services (credential store, project registry, GitHub client, sync host, machine identity) live at runtime scope, not project scope. + +#### 1.5 JSON-RPC envelope change + +Add `projectId?: string` to the JSON-RPC request envelope. Update: + +- `apps/ade-cli/src/jsonrpc.ts` — pass `projectId` through to handler. +- `apps/ade-cli/src/adeRpcServer.ts` — handler checks: if method is project-scoped, look up the scope from `ProjectScopeRegistry`; if runtime-scoped (e.g. `projects.list`), no scope lookup. +- `apps/ade-code/src/jsonRpcClient.ts` and any other client — accept `projectId` in `call()` options. + +Method classification (every method gets one of these tags in the registry): +- `runtime` — e.g. `projects.*`, `auth.*`, `machineInfo.*`. No projectId required. +- `project` — e.g. `lanes.*`, `prs.*`, `chat.*`. ProjectId required; error if missing. + +#### 1.6 CLI surface unification + +Merge `apps/ade-code` into `apps/ade-cli`: + +- Move `apps/ade-code/src/*` to `apps/ade-cli/src/tuiClient/`. +- Update `apps/ade-cli/package.json` to add `ink`, `ink-text-input`, `react`, `@types/react` as dependencies. +- Add CLI subcommand routing in `apps/ade-cli/src/cli.ts`: + - `ade` (no args) → `ade code` in current dir + - `ade code` → launch TUI (lazy-import `./tuiClient/`) + - `ade serve [--port N] [--socket PATH]` → run runtime daemon foreground + - `ade rpc --stdio` → SSH transport mode (read RPC on stdin, write on stdout, exit when stdin closes) + - `ade init [path]` → register a project with the local runtime + - `ade doctor` → diagnostics (already exists) + - existing scripting subcommands (`ade lanes`, `ade prs`, etc.) remain +- Update `apps/ade-cli/package.json` `bin` to expose only `ade`. Drop `ade-code` from the desktop wrapper scripts. +- Update `apps/desktop/scripts/ade-cli-{macos,windows}-wrapper.{sh,cmd}` to be aware of subcommands (no functional change — wrappers just exec the binary with whatever args came in). + +#### 1.7 Phase 1 acceptance criteria + +- [ ] `ade serve` launches a standalone daemon that exposes the full RPC method set on a Unix socket. +- [ ] `ade code` connects to it (or auto-spawns one if not running) and works the same as `ade-code` does today. +- [ ] `projects.list` returns a registry with at least one project (after `ade init`). +- [ ] All RPC methods either route by `projectId` or are explicitly runtime-scoped. +- [ ] No service in `apps/ade-cli/` imports `electron`. +- [ ] All existing tests pass. + +--- + +### Phase 2 — Desktop becomes a client + sync moves to runtime + +#### 2.1 Spawn the daemon from desktop + +In `apps/desktop/src/main/main.ts`: +- On startup, attempt to connect to the runtime via `~/.ade/sock/ade.sock` (path resolved by `apps/desktop/src/shared/adeLayout.ts`). +- If connection fails (no daemon running): spawn `ade serve` as a child process, wait for the socket, then connect. +- If the launchd/systemd service is registered and running, `ade serve` is already running and connect succeeds immediately. + +Clean up the existing in-process service instantiation in `main.ts`. Replace with a `RuntimeRpcClient` that wraps `JsonRpcClient` and exposes typed methods to the rest of the desktop main process. + +#### 2.2 IPC façade rewrite + +`apps/desktop/src/main/services/ipc/registerIpc.ts` (10,240 LOC) needs systematic rewriting: + +- Each `ipcMain.handle("foo", ...)` channel that maps to a runtime operation becomes: + ``` + ipcMain.handle("foo", async (event, args) => { + return runtimeClient.call("foo_rpc_method", { projectId: getCurrentProject(event), ...args }); + }); + ``` +- Pub/sub event subscriptions (where main pushes events to renderer): the renderer subscribes via a new `runtimeEvents.subscribe(...)` RPC that streams via JSON-RPC notifications back through the IPC bridge. +- UI-only channels (clipboard, keybindings, dialogs, window management) stay as-is — they never round-trip through the runtime. + +Suggested file structure after refactor: +- `apps/desktop/src/main/ipc/runtimeBridge.ts` — handles RPC-bound channels (auto-generated where possible from a method registry) +- `apps/desktop/src/main/ipc/uiBridge.ts` — handles UI-only Electron-native channels +- `apps/desktop/src/main/ipc/registerIpc.ts` — orchestrator that wires both + +#### 2.3 Sync server moves into the runtime + +Move `apps/desktop/src/main/services/sync/` to `apps/ade-cli/src/services/sync/`. The whole directory has zero Electron imports per audit; this is a file move. + +Adjustments: +- `syncRemoteCommandService.ts` constructor receives runtime services from the runtime's bootstrap, not the desktop's `AppContext`. Wiring change in `apps/ade-cli/src/bootstrap.ts`. +- `syncHostService.ts` mDNS publishing now identifies the *runtime* as the host. TXT records add a `projects` field listing project IDs the runtime can serve (for mobile UI to enumerate). Keep `deviceId` as the stable host identity. +- Pairing secrets (`sync-paired-devices.json`) move from per-project `.ade/secrets/` to per-machine `~/.ade/secrets/`. Pairing is now machine-scoped, not project-scoped. **This is a migration path** — see Section 7. +- The 181-action command registry needs project-scoped routing: each command in `syncRemoteCommandService.ts` declares whether it's `runtime` or `project` scope; project-scoped commands require the message envelope to carry `projectId`. + +#### 2.4 Daemon registration on first run + +New module: `apps/ade-cli/src/serviceManager/`: +- `installLaunchd.ts` (macOS): writes `~/Library/LaunchAgents/com.ade.runtime.plist`, runs `launchctl load`. +- `installSystemd.ts` (Linux): writes `~/.config/systemd/user/ade-runtime.service`, runs `systemctl --user enable --now`. +- `installWindows.ts`: registers a Scheduled Task with `OnLogon` trigger. + +Triggered on: +- Desktop first launch after upgrade (idempotent — checks if already installed). +- `ade serve --install-service` invoked manually. + +Uninstall handler (called from desktop uninstaller, where supported by platform). + +#### 2.5 Local-vs-remote uncommitted warning (D17) + +Implement in `apps/desktop/src/renderer/components/projects/RemoteProjectOpenDialog.tsx` (new file). Logic: +- When user picks "Open project on Mac Studio," desktop queries: + - Local runtime: `projects.list()`, find any with matching `gitOriginUrl`. + - For each match: `git.status({ projectId })` to detect uncommitted/unstaged work. +- If matches with dirty state exist, show a non-blocking dialog: *"Your local copy has uncommitted changes. Anything you do on Mac Studio will be on different code. Push first?"* with Continue / Cancel buttons. + +#### 2.6 Phase 2 acceptance criteria + +- [ ] Closing the desktop app does not stop the runtime daemon. +- [ ] Mobile can pair with a runtime that has no desktop app open. +- [ ] All 687 IPC channels behave identically to before (functional parity). +- [ ] launchd / systemd unit is registered on first launch and survives reboot. +- [ ] mDNS broadcasts include project list in TXT records. + +--- + +### Phase 3 — SSH transport + remote machine support + +#### 3.1 `ade rpc --stdio` mode + +In `apps/ade-cli/src/cli.ts` — add the subcommand. Implementation in `apps/ade-cli/src/transports/stdioTransport.ts`: + +``` +const stdioTransport: JsonRpcTransport = { + onData(callback) { + process.stdin.on("data", chunk => callback(Buffer.from(chunk))); + }, + write(data) { process.stdout.write(data); }, + close() { process.exit(0); }, +}; +startJsonRpcServer(handler, stdioTransport); +``` + +Implementation note: the current `ade rpc --stdio` transport is a stdio bridge to the per-machine daemon. If the daemon is missing, it starts `ade serve`, then proxies JSON-RPC over the SSH exec channel. Disconnecting the SSH channel closes only that bridge; the daemon remains alive so missions, project registry state, and mobile pairing can survive desktop/client exits. This supersedes the earlier single in-process runtime sketch and matches the Phase 2 daemon persistence requirement. + +#### 3.2 SSH transport on the desktop side + +New package or module: `apps/desktop/src/main/services/remoteRuntime/`. + +Files: +- `sshTransport.ts` — uses `ssh2` package. Implements `JsonRpcTransport` interface, opens an exec channel running `ade rpc --stdio` on the remote, pipes data both ways. +- `remoteTargetRegistry.ts` — `~/.ade/secrets/remote-machines.json`: `{ name, hostname, sshUser, port, sshKeyPath, lastSeenArch, runtimeBinaryVersion, lastConnectedAt }`. +- `remoteBootstrap.ts` — first-connect flow: + 1. `ssh user@host uname -sm` → detect platform/arch. + 2. Check if `~/.ade/bin/ade` exists on remote and version-match it via `ade --version`. + 3. If missing or stale: `scp` the matching static binary from `apps/desktop/resources/runtime/ade-{platform}-{arch}` to `~/.ade/bin/ade`, `chmod +x`, retry version check. + 4. Spawn `ade rpc --stdio` over SSH, attach `JsonRpcClient`. +- `remoteConnectionPool.ts` — caches SSH connections, handles reconnect on transient failures. + +Add `ssh2` to `apps/desktop/package.json` dependencies. + +#### 3.3 Static binary build pipeline + +New scripts under `apps/ade-cli/scripts/`: +- `build-static.mjs` — builds via Node SEA. Per-platform: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`. +- `package-native-deps.mjs` — bundles `node-pty` prebuilds and `@cursor/sdk` platform variants alongside the SEA binary. + +CI changes: +- Add a job that builds all four platforms on each release tag. +- Upload artifacts named `ade-{platform}-{arch}` to GitHub Releases. +- Desktop's `electron-builder.yml` `extraResources` includes the four binaries from a `runtime/` folder; desktop's `prebuild` step downloads the matching release artifacts. + +#### 3.4 Agent CLI auth UX (D13) + +In renderer: +- New component: `apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx`. +- Triggered when an agent run fails with the specific error patterns: `command not found`, `ENOENT`, `not authenticated`, `unauthorized`, etc. (Pattern matching in `apps/ade-cli/src/services/chat/agentChatService.ts` — return a structured error type instead of opaque string.) +- Card shows: agent name (`claude` / `codex`), error category (missing / unauthenticated), and one or two buttons: + - **Install** → calls `remoteRuntime.runShell({ command: <official install command> })` and streams output to a terminal pane. + - **Authenticate** → opens a terminal pane connected to the remote via SSH, runs the CLI's auth command (`claude /login` or equivalent), streams stdout/stderr. The user copies the device-code URL from that terminal and completes auth in their local browser. +- For **local** runtime, "Install" and "Authenticate" run via the local runtime's shell tool, not SSH. + +A small registry in `apps/ade-cli/src/services/agentRegistry.ts`: + +``` +{ + claude: { + installCommand: "curl -fsSL https://claude.ai/install.sh | sh", + authCommand: "claude /login", + notAuthErrorPatterns: [/not logged in/, /unauthorized/i], + }, + codex: { ... }, +} +``` + +#### 3.5 Desktop UI for remote targets + +New screens: +- `apps/desktop/src/renderer/components/projects/HomePage.tsx` (existing, modify): adds "Connect to remote machine" button alongside "Open project." +- `apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx`: hostname, SSH user, optional port, optional key path. On submit triggers `remoteRuntime.connect()` which runs the bootstrap flow. +- `apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx`: shows registered remotes + LAN-discovered runtimes (mDNS). Combined picker. +- Tab labels (existing tab system from #273) updated to show `<projectName> · <runtimeName>` where runtimeName is `local` or the remote's display name. + +#### 3.6 Phase 3 acceptance criteria + +- [ ] Adding a remote target with valid SSH credentials succeeds, uploads binary if needed, and reaches `projects.list()`. +- [ ] Opening a project on a remote runtime works end-to-end: lane creation, agent chat, git ops, PR creation. +- [ ] Disconnecting and reconnecting reattaches transparently (long-running missions resumed via existing checkpoint mechanism). +- [ ] Agent CLI missing-or-unauthenticated errors render the auth card; install + auth flows complete successfully on the remote. +- [ ] Static binaries are present in CI release artifacts for all four platforms. + +--- + +### Phase 4 — Mobile UI updates + +#### 4.1 Discovery list + +`apps/ios/ADE/Services/SyncService.swift` already does the mDNS work and supports multiple runtimes. UI changes: +- The "available hosts" list label changes from "Desktops" to "Machines." +- Each entry displays: machine name, project list (now sourced from mDNS TXT record `projects` added in Phase 2.3, or from `projects.list` after pairing). +- A device's pairing entry persists per-machine (already does, via `deviceId`-keyed Keychain storage). + +#### 4.2 Project picker after machine selection + +New screen: after picking a machine, list its projects. User taps a project; that establishes the `(machine, projectId)` binding for the session. + +#### 4.3 Copy/labels + +Search-and-replace "desktop" → "machine" in user-visible strings. The presence indicator on the desktop app stays as a phone icon (showing "phone connected") — the user-facing copy reads "Phone connected to [machine name]" rather than referencing runtimes or sockets. + +#### 4.4 Tailscale guidance + +Add a Help screen entry: *"To use ADE Mobile away from your home network, install Tailscale on both your phone and your machine. Once both are on the Tailscale network, your machine will show up here just like it does at home."* No code change. + +#### 4.5 Phase 4 acceptance criteria + +- [ ] Mobile lists all reachable runtimes on the network with machine name + project count. +- [ ] Pairing flow per-machine, not per-desktop, works against a headless `ade serve`. +- [ ] All user-visible copy uses "machine," not "desktop" or "runtime." + +--- + +## 7. Migration & Upgrade Path + +### 7.1 One-time upgrade detection (D18) + +When the desktop app launches the first version of itself that includes Phase 2: + +1. Check for `~/.ade/secrets/` existence. + - If absent: fresh install. Initialize state and register the daemon. Done. + - If present: existing user. Run migration steps below. +2. Migrate paired devices: if `<projectRoot>/.ade/secrets/sync-paired-devices.json` exists for any project in the legacy registry, merge entries into `~/.ade/secrets/sync-paired-devices.json`. Each entry is keyed by `deviceId`, so deduplication is natural. +3. Migrate bootstrap token similarly: if any project has a `sync-bootstrap-token`, copy the first one found to `~/.ade/secrets/sync-bootstrap-token`. Subsequent project tokens become obsolete. +4. Build the project registry: walk legacy `recentProjects` from desktop config, register each path that still has a valid `.ade/` directory in `~/.ade/projects.json`. +5. Install the launchd/systemd service. +6. Show one-time onboarding: *"ADE now runs in the background. Your phone can connect any time, agents survive app restarts. (Disable in Settings.)"* +7. Write a marker file `~/.ade/.migrated-v2` so subsequent launches skip migration. + +Subsequent updates: no migration logic runs, just normal app upgrades + silent runtime restart per D10. + +### 7.2 Existing user state preservation + +- `~/.ade/` per-user state — preserved. +- `<project>/.ade/` per-project state — preserved. +- `<project>/.ade/secrets/` — values migrated to `~/.ade/secrets/` then orphaned (legacy files left in place; not deleted, in case of rollback). On a subsequent release we can clean up. +- SQLite database (`~/.ade/ade.db`) — schema unchanged in this work; migration concerns are minimal. + +### 7.3 If a user rolls back + +Rollback to the prior desktop version: the legacy app reads its old config locations, which are still intact. The daemon may keep running (orphan process); a Settings option in the new version lets the user uninstall it manually if needed. Document this in release notes. + +--- + +## 8. Installation Story + +### 8.1 Desktop installer (mac/win/linux) + +Same one-installer model as today. Adds: +- First-run hook registers the launchd/systemd/Task Scheduler entry (D6). +- One-time migration logic per Section 7 if upgrading from a pre-v2 ADE. +- The installer continues to bundle `ade` on PATH via existing wrapper scripts. + +### 8.2 Standalone runtime installer + +For headless users who don't want the desktop GUI: +- macOS/Linux: `brew install ade` (formula in a tap repo; ships the same `ade` binary built by CI). +- Linux: `curl -fsSL https://ade.dev/install.sh | sh` script; downloads platform-matched binary from GitHub Releases, places it in `/usr/local/bin/ade` (or `~/.local/bin/ade` if no root), registers systemd user unit if appropriate. +- Windows: Scoop bucket + manual installer. + +This installer is also what the desktop's remote-bootstrap flow (Phase 3.2) effectively does over SSH, just non-interactive. + +### 8.3 Per-platform notes + +- **macOS notarization**: the static `ade` binary needs to be code-signed for distribution. Add notarization to the existing `apps/desktop/scripts/notarize-mac-dmg.mjs` flow, and a parallel pipeline for the standalone binary. +- **Linux**: prebuilt binaries should be statically linked against musl where possible to avoid glibc version drift on older distros. +- **Windows**: may require additional setup for `node-pty` ConPTY usage. Verify in CI. + +--- + +## 9. CLI Surface (D9) + +``` +ade # Default: launch TUI in current directory (= ade code) +ade code [path] # Launch TUI explicitly +ade serve [--port N] # Run runtime daemon in foreground + # --install-service registers launchd/systemd entry + # --uninstall-service removes it +ade rpc --stdio # SSH transport mode (read RPC on stdin, write on stdout) + +ade init [path] # Register project with local runtime; create .ade/ if missing +ade doctor # Diagnostics (existing) + +ade lanes <subcmd> # Existing scripting commands, unchanged +ade prs <subcmd> +ade missions <subcmd> +ade actions run <action> # Existing escape hatch +ade mcp # Existing MCP stdio server + +# Project-scoped commands accept --project <id-or-path> or auto-detect from cwd +``` + +The TUI (`ade code`) lazy-imports React and Ink; baseline `ade` invocation does not pay the load cost. + +--- + +## 10. Protocol Changes + +### 10.1 JSON-RPC envelope + +Existing fields: `jsonrpc`, `id`, `method`, `params`. + +Added field: `params.projectId?: string`. Methods declare in their registration whether they require it. The handler in `apps/ade-cli/src/adeRpcServer.ts` looks up the appropriate `ProjectScope` if required. + +### 10.2 mDNS TXT records + +Existing records (per audit): `version`, `deviceId`, `siteId`, `deviceName`, `port`, `host`, `addresses`, `tailscaleIp`, `tailscaleDnsName`. + +Added: `projects` (CSV of project IDs known to the runtime), `runtimeVersion` (binary version), `runtimeKind` (`desktop-embedded`, `headless`, `remote-stdio` — for diagnostics only). + +### 10.3 Sync WS message envelope + +Existing envelope: `{ version, type, requestId, compression, payloadEncoding, payload, ... }`. + +Added: `projectId` field (required for project-scoped command types, omitted for runtime-scoped). `syncRemoteCommandService.ts` routes by this field. + +### 10.4 Pairing payload + +QR pairing payload `SyncPairingQrPayload` already includes `hostIdentity`, `port`, `addressCandidates`. No change required; pairing is now machine-scoped, but the protocol payload is unchanged. + +--- + +## 11. Authentication & Security + +### 11.1 Local socket + +`~/.ade/sock/ade.sock` permissions mode `0600`, owned by the user. Any local process owned by that user can connect (desktop, TUI, scripts). This is the model today. + +### 11.2 SSH transport + +Auth = SSH auth. Whatever `ssh2` would accept (key file, agent socket, password if the user really wants). No additional layer. + +The remote's `~/.ade/bin/ade` and `~/.ade/` directory inherit the SSH user's permissions. The agent runs as the SSH user (D5). + +### 11.3 Mobile pairing + +Existing flow unchanged: bootstrap token + QR + PIN. Now machine-scoped instead of project-scoped (Phase 2.3). + +### 11.4 Agent CLI auth (D13) + +ADE never sees agent CLI credentials. The CLIs handle their own auth in their own config dirs. ADE only orchestrates the install + the auth invocation. + +### 11.5 What's deliberately not in scope + +- Per-project access control (one user pairing scoped to project subset). +- Audit logging of which user invoked which agent action. +- Multi-tenant remote machines. + +These are reasonable v2 features but explicitly out for v1. + +--- + +## 12. Known Risks & Gotchas + +### 12.1 Native deps in the static binary + +`node-pty` and `@cursor/sdk` ship as native modules. Node SEA supports asset injection but requires careful packaging (see `apps/desktop/scripts/after-pack-runtime-fixes.cjs` for the existing prebuilt-binary handling pattern; use as a reference). + +`onnxruntime-node` is explicitly excluded from the remote binary (D16); the runtime gracefully degrades by not exposing memory-related RPC methods on remotes. + +### 12.2 In-flight state across daemon restart + +Silent updates (D10) require that an active agent run survives a daemon process restart. The orchestrator already supports mission-checkpoint resume, but **chat session state, PTY buffers, and in-flight tool calls** may not all persist today. **Per user direction, we are not implementing checkpoint-survives-restart in v1.** Active agent runs may be lost on update. Once user base grows, revisit and add a "drain to disk on shutdown" path. This is acceptable risk during the transition phase. + +### 12.3 Multi-window state coherence + +The merge from main brought multi-window scaffolding (#273). Each window holds its own `(runtime, projectId)` binding. State coherence between windows of the same `(runtime, projectId)` is handled by the existing CRDT layer (cr-sqlite). State across different `(runtime, projectId)` pairs is intentionally not synced. + +### 12.4 mDNS visibility on cellular + +Outside of LAN (or Tailscale-extended networks), mDNS does not reach. We document the Tailscale path; it is not an in-app feature. + +### 12.5 Branch name collisions across runtimes (D15) + +Two runtimes with copies of the same project may push lanes targeting the same upstream branch and collide on `git push`. Treated as a normal git collaboration concern. Surface git's own error message; do not invent prevention. + +### 12.6 SSH key UX + +First-connect requires a working SSH key chain. If the user has password-only auth, the bootstrap flow needs to handle prompting (or refuse with a clear error). Recommend keys; document setting up SSH key-based auth. + +### 12.7 Long mDNS-instance-name collisions on same host + +Existing behaviour: instance names include port suffix to disambiguate multiple runtimes on the same host (e.g. when a user has both desktop-embedded runtime and a separate `ade serve` running). Verify this still holds after Phase 2 when port allocation moves to runtime-scope. + +### 12.8 Service install failure modes + +If launchd/systemd registration fails (permissions, unsupported platform), fall back to "spawn-on-launch, die when last UI disconnects" mode. Surface a non-blocking notice in Settings. + +--- + +## 13. Parallelization Tracks + +The phases are sequential at a high level, but within them work can be split across the following independent tracks: + +### Track A — Runtime extraction (Phase 1 core) + +Owner-area: `apps/ade-cli/src/services/`, `apps/ade-cli/src/bootstrap.ts`, `apps/ade-cli/src/adeRpcServer.ts`. + +Tasks: Move services 1.1, abstract Electron APIs 1.2, project registry 1.3, project-scope refactor 1.4, RPC envelope 1.5, CLI unification 1.6. + +Dependencies: none. Can start immediately. + +### Track B — Static binary build pipeline (independent) + +Owner-area: `apps/ade-cli/scripts/`, CI workflows, release tooling. + +Tasks: 3.3 in its entirety. Can be done in parallel with Tracks A and C; needs Track A's CLI shape (D9) finalized before producing artifacts. + +Dependencies: Track A's package layout. + +### Track C — Sync layer migration (Phase 2.3) + +Owner-area: `apps/ade-cli/src/services/sync/` (new), `apps/desktop/src/main/services/sync/` (deletion), `apps/ade-cli/src/bootstrap.ts`. + +Tasks: File move, dependency wiring, TXT-record additions, project-scope routing in `syncRemoteCommandService.ts`. + +Dependencies: Track A's project registry and project-scope abstractions exist. + +### Track D — Desktop IPC façade rewrite (Phase 2.1, 2.2) + +Owner-area: `apps/desktop/src/main/services/ipc/`, `apps/desktop/src/main/main.ts`. + +Tasks: Daemon spawning, RPC client integration, mass-rewrite of IPC handlers to RPC dispatchers, separation of UI-only vs runtime channels. + +Dependencies: Track A's RPC method set is stable. Can prototype against an embedded runtime; switch to spawned daemon when both are ready. + +### Track E — Service manager (Phase 2.4) + +Owner-area: `apps/ade-cli/src/serviceManager/` (new). + +Tasks: launchd, systemd, Windows Task Scheduler integration. Uninstall hooks. Migration logic (Section 7). + +Dependencies: none for the platform integration code; Section 7 migration depends on Track C completion. + +### Track F — SSH transport (Phase 3.1, 3.2) + +Owner-area: `apps/ade-cli/src/transports/stdioTransport.ts` (new), `apps/desktop/src/main/services/remoteRuntime/` (new). + +Tasks: stdio transport in ade-cli, ssh2-based transport in desktop, target registry, bootstrap flow, connection pool. + +Dependencies: Track A (`ade rpc --stdio` subcommand exists), Track B (binaries to upload). + +### Track G — Desktop UI (Phase 3.5) + +Owner-area: `apps/desktop/src/renderer/components/`. + +Tasks: HomePage modification, RemoteTargetForm, RemoteTargetList, tab labels, AgentCliAuthCard. + +Dependencies: Track F's `remoteTargetRegistry` types and bootstrap flow defined. + +### Track H — Mobile UI updates (Phase 4) + +Owner-area: `apps/ios/`. + +Tasks: discovery list copy, project picker, Tailscale help screen. + +Dependencies: Track C completion (TXT records include project list). + +### Track I — Documentation + +Owner-area: `docs/`. + +Tasks: User-facing guides — installing, adding remote machine, Tailscale setup for mobile, agent CLI auth troubleshooting. Internal docs — daemon lifecycle, transport architecture, project model. Update existing `docs/ARCHITECTURE.md`. + +Dependencies: none; can shadow each track and write docs as the relevant code lands. + +### Recommended initial parallelization + +Phase 1 work fans out across A, B, I in parallel from day one. +Phase 2 work (C, D, E) starts as soon as A is far enough along that the RPC method set and project model are stable. +Phase 3 work (F, G) starts as soon as B has buildable artifacts and A's stdio mode is in place. +Phase 4 work (H) starts after C completes. + +--- + +## 14. Explicit Non-Goals (v1) + +These are valuable features that are deliberately out of scope: + +1. **Cloud-agent / per-chat dispatch (Cursor Background Agents).** Sending one chat in an otherwise-local lane to a remote machine to run on the same exact code state. Requires file sync infrastructure or ephemeral branches. Revisit as a separate feature. +2. **Mobile direct SSH.** Mobile only sees network-reachable runtimes. NAT traversal is documented Tailscale. +3. **Memory / embeddings on remote runtimes.** `onnxruntime-node` not in static binary. Memory features unavailable when active runtime is remote. +4. **Cross-machine project federation.** No "show me all my projects across all my runtimes" aggregate view. User picks a runtime, sees its projects. +5. **Multi-tenant remote machines.** Run-as-SSH-user only. No per-ADE-user separation when multiple humans share a remote. +6. **Branch collision protection.** Treated as a git-level concern. +7. **Per-project access control on a runtime.** A paired device sees all projects on that runtime. +8. **In-flight agent run survival across daemon restart.** Drain-to-disk path deferred. Active runs may be lost on update during this transition. +9. **Audit logging.** No structured logs of which user did what on which remote. +10. **Runtime auto-update without app update.** Runtime version is tied to desktop version. Headless users update via brew/curl manually. + +--- + +## 15. Acceptance / Definition of Done + +For the v1 release shipping this work: + +### End-to-end scenario 1 — Local refactor invisible to user +- User upgrades desktop from pre-v2 to v2. +- One-time onboarding modal appears. +- User opens an existing project — works identically to before. +- User closes desktop — daemon is still running. +- User reopens desktop — reattaches to same daemon, same state. +- All existing tests pass; manual smoke covers lane creation, agent chat, git operations, PR creation. + +### End-to-end scenario 2 — Mobile reaches runtime without desktop +- User closes desktop. +- User opens mobile app on the same Wi-Fi. +- Discovery shows the user's machine with project list. +- User pairs (one-time QR) and opens a project. +- Mobile chat works, lane operations work. + +### End-to-end scenario 3 — Remote target via SSH +- User adds Mac Studio as a remote target (hostname, SSH user, key). +- First connect: desktop detects arch, uploads `ade-darwin-arm64`, version-checks, succeeds. +- User opens a project that exists on Mac Studio. +- Lane creation, agent chat, git operations all succeed against the Mac Studio runtime. +- User closes desktop. SSH connection drops. Long-running mission keeps running on Mac Studio (visible via `ssh user@mac-studio ade lanes list`). +- User reopens desktop, reconnects. Mission status reflects progress made while disconnected. + +### End-to-end scenario 4 — Agent CLI not authenticated on remote +- User connects to a fresh VPS, opens a project. +- User sends a chat in a lane. +- Desktop renders the auth card with **Install** and **Authenticate** buttons. +- Install runs successfully via terminal pane; auth runs successfully via terminal pane (user completes device code flow in local browser). +- Subsequent chat completes normally. + +### End-to-end scenario 5 — Three UIs on one runtime +- User has desktop open on their MacBook. +- User opens TUI in a terminal on the same MacBook (`ade code`). +- User opens mobile app, paired with the MacBook runtime. +- All three reflect the same lane state, the same chat history, in real time as edits happen. +- Closing any one of them does not disrupt the others. + +--- + +## 16. Open Implementation Questions + +Items that may surface during development and need a call: + +1. **Where does the runtime persist `currentProjectId` per-window for the desktop?** Could be window state in Electron, or a `windowSession` registry inside the runtime. Probably the latter for consistency, but the former is simpler. Decide during Phase 2. +2. **Reconnect semantics for SSH transport.** When the SSH connection drops, should we auto-reconnect and resume in-flight RPC calls, or fail-fast? Recommend fail-fast for v1 (idempotent retries by the caller); revisit if it's annoying in practice. +3. **Static binary size budget.** Target: under 100 MB per platform. If it bloats above 150 MB, audit dependencies and consider splitting `@cursor/sdk` into a separately-fetched module. +4. **Service-install permissions on Linux.** systemd user units don't require root. Verify on common distros (Ubuntu LTS, Fedora, Arch). Document the manual fallback for edge cases. +5. **Onboarding modal copy.** First-run-after-upgrade text needs product-side wording. Engineering ships the trigger and the placeholder; product owns the words. +6. **Telemetry boundary.** Does the runtime emit telemetry events independently of the desktop, or pipe them through? Existing usage tracking needs reattachment after the split. + +--- + +## 17. Glossary + +- **Runtime** — the `ade` daemon process. Holds all state (lanes, sessions, missions, sync server, project registry). One per machine. +- **Project** — a registered repository root with a `.ade/` directory. A runtime manages many projects. +- **UI / Client** — desktop, mobile, or TUI. Connects to a runtime; holds no durable state. +- **Local runtime** — the runtime running on the same machine as the UI. +- **Remote runtime** — a runtime running on a different machine, accessed via SSH. +- **Target** — a registered (machine, optional path) entry that a UI can connect to. +- **Project scope** — the per-project service tree inside a runtime, lazily instantiated. +- **Runtime scope** — services and operations not tied to a single project (project registry, credential store, machine identity). + +--- diff --git a/scripts/dev-all.mjs b/scripts/dev-all.mjs new file mode 100644 index 000000000..94dc73f71 --- /dev/null +++ b/scripts/dev-all.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +import { + buildRuntimeCli, + ensureRuntime, + resolveDevSocketPath, + resolveProjectRoot, +} from "./dev-shared.mjs"; + +function usage() { + return [ + "Usage: npm run dev:all -- [options]", + "", + "Builds the ADE CLI/runtime and starts the shared dev runtime.", + "Then run npm run dev:desktop:attach and npm run dev:code:attach in separate terminals.", + "", + "Options:", + " --project-root <path> Project root. Defaults to this checkout.", + " --socket <path> Dev runtime socket. Defaults to /tmp/ade-runtime-dev.sock.", + " --skip-runtime-build Launch without rebuilding apps/ade-cli.", + " -h, --help Show this help.", + ].join("\n"); +} + +function parseArgs(argv) { + const options = { + projectRoot: null, + socketPath: null, + skipRuntimeBuild: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--project-root") { + options.projectRoot = argv[++i] ?? null; + if (!options.projectRoot) throw new Error("--project-root requires a path."); + continue; + } + if (arg.startsWith("--project-root=")) { + options.projectRoot = arg.slice("--project-root=".length); + continue; + } + if (arg === "--socket") { + options.socketPath = argv[++i] ?? null; + if (!options.socketPath) throw new Error("--socket requires a path."); + continue; + } + if (arg.startsWith("--socket=")) { + options.socketPath = arg.slice("--socket=".length); + continue; + } + if (arg === "--skip-runtime-build") { + options.skipRuntimeBuild = true; + continue; + } + if (arg === "-h" || arg === "--help") { + process.stdout.write(`${usage()}\n`); + process.exit(0); + } + throw new Error(`Unknown option: ${arg}`); + } + return { + ...options, + projectRoot: resolveProjectRoot(options.projectRoot), + socketPath: resolveDevSocketPath(options.socketPath), + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + process.stdout.write(`[ade] project root: ${options.projectRoot}\n`); + process.stdout.write(`[ade] runtime socket: ${options.socketPath}\n`); + await buildRuntimeCli(options.skipRuntimeBuild); + await ensureRuntime(options.socketPath); + process.stdout.write("[ade] dev runtime is ready.\n"); + process.stdout.write("[ade] terminal 1: npm run dev:desktop:attach\n"); + process.stdout.write("[ade] terminal 2: npm run dev:code:attach\n"); +} + +main().catch((error) => { + process.stderr.write(`[ade] dev all failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/scripts/dev-code.mjs b/scripts/dev-code.mjs new file mode 100644 index 000000000..a19ca32a0 --- /dev/null +++ b/scripts/dev-code.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node + +import { + buildRuntimeCli, + canConnectToSocket, + cliPath, + devRuntimeEnv, + ensureRuntime, + resolveDevSocketPath, + resolveProjectRoot, + run, +} from "./dev-shared.mjs"; + +function usage() { + return [ + "Usage: npm run dev:code -- [options] [-- ade-code-args...]", + "", + "Builds the ADE CLI/runtime, then launches ade code against the dev socket.", + "Default mode is --auto: start the dev runtime if it is missing.", + "", + "Options:", + " --auto Start dev runtime if missing. Default.", + " --attach Require an existing dev runtime.", + " --project-root <path> Project root. Defaults to this checkout.", + " --socket <path> Dev runtime socket. Defaults to /tmp/ade-runtime-dev.sock.", + " --skip-runtime-build Launch without rebuilding apps/ade-cli.", + " -h, --help Show this help.", + ].join("\n"); +} + +function parseArgs(argv) { + const options = { + mode: "auto", + projectRoot: null, + socketPath: null, + skipRuntimeBuild: false, + rest: [], + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--") { + options.rest = argv.slice(i + 1); + break; + } + if (arg === "--auto") { + options.mode = "auto"; + continue; + } + if (arg === "--attach") { + options.mode = "attach"; + continue; + } + if (arg === "--skip-runtime-build") { + options.skipRuntimeBuild = true; + continue; + } + if (arg === "--project-root") { + options.projectRoot = argv[++i] ?? null; + if (!options.projectRoot) throw new Error("--project-root requires a path."); + continue; + } + if (arg.startsWith("--project-root=")) { + options.projectRoot = arg.slice("--project-root=".length); + continue; + } + if (arg === "--socket") { + options.socketPath = argv[++i] ?? null; + if (!options.socketPath) throw new Error("--socket requires a path."); + continue; + } + if (arg.startsWith("--socket=")) { + options.socketPath = arg.slice("--socket=".length); + continue; + } + if (arg === "-h" || arg === "--help") { + process.stdout.write(`${usage()}\n`); + process.exit(0); + } + throw new Error(`Unknown option: ${arg}`); + } + return { + ...options, + projectRoot: resolveProjectRoot(options.projectRoot), + socketPath: resolveDevSocketPath(options.socketPath), + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + process.stdout.write(`[ade] code mode: ${options.mode}\n`); + process.stdout.write(`[ade] project root: ${options.projectRoot}\n`); + process.stdout.write(`[ade] runtime socket: ${options.socketPath}\n`); + await buildRuntimeCli(options.skipRuntimeBuild); + if (options.mode === "attach") { + if (!(await canConnectToSocket(options.socketPath))) { + throw new Error(`No dev runtime is listening at ${options.socketPath}. Start it with npm run dev:runtime.`); + } + } else { + await ensureRuntime(options.socketPath); + } + await run( + process.execPath, + [ + cliPath(), + "code", + "--project-root", + options.projectRoot, + "--workspace-root", + options.projectRoot, + "--socket", + options.socketPath, + "--require-socket", + ...options.rest, + ], + devRuntimeEnv(options.socketPath, options.projectRoot), + ); +} + +main().catch((error) => { + process.stderr.write(`[ade] dev code failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/scripts/dev-desktop.mjs b/scripts/dev-desktop.mjs new file mode 100644 index 000000000..5f36c805f --- /dev/null +++ b/scripts/dev-desktop.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import { + buildRuntimeCli, + canConnectToSocket, + devRuntimeEnv, + npmCommand, + resolveDevSocketPath, + resolveProjectRoot, + run, +} from "./dev-shared.mjs"; + +function usage() { + return [ + "Usage: npm run dev:desktop -- [options]", + "", + "Builds the ADE CLI/runtime, then launches desktop dev against the dev socket.", + "Default mode is --auto: desktop is allowed to auto-create the dev runtime.", + "", + "Options:", + " --auto Use dev socket and let desktop create runtime if missing. Default.", + " --attach Require an existing dev runtime before launching desktop.", + " --project-root <path> Project to auto-open. Defaults to this checkout.", + " --socket <path> Dev runtime socket. Defaults to /tmp/ade-runtime-dev.sock.", + " --clean Use desktop dev:clean instead of dev.", + " --skip-runtime-build Launch without rebuilding apps/ade-cli.", + " -h, --help Show this help.", + ].join("\n"); +} + +function parseArgs(argv) { + const options = { + mode: "auto", + projectRoot: null, + socketPath: null, + clean: false, + skipRuntimeBuild: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--auto") { + options.mode = "auto"; + continue; + } + if (arg === "--attach") { + options.mode = "attach"; + continue; + } + if (arg === "--clean") { + options.clean = true; + continue; + } + if (arg === "--skip-runtime-build") { + options.skipRuntimeBuild = true; + continue; + } + if (arg === "--project-root") { + options.projectRoot = argv[++i] ?? null; + if (!options.projectRoot) throw new Error("--project-root requires a path."); + continue; + } + if (arg.startsWith("--project-root=")) { + options.projectRoot = arg.slice("--project-root=".length); + continue; + } + if (arg === "--socket") { + options.socketPath = argv[++i] ?? null; + if (!options.socketPath) throw new Error("--socket requires a path."); + continue; + } + if (arg.startsWith("--socket=")) { + options.socketPath = arg.slice("--socket=".length); + continue; + } + if (arg === "-h" || arg === "--help") { + process.stdout.write(`${usage()}\n`); + process.exit(0); + } + throw new Error(`Unknown option: ${arg}`); + } + return { + ...options, + projectRoot: resolveProjectRoot(options.projectRoot), + socketPath: resolveDevSocketPath(options.socketPath), + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (!fs.existsSync(`${options.projectRoot}/.ade`)) { + process.stderr.write(`[ade] warning: ${options.projectRoot} does not contain .ade; desktop may open the project picker.\n`); + } + process.stdout.write(`[ade] desktop mode: ${options.mode}\n`); + process.stdout.write(`[ade] project root: ${options.projectRoot}\n`); + process.stdout.write(`[ade] runtime socket: ${options.socketPath}\n`); + await buildRuntimeCli(options.skipRuntimeBuild); + if (options.mode === "attach" && !(await canConnectToSocket(options.socketPath))) { + throw new Error(`No dev runtime is listening at ${options.socketPath}. Start it with npm run dev:runtime.`); + } + const desktopScript = options.clean ? "dev:clean" : "dev"; + await run( + npmCommand, + ["--prefix", "apps/desktop", "run", desktopScript], + devRuntimeEnv(options.socketPath, options.projectRoot), + ); +} + +main().catch((error) => { + process.stderr.write(`[ade] dev desktop failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/scripts/dev-runtime-stop.mjs b/scripts/dev-runtime-stop.mjs new file mode 100644 index 000000000..5d54a20be --- /dev/null +++ b/scripts/dev-runtime-stop.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +function usage() { + return [ + "Usage: npm stop dev [-- options]", + "", + "Stops the isolated ADE dev runtime daemon by sending JSON-RPC exit to its socket.", + "", + "Options:", + " --socket <path> Runtime socket. Defaults to ADE_DEV_RUNTIME_SOCKET_PATH or /tmp/ade-runtime-dev.sock.", + " -h, --help Show this help.", + ].join("\n"); +} + +function parseArgs(argv) { + const defaultSocketPath = process.platform === "win32" + ? path.join(os.tmpdir(), "ade-runtime-dev.sock") + : "/tmp/ade-runtime-dev.sock"; + let socketPath = process.env.ADE_DEV_RUNTIME_SOCKET_PATH?.trim() + || process.env.ADE_RUNTIME_SOCKET_PATH?.trim() + || process.env.ADE_RPC_SOCKET_PATH?.trim() + || defaultSocketPath; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "dev" || arg === "runtime") { + continue; + } + if (arg === "--socket") { + const value = argv[i + 1]; + if (!value) throw new Error("--socket requires a path."); + socketPath = value; + i += 1; + continue; + } + if (arg.startsWith("--socket=")) { + socketPath = arg.slice("--socket=".length); + continue; + } + if (arg === "-h" || arg === "--help") { + process.stdout.write(`${usage()}\n`); + process.exit(0); + } + throw new Error(`Unknown option: ${arg}`); + } + + return socketPath.startsWith("tcp://") ? socketPath : path.resolve(socketPath); +} + +function connectSocket(socketPath) { + if (socketPath.startsWith("tcp://")) { + const parsed = new URL(socketPath); + return net.createConnection({ host: parsed.hostname, port: Number(parsed.port) }); + } + return net.createConnection(socketPath); +} + +async function stopRuntime(socketPath) { + await new Promise((resolve, reject) => { + const socket = connectSocket(socketPath); + let buffer = ""; + let nextId = 1; + const pending = new Map(); + let settled = false; + const timer = setTimeout(() => { + finish(new Error(`Timed out waiting for ADE dev runtime at ${socketPath} to exit.`)); + }, 5000); + + const finish = (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + if (error) reject(error); + else resolve(); + }; + + const request = (method, params) => { + const id = nextId; + nextId += 1; + socket.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, ...(params ? { params } : {}) })}\n`, "utf8"); + return new Promise((requestResolve, requestReject) => { + pending.set(id, { resolve: requestResolve, reject: requestReject }); + }); + }; + + socket.once("connect", () => { + void (async () => { + await request("ade/initialize", { + protocolVersion: "2025-06-18", + clientInfo: { name: "ade-dev-runtime-stop", version: "dev" }, + identity: { + callerId: `ade-dev-runtime-stop:${process.pid}`, + role: "external", + computerUsePolicy: { + mode: "auto", + allowLocalFallback: false, + retainArtifacts: true, + }, + }, + }); + await request("exit"); + finish(); + })().catch(finish); + }); + socket.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + while (true) { + const lineEnd = buffer.indexOf("\n"); + if (lineEnd === -1) return; + const line = buffer.slice(0, lineEnd).trim(); + buffer = buffer.slice(lineEnd + 1); + if (!line) continue; + try { + const response = JSON.parse(line); + if (!response || typeof response !== "object" || !("id" in response)) continue; + const entry = pending.get(response.id); + if (!entry) continue; + pending.delete(response.id); + if (response.error) { + entry.reject(new Error(response.error.message || "ADE dev runtime rejected request.")); + } else { + entry.resolve(response.result); + } + } catch { + finish(new Error(`ADE dev runtime returned invalid JSON-RPC response: ${line}`)); + return; + } + } + }); + socket.once("close", () => finish()); + socket.once("error", (error) => { + if (error && (error.code === "ENOENT" || error.code === "ECONNREFUSED")) { + finish(); + return; + } + finish(error); + }); + }); +} + +async function main() { + const socketPath = parseArgs(process.argv.slice(2)); + await stopRuntime(socketPath); + if (!socketPath.startsWith("tcp://")) { + try { fs.unlinkSync(socketPath); } catch {} + } + process.stdout.write(`[ade] stopped dev runtime at ${socketPath}\n`); +} + +main().catch((error) => { + process.stderr.write(`[ade] failed to stop dev runtime: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/scripts/dev-runtime.mjs b/scripts/dev-runtime.mjs new file mode 100644 index 000000000..4c33e75ef --- /dev/null +++ b/scripts/dev-runtime.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +import { + buildRuntimeCli, + cliPath, + devRuntimeEnv, + resolveDevSocketPath, + resolveProjectRoot, + run, +} from "./dev-shared.mjs"; + +function usage() { + return [ + "Usage: npm run dev:runtime -- [options]", + "", + "Builds the ADE CLI/runtime, then runs only the dev runtime in the foreground.", + "", + "Options:", + " --project-root <path> Project root exported to the runtime. Defaults to this checkout.", + " --socket <path> Dev runtime socket. Defaults to /tmp/ade-runtime-dev.sock.", + " --skip-runtime-build Launch without rebuilding apps/ade-cli.", + " --no-sync Disable runtime sync discovery for this run.", + " -h, --help Show this help.", + ].join("\n"); +} + +function parseArgs(argv) { + const options = { + projectRoot: null, + socketPath: null, + skipRuntimeBuild: false, + sync: true, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--project-root") { + options.projectRoot = argv[++i] ?? null; + if (!options.projectRoot) throw new Error("--project-root requires a path."); + continue; + } + if (arg.startsWith("--project-root=")) { + options.projectRoot = arg.slice("--project-root=".length); + continue; + } + if (arg === "--socket") { + options.socketPath = argv[++i] ?? null; + if (!options.socketPath) throw new Error("--socket requires a path."); + continue; + } + if (arg.startsWith("--socket=")) { + options.socketPath = arg.slice("--socket=".length); + continue; + } + if (arg === "--skip-runtime-build") { + options.skipRuntimeBuild = true; + continue; + } + if (arg === "--no-sync") { + options.sync = false; + continue; + } + if (arg === "-h" || arg === "--help") { + process.stdout.write(`${usage()}\n`); + process.exit(0); + } + throw new Error(`Unknown option: ${arg}`); + } + return { + ...options, + projectRoot: resolveProjectRoot(options.projectRoot), + socketPath: resolveDevSocketPath(options.socketPath), + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + process.stdout.write(`[ade] project root: ${options.projectRoot}\n`); + process.stdout.write(`[ade] runtime socket: ${options.socketPath}\n`); + await buildRuntimeCli(options.skipRuntimeBuild); + await run( + process.execPath, + [cliPath(), "serve", "--socket", options.socketPath, ...(options.sync ? [] : ["--no-sync"])], + devRuntimeEnv(options.socketPath, options.projectRoot), + ); +} + +main().catch((error) => { + process.stderr.write(`[ade] dev runtime failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/scripts/dev-shared.mjs b/scripts/dev-shared.mjs new file mode 100644 index 000000000..d5d4c2ef1 --- /dev/null +++ b/scripts/dev-shared.mjs @@ -0,0 +1,124 @@ +import { spawn } from "node:child_process"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const sharedPath = fileURLToPath(import.meta.url); + +export const repoRoot = path.resolve(path.dirname(sharedPath), ".."); +export const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +export const defaultDevSocketPath = process.platform === "win32" + ? path.join(os.tmpdir(), "ade-runtime-dev.sock") + : "/tmp/ade-runtime-dev.sock"; + +export function resolveDevSocketPath(rawSocketPath = null) { + const candidate = rawSocketPath?.trim() + || process.env.ADE_DEV_RUNTIME_SOCKET_PATH?.trim() + || defaultDevSocketPath; + return candidate.startsWith("tcp://") ? candidate : path.resolve(candidate); +} + +export function resolveProjectRoot(rawProjectRoot = null) { + return path.resolve(rawProjectRoot?.trim() || process.env.ADE_PROJECT_ROOT?.trim() || repoRoot); +} + +export function cliPath() { + return path.join(repoRoot, "apps", "ade-cli", "dist", "cli.cjs"); +} + +export function run(command, args, extraEnv = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: repoRoot, + env: { ...process.env, ...extraEnv }, + stdio: "inherit", + }); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (signal) { + reject(new Error(`${command} ${args.join(" ")} exited with signal ${signal}.`)); + return; + } + if (code !== 0) { + reject(new Error(`${command} ${args.join(" ")} exited with code ${code}.`)); + return; + } + resolve(); + }); + }); +} + +export async function buildRuntimeCli(skipRuntimeBuild = false) { + if (skipRuntimeBuild) return; + process.stdout.write("[ade] building runtime CLI\n"); + await run(npmCommand, ["--prefix", "apps/ade-cli", "run", "build"]); +} + +function createSocket(socketPath) { + if (socketPath.startsWith("tcp://")) { + const parsed = new URL(socketPath); + return net.createConnection({ host: parsed.hostname, port: Number(parsed.port) }); + } + return net.createConnection(socketPath); +} + +export function canConnectToSocket(socketPath, timeoutMs = 300) { + return new Promise((resolve) => { + const socket = createSocket(socketPath); + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(result); + }; + socket.setTimeout(timeoutMs); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + }); +} + +export async function waitForSocket(socketPath, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + // eslint-disable-next-line no-await-in-loop + if (await canConnectToSocket(socketPath, 250)) return; + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 150)); + } + throw new Error(`Timed out waiting for ADE dev runtime at ${socketPath}.`); +} + +export async function ensureRuntime(socketPath) { + if (await canConnectToSocket(socketPath)) return false; + if (socketPath.startsWith("tcp://")) { + throw new Error(`Cannot auto-start ADE dev runtime on TCP socket ${socketPath}.`); + } + process.stdout.write(`[ade] starting dev runtime at ${socketPath}\n`); + const child = spawn(process.execPath, [cliPath(), "serve", "--socket", socketPath], { + cwd: repoRoot, + env: { + ...process.env, + ADE_DEV_RUNTIME_SOCKET_PATH: socketPath, + ADE_RUNTIME_SOCKET_PATH: socketPath, + ADE_RPC_SOCKET_PATH: socketPath, + }, + detached: true, + stdio: "ignore", + }); + child.once("error", () => {}); + child.unref(); + await waitForSocket(socketPath); + return true; +} + +export function devRuntimeEnv(socketPath, projectRoot) { + return { + ADE_DEV_RUNTIME_SOCKET_PATH: socketPath, + ADE_RUNTIME_SOCKET_PATH: socketPath, + ADE_RPC_SOCKET_PATH: socketPath, + ADE_PROJECT_ROOT: projectRoot, + }; +} diff --git a/scripts/package-channel.mjs b/scripts/package-channel.mjs new file mode 100644 index 000000000..3b2cd2ee1 --- /dev/null +++ b/scripts/package-channel.mjs @@ -0,0 +1,357 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const currentRepoRoot = path.resolve(scriptDir, ".."); + +const CHANNELS = { + alpha: { + source: "current", + productName: "ADE Alpha", + appId: "com.ade.desktop.alpha", + cliName: "ade-alpha", + adeHome: path.join(os.homedir(), ".ade-alpha"), + outputDir: "release-alpha", + }, + beta: { + source: "origin-main", + productName: "ADE Beta", + appId: "com.ade.desktop.beta", + cliName: "ade-beta", + adeHome: path.join(os.homedir(), ".ade-beta"), + outputDir: "release-beta", + }, +}; + +function usage() { + process.stdout.write([ + "Usage: node scripts/package-channel.mjs <alpha|beta> [options]", + "", + "Builds a local packaged macOS ADE channel without using the GitHub release workflow.", + "", + "Channels:", + " alpha Builds the current checkout as ADE Alpha.", + " beta Builds origin/main in a temporary worktree as ADE Beta.", + "", + "Options:", + " --skip-install Do not run app-local npm install before building.", + " --skip-fetch For beta, do not fetch origin/main before creating the worktree.", + " --dry-run Print the commands without running them.", + " --repo <path> Internal/debug: build the selected channel from an existing repo path.", + " --help Show this help.", + "", + ].join("\n")); +} + +function fail(message) { + process.stderr.write(`[ade] ${message}\n`); + process.exit(1); +} + +function parseArgs(argv) { + const options = { + channel: null, + skipInstall: false, + skipFetch: false, + dryRun: false, + repo: null, + }; + const args = [...argv]; + while (args.length > 0) { + const arg = args.shift(); + if (!arg) continue; + if (arg === "--help" || arg === "-h") { + usage(); + process.exit(0); + } + if (arg === "--skip-install") { + options.skipInstall = true; + continue; + } + if (arg === "--skip-fetch") { + options.skipFetch = true; + continue; + } + if (arg === "--dry-run") { + options.dryRun = true; + continue; + } + if (arg === "--repo") { + const value = args.shift(); + if (!value) fail("--repo requires a path."); + options.repo = path.resolve(value); + continue; + } + if (arg.startsWith("--repo=")) { + options.repo = path.resolve(arg.slice("--repo=".length)); + continue; + } + if (arg.startsWith("-")) fail(`Unknown option: ${arg}`); + if (options.channel) fail(`Unexpected extra argument: ${arg}`); + options.channel = arg; + } + if (!options.channel) fail("Missing channel. Use alpha or beta."); + if (!CHANNELS[options.channel]) fail(`Unknown channel: ${options.channel}`); + return options; +} + +function run(command, args, options = {}) { + const cwd = options.cwd ?? currentRepoRoot; + const env = options.env ?? process.env; + const printable = [command, ...args].join(" "); + process.stdout.write(`[ade] ${cwd}$ ${printable}\n`); + if (options.dryRun) return; + const result = spawnSync(command, args, { + cwd, + env, + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + if (options.allowFailure) return; + throw new Error(`${printable} exited with ${result.status ?? "unknown status"}`); + } +} + +function removePath(targetPath, dryRun) { + if (dryRun) { + process.stdout.write(`[ade] rm -rf ${targetPath}\n`); + return; + } + fs.rmSync(targetPath, { recursive: true, force: true }); +} + +function currentTarget() { + const platform = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform; + const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch; + return `${platform}-${arch}`; +} + +function runtimeArtifactNames(target) { + return [`ade-${target}`, `ade-${target}.native.tar.gz`]; +} + +function readDesktopVersion(repoRoot) { + const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps", "desktop", "package.json"), "utf8")); + const version = typeof packageJson.version === "string" ? packageJson.version.trim() : ""; + if (!version) fail("apps/desktop/package.json is missing a version."); + return version; +} + +function assertRuntimeArtifacts(repoRoot, target) { + const runtimeRoot = path.join(repoRoot, "apps", "desktop", "resources", "runtime"); + for (const name of runtimeArtifactNames(target)) { + const artifactPath = path.join(runtimeRoot, name); + const stat = fs.existsSync(artifactPath) ? fs.statSync(artifactPath) : null; + if (!stat?.isFile() || stat.size <= 0) { + fail(`Missing host runtime artifact after build: ${artifactPath}`); + } + } +} + +function cleanHostRuntimeArtifacts(repoRoot, target, options) { + const runtimeRoot = path.join(repoRoot, "apps", "desktop", "resources", "runtime"); + for (const name of runtimeArtifactNames(target)) { + removePath(path.join(runtimeRoot, name), options.dryRun); + } +} + +function ensureHostRuntimeResources(repoRoot, options, baseEnv = process.env) { + const target = currentTarget(); + const env = { + ...baseEnv, + ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY: "1", + }; + cleanHostRuntimeArtifacts(repoRoot, target, options); + run("npm", [ + "--prefix", + "apps/desktop", + "run", + "materialize:runtime-resources", + ], { cwd: repoRoot, env, dryRun: options.dryRun }); + if (!options.dryRun) assertRuntimeArtifacts(repoRoot, target); + cleanRuntimeBuildIntermediates(repoRoot, target, options); +} + +function cleanRuntimeBuildIntermediates(repoRoot, target, options) { + const runtimeRoot = path.join(repoRoot, "apps", "desktop", "resources", "runtime"); + removePath(path.join(runtimeRoot, ".sea"), options.dryRun); + removePath(path.join(runtimeRoot, `ade-${target}.native`), options.dryRun); +} + +function ensureRepoRoot(repoRoot, options) { + if (options.dryRun && !fs.existsSync(repoRoot)) return; + if (!fs.existsSync(path.join(repoRoot, "apps", "desktop", "package.json"))) { + fail(`${repoRoot} is not an ADE repo root.`); + } +} + +function packageScriptExists(repoRoot, packagePath, scriptName) { + try { + const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, packagePath), "utf8")); + return typeof packageJson?.scripts?.[scriptName] === "string"; + } catch { + return false; + } +} + +function assertPackageChannelPrereqs(repoRoot, channel, options) { + if (options.dryRun && !fs.existsSync(repoRoot)) return; + if (packageScriptExists(repoRoot, path.join("apps", "ade-cli", "package.json"), "build:static")) return; + if (channel === "beta") { + throw new Error( + "origin/main does not include ADE's static runtime packaging script yet. " + + "Run npm run package:beta after this branch lands on main, or build Alpha from the current checkout.", + ); + } + throw new Error("apps/ade-cli/package.json is missing the build:static script required for local channel packages."); +} + +function prepareBetaWorktree(options) { + const worktreeRoot = path.join(currentRepoRoot, ".ade", "build-worktrees", "beta"); + if (!options.skipFetch) { + run("git", ["fetch", "origin", "main"], { cwd: currentRepoRoot, dryRun: options.dryRun }); + } + run("git", ["worktree", "remove", "--force", worktreeRoot], { + cwd: currentRepoRoot, + dryRun: options.dryRun, + allowFailure: true, + }); + removePath(worktreeRoot, options.dryRun); + run("git", ["worktree", "add", "--force", "--detach", worktreeRoot, "origin/main"], { + cwd: currentRepoRoot, + dryRun: options.dryRun, + }); + return worktreeRoot; +} + +function installApps(repoRoot, options) { + if (options.skipInstall) return; + run("npm", ["--prefix", "apps/ade-cli", "install"], { cwd: repoRoot, dryRun: options.dryRun }); + run("npm", ["--prefix", "apps/desktop", "install"], { cwd: repoRoot, dryRun: options.dryRun }); +} + +function findBuiltApp(outputRoot, productName) { + const matches = []; + const walk = (dir) => { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory() && entry.name.endsWith(".app")) { + matches.push(entryPath); + continue; + } + if (entry.isDirectory()) walk(entryPath); + } + }; + walk(outputRoot); + const exact = matches.find((candidate) => path.basename(candidate) === `${productName}.app`); + return exact ?? matches[0] ?? null; +} + +function zipApp(appPath, outputRoot, channel, options) { + if (process.platform !== "darwin") return null; + const zipPath = path.join(outputRoot, `${CHANNELS[channel].productName.replace(/\s+/g, "-")}-local.zip`); + removePath(zipPath, options.dryRun); + run("ditto", ["-c", "-k", "--keepParent", appPath, zipPath], { + cwd: path.dirname(appPath), + dryRun: options.dryRun, + }); + return zipPath; +} + +function postprocessChannelApp(appPath, channel, config, options) { + const resourcesRoot = path.join(appPath, "Contents", "Resources"); + const cliRoot = path.join(resourcesRoot, "ade-cli"); + const binRoot = path.join(cliRoot, "bin"); + const sourceWrapper = path.join(binRoot, "ade"); + const channelWrapper = path.join(binRoot, config.cliName); + if (options.dryRun) { + process.stdout.write(`[ade] stamp ${config.cliName} in ${appPath}\n`); + return; + } + if (!fs.existsSync(sourceWrapper)) fail(`Packaged app is missing bundled CLI wrapper: ${sourceWrapper}`); + fs.copyFileSync(sourceWrapper, channelWrapper); + fs.chmodSync(sourceWrapper, 0o755); + fs.chmodSync(channelWrapper, 0o755); + fs.writeFileSync(path.join(cliRoot, "channel"), `${channel}\n`); +} + +function buildChannel(repoRoot, channel, options) { + const config = CHANNELS[channel]; + ensureRepoRoot(repoRoot, options); + const desktopRoot = path.join(repoRoot, "apps", "desktop"); + const outputRoot = path.join(desktopRoot, config.outputDir); + const appVersion = readDesktopVersion(repoRoot); + const env = { + ...process.env, + ADE_PACKAGE_CHANNEL: channel, + ADE_CLI_VERSION: appVersion, + ADE_DESKTOP_APP_NAME: config.productName, + ADE_HOME: config.adeHome, + ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", + ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY: "1", + }; + + removePath(outputRoot, options.dryRun); + assertPackageChannelPrereqs(repoRoot, channel, options); + installApps(repoRoot, options); + run("npm", ["--prefix", "apps/ade-cli", "run", "build"], { cwd: repoRoot, env, dryRun: options.dryRun }); + ensureHostRuntimeResources(repoRoot, options, env); + run("npm", ["--prefix", "apps/desktop", "run", "build"], { cwd: repoRoot, env, dryRun: options.dryRun }); + run("npx", [ + "electron-builder", + "--dir", + "--mac", + "--publish", + "never", + "-c.mac.identity=null", + "-c.mac.notarize=false", + `-c.appId=${config.appId}`, + `-c.productName=${config.productName}`, + `-c.mac.icon=build/icon.${channel}.icns`, + `-c.directories.output=${config.outputDir}`, + `-c.extraMetadata.adePackageChannel=${channel}`, + `-c.extraMetadata.adeCliName=${config.cliName}`, + `-c.mac.extendInfo.LSEnvironment.ADE_PACKAGE_CHANNEL=${channel}`, + `-c.mac.extendInfo.LSEnvironment.ADE_DESKTOP_APP_NAME=${config.productName}`, + `-c.mac.extendInfo.LSEnvironment.ADE_HOME=${config.adeHome}`, + "-c.mac.extendInfo.LSEnvironment.ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1", + ], { cwd: desktopRoot, env, dryRun: options.dryRun }); + + if (options.dryRun) return; + const appPath = findBuiltApp(outputRoot, config.productName); + if (!appPath) fail(`Build finished but no .app was found in ${outputRoot}.`); + postprocessChannelApp(appPath, channel, config, options); + const zipPath = zipApp(appPath, outputRoot, channel, options); + process.stdout.write(`\n[ade] Built ${config.productName}: ${appPath}\n`); + if (zipPath) process.stdout.write(`[ade] Zipped app: ${zipPath}\n`); + process.stdout.write(`[ade] Bundled CLI name: ${config.cliName}\n`); + process.stdout.write(`[ade] Channel ADE_HOME: ${config.adeHome}\n`); +} + +let parsedOptions = null; +let selectedRepoRoot = null; + +try { + parsedOptions = parseArgs(process.argv.slice(2)); + selectedRepoRoot = parsedOptions.repo + ? parsedOptions.repo + : parsedOptions.channel === "beta" + ? prepareBetaWorktree(parsedOptions) + : currentRepoRoot; + buildChannel(selectedRepoRoot, parsedOptions.channel, parsedOptions); +} catch (error) { + if (parsedOptions?.channel === "beta" && !parsedOptions.repo && selectedRepoRoot && !parsedOptions.dryRun) { + spawnSync("git", ["worktree", "remove", "--force", selectedRepoRoot], { + cwd: currentRepoRoot, + stdio: "ignore", + }); + fs.rmSync(selectedRepoRoot, { recursive: true, force: true }); + } + fail(error instanceof Error ? error.message : String(error)); +}