From 4b098fe598574a08124423c4d3aee3188e3b3216 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 9 May 2026 00:52:47 -0500 Subject: [PATCH 01/11] Multi-window scaffolding, ade-code terminal client, and finalize CI/docs (#273) * Multi-window scaffolding and ade-code launcher Add multi-window project tab management to the desktop app, the `ade code` headless launcher backed by a new ade-code Ink terminal client, and an `app/navigate` RPC that routes external clients into a desktop window. Co-Authored-By: Claude Opus 4.7 (1M context) * Finalize ade-code: isolated typecheck, CI jobs, docs, and navigation guard Point ade-code imports at shared/types modules and load embedded ade-cli via dynamic import so apps/ade-code tsc stays bounded. Extend CI install cache and add typecheck/test/build jobs for ade-code. Document ade-code in ARCHITECTURE, PRD, chat feature map, new docs/features/ade-code, and ade-cli README (ade code socket semantics). Guard AppNavigationBridge when preload app API is absent (tests and early bootstrap). Co-authored-by: Arul Sharma * Fix PR review findings for ade-code * refactor(ade-code): share action helpers between attached and embedded RPC Co-authored-by: Cursor --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Cursor Agent Co-authored-by: Arul Sharma --- .github/workflows/ci.yml | 67 +- .gitignore | 2 + apps/ade-cli/README.md | 4 + apps/ade-cli/src/adeRpcServer.test.ts | 69 + apps/ade-cli/src/adeRpcServer.ts | 43 + apps/ade-cli/src/bootstrap.ts | 161 +- apps/ade-cli/src/cli.test.ts | 31 + apps/ade-cli/src/cli.ts | 71 + apps/ade-code/package-lock.json | 4907 +++++++++++++++++ apps/ade-code/package.json | 41 + apps/ade-code/src/__tests__/adeApi.test.ts | 46 + apps/ade-code/src/__tests__/commands.test.ts | 42 + .../ade-code/src/__tests__/connection.test.ts | 80 + apps/ade-code/src/__tests__/format.test.ts | 132 + apps/ade-code/src/__tests__/heartbeat.test.ts | 64 + .../src/__tests__/jsonRpcClient.test.ts | 102 + .../src/__tests__/linearCommands.test.ts | 44 + .../src/__tests__/pendingInput.test.ts | 106 + apps/ade-code/src/__tests__/project.test.ts | 51 + apps/ade-code/src/adeApi.ts | 204 + apps/ade-code/src/app.tsx | 1542 ++++++ apps/ade-code/src/cli.tsx | 141 + apps/ade-code/src/commands.ts | 138 + .../src/components/ApprovalPrompt.tsx | 55 + apps/ade-code/src/components/ChatView.tsx | 68 + apps/ade-code/src/components/Drawer.tsx | 51 + apps/ade-code/src/components/Header.tsx | 38 + .../src/components/MentionPalette.tsx | 34 + apps/ade-code/src/components/RightPane.tsx | 110 + apps/ade-code/src/components/SlashPalette.tsx | 29 + apps/ade-code/src/connection.ts | 212 + apps/ade-code/src/format.ts | 234 + apps/ade-code/src/heartbeat.ts | 129 + apps/ade-code/src/jsonRpcClient.ts | 187 + apps/ade-code/src/linearCommands.ts | 201 + apps/ade-code/src/pendingInput.ts | 99 + apps/ade-code/src/project.ts | 114 + apps/ade-code/src/types.ts | 130 + apps/ade-code/tsconfig.json | 15 + apps/ade-code/tsup.config.ts | 24 + apps/ade-code/vitest.config.ts | 8 + apps/desktop/scripts/dev.cjs | 13 + apps/desktop/scripts/generate-dev-icon.cjs | 96 + apps/desktop/src/main/main.ts | 456 +- .../src/main/services/adeActions/registry.ts | 15 +- .../main/services/ai/cliExecutableResolver.ts | 1 + .../src/main/services/ipc/registerIpc.ts | 53 +- apps/desktop/src/preload/global.d.ts | 13 + apps/desktop/src/preload/preload.ts | 19 + apps/desktop/src/renderer/browserMock.ts | 5 + .../src/renderer/components/app/App.tsx | 39 + .../renderer/components/app/TopBar.test.tsx | 96 +- .../src/renderer/components/app/TopBar.tsx | 193 +- .../src/renderer/components/prs/PRsPage.tsx | 7 +- .../components/prs/prsRouteState.test.ts | 10 + .../renderer/components/prs/prsRouteState.ts | 9 + .../run/RunPage.advancedDrawer.test.tsx | 40 + .../src/renderer/components/run/RunPage.tsx | 41 +- apps/desktop/src/shared/ipc.ts | 5 + apps/desktop/src/shared/types/core.ts | 35 + docs/ARCHITECTURE.md | 34 +- docs/PRD.md | 4 +- docs/README.md | 5 +- docs/features/ade-code/README.md | 36 + docs/features/chat/README.md | 1 + 65 files changed, 10883 insertions(+), 169 deletions(-) create mode 100644 apps/ade-code/package-lock.json create mode 100644 apps/ade-code/package.json create mode 100644 apps/ade-code/src/__tests__/adeApi.test.ts create mode 100644 apps/ade-code/src/__tests__/commands.test.ts create mode 100644 apps/ade-code/src/__tests__/connection.test.ts create mode 100644 apps/ade-code/src/__tests__/format.test.ts create mode 100644 apps/ade-code/src/__tests__/heartbeat.test.ts create mode 100644 apps/ade-code/src/__tests__/jsonRpcClient.test.ts create mode 100644 apps/ade-code/src/__tests__/linearCommands.test.ts create mode 100644 apps/ade-code/src/__tests__/pendingInput.test.ts create mode 100644 apps/ade-code/src/__tests__/project.test.ts create mode 100644 apps/ade-code/src/adeApi.ts create mode 100644 apps/ade-code/src/app.tsx create mode 100644 apps/ade-code/src/cli.tsx create mode 100644 apps/ade-code/src/commands.ts create mode 100644 apps/ade-code/src/components/ApprovalPrompt.tsx create mode 100644 apps/ade-code/src/components/ChatView.tsx create mode 100644 apps/ade-code/src/components/Drawer.tsx create mode 100644 apps/ade-code/src/components/Header.tsx create mode 100644 apps/ade-code/src/components/MentionPalette.tsx create mode 100644 apps/ade-code/src/components/RightPane.tsx create mode 100644 apps/ade-code/src/components/SlashPalette.tsx create mode 100644 apps/ade-code/src/connection.ts create mode 100644 apps/ade-code/src/format.ts create mode 100644 apps/ade-code/src/heartbeat.ts create mode 100644 apps/ade-code/src/jsonRpcClient.ts create mode 100644 apps/ade-code/src/linearCommands.ts create mode 100644 apps/ade-code/src/pendingInput.ts create mode 100644 apps/ade-code/src/project.ts create mode 100644 apps/ade-code/src/types.ts create mode 100644 apps/ade-code/tsconfig.json create mode 100644 apps/ade-code/tsup.config.ts create mode 100644 apps/ade-code/vitest.config.ts create mode 100644 apps/desktop/scripts/generate-dev-icon.cjs create mode 100644 docs/features/ade-code/README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c79ab7f51..d660073ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,8 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} + apps/ade-code/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} - name: Install all dependencies (parallel) if: steps.cache.outputs.cache-hit != 'true' @@ -35,6 +36,7 @@ jobs: cd apps/desktop && npm ci & cd apps/ade-cli && npm ci & cd apps/web && npm ci & + cd apps/ade-code && npm ci & wait # ── Secret scanning (no deps needed) ─────────────────────────────────── @@ -63,7 +65,8 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} + apps/ade-code/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} - run: cd apps/desktop && npm run typecheck typecheck-ade-cli: @@ -80,7 +83,8 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} + apps/ade-code/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} - run: cd apps/ade-cli && npm run typecheck typecheck-web: @@ -97,9 +101,28 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} + apps/ade-code/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} - run: cd apps/web && npm run typecheck + typecheck-ade-code: + needs: install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/cache/restore@v4 + with: + path: | + apps/desktop/node_modules + apps/ade-cli/node_modules + apps/web/node_modules + apps/ade-code/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + - run: cd apps/ade-code && npm run typecheck + lint-desktop: needs: install runs-on: ubuntu-latest @@ -114,7 +137,8 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} + apps/ade-code/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} - run: cd apps/desktop && npm run lint test-desktop: @@ -135,7 +159,8 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} + apps/ade-code/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} - run: cd apps/desktop && npx vitest run --shard=${{ matrix.shard }}/8 test-ade-cli: @@ -152,9 +177,28 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} + apps/ade-code/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} - run: cd apps/ade-cli && npm test + test-ade-code: + needs: install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/cache/restore@v4 + with: + path: | + apps/desktop/node_modules + apps/ade-cli/node_modules + apps/web/node_modules + apps/ade-code/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + - run: cd apps/ade-code && npm test + build: needs: install runs-on: ubuntu-latest @@ -169,10 +213,12 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} + apps/ade-code/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} - run: cd apps/desktop && npm run build - run: cd apps/ade-cli && npm run build - run: cd apps/web && npm run build + - run: cd apps/ade-code && npm run build validate-docs: needs: install @@ -188,7 +234,8 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} + apps/ade-code/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} - run: node scripts/validate-docs.mjs # ── Windows build smoke (self-contained — no shared cache) ──────────── @@ -235,9 +282,11 @@ jobs: - typecheck-desktop - typecheck-ade-cli - typecheck-web + - typecheck-ade-code - lint-desktop - test-desktop - test-ade-cli + - test-ade-code - build - validate-docs - build-win diff --git a/.gitignore b/.gitignore index 007cc49ab..988ba1578 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/ @@ -62,3 +63,4 @@ ios-signing/ /.playwright-mcp /.codex-derived-data package-lock.json +!/apps/ade-code/package-lock.json diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index a5f5569f6..ca9d1e768 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -70,6 +70,8 @@ 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 --socket /path/to/ade.sock code 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 @@ -95,6 +97,8 @@ 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. +**`ade code`** starts the terminal Work chat client (`apps/ade-code`). Build it with `npm run build` inside that directory, install the `ade-code` package, or point **`ADE_CODE_EXECUTABLE`** at `dist/cli.js`. Unlike other commands that auto-pick the desktop socket from the project layout during `executePlan`, **`ade code` only forwards `--socket` when you pass global `--socket` to `ade`** (for example `ade --socket /path/to/ade.sock code`). Without that, the TUI runs in **embedded** headless mode instead of opening a socket implicitly. + 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 | diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index a3cddf02e..a11ea38a8 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1100,6 +1100,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" }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 0edbb8999..33558f43f 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -39,6 +39,7 @@ import { type DockLayout, type GraphPersistedState, type MergeMethod, + type AppNavigationRequest, } from "../../desktop/src/shared/types"; import type { PrActionRun, PrCheck, PrComment, PrReviewThread } from "../../desktop/src/shared/types/prs"; import { resolveAdeLayout } from "../../desktop/src/shared/adeLayout"; @@ -7344,6 +7345,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.ts b/apps/ade-cli/src/bootstrap.ts index 0184df25f..67292c94b 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -33,7 +33,7 @@ import type { createRebaseSuggestionService } from "../../desktop/src/main/servi import type { 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"; @@ -81,6 +81,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 } from "../../desktop/src/shared/types"; import { createAutomationService, type AutomationAdeActionRegistry, @@ -191,6 +192,9 @@ export type AdeRuntime = { budgetCapService?: ReturnType | null; sessionDeltaService?: ReturnType | null; autoUpdateService?: ReturnType | null; + appNavigationService?: { + navigate(args: AppNavigationRequest): Promise; + } | null; eventBuffer: EventBuffer; dispose: () => void; }; @@ -300,12 +304,18 @@ 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"; +} | 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}`); } @@ -570,53 +580,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, @@ -654,7 +670,57 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo openExternal: async () => {}, }); - 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 as never); + } + } agentChatServiceHolder.current = agentChatService; const automationService = createAutomationService({ db, @@ -670,6 +736,7 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo aiOrchestratorService, onEvent: (event) => pushEvent("runtime", { ...event, source: "automations" }), }); + automationServiceRef = automationService; const automationPlannerService = createAutomationPlannerService({ logger, projectRoot, @@ -730,9 +797,9 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo const swallow = (fn: () => void) => { try { fn(); } catch { /* ignore */ } }; swallow(() => automationService.dispose()); swallow(() => processService.disposeAll()); - swallow(() => iosSimulatorService.dispose()); - swallow(() => appControlService.dispose()); - swallow(() => macosVmService.dispose()); + swallow(() => iosSimulatorService?.dispose()); + swallow(() => appControlService?.dispose()); + swallow(() => macosVmService?.dispose()); swallow(() => headlessLinearServices.dispose()); swallow(() => aiOrchestratorService.dispose()); swallow(() => testService.disposeAll()); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 3b738cc99..4711a0814 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { + buildAdeCodeArgs, buildCliPlan, findProjectRoots, formatOutput, @@ -47,6 +48,36 @@ describe("ADE CLI", () => { 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("forwards resolved roots and socket intent to ade code", () => { + 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/project/.ade/ade.sock", + "--require-socket", + "--print-state", + ]); + }); + it("preserves command-local value flags that overlap global flags", () => { const parsed = parseCliArgs(["files", "write", "src/index.ts", "--text", "hello"]); expect(parsed.options.text).toBe(false); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index b72c4e426..ee6cb50e6 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -104,6 +104,7 @@ 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: "ade-code"; rest: string[] } | { kind: "cursor-cloud"; rest: string[] } | { kind: "mcp" }; @@ -345,6 +346,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ 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 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 @@ -752,6 +754,17 @@ const IOS_SIMULATOR_HELP_ALIASES: Record = { }; const HELP_BY_COMMAND: Record = { + code: `${ADE_BANNER} + ADE Code + + Launch the terminal-native ADE Work chat. It shares lanes, chat sessions, + transcript state, and slash commands with desktop ADE. + + $ 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 --project-root code Launch against a specific ADE project +`, lanes: `${ADE_BANNER} Lanes @@ -4147,6 +4160,10 @@ function buildCliPlan(command: string[]): CliPlan { 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 === "status") { return { kind: "execute", label: "status", summary: "status", steps: [{ key: "ping", method: "ping" }] }; } @@ -4311,6 +4328,56 @@ function commandExists(command: string): boolean { return result.status === 0 && result.stdout.trim().length > 0; } +function resolveAdeCodeLaunch(): { command: string; args: string[] } { + const explicit = process.env.ADE_CODE_EXECUTABLE?.trim(); + if (explicit) return { command: explicit, args: [] }; + + const siblingDist = path.resolve(CLI_PACKAGE_ROOT, "..", "ade-code", "dist", "cli.js"); + if (fs.existsSync(siblingDist)) { + return { command: process.execPath, args: [siblingDist] }; + } + + if (commandExists("ade-code")) { + return { command: "ade-code", args: [] }; + } + + throw new CliUsageError("ade code could not find ade-code. Build apps/ade-code or install the ade-code binary."); +} + +function resolveAdeCodeSocketPath(projectRoot: string): string { + return process.env.ADE_RPC_URL?.trim() + || process.env.ADE_RPC_SOCKET_PATH?.trim() + || 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, + ]; +} + +function runAdeCode(rest: string[], options: GlobalOptions): { output: string; exitCode: number } { + const launch = resolveAdeCodeLaunch(); + const args = [ + ...launch.args, + ...buildAdeCodeArgs(rest, options), + ]; + const result = spawnSync(launch.command, args, { + cwd: process.cwd(), + env: process.env, + stdio: "inherit", + }); + if (result.error) throw result.error; + return { output: "", exitCode: typeof result.status === "number" ? result.status : 1 }; +} + function runLocalCommand(command: string, args: string[], cwd: string): { ok: boolean; stdout: string; stderr: string } { const result = spawnSync(command, args, { cwd, @@ -6750,6 +6817,9 @@ async function runCli(argv: string[]): Promise<{ output: string; exitCode: numbe await runMcpServer({ ...parsed.options, headless: true, requireSocket: false }); return { output: "", exitCode: 0 }; } + if (plan.kind === "ade-code") { + return runAdeCode(plan.rest, parsed.options); + } const result = await executePlan(plan, parsed.options); return { output: formatOutput(result, parsed.options, inferFormatter(plan)), exitCode: 0 }; } finally { @@ -6808,6 +6878,7 @@ if (/(^|[/\\])cli\.(?:ts|js|cjs)$/.test(process.argv[1] ?? "")) { export { buildCliPlan, + buildAdeCodeArgs, findProjectRoots, formatOutput, graphWaitState, diff --git a/apps/ade-code/package-lock.json b/apps/ade-code/package-lock.json new file mode 100644 index 000000000..20cdd297a --- /dev/null +++ b/apps/ade-code/package-lock.json @@ -0,0 +1,4907 @@ +{ + "name": "ade-code", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ade-code", + "version": "0.0.0", + "dependencies": { + "@cursor/sdk": "^1.0.9", + "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" + }, + "bin": { + "ade-code": "dist/cli.cjs" + }, + "devDependencies": { + "@types/node": "^22.19.18", + "@types/react": "^18.3.18", + "ink-testing-library": "^4.0.0", + "tsup": "^8.3.5", + "tsx": "^4.20.6", + "typescript": "^5.7.3", + "vitest": "^0.34.6" + }, + "engines": { + "node": ">=22.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/@bufbuild/protobuf": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", + "integrity": "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@connectrpc/connect": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-1.7.0.tgz", + "integrity": "sha512-iNKdJRi69YP3mq6AePRT8F/HrxWCewrhxnLMNm0vpqXAR8biwzRtO6Hjx80C6UvtKJ5sFmffQT7I4Baecz389w==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, + "node_modules/@connectrpc/connect-node": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-1.7.0.tgz", + "integrity": "sha512-6vaPIkG/NyhxlYgytLoR9KYbPhczEboFB2OYWkA9qvUz1K7efXfeGrlRxoLtpa+r8VxyIOw73w5ktNe743nD+A==", + "license": "Apache-2.0", + "dependencies": { + "undici": "^5.28.4" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^1.10.0", + "@connectrpc/connect": "1.7.0" + } + }, + "node_modules/@cursor/sdk": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk/-/sdk-1.0.12.tgz", + "integrity": "sha512-jGx0wFY1N9uIdIKr303CfM6m/dLXmRCUnU/0yNP/oiOpkBXqgqaThGbgYbcOeVrYonMZc/DZJ9EydXOEPJLcbg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@bufbuild/protobuf": "1.10.0", + "@connectrpc/connect": "^1.6.1", + "@connectrpc/connect-node": "^1.6.1", + "@statsig/js-client": "3.31.0", + "sqlite3": "^5.1.7", + "zod": "^3.25.0" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@cursor/sdk-darwin-arm64": "1.0.12", + "@cursor/sdk-darwin-x64": "1.0.12", + "@cursor/sdk-linux-arm64": "1.0.12", + "@cursor/sdk-linux-x64": "1.0.12", + "@cursor/sdk-win32-x64": "1.0.12" + } + }, + "node_modules/@cursor/sdk-darwin-arm64": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-arm64/-/sdk-darwin-arm64-1.0.12.tgz", + "integrity": "sha512-AOFx+aX+4SntAeC66YncHACXk5duxp+HzDrxxF4Tl93N6nLjHaHEKSAXbt87ivL34MCHop4v/3c70QzBhamB2g==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cursor/sdk-darwin-x64": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-x64/-/sdk-darwin-x64-1.0.12.tgz", + "integrity": "sha512-/ZDAYFUrnPd8hAGRky9ZGcROqZSZ2b5W+aEjTdINzLhJ8x5ZNXtjaz0ZYSHabOn2BeErjXgTcq+4bX2/To4C1A==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cursor/sdk-linux-arm64": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-arm64/-/sdk-linux-arm64-1.0.12.tgz", + "integrity": "sha512-kAxNqiB3dPtlW9fVjjIZEdbIGEGLA9moOM3zYwsXh8J1Qw942nJYMGDGR4o8x0zglwZ24a1JpovvZamrCaC3Yw==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cursor/sdk-linux-x64": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-x64/-/sdk-linux-x64-1.0.12.tgz", + "integrity": "sha512-RmBiBCPKMZC5McDerGk2Rk4P47xz2A+uzRoRgH6sMoOjklc33ry11iAZC0D5F5xH85chgY878086A/Q8+XrAuA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cursor/sdk-win32-x64": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk-win32-x64/-/sdk-win32-x64-1.0.12.tgz", + "integrity": "sha512-uH4shdHrKOdtNLapy1uuScJ9lL2Pc8zc9I9ZKC6b6bx+0UX6xLAqjPP7dqVPfO6D9u61yLq1Hs86XOLs5ZVkPA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "license": "MIT", + "optional": true + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@statsig/client-core": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@statsig/client-core/-/client-core-3.31.0.tgz", + "integrity": "sha512-SuxQD6TmVszPG7FoMKwTk/uyBuVFk7XnxI3T/E0uyb7PL7GNjONtfsoh+NqBBVUJVse0CUeSFfgJPoZy1ZOslQ==", + "license": "ISC" + }, + "node_modules/@statsig/js-client": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@statsig/js-client/-/js-client-3.31.0.tgz", + "integrity": "sha512-LFa5E0LjT6sTfZv3sNGoyRLSZ1078+agdgOA+Vm1ecjG+KbSOfBLTW7hMwimrJ29slRwbYDzbtKaPJo/R37N2g==", + "license": "ISC", + "dependencies": { + "@statsig/client-core": "3.31.0" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/chai": { + "version": "4.3.20", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", + "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai-subset": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.6.tgz", + "integrity": "sha512-m8lERkkQj+uek18hXOZuec3W/fCRTrU4hrnXjH3qhHy96ytuPaPiWGgu7sJb7tZxZonO75vYAjCvpe/e4VUwRw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/chai": "<5.2.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", + "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "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/@vitest/expect": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz", + "integrity": "sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "0.34.6", + "@vitest/utils": "0.34.6", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.34.6.tgz", + "integrity": "sha512-1CUQgtJSLF47NnhN+F9X2ycxUP0kLHQ/JWvNHbeBfwW8CzEGgeskzNnHDyv1ieKTltuR6sdIHV+nmR6kPxQqzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "0.34.6", + "p-limit": "^4.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.34.6.tgz", + "integrity": "sha512-B3OZqYn6k4VaN011D+ve+AA4whM4QkcwcrwaKwAbyyvS/NB1hCWjFIBQxAQQSQir9/RtyAAGuq+4RJmbn2dH4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.1", + "pathe": "^1.1.1", + "pretty-format": "^29.5.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.34.6.tgz", + "integrity": "sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.34.6.tgz", + "integrity": "sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.4.3", + "loupe": "^2.3.6", + "pretty-format": "^29.5.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aggregate-error/node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "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", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "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/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "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==", + "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" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "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", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "optional": true, + "engines": { + "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/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/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", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, + "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/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, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "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", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT", + "optional": true + }, + "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.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "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/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==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "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==", + "license": "MIT" + }, + "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", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/gauge/node_modules/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==", + "license": "MIT", + "optional": true + }, + "node_modules/gauge/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", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/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==", + "license": "ISC", + "optional": true + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, + "node_modules/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==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8.19" + } + }, + "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/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "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/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/ip-address": { + "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/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/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", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "license": "MIT", + "optional": true + }, + "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 + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/local-pkg": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", + "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "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", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "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", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "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, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/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, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "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", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "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, + "license": "MIT" + }, + "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", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/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, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "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": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "optional": true + }, + "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", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/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==", + "license": "ISC", + "optional": true + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/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==", + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "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/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", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "license": "MIT" + }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "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, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "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/string-width/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/string-width/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/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-literal": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", + "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.7.0.tgz", + "integrity": "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/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, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.34.6.tgz", + "integrity": "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "mlly": "^1.4.0", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": ">=v14.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.34.6.tgz", + "integrity": "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^4.3.5", + "@types/chai-subset": "^1.3.3", + "@types/node": "*", + "@vitest/expect": "0.34.6", + "@vitest/runner": "0.34.6", + "@vitest/snapshot": "0.34.6", + "@vitest/spy": "0.34.6", + "@vitest/utils": "0.34.6", + "acorn": "^8.9.0", + "acorn-walk": "^8.2.0", + "cac": "^6.7.14", + "chai": "^4.3.10", + "debug": "^4.3.4", + "local-pkg": "^0.4.3", + "magic-string": "^0.30.1", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.3.3", + "strip-literal": "^1.0.1", + "tinybench": "^2.5.0", + "tinypool": "^0.7.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0", + "vite-node": "0.34.6", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": ">=v14.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@vitest/browser": "*", + "@vitest/ui": "*", + "happy-dom": "*", + "jsdom": "*", + "playwright": "*", + "safaridriver": "*", + "webdriverio": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "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" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "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/wide-align/node_modules/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==", + "license": "MIT", + "optional": true + }, + "node_modules/wide-align/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", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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/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/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/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "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", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/apps/ade-code/package.json b/apps/ade-code/package.json new file mode 100644 index 000000000..d244eb955 --- /dev/null +++ b/apps/ade-code/package.json @@ -0,0 +1,41 @@ +{ + "name": "ade-code", + "version": "0.0.0", + "description": "Terminal-native ADE Work chat client", + "type": "module", + "bin": { + "ade-code": "dist/cli.js" + }, + "files": [ + "dist/**/*", + "README.md" + ], + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "dev": "tsx src/cli.tsx", + "build": "tsup", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@cursor/sdk": "^1.0.9", + "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" + }, + "devDependencies": { + "@types/node": "^22.19.18", + "@types/react": "^18.3.18", + "ink-testing-library": "^4.0.0", + "tsup": "^8.3.5", + "tsx": "^4.20.6", + "typescript": "^5.7.3", + "vitest": "^0.34.6" + } +} diff --git a/apps/ade-code/src/__tests__/adeApi.test.ts b/apps/ade-code/src/__tests__/adeApi.test.ts new file mode 100644 index 000000000..5c261b294 --- /dev/null +++ b/apps/ade-code/src/__tests__/adeApi.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import type { AgentChatEventEnvelope } from "../../../desktop/src/shared/types/chat"; +import { latestTokenStats } from "../adeApi"; + +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, + totalTokens: 2_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: 25, + streaming: false, + inputTokens: 2_100, + outputTokens: 700, + costUsd: 0.42, + }); + }); +}); diff --git a/apps/ade-code/src/__tests__/commands.test.ts b/apps/ade-code/src/__tests__/commands.test.ts new file mode 100644 index 000000000..b6c197f9c --- /dev/null +++ b/apps/ade-code/src/__tests__/commands.test.ts @@ -0,0 +1,42 @@ +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 user-defined 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 local project commands override ADE built-ins on exact name", () => { + const parsed = parseCommand("/status please", [ + { name: "/status", description: "Project status prompt", source: "local" }, + ]); + expect(parsed?.spec).toBeNull(); + expect(parsed?.userCommand?.name).toBe("/status"); + expect(parsed ? commandPlacement(parsed) : null).toBe("chat"); + }); + + 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" })); + }); +}); diff --git a/apps/ade-code/src/__tests__/connection.test.ts b/apps/ade-code/src/__tests__/connection.test.ts new file mode 100644 index 000000000..c81e49858 --- /dev/null +++ b/apps/ade-code/src/__tests__/connection.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { connectToAde } from "../connection"; +import type { ProjectLaunchContext } from "../types"; + +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("../../../ade-cli/src/bootstrap", () => ({ + createAdeRuntime: embedded.createAdeRuntime, +})); + +vi.mock("../../../ade-cli/src/adeRpcServer", () => ({ + createAdeRpcRequestHandler: embedded.createAdeRpcRequestHandler, +})); + +const project: ProjectLaunchContext = { + launchCwd: "/tmp/ade-code", + projectRoot: "/tmp/ade-code", + workspaceRoot: "/tmp/ade-code", + laneHint: null, +}; + +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(); + }); + + 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); + }); +}); diff --git a/apps/ade-code/src/__tests__/format.test.ts b/apps/ade-code/src/__tests__/format.test.ts new file mode 100644 index 000000000..a751521b1 --- /dev/null +++ b/apps/ade-code/src/__tests__/format.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { latestExpandableFailureId, renderChatLines, renderObject } from "../format"; + +describe("renderChatLines", () => { + 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("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("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-code/src/__tests__/heartbeat.test.ts b/apps/ade-code/src/__tests__/heartbeat.test.ts new file mode 100644 index 000000000..44de87b1d --- /dev/null +++ b/apps/ade-code/src/__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-code/src/__tests__/jsonRpcClient.test.ts b/apps/ade-code/src/__tests__/jsonRpcClient.test.ts new file mode 100644 index 000000000..40b313108 --- /dev/null +++ b/apps/ade-code/src/__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-code/src/__tests__/linearCommands.test.ts b/apps/ade-code/src/__tests__/linearCommands.test.ts new file mode 100644 index 000000000..c0fd61398 --- /dev/null +++ b/apps/ade-code/src/__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-code/src/__tests__/pendingInput.test.ts b/apps/ade-code/src/__tests__/pendingInput.test.ts new file mode 100644 index 000000000..d1764ba94 --- /dev/null +++ b/apps/ade-code/src/__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-code/src/__tests__/project.test.ts b/apps/ade-code/src/__tests__/project.test.ts new file mode 100644 index 000000000..93872bdf0 --- /dev/null +++ b/apps/ade-code/src/__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-code/src/adeApi.ts b/apps/ade-code/src/adeApi.ts new file mode 100644 index 000000000..683515f94 --- /dev/null +++ b/apps/ade-code/src/adeApi.ts @@ -0,0 +1,204 @@ +import { getDefaultModelDescriptor, type ModelProviderGroup } from "../../desktop/src/shared/modelRegistry"; +import type { + AgentChatEventEnvelope, + AgentChatFileRef, + AgentChatModelInfo, + AgentChatProvider, + AgentChatSession, + AgentChatSessionSummary, + AgentChatSlashCommand, +} from "../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../desktop/src/shared/types/lanes"; +import type { AdeCodeConnection, ChatHistorySnapshot, CreatedChat, NavigateRequest, NavigateResult } from "./types"; + +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 }); +} + +export async function getAvailableModels( + connection: AdeCodeConnection, + provider: AgentChatProvider, +): Promise { + return await connection.action("chat", "getAvailableModels", { + provider, + activateRuntime: false, + }); +} + +export async function createChatSession(args: { + connection: AdeCodeConnection; + laneId: string; + title?: string | null; + provider?: ModelProviderGroup; + modelId?: string | null; + reasoningEffort?: string | null; +}): Promise { + const provider = args.provider ?? "codex"; + const descriptor = args.modelId + ? null + : getDefaultModelDescriptor(provider); + const modelId = args.modelId ?? descriptor?.id ?? null; + const model = descriptor?.providerModelId ?? descriptor?.shortId ?? (provider === "claude" ? "sonnet" : "gpt-5.5"); + return await args.connection.action("chat", "createSession", { + laneId: args.laneId, + provider, + model, + ...(modelId ? { modelId } : {}), + ...(args.title?.trim() ? { title: args.title.trim() } : {}), + ...(args.reasoningEffort ? { reasoningEffort: args.reasoningEffort } : {}), + surface: "work", + }); +} + +export async function sendChatMessage( + connection: AdeCodeConnection, + sessionId: string, + text: string, + attachments: AgentChatFileRef[] = [], +): Promise { + await connection.action("chat", "sendMessage", { + sessionId, + text, + ...(attachments.length ? { attachments } : {}), + }); +} + +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; +}): Promise { + return await args.connection.action("chat", "updateSession", { + sessionId: args.sessionId, + ...(args.modelId !== undefined ? { modelId: args.modelId } : {}), + ...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}), + }); +} + +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[]): TokenStats { + let percent: number | null = null; + let streaming = false; + let inputTokens: number | null = null; + let outputTokens: number | null = null; + let costUsd: 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; + const used = typeof event.totalTokens === "number" + ? event.totalTokens + : inputTokens != null || outputTokens != null + ? (inputTokens ?? 0) + (outputTokens ?? 0) + : null; + const limit = typeof event.contextWindow === "number" ? event.contextWindow : null; + if (used != null && limit != null && limit > 0) { + percent = Math.max(0, Math.min(100, Math.round((used / limit) * 100))); + } + } + 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; + } + } + return { percent, streaming, inputTokens, outputTokens, costUsd }; +} diff --git a/apps/ade-code/src/app.tsx b/apps/ade-code/src/app.tsx new file mode 100644 index 000000000..b23bd847a --- /dev/null +++ b/apps/ade-code/src/app.tsx @@ -0,0 +1,1542 @@ +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 } from "../../desktop/src/shared/modelRegistry"; +import type { + AgentChatEventEnvelope, + AgentChatFileRef, + AgentChatModelInfo, + AgentChatSessionSummary, + AgentChatSlashCommand, +} from "../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../desktop/src/shared/types/lanes"; +import { + approveToolUse, + createChatSession, + getAvailableModels, + getChatHistory, + getSlashCommands, + interruptChat, + latestTokenStats, + listChatSessions, + listLanes, + navigateDesktop, + newestSession, + renameChat, + respondToInput, + resumeChat, + sendChatMessage, + updateChatModel, +} from "./adeApi"; +import { paletteCommands, parseCommand } from "./commands"; +import { connectToAde } from "./connection"; +import { Drawer } from "./components/Drawer"; +import { ChatView } from "./components/ChatView"; +import { Header } from "./components/Header"; +import { RightPane } from "./components/RightPane"; +import { SlashPalette } from "./components/SlashPalette"; +import { MentionPalette } from "./components/MentionPalette"; +import { ApprovalPrompt } from "./components/ApprovalPrompt"; +import { chooseInitialLane } from "./project"; +import { latestExpandableFailureId, renderObject, summarizeDiffChanges } from "./format"; +import { startTuiHeartbeat, type TuiHeartbeat } from "./heartbeat"; +import { buildLinearToolRequest } from "./linearCommands"; +import { buildPendingInputAnswers, latestPendingApproval } from "./pendingInput"; +import type { + AdeCodeConnection, + AdeCodeModelState, + LocalNotice, + MentionSuggestion, + PendingApproval, + ProjectLaunchContext, + RightPaneContent, + RuntimeMode, +} from "./types"; + +const PURPLE = "#A78BFA"; +const EFFORTS = ["low", "medium", "high", "xhigh"]; +const PROVIDERS = new Set(["codex", "claude", "opencode", "cursor", "droid"]); +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: "medium", + }; +} + +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 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() ?? "", + }; +} + +function parseAdeActionArgs(input: string): Record { + const trimmed = input.trim(); + if (!trimmed) return {}; + const parsed = JSON.parse(trimmed) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("/ade action arguments must be a JSON object."); + } + return parsed as Record; +} + +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 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 [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 [prompt, setPrompt] = useState(""); + const [error, setError] = useState(null); + const [tuiCount, setTuiCount] = useState(1); + const [contextPercent, setContextPercent] = useState(null); + const [tokenSummary, setTokenSummary] = useState(null); + const [streaming, setStreaming] = useState(false); + const [desktopDriving, setDesktopDriving] = useState(false); + const [clearedAt, setClearedAt] = useState(null); + const [expandedLineIds, setExpandedLineIds] = useState>(() => new Set()); + 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 connectionRef = useRef(null); + const activeLaneIdRef = useRef(null); + const activeSessionIdRef = useRef(null); + const lastLocalSendAtRef = useRef(0); + const eventCountRef = useRef(0); + const heartbeatRef = useRef(null); + + 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, 10), [lanes]); + const drawerLaneSessions = useMemo( + () => sessions.filter((session) => session.laneId === drawerLaneId), + [drawerLaneId, sessions], + ); + const selectedLaneIndex = useMemo(() => { + const targetId = selectedDrawerLaneId ?? drawerLaneId ?? activeLaneId; + const index = drawerLaneRows.findIndex((lane) => lane.id === targetId); + return index >= 0 ? index : 0; + }, [activeLaneId, drawerLaneId, drawerLaneRows, selectedDrawerLaneId]); + const selectedChatIndex = useMemo(() => { + const targetId = selectedDrawerChatId + ?? (drawerLaneId === activeLaneId ? activeSessionId : null); + const index = drawerLaneSessions.findIndex((session) => session.sessionId === targetId); + return index >= 0 ? index : 0; + }, [activeLaneId, activeSessionId, drawerLaneId, drawerLaneSessions, selectedDrawerChatId]); + const activeMentionRange = useMemo(() => activeMention(prompt), [prompt]); + const slashRows = useMemo(() => ( + prompt.startsWith("/") ? paletteCommands(prompt, slashCommands) : [] + ), [prompt, slashCommands]); + const pendingApproval = useMemo(() => latestPendingApproval(events), [events]); + const activeFormField = rightPane.kind === "form" + ? rightPane.fields[formFieldIndex] ?? rightPane.fields[0] ?? null + : null; + + useEffect(() => { + activeLaneIdRef.current = activeLaneId; + }, [activeLaneId]); + + useEffect(() => { + activeSessionIdRef.current = activeSessionId; + }, [activeSessionId]); + + useEffect(() => { + if (!drawerLaneId || !lanes.some((lane) => lane.id === drawerLaneId)) { + setDrawerLaneId(activeLaneId); + } + }, [activeLaneId, drawerLaneId, lanes]); + + useEffect(() => { + if (selectedDrawerLaneId && drawerLaneRows.some((lane) => lane.id === selectedDrawerLaneId)) return; + setSelectedDrawerLaneId(drawerLaneId ?? activeLaneId ?? drawerLaneRows[0]?.id ?? null); + }, [activeLaneId, drawerLaneId, drawerLaneRows, selectedDrawerLaneId]); + + useEffect(() => { + if (selectedDrawerChatId && drawerLaneSessions.some((session) => session.sessionId === selectedDrawerChatId)) return; + const activeChatInDrawer = drawerLaneSessions.find((session) => session.sessionId === activeSessionId); + setSelectedDrawerChatId(activeChatInDrawer?.sessionId ?? drawerLaneSessions[0]?.sessionId ?? null); + }, [activeSessionId, drawerLaneSessions, 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 openForm = useCallback((content: Extract) => { + const nextValues = Object.fromEntries(content.fields.map((field) => [field.name, field.initialValue ?? ""])); + setFormValues(nextValues); + setFormFieldIndex(0); + setPrompt(content.fields[0]?.initialValue ?? ""); + setRightPane(content); + setRightOpen(true); + }, []); + + 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 nextSession = 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 stats = latestTokenStats(history.events); + setContextPercent(stats.percent); + setTokenSummary(formatTokenSummary(stats)); + setStreaming(stats.streaming || nextSession?.status === "active"); + const previousCount = eventCountRef.current; + eventCountRef.current = history.events.length; + if (previousCount > 0 && history.events.length > previousCount && Date.now() - lastLocalSendAtRef.current > 4_000) { + setDesktopDriving(true); + setTimeout(() => setDesktopDriving(false), 3_000); + } + } + const nextProvider = nextSession?.provider ?? "codex"; + const nextCommands = await getSlashCommands(conn, nextSessionId).catch(() => []); + const nextModels = await getAvailableModels(conn, nextProvider).catch(() => []); + const activeModel = nextModels.find((model) => model.modelId === nextSession?.modelId || model.id === nextSession?.modelId) + ?? nextModels.find((model) => model.isDefault) + ?? null; + setLanes(nextLanes); + setSessions(nextSessions); + setActiveLaneId(nextLaneId); + setActiveSessionId(nextSessionId); + setEvents(nextEvents); + setSlashCommands(nextCommands); + setModels(nextModels); + setTuiCount(heartbeatRef.current?.readCount() ?? 1); + setModelState({ + provider: PROVIDERS.has(nextProvider) ? nextProvider as AdeCodeModelState["provider"] : "codex", + model: nextSession?.model ?? activeModel?.id ?? modelState.model, + modelId: nextSession?.modelId ?? activeModel?.modelId ?? activeModel?.id ?? modelState.modelId, + displayName: activeModel?.displayName ?? nextSession?.model ?? modelState.displayName, + reasoningEffort: nextSession?.reasoningEffort ?? modelState.reasoningEffort, + }); + }, [clearedAt, modelState.displayName, modelState.model, modelState.modelId, modelState.reasoningEffort, project]); + + 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); + 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; + 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); + if (Date.now() - lastLocalSendAtRef.current > 4_000) { + setDesktopDriving(true); + setTimeout(() => setDesktopDriving(false), 3_000); + } + }); + }, [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]); + + 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 created = await createChatSession({ connection: conn, laneId }); + setActiveSessionId(created.id); + await refreshState(); + return created.id; + }, [refreshState]); + + 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; + setRightOpen(true); + + 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"], + ["runtime", mode], + ["socket", conn.socketPath ?? "embedded"], + ], + }); + return; + } + if (name === "/new chat") { + if (!laneId) { + setRightPane({ kind: "details", title: "New chat", body: "No active lane is available." }); + return; + } + if (!args) { + openForm({ + kind: "form", + title: "New chat", + command: "new-chat", + fields: [ + { name: "title", label: "Title", placeholder: "Untitled chat" }, + { name: "message", label: "First message", placeholder: "Optional" }, + ], + }); + return; + } + const created = await createChatSession({ connection: conn, laneId, title: args }); + setActiveSessionId(created.id); + addNotice(`Created chat "${args}".`, "success"); + await refreshState(); + return; + } + if (name === "/new lane") { + if (!args) { + 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" }, + ], + }); + return; + } + const created = await conn.action("lane", "create", { name: args }); + setActiveLaneId(created.id); + setRightPane({ kind: "details", title: "New lane", body: renderObject(created, 20) }); + await refreshState(); + 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.action("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")) { + 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 (!laneId) { + setRightPane({ kind: "details", title: "PR open", body: "No active lane is selected." }); + 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", { 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) { + setActiveLaneId(lane.id); + setDrawerLaneId(lane.id); + setSelectedDrawerLaneId(lane.id); + const session = newestSession(sessions.filter((entry) => entry.laneId === lane.id)); + setActiveSessionId(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 && sessionId) { + await updateChatModel({ connection: conn, sessionId, modelId: args }); + addNotice(`Model set to ${args}.`, "success"); + await refreshState(); + return; + } + setRightSelectionIndex(Math.max(0, models.findIndex((model) => ( + model.id === modelState.modelId || model.modelId === modelState.modelId + )))); + setRightPane({ kind: "models", models, activeModelId: modelState.modelId }); + return; + } + if (name === "/effort") { + if (args && sessionId) { + 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({ mode, project, socketPath: conn.socketPath, pid: process.pid }, 24), + }); + return; + } + if (name === "/ade") { + const parsed = splitFirstArg(args); + const [domain, action] = parsed.first.split(".", 2); + if (!domain || !action) { + setRightPane({ + kind: "details", + title: "ADE action", + body: "Usage: /ade <domain.action> [json-object-args]", + }); + return; + } + const result = await conn.action(domain, action, parseAdeActionArgs(parsed.rest)); + setRightPane({ kind: "details", title: `ADE ${domain}.${action}`, body: renderObject(result, 24) }); + } + }, [activeLane?.name, activeSession?.sessionId, activeSession?.title, addNotice, ensureActiveSession, lanes, mode, modelState.modelId, modelState.reasoningEffort, models, openForm, project, refreshState, sessions]); + + 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([]); + 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 === "/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 === "/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("Attached to desktop and opened this context.", "success"); + await refreshState(); + return; + } + } else { + addNotice(result.message ?? "Desktop route unavailable from this runtime.", "error"); + } + } + }, [addNotice, exit, project, refreshState, 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-chat") { + if (!laneId) return; + const title = values.title?.trim() || null; + const message = values.message?.trim() ?? ""; + const created = await createChatSession({ connection: conn, laneId, title }); + setActiveSessionId(created.id); + if (message) { + await sendChatMessage(conn, created.id, message); + } + setRightOpen(false); + setRightPane({ kind: "empty" }); + addNotice(title ? `Created chat "${title}".` : "Created chat.", "success"); + await refreshState(); + return; + } + + 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 } : {}), + }); + setActiveLaneId(created.id); + setActiveSessionId(null); + setRightOpen(false); + setRightPane({ kind: "empty" }); + addNotice(`Created lane ${created.name}.`, "success"); + await refreshState(); + 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" }); + 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, refreshState]); + + 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 (desktopDriving && streaming && !text.startsWith("/") && rightPane.kind !== "form") { + addNotice("Desktop is driving this chat; draft kept locally until the stream settles.", "info"); + return; + } + setPrompt(""); + 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) { + 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! })); + await sendChatMessage(conn, sessionId, text, attachments); + await refreshState(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + addNotice(message, "error"); + } + }, [activeFormField, addNotice, answerPendingInput, desktopDriving, ensureActiveSession, formValues, pendingApproval, refreshState, resolvePendingApproval, rightPane, runInlineCommand, runRightCommand, selectedMentions, 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]); + + useInput((input, key) => { + 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 (rightPane.kind === "form" && key.tab) { + const fields = rightPane.fields; + const currentField = fields[formFieldIndex] ?? fields[0]; + const nextValues = currentField ? { ...formValues, [currentField.name]: prompt } : formValues; + const nextIndex = fields.length ? (formFieldIndex + 1) % fields.length : 0; + setFormValues(nextValues); + setFormFieldIndex(nextIndex); + setPrompt(fields[nextIndex] ? nextValues[fields[nextIndex]!.name] ?? "" : ""); + return; + } + if (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 (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 (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; + setActiveLaneId(lane.id); + setDrawerLaneId(lane.id); + setSelectedDrawerLaneId(lane.id); + const session = newestSession(sessions.filter((entry) => entry.laneId === lane.id)); + setActiveSessionId(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; + setActiveLaneId(session.laneId); + setDrawerLaneId(session.laneId); + setSelectedDrawerLaneId(session.laneId); + setActiveSessionId(session.sessionId); + setSelectedDrawerChatId(session.sessionId); + addNotice(`Switched to chat ${session.title ?? session.sessionId}.`, "success"); + return; + } + if (rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort") && key.return) { + const conn = connectionRef.current; + const sessionId = activeSessionIdRef.current; + if (!conn || !sessionId) { + addNotice("Create or select a chat before changing model settings.", "error"); + return; + } + if (rightPane.kind === "models") { + const model = rightPane.models[rightSelectionIndex] ?? rightPane.models[0]; + if (!model) return; + const modelId = model.modelId ?? model.id; + 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; + 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; + } + if (key.upArrow && activeMentionRange && mentionSuggestions.length) { + setMentionIndex((index) => (index <= 0 ? mentionSuggestions.length - 1 : index - 1)); + return; + } + if (key.downArrow && activeMentionRange && mentionSuggestions.length) { + setMentionIndex((index) => (index + 1) % mentionSuggestions.length); + return; + } + if (key.tab && activeMentionRange && mentionSuggestions.length) { + insertMention(mentionSuggestions[mentionIndex] ?? mentionSuggestions[0]!); + return; + } + if (key.upArrow && slashRows.length) { + setSlashIndex((index) => (index <= 0 ? slashRows.length - 1 : index - 1)); + return; + } + if (key.downArrow && slashRows.length) { + setSlashIndex((index) => (index + 1) % slashRows.length); + return; + } + if (key.tab && slashRows.length) { + insertSlashCommand(); + return; + } + if (drawerOpen && key.tab) { + setDrawerSection((section) => section === "lanes" ? "chats" : "lanes"); + return; + } + if (drawerOpen && key.upArrow) { + if (drawerSection === "lanes") { + const nextIndex = Math.max(0, selectedLaneIndex - 1); + setSelectedDrawerLaneId(drawerLaneRows[nextIndex]?.id ?? null); + } else { + const nextIndex = Math.max(0, selectedChatIndex - 1); + setSelectedDrawerChatId(drawerLaneSessions[nextIndex]?.sessionId ?? null); + } + return; + } + if (drawerOpen && key.downArrow) { + if (drawerSection === "lanes") { + const nextIndex = Math.min(Math.max(0, drawerLaneRows.length - 1), selectedLaneIndex + 1); + setSelectedDrawerLaneId(drawerLaneRows[nextIndex]?.id ?? null); + } else { + const nextIndex = Math.min(Math.max(0, drawerLaneSessions.length - 1), selectedChatIndex + 1); + setSelectedDrawerChatId(drawerLaneSessions[nextIndex]?.sessionId ?? null); + } + return; + } + if (drawerOpen && key.return) { + if (drawerSection === "lanes") { + const lane = drawerLaneRows[selectedLaneIndex]; + if (lane) { + setDrawerLaneId(lane.id); + setSelectedDrawerLaneId(lane.id); + setDrawerSection("chats"); + setSelectedDrawerChatId(sessions.find((session) => session.laneId === lane.id)?.sessionId ?? null); + } + } else { + const session = drawerLaneSessions[selectedChatIndex]; + if (session) { + setActiveLaneId(session.laneId); + setDrawerLaneId(session.laneId); + setSelectedDrawerLaneId(session.laneId); + setActiveSessionId(session.sessionId); + setSelectedDrawerChatId(session.sessionId); + } + } + return; + } + if (key.ctrl && input === "b") { + setDrawerOpen((value) => !value); + return; + } + if (key.ctrl && input === "j") { + setRightOpen((value) => !value); + return; + } + if (key.escape) { + if (desktopDriving) setDesktopDriving(false); + else if (rightOpen) setRightOpen(false); + else if (drawerOpen) setDrawerOpen(false); + else 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 (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 (key.return || linePrefix != null) { + const suffix = linePrefix == null ? "" : printableInput(linePrefix); + void submitPrompt(`${prompt}${suffix}`); + return; + } + if (key.backspace || key.delete) { + handlePromptChange(prompt.slice(0, -1)); + return; + } + if (!key.ctrl && input) { + const suffix = printableInput(input); + if (suffix) handlePromptChange(`${prompt}${suffix}`); + } + }); + + const handlePromptChange = useCallback((value: string) => { + if (value === "?") { + setRightPane({ kind: "help", title: "Help" }); + setRightOpen(true); + setPrompt(""); + return; + } + if (rightPane.kind === "form" && activeFormField) { + setFormValues((prev) => ({ ...prev, [activeFormField.name]: value })); + } + setPrompt(value); + }, [activeFormField, rightPane]); + + const centerWidth = Math.max(40, columns - (drawerOpen ? 30 : 0) - (rightOpen ? 40 : 0)); + const laneName = activeLane?.name ?? "main"; + + 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} + model={modelState} + mode={mode} + tuiCount={tuiCount} + /> + {desktopDriving ? ( + <Text color="yellow">Desktop is driving this chat; transcript is syncing here.</Text> + ) : null} + {streaming ? ( + <Text color={PURPLE}>● streaming live{tokenSummary ? ` · ${tokenSummary}` : ""} · ctrl-c interrupts</Text> + ) : null} + {contextPercent != null ? ( + <Text dimColor> + context {contextPercent}% {"█".repeat(Math.max(1, Math.round(contextPercent / 10))).padEnd(10, "░")} + {tokenSummary && !streaming ? ` · ${tokenSummary}` : ""} + </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} + /> + ) : null} + <Box width={centerWidth} flexDirection="column"> + {pendingApproval?.highStakes ? ( + <ApprovalPrompt approval={pendingApproval} modal /> + ) : ( + <> + <ChatView + events={events} + notices={notices} + activeSession={activeSession} + projectName={projectName} + laneName={laneName} + expandedLineIds={expandedLineIds} + /> + <ApprovalPrompt approval={pendingApproval} /> + </> + )} + </Box> + {rightOpen ? ( + <RightPane + content={rightPane} + formValues={formValues} + activeFormField={formFieldIndex} + selectedIndex={rightSelectionIndex} + /> + ) : null} + </Box> + <MentionPalette suggestions={mentionSuggestions} selectedIndex={mentionIndex} /> + <SlashPalette query={prompt} userCommands={slashCommands} selectedIndex={slashIndex} /> + {error ? <Text color="red">{error}</Text> : null} + <Box borderStyle="single" borderColor="gray" paddingX={1}> + <Text color={PURPLE}>› </Text> + <Text>{prompt}</Text> + <Text inverse> </Text> + </Box> + <Text dimColor> + [ {drawerOpen ? "▴" : "▾"} lanes & chats ^b ] [ {rightOpen ? "◂" : "▸"} right pane ^j ] / commands + </Text> + </Box> + ); +} diff --git a/apps/ade-code/src/cli.tsx b/apps/ade-code/src/cli.tsx new file mode 100644 index 000000000..1f096e00f --- /dev/null +++ b/apps/ade-code/src/cli.tsx @@ -0,0 +1,141 @@ +#!/usr/bin/env node +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-b toggle lanes and chats + ctrl-j toggle right 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(); + } +} + +async function main(): Promise<void> { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + if (options.printState) { + await printState(options); + process.exit(0); + } + suppressTerminalWarnings(); + const { AdeCodeApp } = await import("./app"); + const { detectProjectLaunchContext } = await import("./project"); + const project = detectProjectLaunchContext({ + projectRoot: options.projectRoot, + workspaceRoot: options.workspaceRoot, + }); + render( + <AdeCodeApp + project={project} + forceEmbedded={options.forceEmbedded} + requireSocket={options.requireSocket} + socketPath={options.socketPath} + />, + ); +} + +void main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`ade-code: ${message}\n`); + process.exit(1); +}); diff --git a/apps/ade-code/src/commands.ts b/apps/ade-code/src/commands.ts new file mode 100644 index 000000000..3ca4dca38 --- /dev/null +++ b/apps/ade-code/src/commands.ts @@ -0,0 +1,138 @@ +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: "/clear", description: "Clear the local terminal transcript view", placement: "inline" }, + { name: "/end", description: "End the active chat runtime", 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 allowlisted ADE action", placement: "right", argumentHint: "<domain.action> [json]" }, +]; + +export type ParsedCommand = { + name: string; + args: string; + spec: BuiltinCommand | null; + userCommand: AgentChatSlashCommand | null; +}; + +function normalizeSlashName(value: string): string { + return value.trim().replace(/\s+/g, " "); +} + +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 exactLocalCommand = userCommands.find((command) => command.source === "local" && command.name === first) ?? null; + if (exactLocalCommand) { + return { + name: first, + args: trimmed.slice(first.length).trim(), + spec: null, + userCommand: exactLocalCommand, + }; + } + const candidates = [...BUILTIN_COMMANDS] + .sort((left, right) => right.name.length - left.name.length); + 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) => command.name === first) ?? null; + if (userCommand) { + return { + name: first, + 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 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, + })); + return [...builtins, ...users] + .filter((command) => { + if (!normalizedQuery || normalizedQuery === "/") return true; + return `${command.name} ${command.description}`.toLowerCase().includes(normalizedQuery.replace(/^\//, "")); + }) + .slice(0, 9); +} + +export function commandPlacement(command: ParsedCommand): CommandPlacement { + if (command.spec) return command.spec.placement; + if (command.userCommand) return "chat"; + return "chat"; +} diff --git a/apps/ade-code/src/components/ApprovalPrompt.tsx b/apps/ade-code/src/components/ApprovalPrompt.tsx new file mode 100644 index 000000000..73a6a00b3 --- /dev/null +++ b/apps/ade-code/src/components/ApprovalPrompt.tsx @@ -0,0 +1,55 @@ +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; + 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"}> + {approval.mode === "question" + ? "Input requested" + : approval.highStakes + ? "High-stakes approval required" + : "Approval required"} + </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} + {approval.mode === "question" ? ( + <Text dimColor>Type an answer, option number/value, deny, or cancel.</Text> + ) : approval.highStakes ? ( + <Text dimColor>Type approve or deny, then press enter.</Text> + ) : ( + <Text dimColor>Press a to approve, d to deny.</Text> + )} + </Box> + ); + if (!modal) return card; + return ( + <Box flexGrow={1} alignItems="center" justifyContent="center"> + {card} + </Box> + ); +} diff --git a/apps/ade-code/src/components/ChatView.tsx b/apps/ade-code/src/components/ChatView.tsx new file mode 100644 index 000000000..96006bd81 --- /dev/null +++ b/apps/ade-code/src/components/ChatView.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; +import type { LocalNotice } from "../types"; +import { renderChatLines } from "../format"; + +const COLORS = { + user: "#A78BFA", + assistant: "white", + tool: "cyan", + error: "red", + notice: "gray", + reasoning: "gray", + approval: "yellow", +} as const; + +export function BootHero({ + projectName, + laneName, +}: { + projectName: string; + laneName: string; +}) { + return ( + <Box flexDirection="column" alignItems="center" paddingY={1}> + <Text color="#A78BFA">██▄ ██▄ ██▀</Text> + <Text color="#A78BFA">█ █ █ █ █▀ </Text> + <Text color="#A78BFA">██▀ ██▀ ██▄</Text> + <Text dimColor>code · v0.1</Text> + <Text dimColor>{projectName} · {laneName}</Text> + <Text dimColor>type to chat · / for commands</Text> + <Text dimColor>try: inspect the current diff</Text> + <Text dimColor>try: @file then ask for a focused review</Text> + <Text dimColor>try: /status or /new chat</Text> + </Box> + ); +} + +export function ChatView({ + events, + notices, + activeSession, + projectName, + laneName, + expandedLineIds, +}: { + events: AgentChatEventEnvelope[]; + notices: LocalNotice[]; + activeSession: AgentChatSessionSummary | null; + projectName: string; + laneName: string; + expandedLineIds?: Set<string>; +}) { + const lines = renderChatLines({ events, notices, activeSession, expandedLineIds, maxLines: 64 }); + if (!lines.length) { + return <BootHero projectName={projectName} laneName={laneName} />; + } + return ( + <Box flexDirection="column" paddingX={1}> + {lines.map((line) => ( + <Box key={line.id} flexDirection="column" marginBottom={line.header ? 1 : 0}> + {line.header ? <Text color={COLORS[line.tone]}>{line.header}</Text> : null} + <Text color={COLORS[line.tone]}>{line.body}</Text> + </Box> + ))} + </Box> + ); +} diff --git a/apps/ade-code/src/components/Drawer.tsx b/apps/ade-code/src/components/Drawer.tsx new file mode 100644 index 000000000..2d5b7d454 --- /dev/null +++ b/apps/ade-code/src/components/Drawer.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { Box, Text } 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 Drawer({ + lanes, + sessions, + activeLaneId, + activeSessionId, + browsingLaneId, + selectedLaneIndex, + selectedChatIndex, +}: { + lanes: LaneSummary[]; + sessions: AgentChatSessionSummary[]; + activeLaneId: string | null; + activeSessionId: string | null; + browsingLaneId: string | null; + selectedLaneIndex: number; + selectedChatIndex: number; +}) { + const browsingLane = lanes.find((lane) => lane.id === browsingLaneId) ?? null; + const laneSessions = sessions.filter((session) => session.laneId === browsingLaneId).slice(0, 12); + return ( + <Box width={28} flexDirection="column" borderStyle="single" borderColor="gray" paddingX={1}> + <Text bold>LANES</Text> + {lanes.slice(0, 10).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 dimColor>+ new lane</Text> + <Text dimColor>{"─".repeat(24)}</Text> + <Text bold>CHATS · {browsingLane?.name ?? "no lane"}</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 ? "›" : " "} {session.sessionId === activeSessionId ? "●" : " "} {formatSessionLabel(session).slice(0, 20)} + </Text> + ))} + <Text dimColor>+ new chat</Text> + <Text dimColor>enter opens selected · arrows move</Text> + </Box> + ); +} diff --git a/apps/ade-code/src/components/Header.tsx b/apps/ade-code/src/components/Header.tsx new file mode 100644 index 000000000..fab0834b6 --- /dev/null +++ b/apps/ade-code/src/components/Header.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { AdeCodeModelState, RuntimeMode } from "../types"; +import { formatLaneLabel } from "../format"; + +const PURPLE = "#A78BFA"; +const AMBER = "#F59E0B"; + +export function Header({ + projectName, + lane, + model, + mode, + tuiCount, +}: { + projectName: string; + lane: LaneSummary | null; + model: AdeCodeModelState; + mode: RuntimeMode | "connecting"; + tuiCount: number; +}) { + const modeColor = mode === "attached" ? "green" : mode === "embedded" ? "yellow" : "gray"; + return ( + <Box borderStyle="single" borderColor="gray" paddingX={1}> + <Text color={PURPLE}>▌ ADE</Text> + <Text dimColor> ◆ </Text> + <Text>{projectName}</Text> + <Text dimColor> ▌ </Text> + <Text color={AMBER}>{formatLaneLabel(lane)}</Text> + <Text dimColor> ▲ </Text> + <Text color={PURPLE}>{model.displayName}</Text> + <Text dimColor> ● </Text> + <Text color={modeColor}>{mode}</Text> + <Text dimColor>{` · ⏵ ${tuiCount} tui${tuiCount === 1 ? "" : "s"}`}</Text> + </Box> + ); +} diff --git a/apps/ade-code/src/components/MentionPalette.tsx b/apps/ade-code/src/components/MentionPalette.tsx new file mode 100644 index 000000000..5477c91b0 --- /dev/null +++ b/apps/ade-code/src/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-code/src/components/RightPane.tsx b/apps/ade-code/src/components/RightPane.tsx new file mode 100644 index 000000000..002b5347c --- /dev/null +++ b/apps/ade-code/src/components/RightPane.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { RightPaneContent } from "../types"; + +function HelpPane() { + return ( + <Box flexDirection="column"> + <Text bold>Help</Text> + <Text dimColor>ctrl-b toggles lanes and chats</Text> + <Text dimColor>ctrl-j toggles this pane</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> + </Box> + ); +} + +export function RightPane({ + content, + formValues = {}, + activeFormField = 0, + selectedIndex = 0, +}: { + content: RightPaneContent; + formValues?: Record<string, string>; + activeFormField?: number; + selectedIndex?: number; +}) { + return ( + <Box width={38} flexDirection="column" borderStyle="single" borderColor="gray" paddingX={1}> + {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 === "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 === "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>tab moves fields · enter submits · / runs a command</Text> + </Box> + ) : null} + </Box> + ); +} diff --git a/apps/ade-code/src/components/SlashPalette.tsx b/apps/ade-code/src/components/SlashPalette.tsx new file mode 100644 index 000000000..0bcd376f4 --- /dev/null +++ b/apps/ade-code/src/components/SlashPalette.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { AgentChatSlashCommand } from "../../../desktop/src/shared/types/chat"; +import { paletteCommands } from "../commands"; + +export function SlashPalette({ + query, + userCommands, + selectedIndex, +}: { + query: string; + userCommands: AgentChatSlashCommand[]; + selectedIndex: number; +}) { + const rows = paletteCommands(query, userCommands); + if (!query.startsWith("/")) return null; + return ( + <Box flexDirection="column" borderStyle="single" borderColor="gray" paddingX={1}> + {rows.map((row, index) => ( + <Text key={`${row.source}:${row.name}`}> + <Text color={index === selectedIndex ? "#A78BFA" : "gray"}>{index === selectedIndex ? "›" : " "}</Text> + <Text color={row.source === "user" ? "#A78BFA" : "gray"}>{row.source}</Text> + <Text> {row.name.padEnd(16)} </Text> + <Text dimColor>{row.description}</Text> + </Text> + ))} + </Box> + ); +} diff --git a/apps/ade-code/src/connection.ts b/apps/ade-code/src/connection.ts new file mode 100644 index 000000000..e5dcd997b --- /dev/null +++ b/apps/ade-code/src/connection.ts @@ -0,0 +1,212 @@ +import fs from "node:fs"; +import { resolveAdeLayout } from "../../desktop/src/shared/adeLayout"; +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 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; + +async function loadEmbeddedAdeCli(): Promise<{ + createAdeRuntime: (args: { + projectRoot: string; + workspaceRoot: string; + chatRuntime: "agent"; + runtimeProfile: "chat"; + }) => Promise<EmbeddedRuntime>; + createAdeRpcRequestHandler: CreateEmbeddedRpcRequestHandler; +}> { + const [bootstrap, rpc] = await Promise.all([ + import("../../ade-cli/src/bootstrap"), + import("../../ade-cli/src/adeRpcServer"), + ]); + return { + createAdeRuntime: bootstrap.createAdeRuntime as unknown as CreateEmbeddedRuntime, + createAdeRpcRequestHandler: rpc.createAdeRpcRequestHandler as unknown as CreateEmbeddedRpcRequestHandler, + }; +} + +function unwrapActionResult<T>(payload: RpcResponseEnvelope<unknown>, domain: string, action: string): T { + if (payload && typeof payload === "object" && "ok" in payload && payload.ok === false) { + const error = (payload as { error?: { message?: string } }).error; + const message = typeof error?.message === "string" + ? error.message + : `ADE action failed: ${domain}.${action}`; + throw new Error(message); + } + const record = payload as { result?: unknown }; + return record.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 ?? {}, + }); + if (payload && typeof payload === "object" && "ok" in payload && payload.ok === false) { + const error = (payload as { error?: { message?: string } }).error; + const message = typeof error?.message === "string" ? error.message : `ADE tool failed: ${name}`; + throw new Error(message); + } + 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<void> { + await request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "ade-code", + identity: { + role: "cto", + callerId: `ade-code:${process.pid}`, + }, + }); + await request("ade/initialized"); +} + +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); + } +} + +export async function connectToAde(args: { + project: ProjectLaunchContext; + forceEmbedded?: boolean; + requireSocket?: boolean; + socketPath?: string | null; +}): Promise<AdeCodeConnection> { + const layout = resolveAdeLayout(args.project.projectRoot); + const socketPath = args.socketPath?.trim() || process.env.ADE_RPC_SOCKET_PATH?.trim() || layout.socketPath; + + if (args.forceEmbedded && args.requireSocket) { + throw new Error("Cannot use embedded mode when a desktop socket is required."); + } + + if (!args.forceEmbedded && socketPath && (args.requireSocket || fs.existsSync(socketPath))) { + let client: JsonRpcClient | null = null; + try { + client = await JsonRpcClient.connect(socketPath); + const connectedClient = client; + const request: AdeRpcRequest = <T>(method: string, params?: unknown) => connectedClient.request<T>(method, params); + await withTimeout(initialize(request), 3000, "ADE RPC socket did not finish initialization."); + return { + mode: "attached", + projectRoot: args.project.projectRoot, + workspaceRoot: args.project.workspaceRoot, + socketPath, + request, + ...createAdeActionHelpers(request), + onChatEvent: (callback: (event: AgentChatEventEnvelope) => void) => ( + connectedClient.onNotification("chat/event", (params) => callback(params as AgentChatEventEnvelope)) + ), + close: async () => connectedClient.close(), + }; + } catch (error) { + client?.close(); + if (args.requireSocket) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`ADE RPC socket is required but unavailable at ${socketPath}: ${message}`); + } + // Fall through to embedded mode; a stale socket should not strand the TUI. + } + } + + if (args.requireSocket) { + throw new Error(`ADE RPC socket is required but unavailable at ${socketPath}.`); + } + + 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-code/src/format.ts b/apps/ade-code/src/format.ts new file mode 100644 index 000000000..fb3896dbb --- /dev/null +++ b/apps/ade-code/src/format.ts @@ -0,0 +1,234 @@ +import path from "node:path"; +import type { AgentChatEventEnvelope, 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; +}; + +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 multiLine(value: unknown, maxLines = 18): string { + if (typeof value === "string") return value.split(/\r?\n/).slice(0, maxLines).join("\n"); + return renderObject(value, maxLines); +} + +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[] = []; + for (const notice of args.notices) { + lines.push({ + id: notice.id, + tone: notice.tone === "error" ? "error" : "notice", + header: `- ade-code · ${timeLabel(notice.timestamp)} ${"-".repeat(20)}`, + body: notice.text, + }); + } + for (const [index, envelope] of args.events.entries()) { + 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)} ${"-".repeat(32)}`, + body: event.displayText ?? event.text, + }); + continue; + } + if (event.type === "text") { + lines.push({ + id, + tone: "assistant", + header: `- ade · ${timeLabel(envelope.timestamp)} · ${args.activeSession?.model ?? "model"} ${"-".repeat(18)}`, + body: 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} ${"-".repeat(24)}`, + }); + continue; + } + if (event.type === "system_notice") { + lines.push({ + id, + tone: "notice", + body: singleLine((event as { message?: unknown }).message, 160), + }); + } + } + return lines.slice(-(args.maxLines ?? 80)); +} + +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-code/src/heartbeat.ts b/apps/ade-code/src/heartbeat.ts new file mode 100644 index 000000000..1832ecbc6 --- /dev/null +++ b/apps/ade-code/src/heartbeat.ts @@ -0,0 +1,129 @@ +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; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + 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 = () => { + fs.writeFileSync(filePath, JSON.stringify({ + pid: process.pid, + startedAt, + updatedAt: Date.now(), + }), "utf8"); + }; + 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-code/src/jsonRpcClient.ts b/apps/ade-code/src/jsonRpcClient.ts new file mode 100644 index 000000000..461d86aad --- /dev/null +++ b/apps/ade-code/src/jsonRpcClient.ts @@ -0,0 +1,187 @@ +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) => { + const socket = socketPath.startsWith("tcp://") + ? (() => { + const parsed = new URL(socketPath); + return net.createConnection({ + host: parsed.hostname || "127.0.0.1", + port: Number.parseInt(parsed.port, 10), + }); + })() + : 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"); + const boundary = crlfBoundary >= 0 + ? { index: crlfBoundary, length: 4 } + : lfBoundary >= 0 + ? { index: lfBoundary, length: 2 } + : null; + 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-code/src/linearCommands.ts b/apps/ade-code/src/linearCommands.ts new file mode 100644 index 000000000..02e5f9482 --- /dev/null +++ b/apps/ade-code/src/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-code/src/pendingInput.ts b/apps/ade-code/src/pendingInput.ts new file mode 100644 index 000000000..ad1740304 --- /dev/null +++ b/apps/ade-code/src/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-code/src/project.ts b/apps/ade-code/src/project.ts new file mode 100644 index 000000000..68d28d20d --- /dev/null +++ b/apps/ade-code/src/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-code/src/types.ts b/apps/ade-code/src/types.ts new file mode 100644 index 000000000..03dc95cc1 --- /dev/null +++ b/apps/ade-code/src/types.ts @@ -0,0 +1,130 @@ +import type { AppNavigationRequest, AppNavigationResult } from "../../desktop/src/shared/types/core"; +import type { + AgentChatEventEnvelope, + AgentChatModelInfo, + 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; + 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 AdeCodeModelState = { + provider: "codex" | "claude" | "opencode" | "cursor" | "droid"; + model: string; + modelId: string | null; + displayName: string; + reasoningEffort: string | null; +}; + +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: "form"; + title: string; + command: "new-chat" | "new-lane" | "rename" | "pr-open"; + fields: Array<{ + name: string; + label: string; + required?: boolean; + placeholder?: string; + initialValue?: string; + }>; + }; + +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; + tuiCount: number; + contextPercent: number | null; + desktopDriving: boolean; + streaming: boolean; +}; + +export type CreatedChat = AgentChatSession; +export type NavigateRequest = AppNavigationRequest; +export type NavigateResult = AppNavigationResult; diff --git a/apps/ade-code/tsconfig.json b/apps/ade-code/tsconfig.json new file mode 100644 index 000000000..4fdc544f0 --- /dev/null +++ b/apps/ade-code/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/apps/ade-code/tsup.config.ts b/apps/ade-code/tsup.config.ts new file mode 100644 index 000000000..fdd8bb591 --- /dev/null +++ b/apps/ade-code/tsup.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + cli: "src/cli.tsx", + }, + format: ["esm"], + platform: "node", + target: "node22", + outDir: "dist", + sourcemap: true, + clean: true, + splitting: false, + banner: { + js: "import { createRequire as __adeCreateRequire } from 'node:module'; const require = __adeCreateRequire(import.meta.url);", + }, + external: ["node-pty", "sql.js", "node:sqlite", "@cursor/sdk", "sqlite3"], + esbuildOptions(options) { + options.alias = { + ...(options.alias ?? {}), + sqlite: "node:sqlite", + }; + }, +}); diff --git a/apps/ade-code/vitest.config.ts b/apps/ade-code/vitest.config.ts new file mode 100644 index 000000000..840e944d9 --- /dev/null +++ b/apps/ade-code/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + }, +}); diff --git a/apps/desktop/scripts/dev.cjs b/apps/desktop/scripts/dev.cjs index 8161f91a8..c56e07a67 100644 --- a/apps/desktop/scripts/dev.cjs +++ b/apps/desktop/scripts/dev.cjs @@ -193,7 +193,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/src/main/main.ts b/apps/desktop/src/main/main.ts index d402d70a4..7f2afede4 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"; @@ -394,15 +395,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 +437,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 +695,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; @@ -922,14 +945,25 @@ app.whenReady().then(async () => { } 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 devFallbackProject = process.env.VITE_DEV_SERVER_URL ? path.resolve(process.cwd(), "..", "..") : fallbackProjectRoot; - const startupUserSelected = Boolean(envRoot && envRoot.trim().length); + const startupUserSelected = Boolean((envRoot && envRoot.trim().length) || pendingStartupProjectRoot); const initialCandidate = envRoot && envRoot.trim().length ? normalizeProjectPath(envRoot) + : pendingStartupProjectRoot + ? pendingStartupProjectRoot : devFallbackProject; const broadcast = (channel: string, payload: unknown) => { @@ -958,6 +992,8 @@ 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 ipcWindowScope = new AsyncLocalStorage<number | null>(); const rpcSocketCleanupByRoot = new Map<string, () => void>(); const projectLastActivatedAt = new Map<string, number>(); const mobileSyncHandoffLeases = new Map<string, number>(); @@ -969,9 +1005,35 @@ app.whenReady().then(async () => { 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 emitProjectChanged = (project: ProjectInfo | null): void => { - broadcast(IPC.appProjectChanged, project); + const projectForRoot = (projectRoot: string | null): ProjectInfo | null => { + if (!projectRoot) return null; + return projectContexts.get(projectRoot)?.project ?? 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 firstAvailableRecentProjectRoot = (): string | null => { @@ -1024,7 +1086,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 +1108,41 @@ 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); + } + 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 (options.emit !== false) { + emitProjectChangedToWindow(windowId, projectForRoot(normalizedRoot)); + } + }; + 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 +1156,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 ( @@ -1183,12 +1285,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 +1313,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, @@ -3644,6 +3753,33 @@ 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 desktop 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 @@ -3705,8 +3841,15 @@ app.whenReady().then(async () => { }, }); 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(); }; conn.once("close", removeConnection); conn.once("end", removeConnection); @@ -4275,7 +4418,7 @@ app.whenReady().then(async () => { for (const root of roots) { await closeProjectContext(root); } - setActiveProject(null); + setForegroundProject(null); }; async function mobileProjectSummaryForContext( @@ -4597,12 +4740,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, @@ -4651,12 +4793,11 @@ app.whenReady().then(async () => { const ctx = await initPromise; ctx.hasUserSelectedProject = true; - setActiveProject(repoRoot); persistRecentProject(ctx.project, { recordLastProject: true, recordRecent: false, }); - emitProjectChanged(ctx.project); + bindWindowToProject(currentIpcWindowId(), repoRoot, { emit: true, foreground: true }); scheduleProjectContextRebalance(); projectOpenLogger.info("project.open.done", { selectedPath, @@ -4680,22 +4821,34 @@ 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); + emitProjectChangedToWindow(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 +4996,7 @@ app.whenReady().then(async () => { } catch { // ignore } - setActiveProject(null); + setForegroundProject(null); dormantContext = createDormantProjectContext(previousRoot); try { @@ -4861,38 +5014,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 any running agents and stop background processes started by ADE, including OpenCode servers, terminal sessions, and test runs.", + 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 +5177,169 @@ 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); + 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); + if (activeProjectRoot === previousRoot) { + setForegroundProject(firstOpenWindowProjectRoot()); + } + scheduleProjectContextRebalance(); + }); + }; + + const getWindowSession = (windowId: number | null): { windowId: number | null; project: ProjectInfo | null } => { + if (windowId == null) { + return { windowId: null, project: projectForRoot(activeProjectRoot) }; + } + return { + windowId, + project: projectForRoot(windowProjectRoots.get(windowId) ?? null), + }; + }; + + 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); + } + 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 +5352,11 @@ app.whenReady().then(async () => { return getMobileSyncService(); }, resolveSyncService: ensureMobileSyncService, + runWithIpcWindow: (event, fn) => + ipcWindowScope.run(BrowserWindow.fromWebContents(event.sender)?.id ?? null, fn), + getWindowSession, + createWindow: openAdeWindow, + closeWindow: closeAdeWindow, switchProjectFromDialog, closeCurrentProject, closeProjectByPath, @@ -5003,24 +5371,22 @@ app.whenReady().then(async () => { try { await switchProjectFromDialog(initialCandidate); } catch { - setActiveProject(null); + setForegroundProject(null); dormantContext = createDormantProjectContext(); } } + const initialWindowProjectRoot = startupUserSelected ? activeProjectRoot : null; const initialWindow = await createWindow({ logger: getActiveContext().logger, + onCreated: (createdWindow) => registerWindowSession(createdWindow, initialWindowProjectRoot), onCloseRequested: handleMainWindowCloseRequested, }); builtInBrowserService.attachToWindow(initialWindow); 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.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 8673bbea8..dd0a8656c 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -210,13 +210,18 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri chat: [ "createSession", "deleteSession", + "dispose", "getAvailableModels", + "getChatEventHistory", "getSessionSummary", "getSlashCommands", "interrupt", "listSessions", + "approveToolUse", + "respondToInput", "resumeSession", "sendMessage", + "updateSession", ], keybindings: ["get", "set"], onboarding: [ @@ -318,7 +323,15 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri "setToken", ], linear_dispatcher: ["dispatchIssue", "getDashboard", "listEmployees", "listQueue"], - linear_issue_tracker: ["getStatus", "listIssues"], + linear_issue_tracker: [ + "createComment", + "fetchIssueById", + "fetchIssuesByIds", + "getStatus", + "listIssues", + "listUsers", + "updateIssueAssignee", + ], linear_sync: ["getDashboard", "getRunDetail", "listQueue", "resolveQueueItem", "runSyncNow"], linear_ingress: ["ensureRelayWebhook", "getStatus", "listRecentEvents"], linear_routing: ["simulateRoute"], 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/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index c6069b09d..75c5f5e0b 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1904,6 +1904,10 @@ export function registerIpc({ getCtx, getSyncService, resolveSyncService, + runWithIpcWindow, + getWindowSession, + createWindow, + closeWindow, switchProjectFromDialog, closeCurrentProject, closeProjectByPath, @@ -1913,6 +1917,10 @@ 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 }; + 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>; @@ -2101,8 +2109,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; @@ -2188,7 +2209,6 @@ export function registerIpc({ } }; - const tracedIpcMain = ipcMain as TracedIpcMain; if (traceIpcInvokes && !tracedIpcMain.__adeTraceWrapped) { const originalHandle = tracedIpcMain.handle.bind(ipcMain); tracedIpcMain.__adeOriginalHandle = originalHandle; @@ -3206,6 +3226,37 @@ 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, + }; + }); + + 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; diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 479571031..02682b9a2 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, @@ -716,9 +717,21 @@ declare global { ping: () => Promise<"pong">; getInfo: () => Promise<AppInfo>; getProject: () => Promise<ProjectInfo | null>; + getWindowSession: () => Promise<{ + windowId: number | null; + project: ProjectInfo | 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; + onNavigate: ( + cb: (request: AppNavigationRequest) => void, + ) => () => void; openExternal: (url: string) => Promise<void>; revealPath: (path: string) => Promise<void>; openPath: (path: string) => Promise<void>; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 2c029ecc3..ca3b2344e 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, @@ -1060,6 +1061,16 @@ 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 }> => + ipcRenderer.invoke(IPC.appGetWindowSession), + 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, @@ -1071,6 +1082,14 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.on(IPC.appProjectChanged, listener); return () => ipcRenderer.removeListener(IPC.appProjectChanged, 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> => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index c59751dfb..f2f991ac2 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -2643,7 +2643,12 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { env: {}, }), getProject: resolved(MOCK_PROJECT), + getWindowSession: resolved({ windowId: 1, project: MOCK_PROJECT }), + newWindow: resolved({ windowId: 2 }), + openProjectInNewWindow: resolvedArg({ windowId: 2, project: MOCK_PROJECT }), + closeWindow: resolvedArg({ closed: false }), onProjectChanged: () => () => {}, + onNavigate: () => () => {}, openExternal: resolvedArg(undefined), revealPath: resolvedArg(undefined), writeClipboardText: resolvedArg(undefined), 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/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 2cf12252f..6375e317f 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"; @@ -101,12 +101,42 @@ function resetStore() { } 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 () => [ { @@ -161,17 +191,67 @@ describe("TopBar", () => { 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("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 />); @@ -276,7 +356,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 />); @@ -284,11 +364,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 />); @@ -297,7 +378,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 0bcc41378..b6925b173 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -1,5 +1,5 @@ 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 { ArrowSquareOut, ChatCircleDots, CircleNotch, 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"; @@ -22,6 +22,8 @@ import { PublishToGitHubDialog } from "../projects/PublishToGitHubDialog"; import { SyncDevicesSection } from "../settings/SyncDevicesSection"; 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 @@ -171,12 +173,16 @@ function projectIconErrorMessage(error: unknown): string { return cleaned || "Failed to update project icon."; } +function fallbackProjectName(rootPath: string): string { + return rootPath.split(/[\\/]/).filter(Boolean).pop() ?? rootPath; +} + function confirmProjectTabRemoval(projectName: string, isCurrent: boolean, isMissing: boolean): 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.`); + ? `Close "${label}" project tab?` + : `Close "${label}" project tab?`; + return window.confirm(`${action}\n\nThis does not remove it from Recent Projects or delete any files on disk.`); } function deriveSyncLabel(snapshot: SyncRoleSnapshot | null): string | null { @@ -467,8 +473,10 @@ export function TopBar() { const [phoneSyncOpen, setPhoneSyncOpen] = useState(false); const [feedbackOpen, setFeedbackOpen] = useState(false); const [publishOpen, setPublishOpen] = useState(false); + const [openProjectTabRoots, setOpenProjectTabRoots] = useState<string[]>([]); 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 dragCounterRef = useRef(0); const isProjectBusy = projectTransition != null || relocatingPath != null; @@ -517,6 +525,51 @@ export function TopBar() { fetchRecent(); }, [project?.rootPath, fetchRecent]); + useEffect(() => { + const rootPath = project?.rootPath ?? null; + if (!rootPath) { + setOpenProjectTabRoots([]); + return; + } + setOpenProjectTabRoots((prev) => + prev.includes(rootPath) ? prev : [...prev, rootPath] + ); + }, [project?.rootPath]); + + 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; + const getWindowSession = (window as unknown as { + ade?: { app?: { getWindowSession?: typeof window.ade.app.getWindowSession } }; + }).ade?.app?.getWindowSession; + if (typeof getWindowSession !== "function") return undefined; + getWindowSession() + .then((session) => { + if (!cancelled) setWindowId(session.windowId); + }) + .catch(() => { + if (!cancelled) setWindowId(null); + }); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { if (!phoneSyncOpen) return; const frame = window.requestAnimationFrame(() => { @@ -634,6 +687,11 @@ export function TopBar() { openNewTab(); }, [isProjectBusy, openNewTab]); + const handleOpenNewWindow = useCallback(() => { + if (isProjectBusy) return; + window.ade.app.newWindow().catch(() => {}); + }, [isProjectBusy]); + const handleSwitchProject = useCallback((rootPath: string) => { if (isProjectBusy) return; if (project?.rootPath === rootPath) { @@ -645,8 +703,8 @@ export function TopBar() { const handleRemoveTab = useCallback((rootPath: string) => { void (async () => { - const target = recentProjects.find((entry) => entry.rootPath === rootPath); - const fallbackName = rootPath.split(/[\\/]/).filter(Boolean).pop() ?? rootPath; + const target = projectTabs.find((entry) => entry.rootPath === rootPath); + const fallbackName = fallbackProjectName(rootPath); const confirmed = confirmProjectTabRemoval( target?.displayName ?? fallbackName, project?.rootPath === rootPath, @@ -657,21 +715,22 @@ export function TopBar() { const shouldClose = await checkForActiveWorkloads(rootPath); if (!shouldClose) return; - const rows = await window.ade.project.forgetRecent(rootPath).catch(() => null); - if (!rows) return; - - setRecentProjects(rows); - // If we just removed the active project, switch to the next available or show welcome. + const currentIndex = openProjectTabRoots.indexOf(rootPath); + const nextTabRoots = openProjectTabRoots.filter((entry) => entry !== rootPath); + setOpenProjectTabRoots(nextTabRoots); if (project?.rootPath === rootPath) { - const next = rows.find((r) => r.exists && r.rootPath !== rootPath); - if (next) { - switchProjectToPath(next.rootPath).catch(() => { }); + const nextRoot = + nextTabRoots[currentIndex] + ?? nextTabRoots[currentIndex - 1] + ?? null; + if (nextRoot) { + switchProjectToPath(nextRoot).catch(() => { }); } else { closeProject().catch(() => { }); } } })().catch(() => { }); - }, [checkForActiveWorkloads, project?.rootPath, recentProjects, closeProject, switchProjectToPath]); + }, [checkForActiveWorkloads, closeProject, openProjectTabRoots, project?.rootPath, projectTabs, switchProjectToPath]); const handleRelocate = useCallback((oldPath: string) => { setRelocatingPath(oldPath); @@ -683,12 +742,16 @@ export function TopBar() { })().catch(() => { }).finally(() => setRelocatingPath(null)); }, [openRepo]); - const handleDragStart = useCallback((e: React.DragEvent, idx: number) => { + 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)); + } + }, [windowId]); const handleDragOver = useCallback((e: React.DragEvent, idx: number) => { e.preventDefault(); @@ -701,23 +764,65 @@ export function TopBar() { }, []); 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 = [...recentProjects]; + const items = [...openProjectTabRoots]; const [moved] = items.splice(dragIdx, 1); items.splice(targetIdx, 0, moved); - setRecentProjects(items); + 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); - window.ade.project.reorderRecent(items.map((r) => r.rootPath)).catch(() => {}); - }, [dragIdx, recentProjects]); - const handleDragEnd = useCallback(() => { + 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; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }, []); + + 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) { + window.ade.app.openProjectInNewWindow(rootPath).catch(() => {}); + } }, []); const handleProjectAccentColorChange = useCallback((rootPath: string, color: string | null) => { @@ -761,8 +866,9 @@ export function TopBar() { const syncLabel = deriveSyncLabel(syncSnapshot); const transitionTargetName = projectTransition?.rootPath - ? (recentProjects.find((entry) => entry.rootPath === projectTransition.rootPath)?.displayName - ?? projectTransition.rootPath.split(/[\\/]/).filter(Boolean).pop() + ? (projectTabs.find((entry) => entry.rootPath === projectTransition.rootPath)?.displayName + ?? recentProjects.find((entry) => entry.rootPath === projectTransition.rootPath)?.displayName + ?? fallbackProjectName(projectTransition.rootPath) ?? "project") : "project"; const projectTransitionLabel = @@ -794,23 +900,12 @@ 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> - ) : ( + {projectTabs.length > 0 || isNewTabOpen ? ( <> - {recentProjects.map((rp, idx) => { + {projectTabs.map((rp, idx) => { const isCurrent = project?.rootPath === rp.rootPath; const isMissing = !rp.exists; const isRelocating = relocatingPath === rp.rootPath; @@ -840,11 +935,11 @@ export function TopBar() { aria-current={isCurrent ? "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", @@ -993,7 +1088,7 @@ export function TopBar() { </div> )} </> - )} + ) : null} {/* Add project button */} <button @@ -1011,6 +1106,20 @@ export function TopBar() { > <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 ? ( 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/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..10575b009 100644 --- a/apps/desktop/src/renderer/components/run/RunPage.tsx +++ b/apps/desktop/src/renderer/components/run/RunPage.tsx @@ -21,6 +21,7 @@ import type { ProcessRuntime, ProjectConfigSnapshot, ConfigProcessGroupDefinition, + ProjectIcon, } from "../../../shared/types"; function generateId(): string { @@ -236,6 +237,44 @@ function runPageLaneStateEqual(left: PersistedRunPageLaneState, right: Persisted 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); @@ -346,7 +385,7 @@ function WelcomeScreen() { 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> diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index def3a3326..85af6de24 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -2,6 +2,11 @@ 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", appOpenExternal: "ade.app.openExternal", appRevealPath: "ade.app.revealPath", diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts index 160a2acc4..a728ad607 100644 --- a/apps/desktop/src/shared/types/core.ts +++ b/apps/desktop/src/shared/types/core.ts @@ -45,6 +45,41 @@ export type ProjectInfo = { baseRef: 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; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e33f59770..5d9760664 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,7 +8,7 @@ Consolidated technical reference for the ADE (Agentic Development Environment) s 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 ships as four coordinated apps: +ADE ships as five coordinated apps: ``` ┌─────────────────────────┐ @@ -31,10 +31,15 @@ ADE ships as four coordinated apps: │ │─── spawns ─────────────────────┐ │ │ │ │ ▼ │ │ │ │ ┌──────────────────────┐ │ -│ │ │ apps/ade-cli │ │ -│ │ │ (JSON-RPC over stdio │◀──── headless mode ──────┤ +│ │ │ apps/ade-cli │◀──── headless mode ──────┤ +│ │ │ (JSON-RPC over stdio │ │ │ │ │ or .ade/ade.sock) │ │ │ │ └──────────────────────┘ │ +│ │ ┌──────────────────────┐ │ +│ │ │ apps/ade-code │◀──── terminal Work chat ─┤ +│ │ │ (Ink TUI, same RPC │ │ +│ │ │ socket or embedded) │ │ +│ │ └──────────────────────┘ │ │ │ │ │ └── spawns CLI runtimes: │ │ claude (Claude Agent SDK) · codex CLI · opencode server │ @@ -134,6 +139,7 @@ bridge. `app_control` (every public method on `AppControlService`) and `terminal` (`list`, `read`, `write`, `signal`, `activeForChat` against `ptyService`). +- **`ade code`** — launches the terminal-native Work chat client (`apps/ade-code`, Ink + React). Uses the desktop JSON-RPC socket when `--socket` is set on the parent `ade` invocation (same path as other socket-backed commands); otherwise the TUI runs embedded against the headless runtime (`--embedded`) without implying the global auto-socket discovery used by `executePlan`. Override the binary with `ADE_CODE_EXECUTABLE` or a sibling `apps/ade-code/dist/cli.js` after `npm run build` in that package. - **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`. @@ -145,11 +151,15 @@ bridge. `--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 (`apps/ade-code/`) + +Terminal-native **Work** chat client (Ink + React) for agents and power users who live in a shell. It speaks the same ADE JSON-RPC surface as the desktop app and `ade-cli`: **attached** mode connects to `.ade/ade.sock` (or the Windows named pipe from `adeMcpIpc`) when a socket is present; **embedded** mode loads `createAdeRuntime` / `createAdeRpcRequestHandler` from `apps/ade-cli` at runtime so headless services run in-process without Electron. Shared chat DTOs are imported from `apps/desktop/src/shared/types/*` (never the renderer barrel) so `npm run typecheck` in this package stays isolated. Entry: `src/cli.tsx` → `dist/cli.js` (`ade-code` bin). Launched from the desktop shell via `ade code` (see §2.2). Multi-window navigation from the TUI uses the `app/navigate` JSON-RPC method when a desktop socket is attached. + +### 2.4 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). -### 2.4 iOS companion (`apps/ios/`) +### 2.5 iOS companion (`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. @@ -407,6 +417,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) @@ -906,7 +917,8 @@ 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) +│ ├── ade-cli/ # Headless ADE CLI (Node, JSON-RPC over stdio) +│ ├── ade-code/ # Terminal Ink TUI for Work chat (socket or embedded headless) │ ├── web/ # Marketing + download landing (Vite + React) │ └── ios/ # Native SwiftUI controller ├── docs/ @@ -914,7 +926,6 @@ ADE/ │ ├── 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,6 +950,7 @@ Per-app 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-code` | `dev`, `build`, `typecheck`, `test` (Ink TUI; uses granular imports from `apps/desktop/src/shared/types/*`). | | `apps/web` | `dev`, `build`, `preview`, `typecheck`. | | `apps/ios` | Xcode project; tests via `xcodebuild test` / Xcode. | @@ -946,16 +958,18 @@ 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, web, and ade-code with a shared cache keyed on all four lockfiles. 2. **Parallel checks**: - `secret-scan` — gitleaks on full history. - `typecheck-desktop` — `cd apps/desktop && npm run typecheck`. - `typecheck-ade-cli` — `cd apps/ade-cli && npm run typecheck`. - `typecheck-web` — `cd apps/web && npm run typecheck`. + - `typecheck-ade-code` — `cd apps/ade-code && npm run typecheck`. - `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. + - `test-ade-code` — ade-code vitest. + - `build` — all four apps 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). @@ -1061,5 +1075,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..e67877c75 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -50,7 +50,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. @@ -82,7 +82,7 @@ For the system-wide picture — apps, processes, data plane, IPC, security, buil Quick pointers: -- **Apps**: `apps/desktop/` (Electron main + preload + renderer), `apps/ade-cli/` (headless ADE CLI action server), `apps/web/` (marketing), `apps/ios/` (companion). +- **Apps**: `apps/desktop/` (Electron main + preload + renderer), `apps/ade-cli/` (headless ADE CLI action server), `apps/ade-code/` (terminal Ink client for Work chat), `apps/web/` (marketing), `apps/ios/` (companion). - **Main-process services**: `apps/desktop/src/main/services/<domain>/` — one directory per capability. - **Renderer components**: `apps/desktop/src/renderer/components/<feature>/`. - **Shared types + IPC contract**: `apps/desktop/src/shared/`. diff --git a/docs/README.md b/docs/README.md index 5831ffc16..62c24173a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,7 @@ Navigation map for the internal docs. **Start with [PRD.md](./PRD.md).** ## Layout ``` -new-docs/ +docs/ ├── README.md # this file ├── PRD.md # product entry point ├── ARCHITECTURE.md # system architecture @@ -21,6 +21,7 @@ new-docs/ │ └── ship-lane.md # autonomous PR-to-merge driver └── features/ ├── agents/ # agent identity, tools, personas + ├── ade-code/ # terminal Ink Work chat client (ade-code) ├── automations/ # rule triggers + actions + guardrails ├── chat/ # multi-provider agent chat ├── computer-use/ # proof control plane, backends, broker @@ -53,4 +54,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..1d3a77bac --- /dev/null +++ b/docs/features/ade-code/README.md @@ -0,0 +1,36 @@ +# 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, and lane context flow through the same ADE action and JSON-RPC contracts as the desktop. + +## Source file map + +| Path | Role | +|------|------| +| `apps/ade-code/src/cli.tsx` | CLI entry: argv parsing, project discovery, connection bootstrap, Ink mount. | +| `apps/ade-code/src/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle. | +| `apps/ade-code/src/connection.ts` | **Attached** path: JSON-RPC over `.ade/ade.sock` (or Windows named pipe). **Embedded** path: dynamic `import()` of `apps/ade-cli` `bootstrap` + `adeRpcServer` so headless services run in-process without pulling the whole dependency graph into `tsc` for this package. | +| `apps/ade-code/src/jsonRpcClient.ts` | Socket client: connect, request/response, `chat/event` notifications. | +| `apps/ade-code/src/adeApi.ts` | Typed wrappers over `AdeCodeConnection.action` / `actionList` for lanes, chat, models, navigation. | +| `apps/ade-code/src/commands.ts` / `linearCommands.ts` | Slash and command routing. | +| `apps/ade-code/src/format.ts` | Transcript rendering helpers for the TUI. | +| `apps/ade-code/src/types.ts` | Connection shape, launch context, navigation DTOs aligned with `apps/desktop/src/shared/types`. | +| `apps/desktop/src/shared/types/chat.ts` | Canonical chat DTOs (`AgentChatEventEnvelope`, sessions, pending input). Imported from **per-module** paths (not `types/index.ts`) so ade-code typecheck stays scoped. | +| `apps/desktop/src/shared/modelRegistry.ts` | Default model selection for new sessions (`getDefaultModelDescriptor`). | +| `apps/desktop/src/shared/adeLayout.ts` | Resolves `.ade` paths including socket location. | +| `apps/ade-cli/src/cli.ts` | `ade code` launcher: resolves `ade-code` binary (`ADE_CODE_EXECUTABLE`, sibling `dist/cli.js`, or `PATH`). | +| `apps/desktop/src/main/main.ts` | Multi-window shell: project windows, shared menu, JSON-RPC `app/navigate` for external controllers. | +| `apps/desktop/src/renderer/components/app/TopBar.tsx` | Window tab strip + project navigation when multiple windows are open. | + +## Runtime modes + +- **Attached** — `JsonRpcClient` connects to the desktop RPC socket. Initialization follows the same `ade/initialize` handshake as other socket clients. +- **Embedded** — no socket: `createAdeRuntime` + `createAdeRpcRequestHandler` from `apps/ade-cli` serve actions in-process. Used for headless/dev environments where Electron is not running. + +## Launch + +From a machine with the `ade` CLI on `PATH`: `ade code` (see `apps/ade-cli/README.md` for flags, `ADE_CODE_EXECUTABLE`, and how `--socket` on the parent `ade` process is forwarded). After local changes, run `npm run build` inside `apps/ade-code` so `dist/cli.js` exists for sibling resolution. + +## Related docs + +- [Chat feature](../chat/README.md) — in-app Work chat architecture (service + renderer). +- [ARCHITECTURE.md](../../ARCHITECTURE.md) §2.2–2.3 — CLI and ade-code placement in the system diagram. diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 247eb4a68..aaf39d90a 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -12,6 +12,7 @@ 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-code/` | 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. | From 05505a3436ff86dffdfeeff4cb44a93182906d17 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 9 May 2026 23:26:42 -0500 Subject: [PATCH 02/11] Remote runtime architecture spec (#275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Virtualized file tree, shared Ade diff viewer, and finalize (CLI + docs) (#272) * feat: virtualized file tree explorer + shared Ade diff viewer Splits FilesPage into a virtualized FilesExplorer with local path filter, inline F2 rename, and per-row git status badges, and replaces direct MonacoDiffView usage in LaneDiffPane / PrDetailPane / ChatFileChangesPanel with a shared AdeDiffViewer that supports split/unified/wrap toggles and read-only patch rendering. Diff service gains getChanges (numstat + renames) and getFilePatch with bounded output and worktree-escape checks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(finalize): allow diff.getFilePatch, CLI patch command, and docs - Extend ADE_ACTION_ALLOWLIST.diff with getFilePatch for ade actions/CLI. - Add ade diff patch wired to diff.getFilePatch; update README and tests. - Refresh internal docs for files editor, lanes diff, chat composer, architecture IPC. Co-authored-by: Arul Sharma <arul28@users.noreply.github.com> * Fix PR review feedback for diff viewer --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Arul Sharma <arul28@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> * path to merge audit (#260) * Route path-to-merge through ADE actions * Keep polling after GitHub auto-merge is armed * Remove generated dev log from PtM lane * Align PtM pipeline defaults in tests and iOS bootstrap * Respect max rounds when starting PtM from PR panel * Backfill legacy PtM pipeline defaults once * Persist PtM review bot wait state * Address PtM review automation gaps * Fix PtM desktop typecheck import * Keep admin merge behind explicit force policy * Ignore Capy spend-limit notices in PR inventory * Retire noisy PR issue comments during inventory sync * Throttle GitHub PR hot refresh polling * Limit GitHub PR hot refresh to one follow-up * Avoid GitHub snapshot refreshes for PR status ticks * Avoid duplicate review bot pings * Refresh PR detail checks on status updates * Keep action run polling results live * Fix Path to Merge readiness refresh * Fix PtM readiness test label * Make PtM readiness test less brittle * Refresh PtM external checks while polling * Reuse Vite optimizer cache in dev * Keep review gates on at-cap PtM merges * Add Linear issue dropdown to lane creation (#274) * Add Linear issue lane workflows * Fix lane git mocks for branch validation; sync iOS bootstrap SQL - Add defaultLaneBranchGitStub for check-ref-format and show-ref ade/* probes from resolveCreateBranchRef so laneService tests stub git consistently. - Drop overly broad show-ref and ls-remote stubs that broke getDeleteRisk and remote-branch checks. - Regenerate DatabaseBootstrap.sql from kvDb migrate SQL for lane_linear_issues table. Co-authored-by: Arul Sharma <arul28@users.noreply.github.com> * Add Linear issue dropdown to lane creation Surface a searchable Linear issue picker in the new-lane dialog so users can attach a Linear issue at lane creation time instead of pasting an identifier. Adds the supporting Linear browser, CLI plumbing, and doc updates for the workflow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ship: iteration 1 — rebase + address Greptile/CodeRabbit/Cursor review - N+1 fix: batch lane_linear_issues lookup in listLanes - GraphQL: pass IssueFilter via variables, not string interpolation - Branch sanitizer: strip @{, .., trailing .lock - Magic words: skip duplicate ID prefix on commit messages - RPC schema: nullable url/assignee* fields; validate first cap; reject non-object linearIssue payload; CLI mirrors the validation - Empty-text steer allowed when context attachments present - IPC picker/search return empty when tracker unavailable (no throw) - Lane teardown deletes lane_linear_issues; full payload validated - Adopted PR bodies now patched with Linear references too - kvDb: unique index on (project_id, lane_id) for lane_linear_issues - AgentChatPane resets context attachments on lane change - LinearIssueBadge keyboard-focusable; popover open via focus-within - LinearIssuePicker seeds pendingIssue from active selection too - CreatePrModal clears Linear close-toggle and refs when issue dropped - chatContextAttachments wraps Linear text as untrusted prompt data - CLI Linear connection status forwards organization fields * ship: iteration 2 — fix CI shards 1 & 3, align Linear RPC schema - linearAuth.test.ts: assert filter via body.variables.filter to match the variables-based GraphQL contract from iter 1 - laneService.test.ts: stub check-ref-format --branch in the runGit mock so the new branch sanitizer round-trip is allowed - kvDb.ts: replace UNIQUE index on lane_linear_issues with a bootstrap-time duplicate-coalescing sweep (CRRs disallow non-PK unique indices); app-layer enforcement remains - adeRpcServer.ts: searchLinearIssues schema first.max 200 -> 50 to match runtime clamp + error message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ship: iteration 3 — bootstrap SQL refresh + 4 new review fixes - iOS DatabaseBootstrap.sql regenerated to track kvDb dedupe sweep - agentChatService: Codex steer uses preparedSteer.submittedText so context-only steers send the fallback prompt - agentChatService: Droid busy-steer routes through prepareSendMessage (allowActiveSession: true) like Cursor's busy path - linearClient.normalizeSdkIssue: labels now accepts resolved connection objects, not just callable thunks - prService.createFromLane: pass preserveExisting:false to ensureLinearPrReference so Refs upgrades to Fixes when closeLinearIssueOnMerge is true Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ship: iteration 4 — XML-escape untrusted text, fix adopt path Refs->Fixes, drop searchIssues min clamp - chatContextAttachments.wrapUntrustedLinearText: HTML-entity-escape &/</>/"/' before wrapping so Linear titles can't break out of the <untrusted-data> tag (Greptile P1/security) - prService adoption branch: pass preserveExisting:false to ensureLinearPrReference when closeLinearIssueOnMerge is true so Refs upgrades to Fixes on adopted PRs too (CodeRabbit Major) - linearClient.searchIssues: lower clamp 10 -> 1 to match the schema contract (Cursor Low) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ship: iteration 5 — wrap all untrusted Linear fields, raw-GraphQL quick view, drop dead helpers - chatContextAttachments: wrap assignee/creator/team/project/state/ labels/branchName/url through wrapUntrustedLinearText so user- controlled Linear fields can't break out of the prompt sandbox (Greptile P1/security) - linearClient.getQuickView: replace SDK lazy-loaded issues calls with searchIssues raw GraphQL using ISSUE_FIELDS_FRAGMENT (was ~168 round-trips per call, now 2) (Cursor Medium) - linearClient: drop unused gqlString / gqlStringArray helpers (Cursor Low) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Arul Sharma <arul28@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add remote runtime architecture spec Comprehensive engineering specification for the runtime extraction, multi-project unification, and SSH-tunneled remote runtime feature. Captures all architectural decisions, audit findings, phased implementation plan with file-level detail, migration path, and parallelization tracks for the dev team. * Address PR #275 review comments - Linear tool schema: add nullable optional properties (description, url, projectName, teamName, assigneeId, assigneeName, creatorId, creatorName, dueDate, estimate, branchName) to required so OpenAI strict mode accepts create_lane calls. The anyOf [type, null] entries already make them safely omittable at the value level. - linearClient.metadataTags: defensively read from node.metadata.tags so any populated data is preserved instead of silently dropped to []. Falls back to [] when the field is absent. - kvDb pr_pipeline_settings backfill: log via console.warn on failure instead of swallowing silently. Documents that an invisible state split (existing rows on legacy defaults vs new rows on new defaults) could otherwise occur. ALTER TABLE catches stay silent because column-already-exists is the expected re-run path. - laneService.resolveCreateBranchRef: don't blame Linear when the conflicting branch came from an explicit branchName arg or the fallback path. Differentiate the error message based on the source of the suggested branch. - bootstrap.createPathToMergeOrchestrator: replace `as never` with a typed cast through Parameters<typeof createPathToMergeOrchestrator>[0] so any future tightening of PathToMergeDeps surfaces as a type error. Also drop a gratuitous `as never` on a call whose target accepts unknown. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Arul Sharma <arul28@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .gitignore | 1 + apps/ade-cli/README.md | 5 + apps/ade-cli/src/adeRpcServer.test.ts | 186 ++++ apps/ade-cli/src/adeRpcServer.ts | 335 ++++++- apps/ade-cli/src/bootstrap.ts | 27 +- apps/ade-cli/src/cli.test.ts | 173 +++- apps/ade-cli/src/cli.ts | 124 ++- apps/ade-cli/src/headlessLinearServices.ts | 4 + apps/desktop/package-lock.json | 39 +- apps/desktop/package.json | 5 +- apps/desktop/resources/ade-cli-help.txt | 5 +- apps/desktop/scripts/dev.cjs | 6 +- .../main/services/adeActions/registry.test.ts | 8 + .../src/main/services/adeActions/registry.ts | 9 +- .../services/chat/agentChatService.test.ts | 120 ++- .../main/services/chat/agentChatService.ts | 117 ++- .../src/main/services/cto/issueTracker.ts | 28 + .../src/main/services/cto/linearAuth.test.ts | 125 ++- .../src/main/services/cto/linearClient.ts | 473 +++++++++- .../main/services/cto/linearIssueTracker.ts | 26 +- .../main/services/cto/linearOAuthService.ts | 11 +- .../main/services/diffs/diffService.test.ts | 165 ++++ .../src/main/services/diffs/diffService.ts | 291 +++++- .../main/services/files/fileService.test.ts | 27 + .../src/main/services/files/fileService.ts | 26 +- .../services/git/gitOperationsService.test.ts | 165 ++++ .../main/services/git/gitOperationsService.ts | 12 +- .../src/main/services/ipc/registerIpc.ts | 85 +- .../main/services/lanes/laneService.test.ts | 93 +- .../src/main/services/lanes/laneService.ts | 312 +++++- .../services/prs/issueInventoryService.ts | 102 +- .../prs/pathToMergeOrchestrator.test.ts | 589 +++++++++++- .../services/prs/pathToMergeOrchestrator.ts | 299 +++++- .../services/prs/prIssueResolution.test.ts | 147 ++- .../src/main/services/prs/prService.test.ts | 53 ++ .../src/main/services/prs/prService.ts | 65 +- .../src/main/services/prs/resolverUtils.ts | 2 + .../kvDb.pipelineSettingsMigration.test.ts | 128 +++ apps/desktop/src/main/services/state/kvDb.ts | 95 +- .../sync/syncRemoteCommandService.test.ts | 2 + .../services/sync/syncRemoteCommandService.ts | 6 + apps/desktop/src/preload/global.d.ts | 12 + apps/desktop/src/preload/preload.ts | 16 + apps/desktop/src/renderer/browserMock.ts | 88 ++ .../components/app/LinearIssueBrowser.tsx | 787 ++++++++++++++++ .../components/app/LinearQuickViewButton.tsx | 217 +++++ .../renderer/components/app/TopBar.test.tsx | 120 +++ .../src/renderer/components/app/TopBar.tsx | 3 + .../chat/AgentChatComposer.test.tsx | 193 ++++ .../components/chat/AgentChatComposer.tsx | 250 ++++- .../components/chat/AgentChatMessageList.tsx | 26 +- .../components/chat/AgentChatPane.test.ts | 11 + .../components/chat/AgentChatPane.tsx | 88 +- .../chat/ChatAttachmentTray.test.tsx | 56 ++ .../components/chat/ChatAttachmentTray.tsx | 84 +- .../components/chat/ChatFileChangesPanel.tsx | 4 +- .../components/files/FilesExplorer.tsx | 492 ++++++++++ .../components/files/FilesPage.test.tsx | 176 +++- .../renderer/components/files/FilesPage.tsx | 496 ++++------ .../components/files/filePresentation.tsx | 125 +++ .../components/lanes/CreateLaneDialog.tsx | 133 ++- .../components/lanes/LaneDialogShell.tsx | 7 +- .../components/lanes/LaneDiffPane.tsx | 96 +- .../components/lanes/LaneGitActionsPane.tsx | 151 ++- .../components/lanes/LaneStackPane.tsx | 15 +- .../components/lanes/LaneWorkPane.tsx | 7 + .../renderer/components/lanes/LanesPage.tsx | 108 ++- .../lanes/LinearIssueBadge.test.tsx | 96 ++ .../components/lanes/LinearIssueBadge.tsx | 240 +++++ .../components/lanes/LinearIssuePicker.tsx | 725 ++++++++++++++ .../renderer/components/lanes/linearBrand.tsx | 176 ++++ .../components/prs/CreatePrModal.test.tsx | 95 +- .../renderer/components/prs/CreatePrModal.tsx | 140 ++- .../PrDetailPane.issueResolver.test.tsx | 230 ++++- .../components/prs/detail/PrDetailPane.tsx | 149 ++- .../prs/shared/PrConvergencePanel.test.tsx | 32 + .../prs/shared/PrConvergencePanel.tsx | 11 +- .../components/prs/state/PrsContext.test.tsx | 3 + .../components/prs/tabs/GitHubTab.test.tsx | 89 +- .../components/prs/tabs/GitHubTab.tsx | 30 +- .../tabs/QueueAutomateMergingModal.test.ts | 66 ++ .../prs/tabs/QueueAutomateMergingModal.tsx | 11 +- .../prs/tabs/queueAutomateMergingRuntime.ts | 7 + .../components/settings/LinearSection.tsx | 78 +- .../components/shared/AdeDiffViewer.tsx | 204 ++++ .../components/terminals/WorkStartSurface.tsx | 8 +- .../components/terminals/WorkViewArea.tsx | 10 +- .../src/shared/chatContextAttachments.ts | 192 ++++ apps/desktop/src/shared/ipc.ts | 4 + apps/desktop/src/shared/linearIssueBranch.ts | 43 + apps/desktop/src/shared/linearMagicWords.ts | 50 + apps/desktop/src/shared/types/chat.ts | 13 + apps/desktop/src/shared/types/cto.ts | 105 ++- apps/desktop/src/shared/types/files.ts | 14 +- apps/desktop/src/shared/types/git.ts | 17 + apps/desktop/src/shared/types/lanes.ts | 35 + apps/desktop/src/shared/types/linearSync.ts | 12 + apps/desktop/src/shared/types/prs.ts | 17 +- apps/ios/ADE/Resources/DatabaseBootstrap.sql | 69 +- apps/ios/ADE/Services/Database.swift | 35 +- docs/ARCHITECTURE.md | 1 + docs/features/chat/composer-and-ui.md | 16 +- docs/features/cto/linear-integration.md | 17 +- docs/features/files-and-editor/README.md | 58 +- .../files-and-editor/editor-surfaces.md | 49 +- docs/features/lanes/README.md | 28 +- docs/features/linear-integration/README.md | 185 +++- .../sync-and-multi-device/remote-commands.md | 21 + plans/remote-runtime-architecture.md | 888 ++++++++++++++++++ 109 files changed, 11658 insertions(+), 863 deletions(-) create mode 100644 apps/desktop/src/main/services/state/kvDb.pipelineSettingsMigration.test.ts create mode 100644 apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx create mode 100644 apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx create mode 100644 apps/desktop/src/renderer/components/files/FilesExplorer.tsx create mode 100644 apps/desktop/src/renderer/components/files/filePresentation.tsx create mode 100644 apps/desktop/src/renderer/components/lanes/LinearIssueBadge.test.tsx create mode 100644 apps/desktop/src/renderer/components/lanes/LinearIssueBadge.tsx create mode 100644 apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx create mode 100644 apps/desktop/src/renderer/components/lanes/linearBrand.tsx create mode 100644 apps/desktop/src/renderer/components/prs/tabs/QueueAutomateMergingModal.test.ts create mode 100644 apps/desktop/src/renderer/components/prs/tabs/queueAutomateMergingRuntime.ts create mode 100644 apps/desktop/src/renderer/components/shared/AdeDiffViewer.tsx create mode 100644 apps/desktop/src/shared/chatContextAttachments.ts create mode 100644 apps/desktop/src/shared/linearIssueBranch.ts create mode 100644 apps/desktop/src/shared/linearMagicWords.ts create mode 100644 plans/remote-runtime-architecture.md diff --git a/.gitignore b/.gitignore index 988ba1578..033d0764a 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ ios-signing/ .pnpm-store/ /apps/desktop/.ade /.ade/shipLane/ +/.ade/logs/ /.playwright-mcp /.codex-derived-data package-lock.json diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index ca9d1e768..ece7db0fb 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -55,11 +55,16 @@ ade auth status ade doctor 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":"..."}' +ade --role cto linear quick-view --text +ade --role cto linear search-issues --query "auth" --state-type started,unstarted --first 50 ade git commit --lane lane-id ade git push --lane lane-id ade git branches --lane lane-id --text ade git user-identity --lane lane-id --text +ade diff patch --lane lane-id --path src/file.ts --text ade prs create --lane lane-id --base main --title "Fix checkout flow" +ade prs create --lane lane-id --base main --close-linear-issue-on-merge ade prs list-open --text ade prs path-to-merge --pr pr-id --model gpt-5.5 --max-rounds 3 --no-auto-merge ade prs path-to-merge --pr pr-id --model gpt-5.5 --conflict-strategy auto --force-finalize conditional diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index a11ea38a8..4f314c0e4 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -695,7 +695,52 @@ function createRuntime() { resolveRunAction: vi.fn(async (runId: string, action: string) => ({ id: runId, status: action })), cancelRun: vi.fn(async () => {}), } as any, + linearCredentialService: { + getStatus: vi.fn(() => ({ + tokenStored: true, + authMode: "manual", + tokenExpiresAt: null, + refreshTokenStored: false, + oauthConfigured: true, + })), + } as any, linearIssueTracker: { + getConnectionStatus: vi.fn(async () => ({ + connected: true, + viewerId: "user-1", + viewerName: "Arul", + message: null, + })), + getQuickView: vi.fn(async (connection: unknown) => ({ + connection, + organization: { + id: "org-1", + name: "ADE", + urlKey: "ade", + logoUrl: null, + gitBranchFormat: null, + createdIssueCount: 12, + roadmapEnabled: true, + customersEnabled: false, + releasesEnabled: false, + }, + viewer: { + id: "user-1", + name: "Arul", + displayName: "Arul", + email: "arul@example.com", + avatarUrl: null, + admin: true, + guest: false, + url: null, + }, + projects: [], + teams: [], + assignedIssues: [], + recentIssues: [], + fetchedAt: "2026-03-17T19:11:00.000Z", + sdk: { packageName: "@linear/sdk", surfaces: ["viewer", "organization"] }, + })), fetchIssueById: vi.fn(async (issueId: string) => ({ id: issueId, identifier: "LIN-1", @@ -711,6 +756,13 @@ function createRuntime() { createComment: vi.fn(async () => ({ id: "comment-1" })), fetchWorkflowStates: vi.fn(async () => [{ id: "state-done", name: "Done" }]), updateIssueState: vi.fn(async () => {}), + listProjects: vi.fn(async () => [{ id: "proj-1", name: "ADE", slug: "ade", teamName: "ADE", teamKey: "ADE" }]), + listUsers: vi.fn(async () => [{ id: "user-1", name: "Arul", displayName: "Arul" }]), + listWorkflowStates: vi.fn(async () => [{ id: "state-1", name: "Todo", type: "unstarted", teamId: "team-1", teamKey: "ADE" }]), + searchIssues: vi.fn(async (query: any) => ({ + issues: [{ id: "issue-1", identifier: "ADE-123", title: "Test", _query: query }], + pageInfo: { hasNextPage: false, endCursor: null }, + })), } as any, linearSyncService: { getDashboard: vi.fn(() => ({ enabled: true, running: false, ingressMode: "webhook-first", reconciliationIntervalSec: 60, lastPollAt: null, lastSuccessAt: null, lastError: null, queue: { queued: 1, blocked: 0, failed: 0 }, workflowRuns: { active: 1, waiting: 0 }, recentIssues: [] })), @@ -1534,6 +1586,7 @@ describe("adeRpcServer", () => { "pr_rerun_failed_checks", "pr_reply_to_review_thread", "pr_resolve_review_thread", + "getLinearQuickView", "listLinearWorkflows", "getLinearRunStatus", "getLinearSyncDashboard", @@ -1592,6 +1645,78 @@ describe("adeRpcServer", () => { ); }); + it("returns the Linear quick view for cto callers", async () => { + const { runtime } = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + + await initialize(handler, { callerId: "cto-1", role: "cto" }); + const result = await callTool(handler, "getLinearQuickView", {}); + + expect((runtime.linearIssueTracker as any).getConnectionStatus).toHaveBeenCalled(); + expect((runtime.linearIssueTracker as any).getQuickView).toHaveBeenCalledWith( + expect.objectContaining({ + connected: true, + tokenStored: true, + viewerId: "user-1", + }), + ); + expect(result.structuredContent).toEqual( + expect.objectContaining({ + organization: expect.objectContaining({ name: "ADE" }), + sdk: expect.objectContaining({ packageName: "@linear/sdk" }), + }), + ); + }); + + it("returns the Linear issue picker data for cto callers", async () => { + const { runtime } = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + + await initialize(handler, { callerId: "cto-1", role: "cto" }); + const result = await callTool(handler, "getLinearIssuePickerData", {}); + + expect((runtime.linearIssueTracker as any).listProjects).toHaveBeenCalled(); + expect((runtime.linearIssueTracker as any).listUsers).toHaveBeenCalled(); + expect((runtime.linearIssueTracker as any).listWorkflowStates).toHaveBeenCalled(); + expect(result.structuredContent).toEqual( + expect.objectContaining({ + projects: expect.any(Array), + users: expect.any(Array), + states: expect.any(Array), + }), + ); + }); + + it("forwards search filters when calling searchLinearIssues", async () => { + const { runtime } = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + + await initialize(handler, { callerId: "cto-1", role: "cto" }); + const result = await callTool(handler, "searchLinearIssues", { + projectId: "proj-1", + stateTypes: ["started", "unstarted"], + query: "auth", + first: 25, + includeArchived: true, + }); + + expect((runtime.linearIssueTracker as any).searchIssues).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "proj-1", + stateTypes: ["started", "unstarted"], + query: "auth", + first: 25, + includeArchived: true, + }), + ); + expect(result.structuredContent).toEqual( + expect.objectContaining({ + issues: expect.any(Array), + pageInfo: expect.objectContaining({ hasNextPage: false }), + }), + ); + }); + it("forwards employeeOverride and laneId when resuming a Linear sync queue item", async () => { const { runtime } = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); @@ -3934,6 +4059,7 @@ describe("adeRpcServer", () => { title: "My PR", body: "Body text", draft: true, + closeLinearIssueOnMerge: true, }); expect(created?.isError).toBeUndefined(); expect(fixture.runtime.prService.createFromLane).toHaveBeenCalledWith({ @@ -3942,6 +4068,7 @@ describe("adeRpcServer", () => { title: "My PR", body: "Body text", draft: true, + closeLinearIssueOnMerge: true, }); const drafted = await callTool(handler, "create_pr_from_lane", { @@ -4328,6 +4455,65 @@ describe("adeRpcServer", () => { ); }); + it("passes branch and Linear issue data through create_lane", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const linearIssue = { + id: "issue-1", + identifier: "ADE-123", + title: "Create linked lane", + description: null, + url: "https://linear.app/ade/issue/ADE-123/create-linked-lane", + projectId: "project-1", + projectSlug: "ade", + projectName: "ADE", + teamId: "team-1", + teamKey: "ADE", + teamName: "ADE", + stateId: "state-1", + stateName: "Todo", + stateType: "unstarted", + priority: 2, + priorityLabel: "high", + labels: ["desktop"], + assigneeId: null, + assigneeName: null, + creatorId: null, + creatorName: null, + dueDate: null, + estimate: null, + branchName: "ade-123-create-linked-lane", + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + secretToken: "do-not-forward", + }; + + await initialize(handler, { callerId: "orchestrator", role: "orchestrator" }); + const response = await callTool(handler, "create_lane", { + name: "new-feature", + baseBranch: "main", + branchName: "ade-123-create-linked-lane", + linearIssue, + }); + + expect(response?.isError).toBeUndefined(); + expect(fixture.runtime.laneService.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: "new-feature", + baseBranch: "main", + branchName: "ade-123-create-linked-lane", + linearIssue: expect.objectContaining({ + id: "issue-1", + identifier: "ADE-123", + title: "Create linked lane", + projectId: "project-1", + priorityLabel: "high", + }), + }) + ); + expect((fixture.runtime.laneService.create as any).mock.calls[0][0].linearIssue).not.toHaveProperty("secretToken"); + }); + it("routes simulate_integration as a read-only dry-merge", 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 33558f43f..effbdca0c 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -38,10 +38,13 @@ import { type ComputerUseArtifactOwner, type DockLayout, 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"; +import type { LinearConnectionStatus } from "../../desktop/src/shared/types/linearSync"; import { resolveAdeLayout } from "../../desktop/src/shared/adeLayout"; import { buildTrackedCliLaunchCommand, @@ -72,6 +75,67 @@ type ExecutableTool = { execute?: (args: Record<string, unknown>) => Promise<unknown>; }; +const LINEAR_ISSUE_TOOL_SCHEMA: Record<string, unknown> = { + type: "object", + required: [ + "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", + ], + additionalProperties: false, + properties: { + id: { type: "string", minLength: 1 }, + identifier: { type: "string", minLength: 1 }, + title: { type: "string", minLength: 1 }, + description: { anyOf: [{ type: "string" }, { type: "null" }] }, + url: { anyOf: [{ type: "string" }, { type: "null" }] }, + projectId: { type: "string", minLength: 1 }, + projectSlug: { type: "string", minLength: 1 }, + projectName: { anyOf: [{ type: "string" }, { type: "null" }] }, + teamId: { type: "string", minLength: 1 }, + teamKey: { type: "string", minLength: 1 }, + teamName: { anyOf: [{ type: "string" }, { type: "null" }] }, + stateId: { type: "string", minLength: 1 }, + stateName: { type: "string", minLength: 1 }, + stateType: { type: "string", minLength: 1 }, + priority: { type: "number" }, + priorityLabel: { type: "string", enum: ["urgent", "high", "normal", "low", "none"] }, + labels: { type: "array", items: { type: "string" } }, + assigneeId: { anyOf: [{ type: "string" }, { type: "null" }] }, + assigneeName: { anyOf: [{ type: "string" }, { type: "null" }] }, + creatorId: { anyOf: [{ type: "string" }, { type: "null" }] }, + creatorName: { anyOf: [{ type: "string" }, { type: "null" }] }, + dueDate: { anyOf: [{ type: "string" }, { type: "null" }] }, + estimate: { anyOf: [{ type: "number" }, { type: "null" }] }, + branchName: { anyOf: [{ type: "string" }, { type: "null" }] }, + createdAt: { type: "string", minLength: 1 }, + updatedAt: { type: "string", minLength: 1 }, + }, +}; + type SessionIdentity = { callerId: string; role: "cto" | "orchestrator" | "agent" | "external" | "evaluator"; @@ -194,7 +258,10 @@ const TOOL_SPECS: ToolSpec[] = [ properties: { name: { type: "string", minLength: 1 }, description: { type: "string" }, - parentLaneId: { type: "string" } + parentLaneId: { type: "string" }, + baseBranch: { type: "string" }, + branchName: { type: "string" }, + linearIssue: LINEAR_ISSUE_TOOL_SCHEMA } } }, @@ -1097,6 +1164,7 @@ const TOOL_SPECS: ToolSpec[] = [ title: { type: "string", minLength: 1 }, body: { type: "string" }, draft: { type: "boolean", default: false }, + closeLinearIssueOnMerge: { type: "boolean", default: false }, } } }, @@ -1835,6 +1903,36 @@ const CTO_OPERATOR_TOOL_SPECS: ToolSpec[] = [ ]; const CTO_LINEAR_SYNC_TOOL_SPECS: ToolSpec[] = [ + { + name: "getLinearQuickView", + description: "Read a compact Linear workspace, project, and issue quick view through the connected Linear SDK account.", + inputSchema: { type: "object", additionalProperties: false, properties: {} } + }, + { + name: "getLinearIssuePickerData", + description: "Read the projects, users, and workflow states needed to populate the Linear issue picker for lane creation.", + inputSchema: { type: "object", additionalProperties: false, properties: {} } + }, + { + name: "searchLinearIssues", + description: "Search Linear issues for the lane Linear-issue picker, filtered by project, team, state, assignee, priority, or text query.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + projectId: { anyOf: [{ type: "string" }, { type: "null" }] }, + projectSlug: { anyOf: [{ type: "string" }, { type: "null" }] }, + teamKey: { anyOf: [{ type: "string" }, { type: "null" }] }, + stateTypes: { type: "array", items: { type: "string" } }, + assigneeId: { anyOf: [{ type: "string" }, { type: "null" }] }, + priority: { anyOf: [{ type: "number" }, { type: "null" }] }, + query: { anyOf: [{ type: "string" }, { type: "null" }] }, + first: { type: "number", minimum: 1, maximum: 50 }, + after: { anyOf: [{ type: "string" }, { type: "null" }] }, + includeArchived: { type: "boolean" } + } + } + }, { name: "getLinearSyncDashboard", description: "Read the ADE Linear sync dashboard.", @@ -2070,6 +2168,9 @@ const READ_ONLY_TOOLS = new Set([ "listChats", "getChatStatus", "readChatTranscript", + "getLinearQuickView", + "getLinearIssuePickerData", + "searchLinearIssues", "listLinearWorkflows", "getLinearRunStatus", "getLinearSyncDashboard", @@ -2275,6 +2376,87 @@ function asNumber(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } +function assertOptionalStringOrNull(value: unknown, field: string): string | null { + if (value == null) return null; + if (typeof value !== "string") { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `${field} must be a string or null`); + } + return value; +} + +function assertStringArray(value: unknown, field: string): string[] { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `${field} must be an array of strings`); + } + return [...value]; +} + +function assertOptionalNumberOrNull(value: unknown, field: string): number | null { + if (value == null) return null; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `${field} must be a number or null`); + } + return value; +} + +function assertNumber(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `${field} must be a number`); + } + return value; +} + +function assertLinearPriorityLabel(value: unknown, field: string): LaneLinearIssue["priorityLabel"] { + if (value === "urgent" || value === "high" || value === "normal" || value === "low" || value === "none") { + return value; + } + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `${field} must be one of: urgent, high, normal, low, none`); +} + +function parseLaneLinearIssue(value: unknown, field = "linearIssue"): LaneLinearIssue { + const issue = safeObject(value); + if (Object.keys(issue).length === 0) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `${field} must be an object`); + } + return { + id: assertNonEmptyString(issue.id, `${field}.id`), + identifier: assertNonEmptyString(issue.identifier, `${field}.identifier`), + title: assertNonEmptyString(issue.title, `${field}.title`), + description: assertOptionalStringOrNull(issue.description, `${field}.description`), + url: assertOptionalStringOrNull(issue.url, `${field}.url`), + projectId: assertNonEmptyString(issue.projectId, `${field}.projectId`), + projectSlug: assertNonEmptyString(issue.projectSlug, `${field}.projectSlug`), + projectName: assertOptionalStringOrNull(issue.projectName, `${field}.projectName`), + teamId: assertNonEmptyString(issue.teamId, `${field}.teamId`), + teamKey: assertNonEmptyString(issue.teamKey, `${field}.teamKey`), + teamName: assertOptionalStringOrNull(issue.teamName, `${field}.teamName`), + stateId: assertNonEmptyString(issue.stateId, `${field}.stateId`), + stateName: assertNonEmptyString(issue.stateName, `${field}.stateName`), + stateType: assertNonEmptyString(issue.stateType, `${field}.stateType`), + priority: assertNumber(issue.priority, `${field}.priority`), + priorityLabel: assertLinearPriorityLabel(issue.priorityLabel, `${field}.priorityLabel`), + labels: assertStringArray(issue.labels, `${field}.labels`), + assigneeId: assertOptionalStringOrNull(issue.assigneeId, `${field}.assigneeId`), + assigneeName: assertOptionalStringOrNull(issue.assigneeName, `${field}.assigneeName`), + creatorId: assertOptionalStringOrNull(issue.creatorId, `${field}.creatorId`), + creatorName: assertOptionalStringOrNull(issue.creatorName, `${field}.creatorName`), + dueDate: assertOptionalStringOrNull(issue.dueDate, `${field}.dueDate`), + estimate: assertOptionalNumberOrNull(issue.estimate, `${field}.estimate`), + branchName: assertOptionalStringOrNull(issue.branchName, `${field}.branchName`), + createdAt: assertNonEmptyString(issue.createdAt, `${field}.createdAt`), + updatedAt: assertNonEmptyString(issue.updatedAt, `${field}.updatedAt`), + }; +} + +function projectLaneLinearIssue(value: unknown): LaneLinearIssue | null { + if (!value) return null; + try { + return parseLaneLinearIssue(value); + } catch { + return null; + } +} + function assertNonEmptyString(value: unknown, field: string): string { const text = asTrimmedString(value); if (!text.length) { @@ -2693,6 +2875,13 @@ function requireLinearSyncService(runtime: AdeRuntime): NonNullable<AdeRuntime[" return runtime.linearSyncService; } +function requireLinearIssueTracker(runtime: AdeRuntime): NonNullable<AdeRuntime["linearIssueTracker"]> { + if (!runtime.linearIssueTracker) { + throw new JsonRpcError(JsonRpcErrorCode.internalError, "linearIssueTracker is not available in this ADE runtime configuration"); + } + return runtime.linearIssueTracker; +} + function requireLinearIngressService(runtime: AdeRuntime): NonNullable<AdeRuntime["linearIngressService"]> { if (!runtime.linearIngressService) { throw new JsonRpcError(JsonRpcErrorCode.internalError, "linearIngressService is not available in this ADE runtime configuration"); @@ -2714,6 +2903,73 @@ function requireLinearRoutingService(runtime: AdeRuntime): NonNullable<AdeRuntim return runtime.linearRoutingService; } +async function buildCliLinearConnectionStatus(runtime: AdeRuntime): Promise<LinearConnectionStatus> { + const credentialStatus = runtime.linearCredentialService?.getStatus() ?? { + tokenStored: false, + authMode: null, + tokenExpiresAt: null, + oauthConfigured: false, + }; + 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: status.message, + }; + } catch (err) { + return { + tokenStored, + connected: false, + viewerId: null, + viewerName: null, + checkedAt: nowIso(), + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: err instanceof Error && err.message ? err.message : "Linear tracker error", + }; + } +} + +function emptyLinearQuickView(connection: LinearConnectionStatus): CtoLinearQuickView { + return { + connection, + organization: null, + viewer: null, + projects: [], + teams: [], + assignedIssues: [], + recentIssues: [], + fetchedAt: nowIso(), + sdk: { packageName: "@linear/sdk", surfaces: [] }, + }; +} + async function resolveDefaultLaneId(runtime: AdeRuntime): Promise<string> { await runtime.laneService.ensurePrimaryLane().catch(() => {}); const lanes = await runtime.laneService.list({ includeArchived: false, includeStatus: false }); @@ -3074,6 +3330,7 @@ function mapLaneSummary(lane: Record<string, unknown>): Record<string, unknown> worktreePath: lane.worktreePath, archivedAt: lane.archivedAt, stackDepth: lane.stackDepth, + linearIssue: projectLaneLinearIssue(lane.linearIssue), status: lane.status }; } @@ -4658,6 +4915,62 @@ async function runTool(args: { throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported tool: ${name}`); } + if (name === "getLinearQuickView") { + const connection = await buildCliLinearConnectionStatus(runtime); + if (!connection.connected) return emptyLinearQuickView(connection); + try { + return await requireLinearIssueTracker(runtime).getQuickView(connection); + } catch (err) { + return emptyLinearQuickView({ + ...connection, + connected: false, + viewerId: null, + viewerName: null, + checkedAt: nowIso(), + message: err instanceof Error && err.message ? err.message : "Linear tracker error", + }); + } + } + + if (name === "getLinearIssuePickerData") { + const tracker = requireLinearIssueTracker(runtime); + const [projects, users, states] = await Promise.all([ + tracker.listProjects().catch(() => []), + tracker.listUsers().catch(() => []), + tracker.listWorkflowStates().catch(() => []), + ]); + return { projects, users, states }; + } + + if (name === "searchLinearIssues") { + const tracker = requireLinearIssueTracker(runtime); + const stateTypes = Array.isArray(toolArgs.stateTypes) + ? assertStringArray(toolArgs.stateTypes, "stateTypes") + : []; + let first = 50; + if (toolArgs.first !== undefined && toolArgs.first !== null) { + if (typeof toolArgs.first !== "number" || !Number.isFinite(toolArgs.first) || !Number.isInteger(toolArgs.first) || toolArgs.first <= 0) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "first must be a positive integer (1-50)", + ); + } + first = Math.min(50, toolArgs.first); + } + return await tracker.searchIssues({ + projectId: assertOptionalStringOrNull(toolArgs.projectId ?? null, "projectId"), + projectSlug: assertOptionalStringOrNull(toolArgs.projectSlug ?? null, "projectSlug"), + teamKey: assertOptionalStringOrNull(toolArgs.teamKey ?? null, "teamKey"), + stateTypes, + assigneeId: assertOptionalStringOrNull(toolArgs.assigneeId ?? null, "assigneeId"), + priority: assertOptionalNumberOrNull(toolArgs.priority ?? null, "priority"), + query: assertOptionalStringOrNull(toolArgs.query ?? null, "query"), + first, + after: assertOptionalStringOrNull(toolArgs.after ?? null, "after"), + includeArchived: asBoolean(toolArgs.includeArchived, false), + }); + } + if (name === "getLinearSyncDashboard") { return requireLinearSyncService(runtime).getDashboard(); } @@ -4972,11 +5285,26 @@ async function runTool(args: { const nameArg = assertNonEmptyString(toolArgs.name, "name"); const description = asOptionalTrimmedString(toolArgs.description); const parentLaneId = asOptionalTrimmedString(toolArgs.parentLaneId); + const baseBranch = asOptionalTrimmedString(toolArgs.baseBranch); + const branchName = asOptionalTrimmedString(toolArgs.branchName); + let linearIssue: LaneLinearIssue | null = null; + if (toolArgs.linearIssue !== undefined && toolArgs.linearIssue !== null) { + if (typeof toolArgs.linearIssue !== "object" || Array.isArray(toolArgs.linearIssue)) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "linearIssue must be a non-array object", + ); + } + linearIssue = parseLaneLinearIssue(toolArgs.linearIssue); + } const lane = await runtime.laneService.create({ name: nameArg, ...(description ? { description } : {}), - ...(parentLaneId ? { parentLaneId } : {}) + ...(parentLaneId ? { parentLaneId } : {}), + ...(baseBranch ? { baseBranch } : {}), + ...(branchName ? { branchName } : {}), + ...(linearIssue ? { linearIssue } : {}) }); return { @@ -6228,10 +6556,12 @@ async function runTool(args: { const prSvc = requirePrService(runtime); let title = asOptionalTrimmedString(toolArgs.title); let body = typeof toolArgs.body === "string" ? toolArgs.body : null; + const closeLinearIssueOnMerge = asBoolean(toolArgs.closeLinearIssueOnMerge, false); if (!title || body == null) { const draft = await prSvc.draftDescription({ laneId, ...(baseBranch ? { baseBranch } : {}), + ...(closeLinearIssueOnMerge ? { closeLinearIssueOnMerge } : {}), }); title = title || asOptionalTrimmedString(draft.title) || `PR for ${laneId}`; body = body ?? asOptionalTrimmedString(draft.body) ?? ""; @@ -6243,6 +6573,7 @@ async function runTool(args: { body, draft, ...(baseBranch ? { baseBranch } : {}), + ...(closeLinearIssueOnMerge ? { closeLinearIssueOnMerge } : {}), }); return { pr }; } diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 67292c94b..6538c75ce 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -38,6 +38,7 @@ import type { createPrService } from "../../desktop/src/main/services/prs/prServ import type { createPrSummaryService } from "../../desktop/src/main/services/prs/prSummaryService"; import type { createQueueLandingService } from "../../desktop/src/main/services/prs/queueLandingService"; import { createIssueInventoryService } from "../../desktop/src/main/services/prs/issueInventoryService"; +import { createPathToMergeOrchestrator } from "../../desktop/src/main/services/prs/pathToMergeOrchestrator"; import { createMemoryService } from "../../desktop/src/main/services/memory/memoryService"; import { createCtoStateService } from "../../desktop/src/main/services/cto/ctoStateService"; import { createWorkerAgentService } from "../../desktop/src/main/services/cto/workerAgentService"; @@ -93,7 +94,7 @@ import { getAdeActionDomainServices, isAllowedAdeAction, } from "../../desktop/src/main/services/adeActions/registry"; -import type { LaneWorktreeLockService } from "../../desktop/src/main/services/lanes/laneWorktreeLockService"; +import { createLaneWorktreeLockService, type LaneWorktreeLockService } from "../../desktop/src/main/services/lanes/laneWorktreeLockService"; import { createHeadlessLinearServices } from "./headlessLinearServices"; import { createEventBuffer, type BufferedEvent, type EventBuffer } from "./eventBuffer"; @@ -156,6 +157,7 @@ export type AdeRuntime = { prSummaryService?: ReturnType<typeof createPrSummaryService> | null; queueLandingService?: ReturnType<typeof createQueueLandingService> | null; issueInventoryService: ReturnType<typeof createIssueInventoryService>; + pathToMergeOrchestrator?: ReturnType<typeof createPathToMergeOrchestrator> | null; fileService?: ReturnType<typeof createFileService> | null; memoryService: ReturnType<typeof createMemoryService>; ctoStateService: ReturnType<typeof createCtoStateService>; @@ -421,6 +423,7 @@ export async function createAdeRuntime(args: { broadcastEvent: () => {} }); const issueInventoryService = createIssueInventoryService({ db }); + const laneWorktreeLockService = createLaneWorktreeLockService({ db, logger }); const eventBuffer = createEventBuffer(); function pushEvent(category: BufferedEvent["category"], payload: Record<string, unknown>): void { @@ -718,10 +721,27 @@ export async function createAdeRuntime(args: { getDirtyFileTextForPath: () => undefined, }); if (typeof (headlessLinearServices.prService as { setAgentChatService?: (svc: unknown) => void }).setAgentChatService === "function") { - (headlessLinearServices.prService as { setAgentChatService: (svc: unknown) => void }).setAgentChatService(agentChatService as never); + (headlessLinearServices.prService as { setAgentChatService: (svc: unknown) => void }).setAgentChatService(agentChatService); } } agentChatServiceHolder.current = agentChatService; + // The headless agent-chat stub returns less-typed payloads than the full + // agentChatService. Cast through the orchestrator's expected shape (rather + // than `never`) so that any future tightening of PathToMergeDeps surfaces + // as a type error here. + type PathToMergeAgentChatService = Parameters<typeof createPathToMergeOrchestrator>[0]["agentChatService"]; + const pathToMergeOrchestrator = createPathToMergeOrchestrator({ + logger, + prService: headlessLinearServices.prService, + laneService, + agentChatService: headlessLinearServices.agentChatService as unknown as PathToMergeAgentChatService, + sessionService, + issueInventoryService, + conflictService, + laneWorktreeLockService, + defaultModelId: null, + defaultReasoningEffort: null, + }); const automationService = createAutomationService({ db, logger, @@ -762,11 +782,13 @@ export async function createAdeRuntime(args: { diffService, missionService, missionBudgetService, + laneWorktreeLockService, ptyService, testService, aiIntegrationService, agentChatService, issueInventoryService, + pathToMergeOrchestrator, memoryService, ctoStateService, workerAgentService, @@ -796,6 +818,7 @@ export async function createAdeRuntime(args: { dispose: () => { const swallow = (fn: () => void) => { try { fn(); } catch { /* ignore */ } }; swallow(() => automationService.dispose()); + swallow(() => pathToMergeOrchestrator.dispose()); swallow(() => processService.disposeAll()); swallow(() => iosSimulatorService?.dispose()); swallow(() => appControlService?.dispose()); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 4711a0814..aa10a397c 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -129,6 +129,41 @@ 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"]); + expect(parsed.options.text).toBe(true); + + const plan = buildCliPlan(parsed.command); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + + expect(plan).toEqual({ + kind: "execute", + label: "diff patch", + steps: [ + { + key: "result", + method: "ade/actions/call", + params: { + name: "run_ade_action", + arguments: { + domain: "diff", + action: "getFilePatch", + args: { + filePath: "file.txt", + mode: "unstaged", + compareRef: null, + compareTo: null, + laneId: "main", + }, + }, + }, + unwrapToolResult: true, + }, + ], + }); + }); + it("builds the stdio MCP server command", () => { expect(buildCliPlan(["mcp"])).toEqual({ kind: "mcp" }); expect(buildCliPlan(["mcp-server"])).toEqual({ kind: "mcp" }); @@ -619,11 +654,15 @@ describe("ADE CLI", () => { }, }); expect(plan.steps[1]?.params).toEqual({ - name: "pr_start_issue_resolution", + name: "run_ade_action", arguments: { - prId: "pr-1", - scope: "both", - modelId: "gpt-5.4", + domain: "path_to_merge", + action: "startPathToMerge", + args: { + prId: "pr-1", + scope: "both", + modelId: "gpt-5.4", + }, }, }); }); @@ -671,11 +710,15 @@ describe("ADE CLI", () => { }, }); expect(plan.steps[1]?.params).toEqual({ - name: "pr_start_issue_resolution", + name: "run_ade_action", arguments: { - prId: "pr-2", - scope: "both", - modelId: "gpt-5.4", + domain: "path_to_merge", + action: "startPathToMerge", + args: { + prId: "pr-2", + scope: "both", + modelId: "gpt-5.4", + }, }, }); }); @@ -731,6 +774,7 @@ describe("ADE CLI", () => { 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/); @@ -1180,6 +1224,119 @@ describe("ADE CLI", () => { expect(lanesHelp.kind).toBe("help"); }); + it("maps PR create Linear close flag to the typed RPC tool", () => { + const plan = buildCliPlan([ + "prs", + "create", + "--lane", + "lane-1", + "--title", + "Linked PR", + "--body", + "Body", + "--close-linear-issue-on-merge", + ]); + + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toEqual({ + name: "create_pr_from_lane", + arguments: { + laneId: "lane-1", + title: "Linked PR", + body: "Body", + draft: false, + closeLinearIssueOnMerge: true, + }, + }); + }); + + it("maps lane create Linear issue JSON to the typed RPC tool", () => { + const plan = buildCliPlan([ + "lanes", + "create", + "--name", + "Linked lane", + "--base", + "main", + "--branch-name", + "ade-123-linked-lane", + "--linear-issue-json", + "{\"id\":\"issue-1\",\"identifier\":\"ADE-123\",\"title\":\"Linked lane\"}", + ]); + + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toEqual({ + name: "create_lane", + arguments: { + name: "Linked lane", + baseBranch: "main", + branchName: "ade-123-linked-lane", + linearIssue: { + id: "issue-1", + identifier: "ADE-123", + title: "Linked lane", + }, + }, + }); + }); + + it("maps Linear quick view to the typed RPC tool", () => { + const plan = buildCliPlan(["linear", "quick-view", "--text"]); + + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.label).toBe("Linear quick view"); + expect(plan.formatter).toBe("linear-quick-view"); + expect(plan.steps[0]?.params).toEqual({ + name: "getLinearQuickView", + arguments: {}, + }); + }); + + it("maps Linear picker data to the typed RPC tool", () => { + const plan = buildCliPlan(["linear", "picker-data", "--text"]); + + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.label).toBe("Linear picker data"); + expect(plan.steps[0]?.params).toEqual({ + name: "getLinearIssuePickerData", + arguments: {}, + }); + }); + + it("maps Linear search-issues filters to the typed RPC tool", () => { + const plan = buildCliPlan([ + "linear", + "search-issues", + "--project-id", + "proj-1", + "--state-type", + "started,unstarted", + "--query", + "auth", + "--first", + "25", + "--include-archived", + ]); + + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.label).toBe("Linear search issues"); + expect(plan.steps[0]?.params).toEqual({ + name: "searchLinearIssues", + arguments: { + projectId: "proj-1", + stateTypes: ["started", "unstarted"], + query: "auth", + first: 25, + includeArchived: true, + }, + }); + }); + it("shows focused ios-sim help for subcommand help flags", () => { const renderHelp = buildCliPlan(["ios-sim", "preview-render", "--help"]); expect(renderHelp.kind).toBe("help"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index ee6cb50e6..cfb7a2b9a 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -58,6 +58,7 @@ type FormatterId = | "status" | "doctor" | "auth" + | "linear-quick-view" | "lanes" | "lane-detail" | "git-status" @@ -350,7 +351,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ 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 diff changes | file Inspect lane diffs + $ 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 $ ade prs list | create | path-to-merge Manage PRs, queues, and Path to Merge repair rounds @@ -774,6 +775,8 @@ const HELP_BY_COMMAND: Record<string, string> = { $ ade lanes list --text Show lane stack graph and branch names $ ade lanes show <lane> --text Inspect one lane status $ ade lanes create --name <name> Create a lane from the current project context + $ ade lanes create --linear-issue-json '{...}' Create a lane linked to a Linear issue + $ ade lanes create --branch-name <branch> Override the auto-generated branch name $ ade lanes child --lane <parent> --name <name> Create a child lane under a parent $ ade lanes import --branch <branch> Register an existing branch/worktree $ ade lanes archive <lane> Archive a lane in ADE @@ -791,7 +794,7 @@ const HELP_BY_COMMAND: Record<string, string> = { $ ade git stage --lane <lane> src/file.ts Stage one file $ ade git stage-all --lane <lane> Stage all current changes $ ade git unstage --lane <lane> src/file.ts Unstage one file - $ ade git commit --lane <lane> [-m <message>] Commit, generating a message when omitted + $ ade git commit --lane <lane> [-m <message>] Commit, adding Refs <issue-id> on linked Linear lanes $ ade git push --lane <lane> --set-upstream Push through ADE $ ade git branches --lane <lane> --text List branches with last-commit metadata $ ade git user-identity --lane <lane> --text Read lane checkout's git user.name/email @@ -803,7 +806,8 @@ const HELP_BY_COMMAND: Record<string, string> = { Diffs $ ade diff changes --lane <lane> --text Summarize staged/unstaged file changes - $ ade diff file --lane <lane> <path> --text Show one file diff + $ ade diff file --lane <lane> <path> --text Show one file diff (side-by-side text) + $ ade diff patch --lane <lane> <path> --text Raw unified diff / patch for one file $ ade diff file --mode staged <path> Inspect staged diff for one file $ ade diff actions --text List diff service actions `, @@ -816,6 +820,7 @@ const HELP_BY_COMMAND: Record<string, string> = { $ ade prs list --text List PRs known to ADE $ ade prs list-open --text List every open GitHub PR in the repo, keyed by head branch $ ade prs create --lane <lane> --base main Open and map a GitHub PR from a lane + $ ade prs create --lane <lane> --close-linear-issue-on-merge $ ade prs link --lane <lane> --url <pr-url> Map an existing GitHub PR to a lane $ ade prs checks <pr> --text Show check status $ ade prs comments <pr> --text Show unresolved review work @@ -1165,6 +1170,10 @@ const HELP_BY_COMMAND: Record<string, string> = { linear: `${ADE_BANNER} Linear workflows + $ ade --role cto linear quick-view --text Show connected workspace, projects, and issues + $ ade --role cto linear picker-data --text Read projects/users/states for the issue picker + $ ade --role cto linear search-issues --query "auth" --state-type started,unstarted --first 50 + Search issues for the lane Linear-issue picker $ ade linear workflows --text List configured workflows $ ade linear sync dashboard --text Show sync dashboard $ ade linear sync run Trigger a sync run @@ -1998,6 +2007,16 @@ function buildLanePlan(args: string[]): CliPlan { 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, "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"); + } + input.linearIssue = parsed as JsonObject; + } if (sub === "child" && !input.parentLaneId) throw new CliUsageError("parent lane is required. Use --lane <parent> or --parent <parent>."); return { kind: "execute", label: "lane create", steps: [actionCallStep("result", "create_lane", collectGenericObjectArgs(args, input))] }; } @@ -2212,6 +2231,19 @@ function buildDiffPlan(args: string[]): CliPlan { }))], }; } + if (sub === "patch") { + 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"]), + }))], + }; + } return { kind: "execute", label: `diff ${sub}`, steps: [actionStep("result", "diff", sub, withLane())] }; } @@ -2240,6 +2272,11 @@ function buildPrPlan(args: string[]): CliPlan { maybePut(input, "title", readValue(args, ["--title"])); maybePut(input, "body", readValue(args, ["--body"])); input.draft = readFlag(args, ["--draft"]); + input.closeLinearIssueOnMerge = readFlag(args, [ + "--close-linear-issue-on-merge", + "--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) }))] }; @@ -2374,7 +2411,11 @@ function buildPrPlan(args: string[]): CliPlan { pipelinePatch, ])); } - steps.push(actionCallStep("result", mode === "preview" ? "pr_preview_issue_resolution_prompt" : "pr_start_issue_resolution", collectGenericObjectArgs(args, input))); + if (mode === "preview") { + steps.push(actionCallStep("result", "pr_preview_issue_resolution_prompt", collectGenericObjectArgs(args, input))); + } else { + steps.push(actionStep("result", "path_to_merge", "startPathToMerge", collectGenericObjectArgs(args, input))); + } return { kind: "execute", label: `PR path-to-merge ${mode}`, steps }; } @@ -3933,6 +3974,32 @@ 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))] }; + } + if (sub === "picker-data" || sub === "picker") { + 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 stateTypes = stateTypesValue + ? 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, "teamKey", readValue(args, ["--team-key", "--team"])); + if (stateTypes.length) input.stateTypes = stateTypes; + 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"])); + const first = readNumberOption(args, ["--first", "--limit"]); + 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))] }; + } if (sub === "workflows") return { kind: "execute", label: "Linear workflows", steps: [actionCallStep("result", "listLinearWorkflows", collectGenericObjectArgs(args))] }; if (sub === "run") { const mode = firstPositional(args) ?? "status"; @@ -6286,6 +6353,53 @@ function formatTerminalRead(value: unknown): string { return data.length ? `${header}\n\n${data}` : `${header}\n\n(no output)`; } +function formatLinearQuickView(value: unknown): string { + if (!isRecord(value)) return JSON.stringify(value, null, 2); + const connection = isRecord(value.connection) ? value.connection : {}; + const organization = isRecord(value.organization) ? value.organization : null; + const viewer = isRecord(value.viewer) ? value.viewer : null; + const projects = firstArray(value, ["projects"]); + const assignedIssues = firstArray(value, ["assignedIssues"]); + const recentIssues = firstArray(value, ["recentIssues"]); + const teams = firstArray(value, ["teams"]); + const header = renderKeyValues("Linear quick view", [ + ["connected", connection.connected], + ["auth", connection.authMode], + ["workspace", organization?.name ?? organization?.urlKey], + ["viewer", viewer?.displayName ?? viewer?.name ?? connection.viewerName], + ["projects", projects.length], + ["teams", teams.length], + ["assigned issues", assignedIssues.length], + ["recent issues", recentIssues.length], + ["checked", value.fetchedAt ?? connection.checkedAt], + ["message", connection.message], + ]); + const projectRows = projects.map((project) => [ + project.name, + project.statusName ?? project.statusType, + 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) + .slice(0, 12) + .map((issue) => [ + issue.identifier, + issue.title, + issue.stateName, + issue.projectName ?? issue.teamName ?? issue.teamKey, + ]); + return [ + header, + "", + "Projects", + renderTable(["project", "status", "progress", "issues"], projectRows, "(no projects)"), + "", + "Issues", + renderTable(["id", "title", "state", "area"], issueRows, "(no issues)"), + ].join("\n"); +} + function formatAppControlSelection(value: unknown): string { const item = firstRecord(value, ["item", "selection"]) ?? (isRecord(value) ? value : {}); const metadata = isRecord(item.metadata) ? item.metadata : {}; @@ -6367,6 +6481,8 @@ function formatTextOutput(value: unknown, formatter: FormatterId | undefined): s ["note", isRecord(value) ? value.note : null], ]); } + case "linear-quick-view": + return formatLinearQuickView(value); case "lanes": return renderLaneGraph(value); case "lane-detail": diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 9bfd53440..db732c7a6 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -170,6 +170,7 @@ type HeadlessLinearServices = { truncated: boolean; totalEntries: number; }>; + previewSessionToolNames: (args?: { sessionId?: string | null }) => Promise<string[]>; createSession: (args: { laneId: string; title?: string }) => Promise<HeadlessAgentChatSession>; updateSession: (args: { sessionId: string; title?: string | null }) => Promise<HeadlessAgentChatSession>; sendMessage: (args: { sessionId: string; text: string }) => Promise<void>; @@ -524,6 +525,9 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ totalEntries: source.length, }; }, + async previewSessionToolNames() { + return []; + }, async createSession(args: { laneId: string; title?: string }) { return ensureSession({ laneId: args.laneId, title: args.title }); }, diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index f7264b8ea..ca61b2249 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -16,12 +16,14 @@ "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/space-grotesk": "^5.2.10", "@huggingface/transformers": "^3.8.1", + "@linear/sdk": "^84.0.0", "@lobehub/fluent-emoji": "^4.1.0", "@lobehub/icons": "^5.2.0", "@lobehub/icons-static-svg": "^1.84.0", "@lobehub/ui": "^5.6.3", "@opencode-ai/sdk": "^1.4.2", "@phosphor-icons/react": "^2.1.10", + "@pierre/diffs": "^1.1.21", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-tabs": "^1.1.13", @@ -2238,6 +2240,15 @@ "react-dom": "^16 || ^17 || ^18 || ^19" } }, + "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/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -3035,6 +3046,18 @@ "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/@lit-labs/ssr-dom-shim": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz", @@ -3660,12 +3683,11 @@ } }, "node_modules/@pierre/diffs": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.1.8.tgz", - "integrity": "sha512-eS9cardFDJ9B8Z2V+c2YIcPphkS/aY4TA4o8xgItcXEX+Bq2vZ/3BKGlY4r4BdfSLvo+VHenI2HhOSl8Ax1jgg==", - "license": "apache-2.0", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.1.21.tgz", + "integrity": "sha512-4vz4YRg1qZEiVwx6EnaYlMSIIDOq1CvtcBEc4b/gNxDbQtlvGJof+IWH5cv/bwgDre377Txe/ML4zoSp78yWWw==", "dependencies": { - "@pierre/theme": "0.0.22", + "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", @@ -3755,10 +3777,9 @@ } }, "node_modules/@pierre/theme": { - "version": "0.0.22", - "resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-0.0.22.tgz", - "integrity": "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA==", - "license": "MIT", + "version": "0.0.28", + "resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-0.0.28.tgz", + "integrity": "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw==", "engines": { "vscode": "^1.0.0" } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e9e2dee6a..a832f329e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,9 +7,10 @@ "author": "Arul Sharma", "license": "AGPL-3.0", "scripts": { - "predev": "node ./scripts/clear-vite-cache.cjs && node ./scripts/normalize-runtime-binaries.cjs", + "predev": "node ./scripts/normalize-runtime-binaries.cjs", "prebuild": "node ./scripts/normalize-runtime-binaries.cjs && npm run ade:build", "dev": "node ./scripts/ensure-electron.cjs && node ./scripts/dev.cjs", + "dev:clean": "node ./scripts/clear-vite-cache.cjs && node ./scripts/normalize-runtime-binaries.cjs && node ./scripts/ensure-electron.cjs && node ./scripts/dev.cjs --force-vite", "predev:vite": "node ./scripts/export-browser-mock-ade-snapshot.mjs --optional", "dev:vite": "vite --port 5173 --strictPort", "export:browser-mock-ade": "node ./scripts/export-browser-mock-ade-snapshot.mjs", @@ -53,12 +54,14 @@ "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/space-grotesk": "^5.2.10", "@huggingface/transformers": "^3.8.1", + "@linear/sdk": "^84.0.0", "@lobehub/fluent-emoji": "^4.1.0", "@lobehub/icons": "^5.2.0", "@lobehub/icons-static-svg": "^1.84.0", "@lobehub/ui": "^5.6.3", "@opencode-ai/sdk": "^1.4.2", "@phosphor-icons/react": "^2.1.10", + "@pierre/diffs": "^1.1.21", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-tabs": "^1.1.13", diff --git a/apps/desktop/resources/ade-cli-help.txt b/apps/desktop/resources/ade-cli-help.txt index af5bf09fe..310a97705 100644 --- a/apps/desktop/resources/ade-cli-help.txt +++ b/apps/desktop/resources/ade-cli-help.txt @@ -268,6 +268,7 @@ _ ____ _____ $ ade lanes list --text Show lane stack graph and branch names $ ade lanes show <lane> --text Inspect one lane status $ ade lanes create --name <name> Create a lane from the current project context + $ ade lanes create --linear-issue-json '{...}' Create a lane linked to a Linear issue $ ade lanes child --lane <parent> --name <name> Create a child lane under a parent $ ade lanes import --branch <branch> Register an existing branch/worktree $ ade lanes archive <lane> Archive a lane in ADE @@ -291,7 +292,7 @@ _ ____ _____ $ ade git stage --lane <lane> src/file.ts Stage one file $ ade git stage-all --lane <lane> Stage all current changes $ ade git unstage --lane <lane> src/file.ts Unstage one file - $ ade git commit --lane <lane> [-m <message>] Commit, generating a message when omitted + $ ade git commit --lane <lane> [-m <message>] Commit, adding Refs <issue-id> on linked Linear lanes $ ade git push --lane <lane> --set-upstream Push through ADE $ ade git branches --lane <lane> --text List branches with last-commit metadata $ ade git user-identity --lane <lane> --text Read lane checkout's git user.name/email @@ -349,6 +350,7 @@ _ ____ _____ $ ade prs list --text List PRs known to ADE $ ade prs list-open --text List every open GitHub PR in the repo, keyed by head branch $ ade prs create --lane <lane> --base main Open and map a GitHub PR from a lane + $ ade prs create --lane <lane> --close-linear-issue-on-merge $ ade prs link --lane <lane> --url <pr-url> Map an existing GitHub PR to a lane $ ade prs checks <pr> --text Show check status $ ade prs comments <pr> --text Show unresolved review work @@ -477,6 +479,7 @@ _ ____ _____ Linear workflows + $ ade linear quick-view --text Show connected workspace, projects, and issues $ ade linear workflows --text List configured workflows $ ade linear sync dashboard --text Show sync dashboard $ ade linear sync run Trigger a sync run diff --git a/apps/desktop/scripts/dev.cjs b/apps/desktop/scripts/dev.cjs index c56e07a67..5720f9eaf 100644 --- a/apps/desktop/scripts/dev.cjs +++ b/apps/desktop/scripts/dev.cjs @@ -8,6 +8,8 @@ const path = require("node:path"); const projectRoot = path.resolve(__dirname, ".."); const distMainFile = path.join(projectRoot, "dist", "main", "main.cjs"); const npxCommand = "npx"; +const forceViteOptimize = + process.argv.includes("--force-vite") || process.env.ADE_VITE_FORCE_OPTIMIZE === "1"; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -248,7 +250,9 @@ async function main() { process.on("SIGTERM", () => teardown("SIGTERM")); process.on("exit", () => teardown("SIGTERM")); - const vite = spawnProcess("renderer", npxCommand, ["vite", "--port", String(devPort), "--strictPort", "--force"]); + const viteArgs = ["vite", "--port", String(devPort), "--strictPort"]; + if (forceViteOptimize) viteArgs.push("--force"); + const vite = spawnProcess("renderer", npxCommand, viteArgs); const main = spawnProcess("main", npxCommand, ["tsup", "--watch"]); children.add(vite); children.add(main); diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 7a3a4f928..9b4605abf 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { ADE_ACTION_ALLOWLIST, + isCtoOnlyAdeAction, isAllowedAdeAction, listAllowedAdeActionNames, type AdeActionDomain, @@ -98,6 +99,13 @@ describe("listAllowedAdeActionNames", () => { }); }); +describe("isCtoOnlyAdeAction", () => { + it("keeps Path to Merge actions CTO-only", () => { + expect(isCtoOnlyAdeAction("path_to_merge", "startPathToMerge")).toBe(true); + expect(isCtoOnlyAdeAction("path_to_merge", "stopPathToMerge")).toBe(true); + }); +}); + describe("ADE_ACTION_ALLOWLIST shape", () => { it("has no duplicate action names within any domain", () => { // A duplicate would be a silent footgun: the sort would keep both, diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index dd0a8656c..cb28bd55a 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -31,6 +31,7 @@ export const ADE_ACTION_DOMAIN_NAMES = [ "operation", "project_config", "issue_inventory", + "path_to_merge", "flow_policy", "linear_credentials", "linear_dispatcher", @@ -70,6 +71,7 @@ export type AdeActionRole = "cto" | "orchestrator" | "agent" | "external" | "eva * must be listed here. */ export const ADE_ACTION_CTO_ONLY: Partial<Record<AdeActionDomain, readonly string[]>> = { + path_to_merge: ["startPathToMerge", "stopPathToMerge"], linear_credentials: [ "setToken", "setOAuthToken", @@ -159,7 +161,7 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri "unstageFile", "unstagePaths", ], - diff: ["getChanges", "getFileDiff"], + diff: ["getChanges", "getFileDiff", "getFilePatch"], conflicts: ["getLaneStatus", "listOverlaps", "rebaseLane", "runPrediction"], pr: [ "addComment", @@ -306,6 +308,10 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri "savePipelineSettings", "syncFromPrData", ], + path_to_merge: [ + "startPathToMerge", + "stopPathToMerge", + ], flow_policy: [ "diffPolicyPaths", "getPolicy", @@ -631,6 +637,7 @@ export function getAdeActionDomainServices( operation: toService(runtime.operationService), 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_dispatcher: toService(runtime.linearDispatcherService), diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 7cf3840f3..acf836871 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -585,7 +585,8 @@ import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { mapPermissionToClaude, mapPermissionToCodex } from "../orchestrator/permissionMapping"; import { acquireCursorSdkConnection } from "./cursorSdkPool"; import { acquireDroidAcpConnection } from "./droidAcpPool"; -import type { AgentChatEvent, AgentChatEventEnvelope, ComputerUseBackendStatus } from "../../../shared/types"; +import type { AgentChatEvent, AgentChatEventEnvelope, ComputerUseBackendStatus, LaneLinearIssue } from "../../../shared/types"; +import { makeLinearIssueContextAttachment } from "../../../shared/chatContextAttachments"; import { createDynamicOpenCodeModelDescriptor, replaceDynamicOpenCodeModelDescriptors, @@ -914,6 +915,38 @@ async function waitForSessionTitle(sessionService: ReturnType<typeof createMockS }, { timeout: 1_000 }); } +function makeLaneLinearIssue(overrides: Partial<LaneLinearIssue> = {}): LaneLinearIssue { + return { + id: "issue-1", + identifier: "ADE-123", + title: "Attach Linear context to chat", + description: "Use this issue as prompt context.", + url: "https://linear.app/ade/issue/ADE-123/attach-linear-context-to-chat", + projectId: "project-1", + projectSlug: "ade", + projectName: "ADE", + teamId: "team-1", + teamKey: "ADE", + teamName: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 2, + priorityLabel: "high", + labels: ["desktop"], + assigneeId: "user-1", + assigneeName: "Arul", + creatorId: "user-2", + creatorName: "Annie", + dueDate: null, + estimate: null, + branchName: "ade-123-attach-linear-context-to-chat", + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + ...overrides, + }; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -4161,6 +4194,91 @@ describe("createAgentChatService", () => { expect(userMessage.event.attachments).toEqual([{ path: "note.txt", type: "file" }]); }); + it("injects Linear issue context into Codex prompts and public user events", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + }, + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + const contextAttachment = makeLinearIssueContextAttachment(makeLaneLinearIssue(), "manual"); + + await service.sendMessage({ + sessionId: session.id, + text: "Plan the implementation.", + contextAttachments: [contextAttachment], + }); + + const userMessage = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: { type: "user_message"; contextAttachments?: unknown[] } } => + event.event.type === "user_message", + ); + expect(userMessage.event.contextAttachments).toHaveLength(1); + expect(userMessage.event.contextAttachments?.[0]).toMatchObject({ + type: "linear_issue", + issue: { + id: "issue-1", + identifier: "ADE-123", + title: "Attach Linear context to chat", + }, + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + const turnParams = turnStartRequest?.params as { input?: Array<{ text?: unknown }> } | undefined; + const textInput = turnParams?.input?.map((entry) => String(entry.text ?? "")).join("\n") ?? ""; + expect(textInput).toContain("Attached issue context"); + expect(textInput).toContain("- Identifier: ADE-123"); + expect(textInput).toContain("Attach Linear context to chat"); + expect(textInput).toContain("do not ask the user for a Linear API key"); + expect(textInput).toContain("Plan the implementation."); + }); + + it("dispatches context-only Linear issue sends with a fallback prompt", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + }, + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "", + contextAttachments: [makeLinearIssueContextAttachment(makeLaneLinearIssue(), "manual")], + }); + + const userMessage = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: { type: "user_message"; text: string; contextAttachments?: unknown[] } } => + event.event.type === "user_message", + ); + expect(userMessage.event.text).toBe("Use the attached issue context."); + expect(userMessage.event.contextAttachments).toHaveLength(1); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + const turnParams = turnStartRequest?.params as { input?: Array<{ text?: unknown }> } | undefined; + const textInput = turnParams?.input?.map((entry) => String(entry.text ?? "")).join("\n") ?? ""; + expect(textInput).toContain("Attached issue context"); + expect(textInput).toContain("Use the attached issue context."); + }); + it("prefers the canonical turn-scoped Codex text stream when item-scoped deltas also arrive", async () => { const textEvents: Array<{ text: string; itemId?: string; turnId?: string }> = []; const { service } = createService({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 0eac27f3f..57d315d12 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -94,6 +94,7 @@ import type { AgentChatExecutionMode, AgentChatEvent, AgentChatEventEnvelope, + AgentChatContextAttachment, AgentChatFileRef, AgentChatHandoffArgs, AgentChatHandoffResult, @@ -134,6 +135,10 @@ import type { TerminalToolType, CtoCapabilityMode, } from "../../../shared/types"; +import { + buildChatContextAttachmentPrompt, + normalizeChatContextAttachments, +} from "../../../shared/chatContextAttachments"; import { getDefaultModelDescriptor, getDynamicOpenCodeModelDescriptors, @@ -361,6 +366,7 @@ type PersistedPendingSteer = { steerId: string; text: string; attachments?: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; }; type PendingRpc = { @@ -437,6 +443,7 @@ type QueuedSteer = { steerId: string; text: string; attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; resolvedAttachments: ResolvedAgentChatFileRef[]; }; @@ -1170,6 +1177,7 @@ type PreparedSendMessage = { promptText: string; visibleText: string; attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; resolvedAttachments: ResolvedAgentChatFileRef[]; reasoningEffort?: string | null; interactionMode?: AgentChatInteractionMode | null; @@ -6318,6 +6326,7 @@ export function createAgentChatService(args: { steerId: s.steerId, text: s.text, ...(s.attachments.length ? { attachments: s.attachments } : {}), + ...(s.contextAttachments.length ? { contextAttachments: s.contextAttachments } : {}), })), } : prevPersisted?.pendingSteers?.length ? { pendingSteers: prevPersisted.pendingSteers } : {}), @@ -7540,6 +7549,7 @@ export function createAgentChatService(args: { text: string; displayText?: string; attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; turnId?: string; laneDirectiveKey?: string | null; onDispatched?: () => void; @@ -7552,6 +7562,7 @@ export function createAgentChatService(args: { ? { displayText: args.displayText.trim() } : {}), attachments: args.attachments, + ...(args.contextAttachments.length ? { contextAttachments: args.contextAttachments } : {}), ...(args.turnId ? { turnId: args.turnId } : {}), }); args.onDispatched?.(); @@ -7578,6 +7589,7 @@ export function createAgentChatService(args: { userText?: string; displayText?: string; attachments?: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; resolvedAttachments?: ResolvedAgentChatFileRef[]; laneDirectiveKey?: string | null; providerSlashCommand?: boolean; @@ -7597,6 +7609,7 @@ export function createAgentChatService(args: { } const runtime = managed.runtime; const attachments = args.attachments ?? []; + const contextAttachments = args.contextAttachments ?? []; const resolvedAttachments = args.resolvedAttachments ?? attachments.map((attachment) => ({ ...attachment, _resolvedPath: attachment.path, @@ -7617,6 +7630,7 @@ export function createAgentChatService(args: { text: userText, displayText, attachments, + contextAttachments, laneDirectiveKey: args.laneDirectiveKey, onDispatched: markDispatched, }); @@ -7865,6 +7879,7 @@ export function createAgentChatService(args: { userText?: string; displayText?: string; attachments?: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; resolvedAttachments?: ResolvedAgentChatFileRef[]; laneDirectiveKey?: string | null; providerSlashCommand?: boolean; @@ -7891,6 +7906,7 @@ export function createAgentChatService(args: { setSessionActive(managed); const attachments = args.attachments ?? []; + const contextAttachments = args.contextAttachments ?? []; const resolvedAttachments = args.resolvedAttachments ?? attachments.map((attachment) => ({ ...attachment, _resolvedPath: attachment.path, @@ -7902,6 +7918,7 @@ export function createAgentChatService(args: { text: userText, displayText, attachments, + contextAttachments, turnId, laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, @@ -8960,6 +8977,7 @@ export function createAgentChatService(args: { userText?: string; displayText?: string; attachments?: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; resolvedAttachments?: ResolvedAgentChatFileRef[]; laneDirectiveKey?: string | null; providerSlashCommand?: boolean; @@ -8987,6 +9005,7 @@ export function createAgentChatService(args: { runtime.interrupted = false; setSessionActive(managed); const attachments = args.attachments ?? []; + const contextAttachments = args.contextAttachments ?? []; const resolvedAttachments = args.resolvedAttachments ?? attachments.map((attachment) => ({ ...attachment, _resolvedPath: attachment.path, @@ -8998,6 +9017,7 @@ export function createAgentChatService(args: { text: userText, displayText, attachments, + contextAttachments, turnId, laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, @@ -11685,6 +11705,7 @@ export function createAgentChatService(args: { laneWorktreePath: executionContext.laneWorktreePath, }) : null, + buildChatContextAttachmentPrompt(nextSteer.contextAttachments) || null, ]); if (runtime.kind === "claude") { @@ -11692,6 +11713,7 @@ export function createAgentChatService(args: { promptText, displayText: trimmed, attachments: nextSteer.attachments, + contextAttachments: nextSteer.contextAttachments, resolvedAttachments: nextSteer.resolvedAttachments, laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, }); @@ -11700,6 +11722,7 @@ export function createAgentChatService(args: { promptText, displayText: trimmed, attachments: nextSteer.attachments, + contextAttachments: nextSteer.contextAttachments, resolvedAttachments: nextSteer.resolvedAttachments, laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, }); @@ -11708,6 +11731,7 @@ export function createAgentChatService(args: { promptText, displayText: trimmed, attachments: [], + contextAttachments: nextSteer.contextAttachments, resolvedAttachments: [], laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, }); @@ -11716,6 +11740,7 @@ export function createAgentChatService(args: { promptText, displayText: trimmed, attachments: nextSteer.attachments, + contextAttachments: nextSteer.contextAttachments, resolvedAttachments: nextSteer.resolvedAttachments, laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, }); @@ -11732,6 +11757,7 @@ export function createAgentChatService(args: { steerId: string, text: string, attachments: AgentChatFileRef[] = [], + contextAttachments: AgentChatContextAttachment[] = [], resolvedAttachments: ResolvedAgentChatFileRef[] = [], ): boolean => { if (runtime.pendingSteers.length >= MAX_PENDING_STEERS) { @@ -11744,11 +11770,12 @@ export function createAgentChatService(args: { }); return false; } - runtime.pendingSteers.push({ steerId, text, attachments, resolvedAttachments }); + runtime.pendingSteers.push({ steerId, text, attachments, contextAttachments, resolvedAttachments }); emitChatEvent(managed, { type: "user_message", text, ...(attachments.length ? { attachments } : {}), + ...(contextAttachments.length ? { contextAttachments } : {}), steerId, turnId: runtime.activeTurnId ?? undefined, deliveryState: "queued", @@ -11933,6 +11960,7 @@ export function createAgentChatService(args: { && typeof (a as AgentChatFileRef).path === "string" && ((a as AgentChatFileRef).type === "file" || (a as AgentChatFileRef).type === "image")) : []; + const contextAttachments = normalizeChatContextAttachments(entry.contextAttachments); let resolvedAttachments: ResolvedAgentChatFileRef[] = []; try { resolvedAttachments = attachments.map((attachment) => { @@ -11952,7 +11980,7 @@ export function createAgentChatService(args: { }); continue; } - out.push({ steerId: entry.steerId, text, attachments, resolvedAttachments }); + out.push({ steerId: entry.steerId, text, attachments, contextAttachments, resolvedAttachments }); if (out.length >= MAX_PENDING_STEERS) break; } return out; @@ -12633,6 +12661,7 @@ export function createAgentChatService(args: { text, displayText, attachments = [], + contextAttachments = [], reasoningEffort, executionMode, interactionMode, @@ -12640,7 +12669,11 @@ export function createAgentChatService(args: { cloudOverrides, allowActiveSession = false, }: AgentChatSendArgs & { allowActiveSession?: boolean }): PreparedSendMessage | null => { - const trimmed = text.trim(); + const publicContextAttachments = normalizeChatContextAttachments(contextAttachments); + const trimmedText = text.trim(); + const trimmed = trimmedText.length || !publicContextAttachments.length + ? trimmedText + : "Use the attached issue context."; if (!trimmed.length) return null; const slashCommand = extractLeadingSlashCommand(trimmed); const providerSlashCommand = isProviderSlashCommandInput(trimmed); @@ -12751,6 +12784,9 @@ export function createAgentChatService(args: { && !codexRuntimeSlashCommandNames.has(slashCommand) ? resolveCodexSlashCommandInvocation(managed.laneWorktreePath, trimmed) : null; + const contextAttachmentPrompt = providerSlashCommand + ? "" + : buildChatContextAttachmentPrompt(publicContextAttachments); const promptText = providerSlashCommand ? expandedClaudeSlashCommand?.promptText ?? expandedCodexSlashCommand?.promptText ?? trimmed : composeLaunchDirectives(trimmed, [ @@ -12766,6 +12802,7 @@ export function createAgentChatService(args: { buildComputerUseDirective( computerUseArtifactBrokerRef?.getBackendStatus() ?? null, ), + contextAttachmentPrompt || null, ]); const autoTitleSeed = providerSlashCommand ? expandedClaudeSlashCommand?.promptText ?? expandedCodexSlashCommand?.promptText ?? null @@ -12790,6 +12827,7 @@ export function createAgentChatService(args: { promptText, visibleText, attachments: publicAttachments, + contextAttachments: publicContextAttachments, resolvedAttachments, reasoningEffort, interactionMode: managed.session.provider === "claude" ? managed.session.interactionMode ?? "default" : null, @@ -13175,6 +13213,7 @@ export function createAgentChatService(args: { promptText: text, displayText: "", attachments: [], + contextAttachments: [], resolvedAttachments: [], optimisticCursorTurnStart: true, }); @@ -14250,6 +14289,7 @@ export function createAgentChatService(args: { userText?: string; displayText: string; attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; resolvedAttachments: ResolvedAgentChatFileRef[]; laneDirectiveKey?: string | null; turnId?: string; @@ -14277,6 +14317,7 @@ export function createAgentChatService(args: { text: userText, displayText, attachments: args.attachments, + contextAttachments: args.contextAttachments, turnId, laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, @@ -14608,6 +14649,7 @@ export function createAgentChatService(args: { userText?: string; displayText: string; attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; resolvedAttachments: ResolvedAgentChatFileRef[]; laneDirectiveKey?: string | null; turnId?: string; @@ -14641,6 +14683,7 @@ export function createAgentChatService(args: { text: userText, displayText, attachments: args.attachments, + contextAttachments: args.contextAttachments, turnId, laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, @@ -14891,6 +14934,7 @@ export function createAgentChatService(args: { promptText: trimmedPrompt, displayText: trimmedPrompt, attachments: [], + contextAttachments: [], resolvedAttachments: [], }); const last = matched.runtime?.kind === "cursor" ? matched.runtime.activeCloudRunId : null; @@ -15338,6 +15382,7 @@ export function createAgentChatService(args: { userText?: string; displayText: string; attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; resolvedAttachments: ResolvedAgentChatFileRef[]; laneDirectiveKey?: string | null; turnId?: string; @@ -15383,6 +15428,7 @@ export function createAgentChatService(args: { text: userText, displayText, attachments: args.attachments, + contextAttachments: args.contextAttachments, turnId, laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, @@ -15622,6 +15668,7 @@ export function createAgentChatService(args: { promptText, visibleText, attachments, + contextAttachments, resolvedAttachments, reasoningEffort, laneDirectiveKey, @@ -15662,6 +15709,7 @@ export function createAgentChatService(args: { userText: submittedText, displayText: visibleText, attachments, + contextAttachments, resolvedAttachments, laneDirectiveKey, providerSlashCommand, @@ -15688,6 +15736,7 @@ export function createAgentChatService(args: { userText: submittedText, displayText: visibleText, attachments, + contextAttachments, resolvedAttachments, laneDirectiveKey, turnId, @@ -15702,6 +15751,7 @@ export function createAgentChatService(args: { userText: submittedText, displayText: visibleText, attachments, + contextAttachments, resolvedAttachments, laneDirectiveKey, turnId, @@ -15723,6 +15773,7 @@ export function createAgentChatService(args: { userText: submittedText, displayText: visibleText, attachments, + contextAttachments, resolvedAttachments, laneDirectiveKey, turnId, @@ -15846,6 +15897,7 @@ export function createAgentChatService(args: { userText: submittedText, displayText: visibleText, attachments, + contextAttachments, resolvedAttachments, laneDirectiveKey, providerSlashCommand, @@ -15870,6 +15922,7 @@ export function createAgentChatService(args: { userText: submittedText, displayText: visibleText, attachments, + contextAttachments, resolvedAttachments, laneDirectiveKey, providerSlashCommand, @@ -15916,6 +15969,7 @@ export function createAgentChatService(args: { text: prepared.submittedText, ...(prepared.visibleText !== prepared.submittedText ? { displayText: prepared.visibleText } : {}), attachments: prepared.attachments, + ...(prepared.contextAttachments.length ? { contextAttachments: prepared.contextAttachments } : {}), turnId, }); emitChatEvent(prepared.managed, { type: "status", turnStatus: "started", turnId }); @@ -15939,6 +15993,7 @@ export function createAgentChatService(args: { text: prepared.submittedText, displayText: prepared.visibleText, attachments: prepared.attachments, + contextAttachments: prepared.contextAttachments, laneDirectiveKey: prepared.laneDirectiveKey, }); emitChatEvent(prepared.managed, { type: "status", turnStatus: "started" }); @@ -15973,10 +16028,12 @@ export function createAgentChatService(args: { } }; - const steer = async ({ sessionId, text, attachments = [] }: AgentChatSteerArgs): Promise<AgentChatSteerResult> => { + const steer = async ({ sessionId, text, attachments = [], contextAttachments = [] }: AgentChatSteerArgs): Promise<AgentChatSteerResult> => { const trimmed = text.trim(); const steerId = randomUUID(); - if (!trimmed.length) { + // Allow context-only steers: if text is empty but issue context attachments + // are present, prepareSendMessage will substitute a fallback prompt. + if (!trimmed.length && contextAttachments.length === 0) { return { steerId, queued: false }; } @@ -15994,6 +16051,7 @@ export function createAgentChatService(args: { text: trimmed, displayText: trimmed, attachments, + contextAttachments, }); if (!preparedSteer) { return { steerId, queued: false }; @@ -16005,6 +16063,7 @@ export function createAgentChatService(args: { steerId, preparedSteer.visibleText, preparedSteer.attachments, + preparedSteer.contextAttachments, preparedSteer.resolvedAttachments, ); return { steerId, queued: true }; @@ -16014,6 +16073,7 @@ export function createAgentChatService(args: { text: trimmed, displayText: trimmed, attachments, + contextAttachments, }); if (!preparedSteer) { return { steerId, queued: false }; @@ -16030,6 +16090,7 @@ export function createAgentChatService(args: { text: trimmed, displayText: trimmed, attachments, + contextAttachments, allowActiveSession: true, }); if (!preparedSteer) { @@ -16049,12 +16110,14 @@ export function createAgentChatService(args: { steerId, text: preparedSteer.visibleText, attachments: preparedSteer.attachments, + contextAttachments: preparedSteer.contextAttachments, resolvedAttachments: preparedSteer.resolvedAttachments, }); emitChatEvent(managed, { type: "user_message", text: preparedSteer.visibleText, ...(preparedSteer.attachments.length ? { attachments: preparedSteer.attachments } : {}), + ...(preparedSteer.contextAttachments.length ? { contextAttachments: preparedSteer.contextAttachments } : {}), steerId, turnId: rt.activeTurnId ?? undefined, deliveryState: "queued", @@ -16074,6 +16137,7 @@ export function createAgentChatService(args: { text: trimmed, displayText: trimmed, attachments, + contextAttachments, }); if (!preparedSteer) { return { steerId, queued: false }; @@ -16085,6 +16149,17 @@ export function createAgentChatService(args: { if (managed.session.provider === "droid") { if (managed.runtime?.kind === "droid" && managed.runtime.busy) { const rt = managed.runtime; + const preparedSteer = prepareSendMessage({ + sessionId, + text: trimmed, + displayText: trimmed, + attachments: [], + contextAttachments, + allowActiveSession: true, + }); + if (!preparedSteer) { + return { steerId, queued: false }; + } if (rt.pendingSteers.length >= MAX_PENDING_STEERS) { logger.warn("agent_chat.steer_queue_full", { sessionId, queueSize: rt.pendingSteers.length }); emitChatEvent(managed, { @@ -16095,10 +16170,17 @@ export function createAgentChatService(args: { }); return { steerId, queued: false }; } - rt.pendingSteers.push({ steerId, text: trimmed, attachments: [], resolvedAttachments: [] }); + rt.pendingSteers.push({ + steerId, + text: preparedSteer.submittedText, + attachments: [], + contextAttachments: preparedSteer.contextAttachments, + resolvedAttachments: [], + }); emitChatEvent(managed, { type: "user_message", - text: trimmed, + text: preparedSteer.visibleText, + ...(preparedSteer.contextAttachments.length ? { contextAttachments: preparedSteer.contextAttachments } : {}), steerId, turnId: rt.activeTurnId ?? undefined, deliveryState: "queued", @@ -16118,6 +16200,7 @@ export function createAgentChatService(args: { text: trimmed, displayText: trimmed, attachments: [], + contextAttachments, }); if (!preparedSteer) { return { steerId, queued: false }; @@ -16138,6 +16221,7 @@ export function createAgentChatService(args: { text: trimmed, displayText: trimmed, attachments, + contextAttachments, }); if (!preparedSteer) { return { steerId, queued: false }; @@ -16146,10 +16230,18 @@ export function createAgentChatService(args: { const input: Array<Record<string, unknown>> = [ { type: "text", - text: trimmed, + text: preparedSteer.submittedText, text_elements: [], }, ]; + const contextPrompt = buildChatContextAttachmentPrompt(preparedSteer.contextAttachments); + if (contextPrompt) { + input.unshift({ + type: "text", + text: contextPrompt, + text_elements: [], + }); + } for (const attachment of preparedSteer.resolvedAttachments) { const stagedPath = stageAttachmentForCodexInput(attachment); if (attachment.type === "image") { @@ -16169,6 +16261,7 @@ export function createAgentChatService(args: { type: "user_message", text: preparedSteer.visibleText, ...(preparedSteer.attachments.length ? { attachments: preparedSteer.attachments } : {}), + ...(preparedSteer.contextAttachments.length ? { contextAttachments: preparedSteer.contextAttachments } : {}), steerId, deliveryState: "delivered", turnId: runtime.activeTurnId, @@ -16182,6 +16275,7 @@ export function createAgentChatService(args: { text: trimmed, displayText: trimmed, attachments, + contextAttachments, }); if (!preparedSteer) { return { steerId, queued: false }; @@ -16194,6 +16288,7 @@ export function createAgentChatService(args: { steerId, preparedSteer.visibleText, preparedSteer.attachments, + preparedSteer.contextAttachments, preparedSteer.resolvedAttachments, ); return { steerId, queued: true }; @@ -16296,6 +16391,7 @@ export function createAgentChatService(args: { text: steer.text, displayText: steer.text, attachments: steer.attachments, + contextAttachments: steer.contextAttachments, }); if (!prepared) { logger.warn("agent_chat.dispatch_steer_inline_drop_skipped", { @@ -16315,7 +16411,9 @@ export function createAgentChatService(args: { // to the in-flight transcript and the model picks it up at the next // thinking step (verified in the V2 mid-turn spike, test C). const dispatchUuid = randomUUID(); - const sdkMsg = buildClaudeV2Message(steer.text, steer.resolvedAttachments, { + const contextPrompt = buildChatContextAttachmentPrompt(steer.contextAttachments); + const inlineSteerText = contextPrompt ? `${contextPrompt}\n\n${steer.text}` : steer.text; + const sdkMsg = buildClaudeV2Message(inlineSteerText, steer.resolvedAttachments, { baseDir: managed.laneWorktreePath, sessionId: runtime.sdkSessionId ?? null, forceUserMessage: true, @@ -16342,6 +16440,7 @@ export function createAgentChatService(args: { type: "user_message", text: steer.text, ...(steer.attachments.length ? { attachments: steer.attachments } : {}), + ...(steer.contextAttachments.length ? { contextAttachments: steer.contextAttachments } : {}), steerId, deliveryState: "inline", turnId: runtime.activeTurnId ?? undefined, diff --git a/apps/desktop/src/main/services/cto/issueTracker.ts b/apps/desktop/src/main/services/cto/issueTracker.ts index a1e235701..8782dfccd 100644 --- a/apps/desktop/src/main/services/cto/issueTracker.ts +++ b/apps/desktop/src/main/services/cto/issueTracker.ts @@ -1,5 +1,6 @@ import type { CtoLinearProject, + CtoLinearQuickView, LinearCatalogLabel, LinearCatalogState, LinearCatalogUser, @@ -11,6 +12,27 @@ export type IssueTrackerCandidateQuery = { stateTypes: string[]; }; +export type IssueTrackerIssueSearchQuery = { + projectId?: string | null; + projectSlug?: string | null; + teamKey?: string | null; + stateTypes?: string[]; + assigneeId?: string | null; + priority?: number | null; + query?: string | null; + first?: number; + after?: string | null; + includeArchived?: boolean; +}; + +export type IssueTrackerIssueSearchResult = { + issues: NormalizedLinearIssue[]; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; +}; + export type IssueTrackerWorkpadResult = { commentId: string; }; @@ -25,8 +47,10 @@ export type IssueTrackerWorkflowState = { export type IssueTracker = { listProjects(): Promise<CtoLinearProject[]>; + getQuickView(connection: CtoLinearQuickView["connection"]): Promise<CtoLinearQuickView>; listUsers(): Promise<LinearCatalogUser[]>; listLabels(teamKey?: string | null): Promise<LinearCatalogLabel[]>; + searchIssues(query: IssueTrackerIssueSearchQuery): Promise<IssueTrackerIssueSearchResult>; fetchCandidateIssues(query: IssueTrackerCandidateQuery): Promise<NormalizedLinearIssue[]>; fetchIssueById(issueId: string): Promise<NormalizedLinearIssue | null>; fetchIssuesByIds(issueIds: string[]): Promise<Map<string, NormalizedLinearIssue>>; @@ -42,6 +66,10 @@ export type IssueTracker = { connected: boolean; viewerId: string | null; viewerName: string | null; + organizationId?: string | null; + organizationName?: string | null; + organizationUrlKey?: string | null; + organizationLogoUrl?: string | null; message: string | null; }>; }; diff --git a/apps/desktop/src/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts index 6d7e3d446..68876af3a 100644 --- a/apps/desktop/src/main/services/cto/linearAuth.test.ts +++ b/apps/desktop/src/main/services/cto/linearAuth.test.ts @@ -371,22 +371,37 @@ describe("linearOAuthService", () => { expect(session.error).toContain("User declined"); }); - it("handles OAuth callback with state mismatch", async () => { + it("rejects stale OAuth callbacks without failing the active session", async () => { const credentials = createCredentialsMock(); + const mockFetch = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ access_token: "linear-access-token-123" }), + })) as any; const service = createLinearOAuthService({ credentials: credentials as any, logger: createLogger(), + fetchImpl: mockFetch, }); activeServices.push(service); - const { sessionId, redirectUri } = await service.startSession(); + const { sessionId, authUrl, redirectUri } = await service.startSession(); + const stateParam = new URL(authUrl).searchParams.get("state")!; + + const staleCallbackUrl = `${redirectUri}?code=stale-code&state=wrong-state`; + const staleResponse = await httpGet(staleCallbackUrl); - const callbackUrl = `${redirectUri}?code=test-code&state=wrong-state`; + expect(staleResponse.statusCode).toBe(400); + expect(service.getSession(sessionId).status).toBe("pending"); + expect(credentials.setOAuthToken).not.toHaveBeenCalled(); + + const callbackUrl = `${redirectUri}?code=test-code&state=${stateParam}`; await httpGet(callbackUrl); - await waitForSessionStatus(service, sessionId, "failed"); - const session = service.getSession(sessionId); - expect(session.error).toContain("state did not match"); + await waitForSessionStatus(service, sessionId, "completed"); + expect(credentials.setOAuthToken).toHaveBeenCalledWith(expect.objectContaining({ + accessToken: "linear-access-token-123", + })); }); it("handles OAuth callback without authorization code", async () => { @@ -623,6 +638,44 @@ describe("linearClient", () => { expect(fetchImpl).toHaveBeenCalledTimes(2); }); + it("loads connection identity with the authorized Linear workspace", async () => { + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + expect(init?.headers).toMatchObject({ authorization: "Bearer test-token" }); + return new Response( + JSON.stringify({ + data: { + viewer: { id: "viewer-1", displayName: "Alex" }, + organization: { + id: "org-1", + name: "Acme Workspace", + urlKey: "acme", + logoUrl: "https://linear.app/acme/logo.png", + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }); + + const client = createLinearClient({ + credentials: { + getTokenOrThrow: () => "Bearer test-token", + getStatus: () => ({ authMode: "oauth" }), + } as any, + fetchImpl: fetchImpl as any, + logger: null, + }); + + await expect(client.getConnectionIdentity()).resolves.toEqual({ + viewerId: "viewer-1", + viewerName: "Alex", + organizationId: "org-1", + organizationName: "Acme Workspace", + organizationUrlKey: "acme", + organizationLogoUrl: "https://linear.app/acme/logo.png", + }); + }); + it("lists projects with their owning team names", async () => { const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string }; @@ -665,6 +718,66 @@ describe("linearClient", () => { ]); }); + it("searches issues with picker filters and pagination", async () => { + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string; variables?: Record<string, unknown> }; + if (!body.query?.includes("SearchIssues")) { + return new Response(JSON.stringify({ data: {} }), { status: 200, headers: { "content-type": "application/json" } }); + } + expect(body.query).toContain("$filter: IssueFilter"); + expect(body.variables).toMatchObject({ + first: 25, + after: "cursor-1", + includeArchived: false, + filter: { + project: { id: { eq: "project-1" } }, + state: { type: { in: ["unstarted", "started"] } }, + assignee: { id: { eq: "user-1" } }, + priority: { eq: 2 }, + or: [ + { title: { containsIgnoreCase: "auth" } }, + { description: { containsIgnoreCase: "auth" } }, + { identifier: { containsIgnoreCase: "auth" } }, + ], + }, + }); + return new Response( + JSON.stringify({ + data: { + issues: { + pageInfo: { hasNextPage: true, endCursor: "cursor-2" }, + nodes: [makeIssueNode("7", "2026-03-05T00:07:00.000Z")], + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }); + + const client = createLinearClient({ + credentials: { + getTokenOrThrow: () => "lin_api_test", + getStatus: () => ({ authMode: "manual" }), + } as any, + fetchImpl: fetchImpl as any, + logger: null, + }); + + const result = await client.searchIssues({ + projectId: "project-1", + stateTypes: ["unstarted", "started"], + assigneeId: "user-1", + priority: 2, + query: "auth", + first: 25, + after: "cursor-1", + }); + + expect(result.pageInfo).toEqual({ hasNextPage: true, endCursor: "cursor-2" }); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]?.identifier).toBe("ABC-7"); + }); + it("strips a pasted bearer prefix from manual API keys", async () => { const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { expect(init?.headers).toMatchObject({ authorization: "lin_api_test" }); diff --git a/apps/desktop/src/main/services/cto/linearClient.ts b/apps/desktop/src/main/services/cto/linearClient.ts index e2e2adb3e..fa5901dbb 100644 --- a/apps/desktop/src/main/services/cto/linearClient.ts +++ b/apps/desktop/src/main/services/cto/linearClient.ts @@ -1,7 +1,11 @@ import fs from "node:fs"; import path from "node:path"; +import { LinearClient as LinearSdkClient } from "@linear/sdk"; import type { Logger } from "../logging/logger"; import type { + CtoLinearQuickView, + CtoLinearQuickViewProject, + CtoLinearQuickViewTeam, CtoLinearProject, LinearCatalogLabel, LinearCatalogState, @@ -10,6 +14,7 @@ import type { NormalizedLinearIssue, } from "../../../shared/types"; import type { LinearCredentialService } from "./linearCredentialService"; +import type { IssueTrackerIssueSearchQuery, IssueTrackerIssueSearchResult } from "./issueTracker"; import { isRecord, toOptionalString as asString, asArray, sleep, getErrorMessage } from "../shared/utils"; const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"; @@ -33,6 +38,20 @@ function toAuthorizationHeaderValue(token: string, authMode: "manual" | "oauth" return trimmed; } +function toSdkTokenValue(token: string): string { + return token.trim().replace(/^bearer\s+/i, ""); +} + +function priorityIsValid(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 4; +} + +function toIsoString(value: unknown): string | null { + if (value instanceof Date) return value.toISOString(); + if (typeof value === "string" && value.trim().length > 0) return value; + return null; +} + function toNormalizedIssue(node: Record<string, unknown>): NormalizedLinearIssue | null { const id = asString(node.id); const identifier = asString(node.identifier); @@ -45,7 +64,7 @@ function toNormalizedIssue(node: Record<string, unknown>): NormalizedLinearIssue if (!project || !team || !state) return null; const projectId = asString(project.id); - const projectSlug = asString(project.slug); + const projectSlug = asString(project.slug) ?? asString(project.slugId); const teamId = asString(team.id); const teamKey = asString(team.key); const stateId = asString(state.id); @@ -72,8 +91,12 @@ function toNormalizedIssue(node: Record<string, unknown>): NormalizedLinearIssue const assignee = isRecord(node.assignee) ? node.assignee : null; const owner = isRecord(node.creator) ? node.creator : null; - const metadata = isRecord(node.metadata) ? node.metadata : 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, @@ -83,17 +106,17 @@ function toNormalizedIssue(node: Record<string, unknown>): NormalizedLinearIssue url: asString(node.url), projectId, projectSlug, + projectName: asString(project.name), teamId, teamKey, + teamName: asString(team.name), stateId, stateName, stateType, priority: Number.isFinite(priority) ? priority : 0, priorityLabel: mapPriorityLabel(Number.isFinite(priority) ? priority : 0), labels, - metadataTags: asArray(metadata?.tags) - .map((entry) => (typeof entry === "string" ? entry.trim().toLowerCase() : "")) - .filter((entry) => entry.length > 0), + metadataTags, assigneeId: assignee ? asString(assignee.id) : null, assigneeName: assignee ? (asString(assignee.displayName) ?? asString(assignee.name)) : null, ownerId: owner ? asString(owner.id) : null, @@ -101,6 +124,12 @@ function toNormalizedIssue(node: Record<string, unknown>): NormalizedLinearIssue creatorName: owner ? (asString(owner.displayName) ?? asString(owner.name)) : null, blockerIssueIds, hasOpenBlockers, + dueDate: asString(node.dueDate), + estimate: typeof node.estimate === "number" && Number.isFinite(node.estimate) ? node.estimate : null, + archivedAt: asString(node.archivedAt), + completedAt: asString(node.completedAt), + canceledAt: asString(node.canceledAt), + startedAt: asString(node.startedAt), createdAt: asString(node.createdAt) ?? new Date().toISOString(), updatedAt: asString(node.updatedAt) ?? new Date().toISOString(), raw: node, @@ -125,6 +154,14 @@ type LinearWebhookSummary = { export function createLinearClient(args: LinearClientArgs) { const fetchImpl = args.fetchImpl ?? fetch; + const createSdkClient = () => { + const token = toSdkTokenValue(args.credentials.getTokenOrThrow()); + const authMode = args.credentials.getStatus().authMode; + if (authMode === "oauth") return new LinearSdkClient({ accessToken: token }); + if (authMode === "manual") return new LinearSdkClient({ apiKey: token }); + throw new Error("Linear credential auth mode is missing or unknown."); + }; + const request = async <TData = Record<string, unknown>>(params: { query: string; variables?: Record<string, unknown>; @@ -193,47 +230,102 @@ export function createLinearClient(args: LinearClientArgs) { }; }; - const listProjects = async (): Promise<CtoLinearProject[]> => { + const getConnectionIdentity = async (): Promise<{ + viewerId: string | null; + viewerName: string | null; + organizationId: string | null; + organizationName: string | null; + organizationUrlKey: string | null; + organizationLogoUrl: string | null; + }> => { const data = await request<{ - projects?: { - nodes?: Array<Record<string, unknown>>; - }; + viewer?: { id?: string; name?: string; displayName?: string }; + organization?: { id?: string; name?: string; urlKey?: string | null; logoUrl?: string | null }; }>({ query: ` - query Projects { - projects(first: 100) { - nodes { - id - name - slug - teams { - nodes { - name + query LinearConnectionIdentity { + viewer { id name displayName } + organization { id name urlKey logoUrl } + } + `, + maxRetries: 1, + }); + return { + viewerId: asString(data.viewer?.id), + viewerName: asString(data.viewer?.displayName) ?? asString(data.viewer?.name), + organizationId: asString(data.organization?.id), + organizationName: asString(data.organization?.name), + organizationUrlKey: asString(data.organization?.urlKey), + organizationLogoUrl: asString(data.organization?.logoUrl), + }; + }; + + const listProjects = async (): Promise<CtoLinearProject[]> => { + const projects = new Map<string, CtoLinearProject>(); + let after: string | null = null; + for (let page = 0; page < 25; page += 1) { + const data = await request<{ + projects?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + nodes?: Array<Record<string, unknown>>; + }; + }>({ + query: ` + query Projects($after: String) { + projects(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + id + name + slug: slugId + teams { + nodes { + key + name + } } } } } - } - `, - maxRetries: 2, - }); + `, + variables: { after }, + maxRetries: 2, + }); - return asArray(data.projects?.nodes) - .map((node) => { - if (!isRecord(node)) return null; - const id = asString(node.id); - const name = asString(node.name); - const slug = asString(node.slug); - if (!id || !name || !slug) return null; - const teamName = - (isRecord(node.teams) - ? asArray(node.teams.nodes) - .map((entry) => (isRecord(entry) ? asString(entry.name) : null)) - .find((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) - : null) ?? "Unassigned"; - return { id, name, slug, teamName }; - }) - .filter((entry): entry is CtoLinearProject => entry != null) + const pageProjects = asArray(data.projects?.nodes) + .map((node): CtoLinearProject | null => { + if (!isRecord(node)) return null; + const id = asString(node.id); + const name = asString(node.name); + const slug = asString(node.slug); + if (!id || !name || !slug) return null; + const teamName = + (isRecord(node.teams) + ? asArray(node.teams.nodes) + .map((entry) => (isRecord(entry) ? asString(entry.name) : null)) + .find((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + : null) ?? "Unassigned"; + const teamKey = + (isRecord(node.teams) + ? asArray(node.teams.nodes) + .map((entry) => (isRecord(entry) ? asString(entry.key) : null)) + .find((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + : null) ?? null; + return teamKey ? { id, name, slug, teamName, teamKey } : { id, name, slug, teamName }; + }) + .filter((entry): entry is CtoLinearProject => entry != null); + + for (const project of pageProjects) { + projects.set(project.id, project); + } + + if (!data.projects?.pageInfo?.hasNextPage) break; + const nextCursor = asString(data.projects.pageInfo.endCursor); + if (!nextCursor || nextCursor === after) break; + after = nextCursor; + } + + return [...projects.values()] .sort((left, right) => left.name.localeCompare(right.name)); }; @@ -334,12 +426,17 @@ export function createLinearClient(args: LinearClientArgs) { priority createdAt updatedAt - project { id slug } - team { id key } + dueDate + estimate + archivedAt + completedAt + canceledAt + startedAt + project { id name slug: slugId } + team { id key name } state { id name type } assignee { id name displayName } creator { id name displayName } - metadata labels { nodes { id name } } children { nodes { @@ -362,7 +459,7 @@ export function createLinearClient(args: LinearClientArgs) { first: 50, after: $after, filter: { - project: { slug: { eq: $projectSlug } }, + project: { slugId: { eq: $projectSlug } }, state: { type: { in: $stateTypes } } } ) { @@ -413,6 +510,88 @@ export function createLinearClient(args: LinearClientArgs) { return results.flat(); }; + const buildIssueSearchFilter = (params: IssueTrackerIssueSearchQuery): Record<string, unknown> => { + const filter: Record<string, unknown> = {}; + const projectId = params.projectId?.trim(); + const projectSlug = params.projectSlug?.trim(); + const teamKey = params.teamKey?.trim(); + const stateTypes = (params.stateTypes ?? []).map((entry) => entry.trim()).filter(Boolean); + const assigneeId = params.assigneeId?.trim(); + const query = params.query?.trim(); + + if (projectId) { + filter.project = { id: { eq: projectId } }; + } else if (projectSlug) { + filter.project = { slugId: { eq: projectSlug } }; + } + if (teamKey) { + filter.team = { key: { eq: teamKey } }; + } + if (stateTypes.length > 0) { + filter.state = { type: { in: stateTypes } }; + } + if (assigneeId) { + filter.assignee = { id: { eq: assigneeId } }; + } + if (priorityIsValid(params.priority)) { + filter.priority = { eq: params.priority }; + } + if (query) { + filter.or = [ + { title: { containsIgnoreCase: query } }, + { description: { containsIgnoreCase: query } }, + { identifier: { containsIgnoreCase: query } }, + ]; + } + + return filter; + }; + + const searchIssues = async (params: IssueTrackerIssueSearchQuery): Promise<IssueTrackerIssueSearchResult> => { + const first = Math.min(100, Math.max(1, Math.floor(params.first ?? 50))); + const filter = buildIssueSearchFilter(params); + const data = await request<{ + issues?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + nodes?: Array<Record<string, unknown>>; + }; + }>({ + query: ` + query SearchIssues($first: Int!, $after: String, $includeArchived: Boolean!, $filter: IssueFilter) { + issues( + first: $first, + after: $after, + includeArchived: $includeArchived, + orderBy: updatedAt, + filter: $filter + ) { + pageInfo { hasNextPage endCursor } + nodes { + ${ISSUE_FIELDS_FRAGMENT} + } + } + } + `, + variables: { + first, + after: params.after?.trim() || null, + includeArchived: params.includeArchived === true, + filter, + }, + maxRetries: 2, + }); + + return { + issues: asArray(data.issues?.nodes) + .map((entry) => (isRecord(entry) ? toNormalizedIssue(entry) : null)) + .filter((entry): entry is NormalizedLinearIssue => entry != null), + pageInfo: { + hasNextPage: Boolean(data.issues?.pageInfo?.hasNextPage), + endCursor: asString(data.issues?.pageInfo?.endCursor), + }, + }; + }; + const fetchIssueById = async (issueId: string): Promise<NormalizedLinearIssue | null> => { const data = await request<{ issue?: Record<string, unknown> }>({ query: ` @@ -428,6 +607,213 @@ export function createLinearClient(args: LinearClientArgs) { return data.issue && isRecord(data.issue) ? toNormalizedIssue(data.issue) : null; }; + const normalizeSdkIssue = async (issue: Record<string, unknown>): Promise<NormalizedLinearIssue | null> => { + const [project, team, state, assignee, creator, labelsConnection, childrenConnection] = await Promise.all([ + typeof issue.project === "object" ? issue.project : Promise.resolve(null), + typeof issue.team === "object" ? issue.team : Promise.resolve(null), + typeof issue.state === "object" ? issue.state : Promise.resolve(null), + typeof issue.assignee === "object" ? issue.assignee : Promise.resolve(null), + typeof issue.creator === "object" ? issue.creator : Promise.resolve(null), + typeof issue.labels === "function" + ? (issue.labels as (args: { first: number }) => Promise<{ nodes?: unknown[] }>)({ first: 8 }).catch(() => null) + : typeof issue.labels === "object" + ? issue.labels + : Promise.resolve(null), + typeof issue.children === "function" + ? (issue.children as (args: { first: number }) => Promise<{ nodes?: unknown[] }>)({ first: 20 }).catch(() => null) + : typeof issue.children === "object" + ? issue.children + : Promise.resolve(null), + ]); + const childNodes = await Promise.all( + asArray(isRecord(childrenConnection) ? childrenConnection.nodes : []) + .filter(isRecord) + .map(async (child) => { + const childState = typeof child.state === "object" + ? await Promise.resolve(child.state).catch(() => null) + : null; + return { + id: child.id, + state: isRecord(childState) ? { type: childState.type } : null, + }; + }), + ); + + const raw = { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description, + url: issue.url, + priority: issue.priority, + createdAt: toIsoString(issue.createdAt), + updatedAt: toIsoString(issue.updatedAt), + dueDate: issue.dueDate, + estimate: issue.estimate, + archivedAt: toIsoString(issue.archivedAt), + completedAt: toIsoString(issue.completedAt), + canceledAt: toIsoString(issue.canceledAt), + startedAt: toIsoString(issue.startedAt), + project: isRecord(project) ? { + id: project.id, + name: project.name, + slug: project.slugId ?? project.slug, + } : null, + team: isRecord(team) ? { + id: team.id, + key: team.key, + name: team.name, + } : null, + state: isRecord(state) ? { + id: state.id, + name: state.name, + type: state.type, + } : null, + assignee: isRecord(assignee) ? { + id: assignee.id, + name: assignee.name, + displayName: assignee.displayName, + } : null, + creator: isRecord(creator) ? { + id: creator.id, + name: creator.name, + displayName: creator.displayName, + } : null, + labels: { + nodes: asArray(isRecord(labelsConnection) ? labelsConnection.nodes : []) + .filter(isRecord) + .map((label) => ({ id: label.id, name: label.name })), + }, + children: { nodes: childNodes }, + }; + + return toNormalizedIssue(raw); + }; + + const getQuickView = async (connection: CtoLinearQuickView["connection"]): Promise<CtoLinearQuickView> => { + const sdk = createSdkClient(); + // Recent issues fetched via raw GraphQL (single request with ISSUE_FIELDS_FRAGMENT) + // to avoid the lazy-relation fan-out that the SDK normalizer triggers. + const recentIssuesPromise = searchIssues({ first: 12, includeArchived: false }).catch(() => null); + const [viewer, organization, projectsConnection, teamsConnection, recentIssuesResult] = await Promise.all([ + sdk.viewer, + sdk.organization.catch(() => null), + sdk.projects({ first: 8, includeArchived: false } as never).catch(() => null), + sdk.teams({ first: 8, includeArchived: false } as never).catch(() => null), + recentIssuesPromise, + ]); + + // Assigned issues also via raw GraphQL using the resolved viewer id. + const viewerId = asString(viewer.id); + const assignedIssuesResult = viewerId + ? await searchIssues({ first: 12, includeArchived: false, assigneeId: viewerId }).catch(() => null) + : null; + + const projects: CtoLinearQuickViewProject[] = await Promise.all( + asArray(projectsConnection?.nodes).filter(isRecord).map(async (project) => { + const [status, lead, teams] = await Promise.all([ + typeof project.status === "object" ? project.status : Promise.resolve(null), + typeof project.lead === "object" ? project.lead : Promise.resolve(null), + typeof project.teams === "function" + ? (project.teams as (args: { first: number }) => Promise<{ nodes?: unknown[] }>)({ first: 4 }).catch(() => null) + : Promise.resolve(null), + ]); + const teamNodes = asArray(isRecord(teams) ? teams.nodes : []).filter(isRecord); + const teamName = teamNodes + .map((team) => asString(team.name)) + .find((entry): entry is string => Boolean(entry?.trim())) ?? "Unassigned"; + const teamKey = teamNodes + .map((team) => asString(team.key)) + .find((entry): entry is string => Boolean(entry?.trim())) ?? null; + return { + id: String(project.id ?? ""), + name: String(project.name ?? "Untitled project"), + slug: String(project.slugId ?? project.slug ?? ""), + teamName, + ...(teamKey ? { teamKey } : {}), + url: asString(project.url), + color: asString(project.color), + icon: asString(project.icon), + description: asString(project.description), + statusName: isRecord(status) ? asString(status.name) : null, + statusType: isRecord(status) ? asString(status.type) : null, + health: asString(project.health), + progress: typeof project.progress === "number" ? project.progress : null, + scope: typeof project.scope === "number" ? project.scope : null, + priority: typeof project.priority === "number" ? project.priority : null, + priorityLabel: asString(project.priorityLabel), + issueCount: Array.isArray(project.issueCountHistory) ? Number(project.issueCountHistory.at(-1) ?? 0) : null, + completedIssueCount: Array.isArray(project.completedIssueCountHistory) + ? Number(project.completedIssueCountHistory.at(-1) ?? 0) + : null, + startDate: asString(project.startDate), + targetDate: asString(project.targetDate), + leadName: isRecord(lead) ? (asString(lead.displayName) ?? asString(lead.name)) : null, + teamKeys: teamNodes.map((team) => asString(team.key)).filter((entry): entry is string => Boolean(entry)), + }; + }) + ); + + const teams: CtoLinearQuickViewTeam[] = asArray(teamsConnection?.nodes) + .filter(isRecord) + .map((team) => ({ + id: String(team.id ?? ""), + key: String(team.key ?? ""), + name: String(team.name ?? "Team"), + displayName: String(team.displayName ?? team.name ?? "Team"), + color: asString(team.color), + issueCount: typeof team.issueCount === "number" ? team.issueCount : null, + cyclesEnabled: typeof team.cyclesEnabled === "boolean" ? team.cyclesEnabled : null, + private: typeof team.private === "boolean" ? team.private : null, + })); + + const assignedIssues = assignedIssuesResult?.issues ?? []; + const recentIssues = recentIssuesResult?.issues ?? []; + + return { + connection, + organization: isRecord(organization) ? { + id: String(organization.id ?? ""), + name: String(organization.name ?? "Linear"), + urlKey: asString(organization.urlKey), + logoUrl: asString(organization.logoUrl), + gitBranchFormat: asString(organization.gitBranchFormat), + createdIssueCount: typeof organization.createdIssueCount === "number" ? organization.createdIssueCount : null, + roadmapEnabled: typeof organization.roadmapEnabled === "boolean" ? organization.roadmapEnabled : null, + customersEnabled: typeof organization.customersEnabled === "boolean" ? organization.customersEnabled : null, + releasesEnabled: typeof organization.releasesEnabled === "boolean" ? organization.releasesEnabled : null, + } : null, + viewer: { + id: String(viewer.id ?? ""), + name: String(viewer.name ?? viewer.displayName ?? "Linear user"), + displayName: String(viewer.displayName ?? viewer.name ?? "Linear user"), + email: asString(viewer.email), + avatarUrl: asString(viewer.avatarUrl), + admin: typeof viewer.admin === "boolean" ? viewer.admin : null, + guest: typeof viewer.guest === "boolean" ? viewer.guest : null, + url: asString(viewer.url), + }, + projects: projects.filter((project) => project.id && project.slug), + teams: teams.filter((team) => team.id && team.key), + assignedIssues: assignedIssues.filter((issue): issue is NormalizedLinearIssue => issue != null), + recentIssues: recentIssues.filter((issue): issue is NormalizedLinearIssue => issue != null), + fetchedAt: new Date().toISOString(), + sdk: { + packageName: "@linear/sdk", + surfaces: [ + "viewer", + "organization", + "projects", + "teams", + "assignedIssues", + "issues", + "project.status", + "project.lead", + ], + }, + }; + }; + const fetchIssuesByIds = async (issueIds: string[]): Promise<Map<string, NormalizedLinearIssue>> => { const results = new Map<string, NormalizedLinearIssue>(); if (!issueIds.length) return results; @@ -885,14 +1271,17 @@ export function createLinearClient(args: LinearClientArgs) { return { request, getViewer, + getConnectionIdentity, listProjects, listUsers, listLabels, listWebhooks, createWebhook, + searchIssues, fetchCandidateIssues, fetchIssueById, fetchIssuesByIds, + getQuickView, fetchWorkflowStates, listWorkflowStates, updateIssueState, diff --git a/apps/desktop/src/main/services/cto/linearIssueTracker.ts b/apps/desktop/src/main/services/cto/linearIssueTracker.ts index a46080133..ff705c2a3 100644 --- a/apps/desktop/src/main/services/cto/linearIssueTracker.ts +++ b/apps/desktop/src/main/services/cto/linearIssueTracker.ts @@ -8,6 +8,10 @@ export function createLinearIssueTracker(args: { client: LinearClient }): IssueT return args.client.listProjects(); }, + getQuickView(connection) { + return args.client.getQuickView(connection); + }, + listUsers() { return args.client.listUsers(); }, @@ -16,6 +20,10 @@ export function createLinearIssueTracker(args: { client: LinearClient }): IssueT return args.client.listLabels(teamKey); }, + searchIssues(query) { + return args.client.searchIssues(query); + }, + fetchCandidateIssues(query) { return args.client.fetchCandidateIssues(query); }, @@ -62,18 +70,26 @@ export function createLinearIssueTracker(args: { client: LinearClient }): IssueT async getConnectionStatus() { try { - const viewer = await args.client.getViewer(); + const identity = await args.client.getConnectionIdentity(); return { - connected: Boolean(viewer.id), - viewerId: viewer.id, - viewerName: viewer.name, - message: viewer.id ? null : "Linear API token is valid but viewer lookup returned no id.", + connected: Boolean(identity.viewerId), + viewerId: identity.viewerId, + viewerName: identity.viewerName, + organizationId: identity.organizationId, + organizationName: identity.organizationName, + organizationUrlKey: identity.organizationUrlKey, + organizationLogoUrl: identity.organizationLogoUrl, + message: identity.viewerId ? null : "Linear API token is valid but viewer lookup returned no id.", }; } catch (error) { return { connected: false, viewerId: null, viewerName: null, + organizationId: null, + organizationName: null, + organizationUrlKey: null, + organizationLogoUrl: null, message: getErrorMessage(error), }; } diff --git a/apps/desktop/src/main/services/cto/linearOAuthService.ts b/apps/desktop/src/main/services/cto/linearOAuthService.ts index b20e3b297..c2e910738 100644 --- a/apps/desktop/src/main/services/cto/linearOAuthService.ts +++ b/apps/desktop/src/main/services/cto/linearOAuthService.ts @@ -150,12 +150,12 @@ export function createLinearOAuthService(args: { const errorDescription = requestUrl.searchParams.get("error_description"); if (returnedState !== session.state) { - finalizeSession(session, { - status: "failed", - error: "OAuth callback state did not match the active Linear session.", + args.logger?.warn("linear_sync.oauth_callback_state_mismatch", { + sessionId: session.id, + hasReturnedState: returnedState != null, }); res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }); - res.end("OAuth state mismatch."); + res.end("OAuth state mismatch. Return to ADE and continue the active Linear sign-in."); return; } @@ -215,7 +215,8 @@ export function createLinearOAuthService(args: { authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("state", state); authUrl.searchParams.set("scope", "read,write"); - // Always show consent screen so users can pick which workspace to connect. + // Ask Linear for a consent screen; Linear still resolves the workspace + // from the user's active browser session/workspace switcher. authUrl.searchParams.set("prompt", "consent"); if (pkce) { authUrl.searchParams.set("code_challenge_method", "S256"); diff --git a/apps/desktop/src/main/services/diffs/diffService.test.ts b/apps/desktop/src/main/services/diffs/diffService.test.ts index e21675dcd..ae06b670b 100644 --- a/apps/desktop/src/main/services/diffs/diffService.test.ts +++ b/apps/desktop/src/main/services/diffs/diffService.test.ts @@ -18,6 +18,171 @@ function createLaneServiceStub(rootPath: string) { } describe("diffService", () => { + it("returns change stats and rename metadata for staged and unstaged files", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-diff-service-status-")); + const service = createDiffService({ laneService: createLaneServiceStub(rootPath) }); + + try { + git(rootPath, ["init"]); + git(rootPath, ["config", "user.email", "ade@example.com"]); + git(rootPath, ["config", "user.name", "ADE"]); + fs.writeFileSync(path.join(rootPath, "alpha.txt"), "one\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "rename-me.txt"), "old\n", "utf8"); + git(rootPath, ["add", "."]); + git(rootPath, ["commit", "-m", "base"]); + + git(rootPath, ["mv", "rename-me.txt", "renamed.txt"]); + fs.writeFileSync(path.join(rootPath, "alpha.txt"), "one\ntwo\n", "utf8"); + + const changes = await service.getChanges("lane-1"); + + expect(changes.unstaged.find((change) => change.path === "alpha.txt")).toMatchObject({ + kind: "modified", + additions: 1, + deletions: 0, + }); + expect(changes.staged.find((change) => change.path === "renamed.txt")).toMatchObject({ + kind: "renamed", + oldPath: "rename-me.txt", + }); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + + it("preserves numstat for non-ASCII and tabbed paths", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-diff-service-quoted-paths-")); + const service = createDiffService({ laneService: createLaneServiceStub(rootPath) }); + const unicodePath = "caf\u00e9.txt"; + const tabbedPath = "tab\tname.txt"; + + try { + git(rootPath, ["init"]); + git(rootPath, ["config", "user.email", "ade@example.com"]); + git(rootPath, ["config", "user.name", "ADE"]); + fs.writeFileSync(path.join(rootPath, unicodePath), "one\n", "utf8"); + fs.writeFileSync(path.join(rootPath, tabbedPath), "one\n", "utf8"); + git(rootPath, ["add", "."]); + git(rootPath, ["commit", "-m", "base"]); + + fs.writeFileSync(path.join(rootPath, unicodePath), "one\ntwo\n", "utf8"); + fs.writeFileSync(path.join(rootPath, tabbedPath), "one\ntwo\n", "utf8"); + + const changes = await service.getChanges("lane-1"); + + expect(changes.unstaged.find((change) => change.path === unicodePath)).toMatchObject({ + kind: "modified", + additions: 1, + deletions: 0, + }); + expect(changes.unstaged.find((change) => change.path === tabbedPath)).toMatchObject({ + kind: "modified", + additions: 1, + deletions: 0, + }); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + + it("returns a bounded read-only patch for a selected file", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-diff-service-patch-")); + const service = createDiffService({ laneService: createLaneServiceStub(rootPath) }); + + try { + git(rootPath, ["init"]); + git(rootPath, ["config", "user.email", "ade@example.com"]); + git(rootPath, ["config", "user.name", "ADE"]); + fs.writeFileSync(path.join(rootPath, "sample.ts"), "const x = 1;\n", "utf8"); + git(rootPath, ["add", "sample.ts"]); + git(rootPath, ["commit", "-m", "base"]); + fs.writeFileSync(path.join(rootPath, "sample.ts"), "const x = 2;\n", "utf8"); + + const patch = await service.getFilePatch({ + laneId: "lane-1", + filePath: "sample.ts", + mode: "unstaged", + }); + + expect(patch.path).toBe("sample.ts"); + expect(patch.patch).toContain("diff --git"); + expect(patch.patch).toContain("-const x = 1;"); + expect(patch.patch).toContain("+const x = 2;"); + expect(patch.additions).toBe(1); + expect(patch.deletions).toBe(1); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + + it("rejects diff paths that escape the worktree", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-diff-service-escape-")); + const service = createDiffService({ laneService: createLaneServiceStub(rootPath) }); + + try { + git(rootPath, ["init"]); + + await expect(service.getFileDiff({ + laneId: "lane-1", + filePath: "../outside.txt", + mode: "unstaged", + })).rejects.toThrow("Path escapes root"); + + await expect(service.getFilePatch({ + laneId: "lane-1", + filePath: "../outside.txt", + mode: "unstaged", + })).rejects.toThrow("Path escapes root"); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + + it("rejects diff paths that contain null bytes", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-diff-service-null-path-")); + const service = createDiffService({ laneService: createLaneServiceStub(rootPath) }); + + try { + await expect(service.getFileDiff({ + laneId: "lane-1", + filePath: "bad\0name.txt", + mode: "unstaged", + })).rejects.toThrow("File path contains an invalid null byte"); + + await expect(service.getFilePatch({ + laneId: "lane-1", + filePath: "bad\0name.txt", + mode: "unstaged", + })).rejects.toThrow("File path contains an invalid null byte"); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + + it("rejects option-looking commit compare refs", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-diff-service-compare-ref-")); + const service = createDiffService({ laneService: createLaneServiceStub(rootPath) }); + + try { + await expect(service.getFileDiff({ + laneId: "lane-1", + filePath: "sample.ts", + mode: "commit", + compareRef: "--help", + })).rejects.toThrow("compareRef cannot start with '-'"); + + await expect(service.getFilePatch({ + laneId: "lane-1", + filePath: "sample.ts", + mode: "commit", + compareRef: "--help", + compareTo: "parent", + })).rejects.toThrow("compareRef cannot start with '-'"); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + it("bounds large file diff sides before they reach Monaco", async () => { const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-diff-service-large-")); const service = createDiffService({ laneService: createLaneServiceStub(rootPath) }); diff --git a/apps/desktop/src/main/services/diffs/diffService.ts b/apps/desktop/src/main/services/diffs/diffService.ts index d0cbf6ed1..3829ceb49 100644 --- a/apps/desktop/src/main/services/diffs/diffService.ts +++ b/apps/desktop/src/main/services/diffs/diffService.ts @@ -2,9 +2,11 @@ import fs from "node:fs"; import path from "node:path"; import type { createLaneService } from "../lanes/laneService"; import { runGit } from "../git/git"; -import type { DiffChanges, DiffMode, FileDiff, FileChange } from "../../../shared/types"; +import { resolvePathWithinRoot } from "../shared/utils"; +import type { DiffChanges, DiffMode, FileDiff, FileChange, FilePatch } from "../../../shared/types"; export const MAX_DIFF_SIDE_TEXT_BYTES = 192 * 1024; +export const MAX_DIFF_PATCH_BYTES = 512 * 1024; export const DIFF_TRUNCATION_NOTICE = "\n\n[Preview truncated. Open the file externally or in Files for the full content.]\n"; export function appendDiffTruncationNotice(text: string): string { @@ -14,13 +16,22 @@ export function appendDiffTruncationNotice(text: string): string { function parseStatusKind(code: string): FileChange["kind"] { if (code === "??") return "untracked"; const c = code.replace(/[^A-Z]/g, ""); - if (c.includes("M")) return "modified"; - if (c.includes("A")) return "added"; - if (c.includes("D")) return "deleted"; if (c.includes("R")) return "renamed"; + if (c.includes("D")) return "deleted"; + if (c.includes("A")) return "added"; + if (c.includes("M")) return "modified"; return "unknown"; } +function parseStatusColumnKind(column: string): FileChange["kind"] | null { + if (column === "R") return "renamed"; + if (column === "D") return "deleted"; + if (column === "A") return "added"; + if (column === "M") return "modified"; + if (column === "?") return "untracked"; + return null; +} + function stripGitStatusPath(raw: string): string { // Handles rename format: "old -> new" const idx = raw.indexOf("->"); @@ -28,6 +39,84 @@ function stripGitStatusPath(raw: string): string { return raw.trim(); } +function parseNumstatZ(stdout: string): Array<{ addRaw: string; delRaw: string; filePath: string }> { + const entries: Array<{ addRaw: string; delRaw: string; filePath: string }> = []; + const records = stdout.split("\0"); + + for (let i = 0; i < records.length; i++) { + const record = records[i] ?? ""; + if (!record) continue; + + const [addRaw, delRaw, ...pathParts] = record.split("\t"); + if (addRaw == null || delRaw == null || pathParts.length === 0) continue; + + let filePath = pathParts.join("\t"); + if (!filePath) { + filePath = records[i + 2] ?? ""; + i += 2; + } + if (!filePath) continue; + + entries.push({ addRaw, delRaw, filePath }); + } + + return entries; +} + +function parsePorcelainStatusZ(stdout: string): DiffChanges { + const unstaged: FileChange[] = []; + const staged: FileChange[] = []; + const records = stdout.split("\0").filter(Boolean); + + for (let i = 0; i < records.length; i++) { + const record = records[i] ?? ""; + if (record.length < 3) continue; + const x = record[0] ?? " "; + const y = record[1] ?? " "; + const code = `${x}${y}`; + const p = stripGitStatusPath(record.slice(3)); + if (!p) continue; + + let oldPath: string | undefined; + if (x === "R" || x === "C" || y === "R" || y === "C") { + const previous = records[i + 1]; + if (previous) { + oldPath = previous; + i += 1; + } + } + + if (code === "??") { + unstaged.push({ path: p, kind: "untracked" }); + continue; + } + + const stagedKind = parseStatusColumnKind(x) ?? parseStatusKind(code); + const unstagedKind = parseStatusColumnKind(y) ?? parseStatusKind(code); + if (x !== " " && x !== "?") { + staged.push(oldPath && stagedKind === "renamed" ? { path: p, oldPath, kind: stagedKind } : { path: p, kind: stagedKind }); + } + if (y !== " " && y !== "?") { + unstaged.push(oldPath && unstagedKind === "renamed" ? { path: p, oldPath, kind: unstagedKind } : { path: p, kind: unstagedKind }); + } + } + + return { unstaged, staged }; +} + +function applyNumstat(changes: FileChange[], stdout: string): void { + const byPath = new Map(changes.map((change) => [change.path, change])); + for (const { addRaw, delRaw, filePath } of parseNumstatZ(stdout)) { + const change = byPath.get(filePath); + if (!change) continue; + const additions = Number.parseInt(addRaw, 10); + const deletions = Number.parseInt(delRaw, 10); + if (Number.isFinite(additions)) change.additions = additions; + if (Number.isFinite(deletions)) change.deletions = deletions; + if (addRaw === "-" || delRaw === "-") change.isBinary = true; + } +} + function detectBinary(buf: Buffer): boolean { // Simple heuristic: null byte indicates binary. return buf.includes(0); @@ -85,35 +174,101 @@ async function gitShowText( return { exists: true, text: res.stdout, size: buf.length }; } +function parsePatchSummary(patch: string, fallbackPath: string): Pick<FilePatch, "oldPath" | "path" | "status" | "isBinary" | "additions" | "deletions"> { + let currentPath = fallbackPath; + let oldPath: string | undefined; + let status: FileChange["kind"] = "modified"; + let additions = 0; + let deletions = 0; + let isBinary = false; + + for (const line of patch.split("\n")) { + if (line.startsWith("rename from ")) { + oldPath = line.slice("rename from ".length).trim(); + status = "renamed"; + } else if (line.startsWith("rename to ")) { + currentPath = line.slice("rename to ".length).trim() || currentPath; + status = "renamed"; + } else if (line.startsWith("new file mode ")) { + status = "added"; + } else if (line.startsWith("deleted file mode ")) { + status = "deleted"; + } else if (line.startsWith("Binary files ") || line.startsWith("GIT binary patch")) { + isBinary = true; + } else if (line.startsWith("+++ b/")) { + const next = line.slice("+++ b/".length).trim(); + if (next && next !== "/dev/null") currentPath = next; + } else if (line.startsWith("--- a/")) { + const prev = line.slice("--- a/".length).trim(); + if (prev && prev !== "/dev/null") oldPath = prev; + } else if (line.startsWith("+") && !line.startsWith("+++")) { + additions += 1; + } else if (line.startsWith("-") && !line.startsWith("---")) { + deletions += 1; + } + } + + return { + path: currentPath, + ...(oldPath && oldPath !== currentPath ? { oldPath } : {}), + status, + isBinary, + additions, + deletions + }; +} + +async function getCommitParentRef(cwd: string, ref: string): Promise<string | null> { + const parentsRes = await runGit(["rev-list", "--parents", "-n", "1", ref], { cwd, timeoutMs: 10_000 }); + const parentSha = parentsRes.exitCode === 0 ? parentsRes.stdout.trim().split(" ").slice(1)[0] : undefined; + return parentSha?.trim() ? parentSha.trim() : null; +} + +function readCommitCompareRef(compareRef: string | undefined): string { + const ref = compareRef?.trim(); + if (!ref) { + throw new Error("compareRef is required for commit mode"); + } + if (ref.startsWith("-")) { + throw new Error("compareRef cannot start with '-'"); + } + return ref; +} + +function resolveGitFilePath(worktreePath: string, filePath: string): { absPath: string; gitPath: string } { + if (!filePath.trim()) { + throw new Error("File path is required"); + } + if (filePath.includes("\0")) { + throw new Error("File path contains an invalid null byte"); + } + const root = fs.realpathSync(worktreePath); + const absPath = resolvePathWithinRoot(root, filePath, { allowMissing: true }); + const gitPath = path.relative(root, absPath).replace(/\\/g, "/"); + if (!gitPath || gitPath.startsWith("../") || gitPath === "..") { + throw new Error("Path escapes root"); + } + return { absPath, gitPath }; +} + export function createDiffService({ laneService }: { laneService: ReturnType<typeof createLaneService> }) { return { async getChanges(laneId: string): Promise<DiffChanges> { const { worktreePath } = laneService.getLaneBaseAndBranch(laneId); - const res = await runGit(["status", "--porcelain=v1"], { cwd: worktreePath, timeoutMs: 12_000 }); + const res = await runGit(["status", "--porcelain=v1", "-z"], { cwd: worktreePath, timeoutMs: 12_000 }); if (res.exitCode !== 0) { return { unstaged: [], staged: [] }; } - const unstaged: FileChange[] = []; - const staged: FileChange[] = []; + const changes = parsePorcelainStatusZ(res.stdout); + const [unstagedStats, stagedStats] = await Promise.all([ + runGit(["diff", "--numstat", "--find-renames", "-z"], { cwd: worktreePath, timeoutMs: 12_000, maxOutputBytes: 512 * 1024 }), + runGit(["diff", "--cached", "--numstat", "--find-renames", "-z"], { cwd: worktreePath, timeoutMs: 12_000, maxOutputBytes: 512 * 1024 }), + ]); + if (unstagedStats.exitCode === 0) applyNumstat(changes.unstaged, unstagedStats.stdout); + if (stagedStats.exitCode === 0) applyNumstat(changes.staged, stagedStats.stdout); - const lines = res.stdout.split("\n").map((l) => l.trimEnd()).filter(Boolean); - for (const line of lines) { - if (line.startsWith("??")) { - const p = stripGitStatusPath(line.slice(2)); - unstaged.push({ path: p, kind: "untracked" }); - continue; - } - const x = line[0] ?? " "; - const y = line[1] ?? " "; - const p = stripGitStatusPath(line.slice(2)); - const code = `${x}${y}`; - const kind = parseStatusKind(code); - if (x !== " " && x !== "?") staged.push({ path: p, kind }); - if (y !== " " && y !== "?") unstaged.push({ path: p, kind }); - } - - return { unstaged, staged }; + return changes; }, async getFileDiff({ @@ -130,14 +285,14 @@ export function createDiffService({ laneService }: { laneService: ReturnType<typ compareTo?: "worktree" | "parent"; }): Promise<FileDiff> { const { worktreePath } = laneService.getLaneBaseAndBranch(laneId); - const abs = path.join(worktreePath, filePath); + const { absPath, gitPath } = resolveGitFilePath(worktreePath, filePath); if (mode === "staged") { - const head = await gitShowText(worktreePath, `HEAD:${filePath}`, MAX_DIFF_SIDE_TEXT_BYTES); - const idx = await gitShowText(worktreePath, `:${filePath}`, MAX_DIFF_SIDE_TEXT_BYTES); + const head = await gitShowText(worktreePath, `HEAD:${gitPath}`, MAX_DIFF_SIDE_TEXT_BYTES); + const idx = await gitShowText(worktreePath, `:${gitPath}`, MAX_DIFF_SIDE_TEXT_BYTES); const isBinary = Boolean(head.isBinary || idx.isBinary); return { - path: filePath, + path: gitPath, mode, original: { exists: head.exists, text: head.text, size: head.size, isTruncated: head.isTruncated }, modified: { exists: idx.exists, text: idx.text, size: idx.size, isTruncated: idx.isTruncated }, @@ -146,22 +301,16 @@ export function createDiffService({ laneService }: { laneService: ReturnType<typ } if (mode === "commit") { - const ref = compareRef?.trim(); - if (!ref) { - throw new Error("compareRef is required for commit mode"); - } + const ref = readCommitCompareRef(compareRef); const target = compareTo ?? "worktree"; if (target === "parent") { - const parentsRes = await runGit(["rev-list", "--parents", "-n", "1", ref], { cwd: worktreePath, timeoutMs: 10_000 }); - const parentSha = parentsRes.exitCode === 0 ? parentsRes.stdout.trim().split(" ").slice(1)[0] : undefined; - const parentRef = parentSha?.trim() ? parentSha.trim() : null; - - const parentSide = parentRef ? await gitShowText(worktreePath, `${parentRef}:${filePath}`, MAX_DIFF_SIDE_TEXT_BYTES) : { exists: false, text: "" }; - const commitSide = await gitShowText(worktreePath, `${ref}:${filePath}`, MAX_DIFF_SIDE_TEXT_BYTES); + const parentRef = await getCommitParentRef(worktreePath, ref); + const parentSide = parentRef ? await gitShowText(worktreePath, `${parentRef}:${gitPath}`, MAX_DIFF_SIDE_TEXT_BYTES) : { exists: false, text: "" }; + const commitSide = await gitShowText(worktreePath, `${ref}:${gitPath}`, MAX_DIFF_SIDE_TEXT_BYTES); const isBinary = Boolean(parentSide.isBinary || commitSide.isBinary); return { - path: filePath, + path: gitPath, mode, original: { exists: parentSide.exists, text: parentSide.text, size: parentSide.size, isTruncated: parentSide.isTruncated }, modified: { exists: commitSide.exists, text: commitSide.text, size: commitSide.size, isTruncated: commitSide.isTruncated }, @@ -169,11 +318,11 @@ export function createDiffService({ laneService }: { laneService: ReturnType<typ }; } - const commitSide = await gitShowText(worktreePath, `${ref}:${filePath}`, MAX_DIFF_SIDE_TEXT_BYTES); - const wt = readTextFileSafe(abs, MAX_DIFF_SIDE_TEXT_BYTES); + const commitSide = await gitShowText(worktreePath, `${ref}:${gitPath}`, MAX_DIFF_SIDE_TEXT_BYTES); + const wt = readTextFileSafe(absPath, MAX_DIFF_SIDE_TEXT_BYTES); const isBinary = Boolean(commitSide.isBinary || wt.isBinary); return { - path: filePath, + path: gitPath, mode, original: { exists: commitSide.exists, text: commitSide.text, size: commitSide.size, isTruncated: commitSide.isTruncated }, modified: { exists: wt.exists, text: wt.text, size: wt.size, isTruncated: wt.isTruncated }, @@ -182,16 +331,68 @@ export function createDiffService({ laneService }: { laneService: ReturnType<typ } // Unstaged: index -> working tree - const idx = await gitShowText(worktreePath, `:${filePath}`, MAX_DIFF_SIDE_TEXT_BYTES); - const wt = readTextFileSafe(abs, MAX_DIFF_SIDE_TEXT_BYTES); + const idx = await gitShowText(worktreePath, `:${gitPath}`, MAX_DIFF_SIDE_TEXT_BYTES); + const wt = readTextFileSafe(absPath, MAX_DIFF_SIDE_TEXT_BYTES); const isBinary = Boolean(idx.isBinary || wt.isBinary); return { - path: filePath, + path: gitPath, mode, original: { exists: idx.exists, text: idx.text, size: idx.size, isTruncated: idx.isTruncated }, modified: { exists: wt.exists, text: wt.text, size: wt.size, isTruncated: wt.isTruncated }, ...(isBinary ? { isBinary: true } : {}) }; + }, + + async getFilePatch({ + laneId, + filePath, + mode, + compareRef, + compareTo + }: { + laneId: string; + filePath: string; + mode: DiffMode; + compareRef?: string; + compareTo?: "worktree" | "parent"; + }): Promise<FilePatch> { + const { worktreePath } = laneService.getLaneBaseAndBranch(laneId); + const { gitPath } = resolveGitFilePath(worktreePath, filePath); + let args: string[]; + + if (mode === "staged") { + args = ["diff", "--cached", "--no-ext-diff", "--find-renames", "--patch", "--", gitPath]; + } else if (mode === "commit") { + const ref = readCommitCompareRef(compareRef); + const target = compareTo ?? "worktree"; + if (target === "parent") { + const parentRef = await getCommitParentRef(worktreePath, ref); + args = parentRef + ? ["diff", "--no-ext-diff", "--find-renames", "--patch", parentRef, ref, "--", gitPath] + : ["show", "--format=", "--no-ext-diff", "--find-renames", "--patch", ref, "--", gitPath]; + } else { + args = ["diff", "--no-ext-diff", "--find-renames", "--patch", ref, "--", gitPath]; + } + } else { + args = ["diff", "--no-ext-diff", "--find-renames", "--patch", "--", gitPath]; + } + + const res = await runGit(args, { + cwd: worktreePath, + timeoutMs: 12_000, + maxOutputBytes: MAX_DIFF_PATCH_BYTES + }); + if (res.exitCode !== 0) { + throw new Error(res.stderr.trim() || `git ${args.join(" ")} failed`); + } + + return { + mode, + patch: res.stdout, + size: Buffer.byteLength(res.stdout, "utf8"), + isTruncated: res.stdoutTruncated || undefined, + ...parsePatchSummary(res.stdout, gitPath) + }; } }; } diff --git a/apps/desktop/src/main/services/files/fileService.test.ts b/apps/desktop/src/main/services/files/fileService.test.ts index 7f70b1ea9..8b0259452 100644 --- a/apps/desktop/src/main/services/files/fileService.test.ts +++ b/apps/desktop/src/main/services/files/fileService.test.ts @@ -322,6 +322,33 @@ describe("fileService", () => { } }); + it("preserves distinct git status labels in tree listings", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-status-")); + const { execSync } = await import("node:child_process"); + execSync("git init", { cwd: rootPath, stdio: "ignore" }); + execSync("git config user.email test@example.com && git config user.name Test", { cwd: rootPath, stdio: "ignore" }); + const laneService = createLaneServiceStub(rootPath); + const service = createFileService({ laneService }); + + try { + fs.writeFileSync(path.join(rootPath, "package.json"), "{\n \"name\": \"fixture\"\n}\n", "utf8"); + execSync("git add package.json && git commit -m init", { cwd: rootPath, stdio: "ignore" }); + execSync("git mv package.json package-renamed.json", { cwd: rootPath, stdio: "ignore" }); + fs.writeFileSync(path.join(rootPath, "scratch.ts"), "export const value = 1;\n", "utf8"); + + const rootNodes = await service.listTree({ + workspaceId: "workspace-1", + depth: 1, + includeIgnored: true, + }); + + expect(rootNodes.find((node) => node.path === "package-renamed.json")?.changeStatus).toBe("renamed"); + expect(rootNodes.find((node) => node.path === "scratch.ts")?.changeStatus).toBe("untracked"); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + it("returns the primary workspace first", () => { const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-workspaces-")); const laneService = { diff --git a/apps/desktop/src/main/services/files/fileService.ts b/apps/desktop/src/main/services/files/fileService.ts index 6dc84e0ea..76d28376b 100644 --- a/apps/desktop/src/main/services/files/fileService.ts +++ b/apps/desktop/src/main/services/files/fileService.ts @@ -347,7 +347,19 @@ function buildGitStatusSnapshot(fileStatus: Map<string, FileTreeChangeStatus>): } function inferDirectoryStatus(statusSnapshot: GitStatusSnapshot, relPath: string): FileTreeChangeStatus { - return statusSnapshot.changedDirectories.has(normalizeRelative(relPath)) ? "M" : null; + return statusSnapshot.changedDirectories.has(normalizeRelative(relPath)) ? "modified" : null; +} + +function parseFileTreeStatus(code: string): FileTreeChangeStatus { + if (code === "??") return "untracked"; + if (code === "!!") return "ignored"; + const combined = code.replace(/\s/g, ""); + if (!combined) return null; + if (combined.includes("R")) return "renamed"; + if (combined.includes("D")) return "deleted"; + if (combined.includes("A")) return "added"; + if (combined.includes("M")) return "modified"; + return "unknown"; } export function createFileService({ @@ -478,15 +490,7 @@ export function createFileService({ } const normalized = normalizeRelative(rel); - if (code === "??") { - out.set(normalized, "A"); - continue; - } - const combined = code.replace(/\s/g, ""); - if (combined.includes("D")) out.set(normalized, "D"); - else if (combined.includes("A")) out.set(normalized, "A"); - else if (combined.length) out.set(normalized, "M"); - else out.set(normalized, null); + out.set(normalized, parseFileTreeStatus(code)); } const snapshot = buildGitStatusSnapshot(out); gitStatusCache.set(rootPath, { fetchedAt: now, snapshot }); @@ -575,7 +579,7 @@ export function createFileService({ node.children = sub.children; if (sub.truncated) node.childrenTruncated = true; if (!node.changeStatus && node.children.some((child) => child.changeStatus)) { - node.changeStatus = "M"; + node.changeStatus = "modified"; } } } diff --git a/apps/desktop/src/main/services/git/gitOperationsService.test.ts b/apps/desktop/src/main/services/git/gitOperationsService.test.ts index 9fd605bd4..48d1293c6 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.test.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.test.ts @@ -191,6 +191,79 @@ describe("gitOperationsService stash item commands", () => { }); }); +describe("gitOperationsService.commit", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("prefixes commits from linked Linear lanes with a non-closing reference", async () => { + mockGit.getHeadSha.mockResolvedValueOnce("before").mockResolvedValueOnce("after"); + mockGit.runGitOrThrow.mockResolvedValue(undefined); + const mockStart = vi.fn().mockReturnValue({ operationId: "op-1" }); + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef: "ade-123-linked-commit", + worktreePath: "/tmp/ade-lane", + laneType: "worktree", + linearIssue: { + id: "issue-1", + identifier: "ADE-123", + title: "Linked commit", + description: null, + url: null, + projectId: "project-1", + projectSlug: "ade", + teamId: "team-1", + teamKey: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 0, + priorityLabel: "none", + labels: [], + assigneeId: null, + assigneeName: null, + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + }, + }), + } as any, + operationService: { + start: mockStart, + finish: vi.fn(), + } as any, + projectConfigService: { + get: () => ({ effective: { ai: {} } }), + } as any, + aiIntegrationService: { + getFeatureFlag: () => false, + getStatus: vi.fn(async () => ({ availableModelIds: [] })), + generateCommitMessage: vi.fn(), + } as any, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any, + }); + + await service.commit({ laneId: "lane-1", message: "Update git service" }); + + expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( + ["commit", "-m", "Refs ADE-123: Update git service"], + { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, + ); + expect(mockStart).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ message: "Refs ADE-123: Update git service" }), + }), + ); + }); +}); + describe("gitOperationsService.generateCommitMessage", () => { beforeEach(() => { vi.clearAllMocks(); @@ -295,6 +368,98 @@ describe("gitOperationsService.generateCommitMessage", () => { ["diff", "--cached", "--no-color", "-U2", "--find-renames"], ]); }); + + it("prefixes generated commit messages with a Linear reference for linked lanes", async () => { + mockGit.runGit.mockImplementation(async (args: string[]) => { + if (args[0] === "diff") { + return { + exitCode: 0, + stdout: "M\tapps/desktop/src/main/foo.ts\n", + stderr: "", + }; + } + if (args[0] === "show") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { exitCode: 1, stdout: "", stderr: `unexpected git command: ${args.join(" ")}` }; + }); + + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: () => ({ + baseRef: "main", + branchRef: "ade-123-connect-linear-commits", + worktreePath: "/tmp/ade-lane", + laneType: "worktree", + linearIssue: { + id: "issue-1", + identifier: "ADE-123", + title: "Connect Linear commits", + description: null, + url: null, + projectId: "project-1", + projectSlug: "ade", + teamId: "team-1", + teamKey: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 0, + priorityLabel: "none", + labels: [], + assigneeId: null, + assigneeName: null, + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + }, + }), + } as any, + operationService: { + start: vi.fn(), + finish: vi.fn(), + } as any, + projectConfigService: { + get: () => ({ + effective: { + ai: { + featureModelOverrides: { + commit_messages: "anthropic/claude-haiku-4-5", + }, + }, + }, + }), + } as any, + aiIntegrationService: { + getFeatureFlag: () => true, + getStatus: vi.fn(async () => ({ + availableModelIds: ["anthropic/claude-haiku-4-5"], + })), + generateCommitMessage: vi.fn(async () => ({ + text: "Update git service.", + structuredOutput: null, + provider: "anthropic", + model: null, + sessionId: null, + inputTokens: null, + outputTokens: null, + durationMs: 5, + })), + } as any, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any, + }); + + const result = await service.generateCommitMessage({ laneId: "lane-1" }); + + expect(result).toEqual({ + message: "Refs ADE-123: Update git service", + model: "anthropic/claude-haiku-4-5", + }); + }); }); describe("gitOperationsService cached lane reads", () => { diff --git a/apps/desktop/src/main/services/git/gitOperationsService.ts b/apps/desktop/src/main/services/git/gitOperationsService.ts index 7adc46cfe..88ece3511 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.ts @@ -27,8 +27,10 @@ import type { GitSyncArgs, GitSyncMode, GitUpstreamSyncStatus, + LaneLinearIssue, LaneType } from "../../../shared/types"; +import { ensureLinearCommitReference } from "../../../shared/linearMagicWords"; import type { Logger } from "../logging/logger"; import type { createLaneService } from "../lanes/laneService"; import type { createOperationService } from "../history/operationService"; @@ -41,6 +43,7 @@ type LaneInfo = { branchRef: string; worktreePath: string; laneType: LaneType; + linearIssue?: LaneLinearIssue | null; }; type CommitMessagePromptContext = { @@ -512,7 +515,11 @@ export function createGitOperationsService({ }, async commit(args: GitCommitArgs): Promise<GitActionResult> { - const message = args.message.trim(); + const inputMessage = args.message.trim(); + const laneForMessage = laneService.getLaneBaseAndBranch(args.laneId); + const message = laneForMessage.linearIssue + ? ensureLinearCommitReference(inputMessage, laneForMessage.linearIssue) + : inputMessage; if (!message.length) { throw new Error("Commit message is required"); } @@ -547,8 +554,9 @@ export function createGitOperationsService({ prompt, model }); + const normalized = normalizeCommitMessage(result.text); return { - message: normalizeCommitMessage(result.text), + message: lane.linearIssue ? ensureLinearCommitReference(normalized, lane.linearIssue) : normalized, model: result.model ?? model }; } catch (error) { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 75c5f5e0b..3f27d342e 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -221,6 +221,7 @@ import type { GetLaneConflictStatusArgs, GetDiffChangesArgs, GetFileDiffArgs, + GetFilePatchArgs, GetProcessLogTailArgs, GetTestLogTailArgs, ExportHistoryArgs, @@ -525,6 +526,10 @@ import type { CtoClearAgentTaskSessionArgs, CtoGetLinearOAuthSessionArgs, CtoGetLinearOAuthSessionResult, + CtoGetLinearIssuePickerDataResult, + CtoLinearQuickView, + CtoSearchLinearIssuesArgs, + CtoSearchLinearIssuesResult, CtoRunProjectScanResult, CtoStartLinearOAuthResult, LinearConnectionStatus, @@ -1675,6 +1680,10 @@ async function buildLinearConnectionStatus( connected: status.connected, viewerId: status.viewerId, viewerName: status.viewerName, + organizationId: status.organizationId, + organizationName: status.organizationName, + organizationUrlKey: status.organizationUrlKey, + organizationLogoUrl: status.organizationLogoUrl, projectCount: undefined, projectPreview: undefined, checkedAt: nowIso(), @@ -5455,6 +5464,8 @@ export function registerIpc({ description: arg.description, parentLaneId: arg.parentLaneId, baseBranch: arg.baseBranch, + branchName: arg.branchName, + linearIssue: arg.linearIssue ?? null, }); await ensureLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); @@ -7242,7 +7253,9 @@ export function registerIpc({ ipcMain.handle(IPC.diffGetChanges, async (_event, arg: GetDiffChangesArgs) => { const ctx = getCtx(); - return await ctx.diffService.getChanges(arg.laneId); + return await withIpcTiming(ctx, "diff.getChanges", async () => await ctx.diffService.getChanges(arg.laneId), { + laneId: arg.laneId, + }); }); ipcMain.handle(IPC.diffGetFile, async (_event, arg: GetFileDiffArgs) => { @@ -7265,6 +7278,26 @@ export function registerIpc({ ); }); + ipcMain.handle(IPC.diffGetFilePatch, async (_event, arg: GetFilePatchArgs) => { + const ctx = getCtx(); + return await withIpcTiming( + ctx, + "diff.getFilePatch", + async () => await ctx.diffService.getFilePatch({ + laneId: arg.laneId, + filePath: arg.path, + mode: arg.mode, + compareRef: arg.compareRef, + compareTo: arg.compareTo + }), + { + laneId: arg.laneId, + mode: arg.mode, + pathLength: arg.path.length, + } + ); + }); + ipcMain.handle(IPC.filesWriteTextAtomic, async (_event, arg: WriteTextAtomicArgs): Promise<void> => { const ctx = getCtx(); ctx.fileService.writeTextAtomic({ laneId: arg.laneId, relPath: arg.path, text: arg.text }); @@ -9599,6 +9632,56 @@ export function registerIpc({ } }); + ipcMain.handle(IPC.ctoGetLinearQuickView, async (): Promise<CtoLinearQuickView> => { + const ctx = getCtx(); + const tokenStored = Boolean(ctx.linearCredentialService?.getStatus().tokenStored); + const connection = await buildLinearConnectionStatus(ctx, tokenStored); + if (!connection.connected || !ctx.linearIssueTracker) { + return { + connection, + organization: null, + viewer: null, + projects: [], + teams: [], + assignedIssues: [], + recentIssues: [], + fetchedAt: nowIso(), + sdk: { + packageName: "@linear/sdk", + surfaces: [], + }, + }; + } + return ctx.linearIssueTracker.getQuickView(connection); + }); + + ipcMain.handle(IPC.ctoGetLinearIssuePickerData, async (): Promise<CtoGetLinearIssuePickerDataResult> => { + const ctx = getCtx(); + // When Linear is not configured, return an empty payload so the renderer + // can render a graceful empty state instead of having to handle a thrown + // error — matches the behavior the picker expects when Linear is offline. + if (!ctx.linearIssueTracker) { + return { projects: [], users: [], states: [] }; + } + const [projects, users, states] = await Promise.all([ + ctx.linearIssueTracker.listProjects().catch(() => []), + ctx.linearIssueTracker.listUsers().catch(() => []), + ctx.linearIssueTracker.listWorkflowStates().catch(() => []), + ]); + return { projects, users, states }; + }); + + ipcMain.handle( + IPC.ctoSearchLinearIssues, + async (_event, arg: CtoSearchLinearIssuesArgs = {}): Promise<CtoSearchLinearIssuesResult> => { + const ctx = getCtx(); + if (!ctx.linearIssueTracker) { + return { issues: [], pageInfo: { hasNextPage: false, endCursor: null } }; + } + return ctx.linearIssueTracker.searchIssues(arg); + } + ); + ipcMain.handle(IPC.ctoRunProjectScan, async (): Promise<CtoRunProjectScanResult> => { const ctx = getCtx(); const detection = await ctx.onboardingService.detectDefaults().catch(() => null); diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 085aebc07..cadc11fc9 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -22,6 +22,20 @@ function createLogger() { } as any; } +/** Default stubs for `resolveCreateBranchRef` git probes. Limits `show-ref` to `ade/*` lane branches so tests can still mock specific feature/upstream refs. */ +function defaultLaneBranchGitStub(args: string[]): { exitCode: number; stdout: string; stderr: string } | null { + if (args[0] === "check-ref-format" && args[1] === "--branch") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "show-ref" && args[1] === "--verify" && args[2] === "--quiet") { + const ref = args[3] ?? ""; + if (ref.startsWith("refs/heads/ade/") || ref.startsWith("refs/remotes/origin/ade/")) { + return { exitCode: 1, stdout: "", stderr: "fatal: not a valid ref" }; + } + } + return null; +} + async function seedProjectAndStack(db: any, args: { projectId: string; repoRoot: string }) { const now = "2026-03-11T12:00:00.000Z"; db.run( @@ -83,6 +97,8 @@ describe("laneService createFromUnstaged", () => { ); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") { return { exitCode: 0, stdout: "main\n", stderr: "" }; } @@ -143,6 +159,8 @@ describe("laneService createFromUnstaged", () => { }); vi.mocked(runGit).mockImplementation(async (args: string[], options: { cwd?: string } = {}) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "status" && args[1] === "--porcelain=v1") { if (options.cwd === sourceWorktreePath) { return { exitCode: 0, stdout: stashPushed ? "" : " M src/file.ts\n?? src/new.ts\n", stderr: "" }; @@ -205,6 +223,8 @@ describe("laneService createFromUnstaged", () => { vi.mocked(getHeadSha).mockResolvedValue("sha-parent-head"); vi.mocked(runGit).mockImplementation(async (args: string[], options: { cwd?: string } = {}) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "status" && args[1] === "--porcelain=v1" && options.cwd === sourceWorktreePath) { return { exitCode: 0, stdout: "M src/file.ts\n", stderr: "" }; } @@ -262,6 +282,8 @@ describe("laneService createFromUnstaged", () => { }); vi.mocked(runGit).mockImplementation(async (args: string[], options: { cwd?: string } = {}) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "status" && args[1] === "--porcelain=v1") { if (options.cwd === sourceWorktreePath) { return { exitCode: 0, stdout: stashPushed ? "" : " M README.md\n", stderr: "" }; @@ -363,6 +385,8 @@ describe("laneService createFromUnstaged", () => { }); vi.mocked(runGit).mockImplementation(async (args: string[], options: { cwd?: string } = {}) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "status" && args[1] === "--porcelain=v1") { if (options.cwd === sourceWorktreePath) { return { exitCode: 0, stdout: stashPushed ? "" : " M src/file.ts\n", stderr: "" }; @@ -384,9 +408,6 @@ describe("laneService createFromUnstaged", () => { if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") { return { exitCode: 1, stdout: "", stderr: "fatal: no git dir" }; } - if (args[0] === "show-ref" && args[1] === "--verify" && args[2] === "--quiet") { - return { exitCode: 0, stdout: "", stderr: "" }; - } throw new Error(`Unexpected git call: ${args.join(" ")}`); }); @@ -414,6 +435,8 @@ describe("laneService createFromUnstaged", () => { vi.mocked(getHeadSha).mockResolvedValue("sha-parent-head"); vi.mocked(runGit).mockImplementation(async (args: string[], options: { cwd?: string } = {}) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "status" && args[1] === "--porcelain=v1" && options.cwd === sourceWorktreePath) { return { exitCode: 0, stdout: "", stderr: "" }; } @@ -470,6 +493,8 @@ describe("laneService create", () => { }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "rev-parse" && args[1] === "main") { return { exitCode: 0, stdout: "sha-main\n", stderr: "" }; } @@ -545,6 +570,8 @@ describe("laneService create", () => { }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "rev-parse" && args[1] === "@{upstream}") { return { exitCode: 1, stdout: "", stderr: "fatal: no upstream configured" }; } @@ -620,6 +647,8 @@ describe("laneService create", () => { }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "rev-parse" && args[1] === "main") { return { exitCode: 0, stdout: "sha-main\n", stderr: "" }; } @@ -735,6 +764,8 @@ describe("laneService list repairs", () => { ); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") { return { exitCode: 0, stdout: "missions-overhaul\n", stderr: "" }; } @@ -786,6 +817,8 @@ describe("laneService importBranch", () => { }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "show-ref" && args[1] === "--verify" && args[3] === "refs/heads/upstream/feature/import") { return { exitCode: 1, stdout: "", stderr: "" }; } @@ -851,6 +884,8 @@ describe("laneService importBranch", () => { ); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "show-ref" && args[1] === "--verify" && args[3] === "refs/heads/origin/feature/existing") { return { exitCode: 1, stdout: "", stderr: "" }; } @@ -886,6 +921,8 @@ describe("laneService importBranch", () => { await seedProjectAndStack(db, { projectId: "proj-import-local-existing", repoRoot }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "show-ref" && args[1] === "--verify" && args[3] === "refs/heads/origin/feature/existing-local") { return { exitCode: 1, stdout: "", stderr: "" }; } @@ -944,6 +981,8 @@ describe("laneService importBranch", () => { await seedProjectAndStack(db, { projectId: "proj-import-cleanup", repoRoot }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "show-ref" && args[1] === "--verify" && args[3] === "refs/heads/origin/feature/broken") { return { exitCode: 1, stdout: "", stderr: "" }; } @@ -1008,6 +1047,8 @@ describe("laneService rebaseStart", () => { return "sha-main"; }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "merge-base" && args[1] === "--is-ancestor") { return { exitCode: 0, stdout: "", stderr: "" }; } @@ -1068,6 +1109,8 @@ describe("laneService rebaseStart", () => { return "sha-main"; }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "fetch" && args[1] === "--prune" && args[2] === "origin") { return { exitCode: 0, stdout: "", stderr: "" }; } @@ -1134,6 +1177,8 @@ describe("laneService rebaseStart", () => { return "sha-main"; }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "origin/main") { return { exitCode: 0, stdout: "sha-origin-main\n", stderr: "" }; } @@ -1189,6 +1234,8 @@ describe("laneService rebaseStart", () => { return "sha-main"; }); vi.mocked(runGit).mockImplementation((args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return Promise.resolve(laneBranchGitStub); if (args[0] === "merge-base" && args[1] === "--is-ancestor") { return Promise.resolve({ exitCode: 1, stdout: "", stderr: "" }); } @@ -1255,6 +1302,8 @@ describe("laneService rebaseStart", () => { }); vi.mocked(runGitOrThrow).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "origin/main") { return { exitCode: 0, stdout: "sha-origin-main\n", stderr: "" }; } @@ -1311,6 +1360,8 @@ describe("laneService rebaseStart", () => { return "sha-main"; }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if ( args[0] === "rev-parse" && args[1] === "--abbrev-ref" @@ -1365,6 +1416,8 @@ describe("laneService rebaseStart", () => { return "sha-main"; }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; // upstream detection fails (no upstream configured) if ( args[0] === "rev-parse" @@ -1426,6 +1479,8 @@ describe("laneService rebaseStart", () => { return "sha-main"; }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; // upstream detection fails if ( args[0] === "rev-parse" @@ -1474,6 +1529,8 @@ describe("laneService rebaseStart", () => { return "sha-main"; }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; // For a worktree parent, resolveParentRebaseTarget should NOT call // rev-parse for upstream or origin refs. It goes straight to getHeadSha. if (args[0] === "rev-parse" && args[1] === "--abbrev-ref") { @@ -1530,6 +1587,8 @@ describe("laneService rebaseStart", () => { return null; }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; // All remote resolution attempts fail if (args[0] === "rev-parse") { return { exitCode: 1, stdout: "", stderr: "fatal: not found" }; @@ -1564,6 +1623,8 @@ describe("laneService rebaseStart", () => { return "sha-main"; }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if ( args[0] === "rev-parse" && args[1] === "--abbrev-ref" @@ -1614,6 +1675,8 @@ describe("laneService rebaseStart", () => { return "sha-main"; }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "merge-base" && args[1] === "--is-ancestor") { return { exitCode: 1, stdout: "", stderr: "" }; } @@ -1657,6 +1720,8 @@ describe("laneService rebaseStart", () => { const revParseVerifyCalls: string[] = []; vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if ( args[0] === "rev-parse" && args[1] === "--abbrev-ref" @@ -1716,6 +1781,8 @@ describe("laneService reparent", () => { }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if ( args[0] === "rev-parse" && args[1] === "--abbrev-ref" @@ -1786,6 +1853,8 @@ describe("laneService missionId and laneRole", () => { }); vi.mocked(runGit).mockImplementation(async (args: string[], options: { cwd?: string } = {}) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "push" && args[1] === "-u") { return { exitCode: 0, stdout: "", stderr: "" }; } @@ -1853,6 +1922,8 @@ describe("laneService missionId and laneRole", () => { throw new Error(`Unexpected git call: ${args.join(" ")}`); }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "push" && args[1] === "-u") return { exitCode: 0, stdout: "", stderr: "" }; if (args[0] === "status" && args[1] === "--porcelain=v1") return { exitCode: 0, stdout: "", stderr: "" }; if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; @@ -1899,6 +1970,8 @@ describe("laneService missionId and laneRole", () => { }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "push" && args[1] === "-u") { return { exitCode: 0, stdout: "", stderr: "" }; } @@ -1962,6 +2035,8 @@ describe("laneService missionId and laneRole", () => { }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "rev-parse" && args[1] === "main") { return { exitCode: 0, stdout: "sha-main\n", stderr: "" }; } @@ -2031,6 +2106,8 @@ describe("laneService missionId and laneRole", () => { }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "show-ref" && args[1] === "--verify" && args[3] === "refs/heads/origin/feature/remote-only") { return { exitCode: 1, stdout: "", stderr: "" }; } @@ -2089,6 +2166,8 @@ describe("laneService missionId and laneRole", () => { await seedProjectAndStack(db, { projectId: "proj-set-ownership", repoRoot }); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "status" && args[1] === "--porcelain=v1") { return { exitCode: 0, stdout: "", stderr: "" }; } @@ -2154,6 +2233,8 @@ describe("laneService missionId and laneRole", () => { db.run("update lanes set mission_id = ?, lane_role = ? where id = ?", ["mission-existing", "worker", "lane-parent"]); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "status" && args[1] === "--porcelain=v1") { return { exitCode: 0, stdout: "", stderr: "" }; } @@ -2257,6 +2338,8 @@ describe("laneService missionId and laneRole", () => { db.run("update lanes set mission_id = ?, lane_role = ? where id = ?", ["mission-map-test", "integration", "lane-parent"]); vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "status" && args[1] === "--porcelain=v1") { return { exitCode: 0, stdout: "", stderr: "" }; } @@ -2450,6 +2533,8 @@ describe("laneService delete teardown + cancellation + streaming", () => { const { service } = await setupWithLane({ teardown: fake, events }); // git status: clean. git_worktree_remove: succeeds. branch ref check: not found (skip branch delete). vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "status") return { exitCode: 0, stdout: "", stderr: "" } as any; if (args[0] === "show-ref") return { exitCode: 1, stdout: "", stderr: "" } as any; if (args[0] === "worktree" && args[1] === "remove") { @@ -2512,6 +2597,8 @@ describe("laneService delete teardown + cancellation + streaming", () => { const { service } = await setupWithLane({ teardown: fake, events }); // 3 unpushed commits + remote branch exists. vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "status") return { exitCode: 0, stdout: "", stderr: "" } as any; if (args[0] === "rev-list") return { exitCode: 0, stdout: "3", stderr: "" } as any; if (args[0] === "ls-remote") return { exitCode: 0, stdout: "abc123\trefs/heads/feature/child", stderr: "" } as any; diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index b88a0a3e8..bcbe7cd01 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -7,6 +7,7 @@ import { isWithinDir, normalizeBranchName } from "../shared/utils"; import { fetchRemoteTrackingBranch, resolveQueueRebaseOverride, type QueueRebaseOverride } from "../shared/queueRebase"; import { detectConflictKind } from "../git/gitConflictState"; import { shouldLaneTrackParent } from "../../../shared/laneBaseResolution"; +import { linearIssueBranchName, sanitizeLinearIssueBranchName } from "../../../shared/linearIssueBranch"; import type { createOperationService } from "../history/operationService"; import type { Logger } from "../logging/logger"; import type { @@ -27,6 +28,7 @@ import type { LaneBranchSwitchArgs, LaneBranchSwitchPreview, LaneBranchSwitchResult, + LaneLinearIssue, MissionLaneRole, LaneStateSnapshotSummary, LaneStatus, @@ -96,6 +98,16 @@ type LaneBranchProfileRow = { last_checked_out_at: string | null; }; +type LaneLinearIssueRow = { + id: string; + project_id: string; + lane_id: string; + issue_id: string; + issue_json: string; + created_at: string; + updated_at: string; +}; + const DEFAULT_LANE_STATUS: LaneStatus = { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }; const LANE_LIST_CACHE_TTL_MS = 10_000; @@ -115,7 +127,8 @@ function cloneLaneSummary(summary: LaneSummary): LaneSummary { status: cloneLaneStatus(summary.status), parentStatus: summary.parentStatus ? cloneLaneStatus(summary.parentStatus) : null, tags: [...summary.tags], - activeBranchProfile: summary.activeBranchProfile ? { ...summary.activeBranchProfile } : null + activeBranchProfile: summary.activeBranchProfile ? { ...summary.activeBranchProfile } : null, + linearIssue: summary.linearIssue ? { ...summary.linearIssue, labels: [...summary.linearIssue.labels] } : null }; } @@ -167,6 +180,64 @@ function parseSummaryRecord(raw: string | null): Record<string, unknown> | null } } +function parseLaneLinearIssue(raw: string | null): LaneLinearIssue | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const record = parsed as Record<string, unknown>; + const id = typeof record.id === "string" ? record.id : ""; + const identifier = typeof record.identifier === "string" ? record.identifier : ""; + const title = typeof record.title === "string" ? record.title : ""; + const projectId = typeof record.projectId === "string" ? record.projectId : ""; + const projectSlug = typeof record.projectSlug === "string" ? record.projectSlug : ""; + const teamId = typeof record.teamId === "string" ? record.teamId : ""; + const teamKey = typeof record.teamKey === "string" ? record.teamKey : ""; + const stateId = typeof record.stateId === "string" ? record.stateId : ""; + const stateName = typeof record.stateName === "string" ? record.stateName : ""; + const stateType = typeof record.stateType === "string" ? record.stateType : ""; + const createdAt = typeof record.createdAt === "string" ? record.createdAt : ""; + const updatedAt = typeof record.updatedAt === "string" ? record.updatedAt : ""; + if (!id || !identifier || !title || !projectId || !projectSlug || !teamId || !teamKey || !stateId || !stateName || !stateType || !createdAt || !updatedAt) { + return null; + } + const priority = typeof record.priority === "number" && Number.isFinite(record.priority) ? record.priority : 0; + const priorityLabel = record.priorityLabel === "urgent" || record.priorityLabel === "high" || record.priorityLabel === "normal" || record.priorityLabel === "low" + ? record.priorityLabel + : "none"; + return { + id, + identifier, + title, + description: typeof record.description === "string" ? record.description : null, + url: typeof record.url === "string" ? record.url : null, + projectId, + projectSlug, + projectName: typeof record.projectName === "string" ? record.projectName : null, + teamId, + teamKey, + teamName: typeof record.teamName === "string" ? record.teamName : null, + stateId, + stateName, + stateType, + priority, + priorityLabel, + labels: Array.isArray(record.labels) ? record.labels.filter((entry): entry is string => typeof entry === "string") : [], + assigneeId: typeof record.assigneeId === "string" ? record.assigneeId : null, + assigneeName: typeof record.assigneeName === "string" ? record.assigneeName : null, + creatorId: typeof record.creatorId === "string" ? record.creatorId : null, + creatorName: typeof record.creatorName === "string" ? record.creatorName : null, + dueDate: typeof record.dueDate === "string" ? record.dueDate : null, + estimate: typeof record.estimate === "number" && Number.isFinite(record.estimate) ? record.estimate : null, + branchName: typeof record.branchName === "string" ? record.branchName : null, + createdAt, + updatedAt, + }; + } catch { + return null; + } +} + function toLaneSummary(args: { row: LaneRow; status: LaneStatus; @@ -174,8 +245,9 @@ function toLaneSummary(args: { childCount: number; stackDepth: number; activeBranchProfile?: LaneBranchProfile | null; + linearIssue?: LaneLinearIssue | null; }): LaneSummary { - const { row, status, parentStatus, childCount, stackDepth, activeBranchProfile } = args; + const { row, status, parentStatus, childCount, stackDepth, activeBranchProfile, linearIssue } = args; return { id: row.id, name: row.name, @@ -199,7 +271,8 @@ function toLaneSummary(args: { laneRole: row.lane_role, createdAt: row.created_at, archivedAt: row.archived_at, - activeBranchProfile: activeBranchProfile ?? null + activeBranchProfile: activeBranchProfile ?? null, + linearIssue: linearIssue ?? null }; } @@ -661,6 +734,25 @@ export function createLaneService({ const getLaneRow = (laneId: string) => db.get<LaneRow>("select * from lanes where id = ? and project_id = ? limit 1", [laneId, projectId]); + const getLaneLinearIssue = (laneId: string): LaneLinearIssue | null => { + try { + const row = db.get<LaneLinearIssueRow>( + ` + select * + from lane_linear_issues + where project_id = ? + and lane_id = ? + order by updated_at desc + limit 1 + `, + [projectId, laneId], + ); + return parseLaneLinearIssue(row?.issue_json ?? null); + } catch { + return null; + } + }; + const getAllLaneRows = (includeArchived = false) => db.all<LaneRow>( includeArchived @@ -793,6 +885,150 @@ export function createLaneService({ return toLaneBranchProfile(profile); }; + const normalizeLaneLinearIssue = (issue: LaneLinearIssue, branchName: string): LaneLinearIssue => ({ + ...issue, + id: issue.id.trim(), + identifier: issue.identifier.trim(), + title: issue.title.trim(), + description: issue.description ?? null, + url: issue.url ?? null, + projectId: issue.projectId.trim(), + projectSlug: issue.projectSlug.trim(), + projectName: issue.projectName ?? null, + teamId: issue.teamId.trim(), + teamKey: issue.teamKey.trim(), + teamName: issue.teamName ?? null, + stateId: issue.stateId.trim(), + stateName: issue.stateName.trim(), + stateType: issue.stateType.trim(), + labels: issue.labels.map((entry) => entry.trim()).filter(Boolean).slice(0, 24), + assigneeId: issue.assigneeId ?? null, + assigneeName: issue.assigneeName ?? null, + creatorId: issue.creatorId ?? null, + creatorName: issue.creatorName ?? null, + dueDate: issue.dueDate ?? null, + estimate: issue.estimate ?? null, + branchName, + }); + + const upsertLaneLinearIssue = (laneId: string, issue: LaneLinearIssue, branchName: string): LaneLinearIssue => { + const normalized = normalizeLaneLinearIssue(issue, branchName); + const missing: string[] = []; + if (!normalized.id) missing.push("id"); + if (!normalized.identifier) missing.push("identifier"); + if (!normalized.title) missing.push("title"); + if (!normalized.projectId) missing.push("projectId"); + if (!normalized.projectSlug) missing.push("projectSlug"); + if (!normalized.teamId) missing.push("teamId"); + if (!normalized.teamKey) missing.push("teamKey"); + if (!normalized.stateId) missing.push("stateId"); + if (!normalized.stateName) missing.push("stateName"); + if (!normalized.stateType) missing.push("stateType"); + if (!issue.createdAt || typeof issue.createdAt !== "string" || !issue.createdAt.trim()) missing.push("createdAt"); + if (!issue.updatedAt || typeof issue.updatedAt !== "string" || !issue.updatedAt.trim()) missing.push("updatedAt"); + if (missing.length > 0) { + throw new Error(`Linear issue attachment is missing required fields: ${missing.join(", ")}.`); + } + const now = new Date().toISOString(); + db.run("begin"); + try { + db.run( + ` + delete from lane_linear_issues + where project_id = ? + and lane_id = ? + `, + [projectId, laneId], + ); + db.run( + ` + insert into lane_linear_issues( + id, project_id, lane_id, issue_id, issue_json, created_at, updated_at + ) + values(?, ?, ?, ?, ?, ?, ?) + `, + [ + randomUUID(), + projectId, + laneId, + normalized.id, + JSON.stringify(normalized), + now, + now, + ], + ); + db.run("commit"); + } catch (err) { + try { db.run("rollback"); } catch { /* keep the original upsert error */ } + throw err; + } + return normalized; + }; + + const resolveCreateBranchRef = async (args: { + name: string; + laneId: string; + branchName?: string | null; + linearIssue?: LaneLinearIssue | null; + }): Promise<string> => { + const explicitBranch = args.branchName?.trim() ?? ""; + const linearBranch = !explicitBranch && args.linearIssue + ? linearIssueBranchName(args.linearIssue) + : ""; + const suggested = explicitBranch || linearBranch; + const isCustomBranch = suggested.length > 0; + const branchSource: "explicit" | "linear" | "fallback" = explicitBranch + ? "explicit" + : linearBranch + ? "linear" + : "fallback"; + const slug = slugify(args.name); + const fallback = `ade/${slug}-${args.laneId.slice(0, 8)}`; + const branchRef = suggested + ? sanitizeLinearIssueBranchName(suggested) + : fallback; + + const check = await runGit(["check-ref-format", "--branch", branchRef], { + cwd: projectRoot, + timeoutMs: 8_000, + }); + if (check.exitCode !== 0) { + throw new Error(`Generated branch name "${branchRef}" is not valid.`); + } + + const localExists = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branchRef}`], { + cwd: projectRoot, + timeoutMs: 8_000, + }).then((res) => res.exitCode === 0); + if (localExists) { + throw new Error(`Branch "${branchRef}" already exists locally.`); + } + + const remoteCollisionMessage = branchSource === "linear" + ? `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(remoteCollisionMessage); + } + + const remoteExists = await runGit(["ls-remote", "--heads", "origin", branchRef], { + cwd: projectRoot, + timeoutMs: 15_000, + }).then((res) => res.exitCode === 0 && res.stdout.trim().length > 0); + if (remoteExists) { + throw new Error(remoteCollisionMessage); + } + } + + return branchRef; + }; + const ensureBranchProfileForRow = (row: LaneRow): LaneBranchProfile => upsertBranchProfileForRow(row); @@ -1175,6 +1411,28 @@ export function createLaneService({ const statusCache = new Map<string, LaneStatus>(); const childCountMap = new Map<string, number>(); + // Fetch all lane_linear_issues in a single query and build a map keyed by + // lane_id (latest by updated_at) — avoids an N+1 in the loop below. + const linearIssueByLaneId = new Map<string, LaneLinearIssue>(); + try { + const linearRows = db.all<LaneLinearIssueRow>( + ` + select * + from lane_linear_issues + where project_id = ? + order by updated_at desc + `, + [projectId], + ); + for (const linearRow of linearRows) { + if (!linearRow?.lane_id || linearIssueByLaneId.has(linearRow.lane_id)) continue; + const parsed = parseLaneLinearIssue(linearRow.issue_json ?? null); + if (parsed) linearIssueByLaneId.set(linearRow.lane_id, parsed); + } + } catch { + // Non-fatal — fall back to empty map (per-lane lookup absent). + } + for (const row of activeRows) { if (!row.parent_lane_id) continue; childCountMap.set(row.parent_lane_id, (childCountMap.get(row.parent_lane_id) ?? 0) + 1); @@ -1280,7 +1538,8 @@ export function createLaneService({ parentStatus, childCount: childCountMap.get(row.id) ?? 0, stackDepth, - activeBranchProfile: ensureBranchProfileForRow(row) + activeBranchProfile: ensureBranchProfileForRow(row), + linearIssue: linearIssueByLaneId.get(row.id) ?? null, }) ); if (includeStatus) { @@ -1312,12 +1571,19 @@ export function createLaneService({ folder?: string; missionId?: string | null; laneRole?: MissionLaneRole | null; + branchName?: string | null; + linearIssue?: LaneLinearIssue | null; }): Promise<LaneSummary> => { const laneId = randomUUID(); const now = new Date().toISOString(); const slug = slugify(args.name); const suffix = laneId.slice(0, 8); - const branchRef = `ade/${slug}-${suffix}`; + const branchRef = await resolveCreateBranchRef({ + name: args.name, + laneId, + branchName: args.branchName, + linearIssue: args.linearIssue, + }); const worktreePath = path.join(worktreesDir, `${slug}-${suffix}`); await runGitOrThrow(["worktree", "add", "-b", branchRef, worktreePath, args.startPoint], { @@ -1349,6 +1615,9 @@ export function createLaneService({ now ] ); + const linearIssue = args.linearIssue + ? upsertLaneLinearIssue(laneId, args.linearIssue, branchRef) + : null; invalidateLaneListCache(); // Best-effort initial push to establish upstream tracking @@ -1379,7 +1648,8 @@ export function createLaneService({ parentStatus, childCount: 0, stackDepth: computeStackDepth({ laneId: laneId, rowsById, memo: new Map() }), - activeBranchProfile: ensureBranchProfileForRow(row) + activeBranchProfile: ensureBranchProfileForRow(row), + linearIssue, }); }; @@ -1568,7 +1838,7 @@ export function createLaneService({ invalidateLaneListCache(); }, - async create({ name, description, parentLaneId, baseBranch }: CreateLaneArgs): Promise<LaneSummary> { + async create({ name, description, parentLaneId, baseBranch, branchName, linearIssue }: CreateLaneArgs): Promise<LaneSummary> { if (parentLaneId) { const parent = getLaneRow(parentLaneId); if (!parent) throw new Error(`Parent lane not found: ${parentLaneId}`); @@ -1615,7 +1885,9 @@ export function createLaneService({ description, baseRef: requestedBaseRef, startPoint: parentHeadSha, - parentLaneId: parent.lane_type === "primary" ? null : parent.id + parentLaneId: parent.lane_type === "primary" ? null : parent.id, + branchName, + linearIssue, }); } @@ -1632,7 +1904,9 @@ export function createLaneService({ description, baseRef: requestedBaseRef, startPoint, - parentLaneId: null + parentLaneId: null, + branchName, + linearIssue, }); }, @@ -1680,6 +1954,8 @@ export function createLaneService({ folder: args.folder, missionId: args.missionId ?? null, laneRole: args.laneRole ?? null, + branchName: args.branchName, + linearIssue: args.linearIssue ?? null, }); } @@ -1699,6 +1975,8 @@ export function createLaneService({ folder: args.folder, missionId: args.missionId ?? null, laneRole: args.laneRole ?? null, + branchName: args.branchName, + linearIssue: args.linearIssue ?? null, }); } @@ -1712,7 +1990,9 @@ export function createLaneService({ parentLaneId: parent.id, folder: args.folder, missionId: args.missionId ?? null, - laneRole: args.laneRole ?? null + laneRole: args.laneRole ?? null, + branchName: args.branchName, + linearIssue: args.linearIssue ?? null, }); }, @@ -1797,6 +2077,7 @@ export function createLaneService({ } } + db.run("delete from lane_linear_issues where lane_id = ? and project_id = ?", [laneId, projectId]); db.run("delete from lanes where id = ? and project_id = ?", [laneId, projectId]); invalidateLaneListCache(); }; @@ -3305,6 +3586,7 @@ export function createLaneService({ db.run("delete from process_runtime where lane_id = ?", [laneId]); db.run("delete from process_runs where lane_id = ?", [laneId]); db.run("delete from test_runs where lane_id = ?", [laneId]); + db.run("delete from lane_linear_issues where lane_id = ? and project_id = ?", [laneId, projectId]); db.run("delete from lanes where id = ? and project_id = ?", [laneId, projectId]); db.run("commit"); } catch (error) { @@ -3399,10 +3681,16 @@ export function createLaneService({ return row.worktree_path; }, - getLaneBaseAndBranch(laneId: string): { baseRef: string; branchRef: string; worktreePath: string; laneType: LaneType } { + getLaneBaseAndBranch(laneId: string): { baseRef: string; branchRef: string; worktreePath: string; laneType: LaneType; linearIssue: LaneLinearIssue | null } { const row = getLaneRow(laneId); if (!row) throw new Error(`Lane not found: ${laneId}`); - return { baseRef: row.base_ref, branchRef: row.branch_ref, worktreePath: row.worktree_path, laneType: row.lane_type }; + return { + baseRef: row.base_ref, + branchRef: row.branch_ref, + worktreePath: row.worktree_path, + laneType: row.lane_type, + linearIssue: getLaneLinearIssue(laneId), + }; }, updateBranchRef(laneId: string, branchRef: string): void { diff --git a/apps/desktop/src/main/services/prs/issueInventoryService.ts b/apps/desktop/src/main/services/prs/issueInventoryService.ts index b0b83847c..30c1eefc0 100644 --- a/apps/desktop/src/main/services/prs/issueInventoryService.ts +++ b/apps/desktop/src/main/services/prs/issueInventoryService.ts @@ -56,6 +56,7 @@ const KNOWN_BOT_ALIASES: Record<string, IssueSource> = { "ade-review": "ade", "greptile-review": "greptile", "greptile": "greptile", + "greptile-apps": "greptile", "seer-code-review": "seer", "seer": "seer", }; @@ -83,6 +84,10 @@ const CONVERGENCE_POLLER_STATUS_VALUES = new Set<ConvergenceRuntimeState["poller "stopped", ]); +const CONVERGENCE_MERGE_WAIT_KIND_VALUES = new Set<NonNullable<ConvergenceRuntimeState["mergeWaitKind"]>>([ + "github_auto_merge_armed", +]); + export function detectSource(author: string | null | undefined): IssueSource { const name = (author ?? "").trim(); if (!name) return "unknown"; @@ -166,6 +171,26 @@ function extractHeadline(body: string | null | undefined, fallback: string): str return fallback; } +function isReviewThreadMetadataComment(comment: PrReviewThread["comments"][number] | null | undefined): boolean { + const body = (comment?.body ?? "").trim(); + if (!body) return false; + return /^>\s*Skipped:\s*comment is from another GitHub bot\./i.test(body) + && /auto-generated reply by CodeRabbit/i.test(body); +} + +function latestInventoryReviewComment(thread: PrReviewThread): PrReviewThread["comments"][number] | null { + for (let index = thread.comments.length - 1; index >= 0; index--) { + const comment = thread.comments[index]; + if (!isReviewThreadMetadataComment(comment)) return comment; + } + return thread.comments.at(-1) ?? null; +} + +function countInventoryReviewComments(thread: PrReviewThread): number { + const actionableCount = thread.comments.filter((comment) => !isReviewThreadMetadataComment(comment)).length; + return actionableCount > 0 ? actionableCount : thread.comments.length; +} + // --------------------------------------------------------------------------- // DB row shape // --------------------------------------------------------------------------- @@ -285,6 +310,7 @@ type ConvergenceRuntimeRow = { auto_converge_enabled: number; status: string; poller_status: string; + merge_wait_kind: string | null; current_round: number; active_session_id: string | null; active_lane_id: string | null; @@ -295,6 +321,8 @@ type ConvergenceRuntimeRow = { ci_retry_attempts_used: number | null; wait_for_ci_started_at: string | null; last_dispatch_head_sha: string | null; + last_bot_ping_head_sha: string | null; + last_bot_ping_at: string | null; pause_repeat_count: number | null; last_pause_reason_hash: string | null; last_started_at: string | null; @@ -337,6 +365,11 @@ function validateConvergenceRuntimeState(state: Partial<ConvergenceRuntimeState> throw new Error(`Invalid convergence poller status: ${JSON.stringify(state.pollerStatus)}`); } } + if (state.mergeWaitKind !== undefined && state.mergeWaitKind !== null) { + if (typeof state.mergeWaitKind !== "string" || !CONVERGENCE_MERGE_WAIT_KIND_VALUES.has(state.mergeWaitKind as NonNullable<ConvergenceRuntimeState["mergeWaitKind"]>)) { + throw new Error(`Invalid convergence merge wait kind: ${JSON.stringify(state.mergeWaitKind)}`); + } + } if (state.currentRound !== undefined) { if (typeof state.currentRound !== "number" || !Number.isFinite(state.currentRound)) { throw new Error(`Invalid currentRound: expected a finite number, got ${JSON.stringify(state.currentRound)}`); @@ -363,6 +396,8 @@ function validateConvergenceRuntimeState(state: Partial<ConvergenceRuntimeState> "errorMessage", "waitForCiStartedAt", "lastDispatchHeadSha", + "lastBotPingHeadSha", + "lastBotPingAt", "lastPauseReasonHash", "lastStartedAt", "lastPolledAt", @@ -389,12 +424,18 @@ function sanitizeConvergenceRuntimeState( state: ConvergenceRuntimeState, ): ConvergenceRuntimeState { const now = nowIso(); + const mergeWaitKind = state.mergeWaitKind && CONVERGENCE_MERGE_WAIT_KIND_VALUES.has(state.mergeWaitKind) + ? state.mergeWaitKind + : null; return { prId, autoConvergeEnabled: state.autoConvergeEnabled, pathToMergeActive: state.pathToMergeActive, status: state.status, pollerStatus: state.pollerStatus, + mergeWaitKind: state.status === "converged" && state.pollerStatus === "waiting_for_checks" + ? mergeWaitKind + : null, currentRound: state.currentRound, activeSessionId: trimOrNull(state.activeSessionId), activeLaneId: trimOrNull(state.activeLaneId), @@ -405,6 +446,8 @@ function sanitizeConvergenceRuntimeState( ciRetryAttemptsUsed: Math.max(0, Math.floor(state.ciRetryAttemptsUsed)), waitForCiStartedAt: trimOrNull(state.waitForCiStartedAt), lastDispatchHeadSha: trimOrNull(state.lastDispatchHeadSha), + lastBotPingHeadSha: trimOrNull(state.lastBotPingHeadSha), + lastBotPingAt: trimOrNull(state.lastBotPingAt), pauseRepeatCount: Math.max(0, Math.floor(state.pauseRepeatCount)), lastPauseReasonHash: trimOrNull(state.lastPauseReasonHash), lastStartedAt: trimOrNull(state.lastStartedAt), @@ -423,6 +466,7 @@ function rowToConvergenceRuntime(row: ConvergenceRuntimeRow): ConvergenceRuntime pathToMergeActive: false, status: row.status as ConvergenceRuntimeState["status"], pollerStatus: row.poller_status as ConvergenceRuntimeState["pollerStatus"], + mergeWaitKind: row.merge_wait_kind as ConvergenceRuntimeState["mergeWaitKind"], currentRound: row.current_round, activeSessionId: row.active_session_id, activeLaneId: row.active_lane_id, @@ -433,6 +477,8 @@ function rowToConvergenceRuntime(row: ConvergenceRuntimeRow): ConvergenceRuntime ciRetryAttemptsUsed: row.ci_retry_attempts_used ?? 0, waitForCiStartedAt: row.wait_for_ci_started_at, lastDispatchHeadSha: row.last_dispatch_head_sha, + lastBotPingHeadSha: row.last_bot_ping_head_sha, + lastBotPingAt: row.last_bot_ping_at, pauseRepeatCount: row.pause_repeat_count ?? 0, lastPauseReasonHash: row.last_pause_reason_hash, lastStartedAt: row.last_started_at, @@ -771,17 +817,19 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { db.run( `insert into pr_convergence_state - (pr_id, auto_converge_enabled, status, poller_status, current_round, active_session_id, + (pr_id, auto_converge_enabled, status, poller_status, merge_wait_kind, current_round, active_session_id, active_lane_id, active_href, pause_reason, error_message, force_finalize_used, ci_retry_attempts_used, wait_for_ci_started_at, - last_dispatch_head_sha, pause_repeat_count, last_pause_reason_hash, + last_dispatch_head_sha, last_bot_ping_head_sha, last_bot_ping_at, + pause_repeat_count, last_pause_reason_hash, last_started_at, last_polled_at, last_paused_at, last_stopped_at, created_at, updated_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(pr_id) do update set auto_converge_enabled = excluded.auto_converge_enabled, status = excluded.status, poller_status = excluded.poller_status, + merge_wait_kind = excluded.merge_wait_kind, current_round = excluded.current_round, active_session_id = excluded.active_session_id, active_lane_id = excluded.active_lane_id, @@ -792,6 +840,8 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { ci_retry_attempts_used = excluded.ci_retry_attempts_used, wait_for_ci_started_at = excluded.wait_for_ci_started_at, last_dispatch_head_sha = excluded.last_dispatch_head_sha, + last_bot_ping_head_sha = excluded.last_bot_ping_head_sha, + last_bot_ping_at = excluded.last_bot_ping_at, pause_repeat_count = excluded.pause_repeat_count, last_pause_reason_hash = excluded.last_pause_reason_hash, last_started_at = excluded.last_started_at, @@ -804,6 +854,7 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { merged.autoConvergeEnabled ? 1 : 0, merged.status, merged.pollerStatus, + merged.mergeWaitKind, merged.currentRound, merged.activeSessionId, merged.activeLaneId, @@ -814,6 +865,8 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { merged.ciRetryAttemptsUsed, merged.waitForCiStartedAt, merged.lastDispatchHeadSha, + merged.lastBotPingHeadSha, + merged.lastBotPingAt, merged.pauseRepeatCount, merged.lastPauseReasonHash, merged.lastStartedAt, @@ -900,8 +953,8 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { // Sync review threads using the latest reply in the conversation. for (const thread of reviewThreads) { const externalId = `thread:${thread.id}`; - const latestComment = thread.comments.at(-1) ?? null; - const commentCount = thread.comments.length; + const latestComment = latestInventoryReviewComment(thread); + const commentCount = countInventoryReviewComments(thread); const author = latestComment?.author ?? null; const body = latestComment?.body ?? null; const source = detectSource(author); @@ -981,25 +1034,38 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { }); } - for (const comment of comments) { - if (comment.source !== "issue") continue; - if (isNoisyIssueComment(comment)) continue; - const body = comment.body ?? ""; - upsertItem(prId, `comment:${comment.id}`, { - source: detectSource(comment.author), - type: "issue_comment", + const noisyIssueCommentIds = new Set<string>(); + for (const comment of comments) { + if (comment.source !== "issue") continue; + if (isNoisyIssueComment(comment)) { + noisyIssueCommentIds.add(`comment:${comment.id}`); + continue; + } + const body = comment.body ?? ""; + upsertItem(prId, `comment:${comment.id}`, { + source: detectSource(comment.author), + type: "issue_comment", filePath: comment.path, line: comment.line, severity: extractSeverity(body), headline: extractHeadline(body, `Comment by ${comment.author}`), body, author: comment.author, - url: comment.url, - }); - } - - return buildSnapshot(prId); - }, + url: comment.url, + }); + } + for (const existing of existingRows) { + if (existing.type !== "issue_comment") continue; + if (!noisyIssueCommentIds.has(existing.external_id)) continue; + if (existing.state === "fixed" || existing.state === "dismissed") continue; + db.run( + "update pr_issue_inventory set state = 'fixed', updated_at = ? where id = ?", + [nowIso(), existing.id], + ); + } + + return buildSnapshot(prId); + }, getInventory(prId: string): IssueInventorySnapshot { return buildSnapshot(prId); diff --git a/apps/desktop/src/main/services/prs/pathToMergeOrchestrator.test.ts b/apps/desktop/src/main/services/prs/pathToMergeOrchestrator.test.ts index 3395618b1..81abee9a3 100644 --- a/apps/desktop/src/main/services/prs/pathToMergeOrchestrator.test.ts +++ b/apps/desktop/src/main/services/prs/pathToMergeOrchestrator.test.ts @@ -1,25 +1,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { launchPrIssueResolutionChat } from "./prIssueResolver"; import { - PHASE_DELAY_SECONDS, - createPathToMergeOrchestrator, - decideAtCapAction, - makeInProcessState, - type InProcessState, - type PathToMergeDeps, -} from "./pathToMergeOrchestrator"; + PHASE_DELAY_SECONDS, + createPathToMergeOrchestrator, + decideAtCapAction, + makeInProcessState, + shouldAttemptAdminMergeForRestError, + type InProcessState, + type PathToMergeDeps, + } from "./pathToMergeOrchestrator"; import { LaneWorktreeLockedError, formatLaneWorktreeLockBlocker } from "../lanes/laneWorktreeLockService"; import type { AtCapPolicy, ConvergenceRuntimeState, LaneWorktreeLockInfo, LaneWorktreeLockOwnerKind, - PipelineSettings, - PrCheck, - PrReview, - PrReviewThread, - PrSummary, -} from "../../../shared/types"; + PipelineSettings, + PrCheck, + PrComment, + PrFile, + PrReview, + PrReviewThread, + PrSummary, + } from "../../../shared/types"; import { DEFAULT_CONVERGENCE_RUNTIME_STATE, DEFAULT_PIPELINE_SETTINGS } from "../../../shared/types/prs"; vi.mock("./prIssueResolver", () => ({ @@ -131,6 +134,17 @@ function makeReview(overrides: Partial<PrReview> = {}): PrReview { }; } +function makePrFile(index: number): PrFile { + return { + filename: `file-${index}.ts`, + status: "modified", + additions: 1, + deletions: 0, + patch: null, + previousFilename: null, + }; +} + async function flushIteration(): Promise<void> { await new Promise<void>((resolve) => setImmediate(resolve)); await Promise.resolve(); @@ -236,7 +250,10 @@ function buildDeps(initial?: { getChecks?: PathToMergeDeps["prService"]["getChecks"]; getReviewThreads?: PathToMergeDeps["prService"]["getReviewThreads"]; getReviews?: PathToMergeDeps["prService"]["getReviews"]; + getComments?: PathToMergeDeps["prService"]["getComments"]; getCommits?: PathToMergeDeps["prService"]["getCommits"]; + getFiles?: PathToMergeDeps["prService"]["getFiles"]; + addComment?: PathToMergeDeps["prService"]["addComment"]; land?: PathToMergeDeps["prService"]["land"]; laneWorktreeLockService?: ReturnType<typeof makeLaneWorktreeLockService>; }): { @@ -245,6 +262,7 @@ function buildDeps(initial?: { ptmArgsByPrId: Map<string, Record<string, unknown> | null>; interrupt: ReturnType<typeof vi.fn>; resetSentToAgent: ReturnType<typeof vi.fn>; + addComment: PathToMergeDeps["prService"]["addComment"]; land: PathToMergeDeps["prService"]["land"]; laneWorktreeLockService: ReturnType<typeof makeLaneWorktreeLockService>; } { @@ -252,6 +270,19 @@ function buildDeps(initial?: { const ptmArgsByPrId = initial?.ptmArgsByPrId ?? new Map<string, Record<string, unknown> | null>(); const prs = initial?.prs ?? []; const prById = (prId: string) => prs.find((pr) => pr.id === prId) ?? prs[0] ?? null; + let convergenceStatus = { + currentRound: 0, + maxRounds: 5, + issuesPerRound: [], + totalNew: 0, + totalFixed: 0, + totalDismissed: 0, + totalEscalated: 0, + totalSentToAgent: 0, + isConverging: false, + canAutoAdvance: false, + ...initial?.convergenceStatus, + } as ReturnType<PathToMergeDeps["issueInventoryService"]["getConvergenceStatus"]>; const interrupt = vi.fn(async (_args: { sessionId: string }) => undefined); const resetSentToAgent = vi.fn(); @@ -265,6 +296,18 @@ function buildDeps(initial?: { laneArchived: false, error: "test", })); + const addComment = initial?.addComment ?? vi.fn(async (args: { prId: string; body: string }) => ({ + id: `comment-${args.body}`, + author: "ade", + authorAvatarUrl: null, + body: args.body, + source: "issue" as const, + url: null, + path: null, + line: null, + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-01T00:00:00.000Z", + })); const logger = { debug: () => {}, @@ -296,7 +339,10 @@ function buildDeps(initial?: { const pr = prById(prId); return pr?.reviewStatus === "changes_requested" ? [makeReview({ state: "changes_requested", body: "Please fix this." })] : []; }), + getComments: initial?.getComments ?? (async () => []), getCommits: initial?.getCommits ?? (async () => []), + getFiles: initial?.getFiles ?? (async () => []), + addComment, land, runPostMergeCleanup: async () => undefined, } as unknown as PathToMergeDeps["prService"], @@ -336,19 +382,22 @@ function buildDeps(initial?: { getPathToMergeArgs: (prId: string) => ptmArgsByPrId.get(prId) ?? null, resetSentToAgent, getPipelineSettings: () => ({ ...DEFAULT_PIPELINE_SETTINGS, ...initial?.pipelineSettings }), - getConvergenceStatus: () => ({ - currentRound: 0, - maxRounds: 5, - issuesPerRound: [], - totalNew: 0, - totalFixed: 0, - totalDismissed: 0, - totalEscalated: 0, - totalSentToAgent: 0, - isConverging: false, - canAutoAdvance: false, - ...initial?.convergenceStatus, - }), + getConvergenceStatus: () => convergenceStatus, + syncFromPrData: (_prId: string, checks: PrCheck[], reviewThreads: PrReviewThread[], comments: PrComment[]) => { + const totalNew = checks.filter((check) => check.conclusion === "failure").length + + reviewThreads.filter((thread) => !thread.isResolved && !thread.isOutdated).length + + comments.length; + convergenceStatus = { + ...convergenceStatus, + totalNew, + }; + return { + prId: _prId, + items: [], + convergence: convergenceStatus, + runtime: runtimeByPrId.get(_prId) ?? buildRuntime(_prId), + }; + }, } as unknown as PathToMergeDeps["issueInventoryService"], conflictService: { runExternalResolver: async () => ({ status: "completed" }), @@ -358,7 +407,7 @@ function buildDeps(initial?: { defaultReasoningEffort: null, }; - return { deps, runtimeByPrId, ptmArgsByPrId, interrupt, resetSentToAgent, land, laneWorktreeLockService }; + return { deps, runtimeByPrId, ptmArgsByPrId, interrupt, resetSentToAgent, addComment, land, laneWorktreeLockService }; } // --------------------------------------------------------------------------- @@ -385,6 +434,21 @@ describe("PHASE_DELAY_SECONDS", () => { }); }); +describe("shouldAttemptAdminMergeForRestError", () => { + it("does not use admin merge when review checks were intentionally skipped", () => { + expect(shouldAttemptAdminMergeForRestError("Review is required before merging", { ignoreReview: true })).toBe(false); + }); + + it("allows the admin rung after full review readiness or explicit force merge", () => { + expect(shouldAttemptAdminMergeForRestError("required status check failed", { ignoreReview: false })).toBe(true); + expect(shouldAttemptAdminMergeForRestError("required review", { allowForceMerge: true, ignoreReview: true })).toBe(true); + }); + + it("ignores non-policy REST failures", () => { + expect(shouldAttemptAdminMergeForRestError("network timed out", { ignoreReview: false })).toBe(false); + }); +}); + describe("createPathToMergeOrchestrator.startPathToMerge", () => { it("rejects an empty prId so callers can't accidentally arm a global loop", async () => { const { deps } = buildDeps(); @@ -637,7 +701,7 @@ describe("createPathToMergeOrchestrator.runIteration", () => { const runtimeByPrId = new Map<string, ConvergenceRuntimeState>(); const { deps } = buildDeps({ runtimeByPrId, - prs: [buildPrSummary({ checksStatus: "failing", reviewStatus: "changes_requested" })], + prs: [buildPrSummary({ checksStatus: "passing", reviewStatus: "none" })], pipelineSettings: { earlyMergeOnGreen: false, autoMerge: false }, convergenceStatus: { totalNew: 0 }, }); @@ -838,6 +902,209 @@ describe("createPathToMergeOrchestrator.runIteration", () => { } }); + it("waits for pending review-bot checks before dispatching a fix iteration", async () => { + const runtimeByPrId = new Map<string, ConvergenceRuntimeState>(); + const pendingReviewCheckStartedAt = new Date().toISOString(); + const { deps } = buildDeps({ + runtimeByPrId, + prs: [buildPrSummary({ checksStatus: "passing", reviewStatus: "none" })], + pipelineSettings: { earlyMergeOnGreen: false, autoMerge: false }, + convergenceStatus: { totalNew: 1 }, + getChecks: async () => [ + makeCheck({ name: "ci / unit", status: "completed", conclusion: "success" }), + makeCheck({ name: "Greptile Review", status: "in_progress", conclusion: null, startedAt: pendingReviewCheckStartedAt }), + ], + getReviewThreads: async () => [makeReviewThread()], + }); + const orchestrator = createPathToMergeOrchestrator(deps); + try { + await orchestrator.startPathToMerge({ prId: "pr-1", modelId: "openai/gpt-5.4" }); + await flushIteration(); + + expect(launchPrIssueResolutionChatMock).not.toHaveBeenCalled(); + expect(runtimeByPrId.get("pr-1")).toMatchObject({ + status: "running", + pollerStatus: "waiting_for_comments", + }); + } finally { + orchestrator.dispose(); + } + }); + + it("posts review-bot pings and waits after observing a fix-agent push", async () => { + vi.useFakeTimers(); + const runtimeByPrId = new Map<string, ConvergenceRuntimeState>([ + ["pr-1", buildRuntime("pr-1", { + autoConvergeEnabled: true, + status: "running", + pollerStatus: "polling", + activeSessionId: "sess-1", + lastDispatchHeadSha: "sha-before", + })], + ]); + const ptmArgsByPrId = new Map<string, Record<string, unknown> | null>([ + ["pr-1", { modelId: "openai/gpt-5.4", reasoning: null, permissionMode: "default", scope: "both", additionalInstructions: null }], + ]); + const addComment = vi.fn(async (args: { prId: string; body: string }) => ({ + id: `comment-${args.body}`, + author: "ade", + authorAvatarUrl: null, + body: args.body, + source: "issue" as const, + url: null, + path: null, + line: null, + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-01T00:00:00.000Z", + })); + const { deps } = buildDeps({ + runtimeByPrId, + ptmArgsByPrId, + prs: [buildPrSummary({ checksStatus: "failing", reviewStatus: "changes_requested" })], + pipelineSettings: { earlyMergeOnGreen: false }, + convergenceStatus: { totalNew: 1 }, + getFiles: async () => Array.from({ length: 251 }, (_, index) => ({ + filename: `file-${index}.ts`, + status: "modified" as const, + additions: 1, + deletions: 0, + patch: null, + previousFilename: null, + })), + getCommits: async () => [{ + sha: "sha-after", + shortSha: "sha-after", + message: "fix", + author: { login: null, name: "Test", email: null }, + committedDate: "2026-05-01T00:01:00.000Z", + }], + getSessionSummary: async () => ({ status: "idle", awaitingInput: false }) as Awaited<ReturnType<PathToMergeDeps["agentChatService"]["getSessionSummary"]>>, + addComment: addComment as unknown as PathToMergeDeps["prService"]["addComment"], + }); + const orchestrator = createPathToMergeOrchestrator(deps); + try { + orchestrator.resumeFromPersistedState(); + await vi.advanceTimersByTimeAsync(PHASE_DELAY_SECONDS.warming * 1000); + + expect(launchPrIssueResolutionChatMock).not.toHaveBeenCalled(); + expect(addComment).toHaveBeenCalledTimes(3); + expect(addComment.mock.calls.map((call) => call[0].body)).toEqual([ + "@copilot review but do not make fixes", + "@greptile review", + "@coderabbit review", + ]); + expect(runtimeByPrId.get("pr-1")).toMatchObject({ + activeSessionId: null, + pollerStatus: "waiting_for_comments", + lastDispatchHeadSha: null, + }); + } finally { + orchestrator.dispose(); + vi.useRealTimers(); + } + }); + + it("does not repost review-bot pings for a restored head SHA", async () => { + vi.useFakeTimers(); + const runtimeByPrId = new Map<string, ConvergenceRuntimeState>([ + ["pr-1", buildRuntime("pr-1", { + autoConvergeEnabled: true, + status: "running", + pollerStatus: "polling", + activeSessionId: "sess-1", + lastDispatchHeadSha: "sha-before", + lastBotPingHeadSha: "sha-after", + lastBotPingAt: "2026-05-01T00:02:00.000Z", + })], + ]); + const ptmArgsByPrId = new Map<string, Record<string, unknown> | null>([ + ["pr-1", { modelId: "openai/gpt-5.4", reasoning: null, permissionMode: "default", scope: "both", additionalInstructions: null }], + ]); + const addComment = vi.fn(); + const { deps } = buildDeps({ + runtimeByPrId, + ptmArgsByPrId, + prs: [buildPrSummary({ checksStatus: "failing", reviewStatus: "changes_requested" })], + pipelineSettings: { earlyMergeOnGreen: false }, + convergenceStatus: { totalNew: 1 }, + getCommits: async () => [{ + sha: "sha-after", + shortSha: "sha-after", + message: "fix", + author: { login: null, name: "Test", email: null }, + committedDate: "2026-05-01T00:01:00.000Z", + }], + getSessionSummary: async () => ({ status: "idle", awaitingInput: false }) as Awaited<ReturnType<PathToMergeDeps["agentChatService"]["getSessionSummary"]>>, + addComment: addComment as unknown as PathToMergeDeps["prService"]["addComment"], + }); + const orchestrator = createPathToMergeOrchestrator(deps); + try { + orchestrator.resumeFromPersistedState(); + await vi.advanceTimersByTimeAsync(PHASE_DELAY_SECONDS.warming * 1000); + + expect(addComment).not.toHaveBeenCalled(); + expect(runtimeByPrId.get("pr-1")).toMatchObject({ + activeSessionId: null, + pollerStatus: "waiting_for_comments", + lastDispatchHeadSha: null, + lastBotPingHeadSha: "sha-after", + lastBotPingAt: "2026-05-01T00:02:00.000Z", + }); + } finally { + orchestrator.dispose(); + vi.useRealTimers(); + } + }); + + it("does not wait forever on stale review-bot checks", async () => { + const runtimeByPrId = new Map<string, ConvergenceRuntimeState>(); + const land = vi.fn(async () => ({ + prId: "pr-1", + prNumber: 1, + success: true as const, + mergeCommitSha: "merge-sha", + branchDeleted: false, + laneArchived: false, + error: null, + })); + const { deps } = buildDeps({ + runtimeByPrId, + prs: [buildPrSummary({ checksStatus: "passing", reviewStatus: "approved" })], + pipelineSettings: { earlyMergeOnGreen: false, autoMerge: true, atCapPolicy: "ci_retry_once" }, + convergenceStatus: { totalNew: 0 }, + getChecks: async () => [ + makeCheck({ name: "ci / unit", status: "completed", conclusion: "success" }), + makeCheck({ + name: "Greptile Review", + status: "in_progress", + conclusion: null, + startedAt: "2026-05-01T00:00:00.000Z", + }), + ], + getReviewThreads: async () => [], + getReviews: async () => [], + land, + }); + const orchestrator = createPathToMergeOrchestrator(deps); + try { + await orchestrator.startPathToMerge({ prId: "pr-1", modelId: "openai/gpt-5.4" }); + runtimeByPrId.set("pr-1", { + ...runtimeByPrId.get("pr-1")!, + currentRound: 5, + }); + await flushIteration(); + + expect(land).not.toHaveBeenCalled(); + expect(runtimeByPrId.get("pr-1")).toMatchObject({ + status: "failed", + pollerStatus: "stopped", + errorMessage: "At-cap merge ladder blocked: Auto-merge blocked because 1 CI check is still running.", + }); + } finally { + orchestrator.dispose(); + } + }); + it("narrows the default both scope to review comments when CI is already green", async () => { const runtimeByPrId = new Map<string, ConvergenceRuntimeState>(); const { deps } = buildDeps({ @@ -893,6 +1160,204 @@ describe("createPathToMergeOrchestrator.runIteration", () => { } }); + it("syncs inventory before deciding a failing-check PR has nothing to fix", async () => { + const runtimeByPrId = new Map<string, ConvergenceRuntimeState>(); + const { deps } = buildDeps({ + runtimeByPrId, + prs: [buildPrSummary({ checksStatus: "failing", reviewStatus: "none" })], + pipelineSettings: { earlyMergeOnGreen: false, autoMerge: true }, + convergenceStatus: { totalNew: 0 }, + getChecks: async () => [makeCheck({ name: "ci / unit", conclusion: "failure" })], + getReviewThreads: async () => [], + }); + const orchestrator = createPathToMergeOrchestrator(deps); + try { + await orchestrator.startPathToMerge({ prId: "pr-1", modelId: "openai/gpt-5.4" }); + await flushIteration(); + + expect(launchPrIssueResolutionChatMock).toHaveBeenCalledTimes(1); + expect(launchPrIssueResolutionChatMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ + prId: "pr-1", + scope: "checks", + })); + expect(runtimeByPrId.get("pr-1")).toMatchObject({ + status: "running", + activeSessionId: "sess-launched", + }); + } finally { + orchestrator.dispose(); + } + }); + + it("blocks review blockers at the cap for non-force policies", async () => { + const runtimeByPrId = new Map<string, ConvergenceRuntimeState>(); + const land = vi.fn(async () => ({ + prId: "pr-1", + prNumber: 1, + success: true as const, + mergeCommitSha: "merge-sha", + branchDeleted: false, + laneArchived: false, + error: null, + })); + const { deps } = buildDeps({ + runtimeByPrId, + prs: [buildPrSummary({ checksStatus: "passing", reviewStatus: "changes_requested" })], + pipelineSettings: { earlyMergeOnGreen: false, autoMerge: true, atCapPolicy: "ci_retry_once" }, + convergenceStatus: { totalNew: 0 }, + getReviewThreads: async () => [makeReviewThread()], + getReviews: async () => [makeReview({ state: "changes_requested", body: "Still needs work." })], + land, + }); + const orchestrator = createPathToMergeOrchestrator(deps); + try { + await orchestrator.startPathToMerge({ prId: "pr-1", modelId: "openai/gpt-5.4" }); + runtimeByPrId.set("pr-1", { + ...runtimeByPrId.get("pr-1")!, + currentRound: 5, + }); + await flushIteration(); + + expect(launchPrIssueResolutionChatMock).not.toHaveBeenCalled(); + expect(land).not.toHaveBeenCalled(); + expect(runtimeByPrId.get("pr-1")).toMatchObject({ + status: "failed", + pollerStatus: "stopped", + errorMessage: "At-cap merge ladder blocked: Auto-merge blocked because a review requested changes.", + }); + } finally { + orchestrator.dispose(); + } + }); + + it("blocks requested human review at the cap for non-force policies", async () => { + const runtimeByPrId = new Map<string, ConvergenceRuntimeState>(); + const land = vi.fn(async () => ({ + prId: "pr-1", + prNumber: 1, + success: true as const, + mergeCommitSha: "merge-sha", + branchDeleted: false, + laneArchived: false, + error: null, + })); + const { deps } = buildDeps({ + runtimeByPrId, + prs: [buildPrSummary({ checksStatus: "passing", reviewStatus: "requested" })], + pipelineSettings: { earlyMergeOnGreen: false, autoMerge: true, atCapPolicy: "ci_retry_once" }, + convergenceStatus: { totalNew: 0 }, + getChecks: async () => [makeCheck({ name: "ci / unit", status: "completed", conclusion: "success" })], + getReviewThreads: async () => [], + getReviews: async () => [], + land, + }); + const orchestrator = createPathToMergeOrchestrator(deps); + try { + await orchestrator.startPathToMerge({ prId: "pr-1", modelId: "openai/gpt-5.4" }); + runtimeByPrId.set("pr-1", { + ...runtimeByPrId.get("pr-1")!, + currentRound: 5, + }); + await flushIteration(); + + expect(launchPrIssueResolutionChatMock).not.toHaveBeenCalled(); + expect(land).not.toHaveBeenCalled(); + expect(runtimeByPrId.get("pr-1")).toMatchObject({ + status: "failed", + pollerStatus: "stopped", + errorMessage: "At-cap merge ladder blocked: Auto-merge blocked because a requested review is still pending.", + }); + } finally { + orchestrator.dispose(); + } + }); + + it("still verifies CI before merging at the cap", async () => { + const runtimeByPrId = new Map<string, ConvergenceRuntimeState>(); + const land = vi.fn(async () => ({ + prId: "pr-1", + prNumber: 1, + success: true as const, + mergeCommitSha: "merge-sha", + branchDeleted: false, + laneArchived: false, + error: null, + })); + const { deps } = buildDeps({ + runtimeByPrId, + prs: [buildPrSummary({ checksStatus: "passing", reviewStatus: "approved" })], + pipelineSettings: { earlyMergeOnGreen: false, autoMerge: true, atCapPolicy: "ci_retry_once" }, + convergenceStatus: { totalNew: 0 }, + getChecks: async () => [], + getReviewThreads: async () => [], + getReviews: async () => [], + land, + }); + const orchestrator = createPathToMergeOrchestrator(deps); + try { + await orchestrator.startPathToMerge({ prId: "pr-1", modelId: "openai/gpt-5.4" }); + runtimeByPrId.set("pr-1", { + ...runtimeByPrId.get("pr-1")!, + currentRound: 5, + }); + await flushIteration(); + + expect(land).not.toHaveBeenCalled(); + expect(runtimeByPrId.get("pr-1")).toMatchObject({ + status: "failed", + pollerStatus: "stopped", + errorMessage: "At-cap merge ladder blocked: Auto-merge blocked because no CI checks were found on the PR head.", + }); + } finally { + orchestrator.dispose(); + } + }); + + it("still waits for pending review-bot checks before entering the at-cap action", async () => { + const runtimeByPrId = new Map<string, ConvergenceRuntimeState>(); + const pendingReviewCheckStartedAt = new Date().toISOString(); + const land = vi.fn(async () => ({ + prId: "pr-1", + prNumber: 1, + success: true as const, + mergeCommitSha: "merge-sha", + branchDeleted: false, + laneArchived: false, + error: null, + })); + const { deps } = buildDeps({ + runtimeByPrId, + prs: [buildPrSummary({ checksStatus: "passing", reviewStatus: "approved" })], + pipelineSettings: { earlyMergeOnGreen: false, autoMerge: true, atCapPolicy: "ci_retry_once" }, + convergenceStatus: { totalNew: 0 }, + getChecks: async () => [ + makeCheck({ name: "ci / unit", status: "completed", conclusion: "success" }), + makeCheck({ name: "Greptile Review", status: "queued", conclusion: null, startedAt: pendingReviewCheckStartedAt }), + ], + getReviewThreads: async () => [], + getReviews: async () => [], + land, + }); + const orchestrator = createPathToMergeOrchestrator(deps); + try { + await orchestrator.startPathToMerge({ prId: "pr-1", modelId: "openai/gpt-5.4" }); + runtimeByPrId.set("pr-1", { + ...runtimeByPrId.get("pr-1")!, + currentRound: 5, + }); + await flushIteration(); + + expect(launchPrIssueResolutionChatMock).not.toHaveBeenCalled(); + expect(land).not.toHaveBeenCalled(); + expect(runtimeByPrId.get("pr-1")).toMatchObject({ + status: "running", + pollerStatus: "waiting_for_comments", + }); + } finally { + orchestrator.dispose(); + } + }); + it("gives up on a missing active session after two polls and dispatches a fresh agent", async () => { vi.useFakeTimers(); const runtimeByPrId = new Map<string, ConvergenceRuntimeState>(); @@ -1052,6 +1517,74 @@ describe("createPathToMergeOrchestrator.runIteration", () => { orchestrator.dispose(); } }); + + it("does not post secondary review-bot pings when the Copilot ping fails", async () => { + vi.useFakeTimers(); + const runtimeByPrId = new Map<string, ConvergenceRuntimeState>([ + ["pr-1", buildRuntime("pr-1", { + autoConvergeEnabled: true, + status: "running", + pollerStatus: "polling", + activeSessionId: "sess-1", + lastDispatchHeadSha: "sha-before", + })], + ]); + const ptmArgsByPrId = new Map<string, Record<string, unknown> | null>([ + ["pr-1", { modelId: "openai/gpt-5.4", reasoning: null, permissionMode: "default", scope: "both", additionalInstructions: null }], + ]); + const addComment = vi.fn(async (args: { prId: string; body: string }) => { + if (/^@copilot\b/i.test(args.body)) { + throw new Error("copilot unavailable"); + } + return { + id: `comment-${args.body}`, + author: "ade", + authorAvatarUrl: null, + body: args.body, + source: "issue" as const, + url: null, + path: null, + line: null, + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-01T00:00:00.000Z", + }; + }); + const { deps } = buildDeps({ + runtimeByPrId, + ptmArgsByPrId, + prs: [buildPrSummary({ checksStatus: "failing", reviewStatus: "changes_requested" })], + pipelineSettings: { earlyMergeOnGreen: false }, + convergenceStatus: { totalNew: 1 }, + getCommits: async () => [{ + sha: "sha-after", + shortSha: "sha-after", + message: "fix", + author: { login: null, name: "Test", email: null }, + committedDate: "2026-05-01T00:01:00.000Z", + }], + getSessionSummary: async () => ({ status: "idle", awaitingInput: false }) as Awaited<ReturnType<PathToMergeDeps["agentChatService"]["getSessionSummary"]>>, + getFiles: async () => Array.from({ length: 251 }, (_, index) => makePrFile(index)), + addComment: addComment as unknown as PathToMergeDeps["prService"]["addComment"], + }); + const orchestrator = createPathToMergeOrchestrator(deps); + try { + orchestrator.resumeFromPersistedState(); + await vi.advanceTimersByTimeAsync(PHASE_DELAY_SECONDS.warming * 1000); + + expect(addComment).toHaveBeenCalledTimes(1); + expect(addComment).toHaveBeenCalledWith({ prId: "pr-1", body: "@copilot review but do not make fixes" }); + expect(runtimeByPrId.get("pr-1")).toMatchObject({ + activeSessionId: null, + pollerStatus: "waiting_for_comments", + lastDispatchHeadSha: null, + lastBotPingHeadSha: null, + lastBotPingAt: null, + }); + } finally { + orchestrator.dispose(); + vi.useRealTimers(); + } + }); }); describe("decideAtCapAction", () => { diff --git a/apps/desktop/src/main/services/prs/pathToMergeOrchestrator.ts b/apps/desktop/src/main/services/prs/pathToMergeOrchestrator.ts index e40c5b4f3..d997800b1 100644 --- a/apps/desktop/src/main/services/prs/pathToMergeOrchestrator.ts +++ b/apps/desktop/src/main/services/prs/pathToMergeOrchestrator.ts @@ -55,6 +55,7 @@ import type { PathToMergeStopResult, PipelineSettings, PrCheck, + PrComment, PrIssueResolutionScope, PrAgentPermissionMode, PrReview, @@ -187,6 +188,10 @@ export type InProcessState = { sessionMissingSessionId: string | null; /** PR head SHA captured when the last fix agent was dispatched. */ lastDispatchHeadSha: string | null; + /** PR head SHA for which post-push review-bot pings were already sent. */ + lastBotPingHeadSha: string | null; + /** Timestamp for the last post-push review-bot ping. */ + lastBotPingAt: string | null; /** Durable lane/worktree lock token held by this PtM run. */ laneWorktreeLockToken: string | null; }; @@ -200,6 +205,8 @@ export function makeInProcessState(runArgs: StartPathToMergeArgs): InProcessStat sessionMissingPolls: 0, sessionMissingSessionId: null, lastDispatchHeadSha: null, + lastBotPingHeadSha: null, + lastBotPingAt: null, laneWorktreeLockToken: null, }; } @@ -214,6 +221,8 @@ function makeInProcessStateFromRuntime( waitForCiStartedAt: runtime.waitForCiStartedAt, ciRetryAttemptsUsed: runtime.ciRetryAttemptsUsed, lastDispatchHeadSha: runtime.lastDispatchHeadSha, + lastBotPingHeadSha: runtime.lastBotPingHeadSha, + lastBotPingAt: runtime.lastBotPingAt, laneWorktreeLockToken: null, }; } @@ -335,6 +344,68 @@ function resolveMergeMethod(settings: PipelineSettings): MergeMethod { return settings.mergeMethod; } +const REVIEW_BOT_WAIT_TIMEOUT_MS = 5 * 60_000; +const REVIEW_BOT_CHECKS = [ + { name: "Greptile", pattern: /\bgreptile\b/i }, + { name: "CodeRabbit", pattern: /\bcoderabbit\b|code\s*rabbit/i }, +] as const; + +function hasCopilotResponse(comments: PrComment[], reviews: PrReview[]): boolean { + const isCopilotAuthor = (author: string | null | undefined) => { + const normalized = author?.trim().toLowerCase(); + return normalized === "copilot-pull-request-reviewer[bot]" + || normalized === "github-copilot[bot]" + || normalized === "copilot[bot]"; + }; + return comments.some((comment) => isCopilotAuthor(comment.author)) + || reviews.some((review) => isCopilotAuthor(review.reviewer)); +} + +function isWithinReviewBotWaitWindow(timestamp: string | null | undefined, now: () => number): boolean { + if (!timestamp) return false; + const startedAt = Date.parse(timestamp); + return Number.isFinite(startedAt) && now() - startedAt < REVIEW_BOT_WAIT_TIMEOUT_MS; +} + +function getPendingReviewBots( + checks: PrCheck[], + comments: PrComment[], + reviews: PrReview[], + inProc: InProcessState, + now: () => number = Date.now, +): string[] { + const pending = new Set<string>(); + for (const bot of REVIEW_BOT_CHECKS) { + const matchingChecks = checks.filter((check) => bot.pattern.test(check.name)); + if (matchingChecks.some((check) => check.status !== "completed" + && isWithinReviewBotWaitWindow(inProc.lastBotPingAt ?? check.startedAt, now))) { + pending.add(bot.name); + } + } + + if (inProc.lastBotPingAt && !hasCopilotResponse(comments, reviews)) { + const pingedAt = Date.parse(inProc.lastBotPingAt); + if (Number.isFinite(pingedAt) && now() - pingedAt < REVIEW_BOT_WAIT_TIMEOUT_MS) { + pending.add("Copilot"); + } + } + + return [...pending]; +} + +function looksLikeBranchPolicyBlock(error: string): boolean { + return /base branch policy|branch protection|protected branch|required status|required check|required review|review is required|review required|code owner|codeowner/i.test(error); +} + +export function shouldAttemptAdminMergeForRestError( + error: string, + opts: { allowForceMerge?: boolean; ignoreReview?: boolean } = {}, +): boolean { + if (!looksLikeBranchPolicyBlock(error)) return false; + if (opts.allowForceMerge) return true; + return !opts.ignoreReview; +} + // --------------------------------------------------------------------------- // `gh` shell wrapper (used for the `--admin` and `--auto` rungs of the // merge ladder; the first rung uses the existing `prService.land()` REST @@ -717,6 +788,8 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg ciRetryAttemptsUsed: inProc.ciRetryAttemptsUsed, waitForCiStartedAt: inProc.waitForCiStartedAt, lastDispatchHeadSha: inProc.lastDispatchHeadSha, + lastBotPingHeadSha: inProc.lastBotPingHeadSha, + lastBotPingAt: inProc.lastBotPingAt, }); } @@ -734,6 +807,49 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg } } + async function postReviewBotPings(ctx: IterationContext, inProc: InProcessState, headSha: string | null): Promise<void> { + const normalizedHeadSha = headSha?.trim() || null; + if (!normalizedHeadSha) return; + if (inProc.lastBotPingHeadSha === normalizedHeadSha) return; + + const bodies = ["@copilot review but do not make fixes"]; + let changedFileCount = 0; + try { + changedFileCount = (await prService.getFiles(ctx.pr.id)).length; + } catch (err) { + logger.debug("ptm.bot_ping_file_count_failed", { prId: ctx.pr.id, error: getErrorMessage(err) }); + } + if (changedFileCount > 250) { + bodies.push("@greptile review", "@coderabbit review"); + } + + const copilotBody = bodies[0]!; + const secondaryBodies = bodies.slice(1); + let postedCount = 0; + try { + await prService.addComment({ prId: ctx.pr.id, body: copilotBody }); + postedCount += 1; + } catch (err) { + logger.warn("ptm.bot_ping_failed", { prId: ctx.pr.id, body: copilotBody, error: getErrorMessage(err) }); + logger.warn("ptm.bot_pings_not_recorded", { prId: ctx.pr.id, headSha: normalizedHeadSha, postedCount }); + return; + } + + for (const body of secondaryBodies) { + try { + await prService.addComment({ prId: ctx.pr.id, body }); + postedCount += 1; + } catch (err) { + logger.warn("ptm.bot_ping_failed", { prId: ctx.pr.id, body, error: getErrorMessage(err) }); + } + } + + inProc.lastBotPingHeadSha = normalizedHeadSha; + inProc.lastBotPingAt = nowIso(); + persistInProcessState(ctx.pr.id, inProc); + logger.info("ptm.bot_pings_posted", { prId: ctx.pr.id, headSha: normalizedHeadSha, count: postedCount }); + } + function pauseLoop(prId: string, reason: string, errorMessage?: string | null): ConvergenceRuntimeState { clearTimer(prId); releaseLoopLock(prId); @@ -928,26 +1044,24 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg async function getMergeReadinessBlocker( ctx: IterationContext, - opts: { allowForceMerge?: boolean } = {}, + opts: { allowForceMerge?: boolean; ignoreReview?: boolean } = {}, ): Promise<string | null> { if (opts.allowForceMerge) return null; if (ctx.pr.checksStatus !== "passing") { return `Auto-merge blocked because CI is ${ctx.pr.checksStatus}.`; } - if (ctx.pr.reviewStatus === "changes_requested") { - return "Auto-merge blocked because a review requested changes."; - } - if (ctx.pr.reviewStatus === "requested") { - return "Auto-merge blocked because a requested review is still pending."; + if (!opts.ignoreReview) { + if (ctx.pr.reviewStatus === "changes_requested") { + return "Auto-merge blocked because a review requested changes."; + } + if (ctx.pr.reviewStatus === "requested") { + return "Auto-merge blocked because a requested review is still pending."; + } } try { - const [checks, reviewThreads, reviews] = await Promise.all([ - prService.getChecks(ctx.pr.id), - prService.getReviewThreads(ctx.pr.id), - prService.getReviews(ctx.pr.id), - ]); + const checks = await prService.getChecks(ctx.pr.id); if (checks.length === 0) { return "Auto-merge blocked because no CI checks were found on the PR head."; @@ -961,7 +1075,8 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg } const pendingChecks = checks.filter((check) => check.status !== "completed"); if (pendingChecks.length > 0) { - return `Auto-merge blocked because ${pendingChecks.length} CI check${pendingChecks.length === 1 ? "" : "s"} are still running.`; + const verb = pendingChecks.length === 1 ? "is" : "are"; + return `Auto-merge blocked because ${pendingChecks.length} CI check${pendingChecks.length === 1 ? "" : "s"} ${verb} still running.`; } const passingChecks = checks.filter((check) => check.status === "completed" && @@ -971,19 +1086,25 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg return "Auto-merge blocked because no completed successful CI checks were found."; } - const unresolvedThreads = reviewThreads.filter(hasActionableReviewThread); - if (unresolvedThreads.length > 0) { - return `Auto-merge blocked because ${unresolvedThreads.length} review thread${unresolvedThreads.length === 1 ? "" : "s"} are unresolved.`; - } - const latestReviews = latestReviewByReviewer(reviews); - if (latestReviews.some((review) => review.state === "changes_requested")) { - return "Auto-merge blocked because a review requested changes."; - } - const commentedReviews = latestReviews.filter(hasBlockingCommentedReview); - if (commentedReviews.length > 0) { - const reviewers = Array.from(new Set(commentedReviews.map((review) => review.reviewer).filter(Boolean))).slice(0, 3); - const suffix = reviewers.length ? ` (${reviewers.join(", ")})` : ""; - return `Auto-merge blocked because ${commentedReviews.length} commented review${commentedReviews.length === 1 ? "" : "s"} still need operator review${suffix}.`; + if (!opts.ignoreReview) { + const [reviewThreads, reviews] = await Promise.all([ + prService.getReviewThreads(ctx.pr.id), + prService.getReviews(ctx.pr.id), + ]); + const unresolvedThreads = reviewThreads.filter(hasActionableReviewThread); + if (unresolvedThreads.length > 0) { + return `Auto-merge blocked because ${unresolvedThreads.length} review thread${unresolvedThreads.length === 1 ? "" : "s"} are unresolved.`; + } + const latestReviews = latestReviewByReviewer(reviews); + if (latestReviews.some((review) => review.state === "changes_requested")) { + return "Auto-merge blocked because a review requested changes."; + } + const commentedReviews = latestReviews.filter(hasBlockingCommentedReview); + if (commentedReviews.length > 0) { + const reviewers = Array.from(new Set(commentedReviews.map((review) => review.reviewer).filter(Boolean))).slice(0, 3); + const suffix = reviewers.length ? ` (${reviewers.join(", ")})` : ""; + return `Auto-merge blocked because ${commentedReviews.length} commented review${commentedReviews.length === 1 ? "" : "s"} still need operator review${suffix}.`; + } } } catch (err) { return `Auto-merge blocked because merge readiness could not be verified: ${getErrorMessage(err)}`; @@ -1005,7 +1126,7 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg */ async function runMergeLadder( ctx: IterationContext, - opts: { allowForceMerge?: boolean } = {}, + opts: { allowForceMerge?: boolean; ignoreReview?: boolean } = {}, ): Promise<MergeLadderResult> { const { pr, pipelineSettings } = ctx; const method = resolveMergeMethod(pipelineSettings); @@ -1042,26 +1163,38 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg const ghMethodFlag = `--${method}`; const prNumberArg = String(pr.githubPrNumber); - // Rung 2: gh pr merge --admin (overrides branch protection if the - // operator has admin rights). - const adminRes = await runGh( - ["pr", "merge", prNumberArg, ghMethodFlag, "--admin"], - { cwd: laneWorktreePath, timeoutMs: 90_000 }, - ); - if (adminRes.exitCode === 0) { - logger.info("ptm.merge_ladder_admin_succeeded", { prId: pr.id }); - // gh CLI doesn't run our cleanup pipeline — invoke it explicitly so the - // remote branch is deleted, child lanes advance, group memberships are - // cleared, and caches refresh. Any cleanup error is logged inside the - // helper and never masks the successful merge. - try { - await prService.runPostMergeCleanup({ prId: pr.id, mergeCommitSha: null, archiveLane: false }); - } catch (err) { - logger.warn("ptm.merge_ladder_admin_cleanup_failed", { prId: pr.id, error: getErrorMessage(err) }); + // Rung 2: gh pr merge --admin. Per shipLane, only try this for branch + // policy/protection blocks; using admin for arbitrary API failures can + // accidentally bypass real red signals. + let adminError = "admin merge skipped because REST failure was not a branch-policy block"; + if (shouldAttemptAdminMergeForRestError(restErr, opts)) { + const adminRes = await runGh( + ["pr", "merge", prNumberArg, ghMethodFlag, "--admin"], + { cwd: laneWorktreePath, timeoutMs: 90_000 }, + ); + if (adminRes.exitCode === 0) { + logger.info("ptm.merge_ladder_admin_succeeded", { prId: pr.id }); + // gh CLI doesn't run our cleanup pipeline — invoke it explicitly so the + // remote branch is deleted, child lanes advance, group memberships are + // cleared, and caches refresh. Any cleanup error is logged inside the + // helper and never masks the successful merge. + try { + await prService.runPostMergeCleanup({ prId: pr.id, mergeCommitSha: null, archiveLane: false }); + } catch (err) { + logger.warn("ptm.merge_ladder_admin_cleanup_failed", { prId: pr.id, error: getErrorMessage(err) }); + } + return { kind: "merged", via: "admin" }; } - return { kind: "merged", via: "admin" }; + adminError = adminRes.stderr.trim() || adminRes.stdout.trim() || `exit ${adminRes.exitCode}`; + logger.warn("ptm.merge_ladder_admin_failed", { prId: pr.id, stderr: adminRes.stderr.trim() }); + } else if (looksLikeBranchPolicyBlock(restErr)) { + adminError = opts.ignoreReview && !opts.allowForceMerge + ? "admin merge skipped because review policy was intentionally left for GitHub to enforce" + : "admin merge skipped because force merge is not allowed"; + logger.info("ptm.merge_ladder_admin_skipped", { prId: pr.id, restErr, reason: adminError }); + } else { + logger.info("ptm.merge_ladder_admin_skipped", { prId: pr.id, restErr }); } - logger.warn("ptm.merge_ladder_admin_failed", { prId: pr.id, stderr: adminRes.stderr.trim() }); // Rung 3: gh pr merge --auto (queue the merge for when checks/policy // gates clear). This is a "park & wait" outcome, not an immediate land. @@ -1069,7 +1202,7 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg if (!pipelineSettings.autoMerge) { return { kind: "blocked", - error: `Merge ladder exhausted (REST: ${restErr}; admin: ${adminRes.stderr.trim() || adminRes.stdout.trim() || "exit " + adminRes.exitCode}). auto-merge skipped because pipelineSettings.autoMerge is false.`, + error: `Merge ladder exhausted (REST: ${restErr}; admin: ${adminError}). auto-merge skipped because pipelineSettings.autoMerge is false.`, }; } const autoRes = await runGh( @@ -1087,7 +1220,7 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg return { kind: "blocked", - error: `Merge ladder exhausted (REST: ${restErr}; admin: ${adminRes.stderr.trim() || adminRes.stdout.trim() || "exit " + adminRes.exitCode}; auto: ${autoRes.stderr.trim() || autoRes.stdout.trim() || "exit " + autoRes.exitCode})`, + error: `Merge ladder exhausted (REST: ${restErr}; admin: ${adminError}; auto: ${autoRes.stderr.trim() || autoRes.stdout.trim() || "exit " + autoRes.exitCode})`, }; } @@ -1181,6 +1314,32 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg } const fresh = refreshed; + // Keep issue inventory fresh even when Path to Merge is started from the + // CLI or a queue flow instead of an already-open PR detail tab. Without + // this, a red PR with stale/empty inventory can look "clean" and park + // before the fix agent ever sees the failing check. + let latestChecks: PrCheck[] = []; + let latestComments: PrComment[] = []; + let latestReviews: PrReview[] = []; + try { + const [checks, reviewThreads, comments, reviews] = await Promise.all([ + prService.getChecks(prId), + prService.getReviewThreads(prId), + (prService.getComments?.(prId) ?? Promise.resolve([] as PrComment[])).catch(() => [] as PrComment[]), + prService.getReviews(prId).catch(() => [] as PrReview[]), + ]); + latestChecks = checks; + latestComments = comments; + latestReviews = reviews; + issueInventoryService.syncFromPrData(prId, checks, reviewThreads, comments); + } catch (err) { + logger.warn("ptm.inventory_sync_failed", { prId, error: getErrorMessage(err) }); + } + if (!isAutoConvergeStillEnabled(prId)) { + clearTimer(prId); + return; + } + // Re-check merged state in case a previously armed `gh pr merge --auto` // landed between iterations. If so, complete the cleanup now and exit. if (fresh.pr.state === "merged") { @@ -1234,6 +1393,10 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg pauseLoop(prId, "Base sync failed.", baseSync.error); return; } + const headSha = await readCurrentHeadSha(prId); + await postReviewBotPings(fresh, inProc, headSha); + schedule(prId, "justPushed"); + return; } // Helper: persist a "converged but not auto-merging" parked state. Used @@ -1256,6 +1419,7 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg issueInventoryService.saveConvergenceRuntime(prId, { status: "converged", pollerStatus: "waiting_for_checks", + mergeWaitKind: "github_auto_merge_armed", pauseReason: "auto-merge armed via gh CLI; waiting for GitHub to land.", errorMessage: null, }); @@ -1307,6 +1471,7 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg pauseLoop(prId, "Merge-time conflict resolution failed.", conflictRes.error); return; } + await postReviewBotPings(fresh, inProc, await readCurrentHeadSha(prId)); schedule(prId, "justPushed"); return; } @@ -1321,26 +1486,44 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg } } + const maxRounds = fresh.pipelineSettings.maxRounds; + const completedRounds = fresh.runtime.currentRound; + const atCap = completedRounds >= maxRounds; + const afterForceFinalizePush = atCap && (inProc.ciRetryAttemptsUsed > 0 || inProc.forceFinalizeUsed); + // ---- Step 3: terminal-gate check before dispatching fixes ---- - const gate = isTerminalForFixPush(fresh.pr); + const gate = atCap + ? { terminal: CHECKS_TERMINAL_STATUSES.has(fresh.pr.checksStatus), pendingSignal: "checks" as const } + : isTerminalForFixPush(fresh.pr); if (!gate.terminal) { logger.info("ptm.terminal_gate_pending", { prId, pendingSignal: gate.pendingSignal }); issueInventoryService.saveConvergenceRuntime(prId, { - pollerStatus: "waiting_for_checks", + pollerStatus: gate.pendingSignal === "review" ? "waiting_for_comments" : "waiting_for_checks", currentRound: fresh.runtime.currentRound, }); schedule(prId, "warming"); return; } + if (!afterForceFinalizePush) { + const pendingReviewBots = getPendingReviewBots(latestChecks, latestComments, latestReviews, inProc); + if (pendingReviewBots.length > 0) { + logger.info("ptm.review_bots_pending", { prId, pendingReviewBots }); + issueInventoryService.saveConvergenceRuntime(prId, { + pollerStatus: "waiting_for_comments", + currentRound: fresh.runtime.currentRound, + }); + schedule(prId, "warming"); + return; + } + } + // ---- Step 4: hard cap + at-cap policy logic ---- - const maxRounds = fresh.pipelineSettings.maxRounds; - const completedRounds = fresh.runtime.currentRound; inProcessState.set(prId, inProc); let isAtCapDispatchCi = false; let isAtCapMergeNow = false; - if (completedRounds >= maxRounds) { + if (atCap) { if (inProc.forceFinalizeUsed) { // Bonus merge ladder already attempted; nothing left to try. pauseLoop(prId, "Hard cap reached (at-cap action already attempted)."); @@ -1409,7 +1592,8 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg } else if (summary.status === "active") { priorActive = true; } else if (summary.status === "idle" && summary.awaitingInput === true) { - priorActive = true; + pauseLoop(prId, "Fix agent is awaiting user input; Path to Merge cannot continue unattended."); + return; } else { priorSessionFinished = true; } @@ -1467,7 +1651,16 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg } if (currentHeadSha && currentHeadSha !== inProc.lastDispatchHeadSha) { inProc.lastDispatchHeadSha = null; + issueInventoryService.saveConvergenceRuntime(prId, { + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pollerStatus: "waiting_for_comments", + }); persistInProcessState(prId, inProc); + await postReviewBotPings(fresh, inProc, currentHeadSha); + schedule(prId, "justPushed"); + return; } } } @@ -1513,6 +1706,7 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg pauseLoop(prId, "Clean-inventory merge-time conflict resolution failed.", conflictRes.error); return; } + await postReviewBotPings(fresh, inProc, await readCurrentHeadSha(prId)); schedule(prId, "justPushed"); return; } @@ -1666,6 +1860,7 @@ export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMerg pauseLoop(prId, "At-cap merge-time conflict resolution failed.", conflictRes.error); return; } + await postReviewBotPings(fresh, inProc, await readCurrentHeadSha(prId)); schedule(prId, "justPushed"); return; } diff --git a/apps/desktop/src/main/services/prs/prIssueResolution.test.ts b/apps/desktop/src/main/services/prs/prIssueResolution.test.ts index acd4eccc9..f2c465ad2 100644 --- a/apps/desktop/src/main/services/prs/prIssueResolution.test.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolution.test.ts @@ -134,6 +134,7 @@ function makeRuntimeRow(overrides: Record<string, unknown> = {}) { auto_converge_enabled: 1, status: "running", poller_status: "waiting_for_comments", + merge_wait_kind: null, current_round: 2, active_session_id: "session-1", active_lane_id: "lane-1", @@ -144,6 +145,8 @@ function makeRuntimeRow(overrides: Record<string, unknown> = {}) { ci_retry_attempts_used: 0, wait_for_ci_started_at: null, last_dispatch_head_sha: null, + last_bot_ping_head_sha: null, + last_bot_ping_at: null, pause_repeat_count: 0, last_pause_reason_hash: null, last_started_at: "2026-03-23T12:00:00.000Z", @@ -687,6 +690,51 @@ describe("issueInventoryService", () => { expect(args[2]).toBe("greptile"); // source — known alias }); + it("ignores CodeRabbit skipped-bot metadata when selecting the review thread issue", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + comments: [ + { + id: "greptile-1", + author: "greptile-apps", + authorAvatarUrl: null, + body: "**Greptile/CodeRabbit pings can duplicate when Copilot comment fails**\n\nFix this issue.", + url: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + }, + { + id: "coderabbit-skip-1", + author: "coderabbitai", + authorAvatarUrl: null, + body: "> Skipped: comment is from another GitHub bot.\n\n<!-- This is an auto-generated reply by CodeRabbit -->", + url: null, + createdAt: "2026-03-23T12:05:00.000Z", + updatedAt: "2026-03-23T12:05:00.000Z", + }, + ], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + const args = insertCalls[0][1] as unknown[]; + expect(args[2]).toBe("greptile"); + expect(args[10]).toBe("Greptile/CodeRabbit pings can duplicate when Copilot comment fails"); + expect(args[12]).toBe("greptile-apps"); + expect(args[16]).toBe(1); + expect(args[17]).toBe("greptile-1"); + }); + it("extracts severity from bold keywords (Critical/Major/Minor)", () => { const db = makeMockDb(); db.get.mockReturnValue(null); @@ -949,6 +997,60 @@ describe("issueInventoryService", () => { expect(insertCalls.length).toBe(0); }); + it("filters Capy auto-review spend-limit notices", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [], + [makeComment({ + id: "ic-capy", + author: "capy-ai[bot]", + body: "<!-- capy:auto-review-spend-limit -->\nCapy auto-review is paused for this organization because the monthly auto-review limit has been reached.", + source: "issue", + })], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + expect(insertCalls.length).toBe(0); + }); + + it("marks previously inventoried noisy issue comments as fixed", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([makeFakeRow({ + id: "capy-item", + external_id: "comment:ic-capy", + type: "issue_comment", + state: "new", + })]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [], + [makeComment({ + id: "ic-capy", + author: "capy-ai[bot]", + body: "<!-- capy:auto-review-spend-limit -->\nCapy auto-review is paused for this organization because the monthly auto-review limit has been reached.", + source: "issue", + })], + ); + + const fixedCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("set state = 'fixed'"), + ); + expect(fixedCalls).toHaveLength(1); + expect((fixedCalls[0][1] as unknown[])[1]).toBe("capy-item"); + }); + it("skips comments with source !== issue", () => { const db = makeMockDb(); db.get.mockReturnValue(null); @@ -1365,6 +1467,8 @@ describe("issueInventoryService", () => { ci_retry_attempts_used: 2, wait_for_ci_started_at: "2026-03-23T12:02:00.000Z", last_dispatch_head_sha: "abc123", + last_bot_ping_head_sha: "def456", + last_bot_ping_at: "2026-03-23T12:03:00.000Z", pause_repeat_count: 3, last_pause_reason_hash: "hash-1", }); @@ -1384,6 +1488,8 @@ describe("issueInventoryService", () => { ciRetryAttemptsUsed: 2, waitForCiStartedAt: "2026-03-23T12:02:00.000Z", lastDispatchHeadSha: "abc123", + lastBotPingHeadSha: "def456", + lastBotPingAt: "2026-03-23T12:03:00.000Z", pauseRepeatCount: 3, lastPauseReasonHash: "hash-1", })); @@ -1452,6 +1558,8 @@ describe("issueInventoryService", () => { ciRetryAttemptsUsed: 2, waitForCiStartedAt: "2026-03-23T12:02:00.000Z", lastDispatchHeadSha: "abc123", + lastBotPingHeadSha: "def456", + lastBotPingAt: "2026-03-23T12:03:00.000Z", pauseRepeatCount: 3, lastPauseReasonHash: "hash-1", }); @@ -1467,14 +1575,17 @@ describe("issueInventoryService", () => { expect(params[1]).toBe(1); expect(params[2]).toBe("running"); expect(params[3]).toBe("scheduled"); - expect(params[4]).toBe(3); - expect(params[5]).toBe("session-9"); - expect(params[10]).toBe(1); - expect(params[11]).toBe(2); - expect(params[12]).toBe("2026-03-23T12:02:00.000Z"); - expect(params[13]).toBe("abc123"); - expect(params[14]).toBe(3); - expect(params[15]).toBe("hash-1"); + expect(params[4]).toBeNull(); + expect(params[5]).toBe(3); + expect(params[6]).toBe("session-9"); + expect(params[11]).toBe(1); + expect(params[12]).toBe(2); + expect(params[13]).toBe("2026-03-23T12:02:00.000Z"); + expect(params[14]).toBe("abc123"); + expect(params[15]).toBe("def456"); + expect(params[16]).toBe("2026-03-23T12:03:00.000Z"); + expect(params[17]).toBe(3); + expect(params[18]).toBe("hash-1"); }); it("reconciles active convergence sessions when a tracked chat exits", () => { @@ -1536,7 +1647,7 @@ describe("issueInventoryService", () => { const settings = service.getPipelineSettings(PR_ID); expect(settings).toEqual({ - autoMerge: false, + autoMerge: true, mergeMethod: "repo_default", maxRounds: 5, onRebaseNeeded: "pause", @@ -1548,9 +1659,9 @@ describe("issueInventoryService", () => { permissionMode: null, confidenceThreshold: null, }, - forceFinalizeMode: "off", + forceFinalizeMode: "conditional", forceFinalizeRequireNoCiFailures: true, - atCapPolicy: "stop", + atCapPolicy: "ci_retry_once", atCapWaitMinutes: 30, atCapCiRetryMax: 3, forceMergeRequiresConfirmation: true, @@ -2113,6 +2224,16 @@ describe("issueInventoryService", () => { ).toThrow(/Invalid convergence poller status/); }); + it("rejects an unknown mergeWaitKind value", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + + const service = createIssueInventoryService({ db }); + expect(() => + service.saveConvergenceRuntime(PR_ID, { mergeWaitKind: "made_up" as any }), + ).toThrow(/Invalid convergence merge wait kind/); + }); + it("rejects a negative currentRound", () => { const db = makeMockDb(); db.get.mockReturnValue(null); @@ -2168,11 +2289,14 @@ describe("issueInventoryService", () => { service.saveConvergenceRuntime(PR_ID, { status: "running", pollerStatus: "polling", + mergeWaitKind: null, currentRound: 3, forceFinalizeUsed: true, ciRetryAttemptsUsed: 1, waitForCiStartedAt: "2026-03-23T12:02:00.000Z", lastDispatchHeadSha: "abc123", + lastBotPingHeadSha: "def456", + lastBotPingAt: "2026-03-23T12:03:00.000Z", pauseRepeatCount: 2, lastPauseReasonHash: "hash-1", }), @@ -2351,6 +2475,7 @@ describe("detectSource", () => { expect(detectSource("ade-review[bot]")).toBe("ade"); expect(detectSource("greptile[bot]")).toBe("greptile"); expect(detectSource("greptile-review[bot]")).toBe("greptile"); + expect(detectSource("greptile-apps")).toBe("greptile"); expect(detectSource("seer[bot]")).toBe("seer"); expect(detectSource("seer-code-review[bot]")).toBe("seer"); }); diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index e112dfa4e..942884656 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -793,6 +793,59 @@ describe("prService.createFromLane", () => { ); }); + it("adds a non-closing Linear reference when creating a PR from a linked lane", async () => { + const ghService = makeGithubService({ + apiRequest: vi.fn().mockRejectedValue(new Error("stop after payload capture")), + }); + const laneService = makeLaneService([ + makeFakeLane({ + linearIssue: { + id: "issue-1", + identifier: "ADE-123", + title: "Connect Linear PR linking", + description: null, + url: "https://linear.app/ade/issue/ADE-123/connect-linear-pr-linking", + projectId: "project-1", + projectSlug: "ade", + teamId: "team-1", + teamKey: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 0, + priorityLabel: "none", + labels: [], + assigneeId: null, + assigneeName: null, + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + }, + }), + ]); + + const { service } = buildService({ githubService: ghService, laneService }); + + await expect( + service.createFromLane({ + laneId: LANE_ID, + title: "My PR", + body: "description", + draft: false, + allowDirtyWorktree: true, + }), + ).rejects.toThrow('Failed to create pull request for "my-feature" → "main": stop after payload capture'); + + expect(ghService.apiRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + body: expect.objectContaining({ + title: "My PR", + body: "Refs ADE-123\n\ndescription", + }), + }), + ); + }); + it("blocks PR creation when the remote branch has newer commits", async () => { const ghService = makeGithubService({ apiRequest: vi.fn().mockRejectedValue(new Error("should not create")), diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index f0e9d9072..c304bea63 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -137,6 +137,10 @@ import { fetchRemoteTrackingBranch } from "../shared/queueRebase"; import { asNumber, asString, getErrorMessage, normalizeBranchName, nowIso, resolvePathWithinRoot } from "../shared/utils"; import { branchNameFromLaneRef, resolveStableLaneBaseBranch } from "../../../shared/laneBaseResolution"; import { normalizePrCreationStrategy, resolvePrRebaseMode } from "../../../shared/prStrategy"; +import { + buildLinearPrTitle, + ensureLinearPrReference, +} from "../../../shared/linearMagicWords"; type CreatePrFromLaneInternalArgs = CreatePrFromLaneArgs & { skipBranchPush?: boolean; @@ -2790,19 +2794,32 @@ export function createPrService({ branchRef: lane.branchRef, baseRef: baseRefForDiff, parentLaneId: lane.parentLaneId, + linearIssue: lane.linearIssue, commits, packBody, prTemplate: template }; const providerMode = projectConfigService.get().effective.providerMode ?? "guest"; - const defaultTitle = lane.name.replace(/[-_/]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim() || lane.name; + const defaultTitle = lane.linearIssue + ? buildLinearPrTitle(lane.linearIssue) + : lane.name.replace(/[-_/]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim() || lane.name; + const finalizeDraft = (draft: { title: string; body: string }): { title: string; body: string } => { + if (!lane.linearIssue) return draft; + const escapedIdentifier = lane.linearIssue.identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const hasExactIdentifier = new RegExp(`(^|[^A-Za-z0-9])${escapedIdentifier}([^A-Za-z0-9]|$)`, "i").test(draft.title); + return { + title: hasExactIdentifier ? draft.title : defaultTitle, + body: ensureLinearPrReference(draft.body, lane.linearIssue, args.closeLinearIssueOnMerge === true), + }; + }; if (providerMode !== "guest" && aiIntegrationService) { const prompt = [ "You are ADE's PR drafting assistant. Keep content factual and concise.", "Return JSON only with shape: {\"title\": string, \"body\": string}.", "The body must be GitHub-flavored markdown with sections: Summary, What Changed, Validation, Risks.", + "If Linear issue context is present, include the exact Linear issue identifier in the title and include a non-closing Linear reference in the body.", "", "PR Context JSON:", JSON.stringify(context, null, 2) @@ -2817,13 +2834,13 @@ export function createPrService({ ...(reasoningEffort ? { reasoningEffort } : {}) }); const parsed = parsePrDraftJson(draft.text); - if (parsed) return parsed; + if (parsed) return finalizeDraft(parsed); if (draft.text.trim().length) { - return { + return finalizeDraft({ title: defaultTitle, body: `${draft.text.trim()}\n` - }; + }); } } catch (error) { logger.warn("prs.draft.ai_failed", { @@ -2856,10 +2873,10 @@ export function createPrService({ lines.push(""); lines.push(template); } - return { + return finalizeDraft({ title: defaultTitle || lane.name, body: `${lines.join("\n")}\n` - }; + }); }; const createFromLane = async (args: CreatePrFromLaneInternalArgs): Promise<PrSummary> => { @@ -2890,6 +2907,9 @@ export function createPrService({ if (!baseBranch) { throw new Error("Choose a target branch before creating the PR."); } + const prBody = lane.linearIssue + ? ensureLinearPrReference(args.body, lane.linearIssue, args.closeLinearIssueOnMerge === true, { preserveExisting: false }) + : args.body; if (!args.skipBranchPush) { await pushLaneBranchForPr(lane, headBranch); @@ -2906,7 +2926,7 @@ export function createPrService({ title: args.title, head: headBranch, base: baseBranch, - body: args.body, + body: prBody, draft: Boolean(args.draft) } }); @@ -2929,6 +2949,37 @@ export function createPrService({ : null; if (existingPr) { logger.info("prs.create_existing_mapped", { headBranch, baseBranch, prNumber: Number(existingPr?.number) || null }); + // When we adopt an already-existing PR, ensure its body carries the + // Linear `Refs`/`Fixes` reference so close-on-merge / linkage works + // even though we couldn't inject it via the initial POST /pulls call. + if (lane.linearIssue) { + const existingPrNumber = Number(existingPr?.number); + if (Number.isFinite(existingPrNumber) && existingPrNumber > 0) { + const existingBody = typeof existingPr?.body === "string" ? existingPr.body : ""; + const closeOnMerge = args.closeLinearIssueOnMerge === true; + const patchedBody = ensureLinearPrReference( + existingBody, + lane.linearIssue, + closeOnMerge, + closeOnMerge ? { preserveExisting: false } : undefined, + ); + if (patchedBody !== existingBody) { + try { + await githubService.apiRequest({ + method: "PATCH", + path: `/repos/${repo.owner}/${repo.name}/pulls/${existingPrNumber}`, + body: { body: patchedBody }, + }); + existingPr.body = patchedBody; + } catch (patchError) { + logger.warn("prs.adopt_linear_body_patch_failed", { + prNumber: existingPrNumber, + error: patchError instanceof Error ? patchError.message : String(patchError), + }); + } + } + } + } created = { data: existingPr, response: null }; } else { throw new Error( diff --git a/apps/desktop/src/main/services/prs/resolverUtils.ts b/apps/desktop/src/main/services/prs/resolverUtils.ts index 3f771c7e9..842b57b6a 100644 --- a/apps/desktop/src/main/services/prs/resolverUtils.ts +++ b/apps/desktop/src/main/services/prs/resolverUtils.ts @@ -11,6 +11,8 @@ const NOISY_BODY_PATTERNS = [ /\[vc\]:/i, /mintlify-preview/i, /this is an auto-generated comment/i, + /<!--\s*capy:auto-review-spend-limit\s*-->/i, + /\bcapy auto-review is paused\b/i, /pre-merge checks/i, /thanks for using \[coderabbit\]/i, /<!-- internal state/i, diff --git a/apps/desktop/src/main/services/state/kvDb.pipelineSettingsMigration.test.ts b/apps/desktop/src/main/services/state/kvDb.pipelineSettingsMigration.test.ts new file mode 100644 index 000000000..b2f6eaf10 --- /dev/null +++ b/apps/desktop/src/main/services/state/kvDb.pipelineSettingsMigration.test.ts @@ -0,0 +1,128 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; +import { openKvDb } from "./kvDb"; + +const require = createRequire(import.meta.url); + +type RawDb = { + exec: (sql: string) => void; + prepare: (sql: string) => { run: (...params: unknown[]) => void }; + close: () => void; +}; + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as const; +} + +function makeDbPath(prefix: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + return path.join(root, ".ade", "kv.sqlite"); +} + +describe("kvDb pipeline settings migration", () => { + it("backfills legacy default-shaped PtM settings without touching customized rows", async () => { + const dbPath = makeDbPath("ade-kvdb-pipeline-settings-legacy-"); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + + const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: new (path: string) => RawDb }; + const rawDb = new DatabaseSync(dbPath); + rawDb.exec(` + create table pr_pipeline_settings ( + pr_id text primary key, + auto_merge integer not null default 0, + merge_method text not null default 'repo_default', + max_rounds integer not null default 5, + on_rebase_needed text not null default 'pause', + conflict_strategy text not null default 'pause', + force_finalize_mode text not null default 'off', + force_finalize_require_no_ci_failures integer not null default 1, + early_merge_on_green integer not null default 1, + auto_agent_provider text, + auto_agent_model text, + auto_agent_reasoning_effort text, + auto_agent_permission_mode text, + auto_agent_confidence_threshold real, + at_cap_policy text, + at_cap_wait_minutes integer, + at_cap_ci_retry_max integer, + force_merge_requires_confirmation integer, + updated_at text not null + ); + `); + const insert = rawDb.prepare(` + insert into pr_pipeline_settings ( + pr_id, auto_merge, merge_method, max_rounds, on_rebase_needed, + conflict_strategy, force_finalize_mode, force_finalize_require_no_ci_failures, + early_merge_on_green, at_cap_policy, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + insert.run("pr-legacy", 0, "repo_default", 5, "pause", "pause", "off", 1, 1, null, "2026-05-01T00:00:00.000Z"); + insert.run("pr-custom", 0, "squash", 5, "pause", "pause", "off", 1, 1, "stop", "2026-05-01T00:00:00.000Z"); + rawDb.close(); + + const db = await openKvDb(dbPath, createLogger() as any); + try { + const legacy = db.get<{ + auto_merge: number; + force_finalize_mode: string; + at_cap_policy: string | null; + }>( + "select auto_merge, force_finalize_mode, at_cap_policy from pr_pipeline_settings where pr_id = ?", + ["pr-legacy"], + ); + expect(legacy).toEqual({ + auto_merge: 1, + force_finalize_mode: "conditional", + at_cap_policy: "ci_retry_once", + }); + + const custom = db.get<{ + auto_merge: number; + force_finalize_mode: string; + at_cap_policy: string | null; + }>( + "select auto_merge, force_finalize_mode, at_cap_policy from pr_pipeline_settings where pr_id = ?", + ["pr-custom"], + ); + expect(custom).toEqual({ + auto_merge: 0, + force_finalize_mode: "off", + at_cap_policy: "stop", + }); + + db.run( + "update pr_pipeline_settings set auto_merge = 0, force_finalize_mode = 'off', at_cap_policy = 'stop' where pr_id = ?", + ["pr-legacy"], + ); + } finally { + db.close(); + } + + const reopened = await openKvDb(dbPath, createLogger() as any); + try { + const legacyAfterUserOverride = reopened.get<{ + auto_merge: number; + force_finalize_mode: string; + at_cap_policy: string | null; + }>( + "select auto_merge, force_finalize_mode, at_cap_policy from pr_pipeline_settings where pr_id = ?", + ["pr-legacy"], + ); + expect(legacyAfterUserOverride).toEqual({ + auto_merge: 0, + force_finalize_mode: "off", + at_cap_policy: "stop", + }); + } finally { + reopened.close(); + } + }); +}); diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index e6fcd1c74..b4e02dc72 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -781,6 +781,60 @@ function migrate(db: MigrationDb) { db.run("create index if not exists idx_lanes_project_mission on lanes(project_id, mission_id)"); db.run("create index if not exists idx_lanes_project_role on lanes(project_id, lane_role)"); + db.run(` + create table if not exists lane_linear_issues ( + id text primary key, + project_id text not null, + lane_id text not null, + issue_id text not null, + issue_json text not null, + created_at text not null, + updated_at text not null, + foreign key(project_id) references projects(id) on delete cascade, + foreign key(lane_id) references lanes(id) on delete cascade + ) + `); + db.run("create index if not exists idx_lane_linear_issues_lane on lane_linear_issues(project_id, lane_id)"); + db.run("create index if not exists idx_lane_linear_issues_issue on lane_linear_issues(project_id, issue_id)"); + // Drop a previously-created UNIQUE index on (project_id, lane_id) — it + // existed briefly in development builds but conflicts with cr-sqlite's + // `crsql_as_crr` requirement that CRR tables carry no unique indices + // besides the primary key. + try { + db.run("drop index if exists uniq_lane_linear_issues_lane"); + } catch { + // best-effort cleanup + } + // Each lane is linked to at most one Linear issue. CRR-converted tables + // cannot carry UNIQUE indices besides the primary key (`crsql_as_crr` + // rejects them with "Table … has unique indices besides the primary key. + // This is not allowed for CRRs"), so uniqueness on (project_id, lane_id) + // is enforced at the application layer inside `attachLinearIssue` + // (delete-then-insert in a transaction). Coalesce duplicates from older + // dev builds — keep the most recently updated row per (project, lane) + // and delete the rest. This runs on every bootstrap so the app-layer + // guarantee has a clean slate even after a multi-writer race produced + // extras. + try { + db.run(` + delete from lane_linear_issues + where rowid not in ( + select rowid from lane_linear_issues as keep + where keep.id = ( + select id from lane_linear_issues inner_p + where inner_p.project_id = keep.project_id + and inner_p.lane_id = keep.lane_id + order by inner_p.updated_at desc, + inner_p.id asc + limit 1 + ) + ) + `); + } catch { + // best-effort migration; duplicates will be coalesced on the next + // upsert via the existing delete-then-insert path. + } + db.run(` create table if not exists lane_branch_profiles ( id text primary key, @@ -3387,7 +3441,7 @@ function migrate(db: MigrationDb) { db.run(` create table if not exists pr_pipeline_settings ( pr_id text primary key, - auto_merge integer not null default 0, + auto_merge integer not null default 1, merge_method text not null default 'repo_default', max_rounds integer not null default 5, on_rebase_needed text not null default 'pause', @@ -3396,7 +3450,7 @@ function migrate(db: MigrationDb) { ) `); try { db.run("alter table pr_pipeline_settings add column conflict_strategy text not null default 'pause'"); } catch {} - try { db.run("alter table pr_pipeline_settings add column force_finalize_mode text not null default 'off'"); } catch {} + try { db.run("alter table pr_pipeline_settings add column force_finalize_mode text not null default 'conditional'"); } catch {} try { db.run("alter table pr_pipeline_settings add column force_finalize_require_no_ci_failures integer not null default 1"); } catch {} try { db.run("alter table pr_pipeline_settings add column early_merge_on_green integer not null default 1"); } catch {} try { db.run("alter table pr_pipeline_settings add column auto_agent_provider text"); } catch {} @@ -3404,10 +3458,42 @@ function migrate(db: MigrationDb) { try { db.run("alter table pr_pipeline_settings add column auto_agent_reasoning_effort text"); } catch {} try { db.run("alter table pr_pipeline_settings add column auto_agent_permission_mode text"); } catch {} try { db.run("alter table pr_pipeline_settings add column auto_agent_confidence_threshold real"); } catch {} - try { db.run("alter table pr_pipeline_settings add column at_cap_policy text"); } catch {} + try { db.run("alter table pr_pipeline_settings add column at_cap_policy text default 'ci_retry_once'"); } catch {} try { db.run("alter table pr_pipeline_settings add column at_cap_wait_minutes integer"); } catch {} try { db.run("alter table pr_pipeline_settings add column at_cap_ci_retry_max integer"); } catch {} try { db.run("alter table pr_pipeline_settings add column force_merge_requires_confirmation integer"); } catch {} + try { db.run("alter table pr_pipeline_settings add column ptm_defaults_backfilled_version text"); } catch {} + try { + db.run(` + update pr_pipeline_settings + set auto_merge = 1, + force_finalize_mode = 'conditional', + at_cap_policy = 'ci_retry_once', + ptm_defaults_backfilled_version = 'ptm-defaults-v1' + where auto_merge = 0 + and merge_method = 'repo_default' + and max_rounds = 5 + and on_rebase_needed = 'pause' + and coalesce(conflict_strategy, 'pause') = 'pause' + and coalesce(force_finalize_mode, 'off') = 'off' + and coalesce(force_finalize_require_no_ci_failures, 1) = 1 + and coalesce(early_merge_on_green, 1) = 1 + and (at_cap_policy is null or at_cap_policy = 'stop') + and (at_cap_wait_minutes is null or at_cap_wait_minutes = 30) + and (at_cap_ci_retry_max is null or at_cap_ci_retry_max = 3) + and coalesce(force_merge_requires_confirmation, 1) = 1 + and auto_agent_provider is null + and auto_agent_model is null + and auto_agent_reasoning_effort is null + and auto_agent_permission_mode is null + and auto_agent_confidence_threshold is null + and (ptm_defaults_backfilled_version is null or ptm_defaults_backfilled_version <> 'ptm-defaults-v1') + `); + } 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 ( @@ -3438,6 +3524,9 @@ function migrate(db: MigrationDb) { try { db.run("alter table pr_convergence_state add column ci_retry_attempts_used integer not null default 0"); } catch {} try { db.run("alter table pr_convergence_state add column wait_for_ci_started_at text"); } catch {} try { db.run("alter table pr_convergence_state add column last_dispatch_head_sha text"); } catch {} + try { db.run("alter table pr_convergence_state add column last_bot_ping_head_sha text"); } catch {} + try { db.run("alter table pr_convergence_state add column last_bot_ping_at text"); } catch {} + try { db.run("alter table pr_convergence_state add column merge_wait_kind text"); } catch {} try { db.run("alter table pr_convergence_state add column pause_repeat_count integer not null default 0"); } catch {} try { db.run("alter table pr_convergence_state add column last_pause_reason_hash text"); } catch {} diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 43424bca8..088a1a387 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -812,12 +812,14 @@ describe("createSyncRemoteCommandService", () => { title: "My PR", body: "Description", draft: true, + closeLinearIssueOnMerge: true, })); expect(prService.createFromLane).toHaveBeenCalledWith({ laneId: "lane-1", title: "My PR", body: "Description", draft: true, + closeLinearIssueOnMerge: true, }); }); diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index 004cc3854..e4b4c4452 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -964,6 +964,7 @@ function parseCreatePrArgs(value: Record<string, unknown>): CreatePrFromLaneArgs ...(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 } : {}), }; } @@ -983,6 +984,7 @@ function parseDraftPrDescriptionArgs(value: Record<string, unknown>): DraftPrDes ? { reasoningEffort: value.reasoningEffort == null ? null : asTrimmedString(value.reasoningEffort) ?? null } : {}), ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), + ...(typeof value.closeLinearIssueOnMerge === "boolean" ? { closeLinearIssueOnMerge: value.closeLinearIssueOnMerge } : {}), }; } @@ -2122,6 +2124,10 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg 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, diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 02682b9a2..ad235d767 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -41,6 +41,7 @@ import type { FileChangeEvent, FileContent, FileDiff, + FilePatch, FileTreeNode, FilesCreateDirectoryArgs, FilesCreateFileArgs, @@ -59,6 +60,7 @@ import type { GetLaneConflictStatusArgs, GetDiffChangesArgs, GetFileDiffArgs, + GetFilePatchArgs, GetProcessLogTailArgs, GetTestLogTailArgs, ExportHistoryArgs, @@ -207,6 +209,10 @@ import type { CtoOnboardingState, CtoSystemPromptPreview, CtoLinearProject, + CtoLinearQuickView, + CtoGetLinearIssuePickerDataResult, + CtoSearchLinearIssuesArgs, + CtoSearchLinearIssuesResult, CtoSetLinearOAuthClientArgs, CtoStartLinearOAuthResult, CtoGetLinearOAuthSessionArgs, @@ -1489,6 +1495,7 @@ declare global { diff: { getChanges: (args: GetDiffChangesArgs) => Promise<DiffChanges>; getFile: (args: GetFileDiffArgs) => Promise<FileDiff>; + getFilePatch: (args: GetFilePatchArgs) => Promise<FilePatch>; }; files: { writeTextAtomic: (args: WriteTextAtomicArgs) => Promise<void>; @@ -2090,6 +2097,11 @@ declare global { identityOverride?: Record<string, unknown>; }) => Promise<CtoSystemPromptPreview>; getLinearProjects: () => Promise<CtoLinearProject[]>; + getLinearQuickView: () => Promise<CtoLinearQuickView>; + getLinearIssuePickerData: () => Promise<CtoGetLinearIssuePickerDataResult>; + searchLinearIssues: ( + args?: CtoSearchLinearIssuesArgs, + ) => Promise<CtoSearchLinearIssuesResult>; setLinearOAuthClient: ( args: CtoSetLinearOAuthClientArgs, ) => Promise<LinearConnectionStatus>; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index ca3b2344e..f9c700fec 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -111,6 +111,10 @@ import type { CtoOnboardingState, CtoSystemPromptPreview, CtoLinearProject, + CtoLinearQuickView, + CtoGetLinearIssuePickerDataResult, + CtoSearchLinearIssuesArgs, + CtoSearchLinearIssuesResult, CtoStartLinearOAuthResult, CtoGetLinearOAuthSessionArgs, CtoGetLinearOAuthSessionResult, @@ -166,6 +170,7 @@ import type { FileChangeEvent, FileContent, FileDiff, + FilePatch, FileTreeNode, FilesCreateDirectoryArgs, FilesCreateFileArgs, @@ -266,6 +271,7 @@ import type { GetDiffChangesArgs, GetLaneConflictStatusArgs, GetFileDiffArgs, + GetFilePatchArgs, GetProcessLogTailArgs, GetTestLogTailArgs, ExportHistoryArgs, @@ -2630,6 +2636,8 @@ contextBridge.exposeInMainWorld("ade", { 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), }, files: { writeTextAtomic: async (args: WriteTextAtomicArgs): Promise<void> => @@ -3685,6 +3693,14 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.ctoPreviewSystemPrompt, args), getLinearProjects: async (): Promise<CtoLinearProject[]> => ipcRenderer.invoke(IPC.ctoGetLinearProjects), + getLinearQuickView: async (): Promise<CtoLinearQuickView> => + ipcRenderer.invoke(IPC.ctoGetLinearQuickView), + getLinearIssuePickerData: async (): Promise<CtoGetLinearIssuePickerDataResult> => + ipcRenderer.invoke(IPC.ctoGetLinearIssuePickerData), + searchLinearIssues: async ( + args: CtoSearchLinearIssuesArgs = {}, + ): Promise<CtoSearchLinearIssuesResult> => + ipcRenderer.invoke(IPC.ctoSearchLinearIssues, args), setLinearOAuthClient: async ( args: CtoSetLinearOAuthClientArgs, ): Promise<LinearConnectionStatus> => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index f2f991ac2..6d9e8e5bb 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4266,6 +4266,94 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }), onLinearWorkflowEvent: noop, getLinearProjects: resolvedArg([]), + getLinearQuickView: resolvedArg({ + connection: { + tokenStored: true, + connected: true, + viewerId: "mock-linear-user", + viewerName: "Mock Linear User", + checkedAt: now, + authMode: "manual", + oauthAvailable: true, + tokenExpiresAt: null, + message: null, + }, + organization: { + id: "mock-linear-org", + name: "ADE", + urlKey: "ade", + logoUrl: null, + gitBranchFormat: null, + createdIssueCount: 128, + roadmapEnabled: true, + customersEnabled: false, + releasesEnabled: true, + }, + viewer: { + id: "mock-linear-user", + name: "Mock Linear User", + displayName: "Mock Linear User", + email: "mock@example.com", + avatarUrl: null, + admin: true, + guest: false, + url: null, + }, + projects: [ + { + id: "mock-linear-project", + name: "Desktop polish", + slug: "desktop-polish", + teamName: "ADE", + teamKey: "ADE", + url: "https://linear.app/ade/project/desktop-polish", + color: "#5E6AD2", + icon: null, + description: "Mock Linear project", + statusName: "Started", + statusType: "started", + health: "onTrack", + progress: 0.42, + scope: 21, + priority: 2, + priorityLabel: "High", + issueCount: 9, + completedIssueCount: 4, + startDate: null, + targetDate: null, + leadName: "Mock Linear User", + teamKeys: ["ADE"], + }, + ], + teams: [ + { + id: "mock-linear-team", + key: "ADE", + name: "ADE", + displayName: "ADE", + color: "#5E6AD2", + issueCount: 32, + cyclesEnabled: true, + private: false, + }, + ], + assignedIssues: [], + recentIssues: [], + fetchedAt: now, + sdk: { + packageName: "@linear/sdk", + surfaces: ["viewer", "organization", "projects", "teams", "assignedIssues", "issues"], + }, + }), + getLinearIssuePickerData: resolvedArg({ + projects: [], + users: [], + states: [], + }), + searchLinearIssues: resolvedArg({ + issues: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }), getLinearConnectionStatus: resolvedArg({ tokenStored: false, connected: false, diff --git a/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx b/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx new file mode 100644 index 000000000..b071b1664 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx @@ -0,0 +1,787 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ArrowSquareOut, + CaretDown, + CircleNotch, + GitBranch, + MagnifyingGlass, + Plus, + Warning, +} from "@phosphor-icons/react"; + +import type { + CtoGetLinearIssuePickerDataResult, + CtoLinearProject, + CtoLinearQuickView, + CtoLinearQuickViewProject, + LaneLinearIssue, + NormalizedLinearIssue, +} from "../../../shared/types"; +import { linearIssueBranchName } from "../../../shared/linearIssueBranch"; +import { cn } from "../ui/cn"; +import { Button } from "../ui/Button"; +import { + issueProjectLabel, + issueUpdatedLabel, + LinearIssueRow, + linearPriorityLabel, + toLaneLinearIssue, +} from "../lanes/LinearIssuePicker"; +import { LinearPriorityIcon, LinearStateIcon, LINEAR_BRAND } from "../lanes/linearBrand"; + +type BrowserIssue = NormalizedLinearIssue | LaneLinearIssue; +type IssueSort = "updated_desc" | "created_desc" | "priority" | "due_soon" | "identifier_asc"; + +type LinearIssueBrowserFilters = { + projectId: string; + statePreset: "active" | "all" | string; + assigneeId: string; + priority: string; + query: string; + sort: IssueSort; +}; + +const ACTIVE_LINEAR_STATE_TYPES = ["backlog", "unstarted", "started"]; +const FILTER_STORAGE_PREFIX = "ade.linear.quickView.filters.v1:"; + +const DEFAULT_FILTERS: LinearIssueBrowserFilters = { + projectId: "", + statePreset: "all", + assigneeId: "", + priority: "", + query: "", + sort: "updated_desc", +}; + +const STATE_LABELS: Record<string, string> = { + active: "Active", + all: "All states", + backlog: "Backlog", + unstarted: "Todo", + started: "In progress", + completed: "Done", + canceled: "Canceled", + triage: "Triage", +}; + +const PRIORITY_OPTIONS = [ + { value: "", label: "Any priority" }, + { value: "1", label: "Urgent" }, + { value: "2", label: "High" }, + { value: "3", label: "Medium" }, + { value: "4", label: "Low" }, + { value: "0", label: "No priority" }, +] as const; + +const SORT_OPTIONS: ReadonlyArray<{ value: IssueSort; label: string }> = [ + { value: "updated_desc", label: "Recently updated" }, + { value: "created_desc", label: "Recently created" }, + { value: "priority", label: "Priority" }, + { value: "due_soon", label: "Due soon" }, + { value: "identifier_asc", label: "Issue key" }, +]; + +function storageKey(projectRoot: string | null | undefined): string | null { + const root = projectRoot?.trim(); + return root ? `${FILTER_STORAGE_PREFIX}${root}` : null; +} + +function safeLoadFilters(projectRoot: string | null | undefined): LinearIssueBrowserFilters { + const key = storageKey(projectRoot); + if (!key || typeof window === "undefined") return DEFAULT_FILTERS; + try { + const parsed = JSON.parse(window.localStorage.getItem(key) ?? "null") as Partial<LinearIssueBrowserFilters> | null; + if (!parsed || typeof parsed !== "object") return DEFAULT_FILTERS; + return { + ...DEFAULT_FILTERS, + projectId: typeof parsed.projectId === "string" ? parsed.projectId : "", + statePreset: typeof parsed.statePreset === "string" ? parsed.statePreset : DEFAULT_FILTERS.statePreset, + assigneeId: typeof parsed.assigneeId === "string" ? parsed.assigneeId : "", + priority: typeof parsed.priority === "string" ? parsed.priority : "", + query: typeof parsed.query === "string" ? parsed.query : "", + sort: SORT_OPTIONS.some((option) => option.value === parsed.sort) ? (parsed.sort as IssueSort) : DEFAULT_FILTERS.sort, + }; + } catch { + return DEFAULT_FILTERS; + } +} + +function safeSaveFilters(projectRoot: string | null | undefined, filters: LinearIssueBrowserFilters): void { + const key = storageKey(projectRoot); + if (!key || typeof window === "undefined") return; + try { + if ( + filters.projectId === DEFAULT_FILTERS.projectId && + filters.statePreset === DEFAULT_FILTERS.statePreset && + filters.assigneeId === DEFAULT_FILTERS.assigneeId && + filters.priority === DEFAULT_FILTERS.priority && + filters.query === DEFAULT_FILTERS.query && + filters.sort === DEFAULT_FILTERS.sort + ) { + window.localStorage.removeItem(key); + return; + } + window.localStorage.setItem(key, JSON.stringify(filters)); + } catch { + // Best effort only; losing this preference should never block browsing issues. + } +} + +function issueListKey(issue: BrowserIssue): string { + return `${issue.id}:${issue.updatedAt}`; +} + +function mergeIssuePages(current: NormalizedLinearIssue[], next: NormalizedLinearIssue[]): NormalizedLinearIssue[] { + const map = new Map<string, NormalizedLinearIssue>(); + for (const issue of [...current, ...next]) map.set(issue.id, issue); + return [...map.values()]; +} + +function toTimestamp(value: string | null | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function sortedIssues(issues: NormalizedLinearIssue[], sort: IssueSort): NormalizedLinearIssue[] { + const out = [...issues]; + out.sort((left, right) => { + if (sort === "created_desc") return toTimestamp(right.createdAt) - toTimestamp(left.createdAt); + if (sort === "priority") { + const leftRank = left.priority === 0 ? 99 : left.priority; + const rightRank = right.priority === 0 ? 99 : right.priority; + return leftRank - rightRank || toTimestamp(right.updatedAt) - toTimestamp(left.updatedAt); + } + if (sort === "due_soon") { + const leftDue = left.dueDate ? toTimestamp(left.dueDate) : Number.POSITIVE_INFINITY; + const rightDue = right.dueDate ? toTimestamp(right.dueDate) : Number.POSITIVE_INFINITY; + return leftDue - rightDue || toTimestamp(right.updatedAt) - toTimestamp(left.updatedAt); + } + if (sort === "identifier_asc") return left.identifier.localeCompare(right.identifier, undefined, { numeric: true }); + return toTimestamp(right.updatedAt) - toTimestamp(left.updatedAt); + }); + return out; +} + +function formatDate(value: string | null | undefined): string { + if (!value) return "n/a"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" }).format(date); +} + +function stateTypesForPreset(preset: string): string[] { + if (preset === "all") return []; + if (preset === "active") return ACTIVE_LINEAR_STATE_TYPES; + return preset ? [preset] : []; +} + +function openLinearUrl(url: string | null | undefined): void { + if (!url) return; + void window.ade.app.openExternal(url); +} + +export function linearBrowserIssueToLaneIssue(issue: BrowserIssue): LaneLinearIssue { + return "raw" in issue ? toLaneLinearIssue(issue) : issue; +} + +function isConnectionError(message: string): boolean { + return /token|oauth|auth|connect|settings|linear/i.test(message); +} + +export function LinearIssueBrowser({ + projectRoot, + featuredIssue, + featuredIssueLabel = "Linked issue", + actionLabel, + actionBusyLabel, + actionIcon, + actionBusyIssueId, + actionDisabled = false, + showBranchPreview = true, + refreshKey = 0, + onIssueAction, + onOpenLinearSettings, + onConnectionVisibilityChange, + onQuickViewChange, + onLoadingChange, +}: { + projectRoot?: string | null; + featuredIssue?: LaneLinearIssue | null; + featuredIssueLabel?: string; + actionLabel: string; + actionBusyLabel?: string; + actionIcon?: React.ReactNode; + actionBusyIssueId?: string | null; + actionDisabled?: boolean; + showBranchPreview?: boolean; + refreshKey?: number; + onIssueAction: (issue: BrowserIssue) => void | Promise<void>; + onOpenLinearSettings?: () => void; + onConnectionVisibilityChange?: (visible: boolean) => void; + onQuickViewChange?: (quickView: CtoLinearQuickView | null) => void; + onLoadingChange?: (loading: boolean) => void; +}) { + const [quickView, setQuickView] = useState<CtoLinearQuickView | null>(null); + const quickViewRef = useRef<CtoLinearQuickView | null>(null); + const [catalog, setCatalog] = useState<CtoGetLinearIssuePickerDataResult>({ projects: [], users: [], states: [] }); + const [filters, setFilters] = useState<LinearIssueBrowserFilters>(() => safeLoadFilters(projectRoot)); + const [issues, setIssues] = useState<NormalizedLinearIssue[]>([]); + const [pageInfo, setPageInfo] = useState<{ hasNextPage: boolean; endCursor: string | null }>({ hasNextPage: false, endCursor: null }); + const pageInfoRef = useRef(pageInfo); + const [loadingQuickView, setLoadingQuickView] = useState(false); + const [loadingCatalog, setLoadingCatalog] = useState(false); + const [loadingIssues, setLoadingIssues] = useState(false); + const [localActionIssueId, setLocalActionIssueId] = useState<string | null>(null); + const [selectedIssueId, setSelectedIssueId] = useState<string | null>(featuredIssue?.id ?? null); + const [error, setError] = useState<string | null>(null); + const quickViewRequestIdRef = useRef(0); + const searchRequestIdRef = useRef(0); + + useEffect(() => { + quickViewRef.current = quickView; + onQuickViewChange?.(quickView); + }, [onQuickViewChange, quickView]); + + useEffect(() => { + pageInfoRef.current = pageInfo; + }, [pageInfo]); + + useEffect(() => { + setFilters(safeLoadFilters(projectRoot)); + setIssues([]); + setPageInfo({ hasNextPage: false, endCursor: null }); + }, [projectRoot]); + + useEffect(() => { + if (featuredIssue && !selectedIssueId) { + setSelectedIssueId(featuredIssue.id); + } + }, [featuredIssue, selectedIssueId]); + + const loading = loadingQuickView || loadingCatalog || loadingIssues || Boolean(actionBusyIssueId ?? localActionIssueId); + useEffect(() => { + onLoadingChange?.(loading); + }, [loading, onLoadingChange]); + + const loadQuickView = useCallback((force = false) => { + if (!window.ade.cto?.getLinearQuickView) return; + if (!force && quickViewRef.current) return; + const requestId = quickViewRequestIdRef.current + 1; + quickViewRequestIdRef.current = requestId; + setLoadingQuickView(true); + setError(null); + void window.ade.cto.getLinearQuickView() + .then((data) => { + if (quickViewRequestIdRef.current !== requestId) return; + setQuickView(data); + onConnectionVisibilityChange?.(data.connection.connected === true); + }) + .catch((err) => { + if (quickViewRequestIdRef.current !== requestId) return; + setError(err instanceof Error ? err.message : "Unable to load Linear."); + }) + .finally(() => { + if (quickViewRequestIdRef.current === requestId) setLoadingQuickView(false); + }); + }, [onConnectionVisibilityChange]); + + const loadCatalog = useCallback(() => { + const cto = window.ade.cto; + if (!cto?.getLinearIssuePickerData) { + setError("Linear controls are not available in this ADE surface."); + return; + } + setLoadingCatalog(true); + setError(null); + void cto.getLinearIssuePickerData() + .then((data) => setCatalog(data)) + .catch((err) => setError(err instanceof Error ? err.message : "Unable to load Linear filters.")) + .finally(() => setLoadingCatalog(false)); + }, []); + + const searchIssues = useCallback((append: boolean) => { + const cto = window.ade.cto; + if (!cto?.searchLinearIssues) { + setError("Linear issue search is not available in this ADE surface."); + return; + } + const requestId = searchRequestIdRef.current + 1; + searchRequestIdRef.current = requestId; + setLoadingIssues(true); + setError(null); + void cto.searchLinearIssues({ + projectId: filters.projectId || null, + stateTypes: stateTypesForPreset(filters.statePreset), + assigneeId: filters.assigneeId || null, + priority: filters.priority ? Number(filters.priority) : null, + query: filters.query.trim() || null, + first: 50, + after: append ? pageInfoRef.current.endCursor : null, + includeArchived: false, + }) + .then((result) => { + if (searchRequestIdRef.current !== requestId) return; + setIssues((current) => append ? mergeIssuePages(current, result.issues) : result.issues); + setPageInfo(result.pageInfo); + }) + .catch((err) => { + if (searchRequestIdRef.current !== requestId) return; + setError(err instanceof Error ? err.message : "Unable to search Linear issues."); + }) + .finally(() => { + if (searchRequestIdRef.current === requestId) setLoadingIssues(false); + }); + }, [filters]); + + useEffect(() => { + loadQuickView(true); + loadCatalog(); + }, [loadCatalog, loadQuickView, refreshKey]); + + useEffect(() => { + const timer = window.setTimeout(() => searchIssues(false), 220); + return () => window.clearTimeout(timer); + }, [filters, searchIssues]); + + useEffect(() => { + if (refreshKey === 0) return; + searchIssues(false); + }, [refreshKey, searchIssues]); + + const updateFilters = useCallback((patch: Partial<LinearIssueBrowserFilters>) => { + const next = { ...filters, ...patch }; + setFilters(next); + safeSaveFilters(projectRoot, next); + }, [filters, projectRoot]); + + const resetFilters = useCallback(() => { + setFilters(DEFAULT_FILTERS); + safeSaveFilters(projectRoot, DEFAULT_FILTERS); + setIssues([]); + setPageInfo({ hasNextPage: false, endCursor: null }); + }, [projectRoot]); + + const sorted = useMemo(() => sortedIssues(issues, filters.sort), [filters.sort, issues]); + const displayIssues = useMemo<BrowserIssue[]>(() => { + if (!featuredIssue) return sorted; + return [ + featuredIssue, + ...sorted.filter((issue) => issue.id !== featuredIssue.id), + ]; + }, [featuredIssue, sorted]); + + useEffect(() => { + if (selectedIssueId && displayIssues.some((issue) => issue.id === selectedIssueId)) return; + setSelectedIssueId(displayIssues[0]?.id ?? null); + }, [displayIssues, selectedIssueId]); + + const selectedIssue = displayIssues.find((issue) => issue.id === selectedIssueId) ?? displayIssues[0] ?? null; + + const stateOptions = useMemo(() => { + const seen = new Set<string>(); + const dynamic = catalog.states + .filter((state) => { + if (seen.has(state.type)) return false; + seen.add(state.type); + return true; + }) + .sort((left, right) => left.type.localeCompare(right.type)) + .map((state) => ({ value: state.type, label: STATE_LABELS[state.type] ?? state.type })); + const options = [ + { value: "all", label: "All states" }, + { value: "active", label: "Active" }, + ...dynamic, + ]; + if (filters.statePreset && !options.some((option) => option.value === filters.statePreset)) { + options.push({ value: filters.statePreset, label: STATE_LABELS[filters.statePreset] ?? filters.statePreset }); + } + return options; + }, [catalog.states, filters.statePreset]); + + const assigneeOptions = useMemo( + () => [ + { value: "", label: "Anyone" }, + ...catalog.users.map((user) => ({ value: user.id, label: user.displayName ?? user.name })), + ], + [catalog.users], + ); + + const projectFilters = useMemo(() => { + const quickProjects = new Map<string, CtoLinearQuickViewProject>(); + for (const projectEntry of quickView?.projects ?? []) quickProjects.set(projectEntry.id, projectEntry); + return catalog.projects.map((projectEntry) => ({ + ...projectEntry, + quick: quickProjects.get(projectEntry.id) ?? null, + })); + }, [catalog.projects, quickView?.projects]); + + const handleIssueAction = useCallback(async (issue: BrowserIssue) => { + const busyIssueId = actionBusyIssueId ?? localActionIssueId; + if (busyIssueId || actionDisabled) return; + setLocalActionIssueId(issue.id); + setError(null); + try { + await onIssueAction(issue); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to update Linear issue selection."); + } finally { + setLocalActionIssueId(null); + } + }, [actionBusyIssueId, actionDisabled, localActionIssueId, onIssueAction]); + + const showSettingsAction = Boolean(error && onOpenLinearSettings && isConnectionError(error)); + const busyIssueId = actionBusyIssueId ?? localActionIssueId; + + return ( + <div className="min-h-[560px] overflow-hidden"> + {error ? ( + <div className="mx-4 mt-3 flex flex-wrap items-center gap-2 rounded-lg border border-red-500/25 px-3 py-2 text-[12px] text-red-100" style={{ backgroundColor: "#321B20" }}> + <Warning size={14} className="shrink-0" /> + <span className="min-w-0 flex-1">{error}</span> + {showSettingsAction ? ( + <Button type="button" variant="danger" size="sm" onClick={onOpenLinearSettings}> + Open Linear settings + </Button> + ) : null} + </div> + ) : null} + + <div className="grid min-h-0 md:grid-cols-[232px_minmax(0,1fr)_334px]"> + <aside className="min-h-0 border-r border-white/10 bg-black/10 px-3 py-3"> + <div className="mb-3 flex items-center justify-between"> + <div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-fg/55"> + Projects + </div> + <button + type="button" + className="text-[11px] text-muted-fg/60 transition-colors hover:text-fg" + onClick={resetFilters} + > + Clear + </button> + </div> + + <div className="space-y-2"> + <button + type="button" + className={cn( + "flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-[12px] transition-colors", + !filters.projectId ? "border-white/12 bg-white/[0.06] text-fg" : "border-white/[0.06] bg-white/[0.025] text-muted-fg/75 hover:bg-white/[0.05]", + )} + onClick={() => updateFilters({ projectId: "" })} + > + <span>All issues</span> + <span className="text-[10px] text-muted-fg/50"> + {quickView?.organization?.createdIssueCount?.toLocaleString() ?? ""} + </span> + </button> + </div> + + <div className="mt-2 max-h-[350px] space-y-1.5 overflow-y-auto pr-1"> + {loadingCatalog && projectFilters.length === 0 ? ( + <div className="rounded-lg border border-white/[0.06] px-3 py-6 text-center text-[12px] text-muted-fg/50"> + Loading projects... + </div> + ) : projectFilters.length > 0 ? ( + projectFilters.map((projectEntry) => ( + <ProjectFilterButton + key={projectEntry.id} + project={projectEntry} + active={filters.projectId === projectEntry.id} + onClick={() => updateFilters({ projectId: projectEntry.id })} + /> + )) + ) : ( + <div className="rounded-lg border border-white/[0.06] px-3 py-6 text-center text-[12px] text-muted-fg/50"> + No visible projects. + </div> + )} + </div> + </aside> + + <section className="min-h-0 border-r border-white/10 px-4 py-3"> + <div className="mb-3 grid gap-2"> + <div className="relative"> + <MagnifyingGlass size={13} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-fg/45" /> + <input + value={filters.query} + onChange={(event) => updateFilters({ query: event.target.value })} + placeholder="Search all Linear issues" + className="h-9 w-full rounded-lg border border-white/[0.07] bg-black/20 pl-8 pr-3 text-[12px] text-fg outline-none transition-colors placeholder:text-muted-fg/40 focus:border-white/18" + /> + </div> + <div className="grid grid-cols-2 gap-2 lg:grid-cols-4"> + <FilterSelect + label="State" + value={filters.statePreset} + options={stateOptions} + onChange={(value) => updateFilters({ statePreset: value })} + /> + <FilterSelect + label="Assignee" + value={filters.assigneeId} + options={assigneeOptions} + onChange={(value) => updateFilters({ assigneeId: value })} + /> + <FilterSelect + label="Priority" + value={filters.priority} + options={PRIORITY_OPTIONS} + onChange={(value) => updateFilters({ priority: value })} + /> + <FilterSelect + label="Sort" + value={filters.sort} + options={SORT_OPTIONS} + onChange={(value) => updateFilters({ sort: value as IssueSort })} + /> + </div> + </div> + + <div className="mb-2 flex items-center justify-between"> + <div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-fg/55"> + Issues + </div> + <div className="flex items-center gap-2 text-[11px] text-muted-fg/55"> + {filters.projectId + ? projectFilters.find((projectEntry) => projectEntry.id === filters.projectId)?.name ?? "Project issues" + : "All issues"} + {loadingIssues ? <CircleNotch size={11} className="animate-spin" /> : null} + </div> + </div> + + <div className="max-h-[438px] overflow-y-auto rounded-lg border border-white/[0.06] bg-black/20"> + {loadingQuickView && !quickView && displayIssues.length === 0 ? ( + <div className="grid h-44 place-items-center text-[12px] text-muted-fg/55"> + <CircleNotch size={16} className="animate-spin" /> + </div> + ) : displayIssues.length > 0 ? ( + <> + {displayIssues.map((issue) => ( + <LinearIssueRow + key={issueListKey(issue)} + issue={issue} + active={selectedIssue?.id === issue.id} + eyebrow={featuredIssue?.id === issue.id ? featuredIssueLabel : undefined} + busy={busyIssueId === issue.id} + onClick={() => setSelectedIssueId(issue.id)} + /> + ))} + {pageInfo.hasNextPage ? ( + <button + type="button" + className="flex w-full items-center justify-center gap-2 px-3 py-2.5 text-[12px] text-muted-fg/70 transition-colors hover:bg-white/[0.04] hover:text-fg" + disabled={loadingIssues} + onClick={() => searchIssues(true)} + > + {loadingIssues ? <CircleNotch size={13} className="animate-spin" /> : null} + Load more + </button> + ) : null} + </> + ) : ( + <div className="px-4 py-12 text-center text-[12px] text-muted-fg/55"> + No issues match these filters. + </div> + )} + </div> + </section> + + <IssueDetails + issue={selectedIssue} + actionLabel={actionLabel} + actionBusyLabel={actionBusyLabel} + actionIcon={actionIcon} + actionBusy={selectedIssue ? busyIssueId === selectedIssue.id : false} + actionDisabled={actionDisabled || Boolean(busyIssueId && busyIssueId !== selectedIssue?.id)} + showBranchPreview={showBranchPreview} + onIssueAction={handleIssueAction} + /> + </div> + </div> + ); +} + +function ProjectFilterButton({ + project, + active, + onClick, +}: { + project: CtoLinearProject & { quick: CtoLinearQuickViewProject | null }; + active: boolean; + onClick: () => void; +}) { + return ( + <button + type="button" + className={cn( + "flex w-full items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors", + active ? "border-white/12 bg-white/[0.06]" : "border-white/[0.06] bg-white/[0.025] hover:bg-white/[0.05]", + )} + onClick={onClick} + > + <span + className="h-2 w-2 shrink-0 rounded-full" + style={{ background: project.quick?.color ?? LINEAR_BRAND.primaryBright }} + /> + <span className="min-w-0 flex-1"> + <span className="block truncate text-[12px] font-medium text-fg">{project.name}</span> + <span className="block truncate text-[10.5px] text-muted-fg/50"> + {project.teamName} + {project.quick?.statusName ? ` · ${project.quick.statusName}` : ""} + </span> + </span> + <span className="shrink-0 text-[10px] text-muted-fg/50"> + {project.quick?.issueCount ?? ""} + </span> + </button> + ); +} + +function FilterSelect({ + label, + value, + options, + onChange, +}: { + label: string; + value: string; + options: ReadonlyArray<{ value: string; label: string }>; + onChange: (value: string) => void; +}) { + return ( + <label className="relative block"> + <span className="sr-only">{label}</span> + <select + value={value} + onChange={(event) => onChange(event.target.value)} + className="h-8 w-full appearance-none rounded-lg border border-white/[0.07] bg-black/20 px-2.5 pr-7 text-[11px] text-fg outline-none transition-colors focus:border-white/18" + aria-label={label} + > + {options.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> + <CaretDown size={9} className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-fg/50" /> + </label> + ); +} + +function IssueDetails({ + issue, + actionLabel, + actionBusyLabel, + actionIcon, + actionBusy, + actionDisabled, + showBranchPreview, + onIssueAction, +}: { + issue: BrowserIssue | null; + actionLabel: string; + actionBusyLabel?: string; + actionIcon?: React.ReactNode; + actionBusy: boolean; + actionDisabled: boolean; + showBranchPreview: boolean; + onIssueAction: (issue: BrowserIssue) => void | Promise<void>; +}) { + if (!issue) { + return ( + <aside className="grid min-h-[420px] place-items-center px-4 py-8 text-center text-[12px] text-muted-fg/55"> + Select an issue to preview it. + </aside> + ); + } + + const laneIssue = linearBrowserIssueToLaneIssue(issue); + const branchName = linearIssueBranchName(laneIssue); + const normalizedIssue = "raw" in issue ? issue : null; + const description = issue.description?.trim() ?? ""; + return ( + <aside className="flex min-h-0 flex-col"> + <div className="min-h-0 flex-1 overflow-y-auto px-4 py-3"> + <div className="flex items-center gap-2"> + <LinearPriorityIcon priority={issue.priority} size={12} /> + <LinearStateIcon stateType={issue.stateType} size={12} /> + <span className="rounded bg-white/[0.06] px-1.5 py-0.5 font-mono text-[10px] text-fg/80"> + {issue.identifier} + </span> + </div> + <div className="mt-2 text-[14px] font-semibold leading-snug">{issue.title}</div> + {showBranchPreview ? ( + <div className="mt-2 rounded-md bg-black/25 px-2 py-1.5 font-mono text-[10.5px] text-fg/80"> + <GitBranch size={11} className="mr-1 inline" /> + {branchName} + </div> + ) : null} + + {description ? ( + <div className="mt-3 max-h-28 overflow-y-auto rounded-lg border border-white/[0.06] bg-white/[0.025] px-3 py-2 text-[12px] leading-relaxed text-muted-fg/80 whitespace-pre-wrap"> + {description} + </div> + ) : null} + + {issue.labels.length > 0 ? ( + <div className="mt-3 flex flex-wrap gap-1.5"> + {issue.labels.map((label) => ( + <span key={label} className="rounded-full border border-white/[0.07] bg-white/[0.035] px-2 py-0.5 text-[10px] text-muted-fg/75"> + {label} + </span> + ))} + </div> + ) : null} + + <div className="mt-3 grid gap-1.5 text-[11px] text-muted-fg/65"> + <InfoRow label="Project" value={issueProjectLabel(issue)} /> + <InfoRow label="Team" value={issue.teamName ?? issue.teamKey} /> + <InfoRow label="Status" value={issue.stateName} /> + <InfoRow label="Priority" value={linearPriorityLabel(issue)} /> + <InfoRow label="Assignee" value={issue.assigneeName ?? "Unassigned"} /> + <InfoRow label="Creator" value={issue.creatorName ?? "Unknown"} /> + <InfoRow label="Estimate" value={issue.estimate != null ? String(issue.estimate) : "n/a"} /> + <InfoRow label="Due" value={formatDate(issue.dueDate)} /> + <InfoRow label="Created" value={formatDate(issue.createdAt)} /> + <InfoRow label="Updated" value={issueUpdatedLabel(issue)} /> + {normalizedIssue ? ( + <> + <InfoRow label="Started" value={formatDate(normalizedIssue.startedAt)} /> + <InfoRow label="Completed" value={formatDate(normalizedIssue.completedAt)} /> + <InfoRow label="Canceled" value={formatDate(normalizedIssue.canceledAt)} /> + <InfoRow label="Open blockers" value={normalizedIssue.hasOpenBlockers ? `${normalizedIssue.blockerIssueIds.length}` : "None"} /> + </> + ) : null} + </div> + </div> + + <div className="flex flex-wrap items-center gap-2 border-t border-white/10 px-4 py-3"> + <Button + variant="primary" + disabled={actionBusy || actionDisabled} + onClick={() => void onIssueAction(issue)} + > + {actionBusy ? <CircleNotch size={14} className="animate-spin" /> : actionIcon ?? <Plus size={14} />} + {actionBusy ? actionBusyLabel ?? actionLabel : actionLabel} + </Button> + {issue.url ? ( + <Button variant="outline" onClick={() => openLinearUrl(issue.url)}> + <ArrowSquareOut size={13} /> + Open + </Button> + ) : null} + </div> + </aside> + ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( + <div className="flex items-baseline justify-between gap-2"> + <span className="text-muted-fg/45">{label}</span> + <span className="truncate text-right text-fg/80" title={value}>{value}</span> + </div> + ); +} diff --git a/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx new file mode 100644 index 000000000..990c3e704 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx @@ -0,0 +1,217 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { CircleNotch, X } from "@phosphor-icons/react"; + +import type { + CtoLinearQuickView, + LaneLinearIssue, + NormalizedLinearIssue, +} from "../../../shared/types"; +import { linearIssueBranchName, linearIssueLaneName } from "../../../shared/linearIssueBranch"; +import { useAppStore } from "../../state/appStore"; +import { cn } from "../ui/cn"; +import { LinearMark, LINEAR_BRAND } from "../lanes/linearBrand"; +import { LinearIssueBrowser, linearBrowserIssueToLaneIssue } from "./LinearIssueBrowser"; + +type PopoverPosition = { top: number; right: number } | null; + +export function LinearQuickViewButton() { + const project = useAppStore((s) => s.project); + const refreshLanes = useAppStore((s) => s.refreshLanes); + const selectLane = useAppStore((s) => s.selectLane); + const [visible, setVisible] = useState(false); + const [open, setOpen] = useState(false); + const [position, setPosition] = useState<PopoverPosition>(null); + const [quickView, setQuickView] = useState<CtoLinearQuickView | null>(null); + const [refreshKey, setRefreshKey] = useState(0); + const [browserLoading, setBrowserLoading] = useState(false); + const [creatingIssueId, setCreatingIssueId] = useState<string | null>(null); + const buttonRef = useRef<HTMLButtonElement | null>(null); + const popoverRef = useRef<HTMLDivElement | null>(null); + + useEffect(() => { + let cancelled = false; + setVisible(false); + setOpen(false); + setQuickView(null); + if (!project?.rootPath || !window.ade.cto?.getLinearConnectionStatus) return; + void window.ade.cto.getLinearConnectionStatus() + .then((status) => { + if (!cancelled) setVisible(status.connected === true); + }) + .catch(() => { + if (!cancelled) setVisible(false); + }); + return () => { + cancelled = true; + }; + }, [project?.rootPath]); + + const openAtButton = useCallback(() => { + const el = buttonRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setPosition({ top: rect.bottom + 7, right: Math.max(8, window.innerWidth - rect.right) }); + setOpen(true); + }, []); + + const close = useCallback(() => { + setOpen(false); + setPosition(null); + }, []); + + useEffect(() => { + if (!open) return; + const onDown = (event: MouseEvent) => { + const target = event.target as Node | null; + if (!target) return; + if (popoverRef.current?.contains(target)) return; + if (buttonRef.current?.contains(target)) return; + close(); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + close(); + } + }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey); + }; + }, [close, open]); + + const createLaneForIssue = useCallback(async (issue: NormalizedLinearIssue | LaneLinearIssue) => { + const laneIssue = linearBrowserIssueToLaneIssue(issue); + const name = linearIssueLaneName(laneIssue); + const branchName = linearIssueBranchName(laneIssue); + setCreatingIssueId(laneIssue.id); + try { + const lane = await window.ade.lanes.create({ + name, + branchName, + linearIssue: { ...laneIssue, branchName }, + }); + await refreshLanes({ includeStatus: false }).catch(() => undefined); + selectLane(lane.id); + close(); + window.location.hash = `#/lanes?laneId=${encodeURIComponent(lane.id)}&focus=single`; + } finally { + setCreatingIssueId(null); + } + }, [close, refreshLanes, selectLane]); + + if (!visible) return null; + + return ( + <> + <button + ref={buttonRef} + type="button" + aria-label="Linear quick view" + aria-haspopup="dialog" + aria-expanded={open} + title="Linear quick view" + 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", + )} + data-state={open ? "open" : undefined} + onClick={() => (open ? close() : openAtButton())} + style={{ + WebkitAppRegion: "no-drag", + color: open ? LINEAR_BRAND.primaryBright : undefined, + } as React.CSSProperties} + > + <LinearMark size={13} /> + </button> + + {open && position ? createPortal( + <> + <button + type="button" + aria-label="Close Linear quick view backdrop" + data-linear-quick-view-backdrop="true" + className="fixed inset-0 z-[9998] cursor-default bg-black/30 backdrop-blur-sm" + onClick={close} + tabIndex={-1} + /> + <div + ref={popoverRef} + role="dialog" + aria-modal="false" + aria-label="Linear quick view" + className="fixed z-[9999] w-[min(1040px,calc(100vw-24px))] overflow-y-auto rounded-xl border bg-[color:var(--ade-shell-surface,#121019)] text-fg shadow-2xl shadow-black/50" + style={{ + top: position.top, + right: position.right, + maxHeight: "min(760px, calc(100vh - 64px))", + borderColor: "rgba(123, 138, 240, 0.55)", + boxShadow: "0 24px 70px rgba(0, 0, 0, 0.58), 0 0 0 1px rgba(123, 138, 240, 0.18)", + }} + > + <div + className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-3" + style={{ background: LINEAR_BRAND.surface }} + > + <div className="flex min-w-0 items-center gap-2.5"> + <span + className="grid h-8 w-8 shrink-0 place-items-center rounded-lg" + style={{ background: LINEAR_BRAND.surfaceHover, color: LINEAR_BRAND.primaryBright }} + > + <LinearMark size={17} /> + </span> + <div className="min-w-0"> + <div className="truncate text-[13px] font-semibold"> + {quickView?.organization?.name ?? "Linear"} + </div> + <div className="truncate text-[11px] text-muted-fg/65"> + {quickView?.viewer?.displayName ?? quickView?.connection.viewerName ?? "Connected"} + {quickView?.organization?.urlKey ? ` · ${quickView.organization.urlKey}` : ""} + </div> + </div> + </div> + <div className="flex items-center gap-1.5"> + <button + type="button" + className="ade-shell-control inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-[11px]" + data-variant="ghost" + onClick={() => setRefreshKey((key) => key + 1)} + disabled={browserLoading} + title="Refresh Linear" + > + {browserLoading ? <CircleNotch size={12} className="animate-spin" /> : null} + Refresh + </button> + <button + type="button" + className="ade-shell-control inline-flex h-7 w-7 items-center justify-center rounded-md" + data-variant="ghost" + onClick={close} + title="Close Linear" + > + <X size={13} /> + </button> + </div> + </div> + + <LinearIssueBrowser + projectRoot={project?.rootPath} + actionLabel="Create lane" + actionBusyLabel="Creating lane" + actionBusyIssueId={creatingIssueId} + refreshKey={refreshKey} + onIssueAction={createLaneForIssue} + onConnectionVisibilityChange={setVisible} + onQuickViewChange={setQuickView} + onLoadingChange={setBrowserLoading} + /> + </div> + </>, + document.body, + ) : null} + </> + ); +} diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 6375e317f..dcd0527a4 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -343,6 +343,126 @@ describe("TopBar", () => { expect(getStatus).toHaveBeenCalledTimes(2); }); + it("opens Linear quick view and creates a linked lane from an issue", async () => { + const issue = { + id: "issue-1", + identifier: "ADE-123", + title: "Add Linear quick view", + description: "Show Linear in the app chrome.", + url: "https://linear.app/ade/issue/ADE-123/add-linear-quick-view", + projectId: "project-1", + projectSlug: "desktop", + projectName: "Desktop", + teamId: "team-1", + teamKey: "ADE", + teamName: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 2, + priorityLabel: "high", + labels: [], + metadataTags: [], + assigneeId: "user-1", + assigneeName: "Arul", + creatorId: "user-1", + creatorName: "Arul", + blockerIssueIds: [], + hasOpenBlockers: false, + dueDate: null, + estimate: 3, + archivedAt: null, + completedAt: null, + canceledAt: null, + startedAt: null, + createdAt: "2026-04-22T00:00:00.000Z", + updatedAt: "2026-04-22T01:00:00.000Z", + raw: {}, + }; + const createLane = vi.fn(async () => ({ + id: "lane-linear", + name: "ADE-123 Add Linear quick view", + })); + globalThis.window.ade.cto = { + getLinearConnectionStatus: vi.fn(async () => ({ + tokenStored: true, + connected: true, + viewerId: "user-1", + viewerName: "Arul", + checkedAt: "2026-04-22T01:00:00.000Z", + authMode: "manual", + oauthAvailable: true, + tokenExpiresAt: null, + message: null, + })), + getLinearQuickView: vi.fn(async () => ({ + connection: { + tokenStored: true, + connected: true, + viewerId: "user-1", + viewerName: "Arul", + checkedAt: "2026-04-22T01:00:00.000Z", + authMode: "manual", + oauthAvailable: true, + tokenExpiresAt: null, + message: null, + }, + organization: { + id: "org-1", + name: "ADE", + urlKey: "ade", + logoUrl: null, + gitBranchFormat: null, + createdIssueCount: 40, + roadmapEnabled: true, + customersEnabled: false, + releasesEnabled: true, + }, + viewer: null, + projects: [], + teams: [], + assignedIssues: [issue], + recentIssues: [], + fetchedAt: "2026-04-22T01:00:00.000Z", + sdk: { packageName: "@linear/sdk", surfaces: ["viewer", "issues"] }, + })), + getLinearIssuePickerData: vi.fn(async () => ({ + projects: [{ id: "project-1", name: "Desktop", slug: "desktop", teamName: "ADE", teamKey: "ADE" }], + users: [{ id: "user-1", name: "arul", displayName: "Arul", email: null, active: true }], + states: [{ id: "state-1", name: "In Progress", type: "started", teamId: "team-1", teamKey: "ADE" }], + })), + searchLinearIssues: vi.fn(async () => ({ + issues: [issue], + pageInfo: { hasNextPage: false, endCursor: null }, + })), + } as any; + globalThis.window.ade.lanes.create = createLane as any; + + render(<TopBar />); + + fireEvent.click(await screen.findByRole("button", { name: /linear quick view/i })); + + await waitFor(() => { + expect(screen.getAllByText("Add Linear quick view").length).toBeGreaterThan(0); + }); + const quickViewDialog = screen.getByRole("dialog", { name: /linear quick view/i }); + expect(document.body.querySelector("[data-linear-quick-view-backdrop]")).toBeTruthy(); + expect(quickViewDialog.getAttribute("style")).toContain("rgba(123, 138, 240, 0.55)"); + + fireEvent.click(screen.getByRole("button", { name: /create lane/i })); + + await waitFor(() => { + expect(createLane).toHaveBeenCalledWith(expect.objectContaining({ + name: "ADE-123 Add Linear quick view", + branchName: "ade-123-add-linear-quick-view", + linearIssue: expect.objectContaining({ + identifier: "ADE-123", + branchName: "ade-123-add-linear-quick-view", + }), + })); + }); + }); + it("shows project icon replacement errors", async () => { globalThis.window.ade.project.chooseIcon = vi.fn(async () => { throw new Error("Failed to set project icon: Project icon must be 10 MB or smaller."); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index b6925b173..d6f496834 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -18,6 +18,7 @@ import type { ProcessRuntime, ProjectIcon, RecentProjectSummary, SyncRoleSnapsho import { AutoUpdateControl } from "./AutoUpdateControl"; import { FeedbackReporterModal } from "./FeedbackReporterModal"; import { HelpMenu } from "../onboarding/HelpMenu"; +import { LinearQuickViewButton } from "./LinearQuickViewButton"; import { PublishToGitHubDialog } from "../projects/PublishToGitHubDialog"; import { SyncDevicesSection } from "../settings/SyncDevicesSection"; @@ -1192,6 +1193,8 @@ export function TopBar() { </div> ) : null} + <LinearQuickViewButton /> + {syncSnapshot && syncLabel ? ( <button type="button" diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 25af84a64..c31fbeff2 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor, type RenderResult } from "@testing-library/react"; import type { ComponentProps } from "react"; +import type { NormalizedLinearIssue } from "../../../shared/types"; import { AgentChatComposer } from "./AgentChatComposer"; function installMatchMediaMock(): void { @@ -119,6 +120,41 @@ function renderComposer(overrides: Partial<ComponentProps<typeof AgentChatCompos return Object.assign(view, props) as RenderResult & ComponentProps<typeof AgentChatComposer>; } +function makeLinearIssue(overrides: Partial<NormalizedLinearIssue> = {}): NormalizedLinearIssue { + return { + id: "issue-1", + identifier: "ADE-123", + title: "Attach Linear context to chat", + description: "Use this issue as prompt context.", + url: "https://linear.app/ade/issue/ADE-123/attach-linear-context-to-chat", + projectId: "project-1", + projectSlug: "ade", + projectName: "ADE", + teamId: "team-1", + teamKey: "ADE", + teamName: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 2, + priorityLabel: "high", + labels: ["desktop"], + assigneeId: "user-1", + assigneeName: "Arul", + ownerId: "user-1", + creatorId: "user-2", + creatorName: "Annie", + blockerIssueIds: [], + hasOpenBlockers: false, + dueDate: null, + estimate: null, + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + raw: {}, + ...overrides, + }; +} + const executionModeOptions = [ { value: "focused", @@ -581,6 +617,163 @@ describe("AgentChatComposer", () => { expect((screen.getByLabelText("Upload file from disk") as HTMLButtonElement).disabled).toBe(false); }); + it("renders the issue context menu outside the clipped composer shell", () => { + const { container } = renderComposer({ + draft: "", + turnActive: false, + onAddContextAttachment: vi.fn(), + }); + + fireEvent.click(screen.getByRole("button", { name: "Attach issue context" })); + + const menu = document.body.querySelector("[data-issue-context-menu]"); + const composerShell = container.querySelector("[data-chat-composer-mode]"); + expect(menu).toBeTruthy(); + expect(menu?.parentElement).toBe(document.body); + expect(composerShell?.contains(menu)).toBe(false); + expect((menu as HTMLElement).className).toContain("fixed"); + }); + + it("offers Linear settings when issue search needs a connection", async () => { + const onOpenLinearSettings = vi.fn(); + Object.defineProperty(window, "ade", { + configurable: true, + value: { + cto: { + getLinearIssuePickerData: vi.fn().mockResolvedValue({ + projects: [], + users: [], + states: [], + }), + searchLinearIssues: vi.fn().mockRejectedValue(new Error("Linear token missing. Set it in Settings > Linear.")), + }, + }, + }); + + renderComposer({ + draft: "", + turnActive: false, + onAddContextAttachment: vi.fn(), + onOpenLinearSettings, + }); + + fireEvent.click(screen.getByRole("button", { name: "Attach issue context" })); + fireEvent.click(screen.getByRole("button", { name: /Linear issue/i })); + + await screen.findByText(/Linear token missing/i); + fireEvent.click(screen.getByRole("button", { name: "Open Linear settings" })); + + expect(onOpenLinearSettings).toHaveBeenCalledTimes(1); + }); + + it("attaches Linear issue context from the issue dropdown", async () => { + const issue = makeLinearIssue(); + const onAddContextAttachment = vi.fn(); + const searchLinearIssues = vi.fn().mockResolvedValue({ + issues: [issue], + pageInfo: { hasNextPage: false, endCursor: null }, + }); + Object.defineProperty(window, "ade", { + configurable: true, + value: { + cto: { + getLinearIssuePickerData: vi.fn().mockResolvedValue({ + projects: [{ id: "project-1", name: "ADE", slug: "ade", teamName: "ADE", teamKey: "ADE" }], + users: [{ id: "user-1", name: "arul", displayName: "Arul", email: "arul@example.com", active: true }], + states: [{ id: "state-1", name: "In Progress", type: "started", teamId: "team-1", teamKey: "ADE" }], + }), + searchLinearIssues, + }, + }, + }); + + renderComposer({ + draft: "", + turnActive: false, + onAddContextAttachment, + }); + + fireEvent.click(screen.getByRole("button", { name: "Attach issue context" })); + fireEvent.click(screen.getByRole("button", { name: /Linear issue/i })); + + await waitFor(() => expect(searchLinearIssues).toHaveBeenCalled()); + const issueIdentifier = (await screen.findAllByText("ADE-123"))[0]!; + const issueRow = issueIdentifier.closest("button"); + expect(issueRow).toBeTruthy(); + fireEvent.click(issueRow!); + fireEvent.click(screen.getByRole("button", { name: "Attach issue" })); + + await waitFor(() => { + expect(onAddContextAttachment).toHaveBeenCalledTimes(1); + }); + expect(onAddContextAttachment.mock.calls[0]?.[0]).toMatchObject({ + type: "linear_issue", + source: "manual", + issue: { + id: "issue-1", + identifier: "ADE-123", + title: "Attach Linear context to chat", + projectSlug: "ade", + }, + }); + }); + + it("keeps appended Linear issue search pages loaded", async () => { + const firstIssue = makeLinearIssue(); + const secondIssue = makeLinearIssue({ + id: "issue-2", + identifier: "ADE-124", + title: "Second page issue", + }); + const searchLinearIssues = vi.fn().mockImplementation(async (args: { after?: string | null }) => { + if (args.after === "cursor-1") { + return { + issues: [secondIssue], + pageInfo: { hasNextPage: false, endCursor: null }, + }; + } + return { + issues: [firstIssue], + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + }; + }); + Object.defineProperty(window, "ade", { + configurable: true, + value: { + cto: { + getLinearIssuePickerData: vi.fn().mockResolvedValue({ + projects: [], + users: [], + states: [], + }), + searchLinearIssues, + }, + }, + }); + + renderComposer({ + draft: "", + turnActive: false, + onAddContextAttachment: vi.fn(), + }); + + fireEvent.click(screen.getByRole("button", { name: "Attach issue context" })); + fireEvent.click(screen.getByRole("button", { name: /Linear issue/i })); + + await waitFor(() => expect(screen.getAllByText("ADE-123").length).toBeGreaterThan(0)); + await new Promise((resolve) => window.setTimeout(resolve, 260)); + expect(searchLinearIssues).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Load more" })); + await waitFor(() => expect(screen.getAllByText("ADE-124").length).toBeGreaterThan(0)); + expect(searchLinearIssues).toHaveBeenLastCalledWith(expect.objectContaining({ after: "cursor-1" })); + await new Promise((resolve) => window.setTimeout(resolve, 260)); + + expect(searchLinearIssues).toHaveBeenCalledTimes(2); + expect(screen.getAllByText("ADE-123").length).toBeGreaterThan(0); + expect(screen.getAllByText("ADE-124").length).toBeGreaterThan(0); + }); + it("attaches a native clipboard image when macOS Cmd+V does not expose paste files", async () => { const originalPlatform = navigator.platform; Object.defineProperty(navigator, "platform", { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 28e38bfe7..fe4c49809 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,11 +1,12 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { At, CaretDown, Check, CloudArrowUp, Cube, Desktop, DeviceMobile, Globe, Image, Paperclip, PencilSimple, Square, X, PaperPlaneTilt, SquareSplitHorizontal, Plus, Trash, Lightning, ArrowBendDownRight } from "@phosphor-icons/react"; +import { ArrowBendDownRight, At, Bug, CaretDown, Check, CloudArrowUp, Cube, Desktop, DeviceMobile, GithubLogo, Globe, Image, Lightning, PaperPlaneTilt, Paperclip, PencilSimple, Plus, Square, SquareSplitHorizontal, Trash, X } from "@phosphor-icons/react"; import { BorderBeam } from "border-beam"; import { inferAttachmentType, PARALLEL_CHAT_MAX_ATTACHMENTS, type AgentChatApprovalDecision, + type AgentChatContextAttachment, type AgentChatClaudePermissionMode, type AgentChatCursorConfigOption, type AgentChatCursorModeSnapshot, @@ -23,15 +24,23 @@ import { type AppControlContextItem, type BuiltInBrowserContextItem, type IosElementContextItem, + type LaneLinearIssue, type MacosVmContextItem, type PendingInputRequest, } from "../../../shared/types"; +import { + buildChatContextAttachmentPrompt, + makeLinearIssueContextAttachment, +} from "../../../shared/chatContextAttachments"; import { getModelById, modelSupportsFastMode } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; import { ProviderModelSelector } from "../shared/ProviderModelSelector"; import { getPermissionOptions, safetyColors } from "../shared/permissionOptions"; import { ChatAttachmentTray } from "./ChatAttachmentTray"; import { ChatComposerShell } from "./ChatComposerShell"; +import { LaneDialogShell } from "../lanes/LaneDialogShell"; +import { LinearIssueBrowser, linearBrowserIssueToLaneIssue } from "../app/LinearIssueBrowser"; +import { LinearMark, LINEAR_BRAND } from "../lanes/linearBrand"; import { getPendingInputQuestionCount, hasPendingInputOptions } from "./pendingInput"; import { CURSOR_MODE_LABELS } from "../../../shared/cursorModes"; import { ChatStatusGlyph } from "./chatStatusVisuals"; @@ -42,6 +51,9 @@ import { SmartTooltip } from "../ui/SmartTooltip"; const MAX_TEMP_ATTACHMENT_BYTES = 10 * 1024 * 1024; const CLIPBOARD_IMAGE_PASTE_FALLBACK_DELAY_MS = 80; +const ISSUE_CONTEXT_MENU_WIDTH = 256; +const ISSUE_CONTEXT_MENU_GAP = 8; +const ISSUE_CONTEXT_MENU_VIEWPORT_GUTTER = 8; type PasteShortcutEvent = { key: string; @@ -66,6 +78,24 @@ function isMacPasteShortcut(event: PasteShortcutEvent): boolean { ); } +function getIssueContextMenuStyle(trigger: HTMLButtonElement): React.CSSProperties { + const rect = trigger.getBoundingClientRect(); + const maxLeft = Math.max( + ISSUE_CONTEXT_MENU_VIEWPORT_GUTTER, + window.innerWidth - ISSUE_CONTEXT_MENU_WIDTH - ISSUE_CONTEXT_MENU_VIEWPORT_GUTTER, + ); + const left = Math.min( + Math.max(ISSUE_CONTEXT_MENU_VIEWPORT_GUTTER, rect.right - ISSUE_CONTEXT_MENU_WIDTH), + maxLeft, + ); + + return { + left, + bottom: Math.max(ISSUE_CONTEXT_MENU_VIEWPORT_GUTTER, window.innerHeight - rect.top + ISSUE_CONTEXT_MENU_GAP), + width: ISSUE_CONTEXT_MENU_WIDTH, + }; +} + type ExecutionModeOption = { value: AgentChatExecutionMode; label: string; @@ -587,6 +617,61 @@ function CodexFastModeToggle({ ); } +function LinearIssueContextDialog({ + open, + selectedIssue, + pinnedIssue, + busy, + onOpenChange, + onAttach, + onOpenLinearSettings, +}: { + open: boolean; + selectedIssue: LaneLinearIssue | null; + pinnedIssue?: LaneLinearIssue | null; + busy?: boolean; + onOpenChange: (open: boolean) => void; + onAttach: (attachment: AgentChatContextAttachment) => void; + onOpenLinearSettings?: () => void; +}) { + const featuredIssue = pinnedIssue ?? selectedIssue; + const openLinearSettings = useCallback(() => { + onOpenChange(false); + onOpenLinearSettings?.(); + }, [onOpenChange, onOpenLinearSettings]); + + return ( + <LaneDialogShell + open={open} + onOpenChange={onOpenChange} + title="Attach Linear issue" + description="Browse Linear issues and attach one as chat context." + icon={Bug} + widthClassName="w-[min(1040px,calc(100vw-24px))]" + busy={busy} + > + <LinearIssueBrowser + featuredIssue={featuredIssue} + featuredIssueLabel={pinnedIssue ? "Linked to this lane" : "Attached to chat"} + actionLabel="Attach issue" + actionBusyLabel="Attaching issue" + actionIcon={<Check size={14} />} + actionDisabled={busy} + showBranchPreview={false} + onOpenLinearSettings={openLinearSettings} + onIssueAction={(issue) => { + const laneIssue = linearBrowserIssueToLaneIssue(issue); + onAttach(makeLinearIssueContextAttachment( + laneIssue, + pinnedIssue?.id === laneIssue.id ? "lane_link" : "manual", + )); + onOpenChange(false); + }} + /> + </LaneDialogShell> + ); +} + export function AgentChatComposer({ surfaceMode = "standard", layoutVariant = "standard", @@ -600,6 +685,8 @@ export function AgentChatComposer({ codexFastMode = false, draft, attachments, + contextAttachments = [], + pinnedLinearIssue = null, pendingInput, approvalResponding, turnActive, @@ -636,6 +723,8 @@ export function AgentChatComposer({ onApproval, onAddAttachment, onRemoveAttachment, + onAddContextAttachment, + onRemoveContextAttachment, onSearchAttachments, onExecutionModeChange, onInteractionModeChange, @@ -662,6 +751,7 @@ export function AgentChatComposer({ onDispatchSteerInline, onDispatchSteerInterrupt, onOpenAiSettings, + onOpenLinearSettings, sessionId, parallelChatMode = false, onParallelChatModeChange, @@ -710,6 +800,8 @@ export function AgentChatComposer({ codexFastMode?: boolean; draft: string; attachments: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; + pinnedLinearIssue?: LaneLinearIssue | null; pendingInput: PendingInputRequest | null; approvalResponding?: boolean; turnActive: boolean; @@ -746,6 +838,8 @@ export function AgentChatComposer({ onApproval: (decision: AgentChatApprovalDecision, responseText?: string | null) => void; onAddAttachment: (attachment: AgentChatFileRef) => void; onRemoveAttachment: (path: string) => void; + onAddContextAttachment?: (attachment: AgentChatContextAttachment) => void; + onRemoveContextAttachment?: (key: string) => void; onSearchAttachments: (query: string) => Promise<AgentChatFileRef[]>; onExecutionModeChange?: (mode: AgentChatExecutionMode) => void; onInteractionModeChange?: (mode: AgentChatInteractionMode) => void; @@ -777,6 +871,7 @@ export function AgentChatComposer({ onDispatchSteerInline?: (steerId: string) => void; onDispatchSteerInterrupt?: (steerId: string) => void; onOpenAiSettings?: () => void; + onOpenLinearSettings?: () => void; sessionId?: string | null; parallelChatMode?: boolean; onParallelChatModeChange?: (enabled: boolean) => void; @@ -826,6 +921,8 @@ export function AgentChatComposer({ const [attachmentResults, setAttachmentResults] = useState<AgentChatFileRef[]>([]); const [attachmentCursor, setAttachmentCursor] = useState(0); const [attachError, setAttachError] = useState<string | null>(null); + const [issueContextMenuOpen, setIssueContextMenuOpen] = useState(false); + const [linearIssuePickerOpen, setLinearIssuePickerOpen] = useState(false); const [selectedIosContextId, setSelectedIosContextId] = useState<string | null>(null); const [selectedAppControlContextId, setSelectedAppControlContextId] = useState<string | null>(null); const [selectedBuiltInBrowserContextId, setSelectedBuiltInBrowserContextId] = useState<string | null>(null); @@ -837,6 +934,7 @@ export function AgentChatComposer({ const claudeModePickerRef = useRef<HTMLDivElement | null>(null); const [codexPresetPickerOpen, setCodexPresetPickerOpen] = useState(false); const codexPresetPickerRef = useRef<HTMLDivElement | null>(null); + const issueContextButtonRef = useRef<HTMLButtonElement | null>(null); const [dragActive, setDragActive] = useState(false); const [commandMenuTrigger, setCommandMenuTrigger] = useState<{ type: "at" | "slash"; query: string; cursorIndex: number } | null>(null); const [commandMenuAnchor, setCommandMenuAnchor] = useState<{ top: number; left: number } | null>(null); @@ -876,6 +974,8 @@ export function AgentChatComposer({ parallelChatMode, attachmentCount: attachments.length, }); + const contextAttachmentCount = contextAttachments.length; + const canAttachIssueContext = !composerInputLocked && typeof onAddContextAttachment === "function"; const resizeTextarea = useCallback(() => { if (useRichComposer) return; @@ -907,6 +1007,8 @@ export function AgentChatComposer({ useEffect(() => { if (!composerInputLocked) return; setAttachmentPickerOpen(false); + setIssueContextMenuOpen(false); + setLinearIssuePickerOpen(false); setCommandMenuTrigger(null); setDragActive(false); if (clipboardImagePasteFallbackTimerRef.current != null) { @@ -1518,6 +1620,28 @@ export function AgentChatComposer({ window.removeEventListener("keydown", handleKey); }; }, [claudeModePickerOpen]); + + useEffect(() => { + if (!issueContextMenuOpen) return; + const handleClick = (event: MouseEvent) => { + if (issueContextButtonRef.current?.contains(event.target as Node)) return; + const target = event.target as Element | null; + if (target?.closest?.("[data-issue-context-menu]")) return; + setIssueContextMenuOpen(false); + }; + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIssueContextMenuOpen(false); + } + }; + window.addEventListener("mousedown", handleClick); + window.addEventListener("keydown", handleKey); + return () => { + window.removeEventListener("mousedown", handleClick); + window.removeEventListener("keydown", handleKey); + }; + }, [issueContextMenuOpen]); + const codexCustomSummary = useMemo(() => { if (sp !== "codex" || codexPreset !== "custom") return null; if (ccsUse === "config-toml") { @@ -2218,7 +2342,7 @@ export function AgentChatComposer({ if (busy || parallelLaunchBusy) return; if (parallelModelSlots.length < 2) return; const hasPrompt = draft.trim().length > 0; - const hasAttachments = attachments.length > 0; + const hasAttachments = attachments.length > 0 || contextAttachmentCount > 0; if (!hasPrompt && !hasAttachments) return; onSubmit(); return; @@ -2238,15 +2362,20 @@ export function AgentChatComposer({ && onSubmitToCloud ) { const trimmed = draft.trim(); - if (!trimmed.length && !hasContextSelection) return; - void Promise.resolve(onSubmitToCloud(trimmed)).then((ok) => { + if (!trimmed.length && !hasContextSelection && contextAttachmentCount === 0) return; + const issueContextPrompt = buildChatContextAttachmentPrompt(contextAttachments); + const cloudPrompt = [ + issueContextPrompt || null, + trimmed || (issueContextPrompt ? "Use the attached issue context." : null), + ].filter((part): part is string => Boolean(part)).join("\n\n"); + void Promise.resolve(onSubmitToCloud(cloudPrompt)).then((ok) => { if (ok) onDraftChange(""); }); return; } - if (busy || !modelId || (!draft.trim().length && !hasContextSelection)) return; + if (busy || !modelId || (!draft.trim().length && !hasContextSelection && contextAttachmentCount === 0)) return; onSubmit(); - }, [appControlContextItems.length, attachments, builtInBrowserContextItems.length, busy, cursorCloudAvailable, cursorCloudCanLaunch, cursorCloudLaunchModeOpen, draft, iosElementContextItems.length, macosVmContextItems.length, modelId, onDraftChange, onSubmit, onSubmitToCloud, pendingInput, parallelChatMode, parallelLaunchBusy, parallelModelSlots.length]); + }, [appControlContextItems.length, attachments, builtInBrowserContextItems.length, busy, contextAttachmentCount, contextAttachments, cursorCloudAvailable, cursorCloudCanLaunch, cursorCloudLaunchModeOpen, draft, iosElementContextItems.length, macosVmContextItems.length, modelId, onDraftChange, onSubmit, onSubmitToCloud, pendingInput, parallelChatMode, parallelLaunchBusy, parallelModelSlots.length]); const pendingQuestionCount = getPendingInputQuestionCount(pendingInput); const showPendingInputOptionsHint = hasPendingInputOptions(pendingInput); @@ -2299,22 +2428,23 @@ export function AgentChatComposer({ const parallelReady = parallelChatMode && parallelModelSlots.length >= 2 - && (draft.trim().length > 0 || attachments.length > 0); + && (draft.trim().length > 0 || attachments.length > 0 || contextAttachmentCount > 0); const hasIosElementContext = iosElementContextItems.length > 0; const hasAppControlContext = appControlContextItems.length > 0; const hasBuiltInBrowserContext = builtInBrowserContextItems.length > 0; const hasMacosVmContext = macosVmContextItems.length > 0; - const singleReady = !parallelChatMode && Boolean(modelId) && (draft.trim().length > 0 || hasIosElementContext || hasAppControlContext || hasBuiltInBrowserContext || hasMacosVmContext); + const singleReady = !parallelChatMode && Boolean(modelId) && (draft.trim().length > 0 || hasIosElementContext || hasAppControlContext || hasBuiltInBrowserContext || hasMacosVmContext || contextAttachmentCount > 0); const sendEnabled = !busy && !parallelLaunchBusy && !composerInputLocked && (parallelReady || singleReady); function sendButtonTitle(): string { if (composerInputLocked) return composerInputLockMessage ?? "Resolve the pending request before sending."; if (parallelChatMode) { if (parallelModelSlots.length < 2) return "Add at least two models"; - if (draft.trim().length === 0 && attachments.length === 0) return "Add a message or at least one attachment"; + if (draft.trim().length === 0 && attachments.length === 0 && contextAttachmentCount === 0) return "Add a message or at least one attachment"; return "Send to all lanes"; } if (!modelId) return "Select a model first"; + if (!draft.trim().length && contextAttachmentCount > 0) return "Send attached issue context"; if (!draft.trim().length && hasAppControlContext) return "Send selected App Control context"; if (!draft.trim().length && hasIosElementContext) return "Send selected iOS context"; if (!draft.trim().length && hasBuiltInBrowserContext) return "Send selected browser context"; @@ -2326,9 +2456,72 @@ export function AgentChatComposer({ "m-3 mt-0 rounded-[var(--chat-radius-shell)]", layoutVariant === "grid-tile" ? "m-0" : "", ); + const issueContextMenu = issueContextMenuOpen && issueContextButtonRef.current ? createPortal( + <div + className="ade-chat-drawer-glass fixed z-[1000] overflow-hidden" + data-issue-context-menu="true" + role="menu" + aria-label="Attach issue context" + style={getIssueContextMenuStyle(issueContextButtonRef.current)} + > + <div className="border-b border-white/[0.04] px-3 py-2"> + <div className="font-sans text-[length:calc(var(--chat-font-size)*11/14)] font-semibold text-fg/80">Attach issue context</div> + </div> + <div className="p-1"> + <button + type="button" + className="ade-chat-drawer-row flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-left font-sans text-[length:calc(var(--chat-font-size)*11/14)] text-fg/75" + disabled={!canAttachIssueContext} + onClick={() => { + if (!canAttachIssueContext) return; + setIssueContextMenuOpen(false); + setLinearIssuePickerOpen(true); + }} + > + <span + className="flex h-6 w-6 shrink-0 items-center justify-center rounded" + style={{ background: LINEAR_BRAND.surfaceHover, color: LINEAR_BRAND.primaryBright }} + > + <LinearMark size={11} /> + </span> + <span className="min-w-0 flex-1"> + <span className="block font-medium">Linear issue</span> + <span className="block truncate text-[length:calc(var(--chat-font-size)*9/14)] text-muted-fg/45">Attach a ticket as chat context.</span> + </span> + </button> + <button + type="button" + className="flex w-full cursor-not-allowed items-center gap-2 rounded-lg px-3 py-2.5 text-left font-sans text-[length:calc(var(--chat-font-size)*11/14)] text-muted-fg/30" + disabled + > + <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/[0.04] text-muted-fg/35"> + <GithubLogo size={13} weight="fill" /> + </span> + <span className="min-w-0 flex-1"> + <span className="block font-medium">GitHub issue</span> + <span className="block truncate text-[length:calc(var(--chat-font-size)*9/14)] text-muted-fg/30">Coming later.</span> + </span> + </button> + </div> + </div>, + document.body, + ) : null; return ( <> + {issueContextMenu} + <LinearIssueContextDialog + open={linearIssuePickerOpen} + selectedIssue={contextAttachments[0]?.issue ?? null} + pinnedIssue={pinnedLinearIssue} + busy={busy || parallelLaunchBusy} + onOpenChange={setLinearIssuePickerOpen} + onAttach={(attachment) => { + onAddContextAttachment?.(attachment); + setLinearIssuePickerOpen(false); + }} + onOpenLinearSettings={onOpenLinearSettings} + /> <BorderBeam size="md" colorVariant={composerBeamVariant} @@ -2397,7 +2590,7 @@ export function AgentChatComposer({ ) ) : undefined} trays={ - attachments.length || attachError || selectedIosContext || selectedAppControlContext || selectedBuiltInBrowserContext || selectedMacosVmContext ? ( + attachments.length || contextAttachmentCount || attachError || selectedIosContext || selectedAppControlContext || selectedBuiltInBrowserContext || selectedMacosVmContext ? ( <div className="space-y-2 px-1 py-2"> {selectedMacosVmContext ? ( <div className="relative mx-3 grid grid-cols-[72px_minmax(0,1fr)] gap-2 rounded-md border border-violet-300/12 bg-black/20 p-2 pr-6"> @@ -2642,8 +2835,10 @@ export function AgentChatComposer({ ) : null} <ChatAttachmentTray attachments={attachments} + contextAttachments={contextAttachments} mode={surfaceMode} onRemove={onRemoveAttachment} + onRemoveContext={onRemoveContextAttachment} className="px-3 py-0" /> </div> @@ -2952,6 +3147,39 @@ export function AgentChatComposer({ <Paperclip className="h-3 w-3" size={14} weight="bold" /> </button> </SmartTooltip> + <SmartTooltip + content={{ + label: "Issue context", + description: canAttachIssueContext + ? "Attach a Linear ticket as context for this chat. GitHub issue attachment is coming later." + : composerInputLockMessage ?? "Resolve the pending request before adding issue context.", + }} + > + <button + type="button" + ref={issueContextButtonRef} + className={cn( + "relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-fg/35 transition-colors hover:bg-violet-500/[0.06] hover:text-violet-300/60", + issueContextMenuOpen && "bg-violet-500/[0.08] text-violet-200/80", + )} + disabled={!canAttachIssueContext} + onClick={() => { + if (!canAttachIssueContext) return; + setAttachmentPickerOpen(false); + setIssueContextMenuOpen((open) => !open); + }} + aria-label="Attach issue context" + aria-haspopup="menu" + aria-expanded={issueContextMenuOpen} + > + <Bug className="h-3 w-3" size={14} weight={contextAttachmentCount ? "fill" : "regular"} /> + {contextAttachmentCount ? ( + <span className="absolute -right-1 -top-1 inline-flex min-w-[14px] items-center justify-center rounded-full border border-violet-200/30 bg-violet-500 px-1 font-mono text-[8px] leading-[14px] text-white"> + {contextAttachmentCount} + </span> + ) : null} + </button> + </SmartTooltip> <SmartTooltip content={{ label: "Commands", description: "Open the slash-command picker for this chat.", shortcut: "/" }}> <button type="button" @@ -3084,7 +3312,7 @@ export function AgentChatComposer({ </button> </SmartTooltip> ) : null} - {(draft.trim().length > 0 || hasIosElementContext || hasAppControlContext || hasBuiltInBrowserContext) && !composerInputLocked ? ( + {(draft.trim().length > 0 || hasIosElementContext || hasAppControlContext || hasBuiltInBrowserContext || contextAttachmentCount > 0) && !composerInputLocked ? ( <SmartTooltip content={{ label: "Send steer message", description: "Queue this message for the running chat after the current turn finishes." }}> <button type="button" diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 70602fb4c..70f373cea 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -7,6 +7,7 @@ import { CaretDown, CaretLeft, CaretRight, + Bug, CloudArrowUp, Warning, Terminal, @@ -429,12 +430,14 @@ function UserMessageSendConfirmations({ if (event.deliveryState === "queued") return null; const attachments = event.attachments ?? []; + const contextAttachments = event.contextAttachments ?? []; const hasImage = attachments.some((a) => a.type === "image"); const hasFile = attachments.some((a) => a.type === "file"); + const hasIssueContext = contextAttachments.some((a) => a.type === "linear_issue"); const showFilesRow = hasImage || hasFile; const showSimRow = event.text.startsWith(IOS_SIMULATOR_CONTEXT_PREFIX); - if (!showFilesRow && !showSimRow) return null; + if (!showFilesRow && !showSimRow && !hasIssueContext) return null; const attachmentCount = attachments.length; const attachmentLabel = attachmentCount <= 1 ? "Attachment analyzed" : "Attachments analyzed"; @@ -469,6 +472,18 @@ function UserMessageSendConfirmations({ <span>Attachments from simulator analyzed</span> </motion.div> ) : null} + {hasIssueContext ? ( + <motion.div + className="flex items-center gap-1.5 font-sans text-[length:calc(var(--chat-font-size)*12/14)] italic text-emerald-400/80" + data-testid="user-message-issue-context-analyzed" + initial={{ opacity: 0, y: 2 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.15, ease: "easeOut" }} + > + <Bug size={12} weight="regular" className="shrink-0 text-emerald-400/85" aria-hidden /> + <span>Issue context analyzed</span> + </motion.div> + ) : null} </div> ); } @@ -1987,8 +2002,13 @@ function renderEvent( </div> ); })()} - {event.attachments?.length ? ( - <ChatAttachmentTray attachments={event.attachments} mode={options?.surfaceMode ?? "standard"} className="mt-1 px-0 py-0" /> + {event.attachments?.length || event.contextAttachments?.length ? ( + <ChatAttachmentTray + attachments={event.attachments ?? []} + contextAttachments={event.contextAttachments ?? []} + mode={options?.surfaceMode ?? "standard"} + className="mt-1 px-0 py-0" + /> ) : null} <UserMessageSendConfirmations event={event} /> </div> diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts index 61b4747a5..2501336bb 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts @@ -138,6 +138,17 @@ describe("parallel launch helpers", () => { expect(result.sendText).toBe("Please review the attached files."); }); + it("uses an issue-context prompt for context-only parallel launches", () => { + const result = buildParallelLaunchPrompt({ + text: "", + attachmentCount: 0, + contextAttachmentCount: 1, + }); + + expect(result.displayText).toBe("Use the attached issue context."); + expect(result.sendText).toBe("Use the attached issue context."); + }); + it("force-cleans transient lanes and refreshes lane state after rollback", async () => { const deleteLane = vi.fn() .mockResolvedValueOnce(undefined) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index ec45730fd..69503a85b 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -14,6 +14,7 @@ import { type AgentChatDroidPermissionMode, type AgentChatExecutionMode, type AgentChatEventEnvelope, + type AgentChatContextAttachment, type AgentChatFileRef, type AgentChatInteractionMode, type AiProviderConnectionStatus, @@ -32,12 +33,18 @@ import { type AppControlContextItem, type IosElementContextItem, type IosSimulatorDrawerMode, + type LaneLinearIssue, type AiSettingsStatus, type MacosVmContextItem, type MacosVmStatus, type TerminalSessionDetail, type TerminalToolType, } from "../../../shared/types"; +import { + makeLinearIssueContextAttachment, + mergeChatContextAttachments, + removeChatContextAttachment, +} from "../../../shared/chatContextAttachments"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { isProviderSlashCommandInput } from "../../../shared/chatSlashCommands"; import { @@ -1373,6 +1380,7 @@ export function parallelLaneModelSuffix(descriptor: ModelDescriptor | null | und export function buildParallelLaunchPrompt(args: { text: string; attachmentCount: number; + contextAttachmentCount?: number; }): { sendText: string; displayText: string } { const trimmed = args.text.trim(); let displayText = ""; @@ -1380,6 +1388,8 @@ export function buildParallelLaunchPrompt(args: { displayText = trimmed; } else if (args.attachmentCount > 0) { displayText = DEFAULT_PARALLEL_ATTACHMENT_REQUEST; + } else if ((args.contextAttachmentCount ?? 0) > 0) { + displayText = "Use the attached issue context."; } if (!displayText.length) { return { sendText: "", displayText: "" }; @@ -1609,6 +1619,8 @@ export function AgentChatPane({ isTileActive = true, isTileVisible = isTileActive, shouldAutofocusComposer = false, + initialLinearIssueContext = null, + onInitialLinearIssueContextConsumed, onSessionCreated, availableLanes, onLaneChange, @@ -1636,6 +1648,8 @@ export function AgentChatPane({ /** Visible grid tiles hydrate transcripts even when they are not the focused tile. */ isTileVisible?: boolean; shouldAutofocusComposer?: boolean; + initialLinearIssueContext?: LaneLinearIssue | null; + onInitialLinearIssueContextConsumed?: () => void; onSessionCreated?: (session: AgentChatSession) => void | Promise<void>; /** Available lanes for the lane selector in empty state (full `LaneSummary` includes `branchRef` for branch sublines in the menu). */ availableLanes?: Array<{ id: string; name: string; color?: string | null; branchRef?: string | null }>; @@ -1659,6 +1673,9 @@ export function AgentChatPane({ const openAiProvidersSettings = useCallback(() => { navigate("/settings?tab=ai#ai-providers"); }, [navigate]); + const openLinearSettings = useCallback(() => { + navigate("/settings?tab=integrations&integration=linear"); + }, [navigate]); const setWorkViewState = useAppStore((s) => s.setWorkViewState); const setLaneWorkViewState = useAppStore((s) => s.setLaneWorkViewState); const refreshLanesStore = useAppStore((s) => s.refreshLanes); @@ -1666,6 +1683,10 @@ export function AgentChatPane({ if (!laneId) return null; return s.lanes.find((l) => l.id === laneId)?.color ?? null; }); + const pinnedLinearIssue = useAppStore((s) => { + if (!laneId) return null; + return s.lanes.find((l) => l.id === laneId)?.linearIssue ?? null; + }); const lockedSingleSessionMode = Boolean(lockSessionId && hideSessionTabs); const forceDraft = forceDraftMode || forceNewSession; const preferDraftStart = !lockSessionId && !initialSessionId && !forceNewSession; @@ -1740,6 +1761,7 @@ export function AgentChatPane({ : null, ); const [attachments, setAttachments] = useState<AgentChatFileRef[]>([]); + const [contextAttachments, setContextAttachments] = useState<AgentChatContextAttachment[]>([]); const [sdkSlashCommands, setSdkSlashCommands] = useState<import("../../../shared/types").AgentChatSlashCommand[]>([]); const [sendOnEnter, setSendOnEnter] = useState(true); const [draft, setDraft] = useState(""); @@ -3471,11 +3493,14 @@ export function AgentChatPane({ useEffect(() => { setAttachments([]); + setContextAttachments([]); setPromptSuggestion(null); setHandoffOpen(false); setHandoffBusy(false); setOptimisticOutgoingMessage(null); - }, [selectedSessionId]); + // Also clear when the active lane changes — otherwise issue context staged + // in draft mode would persist after switching lanes. + }, [selectedSessionId, laneId]); useEffect(() => { optimisticOutgoingMessageRef.current = optimisticOutgoingMessage; @@ -3959,6 +3984,29 @@ export function AgentChatPane({ setBuiltInBrowserContextItems((prev) => prev.filter((entry) => getBuiltInBrowserContextAttachmentPath(entry) !== attachmentPath)); }, []); + const addContextAttachment = useCallback((attachment: AgentChatContextAttachment) => { + setContextAttachments((prev) => mergeChatContextAttachments(prev, [attachment])); + }, []); + + const removeContextAttachment = useCallback((key: string) => { + setContextAttachments((prev) => removeChatContextAttachment(prev, key)); + }, []); + + const consumedInitialLinearIssueContextRef = useRef<string | null>(null); + useEffect(() => { + if (!initialLinearIssueContext) { + consumedInitialLinearIssueContextRef.current = null; + return; + } + const key = initialLinearIssueContext.id; + if (consumedInitialLinearIssueContextRef.current === key) return; + consumedInitialLinearIssueContextRef.current = key; + setContextAttachments((prev) => mergeChatContextAttachments(prev, [ + makeLinearIssueContextAttachment(initialLinearIssueContext, "lane_link"), + ])); + onInitialLinearIssueContextConsumed?.(); + }, [initialLinearIssueContext, onInitialLinearIssueContextConsumed]); + const currentNativeControls = useMemo<NativeControlState>(() => ({ interactionMode, claudePermissionMode, @@ -4309,7 +4357,7 @@ export function AgentChatPane({ if (isParallelLaunch) { const text = draft.trim(); - if ((!text.length && attachments.length === 0) || !laneId || !projectRoot) return; + if ((!text.length && attachments.length === 0 && contextAttachments.length === 0) || !laneId || !projectRoot) return; if (parallelModelSlots.length < 2) { setError("Add at least two models for a parallel launch."); return; @@ -4331,6 +4379,7 @@ export function AgentChatPane({ const draftSnapshot = draft; const attachmentsSnapshot = [...attachments]; + const contextAttachmentsSnapshot = [...contextAttachments]; submitInFlightRef.current = true; setParallelLaunchBusy(true); setParallelLaunchStatus("Naming lanes…"); @@ -4340,13 +4389,15 @@ export function AgentChatPane({ const sessionByLane = new Map<string, string>(); try { let namingSeed = text; - if (!text.length && attachmentsSnapshot.length) { + if (!text.length && (attachmentsSnapshot.length || contextAttachmentsSnapshot.length)) { const imageCount = attachmentsSnapshot.filter((a) => a.type === "image").length; const fileCount = attachmentsSnapshot.filter((a) => a.type === "file").length; + const issueCount = contextAttachmentsSnapshot.filter((a) => a.type === "linear_issue").length; namingSeed = [ "Parallel attachment task", imageCount ? `${imageCount} image${imageCount === 1 ? "" : "s"}` : null, fileCount ? `${fileCount} file${fileCount === 1 ? "" : "s"}` : null, + issueCount ? `${issueCount} issue${issueCount === 1 ? "" : "s"}` : null, ].filter(Boolean).join(" · "); } const baseName = await window.ade.agentChat.suggestLaneName({ @@ -4388,6 +4439,7 @@ export function AgentChatPane({ const { sendText, displayText: displayForSend } = buildParallelLaunchPrompt({ text, attachmentCount: attachmentsSnapshot.length, + contextAttachmentCount: contextAttachmentsSnapshot.length, }); setParallelLaunchStatus("Sending prompt to each lane…"); @@ -4410,6 +4462,7 @@ export function AgentChatPane({ text: sendText, displayText: displayForSend, attachments: attachmentsSnapshot, + contextAttachments: contextAttachmentsSnapshot, reasoningEffort: slot.reasoningEffort, executionMode: slot.executionMode, interactionMode: provider === "claude" ? slot.interactionMode : null, @@ -4422,6 +4475,7 @@ export function AgentChatPane({ sessionId, text: sendText, ...(attachmentsSnapshot.length ? { attachments: attachmentsSnapshot } : {}), + ...(contextAttachmentsSnapshot.length ? { contextAttachments: contextAttachmentsSnapshot } : {}), }); } else { throw sendError; @@ -4457,6 +4511,7 @@ export function AgentChatPane({ setDraft(""); setAttachments([]); + setContextAttachments([]); setParallelChatMode(false); setParallelModelSlots([]); setParallelConfiguringIndex(null); @@ -4488,6 +4543,7 @@ export function AgentChatPane({ } setDraft((current) => (current.trim().length ? current : draftSnapshot)); setAttachments((current) => (current.length ? current : attachmentsSnapshot)); + setContextAttachments((current) => (current.length ? current : contextAttachmentsSnapshot)); setError(formatParallelLaunchFailureMessage({ launchError: message, cleanupIssues, @@ -4506,6 +4562,7 @@ export function AgentChatPane({ const appControlContextSnapshot = [...appControlContextItems]; const builtInBrowserContextSnapshot = [...builtInBrowserContextItems]; const macosVmContextSnapshot = [...macosVmContextItems]; + const contextAttachmentsSnapshot = [...contextAttachments]; const iosContextPrefix = formatIosElementContextForPrompt(iosContextSnapshot); const appControlContextPrefix = formatAppControlContextForPrompt(appControlContextSnapshot); const builtInBrowserContextPrefix = formatBuiltInBrowserContextForPrompt(builtInBrowserContextSnapshot); @@ -4516,7 +4573,7 @@ export function AgentChatPane({ const macosVmContextDisplayChips = formatMacosVmContextChipsForDisplay(macosVmContextSnapshot); const visualContextPrefix = [iosContextPrefix, appControlContextPrefix, builtInBrowserContextPrefix, macosVmContextPrefix].filter(Boolean).join("\n"); const visualContextDisplayChips = [iosContextDisplayChips, appControlContextDisplayChips, builtInBrowserContextDisplayChips, macosVmContextDisplayChips].filter(Boolean).join(" "); - if ((!text.length && !visualContextPrefix.length) || !laneId) return; + if ((!text.length && !visualContextPrefix.length && !contextAttachmentsSnapshot.length) || !laneId) return; const pendingNativeControlUpdate = pendingNativeControlUpdateRef.current; if (selectedSessionId && pendingNativeControlUpdate?.sessionId === selectedSessionId) { try { @@ -4543,16 +4600,22 @@ export function AgentChatPane({ setDraft(""); draftsPerSessionRef.current.delete(selectedSessionId); setAttachments([]); + setContextAttachments([]); try { const automaticMacosVmContextPrefix = await buildAutomaticMacosVmContextForPrompt(laneId); let justCreatedSession = false; const finalTextPrefix = [automaticMacosVmContextPrefix, visualContextPrefix].filter(Boolean).join("\n"); - const finalText = finalTextPrefix ? `${finalTextPrefix}${text}` : text; + let finalText = finalTextPrefix ? `${finalTextPrefix}${text}` : text; + if (!finalText.trim().length && contextAttachmentsSnapshot.length) { + finalText = "Use the attached issue context."; + } const finalDisplayText = visualContextDisplayChips ? text.length ? `${visualContextDisplayChips} ${text}` : visualContextDisplayChips - : text; + : text.length + ? text + : "Attached issue context"; let sessionId = selectedSessionId; const shouldPromoteLightSession = shouldPromoteSessionForComputerUse(selectedSession); @@ -4565,6 +4628,7 @@ export function AgentChatPane({ && selectedSession?.provider === "codex" && (selectedSession.codexFastMode === true) !== codexFastMode; const selectedAttachments = isLiteralSlashCommand ? [] : attachmentsSnapshot; + const selectedContextAttachments = isLiteralSlashCommand ? [] : contextAttachmentsSnapshot; const optimisticEnvelope = (nextSessionId: string): AgentChatEventEnvelope => ({ sessionId: nextSessionId, timestamp: new Date().toISOString(), @@ -4572,6 +4636,7 @@ export function AgentChatPane({ type: "user_message", text: finalDisplayText || finalText, ...(selectedAttachments.length ? { attachments: selectedAttachments } : {}), + ...(selectedContextAttachments.length ? { contextAttachments: selectedContextAttachments } : {}), deliveryState: "queued", }, }); @@ -4620,6 +4685,7 @@ export function AgentChatPane({ sessionId, text: finalText, ...(selectedAttachments.length ? { attachments: selectedAttachments } : {}), + ...(selectedContextAttachments.length ? { contextAttachments: selectedContextAttachments } : {}), }); } else { try { @@ -4629,6 +4695,7 @@ export function AgentChatPane({ text: finalText, displayText: finalDisplayText || "Selected visual app context", attachments: selectedAttachments, + contextAttachments: selectedContextAttachments, reasoningEffort, executionMode: launchModeEditable ? executionMode : null, interactionMode: sessionProvider === "claude" ? interactionMode : null, @@ -4645,6 +4712,7 @@ export function AgentChatPane({ sessionId, text: finalText, ...(selectedAttachments.length ? { attachments: selectedAttachments } : {}), + ...(selectedContextAttachments.length ? { contextAttachments: selectedContextAttachments } : {}), }); } else { throw sendError; @@ -4664,6 +4732,7 @@ export function AgentChatPane({ const message = submitError instanceof Error ? submitError.message : String(submitError); setDraft((current) => (current.trim().length ? current : draftSnapshot)); setAttachments((current) => (current.length ? current : attachmentsSnapshot)); + setContextAttachments((current) => (current.length ? current : contextAttachmentsSnapshot)); setIosElementContextItems((current) => (current.length ? current : iosContextSnapshot)); setAppControlContextItems((current) => (current.length ? current : appControlContextSnapshot)); setBuiltInBrowserContextItems((current) => (current.length ? current : builtInBrowserContextSnapshot)); @@ -4687,6 +4756,7 @@ export function AgentChatPane({ busy, codexFastMode, createSession, + contextAttachments, draft, executionMode, hasComputerUseSelectionChanged, @@ -5544,6 +5614,7 @@ export function AgentChatPane({ setSelectedSessionId(null); setDraft(""); setAttachments([]); + setContextAttachments([]); }} > <Plus size={10} weight="bold" /> @@ -5590,6 +5661,8 @@ export function AgentChatPane({ codexFastMode={codexFastMode} draft={draft} attachments={attachments} + contextAttachments={contextAttachments} + pinnedLinearIssue={pinnedLinearIssue} pendingInput={pendingInput?.request ?? null} approvalResponding={pendingInput ? respondingApprovalIds.has(pendingInput.itemId) : false} turnActive={turnActive} @@ -5641,6 +5714,7 @@ export function AgentChatPane({ onRemoveBuiltInBrowserContext={removeBuiltInBrowserContext} onRemoveMacosVmContext={removeMacosVmContext} onOpenAiSettings={openAiProvidersSettings} + onOpenLinearSettings={openLinearSettings} onModelChange={(nextModelId) => { if (selectedSessionModelId && effectiveAvailableModelIds.length && !effectiveAvailableModelIds.includes(nextModelId)) { return; @@ -5736,6 +5810,8 @@ export function AgentChatPane({ }} onAddAttachment={addAttachment} onRemoveAttachment={removeAttachment} + onAddContextAttachment={addContextAttachment} + onRemoveContextAttachment={removeContextAttachment} onSearchAttachments={searchAttachments} onClearEvents={() => { if (selectedSessionId) { diff --git a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx index d29a5b371..26fc5982f 100644 --- a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx @@ -2,8 +2,42 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { makeLinearIssueContextAttachment } from "../../../shared/chatContextAttachments"; +import type { LaneLinearIssue } from "../../../shared/types"; import { ChatAttachmentTray } from "./ChatAttachmentTray"; +function makeIssue(overrides: Partial<LaneLinearIssue> = {}): LaneLinearIssue { + return { + id: "issue-1", + identifier: "ADE-123", + title: "Connect chat context to Linear", + description: null, + url: "https://linear.app/ade/issue/ADE-123/connect-chat-context-to-linear", + projectId: "project-1", + projectSlug: "ade", + projectName: "ADE", + teamId: "team-1", + teamKey: "ADE", + teamName: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 2, + priorityLabel: "high", + labels: ["desktop"], + assigneeId: "user-1", + assigneeName: "Arul", + creatorId: null, + creatorName: null, + dueDate: null, + estimate: null, + branchName: "ade-123-connect-chat-context-to-linear", + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + ...overrides, + }; +} + describe("ChatAttachmentTray", () => { const getImageDataUrl = vi.fn(); const writeClipboardImage = vi.fn(); @@ -74,4 +108,26 @@ describe("ChatAttachmentTray", () => { expect(screen.getByText("context.txt")).toBeTruthy(); expect(screen.queryByRole("button", { name: "Open context.txt" })).toBeNull(); }); + + it("renders removable Linear issue context chips", () => { + const onRemoveContext = vi.fn(); + const contextAttachment = makeLinearIssueContextAttachment(makeIssue(), "manual"); + + render( + <ChatAttachmentTray + attachments={[]} + contextAttachments={[contextAttachment]} + mode="standard" + onRemoveContext={onRemoveContext} + />, + ); + + expect(screen.getByTestId("linear-issue-context-chip")).toBeTruthy(); + expect(screen.getByText("ADE-123")).toBeTruthy(); + expect(screen.getByText("Connect chat context to Linear")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Remove ADE-123" })); + + expect(onRemoveContext).toHaveBeenCalledWith("linear:issue-1"); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx index b963007dc..4f5e57a08 100644 --- a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx @@ -1,8 +1,10 @@ import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; import { createPortal } from "react-dom"; import { Copy, File, Image, X } from "@phosphor-icons/react"; -import type { AgentChatFileRef, ChatSurfaceMode } from "../../../shared/types"; +import type { AgentChatContextAttachment, AgentChatFileRef, ChatSurfaceMode } from "../../../shared/types"; +import { chatContextAttachmentKey } from "../../../shared/chatContextAttachments"; import { cn } from "../ui/cn"; +import { LinearMark, LINEAR_BRAND } from "../lanes/linearBrand"; function attachmentName(path: string): string { // Split on both POSIX and Windows separators so a Windows path @@ -12,6 +14,73 @@ function attachmentName(path: string): string { return segments.pop() || path; } +function LinearIssueContextChip({ + attachment, + onRemove, +}: { + attachment: AgentChatContextAttachment; + onRemove?: (key: string) => void; +}) { + const issue = attachment.issue; + const projectLabel = issue.projectName?.trim() || issue.projectSlug || issue.teamKey || null; + const title = [ + attachment.issue.identifier, + attachment.issue.title, + projectLabel, + attachment.issue.stateName, + ].filter(Boolean).join(" - "); + + return ( + <span + className={cn( + "ade-liquid-glass-pill group inline-flex max-w-full items-center gap-2 rounded-[var(--chat-radius-pill)] border px-2.5 py-1.5 text-[10px] transition-colors", + )} + style={{ + borderColor: LINEAR_BRAND.borderSubtle, + background: LINEAR_BRAND.surface, + color: LINEAR_BRAND.text, + }} + title={title} + data-testid="linear-issue-context-chip" + > + <span + className="flex h-4 w-4 shrink-0 items-center justify-center rounded" + style={{ background: LINEAR_BRAND.surfaceHover, color: LINEAR_BRAND.primaryBright }} + > + <LinearMark size={9} /> + </span> + <span + className="shrink-0 rounded font-mono text-[10px] font-semibold" + style={{ background: "rgba(255,255,255,0.08)", color: LINEAR_BRAND.text, padding: "1px 4px" }} + > + {attachment.issue.identifier} + </span> + <span className="min-w-0 max-w-[240px] truncate font-sans text-[11px] font-medium text-fg/90"> + {attachment.issue.title} + </span> + {projectLabel ? ( + <span + className="hidden shrink-0 rounded font-mono text-[9px] sm:inline" + style={{ background: "rgba(255,255,255,0.05)", color: LINEAR_BRAND.textMuted, padding: "1px 4px" }} + > + {projectLabel} + </span> + ) : null} + {onRemove ? ( + <button + type="button" + className="rounded-full text-current/55 transition-colors hover:bg-white/[0.06] hover:text-current" + title={`Remove ${attachment.issue.identifier}`} + aria-label={`Remove ${attachment.issue.identifier}`} + onClick={() => onRemove(chatContextAttachmentKey(attachment))} + > + <X size={10} weight="bold" /> + </button> + ) : null} + </span> + ); +} + function ImageAttachmentPreview({ attachment, toneClassName, @@ -244,16 +313,20 @@ function ImageLightbox({ export function ChatAttachmentTray({ attachments, + contextAttachments = [], mode, onRemove, + onRemoveContext, className, }: { attachments: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; mode: ChatSurfaceMode; onRemove?: (path: string) => void; + onRemoveContext?: (key: string) => void; className?: string; }) { - if (!attachments.length) return null; + if (!attachments.length && !contextAttachments.length) return null; let chipTone: string; switch (mode) { @@ -273,6 +346,13 @@ export function ChatAttachmentTray({ return ( <div className={cn("flex flex-wrap items-center gap-2 px-4 py-3", className)}> + {contextAttachments.map((attachment) => ( + <LinearIssueContextChip + key={chatContextAttachmentKey(attachment)} + attachment={attachment} + onRemove={onRemoveContext} + /> + ))} {attachments.map((attachment) => { if (attachment.type === "image") { return ( diff --git a/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx index d26e0a634..7eef9ac5e 100644 --- a/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx @@ -11,7 +11,7 @@ import type { TurnDiffFile, TurnDiffSummary, } from "../../../shared/types"; -import { MonacoDiffView } from "../lanes/MonacoDiffView"; +import { AdeDiffViewer } from "../shared/AdeDiffViewer"; import { cn } from "../ui/cn"; import { BottomDrawerSection } from "./BottomDrawerSection"; @@ -135,7 +135,7 @@ function renderDiffPane({ if (activeDiff) { return ( <div className="h-full min-h-[200px]" style={{ maxHeight: 400 }}> - <MonacoDiffView diff={activeDiff} editable={false} theme="dark" className="h-full rounded-none border-0" /> + <AdeDiffViewer diff={activeDiff} editable={false} theme="dark" compact showToolbar={false} className="h-full rounded-none border-0" /> </div> ); } diff --git a/apps/desktop/src/renderer/components/files/FilesExplorer.tsx b/apps/desktop/src/renderer/components/files/FilesExplorer.tsx new file mode 100644 index 000000000..0cf05739f --- /dev/null +++ b/apps/desktop/src/renderer/components/files/FilesExplorer.tsx @@ -0,0 +1,492 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { + CaretDown as ChevronDown, + CaretRight as ChevronRight, + FilePlus as FilePlus2, + Folder, + FolderOpen, + FolderPlus, + MagnifyingGlass as Search, + TextAlignLeft, + X, +} from "@phosphor-icons/react"; +import type { FileTreeNode } from "../../../shared/types"; +import { arePathsEqual, normalizePath } from "../../lib/pathUtils"; +import { modifierKeyLabel } from "../../lib/platform"; +import { COLORS, LABEL_STYLE, MONO_FONT, outlineButton } from "../lanes/laneDesignTokens"; +import { SmartTooltip } from "../ui/SmartTooltip"; +import { changeStatusColor, changeStatusLabel, changeStatusTitle, getFileIcon } from "./filePresentation"; + +const ROW_HEIGHT = 26; + +type InlineRenameRequest = { + path: string; + nonce: number; +} | null; + +type ExplorerRow = { + node: FileTreeNode; + level: number; +}; + +export type FilesExplorerContextMenuEvent = { + x: number; + y: number; + nodePath: string; + nodeType: "file" | "directory"; +}; + +export type FilesExplorerProps = { + tree: FileTreeNode[]; + expanded: Set<string>; + selectedNodePath: string | null; + activeTabPath: string | null; + activeContextDir: string; + workspaceComparisonRoot: string | null; + searchQuery: string; + inlineRenameRequest: InlineRenameRequest; + onSearchQueryChange: (value: string) => void; + onOpenQuickOpen: () => void; + onOpenContentSearch: () => void; + onCreateFile: (basePath: string) => void; + onCreateDirectory: (basePath: string) => void; + onToggleDirectory: (path: string, isExpanded: boolean, hasLoadedChildren: boolean) => void; + onOpenFile: (path: string) => void; + onSelectNode: (path: string) => void; + onContextMenu: (event: FilesExplorerContextMenuEvent) => void; + onRenamePath: (sourcePath: string, destinationPath: string) => Promise<void>; + onInlineRenameSettled: () => void; +}; + +function parentDirOfPath(filePath: string): string { + const normalized = normalizePath(filePath); + if (!normalized) return ""; + const idx = normalized.lastIndexOf("/"); + if (idx <= 0) return ""; + return normalized.slice(0, idx); +} + +function destinationPathForName(sourcePath: string, nextName: string): string { + const parent = parentDirOfPath(sourcePath); + return parent ? `${parent}/${nextName}` : nextName; +} + +function matchesQuery(node: FileTreeNode, query: string): boolean { + if (!query) return true; + const haystack = `${node.name} ${node.path}`.toLowerCase(); + return query.split(/\s+/).filter(Boolean).every((part) => haystack.includes(part)); +} + +function flattenVisibleRows(args: { + nodes: FileTreeNode[]; + expanded: Set<string>; + query: string; + level?: number; +}): ExplorerRow[] { + const level = args.level ?? 0; + const rows: ExplorerRow[] = []; + const query = args.query.trim().toLowerCase(); + + for (const node of args.nodes) { + const children = node.children ?? []; + if (!query) { + rows.push({ node, level }); + if (node.type === "directory" && args.expanded.has(node.path) && children.length) { + rows.push(...flattenVisibleRows({ nodes: children, expanded: args.expanded, query, level: level + 1 })); + } + continue; + } + + const childRows = children.length + ? flattenVisibleRows({ nodes: children, expanded: args.expanded, query, level: level + 1 }) + : []; + if (matchesQuery(node, query) || childRows.length > 0) { + rows.push({ node, level }); + rows.push(...childRows); + } + } + + return rows; +} + +export function FilesExplorer({ + tree, + expanded, + selectedNodePath, + activeTabPath, + activeContextDir, + workspaceComparisonRoot, + searchQuery, + inlineRenameRequest, + onSearchQueryChange, + onOpenQuickOpen, + onOpenContentSearch, + onCreateFile, + onCreateDirectory, + onToggleDirectory, + onOpenFile, + onSelectNode, + onContextMenu, + onRenamePath, + onInlineRenameSettled, +}: FilesExplorerProps) { + const scrollRef = useRef<HTMLDivElement | null>(null); + const renameInputRef = useRef<HTMLInputElement | null>(null); + const renameSubmittingRef = useRef(false); + const [renamingPath, setRenamingPath] = useState<string | null>(null); + const [renameValue, setRenameValue] = useState(""); + const [renameError, setRenameError] = useState<string | null>(null); + const rows = useMemo( + () => flattenVisibleRows({ nodes: tree, expanded, query: searchQuery }), + [tree, expanded, searchQuery], + ); + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 14, + }); + + useEffect(() => { + if (!inlineRenameRequest?.path) return; + const target = rows.find((row) => arePathsEqual(row.node.path, inlineRenameRequest.path, workspaceComparisonRoot)); + setRenamingPath(inlineRenameRequest.path); + setRenameValue(target?.node.name ?? inlineRenameRequest.path.split("/").pop() ?? inlineRenameRequest.path); + setRenameError(null); + onInlineRenameSettled(); + }, [inlineRenameRequest, onInlineRenameSettled, rows, workspaceComparisonRoot]); + + useEffect(() => { + if (!renamingPath) return; + const frame = window.requestAnimationFrame(() => { + renameInputRef.current?.focus(); + renameInputRef.current?.select(); + }); + return () => window.cancelAnimationFrame(frame); + }, [renamingPath]); + + const cancelRename = () => { + setRenamingPath(null); + setRenameValue(""); + setRenameError(null); + }; + + const submitRename = async () => { + if (!renamingPath) return; + if (renameSubmittingRef.current) return; + const nextName = renameValue.trim(); + if (!nextName) { + setRenameError("Name is required."); + return; + } + if (nextName === "." || nextName === "..") { + setRenameError("Name cannot be '.' or '..'."); + return; + } + if (nextName.includes("/") || nextName.includes("\\")) { + setRenameError("Use a file name, not a path."); + return; + } + const destinationPath = destinationPathForName(renamingPath, nextName); + if (arePathsEqual(destinationPath, renamingPath, workspaceComparisonRoot)) { + cancelRename(); + return; + } + try { + renameSubmittingRef.current = true; + await onRenamePath(renamingPath, destinationPath); + cancelRename(); + } catch (error) { + setRenameError(error instanceof Error ? error.message : String(error)); + } finally { + renameSubmittingRef.current = false; + } + }; + + return ( + <div className="flex h-full min-h-0 flex-col" style={{ background: COLORS.cardBg, borderRadius: 8 }}> + <div style={{ padding: "8px 10px", borderBottom: `1px solid ${COLORS.border}` }} data-tour="files.searchBar"> + <div className="relative flex items-center"> + <Search size={14} weight="regular" className="pointer-events-none absolute" style={{ left: 8, color: COLORS.textDim }} /> + <input + value={searchQuery} + onChange={(event) => onSearchQueryChange(event.target.value)} + aria-label="Filter paths" + placeholder="Filter paths" + style={{ + height: 30, + width: "100%", + padding: "0 28px 0 28px", + fontSize: 11, + fontFamily: MONO_FONT, + fontWeight: 500, + background: COLORS.recessedBg, + borderRadius: 8, + border: `1px solid ${COLORS.outlineBorder}`, + color: COLORS.textSecondary, + outline: "none", + }} + onFocus={(event) => { event.currentTarget.style.borderColor = COLORS.accent; }} + onBlur={(event) => { event.currentTarget.style.borderColor = COLORS.outlineBorder; }} + /> + {searchQuery.trim() ? ( + <button + type="button" + className="absolute" + style={{ right: 4, top: "50%", transform: "translateY(-50%)", display: "inline-flex", width: 18, height: 18, alignItems: "center", justifyContent: "center", background: "transparent", border: "none", color: COLORS.textMuted, cursor: "pointer" }} + onClick={() => onSearchQueryChange("")} + title="Clear filter" + aria-label="Clear path filter" + > + <X size={10} /> + </button> + ) : null} + </div> + <div className="mt-1.5 flex items-center justify-end gap-1.5"> + <SmartTooltip content={{ label: "Content search", description: "Search file contents in this workspace.", shortcut: `${modifierKeyLabel}+Shift+F` }}> + <button + type="button" + style={{ ...outlineButton({ height: 22, padding: "0 8px", fontSize: 9 }) }} + onClick={onOpenContentSearch} + onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} + onMouseLeave={(event) => { event.currentTarget.style.borderColor = COLORS.outlineBorder; event.currentTarget.style.color = COLORS.textSecondary; }} + > + <TextAlignLeft size={10} /> CONTENT + </button> + </SmartTooltip> + <SmartTooltip content={{ label: "Quick open", description: "Search and open any file in the project.", shortcut: `${modifierKeyLabel}+P` }}> + <button + type="button" + style={{ ...outlineButton({ height: 22, padding: "0 8px", fontSize: 9 }) }} + onClick={onOpenQuickOpen} + onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} + onMouseLeave={(event) => { event.currentTarget.style.borderColor = COLORS.outlineBorder; event.currentTarget.style.color = COLORS.textSecondary; }} + > + <Search size={10} /> QUICK OPEN + </button> + </SmartTooltip> + </div> + </div> + + <div className="flex shrink-0 items-center gap-1" style={{ padding: "6px 8px", borderBottom: `1px solid ${COLORS.border}` }}> + <SmartTooltip content={{ label: "New file", description: "Create a new file in the current directory." }}> + <button + type="button" + title="New file" + aria-label="New file" + style={{ ...outlineButton({ height: 24, padding: "0 6px", fontSize: 10 }) }} + onClick={() => onCreateFile(activeContextDir)} + onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} + onMouseLeave={(event) => { event.currentTarget.style.borderColor = COLORS.outlineBorder; event.currentTarget.style.color = COLORS.textSecondary; }} + > + <FilePlus2 size={12} weight="regular" /> + </button> + </SmartTooltip> + <SmartTooltip content={{ label: "New folder", description: "Create a new folder in the current directory." }}> + <button + type="button" + title="New folder" + aria-label="New folder" + style={{ ...outlineButton({ height: 24, padding: "0 6px", fontSize: 10 }) }} + onClick={() => onCreateDirectory(activeContextDir)} + onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} + onMouseLeave={(event) => { event.currentTarget.style.borderColor = COLORS.outlineBorder; event.currentTarget.style.color = COLORS.textSecondary; }} + > + <FolderPlus size={12} weight="regular" /> + </button> + </SmartTooltip> + <span className="ml-auto" style={{ ...LABEL_STYLE, fontSize: 9, color: COLORS.textDim }}> + {rows.length} rows + </span> + </div> + + {renameError ? ( + <div style={{ padding: "5px 10px", borderBottom: `1px solid ${COLORS.border}`, color: COLORS.danger, fontFamily: MONO_FONT, fontSize: 11 }}> + {renameError} + </div> + ) : null} + + <div ref={scrollRef} className="min-h-0 flex-1 overflow-auto" style={{ paddingTop: 4, paddingBottom: 4 }} data-tour="files.fileTree"> + {rows.length === 0 ? ( + <div style={{ padding: 12, fontFamily: MONO_FONT, fontSize: 11, color: COLORS.textMuted }}> + {searchQuery.trim() ? "No matching paths." : "No files."} + </div> + ) : ( + <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}> + {virtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index]; + if (!row) return null; + const { node, level } = row; + const isExpanded = expanded.has(node.path); + const isActive = (activeTabPath != null && arePathsEqual(activeTabPath, node.path, workspaceComparisonRoot)) + || (selectedNodePath != null && arePathsEqual(selectedNodePath, node.path, workspaceComparisonRoot)); + const statusColor = changeStatusColor(node.changeStatus ?? null); + const statusLabel = changeStatusLabel(node.changeStatus ?? null); + const fileIcon = node.type === "file" ? getFileIcon(node.name) : null; + const FileIcon = fileIcon?.icon; + const folderColor = isActive ? COLORS.accent : COLORS.textMuted; + const isRenaming = renamingPath != null && arePathsEqual(renamingPath, node.path, workspaceComparisonRoot); + const rowStyle: React.CSSProperties = { + height: ROW_HEIGHT, + paddingLeft: `${10 + level * 14}px`, + paddingRight: 8, + fontFamily: MONO_FONT, + fontSize: 11, + color: isActive ? COLORS.textPrimary : COLORS.textSecondary, + background: isActive ? COLORS.accentSubtle : "transparent", + border: "none", + borderLeft: isActive ? `2px solid ${COLORS.accent}` : "2px solid transparent", + cursor: isRenaming ? "text" : "pointer", + }; + const handleRowActivate = () => { + onSelectNode(node.path); + if (node.type === "directory") { + onToggleDirectory(node.path, isExpanded, Boolean(node.children)); + return; + } + onOpenFile(node.path); + }; + const handleRowContextMenu = (event: React.MouseEvent) => { + event.preventDefault(); + onSelectNode(node.path); + onContextMenu({ + x: event.clientX, + y: event.clientY, + nodePath: node.path, + nodeType: node.type, + }); + }; + const handleRowMouseEnter = (event: React.MouseEvent<HTMLElement>) => { + if (!isActive) event.currentTarget.style.background = COLORS.hoverBg; + }; + const handleRowMouseLeave = (event: React.MouseEvent<HTMLElement>) => { + if (!isActive) event.currentTarget.style.background = "transparent"; + }; + const rowContent = ( + <> + {level > 0 ? ( + <span className="pointer-events-none absolute inset-y-0 left-0"> + {Array.from({ length: level }).map((_, idx) => ( + <span + key={`${node.path}:guide:${idx}`} + className="absolute inset-y-0" + style={{ left: `${10 + idx * 14 + 5}px`, width: 1, background: "color-mix(in srgb, var(--color-border) 80%, transparent)" }} + /> + ))} + </span> + ) : null} + {node.type === "directory" ? ( + <> + {isExpanded + ? <ChevronDown size={12} weight="bold" style={{ color: folderColor, flexShrink: 0 }} /> + : <ChevronRight size={12} weight="bold" style={{ color: folderColor, flexShrink: 0 }} />} + {isExpanded + ? <FolderOpen size={14} weight="fill" style={{ color: folderColor, flexShrink: 0 }} /> + : <Folder size={14} weight="fill" style={{ color: folderColor, flexShrink: 0 }} />} + </> + ) : ( + <> + <span style={{ width: 12, flexShrink: 0 }} /> + {FileIcon + ? <FileIcon size={14} weight="regular" style={{ color: fileIcon?.color, flexShrink: 0 }} /> + : null} + </> + )} + {isRenaming ? ( + <input + ref={renameInputRef} + value={renameValue} + onChange={(event) => setRenameValue(event.target.value)} + onClick={(event) => event.stopPropagation()} + aria-label={`Rename ${node.name}`} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + cancelRename(); + } + if (event.key === "Enter") { + event.preventDefault(); + void submitRename(); + } + }} + onBlur={() => void submitRename()} + style={{ + minWidth: 0, + flex: 1, + height: 20, + padding: "0 4px", + fontFamily: MONO_FONT, + fontSize: 11, + color: COLORS.textPrimary, + background: COLORS.recessedBg, + border: `1px solid ${COLORS.accent}`, + borderRadius: 4, + outline: "none", + }} + /> + ) : ( + <span className="truncate">{node.name}</span> + )} + {node.type === "directory" && node.changeStatus ? ( + <span title={changeStatusTitle(node.changeStatus)} style={{ marginLeft: "auto", width: 6, height: 6, borderRadius: "50%", background: statusColor, flexShrink: 0 }} /> + ) : null} + {node.type === "file" && statusLabel ? ( + <span style={{ + marginLeft: "auto", + flexShrink: 0, + fontFamily: MONO_FONT, + fontSize: 9, + fontWeight: 700, + color: statusColor, + padding: "1px 5px", + background: `${statusColor}18`, + borderRadius: 4, + }} title={changeStatusTitle(node.changeStatus)}> + {statusLabel} + </span> + ) : null} + </> + ); + + return ( + <div + key={node.path} + className="absolute left-0 top-0 w-full" + style={{ height: virtualRow.size, transform: `translateY(${virtualRow.start}px)` }} + > + {isRenaming ? ( + <div + className="group relative flex w-full items-center gap-1.5 text-left transition-colors" + style={rowStyle} + onContextMenu={handleRowContextMenu} + onMouseEnter={handleRowMouseEnter} + onMouseLeave={handleRowMouseLeave} + title={node.path} + > + {rowContent} + </div> + ) : ( + <button + type="button" + className="group relative flex w-full items-center gap-1.5 text-left transition-colors" + style={rowStyle} + onClick={handleRowActivate} + onContextMenu={handleRowContextMenu} + onMouseEnter={handleRowMouseEnter} + onMouseLeave={handleRowMouseLeave} + title={node.path} + > + {rowContent} + </button> + )} + </div> + ); + })} + </div> + )} + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/components/files/FilesPage.test.tsx b/apps/desktop/src/renderer/components/files/FilesPage.test.tsx index a86e23223..f24036607 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.test.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.test.tsx @@ -23,11 +23,43 @@ type MockEditorInstance = { let latestMockEditor: MockEditorInstance | null = null; let createdMockEditors: MockEditorInstance[] = []; +const adeDiffViewerMock = vi.hoisted(() => ({ + getModifiedValue: vi.fn(() => ""), + revealLineInCenter: vi.fn(), +})); vi.mock("../lanes/MonacoDiffView", () => ({ MonacoDiffView: () => <div data-testid="monaco-diff" />, })); +vi.mock("../shared/AdeDiffViewer", async () => { + const React = await import("react"); + const AdeDiffViewer = React.forwardRef((_props: any, ref: any) => { + React.useImperativeHandle(ref, () => ({ + getModifiedValue: adeDiffViewerMock.getModifiedValue, + revealLineInCenter: adeDiffViewerMock.revealLineInCenter, + })); + return React.createElement("div", { "data-testid": "ade-diff-viewer" }); + }); + AdeDiffViewer.displayName = "MockAdeDiffViewer"; + return { AdeDiffViewer }; +}); + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ count, estimateSize }: { count: number; estimateSize: () => number }) => { + const size = estimateSize(); + return { + getTotalSize: () => count * size, + getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ + index, + key: index, + size, + start: index * size, + })), + }; + }, +})); + vi.mock("monaco-editor/esm/vs/editor/editor.worker?worker", () => ({ default: class MockEditorWorker {}, })); @@ -186,6 +218,33 @@ function renderFilesPage(initialState?: Record<string, unknown>) { ); } +function useLaneWorkspace(laneId = "lane-diff") { + useAppStore.setState({ + selectedLaneId: laneId, + lanes: [{ id: laneId, name: "Diff lane", branchRef: "refs/heads/feat/diff" }] as any, + }); + vi.mocked(window.ade.files.listWorkspaces).mockResolvedValue([ + { + id: "primary", + kind: "primary", + laneId: null, + name: "ADE", + branchRef: "refs/heads/main", + rootPath: projectRoot, + isReadOnlyByDefault: false, + }, + { + id: "lane-ws", + kind: "worktree", + laneId, + name: "Diff lane", + branchRef: "refs/heads/feat/diff", + rootPath: `${projectRoot}/.ade/worktrees/diff-lane`, + isReadOnlyByDefault: false, + }, + ]); +} + async function waitForEditorText(text: string) { await waitFor(() => { expect(screen.getByTestId("mock-monaco-editor").textContent).toContain(text); @@ -198,6 +257,15 @@ async function waitForFilesWatcherStartup() { }); } +async function switchOpenLaneFileToDiff(laneId: string) { + renderFilesPage({ + openFilePath: "src/index.ts", + laneId, + }); + await waitForEditorText("value = 1"); + fireEvent.click(screen.getByRole("button", { name: "CHANGES" })); +} + describe("FilesPage", () => { const originalAde = globalThis.window.ade; const originalConfirm = globalThis.window.confirm; @@ -208,6 +276,8 @@ describe("FilesPage", () => { resetStore(); latestMockEditor = null; createdMockEditors = []; + adeDiffViewerMock.getModifiedValue.mockClear(); + adeDiffViewerMock.revealLineInCenter.mockClear(); changeListener = null; currentTree = cloneTree(ignoredTree); fileContents = { @@ -290,6 +360,11 @@ describe("FilesPage", () => { modified: { exists: true, text: fileContents[path] ?? "" }, language: path.endsWith(".ts") ? "typescript" : "markdown", })), + getFilePatch: vi.fn(async ({ path, mode }: { path: string; mode: string }) => ({ + path, + mode, + patch: "", + })), }, app: { openPathInEditor: vi.fn(async () => undefined), @@ -335,7 +410,7 @@ describe("FilesPage", () => { }); }); - it("passes includeIgnored through quick open and search affordances", async () => { + it("filters loaded tree paths locally and keeps content search explicit", async () => { renderFilesPage({ openFilePath: ".ade/notes/project.md", preferPrimaryWorkspace: true, @@ -343,7 +418,18 @@ describe("FilesPage", () => { await waitForEditorText("# Project notes"); - fireEvent.change(screen.getByPlaceholderText("SEARCH FILES"), { + fireEvent.change(screen.getByPlaceholderText("Filter paths"), { + target: { value: "src" }, + }); + + expect(await screen.findByTitle("src")).toBeTruthy(); + await waitFor(() => { + expect(screen.queryByTitle(".ade")).toBeNull(); + }); + expect(window.ade.files.searchText).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: /content/i })); + fireEvent.change(screen.getByPlaceholderText(/Search file contents/i), { target: { value: "renderer" }, }); @@ -355,6 +441,7 @@ describe("FilesPage", () => { }); }); expect(await screen.findByText(".ade/notes/project.md:3:1")).toBeTruthy(); + fireEvent.keyDown(window, { key: "Escape" }); fireEvent.click(screen.getByText(/QUICK OPEN/i)); fireEvent.change(screen.getByPlaceholderText(/Type to search files/i), { @@ -491,6 +578,28 @@ describe("FilesPage", () => { expect((window.ade.files.readFile as any).mock.calls.some(([arg]: [{ path: string }]) => arg.path === "src/main.ts")).toBe(true); }); + it("renames the selected tree row inline with F2", async () => { + renderFilesPage({ preferPrimaryWorkspace: true }); + + fireEvent.click(await screen.findByTitle("src")); + const fileRow = await screen.findByTitle("src/index.ts"); + fireEvent.click(fileRow); + await waitForEditorText("value = 1"); + + fireEvent.keyDown(window, { key: "F2" }); + const renameInput = await screen.findByDisplayValue("index.ts"); + fireEvent.change(renameInput, { target: { value: "main.ts" } }); + fireEvent.keyDown(renameInput, { key: "Enter" }); + + await waitFor(() => { + expect(window.ade.files.rename).toHaveBeenCalledWith({ + workspaceId: "primary", + oldPath: "src/index.ts", + newPath: "src/main.ts", + }); + }); + }); + it("closes deleted tabs without crashing the page", async () => { renderFilesPage({ openFilePath: "src/index.ts", @@ -671,6 +780,69 @@ describe("FilesPage", () => { }); }); + it("renders the diff viewer mock with ref forwarding in diff view", async () => { + const laneId = "lane-diff"; + useLaneWorkspace(laneId); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(window.ade.diff.getFile).mockResolvedValue({ + path: "src/index.ts", + mode: "unstaged", + original: { exists: true, text: "export const value = 1;\n" }, + modified: { exists: true, text: "export const value = 2;\n" }, + language: "typescript", + }); + vi.mocked(window.ade.diff.getFilePatch).mockResolvedValue({ + path: "src/index.ts", + mode: "unstaged", + patch: "@@ -1 +1 @@\n-export const value = 1;\n+export const value = 2;\n", + }); + + try { + await switchOpenLaneFileToDiff(laneId); + + await screen.findByTestId("ade-diff-viewer"); + expect(consoleError.mock.calls.some(([message]) => + String(message).includes("Function components cannot be given refs") + )).toBe(false); + } finally { + consoleError.mockRestore(); + } + }); + + it("surfaces diff load failures when the patch fallback is empty", async () => { + const laneId = "lane-diff"; + useLaneWorkspace(laneId); + vi.mocked(window.ade.diff.getFile).mockRejectedValue(new Error("diff unavailable")); + vi.mocked(window.ade.diff.getFilePatch).mockResolvedValue({ + path: "src/index.ts", + mode: "unstaged", + patch: "", + }); + + await switchOpenLaneFileToDiff(laneId); + + expect(await screen.findByText("diff unavailable")).toBeTruthy(); + expect(screen.queryByTestId("ade-diff-viewer")).toBeNull(); + }); + + it("surfaces patch load failures when the inline diff has no changes", async () => { + const laneId = "lane-diff"; + useLaneWorkspace(laneId); + vi.mocked(window.ade.diff.getFile).mockResolvedValue({ + path: "src/index.ts", + mode: "unstaged", + original: { exists: true, text: "export const value = 1;\n" }, + modified: { exists: true, text: "export const value = 1;\n" }, + language: "typescript", + }); + vi.mocked(window.ade.diff.getFilePatch).mockRejectedValue(new Error("patch unavailable")); + + await switchOpenLaneFileToDiff(laneId); + + expect(await screen.findByText("patch unavailable")).toBeTruthy(); + expect(screen.queryByTestId("ade-diff-viewer")).toBeNull(); + }); + it("toggles editor theme from main Files header and persists", async () => { renderFilesPage({ openFilePath: "src/index.ts", diff --git a/apps/desktop/src/renderer/components/files/FilesPage.tsx b/apps/desktop/src/renderer/components/files/FilesPage.tsx index 917b9837d..e78276d0e 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.tsx @@ -1,27 +1,15 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Warning as AlertTriangle, - BookOpenText, ArrowSquareOut, - CaretDown as ChevronDown, - CaretRight as ChevronRight, - FileZip as FileArchive, - FileCss as FileBraces, - GearSix as FileCog, FileTs as FileCode2, - FileImage, - FilePlus as FilePlus2, FileText, - Folder, FolderOpen, - FolderPlus, FloppyDisk as Save, MagnifyingGlass as Search, Moon, Sparkle as Sparkles, Sun, - Terminal as TerminalSquare, - FileXls as FileSpreadsheet, X, } from "@phosphor-icons/react"; import { useLocation, useNavigate } from "react-router-dom"; @@ -34,10 +22,11 @@ import type { FilesSearchTextMatch, FilesWorkspace, FileDiff, + FilePatch, GitCommitSummary, LaneSummary, } from "../../../shared/types"; -import { MonacoDiffView, type MonacoDiffHandle } from "../lanes/MonacoDiffView"; +import { AdeDiffViewer, type AdeDiffViewerHandle } from "../shared/AdeDiffViewer"; import { useAppStore } from "../../state/appStore"; import { clearDirtyBuffersForWorkspace, replaceDirtyBuffersForWorkspace } from "../../lib/dirtyWorkspaceBuffers"; import { modifierKeyLabel, revealLabel } from "../../lib/platform"; @@ -47,6 +36,8 @@ import { COLORS, MONO_FONT, SANS_FONT, LABEL_STYLE, inlineBadge, outlineButton, import { cn } from "../ui/cn"; import { HelpChip } from "../onboarding/HelpChip"; import { SmartTooltip } from "../ui/SmartTooltip"; +import { FilesExplorer } from "./FilesExplorer"; +import { getFileIcon } from "./filePresentation"; type OpenTab = { path: string; content: string; @@ -401,73 +392,20 @@ function findItemByWorkspacePath<T extends { path: string }>( return items.find((item) => areWorkspacePathsEqual(item.path, targetPath, workspaceRoot)); } -const FILE_ICON_COLORS = { - code: "#38BDF8", // sky-400 - json: "#34D399", // emerald-400 - config: "#FB923C", // orange-400 - markdown: "#FBBF24", // amber-400 - style: "#818CF8", // indigo-400 - shell: "#2DD4BF", // teal-400 - image: "#E879F9", // fuchsia-400 - archive: "#FB7185", // rose-400 - spreadsheet: "#4ADE80", // green-400 - default: COLORS.textMuted, -} as const; - -function getFileIcon(fileName: string): { icon: React.ComponentType<any>; color: string } { - const lower = fileName.toLowerCase(); - const ext = lower.includes(".") ? lower.slice(lower.lastIndexOf(".")) : ""; - - if ( - ext === ".ts" || - ext === ".tsx" || - ext === ".mts" || - ext === ".cts" || - ext === ".js" || - ext === ".jsx" || - ext === ".mjs" || - ext === ".cjs" - ) { - return { icon: FileCode2, color: FILE_ICON_COLORS.code }; - } - if (ext === ".json" || ext === ".jsonc") { - return { icon: FileBraces, color: FILE_ICON_COLORS.json }; - } - if (ext === ".yml" || ext === ".yaml" || ext === ".toml" || ext === ".ini") { - return { icon: FileCog, color: FILE_ICON_COLORS.config }; - } - if (ext === ".md" || ext === ".mdx") { - return { icon: BookOpenText, color: FILE_ICON_COLORS.markdown }; - } - if (ext === ".css" || ext === ".scss" || ext === ".sass" || ext === ".less") { - return { icon: FileCode2, color: FILE_ICON_COLORS.style }; - } - if (ext === ".sh" || ext === ".bash" || ext === ".zsh" || ext === ".fish" || ext === ".ps1") { - return { icon: TerminalSquare, color: FILE_ICON_COLORS.shell }; - } - if (ext === ".png" || ext === ".jpg" || ext === ".jpeg" || ext === ".gif" || ext === ".webp" || ext === ".svg" || ext === ".ico") { - return { icon: FileImage, color: FILE_ICON_COLORS.image }; - } - if (ext === ".zip" || ext === ".tar" || ext === ".gz" || ext === ".tgz" || ext === ".rar" || ext === ".7z") { - return { icon: FileArchive, color: FILE_ICON_COLORS.archive }; - } - if (ext === ".csv" || ext === ".tsv" || ext === ".xls" || ext === ".xlsx") { - return { icon: FileSpreadsheet, color: FILE_ICON_COLORS.spreadsheet }; - } - return { icon: FileText, color: FILE_ICON_COLORS.default }; -} - function fileDiffHasRenderableChanges(diff: FileDiff): boolean { if (diff.original.exists !== diff.modified.exists) return true; if (diff.isBinary) return true; return diff.original.text !== diff.modified.text; } -function changeStatusColor(changeStatus: FileTreeNode["changeStatus"]): string { - if (changeStatus === "A") return COLORS.success; - if (changeStatus === "D") return COLORS.danger; - if (changeStatus === "M") return COLORS.warning; - return COLORS.textDim; +function filePatchHasRenderableChanges(patch: FilePatch | null | undefined): patch is FilePatch { + if (!patch) return false; + if (patch.isBinary) return true; + return patch.patch.trim().length > 0; +} + +function settledErrorMessage(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason); } const FILES_WORKSPACE_SELECT_LABEL_MAX_LEN = 52; @@ -595,7 +533,7 @@ export function FilesPage({ startColumn?: number; } | null>(null); const pendingRevealRef = useRef<{ mode: EditorViewMode; startLine: number; startColumn?: number; targetPath?: string } | null>(null); - const diffViewRef = useRef<MonacoDiffHandle | null>(null); + const diffViewRef = useRef<AdeDiffViewerHandle | null>(null); const treeRefreshStateRef = useRef<{ inFlight: boolean; queuedFull: boolean; @@ -617,7 +555,10 @@ export function FilesPage({ const [showQuickOpen, setShowQuickOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(initialSession?.searchQuery ?? ""); - const [searchResults, setSearchResults] = useState<FilesSearchTextMatch[]>([]); + const [showContentSearch, setShowContentSearch] = useState(false); + const [contentSearchQuery, setContentSearchQuery] = useState(""); + const [contentSearchResults, setContentSearchResults] = useState<FilesSearchTextMatch[]>([]); + const [inlineRenameRequest, setInlineRenameRequest] = useState<{ path: string; nonce: number } | null>(null); const [resolvedConflictKeys, setResolvedConflictKeys] = useState<Set<string>>(new Set()); const [textPrompt, setTextPrompt] = useState<TextPromptState | null>(null); @@ -639,7 +580,7 @@ export function FilesPage({ const activeTabPathRef = useRef<string | null>(null); const openTabsRef = useRef<OpenTab[]>([]); - const searchInputRef = useRef<HTMLInputElement | null>(null); + const contentSearchInputRef = useRef<HTMLInputElement | null>(null); const openInMenuRef = useRef<HTMLDivElement | null>(null); const setEditorHostRef = useCallback((node: HTMLDivElement | null) => { setEditorHostEl(node); @@ -1224,32 +1165,20 @@ export function FilesPage({ } }, [canEdit, laneIdForWorkspace, refreshTree, activeTabPath, openFile, workspaceComparisonRoot]); - const renamePath = useCallback(async (targetPath: string) => { + const renamePathTo = useCallback(async (targetPath: string, nextPath: string) => { if (!canEdit) { - setError("Editing is disabled for the current workspace."); - return; + throw new Error("Editing is disabled for the current workspace."); } if (!workspaceId) return; - const next = await requestTextInput({ - title: "Rename path", - message: "Enter the new path.", - defaultValue: targetPath, - confirmLabel: "Rename", - validate: (value) => { - if (!value) return "Path is required."; - if (value === targetPath) return "Path is unchanged."; - return null; - } - }); - if (!next || areWorkspacePathsEqual(next, targetPath, workspaceComparisonRoot)) return; - await window.ade.files.rename({ workspaceId, oldPath: targetPath, newPath: next }); + if (!nextPath || areWorkspacePathsEqual(nextPath, targetPath, workspaceComparisonRoot)) return; + await window.ade.files.rename({ workspaceId, oldPath: targetPath, newPath: nextPath }); setOpenTabs((prev) => prev.map((tab) => ( - areWorkspacePathsEqual(tab.path, targetPath, workspaceComparisonRoot) ? { ...tab, path: next } : tab + areWorkspacePathsEqual(tab.path, targetPath, workspaceComparisonRoot) ? { ...tab, path: nextPath } : tab ))); - if (areWorkspacePathsEqual(activeTabPath, targetPath, workspaceComparisonRoot)) setActiveTabPath(next); - setSelectedNodePath(next); + if (areWorkspacePathsEqual(activeTabPath, targetPath, workspaceComparisonRoot)) setActiveTabPath(nextPath); + setSelectedNodePath(nextPath); await refreshTree(); - }, [canEdit, workspaceId, requestTextInput, activeTabPath, refreshTree, workspaceComparisonRoot]); + }, [canEdit, workspaceId, activeTabPath, refreshTree, workspaceComparisonRoot]); const deletePath = useCallback(async (targetPath: string) => { if (!canEdit) { @@ -1568,6 +1497,7 @@ export function FilesPage({ if (e.key === "Escape") { setContextMenu(null); if (showQuickOpen) setShowQuickOpen(false); + if (showContentSearch) setShowContentSearch(false); if (openInMenuOpen) setOpenInMenuOpen(false); return; } @@ -1592,8 +1522,7 @@ export function FilesPage({ if (mod && e.shiftKey && e.key.toLowerCase() === "f") { e.preventDefault(); - searchInputRef.current?.focus(); - searchInputRef.current?.select(); + setShowContentSearch(true); return; } @@ -1603,13 +1532,13 @@ export function FilesPage({ e.preventDefault(); const target = selectedNodePath ?? activeTabPath; if (!target) return; - renamePath(target).catch((err) => setError(err instanceof Error ? err.message : String(err))); + setInlineRenameRequest((prev) => ({ path: target, nonce: (prev?.nonce ?? 0) + 1 })); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [saveActive, activeTabPath, closeTab, renamePath, selectedNodePath, showQuickOpen, openInMenuOpen]); + }, [saveActive, activeTabPath, closeTab, selectedNodePath, showQuickOpen, showContentSearch, openInMenuOpen]); useEffect(() => { if (!quickOpen.trim()) { @@ -1634,17 +1563,26 @@ export function FilesPage({ }, [quickOpen, workspaceId, showQuickOpen]); useEffect(() => { - if (!searchQuery.trim()) { - setSearchResults([]); + if (!showContentSearch) return; + const frame = window.requestAnimationFrame(() => { + contentSearchInputRef.current?.focus(); + contentSearchInputRef.current?.select(); + }); + return () => window.cancelAnimationFrame(frame); + }, [showContentSearch]); + + useEffect(() => { + if (!showContentSearch || !contentSearchQuery.trim()) { + setContentSearchResults([]); return; } const timer = setTimeout(() => { - window.ade.files.searchText({ workspaceId, query: searchQuery, limit: 200, includeIgnored: true }) - .then(setSearchResults) - .catch(() => setSearchResults([])); + window.ade.files.searchText({ workspaceId, query: contentSearchQuery, limit: 200, includeIgnored: true }) + .then(setContentSearchResults) + .catch(() => setContentSearchResults([])); }, 150); return () => clearTimeout(timer); - }, [searchQuery, workspaceId]); + }, [contentSearchQuery, workspaceId, showContentSearch]); useEffect(() => { if (!activeTab) return; @@ -1791,113 +1729,8 @@ export function FilesPage({ setResolvedConflictKeys(new Set()); }, [activeTabPath]); - const renderTree = (nodes: FileTreeNode[], level = 0): React.ReactNode => ( - <div> - {nodes.map((node) => { - const isExpanded = expanded.has(node.path); - const isActive = areWorkspacePathsEqual(activeTabPath, node.path, workspaceComparisonRoot) - || areWorkspacePathsEqual(selectedTreeNodePath, node.path, workspaceComparisonRoot); - const statusColor = changeStatusColor(node.changeStatus ?? null); - const fileIcon = node.type === "file" ? getFileIcon(node.name) : null; - const FileIcon = fileIcon?.icon; - const folderColor = isActive ? COLORS.accent : COLORS.textMuted; - - return ( - <div key={node.path}> - <button - className="group relative flex w-full items-center gap-1.5 text-left transition-colors" - style={{ - height: 26, - paddingLeft: `${10 + level * 14}px`, - paddingRight: 8, - fontFamily: MONO_FONT, - fontSize: 11, - color: isActive ? COLORS.textPrimary : COLORS.textSecondary, - background: isActive ? COLORS.accentSubtle : "transparent", - border: "none", - borderLeft: isActive ? `2px solid ${COLORS.accent}` : "2px solid transparent", - cursor: "pointer", - }} - onClick={() => { - setSelectedNodePath(node.path); - if (node.type === "directory") { - setExpanded((prev) => { - const next = new Set(prev); - if (next.has(node.path)) next.delete(node.path); - else next.add(node.path); - return next; - }); - if (!isExpanded && !node.children) refreshTree(node.path).catch(() => {}); - return; - } - openFile(node.path).catch(() => {}); - }} - onContextMenu={(event) => { - event.preventDefault(); - setSelectedNodePath(node.path); - setContextMenu({ - x: event.clientX, - y: event.clientY, - nodePath: node.path, - nodeType: node.type - }); - }} - onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = COLORS.hoverBg; }} - onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }} - title={node.path} - > - {level > 0 ? ( - <span className="pointer-events-none absolute inset-y-0 left-0"> - {Array.from({ length: level }).map((_, idx) => ( - <span - key={`${node.path}:guide:${idx}`} - className="absolute inset-y-0" - style={{ left: `${10 + idx * 14 + 5}px`, width: 1, background: "color-mix(in srgb, var(--color-border) 80%, transparent)" }} - /> - ))} - </span> - ) : null} - {node.type === "directory" ? ( - <> - {isExpanded - ? <ChevronDown size={12} weight="bold" style={{ color: folderColor, flexShrink: 0 }} /> - : <ChevronRight size={12} weight="bold" style={{ color: folderColor, flexShrink: 0 }} />} - {isExpanded - ? <FolderOpen size={14} weight="fill" style={{ color: folderColor, flexShrink: 0 }} /> - : <Folder size={14} weight="fill" style={{ color: folderColor, flexShrink: 0 }} />} - </> - ) : ( - <> - <span style={{ width: 12, flexShrink: 0 }} /> - {FileIcon - ? <FileIcon size={14} weight="regular" style={{ color: fileIcon?.color, flexShrink: 0 }} /> - : <FileText size={14} weight="regular" style={{ color: COLORS.textMuted, flexShrink: 0 }} />} - </> - )} - <span className="truncate">{node.name}</span> - {node.type === "directory" && node.changeStatus ? ( - <span style={{ marginLeft: "auto", width: 6, height: 6, borderRadius: "50%", background: statusColor, flexShrink: 0 }} /> - ) : null} - {node.type === "file" && node.changeStatus ? ( - <span style={{ - marginLeft: "auto", flexShrink: 0, - fontFamily: MONO_FONT, fontSize: 9, fontWeight: 700, letterSpacing: "1px", - color: statusColor, - padding: "1px 5px", - background: `${statusColor}18`, - }}>{node.changeStatus}</span> - ) : null} - </button> - {node.type === "directory" && isExpanded && node.children?.length ? renderTree(node.children, level + 1) : null} - </div> - ); - })} - </div> - ); - const conflictHunks = activeTab ? parseConflictHunks(activeTab.content) : []; const laneIdForDiff = activeWorkspace?.laneId; - const hasConflictMarkers = conflictHunks.length > 0; const editorModeHint = mode === "edit" ? activeTabPreviewKind === "image" @@ -1929,6 +1762,18 @@ export function FilesPage({ return parentDirOfPath(activeContextPath); })(); + const toggleDirectory = useCallback((nodePath: string, isExpanded: boolean, hasLoadedChildren: boolean) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(nodePath)) next.delete(nodePath); + else next.add(nodePath); + return next; + }); + if (!isExpanded && !hasLoadedChildren) { + refreshTree(nodePath).catch(() => {}); + } + }, [refreshTree]); + const runContextAction = (fn: () => Promise<void>) => { setContextMenu(null); fn().catch((err) => setError(err instanceof Error ? err.message : String(err))); @@ -1941,111 +1786,29 @@ export function FilesPage({ title: "Explorer", icon: FolderOpen, meta: activeWorkspace?.name, - headerActions: ( - <div className="flex items-center gap-1"> - <SmartTooltip content={{ label: "New File", description: "Create a new file in the current directory." }}> - <button - type="button" - title="New file" - style={{ ...outlineButton({ height: 24, padding: "0 6px", fontSize: 10 }) }} - onClick={() => createFileAt(activeContextDir).catch((err) => setError(err instanceof Error ? err.message : String(err)))} - onMouseEnter={(e) => { e.currentTarget.style.borderColor = COLORS.accent; e.currentTarget.style.color = COLORS.accent; }} - onMouseLeave={(e) => { e.currentTarget.style.borderColor = COLORS.outlineBorder; e.currentTarget.style.color = COLORS.textSecondary; }} - > - <FilePlus2 size={12} weight="regular" /> - </button> - </SmartTooltip> - <SmartTooltip content={{ label: "New Folder", description: "Create a new folder in the current directory." }}> - <button - type="button" - title="New folder" - style={{ ...outlineButton({ height: 24, padding: "0 6px", fontSize: 10 }) }} - onClick={() => createDirectoryAt(activeContextDir).catch((err) => setError(err instanceof Error ? err.message : String(err)))} - onMouseEnter={(e) => { e.currentTarget.style.borderColor = COLORS.accent; e.currentTarget.style.color = COLORS.accent; }} - onMouseLeave={(e) => { e.currentTarget.style.borderColor = COLORS.outlineBorder; e.currentTarget.style.color = COLORS.textSecondary; }} - > - <FolderPlus size={12} weight="regular" /> - </button> - </SmartTooltip> - </div> - ), bodyClassName: "flex min-h-0 flex-col overflow-hidden", children: ( - <div className="flex h-full min-h-0 flex-col" style={{ background: COLORS.cardBg, backdropFilter: "blur(20px)", WebkitBackdropFilter: "blur(20px)", borderRadius: 12 }}> - {/* Search bar */} - <div style={{ padding: "8px 10px", borderBottom: `1px solid ${COLORS.border}` }} data-tour="files.searchBar"> - <div className="relative flex items-center"> - <Search size={14} weight="regular" className="pointer-events-none absolute" style={{ left: 8, color: COLORS.textDim }} /> - <input - ref={searchInputRef} - value={searchQuery} - onChange={(event) => setSearchQuery(event.target.value)} - placeholder="SEARCH FILES" - style={{ - height: 30, width: "100%", padding: "0 28px 0 28px", fontSize: 10, - fontFamily: MONO_FONT, fontWeight: 500, - background: COLORS.recessedBg, borderRadius: 8, - border: `1px solid ${COLORS.outlineBorder}`, color: COLORS.textSecondary, - outline: "none", textTransform: "uppercase", letterSpacing: "1px", - }} - onFocus={(e) => { e.currentTarget.style.borderColor = COLORS.accent; }} - onBlur={(e) => { e.currentTarget.style.borderColor = COLORS.outlineBorder; }} - /> - {searchQuery.trim() ? ( - <button - type="button" - className="absolute" - style={{ right: 4, top: "50%", transform: "translateY(-50%)", display: "inline-flex", width: 18, height: 18, alignItems: "center", justifyContent: "center", background: "transparent", border: "none", color: COLORS.textMuted, cursor: "pointer" }} - onClick={() => setSearchQuery("")} - title="Clear search" - > - <X size={10} /> - </button> - ) : null} - </div> - <div className="mt-1.5 flex items-center justify-end"> - <SmartTooltip content={{ label: "Quick Open", description: "Search and open any file in the project.", shortcut: "\u2318P" }}> - <button - type="button" - style={{ ...outlineButton({ height: 22, padding: "0 8px", fontSize: 9 }) }} - onClick={() => setShowQuickOpen(true)} - onMouseEnter={(e) => { e.currentTarget.style.borderColor = COLORS.accent; e.currentTarget.style.color = COLORS.accent; }} - onMouseLeave={(e) => { e.currentTarget.style.borderColor = COLORS.outlineBorder; e.currentTarget.style.color = COLORS.textSecondary; }} - > - <Search size={10} /> QUICK OPEN - </button> - </SmartTooltip> - </div> - </div> - {/* Search results */} - {searchQuery.trim() ? ( - <div className="max-h-[38%] shrink-0 overflow-auto" style={{ borderBottom: `1px solid ${COLORS.border}`, background: COLORS.recessedBg, padding: 4 }}> - {searchResults.map((item, idx) => { - const srIcon = getFileIcon(item.path.split("/").pop() ?? ""); - const SrIcon = srIcon.icon; - return ( - <button - key={`${item.path}:${item.line}:${idx}`} - className="flex w-full items-start gap-2 text-left" - style={{ padding: "6px 8px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textSecondary, background: "transparent", border: "none", cursor: "pointer" }} - onClick={() => { openFile(item.path).catch(() => {}); }} - onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.hoverBg; }} - onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }} - > - <SrIcon size={12} style={{ color: srIcon.color, flexShrink: 0, marginTop: 2 }} /> - <div className="min-w-0 flex-1"> - <div className="truncate" style={{ fontWeight: 600, color: COLORS.textPrimary }}>{item.path}:{item.line}:{item.column}</div> - <div className="truncate" style={{ color: COLORS.textMuted, fontSize: 10 }}>{item.preview}</div> - </div> - </button> - ); - })} - {!searchResults.length ? <div style={{ padding: "8px", fontSize: 11, color: COLORS.textMuted, fontFamily: MONO_FONT }}>No matches</div> : null} - </div> - ) : null} - {/* File tree */} - <div className="min-h-0 flex-1 overflow-auto" style={{ paddingTop: 4, paddingBottom: 4 }} data-tour="files.fileTree">{renderTree(tree)}</div> - </div> + <FilesExplorer + tree={tree} + expanded={expanded} + selectedNodePath={selectedTreeNodePath} + activeTabPath={activeTabPath} + activeContextDir={activeContextDir} + workspaceComparisonRoot={workspaceComparisonRoot} + searchQuery={searchQuery} + inlineRenameRequest={inlineRenameRequest} + onSearchQueryChange={setSearchQuery} + onOpenQuickOpen={() => setShowQuickOpen(true)} + onOpenContentSearch={() => setShowContentSearch(true)} + onCreateFile={(basePath) => createFileAt(basePath).catch((err) => setError(err instanceof Error ? err.message : String(err)))} + onCreateDirectory={(basePath) => createDirectoryAt(basePath).catch((err) => setError(err instanceof Error ? err.message : String(err)))} + onToggleDirectory={toggleDirectory} + onOpenFile={(path) => { openFile(path).catch(() => {}); }} + onSelectNode={setSelectedNodePath} + onContextMenu={(event) => setContextMenu(event)} + onRenamePath={renamePathTo} + onInlineRenameSettled={() => setInlineRenameRequest(null)} + /> ) }, editor: { @@ -2293,12 +2056,12 @@ export function FilesPage({ ) } }), [ - tree, activeWorkspace, activeTabPath, activeContextDir, openTabs, activeTab, activeTabIsText, - mode, canEdit, editorStatus, laneIdForDiff, activeContextPath, - searchQuery, searchResults, conflictHunks, editorTheme, editorModeHint, hasConflictMarkers, - resolvedConflictKeys, renderTree, createFileAt, createDirectoryAt, saveActive, + tree, expanded, activeWorkspace, activeTabPath, activeContextDir, openTabs, activeTab, activeTabIsText, + mode, canEdit, editorStatus, laneIdForDiff, + searchQuery, inlineRenameRequest, selectedTreeNodePath, conflictHunks, editorTheme, editorModeHint, + resolvedConflictKeys, createFileAt, createDirectoryAt, saveActive, closeTab, stagePath, unstagePath, discardPath, openFile, setShowQuickOpen, navigate, - applyConflictResolution, setEditorHostRef, workspaceComparisonRoot + applyConflictResolution, setEditorHostRef, workspaceComparisonRoot, toggleDirectory, renamePathTo ]); const renderPane = useCallback((paneId: keyof typeof paneConfigs) => { @@ -2653,7 +2416,7 @@ export function FilesPage({ <button className="flex w-full items-center text-left" style={{ padding: "6px 12px", fontSize: 11, fontFamily: MONO_FONT, fontWeight: 500, letterSpacing: "0.5px", color: COLORS.textSecondary, background: "transparent", border: "none", cursor: "pointer" }} onClick={() => { setContextMenu(null); if (activeWorkspace) window.ade.app.revealPath(`${activeWorkspace.rootPath}/${contextMenu.nodePath}`).catch(() => {}); }} onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.hoverBg; }} onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}>{revealLabel.toUpperCase()}</button> <button className="flex w-full items-center text-left" style={{ padding: "6px 12px", fontSize: 11, fontFamily: MONO_FONT, fontWeight: 500, letterSpacing: "0.5px", color: COLORS.textSecondary, background: "transparent", border: "none", cursor: "pointer" }} onClick={() => runContextAction(async () => createFileAt(contextMenu.nodeType === "directory" ? contextMenu.nodePath : parentDirOfPath(contextMenu.nodePath)))} onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.hoverBg; }} onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}>NEW FILE</button> <button className="flex w-full items-center text-left" style={{ padding: "6px 12px", fontSize: 11, fontFamily: MONO_FONT, fontWeight: 500, letterSpacing: "0.5px", color: COLORS.textSecondary, background: "transparent", border: "none", cursor: "pointer" }} onClick={() => runContextAction(async () => createDirectoryAt(contextMenu.nodeType === "directory" ? contextMenu.nodePath : parentDirOfPath(contextMenu.nodePath)))} onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.hoverBg; }} onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}>NEW FOLDER</button> - <button className="flex w-full items-center text-left" style={{ padding: "6px 12px", fontSize: 11, fontFamily: MONO_FONT, fontWeight: 500, letterSpacing: "0.5px", color: COLORS.accent, background: "transparent", border: "none", cursor: "pointer" }} onClick={() => runContextAction(async () => renamePath(contextMenu.nodePath))} onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.hoverBg; }} onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}>RENAME</button> + <button className="flex w-full items-center text-left" style={{ padding: "6px 12px", fontSize: 11, fontFamily: MONO_FONT, fontWeight: 500, letterSpacing: "0.5px", color: COLORS.accent, background: "transparent", border: "none", cursor: "pointer" }} onClick={() => { const path = contextMenu.nodePath; setContextMenu(null); setInlineRenameRequest((prev) => ({ path, nonce: (prev?.nonce ?? 0) + 1 })); }} onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.hoverBg; }} onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}>RENAME</button> <div style={{ margin: "4px 0", height: 1, background: COLORS.border }} /> <button className="flex w-full items-center text-left" style={{ padding: "6px 12px", fontSize: 11, fontFamily: MONO_FONT, fontWeight: 700, letterSpacing: "0.5px", color: COLORS.danger, background: "transparent", border: "none", cursor: "pointer" }} onClick={() => runContextAction(async () => deletePath(contextMenu.nodePath))} onMouseEnter={(e) => { e.currentTarget.style.background = "color-mix(in srgb, var(--color-error) 18%, transparent)"; }} onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}>DELETE</button> </div> @@ -2711,6 +2474,68 @@ export function FilesPage({ </div> ) : null} + {/* Content Search overlay */} + {showContentSearch ? ( + <div className="absolute inset-0 z-30 flex items-start justify-center" style={{ background: "rgba(0,0,0,0.6)", paddingTop: 80 }}> + <div style={{ width: 720, background: COLORS.cardBg, backdropFilter: "blur(20px)", WebkitBackdropFilter: "blur(20px)", border: `1px solid ${COLORS.border}`, borderRadius: 16, padding: 16 }}> + <div style={{ ...LABEL_STYLE, marginBottom: 8, fontSize: 9 }}>CONTENT SEARCH</div> + <div className="relative flex items-center"> + <Search size={14} weight="regular" className="pointer-events-none absolute" style={{ left: 10, color: COLORS.textDim }} /> + <input + ref={contentSearchInputRef} + value={contentSearchQuery} + onChange={(e) => setContentSearchQuery(e.target.value)} + placeholder={`Search file contents... (${modifierKeyLabel}+Shift+F)`} + style={{ + height: 36, width: "100%", padding: "0 36px 0 32px", + fontSize: 12, fontFamily: MONO_FONT, fontWeight: 500, + background: COLORS.recessedBg, border: `1px solid ${COLORS.accent}`, + borderRadius: 8, color: COLORS.textPrimary, outline: "none", + letterSpacing: "0.3px", + }} + onKeyDown={(e) => { if (e.key === "Escape") setShowContentSearch(false); }} + /> + <button + type="button" + className="absolute" + style={{ right: 8, ...outlineButton({ height: 22, padding: "0 6px", fontSize: 8 }) }} + onClick={() => setShowContentSearch(false)} + >ESC</button> + </div> + <div className="mt-2 max-h-[46vh] overflow-auto" style={{ border: `1px solid ${COLORS.border}`, background: COLORS.recessedBg, borderRadius: 8 }}> + {contentSearchResults.map((item, idx) => { + const srIcon = getFileIcon(item.path.split("/").pop() ?? ""); + const SrIcon = srIcon.icon; + return ( + <button + key={`${item.path}:${item.line}:${idx}`} + className="flex w-full items-start gap-2 text-left" + style={{ padding: "8px 12px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textSecondary, background: "transparent", border: "none", cursor: "pointer" }} + onClick={() => { + openFile(item.path).catch(() => {}); + setShowContentSearch(false); + }} + onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.hoverBg; e.currentTarget.style.color = COLORS.textPrimary; }} + onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = COLORS.textSecondary; }} + > + <SrIcon size={12} style={{ color: srIcon.color, flexShrink: 0, marginTop: 2 }} /> + <div className="min-w-0 flex-1"> + <div className="truncate" style={{ fontWeight: 600, color: COLORS.textPrimary }}>{item.path}:{item.line}:{item.column}</div> + <div className="truncate" style={{ color: COLORS.textMuted, fontSize: 10 }}>{item.preview}</div> + </div> + </button> + ); + })} + {!contentSearchResults.length ? ( + <div style={{ padding: "12px", fontFamily: MONO_FONT, fontSize: 11, color: COLORS.textDim }}> + {contentSearchQuery.trim() ? "NO MATCHES" : "TYPE TO SEARCH CONTENTS"} + </div> + ) : null} + </div> + </div> + </div> + ) : null} + {/* Text prompt modal */} {textPrompt ? ( <div className="absolute inset-0 z-40 flex items-center justify-center" style={{ background: "rgba(0,0,0,0.6)", padding: 16 }}> @@ -2771,10 +2596,11 @@ function FilesDiffPanel({ laneId: string; path: string; theme: EditorThemeMode; - diffViewRef?: React.MutableRefObject<MonacoDiffHandle | null>; + diffViewRef?: React.MutableRefObject<AdeDiffViewerHandle | null>; }) { const [mode, setMode] = useState<"unstaged" | "staged" | "commit">("unstaged"); const [diff, setDiff] = useState<any>(null); + const [patch, setPatch] = useState<FilePatch | null>(null); const [error, setError] = useState<string | null>(null); const [commits, setCommits] = useState<GitCommitSummary[]>([]); const [compareRef, setCompareRef] = useState<string>(""); @@ -2805,23 +2631,37 @@ function FilesDiffPanel({ const load = async () => { if (mode === "commit" && !compareRef.trim()) { setDiff(null); + setPatch(null); return; } - const next = await window.ade.diff.getFile({ + const args = { laneId, path, mode, compareRef: mode === "commit" ? compareRef : undefined - }); + } as const; + const [nextDiff, nextPatch] = await Promise.allSettled([ + window.ade.diff.getFile(args), + window.ade.diff.getFilePatch(args), + ]); if (cancelled) return; - setDiff(next); + const resolvedDiff = nextDiff.status === "fulfilled" ? nextDiff.value : null; + const resolvedPatch = nextPatch.status === "fulfilled" && filePatchHasRenderableChanges(nextPatch.value) ? nextPatch.value : null; + const hasRenderableDiff = resolvedDiff ? fileDiffHasRenderableChanges(resolvedDiff) : false; + if (!resolvedPatch && !hasRenderableDiff) { + if (nextDiff.status === "rejected") throw nextDiff.reason; + if (nextPatch.status === "rejected") throw nextPatch.reason; + } + setDiff(resolvedDiff); + setPatch(resolvedPatch); }; load().catch((err) => { if (cancelled) return; setDiff(null); - setError(err instanceof Error ? err.message : String(err)); + setPatch(null); + setError(settledErrorMessage(err)); }); return () => { @@ -2881,8 +2721,8 @@ function FilesDiffPanel({ {error ? <div style={{ padding: 12, fontFamily: MONO_FONT, fontSize: 11, color: COLORS.danger }}>{error}</div> : null} <div className="min-h-0 flex-1"> - {diff && fileDiffHasRenderableChanges(diff) ? ( - <MonacoDiffView ref={diffViewRef} diff={diff} className="h-full" theme={theme} /> + {patch || (diff && fileDiffHasRenderableChanges(diff)) ? ( + <AdeDiffViewer ref={diffViewRef} diff={diff} patch={patch} className="h-full" theme={theme} /> ) : diff ? ( <div className="flex h-full items-center justify-center px-4 text-xs" style={{ color: COLORS.textMuted }}> No changes for this file. diff --git a/apps/desktop/src/renderer/components/files/filePresentation.tsx b/apps/desktop/src/renderer/components/files/filePresentation.tsx new file mode 100644 index 000000000..ae27b0f87 --- /dev/null +++ b/apps/desktop/src/renderer/components/files/filePresentation.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import { + BookOpenText, + FileZip as FileArchive, + FileCss as FileBraces, + GearSix as FileCog, + FileTs as FileCode2, + FileImage, + FileText, + Terminal as TerminalSquare, + FileXls as FileSpreadsheet, +} from "@phosphor-icons/react"; +import type { FileTreeNode } from "../../../shared/types"; +import { COLORS } from "../lanes/laneDesignTokens"; + +const FILE_ICON_COLORS = { + code: "#38BDF8", + json: "#34D399", + config: "#FB923C", + markdown: "#FBBF24", + style: "#818CF8", + shell: "#2DD4BF", + image: "#E879F9", + archive: "#FB7185", + spreadsheet: "#4ADE80", + default: COLORS.textMuted, +} as const; + +export function getFileIcon(fileName: string): { icon: React.ComponentType<any>; color: string } { + const lower = fileName.toLowerCase(); + const ext = lower.includes(".") ? lower.slice(lower.lastIndexOf(".")) : ""; + + if ( + ext === ".ts" || + ext === ".tsx" || + ext === ".mts" || + ext === ".cts" || + ext === ".js" || + ext === ".jsx" || + ext === ".mjs" || + ext === ".cjs" + ) { + return { icon: FileCode2, color: FILE_ICON_COLORS.code }; + } + if (ext === ".json" || ext === ".jsonc") { + return { icon: FileBraces, color: FILE_ICON_COLORS.json }; + } + if (ext === ".yml" || ext === ".yaml" || ext === ".toml" || ext === ".ini") { + return { icon: FileCog, color: FILE_ICON_COLORS.config }; + } + if (ext === ".md" || ext === ".mdx") { + return { icon: BookOpenText, color: FILE_ICON_COLORS.markdown }; + } + if (ext === ".css" || ext === ".scss" || ext === ".sass" || ext === ".less") { + return { icon: FileCode2, color: FILE_ICON_COLORS.style }; + } + if (ext === ".sh" || ext === ".bash" || ext === ".zsh" || ext === ".fish" || ext === ".ps1") { + return { icon: TerminalSquare, color: FILE_ICON_COLORS.shell }; + } + if (ext === ".png" || ext === ".jpg" || ext === ".jpeg" || ext === ".gif" || ext === ".webp" || ext === ".svg" || ext === ".ico") { + return { icon: FileImage, color: FILE_ICON_COLORS.image }; + } + if (ext === ".zip" || ext === ".tar" || ext === ".gz" || ext === ".tgz" || ext === ".rar" || ext === ".7z") { + return { icon: FileArchive, color: FILE_ICON_COLORS.archive }; + } + if (ext === ".csv" || ext === ".tsv" || ext === ".xls" || ext === ".xlsx") { + return { icon: FileSpreadsheet, color: FILE_ICON_COLORS.spreadsheet }; + } + return { icon: FileText, color: FILE_ICON_COLORS.default }; +} + +export function changeStatusColor(changeStatus: FileTreeNode["changeStatus"]): string { + if (changeStatus === "added" || changeStatus === "untracked" || changeStatus === "A") return COLORS.success; + if (changeStatus === "deleted" || changeStatus === "D") return COLORS.danger; + if (changeStatus === "renamed") return COLORS.info; + if (changeStatus === "modified" || changeStatus === "M") return COLORS.warning; + if (changeStatus === "ignored") return COLORS.textDim; + return COLORS.textDim; +} + +export function changeStatusLabel(changeStatus: FileTreeNode["changeStatus"]): string | null { + if (!changeStatus) return null; + switch (changeStatus) { + case "A": + case "added": + return "A"; + case "D": + case "deleted": + return "D"; + case "M": + case "modified": + return "M"; + case "renamed": + return "R"; + case "untracked": + return "U"; + case "ignored": + return "I"; + default: + return "?"; + } +} + +export function changeStatusTitle(changeStatus: FileTreeNode["changeStatus"]): string | undefined { + if (!changeStatus) return undefined; + switch (changeStatus) { + case "A": + case "added": + return "Added"; + case "D": + case "deleted": + return "Deleted"; + case "M": + case "modified": + return "Modified"; + case "renamed": + return "Renamed"; + case "untracked": + return "Untracked"; + case "ignored": + return "Ignored"; + default: + return "Changed"; + } +} diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx index cdbe4731a..ec2b7287d 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx @@ -1,7 +1,13 @@ import React from "react"; import { CaretDown, CaretRight, GitBranch, GitFork, Plus, StackSimple, Tag } from "@phosphor-icons/react"; import { Button } from "../ui/Button"; -import type { BranchPullRequest, LaneSummary, LaneEnvInitProgress, LaneTemplate } from "../../../shared/types"; +import type { + BranchPullRequest, + LaneLinearIssue, + LaneSummary, + LaneEnvInitProgress, + LaneTemplate, +} from "../../../shared/types"; import type { LaneBranchOption } from "./laneUtils"; import { LaneEnvInitProgressPanel } from "./LaneEnvInitProgress"; import { LaneDialogShell } from "./LaneDialogShell"; @@ -9,6 +15,13 @@ import { LaneColorPicker } from "./LaneColorPicker"; import { colorsInUse, nextAvailableColor } from "./laneColorPalette"; import { BranchPickerView } from "./BranchPickerView"; import { formatRelativeTime } from "./branchPickerSearch"; +import { linearIssueBranchName } from "../../../shared/linearIssueBranch"; +import { + LinearIssuePickerView, + LinearIssueSummaryCard, + branchExistsForLinearIssue, +} from "./LinearIssuePicker"; +import { LinearMark, LINEAR_BRAND } from "./linearBrand"; import { SECTION_CLASS_NAME, LABEL_CLASS_NAME, @@ -90,6 +103,8 @@ export function CreateLaneDialog({ importBranchWarning, selectedColor, setSelectedColor, + selectedLinearIssue, + setSelectedLinearIssue, branchPullRequests, currentGitUserName, loadingBranches, @@ -125,6 +140,8 @@ export function CreateLaneDialog({ importBranchWarning?: string | null; selectedColor: string | null; setSelectedColor: (c: string | null) => void; + selectedLinearIssue: LaneLinearIssue | null; + setSelectedLinearIssue: (issue: LaneLinearIssue | null) => void; /** Open PRs in the project's GitHub repo, keyed by head branch. */ branchPullRequests?: BranchPullRequest[]; /** Local git user.name — used by the picker to resolve `mine` / `author:me`. */ @@ -138,15 +155,26 @@ export function CreateLaneDialog({ const usedColors = React.useMemo(() => colorsInUse(lanes), [lanes]); const [pickerOpen, setPickerOpen] = React.useState(false); + const [issuePickerOpen, setIssuePickerOpen] = React.useState(false); React.useEffect(() => { - if (!open) setPickerOpen(false); + if (!open) { + setPickerOpen(false); + setIssuePickerOpen(false); + } }, [open]); React.useEffect(() => { if (createMode !== "existing") setPickerOpen(false); }, [createMode]); + React.useEffect(() => { + if (selectedLinearIssue && createMode === "existing") { + setCreateMode("primary"); + setCreateImportBranch(""); + } + }, [createMode, selectedLinearIssue, setCreateImportBranch, setCreateMode]); + const prByBranch = React.useMemo(() => { const map = new Map<string, BranchPullRequest>(); for (const pr of branchPullRequests ?? []) map.set(pr.branch, pr); @@ -169,6 +197,11 @@ export function CreateLaneDialog({ else if (allBranches.length === 0) branchPickerPlaceholder = "No branches found"; else branchPickerPlaceholder = "Pick a branch…"; + const selectedLinearBranchName = selectedLinearIssue ? linearIssueBranchName(selectedLinearIssue) : ""; + const selectedLinearBranchConflict = selectedLinearIssue + ? branchExistsForLinearIssue(selectedLinearBranchName, createBranches) + : false; + React.useEffect(() => { if (open && selectedColor === null) { const next = nextAvailableColor(lanes); @@ -182,20 +215,21 @@ export function CreateLaneDialog({ || !createLaneName.trim() || (createMode === "child" && !createParentLaneId) || (createMode === "primary" && !createBaseBranch) - || (createMode === "existing" && !createImportBranch)); - - const hasAdvanced = templates.length > 0 || !!onNavigateToTemplates; + || (createMode === "existing" && !createImportBranch) + || selectedLinearBranchConflict); return ( <LaneDialogShell open={open} onOpenChange={onOpenChange} - title={pickerOpen ? "Pick branch" : "Create lane"} - description={pickerOpen - ? "Search by name, PR, author, or staleness." - : "Create a lane from Primary, an existing branch, or another lane."} - icon={Plus} - widthClassName="w-[min(560px,calc(100vw-24px))]" + title={issuePickerOpen ? "Connect Linear issue" : pickerOpen ? "Pick branch" : "Create lane"} + description={issuePickerOpen + ? undefined + : pickerOpen + ? "Search by name, PR, author, or staleness." + : "Create a lane from Primary, an existing branch, or another lane."} + icon={issuePickerOpen ? LinearMark : Plus} + widthClassName={issuePickerOpen ? "w-[min(920px,calc(100vw-24px))]" : "w-[min(560px,calc(100vw-24px))]"} busy={busy} onCloseAutoFocus={(event) => { event.preventDefault(); @@ -205,7 +239,14 @@ export function CreateLaneDialog({ target?.focus?.(); }} > - {pickerOpen ? ( + {issuePickerOpen ? ( + <LinearIssuePickerView + selectedIssue={selectedLinearIssue} + onSelect={setSelectedLinearIssue} + onBack={() => setIssuePickerOpen(false)} + busy={busy || laneCreated} + /> + ) : pickerOpen ? ( <BranchPickerView branches={createBranches} pullRequests={branchPullRequests ?? []} @@ -256,6 +297,8 @@ export function CreateLaneDialog({ const meta = MODE_META[mode]; const Icon = meta.icon; const active = createMode === mode; + const disabledByLinearIssue = Boolean(selectedLinearIssue && mode === "existing"); + const disabled = Boolean(busy || laneCreated || disabledByLinearIssue); const cardClass = active ? `${CARD_CLASS_NAME} ${CARD_ACTIVE_CLASS_NAME}` : CARD_CLASS_NAME; @@ -264,7 +307,8 @@ export function CreateLaneDialog({ key={mode} type="button" aria-pressed={active} - disabled={busy || laneCreated} + disabled={disabled} + title={disabledByLinearIssue ? "Detach the Linear issue before importing an existing branch." : undefined} {...(mode === "primary" ? { "data-tour": "lanes.createDialog.primaryTab" } : mode === "existing" @@ -288,7 +332,7 @@ export function CreateLaneDialog({ </div> </div> <div className="mt-1.5 line-clamp-2 text-[11px] leading-snug text-muted-fg/70"> - {meta.description} + {disabledByLinearIssue ? "Unavailable while a Linear issue is connected" : meta.description} </div> </button> ); @@ -443,9 +487,8 @@ export function CreateLaneDialog({ </div> </section> - {/* Advanced — template (collapsed by default) */} - {hasAdvanced ? ( - <details className="group rounded-xl border border-white/[0.06] bg-white/[0.02] open:bg-white/[0.03]"> + {/* 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" /> @@ -468,6 +511,60 @@ export function CreateLaneDialog({ ) : 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)} + 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, + }} + > + <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 ? ( @@ -498,7 +595,6 @@ export function CreateLaneDialog({ </div> </div> </details> - ) : null} {error ? ( <div className="rounded-xl border border-red-500/25 bg-red-500/10 px-3 py-2 text-sm text-red-200"> @@ -519,6 +615,7 @@ export function CreateLaneDialog({ setCreateImportBranch(""); setCreateChildBaseBranch(""); setSelectedColor(null); + setSelectedLinearIssue(null); }} > Cancel diff --git a/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx b/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx index af206c215..7905f3ecf 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx @@ -27,13 +27,16 @@ export function LaneDialogShell({ return ( <Dialog.Root open={open} onOpenChange={(next) => { if (!busy || next) onOpenChange(next); }}> <Dialog.Portal> - <Dialog.Overlay className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm" /> + <Dialog.Overlay className="fixed inset-0 z-50 bg-black/55 backdrop-blur-sm" /> <Dialog.Content className={`fixed left-1/2 top-[14%] z-50 -translate-x-1/2 focus:outline-none ${widthClassName ?? "w-[min(680px,calc(100vw-24px))]"}`} onCloseAutoFocus={onCloseAutoFocus} > <BorderBeam size="md" colorVariant="mono" duration={25} strength={0.85} borderRadius={12}> - <div className="relative rounded-xl border border-white/[0.06] bg-bg/80 p-4 shadow-float backdrop-blur-xl"> + <div + className="relative isolate rounded-xl border border-white/[0.08] p-4 shadow-float" + style={{ backgroundColor: "var(--color-modal-bg, var(--color-card, #1A1830))" }} + > <div className="pointer-events-none absolute inset-x-6 top-0 h-px bg-gradient-to-r from-transparent via-accent/40 to-transparent" /> <div className="mb-4 flex items-start justify-between gap-3"> <div className="min-w-0"> diff --git a/apps/desktop/src/renderer/components/lanes/LaneDiffPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneDiffPane.tsx index 161b9d667..46c130c7a 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneDiffPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneDiffPane.tsx @@ -4,8 +4,8 @@ import { useNavigate } from "react-router-dom"; import { Group, Panel } from "react-resizable-panels"; import { EmptyState } from "../ui/EmptyState"; import { ResizeGutter } from "../ui/ResizeGutter"; -import { MonacoDiffView, type MonacoDiffHandle } from "./MonacoDiffView"; -import type { FileDiff, GitCommitSummary } from "../../../shared/types"; +import { AdeDiffViewer, type AdeDiffViewerHandle } from "../shared/AdeDiffViewer"; +import type { FileDiff, FilePatch, GitCommitSummary } from "../../../shared/types"; import { SmartTooltip } from "../ui/SmartTooltip"; import { COLORS, LABEL_STYLE, MONO_FONT, inlineBadge, outlineButton } from "./laneDesignTokens"; @@ -13,6 +13,19 @@ function normalizePath(pathValue: string): string { return pathValue.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, ""); } +function filePatchHasRenderableChanges(patch: FilePatch | null | undefined): patch is FilePatch { + if (!patch) return false; + if (patch.isBinary) return true; + return patch.patch.trim().length > 0; +} + +function fileDiffHasRenderableChanges(diff: FileDiff | null | undefined): diff is FileDiff { + if (!diff) return false; + if (diff.original.exists !== diff.modified.exists) return true; + if (diff.isBinary) return true; + return diff.original.text !== diff.modified.text; +} + const MAX_COMMIT_FILE_ROWS = 500; function DiffFailedRetry({ onRetry }: { onRetry: () => void }) { @@ -44,16 +57,18 @@ export function LaneDiffPane({ liveSync?: boolean; }) { const navigate = useNavigate(); - const diffRef = useRef<MonacoDiffHandle | null>(null); + const diffRef = useRef<AdeDiffViewerHandle | null>(null); const workingDiffRequestSeq = useRef(0); const commitFilesRequestSeq = useRef(0); const commitDiffRequestSeq = useRef(0); const [diff, setDiff] = useState<FileDiff | null>(null); + const [patch, setPatch] = useState<FilePatch | null>(null); const [diffFailed, setDiffFailed] = useState(false); const [commitFiles, setCommitFiles] = useState<string[]>([]); const [selectedCommitFilePath, setSelectedCommitFilePath] = useState<string | null>(null); const [commitDiff, setCommitDiff] = useState<FileDiff | null>(null); + const [commitPatch, setCommitPatch] = useState<FilePatch | null>(null); const [commitDiffFailed, setCommitDiffFailed] = useState(false); const [busyAction, setBusyAction] = useState<string | null>(null); const [showAllCommitFiles, setShowAllCommitFiles] = useState(false); @@ -70,20 +85,41 @@ export function LaneDiffPane({ const requestId = ++workingDiffRequestSeq.current; if (!laneId || !selectedPath || !selectedFileMode) { setDiff(null); + setPatch(null); setDiffFailed(false); return Promise.resolve(); } - return window.ade.diff - .getFile({ laneId, path: selectedPath, mode: selectedFileMode }) - .then((value) => { + return Promise.allSettled([ + window.ade.diff.getFile({ laneId, path: selectedPath, mode: selectedFileMode }), + window.ade.diff.getFilePatch({ laneId, path: selectedPath, mode: selectedFileMode }), + ]) + .then(([diffResult, patchResult]) => { if (workingDiffRequestSeq.current !== requestId) return; - setDiff(value); + const nextDiff = diffResult.status === "fulfilled" ? diffResult.value : null; + const nextPatch = patchResult.status === "fulfilled" && filePatchHasRenderableChanges(patchResult.value) ? patchResult.value : null; + if (!nextPatch && (!nextDiff || !fileDiffHasRenderableChanges(nextDiff))) { + if (diffResult.status === "rejected" || patchResult.status === "rejected") { + setDiff(null); + setPatch(null); + setDiffFailed(true); + return; + } + } + if (!nextDiff && !nextPatch) { + setDiff(null); + setPatch(null); + setDiffFailed(true); + return; + } + setDiff(nextDiff); + setPatch(nextPatch); setDiffFailed(false); }) .catch(() => { if (workingDiffRequestSeq.current !== requestId) return; setDiff(null); + setPatch(null); setDiffFailed(true); }); }, [laneId, selectedPath, selectedFileMode]); @@ -158,6 +194,7 @@ export function LaneDiffPane({ setCommitFiles([]); setSelectedCommitFilePath(null); setCommitDiff(null); + setCommitPatch(null); setCommitDiffFailed(false); if (!laneId || !selectedCommit) return; @@ -182,19 +219,41 @@ export function LaneDiffPane({ const refreshCommitDiff = React.useCallback(() => { const requestId = ++commitDiffRequestSeq.current; setCommitDiff(null); + setCommitPatch(null); setCommitDiffFailed(false); if (!laneId || !selectedCommit || !selectedCommitFilePath) return; - window.ade.diff - .getFile({ + const args = { laneId, path: selectedCommitFilePath, mode: "commit", compareRef: selectedCommit.sha, compareTo: "parent" - }) - .then((value) => { + } as const; + Promise.allSettled([ + window.ade.diff.getFile(args), + window.ade.diff.getFilePatch(args), + ]) + .then(([diffResult, patchResult]) => { if (commitDiffRequestSeq.current !== requestId) return; - setCommitDiff(value); + const nextDiff = diffResult.status === "fulfilled" ? diffResult.value : null; + const nextPatch = patchResult.status === "fulfilled" && filePatchHasRenderableChanges(patchResult.value) ? patchResult.value : null; + if (!nextPatch && !fileDiffHasRenderableChanges(nextDiff)) { + if (diffResult.status === "rejected" || patchResult.status === "rejected") { + setCommitDiff(null); + setCommitPatch(null); + setCommitDiffFailed(true); + return; + } + } + if (diffResult.status !== "fulfilled" && patchResult.status !== "fulfilled") { + setCommitDiff(null); + setCommitPatch(null); + setCommitDiffFailed(true); + return; + } + setCommitDiff(nextDiff); + setCommitPatch(nextPatch); + setCommitDiffFailed(false); }) .catch(() => { if (commitDiffRequestSeq.current !== requestId) return; @@ -301,10 +360,10 @@ export function LaneDiffPane({ </div> ) : commitDiffFailed ? ( <DiffFailedRetry onRetry={refreshCommitDiff} /> - ) : !commitDiff ? ( + ) : !commitDiff && !commitPatch ? ( <div className="flex h-full items-center justify-center" style={{ fontSize: 12, color: COLORS.textMuted }}>Loading diff...</div> ) : ( - <MonacoDiffView diff={commitDiff} editable={false} className="h-full" /> + <AdeDiffViewer diff={commitDiff} patch={commitPatch} editable={false} className="h-full" /> )} </Panel> </Group> @@ -314,7 +373,8 @@ export function LaneDiffPane({ } // Working tree file diff - if (selectedPath && diff && laneId) { + if (selectedPath && laneId && (diff || patch)) { + const displayPath = diff?.path ?? patch?.path ?? selectedPath; return ( <div className="h-full flex flex-col" style={{ background: COLORS.pageBg }}> <div @@ -333,7 +393,7 @@ export function LaneDiffPane({ {selectedFileMode === "unstaged" ? "WORKING TREE" : "INDEX"} </span> <span style={{ color: COLORS.outlineBorder }}>/</span> - {diff.path.split("/").map((segment, idx, arr) => ( + {displayPath.split("/").map((segment, idx, arr) => ( <React.Fragment key={idx}> <span style={{ fontFamily: MONO_FONT, @@ -365,7 +425,7 @@ export function LaneDiffPane({ </button> </SmartTooltip> ) : null} - {selectedFileMode === "unstaged" && !diff.isBinary ? ( + {selectedFileMode === "unstaged" && diff && !diff.isBinary ? ( <SmartTooltip content={{ label: "Save", description: "Write the edited content back to the working tree.", @@ -396,7 +456,7 @@ export function LaneDiffPane({ ) : null} </div> </div> - <MonacoDiffView ref={diffRef} diff={diff} editable={selectedFileMode === "unstaged"} className="flex-1" /> + <AdeDiffViewer ref={diffRef} diff={diff} patch={patch} editable={selectedFileMode === "unstaged"} className="flex-1" /> </div> ); } diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 9a72e56e1..487bd9e26 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ArrowDown, ArrowLeft, ArrowsClockwise, Check, Stack, Trash, Upload, Warning } from "@phosphor-icons/react"; +import { ArrowDown, ArrowLeft, ArrowsClockwise, CaretDown, CaretRight, Check, Folder, Stack, Trash, Upload, Warning } from "@phosphor-icons/react"; import { useNavigate } from "react-router-dom"; import { useAppStore } from "../../state/appStore"; import { getProjectConfigCached } from "../../lib/projectConfigCache"; @@ -9,6 +9,7 @@ import { SmartTooltip, type SmartTooltipContent } from "../ui/SmartTooltip"; import { COLORS, LABEL_STYLE, MONO_FONT, inlineBadge, outlineButton, primaryButton, dangerButton } from "./laneDesignTokens"; import { CommitTimeline } from "./CommitTimeline"; import { LaneDiffPane } from "./LaneDiffPane"; +import { LinearIssueBadge } from "./LinearIssueBadge"; import type { DiffChanges, FileChange, @@ -192,6 +193,63 @@ function getFileKindColor(kind: FileChange["kind"]): string { return COLORS.warning; } +type ChangeTreeNode = { + name: string; + path: string; + dirs: Map<string, ChangeTreeNode>; + files: FileChange[]; +}; + +type ChangeTreeStats = { + files: number; + additions: number; + deletions: number; +}; + +function createChangeTreeNode(name: string, path: string): ChangeTreeNode { + return { name, path, dirs: new Map(), files: [] }; +} + +function buildChangeTree(files: FileChange[]): ChangeTreeNode { + const root = createChangeTreeNode("", ""); + for (const file of files) { + const parts = file.path.split("/").filter(Boolean); + let node = root; + for (const part of parts.slice(0, -1)) { + const nextPath = node.path ? `${node.path}/${part}` : part; + let next = node.dirs.get(part); + if (!next) { + next = createChangeTreeNode(part, nextPath); + node.dirs.set(part, next); + } + node = next; + } + node.files.push(file); + } + return root; +} + +function getChangeTreeStats(node: ChangeTreeNode, statsByPath?: Map<string, ChangeTreeStats>): ChangeTreeStats { + let files = node.files.length; + let additions = node.files.reduce((sum, file) => sum + (file.additions ?? 0), 0); + let deletions = node.files.reduce((sum, file) => sum + (file.deletions ?? 0), 0); + for (const child of node.dirs.values()) { + const stats = getChangeTreeStats(child, statsByPath); + files += stats.files; + additions += stats.additions; + deletions += stats.deletions; + } + const stats = { files, additions, deletions }; + if (node.path) statsByPath?.set(node.path, stats); + return stats; +} + +function buildChangeTreeStatsByPath(files: FileChange[]): Map<string, ChangeTreeStats> { + const statsByPath = new Map<string, ChangeTreeStats>(); + getChangeTreeStats(buildChangeTree(files), statsByPath); + return statsByPath; +} + function getCommitButtonLabel(args: { busyAction: string | null; amendCommit: boolean; @@ -533,6 +591,7 @@ export function LaneGitActionsPane({ const hasUnstaged = changes.unstaged.length > 0; const [showAllStagedChanges, setShowAllStagedChanges] = useState(false); const [showAllUnstagedChanges, setShowAllUnstagedChanges] = useState(false); + const [collapsedChangeFolders, setCollapsedChangeFolders] = useState<Set<string>>(() => new Set()); const visibleStagedChanges = useMemo( () => (showAllStagedChanges ? changes.staged : changes.staged.slice(0, MAX_RENDERED_CHANGE_ROWS_PER_SECTION)), [changes.staged, showAllStagedChanges], @@ -541,6 +600,8 @@ export function LaneGitActionsPane({ () => (showAllUnstagedChanges ? changes.unstaged : changes.unstaged.slice(0, MAX_RENDERED_CHANGE_ROWS_PER_SECTION)), [changes.unstaged, showAllUnstagedChanges], ); + const stagedChangeTreeStatsByPath = useMemo(() => buildChangeTreeStatsByPath(changes.staged), [changes.staged]); + const unstagedChangeTreeStatsByPath = useMemo(() => buildChangeTreeStatsByPath(changes.unstaged), [changes.unstaged]); const hiddenStagedChangeCount = Math.max(0, changes.staged.length - visibleStagedChanges.length); const hiddenUnstagedChangeCount = Math.max(0, changes.unstaged.length - visibleUnstagedChanges.length); const responsiveMode = getResponsiveMode(paneWidth); @@ -846,6 +907,7 @@ export function LaneGitActionsPane({ setStashes([]); setSyncStatus(null); setForcePushSuggested(false); + setCollapsedChangeFolders(new Set()); setAmendCommit(false); setCommitMessageAi({ enabled: false, modelId: null }); setAutoRebaseStatus(autoRebaseStatusSnapshotRef.current ?? null); @@ -1326,7 +1388,18 @@ export function LaneGitActionsPane({ title={`${file.kind} file`} style={{ width: 7, height: 7, borderRadius: "50%", background: kindColor }} /> - <span className="truncate flex-1" style={{ fontSize: 11 }}>{file.path}</span> + <span className="truncate flex-1" style={{ fontSize: 11 }} title={file.oldPath ? `${file.oldPath} -> ${file.path}` : file.path}> + {file.oldPath ? `${file.oldPath} -> ${file.path}` : file.path} + </span> + {file.additions != null && file.additions > 0 ? ( + <span style={{ fontSize: 10, color: COLORS.success }}>+{file.additions}</span> + ) : null} + {file.deletions != null && file.deletions > 0 ? ( + <span style={{ fontSize: 10, color: COLORS.danger }}>-{file.deletions}</span> + ) : null} + {file.isBinary ? ( + <span style={inlineBadge(COLORS.textDim, { fontSize: 9 })}>binary</span> + ) : null} {(alsoStaged || alsoUnstaged) ? ( <span title="This file has both staged and unstaged changes." @@ -1406,6 +1479,73 @@ export function LaneGitActionsPane({ ); }; + const renderChangeTreeNode = ( + node: ChangeTreeNode, + mode: "staged" | "unstaged", + depth: number, + statsByPath: Map<string, ChangeTreeStats>, + ): React.ReactNode[] => { + const rows: React.ReactNode[] = []; + const dirs = [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name)); + const files = [...node.files].sort((a, b) => a.path.localeCompare(b.path)); + + for (const dir of dirs) { + const key = `${mode}:${dir.path}`; + const collapsed = collapsedChangeFolders.has(key); + const stats = statsByPath.get(dir.path) ?? getChangeTreeStats(dir); + rows.push( + <button + key={key} + type="button" + className="flex w-full items-center gap-2 text-left" + style={{ + padding: "5px 8px", + paddingLeft: 8 + depth * 14, + color: COLORS.textMuted, + background: "transparent", + border: "none", + cursor: "pointer", + fontFamily: MONO_FONT, + fontSize: 11, + }} + onClick={() => { + setCollapsedChangeFolders((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }} + onMouseEnter={(event) => { event.currentTarget.style.background = COLORS.hoverBg; }} + onMouseLeave={(event) => { event.currentTarget.style.background = "transparent"; }} + > + {collapsed ? <CaretRight size={12} /> : <CaretDown size={12} />} + <Folder size={13} weight="duotone" /> + <span className="truncate" style={{ flex: 1 }}>{dir.name}</span> + <span style={{ color: COLORS.textDim }}>{stats.files}</span> + {stats.additions > 0 ? <span style={{ color: COLORS.success }}>+{stats.additions}</span> : null} + {stats.deletions > 0 ? <span style={{ color: COLORS.danger }}>-{stats.deletions}</span> : null} + </button> + ); + if (!collapsed) rows.push(...renderChangeTreeNode(dir, mode, depth + 1, statsByPath)); + } + + for (const file of files) { + rows.push( + <div key={`${mode}:wrap:${file.path}`} style={{ paddingLeft: depth * 14 }}> + {renderFileRow(file, mode)} + </div> + ); + } + + return rows; + }; + + const renderChangeTree = (files: FileChange[], mode: "staged" | "unstaged", statsByPath: Map<string, ChangeTreeStats>) => { + const tree = buildChangeTree(files); + return renderChangeTreeNode(tree, mode, 0, statsByPath); + }; + const diffViewActive = Boolean( (selectedPath && selectedMode) || selectedCommit, ); @@ -1436,6 +1576,9 @@ export function LaneGitActionsPane({ > {lane?.name ?? "NO LANE"} </span> + {lane?.linearIssue ? ( + <LinearIssueBadge issue={lane.linearIssue} /> + ) : null} {lane ? ( <> <span @@ -2559,7 +2702,7 @@ export function LaneGitActionsPane({ {changes.staged.length > 0 ? ( <div style={{ display: "flex", flexDirection: "column", gap: 2 }}> <div style={{ padding: "0 8px 4px", ...LABEL_STYLE }}>STAGED ({changes.staged.length})</div> - {visibleStagedChanges.map((file) => renderFileRow(file, "staged"))} + {renderChangeTree(visibleStagedChanges, "staged", stagedChangeTreeStatsByPath)} {hiddenStagedChangeCount > 0 ? ( <div style={{ padding: "6px 8px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textDim, display: "flex", alignItems: "center", gap: 8 }}> <span>Showing first {MAX_RENDERED_CHANGE_ROWS_PER_SECTION} of {changes.staged.length} staged files.</span> @@ -2577,7 +2720,7 @@ export function LaneGitActionsPane({ {changes.unstaged.length > 0 ? ( <div style={{ display: "flex", flexDirection: "column", gap: 2 }}> <div style={{ padding: "0 8px 4px", ...LABEL_STYLE }}>UNSTAGED ({changes.unstaged.length})</div> - {visibleUnstagedChanges.map((file) => renderFileRow(file, "unstaged"))} + {renderChangeTree(visibleUnstagedChanges, "unstaged", unstagedChangeTreeStatsByPath)} {hiddenUnstagedChangeCount > 0 ? ( <div style={{ padding: "6px 8px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textDim, display: "flex", alignItems: "center", gap: 8 }}> <span>Showing first {MAX_RENDERED_CHANGE_ROWS_PER_SECTION} of {changes.unstaged.length} unstaged files.</span> diff --git a/apps/desktop/src/renderer/components/lanes/LaneStackPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneStackPane.tsx index 6012bbac8..1469fc266 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneStackPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneStackPane.tsx @@ -1,12 +1,13 @@ import React from "react"; import { ArrowSquareOut, GitMerge } from "@phosphor-icons/react"; import { useNavigate } from "react-router-dom"; -import type { LaneSummary } from "../../../shared/types"; +import type { LaneLinearIssue, LaneSummary } from "../../../shared/types"; import type { IntegrationLaneSource } from "../../lib/integrationLanes"; import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, outlineButton } from "./laneDesignTokens"; import { logRendererDebugEvent } from "../../lib/debugLog"; import { SmartTooltip } from "../ui/SmartTooltip"; import { LaneAccentDot } from "./LaneAccentDot"; +import { LinearIssueBadge } from "./LinearIssueBadge"; const TREE_ROW_H = 34; const TREE_INDENT = 22; @@ -73,12 +74,14 @@ function StackGraph({ onSelect, runtimeByLaneId, integrationSourcesByLaneId, + onStartChatWithLinearIssue, }: { lanes: LaneSummary[]; selectedLaneId: string | null; onSelect: (id: string) => void; runtimeByLaneId: LaneRuntimeMap; integrationSourcesByLaneId: Map<string, IntegrationLaneSource[]>; + onStartChatWithLinearIssue?: (laneId: string, issue: LaneLinearIssue) => void; }) { const layout = React.useMemo(() => { const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); @@ -258,6 +261,13 @@ function StackGraph({ > <LaneRuntimeDot bucket={runtimeByLaneId.get(lane.id)?.bucket ?? "none"} /> {lane.color ? <LaneAccentDot lane={lane} size={7} /> : null} + {lane.linearIssue ? ( + <LinearIssueBadge + issue={lane.linearIssue} + compact + onStartChatWithIssue={() => onStartChatWithLinearIssue?.(lane.id, lane.linearIssue!)} + /> + ) : null} <span className="flex flex-col items-start min-w-0" style={{ maxWidth: integrationSources.length > 0 ? 120 : 160, lineHeight: 1.05, @@ -307,12 +317,14 @@ export function LaneStackPane({ onSelect, runtimeByLaneId, integrationSourcesByLaneId, + onStartChatWithLinearIssue, }: { lanes: LaneSummary[]; selectedLaneId: string | null; onSelect: (id: string) => void; runtimeByLaneId: LaneRuntimeMap; integrationSourcesByLaneId?: Map<string, IntegrationLaneSource[]>; + onStartChatWithLinearIssue?: (laneId: string, issue: LaneLinearIssue) => void; }) { const navigate = useNavigate(); React.useEffect(() => { @@ -393,6 +405,7 @@ export function LaneStackPane({ onSelect={onSelect} runtimeByLaneId={runtimeByLaneId} integrationSourcesByLaneId={effectiveIntegrationSourcesByLaneId} + onStartChatWithLinearIssue={onStartChatWithLinearIssue} /> </div> ); diff --git a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx index 0f9dfdf0f..eafab2bed 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo } from "react"; import { ChatCircleText, Command, Terminal } from "@phosphor-icons/react"; +import type { LaneLinearIssue } from "../../../shared/types"; import type { WorkDraftKind } from "../../state/appStore"; import { EmptyState } from "../ui/EmptyState"; import { SmartTooltip } from "../ui/SmartTooltip"; @@ -50,8 +51,12 @@ const ENTRY_OPTIONS: Array<{ export function LaneWorkPane({ laneId, + initialLinearIssueContext = null, + onInitialLinearIssueContextConsumed, }: { laneId: string | null; + initialLinearIssueContext?: LaneLinearIssue | null; + onInitialLinearIssueContextConsumed?: () => void; }) { const work = useLaneWorkSessions(laneId); const laneList = work.lane ? [work.lane] : []; @@ -161,6 +166,8 @@ export function LaneWorkPane({ onLaunchPtySession={work.launchPtySession} onShowDraftKind={work.showDraftKind} closingPtyIds={work.closingPtyIds} + initialLinearIssueContext={initialLinearIssueContext} + onInitialLinearIssueContextConsumed={onInitialLinearIssueContextConsumed} /> </div> </div> diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 2a7cafe24..021292237 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -22,6 +22,7 @@ import { ManageLaneDialog } from "./ManageLaneDialog"; import { LaneContextMenu } from "./LaneContextMenu"; import { getLaneAccent } from "./laneColorPalette"; import { LaneRebaseBanner } from "./LaneRebaseBanner"; +import { LinearIssueBadge } from "./LinearIssueBadge"; import { HelpChip } from "../onboarding/HelpChip"; import { useOnboardingStore } from "../../state/onboardingStore"; import { useDialogBus } from "../../lib/useDialogBus"; @@ -50,6 +51,7 @@ import { formatPrBadgeLabel } from "../prs/shared/prFormatters"; import { getProjectConfigCached } from "../../lib/projectConfigCache"; import { logRendererDebugEvent } from "../../lib/debugLog"; import { branchNameFromLaneRef } from "../../../shared/laneBaseResolution"; +import { linearIssueBranchName, linearIssueLaneName } from "../../../shared/linearIssueBranch"; import type { BranchPullRequest, ConflictChip, @@ -59,6 +61,7 @@ import type { LaneEnvInitProgress, LaneBranchActiveWorkItem, LaneListSnapshot, + LaneLinearIssue, LaneSummary, PrSummary, RebaseRun, @@ -324,6 +327,7 @@ export function LanesPage() { const refreshLanes = useAppStore((s) => s.refreshLanes); const setLaneInspectorTab = useAppStore((s) => s.setLaneInspectorTab); const clearLaneInspectorTab = useAppStore((s) => s.clearLaneInspectorTab); + const setLaneWorkViewState = useAppStore((s) => s.setLaneWorkViewState); const keybindings = useAppStore((s) => s.keybindings); const project = useAppStore((s) => s.project); const activeTourId = useOnboardingStore((s) => s.activeTourId); @@ -356,6 +360,8 @@ export function LanesPage() { const [templates, setTemplates] = useState<LaneTemplate[]>([]); const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [createSelectedColor, setCreateSelectedColor] = useState<string | null>(null); + const [createSelectedLinearIssue, setCreateSelectedLinearIssue] = useState<LaneLinearIssue | null>(null); + const createLinearIssueAutoNameRef = useRef<string | null>(null); const [multiAttachOpen, setMultiAttachOpen] = useState(false); const [attachOpen, setAttachOpen] = useState(false); const [attachName, setAttachName] = useState(""); @@ -439,6 +445,11 @@ export function LanesPage() { const [expandedGitActionsLaneId, setExpandedGitActionsLaneId] = useState<string | null>(null); const [integrationProposals, setIntegrationProposals] = useState<IntegrationProposal[]>([]); const [lanePrTags, setLanePrTags] = useState<PrSummary[]>([]); + const [linearIssueChatContextRequest, setLinearIssueChatContextRequest] = useState<{ + laneId: string; + issue: LaneLinearIssue; + requestedAt: number; + } | null>(null); const laneSnapshots = useAppStore((s) => s.laneSnapshots); const consumedLaneIdsDeepLinkSignatureRef = useRef<string | null>(null); @@ -1526,6 +1537,27 @@ export function LanesPage() { selectLane(laneId); }, [deletingLaneIds, lanesById, pinnedLaneIds, activeWithPins, selectLane]); + const handleStartChatWithLinearIssue = useCallback((laneId: string, issue: LaneLinearIssue) => { + if (deletingLaneIds.has(laneId) || !lanesById.has(laneId)) return; + const pinned = Array.from(pinnedLaneIds).filter((id) => id !== laneId && lanesById.has(id) && !deletingLaneIds.has(id)); + setActiveLaneIds(mergeUnique([laneId], pinned)); + selectLane(laneId); + setStackGraphHeaderOpen(false); + setLaneWorkViewState(project?.rootPath ?? null, laneId, (prev) => ({ + ...prev, + draftKind: "chat", + viewMode: "tabs", + activeItemId: null, + selectedItemId: null, + })); + setLinearIssueChatContextRequest({ + laneId, + issue, + requestedAt: Date.now(), + }); + navigate(`/lanes?laneId=${encodeURIComponent(laneId)}`); + }, [deletingLaneIds, lanesById, navigate, pinnedLaneIds, project?.rootPath, selectLane, setLaneWorkViewState]); + const removeSplitLane = useCallback((laneId: string) => { if (pinnedLaneIds.has(laneId)) return; const pinned = Array.from(pinnedLaneIds).filter((id) => lanesById.has(id) && !deletingLaneIds.has(id)); @@ -1815,6 +1847,8 @@ export function LanesPage() { setCreateEnvInitProgress(null); setSelectedTemplateId(""); setCreateSelectedColor(null); + setCreateSelectedLinearIssue(null); + createLinearIssueAutoNameRef.current = null; }, []); const prepareCreateDialog = useCallback(() => { @@ -1827,6 +1861,8 @@ export function LanesPage() { setCreateBranches([]); setCreateBranchPullRequests([]); setCreateGitUserName(""); + setCreateSelectedLinearIssue(null); + createLinearIssueAutoNameRef.current = null; setCreateBranchesLoading(false); setCreateBranchPullRequestsLoading(false); setLaneCreated(false); @@ -1975,6 +2011,25 @@ export function LanesPage() { setCreateBaseBranch(v); }, []); + const handleSetCreateLinearIssue = useCallback((issue: LaneLinearIssue | null) => { + setCreateSelectedLinearIssue(issue); + if (!issue) return; + + const nextName = linearIssueLaneName(issue); + setCreateLaneName((current) => { + const trimmed = current.trim(); + const previousAutoName = createLinearIssueAutoNameRef.current; + if (!trimmed || (previousAutoName && trimmed === previousAutoName)) { + createLinearIssueAutoNameRef.current = nextName; + return nextName; + } + createLinearIssueAutoNameRef.current = nextName; + return current; + }); + setCreateImportBranch(""); + setCreateMode((mode) => mode === "existing" ? "primary" : mode); + }, []); + /** Run only the environment-setup phase (applyTemplate / initEnv) for a lane * that has already been created. Used as the retry path when env setup fails. */ const runEnvSetupForCreatedLane = useCallback(async (laneId: string) => { @@ -2015,6 +2070,10 @@ export function LanesPage() { if (createMode === "child" && !createParentLaneId) return; if (createMode === "primary" && !createBaseBranch) return; if (createMode === "existing" && !createImportBranch) return; + if (createSelectedLinearIssue && createMode === "existing") { + setCreateError("Detach the Linear issue before importing an existing branch."); + return; + } if (selectedTemplateId && !templates.some((template) => template.id === selectedTemplateId)) { setCreateError("The selected lane template no longer exists. Refresh templates or choose a different option."); return; @@ -2032,6 +2091,15 @@ export function LanesPage() { createBaseBranch, createImportBranch, }); + const linearIssueArgs = createSelectedLinearIssue + ? { + linearIssue: { + ...createSelectedLinearIssue, + branchName: linearIssueBranchName(createSelectedLinearIssue), + }, + branchName: linearIssueBranchName(createSelectedLinearIssue), + } + : {}; let lane: LaneSummary; if (request.kind === "import") { lane = await window.ade.lanes.importBranch(request.args); @@ -2044,11 +2112,11 @@ export function LanesPage() { return; } const childArgs = trimmedBase && trimmedBase !== parentLane.branchRef - ? { ...request.args, baseBranchRef: trimmedBase } - : request.args; + ? { ...request.args, baseBranchRef: trimmedBase, ...linearIssueArgs } + : { ...request.args, ...linearIssueArgs }; lane = await window.ade.lanes.createChild(childArgs); } else { - lane = await window.ade.lanes.create(request.args); + lane = await window.ade.lanes.create({ ...request.args, ...linearIssueArgs }); } // Lane created successfully — record its id so retries skip creation. @@ -2072,7 +2140,7 @@ export function LanesPage() { setCreateError(err instanceof Error ? err.message : String(err)); setCreateBusy(false); } - }, [createLaneName, createMode, createParentLaneId, createBaseBranch, createImportBranch, createChildBaseBranch, lanes, createBusy, navigate, refreshLanes, runEnvSetupForCreatedLane, selectedTemplateId, templates, createSelectedColor]); + }, [createLaneName, createMode, createParentLaneId, createBaseBranch, createImportBranch, createChildBaseBranch, lanes, createBusy, navigate, refreshLanes, runEnvSetupForCreatedLane, selectedTemplateId, templates, createSelectedColor, createSelectedLinearIssue]); const handleAttachSubmit = useCallback(async () => { const name = attachName.trim(); @@ -2148,6 +2216,10 @@ export function LanesPage() { const getPaneConfigs = useCallback((laneId: string | null) => { const laneDetail = laneId ? lanePaneDetails[laneId] ?? EMPTY_LANE_PANE_DETAIL : EMPTY_LANE_PANE_DETAIL; const laneSnapshot = laneId ? laneSnapshotByLaneId.get(laneId) ?? null : null; + const pendingLinearIssueContext = + laneId && linearIssueChatContextRequest?.laneId === laneId + ? linearIssueChatContextRequest + : null; return { "git-actions": { title: "Git Actions", @@ -2206,7 +2278,22 @@ export function LanesPage() { hideHeaderWhenExpanded: true, children: ( <DeferredLanePane cacheKey={`work:${laneId ?? "none"}`} label="work"> - <LaneWorkPane laneId={laneId} /> + <LaneWorkPane + laneId={laneId} + initialLinearIssueContext={pendingLinearIssueContext?.issue ?? null} + onInitialLinearIssueContextConsumed={ + pendingLinearIssueContext + ? () => { + setLinearIssueChatContextRequest((current) => ( + current?.laneId === pendingLinearIssueContext.laneId + && current.requestedAt === pendingLinearIssueContext.requestedAt + ? null + : current + )); + } + : undefined + } + /> </DeferredLanePane> ) }, @@ -2214,6 +2301,7 @@ export function LanesPage() { }, [ lanePaneDetails, laneSnapshotByLaneId, + linearIssueChatContextRequest, expandedGitActionsLaneId, autoRebaseEnabled, openAutoRebaseSettings, @@ -2755,6 +2843,7 @@ export function LanesPage() { }} runtimeByLaneId={laneRuntimeById} integrationSourcesByLaneId={integrationSourcesByLaneId} + onStartChatWithLinearIssue={handleStartChatWithLinearIssue} /> </div> ) : null} @@ -2970,6 +3059,13 @@ export function LanesPage() { fontWeight: isSelected ? 600 : 500, color: isSelected ? COLORS.textPrimary : COLORS.textMuted, }}>{lane.name}</span> + {!isDeleting && lane.linearIssue ? ( + <LinearIssueBadge + issue={lane.linearIssue} + compact + onStartChatWithIssue={() => handleStartChatWithLinearIssue(lane.id, lane.linearIssue!)} + /> + ) : null} {!isDeleting && lanePr ? ( <button type="button" @@ -3368,6 +3464,8 @@ export function LanesPage() { setSelectedTemplateId={setSelectedTemplateId} selectedColor={createSelectedColor} setSelectedColor={setCreateSelectedColor} + selectedLinearIssue={createSelectedLinearIssue} + setSelectedLinearIssue={handleSetCreateLinearIssue} branchPullRequests={createBranchPullRequests} currentGitUserName={createGitUserName} loadingBranches={createBranchesLoading} diff --git a/apps/desktop/src/renderer/components/lanes/LinearIssueBadge.test.tsx b/apps/desktop/src/renderer/components/lanes/LinearIssueBadge.test.tsx new file mode 100644 index 000000000..c44ed417a --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/LinearIssueBadge.test.tsx @@ -0,0 +1,96 @@ +/* @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { LaneLinearIssue } from "../../../shared/types"; +import { LinearIssueBadge } from "./LinearIssueBadge"; + +function makeIssue(overrides: Partial<LaneLinearIssue> = {}): LaneLinearIssue { + return { + id: "issue-1", + identifier: "ADE-123", + title: "Connect lane to Linear issue", + description: null, + url: "https://linear.app/ade/issue/ADE-123/connect-lane-to-linear-issue", + projectId: "project-1", + projectSlug: "ade", + projectName: "ADE", + teamId: "team-1", + teamKey: "ADE", + teamName: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 2, + priorityLabel: "high", + labels: ["desktop"], + assigneeId: "user-1", + assigneeName: "Arul", + creatorId: null, + creatorName: null, + dueDate: null, + estimate: null, + branchName: "ade-123-connect-lane-to-linear-issue", + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + ...overrides, + }; +} + +describe("LinearIssueBadge", () => { + afterEach(() => { + cleanup(); + Reflect.deleteProperty(window, "ade"); + vi.restoreAllMocks(); + }); + + it("copies the Linear issue link to the clipboard", async () => { + const writeClipboardText = vi.fn(async () => undefined); + Object.defineProperty(window, "ade", { + configurable: true, + value: { app: { writeClipboardText } }, + }); + + const issue = makeIssue(); + render(<LinearIssueBadge issue={issue} />); + + fireEvent.click(screen.getByRole("button", { name: /copy link/i })); + + await waitFor(() => { + expect(writeClipboardText).toHaveBeenCalledWith(issue.url); + }); + expect(screen.getByRole("button", { name: /copied/i })).toBeTruthy(); + }); + + it("starts a new chat with issue context from the hover card", () => { + Object.defineProperty(window, "ade", { + configurable: true, + value: { app: { writeClipboardText: vi.fn(async () => undefined), openExternal: vi.fn(async () => undefined) } }, + }); + const onStartChatWithIssue = vi.fn(); + + render(<LinearIssueBadge issue={makeIssue()} onStartChatWithIssue={onStartChatWithIssue} />); + + fireEvent.click(screen.getByRole("button", { name: /start chat with context/i })); + + expect(onStartChatWithIssue).toHaveBeenCalledTimes(1); + }); + + it("opens only http or https Linear links externally", () => { + const openExternal = vi.fn(async () => undefined); + Object.defineProperty(window, "ade", { + configurable: true, + value: { app: { openExternal } }, + }); + + const { rerender } = render(<LinearIssueBadge issue={makeIssue()} />); + + fireEvent.click(screen.getByRole("button", { name: /open in linear/i })); + expect(openExternal).toHaveBeenCalledWith("https://linear.app/ade/issue/ADE-123/connect-lane-to-linear-issue"); + + openExternal.mockClear(); + rerender(<LinearIssueBadge issue={makeIssue({ url: "javascript:alert(1)" })} />); + fireEvent.click(screen.getByRole("button", { name: /open in linear/i })); + expect(openExternal).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/renderer/components/lanes/LinearIssueBadge.tsx b/apps/desktop/src/renderer/components/lanes/LinearIssueBadge.tsx new file mode 100644 index 000000000..c27bb7057 --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/LinearIssueBadge.tsx @@ -0,0 +1,240 @@ +import React from "react"; +import { ArrowSquareOut, ChatCircleText, Check, Clipboard, WarningCircle } from "@phosphor-icons/react"; +import type { LaneLinearIssue } from "../../../shared/types"; +import { COLORS, MONO_FONT } from "./laneDesignTokens"; +import { LinearMark, LinearPriorityIcon, LinearStateIcon, LINEAR_BRAND } from "./linearBrand"; + +function priorityLabel(issue: LaneLinearIssue): string { + if (issue.priorityLabel === "none" || !issue.priorityLabel) return "No priority"; + return issue.priorityLabel[0]!.toUpperCase() + issue.priorityLabel.slice(1); +} + +function isSafeExternalUrl(value: string | null | undefined): value is string { + if (!value) return false; + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +export function LinearIssueBadge({ + issue, + compact = false, + onStartChatWithIssue, +}: { + issue: LaneLinearIssue; + compact?: boolean; + onStartChatWithIssue?: () => void; +}) { + const [copyState, setCopyState] = React.useState<"idle" | "copied" | "error">("idle"); + const project = issue.projectName?.trim() || issue.projectSlug; + + React.useEffect(() => { + if (copyState === "idle") return undefined; + const timer = window.setTimeout(() => setCopyState("idle"), 1800); + return () => window.clearTimeout(timer); + }, [copyState]); + + const handleCopyIssueLink = React.useCallback(async (event: React.MouseEvent<HTMLButtonElement>) => { + event.preventDefault(); + event.stopPropagation(); + if (!issue.url) return; + try { + if (window.ade?.app?.writeClipboardText) { + await window.ade.app.writeClipboardText(issue.url); + } else if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(issue.url); + } else { + throw new Error("Clipboard access is not available."); + } + setCopyState("copied"); + } catch { + setCopyState("error"); + } + }, [issue.url]); + + const handleStartChatWithIssue = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => { + event.preventDefault(); + event.stopPropagation(); + onStartChatWithIssue?.(); + }, [onStartChatWithIssue]); + + return ( + <span className="group relative inline-flex shrink-0" onClick={(event) => event.stopPropagation()}> + <span + tabIndex={0} + role="button" + aria-label={`${issue.identifier}: ${issue.title}`} + className="inline-flex items-center gap-1 rounded-md border outline-none focus-visible:ring-2 focus-visible:ring-white/20" + style={{ + borderColor: LINEAR_BRAND.borderSubtle, + background: LINEAR_BRAND.surface, + color: LINEAR_BRAND.text, + fontFamily: MONO_FONT, + fontSize: compact ? 9.5 : 10.5, + fontWeight: 600, + letterSpacing: "0.02em", + padding: compact ? "1.5px 5px" : "2px 6px", + }} + title={`${issue.identifier}: ${issue.title}`} + > + <LinearMark size={compact ? 9 : 11} /> + {!compact ? issue.identifier : null} + </span> + <span + className="pointer-events-none invisible absolute left-1/2 top-full z-[80] mt-2 w-[280px] -translate-x-1/2 overflow-hidden rounded-xl border opacity-0 shadow-xl transition-opacity group-hover:pointer-events-auto group-hover:visible group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:visible group-focus-within:opacity-100" + style={{ + borderColor: LINEAR_BRAND.borderSubtle, + background: COLORS.cardBg, + color: COLORS.textSecondary, + backdropFilter: "blur(20px)", + }} + > + {/* Linear-branded header strip */} + <span + className="flex items-center gap-2 border-b px-3 py-2" + style={{ + borderColor: "rgba(255,255,255,0.05)", + background: LINEAR_BRAND.surface, + }} + > + <span + className="flex h-5 w-5 items-center justify-center rounded" + style={{ background: LINEAR_BRAND.surfaceHover, color: LINEAR_BRAND.primaryBright }} + > + <LinearMark size={11} /> + </span> + <span + className="text-[11px] font-semibold tracking-tight" + style={{ color: LINEAR_BRAND.text }} + > + Linear + </span> + <span className="ml-auto font-mono text-[10px] text-muted-fg/55">{issue.teamKey}</span> + </span> + + <span className="block px-3 py-2.5"> + <span className="flex items-center gap-1.5"> + <LinearPriorityIcon priority={issue.priority} size={11} /> + <LinearStateIcon stateType={issue.stateType} size={11} /> + <span + className="rounded font-mono text-[10px] font-semibold" + style={{ + color: LINEAR_BRAND.text, + background: LINEAR_BRAND.surface, + padding: "1.5px 5px", + }} + > + {issue.identifier} + </span> + </span> + <span className="mt-1.5 block text-[12.5px] font-semibold leading-snug text-fg">{issue.title}</span> + + <span className="mt-2.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[10.5px]"> + <span className="text-muted-fg/45">Project</span> + <span className="truncate text-fg/85">{project}</span> + <span className="text-muted-fg/45">Status</span> + <span className="truncate text-fg/85">{issue.stateName}</span> + <span className="text-muted-fg/45">Priority</span> + <span className="truncate text-fg/85">{priorityLabel(issue)}</span> + <span className="text-muted-fg/45">Assignee</span> + <span className="truncate text-fg/85">{issue.assigneeName ?? "Unassigned"}</span> + </span> + + {issue.branchName ? ( + <span className="mt-2.5 block"> + <span className="text-[10px] uppercase tracking-[0.10em] text-muted-fg/45">Branch</span> + <span + className="mt-1 block truncate rounded font-mono text-[10px] text-fg/85" + style={{ background: "rgba(0,0,0,0.30)", padding: "4px 6px" }} + > + {issue.branchName} + </span> + </span> + ) : null} + + {issue.labels.length > 0 ? ( + <span className="mt-2.5 flex flex-wrap gap-1"> + {issue.labels.slice(0, 4).map((label) => ( + <span + key={label} + className="rounded-full px-2 py-0.5 text-[9.5px] text-muted-fg/80" + style={{ background: "rgba(255,255,255,0.04)" }} + > + {label} + </span> + ))} + </span> + ) : null} + </span> + + {(onStartChatWithIssue || issue.url) ? ( + <span + className="flex items-center gap-1.5 border-t px-3 py-2" + style={{ borderColor: "rgba(255,255,255,0.05)", background: "rgba(0,0,0,0.20)" }} + > + {onStartChatWithIssue ? ( + <button + type="button" + className="inline-flex h-6 flex-1 items-center justify-center gap-1 rounded text-[10.5px] font-medium transition-colors" + style={{ + color: LINEAR_BRAND.text, + background: LINEAR_BRAND.surface, + border: `1px solid ${LINEAR_BRAND.borderSubtle}`, + }} + title="Start a new chat with this Linear issue attached" + onMouseDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={handleStartChatWithIssue} + > + <ChatCircleText size={11} weight="fill" /> + Start chat with context + </button> + ) : null} + {issue.url ? ( + <> + <button + 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)", + background: copyState === "copied" ? "rgba(34,197,94,0.10)" : "transparent", + }} + title="Copy Linear issue link" + onMouseDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + 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"} + </button> + <button + type="button" + className="inline-flex h-6 items-center justify-center rounded px-1.5 text-muted-fg/65 transition-colors hover:bg-white/[0.06] hover:text-fg" + title="Open in Linear" + onMouseDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + if (isSafeExternalUrl(issue.url)) window.ade?.app?.openExternal?.(issue.url); + }} + > + <ArrowSquareOut size={11} weight="bold" /> + </button> + </> + ) : null} + </span> + ) : null} + </span> + </span> + ); +} diff --git a/apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx b/apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx new file mode 100644 index 000000000..d280b89f4 --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx @@ -0,0 +1,725 @@ +import React from "react"; +import { ArrowSquareOut, CaretDown, Check, CircleNotch, MagnifyingGlass, X } from "@phosphor-icons/react"; +import { Button } from "../ui/Button"; +import type { + CtoGetLinearIssuePickerDataResult, + LaneLinearIssue, + NormalizedLinearIssue, +} from "../../../shared/types"; +import { linearIssueBranchName } from "../../../shared/linearIssueBranch"; +import { formatRelativeTime } from "./branchPickerSearch"; +import type { LaneBranchOption } from "./laneUtils"; +import { LABEL_CLASS_NAME } from "./laneDialogTokens"; +import { LinearMark, LinearPriorityIcon, LinearStateIcon, LINEAR_BRAND } from "./linearBrand"; + +const ACTIVE_LINEAR_STATE_TYPES = ["backlog", "unstarted", "started"] as const; + +const PRIORITY_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [ + { value: "", label: "Any priority" }, + { value: "1", label: "Urgent" }, + { value: "2", label: "High" }, + { value: "3", label: "Medium" }, + { value: "4", label: "Low" }, + { value: "0", label: "No priority" }, +]; + +const STATE_PRESET_LABELS: Record<string, string> = { + active: "Active", + all: "All states", + backlog: "Backlog", + unstarted: "Todo", + started: "In progress", + completed: "Done", + canceled: "Canceled", + triage: "Triage", +}; + +export function linearPriorityLabel(issue: Pick<NormalizedLinearIssue | LaneLinearIssue, "priorityLabel" | "priority">): string { + if (issue.priorityLabel === "none" || !issue.priorityLabel) return "No priority"; + return issue.priorityLabel[0]!.toUpperCase() + issue.priorityLabel.slice(1); +} + +export function issueProjectLabel(issue: Pick<NormalizedLinearIssue | LaneLinearIssue, "projectName" | "projectSlug" | "teamKey">): string { + return issue.projectName?.trim() || issue.projectSlug || issue.teamKey; +} + +export function issueUpdatedLabel(issue: Pick<NormalizedLinearIssue | LaneLinearIssue, "updatedAt">): string { + return formatRelativeTime(issue.updatedAt) || "Updated recently"; +} + +export function toLaneLinearIssue(issue: NormalizedLinearIssue): LaneLinearIssue { + const branchName = linearIssueBranchName(issue); + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description, + url: issue.url, + projectId: issue.projectId, + projectSlug: issue.projectSlug, + projectName: issue.projectName ?? null, + teamId: issue.teamId, + teamKey: issue.teamKey, + teamName: issue.teamName ?? null, + stateId: issue.stateId, + stateName: issue.stateName, + stateType: issue.stateType, + priority: issue.priority, + priorityLabel: issue.priorityLabel, + labels: issue.labels, + assigneeId: issue.assigneeId, + assigneeName: issue.assigneeName, + creatorId: issue.creatorId ?? null, + creatorName: issue.creatorName ?? null, + dueDate: issue.dueDate ?? null, + estimate: issue.estimate ?? null, + branchName, + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + }; +} + +function isLaneLinearIssue(issue: NormalizedLinearIssue | LaneLinearIssue): issue is LaneLinearIssue { + return !("raw" in issue); +} + +export function branchExistsForLinearIssue(branchName: string, branches: LaneBranchOption[]): boolean { + const normalized = branchName.trim().toLowerCase(); + if (!normalized) return false; + return branches.some((branch) => { + const candidate = branch.name.trim().toLowerCase(); + const withoutRemote = candidate.replace(/^[^/]+\//, ""); + return candidate === normalized || withoutRemote === normalized; + }); +} + +function LinearIdentifierTag({ identifier, size = "sm" }: { identifier: string; size?: "xs" | "sm" }) { + const fontSize = size === "xs" ? 9.5 : 10.5; + return ( + <span + className="shrink-0 rounded font-mono font-semibold" + style={{ + fontSize, + color: LINEAR_BRAND.text, + background: LINEAR_BRAND.surface, + padding: size === "xs" ? "1px 4px" : "1.5px 5px", + letterSpacing: "0.02em", + }} + > + {identifier} + </span> + ); +} + +export function LinearIssueSummaryCard({ + issue, + branchName, + branchConflict, + onClear, +}: { + issue: LaneLinearIssue; + branchName: string; + branchConflict: boolean; + onClear: () => void; +}) { + return ( + <div + className="mt-2 rounded-lg border p-3" + style={{ borderColor: LINEAR_BRAND.borderSubtle, background: LINEAR_BRAND.surface }} + > + <div className="flex items-start gap-3"> + <span + className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md" + style={{ background: LINEAR_BRAND.surfaceHover, color: LINEAR_BRAND.primaryBright }} + > + <LinearMark size={13} /> + </span> + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2"> + <LinearPriorityIcon priority={issue.priority} size={12} /> + <LinearStateIcon stateType={issue.stateType} size={12} /> + <LinearIdentifierTag identifier={issue.identifier} /> + <span className="truncate text-sm font-semibold text-fg">{issue.title}</span> + </div> + <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-fg/65"> + <span>{issueProjectLabel(issue)}</span> + <span className="opacity-40">·</span> + <span>{issue.stateName}</span> + {issue.assigneeName ? ( + <> + <span className="opacity-40">·</span> + <span>{issue.assigneeName}</span> + </> + ) : null} + </div> + <div + className="mt-2 flex items-center gap-1.5 font-mono text-[10.5px]" + style={{ color: branchConflict ? "#FBBF24" : "rgba(148, 163, 184, 0.85)" }} + > + <span className="opacity-60">branch</span> + <span className="text-fg/85">{branchName}</span> + {branchConflict ? <span className="opacity-80">already exists</span> : null} + </div> + </div> + <button + type="button" + className="rounded-md p-1 text-muted-fg/60 transition-colors hover:bg-white/[0.06] hover:text-fg" + onClick={onClear} + aria-label="Disconnect Linear issue" + > + <X size={14} /> + </button> + </div> + </div> + ); +} + +export function LinearIssueRow({ + issue, + active, + eyebrow, + busy, + onClick, +}: { + issue: NormalizedLinearIssue | LaneLinearIssue; + active: boolean; + eyebrow?: string; + busy?: boolean; + onClick: () => void; +}) { + return ( + <button + type="button" + className="group flex w-full items-center gap-2.5 border-b border-white/[0.04] px-3 py-2 text-left transition-colors last:border-b-0 disabled:opacity-50" + style={active ? { background: LINEAR_BRAND.surface } : undefined} + onMouseEnter={(event) => { + if (!active) (event.currentTarget as HTMLButtonElement).style.background = "rgba(255,255,255,0.03)"; + }} + onMouseLeave={(event) => { + if (!active) (event.currentTarget as HTMLButtonElement).style.background = ""; + }} + onClick={onClick} + disabled={busy} + > + <span className="flex w-3.5 shrink-0 justify-center"> + <LinearPriorityIcon priority={issue.priority} size={13} /> + </span> + <span className="flex w-3.5 shrink-0 justify-center"> + <LinearStateIcon stateType={issue.stateType} size={13} /> + </span> + <LinearIdentifierTag identifier={issue.identifier} /> + <span className="min-w-0 flex-1"> + {eyebrow ? ( + <span + className="mb-0.5 block font-mono text-[9px] font-bold uppercase tracking-[0.14em]" + style={{ color: LINEAR_BRAND.textMuted }} + > + {eyebrow} + </span> + ) : null} + <span className="block truncate text-[13px] text-fg">{issue.title}</span> + </span> + <span className="hidden shrink-0 items-center gap-2 text-[10.5px] text-muted-fg/55 sm:flex"> + {issue.assigneeName ? ( + <span + className="grid h-5 w-5 place-items-center rounded-full bg-white/[0.07] text-[9px] font-semibold uppercase text-muted-fg/85" + title={issue.assigneeName} + > + {issue.assigneeName.slice(0, 1)} + </span> + ) : null} + <span className="tabular-nums">{issueUpdatedLabel(issue)}</span> + </span> + </button> + ); +} + +function FilterPill({ + label, + value, + options, + onChange, + busy, + width = "8.5rem", +}: { + label: string; + value: string; + options: ReadonlyArray<{ value: string; label: string }>; + onChange: (value: string) => void; + busy?: boolean; + width?: string; +}) { + const selected = options.find((option) => option.value === value); + return ( + <span className="relative inline-flex"> + <select + value={value} + onChange={(event) => onChange(event.target.value)} + disabled={busy} + className="appearance-none rounded-full border bg-transparent pr-7 text-[11px] text-fg outline-none transition-colors disabled:opacity-60" + aria-label={label} + style={{ + height: 28, + paddingLeft: 12, + minWidth: width, + borderColor: "rgba(255,255,255,0.08)", + backgroundColor: "rgba(255,255,255,0.03)", + }} + > + {options.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> + <CaretDown + size={9} + weight="bold" + className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-fg/60" + /> + {/* Visually-hidden label for screen readers */} + <span className="sr-only"> + {label}: {selected?.label ?? value} + </span> + </span> + ); +} + +export function LinearIssuePickerView({ + selectedIssue, + pinnedIssue, + pinnedIssueLabel = "Linked to this lane", + onSelect, + onBack, + onOpenLinearSettings, + busy, + selectOnIssueClick = false, + submitLabel = "Connect issue", +}: { + selectedIssue: LaneLinearIssue | null; + pinnedIssue?: LaneLinearIssue | null; + pinnedIssueLabel?: string; + onSelect: (issue: LaneLinearIssue) => void; + onBack: () => void; + onOpenLinearSettings?: () => void; + busy?: boolean; + selectOnIssueClick?: boolean; + submitLabel?: string; +}) { + const [catalog, setCatalog] = React.useState<CtoGetLinearIssuePickerDataResult>({ + projects: [], + users: [], + states: [], + }); + const [projectId, setProjectId] = React.useState(""); + const [statePreset, setStatePreset] = React.useState<"active" | "all" | string>("active"); + const [assigneeId, setAssigneeId] = React.useState(""); + const [priority, setPriority] = React.useState(""); + const [query, setQuery] = React.useState(""); + const [issues, setIssues] = React.useState<NormalizedLinearIssue[]>([]); + const [pendingIssue, setPendingIssue] = React.useState<NormalizedLinearIssue | LaneLinearIssue | null>(pinnedIssue ?? selectedIssue ?? null); + const [pageInfo, setPageInfo] = React.useState<{ hasNextPage: boolean; endCursor: string | null }>({ + hasNextPage: false, + endCursor: null, + }); + const pageInfoRef = React.useRef(pageInfo); + const [loadingCatalog, setLoadingCatalog] = React.useState(false); + const [loadingIssues, setLoadingIssues] = React.useState(false); + const [error, setError] = React.useState<string | null>(null); + const requestIdRef = React.useRef(0); + + React.useEffect(() => { + pageInfoRef.current = pageInfo; + }, [pageInfo]); + + React.useEffect(() => { + setPendingIssue((current) => current ?? pinnedIssue ?? selectedIssue ?? null); + }, [pinnedIssue, selectedIssue]); + + React.useEffect(() => { + let cancelled = false; + const cto = window.ade.cto; + if (!cto) { + setError("Linear controls are not available in this ADE surface."); + return () => { + cancelled = true; + }; + } + setLoadingCatalog(true); + setError(null); + cto.getLinearIssuePickerData() + .then((data) => { + if (cancelled) return; + setCatalog(data); + if (data.projects.length === 1) setProjectId(data.projects[0].id); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : "Unable to load Linear issue filters."); + }) + .finally(() => { + if (!cancelled) setLoadingCatalog(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const stateTypes = React.useMemo(() => { + if (statePreset === "all") return []; + if (statePreset === "active") return [...ACTIVE_LINEAR_STATE_TYPES]; + return [statePreset]; + }, [statePreset]); + + const searchIssues = React.useCallback(async (append: boolean) => { + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + setLoadingIssues(true); + setError(null); + try { + const cto = window.ade.cto; + if (!cto) throw new Error("Linear controls are not available in this ADE surface."); + const result = await cto.searchLinearIssues({ + projectId: projectId || null, + stateTypes, + assigneeId: assigneeId || null, + priority: priority ? Number(priority) : null, + query: query.trim() || null, + first: 50, + after: append ? pageInfoRef.current.endCursor : null, + }); + if (requestIdRef.current !== requestId) return; + setIssues((current) => append ? [...current, ...result.issues] : result.issues); + setPageInfo(result.pageInfo); + setPendingIssue((current) => { + if (append && current) { + return current; + } + if (current && result.issues.some((issue) => issue.id === current.id)) { + return current; + } + const selectedMatch = selectedIssue + ? result.issues.find((issue) => issue.id === selectedIssue.id) ?? null + : null; + return pinnedIssue ?? selectedMatch ?? result.issues[0] ?? null; + }); + } catch (err) { + if (requestIdRef.current === requestId) { + setError(err instanceof Error ? err.message : "Unable to search Linear issues."); + } + } finally { + if (requestIdRef.current === requestId) setLoadingIssues(false); + } + }, [assigneeId, priority, projectId, query, selectedIssue, stateTypes, pinnedIssue]); + + React.useEffect(() => { + const timer = window.setTimeout(() => { + void searchIssues(false); + }, 180); + return () => window.clearTimeout(timer); + }, [searchIssues]); + + const selectedProject = catalog.projects.find((project) => project.id === projectId) ?? null; + const issueForDetails = pendingIssue ?? selectedIssue; + + const projectOptions = React.useMemo( + () => [ + { value: "", label: "All projects" }, + ...catalog.projects.map((project) => ({ + value: project.id, + label: project.teamName ? `${project.name} · ${project.teamName}` : project.name, + })), + ], + [catalog.projects], + ); + + const assigneeOptions = React.useMemo( + () => [ + { value: "", label: "Anyone" }, + ...catalog.users.map((user) => ({ + value: user.id, + label: user.displayName ?? user.name, + })), + ], + [catalog.users], + ); + + const stateOptions = React.useMemo(() => { + const seen = new Set<string>(); + const presetEntries = [ + { value: "active", label: STATE_PRESET_LABELS.active! }, + { value: "all", label: STATE_PRESET_LABELS.all! }, + ]; + const dynamic = catalog.states + .filter((state) => { + if (seen.has(state.type)) return false; + seen.add(state.type); + return true; + }) + .sort((left, right) => left.type.localeCompare(right.type)) + .map((state) => ({ + value: state.type, + label: STATE_PRESET_LABELS[state.type] ?? state.type, + })); + return [...presetEntries, ...dynamic]; + }, [catalog.states]); + + const handleChooseIssue = React.useCallback((issue: NormalizedLinearIssue | LaneLinearIssue) => { + if (selectOnIssueClick) { + onSelect(isLaneLinearIssue(issue) ? issue : toLaneLinearIssue(issue)); + onBack(); + return; + } + setPendingIssue(issue); + }, [onBack, onSelect, selectOnIssueClick]); + + const openLinearSettings = React.useCallback(() => { + onBack(); + if (onOpenLinearSettings) { + onOpenLinearSettings(); + return; + } + const target = "/settings?tab=integrations&integration=linear"; + if (window.location.protocol === "http:" || window.location.protocol === "https:") { + window.location.assign(target); + return; + } + window.location.hash = target; + }, [onBack, onOpenLinearSettings]); + + return ( + <div className="flex min-h-[560px] flex-col"> + {/* Linear-branded header banner — establishes context */} + <div + className="mb-3 flex items-center gap-2.5 rounded-lg border px-3 py-2" + style={{ borderColor: LINEAR_BRAND.borderSubtle, background: LINEAR_BRAND.surface }} + > + <span + className="flex h-6 w-6 items-center justify-center rounded-md" + style={{ background: LINEAR_BRAND.surfaceHover, color: LINEAR_BRAND.primaryBright }} + > + <LinearMark size={13} /> + </span> + <span className="text-[12px] font-medium" style={{ color: LINEAR_BRAND.text }}> + Linear + </span> + <span className="text-[11px] text-muted-fg/55">Connect this lane to an issue and we'll auto-name the branch.</span> + </div> + + {pinnedIssue ? ( + <div + className="mb-3 overflow-hidden rounded-lg border" + style={{ borderColor: LINEAR_BRAND.borderSubtle }} + > + <LinearIssueRow + issue={pinnedIssue} + active={issueForDetails?.id === pinnedIssue.id} + eyebrow={pinnedIssueLabel} + busy={busy} + onClick={() => handleChooseIssue(pinnedIssue)} + /> + </div> + ) : null} + + {/* Search row — single dominant input, loader inline */} + <div className="relative"> + <MagnifyingGlass size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-fg/55" /> + <input + value={query} + onChange={(event) => setQuery(event.target.value)} + className="h-10 w-full rounded-lg border border-white/[0.06] pl-9 pr-9 text-sm text-fg outline-none transition-colors placeholder:text-muted-fg/55 focus:border-white/15" + style={{ backgroundColor: "var(--color-composer-bg, #14121F)" }} + placeholder="Search issues by title, description, or identifier" + disabled={busy} + /> + {loadingCatalog || loadingIssues ? ( + <CircleNotch size={14} className="absolute right-3 top-1/2 -translate-y-1/2 animate-spin text-muted-fg/60" /> + ) : null} + </div> + + {/* Linear-style filter pill row */} + <div className="mt-2.5 flex flex-wrap items-center gap-1.5"> + <FilterPill + label="State" + value={statePreset} + options={stateOptions} + onChange={setStatePreset} + busy={busy} + width="7rem" + /> + <FilterPill + label="Priority" + value={priority} + options={PRIORITY_OPTIONS} + onChange={setPriority} + busy={busy} + width="8rem" + /> + <FilterPill + label="Project" + value={projectId} + options={projectOptions} + onChange={setProjectId} + busy={busy || loadingCatalog} + width="10rem" + /> + <FilterPill + label="Assignee" + value={assigneeId} + options={assigneeOptions} + onChange={setAssigneeId} + busy={busy || loadingCatalog} + width="8rem" + /> + </div> + + {error ? ( + <div + className="mt-3 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-red-500/25 px-3 py-2 text-sm text-red-200" + style={{ backgroundColor: "#321B20" }} + > + <span className="min-w-0 flex-1">{error}</span> + <Button + type="button" + variant="danger" + size="sm" + onClick={openLinearSettings} + > + Open Linear settings + </Button> + </div> + ) : null} + + {/* List + detail */} + <div className="mt-3 grid min-h-0 flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_280px]"> + <div + className="min-h-0 overflow-y-auto rounded-lg border border-white/[0.06]" + style={{ backgroundColor: "var(--color-bg, #0C0B10)" }} + > + {issues.length === 0 && !loadingIssues ? ( + <div className="px-4 py-12 text-center text-sm text-muted-fg/55"> + No Linear issues match these filters. + </div> + ) : null} + {issues.map((issue) => { + const active = pendingIssue?.id === issue.id || (!pendingIssue && selectedIssue?.id === issue.id); + return ( + <LinearIssueRow + key={issue.id} + issue={issue} + active={active} + busy={busy} + onClick={() => handleChooseIssue(issue)} + /> + ); + })} + {pageInfo.hasNextPage ? ( + <div className="px-3 py-2.5"> + <Button + variant="outline" + disabled={busy || loadingIssues} + onClick={() => void searchIssues(true)} + > + Load more + </Button> + </div> + ) : null} + </div> + + <aside + className="overflow-hidden rounded-lg border border-white/[0.06]" + style={{ backgroundColor: "var(--color-composer-bg, #14121F)" }} + > + {issueForDetails ? ( + <div className="flex h-full flex-col"> + <div className="border-b border-white/[0.05] p-3"> + <div className="flex items-center gap-2"> + <LinearPriorityIcon priority={issueForDetails.priority} size={12} /> + <LinearStateIcon stateType={issueForDetails.stateType} size={12} /> + <LinearIdentifierTag identifier={issueForDetails.identifier} /> + </div> + <div className="mt-2 text-[13px] font-semibold leading-snug text-fg">{issueForDetails.title}</div> + </div> + <div className="space-y-2 px-3 py-3 text-[11.5px]"> + <DetailRow label="Project" value={selectedProject?.name ?? issueProjectLabel(issueForDetails)} /> + <DetailRow label="Team" value={issueForDetails.teamName ?? issueForDetails.teamKey} /> + <DetailRow label="Status" value={issueForDetails.stateName} /> + <DetailRow label="Priority" value={linearPriorityLabel(issueForDetails)} /> + <DetailRow label="Assignee" value={issueForDetails.assigneeName ?? "Unassigned"} /> + {issueForDetails.estimate != null ? ( + <DetailRow label="Estimate" value={String(issueForDetails.estimate)} /> + ) : null} + {issueForDetails.dueDate ? <DetailRow label="Due" value={issueForDetails.dueDate} /> : null} + <DetailRow label="Updated" value={issueUpdatedLabel(issueForDetails)} /> + </div> + {issueForDetails.labels.length > 0 ? ( + <div className="border-t border-white/[0.05] px-3 py-2.5"> + <div className="flex flex-wrap gap-1"> + {issueForDetails.labels.slice(0, 6).map((label) => ( + <span + key={label} + className="rounded-full px-2 py-0.5 text-[10px] text-muted-fg/80" + style={{ background: "rgba(255,255,255,0.04)" }} + > + {label} + </span> + ))} + </div> + </div> + ) : null} + <div className="mt-auto border-t border-white/[0.05] px-3 py-2.5"> + <div className={LABEL_CLASS_NAME}>Branch</div> + <div + className="mt-1 truncate rounded font-mono text-[10.5px] text-fg/85" + style={{ background: "rgba(0,0,0,0.30)", padding: "5px 7px" }} + title={linearIssueBranchName(issueForDetails)} + > + {linearIssueBranchName(issueForDetails)} + </div> + {issueForDetails.url ? ( + <button + type="button" + className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium transition-colors hover:opacity-80" + style={{ color: LINEAR_BRAND.primaryBright }} + onClick={() => window.ade.app.openExternal(issueForDetails.url!)} + > + <ArrowSquareOut size={11} weight="bold" /> Open in Linear + </button> + ) : null} + </div> + </div> + ) : ( + <div className="grid h-full place-items-center px-4 py-10 text-center text-[12px] text-muted-fg/55"> + Select an issue to preview + </div> + )} + </aside> + </div> + + <div className="mt-3 flex items-center justify-between gap-2 border-t border-white/[0.06] pt-3"> + <Button variant="outline" disabled={busy} onClick={onBack}> + Back + </Button> + <Button + variant="primary" + disabled={busy || !pendingIssue} + onClick={() => { + if (!pendingIssue) return; + onSelect(isLaneLinearIssue(pendingIssue) ? pendingIssue : toLaneLinearIssue(pendingIssue)); + onBack(); + }} + > + <Check size={14} /> {submitLabel} + </Button> + </div> + </div> + ); +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( + <div className="flex items-baseline justify-between gap-3"> + <span className="text-[10.5px] uppercase tracking-[0.10em] text-muted-fg/45">{label}</span> + <span className="truncate text-right text-fg/85" title={value}>{value}</span> + </div> + ); +} diff --git a/apps/desktop/src/renderer/components/lanes/linearBrand.tsx b/apps/desktop/src/renderer/components/lanes/linearBrand.tsx new file mode 100644 index 000000000..631021674 --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/linearBrand.tsx @@ -0,0 +1,176 @@ +export const LINEAR_BRAND = { + primary: "#5E6AD2", + primaryBright: "#7B8AF0", + primaryDeep: "#4752B5", + surface: "rgba(94, 106, 210, 0.10)", + surfaceHover: "rgba(94, 106, 210, 0.16)", + border: "rgba(94, 106, 210, 0.32)", + borderSubtle: "rgba(94, 106, 210, 0.20)", + text: "#C7CDF5", + textMuted: "rgba(199, 205, 245, 0.65)", +} as const; + +export function LinearMark({ size = 14, className }: { size?: number | string; className?: string }) { + return ( + <svg + viewBox="0 0 24 24" + width={size} + height={size} + fill="currentColor" + aria-hidden="true" + className={className} + style={{ display: "block" }} + > + <path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" /> + </svg> + ); +} + +const STATE_COLORS = { + backlog: "#94A3B8", + unstarted: "#94A3B8", + started: "#F2C94C", + completed: "#5E6AD2", + canceled: "#94A3B8", + triage: "#F2994A", +} as const; + +type StateType = keyof typeof STATE_COLORS; + +export function LinearStateIcon({ + stateType, + size = 14, +}: { + stateType: string; + size?: number; +}) { + const type = (Object.prototype.hasOwnProperty.call(STATE_COLORS, stateType) ? stateType : "unstarted") as StateType; + const color = STATE_COLORS[type]; + const stroke = Math.max(1.4, size * 0.12); + const inset = stroke / 2; + const r = size / 2 - inset; + const center = size / 2; + const dashArray = `${stroke * 1.7} ${stroke * 1.4}`; + + if (type === "backlog") { + return ( + <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true" style={{ display: "block" }}> + <circle + cx={center} + cy={center} + r={r} + fill="none" + stroke={color} + strokeWidth={stroke} + strokeDasharray={dashArray} + strokeLinecap="round" + /> + </svg> + ); + } + + if (type === "unstarted" || type === "triage") { + return ( + <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true" style={{ display: "block" }}> + <circle cx={center} cy={center} r={r} fill="none" stroke={color} strokeWidth={stroke} /> + </svg> + ); + } + + if (type === "started") { + const innerR = r - stroke * 0.6; + const sweep = `M ${center} ${center} L ${center} ${center - innerR} A ${innerR} ${innerR} 0 0 1 ${center + innerR} ${center} Z`; + return ( + <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true" style={{ display: "block" }}> + <circle cx={center} cy={center} r={r} fill="none" stroke={color} strokeWidth={stroke} /> + <path d={sweep} fill={color} /> + </svg> + ); + } + + if (type === "completed") { + return ( + <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true" style={{ display: "block" }}> + <circle cx={center} cy={center} r={r + inset} fill={color} /> + <path + d={`M ${center - r * 0.45} ${center} L ${center - r * 0.1} ${center + r * 0.38} L ${center + r * 0.55} ${center - r * 0.35}`} + fill="none" + stroke="#0C0B10" + strokeWidth={stroke * 1.1} + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ); + } + + // canceled + return ( + <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true" style={{ display: "block" }}> + <circle cx={center} cy={center} r={r + inset} fill={color} opacity="0.9" /> + <path + d={`M ${center - r * 0.45} ${center - r * 0.45} L ${center + r * 0.45} ${center + r * 0.45} M ${center - r * 0.45} ${center + r * 0.45} L ${center + r * 0.45} ${center - r * 0.45}`} + stroke="#0C0B10" + strokeWidth={stroke * 1.1} + strokeLinecap="round" + /> + </svg> + ); +} + +export function LinearPriorityIcon({ + priority, + size = 14, +}: { + /** Linear priority: 0=none, 1=urgent, 2=high, 3=medium, 4=low. */ + priority: number; + size?: number; +}) { + const dim = "rgba(148, 163, 184, 0.40)"; + const strong = "#C7CDF5"; + + if (priority === 1) { + return ( + <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true" style={{ display: "block" }}> + <rect x={size * 0.12} y={size * 0.12} width={size * 0.76} height={size * 0.76} rx={size * 0.18} fill="#EB5757" /> + <rect x={size * 0.45} y={size * 0.28} width={size * 0.10} height={size * 0.32} rx={size * 0.05} fill="#fff" /> + <rect x={size * 0.45} y={size * 0.65} width={size * 0.10} height={size * 0.10} rx={size * 0.05} fill="#fff" /> + </svg> + ); + } + + if (priority === 0) { + return ( + <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true" style={{ display: "block" }}> + <rect x={size * 0.18} y={size * 0.46} width={size * 0.64} height={size * 0.10} rx={size * 0.05} fill={dim} /> + </svg> + ); + } + + // 2 = high (3 bars), 3 = medium (2 bars), 4 = low (1 bar) + const filledBars = priority === 2 ? 3 : priority === 3 ? 2 : 1; + const barWidth = size * 0.18; + const barGap = size * 0.10; + const totalWidth = barWidth * 3 + barGap * 2; + const startX = (size - totalWidth) / 2; + const heights = [size * 0.32, size * 0.55, size * 0.78]; + return ( + <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true" style={{ display: "block" }}> + {[0, 1, 2].map((i) => { + const h = heights[i]!; + const filled = i < filledBars; + return ( + <rect + key={i} + x={startX + i * (barWidth + barGap)} + y={size - h - size * 0.12} + width={barWidth} + height={h} + rx={size * 0.04} + fill={filled ? strong : dim} + /> + ); + })} + </svg> + ); +} diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx index 53d0bd6de..938e034b8 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx @@ -5,7 +5,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; -import type { LaneSummary } from "../../../shared/types"; +import type { LaneLinearIssue, LaneSummary } from "../../../shared/types"; function renderWithRouter(ui: React.ReactElement) { return render(<MemoryRouter>{ui}</MemoryRouter>); @@ -33,6 +33,38 @@ function makeLane(overrides: Partial<LaneSummary> = {}): LaneSummary { }; } +function makeLinearIssue(overrides: Partial<LaneLinearIssue> = {}): LaneLinearIssue { + return { + id: "issue-1", + identifier: "ADE-123", + title: "Connect Linear issue dropdown", + description: "Let PRs link back to Linear.", + url: "https://linear.app/ade/issue/ADE-123/connect-linear-issue-dropdown", + projectId: "project-1", + projectSlug: "ade", + projectName: "ADE", + teamId: "team-1", + teamKey: "ADE", + teamName: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 2, + priorityLabel: "high", + labels: ["desktop"], + assigneeId: "user-1", + assigneeName: "Arul", + creatorId: "user-2", + creatorName: "Annie", + dueDate: null, + estimate: null, + branchName: "ade-123-connect-linear-issue-dropdown", + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + ...overrides, + }; +} + const mockLanes: LaneSummary[] = [ makeLane({ id: "lane-primary", @@ -62,6 +94,17 @@ const mockLanes: LaneSummary[] = [ status: { dirty: false, ahead: 2, behind: 0, remoteBehind: 0, rebaseInProgress: false }, createdAt: "2026-03-23T12:02:00.000Z", }), + makeLane({ + id: "lane-linear", + name: "Linear linked lane", + branchRef: "ade-123-connect-linear-issue-dropdown", + worktreePath: "/tmp/lane-linear", + parentLaneId: "lane-primary", + stackDepth: 1, + status: { dirty: false, ahead: 3, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + linearIssue: makeLinearIssue(), + createdAt: "2026-05-08T12:02:00.000Z", + }), ]; vi.mock("../../state/appStore", () => ({ @@ -195,6 +238,56 @@ describe("CreatePrModal queue workflow", () => { ); }); + it("defaults single-PR title and body from a linked Linear issue", async () => { + const user = userEvent.setup(); + renderWithRouter(<CreatePrModal open onOpenChange={vi.fn()} />); + + const comboboxes = screen.getAllByRole("combobox"); + await user.selectOptions(comboboxes[0]!, "lane-linear"); + + await user.click(screen.getByRole("button", { name: /next step/i })); + + expect(screen.getByDisplayValue("ADE-123: Connect Linear issue dropdown")).toBeTruthy(); + expect(screen.getByDisplayValue(/Refs ADE-123/)).toBeTruthy(); + expect(screen.getByText(/PR body will include Refs ADE-123/i)).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: /create pr/i })); + + await waitFor(() => expect(createFromLane).toHaveBeenCalledTimes(1)); + expect(createFromLane).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-linear", + title: "ADE-123: Connect Linear issue dropdown", + body: expect.stringContaining("Refs ADE-123"), + closeLinearIssueOnMerge: false, + }), + ); + }); + + it("uses a closing Linear magic word when close-on-merge is enabled", async () => { + const user = userEvent.setup(); + renderWithRouter(<CreatePrModal open onOpenChange={vi.fn()} />); + + const comboboxes = screen.getAllByRole("combobox"); + await user.selectOptions(comboboxes[0]!, "lane-linear"); + await user.click(screen.getByRole("button", { name: /next step/i })); + await user.click(screen.getByRole("checkbox", { name: /close linear issue/i })); + + expect(screen.getByDisplayValue(/Fixes ADE-123/)).toBeTruthy(); + expect(screen.getByText(/PR body will include Fixes ADE-123/i)).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: /create pr/i })); + + await waitFor(() => expect(createFromLane).toHaveBeenCalledTimes(1)); + expect(createFromLane).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-linear", + body: expect.stringContaining("Fixes ADE-123"), + closeLinearIssueOnMerge: true, + }), + ); + }); + it("warns when the PR target branch differs from the lane base branch", async () => { const user = userEvent.setup(); renderWithRouter(<CreatePrModal open onOpenChange={vi.fn()} />); diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx index 7506d6f73..e6ce736e1 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx @@ -13,6 +13,11 @@ import type { GitBranchSummary, LaneSummary, } from "../../../shared/types"; +import { + buildLinearPrReference, + buildLinearPrTitle, + ensureLinearPrReference, +} from "../../../shared/linearMagicWords"; import { COLORS, MONO_FONT, LABEL_STYLE } from "../lanes/laneDesignTokens"; import { isDirtyWorktreeErrorMessage, stripDirtyWorktreePrefix } from "./shared/dirtyWorktree"; import { branchNameFromRef, describePrTargetDiff, resolveLaneBaseBranch } from "./shared/laneBranchTargets"; @@ -521,7 +526,10 @@ export function CreatePrModal({ const [normalTitle, setNormalTitle] = React.useState(""); const [normalDraft, setNormalDraft] = React.useState(false); const [normalBaseBranch, setNormalBaseBranch] = React.useState(""); + const [normalCloseLinearIssueOnMerge, setNormalCloseLinearIssueOnMerge] = React.useState(false); const normalBaseBranchDefaultRef = React.useRef(""); + const normalLinearTitleDefaultRef = React.useRef(""); + const normalLinearBodyDefaultRef = React.useRef(""); // Queue PRs const [queueLaneIds, setQueueLaneIds] = React.useState<string[]>([]); @@ -611,8 +619,10 @@ export function CreatePrModal({ try { const result = await window.ade.prs.draftDescription({ laneId }); if (mode === "normal") { - setNormalTitle(result.title); - setNormalBody(result.body); + const lane = lanes.find((entry) => entry.id === laneId) ?? null; + const issue = lane?.linearIssue ?? null; + setNormalTitle(issue && !result.title.includes(issue.identifier) ? buildLinearPrTitle(issue) : result.title); + setNormalBody(issue ? ensureLinearPrReference(result.body, issue, normalCloseLinearIssueOnMerge, { preserveExisting: false }) : result.body); } } catch (err: unknown) { setDraftError(err instanceof Error ? err.message : String(err)); @@ -648,6 +658,9 @@ export function CreatePrModal({ normalBaseBranchDefaultRef.current = ""; setNormalTitle(""); setNormalDraft(false); + setNormalCloseLinearIssueOnMerge(false); + normalLinearTitleDefaultRef.current = ""; + normalLinearBodyDefaultRef.current = ""; setQueueLaneIds([]); setQueueDraft(false); setQueueDragLaneId(null); @@ -732,6 +745,50 @@ export function CreatePrModal({ () => lanes.find((lane) => lane.id === normalLaneId) ?? null, [lanes, normalLaneId], ); + const selectedNormalLinearIssue = selectedNormalLane?.linearIssue ?? null; + + React.useEffect(() => { + if (!open) return; + if (!selectedNormalLinearIssue) { + // Lane no longer has a linked Linear issue — clear any auto-generated + // title/body fragments and reset the close-on-merge toggle so stale + // Linear-flavored values don't follow the user to a non-Linear lane. + const previousAutoTitle = normalLinearTitleDefaultRef.current; + setNormalTitle((current) => + previousAutoTitle && current.trim() === previousAutoTitle.trim() ? "" : current, + ); + const previousAutoBody = normalLinearBodyDefaultRef.current; + setNormalBody((current) => + previousAutoBody && current.trim() === previousAutoBody.trim() ? "" : current, + ); + setNormalCloseLinearIssueOnMerge(false); + normalLinearTitleDefaultRef.current = ""; + normalLinearBodyDefaultRef.current = ""; + return; + } + + const nextTitle = buildLinearPrTitle(selectedNormalLinearIssue); + setNormalTitle((current) => { + const previousAutoTitle = normalLinearTitleDefaultRef.current; + if (!current.trim() || (previousAutoTitle && current === previousAutoTitle)) { + normalLinearTitleDefaultRef.current = nextTitle; + return nextTitle; + } + normalLinearTitleDefaultRef.current = nextTitle; + return current; + }); + + const nextBody = `${buildLinearPrReference(selectedNormalLinearIssue, normalCloseLinearIssueOnMerge)}\n`; + setNormalBody((current) => { + const previousAutoBody = normalLinearBodyDefaultRef.current; + if (!current.trim() || (previousAutoBody && current === previousAutoBody)) { + normalLinearBodyDefaultRef.current = nextBody; + return nextBody; + } + normalLinearBodyDefaultRef.current = nextBody; + return ensureLinearPrReference(current, selectedNormalLinearIssue, normalCloseLinearIssueOnMerge, { preserveExisting: false }); + }); + }, [open, normalCloseLinearIssueOnMerge, selectedNormalLinearIssue]); React.useEffect(() => { if (!open) return; @@ -803,13 +860,21 @@ export function CreatePrModal({ try { if (mode === "normal") { const lane = lanes.find((l) => l.id === normalLaneId); + const linearIssue = lane?.linearIssue ?? null; + const title = linearIssue && !normalTitle.trim() + ? buildLinearPrTitle(linearIssue) + : normalTitle || lane?.name || "PR"; + const body = linearIssue + ? ensureLinearPrReference(normalBody, linearIssue, normalCloseLinearIssueOnMerge, { preserveExisting: false }) + : normalBody; const pr = await runWithDirtyWorktreeConfirmation({ confirmMessage: "Continue and create the PR anyway?", run: async (allowDirtyWorktree) => await window.ade.prs.createFromLane({ laneId: normalLaneId, - title: normalTitle || lane?.name || "PR", - body: normalBody, + title, + body, draft: normalDraft, + ...(linearIssue ? { closeLinearIssueOnMerge: normalCloseLinearIssueOnMerge } : {}), ...(normalBaseBranch.trim() ? { baseBranch: normalBaseBranch.trim() } : {}), ...(allowDirtyWorktree ? { allowDirtyWorktree: true } : {}) }) @@ -1933,6 +1998,73 @@ export function CreatePrModal({ /> </div> + {selectedNormalLinearIssue ? ( + <div + style={{ + border: `1px solid ${C.accentBorder}`, + background: C.accentSubtleBg, + padding: "10px 12px", + display: "flex", + flexDirection: "column", + gap: 8, + }} + > + <div style={{ display: "flex", alignItems: "center", gap: 8 }}> + <span + style={{ + fontFamily: MONO_FONT, + fontSize: 11, + fontWeight: 700, + color: C.accent, + }} + > + {selectedNormalLinearIssue.identifier} + </span> + <span + style={{ + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + fontFamily: "var(--font-sans)", + fontSize: 12, + color: C.textPrimary, + }} + > + {selectedNormalLinearIssue.title} + </span> + </div> + <label + style={{ + display: "grid", + gridTemplateColumns: "auto minmax(0,1fr)", + alignItems: "start", + gap: "2px 8px", + fontFamily: "var(--font-sans)", + fontSize: 12, + color: C.textSecondary, + cursor: "pointer", + }} + > + <input + type="checkbox" + checked={normalCloseLinearIssueOnMerge} + onChange={(e) => setNormalCloseLinearIssueOnMerge(e.target.checked)} + style={{ accentColor: C.accent }} + /> + <span style={{ fontWeight: 700, color: C.textPrimary }}> + Close Linear issue when this PR merges + </span> + <span style={{ gridColumn: "2", fontSize: 11, color: C.textMuted, lineHeight: "15px" }}> + Off links the PR only. On changes the PR body from Refs to Fixes so Linear can complete the issue on merge. + </span> + </label> + <div style={{ fontFamily: MONO_FONT, fontSize: 10, color: C.textMuted }}> + PR body will include {buildLinearPrReference(selectedNormalLinearIssue, normalCloseLinearIssueOnMerge)} so Linear links the PR. + </div> + </div> + ) : null} + <label style={{ display: "flex", alignItems: "center", diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx index 0bf32e466..f0c5e686f 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx @@ -44,6 +44,10 @@ vi.mock("../shared/PrIssueResolverModal", () => ({ ) : null), })); +vi.mock("../../shared/AdeDiffViewer", () => ({ + AdeDiffViewer: () => <div data-testid="ade-diff-viewer" />, +})); + import { PrDetailPane } from "./PrDetailPane"; function makeCheck(overrides: Partial<PrCheck> = {}): PrCheck { @@ -125,7 +129,7 @@ const visibilityCases: Array<{ }, ]; -function makePr(): PrWithConflicts { +function makePr(overrides: Partial<PrWithConflicts> = {}): PrWithConflicts { return { id: "pr-80", laneId: "lane-1", @@ -147,6 +151,7 @@ function makePr(): PrWithConflicts { createdAt: "2026-03-23T11:00:00.000Z", updatedAt: "2026-03-23T12:00:00.000Z", conflictAnalysis: null, + ...overrides, }; } @@ -197,6 +202,7 @@ function makeConvergenceState(overrides: Partial<PrConvergenceState> = {}): PrCo pathToMergeActive: false, status: "idle", pollerStatus: "idle", + mergeWaitKind: null, currentRound: 0, activeSessionId: null, activeLaneId: null, @@ -207,6 +213,8 @@ function makeConvergenceState(overrides: Partial<PrConvergenceState> = {}): PrCo ciRetryAttemptsUsed: 0, waitForCiStartedAt: null, lastDispatchHeadSha: null, + lastBotPingHeadSha: null, + lastBotPingAt: null, pauseRepeatCount: 0, lastPauseReasonHash: null, lastStartedAt: null, @@ -247,9 +255,11 @@ function renderPane(args: { checks: PrCheck[]; freshChecks?: PrCheck[]; actionRuns?: PrActionRun[]; + freshActionRuns?: PrActionRun[]; reviewThreads: PrReviewThread[]; lanes?: LaneSummary[]; laneStatusOverrides?: Partial<LaneSummary["status"]>; + prOverrides?: Partial<PrWithConflicts>; onNavigate?: (path: string) => void; activity?: PrActivityEvent[]; statusOverrides?: Partial<PrStatus>; @@ -284,6 +294,13 @@ function renderPane(args: { const aiResolutionStop = vi.fn().mockResolvedValue(undefined); const getReviewThreads = vi.fn().mockResolvedValue(args.reviewThreads); const getChecks = vi.fn().mockResolvedValue(args.freshChecks ?? args.checks); + const getActionRuns = vi.fn(); + if (args.freshActionRuns) { + getActionRuns.mockResolvedValueOnce(args.actionRuns ?? []); + getActionRuns.mockResolvedValue(args.freshActionRuns); + } else { + getActionRuns.mockResolvedValue(args.actionRuns ?? []); + } const getStatus = vi.fn().mockResolvedValue(args.statusOverrides ? makeStatus(args.statusOverrides) : makeStatus()); const issueInventorySync = vi.fn().mockResolvedValue({ items: args.inventorySnapshot?.items ?? [], @@ -408,7 +425,7 @@ function renderPane(args: { linkedIssues: [], }), getFiles: vi.fn().mockResolvedValue([]), - getActionRuns: vi.fn().mockResolvedValue(args.actionRuns ?? []), + getActionRuns, getActivity: vi.fn().mockResolvedValue(args.activity ?? []), getReviewThreads, issueInventorySync, @@ -469,11 +486,30 @@ function renderPane(args: { }, }); + const renderSubject = (prOverrides: Partial<PrWithConflicts> = args.prOverrides ?? {}) => ( + <MemoryRouter> + <PrDetailPane + pr={makePr(prOverrides)} + status={makeStatus(args.statusOverrides)} + checks={args.checks} + reviews={[]} + comments={[]} + detailBusy={false} + lanes={laneList} + mergeMethod={args.mergeMethod ?? "squash"} + onRefresh={onRefresh} + onNavigate={args.onNavigate ?? vi.fn()} + /> + </MemoryRouter> + ); + const rendered = render(renderSubject()); + return { issueResolutionStart, issueResolutionPreviewPrompt, aiResolutionStop, getReviewThreads, + getActionRuns, issueInventorySync, issueInventoryReset, getChecks, @@ -492,22 +528,10 @@ function renderPane(args: { writeClipboardText, land, onRefresh, - ...render( - <MemoryRouter> - <PrDetailPane - pr={makePr()} - status={makeStatus(args.statusOverrides)} - checks={args.checks} - reviews={[]} - comments={[]} - detailBusy={false} - lanes={laneList} - mergeMethod={args.mergeMethod ?? "squash"} - onRefresh={onRefresh} - onNavigate={args.onNavigate ?? vi.fn()} - /> - </MemoryRouter>, - ), + rerenderPane: (prOverrides: Partial<PrWithConflicts>) => { + rendered.rerender(renderSubject({ ...(args.prOverrides ?? {}), ...prOverrides })); + }, + ...rendered, }; } @@ -531,6 +555,7 @@ describe("PrDetailPane issue resolver CTA", () => { afterEach(() => { cleanup(); + vi.useRealTimers(); }); it.each(visibilityCases)("$name — Path to Merge tab is always visible", async ({ checks, reviewThreads, statusOverrides }) => { @@ -633,6 +658,175 @@ describe("PrDetailPane issue resolver CTA", () => { }); }); + it("refreshes detail-side action runs when the selected PR status changes", async () => { + const user = userEvent.setup(); + const { getActionRuns, rerenderPane } = renderPane({ + checks: [], + actionRuns: [], + freshActionRuns: [ + makeActionRun({ + status: "in_progress", + conclusion: null, + jobs: [{ + id: 73898654393, + name: "build", + status: "in_progress", + conclusion: null, + startedAt: null, + completedAt: null, + steps: [], + }], + }), + ], + reviewThreads: [], + prOverrides: { + checksStatus: "none", + updatedAt: "2026-03-23T12:00:00.000Z", + }, + }); + + await user.click(screen.getByRole("button", { name: /ci \/ checks/i })); + await waitFor(() => { + expect(screen.getByText("No checks found")).toBeTruthy(); + expect(getActionRuns).toHaveBeenCalledTimes(1); + }); + + rerenderPane({ + checksStatus: "pending", + updatedAt: "2026-03-23T12:01:00.000Z", + }); + + await waitFor(() => { + expect(getActionRuns).toHaveBeenCalledTimes(2); + expect(screen.getByText("0 passing, 1 pending")).toBeTruthy(); + expect(screen.getByText("Path Filtered CI on PR / build")).toBeTruthy(); + }); + }); + + it("refreshes Path to Merge action runs during inventory sync", async () => { + const user = userEvent.setup(); + const { getActionRuns, issueInventorySync } = renderPane({ + checks: [ + makeCheck({ + name: "CI / build-win", + status: "in_progress", + conclusion: null, + }), + ], + freshChecks: [ + makeCheck({ + name: "CI / build-win", + status: "completed", + conclusion: "success", + }), + ], + actionRuns: [ + makeActionRun({ + name: "CI", + status: "in_progress", + conclusion: null, + jobs: [{ + id: 73898654393, + name: "build-win", + status: "in_progress", + conclusion: null, + startedAt: null, + completedAt: null, + steps: [], + }], + }), + ], + freshActionRuns: [ + makeActionRun({ + name: "CI", + status: "completed", + conclusion: "success", + jobs: [{ + id: 73898654393, + name: "build-win", + status: "completed", + conclusion: "success", + startedAt: null, + completedAt: null, + steps: [], + }], + }), + ], + reviewThreads: [], + inventorySnapshot: { + items: [makeInventoryItem()], + convergence: { currentRound: 0, maxRounds: 5, totalNew: 1, totalSentToAgent: 0, isConverging: false }, + }, + }); + + await user.click(screen.getByRole("button", { name: /path to merge/i })); + + await waitFor(() => { + expect(issueInventorySync).toHaveBeenCalled(); + expect(getActionRuns).toHaveBeenCalledTimes(2); + expect(screen.getByText("CI / build-win")).toBeTruthy(); + expect(screen.queryByText("1 running")).toBeNull(); + }); + }); + + it("refreshes external check statuses during detail polling", async () => { + const intervalTicks: Array<() => void> = []; + const setIntervalSpy = vi.spyOn(window, "setInterval").mockImplementation((handler) => { + if (typeof handler === "function") { + intervalTicks.push(handler as () => void); + } + return 1 as unknown as ReturnType<typeof window.setInterval>; + }); + const clearIntervalSpy = vi.spyOn(window, "clearInterval").mockImplementation(() => undefined); + + try { + const user = userEvent.setup(); + const { getChecks } = renderPane({ + checks: [ + makeCheck({ + name: "CodeRabbit", + status: "in_progress", + conclusion: null, + }), + ], + reviewThreads: [], + inventorySnapshot: { + items: [makeInventoryItem()], + convergence: { currentRound: 0, maxRounds: 5, totalNew: 1, totalSentToAgent: 0, isConverging: false }, + }, + }); + + await user.click(screen.getByRole("button", { name: /path to merge/i })); + + await waitFor(() => { + expect(screen.getByText("CodeRabbit")).toBeTruthy(); + expect(screen.getByText("running")).toBeTruthy(); + }); + + getChecks.mockResolvedValue([ + makeCheck({ + name: "CodeRabbit", + status: "completed", + conclusion: "success", + }), + ]); + + await React.act(async () => { + intervalTicks.forEach((tick) => tick()); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(getChecks).toHaveBeenCalled(); + expect(screen.getByText("success")).toBeTruthy(); + expect(screen.queryByText("running")).toBeNull(); + }); + } finally { + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + } + }); + it("lets the operator attempt a bypass merge and uses the selected merge method", async () => { const user = userEvent.setup(); const { land, onRefresh } = renderPane({ diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index 08ea1c79a..750dc2cec 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -13,6 +13,7 @@ import type { PrWithConflicts, PrCheck, PrReview, PrComment, PrStatus, PrDetail, PrFile, PrCommit, PrActionRun, PrActivityEvent, AiReviewSummary, PrReviewThread, LaneSummary, MergeMethod, LandResult, + FilePatch, IssueInventorySnapshot, PipelineSettings, PrConvergenceState, @@ -25,6 +26,7 @@ import { PrDetailTimelineRails as TimelineRailsOverview, type PrDetailTimelineRa import { DEFAULT_PIPELINE_SETTINGS } from "../../../../shared/types"; import { defaultPrIssueResolutionScope, getPrIssueResolutionAvailability } from "../../../../shared/prIssueResolution"; import { COLORS, MONO_FONT, SANS_FONT, LABEL_STYLE, cardStyle, inlineBadge, outlineButton, primaryButton, dangerButton } from "../../lanes/laneDesignTokens"; +import { AdeDiffViewer } from "../../shared/AdeDiffViewer"; import { getPrChecksBadge, getPrReviewsBadge, getPrStateBadge, InlinePrBadge, PrCiRunningIndicator } from "../shared/prVisuals"; import { PrIssueResolverModal } from "../shared/PrIssueResolverModal"; import { PrConvergencePanel } from "../shared/PrConvergencePanel"; @@ -739,6 +741,7 @@ export function PrDetailPane({ // expandedRun state removed — the unified ChecksTab manages its own expand state const [expandedFile, setExpandedFile] = React.useState<string | null>(null); const detailLoadSeqRef = React.useRef(0); + const detailStatusRefreshKeyRef = React.useRef<string | null>(null); const inventoryLoadSeqRef = React.useRef(0); const loadDetail = React.useCallback(async () => { @@ -825,24 +828,48 @@ export function PrDetailPane({ }; }, [applyConvergenceRuntime, loadConvergenceState, loadDetail, pr.id]); - // Poll actionRuns + activity + reviewThreads every 60s so CI data stays fresh. - // PrsContext polls checks/status/reviews/comments, but action runs are only loaded - // in PrDetailPane and would otherwise go stale after the initial fetch. React.useEffect(() => { + const key = [ + pr.id, + pr.checksStatus ?? "", + pr.reviewStatus ?? "", + pr.updatedAt ?? "", + ].join("|"); + const prev = detailStatusRefreshKeyRef.current; + if (!prev || !prev.startsWith(`${pr.id}|`)) { + detailStatusRefreshKeyRef.current = key; + return; + } + if (prev === key) return; + detailStatusRefreshKeyRef.current = key; + void loadDetail(); + void refreshReviewThreads(); + }, [loadDetail, pr.checksStatus, pr.id, pr.reviewStatus, pr.updatedAt, refreshReviewThreads]); + + // Poll checks + actionRuns + activity + reviewThreads every 60s so the + // Path to Merge readiness panel stays fresh without requiring a manual refresh. + React.useEffect(() => { + let cancelled = false; const id = window.setInterval(() => { - const reqId = detailLoadSeqRef.current; Promise.allSettled([ + window.ade.prs.getChecks(pr.id), window.ade.prs.getActionRuns(pr.id), window.ade.prs.getActivity(pr.id), window.ade.prs.getReviewThreads(pr.id), - ]).then(([arResult, actResult, thrResult]) => { - if (reqId !== detailLoadSeqRef.current) return; + ]).then(([checksResult, arResult, actResult, thrResult]) => { + if (cancelled) return; + if (checksResult.status === "fulfilled" && checksResult.value.length > 0) { + setConvergenceChecks(checksResult.value); + } if (arResult.status === "fulfilled") setActionRuns(arResult.value); if (actResult.status === "fulfilled") setActivity(actResult.value); if (thrResult.status === "fulfilled") setReviewThreads(thrResult.value); }); }, 60_000); - return () => window.clearInterval(id); + return () => { + cancelled = true; + window.clearInterval(id); + }; }, [pr.id]); React.useEffect(() => { @@ -1054,9 +1081,10 @@ export function PrDetailPane({ } const requestId = ++inventoryLoadSeqRef.current; try { - const [snapshot, freshChecks] = await Promise.all([ + const [snapshot, freshChecks, freshActionRuns] = await Promise.all([ window.ade.prs.issueInventorySync(pr.id), window.ade.prs.getChecks(pr.id).catch(() => checks), + window.ade.prs.getActionRuns(pr.id).catch(() => null), ]); if (requestId !== inventoryLoadSeqRef.current) return null; setInventorySnapshot(snapshot); @@ -1066,6 +1094,9 @@ export function PrDetailPane({ if (freshChecks.length > 0) { setConvergenceChecks(freshChecks); } + if (freshActionRuns && freshActionRuns.length > 0) { + setActionRuns(freshActionRuns); + } return snapshot; } catch { return null; @@ -1387,14 +1418,18 @@ export function PrDetailPane({ if (!autoConvergeRef.current) { stopAutoConvergePoller(); return; } try { // Poll checks and inventory - const [freshChecks, snapshot] = await Promise.all([ + const [freshChecks, snapshot, freshActionRuns] = await Promise.all([ window.ade.prs.getChecks(pr.id), window.ade.prs.issueInventorySync(pr.id), + window.ade.prs.getActionRuns(pr.id).catch(() => null), ]); setInventorySnapshot(snapshot); if (freshChecks.length > 0) { setConvergenceChecks(freshChecks); } + if (freshActionRuns && freshActionRuns.length > 0) { + setActionRuns(freshActionRuns); + } // Skip rebase logic while an agent session is still active if (!convergenceSessionIdRef.current) { @@ -1715,6 +1750,33 @@ export function PrDetailPane({ } }, [autoConverge, autoConvergeWaitState.phase, convergenceSessionId, pathToMergeActive, startAutoConvergePoller, stopAutoConvergePoller]); + React.useEffect(() => { + if (!pathToMergeActive) return; + let cancelled = false; + let inFlight = false; + const pollRuntime = async () => { + if (inFlight) return; + inFlight = true; + try { + const runtime = await loadConvergenceState(pr.id, { force: true }); + if (cancelled) return; + applyConvergenceRuntime(runtime); + } catch { + // Keep the last known PtM runtime and retry on the next interval. + } finally { + inFlight = false; + } + }; + const id = window.setInterval(() => { + void pollRuntime(); + }, 10_000); + void pollRuntime(); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [applyConvergenceRuntime, loadConvergenceState, pathToMergeActive, pr.id]); + // Cleanup poller on unmount React.useEffect(() => { return () => { @@ -1829,10 +1891,6 @@ export function PrDetailPane({ const handleAutoConvergeToggle = React.useCallback(async (enabled: boolean) => { if (!enabled) { const previousSessionHref = convergenceSessionHrefRef.current; - setAutoConverge(false); - autoConvergeRef.current = false; - setPathToMergeActive(false); - pathToMergeActiveRef.current = false; pathToMergeActionSeqRef.current += 1; // Tear down the orchestrator's per-PR scheduling so a re-enable starts // fresh instead of resuming with stale args. @@ -1840,10 +1898,15 @@ export function PrDetailPane({ const stopped = await window.ade.prs.pathToMergeStop({ prId: pr.id, reason: "user disabled auto-converge" }); applyConvergenceRuntime(stopped.runtime); } catch (err: unknown) { - // Non-fatal — the renderer-side stop below still clears UI state. - // eslint-disable-next-line no-console - console.warn("pathToMergeStop failed", err); + setActionError(`Failed to stop Path to Merge: ${err instanceof Error ? err.message : String(err)}`); + const runtime = await loadConvergenceState(pr.id, { force: true }).catch(() => null); + applyConvergenceRuntime(runtime); + return; } + setAutoConverge(false); + autoConvergeRef.current = false; + setPathToMergeActive(false); + pathToMergeActiveRef.current = false; stopAutoConvergePoller(); const activeSessionId = convergenceSessionIdRef.current; if (activeSessionId) { @@ -1961,7 +2024,7 @@ export function PrDetailPane({ ); } } - }, [applyConvergenceRuntime, pr.id, resolverModel, resolverPermissionMode, resolverReasoningLevel, resolveIssueScope, saveConvergenceRuntime, stopAutoConvergePoller]); + }, [applyConvergenceRuntime, loadConvergenceState, pr.id, resolverModel, resolverPermissionMode, resolverReasoningLevel, resolveIssueScope, saveConvergenceRuntime, stopAutoConvergePoller]); const handleMarkDismissed = React.useCallback(async (itemIds: string[], reason: string) => { try { @@ -3403,6 +3466,23 @@ function OverviewTab(props: OverviewTabProps) { function FilesTab({ files, expandedFile, setExpandedFile }: { files: PrFile[]; expandedFile: string | null; setExpandedFile: (f: string | null) => void }) { const totalAdd = files.reduce((s, f) => s + f.additions, 0); const totalDel = files.reduce((s, f) => s + f.deletions, 0); + const toPatchStatus = (status: PrFile["status"]): FilePatch["status"] => { + if (status === "removed") return "deleted"; + if (status === "copied") return "added"; + return status; + }; + const toPatch = (file: PrFile): FilePatch | null => { + if (!file.patch) return null; + return { + path: file.filename, + oldPath: file.previousFilename ?? undefined, + mode: "commit", + patch: file.patch, + additions: file.additions, + deletions: file.deletions, + status: toPatchStatus(file.status), + }; + }; return ( <div style={{ padding: 20 }}> @@ -3420,6 +3500,7 @@ function FilesTab({ files, expandedFile, setExpandedFile }: { files: PrFile[]; e {files.map((file, idx) => { const isExpanded = expandedFile === file.filename; const statusCol = fileStatusColor(file.status); + const filePatch = toPatch(file); return ( <div key={file.filename}> <button @@ -3451,24 +3532,24 @@ function FilesTab({ files, expandedFile, setExpandedFile }: { files: PrFile[]; e <span style={{ fontFamily: MONO_FONT, fontSize: 11, color: COLORS.success, fontWeight: 600 }}>+{file.additions}</span> <span style={{ fontFamily: MONO_FONT, fontSize: 11, color: COLORS.danger, fontWeight: 600 }}>-{file.deletions}</span> </button> - {isExpanded && file.patch && ( - <div style={{ background: "rgba(0,0,0,0.2)", borderBottom: `1px solid ${COLORS.border}`, overflow: "auto", maxHeight: 500 }}> - <pre style={{ fontFamily: MONO_FONT, fontSize: 11, lineHeight: 1.7, margin: 0, padding: 0 }}> - {file.patch.split("\n").map((line, i) => { - let color: string = COLORS.textSecondary; - let bg: string = "transparent"; - if (line.startsWith("+")) { color = "#4ade80"; bg = "rgba(34,197,94,0.12)"; } - else if (line.startsWith("-")) { color = "#f87171"; bg = "rgba(239,68,68,0.12)"; } - else if (line.startsWith("@@")) { color = COLORS.accent; bg = "color-mix(in srgb, var(--color-accent) 4%, transparent)"; } - return ( - <div key={i} style={{ color, background: bg, padding: "1px 14px", minHeight: "1.7em", borderLeft: line.startsWith("+") ? "3px solid color-mix(in srgb, var(--color-success) 50%, transparent)" : line.startsWith("-") ? "3px solid color-mix(in srgb, var(--color-error) 50%, transparent)" : "3px solid transparent" }}> - {line} - </div> - ); - })} - </pre> + {isExpanded && filePatch ? ( + <div style={{ borderBottom: `1px solid ${COLORS.border}`, height: 500 }}> + <AdeDiffViewer patch={filePatch} editable={false} className="h-full rounded-none border-0" /> </div> - )} + ) : isExpanded ? ( + <div + style={{ + borderBottom: `1px solid ${COLORS.border}`, + padding: "10px 14px", + fontFamily: MONO_FONT, + fontSize: 11, + color: COLORS.textDim, + background: COLORS.recessedBg, + }} + > + Patch unavailable for this file. + </div> + ) : null} </div> ); })} diff --git a/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.test.tsx b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.test.tsx index 1ffdf351c..2b9ee0efa 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.test.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.test.tsx @@ -162,6 +162,38 @@ describe("PrConvergencePanel", () => { expect(props.onAutoConvergeChange).toHaveBeenCalledWith(true); }); + it("disables Path to Merge at max rounds unless it can merge only", async () => { + const user = userEvent.setup(); + const props = renderPanel({ + items: [makeItem()], + checks: [makeCheck({ conclusion: "failure" })], + convergence: makeConvergence({ currentRound: 5, maxRounds: 5 }), + }); + + await user.click(screen.getByRole("button", { name: "Auto-Converge" })); + + const startButton = screen.getByRole("button", { name: "Start Path to Merge" }) as HTMLButtonElement; + expect(startButton.disabled).toBe(true); + expect(startButton.getAttribute("title")).toBe("Maximum rounds reached"); + + await user.click(startButton); + expect(props.onAutoConvergeChange).not.toHaveBeenCalled(); + }); + + it("allows the merge-only Path to Merge path at max rounds when checks are green", async () => { + const user = userEvent.setup(); + const props = renderPanel({ + checks: [makeCheck({ conclusion: "success" })], + convergence: makeConvergence({ currentRound: 5, maxRounds: 5 }), + pipelineSettings: { ...defaultPipelineSettings, autoMerge: true }, + }); + + await user.click(screen.getByRole("button", { name: "Auto-Converge" })); + await user.click(screen.getByRole("button", { name: "Start Path to Merge" })); + + expect(props.onAutoConvergeChange).toHaveBeenCalledWith(true); + }); + it("does not show stale round progress when auto-converge mode is selected but stopped", async () => { const user = userEvent.setup(); renderPanel({ diff --git a/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx index de75f8ad9..fab73b29d 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx @@ -1207,15 +1207,20 @@ export function PrConvergencePanel({ const atMaxRounds = convergence.currentRound >= convergence.maxRounds; const hasActiveWaitState = displayedWaitState.phase === "agent_running" || displayedWaitState.phase === "waiting_checks" || displayedWaitState.phase === "waiting_comments" || displayedWaitState.phase === "paused"; const canRunManualRound = hasNewItems && !atMaxRounds && !busy && !checksStillRunning && !hasActiveWaitState; - const canStartPathToMerge = !autoConverge && !pathToMergeActive && !busy && !atMaxRounds && !hasActiveWaitState && (hasNewItems || checksStillRunning || failingChecks.length > 0); + const canStartMergeOnlyPath = pipelineSettings.autoMerge && allChecksPassing; + const canStartPathToMerge = !autoConverge + && !pathToMergeActive + && !busy + && !hasActiveWaitState + && ((!atMaxRounds && (hasNewItems || checksStillRunning || failingChecks.length > 0)) || canStartMergeOnlyPath); const canRunNext = mode === "auto-converge" ? canStartPathToMerge : canRunManualRound; const launchDisabledReason = mode === "auto-converge" ? autoConverge || pathToMergeActive ? "Path to Merge is already running" : busy ? "Agent is currently running" - : atMaxRounds ? "Maximum rounds reached" + : atMaxRounds && !canStartMergeOnlyPath ? "Maximum rounds reached" : hasActiveWaitState ? "Path to Merge is already waiting" - : !hasNewItems && !checksStillRunning && failingChecks.length === 0 ? "No actionable issues to resolve" + : !hasNewItems && !checksStillRunning && failingChecks.length === 0 && !canStartMergeOnlyPath ? "No actionable issues to resolve" : null : !hasNewItems ? "No new issues to resolve" : atMaxRounds ? "Maximum rounds reached" diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx index dca378cff..4128d1cf4 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx @@ -255,6 +255,7 @@ function makeFakeConvergenceState(prId: string, overrides?: Partial<PrConvergenc pathToMergeActive: false, status: "idle", pollerStatus: "idle", + mergeWaitKind: null, currentRound: 0, activeSessionId: null, activeLaneId: null, @@ -265,6 +266,8 @@ function makeFakeConvergenceState(prId: string, overrides?: Partial<PrConvergenc ciRetryAttemptsUsed: 0, waitForCiStartedAt: null, lastDispatchHeadSha: null, + lastBotPingHeadSha: null, + lastBotPingAt: null, pauseRepeatCount: 0, lastPauseReasonHash: null, lastStartedAt: null, diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx index ffe15f732..759c40ab8 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { MemoryRouter } from "react-router-dom"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GitHubPrSnapshot, LaneSummary, MergeMethod, PrWithConflicts } from "../../../../shared/types"; @@ -132,6 +132,7 @@ describe("GitHubTab", () => { afterEach(() => { cleanup(); + vi.useRealTimers(); }); function renderTab(overrides: Partial<{ @@ -326,6 +327,92 @@ describe("GitHubTab", () => { expect(window.ade.prs.getGitHubSnapshot).not.toHaveBeenCalledWith({ force: true }); }); + it("does not force-refresh the GitHub snapshot for CI/review-only PR status updates", async () => { + const loadedContext = { + prs: [ + { id: "pr-open", checksStatus: "pending", reviewStatus: "requested", additions: 12, deletions: 3, updatedAt: "2026-03-13T11:30:00.000Z" }, + ], + mergeContextByPrId: {}, + detailStatus: null, + detailChecks: [], + detailReviews: [], + detailComments: [], + detailBusy: false, + loading: false, + setViewerLogin: vi.fn(), + }; + mockUsePrs.mockReturnValue(loadedContext); + const { rerender } = render( + <MemoryRouter> + <GitHubTab + lanes={[] satisfies LaneSummary[]} + mergeMethod={"squash" satisfies MergeMethod} + selectedPrId={null} + onSelectPr={vi.fn()} + onRefreshAll={vi.fn().mockResolvedValue(undefined)} + /> + </MemoryRouter>, + ); + + await waitFor(() => { + expect(window.ade.prs.getGitHubSnapshot).toHaveBeenCalledWith({ force: false }); + }); + + vi.useFakeTimers(); + (window.ade.prs.getGitHubSnapshot as ReturnType<typeof vi.fn>).mockClear(); + await vi.advanceTimersByTimeAsync(31_000); + + mockUsePrs.mockReturnValue({ + ...loadedContext, + prs: [ + { + ...loadedContext.prs[0]!, + checksStatus: "passing", + reviewStatus: "approved", + updatedAt: "2026-03-13T11:31:00.000Z", + }, + ], + }); + rerender( + <MemoryRouter> + <GitHubTab + lanes={[] satisfies LaneSummary[]} + mergeMethod={"squash" satisfies MergeMethod} + selectedPrId={null} + onSelectPr={vi.fn()} + onRefreshAll={vi.fn().mockResolvedValue(undefined)} + /> + </MemoryRouter>, + ); + await vi.advanceTimersByTimeAsync(31_000); + + expect(window.ade.prs.getGitHubSnapshot).not.toHaveBeenCalled(); + }); + + it("paces hot snapshot refreshes after manual sync", async () => { + renderTab(); + + await waitFor(() => { + expect(window.ade.prs.getGitHubSnapshot).toHaveBeenCalledWith({ force: false }); + }); + vi.useFakeTimers(); + (window.ade.prs.getGitHubSnapshot as ReturnType<typeof vi.fn>).mockClear(); + + fireEvent.click(screen.getByRole("button", { name: /^sync$/i })); + + expect(window.ade.prs.getGitHubSnapshot).toHaveBeenCalledWith({ force: true }); + expect(window.ade.prs.getGitHubSnapshot).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(29_000); + expect(window.ade.prs.getGitHubSnapshot).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1_000); + expect(window.ade.prs.getGitHubSnapshot).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(30_000); + expect(window.ade.prs.getGitHubSnapshot).toHaveBeenCalledTimes(2); + }); + it("filters by ADE scope showing only linked PRs", async () => { const snapshotWithUnlinked: GitHubPrSnapshot = { ...snapshot, diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx index 7d5e2100c..1a89a22e7 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx @@ -17,6 +17,7 @@ import type { PrDetailRouteTab } from "../prsRouteState"; const VIRTUALIZE_AT = 50; const GITHUB_TAB_REVISIT_CACHE_TTL_MS = 60_000; const GITHUB_TAB_SNAPSHOT_FRESH_MS = 30_000; +const GITHUB_TAB_HOT_REFRESH_DELAY_MS = 30_000; const GITHUB_TAB_CACHE_DISABLED = import.meta.env.MODE === "test"; type GitHubTabProps = { @@ -480,27 +481,13 @@ export function GitHubTab({ }, [setContextViewerLogin, snapshot?.viewerLogin]); const startHotRefreshWindow = React.useCallback(() => { - const now = Date.now(); - hotRefreshUntilRef.current = Math.max(hotRefreshUntilRef.current, now + 180_000); if (hotRefreshTimerRef.current != null) return; - - const schedule = () => { - const remaining = hotRefreshUntilRef.current - Date.now(); - if (remaining <= 0) { - hotRefreshTimerRef.current = null; - return; - } - const elapsed = 180_000 - remaining; - const delay = elapsed < 60_000 ? 5_000 : 15_000; - hotRefreshTimerRef.current = window.setTimeout(() => { - hotRefreshTimerRef.current = null; - void loadSnapshot({ force: true, silent: true }).finally(() => { - schedule(); - }); - }, delay); - }; - - schedule(); + hotRefreshUntilRef.current = Date.now() + GITHUB_TAB_HOT_REFRESH_DELAY_MS; + hotRefreshTimerRef.current = window.setTimeout(() => { + hotRefreshTimerRef.current = null; + hotRefreshUntilRef.current = 0; + void loadSnapshot({ force: true, silent: true }); + }, GITHUB_TAB_HOT_REFRESH_DELAY_MS); }, [loadSnapshot]); React.useEffect(() => { @@ -538,11 +525,8 @@ export function GitHubTab({ .map((pr) => [ pr.id, pr.state, - pr.checksStatus, - pr.reviewStatus, pr.title, pr.githubPrNumber, - pr.updatedAt, ]) .sort((left, right) => String(left[0]).localeCompare(String(right[0]))), ); diff --git a/apps/desktop/src/renderer/components/prs/tabs/QueueAutomateMergingModal.test.ts b/apps/desktop/src/renderer/components/prs/tabs/QueueAutomateMergingModal.test.ts new file mode 100644 index 000000000..437a844ba --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/tabs/QueueAutomateMergingModal.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import type { PrConvergenceState } from "../../../../shared/types"; +import { isWaitingForGithubAutoMerge } from "./queueAutomateMergingRuntime"; + +function runtime(patch: Partial<PrConvergenceState> = {}): PrConvergenceState { + return { + prId: "pr-1", + autoConvergeEnabled: true, + pathToMergeActive: true, + status: "running", + pollerStatus: "polling", + mergeWaitKind: null, + currentRound: 0, + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: null, + errorMessage: null, + forceFinalizeUsed: false, + ciRetryAttemptsUsed: 0, + waitForCiStartedAt: null, + lastDispatchHeadSha: null, + lastBotPingHeadSha: null, + lastBotPingAt: null, + pauseRepeatCount: 0, + lastPauseReasonHash: null, + lastStartedAt: null, + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-01T00:00:00.000Z", + ...patch, + }; +} + +describe("isWaitingForGithubAutoMerge", () => { + it("keeps queue automation polling after gh auto-merge is armed", () => { + expect(isWaitingForGithubAutoMerge(runtime({ + status: "converged", + pollerStatus: "waiting_for_checks", + mergeWaitKind: "github_auto_merge_armed", + pauseReason: "auto-merge armed via gh CLI; waiting for GitHub to land.", + }))).toBe(true); + + expect(isWaitingForGithubAutoMerge(runtime({ + status: "converged", + pollerStatus: "waiting_for_checks", + pauseReason: "auto-merge armed via gh CLI; waiting for GitHub to land.", + }))).toBe(false); + }); + + it("does not treat operator-action converged states as active auto-merge waits", () => { + expect(isWaitingForGithubAutoMerge(runtime({ + status: "converged", + pollerStatus: "waiting_for_comments", + pauseReason: "Converged but pipelineSettings.autoMerge is false; click merge when ready.", + }))).toBe(false); + + expect(isWaitingForGithubAutoMerge(runtime({ + status: "converged", + pollerStatus: "waiting_for_comments", + pauseReason: "Inventory clean; merge ladder is blocked: required review.", + }))).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/components/prs/tabs/QueueAutomateMergingModal.tsx b/apps/desktop/src/renderer/components/prs/tabs/QueueAutomateMergingModal.tsx index a0bb563ec..f77ebb71d 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/QueueAutomateMergingModal.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/QueueAutomateMergingModal.tsx @@ -11,6 +11,7 @@ import { COLORS, MONO_FONT, SANS_FONT, outlineButton, primaryButton } from "../. import { LaneAccentDot } from "../../lanes/LaneAccentDot"; import { PrPipelineSettings } from "../shared/PrPipelineSettings"; import { SmartTooltip } from "../../ui/SmartTooltip"; +import { isWaitingForGithubAutoMerge } from "./queueAutomateMergingRuntime"; // --------------------------------------------------------------------------- // Types @@ -58,6 +59,8 @@ type SequenceState = const POLL_INTERVAL_MS = 4000; const TERMINAL_FAILED: ReadonlySet<ConvergenceRuntimeStatus> = new Set([ + "paused", + "converged", "failed", "cancelled", "stopped", @@ -223,8 +226,14 @@ export function QueueAutomateMergingModal({ if (status && TERMINAL_SUCCESS.has(status)) { return { outcome: "merged", runtime }; } + if (isWaitingForGithubAutoMerge(runtime)) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + continue; + } if (status && TERMINAL_FAILED.has(status)) { - const message = runtime?.errorMessage?.trim() || `Path to Merge ${status}`; + const message = runtime?.errorMessage?.trim() + || runtime?.pauseReason?.trim() + || (status === "converged" ? "Path to Merge converged without merging." : `Path to Merge ${status}`); return { outcome: "failed", runtime, error: message }; } await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); diff --git a/apps/desktop/src/renderer/components/prs/tabs/queueAutomateMergingRuntime.ts b/apps/desktop/src/renderer/components/prs/tabs/queueAutomateMergingRuntime.ts new file mode 100644 index 000000000..8beee1b0e --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/tabs/queueAutomateMergingRuntime.ts @@ -0,0 +1,7 @@ +import type { PrConvergenceState } from "../../../../shared/types"; + +export function isWaitingForGithubAutoMerge(runtime: PrConvergenceState | null): boolean { + return runtime?.status === "converged" + && runtime.pollerStatus === "waiting_for_checks" + && runtime.mergeWaitKind === "github_auto_merge_armed"; +} diff --git a/apps/desktop/src/renderer/components/settings/LinearSection.tsx b/apps/desktop/src/renderer/components/settings/LinearSection.tsx index dad15f691..3f21a7b0b 100644 --- a/apps/desktop/src/renderer/components/settings/LinearSection.tsx +++ b/apps/desktop/src/renderer/components/settings/LinearSection.tsx @@ -65,6 +65,7 @@ export function LinearSection() { if (!connection?.authMode) return null; return connection.authMode === "oauth" ? "OAuth" : "API key"; }, [connection?.authMode]); + const workspaceLabel = connection?.organizationName?.trim() || connection?.organizationUrlKey?.trim() || null; /* ── Load helpers ── */ const loadProjects = useCallback(async (requestIdArg?: number) => { @@ -287,26 +288,73 @@ export function LinearSection() { <div style={{ fontSize: 12, fontFamily: SANS_FONT, color: COLORS.textSecondary, marginTop: 2 }}> {connection?.viewerName ? `Signed in as ${connection.viewerName}` : "Signed in"} {authModeLabel ? ` via ${authModeLabel}` : ""} + {workspaceLabel ? ` · ${workspaceLabel}` : ""} {connection?.projectCount ? ` · ${connection.projectCount} project${connection.projectCount === 1 ? "" : "s"}` : ""} </div> </div> </div> - <button - type="button" - onClick={() => void handleDisconnect()} - style={{ - background: "none", border: "none", cursor: "pointer", - fontSize: 11, fontFamily: SANS_FONT, color: COLORS.textDim, - padding: "4px 8px", borderRadius: 6, - transition: "color 0.15s", - }} - onMouseEnter={(e) => { e.currentTarget.style.color = COLORS.danger; }} - onMouseLeave={(e) => { e.currentTarget.style.color = COLORS.textDim; }} - > - Disconnect - </button> + <div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", flexWrap: "wrap", gap: 8 }}> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => void handleStartOAuth()} + disabled={oauthStarting || validating || connection?.oauthAvailable === false} + > + {oauthStarting ? <CircleNotch size={12} className="animate-spin" /> : null} + {oauthStarting ? "Waiting for Linear..." : "Reconnect current workspace"} + </Button> + <button + type="button" + onClick={() => void handleDisconnect()} + disabled={oauthStarting} + style={{ + background: "none", border: "none", cursor: oauthStarting ? "default" : "pointer", + fontSize: 11, fontFamily: SANS_FONT, color: COLORS.textDim, + padding: "4px 8px", borderRadius: 6, + transition: "color 0.15s", + opacity: oauthStarting ? 0.55 : 1, + }} + onMouseEnter={(e) => { if (!oauthStarting) e.currentTarget.style.color = COLORS.danger; }} + onMouseLeave={(e) => { e.currentTarget.style.color = COLORS.textDim; }} + > + Disconnect + </button> + </div> </div> + {workspaceLabel ? ( + <div style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + flexWrap: "wrap", + gap: 12, + padding: "10px 12px", + marginBottom: 14, + borderRadius: 8, + background: "color-mix(in srgb, var(--color-fg) 4%, transparent)", + border: `1px solid ${COLORS.border}`, + }}> + <div> + <div style={{ fontSize: 10, fontFamily: SANS_FONT, color: COLORS.textDim, marginBottom: 2 }}> + Workspace + </div> + <div style={{ fontSize: 13, fontWeight: 600, fontFamily: SANS_FONT, color: COLORS.textPrimary }}> + {workspaceLabel} + </div> + </div> + {connection?.organizationUrlKey ? ( + <div style={{ fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textMuted }}> + {connection.organizationUrlKey} + </div> + ) : null} + <div style={{ flexBasis: "100%", fontSize: 11, fontFamily: SANS_FONT, color: COLORS.textMuted }}> + To connect a different workspace, switch workspaces in Linear first, then reconnect here. + </div> + </div> + ) : null} + {/* Project list */} {projects.length > 0 ? ( <div> @@ -369,7 +417,7 @@ export function LinearSection() { Sign in with Linear </div> <div style={{ fontSize: 11, fontFamily: SANS_FONT, color: COLORS.textMuted, lineHeight: "17px" }}> - Opens Linear in your browser for a secure OAuth flow. No keys to manage. + Connects the workspace currently selected in Linear. </div> </div> <Button diff --git a/apps/desktop/src/renderer/components/shared/AdeDiffViewer.tsx b/apps/desktop/src/renderer/components/shared/AdeDiffViewer.tsx new file mode 100644 index 000000000..d3222c8ab --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/AdeDiffViewer.tsx @@ -0,0 +1,204 @@ +import React, { forwardRef, useImperativeHandle, useMemo, useRef, useState } from "react"; +import { MultiFileDiff, PatchDiff } from "@pierre/diffs/react"; +import type { FileContents } from "@pierre/diffs/react"; +import type { FileDiff, FilePatch } from "../../../shared/types"; +import { MonacoDiffView, type MonacoDiffHandle } from "../lanes/MonacoDiffView"; +import { COLORS, MONO_FONT, SANS_FONT, outlineButton } from "../lanes/laneDesignTokens"; +import { cn } from "../ui/cn"; + +export type AdeDiffViewerHandle = MonacoDiffHandle; + +type DiffLayout = "split" | "unified"; +type DiffOverflow = "scroll" | "wrap"; + +type AdeDiffViewerProps = { + diff?: FileDiff | null; + patch?: FilePatch | null; + editable?: boolean; + className?: string; + theme?: "dark" | "light"; + compact?: boolean; + showToolbar?: boolean; +}; + +const DIFF_UNSAFE_CSS = ` + [data-diffs-header="default"] { + display: none; + } + [data-code] { + scrollbar-width: thin; + } + [data-diff], + [data-file] { + border-radius: 0; + } +`; + +function makeFileContents(path: string, contents: string, suffix: string): FileContents { + return { + name: path, + contents, + cacheKey: `${path}:${suffix}:${contents.length}`, + }; +} + +function normalizePatchForRenderer(patch: FilePatch): string { + const text = patch.patch.trimEnd(); + if (!text) return ""; + if (text.startsWith("diff --git ") || text.startsWith("--- ")) return text; + + const oldPath = patch.oldPath ?? patch.path; + const oldHeader = patch.status === "added" ? "/dev/null" : `a/${oldPath}`; + const newHeader = patch.status === "deleted" ? "/dev/null" : `b/${patch.path}`; + return [`diff --git a/${oldPath} b/${patch.path}`, `--- ${oldHeader}`, `+++ ${newHeader}`, text].join("\n"); +} + +function copyText(text: string): void { + void navigator.clipboard?.writeText(text).catch(() => { + // Clipboard write is best-effort from an explicit user click. + }); +} + +function ViewerState({ + title, + detail, +}: { + title: string; + detail?: string; +}) { + return ( + <div className="flex h-full w-full items-center justify-center p-4"> + <div style={{ maxWidth: 420, textAlign: "center" }}> + <div style={{ fontFamily: SANS_FONT, fontSize: 13, color: COLORS.textSecondary }}>{title}</div> + {detail ? ( + <div style={{ marginTop: 6, fontFamily: MONO_FONT, fontSize: 11, lineHeight: 1.5, color: COLORS.textDim }}> + {detail} + </div> + ) : null} + </div> + </div> + ); +} + +export const AdeDiffViewer = forwardRef<AdeDiffViewerHandle, AdeDiffViewerProps>(function AdeDiffViewer( + { + diff, + patch, + editable = false, + className, + theme = "dark", + compact = false, + showToolbar = true, + }, + ref, +) { + const monacoRef = useRef<MonacoDiffHandle | null>(null); + const [layout, setLayout] = useState<DiffLayout>("split"); + const [overflow, setOverflow] = useState<DiffOverflow>("scroll"); + const [lineNumbers, setLineNumbers] = useState(true); + + useImperativeHandle(ref, () => ({ + getModifiedValue: () => monacoRef.current?.getModifiedValue() ?? null, + revealLineInCenter: (line: number) => { + monacoRef.current?.revealLineInCenter(line); + }, + })); + + const activePath = patch?.path ?? diff?.path ?? ""; + const options = useMemo( + () => ({ + theme: theme === "light" ? "pierre-light" : "pierre-dark", + themeType: theme, + diffStyle: layout, + overflow, + disableLineNumbers: !lineNumbers, + hunkSeparators: "line-info-basic", + lineDiffType: "word", + collapsedContextThreshold: 12, + expansionLineCount: 20, + unsafeCSS: DIFF_UNSAFE_CSS, + }) as const, + [layout, lineNumbers, overflow, theme], + ); + + if (editable && diff) { + return <MonacoDiffView ref={monacoRef} diff={diff} editable className={className} theme={theme} />; + } + + const binary = Boolean(patch?.isBinary || diff?.isBinary); + const truncated = Boolean(patch?.isTruncated || diff?.original.isTruncated || diff?.modified.isTruncated); + const normalizedPatch = patch ? normalizePatchForRenderer(patch) : ""; + const oldFile = diff && !patch ? makeFileContents(diff.path, diff.original.text ?? "", "old") : null; + const newFile = diff && !patch ? makeFileContents(diff.path, diff.modified.text ?? "", "new") : null; + const hasInlineDiffContent = Boolean( + oldFile + && newFile + && ( + (diff?.original.text ?? "").length > 0 + || (diff?.modified.text ?? "").length > 0 + ), + ); + + return ( + <div + className={cn("flex h-full min-h-0 w-full flex-col overflow-hidden rounded-lg border border-border bg-card/60", className)} + style={{ + ["--diffs-font-family" as string]: MONO_FONT, + ["--diffs-header-font-family" as string]: SANS_FONT, + ["--diffs-font-size" as string]: compact ? "12px" : "13px", + ["--diffs-line-height" as string]: compact ? "18px" : "20px", + ["--diffs-dark-bg" as string]: "transparent", + ["--diffs-light-bg" as string]: "transparent", + }} + > + {showToolbar ? ( + <div + className="flex shrink-0 items-center gap-1 border-b border-border" + style={{ minHeight: compact ? 30 : 34, padding: compact ? "3px 6px" : "4px 8px", background: COLORS.recessedBg }} + > + <button type="button" style={outlineButton({ height: 24, padding: "0 8px", fontSize: 11, borderRadius: 6, color: layout === "split" ? COLORS.accent : COLORS.textSecondary })} onClick={() => setLayout("split")}> + Split + </button> + <button type="button" style={outlineButton({ height: 24, padding: "0 8px", fontSize: 11, borderRadius: 6, color: layout === "unified" ? COLORS.accent : COLORS.textSecondary })} onClick={() => setLayout("unified")}> + Unified + </button> + <button type="button" style={outlineButton({ height: 24, padding: "0 8px", fontSize: 11, borderRadius: 6, color: overflow === "wrap" ? COLORS.accent : COLORS.textSecondary })} onClick={() => setOverflow((value) => (value === "wrap" ? "scroll" : "wrap"))}> + Wrap + </button> + <button type="button" style={outlineButton({ height: 24, padding: "0 8px", fontSize: 11, borderRadius: 6, color: lineNumbers ? COLORS.accent : COLORS.textSecondary })} onClick={() => setLineNumbers((value) => !value)}> + Lines + </button> + {activePath ? ( + <button type="button" style={outlineButton({ height: 24, padding: "0 8px", fontSize: 11, borderRadius: 6, marginLeft: "auto" })} onClick={() => copyText(activePath)}> + Copy path + </button> + ) : null} + </div> + ) : null} + {truncated ? ( + <div style={{ padding: "5px 8px", borderBottom: `1px solid ${COLORS.border}`, fontFamily: MONO_FONT, fontSize: 11, color: COLORS.warning }}> + Preview is truncated. + </div> + ) : null} + <div className="min-h-0 flex-1 overflow-auto"> + {binary ? ( + <ViewerState title="Binary diff preview unavailable" detail={activePath} /> + ) : patch ? ( + normalizedPatch ? ( + <PatchDiff patch={normalizedPatch} options={options} /> + ) : ( + <ViewerState title="No patch available" detail={activePath} /> + ) + ) : oldFile && newFile ? ( + hasInlineDiffContent ? ( + <MultiFileDiff oldFile={oldFile} newFile={newFile} options={options} /> + ) : ( + <ViewerState title="No text diff available" detail={activePath} /> + ) + ) : ( + <ViewerState title="No diff selected" /> + )} + </div> + </div> + ); +}); diff --git a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx index 9eeb876dc..51db8bbe4 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx @@ -3,7 +3,7 @@ import { Terminal, ArrowRight, } from "@phosphor-icons/react"; -import type { AgentChatPermissionMode, AgentChatSession, LaneSummary } from "../../../shared/types"; +import type { AgentChatPermissionMode, AgentChatSession, LaneLinearIssue, LaneSummary } from "../../../shared/types"; import type { WorkDraftKind } from "../../state/appStore"; import { useAppStore } from "../../state/appStore"; import { AgentChatPane } from "../chat/AgentChatPane"; @@ -29,6 +29,8 @@ type WorkStartSurfaceProps = { env?: Record<string, string>; tracked?: boolean; }) => Promise<unknown>; + initialLinearIssueContext?: LaneLinearIssue | null; + onInitialLinearIssueContextConsumed?: () => void; }; type CliProviderOption = { @@ -61,6 +63,8 @@ export function WorkStartSurface({ lanes, onOpenChatSession, onLaunchPtySession, + initialLinearIssueContext = null, + onInitialLinearIssueContextConsumed, }: WorkStartSurfaceProps) { const globallySelectedLaneId = useAppStore((s) => s.selectedLaneId); const selectLaneGlobal = useAppStore((s) => s.selectLane); @@ -198,6 +202,8 @@ export function WorkStartSurface({ hideLaneToolDrawers forceDraftMode embeddedWorkLayout + initialLinearIssueContext={initialLinearIssueContext} + onInitialLinearIssueContextConsumed={onInitialLinearIssueContextConsumed} onSessionCreated={onOpenChatSession} availableLanes={lanes} onLaneChange={setLaneAndSync} diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index a625bfc50..595a3dea0 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -19,7 +19,7 @@ import { Terminal, X, } from "@phosphor-icons/react"; -import type { AgentChatSession, LaneSummary, TerminalSessionSummary } from "../../../shared/types"; +import type { AgentChatSession, LaneLinearIssue, LaneSummary, TerminalSessionSummary } from "../../../shared/types"; import type { WorkDraftKind, WorkViewMode } from "../../state/appStore"; import { TerminalView } from "./TerminalView"; import { ToolLogo } from "./ToolLogos"; @@ -436,6 +436,8 @@ export function WorkViewArea({ sessionsListLoading = false, workSidebarOpen = false, onToggleWorkSidebar, + initialLinearIssueContext = null, + onInitialLinearIssueContextConsumed, }: { gridLayoutId: string; lanes: LaneSummary[]; @@ -473,6 +475,8 @@ export function WorkViewArea({ sessionsListLoading?: boolean; workSidebarOpen?: boolean; onToggleWorkSidebar?: () => void; + initialLinearIssueContext?: LaneLinearIssue | null; + onInitialLinearIssueContextConsumed?: () => void; }) { const expandSessionsProps: SessionsPaneExpandAffordanceProps = { show: Boolean(sessionsPaneCollapsed && onExpandSessionsPane), @@ -626,6 +630,8 @@ export function WorkViewArea({ lanes={lanes} onOpenChatSession={onOpenChatSession} onLaunchPtySession={onLaunchPtySession} + initialLinearIssueContext={initialLinearIssueContext} + onInitialLinearIssueContextConsumed={onInitialLinearIssueContextConsumed} /> </div> </div> @@ -688,6 +694,8 @@ export function WorkViewArea({ lanes={lanes} onOpenChatSession={onOpenChatSession} onLaunchPtySession={onLaunchPtySession} + initialLinearIssueContext={initialLinearIssueContext} + onInitialLinearIssueContextConsumed={onInitialLinearIssueContextConsumed} /> </div> </div> diff --git a/apps/desktop/src/shared/chatContextAttachments.ts b/apps/desktop/src/shared/chatContextAttachments.ts new file mode 100644 index 000000000..631258f62 --- /dev/null +++ b/apps/desktop/src/shared/chatContextAttachments.ts @@ -0,0 +1,192 @@ +import type { AgentChatContextAttachment, LaneLinearIssue } from "./types"; + +export function chatContextAttachmentKey(attachment: AgentChatContextAttachment): string { + return `linear:${attachment.issue.id}`; +} + +export function makeLinearIssueContextAttachment( + issue: LaneLinearIssue, + source: "manual" | "lane_link" = "manual", +): AgentChatContextAttachment { + return { + type: "linear_issue", + issue, + source, + attachedAt: new Date().toISOString(), + }; +} + +export function mergeChatContextAttachments( + current: AgentChatContextAttachment[], + incoming: AgentChatContextAttachment[], +): AgentChatContextAttachment[] { + const deduped = new Map<string, AgentChatContextAttachment>(); + for (const attachment of current) deduped.set(chatContextAttachmentKey(attachment), attachment); + for (const attachment of incoming) deduped.set(chatContextAttachmentKey(attachment), attachment); + return [...deduped.values()]; +} + +export function removeChatContextAttachment( + current: AgentChatContextAttachment[], + key: string, +): AgentChatContextAttachment[] { + return current.filter((attachment) => chatContextAttachmentKey(attachment) !== key); +} + +function readRecord(value: unknown): Record<string, unknown> | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record<string, unknown> + : null; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length ? value.trim() : null; +} + +function readNullableString(value: unknown): string | null { + if (value == null) return null; + return typeof value === "string" ? value : null; +} + +function readNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function readStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function normalizeLinearIssue(value: unknown): LaneLinearIssue | null { + const issue = readRecord(value); + if (!issue) return null; + const id = readString(issue.id); + const identifier = readString(issue.identifier); + const title = readString(issue.title); + const projectId = readString(issue.projectId); + const projectSlug = readString(issue.projectSlug); + const teamId = readString(issue.teamId); + const teamKey = readString(issue.teamKey); + const stateId = readString(issue.stateId); + const stateName = readString(issue.stateName); + const stateType = readString(issue.stateType); + const createdAt = readString(issue.createdAt); + const updatedAt = readString(issue.updatedAt); + if (!id || !identifier || !title || !projectId || !projectSlug || !teamId || !teamKey || !stateId || !stateName || !stateType || !createdAt || !updatedAt) { + return null; + } + const rawPriorityLabel = readString(issue.priorityLabel); + const priorityLabel: LaneLinearIssue["priorityLabel"] = rawPriorityLabel === "urgent" || rawPriorityLabel === "high" || rawPriorityLabel === "normal" || rawPriorityLabel === "low" || rawPriorityLabel === "none" + ? rawPriorityLabel + : "none"; + return { + id, + identifier, + title, + description: readNullableString(issue.description), + url: readNullableString(issue.url), + projectId, + projectSlug, + projectName: readNullableString(issue.projectName), + teamId, + teamKey, + teamName: readNullableString(issue.teamName), + stateId, + stateName, + stateType, + priority: readNumber(issue.priority), + priorityLabel, + labels: readStringArray(issue.labels), + assigneeId: readNullableString(issue.assigneeId), + assigneeName: readNullableString(issue.assigneeName), + creatorId: readNullableString(issue.creatorId), + creatorName: readNullableString(issue.creatorName), + dueDate: readNullableString(issue.dueDate), + estimate: issue.estimate == null ? null : readNumber(issue.estimate), + branchName: readNullableString(issue.branchName), + createdAt, + updatedAt, + }; +} + +export function normalizeChatContextAttachments(value: unknown): AgentChatContextAttachment[] { + if (!Array.isArray(value)) return []; + const out: AgentChatContextAttachment[] = []; + for (const entry of value) { + const record = readRecord(entry); + if (!record || record.type !== "linear_issue") continue; + const issue = normalizeLinearIssue(record.issue); + if (!issue) continue; + out.push({ + type: "linear_issue", + issue, + source: record.source === "lane_link" ? "lane_link" : "manual", + attachedAt: readNullableString(record.attachedAt) ?? undefined, + }); + } + return mergeChatContextAttachments([], out); +} + +function cleanIssueValue(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed?.length ? trimmed : null; +} + +function escapeUntrustedXml(value: string): string { + // Entity-escape characters that would otherwise let untrusted Linear content + // break out of the surrounding `<untrusted-data>` wrapper. `&` must be first + // so we don't double-escape entities introduced by later replacements. + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function wrapUntrustedLinearText(value: string): string { + // Linear titles/descriptions are author-controlled and may contain + // prompt-injection content. Wrap them so downstream agents know the text + // inside is data, not instructions. + return `<untrusted-data source="linear">${escapeUntrustedXml(value)}</untrusted-data>`; +} + +function formatLinearIssueContext(issue: LaneLinearIssue): string { + const project = cleanIssueValue(issue.projectName) ?? cleanIssueValue(issue.projectSlug) ?? cleanIssueValue(issue.teamKey); + const team = cleanIssueValue(issue.teamName) ?? cleanIssueValue(issue.teamKey); + const labels = issue.labels.filter((label) => label.trim().length > 0).join(", "); + const description = issue.description?.trim(); + return [ + `- Provider: Linear`, + `- Identifier: ${issue.identifier}`, + `- Linear issue id: ${issue.id}`, + `- Title: ${wrapUntrustedLinearText(issue.title)}`, + project ? `- Project: ${wrapUntrustedLinearText(project)}` : null, + team ? `- Team: ${wrapUntrustedLinearText(team)}` : null, + `- State: ${wrapUntrustedLinearText(issue.stateName)} (${wrapUntrustedLinearText(issue.stateType)})`, + `- Priority: ${issue.priorityLabel} (${issue.priority})`, + issue.assigneeName ? `- Assignee: ${wrapUntrustedLinearText(issue.assigneeName)}` : `- Assignee: Unassigned`, + issue.creatorName ? `- Creator: ${wrapUntrustedLinearText(issue.creatorName)}` : null, + issue.dueDate ? `- Due date: ${issue.dueDate}` : null, + issue.estimate != null ? `- Estimate: ${issue.estimate}` : null, + labels ? `- Labels: ${wrapUntrustedLinearText(labels)}` : null, + issue.branchName ? `- Suggested branch: ${wrapUntrustedLinearText(issue.branchName)}` : null, + issue.url ? `- URL: ${wrapUntrustedLinearText(issue.url)}` : null, + description ? `- Description:\n${wrapUntrustedLinearText(description)}` : null, + ].filter((line): line is string => Boolean(line)).join("\n"); +} + +export function buildChatContextAttachmentPrompt( + contextAttachments: AgentChatContextAttachment[], +): string { + if (!contextAttachments.length) return ""; + return [ + "Attached issue context:", + "ADE already stores any local Linear credentials; do not ask the user for a Linear API key. Use the identifiers below, and refresh from ADE/Linear tooling only if current state matters.", + ...contextAttachments.map((attachment, index) => [ + `Linear issue ${index + 1}:`, + formatLinearIssueContext(attachment.issue), + ].join("\n")), + ].join("\n\n"); +} diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 85af6de24..274a0bede 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -267,6 +267,7 @@ export const IPC = { terminalActiveForChat: "ade.terminal.activeForChat", diffGetChanges: "ade.diff.getChanges", diffGetFile: "ade.diff.getFile", + diffGetFilePatch: "ade.diff.getFilePatch", filesWriteTextAtomic: "ade.files.writeTextAtomic", filesListWorkspaces: "ade.files.listWorkspaces", filesListTree: "ade.files.listTree", @@ -714,6 +715,9 @@ export const IPC = { ctoResetOnboarding: "ade.cto.resetOnboarding", ctoPreviewSystemPrompt: "ade.cto.previewSystemPrompt", ctoGetLinearProjects: "ade.cto.getLinearProjects", + ctoGetLinearQuickView: "ade.cto.getLinearQuickView", + ctoGetLinearIssuePickerData: "ade.cto.getLinearIssuePickerData", + ctoSearchLinearIssues: "ade.cto.searchLinearIssues", ctoSetLinearOAuthClient: "ade.cto.setLinearOAuthClient", ctoClearLinearOAuthClient: "ade.cto.clearLinearOAuthClient", ctoStartLinearOAuth: "ade.cto.startLinearOAuth", diff --git a/apps/desktop/src/shared/linearIssueBranch.ts b/apps/desktop/src/shared/linearIssueBranch.ts new file mode 100644 index 000000000..21f1f307b --- /dev/null +++ b/apps/desktop/src/shared/linearIssueBranch.ts @@ -0,0 +1,43 @@ +import type { LaneLinearIssue, NormalizedLinearIssue } from "./types"; + +type BranchIssueInput = Pick<LaneLinearIssue | NormalizedLinearIssue, "identifier" | "title">; + +export function linearIssueLaneName(issue: BranchIssueInput): string { + return `${issue.identifier.trim()} ${issue.title.trim()}`.trim(); +} + +export function linearIssueBranchName(issue: BranchIssueInput): string { + const identifier = issue.identifier.trim().toLowerCase(); + const titleSlug = issue.title + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-"); + + const branch = [identifier, titleSlug].filter(Boolean).join("-"); + return sanitizeLinearIssueBranchName(branch || identifier || "linear-issue"); +} + +export function sanitizeLinearIssueBranchName(input: string): string { + return input + .trim() + .replace(/^refs\/heads\//, "") + .replace(/^origin\//, "") + // Git ref-format invalids: backslash, tilde, caret, colon, question, star, + // brackets, whitespace, plus the `@{` sequence (reflog selector syntax). + .replace(/@\{/g, "-") + .replace(/[\\~^:?*\[\]\s]+/g, "-") + .replace(/\/+/g, "/") + .replace(/\/\.+/g, "/") + .replace(/\.+\//g, "/") + .replace(/\.\.+/g, "-") + .replace(/\.+$/g, "") + // Strip a trailing `.lock` (case-insensitive) — invalid as a Git ref suffix. + .replace(/\.lock$/i, "") + .replace(/^-+|-+$/g, "") + .replace(/\/$/g, "") + .replace(/^\/+/g, "") + .replace(/-{2,}/g, "-") + || "linear-issue"; +} diff --git a/apps/desktop/src/shared/linearMagicWords.ts b/apps/desktop/src/shared/linearMagicWords.ts new file mode 100644 index 000000000..960e179c4 --- /dev/null +++ b/apps/desktop/src/shared/linearMagicWords.ts @@ -0,0 +1,50 @@ +import type { LaneLinearIssue } from "./types"; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function linearPrMagicWord(closeOnMerge: boolean): "Fixes" | "Refs" { + return closeOnMerge ? "Fixes" : "Refs"; +} + +export function buildLinearPrTitle(issue: LaneLinearIssue): string { + return `${issue.identifier}: ${issue.title}`.trim(); +} + +export function buildLinearPrReference(issue: LaneLinearIssue, closeOnMerge: boolean): string { + return `${linearPrMagicWord(closeOnMerge)} ${issue.identifier}`; +} + +export function ensureLinearPrReference( + body: string, + issue: LaneLinearIssue, + closeOnMerge: boolean, + options: { preserveExisting?: boolean } = {}, +): string { + const reference = buildLinearPrReference(issue, closeOnMerge); + const identifier = escapeRegExp(issue.identifier); + const supportedLineRe = new RegExp(`^(?:Refs|Fixes)\\s+${identifier}\\s*$`, "im"); + if (supportedLineRe.test(body)) { + return options.preserveExisting === false + ? body.replace(supportedLineRe, reference) + : body; + } + const knownMagicRe = new RegExp(`\\b(?:Refs|Fixes)\\s+${identifier}\\b`, "i"); + if (knownMagicRe.test(body)) return body; + const trimmed = body.trimStart(); + return trimmed.length ? `${reference}\n\n${trimmed}` : `${reference}\n`; +} + +export function ensureLinearCommitReference(message: string, issue: LaneLinearIssue): string { + const trimmed = message.trim(); + if (!trimmed.length) return trimmed; + const identifier = escapeRegExp(issue.identifier); + const knownMagicRe = new RegExp(`\\b(?:Refs|Fixes)\\s+${identifier}\\b`, "i"); + if (knownMagicRe.test(trimmed)) return trimmed; + // If the commit message already starts with the issue identifier (e.g. + // "LIN-123: do thing"), don't prepend a duplicate `Refs LIN-123:` block. + const leadingIdRe = new RegExp(`^${identifier}\\s*[:\\-]`, "i"); + if (leadingIdRe.test(trimmed)) return trimmed; + return `Refs ${issue.identifier}: ${trimmed}`; +} diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 4c0f085ea..594f7be58 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -5,6 +5,7 @@ import type { ModelId } from "./core"; import type { CtoCapabilityMode } from "./cto"; import type { FileDiff } from "./git"; +import type { LaneLinearIssue } from "./lanes"; import type { DelegationContract } from "./orchestrator"; export type AgentChatProvider = "codex" | "claude" | "cursor" | "droid" | "opencode" | (string & {}); @@ -80,6 +81,15 @@ export type AgentChatFileRef = { type: "file" | "image"; }; +export type AgentChatLinearIssueContextAttachment = { + type: "linear_issue"; + issue: LaneLinearIssue; + source?: "manual" | "lane_link"; + attachedAt?: string; +}; + +export type AgentChatContextAttachment = AgentChatLinearIssueContextAttachment; + /** Max attachments per parallel multi-lane launch (same refs sent to each child session). */ export const PARALLEL_CHAT_MAX_ATTACHMENTS = 12; @@ -143,6 +153,7 @@ export type AgentChatEvent = text: string; displayText?: string; attachments?: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; turnId?: string; steerId?: string; deliveryState?: "queued" | "delivered" | "inline" | "failed"; @@ -803,6 +814,7 @@ export type AgentChatSendArgs = { text: string; displayText?: string; attachments?: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; reasoningEffort?: string | null; executionMode?: AgentChatExecutionMode | null; interactionMode?: AgentChatInteractionMode | null; @@ -816,6 +828,7 @@ export type AgentChatSteerArgs = { sessionId: string; text: string; attachments?: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; }; export type AgentChatSteerResult = { diff --git a/apps/desktop/src/shared/types/cto.ts b/apps/desktop/src/shared/types/cto.ts index 401adc7a1..f53845357 100644 --- a/apps/desktop/src/shared/types/cto.ts +++ b/apps/desktop/src/shared/types/cto.ts @@ -1,4 +1,10 @@ -import type { ModelId } from "./core"; +import type { ModelId, OnboardingDetectionResult } from "./core"; +import type { + LinearCatalogState, + LinearCatalogUser, + LinearConnectionStatus, + NormalizedLinearIssue, +} from "./linearSync"; import type { OpenclawBridgeConfig, OpenclawBridgeState, @@ -148,6 +154,99 @@ export type CtoLinearProject = { name: string; slug: string; teamName: string; + teamKey?: string | null; +}; + +export type CtoSearchLinearIssuesArgs = { + projectId?: string | null; + projectSlug?: string | null; + teamKey?: string | null; + stateTypes?: string[]; + assigneeId?: string | null; + priority?: number | null; + query?: string | null; + first?: number; + after?: string | null; + includeArchived?: boolean; +}; + +export type CtoSearchLinearIssuesResult = { + issues: NormalizedLinearIssue[]; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; +}; + +export type CtoGetLinearIssuePickerDataResult = { + projects: CtoLinearProject[]; + users: LinearCatalogUser[]; + states: LinearCatalogState[]; +}; + +export type CtoLinearQuickViewProject = CtoLinearProject & { + url: string | null; + color: string | null; + icon: string | null; + description: string | null; + statusName: string | null; + statusType: string | null; + health: string | null; + progress: number | null; + scope: number | null; + priority: number | null; + priorityLabel: string | null; + issueCount: number | null; + completedIssueCount: number | null; + startDate: string | null; + targetDate: string | null; + leadName: string | null; + teamKeys: string[]; +}; + +export type CtoLinearQuickViewTeam = { + id: string; + key: string; + name: string; + displayName: string; + color: string | null; + issueCount: number | null; + cyclesEnabled: boolean | null; + private: boolean | null; +}; + +export type CtoLinearQuickView = { + connection: LinearConnectionStatus; + organization: { + id: string; + name: string; + urlKey: string | null; + logoUrl: string | null; + gitBranchFormat: string | null; + createdIssueCount: number | null; + roadmapEnabled: boolean | null; + customersEnabled: boolean | null; + releasesEnabled: boolean | null; + } | null; + viewer: { + id: string; + name: string; + displayName: string; + email: string | null; + avatarUrl: string | null; + admin: boolean | null; + guest: boolean | null; + url: string | null; + } | null; + projects: CtoLinearQuickViewProject[]; + teams: CtoLinearQuickViewTeam[]; + assignedIssues: NormalizedLinearIssue[]; + recentIssues: NormalizedLinearIssue[]; + fetchedAt: string; + sdk: { + packageName: "@linear/sdk"; + surfaces: string[]; + }; }; export type CtoStartLinearOAuthArgs = Record<string, never>; @@ -173,14 +272,14 @@ export type CtoGetLinearOAuthSessionArgs = { export type CtoGetLinearOAuthSessionResult = { status: CtoLinearOAuthSessionState; - connection?: import("./linearSync").LinearConnectionStatus; + connection?: LinearConnectionStatus; error?: string | null; }; export type CtoRunProjectScanArgs = Record<string, never>; export type CtoRunProjectScanResult = { - detection: import("./core").OnboardingDetectionResult | null; + detection: OnboardingDetectionResult | null; coreMemoryPatch: Partial<Omit<CtoCoreMemory, "version" | "updatedAt">>; createdMemoryIds: string[]; }; diff --git a/apps/desktop/src/shared/types/files.ts b/apps/desktop/src/shared/types/files.ts index 4171883f1..64f4fa184 100644 --- a/apps/desktop/src/shared/types/files.ts +++ b/apps/desktop/src/shared/types/files.ts @@ -18,7 +18,19 @@ export type FilesListWorkspacesArgs = { includeArchived?: boolean; }; -export type FileTreeChangeStatus = "M" | "A" | "D" | null; +export type FileTreeChangeStatus = + | "modified" + | "added" + | "deleted" + | "renamed" + | "untracked" + | "ignored" + | "unknown" + // Legacy short codes are accepted while older callers/tests migrate. + | "M" + | "A" + | "D" + | null; export type FileTreeNode = { name: string; diff --git a/apps/desktop/src/shared/types/git.ts b/apps/desktop/src/shared/types/git.ts index 6a0788114..edd04887f 100644 --- a/apps/desktop/src/shared/types/git.ts +++ b/apps/desktop/src/shared/types/git.ts @@ -139,9 +139,11 @@ export type DiffMode = "unstaged" | "staged" | "commit"; export type FileChange = { path: string; + oldPath?: string; kind: "modified" | "added" | "deleted" | "renamed" | "untracked" | "unknown"; additions?: number; deletions?: number; + isBinary?: boolean; }; export type DiffChanges = { @@ -161,6 +163,8 @@ export type GetFileDiffArgs = { compareTo?: "worktree" | "parent"; }; +export type GetFilePatchArgs = GetFileDiffArgs; + export type DiffSide = { exists: boolean; text: string; @@ -168,6 +172,19 @@ export type DiffSide = { isTruncated?: boolean; }; +export type FilePatch = { + path: string; + oldPath?: string; + mode: DiffMode; + patch: string; + additions?: number; + deletions?: number; + status?: FileChange["kind"]; + isBinary?: boolean; + isTruncated?: boolean; + size?: number; +}; + export type FileDiff = { path: string; mode: DiffMode; diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index 0d253adab..1e5c06ab9 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -1,6 +1,7 @@ import type { AgentChatSessionSummary } from "./chat"; import type { LaneEnvInitProgress } from "./config"; import type { ConflictOverlap, ConflictStatus } from "./conflicts"; +import type { LinearPriorityLabel } from "./linearSync"; import type { DiffChanges, GitCommitSummary, @@ -58,6 +59,36 @@ export type LaneSummary = { archivedAt?: string | null; devicesOpen?: DeviceMarker[]; activeBranchProfile?: LaneBranchProfile | null; + linearIssue?: LaneLinearIssue | null; +}; + +export type LaneLinearIssue = { + id: string; + identifier: string; + title: string; + description?: string | null; + url: string | null; + projectId: string; + projectSlug: string; + projectName?: string | null; + teamId: string; + teamKey: string; + teamName?: string | null; + stateId: string; + stateName: string; + stateType: string; + priority: number; + priorityLabel: LinearPriorityLabel; + labels: string[]; + assigneeId: string | null; + assigneeName: string | null; + creatorId?: string | null; + creatorName?: string | null; + dueDate?: string | null; + estimate?: number | null; + branchName?: string | null; + createdAt: string; + updatedAt: string; }; export type LaneBranchProfile = { @@ -134,6 +165,8 @@ export type CreateLaneArgs = { description?: string; parentLaneId?: string; baseBranch?: string; + branchName?: string; + linearIssue?: LaneLinearIssue | null; }; export type CreateChildLaneArgs = { @@ -144,6 +177,8 @@ export type CreateChildLaneArgs = { missionId?: string | null; laneRole?: MissionLaneRole | null; baseBranchRef?: string; + branchName?: string; + linearIssue?: LaneLinearIssue | null; }; export type CreateLaneFromUnstagedArgs = { diff --git a/apps/desktop/src/shared/types/linearSync.ts b/apps/desktop/src/shared/types/linearSync.ts index c505649bc..8b40cfc9f 100644 --- a/apps/desktop/src/shared/types/linearSync.ts +++ b/apps/desktop/src/shared/types/linearSync.ts @@ -535,8 +535,10 @@ export type NormalizedLinearIssue = { url: string | null; projectId: string; projectSlug: string; + projectName?: string | null; teamId: string; teamKey: string; + teamName?: string | null; stateId: string; stateName: string; stateType: string; @@ -554,6 +556,12 @@ export type NormalizedLinearIssue = { creatorName?: string | null; blockerIssueIds: string[]; hasOpenBlockers: boolean; + dueDate?: string | null; + estimate?: number | null; + archivedAt?: string | null; + completedAt?: string | null; + canceledAt?: string | null; + startedAt?: string | null; createdAt: string; updatedAt: string; raw: Record<string, unknown>; @@ -564,6 +572,10 @@ export type LinearConnectionStatus = { connected: boolean; viewerId: string | null; viewerName: string | null; + organizationId?: string | null; + organizationName?: string | null; + organizationUrlKey?: string | null; + organizationLogoUrl?: string | null; projectCount?: number; projectPreview?: string[]; checkedAt: string | null; diff --git a/apps/desktop/src/shared/types/prs.ts b/apps/desktop/src/shared/types/prs.ts index c1d8e07c5..ed14d2ea7 100644 --- a/apps/desktop/src/shared/types/prs.ts +++ b/apps/desktop/src/shared/types/prs.ts @@ -243,6 +243,7 @@ export type CreatePrFromLaneArgs = { reviewers?: string[]; allowDirtyWorktree?: boolean; strategy?: PrCreationStrategy; + closeLinearIssueOnMerge?: boolean; }; export type LinkPrToLaneArgs = { @@ -255,6 +256,7 @@ export type DraftPrDescriptionArgs = { model?: string; reasoningEffort?: string | null; baseBranch?: string; + closeLinearIssueOnMerge?: boolean; }; export type UpdatePrDescriptionArgs = { @@ -1344,15 +1346,15 @@ export type PipelineSettings = { }; export const DEFAULT_PIPELINE_SETTINGS: PipelineSettings = { - autoMerge: false, + autoMerge: true, mergeMethod: "repo_default", maxRounds: 5, onRebaseNeeded: "pause", conflictStrategy: "pause", autoAgentSettings: { ...DEFAULT_AUTO_CONFLICT_AGENT_SETTINGS }, - forceFinalizeMode: "off", + forceFinalizeMode: "conditional", forceFinalizeRequireNoCiFailures: true, - atCapPolicy: "stop", + atCapPolicy: "ci_retry_once", atCapWaitMinutes: 30, atCapCiRetryMax: 3, forceMergeRequiresConfirmation: true, @@ -1402,6 +1404,9 @@ export type ConvergencePollerStatus = | "paused" | "stopped"; +export type ConvergenceMergeWaitKind = + | "github_auto_merge_armed"; + export type ConvergenceRuntimeState = { prId: string; autoConvergeEnabled: boolean; @@ -1409,6 +1414,7 @@ export type ConvergenceRuntimeState = { pathToMergeActive: boolean; status: ConvergenceRuntimeStatus; pollerStatus: ConvergencePollerStatus; + mergeWaitKind: ConvergenceMergeWaitKind | null; currentRound: number; activeSessionId: string | null; activeLaneId: string | null; @@ -1419,6 +1425,8 @@ export type ConvergenceRuntimeState = { ciRetryAttemptsUsed: number; waitForCiStartedAt: string | null; lastDispatchHeadSha: string | null; + lastBotPingHeadSha: string | null; + lastBotPingAt: string | null; pauseRepeatCount: number; lastPauseReasonHash: string | null; lastStartedAt: string | null; @@ -1506,6 +1514,7 @@ export const DEFAULT_CONVERGENCE_RUNTIME_STATE: Omit<ConvergenceRuntimeState, "p pathToMergeActive: false, status: "idle", pollerStatus: "idle", + mergeWaitKind: null, currentRound: 0, activeSessionId: null, activeLaneId: null, @@ -1516,6 +1525,8 @@ export const DEFAULT_CONVERGENCE_RUNTIME_STATE: Omit<ConvergenceRuntimeState, "p ciRetryAttemptsUsed: 0, waitForCiStartedAt: null, lastDispatchHeadSha: null, + lastBotPingHeadSha: null, + lastBotPingAt: null, pauseRepeatCount: 0, lastPauseReasonHash: null, lastStartedAt: null, diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index 07869aefc..e57bbb915 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -54,6 +54,37 @@ create index if not exists idx_lanes_project_mission on lanes(project_id, missio create index if not exists idx_lanes_project_role on lanes(project_id, lane_role); +create table if not exists lane_linear_issues ( + id text primary key, + project_id text not null, + lane_id text not null, + issue_id text not null, + issue_json text not null, + created_at text not null, + updated_at text not null, + foreign key(project_id) references projects(id) on delete cascade, + foreign key(lane_id) references lanes(id) on delete cascade + ); + +create index if not exists idx_lane_linear_issues_lane on lane_linear_issues(project_id, lane_id); + +create index if not exists idx_lane_linear_issues_issue on lane_linear_issues(project_id, issue_id); + +drop index if exists uniq_lane_linear_issues_lane; + +delete from lane_linear_issues + where rowid not in ( + select rowid from lane_linear_issues as keep + where keep.id = ( + select id from lane_linear_issues inner_p + where inner_p.project_id = keep.project_id + and inner_p.lane_id = keep.lane_id + order by inner_p.updated_at desc, + inner_p.id asc + limit 1 + ) + ); + create table if not exists lane_branch_profiles ( id text primary key, project_id text not null, @@ -2575,7 +2606,7 @@ create index if not exists idx_inventory_pr_state on pr_issue_inventory(pr_id, s create table if not exists pr_pipeline_settings ( pr_id text primary key, - auto_merge integer not null default 0, + auto_merge integer not null default 1, merge_method text not null default 'repo_default', max_rounds integer not null default 5, on_rebase_needed text not null default 'pause', @@ -2585,7 +2616,7 @@ create table if not exists pr_pipeline_settings ( alter table pr_pipeline_settings add column conflict_strategy text not null default 'pause'; -alter table pr_pipeline_settings add column force_finalize_mode text not null default 'off'; +alter table pr_pipeline_settings add column force_finalize_mode text not null default 'conditional'; alter table pr_pipeline_settings add column force_finalize_require_no_ci_failures integer not null default 1; @@ -2601,7 +2632,7 @@ alter table pr_pipeline_settings add column auto_agent_permission_mode text; alter table pr_pipeline_settings add column auto_agent_confidence_threshold real; -alter table pr_pipeline_settings add column at_cap_policy text; +alter table pr_pipeline_settings add column at_cap_policy text default 'ci_retry_once'; alter table pr_pipeline_settings add column at_cap_wait_minutes integer; @@ -2609,6 +2640,32 @@ alter table pr_pipeline_settings add column at_cap_ci_retry_max integer; alter table pr_pipeline_settings add column force_merge_requires_confirmation integer; +alter table pr_pipeline_settings add column ptm_defaults_backfilled_version text; + +update pr_pipeline_settings + set auto_merge = 1, + force_finalize_mode = 'conditional', + at_cap_policy = 'ci_retry_once', + ptm_defaults_backfilled_version = 'ptm-defaults-v1' + where auto_merge = 0 + and merge_method = 'repo_default' + and max_rounds = 5 + and on_rebase_needed = 'pause' + and coalesce(conflict_strategy, 'pause') = 'pause' + and coalesce(force_finalize_mode, 'off') = 'off' + and coalesce(force_finalize_require_no_ci_failures, 1) = 1 + and coalesce(early_merge_on_green, 1) = 1 + and (at_cap_policy is null or at_cap_policy = 'stop') + and (at_cap_wait_minutes is null or at_cap_wait_minutes = 30) + and (at_cap_ci_retry_max is null or at_cap_ci_retry_max = 3) + and coalesce(force_merge_requires_confirmation, 1) = 1 + and auto_agent_provider is null + and auto_agent_model is null + and auto_agent_reasoning_effort is null + and auto_agent_permission_mode is null + and auto_agent_confidence_threshold is null + and (ptm_defaults_backfilled_version is null or ptm_defaults_backfilled_version <> 'ptm-defaults-v1'); + create table if not exists pr_convergence_state ( pr_id text primary key, auto_converge_enabled integer not null default 0, @@ -2639,6 +2696,12 @@ alter table pr_convergence_state add column wait_for_ci_started_at text; alter table pr_convergence_state add column last_dispatch_head_sha text; +alter table pr_convergence_state add column last_bot_ping_head_sha text; + +alter table pr_convergence_state add column last_bot_ping_at text; + +alter table pr_convergence_state add column merge_wait_kind text; + alter table pr_convergence_state add column pause_repeat_count integer not null default 0; alter table pr_convergence_state add column last_pause_reason_hash text; diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index 3db7f933b..12d828354 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -2245,7 +2245,7 @@ final class DatabaseService { try exec("create index if not exists idx_lane_detail_snapshots_updated_at on lane_detail_snapshots(updated_at)") try ensurePullRequestProjectionTables() try ensureColumn(tableName: "pr_pipeline_settings", columnName: "conflict_strategy", definition: "text not null default 'pause'") - try ensureColumn(tableName: "pr_pipeline_settings", columnName: "force_finalize_mode", definition: "text not null default 'off'") + try ensureColumn(tableName: "pr_pipeline_settings", columnName: "force_finalize_mode", definition: "text not null default 'conditional'") try ensureColumn(tableName: "pr_pipeline_settings", columnName: "force_finalize_require_no_ci_failures", definition: "integer not null default 1") try ensureColumn(tableName: "pr_pipeline_settings", columnName: "early_merge_on_green", definition: "integer not null default 1") try ensureColumn(tableName: "pr_pipeline_settings", columnName: "auto_agent_provider", definition: "text") @@ -2253,15 +2253,46 @@ final class DatabaseService { try ensureColumn(tableName: "pr_pipeline_settings", columnName: "auto_agent_reasoning_effort", definition: "text") try ensureColumn(tableName: "pr_pipeline_settings", columnName: "auto_agent_permission_mode", definition: "text") try ensureColumn(tableName: "pr_pipeline_settings", columnName: "auto_agent_confidence_threshold", definition: "real") - try ensureColumn(tableName: "pr_pipeline_settings", columnName: "at_cap_policy", definition: "text") + try ensureColumn(tableName: "pr_pipeline_settings", columnName: "at_cap_policy", definition: "text default 'ci_retry_once'") try ensureColumn(tableName: "pr_pipeline_settings", columnName: "at_cap_wait_minutes", definition: "integer") try ensureColumn(tableName: "pr_pipeline_settings", columnName: "at_cap_ci_retry_max", definition: "integer") try ensureColumn(tableName: "pr_pipeline_settings", columnName: "force_merge_requires_confirmation", definition: "integer") + try ensureColumn(tableName: "pr_pipeline_settings", columnName: "ptm_defaults_backfilled_version", definition: "text") + if hasTable(named: "pr_pipeline_settings") { + try exec(""" + update pr_pipeline_settings + set auto_merge = 1, + force_finalize_mode = 'conditional', + at_cap_policy = 'ci_retry_once', + ptm_defaults_backfilled_version = 'ptm-defaults-v1' + where auto_merge = 0 + and merge_method = 'repo_default' + and max_rounds = 5 + and on_rebase_needed = 'pause' + and coalesce(conflict_strategy, 'pause') = 'pause' + and coalesce(force_finalize_mode, 'off') = 'off' + and coalesce(force_finalize_require_no_ci_failures, 1) = 1 + and coalesce(early_merge_on_green, 1) = 1 + and (at_cap_policy is null or at_cap_policy = 'stop') + and (at_cap_wait_minutes is null or at_cap_wait_minutes = 30) + and (at_cap_ci_retry_max is null or at_cap_ci_retry_max = 3) + and coalesce(force_merge_requires_confirmation, 1) = 1 + and auto_agent_provider is null + and auto_agent_model is null + and auto_agent_reasoning_effort is null + and auto_agent_permission_mode is null + and auto_agent_confidence_threshold is null + and (ptm_defaults_backfilled_version is null or ptm_defaults_backfilled_version <> 'ptm-defaults-v1') + """) + } try ensureColumn(tableName: "pr_convergence_state", columnName: "ptm_args_json", definition: "text") try ensureColumn(tableName: "pr_convergence_state", columnName: "force_finalize_used", definition: "integer not null default 0") try ensureColumn(tableName: "pr_convergence_state", columnName: "ci_retry_attempts_used", definition: "integer not null default 0") try ensureColumn(tableName: "pr_convergence_state", columnName: "wait_for_ci_started_at", definition: "text") try ensureColumn(tableName: "pr_convergence_state", columnName: "last_dispatch_head_sha", definition: "text") + try ensureColumn(tableName: "pr_convergence_state", columnName: "last_bot_ping_head_sha", definition: "text") + try ensureColumn(tableName: "pr_convergence_state", columnName: "last_bot_ping_at", definition: "text") + try ensureColumn(tableName: "pr_convergence_state", columnName: "merge_wait_kind", definition: "text") try ensureColumn(tableName: "pr_convergence_state", columnName: "pause_repeat_count", definition: "integer not null default 0") try ensureColumn(tableName: "pr_convergence_state", columnName: "last_pause_reason_hash", definition: "text") diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5d9760664..2b2c64af4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -378,6 +378,7 @@ ade.lanes.* # lane list/create/delete/stack/template/env/port/p # delete pipeline: ade.lanes.delete + ade.lanes.delete.cancel # + ade.lanes.delete.risk preflight + ade.lanes.delete.event push ade.files.* # file tree, read, write, search, watch +ade.diff.* # lane-scoped change list + per-file diff / patch (diffService) ade.pty.* # PTY spawn/write/kill, data/exit events ade.git.* # stage/commit/push/sync/revert/cherry-pick/stash ade.github.* # PR list, review, merge, checks diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 3e4b9d1de..994cfdb2b 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -81,6 +81,19 @@ and a footer that contains the composer. - **Attachments** via drag-drop, paste, and an inline picker. Images are written through `ade.agentChat.saveTempAttachment` (10 MB cap; MIME validated per provider). +- **Linear issue context.** A Linear-branded chip in the composer + opens `LinearIssueContextDialog`, which mounts the shared + `LinearIssueBrowser` so the user can attach a Linear issue as + chat context. Each attachment is an + `AgentChatLinearIssueContextAttachment` (`type: "linear_issue"`) + built by `makeLinearIssueContextAttachment(issue, source)` from + `shared/chatContextAttachments.ts`. When the chat opens on a + lane that already has a connected Linear issue, `AgentChatPane` + automatically attaches the lane's issue with + `source: "lane_link"` and pins it inside the dialog so the user + can see what's already linked. The dialog also exposes a deep + link to Settings > Integrations > Linear when the workspace + isn't connected. - **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 @@ -266,7 +279,8 @@ session using `aggregateFiles(summaries)`: - Renders a compact list with status badges (`A`, `D`, `M`, `R`, `C`) and basename. - Clicking a file lazily fetches the diff via - `ade.agentChat.getTurnFileDiff` and shows a Monaco diff view. + `ade.agentChat.getTurnFileDiff` and shows it in `AdeDiffViewer` + (compact toolbar hidden). ## Subagents panel diff --git a/docs/features/cto/linear-integration.md b/docs/features/cto/linear-integration.md index 6ec4f5415..88d5c9573 100644 --- a/docs/features/cto/linear-integration.md +++ b/docs/features/cto/linear-integration.md @@ -8,8 +8,8 @@ CTO owns Linear intake, routing, dispatch, sync, and closeout. Automations never - `linearCredentialService.ts` — token store (personal API key). Exposes `getStatus`, `getTokenOrThrow`, `setToken`, `clearToken`. - `linearOAuthService.ts` — PKCE loopback OAuth on port 19836. `SESSION_TTL_MS = 10 min`. Authorize at `linear.app/oauth/authorize`, exchange at `api.linear.app/oauth/token`. -- `linearClient.ts` — GraphQL client; used by both desktop and headless ADE CLI. -- `linearIssueTracker.ts` + `issueTracker.ts` — issue cache, snapshot hashes, change detection. +- `linearClient.ts` — GraphQL client; used by both desktop and headless ADE CLI. Surfaces `getQuickView(connection)` (workspace + active project counters consumed by the top-bar quick view) and `searchIssues(query)` (paginated issue search consumed by `LinearIssuePicker` and `LinearIssueBrowser`) alongside the existing `fetchIssueById` / `listProjects` / `listLabels` / `listUsers` calls. +- `linearIssueTracker.ts` + `issueTracker.ts` — issue cache, snapshot hashes, change detection. The `IssueTracker` interface includes the matching `getQuickView(connection)` and `searchIssues(query)` shims so renderer-side surfaces have the same entry point as the dispatcher. - `flowPolicyService.ts` — canonical `LinearWorkflowConfig` aggregate: intake rules, workflows, files, migration info, legacy config. File-backed via `linearWorkflowFileService`. - `linearWorkflowFileService.ts` — persists workflows as YAML under the project's ADE config area. - `linearTemplateService.ts` — template metadata registry. @@ -26,6 +26,19 @@ CTO owns Linear intake, routing, dispatch, sync, and closeout. Automations never - `renderer/components/cto/LinearConnectionPanel.tsx` — API key form, OAuth start, project picker. - `renderer/components/cto/LinearSyncPanel.tsx` — workflow list (via `WorkflowListSidebar`), pipeline builder, sync dashboard, run timeline, "Watch It Live" monitor. - `renderer/components/cto/pipeline/` — the visual builder (see `pipeline-builder.md`). +- `renderer/components/lanes/LinearIssuePicker.tsx`, `LinearIssueBadge.tsx`, `linearBrand.tsx` — shared issue picker, lane-list badge, and brand-token / icon family used by every Linear surface in the app. +- `renderer/components/app/LinearQuickViewButton.tsx`, `LinearIssueBrowser.tsx` — top-bar quick view that opens the full filter/search browser, lets the user create a lane straight from a Linear issue (`lanes.create` with `linearIssue` set), and persists per-project filter state under `ade.linear.quickView.filters.v1:<projectRoot>`. +- `renderer/components/settings/LinearSection.tsx` — Settings > Integrations panel for managing the Linear token / OAuth session and surfacing the connected workspace. + +### Lane / commit / PR integration (developer-driven path) + +The CTO autonomous pipeline is one consumer of Linear; the other is the human-in-the-loop developer flow. ADE attaches a `LaneLinearIssue` to a lane at create time (via the `LinearIssuePicker` in `CreateLaneDialog` or the `LinearIssueBrowser` opened from the top bar / chat composer) and persists it in `lane_linear_issues`. Once a lane is connected: + +- `gitOperationsService.commitChanges` (and the AI commit-message generator) auto-prefix the subject with `Refs IDENT: …` via `shared/linearMagicWords.ts#ensureLinearCommitReference`. +- `prService.draftPrMetadata` / `createFromLane` and the renderer `CreatePrModal` default the PR title to `IDENT: title` (`buildLinearPrTitle`) and inject `Fixes IDENT` (closes the Linear issue when the PR merges) or `Refs IDENT` (links without closing) into the body via `ensureLinearPrReference`. The user toggles `closeLinearIssueOnMerge` from a checkbox in `CreatePrModal`; mobile drives the same flag through `syncRemoteCommandService`. +- `agentChatService` accepts an `AgentChatLinearIssueContextAttachment` (`type: "linear_issue"`) on session creation; `AgentChatPane` automatically attaches the lane's connected issue when chat opens (source `"lane_link"`), and the composer's Linear attach dialog adds manual attachments. Attachment helpers live in `shared/chatContextAttachments.ts`. + +Detailed wiring lives in [`../linear-integration/README.md`](../linear-integration/README.md#lane-attachment-commit-references-and-pr-magic-words). ### Shared diff --git a/docs/features/files-and-editor/README.md b/docs/features/files-and-editor/README.md index 36d771cb0..6e728a7a3 100644 --- a/docs/features/files-and-editor/README.md +++ b/docs/features/files-and-editor/README.md @@ -38,27 +38,46 @@ Shared types and IPC: - `apps/desktop/src/shared/types/files.ts` — `FilesWorkspace`, `FileTreeNode`, `FileContent`, `FilesQuickOpenItem`, `FilesSearchTextMatch`, the IPC arg shapes. -- `apps/desktop/src/shared/ipc.ts` — channels `ade.files.*`. +- `apps/desktop/src/shared/types/git.ts` (and related shared types) — + `FileDiff`, `FilePatch`, and other shapes returned by `diffService` + for the diff viewer. +- `apps/desktop/src/shared/ipc.ts` — channels `ade.files.*` and + `ade.diff.getChanges` / `ade.diff.getFile` / `ade.diff.getFilePatch` + (lane-scoped diff lists and per-file payloads). - `apps/desktop/src/main/services/ipc/registerIpc.ts` — handler registrations (`filesListWorkspaces`, `filesListTree`, `filesReadFile`, `filesWriteTextAtomic`, `filesWriteText`, `filesCreateFile`, `filesCreateDirectory`, `filesRename`, `filesDelete`, `filesQuickOpen`, - `filesSearchText`, `filesWatchChanges`, `filesStopWatching`). + `filesSearchText`, `filesWatchChanges`, `filesStopWatching`, plus + `diffGetChanges`, `diffGetFile`, `diffGetFilePatch`). Preload bridge: -- `apps/desktop/src/preload/preload.ts` — `window.ade.files` surface. +- `apps/desktop/src/preload/preload.ts` — `window.ade.files` and + `window.ade.diff` (`getChanges`, `getFile`, `getFilePatch`; changes + list is short-cached per lane). Renderer: -- `apps/desktop/src/renderer/components/files/FilesPage.tsx` — entire - Files tab in a single ~2,570-line component. File explorer, Monaco - editor host, tab bar, diff mode, conflict mode, quick open, text - search, workspace switcher, trust warnings. Accepts optional - `preferredLaneId` and `embedded` props so the same component can be - mounted inside the Work right-edge sidebar against the active lane; - in `embedded` mode the page renders a compact header (only the - workspace selector and read-only badge) so it fits a narrow column. +- `apps/desktop/src/renderer/components/files/FilesPage.tsx` — Files + tab shell (~2,720 lines): workspace chrome, tab bar, Monaco edit host, + diff and conflict modes, quick open, text search, trust warnings. It + composes the virtualized tree below and mounts `AdeDiffViewer` for diff + tabs. Accepts optional `preferredLaneId` and `embedded` props so the + same component can mount inside the Work right-edge sidebar; in + `embedded` mode the header is compact (workspace selector and + read-only badge only). +- `apps/desktop/src/renderer/components/files/FilesExplorer.tsx` — + virtualized file tree (`@tanstack/react-virtual`), inline rename/create, + explorer search, and context-menu wiring; git status coloring uses + helpers from `filePresentation.tsx`. +- `apps/desktop/src/renderer/components/files/filePresentation.tsx` — + file-type icons and `changeStatus*` helpers shared with the explorer. +- `apps/desktop/src/renderer/components/shared/AdeDiffViewer.tsx` — + shared read-only diff chrome (`@pierre/diffs` `MultiFileDiff` / + `PatchDiff` with split/unified, wrap, line numbers); editable working-tree + diffs delegate to `MonacoDiffView`. Also used from `LaneDiffPane`, + `ChatFileChangesPanel`, and `PrDetailPane`. - `apps/desktop/src/renderer/components/files/FilesPage.test.tsx` — renderer tests. - `apps/ios/ADE/Views/Files/FilesRootScreen.swift` — mobile Files @@ -110,10 +129,10 @@ mode concept): - **Edit** — Monaco with read/write semantics, syntax highlighting, Cmd+S saves atomically. -- **Diff** — side-by-side Monaco diff viewer. Read-only by default, - optionally editable on the right pane. Sources: staged vs working - tree, HEAD vs working tree, or commit-to-commit. Driven by - `diffService`. +- **Diff** — `AdeDiffViewer` backed by `diffService`. Read-only views + use `@pierre/diffs`; editable working-tree views use `MonacoDiffView`. + Sources: staged vs working tree, HEAD vs working tree, or + commit-to-commit. - **Conflict** — 3-way merge. Base / Ours / Theirs / Result panes. Interactive "Accept Ours", "Accept Theirs", "Accept Both". Resolves via `conflictService`. @@ -188,10 +207,11 @@ whether a directory should show the "has changes" dot. ## Trust boundary The preload bridge (`apps/desktop/src/preload/preload.ts`) exposes -only the `window.ade.files` surface; nothing from `node:fs` or -`node:path` leaks into the renderer. All path resolution happens in -the main process through `resolvePathWithinRoot`, which refuses -`..` escapes, null bytes, and `.git` internals. +`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. 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 5567d4de9..b3a81f212 100644 --- a/docs/features/files-and-editor/editor-surfaces.md +++ b/docs/features/files-and-editor/editor-surfaces.md @@ -7,17 +7,19 @@ for edit, diff, and conflict modes. Path: `apps/desktop/src/renderer/components/files/FilesPage.tsx` -A single large component (~2,570 lines), parameterized by optional +A single large component (~2,720 lines), parameterized by optional `preferredLaneId` (selects a lane worktree as the default workspace) and `embedded` (compact chrome for the Work sidebar mount). It owns: - workspace selection (dropdown synced to `laneService` workspaces) -- file explorer tree with lazy loading, context menu, and drag/drop - placeholder +- file explorer via `FilesExplorer.tsx` (virtualized rows, lazy-loaded + directories, context menu, drag/drop placeholder) with icons and git + status labels from `filePresentation.tsx` - tab bar with reorderable tabs, dirty indicators, middle-click close - file path breadcrumb under the tab bar - Monaco host for edit mode -- Monaco diff editor for diff mode +- diff mode via `AdeDiffViewer` (read-only: `@pierre/diffs`; editable + right pane: `MonacoDiffView` inside `AdeDiffViewer`) - 3-way merge layout for conflict mode - quick open modal (Cmd+P) - cross-file search panel (Cmd+Shift+F) @@ -60,14 +62,16 @@ workspace is pinned first. Switching workspaces: ## File explorer tree -`FileTreeNode[]` from `files.listTree`. Lazy loading: each directory -is fetched only when expanded, with a `depth: 1` request. The tree -uses sorted output (directories first, then files, alphabetical). +Implementation: `FilesExplorer.tsx` over `FileTreeNode[]` from +`files.listTree`. Lazy loading: each directory is fetched only when +expanded, with a `depth: 1` request. The tree uses sorted output +(directories first, then files, alphabetical). Visual indicators per node: -- file icons by extension (Phosphor icon map or inline SVG fallbacks) -- change status badge (`M` orange, `A` green, `D` red) +- file icons by extension via `filePresentation.tsx` (Phosphor) +- change status badge (modified / added / deleted coloring from the same + helpers) - "has changes" dot on directories that contain any changed descendant Context menu (right-click): @@ -135,23 +139,22 @@ Protection rails: ## Diff mode -Uses `DiffEditor` from Monaco. Sources come from `diffService`: +`FilesPage` mounts `AdeDiffViewer` for diff tabs. Payloads come from +`diffService` via `window.ade.diff` (`getFile` / `getFilePatch` as +appropriate for the tab’s comparison mode). -- **Staged vs unstaged** — shows working tree changes that have not - been staged. -- **HEAD vs working tree** — shows everything since the last commit. -- **Commit to commit** — arbitrary sha comparison. +- **Read-only** — `@pierre/diffs` renders `MultiFileDiff` (old/new text) + or `PatchDiff` (unified patch text) with a small toolbar: split vs + unified layout, wrap vs scroll overflow, line numbers, copy path. +- **Editable working-tree** — when the user enables editing on the + modified side, `AdeDiffViewer` switches to `MonacoDiffView` so changes + save through the same Monaco path as before. -Features: +Comparison sources (staged vs unstaged, HEAD vs working tree, +commit-to-commit) are unchanged at the service layer. -- side-by-side by default, toggleable to inline -- "Next change" / "Previous change" navigation -- read-only left pane (old) -- right pane is read-only by default; users can toggle to editable to - apply changes directly to the working tree - -Save behavior in diff mode writes only when the right pane is -editable; the temp model is written atomically via `files.writeTextAtomic`. +Save behavior in diff mode writes only when the modified side is +editable; content is written atomically via `files.writeTextAtomic`. ## Conflict mode diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 66860f30a..a88936852 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -39,16 +39,19 @@ Renderer components: | `renderer/components/lanes/LaneColorPicker.tsx` | Reusable swatch-row picker used inside `CreateLaneDialog` and `ManageLaneDialog`. Disables swatches already in use by other lanes (passed in as `usedColors`) and offers a clear button. | | `renderer/components/lanes/LaneContextMenu.tsx` | Right-click menu on the lane list. Hosts the inline color swatch row that calls `lanes.updateAppearance` directly, "Reveal/Copy path", manage/adopt/open-in-Run actions, split-tab actions, and batch manage. | | `renderer/components/lanes/LaneStackPane.tsx` | Stack graph sidebar, integration source chips, canvas jump | -| `renderer/components/lanes/LaneDiffPane.tsx` | Diff viewer, per-file stage/unstage/discard | +| `renderer/components/lanes/LaneDiffPane.tsx` | Lane diff list + per-file stage/unstage/discard; file content uses shared `AdeDiffViewer` (commit comparisons read-only; working-tree file can be editable when unstaged) | | `renderer/components/lanes/LaneGitActionsPane.tsx` | Commit, stash, fetch, sync, push, recent commits. Seeds its `autoRebaseStatus` from the `autoRebaseStatusSnapshot` prop that `LanesPage` passes from the lane list (`laneSnapshot.autoRebaseStatus`), so opening a lane does not trigger a per-lane probe. A fallback `refreshAutoRebaseStatus` runs only when the snapshot is `undefined`, after a 3.5 s delay, and only while the document is visible. | | `renderer/components/lanes/LaneWorkPane.tsx` | Terminal/chat toggle work surface | | `renderer/components/lanes/LaneRebaseBanner.tsx` | Inline banner driven by `rebaseSuggestionService` | | `renderer/components/lanes/LaneEnvInitProgress.tsx` | Env init step progress inside create dialog | -| `renderer/components/lanes/CreateLaneDialog.tsx`, `AttachLaneDialog.tsx`, `MultiAttachWorktreeDialog.tsx`, `LaneDialogShell.tsx` | Lane creation / attach dialogs and shared dialog chrome. The "import existing branch" path inside `CreateLaneDialog` swaps the dialog body for `BranchPickerView` when the user opens the picker; the dialog title/description switches with it. | +| `renderer/components/lanes/CreateLaneDialog.tsx`, `AttachLaneDialog.tsx`, `MultiAttachWorktreeDialog.tsx`, `LaneDialogShell.tsx` | Lane creation / attach dialogs and shared dialog chrome. The "import existing branch" path inside `CreateLaneDialog` swaps the dialog body for `BranchPickerView` when the user opens the picker; the "Connect Linear issue" affordance in the always-open Advanced section swaps it for `LinearIssuePickerView`. The dialog title/description/icon switch in lockstep with the active sub-view, and connecting a Linear issue auto-flips the create mode out of `existing` (the import-branch tab is locked while an issue is attached). | | `renderer/components/lanes/BranchPickerView.tsx` | Filterable virtualized branch list rendered inside `CreateLaneDialog`. Each row shows branch name, last-commit author + relative date, and an inline PR pill (`#NNN`, dim for drafts) when the branch has an open PR. Loading/empty/error states are handled inline. Backed by `branchPickerSearch.ts`. | | `renderer/components/lanes/branchPickerSearch.ts` | Pure parser + matcher. Tokens AND together: `pr:open` / `pr:none` / `pr:draft`, `author:NAME` (or `author:me` / `mine` resolved against the local git user), `stale:Nd` (older than N days), `#PRNUMBER` (exact match), and free text fuzzy-matched across branch name / PR title / author. Also exposes `formatRelativeTime` for the row subtitle. | +| `renderer/components/lanes/LinearIssuePicker.tsx` | Filterable Linear issue picker rendered inside `CreateLaneDialog`. Loads project / state / assignee filters from `ade.cto.getLinearIssuePickerData` and pages issues through `ade.cto.searchLinearIssues`. Shared row + label helpers (`LinearIssueRow`, `linearPriorityLabel`, `issueProjectLabel`, `issueUpdatedLabel`, `toLaneLinearIssue`, `branchExistsForLinearIssue`) are reused by `LinearIssueBrowser` (top-bar quick view) and the chat composer's Linear context dialog. Also exports a `LinearIssueSummaryCard` used by the dialog's "currently connected" state. | +| `renderer/components/lanes/LinearIssueBadge.tsx` | Compact lane-list badge that surfaces the lane's connected Linear issue (identifier + state + priority); clicking opens the issue in a new chat with the issue pre-attached as context, falling back to opening the issue in Linear when chat is unavailable. | +| `renderer/components/lanes/linearBrand.tsx` | Linear brand tokens (`LINEAR_BRAND` colour palette) plus the icon family used everywhere ADE references Linear: `LinearMark`, `LinearStateIcon`, `LinearPriorityIcon`. | | `renderer/components/lanes/ManageLaneDialog.tsx` | Unified delete / archive / adopt-attached dialog. Supports single-lane and batch (multi-select) modes, three delete scopes (`worktree`, `local_branch`, `remote_branch`), a typed confirmation phrase, remote-branch name input, dirty-state warnings, and a live multi-step progress strip wired to `lanes.delete.event` (`stop_processes` / `stop_ptys` / `stop_watchers` / `cancel_auto_rebase` / `cleanup_env` / `git_status` / `git_worktree_remove` / `git_branch_delete` / `git_remote_branch_delete` / `pack_dir_remove` / `database_cleanup`). The dialog calls `lanes.getDeleteRisk` on open to surface dirty state, unpushed commits, running processes / PTYs / watchers, and remote-branch existence before the user confirms; while a delete is running, the user can cancel each lane through `lanes.cancelDelete` until the irreversible filesystem step (`git_worktree_remove`) starts. | -| `renderer/components/lanes/MonacoDiffView.tsx` | Monaco-based side-by-side file diff | +| `renderer/components/lanes/MonacoDiffView.tsx` | Monaco diff editor used for editable working-tree views (invoked from `AdeDiffViewer`) | | `renderer/components/run/LaneRuntimeBar.tsx` | Compact lane runtime status bar (health, preview, port, proxy, oauth) | | `renderer/components/run/RunPage.tsx`, `RunNetworkPanel.tsx` | Runtime dashboards that consume lane runtime services | | `renderer/components/ui/PaneTilingLayout.tsx` | Persisted split-pane layout engine for lane panes. Validates saved pane trees against expected pane ids and falls back to the supplied tree when the saved layout is stale. | @@ -164,6 +167,15 @@ a lane parented to primary would always show zero behind. heartbeat; the desktop host calls `ade.sync.setActiveLanePresence` from `LanesPage` whenever the visible lane list changes and clears it on unmount. +- `linearIssue?: LaneLinearIssue | null` — the Linear issue connected + to the lane at create time (or null). Persisted in + `lane_linear_issues` (project-scoped, keyed by `lane_id`) and + hydrated by `laneService` on every `list`/`get`. Drives the + `LinearIssueBadge` in the lane list, the auto-prefixed commit + message in `gitOperationsService` (`Refs IDENT: <message>`), and + the PR-creation flow in `prService` / `CreatePrModal` (default PR + title `IDENT: title`, body magic-word `Fixes IDENT` / + `Refs IDENT`). ## Mission lane roles @@ -187,7 +199,15 @@ default from the Lanes list (see `isMissionLaneHiddenByDefault` in 1. **Create** — `laneService.create()` resolves the base ref (explicit or parent's branch), normalizes the branch name, computes a unique worktree path under `.ade/worktrees/<slug>/`, runs `git worktree - add`, inserts the lane row, and returns a `LaneSummary`. + add`, inserts the lane row, and returns a `LaneSummary`. When + `CreateLaneArgs.linearIssue` is supplied (from `CreateLaneDialog` + via the Linear issue picker), the service derives the branch name + from the issue (`linearIssueBranchName`: `ident-title-slug`, + sanitised against git-ref rules) when no explicit `branchName` was + provided, refuses to create the lane if the resolved branch already + exists locally or under `origin/`, and writes the issue payload + into `lane_linear_issues` so the PR / commit / chat surfaces can + pick it up later. The same path runs for `createChild`. 2. **Create child** — same as create but with `parentLaneId`. Child's base ref defaults to the parent's branch ref. Callers can override with `baseBranchRef` on `CreateChildLaneArgs` to fork from any local diff --git a/docs/features/linear-integration/README.md b/docs/features/linear-integration/README.md index 277ce50b6..d56ceb598 100644 --- a/docs/features/linear-integration/README.md +++ b/docs/features/linear-integration/README.md @@ -13,7 +13,7 @@ headless ADE CLI run the same pipeline. ## Who uses it -The integration is used by three distinct consumers: +The integration is used by four distinct consumers: 1. **The CTO agent.** Linear workflows are authored, saved, and rolled back through the CTO tab's flow-policy surface, and the CTO agent is the @@ -25,7 +25,19 @@ The integration is used by three distinct consumers: `aiOrchestratorService`, links the mission back to the `LinearWorkflowRun` row, and waits for mission completion before moving on to PR gates or closeout. -3. **The headless ADE CLI.** `apps/ade-cli/src/headlessLinearServices.ts` +3. **Lanes, commits, PRs, and chat.** A user can attach a Linear issue + to a brand-new lane from `CreateLaneDialog` (the Linear issue picker + in the always-open Advanced section), or to chat context from the + composer's Linear attach affordance. Once a lane is connected to an + issue, ADE auto-derives the branch name, prefixes commit messages + with `Refs IDENT: …`, seeds the PR title (`IDENT: title`), and adds + a `Fixes IDENT` / `Refs IDENT` magic word to the PR body so Linear + links the PR back to the issue. There is also a top-bar + `LinearQuickViewButton` that opens the same `LinearIssueBrowser` + the chat composer uses, lets the operator filter / search across + their Linear backlog, and turns any selected issue into a new + lane in one click. +4. **The headless ADE CLI.** `apps/ade-cli/src/headlessLinearServices.ts` instantiates the full Linear service stack (sync, dispatcher, closeout, intake, ingress, routing, outbound, templates) so external callers can trigger and resolve Linear runs without the desktop UI running. The @@ -164,11 +176,41 @@ Core Linear services on desktop - `linearIngressService.ts` — webhook HTTP listener + relay poller, hands off to `syncService.processIssueUpdate` -Shared types and workflow presets: +Shared types and helpers: - `apps/desktop/src/shared/types/linearSync.ts` — all `LinearWorkflow*` - types, run statuses, event payloads, catalog types, and the legacy - `LinearSyncConfig` kept for migration reads + types, run statuses, event payloads, catalog types, the + `NormalizedLinearIssue` shape (extended with `projectName`, + `teamName`, `dueDate`, `estimate`, `archivedAt`, `completedAt`, + `canceledAt`, `startedAt`), `LinearConnectionStatus` (extended with + `organizationId` / `organizationName` / `organizationUrlKey` / + `organizationLogoUrl` so controllers can render the workspace + brand), and the legacy `LinearSyncConfig` kept for migration reads. +- `apps/desktop/src/shared/types/lanes.ts` — `LaneLinearIssue` (the + lane-attached subset of a Linear issue that gets persisted with + the lane row) plus the optional `linearIssue` field on + `CreateLaneArgs` / `CreateChildLaneArgs` / `LaneSummary`. +- `apps/desktop/src/shared/linearIssueBranch.ts` — pure helpers + `linearIssueLaneName(issue)` ("IDENT title") and + `linearIssueBranchName(issue)` (slugified, sanitised against git + ref rules: `IDENT-title-slug`). `sanitizeLinearIssueBranchName` + is the underlying ref-safety pass and is also exported. +- `apps/desktop/src/shared/linearMagicWords.ts` — pure helpers for + the PR / commit Linear references: `linearPrMagicWord(closeOnMerge)` + picks `Fixes` (closes the issue when the PR merges) or `Refs` + (links without closing); `buildLinearPrTitle` / + `buildLinearPrReference` build the strings; `ensureLinearPrReference` + injects the magic word into a PR body if one isn't already there + (with `preserveExisting: false` to overwrite an existing + `Refs/Fixes <IDENT>` line); `ensureLinearCommitReference` prefixes + a commit subject with `Refs IDENT: …` when missing. +- `apps/desktop/src/shared/chatContextAttachments.ts` — pure helpers + for the chat composer's Linear context attachment surface: + `makeLinearIssueContextAttachment(issue, source)`, + `mergeChatContextAttachments`, `removeChatContextAttachment`, + `chatContextAttachmentKey`, plus a defensive + `normalizeLinearIssue` reader used when re-hydrating attachments + from disk or wire payloads. - `apps/desktop/src/shared/linearWorkflowPresets.ts` — default workflow presets, visual plan derivation, step rebuilding. See `workflow-presets.md`. @@ -177,9 +219,59 @@ Renderer wiring: - `apps/desktop/src/renderer/components/cto/LinearSyncPanel.tsx` — the main CTO-tab management surface (connection, workflow editor, queue, - dashboard, ingress status) + dashboard, ingress status). - `apps/desktop/src/renderer/components/cto/pipeline/*` — the visual - pipeline canvas with trigger, stage, closeout cards + pipeline canvas with trigger, stage, closeout cards. +- `apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx` — + shared issue picker mounted inside `CreateLaneDialog`. Loads + filters via `ade.cto.getLinearIssuePickerData` (projects + states + + assignees in one call) and pages issues with + `ade.cto.searchLinearIssues`. Exports a row component + (`LinearIssueRow`) and pure label helpers reused by the chat + composer's Linear attach dialog and the top-bar quick-view. +- `apps/desktop/src/renderer/components/lanes/LinearIssueBadge.tsx` — + compact lane-list badge showing the connected issue's + identifier / state / priority. Clicking opens chat with the issue + pre-attached as context, falling back to the public Linear URL + when chat is unavailable. +- `apps/desktop/src/renderer/components/lanes/linearBrand.tsx` — + shared Linear brand tokens (`LINEAR_BRAND` palette) and icon + family (`LinearMark`, `LinearStateIcon`, `LinearPriorityIcon`). +- `apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx` + — top-bar button (rendered in `TopBar.tsx` when + `LinearConnectionStatus.connected === true`). Opens a popover + hosting the shared `LinearIssueBrowser`; selecting an issue + creates a new lane via `lanes.create` with `linearIssue` set, + refreshes the lane store, and selects the new lane. +- `apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx` + — full filter/search surface. Reads `ade.cto.getLinearQuickView` + for the workspace summary and `ade.cto.searchLinearIssues` for + paginated results. Persists per-project filter state in + `localStorage` under `ade.linear.quickView.filters.v1:<projectRoot>`. +- `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` + — the composer's Linear attach affordance opens a + `LinearIssueContextDialog` that hosts the same + `LinearIssueBrowser` and emits an + `AgentChatLinearIssueContextAttachment` (`type: "linear_issue"`) + through the chat session's `contextAttachments` array. + `AgentChatPane` automatically attaches the lane's connected + issue when a chat opens on a Linear-connected lane (via + `initialLinearIssueContext`, source `"lane_link"`), and the + composer pins it to the dialog so the user can see what's + already linked. +- `apps/desktop/src/renderer/components/prs/CreatePrModal.tsx` — + reads `lane.linearIssue`, defaults the PR title to + `buildLinearPrTitle`, and uses `ensureLinearPrReference` against + the body whenever the user toggles the + `closeLinearIssueOnMerge` checkbox so the magic word stays in + sync with `Fixes` / `Refs`. +- `apps/desktop/src/renderer/components/settings/LinearSection.tsx` + — Settings > Integrations panel for connecting Linear. Reads / + writes via `ade.cto.getLinearConnectionStatus`, + `ade.cto.setLinearToken`, `ade.cto.startLinearOAuth`, and + `ade.cto.clearLinearToken`. Surfaces connection state, project + list, and a docs-style hint card describing the issue-routing / + CTO-workflow value props. IPC wiring (`apps/desktop/src/main/services/ipc/registerIpc.ts`): @@ -192,7 +284,18 @@ IPC wiring (`apps/desktop/src/main/services/ipc/registerIpc.ts`): `ctoLinearWorkflowEvent` (renderer notification broadcast), `ctoStartLinearOAuth`, `ctoGetLinearOAuthSession`, `ctoSetLinearOAuthClient`, `ctoClearLinearOAuthClient`, - `ctoGetLinearProjects`, `ctoGetLinearWorkflowCatalog`. + `ctoGetLinearProjects`, `ctoGetLinearWorkflowCatalog`, + `ctoGetLinearQuickView` (workspace summary used by the top-bar + quick view), `ctoGetLinearIssuePickerData` (one-shot + projects + states + assignees catalog for `LinearIssuePicker`), + and `ctoSearchLinearIssues` (paginated issue search consumed by + both `LinearIssuePicker` and `LinearIssueBrowser`). +- `IssueTracker` (`apps/desktop/src/main/services/cto/issueTracker.ts`) + grew matching `getQuickView(connection)` and + `searchIssues(query)` methods, both forwarded to `linearClient` + by `linearIssueTracker.ts`. `IssueTrackerIssueSearchQuery` covers + project / state-types / assignee / priority / free-text / cursor + pagination filters; the result is `{ issues, pageInfo }`. Headless ADE CLI mode: @@ -218,6 +321,65 @@ Deeper reading: - `workflow-presets.md` — how presets produce and round-trip to the visual plan in the pipeline builder +## Lane attachment, commit references, and PR magic words + +The Linear pipeline above is fully autonomous: it runs missions / +chats / workers without the human ever opening a lane manually. Most +day-to-day developer work, though, starts the other way around — the +human picks a Linear ticket and creates a lane to work on it. ADE +exposes that path in three places that all share the same primitives: + +- **Create a lane from a Linear issue.** `CreateLaneDialog`'s Advanced + section hosts a "Connect Linear issue" affordance backed by + `LinearIssuePicker`. Selecting an issue auto-derives the lane name + (`linearIssueLaneName` → `IDENT title`) and the branch name + (`linearIssueBranchName` → `ident-title-slug`, sanitised against + git ref rules), pre-fills the create form, and locks the + "Import existing branch" tab while an issue is connected. The + same picker is launched from the top-bar `LinearQuickViewButton` + and from the chat composer's Linear attach dialog so all three + entry points produce identical lane shapes. +- **`lane_linear_issues` table.** `laneService.create` / + `createChild` accept `linearIssue?: LaneLinearIssue`; when set, + the issue payload (`id`, `identifier`, `title`, project / team / + state / priority / labels / assignee / creator / due / estimate / + branch name / timestamps) is upserted into `lane_linear_issues` + keyed by `(project_id, lane_id)`. `LaneSummary.linearIssue` is + hydrated on every `list` / `get`. The service also enforces a + collision check: if the resolved branch already exists locally + or as `origin/<branch>`, lane creation throws + `Branch "…" already exists. Detach the Linear issue or choose + a different issue.`. +- **Commit message prefix.** When a lane has a connected issue, + `gitOperationsService.commitChanges` (and the commit-message + generator) auto-prefixes the subject with `Refs IDENT: …` via + `ensureLinearCommitReference`. Subjects that already mention the + identifier are left alone. +- **PR title + body magic word.** `prService.draftPrMetadata` / + `createFromLane` and the renderer `CreatePrModal` use + `buildLinearPrTitle(issue)` (`IDENT: title`) as the default PR + title and `ensureLinearPrReference(body, issue, closeOnMerge)` + to inject `Fixes IDENT` (closes the Linear issue when the PR + merges) or `Refs IDENT` (links without closing) into the PR + description. The user toggles `closeLinearIssueOnMerge` from a + checkbox in `CreatePrModal`; the same flag is forwarded by + `syncRemoteCommandService` so phones drive the same behaviour. +- **Chat context attachment.** Chats opened on a lane with a + connected issue automatically receive an + `AgentChatLinearIssueContextAttachment` (`type: "linear_issue"`, + `source: "lane_link"`) via `AgentChatPane`'s + `initialLinearIssueContext`. The composer also supports manual + attachment through `LinearIssueContextDialog`, which reuses + `LinearIssueBrowser`. Helpers live in + `shared/chatContextAttachments.ts`. +- **Top-bar quick view.** `TopBar` mounts + `LinearQuickViewButton` whenever `LinearConnectionStatus.connected` + is true. The popover shows `CtoLinearQuickView` (workspace + + active project counters) plus the shared + `LinearIssueBrowser`; clicking an issue creates a fresh lane via + `lanes.create`, refreshes the lane store, and selects the new + lane. + ## Database tables (selected) All state is kept in `.ade/ade.db` and replicated through cr-sqlite like any @@ -230,6 +392,13 @@ other ADE table. Key tables the Linear stack writes: change detection in `processIssueUpdate` - `linear_sync_events` — `issue_closed`, `watch_only_match`, `workflow_capacity_wait`, `issue_deduped` observability records +- `lane_linear_issues` — issue payload attached to a lane at + create time, keyed by `(project_id, lane_id)`. Used by lane + hydration, `LinearIssueBadge`, commit-message prefixing, and PR + defaults. +- `linear_issue_claims` — active-claim ledger (one active row per + `(project_id, issue_id)`) so two lanes don't try to drive the + same issue simultaneously. Workflow definitions themselves live either inline in the flow policy (stored in the project config row, versioned via `flowPolicyService` diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index b852a1387..5e198a870 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -344,6 +344,27 @@ Steers accept `sessionId`, `text`, and `attachments`. Controllers specify reasoning / execution / interaction modes remotely; the host-side `agentChatService` consumes the same shape end-to-end. +## Lane and PR Linear-issue payload shape + +`parseCreateLaneArgs` / `parseCreateChildLaneArgs` accept an optional +`linearIssue: LaneLinearIssue | null` so a controller can create a +lane already attached to a Linear ticket; `laneService.create` +derives the branch name (`linearIssueBranchName`) and persists the +issue into `lane_linear_issues`. + +`parseCreatePrArgs` and `parseDraftPrDescriptionArgs` accept +`closeLinearIssueOnMerge: boolean`. When the lane has a connected +issue, this flag drives whether `prService` injects `Fixes IDENT` +(closes the issue when the PR merges) or `Refs IDENT` (links +without closing) into the PR body via `ensureLinearPrReference`. + +`brain_status` envelopes carry the host's `LinearConnectionStatus`, +which now includes optional `organizationId`, `organizationName`, +`organizationUrlKey`, and `organizationLogoUrl` fields populated by +the host when the Linear workspace is connected. Controllers use +these to render the workspace brand on Linear-related surfaces +without fetching them separately. + `parseChatModelsArgs` accepts `{ provider, activateRuntime? }`. When `chat.create` is missing an explicit model, `resolveChatCreateArgs` forwards `activateRuntime: true` only for the `opencode` provider so diff --git a/plans/remote-runtime-architecture.md b/plans/remote-runtime-architecture.md new file mode 100644 index 000000000..481fa281d --- /dev/null +++ b/plans/remote-runtime-architecture.md @@ -0,0 +1,888 @@ +# 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/cto/openclawBridgeService.ts` +- `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); +``` + +Plus a single-runtime constraint: `ade rpc --stdio` boots a runtime in-process for this session (no daemon, no service install). Disconnects → process exits. This is correct because each SSH connection wants its own runtime instance. + +#### 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). + +--- From 3be658856289ccbe8d5d37b537aefe4cf4e32675 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 01:29:10 -0400 Subject: [PATCH 03/11] Runtime refactor desktop and CLI sync support (#279) * Implement remote runtime packaging and remove OpenClaw Add remote/local runtime orchestration, packaged runtime resources, standalone runtime release assets, runtime action routing, and remove legacy OpenClaw surfaces. * Add runtime daemon docs and CLI/runtime features Rework documentation and CLI to center on the per-machine ADE runtime daemon: extensive README and apps/ade-cli/README.md updates describe daemon/socket modes, new dev and packaging scripts, and the `ade runtime` / `ade desktop` command surface. Implement operational changes in the CLI/tests to support runtime/socket routing (tests updated to assert new plan kinds and to read ADE_RUNTIME_SOCKET_PATH), add runtime-related helper files (reactDevtools stub) and desktop runtime artifacts/scripts. Add @linear/sdk to ade-cli dependencies (package.json + lockfile updated). Minor housekeeping: remove openclawContextPolicy from .ade/cto/identity.yaml and add /apps/desktop/release-alpha to .gitignore. * Polish runtime-refactor surface and consolidate runtime tests Combines /finalize cleanups (lane UI, ade-cli rpc/tui touch-ups, runtime docs) with /automate test-suite hygiene: merge remoteBootstrap upload flow back into remoteBootstrap.test.ts and fold ade-cli sync host discovery tests into syncHostService.test.ts to remove forbidden {service}.{minor}.test.ts fragmentation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Use materialize runtime resources with host env Replace the previous apps/ade-cli build step with an apps/desktop npm task that runs materialize:runtime-resources and set ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY=1 in the env. Keeps dryRun behavior and runtime artifact assertions, and cleans build intermediates as before. Also add .claude/scheduled_tasks.lock (scheduler lock file). * Add Tailscale discovery and default SSH identity Add Tailscale peer discovery and merge results with mDNS discovery, including parsing of `tailscale status --json` and a discoverTailscalePeers helper that calls the Tailscale CLI. Update discovery to run Bonjour browsing and Tailscale discovery in parallel and add tests for Tailscale peer-to-SSH conversion. Change registry path resolution to use resolveMachineAdeLayout (and add a test that registry files are stored under the active ADE_HOME). Improve SSH transport to pick the first readable OpenSSH default identity when no explicit key is set, allow injecting homeDir for testing, and add a test for default identity selection. Update UI labels to surface Tailscale targets. Modify package-channel script to read desktop version, clean host runtime artifacts, propagate a composed env (setting ADE_CLI_VERSION), and ensure host runtime resources are rebuilt with the proper environment. * Handle scope dispose, SSH retries, and UI tweaks Add disposal propagation and cache cleanup for project-scoped runtimes, plus tests: multiProjectRpcServer now registers a scope onDispose listener to drop cached handlers and event subscriptions when a ProjectScope is disposed; ProjectScopeRegistry exposes onDispose listeners and invokes provided onDisposeProject callback and registered listeners when a scope is disposed. Cleanup of the listener occurs on handler disposal. Tests updated/added to cover cache eviction on scope disposal and project-scope disposal callbacks. Improve SSH transport resiliency and username handling: introduce username candidates and config candidates, prefer explicit SSH config user but fall back to local user and an "admin" retry, and retry connection attempts across username candidates while handling authentication failures. Refactor connectSsh to try multiple configs and add helpers (uniqueUsernames, isSshAuthenticationFailure), plus unit tests for username/config candidate behavior. Remote targets UI/UX improvements: RemoteTargetForm now supports targetId in prefill and customizable busy/submit labels. RemoteTargetList prepares and applies prefill for editing saved targets, sets formprefill when selecting a target or discovered machine, removes replaced targets when saving edits, improves connection label hints (more granular default hints), and updates copy for discovered machines. Related tests/files updated accordingly. * Refactor CLI tests and add auto-register check Massively refactors apps/ade-cli/src/cli.test.ts to standardize multi-line arrays/objects and improve readability of assertions, JSON quoting and formatting. Adds a new test to assert shouldAutoRegisterProjectForPlan behavior for machine-scoped registry commands and introduces apps/ade-cli/src/services/projects/projectRegistry.test.ts. Also includes supporting changes across desktop and runtime code (new remoteConnectionService, tests/adjustments to resolveTailscaleCliPath, runtimeBridge, remoteConnectionPool, renderer components and IPC types) to align with test updates and tighten behavior. These changes improve test clarity and add coverage for project auto-registration logic. * ship: prepare lane for review * ship: iteration 1 address cursor review * ship: iteration 2 fix runtime release workflow --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .ade/cto/identity.yaml | 6 - .github/workflows/ci.yml | 115 +- .github/workflows/release-core.yml | 168 +- .gitignore | 2 + README.md | 73 +- apps/ade-cli/README.md | 338 +- apps/ade-cli/package-lock.json | 1570 ++- apps/ade-cli/package.json | 13 + apps/ade-cli/scripts/build-static.mjs | 287 + apps/ade-cli/scripts/install-runtime.sh | 108 + .../scripts/notarize-static-runtime.mjs | 133 + apps/ade-cli/scripts/package-native-deps.mjs | 195 + apps/ade-cli/scripts/verify-built-cli.mjs | 20 + apps/ade-cli/src/adeRpcServer.test.ts | 86 +- apps/ade-cli/src/adeRpcServer.ts | 90 +- apps/ade-cli/src/bootstrap.test.ts | 39 + apps/ade-cli/src/bootstrap.ts | 420 +- apps/ade-cli/src/cli.test.ts | 1382 ++- apps/ade-cli/src/cli.ts | 9353 ++++++++++++++--- apps/ade-cli/src/eventBuffer.ts | 15 + .../src/headlessLinearServices.test.ts | 23 + apps/ade-cli/src/headlessLinearServices.ts | 1085 +- .../ade-cli/src/multiProjectRpcServer.test.ts | 427 + apps/ade-cli/src/multiProjectRpcServer.ts | 687 ++ .../ade-cli/src/serviceManager/common.test.ts | 348 + apps/ade-cli/src/serviceManager/common.ts | 117 + apps/ade-cli/src/serviceManager/index.ts | 66 + .../src/serviceManager/installLaunchd.ts | 172 + .../src/serviceManager/installSystemd.ts | 117 + .../src/serviceManager/installWindows.ts | 118 + .../src/services/agentRegistry.test.ts | 32 + apps/ade-cli/src/services/agentRegistry.ts | 141 + .../credentials/credentialStore.test.ts | 99 + .../services/credentials/credentialStore.ts | 331 + .../src/services/projects/machineLayout.ts | 36 + .../services/projects/projectRegistry.test.ts | 86 + .../src/services/projects/projectRegistry.ts | 229 + .../services/projects/projectScope.test.ts | 197 + .../src/services/projects/projectScope.ts | 176 + .../services/sync/deviceRegistryService.ts | 673 ++ .../services/sync/resolveTailscaleCliPath.ts | 67 + .../src/services/sync/syncHostService.test.ts | 343 + .../src/services/sync/syncHostService.ts | 3255 ++++++ .../src/services/sync/syncPairingStore.ts | 110 + .../src/services/sync/syncPeerService.ts | 579 + .../ade-cli/src/services/sync/syncPinStore.ts | 147 + .../ade-cli/src/services/sync/syncProtocol.ts | 148 + .../services/sync/syncRemoteCommandService.ts | 2528 +++++ apps/ade-cli/src/services/sync/syncService.ts | 1155 ++ apps/ade-cli/src/stdioRpcDaemon.test.ts | 297 + apps/ade-cli/src/transports/stdioTransport.ts | 18 + .../src/tuiClient}/__tests__/adeApi.test.ts | 2 +- .../src/tuiClient}/__tests__/commands.test.ts | 0 .../tuiClient/__tests__/connection.test.ts | 154 + .../src/tuiClient}/__tests__/format.test.ts | 0 .../tuiClient}/__tests__/heartbeat.test.ts | 0 .../__tests__/jsonRpcClient.test.ts | 0 .../__tests__/linearCommands.test.ts | 0 .../tuiClient}/__tests__/pendingInput.test.ts | 2 +- .../src/tuiClient}/__tests__/project.test.ts | 2 +- .../src => ade-cli/src/tuiClient}/adeApi.ts | 17 +- .../src => ade-cli/src/tuiClient}/app.tsx | 22 +- .../src => ade-cli/src/tuiClient}/cli.tsx | 41 +- .../src => ade-cli/src/tuiClient}/commands.ts | 8 +- .../tuiClient}/components/ApprovalPrompt.tsx | 27 +- .../src/tuiClient}/components/ChatView.tsx | 51 +- .../src/tuiClient}/components/Drawer.tsx | 20 +- .../src/tuiClient}/components/Header.tsx | 6 +- .../tuiClient}/components/MentionPalette.tsx | 0 .../src/tuiClient}/components/RightPane.tsx | 0 .../tuiClient}/components/SlashPalette.tsx | 2 +- apps/ade-cli/src/tuiClient/connection.ts | 533 + .../src => ade-cli/src/tuiClient}/format.ts | 14 +- .../src/tuiClient}/heartbeat.ts | 22 +- .../src/tuiClient}/jsonRpcClient.ts | 27 +- .../src/tuiClient}/linearCommands.ts | 0 .../src/tuiClient}/pendingInput.ts | 2 +- .../src => ade-cli/src/tuiClient}/project.ts | 2 +- .../src/tuiClient/reactDevtoolsStub.ts | 7 + .../src => ade-cli/src/tuiClient}/types.ts | 6 +- apps/ade-cli/tsconfig.json | 1 + apps/ade-cli/tsup.config.ts | 86 +- apps/ade-code/package-lock.json | 4907 --------- apps/ade-code/package.json | 41 - .../ade-code/src/__tests__/connection.test.ts | 80 - apps/ade-code/src/connection.ts | 212 - apps/ade-code/tsconfig.json | 15 - apps/ade-code/tsup.config.ts | 24 - apps/ade-code/vitest.config.ts | 8 - apps/desktop/SYNC_REMOTE_API_ANALYSIS.md | 315 - apps/desktop/build/icon.alpha.icns | Bin 0 -> 377906 bytes apps/desktop/build/icon.beta.icns | Bin 0 -> 378865 bytes apps/desktop/package-lock.json | 312 +- apps/desktop/package.json | 37 +- apps/desktop/resources/runtime/.gitkeep | 1 + apps/desktop/scripts/ade-cli-install-path.sh | 19 +- apps/desktop/scripts/ade-cli-macos-wrapper.sh | 20 + .../scripts/after-pack-runtime-fixes.cjs | 52 + .../scripts/materialize-runtime-resources.mjs | 349 + apps/desktop/scripts/set-ci-version.mjs | 15 +- apps/desktop/scripts/set-release-version.mjs | 17 +- .../scripts/validate-mac-artifacts.mjs | 24 + .../scripts/validate-runtime-resources.mjs | 73 + .../scripts/validate-win-artifacts.mjs | 43 + apps/desktop/src/main/main.ts | 569 +- .../main/services/adeActions/registry.test.ts | 714 +- .../src/main/services/adeActions/registry.ts | 2900 ++++- .../main/services/ai/aiIntegrationService.ts | 19 +- .../src/main/services/ai/apiKeyStore.test.ts | 94 + .../src/main/services/ai/apiKeyStore.ts | 138 +- .../services/ai/tools/ctoOperatorTools.ts | 2 +- .../services/chat/agentChatService.test.ts | 8 +- .../main/services/chat/agentChatService.ts | 75 +- .../main/services/cli/adeCliService.test.ts | 40 +- .../src/main/services/cli/adeCliService.ts | 96 +- .../computerUseArtifactBrokerService.test.ts | 26 + .../computerUseArtifactBrokerService.ts | 63 + .../src/main/services/cto/ctoState.test.ts | 1 - .../src/main/services/cto/ctoStateService.ts | 20 - .../services/cto/ctoWorkerLifecycle.test.ts | 530 +- .../services/cto/openclawBridgeService.ts | 1689 --- .../cto/workerAdapterRuntimeService.ts | 72 - .../main/services/cto/workerAgentService.ts | 30 - .../services/cto/workerHeartbeatService.ts | 4 +- .../feedback/feedbackReporterService.test.ts | 9 +- .../feedback/feedbackReporterService.ts | 24 +- .../main/services/git/gitOperationsService.ts | 94 + .../src/main/services/github/githubService.ts | 22 +- .../src/main/services/ipc/registerIpc.ts | 523 +- .../main/services/ipc/runtimeBridge.test.ts | 375 + .../src/main/services/ipc/runtimeBridge.ts | 857 ++ .../services/lanes/laneListSnapshotService.ts | 259 + .../src/main/services/lanes/laneService.ts | 8 +- .../localRuntimeConnectionPool.test.ts | 720 ++ .../localRuntimeConnectionPool.ts | 823 ++ .../services/macosVm/macosVmService.test.ts | 1 - .../main/services/macosVm/macosVmService.ts | 4 +- .../aiOrchestratorService.test.ts | 4 +- .../orchestrator/aiOrchestratorService.ts | 22 +- .../orchestrator/orchestratorService.test.ts | 4 +- .../services/projects/adeProjectService.ts | 34 +- .../services/projects/projectDetailService.ts | 89 +- .../projects/projectLifecycle.test.ts | 46 +- .../projects/projectScaffoldService.test.ts | 28 + .../projects/projectScaffoldService.ts | 19 +- .../remoteRuntime/remoteBootstrap.test.ts | 528 + .../services/remoteRuntime/remoteBootstrap.ts | 462 + .../remoteConnectionPool.test.ts | 548 + .../remoteRuntime/remoteConnectionPool.ts | 565 + .../remoteRuntime/remoteConnectionService.ts | 387 + .../remoteRuntime/remoteRuntime.e2e.test.ts | 169 + ...moteRuntime.offlineRpc.integration.test.ts | 460 + .../remoteTargetRegistry.test.ts | 34 + .../remoteRuntime/remoteTargetRegistry.ts | 126 + .../remoteRuntime/runtimeDiscovery.test.ts | 145 + .../remoteRuntime/runtimeDiscovery.ts | 304 + .../remoteRuntime/runtimeRpcClient.test.ts | 124 + .../remoteRuntime/runtimeRpcClient.ts | 175 + .../remoteRuntime/sshTransport.test.ts | 191 + .../services/remoteRuntime/sshTransport.ts | 308 + .../runtime/machineStateMigration.test.ts | 131 + .../services/runtime/machineStateMigration.ts | 115 + .../src/main/services/state/kvDb.test.ts | 72 +- apps/desktop/src/main/services/state/kvDb.ts | 156 +- .../services/sync/deviceRegistryService.ts | 674 +- .../sync/resolveTailscaleCliPath.test.ts | 22 + .../services/sync/resolveTailscaleCliPath.ts | 52 +- .../services/sync/syncHostService.test.ts | 142 + .../src/main/services/sync/syncHostService.ts | 3000 +----- .../main/services/sync/syncPairingStore.ts | 94 +- .../src/main/services/sync/syncPeerService.ts | 580 +- .../src/main/services/sync/syncPinStore.ts | 148 +- .../main/services/sync/syncProtocol.test.ts | 3 + .../src/main/services/sync/syncProtocol.ts | 121 +- .../sync/syncRemoteCommandService.test.ts | 16 + .../services/sync/syncRemoteCommandService.ts | 2515 +---- .../main/services/sync/syncService.test.ts | 20 +- .../src/main/services/sync/syncService.ts | 1065 +- apps/desktop/src/preload/global.d.ts | 422 +- apps/desktop/src/preload/preload.test.ts | 1034 ++ apps/desktop/src/preload/preload.ts | 6524 ++++++++++-- apps/desktop/src/renderer/browserMock.ts | 1177 ++- .../src/renderer/components/app/AppShell.tsx | 41 +- .../components/app/CommandPalette.test.tsx | 172 +- .../components/app/CommandPalette.tsx | 1150 +- .../renderer/components/app/SettingsPage.tsx | 16 +- .../renderer/components/app/TopBar.test.tsx | 100 +- .../src/renderer/components/app/TopBar.tsx | 1337 ++- .../components/chat/AgentChatMessageList.tsx | 35 +- .../components/chat/AgentChatPane.tsx | 1 + .../components/chat/AgentCliAuthCard.test.tsx | 142 + .../components/chat/AgentCliAuthCard.tsx | 193 + .../src/renderer/components/cto/CtoPage.tsx | 13 +- .../components/cto/CtoSettingsPanel.tsx | 15 +- .../cto/OpenclawConnectionPanel.tsx | 626 -- .../src/renderer/components/cto/TeamPanel.tsx | 20 - .../renderer/components/cto/ctoUi.test.tsx | 5 - .../components/graph/WorkspaceGraphPage.tsx | 7 +- .../components/lanes/CreateLaneDialog.tsx | 194 +- .../components/lanes/LaneContextMenu.tsx | 28 +- .../components/lanes/LinearIssueBadge.tsx | 34 +- .../components/projects/AddProjectChooser.tsx | 5 +- .../components/projects/CloneProjectForm.tsx | 497 +- .../components/projects/CreateProjectForm.tsx | 169 +- .../projects/RemoteProjectOpenDialog.tsx | 548 + .../remoteTargets/RemoteTargetForm.tsx | 162 + .../remoteTargets/RemoteTargetList.test.tsx | 141 + .../remoteTargets/RemoteTargetList.tsx | 902 ++ .../src/renderer/components/run/RunPage.tsx | 1039 +- .../components/settings/GeneralSection.tsx | 139 + .../settings/SyncDevicesSection.tsx | 200 +- apps/desktop/src/renderer/state/appStore.ts | 59 +- apps/desktop/src/shared/ipc.ts | 38 +- apps/desktop/src/shared/types/adeCli.ts | 2 +- apps/desktop/src/shared/types/agents.ts | 14 +- apps/desktop/src/shared/types/chat.ts | 9 +- apps/desktop/src/shared/types/core.ts | 59 + apps/desktop/src/shared/types/cto.ts | 29 - apps/desktop/src/shared/types/index.ts | 2 +- apps/desktop/src/shared/types/openclaw.ts | 107 - .../desktop/src/shared/types/remoteRuntime.ts | 155 + apps/desktop/src/shared/types/sync.ts | 27 +- apps/ios/ADE/App/ContentView.swift | 34 +- apps/ios/ADE/Info.plist | 2 - apps/ios/ADE/Models/RemoteModels.swift | 20 +- apps/ios/ADE/Services/SyncService.swift | 516 +- .../Views/Components/ADEDesignSystem.swift | 26 +- .../Views/Cto/CtoSessionDestinationView.swift | 4 +- .../ios/ADE/Views/Cto/CtoSettingsScreen.swift | 34 +- apps/ios/ADE/Views/Cto/CtoTeamScreen.swift | 10 +- .../ADE/Views/Cto/CtoWorkerDetailScreen.swift | 4 +- .../ADE/Views/Cto/CtoWorkflowsScreen.swift | 18 +- .../Views/Files/FilesDetailComponents.swift | 2 +- .../ADE/Views/Files/FilesDetailScreen.swift | 10 +- .../Files/FilesDirectoryContentsView.swift | 2 +- apps/ios/ADE/Views/Files/FilesModels.swift | 8 +- .../ios/ADE/Views/Files/FilesRootScreen.swift | 4 +- .../ios/ADE/Views/Lanes/LaneCommitSheet.swift | 4 +- .../Lanes/LaneConnectionPresentation.swift | 16 +- .../ios/ADE/Views/Lanes/LaneCreateSheet.swift | 2 +- apps/ios/ADE/Views/Lanes/LaneHelpers.swift | 6 +- .../ios/ADE/Views/Lanes/LaneManageSheet.swift | 2 +- .../Views/Lanes/LanesOfflineEmptyState.swift | 6 +- apps/ios/ADE/Views/LanesTabView.swift | 2 +- .../ADE/Views/PRs/CreatePrWizardView.swift | 2 +- .../ADE/Views/PRs/PrDetailActivityTab.swift | 4 +- .../ios/ADE/Views/PRs/PrDetailChecksTab.swift | 2 +- apps/ios/ADE/Views/PRs/PrDetailFilesTab.swift | 2 +- apps/ios/ADE/Views/PRs/PrDetailScreen.swift | 4 +- apps/ios/ADE/Views/PRs/PrMergeGateCard.swift | 2 +- apps/ios/ADE/Views/PRs/PrRebaseScreen.swift | 2 +- apps/ios/ADE/Views/PRs/PrStackSheet.swift | 4 +- apps/ios/ADE/Views/PRs/PrWorkflowCards.swift | 8 +- apps/ios/ADE/Views/PRs/PrsRootScreen.swift | 6 +- .../Settings/ConnectionSettingsView.swift | 44 +- .../Settings/NotificationsCenterView.swift | 4 +- .../Settings/PerSessionOverrideView.swift | 2 +- .../Settings/SettingsConnectionHeader.swift | 20 +- .../Settings/SettingsDiagnosticsSection.swift | 2 +- .../Settings/SettingsPairingSection.swift | 324 +- .../ADE/Views/Settings/SettingsPinSheet.swift | 15 +- .../Views/Settings/SettingsSupportTypes.swift | 7 - .../ADE/Views/Work/WorkChatSessionView.swift | 6 +- .../ios/ADE/Views/Work/WorkModelCatalog.swift | 14 +- .../ADE/Views/Work/WorkModelPickerSheet.swift | 4 +- .../ios/ADE/Views/Work/WorkNewChatSheet.swift | 6 +- .../Views/Work/WorkRootScreen+Actions.swift | 2 +- apps/ios/ADE/Views/Work/WorkRootScreen.swift | 2 +- .../WorkSessionDestinationView+Actions.swift | 4 +- .../Work/WorkSessionDestinationView.swift | 2 +- .../Views/Work/WorkSessionSettingsSheet.swift | 8 +- apps/ios/ADETests/ADETests.swift | 388 +- .../public/images/competitors/openclaw.png | Bin 13319 -> 0 bytes apps/web/public/mockup.html | 5 - apps/web/scripts/og-image-combo.html | 2 - apps/web/scripts/og-image.html | 5 - .../editorial/CompetitorEquation.tsx | 1 - changelog/v1.0.10.mdx | 2 +- changelog/v1.1.6.mdx | 2 +- cto/workers.mdx | 13 - docs/ARCHITECTURE.md | 295 +- docs/PRD.md | 53 +- docs/README.md | 11 +- docs/features/ade-code/README.md | 159 +- docs/features/agents/README.md | 49 +- docs/features/agents/identity-and-personas.md | 7 +- docs/features/automations/README.md | 15 +- docs/features/automations/guardrails.md | 4 +- docs/features/chat/README.md | 32 +- docs/features/computer-use/README.md | 15 +- docs/features/computer-use/app-control.md | 2 + docs/features/computer-use/artifact-broker.md | 9 +- docs/features/computer-use/backends.md | 16 +- .../computer-use/settings-and-readiness.md | 12 +- docs/features/conflicts/README.md | 23 +- docs/features/conflicts/detection.md | 17 +- docs/features/cto/README.md | 27 +- docs/features/cto/identity-and-memory.md | 2 +- docs/features/cto/linear-integration.md | 16 +- docs/features/cto/onboarding.md | 4 +- docs/features/cto/pipeline-builder.md | 2 +- docs/features/cto/workers.md | 19 +- docs/features/files-and-editor/README.md | 32 +- .../files-and-editor/editor-surfaces.md | 5 +- .../file-watcher-and-trust.md | 48 +- docs/features/history/README.md | 21 +- docs/features/history/recording-and-export.md | 9 + docs/features/ios-simulator/README.md | 24 + docs/features/ios-simulator/inspector.md | 13 +- docs/features/lanes/README.md | 46 +- docs/features/lanes/oauth-redirect.md | 22 +- docs/features/lanes/runtime.md | 24 +- docs/features/lanes/worktree-isolation.md | 23 +- docs/features/linear-integration/README.md | 21 + .../linear-integration/dispatch-and-sync.md | 10 + docs/features/memory/README.md | 31 + docs/features/memory/compaction.md | 8 + docs/features/memory/embeddings.md | 22 + docs/features/memory/storage.md | 6 + docs/features/missions/README.md | 13 +- docs/features/missions/orchestration.md | 6 +- docs/features/missions/validation-gates.md | 2 + docs/features/missions/workers.md | 6 +- .../onboarding-and-settings/README.md | 94 +- docs/features/project-home/README.md | 36 +- docs/features/proof.md | 30 +- docs/features/pull-requests/README.md | 32 +- docs/features/pull-requests/path-to-merge.md | 33 +- docs/features/remote-runtime/README.md | 117 + .../remote-runtime/internal-architecture.md | 102 + docs/features/sync-and-multi-device/README.md | 489 +- .../sync-and-multi-device/crdt-model.md | 30 +- .../sync-and-multi-device/ios-companion.md | 53 +- .../sync-and-multi-device/remote-commands.md | 74 +- .../features/terminals-and-sessions/README.md | 25 +- .../pty-and-processes.md | 30 +- .../runtime-isolation.md | 8 + docs/features/workspace-graph/README.md | 21 + docs/features/workspace-graph/data-sources.md | 42 +- package.json | 18 + plans/remote-runtime-architecture.md | 3 +- scripts/dev-all.mjs | 82 + scripts/dev-code.mjs | 122 + scripts/dev-desktop.mjs | 112 + scripts/dev-runtime-stop.mjs | 159 + scripts/dev-runtime.mjs | 90 + scripts/dev-shared.mjs | 124 + scripts/package-channel.mjs | 357 + 348 files changed, 58971 insertions(+), 24079 deletions(-) create mode 100644 apps/ade-cli/scripts/build-static.mjs create mode 100644 apps/ade-cli/scripts/install-runtime.sh create mode 100644 apps/ade-cli/scripts/notarize-static-runtime.mjs create mode 100644 apps/ade-cli/scripts/package-native-deps.mjs create mode 100644 apps/ade-cli/src/multiProjectRpcServer.test.ts create mode 100644 apps/ade-cli/src/multiProjectRpcServer.ts create mode 100644 apps/ade-cli/src/serviceManager/common.test.ts create mode 100644 apps/ade-cli/src/serviceManager/common.ts create mode 100644 apps/ade-cli/src/serviceManager/index.ts create mode 100644 apps/ade-cli/src/serviceManager/installLaunchd.ts create mode 100644 apps/ade-cli/src/serviceManager/installSystemd.ts create mode 100644 apps/ade-cli/src/serviceManager/installWindows.ts create mode 100644 apps/ade-cli/src/services/agentRegistry.test.ts create mode 100644 apps/ade-cli/src/services/agentRegistry.ts create mode 100644 apps/ade-cli/src/services/credentials/credentialStore.test.ts create mode 100644 apps/ade-cli/src/services/credentials/credentialStore.ts create mode 100644 apps/ade-cli/src/services/projects/machineLayout.ts create mode 100644 apps/ade-cli/src/services/projects/projectRegistry.test.ts create mode 100644 apps/ade-cli/src/services/projects/projectRegistry.ts create mode 100644 apps/ade-cli/src/services/projects/projectScope.test.ts create mode 100644 apps/ade-cli/src/services/projects/projectScope.ts create mode 100644 apps/ade-cli/src/services/sync/deviceRegistryService.ts create mode 100644 apps/ade-cli/src/services/sync/resolveTailscaleCliPath.ts create mode 100644 apps/ade-cli/src/services/sync/syncHostService.test.ts create mode 100644 apps/ade-cli/src/services/sync/syncHostService.ts create mode 100644 apps/ade-cli/src/services/sync/syncPairingStore.ts create mode 100644 apps/ade-cli/src/services/sync/syncPeerService.ts create mode 100644 apps/ade-cli/src/services/sync/syncPinStore.ts create mode 100644 apps/ade-cli/src/services/sync/syncProtocol.ts create mode 100644 apps/ade-cli/src/services/sync/syncRemoteCommandService.ts create mode 100644 apps/ade-cli/src/services/sync/syncService.ts create mode 100644 apps/ade-cli/src/stdioRpcDaemon.test.ts create mode 100644 apps/ade-cli/src/transports/stdioTransport.ts rename apps/{ade-code/src => ade-cli/src/tuiClient}/__tests__/adeApi.test.ts (92%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/__tests__/commands.test.ts (100%) create mode 100644 apps/ade-cli/src/tuiClient/__tests__/connection.test.ts rename apps/{ade-code/src => ade-cli/src/tuiClient}/__tests__/format.test.ts (100%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/__tests__/heartbeat.test.ts (100%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/__tests__/jsonRpcClient.test.ts (100%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/__tests__/linearCommands.test.ts (100%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/__tests__/pendingInput.test.ts (98%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/__tests__/project.test.ts (95%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/adeApi.ts (94%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/app.tsx (98%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/cli.tsx (81%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/commands.ts (96%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/components/ApprovalPrompt.tsx (66%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/components/ChatView.tsx (55%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/components/Drawer.tsx (65%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/components/Header.tsx (82%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/components/MentionPalette.tsx (100%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/components/RightPane.tsx (100%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/components/SlashPalette.tsx (91%) create mode 100644 apps/ade-cli/src/tuiClient/connection.ts rename apps/{ade-code/src => ade-cli/src/tuiClient}/format.ts (94%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/heartbeat.ts (88%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/jsonRpcClient.ts (90%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/linearCommands.ts (100%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/pendingInput.ts (98%) rename apps/{ade-code/src => ade-cli/src/tuiClient}/project.ts (97%) create mode 100644 apps/ade-cli/src/tuiClient/reactDevtoolsStub.ts rename apps/{ade-code/src => ade-cli/src/tuiClient}/types.ts (95%) delete mode 100644 apps/ade-code/package-lock.json delete mode 100644 apps/ade-code/package.json delete mode 100644 apps/ade-code/src/__tests__/connection.test.ts delete mode 100644 apps/ade-code/src/connection.ts delete mode 100644 apps/ade-code/tsconfig.json delete mode 100644 apps/ade-code/tsup.config.ts delete mode 100644 apps/ade-code/vitest.config.ts delete mode 100644 apps/desktop/SYNC_REMOTE_API_ANALYSIS.md create mode 100644 apps/desktop/build/icon.alpha.icns create mode 100644 apps/desktop/build/icon.beta.icns create mode 100644 apps/desktop/resources/runtime/.gitkeep create mode 100644 apps/desktop/scripts/materialize-runtime-resources.mjs create mode 100644 apps/desktop/scripts/validate-runtime-resources.mjs delete mode 100644 apps/desktop/src/main/services/cto/openclawBridgeService.ts create mode 100644 apps/desktop/src/main/services/ipc/runtimeBridge.test.ts create mode 100644 apps/desktop/src/main/services/ipc/runtimeBridge.ts create mode 100644 apps/desktop/src/main/services/lanes/laneListSnapshotService.ts create mode 100644 apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts create mode 100644 apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts create mode 100644 apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts create mode 100644 apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts create mode 100644 apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts create mode 100644 apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts create mode 100644 apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts create mode 100644 apps/desktop/src/main/services/remoteRuntime/remoteRuntime.e2e.test.ts create mode 100644 apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts create mode 100644 apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.test.ts create mode 100644 apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.ts create mode 100644 apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts create mode 100644 apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts create mode 100644 apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.test.ts create mode 100644 apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts create mode 100644 apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts create mode 100644 apps/desktop/src/main/services/remoteRuntime/sshTransport.ts create mode 100644 apps/desktop/src/main/services/runtime/machineStateMigration.test.ts create mode 100644 apps/desktop/src/main/services/runtime/machineStateMigration.ts create mode 100644 apps/desktop/src/renderer/components/chat/AgentCliAuthCard.test.tsx create mode 100644 apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx delete mode 100644 apps/desktop/src/renderer/components/cto/OpenclawConnectionPanel.tsx create mode 100644 apps/desktop/src/renderer/components/projects/RemoteProjectOpenDialog.tsx create mode 100644 apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx create mode 100644 apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx create mode 100644 apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx delete mode 100644 apps/desktop/src/shared/types/openclaw.ts create mode 100644 apps/desktop/src/shared/types/remoteRuntime.ts delete mode 100644 apps/web/public/images/competitors/openclaw.png create mode 100644 docs/features/remote-runtime/README.md create mode 100644 docs/features/remote-runtime/internal-architecture.md create mode 100644 scripts/dev-all.mjs create mode 100644 scripts/dev-code.mjs create mode 100644 scripts/dev-desktop.mjs create mode 100644 scripts/dev-runtime-stop.mjs create mode 100644 scripts/dev-runtime.mjs create mode 100644 scripts/dev-shared.mjs create mode 100644 scripts/package-channel.mjs 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index d660073ed..5c4aa1f51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,7 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - name: Install all dependencies (parallel) if: steps.cache.outputs.cache-hit != 'true' @@ -36,7 +35,6 @@ jobs: cd apps/desktop && npm ci & cd apps/ade-cli && npm ci & cd apps/web && npm ci & - cd apps/ade-code && npm ci & wait # ── Secret scanning (no deps needed) ─────────────────────────────────── @@ -65,8 +63,7 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - run: cd apps/desktop && npm run typecheck typecheck-ade-cli: @@ -83,8 +80,7 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - run: cd apps/ade-cli && npm run typecheck typecheck-web: @@ -101,28 +97,9 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - run: cd apps/web && npm run typecheck - typecheck-ade-code: - needs: install - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - uses: actions/cache/restore@v4 - with: - path: | - apps/desktop/node_modules - apps/ade-cli/node_modules - apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} - - run: cd apps/ade-code && npm run typecheck - lint-desktop: needs: install runs-on: ubuntu-latest @@ -137,8 +114,7 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - run: cd apps/desktop && npm run lint test-desktop: @@ -159,8 +135,7 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - run: cd apps/desktop && npx vitest run --shard=${{ matrix.shard }}/8 test-ade-cli: @@ -177,11 +152,10 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - run: cd apps/ade-cli && npm test - test-ade-code: + build: needs: install runs-on: ubuntu-latest steps: @@ -195,30 +169,52 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} - - run: cd apps/ade-code && npm test + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} + - run: cd apps/desktop && npm run build + - run: cd apps/ade-cli && npm run build + - run: cd apps/web && npm run build - build: - needs: install - runs-on: ubuntu-latest + build-runtime-binaries: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + os: macos-15 + - target: darwin-x64 + os: macos-13 + - 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 - - uses: actions/cache/restore@v4 + cache: npm + cache-dependency-path: apps/ade-cli/package-lock.json + + - 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/desktop/node_modules - apps/ade-cli/node_modules - apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} - - run: cd apps/desktop && npm run build - - run: cd apps/ade-cli && npm run build - - run: cd apps/web && npm run build - - run: cd apps/ade-code && npm run build + 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 @@ -234,8 +230,7 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - run: node scripts/validate-docs.mjs # ── Windows build smoke (self-contained — no shared cache) ──────────── @@ -244,6 +239,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 @@ -261,6 +257,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: | @@ -282,12 +290,11 @@ jobs: - typecheck-desktop - typecheck-ade-cli - typecheck-web - - typecheck-ade-code - lint-desktop - test-desktop - test-ade-cli - - test-ade-code - 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..4c8f03787 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 }} @@ -121,7 +135,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 +162,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 +203,135 @@ 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-13 + - 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" == http://* || "$CSC_LINK" == https://* ]]; then + curl -fsSL "$CSC_LINK" -o "$CERT_PATH" + 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 +344,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 +372,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 033d0764a..855ee2909 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,5 @@ ios-signing/ /.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 ece7db0fb..fbbf14f40 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/<platform-arch>/` | 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 `<project>/.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-<platform-arch>` to `$ADE_INSTALL_DIR/ade`, extracts `ade-<platform-arch>.native.tar.gz` to `~/.ade/runtime/<platform-arch>/`, 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_<sha256(rootPath)[..24]>`), 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 `<rootPath>/.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. + +## `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` -## CLI surface +`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":"..."}' @@ -61,7 +232,6 @@ ade --role cto linear search-issues --query "auth" --state-type started,unstarte ade git commit --lane lane-id ade git push --lane lane-id ade git branches --lane lane-id --text -ade git user-identity --lane lane-id --text ade diff patch --lane lane-id --path src/file.ts --text ade prs create --lane lane-id --base main --title "Fix checkout flow" ade prs create --lane lane-id --base main --close-linear-issue-on-merge @@ -76,10 +246,9 @@ ade shell start-cli codex --lane lane-id --permission-mode edit --message "fix f 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 --socket /path/to/ade.sock 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 @@ -88,84 +257,105 @@ 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 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 <command> <subcommand>` for exact flags, `ade actions list --text` to discover the full service-backed action catalog, and `ade actions run <domain.action>` only when there is no typed command for the workflow yet. -**`ade code`** starts the terminal Work chat client (`apps/ade-code`). Build it with `npm run build` inside that directory, install the `ade-code` package, or point **`ADE_CODE_EXECUTABLE`** at `dist/cli.js`. Unlike other commands that auto-pick the desktop socket from the project layout during `executePlan`, **`ade code` only forwards `--socket` when you pass global `--socket` to `ade`** (for example `ade --socket /path/to/ade.sock code`). Without that, the TUI runs in **embedded** headless mode instead of opening a socket implicitly. +Output modes are explicit: `--text` for human-readable summaries, `--json` (default for piped output) for stable JSON, and `--pretty` for pretty-printed JSON. -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: +`--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. -| Flag | PipelineSettings field | Values | -| --- | --- | --- | -| `--max-rounds <n>` (alias `--rounds`) | `maxRounds` | positive integer | -| `--auto-merge` / `--no-auto-merge` | `autoMerge` | boolean | -| `--merge-method <m>` | `mergeMethod` | `repo_default` \| `merge` \| `squash` \| `rebase` | -| `--conflict-strategy <s>` | `conflictStrategy` | `pause` \| `rebase` \| `merge` \| `auto` | -| `--force-finalize <m>` | `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 | +## `ade auth` and `ade doctor` -To set fields without a dedicated flag (for example `autoAgentSettings`), call the action directly: +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 <path> --text`, `ade prs checks <pr> --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 <n>` (alias `--rounds`) | `maxRounds` | positive integer | +| `--auto-merge` / `--no-auto-merge` | `autoMerge` | boolean | +| `--merge-method <m>` | `mergeMethod` | `repo_default` \| `merge` \| `squash` \| `rebase` | +| `--conflict-strategy <s>` | `conflictStrategy` | `pause` \| `rebase` \| `merge` \| `auto` | +| `--force-finalize <m>` | `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 <path> --text`, `ade prs checks <pr> --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 <subcommand>`. 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..0e75d5414 100644 --- a/apps/ade-cli/package-lock.json +++ b/apps/ade-cli/package-lock.json @@ -9,9 +9,15 @@ "version": "0.0.0", "dependencies": { "@cursor/sdk": "^1.0.9", + "@linear/sdk": "^84.0.0", + "bonjour-service": "^1.3.0", + "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", + "ws": "^8.20.0", "yaml": "^2.8.2" }, "bin": { @@ -19,6 +25,10 @@ }, "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 +38,43 @@ "node": ">=22.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/@bufbuild/protobuf": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", @@ -580,6 +627,15 @@ "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/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -627,6 +683,24 @@ "@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/@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -1039,6 +1113,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", @@ -1190,6 +1292,21 @@ "node": ">=8" } }, + "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 +1366,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,6 +1425,16 @@ "readable-stream": "^3.4.0" } }, + "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", @@ -1403,6 +1542,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", @@ -1449,6 +1600,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,6 +1793,22 @@ "license": "ISC", "optional": true }, + "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/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", @@ -1575,6 +1887,18 @@ "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/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1611,6 +1935,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 +1954,16 @@ "license": "MIT", "optional": true }, + "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 +2005,15 @@ "@esbuild/win32-x64": "0.27.3" } }, + "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/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1668,6 +2023,12 @@ "node": ">=6" } }, + "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/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1762,6 +2123,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", @@ -1818,6 +2191,16 @@ "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-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -1931,29 +2314,186 @@ "license": "ISC", "optional": true }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "optional": true, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "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/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": { - "once": "^1.3.0", - "wrappy": "1" + "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/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/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", @@ -1975,6 +2515,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", @@ -1998,6 +2553,12 @@ "node": ">=10" } }, + "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/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2037,6 +2598,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 +2669,15 @@ "node": ">= 10" } }, + "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", @@ -2261,6 +2843,19 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "devOptional": true }, + "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", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -2419,6 +3014,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 +3060,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -2583,6 +3202,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", @@ -2670,12 +3315,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", @@ -2721,6 +3394,22 @@ "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": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -2819,6 +3508,15 @@ "license": "MIT", "optional": true }, + "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", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2848,8 +3546,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 +3593,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,6 +3737,18 @@ "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", @@ -3171,6 +3923,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", @@ -3324,6 +4082,18 @@ "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/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3981,37 +4751,181 @@ "dependencies": { "isexe": "^2.0.0" }, - "bin": { - "node-which": "bin/node-which" + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "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": ">= 8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "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", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, + "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": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" + "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 +4934,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,6 +4987,12 @@ "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", @@ -4063,6 +5004,27 @@ } }, "dependencies": { + "@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==" + } + } + }, "@bufbuild/protobuf": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", @@ -4323,6 +5285,12 @@ "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": {} + }, "@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -4364,6 +5332,19 @@ "@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" + } + }, "@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -4612,6 +5593,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", @@ -4730,6 +5736,14 @@ "indent-string": "^4.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 +5784,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 +5818,15 @@ "readable-stream": "^3.4.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", @@ -4874,6 +5902,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", @@ -4903,6 +5936,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,6 +6051,17 @@ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "optional": true }, + "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==" + }, + "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", @@ -4987,6 +6110,14 @@ "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" + } + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -5016,12 +6147,22 @@ "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-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 +6197,21 @@ "@esbuild/win32-x64": "0.27.3" } }, + "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==" + }, "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==" }, + "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==" + }, "fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -5126,6 +6277,11 @@ "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", @@ -5166,6 +6322,12 @@ "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-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -5260,6 +6422,93 @@ "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", @@ -5272,6 +6521,11 @@ "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", @@ -5290,6 +6544,11 @@ "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", "dev": true }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, "lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -5314,6 +6573,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,6 +6632,11 @@ "ssri": "^8.0.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", @@ -5477,6 +6749,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "devOptional": true }, + "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", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -5587,6 +6868,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,6 +6894,11 @@ "aggregate-error": "^3.0.0" } }, + "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", @@ -5672,6 +6966,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", @@ -5738,12 +7049,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", @@ -5772,6 +7100,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", @@ -5833,6 +7170,14 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "optional": true }, + "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", @@ -5853,8 +7198,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 +7215,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,6 +7304,14 @@ "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", @@ -6073,6 +7449,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", @@ -6169,6 +7550,11 @@ "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==" + }, "typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -6519,11 +7905,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,6 +8015,11 @@ "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", diff --git a/apps/ade-cli/package.json b/apps/ade-cli/package.json index 5077eb3f1..20124366c 100644 --- a/apps/ade-cli/package.json +++ b/apps/ade-cli/package.json @@ -17,18 +17,31 @@ "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": { "@cursor/sdk": "^1.0.9", + "@linear/sdk": "^84.0.0", + "bonjour-service": "^1.3.0", + "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", + "ws": "^8.20.0", "yaml": "^2.8.2" }, "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..a2b3e8264 --- /dev/null +++ b/apps/ade-cli/scripts/build-static.mjs @@ -0,0 +1,287 @@ +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 === "--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..309c52382 --- /dev/null +++ b/apps/ade-cli/scripts/install-runtime.sh @@ -0,0 +1,108 @@ +#!/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 +} + +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 + "$dest_dir/ade" serve --install-service >/dev/null 2>&1 || true +elif [ "$(uname -s)" = "Darwin" ]; then + "$dest_dir/ade" serve --install-service >/dev/null 2>&1 || true +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 || "<empty>"}`); + } +} + 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 4f314c0e4..669561d4f 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -216,6 +216,9 @@ function createRuntime() { get: vi.fn(), readTranscriptTail: vi.fn(() => "") }, + sessionDeltaService: { + getSessionDelta: vi.fn((sessionId: string) => ({ sessionId, filesChanged: 2 })), + }, operationService: { start: operationStart, finish: operationFinish, @@ -784,6 +787,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 } = {}) => ({ @@ -3951,7 +3955,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", @@ -4127,6 +4131,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" }); @@ -4173,6 +4202,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 () => { @@ -4197,6 +4247,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 effbdca0c..2378e45e3 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2142,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", @@ -3455,6 +3461,7 @@ function isLocalComputerUseAllowed(callerCtx: CallerContext): boolean { async function listToolSpecsForSession(runtime: AdeRuntime, session: SessionState): Promise<ToolSpec[]> { const callerCtx = await resolveEffectiveCallerContext(runtime, session); + const memoryAllowed = runtime.capabilities?.memory !== false; const externalComputerUseAvailable = runtime.computerUseArtifactBrokerService ?.getBackendStatus() ?.backends.some((backend) => backend.available) ?? false; @@ -3464,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); @@ -4623,6 +4631,9 @@ async function runTool(args: { }): Promise<unknown> { 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}`); } @@ -6871,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)); @@ -7640,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<typeof syncService.connectToBrain>[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(); } 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 6538c75ce..0a9907ff3 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,19 +22,19 @@ 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 { createAgentChatService } from "../../desktop/src/main/services/chat/agentChatService"; @@ -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,7 +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 } from "../../desktop/src/shared/types"; +import type { AppNavigationRequest, AppNavigationResult, PortLease } from "../../desktop/src/shared/types"; import { createAutomationService, type AutomationAdeActionRegistry, @@ -96,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 }; @@ -118,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<typeof createSyncService>[0]["projectCatalogProvider"]; + remoteCommandExecutor?: Parameters<typeof createSyncService>[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; @@ -131,6 +155,7 @@ export type AdeRuntime = { adeCliService?: ReturnType<typeof createAdeCliService> | null; devToolsService?: ReturnType<typeof createDevToolsService> | null; onboardingService?: ReturnType<typeof createOnboardingService> | null; + adeProjectService?: ReturnType<typeof createAdeProjectService> | null; laneService: ReturnType<typeof createLaneService>; laneWorktreeLockService?: LaneWorktreeLockService | null; laneEnvironmentService?: ReturnType<typeof createLaneEnvironmentService> | null; @@ -167,7 +192,7 @@ export type AdeRuntime = { workerHeartbeatService?: ReturnType<typeof createWorkerHeartbeatService> | null; workerTaskSessionService?: ReturnType<typeof createWorkerTaskSessionService> | null; linearCredentialService?: ReturnType<typeof createLinearCredentialService> | null; - openclawBridgeService?: ReturnType<typeof createOpenclawBridgeService> | null; + linearOAuthService?: ReturnType<typeof createLinearOAuthService> | null; flowPolicyService?: ReturnType<typeof createFlowPolicyService> | null; linearDispatcherService?: ReturnType<typeof createLinearDispatcherService> | null; linearIssueTracker?: ReturnType<typeof createLinearIssueTracker> | null; @@ -193,6 +218,7 @@ export type AdeRuntime = { usageTrackingService?: ReturnType<typeof createUsageTrackingService> | null; budgetCapService?: ReturnType<typeof createBudgetCapService> | null; sessionDeltaService?: ReturnType<typeof createSessionDeltaService> | null; + reviewService?: ReturnType<typeof createReviewService> | null; autoUpdateService?: ReturnType<typeof createAutoUpdateService> | null; appNavigationService?: { navigate(args: AppNavigationRequest): Promise<AppNavigationResult>; @@ -311,6 +337,10 @@ export async function createAdeRuntime(args: { workspaceRoot?: string; chatRuntime?: "headless-stub" | "agent"; runtimeProfile?: "full" | "chat"; + syncRuntime?: AdeRuntimeSyncOptions; + capabilities?: { + memory?: boolean; + }; } | string): Promise<AdeRuntime> { const resolvedArgs = typeof args === "string" ? { projectRoot: args, workspaceRoot: args } @@ -325,8 +355,10 @@ export async function createAdeRuntime(args: { 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); @@ -339,6 +371,16 @@ export async function createAdeRuntime(args: { }); const operationService = createOperationService({ db, projectId }); + const keybindingsService = createKeybindingsService({ db }); + const eventBuffer = createEventBuffer(); + + function pushEvent(category: BufferedEvent["category"], payload: Record<string, unknown>): void { + eventBuffer.push({ timestamp: new Date().toISOString(), category, payload }); + } + + let conflictServiceRef: ReturnType<typeof createConflictService> | null = null; + let rebaseSuggestionServiceRef: ReturnType<typeof createRebaseSuggestionService> | null = null; + let autoRebaseServiceRef: ReturnType<typeof createAutoRebaseService> | null = null; const laneService = createLaneService({ db, @@ -346,15 +388,37 @@ export async function createAdeRuntime(args: { 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, @@ -363,6 +427,85 @@ export async function createAdeRuntime(args: { 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<PortLease[]>("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, @@ -381,8 +524,30 @@ export async function createAdeRuntime(args: { 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, @@ -397,7 +562,7 @@ export async function createAdeRuntime(args: { const missionService = createMissionService({ db, projectId, - onEvent: () => {} + onEvent: (event) => pushEvent("mission", event as unknown as Record<string, unknown>) }); const ptyService = createPtyService({ @@ -406,8 +571,8 @@ export async function createAdeRuntime(args: { 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 @@ -420,47 +585,23 @@ export async function createAdeRuntime(args: { logger, laneService, projectConfigService, - broadcastEvent: () => {} + broadcastEvent: (event) => pushEvent("runtime", event as unknown as Record<string, unknown>) }); const issueInventoryService = createIssueInventoryService({ db }); const laneWorktreeLockService = createLaneWorktreeLockService({ db, logger }); - const eventBuffer = createEventBuffer(); - function pushEvent(category: BufferedEvent["category"], payload: Record<string, unknown>): 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<Record<string, string>> => { 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, }; @@ -514,6 +655,15 @@ export async function createAdeRuntime(args: { projectId, adeDir: paths.adeDir, }); + const adeProjectService = createAdeProjectService({ + projectRoot, + db, + projectId, + logger, + projectConfigService, + ctoStateService, + workerAgentService, + }); const workerBudgetService = createWorkerBudgetService({ db, projectId, @@ -672,6 +822,19 @@ export async function createAdeRuntime(args: { 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 }), + }); let automationServiceRef: ReturnType<typeof createAutomationService> | null = null; let agentChatService = headlessLinearServices.agentChatService as unknown as ReturnType<typeof createAgentChatService> | null; @@ -725,16 +888,48 @@ export async function createAdeRuntime(args: { } } agentChatServiceHolder.current = agentChatService; - // The headless agent-chat stub returns less-typed payloads than the full - // agentChatService. Cast through the orchestrator's expected shape (rather - // than `never`) so that any future tightening of PathToMergeDeps surfaces - // as a type error here. + 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<typeof createPathToMergeOrchestrator>[0]["agentChatService"]; const pathToMergeOrchestrator = createPathToMergeOrchestrator({ logger, prService: headlessLinearServices.prService, laneService, - agentChatService: headlessLinearServices.agentChatService as unknown as PathToMergeAgentChatService, + agentChatService: agentChatService as unknown as PathToMergeAgentChatService, sessionService, issueInventoryService, conflictService, @@ -757,6 +952,23 @@ export async function createAdeRuntime(args: { 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, @@ -765,16 +977,89 @@ export async function createAdeRuntime(args: { automationService, }); + let syncService: ReturnType<typeof createSyncService> | 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, @@ -782,9 +1067,12 @@ export async function createAdeRuntime(args: { diffService, missionService, missionBudgetService, + syncService, + syncHostService: syncService?.getHostService() ?? null, laneWorktreeLockService, ptyService, testService, + reviewService, aiIntegrationService, agentChatService, issueInventoryService, @@ -792,11 +1080,13 @@ export async function createAdeRuntime(args: { 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, @@ -806,6 +1096,7 @@ export async function createAdeRuntime(args: { linearIngressService: headlessLinearServices.linearIngressService, linearRoutingService: headlessLinearServices.linearRoutingService, processService, + feedbackReporterService, automationService, automationPlannerService, computerUseArtifactBrokerService, @@ -817,12 +1108,19 @@ export async function createAdeRuntime(args: { 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(() => 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(() => testService.disposeAll()); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index aa10a397c..3f77c827e 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -8,9 +8,11 @@ import { findProjectRoots, formatOutput, graphWaitState, + isFailedServiceManagerResult, parseCliArgs, renderLaneGraph, resolveRoots, + shouldAutoRegisterProjectForPlan, shouldAttemptDesktopSocketConnection, summarizeExecution, unwrapToolResult, @@ -18,7 +20,10 @@ import { type ResolveRootsOptions = Parameters<typeof resolveRoots>[0]; -function baseResolveOpts(): Omit<ResolveRootsOptions, "projectRoot" | "workspaceRoot"> { +function baseResolveOpts(): Omit< + ResolveRootsOptions, + "projectRoot" | "workspaceRoot" +> { return { role: "external", headless: true, @@ -45,11 +50,22 @@ 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"]); + const parsed = parseCliArgs([ + "--project-root", + "/tmp/project", + "code", + "--print-state", + ]); expect(parsed.options.projectRoot).toBe("/tmp/project"); expect(parsed.command).toEqual(["code", "--print-state"]); @@ -57,31 +73,238 @@ describe("ADE CLI", () => { expect(plan).toEqual({ kind: "ade-code", rest: ["--print-state"] }); }); - it("forwards resolved roots and socket intent to ade code", () => { - const args = buildAdeCodeArgs(["--print-state"], { - ...baseResolveOpts(), - projectRoot: "/tmp/project", - workspaceRoot: null, - headless: false, - requireSocket: true, + it("shows help for bare ade invocations", () => { + expect(buildCliPlan([])).toEqual({ + kind: "help", + text: expect.stringContaining( + "Agent-focused command-line interface for ADE", + ), }); + }); - expect(args).toEqual([ - "--project-root", - "/tmp/project", - "--workspace-root", - "/tmp/project", - "--socket", - "/tmp/project/.ade/ade.sock", - "--require-socket", - "--print-state", + 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"); @@ -99,13 +322,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; @@ -130,7 +367,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); @@ -177,7 +422,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; @@ -205,7 +450,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; @@ -226,7 +471,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; @@ -239,7 +484,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({ @@ -253,11 +504,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", @@ -282,8 +549,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: {}, + }, + ], }), }, }); @@ -292,14 +569,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", () => { @@ -330,15 +609,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: { @@ -368,15 +648,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: { @@ -390,18 +671,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, @@ -458,9 +740,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, @@ -487,7 +770,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; @@ -539,12 +828,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", () => { @@ -616,17 +911,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<string, unknown>).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<string, unknown>).polluted).toBeUndefined(); }); @@ -772,13 +1083,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 <id> 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 <id> or --command/, + ); }); it("unwraps typed ADE action results while preserving actions run envelopes", () => { @@ -817,7 +1142,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", () => { @@ -850,14 +1179,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: [], }, }, @@ -866,49 +1203,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"); @@ -917,7 +1275,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); }); @@ -925,8 +1285,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", + }, ], }); @@ -937,8 +1307,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"]); @@ -946,7 +1328,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"]); @@ -954,7 +1345,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); @@ -971,7 +1368,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; @@ -983,12 +1390,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", + }, + ], }, }); }); @@ -1044,6 +1453,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). @@ -1124,7 +1672,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; @@ -1142,7 +1697,13 @@ describe("ADE CLI", () => { }); it("maps `git checkout <branch>` 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; @@ -1159,12 +1720,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"); @@ -1184,26 +1749,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<string, unknown> }).arguments; + const args = ( + plan.steps[0]?.params as { arguments: Record<string, unknown> } + ).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<string, unknown> }).arguments; + const args = ( + plan.steps[0]?.params as { arguments: Record<string, unknown> } + ).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", () => { @@ -1262,7 +1845,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"); @@ -1352,7 +1935,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"); @@ -1372,7 +1961,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({ @@ -1422,14 +2020,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", () => { @@ -1531,7 +2131,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"]); @@ -1539,7 +2143,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" }, + }, }); }); @@ -1632,25 +2240,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 }, + }, }); }); @@ -1784,7 +2416,8 @@ describe("ADE CLI", () => { execution: { laneMode: "create", laneNamePreset: "custom", - laneNameTemplate: "{{trigger.issue.author}}/{{trigger.issue.title}}", + laneNameTemplate: + "{{trigger.issue.author}}/{{trigger.issue.title}}", }, }, }, @@ -1836,7 +2469,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", () => { @@ -1853,7 +2488,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({ @@ -1914,7 +2556,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({ @@ -1942,9 +2590,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", () => { @@ -1953,7 +2601,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"]); @@ -1970,7 +2622,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({ @@ -1979,7 +2637,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({ @@ -2098,7 +2762,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({ @@ -2111,7 +2782,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({ @@ -2122,7 +2800,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({ @@ -2133,7 +2816,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({ @@ -2146,7 +2834,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", @@ -2183,7 +2873,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"); @@ -2198,7 +2890,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({ @@ -2213,7 +2913,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({ @@ -2226,7 +2935,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({ @@ -2237,7 +2952,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({ @@ -2253,7 +2973,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({ @@ -2308,7 +3035,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({ @@ -2348,7 +3082,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({ @@ -2420,7 +3162,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({ @@ -2458,7 +3206,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({ @@ -2472,7 +3227,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({ @@ -2487,7 +3249,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({ @@ -2502,7 +3273,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({ @@ -2518,7 +3296,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({ @@ -2529,7 +3314,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({ @@ -2553,7 +3345,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({ @@ -2566,7 +3364,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({ @@ -2581,47 +3385,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({ @@ -2639,14 +3499,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({ @@ -2657,7 +3532,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({ @@ -2668,7 +3548,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({ @@ -2679,7 +3565,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({ @@ -2690,7 +3581,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({ @@ -2705,10 +3601,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({ @@ -2746,7 +3654,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"]); @@ -2768,13 +3680,23 @@ describe("ADE CLI", () => { close: () => {}, }; const summarized = summarizeExecution({ - plan: { kind: "execute", label: "lanes list", steps: [], visualizer: "lanes" }, + 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" }, + { + id: "child", + name: "child", + branchRef: "feature", + parentLaneId: "main", + }, ], }, }, @@ -2783,6 +3705,8 @@ describe("ADE CLI", () => { lanes: expect.any(Array), }); expect((summarized as any).visual).toContain("\\- main (id: main) [main]"); - expect((summarized as any).visual).toContain("\\- child (id: child) [feature]"); + 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 cfb7a2b9a..fa436febd 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1,15 +1,17 @@ #!/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 { JsonRpcError, JsonRpcErrorCode, @@ -27,6 +29,11 @@ import { validateLaunchProfilePermissionMode, type LaunchProfile, } from "../../desktop/src/shared/cliLaunch"; +import type { + SyncMobileProjectSummary, + SyncProjectSwitchRequestPayload, + SyncProjectSwitchResultPayload, +} from "../../desktop/src/shared/types/sync"; type JsonObject = Record<string, unknown>; @@ -58,6 +65,7 @@ type FormatterId = | "status" | "doctor" | "auth" + | "projects-list" | "linear-quick-view" | "lanes" | "lane-detail" @@ -104,13 +112,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; @@ -146,10 +167,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([ @@ -205,10 +232,7 @@ const WORKER_MISSION_TOOL_CLI_NAMES = new Set([ function resolveCliPackageRoot(entryPath: string): string { const seen = new Set<string>(); - 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); @@ -232,19 +256,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<typeof spawnSync>, fallbackCommand: string): string { +function formatSpawnFailure( + result: ReturnType<typeof spawnSync>, + fallbackCommand: string, +): string { if (result.error) { return result.error.message; } @@ -289,11 +319,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"; @@ -303,8 +339,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; } } @@ -318,7 +358,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; } @@ -341,16 +382,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 <command...> 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 @@ -373,7 +422,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade memory add | search | pin Use ADE memory $ ade settings action <method> 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 @@ -381,8 +430,8 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} Global options: --project-root <path> ADE project root. Inside .ade/worktrees/<lane>, this resolves to the parent project. --workspace-root <path> 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 <ms> Per-request timeout. Long agent/PR workflows may need several minutes. @@ -392,6 +441,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 <lane> --text + $ ade git status --full --lane <lane> --text + $ ade git sync --lane <lane> --rebase --base main $ ade git stage --lane <lane> src/index.ts $ ade git commit --lane <lane> -m "Fix login redirect" $ ade missions launch --prompt "Fix onboarding" --manual --text @@ -755,6 +806,80 @@ const IOS_SIMULATOR_HELP_ALIASES: Record<string, string> = { }; const HELP_BY_COMMAND: Record<string, string> = { + 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 <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 <path> Unix socket or Windows named pipe to listen on. + --port <n> 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 <project-id> + $ ade projects touch <project-id> +`, code: `${ADE_BANNER} ADE Code @@ -791,16 +916,39 @@ const HELP_BY_COMMAND: Record<string, string> = { refresh lane state. Use --lane for anything other than the active workspace. $ ade git status --lane <lane> --text Show ADE-aware sync status + $ ade git status --full --lane <lane> --text Show full lane status, diff, and conflict state + $ ade git fetch --lane <lane> Fetch remote refs + $ ade git pull --lane <lane> Pull with ADE's ff-only lane operation + $ ade git sync --lane <lane> --rebase --base main + Sync the lane with its base branch $ ade git stage --lane <lane> src/file.ts Stage one file $ ade git stage-all --lane <lane> Stage all current changes $ ade git unstage --lane <lane> src/file.ts Unstage one file $ ade git commit --lane <lane> [-m <message>] Commit, adding Refs <issue-id> on linked Linear lanes $ ade git push --lane <lane> --set-upstream Push through ADE + $ ade git push --lane <lane> --force-with-lease Force-push through ADE with lease $ ade git branches --lane <lane> --text List branches with last-commit metadata $ ade git user-identity --lane <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 <lane> --ai Rebase with ADE conflict support + $ ade git rebase continue --lane <lane> Continue an in-progress rebase + $ ade git conflict show --lane <lane> --text Inspect merge/rebase conflict state + $ ade git conflict resolve --kind rebase Continue after manual conflict resolution $ ade diff changes --lane <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 <id> --text + $ ade operations wait --operation <id> --wait-ms 30000 --text + $ ade actions wait --test-run <id> --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 @@ -866,8 +1014,8 @@ const HELP_BY_COMMAND: Record<string, string> = { 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 <lane> --text List process runtime state @@ -896,7 +1044,7 @@ const HELP_BY_COMMAND: Record<string, string> = { 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 <id> --text List terminals for a chat $ ade terminal active --chat-session <id> --text Show the active chat terminal @@ -924,7 +1072,7 @@ const HELP_BY_COMMAND: Record<string, string> = { 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 <lane> --provider codex --model <model> [--fast] @@ -948,8 +1096,8 @@ const HELP_BY_COMMAND: Record<string, string> = { 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 @@ -964,7 +1112,7 @@ const HELP_BY_COMMAND: Record<string, string> = { 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 @@ -1017,7 +1165,7 @@ const HELP_BY_COMMAND: Record<string, string> = { 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: @@ -1278,7 +1426,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 { @@ -1302,7 +1452,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)}`, + ); } } @@ -1314,7 +1466,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.`); @@ -1326,16 +1481,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)) { @@ -1355,7 +1519,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); @@ -1384,7 +1550,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); @@ -1417,8 +1585,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); @@ -1451,17 +1622,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<string, string> = { - 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]]}`; @@ -1472,7 +1654,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]; @@ -1495,10 +1677,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; @@ -1531,7 +1720,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); @@ -1541,7 +1734,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); @@ -1551,12 +1748,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); @@ -1565,16 +1770,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; } @@ -1588,7 +1802,11 @@ function sleep(ms: number): Promise<void> { 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; @@ -1628,10 +1846,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.", @@ -1643,20 +1861,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"]); @@ -1674,40 +1893,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; @@ -1718,7 +1945,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, @@ -1734,21 +1964,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") { @@ -1757,7 +1998,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")) { @@ -1790,7 +2033,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."); } @@ -1799,7 +2045,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."); } @@ -1813,10 +2062,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 { @@ -1825,7 +2082,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", @@ -1834,15 +2095,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 }); } @@ -1853,8 +2129,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", @@ -1873,7 +2153,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 <domain.action> or <domain> <action>."); + if (!target) + throw new CliUsageError( + "actions run requires <domain.action> or <domain> <action>.", + ); let domain: string; let action: string; @@ -1889,18 +2172,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)); @@ -1945,12 +2241,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", @@ -1969,10 +2279,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, { @@ -1988,129 +2306,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 <parent> or --parent <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 <parent> or --parent <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"]); @@ -2118,30 +2809,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") { - const action = firstPositional(args) ?? "list"; - const stashRef = readValue(args, ["--ref", "--stash-ref"]) ?? 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") { + const action = firstPositional(args) ?? "list"; + const stashRef = + readValue(args, ["--ref", "--stash-ref"]) ?? firstPositional(args); const message = readValue(args, ["--message", "-m"]); const common = withLane({ ...(stashRef ? { stashRef } : {}), @@ -2160,58 +2952,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<string, string> = { 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<string, string> = { 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", @@ -2219,51 +3111,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 = {}; @@ -2277,30 +3231,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"), + }), + ), ], }; } @@ -2319,69 +3406,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<string, string> = { 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<string, string> = { + 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") { @@ -2390,15 +3698,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 @@ -2406,15 +3725,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 }; } @@ -2422,25 +3758,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) }))] }; - } - 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 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 create", steps: [actionCallStep("result", "create_queue", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "queue create", + steps: [ + actionCallStep( + "result", + "create_queue", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "integration") { @@ -2456,28 +3884,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<string, string> = { get: "getInventory", @@ -2489,19 +3977,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<string, unknown[]> = { 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") { @@ -2515,21 +4022,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"]); @@ -2545,79 +4086,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"]), + }), + ), + ], }; } @@ -2626,13 +4251,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, { @@ -2641,7 +4281,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, @@ -2687,17 +4331,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", @@ -2707,42 +4364,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", @@ -2779,16 +4469,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", @@ -2807,52 +4531,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<string, string> = { start: "startStack", stop: "stopStack", restart: "restartStack" }; + const stackId = requireValue( + readValue(args, ["--stack", "--stack-id"]) ?? firstPositional(args), + "stackId", + ); + const methodByMode: Record<string, string> = { + 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); } @@ -2863,8 +4722,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 @@ -2881,31 +4744,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); @@ -2913,129 +4849,435 @@ 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 <id> or --command <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))] }; -} - + 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 <id> or --command <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; @@ -3047,43 +5289,216 @@ function readFileTextInput(args: string[]): string | 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 { @@ -3098,193 +5513,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" + 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" - : 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))] }; + : 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), + }), + ), + ], + }; + } + 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", + ), + }), + ), + ], + }; } - 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), - }))] }; + 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 === "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-sim ${sub}`, + steps: [ + actionStep( + "result", + "ios_simulator", + sub, + collectGenericObjectArgs(args), ), - }))] }; - } - 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))] }; + ], + }; } function readTrailingCommand(args: string[]): string | null { @@ -3304,39 +6190,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); @@ -3348,120 +6303,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; }; @@ -3476,250 +6610,897 @@ 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))] }; + 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 === "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))] }; + 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") { + 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))] }; + 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 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 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, - })] }; + 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, - })] }; + 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, - })] }; + 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))] }; + 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 (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 === "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))] }; + 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))] }; + 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))] }; + 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 buildOperationsPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "status"; + if (sub === "status" || sub === "show") { + 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 }), + ), + ], + }; + } + 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.", + ); + } + throw new CliUsageError("operations supports status or wait."); } 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<string, string> = { @@ -3733,16 +7514,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) { @@ -3750,11 +7564,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."); @@ -3762,12 +7579,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<T extends string>( args: string[], @@ -3784,31 +7619,56 @@ function readEnumOption<T extends string>( } function applyLaneFlagsToDraft(draft: JsonObject, args: string[]): JsonObject { - const laneMode = readEnumOption<AutomationLaneModeFlag>(args, ["--lane-mode"], AUTOMATION_LANE_MODES, "--lane-mode"); + const laneMode = readEnumOption<AutomationLaneModeFlag>( + args, + ["--lane-mode"], + AUTOMATION_LANE_MODES, + "--lane-mode", + ); const laneId = readLaneId(args); - const preset = readEnumOption<AutomationLaneNamePresetFlag>(args, ["--lane-name-preset"], AUTOMATION_LANE_NAME_PRESETS, "--lane-name-preset"); + const preset = readEnumOption<AutomationLaneNamePresetFlag>( + 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 }; @@ -3820,18 +7680,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) }; } @@ -3870,12 +7738,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") { @@ -3885,7 +7764,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", @@ -3894,71 +7776,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 <true|false>."); + throw new CliUsageError( + "automations toggle requires --enabled <true|false>.", + ); } 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}`, @@ -3975,22 +7898,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"])); @@ -3998,9 +7957,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<string, string> = { @@ -4010,8 +7990,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"; @@ -4021,8 +8017,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"; @@ -4034,8 +8035,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"; @@ -4045,15 +8053,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<string, string> = { get: "getPolicy", @@ -4065,79 +8103,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<string> = 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]!; @@ -4155,8 +8441,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<string, string> = { @@ -4196,6 +8488,8 @@ function buildCliPlan(command: string[]): CliPlan { automation: "automations", "auto-update": "update", updates: "update", + operation: "operations", + project: "projects", }; const primaryHelpKey = aliases[primary] ?? primary; if (hasHelpFlag(args)) { @@ -4208,7 +8502,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(); @@ -4222,7 +8519,10 @@ 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` }; @@ -4231,8 +8531,38 @@ function buildCliPlan(command: string[]): CliPlan { 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 { @@ -4243,20 +8573,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, + }, ], }; } @@ -4267,31 +8604,85 @@ 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 === "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 === "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'.`); @@ -4304,7 +8695,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(); @@ -4316,7 +8709,9 @@ function buildCursorPlan(args: string[]): CliPlan { return { kind: "cursor-cloud", rest: args }; } -function findAdeManagedWorktreeRoot(startDir: string): { projectRoot: string; workspaceRoot: string } | null { +function findAdeManagedWorktreeRoot( + startDir: string, +): { projectRoot: string; workspaceRoot: string } | null { let resolved = path.resolve(startDir); try { resolved = fs.realpathSync.native(resolved); @@ -4325,18 +8720,26 @@ function findAdeManagedWorktreeRoot(startDir: string): { projectRoot: string; wo } 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; + 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; + 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 { + projectRoot: path.resolve(projectRoot), + workspaceRoot: path.resolve(workspaceRoot), + }; } return null; } -function findProjectRoots(startDir: string): { projectRoot: string; workspaceRoot: string } { +function findProjectRoots(startDir: string): { + projectRoot: string; + workspaceRoot: string; +} { let canonicalStart = path.resolve(startDir); try { canonicalStart = fs.realpathSync.native(canonicalStart); @@ -4366,7 +8769,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()) @@ -4375,13 +8781,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 }; } @@ -4395,26 +8803,14 @@ function commandExists(command: string): boolean { return result.status === 0 && result.stdout.trim().length > 0; } -function resolveAdeCodeLaunch(): { command: string; args: string[] } { - const explicit = process.env.ADE_CODE_EXECUTABLE?.trim(); - if (explicit) return { command: explicit, args: [] }; - - const siblingDist = path.resolve(CLI_PACKAGE_ROOT, "..", "ade-code", "dist", "cli.js"); - if (fs.existsSync(siblingDist)) { - return { command: process.execPath, args: [siblingDist] }; - } - - if (commandExists("ade-code")) { - return { command: "ade-code", args: [] }; - } - - throw new CliUsageError("ade code could not find ade-code. Build apps/ade-code or install the ade-code binary."); -} - function resolveAdeCodeSocketPath(projectRoot: string): string { - return process.env.ADE_RPC_URL?.trim() - || process.env.ADE_RPC_SOCKET_PATH?.trim() - || path.join(projectRoot, ".ade", "ade.sock"); + 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[] { @@ -4425,27 +8821,41 @@ function buildAdeCodeArgs(rest: string[], options: GlobalOptions): string[] { "--workspace-root", roots.workspaceRoot, ...(options.headless ? ["--embedded"] : []), - ...(options.requireSocket ? ["--socket", resolveAdeCodeSocketPath(roots.projectRoot), "--require-socket"] : []), + ...(options.requireSocket + ? [ + "--socket", + resolveAdeCodeSocketPath(roots.projectRoot), + "--require-socket", + ] + : []), ...rest, ]; } -function runAdeCode(rest: string[], options: GlobalOptions): { output: string; exitCode: number } { - const launch = resolveAdeCodeLaunch(); - const args = [ - ...launch.args, - ...buildAdeCodeArgs(rest, options), - ]; - const result = spawnSync(launch.command, args, { - cwd: process.cwd(), - env: process.env, - stdio: "inherit", - }); - if (result.error) throw result.error; - return { output: "", exitCode: typeof result.status === "number" ? result.status : 1 }; +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 } { +function runLocalCommand( + command: string, + args: string[], + cwd: string, +): { ok: boolean; stdout: string; stderr: string } { const result = spawnSync(command, args, { cwd, encoding: "utf8", @@ -4468,7 +8878,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, @@ -4477,8 +8891,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", @@ -4491,7 +8913,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; } @@ -4499,7 +8925,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, @@ -4525,12 +8953,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 { @@ -4550,8 +8980,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); @@ -4563,8 +8997,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", @@ -4587,7 +9028,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, @@ -4612,16 +9054,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" @@ -4638,14 +9086,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<string, number> { - 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<Record<string, number>>((acc, action) => { const domain = asString(action.domain) ?? "core"; acc[domain] = (acc[domain] ?? 0) + 1; @@ -4659,15 +9116,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), @@ -4680,12 +9146,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 <repo>."); + recommendations.unshift( + "project: Run ade doctor from an ADE project or pass --project-root <repo>.", + ); } const actionCountsByDomain = actionDomainCounts(values.actions); const ready = projectInitialized && checks.git.ready && actions.length > 0; @@ -4696,7 +9166,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, @@ -4719,12 +9194,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, @@ -4744,81 +9223,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<number, { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - timer: ReturnType<typeof setTimeout>; - }>(); - - 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<SocketJsonRpcClient> { - 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<typeof setTimeout> | 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<net.Socket> { + 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<typeof setTimeout> | 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<typeof setTimeout>; + } + >(); + 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<SocketJsonRpcClient> { + const socket = await connectSocket(socketPath, timeoutMs, label); + return new SocketJsonRpcClient(socket, timeoutMs); } - request(method: string, params?: JsonObject): Promise<unknown> { + request(method: string, params?: unknown): Promise<unknown> { + if (this.closedError) return Promise.reject(this.closedError); const id = this.nextId; this.nextId += 1; const payload: JsonRpcRequest = { @@ -4843,12 +9374,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; @@ -4864,18 +9443,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); @@ -4888,6 +9485,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 { @@ -4911,8 +9518,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; } @@ -4922,7 +9533,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 }); @@ -4945,7 +9559,9 @@ async function startHeadlessRpcSocketServer(args: { return () => { stopHeadlessRpcServer(serverState); - try { fs.unlinkSync(args.socketPath); } catch {} + try { + fs.unlinkSync(args.socketPath); + } catch {} }; } @@ -4959,7 +9575,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.")); @@ -4986,7 +9606,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<net.Socket>(); const activeStops = new Set<ReturnType<typeof startJsonRpcServer>>(); const server = net.createServer((conn) => { @@ -4994,7 +9622,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); @@ -5004,6 +9634,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 = () => { @@ -5011,8 +9644,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); @@ -5024,12 +9661,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[] { @@ -5089,7 +9732,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(); @@ -5102,34 +9749,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<void> { - await connection.request("ade/initialize", buildInitializeParams(options, "ade-cli")); +async function initializeConnection( + connection: CliConnection, + options: GlobalOptions, +): Promise<void> { + 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<CliConnection> { +async function createConnection( + options: GlobalOptions, + args: { autoRegisterProject?: boolean } = {}, +): Promise<CliConnection> { 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(), }; @@ -5141,21 +10035,23 @@ async function createConnection(options: GlobalOptions): Promise<CliConnection> } 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; @@ -5170,7 +10066,7 @@ async function createConnection(options: GlobalOptions): Promise<CliConnection> try { stopHeadlessSocket = await startHeadlessRpcSocketServers({ projectRoot: roots.projectRoot, - socketPath, + socketPath: legacySocketPath, createHandler, }); } catch { @@ -5182,11 +10078,15 @@ async function createConnection(options: GlobalOptions): Promise<CliConnection> 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(); @@ -5196,7 +10096,10 @@ async function createConnection(options: GlobalOptions): Promise<CliConnection> 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); @@ -5207,7 +10110,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 } : {}), @@ -5239,7 +10143,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 { @@ -5257,14 +10163,19 @@ function formatMcpToolText(value: unknown): string { } async function runMcpServer(options: GlobalOptions): Promise<void> { - 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, @@ -5272,129 +10183,851 @@ async function runMcpServer(options: GlobalOptions): Promise<void> { }); let initialized = false; let nextAdeRequestId = 1; - const callAde = async (method: string, params?: JsonObject): Promise<unknown> => { + const callAde = async ( + method: string, + params?: JsonObject, + ): Promise<unknown> => { return await adeHandler({ jsonrpc: "2.0", id: nextAdeRequestId++, method, ...(params !== undefined ? { params } : {}), }); - }; - const ensureInitialized = async (): Promise<void> => { - if (initialized) return; - await callAde("ade/initialize", buildInitializeParams(options, "ade-mcp")); - initialized = true; - }; + }; + const ensureInitialized = async (): Promise<void> => { + 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<void>((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<string> { + 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<string | null> { + const result = await client.request( + "ade/initialize", + buildInitializeParams(options, "ade-rpc-stdio-proxy"), + ); + return readRuntimeInfoVersion(result); +} + +async function shutdownMachineRuntimeDaemon( + client: SocketJsonRpcClient, +): Promise<void> { + 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<boolean> { + 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<SocketJsonRpcClient> { + 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<unknown> { + 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<unknown> { + 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<void> { + 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<typeof startJsonRpcServer> | 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<void>((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<unknown | null> { + 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<typeof ProjectRegistry>["list"] + >[number]; + const toMobileProjectSummary = ( + record: ProjectRecord, + overrides: Partial<SyncMobileProjectSummary> = {}, + ): 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<typeof ProjectScopeRegistry>; + 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<SyncProjectSwitchResultPayload> => { + 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<void> => { + 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<void> => { + await new Promise<void>((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<void>((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<string, unknown>; + 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)) { @@ -5410,8 +11043,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; } @@ -5421,7 +11056,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"); } @@ -5429,11 +11065,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 { @@ -5460,7 +11099,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; } @@ -5470,11 +11112,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)) ); } @@ -5490,7 +11133,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)"; @@ -5510,10 +11154,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 ?? ""), + ); }); } @@ -5525,9 +11173,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)); @@ -5544,12 +11200,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); } @@ -5559,7 +11226,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], @@ -5572,15 +11241,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; @@ -5591,22 +11266,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(" "), @@ -5636,35 +11335,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<string, JsonObject[]>(); 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 <domain.action> --input-json '{\"key\":\"value\"}'", - "For multi-parameter methods: --args-list-json '[\"first\",{\"second\":true}]'", + 'Use: ade actions run <domain.action> --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"); @@ -5701,7 +11428,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) => [ @@ -5718,46 +11447,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 [ @@ -5779,13 +11529,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 { @@ -5825,7 +11578,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"]); @@ -5847,25 +11601,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"]); @@ -5887,16 +11646,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"); } @@ -5905,7 +11668,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)", ); } @@ -5913,7 +11680,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); } @@ -5921,7 +11693,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)", ); } @@ -5941,7 +11717,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) => [ @@ -5958,7 +11740,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)", ); } @@ -5967,7 +11754,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)", ); } @@ -5976,21 +11768,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], @@ -5998,26 +11804,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)", ); } @@ -6048,17 +11872,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], ]), @@ -6066,25 +11911,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], @@ -6096,14 +11958,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], @@ -6136,7 +12007,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 = [ @@ -6147,7 +12020,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], @@ -6156,13 +12034,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)", ), ]; @@ -6171,7 +12058,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], @@ -6188,7 +12077,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 { @@ -6203,7 +12095,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], ]); @@ -6213,13 +12108,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], @@ -6230,10 +12132,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], @@ -6252,7 +12158,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"); @@ -6291,18 +12201,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], ]), @@ -6310,16 +12241,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 { @@ -6353,6 +12288,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 : {}; @@ -6377,11 +12333,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, @@ -6393,7 +12354,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)"), @@ -6401,22 +12366,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", [ @@ -6426,61 +12410,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": @@ -6488,7 +12490,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": @@ -6500,7 +12505,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": @@ -6567,22 +12578,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"; @@ -6595,20 +12619,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"; @@ -6635,12 +12703,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, @@ -6655,7 +12732,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.", }; } @@ -6667,7 +12746,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, }; @@ -6700,12 +12783,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; } @@ -6718,17 +12801,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), @@ -6783,9 +12878,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; } @@ -6811,52 +12906,81 @@ async function waitForRunGraph(args: { }; } -async function executePlan(plan: CliPlan & { kind: "execute" }, options: GlobalOptions): Promise<unknown> { +async function executePlan( + plan: CliPlan & { kind: "execute" }, + options: GlobalOptions, +): Promise<unknown> { 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, @@ -6878,40 +13002,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; @@ -6922,22 +13064,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 runAdeCode(plan.rest, parsed.options); + 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; @@ -6947,7 +13133,9 @@ async function runCli(argv: string[]): Promise<{ output: string; exitCode: numbe async function main(): Promise<void> { 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; @@ -6983,7 +13171,9 @@ async function main(): Promise<void> { 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; } } @@ -6998,6 +13188,7 @@ export { 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<BufferedEvent, "id">): 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<HeadlessGitHubStatus>; +export type HeadlessGitHubService = { + getStatus: (opts?: { + forceRefresh?: boolean; + }) => Promise<HeadlessGitHubStatus>; detectRepo: () => Promise<{ owner: string; name: string } | null>; getRepoOrThrow: () => Promise<{ owner: string; name: string }>; getTokenOrThrow: () => string; + parseGitHubRepoFromRemoteUrl: typeof parseGitHubRepoFromRemoteUrl; apiRequest: <T>(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<unknown>; - setIssueLabels: (owner: string, name: string, number: number, labels: string[]) => Promise<unknown>; - closeIssue: (owner: string, name: string, number: number, reason?: "completed" | "not_planned") => Promise<unknown>; - reopenIssue: (owner: string, name: string, number: number) => Promise<unknown>; - assignIssue: (owner: string, name: string, number: number, assignees: string[]) => Promise<unknown>; - setIssueTitle: (owner: string, name: string, number: number, title: string) => Promise<unknown>; + setToken: (token: string) => void; + clearToken: () => void; + listRepoLabels: (owner: string, name: string) => Promise<unknown[]>; + listRepoCollaborators: (owner: string, name: string) => Promise<unknown[]>; + 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<unknown>; + setIssueLabels: ( + owner: string, + name: string, + number: number, + labels: string[], + ) => Promise<unknown>; + closeIssue: ( + owner: string, + name: string, + number: number, + reason?: "completed" | "not_planned", + ) => Promise<unknown>; + reopenIssue: ( + owner: string, + name: string, + number: number, + ) => Promise<unknown>; + assignIssue: ( + owner: string, + name: string, + number: number, + assignees: string[], + ) => Promise<unknown>; + setIssueTitle: ( + owner: string, + name: string, + number: number, + title: string, + ) => Promise<unknown>; }; type HeadlessAgentChatSession = { @@ -163,19 +239,39 @@ type HeadlessLinearServices = { prService: ReturnType<typeof createPrService>; agentChatService: { listSessions: () => Promise<Array<Record<string, unknown>>>; - getSessionSummary: (sessionId: string) => Promise<Record<string, unknown> | null>; - getChatTranscript: (args: { sessionId: string; limit?: number; maxChars?: number }) => Promise<{ + getSessionSummary: ( + sessionId: string, + ) => Promise<Record<string, unknown> | 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<string[]>; - createSession: (args: { laneId: string; title?: string }) => Promise<HeadlessAgentChatSession>; - updateSession: (args: { sessionId: string; title?: string | null }) => Promise<HeadlessAgentChatSession>; + previewSessionToolNames: (args?: { + sessionId?: string | null; + }) => Promise<string[]>; + createSession: (args: { + laneId: string; + title?: string; + }) => Promise<HeadlessAgentChatSession>; + updateSession: (args: { + sessionId: string; + title?: string | null; + }) => Promise<HeadlessAgentChatSession>; sendMessage: (args: { sessionId: string; text: string }) => Promise<void>; interrupt: (args: { sessionId: string }) => Promise<void>; - resumeSession: (args: { sessionId: string }) => Promise<HeadlessAgentChatSession>; + resumeSession: (args: { + sessionId: string; + }) => Promise<HeadlessAgentChatSession>; dispose: (args: { sessionId: string }) => Promise<void>; ensureIdentitySession: (args: { identityKey: string; @@ -185,7 +281,9 @@ type HeadlessLinearServices = { reuseExisting?: boolean; permissionMode?: string; }) => Promise<HeadlessAgentChatSession>; - setComputerUseArtifactBrokerService: (svc: ComputerUseArtifactBrokerService) => void; + setComputerUseArtifactBrokerService: ( + svc: ComputerUseArtifactBrokerService, + ) => void; }; workerTaskSessionService: ReturnType<typeof createWorkerTaskSessionService>; workerHeartbeatService: ReturnType<typeof createWorkerHeartbeatService>; @@ -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<ReturnType<HeadlessGitHubService["getStatus"]>> | 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<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); + } +} + +export function createHeadlessGitHubService( + projectRoot: string, + logger: Logger, +): HeadlessGitHubService { + const credentialStore = new EncryptedFileCredentialStore(); + const tokenKey = "github.token.v1"; + let cachedStatus: Awaited< + ReturnType<HeadlessGitHubService["getStatus"]> + > | 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 <T>(args: { + path: string; + query?: Record<string, string | number | boolean | undefined | null>; + token?: string; + }): Promise<T[]> => { + const first = await apiRequest<T[]>({ 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<T[]>({ + 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<string, unknown> = { + 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<Record<string, unknown>>({ + 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<Record<string, unknown>>({ + 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<string, unknown>; + 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<string, HeadlessAgentChatSession>(); const identitySessionIds = new Map<string, string>(); const transcripts = new Map<string, HeadlessTranscriptEntry[]>(); @@ -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<typeof createWorkerHeartbeatService> { +function createHeadlessWorkerHeartbeatService(): ReturnType< + typeof createWorkerHeartbeatService +> { const runs: Array<{ id: string; agentId: string; @@ -633,7 +1440,13 @@ function createHeadlessWorkerHeartbeatService(): ReturnType<typeof createWorkerH result: null, })); }, - async triggerWakeup(args: { agentId: string; reason?: string; taskKey?: string | null; issueKey?: string | null; context?: Record<string, unknown> }) { + async triggerWakeup(args: { + agentId: string; + reason?: string; + taskKey?: string | null; + issueKey?: string | null; + context?: Record<string, unknown>; + }) { const runId = `wake-${randomUUID()}`; const now = new Date().toISOString(); runs.unshift({ @@ -644,7 +1457,8 @@ function createHeadlessWorkerHeartbeatService(): ReturnType<typeof createWorkerH taskKey: args.taskKey ?? null, issueKey: args.issueKey ?? null, context: args.context ?? {}, - errorMessage: "Headless ADE mode does not support worker-backed Linear targets yet.", + errorMessage: + "Headless ADE mode does not support worker-backed Linear targets yet.", startedAt: now, finishedAt: now, createdAt: now, @@ -664,20 +1478,30 @@ function createHeadlessWorkerHeartbeatService(): ReturnType<typeof createWorkerH } as unknown as ReturnType<typeof createWorkerHeartbeatService>; } -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<typeof createAutomationSecretService>, + 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<string, unknown> { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function safeParams(value: unknown): Record<string, unknown> { + 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<string, unknown>, +): 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<string, unknown>, +): 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<string, unknown>, +): 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<string, unknown>, +): 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<string, unknown>): ProjectId | null { + const value = params.projectId; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function omitProjectId( + params: Record<string, unknown>, +): Record<string, unknown> { + 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<ProjectId, Promise<HandlerEntry>>(); + const eventSubscriptions = new Map<string, RuntimeEventSubscription>(); + 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<ProjectScopeRegistry>).onDispose === + "function" + ? scopeRegistry.onDispose(disposeProjectRuntimeCaches) + : null; + let initializedParams: Record<string, unknown> | 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<HandlerEntry> => { + 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<string, unknown>) => { + 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<string, unknown>) => { + 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<string, unknown>) => { + 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<unknown | null> => { + 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..6d42cd2a7 --- /dev/null +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -0,0 +1,348 @@ +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, + resolveAdeServeCommand, + type AdeServiceCommand, + type ServiceManagerProcessResult, + type ServiceManagerSpawnSync, +} from "./common"; +import { installLaunchdService, isLaunchdPrintRunning, launchAgentPath, renderLaunchdPlist } from "./installLaunchd"; +import { installSystemdService, renderSystemdUnit, servicePath as systemdServicePath } from "./installSystemd"; +import { + buildWindowsCreateTaskArgs, + buildWindowsQueryTaskArgs, + buildWindowsRunTaskArgs, + installWindowsService, + isSchtasksOutputRunning, + parseSchtasksListStatus, + TASK_NAME, +} 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<B"], + env: { + NODE_PATH: "/opt/ADE & deps", + ADE_HOME: "/Users/example/'ade'", + }, + }, "/Users/example"); + + expect(plist).toContain(`<string>${ADE_RUNTIME_SERVICE_NAME}</string>`); + expect(plist).toContain("<key>ProgramArguments</key>"); + expect(plist).toContain("<string>/Applications/ADE & Tools/ade</string>"); + expect(plist).toContain("<string>A<B</string>"); + expect(plist).toContain("<key>EnvironmentVariables</key>"); + expect(plist).toContain("<key>NODE_PATH</key>"); + expect(plist).toContain("<string>/opt/ADE & deps</string>"); + expect(plist).toContain("<key>ADE_HOME</key>"); + expect(plist).toContain("<string>/Users/example/'ade'</string>"); + expect(plist).toContain(`<string>${path.join("/Users/example", ".ade", "runtime", "launchd.out.log")}</string>`); + expect(plist).toContain(`<string>${path.join("/Users/example", ".ade", "runtime", "launchd.err.log")}</string>`); + }); +}); + +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 percent environment values", () => { + const unit = renderSystemdUnit({ + command: "/opt/ADE CLI/node", + args: ["/opt/ade/cli.cjs", "serve"], + env: { + NODE_PATH: "/tmp/100%/node_modules", + }, + }); + + 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("WantedBy=default.target"); + }); +}); + +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"], + }; + + it("builds schtasks create, run, and query arguments without invoking schtasks", () => { + const renderedCommand = renderCommand(serviceCommand); + + expect(buildWindowsCreateTaskArgs(renderedCommand)).toEqual([ + "/Create", + "/SC", + "ONLOGON", + "/TN", + TASK_NAME, + "/TR", + renderedCommand, + "/F", + ]); + expect(buildWindowsRunTaskArgs()).toEqual(["/Run", "/TN", TASK_NAME]); + expect(buildWindowsQueryTaskArgs()).toEqual(["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]); + }); + + 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 }); + + 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(renderCommand(serviceCommand)) }, + { 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 }); + + 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(renderCommand(serviceCommand)), + 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 }); + + expect(result.ok).toBe(false); + expect(result.message).toBe("ERROR: create failed"); + expect(calls).toHaveLength(1); + }); +}); + +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..5d70bc303 --- /dev/null +++ b/apps/ade-cli/src/serviceManager/common.ts @@ -0,0 +1,117 @@ +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<string, string>; +}; + +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<string, string> | undefined { + const env: Record<string, string> = {}; + 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 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(" "); +} + +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, """) + .replace(/'/g, "'"); +} + +function plistArray(values: string[]): string { + return [ + "<array>", + ...values.map((value) => ` <string>${escapeXml(value)}</string>`), + "</array>", + ].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 + ? [ + " <key>EnvironmentVariables</key>", + " <dict>", + ...envEntries.flatMap(([key, value]) => [ + ` <key>${escapeXml(key)}</key>`, + ` <string>${escapeXml(value)}</string>`, + ]), + " </dict>", + ].join("\n") + : ""; + const sections = [ + `<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Label</key> + <string>${ADE_RUNTIME_SERVICE_NAME}</string> + <key>ProgramArguments</key> +${plistArray([command.command, ...command.args]).split("\n").map((line) => ` ${line}`).join("\n")} + <key>RunAtLoad</key> + <true/> + <key>KeepAlive</key> + <true/> + <key>StandardOutPath</key> + <string>${escapeXml(path.join(homeDir, ".ade", "runtime", "launchd.out.log"))}</string> + <key>StandardErrorPath</key> + <string>${escapeXml(path.join(homeDir, ".ade", "runtime", "launchd.err.log"))}</string>`, + envBlock, + `</dict> +</plist> +`, + ].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..d5dad6c15 --- /dev/null +++ b/apps/ade-cli/src/serviceManager/installSystemd.ts @@ -0,0 +1,117 @@ +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"); +} + +export function renderSystemdUnit(command: AdeServiceCommand): string { + const envLines = Object.entries(command.env ?? {}) + .map(([key, value]) => `Environment=${key}=${value.replace(/%/g, "%%")}`) + .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..0ed9ab9c4 --- /dev/null +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -0,0 +1,118 @@ +import { spawnSync } from "node:child_process"; +import { + ADE_RUNTIME_SERVICE_NAME, + type AdeServiceCommand, + renderCommand, + resolveAdeServeCommand, + serviceManagerResultText, + type ServiceManagerResult, + type ServiceManagerSpawnSync, + type ServiceManagerStatusResult, +} from "./common"; + +export const TASK_NAME = "ADE Runtime"; + +type WindowsServiceManagerDeps = { + command?: AdeServiceCommand; + spawnSync?: ServiceManagerSpawnSync; +}; + +export function buildWindowsCreateTaskArgs(command: string): string[] { + return [ + "/Create", + "/SC", + "ONLOGON", + "/TN", + TASK_NAME, + "/TR", + command, + "/F", + ]; +} + +export function buildWindowsRunTaskArgs(): string[] { + return ["/Run", "/TN", TASK_NAME]; +} + +export function buildWindowsQueryTaskArgs(): string[] { + return ["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]; +} + +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 = renderCommand(deps.command ?? resolveAdeServeCommand()); + const result = run("schtasks.exe", buildWindowsCreateTaskArgs(command), { 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(): ServiceManagerResult { + spawnSync("schtasks.exe", ["/Delete", "/TN", TASK_NAME, "/F"], { stdio: "ignore" }); + 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<string, string>(); + 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<string | null>; + set(key: string, value: string): Promise<void>; + delete(key: string): Promise<void>; +} + +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<string, unknown> | 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<string, unknown>; + } catch (error: unknown) { + if (isEnoent(error)) return {}; + throw error; + } +} + +function serializeStore(values: Record<string, string>, 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<string, unknown> | null, machineKey: Buffer): Record<string, string> { + 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<string, string> = {}; + for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) { + 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<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 { + 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<string, string> { + const key = readOrCreateMachineKey(this.machineKeyPath); + return deserializeStore(readJsonObject(this.credentialsPath), key); + } + + private writeAll(values: Record<string, string>): 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<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 { + 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<string, string> { + 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<string, string> = {}; + for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) { + if (typeof value === "string") out[key] = value; + } + return out; + } catch (error: unknown) { + if (isEnoent(error)) return {}; + throw error; + } + } + + private writeAll(values: Record<string, string>): 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<string | null>; + setPassword(service: string, account: string, password: string): Promise<void>; + deletePassword(service: string, account: string): Promise<boolean>; +}; + +async function loadOptionalKeytar(): Promise<KeytarModule | null> { + try { + const dynamicImport = new Function("specifier", "return import(specifier)") as (specifier: string) => Promise<unknown>; + const mod = await dynamicImport("keytar"); + const candidate = (mod && typeof mod === "object" && "default" in mod ? (mod as { default: unknown }).default : mod) as Partial<KeytarModule>; + 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<string | null> { + return this.keytar.getPassword(this.service, normalizeKey(key)); + } + + async set(key: string, value: string): Promise<void> { + 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<void> { + await this.keytar.deletePassword(this.service, normalizeKey(key)); + } +} + +export async function createDefaultCredentialStore(args: { + env?: NodeJS.ProcessEnv; + secretsDir?: string; + preferKeytar?: boolean; +} = {}): Promise<CredentialStore> { + 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.ts b/apps/ade-cli/src/services/projects/machineLayout.ts new file mode 100644 index 000000000..6b9b14460 --- /dev/null +++ b/apps/ade-cli/src/services/projects/machineLayout.ts @@ -0,0 +1,36 @@ +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"); +} + +export function resolveMachineAdeLayout(env: NodeJS.ProcessEnv = process.env): MachineAdeLayout { + const adeDir = resolveMachineAdeDir(env); + const secretsDir = path.join(adeDir, "secrets"); + const sockDir = path.join(adeDir, "sock"); + const socketPath = process.platform === "win32" + ? "\\\\.\\pipe\\ade-runtime" + : 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..6f044c2a3 --- /dev/null +++ b/apps/ade-cli/src/services/projects/projectRegistry.test.ts @@ -0,0 +1,86 @@ +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<string>(); + +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 projectRoot = path.join(homeDir, "Projects", "ADE"); + const registryDir = path.join(homeDir, ".ade-runtime"); + fs.mkdirSync(projectRoot, { recursive: true }); + 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, + ]); + }); +}); 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..9be2d69fb --- /dev/null +++ b/apps/ade-cli/src/services/projects/projectRegistry.ts @@ -0,0 +1,229 @@ +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"; + +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 path.resolve(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<string, unknown>; + const rootPath = + typeof record.rootPath === "string" ? normalizeRoot(record.rootPath) : ""; + if (!rootPath) return null; + if (isDisallowedProjectRoot(rootPath)) return null; + const projectId = + typeof record.projectId === "string" && record.projectId.trim() + ? record.projectId.trim() + : 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) + : []; + return { version: 1, projects }; + } 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/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<ProjectId, Promise<ProjectScope>>(); + private readonly disposeListeners = new Set<(projectId: ProjectId) => void>(); + private syncHostProjectId: ProjectId | null = null; + private readonly remoteCommandExecutor = { + execute: async (payload: SyncCommandPayload): Promise<unknown> => { + 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<ProjectScope> { + 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<void> { + 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<void> { + const projectIds = [...this.scopes.keys()]; + await Promise.all(projectIds.map((projectId) => this.dispose(projectId))); + } + + async ensureSyncHost(projectId?: ProjectId): Promise<ProjectScope | null> { + 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<unknown> { + 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<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>; 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<string, string>; + 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>): 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<typeof createSyncHostService>[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..98abf8f2a --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -0,0 +1,3255 @@ +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<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(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<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; + runtimeKind?: SyncRuntimeKind; + runtimeVersion?: string; + 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; + remoteCommandService?: SyncRemoteCommandService; + remoteCommandExecutor?: Pick<SyncRemoteCommandService, "execute">; +}; + +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<SyncPeerMetadata, "platform" | "deviceType"> | 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<SyncEnvelope["type"]>([ + "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<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 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<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 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: "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<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 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<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 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<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") { + 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<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 (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<SyncHostProjectScopeResolution, { ok: false }>, + ): 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<SyncChangesetBatchPayload>; + 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<SyncFileRequest> | 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<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", 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<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(); + 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<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>; 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<string, PairingRecord>; + +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<PairingSecretsFile>(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<typeof createSyncPairingStore>; 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<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.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<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>; 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<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>; 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<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; + 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<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); + 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<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>, + 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<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); + 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<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, + scope: handler.descriptor.scope, + policy: handler.descriptor.policy, + }); + return await handler.handler(commandArgs); + }, + }; +} + +export type SyncRemoteCommandService = ReturnType<typeof createSyncRemoteCommandService>; 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<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 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<SyncProjectCatalogPayload>; + prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise<SyncProjectSwitchResultPayload>; + completeProjectConnection?: ( + args: SyncProjectSwitchRequestPayload, + result: SyncProjectSwitchResultPayload, + ) => Promise<void>; + }; + remoteCommandExecutor?: Pick<SyncRemoteCommandService, "execute">; +}; + +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<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 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<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, + }; + 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<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 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<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, + 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<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 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<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 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<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("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<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 machine."); + } + pinStore.setPin(pin); + const snapshot = await service.getStatus(); + args.onStatusChanged?.(snapshot); + return snapshot; + }, + + async generatePin(): Promise<SyncRoleSnapshot> { + return await service.setPin(generatePairingPin()); + }, + + 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 machine."); + } + 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; + }, + + getRemoteCommandDescriptor(action: string) { + return remoteCommandService.getDescriptor(action); + }, + + async executeRemoteCommand(payload: Parameters<SyncRemoteCommandService["execute"]>[0]): Promise<unknown> { + return await remoteCommandService.execute(payload); + }, + + 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>; 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<typeof setTimeout>; +}; + +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<void> { + const startedAt = Date.now(); + let lastError: Error | null = null; + while (Date.now() - startedAt < timeoutMs) { + try { + await new Promise<void>((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<number, PendingRequest>(); + + 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<unknown> { + 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-code/src/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts similarity index 92% rename from apps/ade-code/src/__tests__/adeApi.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 5c261b294..c58672c49 100644 --- a/apps/ade-code/src/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { AgentChatEventEnvelope } from "../../../desktop/src/shared/types/chat"; +import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; import { latestTokenStats } from "../adeApi"; function envelope( diff --git a/apps/ade-code/src/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts similarity index 100% rename from apps/ade-code/src/__tests__/commands.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/commands.test.ts 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..454ff3f14 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -0,0 +1,154 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { connectToAde } from "../connection"; +import type { ProjectLaunchContext } from "../types"; + +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, +}; + +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(); + }); + + 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<string, unknown> }> = []; + 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<string, unknown> }; + 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<void>((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<void>((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" }); + }); +}); diff --git a/apps/ade-code/src/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts similarity index 100% rename from apps/ade-code/src/__tests__/format.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/format.test.ts diff --git a/apps/ade-code/src/__tests__/heartbeat.test.ts b/apps/ade-cli/src/tuiClient/__tests__/heartbeat.test.ts similarity index 100% rename from apps/ade-code/src/__tests__/heartbeat.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/heartbeat.test.ts diff --git a/apps/ade-code/src/__tests__/jsonRpcClient.test.ts b/apps/ade-cli/src/tuiClient/__tests__/jsonRpcClient.test.ts similarity index 100% rename from apps/ade-code/src/__tests__/jsonRpcClient.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/jsonRpcClient.test.ts diff --git a/apps/ade-code/src/__tests__/linearCommands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/linearCommands.test.ts similarity index 100% rename from apps/ade-code/src/__tests__/linearCommands.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/linearCommands.test.ts diff --git a/apps/ade-code/src/__tests__/pendingInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts similarity index 98% rename from apps/ade-code/src/__tests__/pendingInput.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts index d1764ba94..6f363fb19 100644 --- a/apps/ade-code/src/__tests__/pendingInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { AgentChatEventEnvelope, PendingInputRequest } from "../../../desktop/src/shared/types/chat"; +import type { AgentChatEventEnvelope, PendingInputRequest } from "../../../../desktop/src/shared/types/chat"; import { buildPendingInputAnswers, latestPendingApproval } from "../pendingInput"; const baseRequest: PendingInputRequest = { diff --git a/apps/ade-code/src/__tests__/project.test.ts b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts similarity index 95% rename from apps/ade-code/src/__tests__/project.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/project.test.ts index 93872bdf0..bdb138060 100644 --- a/apps/ade-code/src/__tests__/project.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { chooseInitialLane } from "../project"; -import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; function lane(overrides: Partial<LaneSummary>): LaneSummary { return { diff --git a/apps/ade-code/src/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts similarity index 94% rename from apps/ade-code/src/adeApi.ts rename to apps/ade-cli/src/tuiClient/adeApi.ts index 683515f94..ae46c8a80 100644 --- a/apps/ade-code/src/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -1,4 +1,4 @@ -import { getDefaultModelDescriptor, type ModelProviderGroup } from "../../desktop/src/shared/modelRegistry"; +import { getDefaultModelDescriptor, type ModelProviderGroup } from "../../../desktop/src/shared/modelRegistry"; import type { AgentChatEventEnvelope, AgentChatFileRef, @@ -7,8 +7,8 @@ import type { AgentChatSession, AgentChatSessionSummary, AgentChatSlashCommand, -} from "../../desktop/src/shared/types/chat"; -import type { LaneSummary } from "../../desktop/src/shared/types/lanes"; +} from "../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { AdeCodeConnection, ChatHistorySnapshot, CreatedChat, NavigateRequest, NavigateResult } from "./types"; export async function listLanes(connection: AdeCodeConnection): Promise<LaneSummary[]> { @@ -183,11 +183,12 @@ export function latestTokenStats(events: AgentChatEventEnvelope[]): TokenStats { if (event.type === "tokens") { inputTokens = typeof event.inputTokens === "number" ? event.inputTokens : inputTokens; outputTokens = typeof event.outputTokens === "number" ? event.outputTokens : outputTokens; - const used = typeof event.totalTokens === "number" - ? event.totalTokens - : inputTokens != null || outputTokens != null - ? (inputTokens ?? 0) + (outputTokens ?? 0) - : null; + let used: number | null = null; + if (typeof event.totalTokens === "number") { + used = event.totalTokens; + } else if (inputTokens != null || outputTokens != null) { + used = (inputTokens ?? 0) + (outputTokens ?? 0); + } const limit = typeof event.contextWindow === "number" ? event.contextWindow : null; if (used != null && limit != null && limit > 0) { percent = Math.max(0, Math.min(100, Math.round((used / limit) * 100))); diff --git a/apps/ade-code/src/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx similarity index 98% rename from apps/ade-code/src/app.tsx rename to apps/ade-cli/src/tuiClient/app.tsx index b23bd847a..ba0e88b54 100644 --- a/apps/ade-code/src/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -2,15 +2,15 @@ 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 } from "../../desktop/src/shared/modelRegistry"; +import { getDefaultModelDescriptor } from "../../../desktop/src/shared/modelRegistry"; import type { AgentChatEventEnvelope, AgentChatFileRef, AgentChatModelInfo, AgentChatSessionSummary, AgentChatSlashCommand, -} from "../../desktop/src/shared/types/chat"; -import type { LaneSummary } from "../../desktop/src/shared/types/lanes"; +} from "../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import { approveToolUse, createChatSession, @@ -412,8 +412,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const nextLaneId = nextLane?.id ?? null; const nextSessions = await listChatSessions(conn); const laneSessions = nextSessions.filter((session) => session.laneId === nextLaneId); - const nextSession = nextSessions.find((session) => session.sessionId === activeSessionIdRef.current) - ?? newestSession(laneSessions); + const activeSessionId = activeSessionIdRef.current; + const nextSession = activeSessionId + ? nextSessions.find((session) => session.sessionId === activeSessionId) ?? null + : null; const nextSessionId = nextSession?.sessionId ?? null; let nextEvents: AgentChatEventEnvelope[] = []; if (nextSessionId) { @@ -1457,11 +1459,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const centerWidth = Math.max(40, columns - (drawerOpen ? 30 : 0) - (rightOpen ? 40 : 0)); const laneName = activeLane?.name ?? "main"; + const chromeRows = 5 + + (desktopDriving ? 1 : 0) + + (streaming ? 1 : 0) + + (contextPercent != null ? 1 : 0) + + (pendingApproval && !pendingApproval.highStakes ? 3 : 0) + + (error ? 1 : 0); + const chatMaxRows = Math.max(4, rows - chromeRows); if (error && !connection) { return ( <Box flexDirection="column"> - <Text color="red">ade-code failed to start</Text> + <Text color="red">ade code failed to start</Text> <Text>{error}</Text> </Box> ); @@ -1512,6 +1521,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } projectName={projectName} laneName={laneName} expandedLineIds={expandedLineIds} + maxRows={chatMaxRows} /> <ApprovalPrompt approval={pendingApproval} /> </> diff --git a/apps/ade-code/src/cli.tsx b/apps/ade-cli/src/tuiClient/cli.tsx similarity index 81% rename from apps/ade-code/src/cli.tsx rename to apps/ade-cli/src/tuiClient/cli.tsx index 1f096e00f..1444f93c5 100644 --- a/apps/ade-code/src/cli.tsx +++ b/apps/ade-cli/src/tuiClient/cli.tsx @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { pathToFileURL } from "node:url"; import React from "react"; import { render } from "ink"; @@ -36,15 +37,15 @@ function parseArgs(argv: string[]): CliOptions { } function printHelp(): void { - process.stdout.write(`ade-code + 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 + ade code [--project-root <path>] [--workspace-root <path>] [--socket <path>] + ade code --embedded + ade code --require-socket + ade code --print-state Keys: ctrl-b toggle lanes and chats @@ -107,15 +108,15 @@ async function printState(options: CliOptions): Promise<void> { } } -async function main(): Promise<void> { - const options = parseArgs(process.argv.slice(2)); +export async function runAdeCodeCli(argv: string[] = process.argv.slice(2)): Promise<number> { + const options = parseArgs(argv); if (options.help) { printHelp(); - return; + return 0; } if (options.printState) { await printState(options); - process.exit(0); + return 0; } suppressTerminalWarnings(); const { AdeCodeApp } = await import("./app"); @@ -124,7 +125,7 @@ async function main(): Promise<void> { projectRoot: options.projectRoot, workspaceRoot: options.workspaceRoot, }); - render( + const instance = render( <AdeCodeApp project={project} forceEmbedded={options.forceEmbedded} @@ -132,10 +133,20 @@ async function main(): Promise<void> { socketPath={options.socketPath} />, ); + await instance.waitUntilExit(); + return 0; } -void main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`ade-code: ${message}\n`); - process.exit(1); -}); +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-code/src/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts similarity index 96% rename from apps/ade-code/src/commands.ts rename to apps/ade-cli/src/tuiClient/commands.ts index 3ca4dca38..f5dda49ba 100644 --- a/apps/ade-code/src/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -1,4 +1,4 @@ -import type { AgentChatSlashCommand } from "../../desktop/src/shared/types/chat"; +import type { AgentChatSlashCommand } from "../../../desktop/src/shared/types/chat"; export type CommandPlacement = "inline" | "right" | "overlay" | "chat"; @@ -15,7 +15,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/clear", description: "Clear the local terminal transcript view", placement: "inline" }, { name: "/end", description: "End the active chat runtime", placement: "inline" }, { name: "/open", description: "Open this ADE context in desktop", placement: "inline" }, - { name: "/quit", description: "Exit ade-code", 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]" }, @@ -132,7 +132,5 @@ export function paletteCommands( } export function commandPlacement(command: ParsedCommand): CommandPlacement { - if (command.spec) return command.spec.placement; - if (command.userCommand) return "chat"; - return "chat"; + return command.spec?.placement ?? "chat"; } diff --git a/apps/ade-code/src/components/ApprovalPrompt.tsx b/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx similarity index 66% rename from apps/ade-code/src/components/ApprovalPrompt.tsx rename to apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx index 73a6a00b3..8be16b5ce 100644 --- a/apps/ade-code/src/components/ApprovalPrompt.tsx +++ b/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx @@ -11,6 +11,17 @@ export function ApprovalPrompt({ }) { 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" @@ -20,13 +31,7 @@ export function ApprovalPrompt({ flexDirection="column" width={modal ? 60 : undefined} > - <Text color={approval.highStakes ? "red" : "yellow"}> - {approval.mode === "question" - ? "Input requested" - : approval.highStakes - ? "High-stakes approval required" - : "Approval required"} - </Text> + <Text color={approval.highStakes ? "red" : "yellow"}>{title}</Text> <Text>{question?.question ?? approval.description}</Text> {question?.options?.length ? ( <Box flexDirection="column"> @@ -37,13 +42,7 @@ export function ApprovalPrompt({ ))} </Box> ) : null} - {approval.mode === "question" ? ( - <Text dimColor>Type an answer, option number/value, deny, or cancel.</Text> - ) : approval.highStakes ? ( - <Text dimColor>Type approve or deny, then press enter.</Text> - ) : ( - <Text dimColor>Press a to approve, d to deny.</Text> - )} + <Text dimColor>{footer}</Text> </Box> ); if (!modal) return card; diff --git a/apps/ade-code/src/components/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx similarity index 55% rename from apps/ade-code/src/components/ChatView.tsx rename to apps/ade-cli/src/tuiClient/components/ChatView.tsx index 96006bd81..5e16bcef6 100644 --- a/apps/ade-code/src/components/ChatView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -1,8 +1,8 @@ import React from "react"; import { Box, Text } from "ink"; -import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; +import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import type { LocalNotice } from "../types"; -import { renderChatLines } from "../format"; +import { renderChatLines, type RenderedChatLine } from "../format"; const COLORS = { user: "#A78BFA", @@ -22,7 +22,7 @@ export function BootHero({ laneName: string; }) { return ( - <Box flexDirection="column" alignItems="center" paddingY={1}> + <Box flexDirection="column" alignItems="center" paddingY={1}> <Text color="#A78BFA">██▄ ██▄ ██▀</Text> <Text color="#A78BFA">█ █ █ █ █▀ </Text> <Text color="#A78BFA">██▀ ██▀ ██▄</Text> @@ -36,6 +36,42 @@ export function BootHero({ ); } +function clipBodyToRows(body: string, rows: number): string { + if (rows <= 0) return ""; + const lines = body.split(/\r?\n/); + if (lines.length <= rows) return body; + return lines.slice(-rows).join("\n"); +} + +function rowCount(line: RenderedChatLine): number { + return (line.header ? 1 : 0) + Math.max(1, line.body.split(/\r?\n/).length); +} + +function visibleRows(lines: RenderedChatLine[], maxRows: number): RenderedChatLine[] { + if (maxRows <= 0) return []; + const visible: RenderedChatLine[] = []; + let remaining = maxRows; + for (let index = lines.length - 1; index >= 0 && remaining > 0; index -= 1) { + const line = lines[index]!; + const needed = rowCount(line); + if (needed <= remaining) { + visible.unshift(line); + remaining -= needed; + continue; + } + const headerRows = line.header ? 1 : 0; + const bodyRows = Math.max(0, remaining - headerRows); + if (bodyRows > 0) { + visible.unshift({ + ...line, + body: clipBodyToRows(line.body, bodyRows), + }); + } + break; + } + return visible; +} + export function ChatView({ events, notices, @@ -43,6 +79,8 @@ export function ChatView({ projectName, laneName, expandedLineIds, + maxLines = 64, + maxRows = 24, }: { events: AgentChatEventEnvelope[]; notices: LocalNotice[]; @@ -50,14 +88,17 @@ export function ChatView({ projectName: string; laneName: string; expandedLineIds?: Set<string>; + maxLines?: number; + maxRows?: number; }) { - const lines = renderChatLines({ events, notices, activeSession, expandedLineIds, maxLines: 64 }); + const lines = renderChatLines({ events, notices, activeSession, expandedLineIds, maxLines }); if (!lines.length) { return <BootHero projectName={projectName} laneName={laneName} />; } + const clippedLines = visibleRows(lines, maxRows); return ( <Box flexDirection="column" paddingX={1}> - {lines.map((line) => ( + {clippedLines.map((line) => ( <Box key={line.id} flexDirection="column" marginBottom={line.header ? 1 : 0}> {line.header ? <Text color={COLORS[line.tone]}>{line.header}</Text> : null} <Text color={COLORS[line.tone]}>{line.body}</Text> diff --git a/apps/ade-code/src/components/Drawer.tsx b/apps/ade-cli/src/tuiClient/components/Drawer.tsx similarity index 65% rename from apps/ade-code/src/components/Drawer.tsx rename to apps/ade-cli/src/tuiClient/components/Drawer.tsx index 2d5b7d454..7982ae517 100644 --- a/apps/ade-code/src/components/Drawer.tsx +++ b/apps/ade-cli/src/tuiClient/components/Drawer.tsx @@ -1,12 +1,24 @@ import React from "react"; import { Box, Text } from "ink"; -import type { AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; -import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +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"; +function laneColor(laneId: string, activeLaneId: string | null, browsingLaneId: string | null): string | undefined { + if (laneId === activeLaneId) return AMBER; + if (laneId === browsingLaneId) return "white"; + return undefined; +} + +function laneMarker(laneId: string, activeLaneId: string | null, browsingLaneId: string | null): string { + if (laneId === activeLaneId) return "●"; + if (laneId === browsingLaneId) return "◐"; + return "○"; +} + export function Drawer({ lanes, sessions, @@ -30,8 +42,8 @@ export function Drawer({ <Box width={28} flexDirection="column" borderStyle="single" borderColor="gray" paddingX={1}> <Text bold>LANES</Text> {lanes.slice(0, 10).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 key={lane.id} color={laneColor(lane.id, activeLaneId, browsingLaneId)}> + {index === selectedLaneIndex ? "›" : " "} {laneMarker(lane.id, activeLaneId, browsingLaneId)} {formatLaneLabel(lane).slice(0, 20)} </Text> ))} <Text dimColor>+ new lane</Text> diff --git a/apps/ade-code/src/components/Header.tsx b/apps/ade-cli/src/tuiClient/components/Header.tsx similarity index 82% rename from apps/ade-code/src/components/Header.tsx rename to apps/ade-cli/src/tuiClient/components/Header.tsx index fab0834b6..1a13eae6f 100644 --- a/apps/ade-code/src/components/Header.tsx +++ b/apps/ade-cli/src/tuiClient/components/Header.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Box, Text } from "ink"; -import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; import type { AdeCodeModelState, RuntimeMode } from "../types"; import { formatLaneLabel } from "../format"; @@ -20,7 +20,9 @@ export function Header({ mode: RuntimeMode | "connecting"; tuiCount: number; }) { - const modeColor = mode === "attached" ? "green" : mode === "embedded" ? "yellow" : "gray"; + let modeColor: string = "gray"; + if (mode === "attached") modeColor = "green"; + else if (mode === "embedded") modeColor = "yellow"; return ( <Box borderStyle="single" borderColor="gray" paddingX={1}> <Text color={PURPLE}>▌ ADE</Text> diff --git a/apps/ade-code/src/components/MentionPalette.tsx b/apps/ade-cli/src/tuiClient/components/MentionPalette.tsx similarity index 100% rename from apps/ade-code/src/components/MentionPalette.tsx rename to apps/ade-cli/src/tuiClient/components/MentionPalette.tsx diff --git a/apps/ade-code/src/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx similarity index 100% rename from apps/ade-code/src/components/RightPane.tsx rename to apps/ade-cli/src/tuiClient/components/RightPane.tsx diff --git a/apps/ade-code/src/components/SlashPalette.tsx b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx similarity index 91% rename from apps/ade-code/src/components/SlashPalette.tsx rename to apps/ade-cli/src/tuiClient/components/SlashPalette.tsx index 0bcd376f4..9e2dbad53 100644 --- a/apps/ade-code/src/components/SlashPalette.tsx +++ b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Box, Text } from "ink"; -import type { AgentChatSlashCommand } from "../../../desktop/src/shared/types/chat"; +import type { AgentChatSlashCommand } from "../../../../desktop/src/shared/types/chat"; import { paletteCommands } from "../commands"; export function SlashPalette({ diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts new file mode 100644 index 000000000..4a6ec3dc2 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -0,0 +1,533 @@ +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(); + if (!cliEntrypoint) return false; + const child = spawn( + process.execPath, + [cliEntrypoint, "serve", "--socket", socketPath], + { + 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-code/src/format.ts b/apps/ade-cli/src/tuiClient/format.ts similarity index 94% rename from apps/ade-code/src/format.ts rename to apps/ade-cli/src/tuiClient/format.ts index fb3896dbb..27bbe58a3 100644 --- a/apps/ade-code/src/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -1,6 +1,6 @@ import path from "node:path"; -import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../desktop/src/shared/types/chat"; -import type { LaneSummary } from "../../desktop/src/shared/types/lanes"; +import type { AgentChatEventEnvelope, 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 { @@ -88,7 +88,7 @@ export function renderChatLines(args: { lines.push({ id: notice.id, tone: notice.tone === "error" ? "error" : "notice", - header: `- ade-code · ${timeLabel(notice.timestamp)} ${"-".repeat(20)}`, + header: `- ade code · ${timeLabel(notice.timestamp)} ${"-".repeat(20)}`, body: notice.text, }); } @@ -202,7 +202,9 @@ export function formatLaneLabel(lane: LaneSummary | null): string { export function formatSessionLabel(session: AgentChatSessionSummary): string { const label = (session.title ?? session.goal ?? session.summary ?? session.sessionId).trim(); - const state = session.awaitingInput ? " ?" : session.status === "active" ? " ●" : ""; + let state = ""; + if (session.awaitingInput) state = " ?"; + else if (session.status === "active") state = " ●"; return `${label}${state}`; } @@ -218,7 +220,9 @@ export function renderObject(value: unknown, maxLines = 24): string { 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 : []; + let files: unknown[] = []; + if (Array.isArray(record.files)) files = record.files; + else if (Array.isArray(record.changes)) files = record.changes; return files .map((entry) => { const item = entry && typeof entry === "object" ? entry as Record<string, unknown> : {}; diff --git a/apps/ade-code/src/heartbeat.ts b/apps/ade-cli/src/tuiClient/heartbeat.ts similarity index 88% rename from apps/ade-code/src/heartbeat.ts rename to apps/ade-cli/src/tuiClient/heartbeat.ts index 1832ecbc6..23abf064d 100644 --- a/apps/ade-code/src/heartbeat.ts +++ b/apps/ade-cli/src/tuiClient/heartbeat.ts @@ -29,7 +29,13 @@ function safeUnlink(filePath: string): void { function cleanupAndCount(dir: string, now = Date.now()): number { let count = 0; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + 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 { @@ -98,11 +104,15 @@ export function startTuiHeartbeat(projectRoot: string): TuiHeartbeat { const filePath = path.join(dir, `${process.pid}.json`); const startedAt = new Date().toISOString(); const write = () => { - fs.writeFileSync(filePath, JSON.stringify({ - pid: process.pid, - startedAt, - updatedAt: Date.now(), - }), "utf8"); + 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(() => { diff --git a/apps/ade-code/src/jsonRpcClient.ts b/apps/ade-cli/src/tuiClient/jsonRpcClient.ts similarity index 90% rename from apps/ade-code/src/jsonRpcClient.ts rename to apps/ade-cli/src/tuiClient/jsonRpcClient.ts index 461d86aad..25c2bf5f0 100644 --- a/apps/ade-code/src/jsonRpcClient.ts +++ b/apps/ade-cli/src/tuiClient/jsonRpcClient.ts @@ -36,15 +36,16 @@ export class JsonRpcClient { static connect(socketPath: string): Promise<JsonRpcClient> { return new Promise((resolve, reject) => { - const socket = socketPath.startsWith("tcp://") - ? (() => { - const parsed = new URL(socketPath); - return net.createConnection({ - host: parsed.hostname || "127.0.0.1", - port: Number.parseInt(parsed.port, 10), - }); - })() - : net.createConnection(socketPath); + 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); @@ -138,11 +139,9 @@ export class JsonRpcClient { const crlfBoundary = this.buffer.indexOf("\r\n\r\n"); const lfBoundary = this.buffer.indexOf("\n\n"); - const boundary = crlfBoundary >= 0 - ? { index: crlfBoundary, length: 4 } - : lfBoundary >= 0 - ? { index: lfBoundary, length: 2 } - : null; + 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); diff --git a/apps/ade-code/src/linearCommands.ts b/apps/ade-cli/src/tuiClient/linearCommands.ts similarity index 100% rename from apps/ade-code/src/linearCommands.ts rename to apps/ade-cli/src/tuiClient/linearCommands.ts diff --git a/apps/ade-code/src/pendingInput.ts b/apps/ade-cli/src/tuiClient/pendingInput.ts similarity index 98% rename from apps/ade-code/src/pendingInput.ts rename to apps/ade-cli/src/tuiClient/pendingInput.ts index ad1740304..a569665b2 100644 --- a/apps/ade-code/src/pendingInput.ts +++ b/apps/ade-cli/src/tuiClient/pendingInput.ts @@ -3,7 +3,7 @@ import type { PendingInputOption, PendingInputQuestion, PendingInputRequest, -} from "../../desktop/src/shared/types/chat"; +} from "../../../desktop/src/shared/types/chat"; import { renderObject } from "./format"; import type { PendingApproval } from "./types"; diff --git a/apps/ade-code/src/project.ts b/apps/ade-cli/src/tuiClient/project.ts similarity index 97% rename from apps/ade-code/src/project.ts rename to apps/ade-cli/src/tuiClient/project.ts index 68d28d20d..f83695103 100644 --- a/apps/ade-code/src/project.ts +++ b/apps/ade-cli/src/tuiClient/project.ts @@ -1,7 +1,7 @@ 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 { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { ProjectLaunchContext } from "./types"; function normalizeRoot(value: string): string { 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-code/src/types.ts b/apps/ade-cli/src/tuiClient/types.ts similarity index 95% rename from apps/ade-code/src/types.ts rename to apps/ade-cli/src/tuiClient/types.ts index 03dc95cc1..e51a7c59b 100644 --- a/apps/ade-code/src/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -1,4 +1,4 @@ -import type { AppNavigationRequest, AppNavigationResult } from "../../desktop/src/shared/types/core"; +import type { AppNavigationRequest, AppNavigationResult } from "../../../desktop/src/shared/types/core"; import type { AgentChatEventEnvelope, AgentChatModelInfo, @@ -6,8 +6,8 @@ import type { AgentChatSessionSummary, AgentChatSlashCommand, PendingInputRequest, -} from "../../desktop/src/shared/types/chat"; -import type { LaneSummary } from "../../desktop/src/shared/types/lanes"; +} from "../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; export type RuntimeMode = "attached" | "embedded"; 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..74c24fc3d 100644 --- a/apps/ade-cli/tsup.config.ts +++ b/apps/ade-cli/tsup.config.ts @@ -1,23 +1,71 @@ 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 = ["node-pty", "sql.js", "node:sqlite", "@cursor/sdk", "sqlite3"]; +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-code/package-lock.json b/apps/ade-code/package-lock.json deleted file mode 100644 index 20cdd297a..000000000 --- a/apps/ade-code/package-lock.json +++ /dev/null @@ -1,4907 +0,0 @@ -{ - "name": "ade-code", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "ade-code", - "version": "0.0.0", - "dependencies": { - "@cursor/sdk": "^1.0.9", - "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" - }, - "bin": { - "ade-code": "dist/cli.cjs" - }, - "devDependencies": { - "@types/node": "^22.19.18", - "@types/react": "^18.3.18", - "ink-testing-library": "^4.0.0", - "tsup": "^8.3.5", - "tsx": "^4.20.6", - "typescript": "^5.7.3", - "vitest": "^0.34.6" - }, - "engines": { - "node": ">=22.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/@bufbuild/protobuf": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", - "integrity": "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==", - "license": "(Apache-2.0 AND BSD-3-Clause)" - }, - "node_modules/@connectrpc/connect": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-1.7.0.tgz", - "integrity": "sha512-iNKdJRi69YP3mq6AePRT8F/HrxWCewrhxnLMNm0vpqXAR8biwzRtO6Hjx80C6UvtKJ5sFmffQT7I4Baecz389w==", - "license": "Apache-2.0", - "peerDependencies": { - "@bufbuild/protobuf": "^1.10.0" - } - }, - "node_modules/@connectrpc/connect-node": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-1.7.0.tgz", - "integrity": "sha512-6vaPIkG/NyhxlYgytLoR9KYbPhczEboFB2OYWkA9qvUz1K7efXfeGrlRxoLtpa+r8VxyIOw73w5ktNe743nD+A==", - "license": "Apache-2.0", - "dependencies": { - "undici": "^5.28.4" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@bufbuild/protobuf": "^1.10.0", - "@connectrpc/connect": "1.7.0" - } - }, - "node_modules/@cursor/sdk": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@cursor/sdk/-/sdk-1.0.12.tgz", - "integrity": "sha512-jGx0wFY1N9uIdIKr303CfM6m/dLXmRCUnU/0yNP/oiOpkBXqgqaThGbgYbcOeVrYonMZc/DZJ9EydXOEPJLcbg==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "@bufbuild/protobuf": "1.10.0", - "@connectrpc/connect": "^1.6.1", - "@connectrpc/connect-node": "^1.6.1", - "@statsig/js-client": "3.31.0", - "sqlite3": "^5.1.7", - "zod": "^3.25.0" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@cursor/sdk-darwin-arm64": "1.0.12", - "@cursor/sdk-darwin-x64": "1.0.12", - "@cursor/sdk-linux-arm64": "1.0.12", - "@cursor/sdk-linux-x64": "1.0.12", - "@cursor/sdk-win32-x64": "1.0.12" - } - }, - "node_modules/@cursor/sdk-darwin-arm64": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-arm64/-/sdk-darwin-arm64-1.0.12.tgz", - "integrity": "sha512-AOFx+aX+4SntAeC66YncHACXk5duxp+HzDrxxF4Tl93N6nLjHaHEKSAXbt87ivL34MCHop4v/3c70QzBhamB2g==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@cursor/sdk-darwin-x64": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-x64/-/sdk-darwin-x64-1.0.12.tgz", - "integrity": "sha512-/ZDAYFUrnPd8hAGRky9ZGcROqZSZ2b5W+aEjTdINzLhJ8x5ZNXtjaz0ZYSHabOn2BeErjXgTcq+4bX2/To4C1A==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@cursor/sdk-linux-arm64": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-arm64/-/sdk-linux-arm64-1.0.12.tgz", - "integrity": "sha512-kAxNqiB3dPtlW9fVjjIZEdbIGEGLA9moOM3zYwsXh8J1Qw942nJYMGDGR4o8x0zglwZ24a1JpovvZamrCaC3Yw==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@cursor/sdk-linux-x64": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-x64/-/sdk-linux-x64-1.0.12.tgz", - "integrity": "sha512-RmBiBCPKMZC5McDerGk2Rk4P47xz2A+uzRoRgH6sMoOjklc33ry11iAZC0D5F5xH85chgY878086A/Q8+XrAuA==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@cursor/sdk-win32-x64": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@cursor/sdk-win32-x64/-/sdk-win32-x64-1.0.12.tgz", - "integrity": "sha512-uH4shdHrKOdtNLapy1uuScJ9lL2Pc8zc9I9ZKC6b6bx+0UX6xLAqjPP7dqVPfO6D9u61yLq1Hs86XOLs5ZVkPA==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "license": "MIT", - "optional": true - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@npmcli/fs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", - "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "@gar/promisify": "^1.0.1", - "semver": "^7.3.5" - } - }, - "node_modules/@npmcli/move-file": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", - "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "license": "MIT", - "optional": true, - "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", - "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", - "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", - "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", - "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", - "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", - "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", - "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", - "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", - "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", - "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", - "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", - "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", - "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", - "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", - "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", - "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", - "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", - "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", - "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", - "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", - "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", - "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", - "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", - "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", - "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@statsig/client-core": { - "version": "3.31.0", - "resolved": "https://registry.npmjs.org/@statsig/client-core/-/client-core-3.31.0.tgz", - "integrity": "sha512-SuxQD6TmVszPG7FoMKwTk/uyBuVFk7XnxI3T/E0uyb7PL7GNjONtfsoh+NqBBVUJVse0CUeSFfgJPoZy1ZOslQ==", - "license": "ISC" - }, - "node_modules/@statsig/js-client": { - "version": "3.31.0", - "resolved": "https://registry.npmjs.org/@statsig/js-client/-/js-client-3.31.0.tgz", - "integrity": "sha512-LFa5E0LjT6sTfZv3sNGoyRLSZ1078+agdgOA+Vm1ecjG+KbSOfBLTW7hMwimrJ29slRwbYDzbtKaPJo/R37N2g==", - "license": "ISC", - "dependencies": { - "@statsig/client-core": "3.31.0" - } - }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@types/chai": { - "version": "4.3.20", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", - "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/chai-subset": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.6.tgz", - "integrity": "sha512-m8lERkkQj+uek18hXOZuec3W/fCRTrU4hrnXjH3qhHy96ytuPaPiWGgu7sJb7tZxZonO75vYAjCvpe/e4VUwRw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/chai": "<5.2.0" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.18", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", - "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "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/@vitest/expect": { - "version": "0.34.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz", - "integrity": "sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "0.34.6", - "@vitest/utils": "0.34.6", - "chai": "^4.3.10" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "0.34.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.34.6.tgz", - "integrity": "sha512-1CUQgtJSLF47NnhN+F9X2ycxUP0kLHQ/JWvNHbeBfwW8CzEGgeskzNnHDyv1ieKTltuR6sdIHV+nmR6kPxQqzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "0.34.6", - "p-limit": "^4.0.0", - "pathe": "^1.1.1" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/snapshot": { - "version": "0.34.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.34.6.tgz", - "integrity": "sha512-B3OZqYn6k4VaN011D+ve+AA4whM4QkcwcrwaKwAbyyvS/NB1hCWjFIBQxAQQSQir9/RtyAAGuq+4RJmbn2dH4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.1", - "pathe": "^1.1.1", - "pretty-format": "^29.5.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/spy": { - "version": "0.34.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.34.6.tgz", - "integrity": "sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^2.1.1" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "0.34.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.34.6.tgz", - "integrity": "sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "diff-sequences": "^29.4.3", - "loupe": "^2.3.6", - "pretty-format": "^29.5.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC", - "optional": true - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "license": "MIT", - "optional": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/aggregate-error/node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "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", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "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/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "license": "ISC", - "optional": true - }, - "node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true, - "license": "MIT", - "engines": { - "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", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT", - "optional": true - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "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==", - "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" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/bundle-require": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", - "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "load-tsconfig": "^0.2.3" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "peerDependencies": { - "esbuild": ">=0.18" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cacache": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", - "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "@npmcli/fs": "^1.0.0", - "@npmcli/move-file": "^1.0.1", - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "glob": "^7.1.4", - "infer-owner": "^1.0.4", - "lru-cache": "^6.0.0", - "minipass": "^3.1.1", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.2", - "mkdirp": "^1.0.3", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.0.2", - "unique-filename": "^1.1.1" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/chai": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" - }, - "engines": { - "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", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.2" - }, - "engines": { - "node": "*" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "license": "MIT", - "optional": true, - "engines": { - "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/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/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", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "license": "ISC", - "optional": true, - "bin": { - "color-support": "bin.js" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT", - "optional": true - }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "license": "ISC", - "optional": true - }, - "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/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, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT", - "optional": true - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "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/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "license": "MIT", - "optional": true, - "engines": { - "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", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "license": "MIT", - "optional": true - }, - "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.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "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/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==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "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==", - "license": "MIT" - }, - "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", - "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.17", - "mlly": "^1.7.4", - "rollup": "^4.34.8" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC", - "optional": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/gauge/node_modules/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==", - "license": "MIT", - "optional": true - }, - "node_modules/gauge/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", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/gauge/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "optional": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "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", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "optional": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/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==", - "license": "ISC", - "optional": true - }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "license": "ISC", - "optional": true - }, - "node_modules/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==", - "license": "BSD-2-Clause", - "optional": true - }, - "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.8.19" - } - }, - "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/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "license": "ISC", - "optional": true - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "optional": true, - "dependencies": { - "once": "^1.3.0", - "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/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/ip-address": { - "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/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/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", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "license": "MIT", - "optional": true - }, - "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 - }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "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/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/load-tsconfig": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", - "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/local-pkg": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", - "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "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", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/make-fetch-happen": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", - "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "license": "ISC", - "optional": true, - "dependencies": { - "agentkeepalive": "^4.1.3", - "cacache": "^15.2.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^6.0.0", - "minipass": "^3.1.3", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^1.3.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.2", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.0.0", - "ssri": "^8.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "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", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "optional": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-fetch": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", - "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", - "license": "MIT", - "optional": true, - "dependencies": { - "minipass": "^3.1.0", - "minipass-sized": "^1.0.3", - "minizlib": "^2.0.0" - }, - "engines": { - "node": ">=8" - }, - "optionalDependencies": { - "encoding": "^0.1.12" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", - "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", - "license": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" - } - }, - "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, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT" - }, - "node_modules/node-cron": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", - "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", - "license": "ISC", - "dependencies": { - "uuid": "8.3.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/node-gyp": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", - "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", - "license": "MIT", - "optional": true, - "dependencies": { - "env-paths": "^2.2.0", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^9.1.0", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": ">= 10.12.0" - } - }, - "node_modules/node-pty": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", - "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^7.1.0" - } - }, - "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/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, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "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", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "license": "ISC", - "optional": true - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "license": "MIT", - "optional": true, - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "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, - "license": "MIT" - }, - "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", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/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, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "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": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "optional": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", - "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.3", - "@rollup/rollup-android-arm64": "4.60.3", - "@rollup/rollup-darwin-arm64": "4.60.3", - "@rollup/rollup-darwin-x64": "4.60.3", - "@rollup/rollup-freebsd-arm64": "4.60.3", - "@rollup/rollup-freebsd-x64": "4.60.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", - "@rollup/rollup-linux-arm-musleabihf": "4.60.3", - "@rollup/rollup-linux-arm64-gnu": "4.60.3", - "@rollup/rollup-linux-arm64-musl": "4.60.3", - "@rollup/rollup-linux-loong64-gnu": "4.60.3", - "@rollup/rollup-linux-loong64-musl": "4.60.3", - "@rollup/rollup-linux-ppc64-gnu": "4.60.3", - "@rollup/rollup-linux-ppc64-musl": "4.60.3", - "@rollup/rollup-linux-riscv64-gnu": "4.60.3", - "@rollup/rollup-linux-riscv64-musl": "4.60.3", - "@rollup/rollup-linux-s390x-gnu": "4.60.3", - "@rollup/rollup-linux-x64-gnu": "4.60.3", - "@rollup/rollup-linux-x64-musl": "4.60.3", - "@rollup/rollup-openbsd-x64": "4.60.3", - "@rollup/rollup-openharmony-arm64": "4.60.3", - "@rollup/rollup-win32-arm64-msvc": "4.60.3", - "@rollup/rollup-win32-ia32-msvc": "4.60.3", - "@rollup/rollup-win32-x64-gnu": "4.60.3", - "@rollup/rollup-win32-x64-msvc": "4.60.3", - "fsevents": "~2.3.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT", - "optional": true - }, - "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", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/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==", - "license": "ISC", - "optional": true - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/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==", - "license": "ISC" - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "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/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", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", - "license": "MIT", - "optional": true, - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", - "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sql.js": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", - "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", - "license": "MIT" - }, - "node_modules/sqlite3": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", - "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "bindings": "^1.5.0", - "node-addon-api": "^7.0.0", - "prebuild-install": "^7.1.1", - "tar": "^6.1.11" - }, - "optionalDependencies": { - "node-gyp": "8.x" - }, - "peerDependencies": { - "node-gyp": "8.x" - }, - "peerDependenciesMeta": { - "node-gyp": { - "optional": true - } - } - }, - "node_modules/ssri": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", - "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.1.1" - }, - "engines": { - "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, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "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/string-width/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/string-width/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/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-literal": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", - "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.10.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.7.0.tgz", - "integrity": "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", - "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/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, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tsup": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", - "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.27.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "^0.7.6", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" - }, - "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" - }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "postcss": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, - "license": "MIT", - "engines": { - "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/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", - "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, - "engines": { - "node": ">=14.0" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unique-filename": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", - "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "unique-slug": "^2.0.0" - } - }, - "node_modules/unique-slug": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", - "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", - "license": "ISC", - "optional": true, - "dependencies": { - "imurmurhash": "^0.1.4" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "0.34.6", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.34.6.tgz", - "integrity": "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.4", - "mlly": "^1.4.0", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": ">=v14.18.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/vitest": { - "version": "0.34.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.34.6.tgz", - "integrity": "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^4.3.5", - "@types/chai-subset": "^1.3.3", - "@types/node": "*", - "@vitest/expect": "0.34.6", - "@vitest/runner": "0.34.6", - "@vitest/snapshot": "0.34.6", - "@vitest/spy": "0.34.6", - "@vitest/utils": "0.34.6", - "acorn": "^8.9.0", - "acorn-walk": "^8.2.0", - "cac": "^6.7.14", - "chai": "^4.3.10", - "debug": "^4.3.4", - "local-pkg": "^0.4.3", - "magic-string": "^0.30.1", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.3.3", - "strip-literal": "^1.0.1", - "tinybench": "^2.5.0", - "tinypool": "^0.7.0", - "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0", - "vite-node": "0.34.6", - "why-is-node-running": "^2.2.2" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": ">=v14.18.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@vitest/browser": "*", - "@vitest/ui": "*", - "happy-dom": "*", - "jsdom": "*", - "playwright": "*", - "safaridriver": "*", - "webdriverio": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "playwright": { - "optional": true - }, - "safaridriver": { - "optional": true - }, - "webdriverio": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/which": { - "version": "2.0.2", - "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" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "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", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "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/wide-align/node_modules/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==", - "license": "MIT", - "optional": true - }, - "node_modules/wide-align/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", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/wide-align/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "optional": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "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/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/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/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "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", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", - "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "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==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/apps/ade-code/package.json b/apps/ade-code/package.json deleted file mode 100644 index d244eb955..000000000 --- a/apps/ade-code/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "ade-code", - "version": "0.0.0", - "description": "Terminal-native ADE Work chat client", - "type": "module", - "bin": { - "ade-code": "dist/cli.js" - }, - "files": [ - "dist/**/*", - "README.md" - ], - "engines": { - "node": ">=22.0.0" - }, - "scripts": { - "dev": "tsx src/cli.tsx", - "build": "tsup", - "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run" - }, - "dependencies": { - "@cursor/sdk": "^1.0.9", - "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" - }, - "devDependencies": { - "@types/node": "^22.19.18", - "@types/react": "^18.3.18", - "ink-testing-library": "^4.0.0", - "tsup": "^8.3.5", - "tsx": "^4.20.6", - "typescript": "^5.7.3", - "vitest": "^0.34.6" - } -} diff --git a/apps/ade-code/src/__tests__/connection.test.ts b/apps/ade-code/src/__tests__/connection.test.ts deleted file mode 100644 index c81e49858..000000000 --- a/apps/ade-code/src/__tests__/connection.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { connectToAde } from "../connection"; -import type { ProjectLaunchContext } from "../types"; - -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("../../../ade-cli/src/bootstrap", () => ({ - createAdeRuntime: embedded.createAdeRuntime, -})); - -vi.mock("../../../ade-cli/src/adeRpcServer", () => ({ - createAdeRpcRequestHandler: embedded.createAdeRpcRequestHandler, -})); - -const project: ProjectLaunchContext = { - launchCwd: "/tmp/ade-code", - projectRoot: "/tmp/ade-code", - workspaceRoot: "/tmp/ade-code", - laneHint: null, -}; - -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(); - }); - - 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); - }); -}); diff --git a/apps/ade-code/src/connection.ts b/apps/ade-code/src/connection.ts deleted file mode 100644 index e5dcd997b..000000000 --- a/apps/ade-code/src/connection.ts +++ /dev/null @@ -1,212 +0,0 @@ -import fs from "node:fs"; -import { resolveAdeLayout } from "../../desktop/src/shared/adeLayout"; -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 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; - -async function loadEmbeddedAdeCli(): Promise<{ - createAdeRuntime: (args: { - projectRoot: string; - workspaceRoot: string; - chatRuntime: "agent"; - runtimeProfile: "chat"; - }) => Promise<EmbeddedRuntime>; - createAdeRpcRequestHandler: CreateEmbeddedRpcRequestHandler; -}> { - const [bootstrap, rpc] = await Promise.all([ - import("../../ade-cli/src/bootstrap"), - import("../../ade-cli/src/adeRpcServer"), - ]); - return { - createAdeRuntime: bootstrap.createAdeRuntime as unknown as CreateEmbeddedRuntime, - createAdeRpcRequestHandler: rpc.createAdeRpcRequestHandler as unknown as CreateEmbeddedRpcRequestHandler, - }; -} - -function unwrapActionResult<T>(payload: RpcResponseEnvelope<unknown>, domain: string, action: string): T { - if (payload && typeof payload === "object" && "ok" in payload && payload.ok === false) { - const error = (payload as { error?: { message?: string } }).error; - const message = typeof error?.message === "string" - ? error.message - : `ADE action failed: ${domain}.${action}`; - throw new Error(message); - } - const record = payload as { result?: unknown }; - return record.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 ?? {}, - }); - if (payload && typeof payload === "object" && "ok" in payload && payload.ok === false) { - const error = (payload as { error?: { message?: string } }).error; - const message = typeof error?.message === "string" ? error.message : `ADE tool failed: ${name}`; - throw new Error(message); - } - 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<void> { - await request("ade/initialize", { - protocolVersion: "2025-06-18", - clientName: "ade-code", - identity: { - role: "cto", - callerId: `ade-code:${process.pid}`, - }, - }); - await request("ade/initialized"); -} - -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); - } -} - -export async function connectToAde(args: { - project: ProjectLaunchContext; - forceEmbedded?: boolean; - requireSocket?: boolean; - socketPath?: string | null; -}): Promise<AdeCodeConnection> { - const layout = resolveAdeLayout(args.project.projectRoot); - const socketPath = args.socketPath?.trim() || process.env.ADE_RPC_SOCKET_PATH?.trim() || layout.socketPath; - - if (args.forceEmbedded && args.requireSocket) { - throw new Error("Cannot use embedded mode when a desktop socket is required."); - } - - if (!args.forceEmbedded && socketPath && (args.requireSocket || fs.existsSync(socketPath))) { - let client: JsonRpcClient | null = null; - try { - client = await JsonRpcClient.connect(socketPath); - const connectedClient = client; - const request: AdeRpcRequest = <T>(method: string, params?: unknown) => connectedClient.request<T>(method, params); - await withTimeout(initialize(request), 3000, "ADE RPC socket did not finish initialization."); - return { - mode: "attached", - projectRoot: args.project.projectRoot, - workspaceRoot: args.project.workspaceRoot, - socketPath, - request, - ...createAdeActionHelpers(request), - onChatEvent: (callback: (event: AgentChatEventEnvelope) => void) => ( - connectedClient.onNotification("chat/event", (params) => callback(params as AgentChatEventEnvelope)) - ), - close: async () => connectedClient.close(), - }; - } catch (error) { - client?.close(); - if (args.requireSocket) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`ADE RPC socket is required but unavailable at ${socketPath}: ${message}`); - } - // Fall through to embedded mode; a stale socket should not strand the TUI. - } - } - - if (args.requireSocket) { - throw new Error(`ADE RPC socket is required but unavailable at ${socketPath}.`); - } - - 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-code/tsconfig.json b/apps/ade-code/tsconfig.json deleted file mode 100644 index 4fdc544f0..000000000 --- a/apps/ade-code/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "Bundler", - "jsx": "react-jsx", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true, - "types": ["node"] - }, - "include": ["src"] -} diff --git a/apps/ade-code/tsup.config.ts b/apps/ade-code/tsup.config.ts deleted file mode 100644 index fdd8bb591..000000000 --- a/apps/ade-code/tsup.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { - cli: "src/cli.tsx", - }, - format: ["esm"], - platform: "node", - target: "node22", - outDir: "dist", - sourcemap: true, - clean: true, - splitting: false, - banner: { - js: "import { createRequire as __adeCreateRequire } from 'node:module'; const require = __adeCreateRequire(import.meta.url);", - }, - external: ["node-pty", "sql.js", "node:sqlite", "@cursor/sdk", "sqlite3"], - esbuildOptions(options) { - options.alias = { - ...(options.alias ?? {}), - sqlite: "node:sqlite", - }; - }, -}); diff --git a/apps/ade-code/vitest.config.ts b/apps/ade-code/vitest.config.ts deleted file mode 100644 index 840e944d9..000000000 --- a/apps/ade-code/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - include: ["src/**/*.test.ts", "src/**/*.test.tsx"], - }, -}); 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 0000000000000000000000000000000000000000..639d1840a66305694b27c4eff8cdd803cae87318 GIT binary patch literal 377906 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∓_80)xmKu7yLn8e)1P-H_BzukKb zU;@GG<xTbrsp;Z(>QcA_Skhp4ug;#HRKMQK?Axik$PWqpS8eIKYs?*7DmzT=Hdr$q z=<iclLJ$qs>DJtykzcBQhn(<oC4UaR!8(nv`z;k`27IY23%JxGkvc^Walrc9_dNdr z?hXUorQoIXH4v-`e`hu0EK5zc9y3Ta@OFCb*jRM@BzWOHKXVP|p7rel2z3A=L>20t z7I(KF{xCEYn??vl00IN3Fxo6MjP>qn@=JB=YVuX8faJq(Wx!23oj&_h{yqw}d#w^! zxzL@N%O5pk3>l+j(ssWJt$z*2d3OeMdKy#}*TLGYZ=FOcl_^cp^wY~j8Nl>(A=Scl zw{w+xpm~$|Ce{20F8Cr#$BQ-b!e~Rr&xCu@bZ_Z!Tq=0(>F@N(A5HeO%t=Eiu{<$D zyi5TR!pCh6?U5+jmSq|RJ9qNx4BZ(eL9nGhdMRG?jMqZnXF%15-Y;Ao&4PGgvaRpW zHGddei8>oeCbyCp!Ayx|A(Et&vfH`wT#QxAkmbAS{9+}wjUAGyQ0e_>+!(H!Qn4|Y z{1KMn)4E2t3^)kj`kC|KoNGDF*}&ViMUVzMomwU|92Xk==ob4D-@-6B*q{XB^-Wpa z2w=j+zVLL$5WC#K&OlUJY$`*=PP@GdDzrid5#h&{?){tsDZs1P=d7-LJeU~pG;(fz zF3jQB$k)u4F*+<%1sPnpfbZ`q*=|;4z(-(NtxaBN-DRhVyv^sXXd$W$xk2ipMf}Nl zDEH`*NzGcNRm;O=(;Xk&;jV0~Tr)M+m{A5>nO~%{7OsfgnqjYpRKcqJ;ES&MyMd|U zB<H~ZhHjImCWX0dsOSmErL2cowpP7SVC@w&)H5`YNE0h;A`kKM#-I25o_1-vyVW~) zVZ8|d4JtCPt<*L0yQ$LpV_YPmQ6;<OvD=syFm^e{)l*dgRk_M64jPzileMng-O(w2 zrXWEvh9$(<>G_l@UxyHtVd`L{4UA{WnekRmM;!~&q9$;^@k?&iGqVu^H3+{*W@|j* zmeNn}-|N-d(StnO(PP(a`ou#0%0!p>vr+fqw6|fdO^MDI@){Y*Lr?43(sn0I0n+-p ze1ZNw+lp!2g@NJ<#T57Y*S8RR*<1x$3eZoPz-R)yO&~z1xV5I-df^6Q@3ox3x>g*| zHOkT}msWVId@c~%xr_}qG{$Is^Q>L~)?B(!M3Un8D6&-K$uv6$vf#vC*z!hCXi1el zdaoL(PJ{LJrbe@?0==esnE#-q!rN2Fh3F6HlJ7y3Xj7;n`m%)e_g%K_r`!#8p^C*+ z8zS3+`l3bOOvU<rOC_Ai680`N1C`{B0(r%0H5sgCf^|j5I|tZ^yY($Ji2pA<9JEV8 zx(6l$7q+uPk53%)dq4NTu$CR&ZSNl^4IC9YWtR0E6c`clSD1FGyw{hfXf?fwtGLJ7 zyF2V;uCPW!7Cl^2Z1G{<{0!Sx!EaPaEgChJOK&|?{juDPo=enS6`3eOGZ=1zt#=#Z zpt6oHRDbvu)L!UL{TN&D?YBsa^2#Xlr3k`V8ouc5q-LO;!E#YvfLjkbc!kcp$?$$| zzWlPo_g$k>qJhiN6#mjXjFDCc54z1YhUJr_23zJHa|vEObB)H^iAoI5*NoI5B4mJ( zGiKv9BsF9rua)GCdBL@=$S;k$!D63_B-9V9!OvZ{jAxso<GYx>)@04`i&;(+&&*{l zg959ploYhiZ6^8yH<J6+bHeKCkjet&6n&mOD#vJS45}Js1WWa)L63C5p{wWaD_VIp z>3gS@s$jMQ)y=48H_JeO#T;C;o*>wpoZIFM1C3Z6e=N-9{Z$p6WpZRyno~VzT(?@f zY!Q=D2!`@UdOujZL4N{)q1~{Ij|SBg^4o8vbH``^3EEED%364%oC7gr6kPiz8;leT zw=i7E&e|&FKwc@GyOpEXTpSm2FMAkuOYsB3l-(2``-p9}GXBbrv|1ruwzF}8MfB5D zrQ=1%si1CH@Ar-v+|vdsG=l*MQqQpk=RHy>To+x>{8hYKL9)BahPtpaxApC5T9JME zH`txP@ci}@K=q(byY5K!P}E|5JByx;w0a4=bV1frfW_<gIVd%ZI5xQ7WMDa<rk*Z* z(6q4+xcGG9&(CW>q)Wj5s1oizhwM_7p;_XUdq-25q+3T7@2bZk(OQej#JCOznS+rQ zyKNt%p6wCx9N29kK+jB!=G?qvt)yi4{llXwrPkND_&O%Aj0Tc++JLR7JBF?KX9e+` zxoPAy`@_ekGR2qukckQ|=nPpj?9ThI+f)ep!+4V{=ad39nMYctCj4P8&x^!lBB#eO z^z0buyphVLGo`mvtq2m~rhGGw`OXeE%gO%fv;2xERLQE_Xk$J<Ot0S867dZ9M{^xp z9?(_0@s-4_p@<7!<U+bZ!nfAYaunC>%%P8k<@kiM>2<Y#*(hAk#eVc%)8fpIMA7n- zJ1xE=^ddk=D?zCv8aSTNYkx(cr6Nk+B5i?UqS4>Jqf>A<SD{F+aYHbYA&uEBbW@ua z8fodrw($*d9KJy*G_cWA3`rudKgy+BT8@ZJ$|_&g*2^q!nz?$%4eP>#Dm5ipUo!QV z;=SQ4n5`Wj-O*Y!#zT~U6j3qLX&biC#i8}$0wfFYve2Q=eXii}_cxF118{#8k!sMy ztCx+K=S^%H>=e;9kTc?R?>VUTNU>7<y|h<aKxbpL%zO8B4h$#B3TvZ6i=2nu&+*<9 zZq_>8uQU=1$t&M&Vw+|+SPNzmRJ7&WMyNbHV&?{6e43Pg>g;=$Xr86J+p&L+Y$!^C zZN~>pKW!*FKj_ZI!$;q2i$YwfG)=b-R}`$w7CDk_DT*ym{NcCR3#W|S{$bswANO>H zTgS3_B=B?K4AOhX^K$Uqs(F^as=(cg6BYMrmK!-Uty`*3TW9+e7b=2gG6m%X^NWX_ zm*(2fGd%P8=k&2$oA_fBG8t7>O*3B^^}PJ`Q{I3Lv3W4t@OHbWb*Ph7LtuG9<Whdx zJ5q_pTyJFngfg3&Y6+`eL>MFqs@POieE)nI#nBNwoTrbi(9^5p`0)}~Gn@rsVq-7~ zsYX#;o&}Rf2%16WYepBOnwS|TJPR6yIhA<DxpWU^3T5)#=DVuPcpYgcnrEJUsy-pN zZm^yr>?KXzfR$7$#8yVL+~-7+HMM!|MXOwJ$5vGuWsz<kI~4F(4Sd;{`uk0gm2PG> zLA+k&Ok>{Ft+8I~a}4Pay42<BoW}CDR=|6Kr;&~H6NNeI(P$<w%7bb%XIdh$O~Xud zUh`i<dRitfYw?rf+gm5!Y)xtp@g2ZpIMXc6<aBA(BC1syH0BxyugH_ArjLuAroGJJ zf9dQc6{{@Gi5%NJ=&%)(cIYH@aIP|DT3j&LXI93y-X}WSR!rEw45PvI(8;9~(RWiR zi+K`!moT@P2O9c+&W*n7B~XPQYu`6nyPm{pDvpJ^)azlkx?W{igmbzy>YKM&y3VC* z3K4r#wKu0*lt4lPe72|wM^APB2v+f+d5~jkSt(|qXKg(!sI4oPcB_1Ei8AiD^J*&( z5zEF&51)_9my1D7Wq_5;YLTi8lyjW-Y?ha%zXAgE$U#9E%I&D0{R8tySUuJr-3|+G zX}w!tL$7CO`FkQnWa8EAw(H)V{ZvT#M+mMd!~@qrGpv$3-IN%#d^Mb^SEzRQn?M`^ z_aF<?xyzqvt2DK)ONKPyK(&xYb2fAm2CNbniT$&3FI6;}6>nu>mfL#v6`&Z4ivo6k z67$}pnxVj+qg>SwQHQdfBxsAcR$n7Wn{@y$v@r62l6*j;pM4S1_%@h|wfY4X|9<z3 zfkNG(tFRbzu8M(jy3RLNE@J#}x%}T9u}70m9I|tL4%f2VoE&R5W+d`Pn?{Y#9XiVQ zG)bpRv-J&_sRO*O*y`VzVA}X=7&nD5is%I{x|^IMRfR5NCv5~!?@xF@B11+N2svg2 zWf^_Le`91UY@)@r0|X~^2_wEQHq^hOmC-W@!(FyQ1W>>yk^E*NDmg(p$~Na0G!+4U z!Rc{Qw2+kc<`l8{!l=ikkaQ(SkQJ&6$;5mVfAnr&#POU>(L><%njuY_vH`0s401#t z&-&Io`&;!c$;SFLnbU&a(WjUSwMCxO`3F8WzOe2n-a-iy*=-ovY)AB>?i$EYmD@-) zdj!6<y-2zgsA4R0|7C``!HG_+#YFIusvkaM{wY3-(B9@t-Sd^qFH+)2TpostsuzD} zhFaF0xXX5k!gSu~pVq^Bz<7Hf`z%Wkiu?eKiDm4L#UvvrjS2Rol>VHKuk<R&<ifB< z9aHh3fG6@O_$}30V?I~&JP(2WZ|Las<&g{>`?CBF`?OuV^!>-`BpCDy&o)d3QDsPm zI4<zIU?n0lDf#=D=j{oz#<-2*c24e0mduK29NQ5Wd98P<Ne=}es@BzN><brI6t=dM zH__iX(6eNXrv&=Ez`87%{oK&6#WYC-v}9E(;k;vsDxEX+T&l!@Sr7po36(jwY+@r( zM1j01DO#l2yFW9RTE&0rp0@t@!dssaK5+4d>X(K|0%_|AV{7>><v4+GD?ZeBc<?%5 z*^4q#$<D~;H{ya0#Me++y@~q}(%s5X8;6?7crdLtGWysNf;C8Y1=e*rUx*dg-X*~N z5{sSU0wnA?IavQHcq5{1NVev#Y~<u-kfWl?>(rX8k|da%!f?w!#yd(PcrYbP#yHK4 zz8{UX;L3%hv&p)>k~C@MtS{vp)w*<c+~m2hPzlt@GWq>Nk}Njec`iMD{EXKf;y%YV z8fKu8nd(J2<>P4}doFXj>rGWpx0Nz_H!ve!!?{(30aOtj*9FYMQA#I4^QjtWkfxfW zJFbE;^&MQaBZ3<sBTc+mwt9;nO!|Dp9h&t7&M}j4o<fr^Y1d2e$|2t716C3~5{6~> z19~4(J@7@M)C-48=~rJff^L37;mcNRUUUs^Y#Mh*b*9*zdb%I%i;&s7vwg|dK<PXD z`LuYawbpew_0l8M`cpMQ2)uXA`vDsbL~oY@g%|-|byv?hRe!ZM^-6U~f+!*CBt3HJ zrk|YeMws?iwr1t(rx2Bwc)va`=hwE$uKemJ*kK$O-!MIpo(MDHm4Qrp{UEVbJfLzY zi8i%B=5Kf!=n0CL2@9_B+Sj?gD)=-s9VTd15#HWuF;}4`Ipd9hoexi3)cFQ}75`r? zfD?!l`jlK`ymHg$QUBV>(82mMrYdr@NH>klIj@~%+lE8NmOevOw&n*Ro}Jp&I?t;{ z$TLsMu|=r2Yi<`~hMD`qlvQAgYZeT8Vd#A;AtEUK=}EBMd(#zZJA&P@P!0gvgC%A6 zl)vVYIx*Xlz%rB?3({7@rxU7S7f2R3YVrI<=h3_QSUfE=3PIDgh6V5YR4PSV<YK7D zaX~>f6CJDRyqBHi#r~#5Yc(*oe|erXwk29ZJ6lP$(z8B`YzU$x`D$u<WWJf~lB&9E z;?W{?_Ufkm%H^t~Ox&k265g>``8g0`2*9GroX`KrPHymIoVT4KTj7qa8=JI}zT(lm zdb&YbHkor!Z@gMK*n0hSovW}8Wi(^BBEn;ct!Oi*EOr$fbM=a6@@9yiukxTrdAIp_ zS8UBIE_jyg_H6ozrBV`Pw6eavlj)$dl-mKf=(pHnM&Ru6)Fb2a%TCY|fggxMc3%S0 z@J{Y;zFfv3#}tdnb3cgTe2JRbSpq9!9LLcq?Xya@Gh{gPcIpL7m})<ChO4(=8$}@H zb<JU#pcK3J$Rc150q9v#fy~66JmC#?g60hLl*y(a9jcUN(2w4b57}#r;@I~@VMHCW z_sI6Dzm%W;{3Ek6h_1upK#Qcl{I_7{V*i5o(y+Zz2K)mB+(Z0srpU!CdEss_<-+87 z_<~VriAzUvpHn)dx^irU6?N-^*vgIUgWhE(1R$_|RLuIz(AE~`h#~fH2NW&stY6tN znwAIRpiD{%=*e}~vUE%WeSS6ev|e@J*iM^XiYzcRfTo$zHV+5>RM#x5eXx@;prMvg zI0LlRS)Mh5$<e>^x#M}ztFekGFD!gz#GAn`^+(u+gcs7`xt}%6m~PRJxAhB|g(YDe z15CQsK&}mYvIh4E=EPoJkS_wVbTsmfa&n2x?s&@bSf{eR^CPuFrM2M+Q3)-?|4DaQ zVZx-<6Cs0Ca~sMOkwI5QR2L><@^wHU{V!?^x<{RIHv1Y#bWwk~og*`%?o975#isBH z=trI5<o4EpbX{i7I`5%L-^^Qyc9wW9+GQ3aii$*=-tua=-CRu-_YhoThR&MCZc#8j zvD(obk`}-ELNrO@ZQRg7)bdhXv^U8V<Z5nKkDS}Ywpy_2psh5Ac<8K(3KEnhc#j0O z4r94`A<L6DhCeD{+x?yEu|JbftwIUd7~l|~18KnEKl*ST9lD1G%lAv`y^iOh-~F^> z-S*9j$YIJ9l<1jSea*LTv;<A-zfJh=1}1Pmb#US`u-9CH@aa-1lbH4f_t%aE4%WJV z7&=gDts=fuFp!nyWri&GN<1hm=hjWR`8`iyx-~XmkubhJNUp9ct*;?&w9i?q@+v1< zz{b$U3xRV2_RgGVPa&Vg1)VAi8~-X+-6vSq;Eiq4DON}#<DMGd#vnc0PT3!>aEMj{ zm9$w6c*6Kub}-&r>}B{i`~Gq|AF^z-T>7e4jI$}0-7XfZKpXpAck1?3(Vrp?5}uqz zb}Y~7`$M~Em%?g@Po+{u#o*e;Y=(W`dlMO-p}GdOa##{vEqau_vv+dxWN>%-Ei&qa zDME8C417`0dKXy{R7?5;Kk6>r`MIC_4k?$=_f~JI@rYM$M7)`*DGd?jRgJHVX!!Bp z8Ib}gh1&CgEs1VbJixnwIuKU5U@hZ=aol@Cvxp8I2o@VcjsqbS#0I(WDw*9Vtc~?r zW^s!NNl>G5%!l^Z_OUSSSV?LJW|CrBwUrgbl3uc|ghjE4yx3$+PlV%{=X4fc&iL*% zw}%fV1lZ`$wWNnP(}cA4pXS3yju;AE78<!#s}P+(6Qw3CFeW%1DydNe)>y~Li0r+! zgMFHu8%Nn$q#|2|ayFk{6Oq2oHB6A~OrfJ(S&knLFTtZ+7Aq+qWaEJKrq)0zEYjHY z5d6~>{Q`l0?)-5qvCnIUde20h%048z)c3NE%GX$PzWC}zP*F83Xnv3slkTkM_n>KU zz@#<B&BW=&$!@mnQh<|Sxw*?<?`-0!QgYB)8*@eLP!VZ9^0<K{>lAUPD&zcw9c`VY zbVBamYQ@b3MJDli{&>4LCh?)h`Bj<?2s`c*s%ZVWt}hoQoSK$kejYlETQr_5U!cVN z81woqrEG4D461v;U#^5FcVM`!r){a*&r^=jh%_gguSm6=b0cz_x02nYDVK3Fx+rAS zju)KF6y&u0Sa?vRNXO4J3;90F+5))+0qqvOE_JxNMogCXdk>ImBUFq7x~bJNMy4hx z$?Z!v)_7YA$eO%uzo72gpkuG~U1<CxB7dmf%RnTBEe-1GfUc7Pwj;tWy5_0CPnX!P z7FVGn1vH=QZ5uu@Qe60QOpu%RE>y2bS7}LO)@_&r{+7Au=lO-Z@JCQK!9avp^|?|C zWo;-pVpUL{_clc^)UNUu3l*i$yj2l4F$|BQ+@Md<pF=A2EyN}JRsZPh@$epbg^40+ z5G{CzVlls6Ou4114ar#iOfy`60(l3!<XRD03~PU6gUp=28D-I$nj=XSxk+V!q=;#f zE^9r#)`uz~nR_+m(^s|s80m^D8F@7;KiHZ2`7B$Rk#CC3U3U8jb#eC5RJV;bD>?g( z{brM7%eYz0)9J*XsQ!r8n#Z-0iX!IPy$(3W)laEl4@!X@{4|lxMD{o^Z|Rn~KFH7j zL!-kln@^P07FpU2JEQe>ea^CX*QvAFFURs8d<-lT2D&)KJTrL(=Jyd(2wl*ouU}1x z;N33;1qyL5=)&-+3Wrv8&7t*VR$Pimo+OkeTiNJ|+QoDEX>Is@Xivf7VNTuQbPzR- zddKP4FG3)@MeAcqkNT?Agg&v#52iU=&tNBv!E;hgRbnj}f^#Nm`yAr}XSRWC(U3Q7 zvs$)-P-ffPhn647tz#?1M<bE5ax#_ClRH2BZ5=;ax~x%fJg!Vb!cKTRvFa!ql%#?y z)>gDr^EJ1nE0(lVx?pGwz*abbz``iazJ+iD`5JZKYgJf?_pLmzOvFmIeh2-`3%MJY zs4o%}91=He%n5lxGEevF@>5ORY|i6sn^Y2A&NDeJRh?zbY#x=qF(;H{==~(b>lPg< zz%oFjCx+qA?<d(VpwYBitSjt@>(a%m>td1(!Zz;e6L{@)wbooUV`50X9x1_1`6Kr; zH!Y?q^JAtwQ3knkVjo~G&Kf0Tl)f;mSpQAGS5@QB`F?@mr>LzZU+HO2@z3YCQ*_h% zf0E*#&vLgnm$6uw52n$1Rz!EZE@%a9{^=|TycA_>BykfYtaqsa-DSaWb(VUNQ47{a zb}PtoVnn#1NvQ4&3`UZ(`*Mlp5!a0Nx#a!_q`MhpDk*RBuxvd_`WQ0z)wTKNy_q=F zFSpYjnyebAj7F)~VwZnFf1*$jJD$9<-3%YM`P47J%i}FZkwA_V`+;D3ohR5X+r|OE z-enps3cnvJ%qcprcfA+T2=`$NM{bK7ioNL+)mWoeo^fn=e>&PEk4(uVy2R$fJ^hF# z$+h+56XC;|Gg`HeDYdHtJWWU?MNEeb-Ri!+kz?iQue(x}dBzq&+8lm1)TU(n20Xz# zF~1nd)Nc7lCk}FZj&M{P&4|uWSjPN|TyVHW3_fLS+>biIL_*ZZ3@})vjlph9*c&A5 zCnk2gwyci)JPAG{lKK23n7E$-g|DCnkfHk|*Fdmh3^)pG`avm@zz!qCDxdYf0eAPt zSB8?vmo(^+or_5Qv+0IHw{LELL9A8X&R1`&_7Bkfz@xFU*2iqkwN4&g`38E|hmOcr z4>64<wdAE-6aG($=rr|x=inCcR+iWPFKAi`x>Hz}Ta+ZGTR=l6`BEYT`cE({u6A|0 z(FFk^3s{v%AXtHnQLsHL6%$uU*DUi}gP2JJlVe8uKySlg^QzLxqgiv#>TO@@1hGW_ zXR~nM^VP@0mA&7fLbp^RG!s_8BpfQU^0%1iOSEZ7N+LapspSyxR9xBf1(uC=^=1`O z9B<mAvYdSv7Pb5MxMY#yc?{=?{O)I5_yK-zbi2+kwJu|u`iCem+9w7@(Jv~{C3J^r zZKr$X!-^-5G_TaM27FdPzwXKlg?bG;(@o`#4;+$K3Tffz3GfUo*EaXdtl5U<M%}Yo zeXlo8Rc?4}@I>-z)Q^&Z0Q&$ToIo#Ot*N@vRC1x60?9w6Yd-Lrv2U#NWH^9yY;vME zbl-4i9(M1LvCF@Fa{1oU5_&HnR5``If5O1xj|0S0{f<KGV(*;~&b~UyF5siFquw|> zG8a&nd>jmGeHhb!IKNh#MO;vh`mAM@3awIAsQAzO4z|SttMzG(Cf~}bRWC1@H3p(h zgb@D^TS}K2)gZ6*ebU>0%%tvpo+FgrB>dU$;)QJShb?I{$kD~jGstqEfPApe+izVp zC7@~i$b*Y9zOGGUPx~LECCswXfDzks;*?o<0HjTc+ep-FlCi@G>njeh@}WOf6Z86V zHm3ugeh9i-b%~ux^C!BjSd-8&3-tOX(f^ZY($+SZTC_q8IDN!l)3py@Dn_;ClB%rj zGM)aH{U0(b2SAM>nsn7xZ(_Z}6#SknwmY^%yfcY#Uqo9iX7$Ngqhbm79TE$*msUkh zrQHXBe}W(89L<-Rn(;@TJ)PHC4ejZ#M_#JK)hb0m74OZa9_?c`WPpA~-7@32Grhz& zlbD#u4j&d|PA8w{pTo=&q{@;>uc%WPn(b59wlWx2_s!i!Q@#Yk9ogw*+0Hrb)1D@V zg&v}g5pbtesyu-rCKF(yPVxI+br8*UChk1BR;2c4By`-=%w$8Y_VcJqnmQALEpg4{ z5hYOIgr_=VKT9#-u54F0GvPfSq4}y%9P3mX4C}Q^3`}3Q-Q%E={B;<1cZ^`V3|gTq z3p$V0x&&p>eF;$MmieV04KPk_(PxdGCpvBK6IGz&t3-GYz3uW~_~eOV1W{vsCl{f~ zPiW`F&~-xtB&}ID77KuyR8)1cL9%1!8YJfdSJ+TSPB!!;BM#%=2B;O$&u0XoHIL#V zD`HzyUaYKS#<qS$Vir9+Au8+CAxAyrlWmp>o7cQN-4@@z4Y+@*PU`${sGU7hab zkF!3iv!hH7JTu^^PR872b^Y-f=J6AW2i2yH_D9h)eXc^pa8kb4SOL#3a_-pl^zb(X zg8@rDK+GoL+YdGjZY*wX6-#PaJqc(SJp}qKJ9}qg4&>4_Mg1q4^*>p<`y*94D6E6W zJx-nQEoeLJX%0LTRc~x*DOgUB8MhJRgbP2m$I<W;=U)!bY#LATTUCfA*`)jw{2;T0 zWH(1MA|)iB91AA83tflCU(K35UKHPnayysN`TbGPTFLiNu!}iQ=XdRjnG}L9F*n&7 zWtui{iuK709_$s^`tJWNc%GYu)$KE0mPRfU6nEonWsa6hb-7i*Oxf^UQD9<bLtj86 zox#6e;!yh}>C*#$&~(MKCyu2Ra&KR#%PbjPj*vW3uQz;GZ+7z1;t#1k<F{~$l$Q3w zE8@<3p)V2v2+{~1U-4a+LBE_wh1<ttdHAfFcuSsl*`G6Epb8g;>-~j&fj<m%V*UR4 z#`PqkNM^5jJMo_{t@{)W7l%VLMIT^iCY$fkSfeUZqcOR~GY(#%_v8H#E3xc##V9_S zq2l!GW==SM{+)yZYx0rv2rC1nyqU36+=mqZ3$7+-kxUb>v}d;!C<SlNRu;cML?{&s z$<DIJa5blj@yR#<ZN0C2@HokmB!ssG5)OCC8EP+lMu9Hvl_1#HEi)`bj+HExTkWl1 z<dR&6F6Qer0~ZW_5)7Yx_f>+y(h>{umDEvrC6$hFuD2+9|Mkvfh7KFm=)PTkb@ow3 z{lzmdHNcKBCH9wSJZ9PlB(mie1StOEYa{&I(gvzH_N6r?0D`@{IL{)Ur_@HUE-_H^ z^z;FVx~?^y4MmR#x<$?LHOY70=%*XsWXZ0_$)OESl3Q?>dyc8)5pJW-R3dbAE*u}! zy%6~fes}#m?}!-dAeWOAFqfBVZpnpJo1`;9qUAI)L`>&!%@1)rc|MqQ<yw5uUChDj z_X4KW#ysZox3E#eF~g;i9@TVf!x3$L2Ygg;Z<-p|&h#E!gVz4G4e&Upw2UOnUe?B( zA&Y;tvWvZ6;CEiG`u&$x+j)5$MV*t+ez+(71Sv-ORSJ9oP5f{EY%M_jY2%F;$62Om z`uaq|jY@&O#bEqbZZ4sBi?94T$1$Ct7D4{CCUv?8Du30yb2@%?`Li(uHP{o;V#-*@ zB|WTbD@$a(Q+Gy^qT>@tku`ie1Hs&CqC+zc70shT6TMe_C?gY^SqpSF>bN+i-;cHl zYA~>j3lXGT<6ogWa6s^E4K&P?kxf)#0iWQt(SMKym#)Q-oY%=1=iY^wOus;lXcm*0 z)i3V-U`BK1a6MFZnSOiqqhsOm#Hg`-`=*633mx1%>qdgwrlw&-l5YL2n^#Q76XOKQ z&}})-#)Tnvs%`c#s^(Hgci;Wm;Kzj@bMRz@IEzPS++wxmuwlnixGeZRGeLwnZWYF> z*ZGvd3OFEGRkKR`?Y3#&>~Lu_Kl8{pdR;f-MT5t^Pxa$}G|2UMfGFL`-=}=gMwuGb z)0f}drJq*7y!2AXXGq-WC%iCj0)kci@z%Km9h4q^hwpo;w(jW@)o>Kn_&<Bec$}$o zZ>tkn<w$d7|If4^O3U$PV%xPM!LcZ8SjPxZTDMvdWm+bu9Xi5}Z&62G!+3ap{)_sZ z$9WE`{El01gMcdNpbWupyue!o2<5{%od;yM@UD1&8~Q3Mz249$oZ}BaTl6gB8<oeT zGeXV~!u06SB-)dgf09YImp$G+cPR@Wzy8$uf?&!FI%x$RksNZ|8EP)%psMaIhP4Ji zM@3|bLC+)8lD^jsp<!L9XqJb^nH>0RHk?uKS%?S~wP>t0cc5ZFrk!|;@GA}r$gMBh zdYuyV06&;FSG#P0cRK_V3q{8Zkifhy7?%{({xgblD16r{T`$%hZ9nJDG)0Vj3>G57 z3h91<qFfxm_)C7$4=WUZG4I09x=F+(ixgmJLa3zc4519b{dVv*w67BID||uEEDgWp zg61e3-{0ZnBb4I<uCks%e+hn=Lk_;WXNSjO&k7k)eJ3QK6Vlzk{5B&VPh~HJcMcs5 z3SKY5{DADr*8@t(hNEXs^#oVMXOz0;R4uuvq^k_*{S{WsBjX2tFH5j@ciPA}TWic$ z3z(pO+;bf@?>J``5Y5XhWj&@T?SL8r=vgV!B!Oa+3&JHcpDq--HSoQ4FfntUdDcVv z>6p}kA=V7tIXFKG`bN7RBO#YcbSuiF$bj)D@3ShdmaP|K-TxUK=8^^UI>?Zq(AThF zIUaELXYk16*f*>@<-6v9H2eU$xw+aB&@u1l-ADtF%>3&tGUcnWtJzPOQ2(B<@i`Uv z$_ve^kV}WTj-dfJs+?Gmjo~6M;=FH$2Bu3FLU`o*Q_cJFnF#qQcUMD7`!R7V*7~x~ zaxlxm5;Jr;n8LF(4$%J#NiGc}h=>HM(3M7&SscNTcU+Z9&PTzy!QM<0ThH0X4Y*G( zXIP&<ZBeUTj$LqXJecM^U?RIi&8)EU*H56!Ji2#l+df<Zs((p!H6=g$?LH~956%t5 zxZps`@&BXV;o+12c-1AQ4K=)(fi?!>Ww4pg9@;+@NKOpzj`$X8dDfOXg`}(DXP|m@ z_dqog_zPwnQN;ihb0QH)<E@=xZi)dZZTy8FT+|&W9Rd!(*wI4x!fu6rvy1=LdGX#X zc3;6D6O>JSy>Bf2s|I3v{}mh%qKh^*B^C9X67VOHIp~6JGxJ`kVEZ%qjzM2VoOfH) zo4<<h2;US%j>JzgcH6FX<=q@6eTnW+cyv6NwOLt;qPSp}3tn@oe++Z;Up4#7#cYhq zqfapewMi*N8lx6Hmw?d)_`B0z=81GHxKhqiH9x@T8F7l*^Q_r=Pes8Xi3%06WuE}s zeAC+%HyiVhWa!617P(=|g%Bm1k@mhD<R-e;t<MJ}*OY;o6S}CXAK837i-Mi7x%c8t z3(bTAUdDI-c}PL26Nkt9d=SSMye;s1Hmp+)(O#~i$EjbLGeWFLv%n99VIlDfo@ApZ zpGPxFys)`a27><4#0w#1=q}LA44WsFA}Yp4A-$)-%M7O4!)GcZ=g<IAT5B^(_{pyz z8OoN&fwcJ9wLilOu*U36W=ZB<Zj#5#Pms&rc>LMDLgv_%3uIC!U65;O=iQx~99#8x zeq{4wBUMt!Z%Xjwc0HE*OY~dtdlSO-EY<sqzND_Zp43)1-$K()+D@$Qh5f)bC+cYV z7;DFf^Z%KY&$Y(@&cicxYjn2Ly9#XDb%y71M_L!#0Olh@LOAhHN^dWMq|OqbiRE?X z!2g&v&sC5AaTBMeW4jz}+TWL~Q6B+iQ{_w$wa>3+mIm^BHeN(sV<7Tvf8AQwmhD(b zr3e~)7BZVgwm{~pTD`tZcw;2ipieM%ulai4Sp$T}Liffj11jAl3XN5a;7+tk;&YT< zJk97K^cW*+b@Vm_WDM%?WmJE{vD3bW!lhzWX&wg$pj7j*pQLn2xcpqm`LvvPPn7Mv zh@)paG&>af^RJMlBDICaeatrbg1Hq`0S)2;epN-D?Z1WIrf7KuyhAWT9Lo4V1U>#x zUy(7>dR>P*Iqk#vv+WhJrnkliDG367W(;4#^;J}bzKga9TI;m!eNQ1X$vfPz7{`yw z6ey)~9nxxs*CG;Ly`%-|10uq=4Hfclai2!x&--d@pMB3*3+Z@g1|XlZUx4^;I)Dr2 zE^FdvPP*!v1=5-ZgL23)R}0x(qgRdB6;LWt+5YF}!4jCw3$9TtKU+JSRYwqg2W?pI zhh?Zyw#^1?$2w*5e6`Lk)|CG3iMkgd_<B?1Y@4?{*%DMfSH@xGLx<AlHvFxF>@+c{ z3$Cr1T4}Rjxu`@#)f+Paq+#SY<vEK}Hx<~D?!1Kmoi=$8<~>lnDlnV2>DvG3&d-+h zH;3q^6(eM+XY63vb7FPHX!G+58lsZJI}!d&O+x)F!kaC*Wnu37BCPg)rmbRxy&aVP z4ii`r47e6DefPU3Txe!|dX2?TIrW&#s$AIS3C}J;{f}}O&+RxGxd9rM_7O9htPcJ5 z<I19=O2En3oEj!EiXV^3UM5ANbpQOq3dZm9=_-Z2Jnot1D}4|R(tj9{bjWdkaoZJs z7o}F`g6MnmZA#eR3<_1o{K@6ehkrO)5P8SiB<G;4c4Fu?DLwq0=6d!vE89!7t|G1o z@35vhfRrwfO)0G6R8g4MI9!>H&YXS%1Nhq-KwHywv!QZ%79NJ&`#y{Q&}i;=H=ct~ zrBK3W`QsgsfQ_*f3YIm?4977B(@S;#l&&G+-NSX%S$RE&iRO*bVkzd{S>ru6VKoc0 zUXfQd;rDS-nW+gnnYr}?&HWJ-vI_?zNC7A_SXUfx0jz%_n^zjtivIG1QF#IC0N_7} z09*?{CJvC{harDEueAp2k&+!x|JK`KN9YxEo&rL2r0KVOJW~$CxF|5@{IN`~p9Cu_ zfibYYVl|j+ZcQ;*xtAQB(;WG}co-CKc<7QnnXJbP0IZAFEH(Xcq{1w`pI<Aa0kper zIM$-3aK}VnfW#N{=$hZSs{S<bGAS@5z(b9(U;;}n3?`qqzfm7IP6q+L2U^gIXn&sC zO($PYZHIlq#Q0~{1|c6j*WiX0g2G=`^nm60B-3t5t;8y{(Bav*_2V$joUWjSR>%b^ z^4{3`$VYXl=X#}(yMCQED>4Cgogq;wVoWl9n0}4hKTQDRrWB=S_i!V&>VftWBFh&& z;WT%q?dn_iXg&EbD1y@=y3^g*vzIHS9cb|Z?|a{7T4%&TsA=o^5FLoV+j7V(z(Hx{ zjhJTF!_qs*6p-zDUw_`>Ae#CAqv<QdqWa#hXNEyax{*>4k?wAkmTsh5x*G<N5((+< z?nXMLySux)nR(~;zn=Hw`L@qKXWx6Rb;nv{{A57ufF_E8C51h3j^)u|Q;ZA-gTk28 zF-x)}u#g`<Ko9}?c?k-QZ`TbT)hnCcwBvr(=cC|xjC&e3fBIqEqWdk<hII1F$$A@` z3<khtf7#`-a+jXlocX*tG$DHI9M{jtEelG%sJu&<{)vpMKb=+E1t}b}=*1kaR>W^t z&+pLZ*|E6R=n?IQ*xt@O3kg`Pma<4+=Rc9#3uk%Ng|jC4cb=++<PM(y&8kg2WKn<{ zcfjYCN^vDbF3+0YBGnm0Sn(oXMDl+Z5uxm41X1Mz^2$XS0Cn|dTL(eeO`yp4xFM+j z;*1z;UptB@1+z^G?<_rK+C&w!mUmv9d{(kQJ-Z!XRhZ;h6=$u<8S3@AKR!vlh9xj& z)Or$D+|BqrPmkmO7zsZzd!LD@bz<HOPDK^&P<teFj!5ckUd?}kox^TfzYP?gW=XZ4 zQx4Q9Si1NV5^;>Po^p7cEh)Y#jGx!p0wK48LV4FL2hulQ&Man!4b*_0cGv~K%&C5! z&6&ybqO4&3*G$lE%|lcw{(1LixPHHMcbfbwqsMN6SWBvm%{^@8bioYDuwe(?P<_Q9 zlW49L1x(ET`v)PiF<a#9vw@pzK#RB9`O3pgt?D$PHO>D{_U)Cjr9Qu_dw6&{m5!_a z6eEL$O9(tu9{$np!02Cx2q#HZ0BJ@Pn^#>&;$==BUDrCMHVlJB)+nD>WI^?ntU|g^ zj7v`=3ViynZLDM$`)eTKWdF%R-1`pboMszzb@E2`4P$knpL9rSn~DD}KexP^usNZ) zUV1Q}>-$RJPW*DPK*d)1PGY#e4Q;#kCtil*xFh2)xTvdzTo%gJ({uXL%6}z)L3`GD zl{y|@4_$AJIDUP8s7Lkv_j7r{o@=o~DjcwHA&JFYcDXhCA9Pe&`q$R(DRXPt1-Y`U zhQK1is=rmP;^~k|bm5L|7AX6@9Dcjif7$La*(!EGjjj9sW8Js<J7c))zU$L)hfhpv z%%pm1$yk<HzgL8IvZMI$7NQDNz)V+z_}$S|evjkhez~lo>$i1zu84+au5@s1-qgy3 zu7+Mlct3ZW{9-#_8NMz<=di#lGo22<yb4lBB?eQ!;9;}l*I3}o&&zd;{v$5I)0NK) zSA)2@{2YfJUbSzv<Lh0}vNAU!$}>Fo+WntQQ~uiU+$mgvImWTK^&Q9Nan;=XcO8EX zF@M7UUX49*+xyBj=Yc$EF1%PuIM4W0S77S|7xYrP6k3x{7`_EplHJTA>>og6`FL)O zogEMAF>hmom>2_==@im5cDI;+x87_h3Vz+*8Ls?eXriC_MoAC(U`rmNR+O0JLuq%y z7>LgqC8MF`GZd%Oz(9d;9CxZBWKo7(<)v9rtQkZc=23Hf^_Des&L5s&I}&keVrx>_ zED06S#Vy6EH#)?cqI@{qH+{NU2e$1mHH^cmr(gR{tJ(HP8uYRI&PMrhZ$CcidRbWK zA{U1Z)a@V&HVP`<PyalOxOe9c)NRjLS#(_dv!#C$-3&M61dzlkzU5diS#2u+Sr?@E z9NRD;mmn5tk=T3Q#wwrDX*)dz=}<uXzTaLjGh2&ryIJaUn_MgI5=k@E^8E7a-!BHR zV$X6O8MfaRu_yiptf>?}{HBvS`O(c7TZH-HsR5w^)}XKSEb_(6qpSj6^A8}WM5r(k zKxaUUeF8vQz6~)_Reg0?-cXxg+w`nxUab0$CPqM<-z<iCLNfMjrtj9c1gnkPY^Ud7 zewXFgf}Hay<7D(u(=A~Sn`lGW84{I?dtyPmSpl^*&&c&p8*LL_S=@X&1+;6@N}{{I z=t-73+6o%*RjUxW@Lz?qDe9z_MXx%Yjpw@Sz4;C|_^`W5iqg#IezzIF*V2%q`F80L zH{~@5Ie(ehGRgE^2qB!HWqKP?-TsoC$yU&ApB9b0=j0>|)x`y0`3BB`K}jfJzHs=b z;?r?a^d`e*;jSa(oqB(*m+|{-I`1axWIAl0@!8azfBg~qQ>cSe&_49EX0c%UaV+wT zzVRT>eXY=%N>3Rfg}h=;<D7rJ(B{QMrSu2t(o*oEZ%O<F`Wam~!)jHPptfV`w(yx( zYi&?tuhUDW-?i#%xu3JfYd!2<@pafPtpryanP|#Nz&qMN1MYoL?^RI<J$dGu1)jc{ z&`riH-07h{K3?3#re}X-?orVbw@*Qivyp5TOu52B0>S&;SwLja$FGQQC`Fhwi%S{w zi@Q5HBs;l0QupTww@z;dpJ9yNzi#6rSn}OLSEp`$NA-4fRzS*PLB70;e@MZDC|jmv zoEa7~K8&Sgb2fxK&s12s@t4^VE8ic7NxkjY{GRATjx;RaZ`_0uprVdlU(DBe$Uqqm zcO{>W(8g_PpXf|IS<P}2m_T_l9~AjPru}zI__cUgxtWry@8wbdO%58JJW<ErZVq3S zW7g0ce1_LdXW`JNKqB?9MJVowC{q>ejOE65rpa6<DDqBWFNc*}Hn8~z=yqjhp2DKD z_GCI)m42?usF8u1s=5?3X^j10ph?s*#gknB&l-9ekO8rUiFeH7cK)_C34^E6R4;D( z*=|Au&Hep`aJDWG79h0Qce^^f3Mjib#|%cRykT)2{2+ox!X9<)Saxrd$m-kZJq-8d z0GU4P<9@3MV<W&8mH7gmXrl^P{HnwQt_rVzlkN{U4Z1M7nm&I=omsGLb7a%>pyI*3 zo>b&!`Rq6ObD`k6(Si`b-?0Tuk0_huaLu^0%-&`5Q9gZVmn_TwzT`&L&)A%=lufXP zSkuhW6+ftf?%H@kI-E|R*Lz|vKx8p2q>|0s3=qZbEzxf`SpmuP{h{)@g_b(_2(aPz zt{b2sr@ucuMv9tRw~H2B?k+VVeRTFb`=$G$YVhY2-i>%mDbf*E$63_aM5ZP%?*8e< z+1SyMyW~4E0!a6Z$cw6rlFz|g@D`P#Sxj=_*8?`Iva9f+c!i$}#KPk1g?@K+`afdQ z&HZKt=B`aW6Jc-2_B8gMj*h8r@91$|pfXVz1Mz<pF)`<_Lk^uF-T{2fj(?d7bdq}6 zSy{=}TPxO{r(ijKI}>tI20C#u4BiXi3ogu<YS;?lOkY0c6bZmbf6{^1#eTxcp?ax0 z7nyn<BGN>zE{|<X;rV`p!h1JqO){-W_<E&aDeL~wBScPv=}f}QnV{3@9&;v9kNBG# zd$i5f7QCk)jUIvbKqi2sN0zom!iusP8Mv&d-;4W6*zX2;vXpy8S#tfA`)F?%R=8E= z@v+0wEJp!Yr_C+kmdW=#mD!)u7)G&W6#_r_%^JJGR+FK?VcJ^k9>oDXpdNrH>))M| zhJ~;d^!x#b^0;`Ny8nq$8?X`ECtk^alW{f*C*0j?TQ-xRP6*AbuspLQj6zVkYG*dr zu7cuqWBBNxd_5lp4}HJ3Id#qCt1?e)KjM_3X{>m?!}#-TUt3iCOcvj}HhVEx_i?@F zU^FFmbotkUd9uAAfG^>tpWh?(qi3hz%T@xPlRp7adqyLiWkX!W&)v8P+BFx}>|+FX zFX)?^q{wgBB8=CU6!J77D|#BXt`X+aN+1NiW|OQwR5U}cDvQ{c9h-%xXL72>t5dfJ zi<jE=S?giN9U?!REg+X5M)BU&`2gi$8dnoy)4m47@}0|brixopK*u5aybJ9{MPWgK z>|xIIUQoiYLgB-;-}8O+Hlv?iedov3#W5AfBD)o`Qqez?1Z0ng^Zf9TQrc5}jnWiU z{)gJvz+n^G-t1bDX^o4)6lW$CSgSDu$ymFe4JNsBJ+%AuxSX`=A~1NYaBZ?AasHdn zV|Vz6w|lKUjvfeV5KA36jz9*_9<{<7<d!z6!rOWa1iz{<+v*6Iy_TM*mB2#B5Ff@m z3L93ldw`O4#+#cIN2O81akcRwC)<JislA>y8BlIPo@8{Q%kN;Bh{8JLsz#p_XaTy{ z@&{{Zf=AyWw<7v>yLz;h`#qZnQC~d`ybRai8{JZmoU=E`lI#`Pm+fDO(<OgdYAOe@ z#gsUsMUr>tTzTWCqx&_|Ps>4Q1E-6s#jxOZY2SSsMo^t~@A3yflT25ht3=dmCbyQ8 z;_?t7387J;Zwzwp@0b`>klM2Oo}e8X+uO@Ccc$_gTmuN8OYDuke%v{`HbHg2-Cglp z`VKEXAI<`*QMk{|25TIliZ0>(1d}&e7FBeMf$dk%_;n>GYP%p&MI$vVk$b)Plrjs+ z;g!u_b7f3kI(+a-9L?2~yQ;ECk0mBS8?W`CJ9H%L<K=aVQVm;q8N2jdg?H^9l9P@7 zf}J+e)Vu<}S^W0e-`k)w9bQdt`n<Nu)&<l)Wb7E3>&k7y2U6xPAKi@Fd1&cLE??8} zgcMOb^5V>YDvemU{DiRH&aUKlKOZ^M2qilOA54V*%Isj1!?+tc9{bJ947yO8$MsCm zNj&-D@G>t0zFJC`-DEQsNp}3cOC?ALYM^*5LUc!k7awLEReaKV7J%NCA*If$@hhtN zrpim{fIxe9Fd{ms{b)D9_m$&P1pyw_LPjsfNV15^)40qRc6A?;-S)da+Q%3`$g=aL z$}{GwY^L5yL72P$C$m)Vs6*AB&D~F`e)j^&UqQb-Um1jD=T!PDU%WUim+wMD@&DPh z9$Zm=#+fXB;aVR4w6~sB>HFfgF05WB6z?TvrX#WsZE<QSB=1<z=!YArF89}Hv)$!t z<^JZ?iuz1n>Og4Z5$YxiD~>dMHk~||Y(tKlUDBX`uMhTV5`~N5C1mizosYX+ClXnv zXH_~%JI(|^XJ~L~#oLW2T@tzVCY$9b`on8_ec|GpA8)q1mK|Y}Ue01}V|OdTrJp}X zcF0_a@4s>WYuGgP2oUsr>Uf;!mj-C)DV;L!U4Q7^Y=p)irIRaLuy&-G8Ga8VGi1oB zfQT$NH;u-9^Gt`*--LC-9%Utu9-_5fhuiSUbzH}8X3f_+vbm(^bJhl~{h(j6+IMow z<k6Zmb}>Jfme=7f(^=}opBCv2>slPpTI|l~jYT=|>UPbFg0-QsOQK)0Yw&?fTXI4| zujP~YKgvekijBq0_<o%f3CR}18c##g$)KrlHR67)Tu9f@THj`q7$9KD<5gU!_m@kK z=Iz(jo;eBk64t-xT2FplsRJCXa=mozl9j;UXRW!Bm45LwT4YOhHuW@mM$1p)Z-@VW zQak^~%5fRnRetaHd{zB)OEF<+@WP}p%&aHp6@#J5+tDNJreb<sx2Fr(`vBn;B1|5+ z$rgamRlgbCwvyF*2jB74M#Nap+k>f4WoDfC{Me|phoN{xQ2-Y0e>wYqL~#AK?hWu6 zY+s>AydRON?vvDz)p)vf`oyhAR*HKWSN1Ei<911^O%e%}z)&;)H(Too!xbS>$#?kR zML0_4P4TT_=dwe*j*b?Ro$(ON$y_Nwgv`T7cgZq;M~mGEKxHBAVlwLWsaJr#_N+r; zi(b=|(lI9jCttsqt<I|XsH<g8JFT<x^LsJ763T{VA&cc9E5{k+(B=|<i}MYQA^!N9 zn=B_x%}ykpJZKVw5{LW;54lq$3G(qXgRB24qN|ykxtm$Dmzk$$!Ffw_OY<51_{9AO zcDgfmst;7P8RI4zKSQ3c_cOiRAH1M19)SrPXnEG>74MD+<ZsgWOQ+EKIeyii{=9C9 z2aqP#D~@w)gPr6;W7VgSK@RXuB4!KC)gis1j-ZUD0JV+sU2n_MqJe^_qIY-L4!_V1 z*Te!eLQtL($5g9O(a|8k!Kc)_A^E#uu*Nx^K33nh3VYV};{}x$C~71w!{jjF@s zPcqWa5&jfxbjC<hsa_}?_T<T*z9^{`70(x49<Yt+*@*;U#jmM!ihSwCA2^984z~O_ zZy0^7^iLgfe^!4$kq#fp*kX0g>^+(wn<?nesMfHE-=Sn^hfjfdFfaV=tH^@B+oK~h za_?8l!@r8ZStz=Fl!L{k2B}01I;aeuznr?!=0O8Gq2iEH1%*xrd<sd8g3j0jPu;@j zcc&xZDYAh+IcDn*YnX4{m0fo5<Pkc#eyh%5Oso{)hFI+MnpdH8mI5y32w7L9u9GFy zepIX#+}*LMYmI99aeN?uHvff9?+Y-3#IX!kjU+C9-gx}S^?Ha;imrL3CHv0!Xdc6| zSLp558{1F9h$1U5T6t)G$F;PE_7*p2;f5aiNQL=9DxwCk4~ka`hftfOnE6)y_5PTi z7-lamWWBTan_oH8gT(P1CzYoyuIq0o=3U--!4mCBlTyufY8Db#4Bzp19>2U2%-T~M z5NP%>3DCVa^PyT`f7vk{t)}3iO=Ui}XdU`fC`Fbk+ORV%^|e^hYM0=UzqDE&;bQlF z=qCBr@XRN#qF|F#Y#=!TS2os8E>MHQKu(JC`@N>~)rtEn4dxaHfJeRl{M=m3kV0M^ z;2Z}qERLzA;spbR!7+XxUy9<WY?F>+aFmESltGP|web@~uEN|QGj00FcpoPoW|@ml zTEA6C%=)=!`8lQBmnIzJ4wZUiRx}(=OWGLWZZL=;D)+vkr*ma%CZz{<=Tb|Ov)$w> z0;)OzEl9{HgY(_DuCaB2=0$8Cq1yE=iuQoc37Xx<fcQ>(@ZT2c`eVDvo$T7`4X$S! zt!9jQnjZwcAoV!0!7{?Y&YK(y$L1QuQd6%Dxyk0CEQTW1UqqA5&%d3WQ}Wu<$^qoo zM>nU>=uhvisRUISU}ZnPc1@N*IXRwV->LQG1#QdR>|t2q)<Vz<z^2QI@xso7vVbg_ z0y8sLFtYiNJ)5Tv%XNRAuggdzu{}JGBXtcO#Wf#CdQYCjj@~UAG8tlfaVm3=h9jJ8 z2T|u!OTVjhmi~zRmJXByAeu?QZASzWg3hF)kE<&;oSbFeXIRxN{zwL0J2?c5!fMgV zRNaw*Mo6d-aWvGs7IVoK4VmxL8qV>N-%9!(_m#v-3I$Xp()lEYZUhd8-B{l5twp~T z45~6}a%FZ9!emAu%?7$>xSqAi{$(jMep4hrsJ+@@#PIt_2UFUv^Lw9IkFZ0TCi07S z_m_FGuf@Oru<_0Qp=}nkl|)(G%)m$Vl>w=PyswJXn9#^IUbV<c{C&49HE&*0WzkFe ze3L!oeoWmVbtvSrUvoU&a8trdkhD1BD?Blt%d>NqG=GY{F;}GVMlxy(Wa{Eo&j3v5 z=7E%Xb01%2NLIA?<juv4sA;?9Ws4(KtghcH_M<V2Vqt+DMMdxcO`UXKkWhfaU%i=M z&QUPius+kC$FiuA^~k8<u9XhsY4Z3{d;*wwToE=76O4p*AoK_L)v8s5Arf319^j&q zL8Y?e$wpE>@cmi;@f)LD8owXNW!FA$DN|6*4Rl(05Rm*+MN+gbh}pQ~!VudJ<9N87 z`b?SX=P{Lh%b$X7zME4sKciI@)UMcaS6KJlbuNnh@5grnAiZbsM3nqsK)G^yr`?L; z_v+|UIZ_&Q8K8WwJUNxi9?xOCl7)dpR;$UUd_~7%8_#|DaN9Ji$;ELIL$w-*;h$mX z_Cg`7W<S8j^l{0)e1L2G#wB!(T>Cwb7Z>g49zT)UW4q}Q4XD!;27`$BaJyhxoEfjy zE2qccFFoOdzx|<l<p9;2=W+#TD2YaUIB>v@b7_&BY;%FQF(gpdkxRL0>u&1lI~eVF z?aF)lDQ-7$j5+dDlQh3K@87KbuJg>uw?12DV_JO@epRJDQjZr~($>e1(LACeSQlR@ zp$y~PEniSU0V3qc7f2K2E3xmMYwPCat|M^cr^=z%J04bF3!N@r((8MeGeXyO?B12a zJPcAA7~R=8P`{2Gqv1C{5rN!GvQ%y#O6wvlH45)9I?@mSxa@wk7BVjNpPR#(we671 zHSbu#=W9KDH6oI||2f8--i!VQ6ig*W8?a;*uGlS#JWjhYdaNkcXtD`;PMgB7SmKF_ z8=hqa5M3&QRrmFChE~G!$b4&zH`zSm=5&m2-pPJ}Wkt#tlKf=bV&Z3eVUi>B7qiQ$ zJsnU0Zs?Gxr%Fb4NYumq>F=k{iG6ts)*jSS+&9O?C1WaaIukQ}$cr0)<W*kD)OCQe z*x?-sY__DVtCNYD{M-DE={)necG^^4(X=+ZYb{q~p*~m7dDqg2>Y`28!cGBI8p`_( z@Ag0Lf7{PG_p}z;wrJm*g+(Gl=(EdJoAAJ3{~P2`5T&4NjLN*0YqWtl<!4GQlx*qY zAYp-|RQj8@cO{;}D-Ec(n=!0fUZJm5ynp|hEl~(s<7Uq5`n%X9gBp7T8cggr{9@(3 z7C<8WFL-4Dy{x^qsCzJdVAf?+YRP>&I%Zh?GsP6qNYrAbNKEHKLOJut&Gn3gYIEl| zA9n)yN#YPQU2)v*{y=yUKld#j?XOmcHi7}(ratjU$5mnvI|}wAyI{2{oH3p6af|FX zYp1bw?t6rX5A4P>KeX{agfr6u8~b_Ib_bPaP<$o30~W_eRA0!W{O*Jc=IY&wZVzb6 zUpFXJypFNIwL|Cm6L^X3P*{dBw;ei!*zuHJFn;qrm-~kt=O3@6lpjcp5K|<m2gRpw zKm2o`>v(A0t|_pao6AX6$gDd^{fP0RpAkrZvBMyWCFL*DbYh^QDpG?C|0f;6nhhDL z!C`a_fQPa7F|i{IaO1t#(_5O$Y9B2Hu%?vm(vN9{vo1?LiG)!twX|1q?`x@`zU+i= zuU|Dv^F}kod@BPH^ci&j*tCh3?;cl07X=0%8>-`@XCpX#-YFKK!vQ9GtB6dhFFP&T zB#lo?1<g#dDqAs%6WM<ENhLXlVywR0!Q?uwpJ8RHz5*wJOM-%ADmwT;CnHm<8~|cy z_JG%Iq5;9ra-4cToAz4;%h&YvELR(Zz?>+Slp@IHx9=~Z@ffmDjCe+{>vSiQX{rAO z6*4kN8Vd_i1gDPrbmF~YA7=Ht9a{43k&i1hMd)QIuP<8Y&#>yohA$NE6;^F;kRX^W zo9EhSEtdzo8P!`Tj~~*23APsq`nZY%tvrRqE>S-;+&T<4waAD+oh~@B)SXqVNn#%c zSkkBz%Z$$Qv%I!P1qDsL$6LH{Xm-R1{`kimT?RysHloj#u}w)4@?rXLq9#h#Q-e6` zw-J9gZ5&AIy4KB4B0^$%feS0danrJj#SN?j*m`1RT^G%TVqNtMKXXf`@`l|VSjR7T z?7LY!VnX?U2(^M`Au*QBXh__DMbIQE56P(mYl8%;Ro+S`Z08c(!`gqAtyx`{pQ(3? z&*5U%HaJ}W`$kx+DNTd?76=OD?s*5A$VUwTP(=@}P$R}M0BW5|_T4<jZ3cyY6a8^* zbY2>pY|=m|b)sMp>ze4yE78BE%XBsNN4nx~vzN>D+%oOK5G}|aHC*SA%fG+V;U99; zSn#N%!~emw3c=anYHheT&#z`s2YmEmCoNCvkI>z~C>$U7>zCHU%#@})GfkPn@hw-D zyJAYg_Xb1W@<2g2Vk`YNaeQ01f+orIv@PNU&%rvOPw(-5QzY8$M@>F+n)4bgNxl75 zc;YPn95*u$lF0E3-9wKS<i)6IQT&`!^~yzg9xf3tbB7Cj^t{3K`K*I=AxddAZFPSn ztWaX^aP~s2o;ut>GSu(Q=(|)vKWtO7!%?}9-;`R)C)kdpvYa+_VL7L%<h`>pU;WgN znxcA5hKzwvid58{%V$k?5ib1xG;c>}GOzCyEzAedTVgQuMKr)b#xZ6zNCc~0onnXn zdVh%2-KwCm2uyP;P;~{qoa@U-4yW)_)<XlR8U|ysO7RRYc^OyZ8|)O@;gAWSI`g@! zZbVnt*GO6U-E~ae{qqYqCUN+pizx$Xs}Xp>RQSTB4(JK&H<;}uL3$Hzx2cXrD-1#r z{<d2gZBP3jEI-D75TH@fx*k}lvV2_>r(+aAQMoqpUE2~%<s&-}^s}q`#$N>(Ku|FI z&8oX@GtJZh-0`<N8ls}|63$51I6x-l=u;YVf7s8Rs(Pv<>%{(XA{P;eF12jBpFG}C zqMX0VzyF(kuvT5-5iik0HSM!8sWrQKSsvI2@uV&CpGBW2M~Nd%I(1$5^jsA;G#Aql zLw{-_0D4**LcdTQt(@Hd*9t%F<g&RsKg8z)kuUZJA%sia;&r)0!fO3FByYaaOBoc0 z*WO-h+6r=bGcuF5CnJEUv3af&lBG}5@fYu3;lNZ!j0y|A7T7=LJtZn6hI0U`N~NK4 zNklZIzH?Xa)ttrM^z|{9C$`0U54I68e%sy>e0}0XnoJZMPnPcc-R|;oJ0JDL-EXf* z2FWn{&m2+!29ob=Y-T;#I>Dt(Gx_zT>}Y;z-g!lK)L(Lmtaug=FsDb_ClJHKGb+r3 z;K#(CrPoTa*Qa!fO#HVd!^8A-bvJ+0mI0kl2PWpS3R7;$>wMdV5fcN`>%H|7uICqg znIn*RSh}pAn3dmC$IhBjpt?wnlGVhIPcN=mq5Q#b(e<XvLkd0*EBzrYT-+_nvVV_^ z=P%MBv6r`dHS{TRxq!LnPH>m&dS%08)4@x(km9@Do*C4Rtum)7JZj1QLvT_aWy%-F zhv9&+0q`WDyjp0Zc%BiuIv{?#GG>fEs2Y*c%d~3Uy-_~Gi}3CH9%x>XRR^bv@Ou_J z-`{K@_8E(q3~e_5I{Sc)v3;ZL=dT@Z_d=`fK8dRJX)44GW0`@R3TfJ&mxk^OIp&dZ z;{Aq*8#C%P#$Q~2{q?%!uXBOk+w%ZA&Mzf@KYaG#eOEN_y#W~7z@CShcXs)nWA_gw zC9+AF2yaJ<)|{dR3h3jPtW2j6X*p8D3?*5&p9BgC8r961WPm+E-d&$2ZuJeC${=ay z!6zho%l4T7VQ3r_Wx?w2FGc`<+M6tK>rf0m@RDlb(ko_**jEljX;vQOzleu*Y3yVj z`i`ELL5Te43^&3H?sD9AYchH!qPz=Hdn!B_EcG?<@g6*LPCFxnoz=qfsNTWWt<R}- zPTP|1`n%46TFbWFlt=2!jydktN|TPiCf+yl@3lN)wBblO_#IFQ@Yd~F=JX8_&`NAm zKa2x6fM%zwQaH>f-=30;CG&>445F#m>-Dao=IhD$o(;fFUFT--8PiZY_08Z5V3WO9 zp`&CHnG|#tC;W@wO_<g3{nQ1*MlRq&&BGaub=XKBT;H-^)Krb^9b@?+iGo|<G5cd& zETsWqR_vY=+H*Sar&rN+KPs)=Qqj~)kPdy~?TQ`}y3h5?V^ooP>BqGSqsg)JU;TB7 z4oj%UF<e#5QA6FOJopAH)##mh{ma0(QFQI+SU)L#4POWq=y3*eVbH$6SsZ+9$*d0W z*SYp2I^ff4V>Ep5uotbY&ex9W(uqkmaQ<~r`6snt1q}`gw6Nh<r<RVy!w|kddHXVX z`<6G?X>tw2vOM<~=Ww$*W=Wi4LAZExBgaNK;yT4@_ocT3LAe+G=s-C)d=lI&8jo42 zNe`t7B7S7g8jrsX$>(ZC`dn4PSC0WR2SiLa=Dyc6zQg6=r0b6=0Fwz0u@A;zIe!84 zMR~$4-yLX>68p64R4=`7Nu~DqsO{)b3;qMpf-8OR(l8DGb>!w>a`ShdH^rUUS&jxk zMgv2G!osL6vLMdhzGWEyi`#eqYmqe!$}Yi@-3Y0N#x`zS?W&dQHyu27bYa+FFi^BJ zLS)da-S%!r4{Jdf8C=0;0tTjLN3v~H{Qp@1@=<g*bP4(0pXiA3lOS&^<7_Gq>$CzN zJ%LUnLMkoQu9-ru?&O+rc0@m~?L6;7B_iTb0c8E3Em+9r%=ReRM0l<Yj>7Eo3=56R zZ$JL95%}Wmalwk%QB<+ZOvCGhZ)my2-~c$^0uXC6(imWe*$UH_3v2CPqrL#XcPl|! zS~y`lrlBrk(P>nWf^Jr++nrPqHg}N;$x?c!E;uM~n5m`Q2UgVlc67mo=i`Yd^S z0iB(ciW$kPogy^9Q9+MX(Ls<if;<n-#~mPI|A-;X??QkLHuM<R>2*c%-O5<O?j1&( zH;xJEKQGf`76;+`t`_HO_{ezGoKZ7&v-feNIu<W4brMMZ%H&9)rjj<ET^EQGbB|?` z`_!Wz`zAq|mt6i0YgtPXjm4`A{u|f{J<0lKLqQ^S6Q@0ue+J{er;9Zz91$rIFw&5X zskB}ty?F*|e)p0~WY+(jPbE?@xWLi3;_$oavIIYLcc6H@OWfk0+dD|IG%;D>Uc|k{ zb|H*gq>u7Ew{_XhuBNTM$r^#B>l}LO-Ex}WjI&@H*nVx<0PLloRR4QCCEVdxMqj!D zCt00<4~m_tUH~QR1VsM;Y+9L4KHl6Z+SZW}25S88*YXZFy4h|sT;(TwQD6$2`99yk zyPhZuC#wi`^;inGL~bboH;Po{17v~FLL5>N5n_Ik_{YTe&2HZwsP-Mlz;tRw&;vGX zJU|bLvLU~S!B4cM*Al6yn5y9%h}Y?^LS@1f5`b!i-$Ju%QAI>t9YfH6UF$be*#sH} z9qNh@tTGGyKuGox^37x4yC=|c0QVi}nXK+r&re4qT2|B`3G3jtmO~DXy6;dxqT2VO zCB8G<i)~NiF5AYtW8?vD9@dzQ@dl6~j#NIur72dW$Pz7r@}F!+sd-{mK6MRZ^A&3c zgVGuVq)nDpAcMV7BTZ`#%KSdjf76v7=NHw4S<(g(m}t5VMVe_<ZkK(lQN_B;C*k(9 zJ)4#iA3gEe8L8h~(}lYU2)|{N^oJfu#9IsQ)2koFbFx^qIfbwqcqi2LC<NIe7`%0_ z{Hr~0x&H}PlMAM0<@#EmOa{wS2i&!`zr5Lcms~5$WXrccH7$8pym0eYRvylU2XNq_ z)g-K70tfs7>mrrD7x(-FvZTOX{1|aktlu^5oonKVK7nz~qh?{FUBkwsP|gQp0rje` z_9pK@?O}~gYrCIo&`kvkC#O=?LSWT}x~U&J8c9UgdxMm$Yiy&C@$OCIe+_0o3h@G& zND5uAKSdkts2ECCoXl$>!ye}+akgHEe|fGA{&U$a2bjJs8%XUWfCO&KJmmtKjEbl; z`&h4f6Qz<<f*_dP{y=qcDl!3ArWbN=kCnuGZmd#w%$j3e*1i?21kYhIF`m9$-vqKi zLFo^0s)2$%VEbKGvx=G!91Qdq5c&nzc=**VhfEMV9Wp`_>|w(5ZjY|11l2I%k~3G* zDZT4I-%sP;&8#g!?8_#%0z*_Q=c{EB-z?UB|FUltIL_SDa?gVYU_kx2hcOj`Dh!z2 z&a75Pw*fp)9ZRH)KOomEr8`*=KB|6cT!6}U(U0EgJA!i<fZeoLI{t3N=Yv~YfmhnV z4bz%7S3`VMpMu7eJTjk*nQE<>l;bnP5pKeP-&+q|>10dZ=ZywX&J^#L3o7AG2LiTE zny-Nu$V5eDZj(-{1!AM)m=KsWfWRX+vOQ?o%B~>lop}K_>}i)hGudxDk9B%TAO2wv zkU(su$4E!Yd+lF3?_0i8;-F1?le_pM($F5|vLctR@`|4^baS+$oZq!_x1;X+QRsHa zHQKIzP6zlu3XKYvPYRH77ua$;HxI2`7g<gGGVJ|=9`!CtD1VP8^Nk|JLn1(kO8OOB zYE|Q9kk{z6TS&hp@NtDK6S}4ooJvsI-(c9qe&dht87o=T-03U$62P6%C^2K#Chk(b zb&0^B_B{)Sl8e)hKVR{@kLeo@JWN0+H$p_3zUx0b&^iQ&1S6Y#{`u_F=Vi(%3q5JA zLhRH}^+&QCuxHRrV0@X3H@f?H@l&(XPGx)PzM8vz$sdR_%`fbcs0TmtHC+LWs9)?d zCJu&_B}Kb^n6#;D)DY<JNyf1D<GnlDck@Gt5I=7C`S!`g+;NR*`FW?)P4g&Mf%nU6 zA;%hoI_3ALrUl)8jk%8~PfCE6>je$=N8;E$=qyTl08hjc%l3)ipSAn9H@3FcE*=jS zgBgbImA46ov&NF+l{I_M*~SWLUN&K_LN7if$$lt&0&i)PU2!_5hTrXzoljj3$v`4? z)$M(45y-}DUL~xr$u?Hfav~J|JdQc*^ZWk1@AVDg=zHFbJk8@^Bl1YQH)~By;npqW zFi&=cmVcU43u&iNkk79bwu$8r&v4n@9hW2fUcesYVxF)jk;)@mH8l`VS=9mO+MUoJ z=RUeRPRbl7lu7i5W`d~?7!=6|q%e&JI|{qiRfzqw`>?(U=W+C`J{ict>l%32@{V#) zrBeQ{$96+?)L%6=B$O3%h0UEe4^lrBpa&Dl&P!9G7xR6XT^<WsKLpzm>BD(c%U%RV zZO$r`op!k<7b`U}cS@X*cdl*C*|p3z`&2;(+qrq-xQF>~`SA&U3Ji#?psof?WzEjI zC{(b0u}{5Vg%n^yDu${*&Cww#OjjTYS}{1`&d1Tso@ErKhr<$R^q%o8AjfCa&sQ0Z zaM$W~j*KpDWOd|W<BQ(#T#JZYl9u&s*V+Aui_LMV-LS^16u~ZqiQ75CR!yyzw1p3f zi2P)+4OF-q)AA;jOY(dYYqorLj`XK`#Ba#x*O;T&PxZRi!fnmUC#mcC&U~0y13HDi zP%c{AQ-G+|2*6igvfP}yEzaC;VLQLYw%LK}hZZ<Z`5PXuQ;hCqOe1u_vA^oJIYM*2 z0rdA>LE+9Y=m#=>S3F5Dp9Yg-skdBrX6DQROIoPSOw~Hy-S0y2?~=MRC_L!=RS>lo ztI*!)agMavsEsfAeLV_NmF430r60U<MzHI49f+|H>g*d%SF~<>9bC;b3J2%?KC*9? zF8-L_QEH&1oA<J6pr_}JZErNw8`no>i>FeE72^H6G}o|sxt<=ptLVjo$E>4M_d3~V z@zSJEX=;LV?#EkQbFG_Ug{G)3|50*`b7PLg9Z9d?aQx+VW}T!(ajom~lkP3*1Q2-{ z=Sc-U^ft;B&u9r{;g`LD+xkKWr$E#pCyQV%%=ldv_`l2(oCUtFA>=_3f*_pum*&fM zIg8eZ&U;xBxGI{`d-*#!i1@X$b~c<2V1v}YQuWg$*<&XDU=K8r(<4l&#bRUpf)?_S zzwt|Ne7rAJv^A3y1V0Z)%7)`8#L-~VIh(0xJKf)E7E)IM4CJf$a5F4PcyDRyZC~(y zwhXwAzLdwDX#8%SRp8J>pCJ^^A&5knp~2AVJ=J>ikn$<P_k0wf@frVEJ-l#;oE;ZW zna9JJbnWEShk?#%L30A~g^LeK#myR#)_CLokKZD=PT3`(q(7r@uii~0rk^F;-GDb2 z|KaMdbc+V;Hw|0>k+M7KGwa_Fc0IcKWKuWMp3#R&sfyBRkQ`_Sz+(WHQsHuVJ~`P- z4EU<a*@D=>UnKFi2k{%@2EK-c25cv6o3R9x@q27}#WIU~OkJ&7#U&M(V1eHd|Hp3} zk5SI6v60h={uQt!p}c>7w^vxnY_QZVrXL1sltw7%l3gB2<P77~x{2b<q=5~a^`7dM z8J$<~n(dVRT<AqzjCjW#i#y7T#60tDw8Z%?TH<_V(-^0X=WES`Z6|1hXYzr%HDLR% zID%Dy@g350eXMpN2l^ZWck$x^o27aCfgGh<z6j-Gi3!D`MB!Wn%HKtp=XTKgpNMn< zn~KOuYKHV7lg^&jh;GjiMtA2qj(iWl)r%_pF`|*CyR~)sfFe86=j(+4{LY0%<_|DY z6XwG1UforJvp~t#hy3a+YyIrI;X#`%<^?0!w;c$m9ZiR8fqC>>mNJ*yf+b_V9xqi6 z{rQ{Y->6Q681)S~c9SVt29T@RR01ejW{|&p`#q)FPDS)@{IEYzXZkF$Pfu)$jhET) z#*FpR8^080nnJ{GXASMR)GX8~<x>kM3foYsY;z^-hcR{)C#g~DAabOB;{ka`&CNS- z7z`!^di*<R$|V_%bSco2e)f-ypZrg3|2^&)WpF^&m6$;vQ`j8gwNLi=X0uOcS#+b> zoqRY_W$}@|>>o>3X>KeJ#s%*W>{Qkky%`n>1H_3|zvKpHKZ@cvGNyRyrA8S|LSuXL zJ+J88Ne=lg>=t&_p?POzf~(8Jr(w}11aP@LTSSBb5S&Atj}ria_t!aOkgFYZD$xRD z8aAdEVe2{X=;0jAh`QUlwAvX_20#kr;o1bqO>=1bwu<R2Xz+6e3DzX(C4Q{J*?0Mt zFI~5dTJ{k~fu#Cvu{>$P(95u@emkxrV+Q$CxYl0m(v(XITjU>FOfF#jp93)RL!+kv zh%r%v{~LqVHgH~+?rqx90mm1=W6iMz<8F(1F2ASoVn>6FzKj>%v?HbCpxCLMa$~8b zAR+27|9^TN+)Q_8q%KS4+{JU<u3@?cr`lp`!<)ajk^gS-S+?-HZgy9zBXtlBXG_y6 zvv%~?<|cG;3*2dx+Y+QHX23P>&d&FWy>(t2BG|!*{tVcm?7ZQzG`~J|1X)2Le%GHz zraT67Ou~h%$)7L3IQF|L@jM7}N5ZV;96+5^a%rQt@90etQ6W~CXA|4~QJY8xkweZT z0E%VzEsyw&ww$S`M*i-`&v81O_IeI}jw7OmXF7sVx>;tZ-`LMperCa5o)>VZ%+}_| z9%6&r1NH;iSC^b#m*K5+)-Ehz01Xb*Z#AGjS*C}8+{?lx>KOMcge=~=axq6Tl{eBx zP*QqPlgej?Kf^<IhdT`cS|i>m>-Y0=qdrp7nLHGU=Yr>Aw2;FSeR*X>+f12^mj9SN zGk?xpYthKa`qs_LyM7BaT>sIs$I-aq3a@?*!tEI@WiXe8#1$hd?fGl}v3J!a9eN@$ z-9@7#_{9d{px9uL0gh;cDB-bI%pgJRDM%ziEI=zn?h`T1Bq_8;kv*9E<(7+v*<+kQ z`C|NfjAE^~X)XxxtF7z8Xub8CJx=5HKul9qv2y-Up##R%_ORvj`A4iBf!;^Yy-*3^ z_H^G{-Bd5)Wk5lgYeN@<i1~`~9a_d!{qeFrwQI7(UPiy~9+VDn^;M@R<h=pm)9PwS zW;G53|BV(s@Nbz|@UN<*Ki@`#auV!k)F>bOpMQ9o3lZ$f6=9}_L#j)}UY<**F+ zcU<~Cetr0AK)ky8zK4!rHaTf7RbM>wS%N5jW}GkMC6?ucj_#MA##LUU^@+>&_i*0< zR7hU($2Pcv6-N$KDcACp2oi;ZTd80GJs{HX9JZ-4F`syXHWu5!c=FoO`g72F43`+U z{%%h^Cxp8=0;Njd;A1Bo%XgNT7`P<+Tt%lelRff?+->M<xyLUxe{OB@$7~5f6tNlQ zK?e*6`(8+|06t(f7S<ZrL3yGcoiviSzaff7&qDa4Rz7_J%n5jx6~4(Nw~3--$0#53 zT64EY`PF)Z`j6)$e!?5IEyLxL#A>MHQxyffoj?d70rkk$d@xRkPfPDsZBH<_4beHV zAbr_u9wq!8Xi4Eg9}lAm?{6j+GLG%(SYW`^f`enY*`2xfY2&Z8&Xu!U#E8B^nIb^v zKy7pAtXKvRAM4@oCm-WKzuBu1hEyC3?wjtJI&reR-z9tGJkc-!<VT9CS+8$Ohlnf} zo`-3Qyt?M9trNYXMn7u!7Hkbv@3G<0mPxTDESH%$a=Ih;`rK+p@7L`ymlUMQpVGbM zsiXzvV97p^2KsB06%I|Bvz`^Eb$1bVj(uA9$Amoe18H_@7Iw_`M;`1XMWf3v>}ZqN z_ngjmM^bG4%MYGkKUaBwmcd%EL(Y5gJmF5tYY*t$c(17B;%TavkIfUsPJ{fh1mwLm z*`a4O^<-P6(!%fw><u<JJ-SElqDForgb}R({Of$krbhR1nJg%56<HqWeo2G}fENXM zj2CE=<quuX7w1$RvW(-!BI3*UFCE_(wblN*F5R8}^R^B6EyC8Q;^0#`@V=_fBXCG& z%_@)<-BqA+rL?xtAtekhx~47FHzZW&!v_WMkbMn+|5pEnBB+>%nZt8wTYiJ!hv*>1 zANsFRJ@Tr_zNIj0sBl=@-@-Xwx2A&8RyN&`>%-$=7K2;E2;RsNC(@M>4u+RTH}&6C zfKi@~j#fm2+<Ni-aUX~cik9PshIS2)Uf9-kuYSgS7T^Gq5GPKy2IfRY9~x1*K~@RJ z`ieN-BCU!jUp;rGa=I2|G9r5f{E{_&P>}4nglex~Y6YkUONARK3Rw?!ase8Kr3p7? zjm<+EgkOzGy-4J!emF~MH0<E~r=G$29yh3q%CCbr_6iXwO34ZOHsJQjkjeK&3*UUD zkS11ELQi0Aal*2Eju!~|lzgYZ7u22l#}JC?G+)IC_-<R?AWJCxgeS?K407CC$)hRi zp@;2PZ*dFT*dR!8h=y#p4{g@w$%_IZKE0L!GWb6j_4_{q4zCJ7t6Z5xiAV;;39eH9 zDu6*^4nG7gF!~AHMQcO~M5OZ^G6Md_L_AX{I^8neAR8enLL~?>;7f!{4;cSR4<Y%A zbi^Pn<}DhW2FO=}-A|0u)*z<mFs*o+g5gG3<|yo3-*&n%iY4q6?kuj(eN!1QX!F_^ z6{=1!NxLbYQKEJ*cG_}tFo|QyLsBK@ukbA?NzDS3KxU1N_37_3<+*VANXPHC9w@Hw z-Q795dRle-)>)9hBRK&son8G$8g}qs^gJ@QKz3ZQC)zlVcbLUtHi9yt_#!kxD1ZFF zc8+(m6ytAqs_PNKbp#^+i^U5pjqfEi$Z<emBP0P|L9KPsn(55kC{vBt65fPH*vG#I zORz{z&~_K%+BKKdD33srB4P}vn(%wRBhzk$vCMFAwa$2FT;)8Uum=5}222qE8O3fF zoUpEK29}v}2OQh?h8TYcx1*ZMG~rPzwGLlnfF|wyDLHwNZ;Xn668gD?xK_M;?vpwh z96C1U;4wfggfnMK<db*cFTg6h6i+|3x^<`IoT2;aF(=vU@Yg1Uh3B>57nUfdKwk4& z`5P2<SfKlOgJzn3M~fLxr3&6(>iuApr)&dsdc!EU`NN~x<GyNz#j7cPPJ*>Ho(iRp zT?01f;ohVMyF000koH9fPe|p_A>zCSH7nnY1WIQu?#W-MJGI3ED5Jst3~`uR6H?ty zmeXRuLOIg+`L^x-gS=|2G+Y$ka-POg@(5BG{ySLz{j>)9AEFINnwT&D$e@9eKPdh@ zBt3`u-4)49hg^ClS~rY1Qp2D3x$bfas~{t5N4GAdutxWY8&+CCx0r@<#TFQXwlAC` zff9vaLJz4VpCKK>EqowX?;2xY!%^aW!D|k-q41-|ramaJJaqZdiB0(|O`9VuIWMLB z76X)CJB}2xe&sl1oXX}3cw0cRvrMIkBi#D6UPm}cPqiR$*T!a34xW7g`&(m2V+-pD z1f@`7y#qcES)a(U;#{#hWb$jhPkSQps&s!_lT5wIyLV)iO0vPKyf>2fCA2Bk8cM;Q zrKD<&@YfzHUcyb1gh$w8DtGqV1eg8S<_7ES9fnPw)6azrL_lnWzJd@&6^bO}w4Ps( z|GliToisf>G%<mVc(;H1Hm1sI^kQeBX*Ycf5kV17KBW851onR~2`?WKU+YwEv&IU| z5c`@K>s-%KGD3;_#6{XR+NI)6{L06wu)c1ovfA)K8u8uk^O4)yKaWKv3vJV)i!vX# zTkO?lk5Xq}fO~l*->+@JzCCs3D7^9ebORe$@T^vn0TuY1tCF>+(ChXNK#%g^XW_8K zU%H@O{T>xix;&ufkDRA}%@jSD7QV&EGjv0`X0gnlRw9?~TG8>b5SZ63&xZ1!ro)m1 zFAFhXS^oBy0PKy;5(V>WB3V~yWzHu3;9;6IOX&&ILo;98SA>M;ReS0$=k0BUFw{3^ zyjdsl-a6^MUw_VWMg=t7k@<r*!V5XedJ5SK=Nw40xLkhM9U>}LSsASmHJ$S(bx8zh z>qHedJSaWN$pxgU!9k{YFkr?xzB=Q$cNyUPAZTgB@Jp=HoFnMJ(}D~N^oP&_-sGnL z90ns(N8jQoMr77y?$&BOTq2;3j&(ii0>xatRr%O>&SC36@M?Ygy-b#MqOx2p0>1ef za)rV?bEWskL>&~nI|<0)rXl9^n9p30DiXdY>XG1C1hj=W_8(>@vxVE_%f#@1tOzRD zq*VcmvwzZlG#`vKy;=$UlbghC$5KUv!BlIZ6rZ9~s@ma&vHq48OcwahK8w2ke@8?F z%l-okDLc1$`i?vnynn+_ly>@qK<`w-;4&zKS2?@dK@$)s2ZWU0I>Q`OZ7b?FZPrzG zN2pYjn&<7>0%yzXdt<dT5Z0S~63LkvaVgL>3jmLqG+8^mEPJW|>TRX$Np}65U)}Vf zgF(o5<YPMYzp2P#@EMB#yF}>0Y$Q9JCCUi>UPrg(Wjwfi{|hw8ppOz+5KH$kE9Np3 z5aRodHQ4&`hwyc%pI#Sf&!9CkS)hTYVw({mf`1Alj<34SG)*M%W?Pu6oCsy{MZe!8 zkddcx%4)$an#H1k0;1z4(})fsJT$W(>M6Y*47QWZ4BW_)A6N0hlMGudry>m_;-FAd z<(64o%YWMX+5_rb?ZEA39Q<#ED4+=t9Z_!GAXT=yfP?gv`{J7R(aGeM*Zww6jPw7k z7QtVnhImIrRXPa|{1gI_^rcIRUt|puYRPgbTSH^wFX6E+EeuKJ32Bd3LP7<cz~-&& zRgs(?^F)Kfx~Ha$n?0^wr^<rz_7NTyuqo-iV=C7JzditqNWC)R26O+S5Ul(3!sK5y zv&Fxa2)BYo!E!&94^95~{kK4Iu>0{SGXIqvCy0k=Qj%`wd-D&T`x~_<UghD4|AQa! z4hU~EfdftgXb6da8RCTqkQVsM_Oe8SJWzdn5oD0j21^_^S{3<q+*+SAeP3B$qz&+E z<(t!Plm3QW#G(FeLR(#Vo2L_vuvon`{)|Ut;QVG!G(v)YjQpsz_Q*(&_lZ0bp)A84 zA)s|wulG;rKOnbSd;hQZcz}8<JEfLtT|V!TCz{81H+mnl-&-R3i(wB~paHJSHY^hQ zUNKQk^-*#E4`X1$i#ZQ4&y-6ju|gp_E2we@o}C%D!frTb+3AQ{^V86^8o4G%{leQ6 z9kLiq{S(^%2>3J1DrvDmyiR-apa+v_>h4Zn8|P6KLv<0ebI0(X#d+P>@$YSbg72G1 zFux9UOitbwDG2^Lw_gp?+SCh^GGcUaQ4B>XHNd|a0rq=~&#VxF)bdmOV~L9Y0UvmJ znBYJ8#AaIN-yJT90em&|lqg{cRGeyj3fR-O9IN)nBa=k0SPQ=N^X!UChv~NYg_8^R zu4#_bxkrxlQ;&k41CFTyV<}&vH5~A=_=FJNe+VLG#iKt>=iHVOA&{W{uO{mcawiv( zx`0-2U#^0*LNUu|3}!axU*sxB?f+n89EE?(FVtNmc^*9Q7Nz10of#U%s!A>0H?m-a z&aIsj9;}o6$Fun0YR5LhKhr^*fU0WomU-xJ=QZke@ri6LkMq^*d?)aLn$g#BZsoGp z)N8MFGGKRD5}z09mM}%U1sZD|GHM+C=qmnF+jWM6cap!?hugsN)}M)HWH@HAI{}@F z@1tcL2m?gCuCYIV=nA5wj{4vB5MjV!0SoCVZ9Odz<nS}5h=X;9X+4E&lNd@J8JsGl z+P)2QcX0Q@--ER%hUArJ*_FRix$yMEZG=0qU3hZa`#W_|*1XQeKHvYn7I+=NrArqq zQl^TSLh47cn0De|y9eR1%XO~t+*!Su^jQb0t4I_Legbwb<`Dl<_DtR#{4W_X00L^> zY!G$!UAauPL<-hs(Q&{t`QFA0%~&umKhpF2YuRI5?#?3QWB7ag$8xhRB#OaQia+fa z?ebnp<1dj&mS+zw%b@UDlnQG!csK17W4gMlJINnE#cT%)A&BBqw?`nc^DnD;+li+q z&%-i+rdei++S}nMnp6livR9Qs=@-+pugd)eEL~Ro|6ba@6g-@(fr?~;j_!qX?#}pR z?u8UOW2o`rx4jTm7Wa=lJHc+&@++qMrq4r40z&jJHq|haZ|M(6nSWL(^mRJkRE+AU zPd~mqc+@bf^KM`<f&C4|;1_bwOp0=%9QJ@GYTSI<|KsT`*xKy6rr{8R7k77u;_hv6 zcXumR+})+P6ewQYU5f;Y6t@;D?(S}R!}Z+X_Yace+<UEAvu0+;h!siG;XH;fBG*%c zFX6%iV*l<AsQ~U7**W*{i4q5M@-R=pFeVAX^nJ{um2NLftY&!tOXRA3!ruCX()t_N zQar&A$K5EbA)F%0MHjM28|}||eVd@lh@bOoBW`C--?TulenRWX#1bZJ)F#=t_06*5 zY5x|C|NNUP_ebAN1gf+Qgj0s=R?s5igap|w>BwZNMNzOr$$8r4f5FIDxetW~e{lqz zs{D2#vGUl=fP|K%B3wXn-!6n*+PVPe6yxN)xI>FlS8Q3Ygm~VuWF*g%&Pdw(AVY4p z@beQ+_(3(D=AJR-$yCbkm1(kYl(E%P7XGHWQ;Bx-jd7r#9;GOgG0X}BTykjAz(1eR z-d3kfG2jVi=a5evk-WByqm{Wxn%KPB|0XeUB2X2f{`ci%X$%G=pe_nrD6eY3MVZ{z zEJnwSOt3%;<!BhO{DO2~twXk2qPZ%(4V*Pai`Exa^Bc70p$6T0$KI;VS>+nF!fA#a zHUP8^4rWV5(&yhhHTyq{R;jyh?0lJRa|vG~@@`vr>_RE}bwMQrY>H$sUCsSgn^NMr zm+lKatuhHgas*Jlxb;sueV>^^27&*^6{yzh2Y?}v7%37qMqJHwkWp};mK(*yShzY( z>3)x>PPomlRdGIj!&%e;ZtJ6Ik$rD;T0?zoUnAjag;Uk}#eni>OZRc=bvg^ytL>_D z!hzd_^a9X6tp=1kq=QQH!}g`rO>Cx@{A8d?6lWGfM_Q+dwlj%kH>E?DR&va!)3hhd zG?v46w47C?Ws6U@<t%NOME`4X4-|>x?*zzK!zw7)F%aAEL{N4JyWL`6AZ+J!PII7C zmbxGxo6h_$NZHR-TXBK`RW1G>?*;1!FkdRQnB6v(=$ks53<RZvtVk2KKv6|=<hKF( z0+-xS{Vfnx6m5#usQt@0=Xg&9(8MnM@YqZBh6%t=igG6b3|Qc5eE0B%Rc$b%Af_tL zRSf#dJgwUA;B23>ZM;+bLc|eZ4(w*ILBVEk`jvVwc^d(!s>Z^F!WeNFk&rnN!Q;`> zOsbd>x{?|Rs(mCkHih1*cCLwn+6{tAVka5CUA8EXHWRC4_9F0+^H<U5bu7>}mXf+k znpj}jheA!f26a%4x@D{e3DUyt4J<|+?(-*NeYPcjpXn}C@I;b6(t3AmY1)zLC$JL_ zUo0+hj9@)E0Pb`JcS~ogpe$MA!yCkh>Q4}$P2@g^N;O2h7Ak1UGJ;#DxboHlbgJyW zKoZ<Ly1vP=C{Ba;hkUj~o1J5E?WdF0aIRMZQ&M(k0js`u2A8pL+w1L*{Sx5|=${(5 zh8%1EFifxPr&Hv|Ppa%Jpr}yj`~!qsK1Mq+#i_VH&^3GSuzzSbtGWNVmLyX0@qKc) zw1hh%;|H>xC~>@#B)B+|N`6q$SEAZT08{z@T@cuqH^|zw5q7d6L2(lHEvGxqyx=L5 znn=^hR(%n#R5SKHv(xLm6H>8vb9TH4r26JbAW3_l>KSl+8dmGHr{Tq~dmV~o595V~ z3=lOI&<w&7&+<)b8vdmM?6+aJuGLIK<{>vIm^*a%4Y|~n{R<Cg>C$2#5iZ988!k?a zCPEACgm38i9UG8Sop`k+?R)VjiX$YK8BKY<e-f1tmP1k!?R{?&nEhM8S;=&QuMrO< zG!C!Y9)Am<0u?9+-1zd3Pv{LyRID-)nYOpck_X&#*yxc!fVycUijlZt{LeuFFc6Y+ z0W@}{z}+oCbEvgU4nHCs;EqMR=3GBi*3d?sG#<c6f3;z>R`{8QR?-O3>CV}`pfd)m zLxc*9CwdZxurteWr^7b<9(pRFzt#k*Qv{%(v}1v=sHc<*!p0%^PS<P8x>bISXDwK8 zgT+SkHqM2p&YV#5a6Kf@6LlAoKxwnBzB&kx10eS!=@{xVRs>T${lN?`(S-r?$-?Rb z#P79B_mXB4#7PvK<L@6uW?cKkY|?1_*@4b_T>PGw^sJ)hWIe!$knJ&9Gz8$_zuIr| z!v`iRU*y7q)>?mjI!0oK{xT?rVgLXOk`O|J#OE5M@UXX7;8yeh27V#-c!rD!$3vJ+ zKi1>k)7?VW)~3qUzzl_DegQnUkh3b7I2OHWBqnA8+Z!Uo4!2AqR1ez>f5Hi@l$GWJ zfS{jRac2GY9EFwU0&>2~CYSi6J^TEZKrYjCo_Td@<CDPzRNC0&3toWs^e{L&N$^<Y zYmz@=iklSfmn|b7!9B|%7d_Z5+yJbR=paLz-{x5bGORo4#o6~r+W+_YiGb`RX>UW6 zH)Ue$`lTlS&(Yz{4tg0c2&6E}p`&}b#1`|btWLeH=ws?N4?lo}{4Dw|e@v5Kug&#| zj8)92pl~Q&r84bBD>T2MaMK$o#wm3@FpJyKVUQqq3Lzo2+1}1-;<{8uRbGFM&aP)6 z^JsZjw*)u8AY@?NDTA#XNGi=actN{fMDI!a5rFkZ@zw3h66xIYX}zRay^~%0%1ISg zCG0{u6~XhDBD}(R|Lb*p)xWA3#~8w6IVvut*Ed<xIu#UZ^g0M_bWS2}vo1$!Cfq$M zo0|o^+l5#@v$s+{8D^VYL7qcRU)QQseXmTV5y#7AQ2%f3P$(_W%@<gufefhOG=F_o zjhm^may9-^NX@7|ROWK6YrFCS(D4vO7p`8llOBr0-{*}twHIlVxnAypJy8DU*M*oy zPxph-*DTd0tFNc(`3__m$a#0Q^{sC3`Nj81&~N;>sJ`nT@uFF}-u7L~?}lYS9TsG+ zqI}g|EUx>E{%m?#78%;CFyMl&gM*=O8n~twsbl0alp>+Bhz6ciNo}3K8cCr5Zhzg8 zDFK24`+z`pa12xgG5Pp8;3JU`&+_te(P*AgA-=IscaRS)a|pdbEopzSUtQb!t0}s3 zxQJvmkz5e(6Nr6jB4^frvN*%}Gs1S|iiq@<6IDqZDf{V(Us1c#Q!erG5CeN$9}WCs zu#8!Y0YM`RIIB;%bk=?j@Z}=<Ak#+j9Q|YSzV))2^IB9qkMcB032VYoSKzG?>>4pY zXPmBN;~EoVkp`C|R)9K=IisODUTR4!!(MNvzhe~<$d-u4OiL;m$|Z=OB5w2PPvp2W zieoJFAfDSe4wY(d-S=SW{vbx}``6Q|<t7-|BiR{~=xI{wS{)~8EAkkI*bTsn%zuc> z!eF;%4uaANK>-KpfgEhcp{HtFYoR@u_kjDc3*;{MM%lk%3zf)-v5M3C*v3B|>DMIM z+Y%_b+5NJUVLg0_0|>5H+-HWYFT`75+!6_M0~KNN>@2k5&$}L2&^N?R?MD2L(-Wok zugtdmCLd}v*9Lob^-soCTQWG_J*jtU(n`^D*!{(PHMelmoEtlfLF-bSRgxW*)ss?t zEWk~Tgx)N6Cq$ZTDl~(aFjI;ACPbP%AGeA=y(igYZ@;dBZ<oK_jp_}3gJ!o=CS-g* zGm`E?qG?^e1kuC=H4$+W?XC8R$5}m${UiLM1*kLsO=lNnK&hnZeoQ@OrJUrkPEZ0V zIX`s)?S1^MP|!y&X}42TPa2wFdCarj^|;0y_(Us)avdOscYh+rH6GAgjB|6{eJD~t zaCTWo2D`wl(Y2fKk6khiJ1eh&11H^CmL7Wjp%jqLdz2ztbZ7&G&blF5T;CH%a|M6Q z^l|aOPFf!3m|m32UL1fRXD*@NJSn*E5x<rrAy|I$K1%PF9?vmEyq+dQm_<%A5N75% ztzw;yQzn~1sm%etmL!t&8`NKXFPTj@ZGB)H#9a!W!ut~mZ0~ah9*)VX(W9xmqKe}B zU2IF;WMO&`{j6>+^Vo9W$MF-XBk)!s1BK{up+$OET@Nv8&+ucK>=<9S4W8ML!3f0B zrlrufA5p2~lNqq%zjb~JUMR4OLQSI~@c@}A#l0i=L9g#jXK{eB)1W+AQ8%|MGJo)S zJM(MKv`BQ<K4hcv?}vDR1s9_K9#2K!4od}YA+9+MQa^&1-3UY6;VteZ_)oZ)iF^Nj z(+318+@&vGLI~O`#CGN>inr3G<vx#BfAl)-eWxTm*w{QjXzy{^G~$ZCtiq|~E12W~ zkEKS^{z&lc(@hBBrD|`cP@1$DHW=8byV?wYxFi|gm$waQR7`y!0MjmeZr-n~_?|_A z%6{CwmIgDU&e;skb9c&;L8}4*+1u17?7+ygNRN14aY~4|fNKxSP(a&Zns)wmvNtnZ zAdlsTp(unLS2aczUb$t7z1~_n(h{YbZaPVwDJLXk|J3Je)8|k`Y1~*|bA%v-$wX1L zUgw};R+2jGbfVGLvgSsqr9Y|(KftQ_%N8NxXIZQU$hit_m(<CV`CxM@B_OMzNgxA{ zSE;cT`c3q>bFyaSP_faVf$uA?pVvEm8ET!om|(=+E-HqHH>QLQ)R4iqJ&-kp3qt-# z@zus=1{=`FgwL(L{Q@b>*bi`jH!+tyYGo>E^%_fWAo;>+EgD9l+%U>uPrw4}gc?J6 z3kxR8R)_n{3l&3t{B8nV4k6j>rp%6?jB`{wnee5UP8vx@q|4@&^$8VupvMMnwz$xR z571x@VBbwKUOx;IRvDx_DRARN?AqZpP7EjSh%bBHr8sjil#g~kiZKva_0mtKF9e^h zCY*8ITlQv6VSsL7fo3pu<a)Y?8H~``I)&e6hJ8T2%i9Yg)|UUxrQxVd9qmdD!T+D) z<7|w>r-<$3@ch~h7H}ZiLykX;6q3rR?Z?;<@}MmZfmcFX!zauz=PRz>(_VWS;v5pV zg{92VO<4o^TJ@J(U+#w)<0WdxZtPP{4z2(;(*%6kL*k;(M@iw`7S<#y3nixgeUlw> zAEDwERO?`p)D98eN5S_9g!fsoR&)-?osqhC7Zh4!x9IN^jOW_Q;L|w0E_N}}r0&@o zTIlj*N4Wn9tLD-~s~5#>M~kVk@*^#|aubVs44l?4Uoe}~&P|m9)S=!q0ZNmJV;3qm zi?>fE4_W}OzZ?50P2X6=)D(dPvpOgSX_BKC(>fTG6G}Vpoq#~aG=+oB<eMfvb(0|F z@$6YG*M>PSxA5=8=o>2M7|A+Mv<ebDIx>VMa|G)h1gJJGRvkVWA%Pvs7cTW!d)u+Q zhv65Ocu5tSzjy)m<Fa;%*a^}B2)=feUOO^O68X{~6WqCFz2Y0O2Xfh=i>?xZoGa!K zPO4_a8j@@>QERL2i@VR)*LyOKn*%9tfF&3)Z2TC=H4OWv>-}xEl=|mc%pud^Ga%r# z99dI(q#Y8N@i>q_7rrM!q-9{7JG+0#sdZ(T6X$5CPOq$q3$3QR#vuwDN`&-$U#XnN zbk=A{eWU-DQ=g}sFap}@I1wRV41_7(nS@2#Z7&N*tQ>#n)bqSU2>fs>{K|MM&C)t_ z`c3KR=lzGa)nCGx>u(?A-&7tUwQ+7Nu7Z$}BdRqMYh2Np$&yQ7`<*Cj1&QOZR&2<` zqTbJsacRW&YHlsj-VY4}3-4K(c<s19U6Rfo-q)U=t!WuS5iTg$FKs5I7K+d<A^=&g z<OJ#mZgHsfP}ts!sKH+9vzaL8Ub;-0-TML}duE=!(hzwD#d6pC*`VZ>r_lpZI2L%P zbh_l55z;B_aPBfMOa}(r4Q^uqD7UwHS-B5mq<Oodvic2{8oIEK$_!GA8fF-BQ~Wk# zkXfql8ux54!UK೅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 VOI4`A0;Z}@>cV@p`)~XEe*l=?BbfjI literal 0 HcmV?d00001 diff --git a/apps/desktop/build/icon.beta.icns b/apps/desktop/build/icon.beta.icns new file mode 100644 index 0000000000000000000000000000000000000000..d8d0408eafd39487d4de75b1b914c2e3854a8cdd GIT binary patch literal 378865 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 NPwz1-g2%;$eGZ-q;e%W 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 JoBw`p{118b)Pw*4 literal 0 HcmV?d00001 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/materialize-runtime-resources.mjs b/apps/desktop/scripts/materialize-runtime-resources.mjs new file mode 100644 index 000000000..e7f1effbf --- /dev/null +++ b/apps/desktop/scripts/materialize-runtime-resources.mjs @@ -0,0 +1,349 @@ +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"; + +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) { + 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(); + downloadFile(new URL(response.headers.location, url).toString(), destinationPath).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 7f2afede4..ee474840a 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -61,7 +61,16 @@ 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"; @@ -76,6 +85,7 @@ 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 { createKeybindingsService } from "./services/keybindings/keybindingsService"; import { createAgentToolsService } from "./services/agentTools/agentToolsService"; import { createAdeCliService } from "./services/cli/adeCliService"; @@ -96,6 +106,7 @@ import { } from "./services/adeActions/registry"; import { createUsageTrackingService } from "./services/usage/usageTrackingService"; import { createBudgetCapService } from "./services/usage/budgetCapService"; +import { markMachineStateMigrationComplete, runMachineStateMigration } from "./services/runtime/machineStateMigration"; import { createRebaseSuggestionService } from "./services/lanes/rebaseSuggestionService"; import { createAutoRebaseService } from "./services/lanes/autoRebaseService"; import { createMissionService } from "./services/missions/missionService"; @@ -136,7 +147,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"; @@ -147,6 +157,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 { @@ -162,6 +173,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(); @@ -203,6 +257,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) { @@ -944,6 +1019,19 @@ app.whenReady().then(async () => { writeGlobalState(globalStatePath, normalizedState); } + const machineAdeLayout = resolveMachineAdeLayout(); + 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 envRoot = process.env.ADE_PROJECT_ROOT; const pendingStartupProjectRoot = pendingProjectOpenFiles @@ -993,12 +1081,37 @@ app.whenReady().then(async () => { 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."); + } 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; @@ -1010,11 +1123,27 @@ app.whenReady().then(async () => { 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 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()) { @@ -1036,6 +1165,19 @@ app.whenReady().then(async () => { } }; + 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 => { const recentProjects = readGlobalState(globalStatePath).recentProjects ?? []; for (const project of recentProjects) { @@ -1046,10 +1188,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(); @@ -1116,6 +1264,7 @@ app.whenReady().then(async () => { const normalizedRoot = projectRoot ? normalizeProjectRoot(projectRoot) : null; if (windowId != null) { windowProjectRoots.set(windowId, normalizedRoot); + windowProjectBindings.delete(windowId); } if (options.foreground ?? true) { setForegroundProject(normalizedRoot); @@ -1126,10 +1275,33 @@ app.whenReady().then(async () => { 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) { - emitProjectChangedToWindow(windowId, projectForRoot(normalizedRoot)); + 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 => { @@ -1184,7 +1356,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) { @@ -1192,7 +1364,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) { @@ -1200,7 +1372,7 @@ app.whenReady().then(async () => { } try { - if (ctx.testService.hasActiveRuns()) { + if (ctx.testService?.hasActiveRuns()) { return true; } } catch (error) { @@ -1208,20 +1380,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) { @@ -1471,6 +1645,7 @@ app.whenReady().then(async () => { logger, }); adeCliService.applyToProcessEnv(); + installAdeCliForTerminalInBackground(adeCliService, logger); const devToolsService = createDevToolsService({ logger }); const project = toProjectInfo(projectRoot, baseRef); @@ -1789,6 +1964,7 @@ app.whenReady().then(async () => { projectRoot, aiIntegrationService, githubService, + onSubmissionUpdated: (event) => broadcast(IPC.feedbackOnUpdate, event), }); const conflictService = createConflictService({ @@ -1958,13 +2134,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)); @@ -2007,9 +2193,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; @@ -2025,7 +2208,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]; @@ -2534,7 +2717,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 @@ -2629,7 +2811,6 @@ app.whenReady().then(async () => { laneService, projectConfigService, broadcastEvent: (ev) => { - openclawBridgeServiceRef?.onTestEvent(ev); emitProjectEvent(projectRoot, IPC.testsEvent, ev); }, }); @@ -2713,7 +2894,6 @@ app.whenReady().then(async () => { .catch(() => {}); }, onEvent: (event) => { - openclawBridgeServiceRef?.onMissionEvent(event); emitProjectEvent(projectRoot, IPC.missionsEvent, event); if (event.missionId) { automationService?.onMissionUpdated({ missionId: event.missionId }); @@ -2868,32 +3048,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, @@ -2912,7 +3066,6 @@ app.whenReady().then(async () => { knowledgeCaptureService, onEvent: (event) => { aiOrchestratorServiceRef?.onOrchestratorRuntimeEvent(event); - openclawBridgeServiceRef?.onOrchestratorEvent(event); emitProjectEvent(projectRoot, IPC.orchestratorEvent, event); }, }); @@ -3096,21 +3249,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, @@ -3143,7 +3297,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, @@ -3678,7 +3832,6 @@ app.whenReady().then(async () => { writeGlobalState(globalStatePath, state); // ── ADE RPC Socket Server (embedded mode) ───────────────────── - const rpcEventBuffer = createEventBuffer(); const rpcRuntime: AdeRuntime = { projectRoot, workspaceRoot: projectRoot, @@ -3726,7 +3879,6 @@ app.whenReady().then(async () => { workerHeartbeatService, workerTaskSessionService, linearCredentialService, - openclawBridgeService, flowPolicyService, linearDispatcherService, linearIssueTracker, @@ -3766,7 +3918,7 @@ app.whenReady().then(async () => { return { ok: false, mode: "unavailable" as const, - message: "No ADE desktop window is available for this project.", + message: "No ADE window is available for this project.", }; } if (targetWindow.isMinimized()) targetWindow.restore(); @@ -3785,17 +3937,9 @@ app.whenReady().then(async () => { 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) { @@ -3812,73 +3956,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 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(); - }; - 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 @@ -4035,7 +4199,6 @@ app.whenReady().then(async () => { embeddingService, embeddingWorkerService, ctoStateService, - openclawBridgeService, workerAgentService, adeProjectService, workerRevisionService, @@ -4054,6 +4217,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; @@ -4079,6 +4273,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, @@ -4090,13 +4293,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, @@ -4162,7 +4359,6 @@ app.whenReady().then(async () => { proceduralLearningService: null, skillRegistryService: null, ctoStateService: null, - openclawBridgeService: null, workerAgentService: null, adeProjectService: null, workerRevisionService: null, @@ -4295,11 +4491,6 @@ app.whenReady().then(async () => { } catch { // ignore } - try { - await ctx.openclawBridgeService?.stop?.(); - } catch { - // ignore - } try { await ctx.skillRegistryService?.dispose?.(); } catch { @@ -4514,7 +4705,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); @@ -4572,14 +4763,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.", }; } @@ -4767,18 +4958,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); @@ -4824,7 +5022,9 @@ app.whenReady().then(async () => { 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); @@ -5058,7 +5258,7 @@ app.whenReady().then(async () => { 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.", + "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, }); @@ -5187,6 +5387,7 @@ app.whenReady().then(async () => { 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); @@ -5194,6 +5395,7 @@ app.whenReady().then(async () => { win.on("closed", () => { const previousRoot = windowProjectRoots.get(win.id) ?? null; windowProjectRoots.delete(win.id); + windowProjectBindings.delete(win.id); if (activeProjectRoot === previousRoot) { setForegroundProject(firstOpenWindowProjectRoot()); } @@ -5201,13 +5403,18 @@ app.whenReady().then(async () => { }); }; - const getWindowSession = (windowId: number | null): { windowId: number | null; project: ProjectInfo | null } => { + const getWindowSession = (windowId: number | null): { windowId: number | null; project: ProjectInfo | null; binding: OpenProjectBinding | null } => { if (windowId == null) { - return { windowId: null, project: projectForRoot(activeProjectRoot) }; + 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: projectForRoot(windowProjectRoots.get(windowId) ?? null), + project, + binding: bindingForLocalProject(project), }; }; @@ -5226,6 +5433,7 @@ app.whenReady().then(async () => { }); } else { emitProjectChangedToWindow(win.id, null); + emitProjectBindingChangedToWindow(win.id, null); } return getWindowSession(win.id); }; @@ -5355,6 +5563,8 @@ app.whenReady().then(async () => { runWithIpcWindow: (event, fn) => ipcWindowScope.run(BrowserWindow.fromWebContents(event.sender)?.id ?? null, fn), getWindowSession, + bindRemoteProject: bindWindowToRemoteProject, + localRuntimeConnectionPool: localRuntimePool, createWindow: openAdeWindow, closeWindow: closeAdeWindow, switchProjectFromDialog, @@ -5383,6 +5593,19 @@ app.whenReady().then(async () => { 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) { diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 9b4605abf..137a16944 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,666 @@ 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 tracker = { + listProjects: vi.fn(async () => projects), + listUsers: vi.fn(async () => users), + listLabels: vi.fn(async () => labels), + listWorkflowStates: vi.fn(async () => states), + }; + const runtime = { + linearIssueTracker: tracker, + } as unknown as Parameters<typeof getAdeActionDomainServices>[0]; + const service = getAdeActionDomainServices(runtime).linear_issue_tracker as { + getWorkflowCatalog: () => Promise<unknown>; + getIssuePickerData: () => Promise<unknown>; + } & Record<string, unknown>; + + expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getWorkflowCatalog"); + expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getIssuePickerData"); + 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", "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 cb28bd55a..14c103da3 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,59 +377,172 @@ 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", + "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", ], @@ -263,7 +554,11 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri "completeAttempt", "createHandoff", "emitRuntimeUpdate", + "finalizeRun", + "getLatestGateReport", "getRunGraph", + "getRunState", + "heartbeatClaims", "listAttempts", "listRetrospectivePatternStats", "listRetrospectiveTrends", @@ -273,23 +568,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", @@ -328,20 +681,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: [ "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"], @@ -356,16 +730,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"], @@ -376,8 +769,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", @@ -396,8 +804,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 { @@ -416,8 +827,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), }; } @@ -475,6 +889,1583 @@ 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), + 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.`); @@ -592,6 +2583,864 @@ 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 getConnectionStatus() { + return buildRuntimeLinearConnectionStatus(runtime); + }, + 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 }; + }, + }; +} + +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 { @@ -617,52 +3466,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/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/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..7ab7f319b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -2449,7 +2449,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 +2471,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 +2727,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 +2736,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."); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 57d315d12..ade7e5d28 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, @@ -6721,21 +6722,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 +6774,7 @@ export function createAgentChatService(args: { const envelope: AgentChatEventEnvelope = { sessionId: managed.session.id, timestamp: nowIso(), - event, + event: storedEvent, sequence: ++managed.eventSequence, }; @@ -6766,24 +6795,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 +6824,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 } : {}), }); @@ -17066,7 +17095,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"; 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/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 507100430..024a507f3 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/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 3f27d342e..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[]; @@ -1915,6 +1728,8 @@ export function registerIpc({ resolveSyncService, runWithIpcWindow, getWindowSession, + bindRemoteProject, + localRuntimeConnectionPool, createWindow, closeWindow, switchProjectFromDialog, @@ -1927,7 +1742,9 @@ export function registerIpc({ 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 }; + 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>; @@ -1947,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 @@ -1958,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 @@ -3242,6 +3088,14 @@ export function registerIpc({ 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, }; }); @@ -3656,7 +3510,8 @@ export function registerIpc({ env: { nodeEnv: process.env.NODE_ENV, viteDevServerUrl: process.env.VITE_DEV_SERVER_URL - } + }, + localRuntime: localRuntimeConnectionPool?.getStatus() ?? null }; }); @@ -3821,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[] = []; @@ -3836,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 }; }); @@ -3848,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> => { @@ -4187,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, @@ -4217,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.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 (): Promise<SyncRoleSnapshot> => { + 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, ); }, ); @@ -6585,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()); @@ -8679,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 @@ -8732,8 +8669,8 @@ export function registerIpc({ { exportedAt, project: { - rootPath: ctx.project.rootPath, - displayName: ctx.project.displayName + rootPath: projectRoot, + displayName: projectDisplayName }, filters: { laneId: laneId ?? null, @@ -9307,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 bcbe7cd01..2ad7cc23a 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -977,11 +977,7 @@ export function createLaneService({ : ""; const suggested = explicitBranch || linearBranch; const isCustomBranch = suggested.length > 0; - const branchSource: "explicit" | "linear" | "fallback" = explicitBranch - ? "explicit" - : linearBranch - ? "linear" - : "fallback"; + const isLinearBranch = !explicitBranch && linearBranch.length > 0; const slug = slugify(args.name); const fallback = `ade/${slug}-${args.laneId.slice(0, 8)}`; const branchRef = suggested @@ -1004,7 +1000,7 @@ export function createLaneService({ throw new Error(`Branch "${branchRef}" already exists locally.`); } - const remoteCollisionMessage = branchSource === "linear" + 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.`; 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/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts new file mode 100644 index 000000000..caf10e2a4 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -0,0 +1,528 @@ +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"): { 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"); + 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"); + 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..e38206427 --- /dev/null +++ b/apps/desktop/src/main/services/runtime/machineStateMigration.test.ts @@ -0,0 +1,131 @@ +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, + 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 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("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..5da8090ce --- /dev/null +++ b/apps/desktop/src/main/services/runtime/machineStateMigration.ts @@ -0,0 +1,115 @@ +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 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 }); + } +} + +export function runMachineStateMigration(args: MachineStateMigrationArgs): MachineStateMigrationResult { + const marker = markerPath(args.layout); + if (fs.existsSync(marker)) { + return { didRun: false, shouldShowNotice: false, markerPath: marker }; + } + + const hadExistingUserState = + args.recentProjects.length > 0 || fs.existsSync(args.layout.secretsDir); + fs.mkdirSync(args.layout.secretsDir, { recursive: true, mode: 0o700 }); + + copyFirstLegacySecret({ layout: args.layout, recentProjects: args.recentProjects, fileName: "sync-bootstrap-token" }); + copyFirstLegacySecret({ layout: args.layout, recentProjects: args.recentProjects, fileName: "sync-pin.json" }); + mergePairedDevices({ layout: args.layout, recentProjects: args.recentProjects }); + + const projectRegistry = args.projectRegistry ?? new ProjectRegistry(args.layout); + for (const project of args.recentProjects) { + 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 b4e02dc72..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; } @@ -3579,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); @@ -3594,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 @@ -3641,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); @@ -3648,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; } @@ -3657,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); @@ -3672,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 { @@ -3698,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); @@ -3720,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; @@ -3769,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..4332ae163 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -2306,6 +2306,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 ad235d767..4a7e3e717 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -218,13 +218,6 @@ import type { CtoGetLinearOAuthSessionArgs, CtoGetLinearOAuthSessionResult, CtoRunProjectScanResult, - CtoGetOpenclawStateResult, - CtoUpdateOpenclawConfigArgs, - CtoTestOpenclawConnectionArgs, - CtoTestOpenclawConnectionResult, - CtoListOpenclawMessagesArgs, - CtoListOpenclawMessagesResult, - CtoSendOpenclawMessageArgs, LinearConnectionStatus, CtoSetLinearTokenArgs, CtoSaveFlowPolicyArgs, @@ -244,7 +237,6 @@ import type { CtoEnsureLinearWebhookArgs, CtoListLinearIngressEventsArgs, LinearWorkflowConfig, - OpenclawBridgeStatus, AddMissionArtifactArgs, AddMissionInterventionArgs, KeybindingOverride, @@ -413,6 +405,7 @@ import type { ProjectConfigTrust, ProjectConfigValidationResult, ProjectInfo, + OpenProjectBinding, CreateProjectInput, CreateProjectResult, CloneProjectInput, @@ -700,6 +693,17 @@ import type { MacosVmStopArgs, MacosVmTypeTextArgs, MacosVmWindowTarget, + RemoteRuntimeActionRequest, + RemoteRuntimeActionResult, + RemoteRuntimeConnectionSnapshot, + RemoteRuntimeConnectResult, + RemoteRuntimeDiscoveredMachine, + RemoteRuntimeLocalWorkCheckResult, + RemoteRuntimeProjectRecord, + RemoteRuntimeStreamEventsRequest, + RemoteRuntimeStreamEventsResult, + RemoteRuntimeTarget, + RemoteRuntimeTargetInput, ChatTerminalActiveForChatArgs, ChatTerminalListArgs, ChatTerminalReadArgs, @@ -726,6 +730,7 @@ declare global { getWindowSession: () => Promise<{ windowId: number | null; project: ProjectInfo | null; + binding: OpenProjectBinding | null; }>; newWindow: () => Promise<{ windowId: number | null }>; openProjectInNewWindow: ( @@ -735,15 +740,20 @@ declare global { onProjectChanged: ( cb: (project: ProjectInfo | null) => void, ) => () => void; - onNavigate: ( - cb: (request: AppNavigationRequest) => 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: { @@ -781,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>; @@ -790,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>; @@ -813,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>; @@ -842,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, @@ -880,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, @@ -959,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: { @@ -1205,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[]>; @@ -1301,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[]>; @@ -1312,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: ( @@ -1324,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>; @@ -1390,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; @@ -1435,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>; @@ -1454,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; @@ -1464,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: { @@ -1478,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>; @@ -1549,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>; @@ -1621,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; }; @@ -1631,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: { @@ -1659,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, @@ -1757,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>; @@ -1998,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 f9c700fec..d79ccadf8 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -38,10 +38,15 @@ import type { AutomationSimulateRequest, AutomationSimulateResult, ReviewEventPayload, + ReviewFeedbackRecord, ReviewLaunchContext, ReviewListRunsArgs, + ReviewListSuppressionsArgs, + ReviewQualityReport, + ReviewRecordFeedbackArgs, ReviewRun, ReviewRunDetail, + ReviewSuppression, ReviewStartRunArgs, AdeActionRegistryEntry, AdeCliInstallResult, @@ -119,13 +124,6 @@ import type { CtoGetLinearOAuthSessionArgs, CtoGetLinearOAuthSessionResult, CtoRunProjectScanResult, - CtoGetOpenclawStateResult, - CtoUpdateOpenclawConfigArgs, - CtoTestOpenclawConnectionArgs, - CtoTestOpenclawConnectionResult, - CtoListOpenclawMessagesArgs, - CtoListOpenclawMessagesResult, - CtoSendOpenclawMessageArgs, LinearConnectionStatus, CtoSetLinearOAuthClientArgs, LinearIngressEventRecord, @@ -146,7 +144,6 @@ import type { CtoEnsureLinearWebhookArgs, CtoListLinearIngressEventsArgs, LinearWorkflowConfig, - OpenclawBridgeStatus, AddMissionArtifactArgs, AddMissionInterventionArgs, AutomationsEventPayload, @@ -349,6 +346,7 @@ import type { ProjectConfigTrust, ProjectConfigValidationResult, ProjectInfo, + OpenProjectBinding, CreateProjectInput, CreateProjectResult, CloneProjectInput, @@ -705,6 +703,19 @@ import type { MacosVmStopArgs, MacosVmTypeTextArgs, MacosVmWindowTarget, + RemoteRuntimeActionRequest, + RemoteRuntimeActionResult, + RemoteRuntimeBufferedEvent, + RemoteRuntimeConnectionSnapshot, + RemoteRuntimeConnectResult, + RemoteRuntimeDiscoveredMachine, + RemoteRuntimeEventNotificationPayload, + RemoteRuntimeLocalWorkCheckResult, + RemoteRuntimeProjectRecord, + RemoteRuntimeStreamEventsRequest, + RemoteRuntimeStreamEventsResult, + RemoteRuntimeTarget, + RemoteRuntimeTargetInput, ChatTerminalActiveForChatArgs, ChatTerminalListArgs, ChatTerminalReadArgs, @@ -724,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; @@ -842,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; }; @@ -888,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, ); @@ -927,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 }), @@ -951,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(); @@ -982,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(); @@ -1011,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, + ); } } }; @@ -1050,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); @@ -1067,15 +2336,28 @@ 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 }> => - ipcRenderer.invoke(IPC.appGetWindowSession), + 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 }> => + closeWindow: async ( + windowId?: number | null, + ): Promise<{ closed: boolean }> => ipcRenderer.invoke(IPC.appCloseWindow, { windowId: windowId ?? null }), onProjectChanged: (cb: (project: ProjectInfo | null) => void) => { const listener = ( @@ -1088,6 +2370,21 @@ 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, @@ -1106,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> => @@ -1117,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> => @@ -1136,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); @@ -1157,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, @@ -1191,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> => @@ -1266,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), @@ -1360,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, @@ -1479,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: { @@ -1514,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; @@ -1531,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, @@ -1549,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, @@ -1645,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; @@ -1698,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); @@ -1714,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 = ( @@ -1917,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 = ( @@ -1926,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, ) => { @@ -2064,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) => { @@ -2239,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> => @@ -2403,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: { @@ -2423,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(); } @@ -2476,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, @@ -2921,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, @@ -2937,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 = ( @@ -3128,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; @@ -3206,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, @@ -3278,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) { @@ -3382,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(); } @@ -3415,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; @@ -3427,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; @@ -3448,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"; @@ -3456,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"; @@ -3470,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, @@ -3505,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, ) => { @@ -3520,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, ) => { @@ -3673,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 6d9e8e5bb..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,13 +2775,47 @@ 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 }), + 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 }), + openProjectInNewWindow: resolvedArg({ + windowId: 2, + project: MOCK_PROJECT, + }), closeWindow: resolvedArg({ closed: false }), onProjectChanged: () => () => {}, + onProjectBindingChanged: () => () => {}, onNavigate: () => () => {}, openExternal: resolvedArg(undefined), revealPath: resolvedArg(undefined), @@ -2665,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 { @@ -2699,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([]), @@ -2729,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: [] }), @@ -2749,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: () => () => {}, @@ -2826,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", }, }), @@ -2968,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), @@ -2980,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", @@ -3054,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", @@ -3150,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, @@ -3198,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", @@ -3270,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, }), @@ -3294,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, @@ -3329,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, @@ -3355,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);", @@ -3390,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, }, @@ -3410,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.", }, }), @@ -3425,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", @@ -3452,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", @@ -3494,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, @@ -3505,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" }), @@ -3572,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({ @@ -3774,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, @@ -3940,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([]), @@ -3954,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), @@ -3967,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) => @@ -3981,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, @@ -4007,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" }), @@ -4031,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!)) @@ -4042,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!)) @@ -4342,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({ @@ -4408,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), @@ -4446,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; @@ -4457,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); @@ -4559,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", @@ -4628,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", @@ -4648,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 }), @@ -4670,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([]), @@ -4811,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); @@ -4825,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; @@ -4925,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", @@ -4957,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 = {}) => ({ @@ -5038,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({ @@ -5058,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/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..787710614 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,135 @@ 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, + }, + ], + })), + }; + 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: "Open remote tab?" }), + ).toBeTruthy(), + ); + expect(screen.getByText("3 changed files")).toBeTruthy(); + expect(screen.getAllByText("/Users/admin/Projects/ADE").length).toBeGreaterThan(0); + expect(switchRemoteProject).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Open remote tab" })); + 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 ffb9744b1..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,22 +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: "go-settings-usage", title: "Go to Usage", hint: "Token usage, cost breakdown", group: "Settings", run: () => navigate("/settings?tab=usage") }, + { + 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", @@ -334,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)}`); @@ -348,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)}`); @@ -374,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(); @@ -388,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", ); @@ -404,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]); @@ -456,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(() => { @@ -489,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; @@ -514,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; @@ -545,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); @@ -562,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; @@ -574,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]); @@ -587,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" }); } }, []); @@ -611,7 +932,7 @@ export function CommandPalette({ console.error("Command palette command failed", error); }); }, - [onOpenChange] + [onOpenChange], ); const activateBrowseRow = useCallback((row: BrowseRow) => { @@ -621,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)); @@ -634,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); @@ -652,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) => { @@ -665,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") { @@ -675,7 +1055,7 @@ export function CommandPalette({ runCommand(command); } }, - [filtered, runCommand, selectedIdx] + [filtered, runCommand, selectedIdx], ); const handleBrowseKeyDown = useCallback( @@ -715,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) => { @@ -753,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; @@ -781,7 +1173,7 @@ export function CommandPalette({ setBrowseLoading(false); }); }, - [handleOpenProject, project?.rootPath] + [activeBrowseRoot, browseDirectoriesForActiveLocation, handleOpenProject], ); const isBrowsing = mode === "project-browse"; @@ -789,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 () => { @@ -826,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}> @@ -887,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: @@ -905,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 @@ -919,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", }} @@ -929,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 ? ( @@ -955,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" @@ -983,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) { @@ -1000,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 /> @@ -1017,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} @@ -1059,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 ? ( @@ -1079,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 @@ -1089,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"> @@ -1105,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> @@ -1127,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> @@ -1145,7 +1698,7 @@ export function CommandPalette({ highlightedPath={highlightedPath} highlightedIsRepo={highlightedIsRepo} browseResult={browseResult} - activeProjectPath={project?.rootPath ?? null} + activeProjectPath={activeBrowseRoot} /> </div> @@ -1158,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> @@ -1169,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)]"> @@ -1182,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" @@ -1219,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> @@ -1237,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"> {(() => { @@ -1260,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"> @@ -1270,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"> @@ -1279,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> @@ -1293,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> @@ -1302,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; @@ -1322,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> @@ -1363,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 ? ( @@ -1397,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"} @@ -1437,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>} @@ -1461,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)}% @@ -1491,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> ); @@ -1511,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 @@ -1565,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, }; @@ -1611,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 bf9dbe510..e3505be42 100644 --- a/apps/desktop/src/renderer/components/app/SettingsPage.tsx +++ b/apps/desktop/src/renderer/components/app/SettingsPage.tsx @@ -15,6 +15,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 }, @@ -448,10 +449,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; @@ -463,7 +470,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; @@ -508,7 +518,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 dcd0527a4..25621a33d 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -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,6 +106,15 @@ 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); } @@ -158,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(), }, @@ -187,7 +220,7 @@ 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(); }); @@ -205,6 +238,61 @@ describe("TopBar", () => { 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.getByText("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 />); @@ -255,13 +343,13 @@ describe("TopBar", () => { 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")); @@ -297,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 () => { @@ -339,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); }); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index d6f496834..fac8f09fd 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 { ArrowSquareOut, 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,15 +33,27 @@ 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"; -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"; @@ -33,6 +64,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; @@ -53,7 +85,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) { @@ -64,7 +99,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 { @@ -83,8 +120,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(); @@ -92,8 +131,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 }); @@ -118,7 +163,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; @@ -129,7 +175,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); } @@ -147,21 +199,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"; } @@ -178,12 +233,11 @@ function fallbackProjectName(rootPath: string): string { return rootPath.split(/[\\/]/).filter(Boolean).pop() ?? rootPath; } -function confirmProjectTabRemoval(projectName: string, isCurrent: boolean, isMissing: boolean): boolean { +function confirmProjectTabRemoval(projectName: string): boolean { const label = projectName.trim() || "this project"; - const action = isCurrent && !isMissing - ? `Close "${label}" project tab?` - : `Close "${label}" project tab?`; - return window.confirm(`${action}\n\nThis does not remove it from Recent Projects or 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 { @@ -192,7 +246,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"; } @@ -212,16 +267,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); @@ -250,13 +307,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; @@ -273,11 +333,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; }; @@ -295,19 +357,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; @@ -322,7 +387,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) { @@ -353,6 +420,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} @@ -374,7 +458,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> @@ -388,7 +480,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> @@ -433,7 +527,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 @@ -442,7 +542,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> @@ -454,6 +560,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); @@ -464,32 +571,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 ""; @@ -504,6 +635,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)); @@ -519,7 +653,7 @@ export function TopBar() { window.ade.project .listRecent() .then((rows) => setRecentProjects(rows)) - .catch(() => { }); + .catch(() => {}); }, []); useEffect(() => { @@ -529,37 +663,79 @@ export function TopBar() { useEffect(() => { const rootPath = project?.rootPath ?? null; if (!rootPath) { - setOpenProjectTabRoots([]); + // 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] + prev.includes(rootPath) ? prev : [...prev, rootPath], ); - }, [project?.rootPath]); + }, [project?.rootPath, 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(() => { + 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; - const getWindowSession = (window as unknown as { - ade?: { app?: { getWindowSession?: typeof window.ade.app.getWindowSession } }; - }).ade?.app?.getWindowSession; - if (typeof getWindowSession !== "function") return undefined; - getWindowSession() + window.ade.app + .getWindowSession() .then((session) => { if (!cancelled) setWindowId(session.windowId); }) @@ -579,6 +755,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(); @@ -595,7 +801,7 @@ export function TopBar() { useEffect(() => { let cancelled = false; let statusRequestVersion = 0; - if (!project?.rootPath) { + if (!project?.rootPath || remoteBinding) { setSyncSnapshot(null); setPhoneSyncOpen(false); return () => { @@ -604,11 +810,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(); @@ -628,60 +839,81 @@ 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; + 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 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; @@ -693,66 +925,157 @@ export function TopBar() { window.ade.app.newWindow().catch(() => {}); }, [isProjectBusy]); - const handleSwitchProject = useCallback((rootPath: string) => { + 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; - if (project?.rootPath === rootPath) { - cancelNewTab(); + 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 = projectTabs.find((entry) => entry.rootPath === rootPath); - const fallbackName = fallbackProjectName(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 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], + ); - const currentIndex = openProjectTabRoots.indexOf(rootPath); - const nextTabRoots = openProjectTabRoots.filter((entry) => entry !== rootPath); - setOpenProjectTabRoots(nextTabRoots); - if (project?.rootPath === rootPath) { - const nextRoot = - nextTabRoots[currentIndex] - ?? nextTabRoots[currentIndex - 1] - ?? null; - if (nextRoot) { - switchProjectToPath(nextRoot).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, closeProject, openProjectTabRoots, project?.rootPath, projectTabs, 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, 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)); - } - }, [windowId]); + }, + [windowId], + ); const handleDragOver = useCallback((e: React.DragEvent, idx: number) => { e.preventDefault(); @@ -764,122 +1087,224 @@ export function TopBar() { setDropIdx(null); }, []); - 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) { + 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); - 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(() => {}); + }, + [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; } - return; - } - switchProjectToPath(rootPath).catch(() => {}); - }, [project?.rootPath, switchProjectToPath, windowId]); + 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; + if (!Array.from(e.dataTransfer.types).includes(ADE_PROJECT_TAB_ROOT_MIME)) + return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; }, []); - 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) { + 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(() => {}); - } - }, []); - const handleProjectAccentColorChange = useCallback((rootPath: string, color: string | null) => { - setProjectAccentColors((prev) => { - if ((prev[rootPath] ?? null) === color) return prev; - return { ...prev, [rootPath]: color }; - }); - }, []); + // 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 - ? (projectTabs.find((entry) => entry.rootPath === projectTransition.rootPath)?.displayName - ?? recentProjects.find((entry) => entry.rootPath === projectTransition.rootPath)?.displayName - ?? fallbackProjectName(projectTransition.rootPath) - ?? "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 @@ -904,22 +1329,90 @@ export function TopBar() { onDragOver={handleProjectTabDragOver} onDrop={handleProjectTabDrop} > - {projectTabs.length > 0 || isNewTabOpen ? ( + {openRemoteProjectTabs.length > 0 || + projectTabs.length > 0 || + isNewTabOpen ? ( <> + {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 = project?.rootPath === rp.rootPath; + 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"; @@ -932,9 +1425,15 @@ 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, rp.rootPath)} onDragOver={(e) => handleDragOver(e, idx)} @@ -947,9 +1446,10 @@ export function TopBar() { !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={() => { @@ -972,7 +1472,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 @@ -985,14 +1489,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} @@ -1012,7 +1516,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" @@ -1034,7 +1542,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} @@ -1056,24 +1564,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} @@ -1097,13 +1616,29 @@ 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> @@ -1111,7 +1646,7 @@ export function TopBar() { 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" + "transition-[background-color,color,border-color,box-shadow] duration-150", )} data-variant="ghost" onClick={handleOpenNewWindow} @@ -1145,8 +1680,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, @@ -1162,13 +1699,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} @@ -1176,12 +1715,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" @@ -1195,20 +1736,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", @@ -1219,6 +1803,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]" @@ -1229,7 +1868,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" @@ -1240,12 +1879,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 @@ -1273,7 +1921,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" @@ -1282,7 +1930,10 @@ export function TopBar() { <ChatCircleDots size={12} weight="regular" /> </button> - <FeedbackReporterModal open={feedbackOpen} onOpenChange={setFeedbackOpen} /> + <FeedbackReporterModal + open={feedbackOpen} + onOpenChange={setFeedbackOpen} + /> <PublishToGitHubDialog open={publishOpen} @@ -1302,7 +1953,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" @@ -1313,7 +1964,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}% @@ -1322,7 +1973,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/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.tsx b/apps/desktop/src/renderer/components/run/RunPage.tsx index 10575b009..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, @@ -22,6 +44,7 @@ import type { ProjectConfigSnapshot, ConfigProcessGroupDefinition, ProjectIcon, + RemoteRuntimeConnectionSnapshot, } from "../../../shared/types"; function generateId(): string { @@ -44,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 { @@ -65,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(/^\.\/+/, "") || "."; } @@ -81,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 "."; @@ -94,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, }; } @@ -144,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, @@ -153,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); } @@ -187,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 } @@ -197,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); @@ -208,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", ), ), @@ -218,23 +293,34 @@ 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 }) { @@ -245,11 +331,14 @@ function RecentProjectIcon({ rootPath }: { rootPath: string }) { let cancelled = false; setIcon(null); setFailed(false); - window.ade.project.resolveIcon(rootPath).then((nextIcon) => { - if (!cancelled) setIcon(nextIcon); - }).catch(() => { - if (!cancelled) setIcon(null); - }); + window.ade.project + .resolveIcon(rootPath) + .then((nextIcon) => { + if (!cancelled) setIcon(nextIcon); + }) + .catch(() => { + if (!cancelled) setIcon(null); + }); return () => { cancelled = true; }; @@ -279,14 +368,52 @@ 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(() => {}); }, []); - const realProjects = recentProjects.filter((rp) => rp.exists && !rp.rootPath.includes("ade-project")); + 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 connectedRemoteCount = remoteSnapshot?.connectedCount ?? 0; return ( <div @@ -308,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> @@ -322,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 @@ -380,7 +555,8 @@ 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, }} @@ -388,17 +564,38 @@ function WelcomeScreen() { <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, @@ -409,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> @@ -418,7 +617,11 @@ function WelcomeScreen() { </div> ) : null} - <CommandPalette open={projectBrowserOpen} onOpenChange={setProjectBrowserOpen} intent="project-add" /> + <CommandPalette + open={projectBrowserOpen} + onOpenChange={setProjectBrowserOpen} + intent="project-add" + /> </div> ); } @@ -429,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[]>([]); @@ -439,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; @@ -452,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, @@ -471,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)); @@ -545,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([]); @@ -555,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); @@ -574,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]); @@ -603,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 { @@ -613,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; } } @@ -642,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 { @@ -706,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) { @@ -732,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; } } @@ -752,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)); } @@ -767,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 }); @@ -787,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(() => { @@ -914,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", @@ -940,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> @@ -969,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 @@ -1025,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> @@ -1039,7 +1429,10 @@ export function RunPage() { style={{ flexShrink: 0 }} > {laneRuntimeBarOpen ? ( - <LaneRuntimeBar laneId={fallbackRunLaneId} onOpenPreviewRouting={() => setNetworkDrawerOpen(true)} /> + <LaneRuntimeBar + laneId={fallbackRunLaneId} + onOpenPreviewRouting={() => setNetworkDrawerOpen(true)} + /> ) : null} </div> @@ -1061,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, @@ -1090,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, @@ -1108,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} @@ -1142,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, }} > @@ -1154,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> @@ -1177,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, @@ -1198,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 @@ -1254,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/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 274a0bede..7ce53391f 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -8,6 +8,7 @@ export const IPC = { 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", @@ -36,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", @@ -61,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", @@ -442,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", @@ -665,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..c9da2adac 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; } diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts index a728ad607..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,23 @@ 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"; @@ -159,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 85a2f211707182b8d95fa122bbe4362c3da5db92..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13319 zcmcJ0c|26_7x2Ban6b|wTb8kulr>}tGh>$}S)z#1f=W@8E#?lAz7#E%qNrpSr9{b$ zrBVqcOQD&_zKwkwGw<#9{`da>&gXMKGxwbHJoh~3Ip;iQxpUpc*;ZIURsaA1VS76( zR{%gj|1K;NhU^zN#=~EIE{^Wjj;AlwYiKaEw3cjb2lnruw6J)ydw09OexI>%leRX? z%#7#g$aQoaArimp>sPC({V+8AsH;0pCXbQHvzC^>O-(=O>VDe07bfm8GJ@ynFfiyc zG;Gk+{GzA#ZQs8B{rlm8Fxe=H^j=2?epc=7-Ea2n=`%4wV{t>~=C5|``b#AKFfwY^ z)*di5{b^!Sqpr?#a_TiUW|^B$SXg|~*JqlUeKj!nW@y-DWCZg7jI_;KS-}iz;iV7D zDn_g9%-*dD7GxkUokMyY0$N|9c4;tJ4k_>&CBt2v-GJA(9@<CutBD4_{`>An_ssO% z^53ahg&7GdOu-*&cO(n|1jYYdAaFld8UP4@y%pI#^4H9R)874`U(}o%HdZ?M(ikVc zAS<R5P=jTi3ifK|@_6GJ2A3T)OAKDU@jf{7$E5eCV)>3i_ii_jV}n5qijK4pUqa*m zr?2wN9z)>}aL$qA5FBy&H~nq(%;4tg{s#)a2W~HqXC*~s-fL*z+@JJr?&bXX$K~?N z?JOZT`hn4$aBg`7gD{mbQ=bwSI5+><)tH{`$<OE36-a*}@>^`~i(_<FyP&ntA8rCV zUj6sz=6>IPa@;!)`TYah(`MX&J2&#^Gutc7;@^eO30~Q8WK8Qp_x8r{wR=gsawQ<< z;2_xef~~hmLj6I37qc2KpIJY=x&O%TZKas`%!#KxuFOlnPeps|r<5D<yAE5N-M^TE zIPnzlsx->g+(0nCTU5zk>o5MHg7Q?hm>fZc)e@7_PsU`8&Ga<#*DStM!eut#w%X;; zS1@YX?OW%VKld9k29D$#b9+~d+?d^i{0p`8h*6<ZUi)tqWScW3lJ&GV%5Utv+r_+v z*dXA(Tn0!zeZ~jBc=%n5K>0I0#p_`fbA7|BTJh2coGwTg49=daY?_owy3-UQ=V!Kf z^|9ap&JfvKa%}B=aDfkYQZh-rNqbcJ>2{lF&vvzm)#(1v9t9zjyTW|^yvlWACNgeL zp6#Z!=ZsXu_ZqYDX`yRx-!df&w)}rBzaAX?Q`IfgQurLrO=w7{Ne|yya>twhULQK& zVKnVU$lIO&)h+Tw0&=^jg-$x6Hv2~qZ<zs^n&0Q8fX?c{M-yV3?~ADWJ8SlS{PXfU zg>E=5L~q0HNhWsPblTh(Ec`v-`UQ^C#^+hoI=}wg!_9*~2lTbLl1J96XWEvMIPE&l zaf~f>7OLN8kH_WUmCwUzK<A^-z|Qv7G``iMB=KoglZKA4?-x;&IIf%kQMIl~WFT+D zIaE!y;lW^3=Vu3?`SOWhKd&9uPm~wR+^4_GVXi7rHYqIW`smz;r1ZKltq+w?mIhoV zjO?rAy0)X5X6pw~IyAawd>}I^LE$yWdd)*a=niVd<n7`vaTSsm=SYCf2?cJxd4X{A z-0jc0GY>-Cx{QEwh+YvJE;CIR9~eo>Fwr|6XW$lg<)O3j`kwt@Dy!posL}XGj;8<n zqn2Mw>K<9<NpRkSkyI-0!kS2h-;7ve#$L`~M>=;R8~bcw@pW-Fv4DC&Ext?|2<#Tb zNimGS6OZkQ-}4h~r$H^|mS|faUQmO=%D0s&(H7W-S2^8p_3CeXLskohiCmXr3B6qB z8xp7k%A}#S{SEbUk5Nysac>XT?LgBIw!kpDRU?VmWiCXp{7Zi|nbOZrRt?>K4k10y z&^FAv8H<W9Wl34G4jkqxE3A!hzrRkaRX&L4)T}gd^Iu#h`QBbf#)}QFM#=77QOj_H ztgKib5RtH}NVviA$cif#vM>o<4RqvMtQ|8a5~)@mE(b=w*i)AI2`R)v%*n-74rgl^ z@H_UVDP>(-0pV95*LZvs65?tA^qdv&650NZf+dFNtGK581gHxvp{T&TcFNxxa(r_w zuhE|6ZUeq}H5|@~rAD72-*6AES-R2O@zTRe#zh%yx=8!PF*8%zc9*d3z~~5%ESm<s zTCw?odqlVo`MHjkyS@CE42;;-h}lvvo~I2$dWly3B@(mtSH`um^l@eLG1K^c=eja7 z_!}HYMmyS&H)awVqyw|$z&>4f^q%VtNq;hYg*KS}++gfjbzM0kd+*LG-#F)nb#n7V zXU<A%o$;SD3`FdjkSRz%RyaDxAMI7Oxp~}+BXfTKXYy|JXDXliHfqNq&eqyI{<u~C zn$8uTp)et+jUQ9YPzv`2#MkuBVbykZZbflBuxmu8^%P+SF*re7VvO5Y+MBOOLT3^r z?f~3SVP}{0O}$k0Ep2UHj^}&kD-=gwp@~1!G~C2MS)PJz+`!_RM7$XV`cpHq=G4Sh z6<n^vd%vTY>^x$Zi@3%YzPwm&Dm16pD#of$LL^q%3hlZp7I!U){DGOz(<^+&%jtE4 zaQ@Oq3liCz?{%_8ItkkeVXO2hH4>I9BC4zG=DT*vh}67HZ9u6j0S-TDCl&)`N5u~a z(9feyG~zYsfl<T>F^1kDM9c6T&c9Y-*}<70y@YDZ4W%t_BPdV<q?2T|O4m5Xe|cV) zNPe^*)(|HdjkBJJFF%pErHD`3+6c2)t4IOnr|0o7w2RnGxh~o)u4qo<QoCKCT8^3d zzFSzyd)!0vMk=O>CK2K!$++kURv$+R-}ZnTE6kR<NIS_LtEH<an2(_CB)K&!{MRYu zZaNzkkI!wq?<N*`)DUb#vGF(RrfW`EZ4z%c)NQ8@azZ9qhGm|Zo1YX5?8pl$CSa~O z_Pg&vrk<VpT7ranDzS#|;T_a%-otfNj|-H(0-{DU=!f_Tx3<1<L*Cd;0UrAkS~~LV z2`!;F|76voI@#59^$A`v&c_cN>g&i;BvV#qz5mLn9)QxhAttYf=7bO}f9A#x-aT<~ zUdo6jVRH5F4yb{bQ>Rt+8_|MhkEvk)j&<nN%H`u(X;tqB79j&Ef(VtUsNjNY+Z)Sp z1a5@#ZrA}uH!->0LqcMPRDuqM>NpqJ7Q2Pd-S0x955ac79uvvP4p-u8dr=>4<%vVD zIbR7RIj$U_*eUUe;a7`1o~~I7@p4SYGd?k2nQ#t?u>XMP>Kr-mZ!&P)kONDsX`D|p zo?*mgA>D2&CW<piC=p@u<~D{53Y$R0Wq;x6nh<8OghAlBQX;^=h(YL$Q5)rh-T6O+ zK`D2+A&)L$ZlVb<L$!E;DBG&S^WzVWietkL(liL9Y|bNv)($^AVEYn|aEb4v^N+kR zK8h1M`Gl=4A-#+zDao)xwwoo-J^-H|T5`}aI2xaxNyN1pP#Jc>4Yux5`fASCOE|*F zd>|N^_$d)uHs9_ePni^AhbtH%hq5R&19iMt+1(dWIv+p<`Pil_6b|P!2TM3cd+4?t zI7LjUkbQV=+w*m5_aR7?jX|8AntLI}=)@8pfMO1~X{vVpMjk(N;892e1o*Qf!V|-k z?K##Nz}R1zTJ=k)b|>)MLNL*53eO1Qk_i5m&2|QeaourahWb6;y=|mClC>CVo+B@B zK=wU}G~z1Ans8m$^Jcgj%l>2kEJD;CFozh@T%i>wANS?=y^NEXKEa0Sb6mr+O)N!~ zTKGs)?i=8L6A%-|9T&uyA)Jb+x;q%rg1FufyX^F~6YwgKO(b8s0`2l{C=qx_hFazg z6?z*8gPUjAX~0Gnw1!ZP6bd7A&nDp+k=zfLv@tdk6lGh0f6IX|-bT5_Pbo+Ku!T$m zw!ykJeBo<~u4Muh73%C`CC&(O?%~zrOub2vsu5$p_V@d2d6hck&^jZkiaWMR_Ka_f z<oIdY0#DZTD$rn44BtufQx)WFbTs1`DhU`J3i0t%Z@Ulq-vDIWt;?tRlC`NbBCU>} zMUqnyE!Fo0ZgHiwcv9JoqBlvKNUxK^(a$EZJ^qgnEw5mlQW3DTlDsMiwP5p~kR+d~ zLPrJCFEH+pLstBhguZxU;5ph#2PImp)($MM14=H)_Ko5hsb+tdWXDN@S{L}XAre3N z3GY{y<EMlQo*Lh26ez6!6XBdqu9%W3Q03YTAB<In>;qZjnmTS9(%&6_i{uIgri*d> zw!Sk8P&zt^lh;_usC0JR;{9hz9lJ?q?(yw>NIUTxX)1c2Hh-YgM;!agJmIbo<v}UU zK9E$bXSb+ch8%iGR<@~<)5e0guhYL3+jKMJpP@SaEbRxItt%Rp(V;tep0GJ#xi(LJ z70{4cpxhh2L}P?}cH{l8l12%4Z_&R6K`5PYzQl$k{{wm8a~ahwKVcg{R8WecMSXJL zu}Gv#i%ghRD%fIb{N?E&vYWx1lZR3!LN&=ctzrZ`^hvU+I`MW8;Aw~`t_3cfbLj=- zVnyY;X@ZrFj|1{m<XKx2{Zmr`)lsprZ)0yCfl{Z(2F%AVYCNQyp#CV0iJ=l2u4nip z$jXttS`O#u{oXef_IY3B=3asH{$Am&+^MhqG%s%3QK24^TT$mdJ%|hF|Kw2)Q_lZJ zD0XkvRHj|r0X^xx)RJ)1VTOTT>oUjcAwt`*geXx1W90Y^hH5Hc{_de|e}JC?*V#~4 zbmNU*A;8ZrioyLJp<~pIXd}!7`!VV)c?lP{dSUGd_U6bq!)c$rsEL>a^h&gevr3Tp zfpRRy$h8BOKtn}`_{?&7G<b0>x_v&sX|2lytA`BzA%;4G^_MLL#PpzYOGZadn1c75 zXDr{u<*&>6IkAU06LN{y)H4Zf3B94~{Q3Rhbz|hm^&?I(W&6M@!?Eql-s^~#=K-<N zj0+C9an0f`3MMg^EPRPnOPt#G#^V!n=lg_)3@wtrXwz2Z1^INgwSE{+`S3+d{9;}$ zf;AN-+dtsGgA^C+YBpY$3<RV9#5pBZJ+=ibRj`stCox7lLC24I_N)sA^0jw4XskvW z{hQsFbMj_fnV?CbfJnrL3ZT7@pVc3AwBJ39@ARDwvfq2Qp~*z|Bb3n%Bed!2egf?T zqky}7Ek7`7eQ|;GoS$_z2@|`=8!`goipl&s7lq>hb2?C)-En6>V{`1tw38_{<etJ( z4Xmn9yny8{B_;IU8+{1}=YU+kXXJ~b{x-jxL*@La>#IyEcYTb>yUM%vJP6!teDF#q zm3PYh#D@_KMe|at2op;%OSB$Lu)>WiT8JJ)NlEo`w>GsuayQjlSx1+!tou`Bo``NO zzKQwC-eJ*Nt}8nwy|B@`_)!Y$@dEuldC7AtZ`DI{Q%ySY>SdPJ39buwEA2`<FCMFf zW(OjQ?}_|$x*#eRD_l0rm25F9*VSYN(HA=Wz)fuyD$h!bEd5}SIL-w9j=cabtaUkD z6N=nYG(a@h3fz&U@kc;U#5d`@jXgAm=Pmj4UVTwn0^12oRZ~O88se?Pp%S<3B{Mt0 z9y9$vlI;8a6*|}}Vjr2igsEgi?}!?dd1+a*F>ybEFAD$2?dp8x@$7YDR%$Wts#=wD zS1Fx)fLm2L)J}L;xM9h^vpmFNt*aHy=b)48O-bh6Kg3V@E0);W(?p$~+mOm&RfZ%! zApf`UgO?K7PT%GCPW>3vSLmc(*ldWftoW0Eo(zx3&qDBlR}M^gwh`pi62Ev`ax-@W z*J78gu+(37k0VYL2ZO<#y*h37{4cVv%{>bEN}?BDf<UQSyg>rpehNTpv0sIQ$NS?B zHp_;8$JZLmP-k}3w^-{x4`lUB(D%$7X>Vb#s+=m7{fy{+p9Z{G>uN!-81<6GV_ZsP z+^PJxB1A`1>(D%nOQ?M}&cFQ67@1{`!0=pco(kgnpGE)r`lq^=gvks|Kd~0awMDaZ zsMQ)w1Kh6`4RUMxZdA30F6Tk6{-v``JFt`u<y!S9LAwPrLS-E|kX(m3{QxBW@}OsG zlY&JfyL-NXd$U;EpL<u;TSGr&gL=fWoigVRNF8qpZu}3$*7gMqM+9*>^s>UkBI&T- z>H;?uIv1%eyD#p9rUn$)9A6YQZLH3Wbrb@guemYNBEzdJH<jX-K%s1?>DktfMWhk_ zIhws<8X*#?r3$_Tf_oal_Ul?Yv3R)&P<(B_c}GQjN0xF8(C}dFaO2pCL!6RX#5L2i zE;0*Q8jMc7(SmYY(YQTnc5Y+mkU~Ka5H$sjjbW5gtr;6yRq{+ww-EL&Lkg-z2>D&4 zj-lB`xxBow>BK%Dvmt3!^NZgBO62_EMBp0(EfQ6eAHAUxDJphCFs{u^Xmf1LIHpk& z8mXk+G``XDQ*Z^8VB~$)s*=(}1SJN#OvLDXDl3lCLd2OKoI~)0!Ce&LC?SY991e{Y z?NEbIR8N73#A~|VfEgzU-PVV&`;C&S=Hr>q-WARBGiHiEPcoe~-PvoJ-<z@rRy1JW zr7&no*?mZ8;BLJ@SAP?Euz4FyZ#r{rOFP_j(Y?jY?n^CZD`!e{#3vn=XTN6;dN#Cz z7LW_&h;h?QK`|<R%4Fo4@L~j4bx67AThuJ(n4kmjno}}~*fM6E{vlb;z=m6Fg(y%( zT{)#0RHHTTlse3WsXUFUce6-v<e-OC-}NOb7az`(#$MvPxkPR87=OM>*rF#M+rvK= zj|4KKL|vN}+7iPW&ue?70?#X+uGpumpWy2}mS9p;6WYhKWeKr04gvkW?W$Vd*ZWf` zqWAu()tyoD&$8e|eq8@uQ~BHDKuHSJteM`a-c}I9QZT-}X*HYloL-il;$_?;wiU9O z`%TmYuSN>qzN=n{rVa_;XOJ*0+Yvr(Mgq|9uigVOkDg51w^^4D*LT!Oer3ja4r=-Q zkiBvS9Y;aNtC8z1v#X6ink807(aVZcycW4ngB4JVuj$OgudrVmuzgrZ25Pp^Q3c`$ z)Qss<IscbN!&(5>f4@}n(a+!7(TF6UgpMw-Q0w?HDKXj?da^;Fkgwo2LXG^=4-E^w zS@Pa-l~Ah%+Zo!<!-TfcTq$sDQDZwE(Ng2|3=_MpBky8HHg_x$PrA&N`W>I=0v2fJ zn|~QWsO@+_KJH@`n<L1$HIC}MNQ*_E)hXYBg*UfF?B?llg>;z+OaTFQ7b>KMw~XH1 zYi<={)j8q#=wO81xj!RL!0XF(?Nfk*6k$bv+}MY;QP8GmcZ*Zvas9L&>;}7$h2o6U zk%FgW!RcS#m+8vsbRR8tMLf!-E{QF|Q@HX$h!QHg6rV#cxSl9YUr@CH-mX+>9YCZE z&E-GMM%{Bxm`!_y_Ltoaw!21<?6!}Ug8$(NH%~2h7iFkIJE<4}gG7uo=SDr|dN=qG zuh*$Jq>$v!IVIdwi9jbmKr1vzib2-%frv;Q7yEdl7V8bVu>(;3?2-K#!1M77T(8Q} zYvkMm%metFas<}*2kriW_769g=7xN}Pkp4VocnZSZX?>w5#jX^*75zOa!=Jl^&|&q zD(m}Q3b%jPt-qhAYap#g^3r6<3@aObP*^z0eHQXvtX3gq8(|;y+BM8+exI~bF+xp= zvSkBK#q+mNCf<}!?iG5^sPa+^s<UBfK|`K46$@vj_vy{epG^GFF1f(-nLE3!NqKJ> zI!(ERP-jO_>|>`?^^pw`?EAc^DQ{UsZ`#O1@_(aq>`<aGE$2`MfBva6LZ^g}j8ThH z<%_$r`Nt*XSvYF%>AS6?6E^k0d~p4(cSzfXq9maS>O--vjvqLoI#d#_@lgyb;K*)B zmD=0;dP;v;>JJ=4fBe{6E{tnLwCvk<fOhT!cZ?;|)+r?PhZj&{1E{RMZHW6juQ_$S z7|+<nRZy(sy6?O5<jEOy@;Rh|auvRvJ8l4x!D%sRsxUGFs5y6U=~Qos;gHS0XkYXB z<%YQZCMKm`&@S<W*ZAxPj8+xjo?XzV6w;Ng95rxhT2rKPd8#^F&(0oB+0E-e5XUVq zanOJ}flsusi!J63$Cybz$an0t##3`Z^?JXu^tNQfkX6IS?SvhOAZ6+G*Ds>P-=dTr z64A%m=@YzBn!(>L7tu%?QE=1&;E##xPi&YOy>vw-fFGeo_0FkJ;(GPu^lo7q9dJYI zcZhs%W&4tAucy=}g--e+r4-0tRiM-TT7zbhF@xpj`s*78AIhinHD%r=cEy=zci5X3 z7aMfu^5=``M(UhOVKJN%yE<jZ-5qdipsBfY1pZABXtChAV*TR@V(pGOpPqhbKA%%2 z5J<z50&#`;D4qZ47w5>|uMu}bw?DGt+MKf8_98I0KwS^Xv)NAuk(fuv)UEB$UOJV= zXv~31^s*OOPN6?glaE=bDmW|L&&aEj&zI(&Ol!2vM5;PB-ZEgIqXb)<=z;}0;FSsy z>IjCVj7mo6X8&OPOZjS(GWrsTusf-H<^uh=MXU>a@hjC@=kn%>1G)6v+kvdwIgkdF z90mG~SyrR>%G>ojk-DR8npSR;yTi|*_6%e5|04dn2!hAw4R1whMQ7EasLsa{jh~L# zHsDccbf^NTjHHTR_f%a%$p5Pl>~;3f`~^>G?+myt8s1wvd6Pji4k`kq>#s-uS<EUz zB0yy?_{b4Csof;q$mc^r5-#CR*X+82;*+oaj!Hh|bRC0Fr66xkGolcQeTeZ4o`5VU zkqel2J3@UuS#Pi>P!64c<gpE?Be=7;Go3UyU#RbJlu{vc&h{zv7vGj_2I(C@sHKsn zG)Qp*2LVA=+Og#8&|}i%ubKAt(A6)!m1W(Z2h5k&r+Nqfv^xiO&f=}5RU(DDh$!1| zNM>75IO#5q;4ap8R@7v__fB(aP33mp%BkLykGsyVrUX=0SHD$m%hyJ5>-SF!MQ`=t zHI57BXYp>y<5Gd$N~B!XD!7&%L3EQ+>ic$X^orh{<j1?FFC~>p{bzoM?!i{j`uIGm zdX0-R&j6f1xn&D35YL2@&KM)?79*A;^)?;X2s^1OQnHt$*C#d?7LE_CTCk*+sJ}Zx z#_R+4CH}PzZG?}ovypDvO5g3FU8<1ihpZHeQ0kwMP2J0jI(>@u)SpKg;ESz1-e%!f z<YdCpcv*8qDxQQ!wT>^O;RB%24ov5!v|(T~xyEZ??#ijoj{@|ij{Y_cZreM!EOA!w zaE-wlj*x*(Pk3@dHa?^A6LWs$26cL=dRQu%p36BR$_^aO1C?GgI#)x#c7nIOp=q8M zN|#~{7dYCoa%CG=dZn_6CR^UtMN~4kO-)DX<-ng{NiQ17r{yZ1WQ=Yd|MPL==hyjV z)AS@fw<CFY?mnyIN{M2Zdu7a=8Rsy*TS|*Nofbk3T2wssN3XW^qNrFqfq!W3Ph3N^ zl7=cm4yA&^rVzW51}+vXF5K4Qw5xNPI8n^y>FCjd_){8WhD-rsZGWAiFQP`-h@fuw zkvXzx+n{@&D{_PBkhzrB2DB&7ZE&`@(UN@$X?Ri;s`aTo^TBe67;(3#!_eHpY<kRQ z7nLe|@nrlv?bK;)%MIHkzqt^`#^sJ)y*ZSe4RC5`?zd`3<J|1Xj{lHxTsuq<byM`6 z#H5Pcy53@hJv%V&S<&!MAqU(^al)#|m59c*&88;84r*3U%;vm`eacIfTEpWUMZXPO zm^AOpRq^EIX=s-b!pL`UaPX%>x>cxk0CWpYI?s*cL<Wb=2l6NlOWG}YtFPKR7KVQf ziOS>b*EYEq$4uyN?N+5mcwWNk5O85#kNFYm;=OArq;{>bW7?c~rrfr<P{}SZ3BX_B zo9LU5izo-=CV006JN>zBQeqRx7{7bn^eYp7$;7cI98Q$tbwyM%1zZ@rNtx5K-~|Lh z=^V2tCf7~-I`Pfy0|&0J&B7`>Gzs2Glh90FwKDryE#gik*u<I}>%bx+WVn^dlX`cE zQ%@C#g>DP+v(nzat=c#>$@C~h=+Do~_b$>rL<c_Qajg&MAt&;hncu!C=8{Vv(w|Rk zHK|k56E)C5O`DONBTR1bdr3m-=v-;*Qb!ajhd$%YrRmvqrvb7NwC{p%!Q3aH*x$K7 zL2_csu|<HBA=T5yej;z{^T**6*z{6#Ys4O&pgjw_l~eJLZhYJ=4-Y-KJuE0nh1(|a z^m!!8?mglpQychT0NtoZ2i>R78{raT%CevN`F98I-3s$v5N;=U^wTzHW9Y>K;dtS6 z0(DroB9YlYZwN6<J16xXaDQq?EhWQT-2|RVi-cN>=1YDDjD^^S=lLYC#!DXKY=@I; zubAVC;jB{A1pPOHsdSt`;#~$>pQKQdMwESZ8dG~*)U046jR-$)d<V)dL&dB2&qkP? zfW!~rIrmR2*XC<FQsc>n+#UE?OCX})v!0zz@z$4CK=2II$+@K!Dt&-=_>@Q9^`#Eo zRN!zV-$~vMQ&4^+Wx<f7FrLO-+7U|NW_rgKGRHm{!0U<Nn`pc`ck?H3<mnz@=RQ)< zSlE!jACc`1-%C4MHuaf5U?wgFrgP_ZNXIAK*=2J}7<Zp89<etx>kL=YbThYw`AOUc zDD#eItNGCUG0pL^1PQ8|A_pti%h7DuaBQe4vps@lhY4qfw%rr&)IP#d3;}<Q%}tg{ zB{OW2evah`b&uiag*<I>gl>5YL+Sa|9j|%A!yQ*XXNuFVdgg7}*8YNZ$Muo}E;XAk z`OpDmB4=$gQ)ITN*jy@?dt@$92y`8^E-%Z*mtPT1FDm9sR^+r{ezv!~bW@1k^LWFN z{hAmLl*}p=Y_mnS?5Y7R*stmI3hOxu!%a@xu~_qY1^+4V8?Ee&VE&=w$iKFGshECM zcC3sobEvGNVYA;kF)JN-jQm)K?kvuSM+XkZZ5f}QJ1^56m`6`g!KP2v0PS!I{ZPEt zBXB4-c4}qH_xsN@>(dL(aof|365IzRKcl~Zg&|OZ1}TOkFSn#2tlY41WrUup_zx?) zkdxJViZo*IuW9nDfA33wY^C{~Jre{{DV*3lmZV9Mg>iYr#mV0G-JTl}d3!kL760(5 zK;0EeRO}pSTPHBTu2=b>zocMen%IbP!0oQT7C@AyUezZhS2E8`%Z_;F+4^(q1zns& z`AR;=q~CbtvJU{nivGO|AT+u!^Y(UvId$~iwYPO;9ZWs<ye+}Vo1L_>1%(3QOO)gf zV7`(!xvxROH+J+OuVw`Wx{jFHWzj@*s)1vX$d=8GqQMWn2TD7BZ$%Ut;s_qU6ZGSv z{C_ukoJYc4o$$)}y7BjCC)RQ({1&gI{GY)aq*MqF!NIwg)AG-ex$s2TV0>Flinf>s z9Fcz+f994VP8@A#lP&&Xm-rC{zf!#DQlS{j%Unl*=;<1}qQH_>fimDRb;`?g2i%b@ zVNlGC%8aK}@3&1d1!vZC91A`4Nv4?E-nH9x8=SlEHm14U2^Cn~?*ZqDEwL?o<D=TQ zS^03bX7^n#D>jdzQxouv|JHL-OM{?VCKDCvWxc%qV||MsW!KFy*7XWSwv5b0bRk?K zmw#?;<-w}D6Y}1q3&;|*sPy|c9t=I(f)6g}J#+3vIBWM<$i<8)RBOa6Uu#@8ok>4> z!<p6_S7660XA&D_3jQ+*MN#j3viQNDu0y5s-5^J$^eUHiw0H30U_CVf2Ywy&S0jnU z!}&>#^;0SaTwUEXJaYAeOu^^1969$+xPZ0UeRk=+XP!Ja?uHU+_Y~vMW$k)!Xd&Y4 z?C6?uJ-k|u49q#42i{`yB{wrGXD^e(Z#oopi7H0U&h=K}VT+Z5v&fY5kP&C_yrIf8 zR^FVwK70{^^)U|kt<Z)a1TG-hGnWn9Na4#r*O&3_gilS(lNWc^f<x?xw}WDX;>TiZ z-py5R4`-dk5o%^q&Ot`t+Dgx_Z<|hD%j<@XOlgFb0NluW+6Qys*V|BN6TJL$bgowy zl`My7DM7b~{ccQu1|DCGIP2%pg+CfwGygWWMMm|s80m}1)qSL3?)jM%i)f3oj+L}L z3#j!y^Y2nxD_`<g=G8Ed?}oR!12;eBDUk~Bgmzn5!C-hHmmcKYzKlAYH={^|fLnHu zk>Z4n53*%+?(xX)p50X&!Mrt@c7pv!<^<Q#>mR)7UF9q7XE1yP3BxmhUw#uyF8?qg zKUZf3B@f5e#P!QJH(~QNU`c*@mCo0yokiq*UZNFGR=;P?>_8=_avnT<n0yBE;%H(N zpe@(Zj_IYjl_8W0w6FuJF?)Ij?vtE@f<Ygx)p~632a0ZY;8YRym?hxwIZq}LHc$<2 zGB$XUq6Dv|D33t;JHhzIxkvW0L$(Zs{M-plQ#In|WgX(OiDvP@Ej{q%%(Y~`<oAj) zwCy(z&!_ZyEesD0t-^lXzKuEYw~zzlm@ljsddK;T&Aq>mi6N|>iTHFr6R1y6Dh1Ag zeNTX3A?pIF)E5jEcLbe$R`jVM%))(r>W$0K9E#Sz(^Ezo4Qzm?+X&ChvIc=8d^?g) zDLOne5260NXYpP}wZtU!kv_X&fG_z}44EtY;9^-vzhXmV5^(DX6nUEEM2^t0TC@!( za}GGFFAQgf*4x%5dz=S{;@(}jC!Bt&mT%bo_~#78FC*rt<Y`39R&?5qeI#iE>5pjh zEYH~ATy_W+w~kKUTz~j^56SyOC{!q@QL$0KWgm#$7MNZjNtj*D@fjTs5$jO=0-mpC zyeRGPehVwjHdJfK#}#ddGf>rkoeu>yG`GC9M0F9xNx+4f$rrhZ@R!JeHw<`tzU)Qm zpzMyo+3um!oS9`6SRM<$oA~!k{!Bu#3dv`C7Ti7S1<a(@lpby5!qd$9oj|$BgF`<p z=Y`WNE^YtTqouOtt-w+~{1lZOXnWO=lzoiy{r(QNVO<h%=r%EhVX>W1uA7}NqF%ER z{A&W1riVBoPC}~&6e}ai$-4$E-}}fT0}hRf+Zjw-SkaGfOWvL+n4vg}BA}985!pYQ ztR?gBAgZ<{61Qb@>o%kU(<K~%naYhAcU7uA*yq}`E?Ieq^T20VLG|f+e_ZdK#{!-9 z0)11h+uUP)wLaEjN{UgTesJ>NFvYdh<-D_AVa1=O=uYHy@vZ`gJfO(UdNo^XeNv<t z>9vxp)yCK?p~<@Zvp~AvGp7&h+X(@J1s(j9C#&kh)M<81bV5wJyA+{eOJU?Zq_G-( zgO5lvF6;PGcGjn*lm^V7Bl;o#YF@<ZSO`!*d&D*v{g_hjiB1JR%m*g@IrNaP^<w2< zP{B@}G7h_08<l)p-iQzG>YIQ12H1be+jh13bK1Ot*70sz$i-PF41CjTckbQ!n#siy zs_>q6Ldiaa^q0H|ROO5L-mi?jBR5V56r20W9rNr^Y$t5n1)bji?sF$_dS(01@>hBL zw%(d>Rg9@P!aXsDA9sgk$@*D?z20wgc5mej@cs;oBCK@5g%tKa`pyzr!r*0<Ys=o8 z3FgE|9+7&{h=j%6Lnrg<USk)e^1Zig%%=?2*Us|IH)HdC5s5%(OxIju3O%6m7W3EF zmdQomvR>)`MnnVC69sX%={A+i>d%3`V8j2OU!Kx;j_ad$-O3~8l0QEHOnNSwx(QKZ zDju_|FE*0zXcF7<F2rAXs{zLA*)3pky>c}hGl#QcF_vMSArir(X$Ef#Hv7-k@L46# z`8g;<7w~8AAzBJk^UDl_3|8&9j#|I2c8wkCIV-AhcGZIZTo8O*2^50Ug?}uvU)>ca zNP}%3nCHrrp1e{bolzojBwY3`-i8<Vw1co?G2&a67|D>|3gdhhY&rn$crMaT=q5pS zIfys*gm%J&CaJ&)blObPv)d5FRRbD(sQs69+6kwP5YknJFM-Ph(n~2qp>P4Z>KdLj zgHAqeb{Y(x9um6UpaQ*+@-IIx2NP>SJG7d-u1bfq^rZ-U{|7}SKU0Bx@rByD&<!P2 za$I1#yLywC!wPJouYg-`u;SYiF7Vkag2(Uwz-MnuP$KDz5nk9*4hwz2=9?p|Hn45U zjX1)aaMs;q#v^GV@bMks77>9t@sGD1r1RQ*-;sUIGOFj$$x)M@8(7DW6f91@oqz{E zE7D4&Q$=!p5q~XcSjS09F8O-^s`HrDQxa<Bgbpe>07hKLPJcc5N9gu>#NVOE6)0#c z#3-Rak?IYs%AV;HK_%b(2Y-r%&V{qWB?wYdX4Zgy$4+>Fue?zT{n>7gfeAby52*91 zxeHcdz{d}OL+Xf}r?Av-*Z}g}`;+Y6AWLX`C&^tMVX`8Ou+jz}JL7dMl1R|50oq%a z5^QUxk~HXk6xg$q+_V=yKK>KLy;8NaOJWJ#{w{B{tlHXR4oevp#ZxV;WfBGDatVC= zVOKaQX@CR;sgNXOt_FO;VR0kq<TD1+rD6#pxf=+pI$5C!#5bB-J+kPya8hv#2`bhm zNt8Dlg_Xek!oHx2??gX<uv*5GJkIL0&)25m2(sAply?n}fqf=8!lWQB!)D%6i!CJe zymXLO4Y>D!_YKwj|M&D4Nd1&=f$%)Fz!9w&0c4-F1ynph>t85(feiP7UD+Fi)dft@ zTT(9B)f3t^3usDV!N+GX&PYtq8(1<|(XB5^OsIu|iNK-5P!J19(82|#-<@D+z6ymq zRamh6Ht?+pbk)L1J>R{9^fDAG5QIJbRg=(PMx?Mn{dR)7I_YjD$ajpe=5_!I8V0^T zl7=N<5-MxVm<jraS0iP{#GV6!!$bM(jlmak<IQ;!hzs~KS9znaa`{mtk$(>1aRyR< z2KmqPp8VOJLburtyW2^|y`CN+#s?h2N(p>yFMhMs=6|Qa<3qyti)}2ST`DB~QnKyc z;{?)4Ny7aMiq})=RN%7)DSS_3qJyqcf@%oHxmnDySON(uzXHBhqQrN-Cqb?I5ygT0 zANIVzk_g=L1+-VAe<AwD;VoW5Sb3s{CUCjr+ee|GGEy$#%5^=vy%DUXX`0Ixysstb z<V-W%8$|?HRg&=Kyk8iw{T!BZ9i7~;gY;M$&N#md;BGiTCv{yU!FW7_K#h4P3-n3D zQ|H2Kb%1V>;b=?@bWH)|M4E5}*HoZqCvYtuHXnOs(xe!KBufXTtKtc-_!;3i*yc14 z1?FJ35KQ`l?O6m&WBlQG0cBT#sOlB}hnBCskEXm<OVzV`fn}L2LCpzAaD?xwP{qc4 z?>H5XzOE9tu2p5ff;R-N3;a4rwmpLR7Y@3<77#j=(kP<}ho{xgIQL#j!^u9Zo)bHj z+mE)1|BG1*Zf=u)EQ#cnN)jq;D$Zd+*9zctPYzK}xCKkeKos;>8bblH|B`I}^k_B6 z*`P`*F#V(i0m-&4qTvV@Xgl$xBToKs7%l@^A;yKrEg^R$(n>TXO-vcyn8OeO6vE<D z0Sm}o-HJehlPn2k904wI3E+KNU=AyAFnbp`nvH&~eE;CxHv`B}@=zuESGKD(0)?nO zsty;>Qh@^&1d~u{u>6HI5_CNZ*nmk1uvW7@J&f5nd81rfS*;uvjNgIvaQ;9+;W!@N z=^w-rU)TVLAm@Ls)?bj4fwF)g`hWK?jx+p)qn`F80_sO$K)91}E_wHwf~r2tRV-P= z=aH0uJbmDQw7dBeflpc_hF=`w0-t&e0?XYi)>r--;0kjKlyC^ue-HrzP6c9B9zZCO z_6p+IS+CV#g)rqOY$&d)iFeYzv@4E^3RHn8yB?UUZI&#!?y-}r!}F1#rHwOa^xYL( z{0P-^h}vt5A?TxX@Rl6lhH_uCKES&2^iQQAS3ui-SV)YDNL~n&L6fmKU#?4>dWl?u zh@2EPoxUT*ADdo_!Y#bE(&d0EkUfVScURN8la?aLNLM`NfXAjAaS23}YCMi`h@-S6 zeg_L@*3Q5!?)3vGCHT!*803kUvxE{6I;B*PME_L4grmd}5NX!L!so1HQzcu6!w-jV zW{J!LO*$r_H9X6;iqc)e;0ptZys|w9VC9>5yyjCPS+Z$e?xOg@xbY}Iu9i5FOQ}F% z2lB$5tDNPHlKrk94O<o`#Ng<AyHPopwu8Y<TlUh|&UVof*3w;5;kOL)7x$c%dv*;V zFJ%~sq<F=<oW`8LK3=vfJ{p^@HR}6UvFp1hG%cLYDap4npZH1ZPgrr0A}^z{Y&RM5 z$T~9avy$Ctd}#y=${WI#YLechD}0tbbzkkXAWp*fZq37cLZt(=vyY}8#i`Dg?k3@4 zE?sY_I5^3l{%&XVdD*(g%VB;cB0l2n2k9;xr4ztIN0cBg(bw!;x-K}qg86Fy3&nmt z(fmdRjSa7?0RqGb$G%W&-l(rliPTKK%FzXoj9-XI96I0QyFaNlzOwtWa**K(1Qxi2 z*2w`TT+ak_o{9T!sIp(~<@KeCoI?PT<OH^PfzwH7r;fi>-Dmc_%{0jQ+C%9S=}vts zv!@eqp#oj*`EQR<t8$(r5x^^qjt8dT3J?NP-T{uiv^_$k-y%9dxeA1NiflEb0e}HU z=M?4{0ic2;Ap10`P#SPc1rVeP(9ta3LkoHhRQUphil7FHp&v*BD1(6ME_sG~U^<Zh z0SJ%=H2{VmfC~V#Pw^3nka`vYAe{r-!ueDUq0?ER0O=UmcA2k4A7+36{53+}mSX(y z?gR1*fdJElyW)%`ZvaN9b`VC8I?b{{fR`c=)lL~j=zNjA2zYG=>e_)U1ZCjgjT9hy z0Hf1orVmu@1TJckTR}=qOgcdM3g$|nDIfm*Gz0z~L%5kj{HqL*aTI8MmslJM+|fFY zhAsi@{JsBU!m;{l1qBdaM?waG&R0ni8Sn*2K<fa)O9T4E0ZCh+<16$l7r=N83H?H5 zpBD_d1^h<9vbiiAP6IB^zeYjPNcg4>0Mxkv#R<&4D?$*Z0xl7N8`IKF8p?$yfu#qx z%?|-^dI`9M0D1$KZa8QFM%)0~E{eK-0+Qg_4Ny9>viFYzf1Lm@za0p8gRRB^Zfp$j z3a!&`U2Y9zClP_-J&=rclLkPF0};SkETzdTaUFicND(NPf$Uc)nHUxtKtY&0-$BPu zFmN063GObVbu_5}4sez7A4Tb$g4JmXP55X%z79Bcz%+PBj+23+=u?3zbHEvv=_Q!r zEQ%mStpnPcU;;tt3TFh=v4p`O>+~TYU|1A5b7sre=F3LH5o%>x2>Sp1TZ#S)o+v7t V8qsJN@Bn~6_SVi;&nzhM{|6Rr19JcX 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 2b2c64af4..6f0fda920 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,54 +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 five 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 │◀──── headless mode ──────┤ -│ │ │ (JSON-RPC over stdio │ │ -│ │ │ or .ade/ade.sock) │ │ -│ │ └──────────────────────┘ │ -│ │ ┌──────────────────────┐ │ -│ │ │ apps/ade-code │◀──── terminal Work chat ─┤ -│ │ │ (Ink TUI, same RPC │ │ -│ │ │ socket or embedded) │ │ -│ │ └──────────────────────┘ │ -│ │ │ -│ └── 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. @@ -61,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 | @@ -83,98 +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`). -- **`ade code`** — launches the terminal-native Work chat client (`apps/ade-code`, Ink + React). Uses the desktop JSON-RPC socket when `--socket` is set on the parent `ade` invocation (same path as other socket-backed commands); otherwise the TUI runs embedded against the headless runtime (`--embedded`) without implying the global auto-socket discovery used by `executePlan`. Override the binary with `ADE_CODE_EXECUTABLE` or a sibling `apps/ade-code/dist/cli.js` after `npm run build` in that package. -- **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 ADE Code (`apps/ade-code/`) - -Terminal-native **Work** chat client (Ink + React) for agents and power users who live in a shell. It speaks the same ADE JSON-RPC surface as the desktop app and `ade-cli`: **attached** mode connects to `.ade/ade.sock` (or the Windows named pipe from `adeMcpIpc`) when a socket is present; **embedded** mode loads `createAdeRuntime` / `createAdeRpcRequestHandler` from `apps/ade-cli` at runtime so headless services run in-process without Electron. Shared chat DTOs are imported from `apps/desktop/src/shared/types/*` (never the renderer barrel) so `npm run typecheck` in this package stays isolated. Entry: `src/cli.tsx` → `dist/cli.js` (`ade-code` bin). Launched from the desktop shell via `ade code` (see §2.2). Multi-window navigation from the TUI uses the `app/navigate` JSON-RPC method when a desktop socket is attached. - -### 2.4 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.5 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 @@ -183,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. @@ -446,9 +437,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 | |--------|-----------|------| @@ -461,7 +452,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. | @@ -470,11 +461,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. | @@ -485,11 +477,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)`. | @@ -858,19 +851,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 @@ -917,11 +912,10 @@ 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) -│ ├── ade-code/ # Terminal Ink TUI for Work chat (socket or embedded headless) -│ ├── 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) @@ -950,8 +944,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-code` | `dev`, `build`, `typecheck`, `test` (Ink TUI; uses granular imports from `apps/desktop/src/shared/types/*`). | +| `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. | @@ -959,18 +952,16 @@ Per-app scripts: Stages: -1. **Install** (`install` job) — checkout, setup Node 22, parallel `npm ci` across desktop, ade-cli, web, and ade-code with a shared cache keyed on all four 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`. - `typecheck-ade-cli` — `cd apps/ade-cli && npm run typecheck`. - `typecheck-web` — `cd apps/web && npm run typecheck`. - - `typecheck-ade-code` — `cd apps/ade-code && npm run typecheck`. - `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. - - `test-ade-code` — ade-code vitest. - - `build` — all four 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). diff --git a/docs/PRD.md b/docs/PRD.md index e67877c75..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). @@ -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/ade-code/` (terminal Ink client for Work chat), `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 62c24173a..94f6e0c32 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,11 +2,13 @@ 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 @@ -21,7 +23,7 @@ docs/ │ └── ship-lane.md # autonomous PR-to-merge driver └── features/ ├── agents/ # agent identity, tools, personas - ├── ade-code/ # terminal Ink Work chat client (ade-code) + ├── 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 @@ -37,6 +39,7 @@ 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 diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index 1d3a77bac..af47b318f 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -1,36 +1,155 @@ # 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, and lane context flow through the same ADE action and JSON-RPC contracts as the desktop. +`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-code/src/cli.tsx` | CLI entry: argv parsing, project discovery, connection bootstrap, Ink mount. | -| `apps/ade-code/src/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle. | -| `apps/ade-code/src/connection.ts` | **Attached** path: JSON-RPC over `.ade/ade.sock` (or Windows named pipe). **Embedded** path: dynamic `import()` of `apps/ade-cli` `bootstrap` + `adeRpcServer` so headless services run in-process without pulling the whole dependency graph into `tsc` for this package. | -| `apps/ade-code/src/jsonRpcClient.ts` | Socket client: connect, request/response, `chat/event` notifications. | -| `apps/ade-code/src/adeApi.ts` | Typed wrappers over `AdeCodeConnection.action` / `actionList` for lanes, chat, models, navigation. | -| `apps/ade-code/src/commands.ts` / `linearCommands.ts` | Slash and command routing. | -| `apps/ade-code/src/format.ts` | Transcript rendering helpers for the TUI. | -| `apps/ade-code/src/types.ts` | Connection shape, launch context, navigation DTOs aligned with `apps/desktop/src/shared/types`. | -| `apps/desktop/src/shared/types/chat.ts` | Canonical chat DTOs (`AgentChatEventEnvelope`, sessions, pending input). Imported from **per-module** paths (not `types/index.ts`) so ade-code typecheck stays scoped. | +| `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. | +| `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/types.ts` | `AdeCodeConnection`, `ProjectLaunchContext`, navigation DTOs aligned with `apps/desktop/src/shared/types`. | +| `apps/ade-cli/src/tuiClient/components/` | `Drawer`, `ChatView`, `Header`, `RightPane`, `SlashPalette`, `MentionPalette`, `ApprovalPrompt`. | +| `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 `.ade` paths including socket location. | -| `apps/ade-cli/src/cli.ts` | `ade code` launcher: resolves `ade-code` binary (`ADE_CODE_EXECUTABLE`, sibling `dist/cli.js`, or `PATH`). | -| `apps/desktop/src/main/main.ts` | Multi-window shell: project windows, shared menu, JSON-RPC `app/navigate` for external controllers. | -| `apps/desktop/src/renderer/components/app/TopBar.tsx` | Window tab strip + project navigation when multiple windows are open. | +| `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 -## Runtime modes +`apps/ade-cli/src/tuiClient/app.tsx` is the Ink root. Layout: -- **Attached** — `JsonRpcClient` connects to the desktop RPC socket. Initialization follows the same `ade/initialize` handshake as other socket clients. -- **Embedded** — no socket: `createAdeRuntime` + `createAdeRpcRequestHandler` from `apps/ade-cli` serve actions in-process. Used for headless/dev environments where Electron is not running. +- **Header** — project name, active lane, chat session, model + reasoning effort badge, token / cost counter (`latestTokenStats`). +- **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. Server-provided `AgentChatSlashCommand`s from the active runtime are merged in via `getSlashCommands` (responses with `source: "local"` win over built-ins). + +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 -From a machine with the `ade` CLI on `PATH`: `ade code` (see `apps/ade-cli/README.md` for flags, `ADE_CODE_EXECUTABLE`, and how `--socket` on the parent `ade` process is forwarded). After local changes, run `npm run build` inside `apps/ade-code` so `dist/cli.js` exists for sibling resolution. +```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`. ## Related docs -- [Chat feature](../chat/README.md) — in-app Work chat architecture (service + renderer). -- [ARCHITECTURE.md](../../ARCHITECTURE.md) §2.2–2.3 — CLI and ade-code placement in the system diagram. +- [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 06344f4a9..0cabf37ce 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/settings/` — usage/budget/cost UI for automations and missions (shared with Settings > Usage). `UsageGuardrailsSection`, `BudgetCapEditor`, `CostSummaryCard`, `UsageMeter`, `UsagePacingBadge` all live here; they no longer sit on the Automations page. - `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 5423cd92e..55ade4d13 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 aaf39d90a..27e60a26c 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -12,7 +12,7 @@ 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-code/` | 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/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. | @@ -41,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`, @@ -78,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/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 ccadb3a24..1850a7da3 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` @@ -244,6 +270,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 @@ -261,6 +322,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 @@ -274,8 +346,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 @@ -283,7 +353,7 @@ 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 | 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 index 481fa281d..395fdafea 100644 --- a/plans/remote-runtime-architecture.md +++ b/plans/remote-runtime-architecture.md @@ -216,7 +216,6 @@ The 40-45 services currently in `apps/desktop/src/main/services/` that are not y - `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/cto/openclawBridgeService.ts` - `services/github/githubPollingService.ts` - `services/usage/usageTrackingService.ts`, `services/budget/budgetCapService.ts` - `services/agents/agentToolsService.ts` @@ -440,7 +439,7 @@ const stdioTransport: JsonRpcTransport = { startJsonRpcServer(handler, stdioTransport); ``` -Plus a single-runtime constraint: `ade rpc --stdio` boots a runtime in-process for this session (no daemon, no service install). Disconnects → process exits. This is correct because each SSH connection wants its own runtime instance. +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 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)); +} From b130051da54abc81575403dc3e8492e75795dca8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 03:24:09 -0400 Subject: [PATCH 04/11] Port ade code TUI branch onto runtime refactor (#280) Port the ade-windows-and-cli-2 TUI, slash-command, sync, and docs changes onto ade-windows-and-cli after the runtime refactor. Includes review fixes from Cursor and Greptile. --- .claude/commands/automate.md | 316 ++- .claude/commands/finalize.md | 271 +-- apps/ade-cli/src/adeRpcServer.test.ts | 48 + apps/ade-cli/src/bootstrap.ts | 1 + apps/ade-cli/src/cli.test.ts | 9 + apps/ade-cli/src/cli.ts | 17 +- .../src/services/sync/syncHostService.ts | 22 +- .../src/tuiClient/__tests__/ChatView.test.tsx | 162 ++ .../tuiClient/__tests__/RightPane.test.tsx | 91 + .../src/tuiClient/__tests__/adeApi.test.ts | 213 +- .../src/tuiClient/__tests__/commands.test.ts | 94 +- .../src/tuiClient/__tests__/format.test.ts | 174 +- apps/ade-cli/src/tuiClient/adeApi.ts | 159 +- apps/ade-cli/src/tuiClient/app.tsx | 1976 +++++++++++++++-- apps/ade-cli/src/tuiClient/cli.tsx | 6 +- apps/ade-cli/src/tuiClient/commands.ts | 92 +- .../src/tuiClient/components/AdeWordmark.tsx | 23 + .../src/tuiClient/components/ChatView.tsx | 379 +++- .../src/tuiClient/components/Drawer.tsx | 76 +- .../tuiClient/components/FooterControls.tsx | 77 + .../src/tuiClient/components/Header.tsx | 78 +- .../src/tuiClient/components/ModelStatus.tsx | 76 + .../src/tuiClient/components/RightPane.tsx | 203 +- .../src/tuiClient/components/SlashPalette.tsx | 35 +- apps/ade-cli/src/tuiClient/format.ts | 230 +- apps/ade-cli/src/tuiClient/state.ts | 37 + apps/ade-cli/src/tuiClient/theme.ts | 78 + apps/ade-cli/src/tuiClient/types.ts | 80 +- apps/ade-cli/vitest.config.ts | 2 +- .../main/services/adeActions/registry.test.ts | 30 +- .../src/main/services/adeActions/registry.ts | 30 + .../src/main/services/ai/aiSettingsStatus.ts | 138 ++ .../main/services/ai/claudeRuntimeProbe.ts | 2 +- .../services/ai/providerConnectionStatus.ts | 2 +- .../services/chat/agentChatService.test.ts | 152 +- .../main/services/chat/agentChatService.ts | 121 +- .../chat/claudeSlashCommandDiscovery.test.ts | 221 +- .../chat/claudeSlashCommandDiscovery.ts | 154 +- .../chat/codexSlashCommandDiscovery.ts | 22 +- .../services/chat/cursorSdkEventMapper.ts | 2 + .../services/sync/syncHostService.test.ts | 21 +- .../components/app/CommandPalette.test.tsx | 28 +- .../renderer/components/app/TopBar.test.tsx | 2 +- .../components/settings/ProvidersSection.tsx | 4 +- apps/desktop/src/shared/types/chat.ts | 1 + docs/ARCHITECTURE.md | 4 + docs/features/ade-code/README.md | 17 +- docs/features/chat/README.md | 2 +- docs/features/chat/composer-and-ui.md | 16 +- .../onboarding-and-settings/README.md | 2 +- 50 files changed, 5183 insertions(+), 813 deletions(-) create mode 100644 apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx create mode 100644 apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx create mode 100644 apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx create mode 100644 apps/ade-cli/src/tuiClient/components/FooterControls.tsx create mode 100644 apps/ade-cli/src/tuiClient/components/ModelStatus.tsx create mode 100644 apps/ade-cli/src/tuiClient/state.ts create mode 100644 apps/ade-cli/src/tuiClient/theme.ts create mode 100644 apps/desktop/src/main/services/ai/aiSettingsStatus.ts 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/<name>/` 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/<area>/ | docs/features/<same-area>/ | +| 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/<name>/ 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/<name>/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 <changed swift files>` 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 <domain.action>` 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 <domain.action>` 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 <domain.action>` 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: - <new file or extended file> — <N tests covering: contract A, contract B> - Or "none — feature was visual / fully covered by consolidation" +Parity: +- Docs: <files updated, or "none required"> — validation PASS / blocked +- Mobile: <iOS files changed, or "none required"> — validation PASS / blocked +- CLI: <apps/ade-cli files changed, or "none required"> — typecheck + tests PASS / blocked +- TUI: <apps/ade-cli/src/tuiClient files changed, or "none required"> — typecheck + tests PASS / blocked +- Breaking flag/command/slash renames: <list, or "none"> + Verification: - Affected files: PASS (<N> 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 -- <file>` 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/<name>/` 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/<area>/ | docs/features/<same-area>/ | -| 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/<name>/ 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/<name>/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 <changed swift files>` 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 <domain.action>` 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 <domain.action>` 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 <domain.action>` 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/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 669561d4f..3bbd9d1af 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -225,6 +225,41 @@ function createRuntime() { 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" })), @@ -4113,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); @@ -4187,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", diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 0a9907ff3..9b2dd6f51 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1123,6 +1123,7 @@ export async function createAdeRuntime(args: { 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 3f77c827e..dd10c6bca 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -73,6 +73,15 @@ describe("ADE CLI", () => { 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", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index fa436febd..23175a2e5 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -883,14 +883,25 @@ const HELP_BY_COMMAND: Record<string, string> = { code: `${ADE_BANNER} ADE Code - Launch the terminal-native ADE Work chat. It shares lanes, chat sessions, - transcript state, and slash commands with desktop ADE. + 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 <path> 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 diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 98abf8f2a..8e5d76293 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -755,16 +755,21 @@ export function createSyncHostService(args: SyncHostServiceArgs) { 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(`${args.projectRoot}:`)) continue; + if (!key.startsWith(prefix)) continue; if (nowMs - record.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) continue; - const deviceId = key.slice(`${args.projectRoot}:`.length).split(":")[0] ?? ""; + const deviceId = key.slice(prefix.length).split(":")[0] ?? ""; commands.push({ key, projectRoot: args.projectRoot, @@ -795,7 +800,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ? mobileCommandArgsFingerprint(legacyArgsKey) : null; if (!argsFingerprint) continue; - mobileCommandResultCache.set(command.key, { + 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, @@ -809,10 +819,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } }; const commandLedgerSizeForProject = (): number => - [...mobileCommandResultCache.keys()].filter((key) => key.startsWith(`${args.projectRoot}:`)).length; + [...mobileCommandResultCache.keys()].filter((key) => + key.startsWith(commandLedgerKeyPrefix()), + ).length; const dropInFlightCommandRecordsForProject = (): void => { for (const [key, record] of mobileCommandResultCache) { - if (!key.startsWith(`${args.projectRoot}:`)) continue; + if (!key.startsWith(commandLedgerKeyPrefix())) continue; if (record.result == null) mobileCommandResultCache.delete(key); } }; 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( + <ChatView + events={events} + notices={[]} + activeSession={session} + projectName="ADE" + laneName="Primary" + maxRows={options.maxRows} + scrollOffsetRows={options.scrollOffsetRows} + width={options.width} + />, + ); + 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<Extract<RightPaneContent, { kind: "model-setup" }>> = {}): 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<Extract<RightPaneContent, { kind: "model-setup" }>> = {}): string { + const result = render( + <RightPane content={content(overrides)} selectedIndex={selectedIndex} focused />, + ); + 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 index c58672c49..7d191c4c7 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -1,6 +1,25 @@ -import { describe, expect, it } from "vitest"; +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 { latestTokenStats } from "../adeApi"; +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, @@ -23,7 +42,6 @@ describe("latestTokenStats", () => { turnId: "turn-1", inputTokens: 2_000, outputTokens: 500, - totalTokens: 2_500, contextWindow: 10_000, } as AgentChatEventEnvelope["event"]), envelope(3, { @@ -36,11 +54,198 @@ describe("latestTokenStats", () => { ]; expect(latestTokenStats(events)).toEqual({ - percent: 25, + 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<string, unknown> }> = []; + const connection = { + action: async (domain: string, action: string, args?: Record<string, unknown>) => { + 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<string, unknown> }> = []; + const connection = { + action: async (domain: string, action: string, args?: Record<string, unknown>) => { + 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 index b6c197f9c..b90421961 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -16,7 +16,7 @@ describe("commands", () => { expect(parsed ? commandPlacement(parsed) : null).toBe("right"); }); - it("routes user-defined commands to chat", () => { + it("routes runtime commands to chat", () => { const parsed = parseCommand("/ship now", [ { name: "/ship", description: "Ship it", source: "sdk" }, ]); @@ -24,19 +24,107 @@ describe("commands", () => { expect(parsed ? commandPlacement(parsed) : null).toBe("chat"); }); - it("lets local project commands override ADE built-ins on exact name", () => { + it("lets runtime commands override single-word ADE built-ins on exact name", () => { const parsed = parseCommand("/status please", [ - { name: "/status", description: "Project status prompt", source: "local" }, + { 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__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index a751521b1..18438c099 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -1,7 +1,30 @@ import { describe, expect, it } from "vitest"; -import { latestExpandableFailureId, renderChatLines, renderObject } from "../format"; +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, @@ -23,7 +46,65 @@ describe("renderChatLines", () => { }); expect(lines.map((line) => line.tone)).toEqual(["user", "assistant"]); expect(lines[0]?.header).toContain("you"); - expect(lines[1]?.header).toContain("ade"); + 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", () => { @@ -102,6 +183,95 @@ describe("renderChatLines", () => { 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", diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index ae46c8a80..36d20dd16 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -1,16 +1,36 @@ -import { getDefaultModelDescriptor, type ModelProviderGroup } from "../../../desktop/src/shared/modelRegistry"; +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<LaneSummary[]> { return await connection.action<LaneSummary[]>("lane", "list", { includeArchived: false, @@ -42,6 +62,28 @@ export async function getSlashCommands( return await connection.action<AgentChatSlashCommand[]>("chat", "getSlashCommands", { sessionId }); } +function slashCommandKey(value: string): string { + return value.trim().toLowerCase(); +} + +export function discoverProjectSlashCommands(workspaceRoot: string): AgentChatSlashCommand[] { + const byName = new Map<string, AgentChatSlashCommand>(); + 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, @@ -52,6 +94,21 @@ export async function getAvailableModels( }); } +export async function getAiSettingsStatus( + connection: AdeCodeConnection, + args: { force?: boolean; refreshOpenCodeInventory?: boolean } = {}, +): Promise<AiSettingsStatus> { + return await connection.action<AiSettingsStatus>("ai", "getStatus", args); +} + +export async function getStoredApiKeyProviders(connection: AdeCodeConnection): Promise<string[]> { + return await connection.action<string[]>("ai", "listApiKeys", {}); +} + +export async function getOpenCodeRuntimeDiagnostics(connection: AdeCodeConnection): Promise<OpenCodeRuntimeSnapshot> { + return await connection.action<OpenCodeRuntimeSnapshot>("ai", "getOpenCodeRuntimeDiagnostics", {}); +} + export async function createChatSession(args: { connection: AdeCodeConnection; laneId: string; @@ -59,20 +116,51 @@ export async function createChatSession(args: { 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<string, AgentChatCursorConfigValue>; }): Promise<CreatedChat> { - const provider = args.provider ?? "codex"; - const descriptor = args.modelId - ? null - : getDefaultModelDescriptor(provider); + 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?.providerModelId ?? descriptor?.shortId ?? (provider === "claude" ? "sonnet" : "gpt-5.5"); + 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<AgentChatSession>("chat", "createSession", { laneId: args.laneId, provider, model, ...(modelId ? { modelId } : {}), ...(args.title?.trim() ? { title: args.title.trim() } : {}), - ...(args.reasoningEffort ? { reasoningEffort: args.reasoningEffort } : {}), + ...(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", }); } @@ -83,11 +171,14 @@ export async function sendChatMessage( text: string, attachments: AgentChatFileRef[] = [], ): Promise<void> { - await connection.action("chat", "sendMessage", { - sessionId, - text, - ...(attachments.length ? { attachments } : {}), - }); + await connection.actionList("chat", "sendMessage", [ + { + sessionId, + text, + ...(attachments.length ? { attachments } : {}), + }, + { awaitDispatch: true }, + ]); } export async function approveToolUse(args: { @@ -143,11 +234,33 @@ export async function updateChatModel(args: { 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<string, AgentChatCursorConfigValue>; }): Promise<AgentChatSession> { 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 } : {}), }); } @@ -170,12 +283,16 @@ export type TokenStats = { costUsd: number | null; }; -export function latestTokenStats(events: AgentChatEventEnvelope[]): TokenStats { +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<string, unknown>; if (event.type === "status" && event.turnStatus === "started") streaming = true; @@ -183,16 +300,7 @@ export function latestTokenStats(events: AgentChatEventEnvelope[]): TokenStats { if (event.type === "tokens") { inputTokens = typeof event.inputTokens === "number" ? event.inputTokens : inputTokens; outputTokens = typeof event.outputTokens === "number" ? event.outputTokens : outputTokens; - let used: number | null = null; - if (typeof event.totalTokens === "number") { - used = event.totalTokens; - } else if (inputTokens != null || outputTokens != null) { - used = (inputTokens ?? 0) + (outputTokens ?? 0); - } - const limit = typeof event.contextWindow === "number" ? event.contextWindow : null; - if (used != null && limit != null && limit > 0) { - percent = Math.max(0, Math.min(100, Math.round((used / limit) * 100))); - } + if (typeof event.contextWindow === "number") eventLimit = event.contextWindow; } if (event.type === "done") { const usage = event.usage && typeof event.usage === "object" ? event.usage as Record<string, unknown> : null; @@ -201,5 +309,10 @@ export function latestTokenStats(events: AgentChatEventEnvelope[]): TokenStats { 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 index ba0e88b54..dc3d619ea 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -2,21 +2,38 @@ 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 } from "../../../desktop/src/shared/modelRegistry"; +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, @@ -31,32 +48,55 @@ import { } from "./adeApi"; import { paletteCommands, parseCommand } from "./commands"; import { connectToAde } from "./connection"; -import { Drawer } from "./components/Drawer"; +import { Drawer, visibleDrawerChatCount, visibleDrawerLaneCount } from "./components/Drawer"; import { ChatView } from "./components/ChatView"; import { Header } from "./components/Header"; -import { RightPane } from "./components/RightPane"; +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 = "#A78BFA"; -const EFFORTS = ["low", "medium", "high", "xhigh"]; -const PROVIDERS = new Set(["codex", "claude", "opencode", "cursor", "droid"]); +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<AdeCodeProvider>(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<string, string> = { "/app-control": "/app-control", "/browser": "/browser", @@ -85,10 +125,173 @@ function initialModelState(): AdeCodeModelState { model: descriptor?.providerModelId ?? "gpt-5.5", modelId: descriptor?.id ?? null, displayName: descriptor?.displayName ?? "GPT-5.5", - reasoningEffort: "medium", + 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<AdeCodeModelState, "provider" | "model" | "modelId" | "displayName" | "reasoningEffort"> { + 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<AdeCodeModelState, "provider" | "model" | "modelId" | "displayName" | "reasoningEffort"> { + 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<AdeCodeModelState, "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "permissionMode"> { + 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<AdeCodeModelState> { + 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)}`; } @@ -121,6 +324,158 @@ function formatTokenSummary(stats: ReturnType<typeof latestTokenStats>): string 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<AdeCodeProvider, "opencode">): 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; @@ -135,14 +490,56 @@ function splitFirstArg(input: string): { first: string; rest: string } { }; } -function parseAdeActionArgs(input: string): Record<string, unknown> { +type ParsedAdeActionPayload = + | { args: Record<string, unknown> } + | { argsList: unknown[] } + | { arg: unknown }; + +function parseAdeActionPayload(input: string): ParsedAdeActionPayload { const trimmed = input.trim(); - if (!trimmed) return {}; + if (!trimmed) return { args: {} }; const parsed = JSON.parse(trimmed) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("/ade action arguments must be a JSON object."); + if (Array.isArray(parsed)) { + return { argsList: parsed }; + } + if (parsed && typeof parsed === "object") { + return { args: parsed as Record<string, unknown> }; + } + return { arg: parsed }; +} + +function parseLinearIssueListArgs(input: string): Record<string, unknown> { + 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 parsed as Record<string, unknown>; + return { + projectSlugs, + stateTypes, + ...(limit ? { limit } : {}), + }; } function printableInput(input: string): string { @@ -154,6 +551,55 @@ function inputBeforeLineBreak(input: string): string | null { return index === -1 ? null : input.slice(0, index); } +function runInteractiveTerminalCommand(command: string, args: string[], cwd: string): Promise<number | null> { + 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; @@ -193,21 +639,26 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [slashCommands, setSlashCommands] = useState<AgentChatSlashCommand[]>([]); const [models, setModels] = useState<AgentChatModelInfo[]>([]); const [modelState, setModelState] = useState<AdeCodeModelState>(initialModelState); + const [draftChatActive, setDraftChatActive] = useState(false); + const [aiStatus, setAiStatus] = useState<AiSettingsStatus | null>(null); + const [aiStatusCheckedAt, setAiStatusCheckedAt] = useState<string | null>(null); + const [storedApiKeyProviders, setStoredApiKeyProviders] = useState<string[]>([]); + const [openCodeDiagnostics, setOpenCodeDiagnostics] = useState<OpenCodeRuntimeSnapshot | null>(null); const [rightPane, setRightPane] = useState<RightPaneContent>({ kind: "empty" }); const [formValues, setFormValues] = useState<Record<string, string>>({}); const [formFieldIndex, setFormFieldIndex] = useState(0); const [rightSelectionIndex, setRightSelectionIndex] = useState(0); const [drawerOpen, setDrawerOpen] = useState(false); const [rightOpen, setRightOpen] = useState(false); + const [activePane, setActivePane] = useState<PaneFocus>("chat"); const [prompt, setPrompt] = useState(""); const [error, setError] = useState<string | null>(null); - const [tuiCount, setTuiCount] = useState(1); const [contextPercent, setContextPercent] = useState<number | null>(null); const [tokenSummary, setTokenSummary] = useState<string | null>(null); const [streaming, setStreaming] = useState(false); - const [desktopDriving, setDesktopDriving] = useState(false); const [clearedAt, setClearedAt] = useState<string | null>(null); const [expandedLineIds, setExpandedLineIds] = useState<Set<string>>(() => new Set()); + const [chatScrollOffsetRows, setChatScrollOffsetRows] = useState(0); const [mentionSuggestions, setMentionSuggestions] = useState<MentionSuggestion[]>([]); const [mentionIndex, setMentionIndex] = useState(0); const [selectedMentions, setSelectedMentions] = useState<MentionSuggestion[]>([]); @@ -216,13 +667,187 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [drawerLaneId, setDrawerLaneId] = useState<string | null>(null); const [selectedDrawerLaneId, setSelectedDrawerLaneId] = useState<string | null>(null); const [selectedDrawerChatId, setSelectedDrawerChatId] = useState<string | null>(null); + const [selectedDrawerLaneAction, setSelectedDrawerLaneAction] = useState<DrawerLaneAction | null>(null); + const [selectedDrawerChatAction, setSelectedDrawerChatAction] = useState<DrawerChatAction | null>(null); + const [formDiscardArmed, setFormDiscardArmed] = useState(false); + const [footerControl, setFooterControl] = useState<FooterControl | null>(null); const connectionRef = useRef<AdeCodeConnection | null>(null); const activeLaneIdRef = useRef<string | null>(null); const activeSessionIdRef = useRef<string | null>(null); + const draftChatActiveRef = useRef(false); + const activePaneRef = useRef<PaneFocus>("chat"); + const footerControlRef = useRef<FooterControl | null>(null); + const paneBeforeDetailsRef = useRef<PaneFocus>("chat"); + const chatDraftRef = useRef(""); + const promptRef = useRef(""); const lastLocalSendAtRef = useRef<number>(0); const eventCountRef = useRef<number>(0); + const chatScrollOffsetRowsRef = useRef(0); const heartbeatRef = useRef<TuiHeartbeat | null>(null); + const draftSeededFromHistoryRef = useRef(false); + const attachProbeInFlightRef = useRef(false); + const lastChatByLaneRef = useRef<Map<string, string>>(new Map(Object.entries(loadAdeCodeState().lastChatByLane))); + const lastChatByLaneWriteTimerRef = useRef<NodeJS.Timeout | null>(null); + const pendingNewChatTitleRef = useRef<string | null>(null); + + const persistLastChatByLane = useCallback(() => { + if (lastChatByLaneWriteTimerRef.current) { + clearTimeout(lastChatByLaneWriteTimerRef.current); + } + lastChatByLaneWriteTimerRef.current = setTimeout(() => { + lastChatByLaneWriteTimerRef.current = null; + const lastChatByLane: Record<string, string> = {}; + 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( @@ -234,30 +859,53 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } [activeSessionId, sessions], ); const latestFailedLineId = useMemo(() => latestExpandableFailureId(events), [events]); - const drawerLaneRows = useMemo(() => lanes.slice(0, 10), [lanes]); + 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, selectedDrawerLaneId]); + }, [activeLaneId, drawerLaneId, drawerLaneRows, selectedDrawerLaneAction, selectedDrawerLaneId]); const selectedChatIndex = useMemo(() => { + if (selectedDrawerChatAction === "new-chat") return drawerVisibleLaneSessions.length; const targetId = selectedDrawerChatId ?? (drawerLaneId === activeLaneId ? activeSessionId : null); - const index = drawerLaneSessions.findIndex((session) => session.sessionId === targetId); + const index = drawerVisibleLaneSessions.findIndex((session) => session.sessionId === targetId); return index >= 0 ? index : 0; - }, [activeLaneId, activeSessionId, drawerLaneId, drawerLaneSessions, selectedDrawerChatId]); - const activeMentionRange = useMemo(() => activeMention(prompt), [prompt]); + }, [activeLaneId, activeSessionId, drawerLaneId, drawerVisibleLaneSessions, selectedDrawerChatAction, selectedDrawerChatId]); + const activeMentionRange = useMemo(() => ( + activePane === "chat" ? activeMention(prompt) : null + ), [activePane, prompt]); const slashRows = useMemo(() => ( - prompt.startsWith("/") ? paletteCommands(prompt, slashCommands) : [] - ), [prompt, slashCommands]); + 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; @@ -267,6 +915,143 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } 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<Array<Record<string, unknown>>>("pr", "listAll", { laneId }).catch(() => [] as Array<Record<string, unknown>>), + ]); + 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<string, { path: string; status: "M" | "A" | "D" | "?"; staged: boolean }>(); + 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<Array<{ status?: string; conclusion?: string | null }>>("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); @@ -274,15 +1059,22 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, [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, selectedDrawerLaneId]); + }, [activeLaneId, drawerLaneId, drawerLaneRows, selectedDrawerLaneAction, selectedDrawerLaneId]); useEffect(() => { - if (selectedDrawerChatId && drawerLaneSessions.some((session) => session.sessionId === selectedDrawerChatId)) return; - const activeChatInDrawer = drawerLaneSessions.find((session) => session.sessionId === activeSessionId); - setSelectedDrawerChatId(activeChatInDrawer?.sessionId ?? drawerLaneSessions[0]?.sessionId ?? null); - }, [activeSessionId, drawerLaneSessions, selectedDrawerChatId]); + 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); @@ -295,14 +1087,124 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ]); }, []); + 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<RightPaneContent, { kind: "form" }>) => { + 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; @@ -412,10 +1314,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const nextLaneId = nextLane?.id ?? null; const nextSessions = await listChatSessions(conn); const laneSessions = nextSessions.filter((session) => session.laneId === nextLaneId); - const activeSessionId = activeSessionIdRef.current; - const nextSession = activeSessionId - ? nextSessions.find((session) => session.sessionId === activeSessionId) ?? null - : null; + 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) { @@ -423,39 +1327,85 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } nextEvents = clearedAt ? history.events.filter((event) => event.timestamp > clearedAt) : history.events; - const stats = latestTokenStats(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(stats.streaming || nextSession?.status === "active"); - const previousCount = eventCountRef.current; + setStreaming(nextSession?.status === "active"); eventCountRef.current = history.events.length; - if (previousCount > 0 && history.events.length > previousCount && Date.now() - lastLocalSendAtRef.current > 4_000) { - setDesktopDriving(true); - setTimeout(() => setDesktopDriving(false), 3_000); - } - } - const nextProvider = nextSession?.provider ?? "codex"; - const nextCommands = await getSlashCommands(conn, nextSessionId).catch(() => []); + } 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 === nextSession?.modelId || model.id === nextSession?.modelId) + const activeModel = nextModels.find((model) => model.modelId === configSession?.modelId || model.id === configSession?.modelId) ?? nextModels.find((model) => model.isDefault) ?? null; setLanes(nextLanes); setSessions(nextSessions); - setActiveLaneId(nextLaneId); - setActiveSessionId(nextSessionId); + selectActiveLaneId(nextLaneId); + selectActiveSessionId(nextSessionId); setEvents(nextEvents); setSlashCommands(nextCommands); setModels(nextModels); - setTuiCount(heartbeatRef.current?.readCount() ?? 1); - setModelState({ - provider: PROVIDERS.has(nextProvider) ? nextProvider as AdeCodeModelState["provider"] : "codex", - model: nextSession?.model ?? activeModel?.id ?? modelState.model, - modelId: nextSession?.modelId ?? activeModel?.modelId ?? activeModel?.id ?? modelState.modelId, - displayName: activeModel?.displayName ?? nextSession?.model ?? modelState.displayName, - reasoningEffort: nextSession?.reasoningEffort ?? modelState.reasoningEffort, + 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, }); - }, [clearedAt, modelState.displayName, modelState.model, modelState.modelId, modelState.reasoningEffort, project]); + await refreshState(); + }, [refreshState]); useEffect(() => { let cancelled = false; @@ -470,6 +1420,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } connectionRef.current = conn; setConnection(conn); setMode(conn.mode); + draftSeededFromHistoryRef.current = false; + setDraftChatMode(true); + selectActiveSessionId(null); + setEvents([]); await refreshState(); } catch (err) { heartbeatRef.current?.stop(); @@ -481,6 +1435,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } cancelled = true; heartbeatRef.current?.stop(); heartbeatRef.current = null; + if (lastChatByLaneWriteTimerRef.current) { + clearTimeout(lastChatByLaneWriteTimerRef.current); + lastChatByLaneWriteTimerRef.current = null; + const lastChatByLane: Record<string, string> = {}; + for (const [laneId, sessionId] of lastChatByLaneRef.current) { + lastChatByLane[laneId] = sessionId; + } + saveAdeCodeState({ lastChatByLane }); + } const conn = connectionRef.current; connectionRef.current = null; void conn?.close().catch(() => {}); @@ -503,10 +1466,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const event = envelope.event as Record<string, unknown>; if (event.type === "status" && event.turnStatus === "started") setStreaming(true); if (event.type === "done" || (event.type === "status" && event.turnStatus === "completed")) setStreaming(false); - if (Date.now() - lastLocalSendAtRef.current > 4_000) { - setDesktopDriving(true); - setTimeout(() => setDesktopDriving(false), 3_000); - } }); }, [clearedAt, connection, refreshState]); @@ -520,16 +1479,71 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } 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<string | null> => { const conn = connectionRef.current; const laneId = activeLaneIdRef.current; if (!conn || !laneId) return null; if (activeSessionIdRef.current) return activeSessionIdRef.current; - const created = await createChatSession({ connection: conn, laneId }); - setActiveSessionId(created.id); + 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; - }, [refreshState]); + }, [modelState, refreshState, selectActiveSessionId, setDraftChatMode]); const resolvePendingApproval = useCallback(async ( approval: PendingApproval, @@ -584,7 +1598,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (!conn) return; const laneId = activeLaneIdRef.current; const sessionId = activeSessionIdRef.current; - setRightOpen(true); + focusDetails(); if (name === "/help") { setRightPane({ kind: "help", title: "Help" }); @@ -598,8 +1612,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ["workspace", project.workspaceRoot], ["lane", activeLane?.name ?? laneId ?? "none"], ["chat", activeSession?.title ?? activeSession?.sessionId ?? "none"], - ["runtime", mode], - ["socket", conn.socketPath ?? "embedded"], + ["ADE", "ready"], ], }); return; @@ -609,41 +1622,28 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "New chat", body: "No active lane is available." }); return; } - if (!args) { - openForm({ - kind: "form", - title: "New chat", - command: "new-chat", - fields: [ - { name: "title", label: "Title", placeholder: "Untitled chat" }, - { name: "message", label: "First message", placeholder: "Optional" }, - ], - }); - return; - } - const created = await createChatSession({ connection: conn, laneId, title: args }); - setActiveSessionId(created.id); - addNotice(`Created chat "${args}".`, "success"); - await refreshState(); + openNewChatSetup(args); return; } if (name === "/new lane") { if (!args) { - 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" }, - ], - }); + openNewLaneForm(); return; } const created = await conn.action<LaneSummary>("lane", "create", { name: args }); - setActiveLaneId(created.id); + 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") { @@ -672,7 +1672,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "Diff", body: "No active lane is selected." }); return; } - const diff = await conn.action("diff", "getChanges", { laneId }); + const diff = await conn.actionList("diff", "getChanges", [laneId]); setRightPane({ kind: "diff", title: "Diff", files: summarizeDiffChanges(diff) }); return; } @@ -686,6 +1686,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } 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<Array<Record<string, unknown>>>("pr", "listAll", laneId ? { laneId } : {}); const activePr = prs[0] ?? null; const prId = activePr ? String(activePr.id ?? activePr.prId ?? "") : ""; @@ -714,10 +1718,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "PR open", body: renderObject(activePr, 24) }); return; } - if (!laneId) { - setRightPane({ kind: "details", title: "PR open", body: "No active lane is selected." }); - return; - } if (!args) { openForm({ kind: "form", @@ -753,7 +1753,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (name === "/linear list") { - const linear = await conn.action("linear_issue_tracker", "listIssues", { limit: 20 }); + 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; } @@ -860,11 +1860,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } const lane = lanes.find((entry) => entry.id.toLowerCase() === query || entry.name.toLowerCase().includes(query)); if (lane) { - setActiveLaneId(lane.id); + selectActiveLaneId(lane.id); setDrawerLaneId(lane.id); setSelectedDrawerLaneId(lane.id); const session = newestSession(sessions.filter((entry) => entry.laneId === lane.id)); - setActiveSessionId(session?.sessionId ?? null); + selectActiveSessionId(session?.sessionId ?? null); setSelectedDrawerChatId(session?.sessionId ?? null); addNotice(`Switched to lane ${lane.name}.`, "success"); } else { @@ -883,20 +1883,37 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (name === "/model") { - if (args && sessionId) { + 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; } - setRightSelectionIndex(Math.max(0, models.findIndex((model) => ( - model.id === modelState.modelId || model.modelId === modelState.modelId - )))); - setRightPane({ kind: "models", models, activeModelId: modelState.modelId }); + openModelSetup(); return; } if (name === "/effort") { - if (args && sessionId) { + 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(); @@ -910,25 +1927,48 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "System", - body: renderObject({ mode, project, socketPath: conn.socketPath, pid: process.pid }, 24), + 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> [json-object-args]", + body: "Usage: /ade <domain.action|status|diff|model|effort|help> [json-object|json-array|json-scalar]", }); return; } - const result = await conn.action(domain, action, parseAdeActionArgs(parsed.rest)); - setRightPane({ kind: "details", title: `ADE ${domain}.${action}`, body: renderObject(result, 24) }); + 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, lanes, mode, modelState.modelId, modelState.reasoningEffort, models, openForm, project, refreshState, sessions]); + }, [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; @@ -942,6 +1982,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (name === "/clear") { setClearedAt(new Date().toISOString()); setEvents([]); + setChatScrollOffset(0); addNotice("Local transcript view cleared. The durable chat remains in ADE.", "info"); return; } @@ -955,6 +1996,43 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } 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"); @@ -977,6 +2055,24 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } 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"); @@ -1028,7 +2124,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setConnection(attached); setMode(attached.mode); await previous?.close().catch(() => {}); - addNotice("Attached to desktop and opened this context.", "success"); + addNotice("Opened ADE desktop at this context.", "success"); await refreshState(); return; } @@ -1036,7 +2132,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(result.message ?? "Desktop route unavailable from this runtime.", "error"); } } - }, [addNotice, exit, project, refreshState, socketPath]); + }, [activeSession?.provider, addNotice, exit, loadProviderModels, modelState.provider, project, refreshAiSetupStatus, refreshState, setChatScrollOffset, socketPath]); const submitRightForm = useCallback(async ( form: Extract<RightPaneContent, { kind: "form" }>, @@ -1054,22 +2150,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return null; }; - if (form.command === "new-chat") { - if (!laneId) return; - const title = values.title?.trim() || null; - const message = values.message?.trim() ?? ""; - const created = await createChatSession({ connection: conn, laneId, title }); - setActiveSessionId(created.id); - if (message) { - await sendChatMessage(conn, created.id, message); - } - setRightOpen(false); - setRightPane({ kind: "empty" }); - addNotice(title ? `Created chat "${title}".` : "Created chat.", "success"); - await refreshState(); - return; - } - if (form.command === "new-lane") { const name = requireField("name", "Name"); if (!name) return; @@ -1078,12 +2158,22 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } name, ...(baseBranch ? { baseBranch } : {}), }); - setActiveLaneId(created.id); - setActiveSessionId(null); + 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; } @@ -1094,6 +2184,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await renameChat(conn, sessionId, title); setRightOpen(false); setRightPane({ kind: "empty" }); + focusAfterDetails(); addNotice(`Renamed chat to "${title}".`, "success"); await refreshState(); return; @@ -1114,7 +2205,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice("Created draft PR.", "success"); await refreshState(); } - }, [addNotice, refreshState]); + }, [addNotice, focusAfterDetails, refreshState, selectActiveLaneId, selectActiveSessionId]); const submitPrompt = useCallback(async (value: string) => { const text = value.trim(); @@ -1122,11 +2213,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const conn = connectionRef.current; if (!conn) return; try { - if (desktopDriving && streaming && !text.startsWith("/") && rightPane.kind !== "form") { - addNotice("Desktop is driving this chat; draft kept locally until the stream settles.", "info"); + 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(); @@ -1171,6 +2267,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } const sessionId = await ensureActiveSession(); if (sessionId) { + setStreaming(true); await sendChatMessage(conn, sessionId, selected.name); await refreshState(); } @@ -1208,14 +2305,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } 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, desktopDriving, ensureActiveSession, formValues, pendingApproval, refreshState, resolvePendingApproval, rightPane, runInlineCommand, runRightCommand, selectedMentions, slashCommands, slashIndex, slashRows, streaming, submitRightForm]); + }, [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); @@ -1235,23 +2334,379 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } 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 (rightPane.kind === "form" && key.tab) { + + if (pane === "details" && rightOpen && rightPane.kind === "form" && (key.upArrow || key.downArrow || key.return)) { const fields = rightPane.fields; - const currentField = fields[formFieldIndex] ?? fields[0]; - const nextValues = currentField ? { ...formValues, [currentField.name]: prompt } : formValues; - const nextIndex = fields.length ? (formFieldIndex + 1) % fields.length : 0; + 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 (rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort" || (rightPane.kind === "list" && rightPane.action)) && key.upArrow) { + + 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" @@ -1260,7 +2715,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightSelectionIndex((index) => (index <= 0 ? Math.max(0, max - 1) : index - 1)); return; } - if (rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort" || (rightPane.kind === "list" && rightPane.action)) && key.downArrow) { + 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" @@ -1269,42 +2724,51 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightSelectionIndex((index) => (max > 0 ? (index + 1) % max : 0)); return; } - if (rightOpen && rightPane.kind === "list" && rightPane.action && key.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; - setActiveLaneId(lane.id); + selectActiveLaneId(lane.id); setDrawerLaneId(lane.id); setSelectedDrawerLaneId(lane.id); const session = newestSession(sessions.filter((entry) => entry.laneId === lane.id)); - setActiveSessionId(session?.sessionId ?? null); + 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; - setActiveLaneId(session.laneId); + selectActiveLaneId(session.laneId); setDrawerLaneId(session.laneId); setSelectedDrawerLaneId(session.laneId); - setActiveSessionId(session.sessionId); + selectActiveSessionId(session.sessionId); setSelectedDrawerChatId(session.sessionId); addNotice(`Switched to chat ${session.title ?? session.sessionId}.`, "success"); return; } - if (rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort") && key.return) { + if (pane === "details" && rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort") && key.return) { const conn = connectionRef.current; const sessionId = activeSessionIdRef.current; - if (!conn || !sessionId) { - addNotice("Create or select a chat before changing model settings.", "error"); + 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"); @@ -1315,6 +2779,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } 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"); @@ -1323,103 +2792,150 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); return; } - if (key.upArrow && activeMentionRange && mentionSuggestions.length) { + + 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 (key.downArrow && activeMentionRange && mentionSuggestions.length) { + if (pane === "chat" && key.downArrow && activeMentionRange && mentionSuggestions.length) { setMentionIndex((index) => (index + 1) % mentionSuggestions.length); return; } - if (key.tab && activeMentionRange && mentionSuggestions.length) { + if (pane === "chat" && key.tab && activeMentionRange && mentionSuggestions.length) { insertMention(mentionSuggestions[mentionIndex] ?? mentionSuggestions[0]!); return; } - if (key.upArrow && slashRows.length) { + if (pane === "chat" && key.upArrow && slashRows.length) { setSlashIndex((index) => (index <= 0 ? slashRows.length - 1 : index - 1)); return; } - if (key.downArrow && slashRows.length) { + if (pane === "chat" && key.downArrow && slashRows.length) { setSlashIndex((index) => (index + 1) % slashRows.length); return; } - if (key.tab && slashRows.length) { + if (pane === "chat" && key.tab && slashRows.length) { insertSlashCommand(); return; } - if (drawerOpen && key.tab) { + 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 (drawerOpen && key.upArrow) { + if (pane === "drawer" && drawerOpen && key.upArrow) { if (drawerSection === "lanes") { const nextIndex = Math.max(0, selectedLaneIndex - 1); - setSelectedDrawerLaneId(drawerLaneRows[nextIndex]?.id ?? null); + 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); - setSelectedDrawerChatId(drawerLaneSessions[nextIndex]?.sessionId ?? null); + const session = drawerVisibleLaneSessions[nextIndex] ?? null; + setSelectedDrawerChatAction(session ? null : "new-chat"); + setSelectedDrawerChatId(session?.sessionId ?? null); } return; } - if (drawerOpen && key.downArrow) { + if (pane === "drawer" && drawerOpen && key.downArrow) { if (drawerSection === "lanes") { - const nextIndex = Math.min(Math.max(0, drawerLaneRows.length - 1), selectedLaneIndex + 1); - setSelectedDrawerLaneId(drawerLaneRows[nextIndex]?.id ?? null); + 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(Math.max(0, drawerLaneSessions.length - 1), selectedChatIndex + 1); - setSelectedDrawerChatId(drawerLaneSessions[nextIndex]?.sessionId ?? null); + const nextIndex = Math.min(drawerVisibleLaneSessions.length, selectedChatIndex + 1); + const session = drawerVisibleLaneSessions[nextIndex] ?? null; + setSelectedDrawerChatAction(session ? null : "new-chat"); + setSelectedDrawerChatId(session?.sessionId ?? null); } return; } - if (drawerOpen && key.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"); - setSelectedDrawerChatId(sessions.find((session) => session.laneId === lane.id)?.sessionId ?? null); + addNotice(`Switched to lane ${lane.name}.`, "success"); } } else { - const session = drawerLaneSessions[selectedChatIndex]; + if (selectedDrawerChatAction === "new-chat" || selectedChatIndex >= drawerVisibleLaneSessions.length) { + openNewChatSetup(); + setRightOpen(true); + return; + } + const session = drawerVisibleLaneSessions[selectedChatIndex]; if (session) { - setActiveLaneId(session.laneId); + selectActiveLaneId(session.laneId); setDrawerLaneId(session.laneId); setSelectedDrawerLaneId(session.laneId); - setActiveSessionId(session.sessionId); + setSelectedDrawerLaneAction(null); + selectActiveSessionId(session.sessionId); setSelectedDrawerChatId(session.sessionId); + setSelectedDrawerChatAction(null); } } return; } - if (key.ctrl && input === "b") { - setDrawerOpen((value) => !value); - return; - } - if (key.ctrl && input === "j") { - setRightOpen((value) => !value); - return; - } - if (key.escape) { - if (desktopDriving) setDesktopDriving(false); - else if (rightOpen) setRightOpen(false); - else if (drawerOpen) setDrawerOpen(false); - else 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 (key.return && !prompt.trim() && latestFailedLineId && !pendingApproval && rightPane.kind !== "form" && !slashRows.length) { + + 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); @@ -1429,48 +2945,50 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } const linePrefix = inputBeforeLineBreak(input); - if (key.return || linePrefix != null) { + if (textInputActive && (key.return || linePrefix != null)) { const suffix = linePrefix == null ? "" : printableInput(linePrefix); void submitPrompt(`${prompt}${suffix}`); return; } - if (key.backspace || key.delete) { + if (textInputActive && (key.backspace || key.delete)) { handlePromptChange(prompt.slice(0, -1)); return; } - if (!key.ctrl && input) { + if (textInputActive && !key.ctrl && input) { const suffix = printableInput(input); if (suffix) handlePromptChange(`${prompt}${suffix}`); } }); const handlePromptChange = useCallback((value: string) => { - if (value === "?") { + setFormDiscardArmed(false); + if (activePaneRef.current === "chat" && value === "?") { setRightPane({ kind: "help", title: "Help" }); - setRightOpen(true); + focusDetails(); setPrompt(""); return; } - if (rightPane.kind === "form" && activeFormField) { + if (activePaneRef.current === "chat") { + chatDraftRef.current = value; + } + if (activePaneRef.current === "details" && rightPane.kind === "form" && activeFormField) { setFormValues((prev) => ({ ...prev, [activeFormField.name]: value })); } setPrompt(value); - }, [activeFormField, rightPane]); + }, [activeFormField, focusDetails, rightPane]); const centerWidth = Math.max(40, columns - (drawerOpen ? 30 : 0) - (rightOpen ? 40 : 0)); const laneName = activeLane?.name ?? "main"; - const chromeRows = 5 - + (desktopDriving ? 1 : 0) - + (streaming ? 1 : 0) - + (contextPercent != null ? 1 : 0) - + (pendingApproval && !pendingApproval.highStakes ? 3 : 0) - + (error ? 1 : 0); - const chatMaxRows = Math.max(4, rows - chromeRows); + 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 color="red">ade-code failed to start</Text> <Text>{error}</Text> </Box> ); @@ -1481,22 +2999,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } <Header projectName={projectName} lane={activeLane} - model={modelState} - mode={mode} - tuiCount={tuiCount} /> - {desktopDriving ? ( - <Text color="yellow">Desktop is driving this chat; transcript is syncing here.</Text> - ) : null} {streaming ? ( <Text color={PURPLE}>● streaming live{tokenSummary ? ` · ${tokenSummary}` : ""} · ctrl-c interrupts</Text> ) : null} - {contextPercent != null ? ( - <Text dimColor> - context {contextPercent}% {"█".repeat(Math.max(1, Math.round(contextPercent / 10))).padEnd(10, "░")} - {tokenSummary && !streaming ? ` · ${tokenSummary}` : ""} - </Text> - ) : null} <Box flexGrow={1} minHeight={8}> {drawerOpen ? ( <Drawer @@ -1507,6 +3013,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } browsingLaneId={drawerLaneId ?? activeLaneId} selectedLaneIndex={drawerSection === "lanes" ? selectedLaneIndex : -1} selectedChatIndex={drawerSection === "chats" ? selectedChatIndex : -1} + panelHeight={rows} + focused={activePane === "drawer"} /> ) : null} <Box width={centerWidth} flexDirection="column"> @@ -1520,8 +3028,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } activeSession={activeSession} projectName={projectName} laneName={laneName} + lane={activeLane} expandedLineIds={expandedLineIds} - maxRows={chatMaxRows} + maxRows={chatRowBudget} + scrollOffsetRows={chatScrollOffsetRows} + width={centerWidth} /> <ApprovalPrompt approval={pendingApproval} /> </> @@ -1533,20 +3044,35 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } 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="single" borderColor="gray" paddingX={1}> + <Box borderStyle="round" borderColor={promptFocused ? PURPLE : theme.color.border} paddingX={1} flexShrink={0}> <Text color={PURPLE}>› </Text> <Text>{prompt}</Text> <Text inverse> </Text> </Box> - <Text dimColor> - [ {drawerOpen ? "▴" : "▾"} lanes & chats ^b ] [ {rightOpen ? "◂" : "▸"} right pane ^j ] / commands - </Text> + <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 index 1444f93c5..5087c2ac0 100644 --- a/apps/ade-cli/src/tuiClient/cli.tsx +++ b/apps/ade-cli/src/tuiClient/cli.tsx @@ -48,8 +48,10 @@ Usage: ade code --print-state Keys: - ctrl-b toggle lanes and chats - ctrl-j toggle right pane + 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 `); diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index f5dda49ba..9b720c8e4 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -12,8 +12,11 @@ export type BuiltinCommand = { 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>" }, @@ -47,9 +50,15 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { 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 allowlisted ADE action", placement: "right", argumentHint: "<domain.action> [json]" }, + { 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; @@ -61,21 +70,54 @@ 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 exactLocalCommand = userCommands.find((command) => command.source === "local" && command.name === first) ?? null; - if (exactLocalCommand) { + 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: first, + 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: exactLocalCommand, + userCommand: exactUserCommand, }; } - const candidates = [...BUILTIN_COMMANDS] - .sort((left, right) => right.name.length - left.name.length); + for (const spec of candidates) { const name = normalizeSlashName(spec.name); if (trimmed === name || trimmed.startsWith(`${name} `)) { @@ -88,10 +130,10 @@ export function parseCommand(input: string, userCommands: AgentChatSlashCommand[ } } - const userCommand = userCommands.find((command) => command.name === first) ?? null; + const userCommand = userCommands.find((command) => slashCommandKey(command.name) === firstKey) ?? null; if (userCommand) { return { - name: first, + name: userCommand.name, args: trimmed.slice(first.length).trim(), spec: null, userCommand, @@ -111,6 +153,7 @@ export function paletteCommands( 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, @@ -123,12 +166,31 @@ export function paletteCommands( source: "user" as const, argumentHint: command.argumentHint, })); - return [...builtins, ...users] - .filter((command) => { - if (!normalizedQuery || normalizedQuery === "/") return true; - return `${command.name} ${command.description}`.toLowerCase().includes(normalizedQuery.replace(/^\//, "")); - }) - .slice(0, 9); + // 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 { 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/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx index 5e16bcef6..6381c7480 100644 --- a/apps/ade-cli/src/tuiClient/components/ChatView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -1,75 +1,333 @@ 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 RenderedChatLine } from "../format"; - -const COLORS = { - user: "#A78BFA", - assistant: "white", - tool: "cyan", - error: "red", - notice: "gray", - reasoning: "gray", - approval: "yellow", -} as const; +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}> - <Text color="#A78BFA">██▄ ██▄ ██▀</Text> - <Text color="#A78BFA">█ █ █ █ █▀ </Text> - <Text color="#A78BFA">██▀ ██▀ ██▄</Text> - <Text dimColor>code · v0.1</Text> - <Text dimColor>{projectName} · {laneName}</Text> - <Text dimColor>type to chat · / for commands</Text> - <Text dimColor>try: inspect the current diff</Text> - <Text dimColor>try: @file then ask for a focused review</Text> - <Text dimColor>try: /status or /new chat</Text> + <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 clipBodyToRows(body: string, rows: number): string { - if (rows <= 0) return ""; - const lines = body.split(/\r?\n/); - if (lines.length <= rows) return body; - return lines.slice(-rows).join("\n"); -} +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 }); + } + }; -function rowCount(line: RenderedChatLine): number { - return (line.header ? 1 : 0) + Math.max(1, line.body.split(/\r?\n/).length); + 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 visibleRows(lines: RenderedChatLine[], maxRows: number): RenderedChatLine[] { - if (maxRows <= 0) return []; - const visible: RenderedChatLine[] = []; - let remaining = maxRows; - for (let index = lines.length - 1; index >= 0 && remaining > 0; index -= 1) { - const line = lines[index]!; - const needed = rowCount(line); - if (needed <= remaining) { - visible.unshift(line); - remaining -= needed; - continue; +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 }); + } } - const headerRows = line.header ? 1 : 0; - const bodyRows = Math.max(0, remaining - headerRows); - if (bodyRows > 0) { - visible.unshift({ - ...line, - body: clipBodyToRows(line.body, bodyRows), - }); + 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 }] : []), + ]; } - break; + offset = Math.max(0, Math.min(offset, rows.length - contentRows)); } - return visible; + 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({ @@ -78,31 +336,32 @@ export function ChatView({ activeSession, projectName, laneName, + lane, expandedLineIds, - maxLines = 64, - maxRows = 24, + maxRows, + scrollOffsetRows = 0, + width = DEFAULT_VIEW_WIDTH, }: { events: AgentChatEventEnvelope[]; notices: LocalNotice[]; activeSession: AgentChatSessionSummary | null; projectName: string; laneName: string; + lane?: LaneSummary | null; expandedLineIds?: Set<string>; - maxLines?: number; maxRows?: number; + scrollOffsetRows?: number; + width?: number; }) { - const lines = renderChatLines({ events, notices, activeSession, expandedLineIds, maxLines }); + const lines = renderChatLines({ events, notices, activeSession, expandedLineIds, maxLines: 200 }); if (!lines.length) { - return <BootHero projectName={projectName} laneName={laneName} />; + return <BootHero projectName={projectName} laneName={laneName} lane={lane ?? null} width={width} />; } - const clippedLines = visibleRows(lines, maxRows); + const rows = sliceRows(rowsForLines(lines, Math.max(24, width - 2)), maxRows, scrollOffsetRows); return ( <Box flexDirection="column" paddingX={1}> - {clippedLines.map((line) => ( - <Box key={line.id} flexDirection="column" marginBottom={line.header ? 1 : 0}> - {line.header ? <Text color={COLORS[line.tone]}>{line.header}</Text> : null} - <Text color={COLORS[line.tone]}>{line.body}</Text> - </Box> + {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 index 7982ae517..846f0d6b4 100644 --- a/apps/ade-cli/src/tuiClient/components/Drawer.tsx +++ b/apps/ade-cli/src/tuiClient/components/Drawer.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Box, Text } from "ink"; +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"; @@ -7,16 +7,13 @@ import { formatLaneLabel, formatSessionLabel } from "../format"; const PURPLE = "#A78BFA"; const AMBER = "#F59E0B"; -function laneColor(laneId: string, activeLaneId: string | null, browsingLaneId: string | null): string | undefined { - if (laneId === activeLaneId) return AMBER; - if (laneId === browsingLaneId) return "white"; - return undefined; +export function visibleDrawerLaneCount(panelHeight: number, laneCount: number): number { + const lanesMaxRows = Math.max(2, Math.floor(panelHeight / 2) - 3); + return Math.min(laneCount, 10, lanesMaxRows); } -function laneMarker(laneId: string, activeLaneId: string | null, browsingLaneId: string | null): string { - if (laneId === activeLaneId) return "●"; - if (laneId === browsingLaneId) return "◐"; - return "○"; +export function visibleDrawerChatCount(chatCount: number): number { + return Math.min(chatCount, 12); } export function Drawer({ @@ -27,6 +24,8 @@ export function Drawer({ browsingLaneId, selectedLaneIndex, selectedChatIndex, + panelHeight, + focused = false, }: { lanes: LaneSummary[]; sessions: AgentChatSessionSummary[]; @@ -35,29 +34,50 @@ export function Drawer({ browsingLaneId: string | null; selectedLaneIndex: number; selectedChatIndex: number; + panelHeight?: number; + focused?: boolean; }) { - const browsingLane = lanes.find((lane) => lane.id === browsingLaneId) ?? null; - const laneSessions = sessions.filter((session) => session.laneId === browsingLaneId).slice(0, 12); + 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="gray" paddingX={1}> - <Text bold>LANES</Text> - {lanes.slice(0, 10).map((lane, index) => ( - <Text key={lane.id} color={laneColor(lane.id, activeLaneId, browsingLaneId)}> - {index === selectedLaneIndex ? "›" : " "} {laneMarker(lane.id, activeLaneId, browsingLaneId)} {formatLaneLabel(lane).slice(0, 20)} + <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> - ))} - <Text dimColor>+ new lane</Text> - <Text dimColor>{"─".repeat(24)}</Text> - <Text bold>CHATS · {browsingLane?.name ?? "no lane"}</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 ? "›" : " "} {session.sessionId === activeSessionId ? "●" : " "} {formatSessionLabel(session).slice(0, 20)} + </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> - ))} - <Text dimColor>+ new chat</Text> - <Text dimColor>enter opens selected · arrows move</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 index 1a13eae6f..6318653bb 100644 --- a/apps/ade-cli/src/tuiClient/components/Header.tsx +++ b/apps/ade-cli/src/tuiClient/components/Header.tsx @@ -1,40 +1,56 @@ import React from "react"; import { Box, Text } from "ink"; -import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; -import type { AdeCodeModelState, RuntimeMode } from "../types"; +import type { LaneIcon, LaneSummary } from "../../../../desktop/src/shared/types/lanes"; import { formatLaneLabel } from "../format"; +import { theme } from "../theme"; -const PURPLE = "#A78BFA"; -const AMBER = "#F59E0B"; +const LANE_ICON_GLYPH: Record<NonNullable<LaneIcon>, string> = { + star: "★", + flag: "⚑", + bolt: "↯", + shield: "▣", + tag: "❯", +}; -export function Header({ - projectName, - lane, - model, - mode, - tuiCount, -}: { - projectName: string; - lane: LaneSummary | null; - model: AdeCodeModelState; - mode: RuntimeMode | "connecting"; - tuiCount: number; -}) { - let modeColor: string = "gray"; - if (mode === "attached") modeColor = "green"; - else if (mode === "embedded") modeColor = "yellow"; +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 borderStyle="single" borderColor="gray" paddingX={1}> - <Text color={PURPLE}>▌ ADE</Text> - <Text dimColor> ◆ </Text> - <Text>{projectName}</Text> - <Text dimColor> ▌ </Text> - <Text color={AMBER}>{formatLaneLabel(lane)}</Text> - <Text dimColor> ▲ </Text> - <Text color={PURPLE}>{model.displayName}</Text> - <Text dimColor> ● </Text> - <Text color={modeColor}>{mode}</Text> - <Text dimColor>{` · ⏵ ${tuiCount} tui${tuiCount === 1 ? "" : "s"}`}</Text> + <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/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 index 002b5347c..827e2eb06 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -1,16 +1,43 @@ import React from "react"; import { Box, Text } from "ink"; -import type { RightPaneContent } from "../types"; +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-b toggles lanes and chats</Text> - <Text dimColor>ctrl-j toggles this pane</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> ); } @@ -20,14 +47,18 @@ export function RightPane({ 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="gray" paddingX={1}> + <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} @@ -79,6 +110,54 @@ export function RightPane({ <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> @@ -90,6 +169,120 @@ export function RightPane({ <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> @@ -102,7 +295,7 @@ export function RightPane({ </Text> ); })} - <Text dimColor>tab moves fields · enter submits · / runs a command</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 index 9e2dbad53..9f757f5f6 100644 --- a/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx +++ b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx @@ -3,6 +3,8 @@ 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, @@ -13,17 +15,32 @@ export function SlashPalette({ selectedIndex: number; }) { const rows = paletteCommands(query, userCommands); - if (!query.startsWith("/")) return null; + 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}> - {rows.map((row, index) => ( - <Text key={`${row.source}:${row.name}`}> - <Text color={index === selectedIndex ? "#A78BFA" : "gray"}>{index === selectedIndex ? "›" : " "}</Text> - <Text color={row.source === "user" ? "#A78BFA" : "gray"}>{row.source}</Text> - <Text> {row.name.padEnd(16)} </Text> - <Text dimColor>{row.description}</Text> - </Text> - ))} + {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/format.ts b/apps/ade-cli/src/tuiClient/format.ts index 27bbe58a3..bb75ba01a 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -1,5 +1,6 @@ import path from "node:path"; -import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; +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"; @@ -49,8 +50,22 @@ export type RenderedChatLine = { 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}`; } @@ -63,11 +78,128 @@ function isFailedExpandableEvent(envelope: AgentChatEventEnvelope): boolean { 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]!; @@ -84,15 +216,42 @@ export function renderChatLines(args: { maxLines?: number; }): RenderedChatLine[] { const lines: RenderedChatLine[] = []; - for (const notice of args.notices) { - lines.push({ - id: notice.id, - tone: notice.tone === "error" ? "error" : "notice", - header: `- ade code · ${timeLabel(notice.timestamp)} ${"-".repeat(20)}`, - body: notice.text, - }); - } - for (const [index, envelope] of args.events.entries()) { + 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; @@ -100,7 +259,7 @@ export function renderChatLines(args: { lines.push({ id, tone: "user", - header: `- you · ${timeLabel(envelope.timestamp)} ${"-".repeat(32)}`, + header: `you · ${timeLabel(envelope.timestamp)}`, body: event.displayText ?? event.text, }); continue; @@ -109,8 +268,9 @@ export function renderChatLines(args: { lines.push({ id, tone: "assistant", - header: `- ade · ${timeLabel(envelope.timestamp)} · ${args.activeSession?.model ?? "model"} ${"-".repeat(18)}`, + header: `${providerEventLabel(args.activeSession?.provider)} · ${timeLabel(envelope.timestamp)} · ${sessionModelLabel(args.activeSession)}`, body: event.text, + blocks: parseAssistantMarkdown(event.text), }); continue; } @@ -178,7 +338,7 @@ export function renderChatLines(args: { lines.push({ id, tone: "notice", - body: `- context compacted · ${event.trigger}${preTokens} ${"-".repeat(24)}`, + body: `context compacted · ${event.trigger}${preTokens}`, }); continue; } @@ -186,11 +346,45 @@ export function renderChatLines(args: { lines.push({ id, tone: "notice", + header: `${providerEventLabel(args.activeSession?.provider)} · ${timeLabel(envelope.timestamp)}`, body: singleLine((event as { message?: unknown }).message, 160), }); } } - return lines.slice(-(args.maxLines ?? 80)); + 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 { @@ -202,9 +396,7 @@ export function formatLaneLabel(lane: LaneSummary | null): string { export function formatSessionLabel(session: AgentChatSessionSummary): string { const label = (session.title ?? session.goal ?? session.summary ?? session.sessionId).trim(); - let state = ""; - if (session.awaitingInput) state = " ?"; - else if (session.status === "active") state = " ●"; + const state = session.awaitingInput ? " ?" : session.status === "active" ? " ●" : ""; return `${label}${state}`; } @@ -220,9 +412,7 @@ export function renderObject(value: unknown, maxLines = 24): string { 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> : {}; - let files: unknown[] = []; - if (Array.isArray(record.files)) files = record.files; - else if (Array.isArray(record.changes)) files = record.changes; + 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> : {}; 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 index e51a7c59b..8cca09cf6 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -1,7 +1,17 @@ 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, @@ -36,6 +46,7 @@ export type AdeCodeConnection = { 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>; @@ -44,12 +55,52 @@ export type AdeCodeConnection = { close(): Promise<void>; }; +export type AdeCodeProvider = Extract<AgentChatProvider, "codex" | "claude" | "opencode" | "cursor" | "droid">; + export type AdeCodeModelState = { - provider: "codex" | "claude" | "opencode" | "cursor" | "droid"; + 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 = @@ -70,10 +121,24 @@ export type RightPaneContent = | { 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-chat" | "new-lane" | "rename" | "pr-open"; + command: "new-lane" | "rename" | "pr-open"; fields: Array<{ name: string; label: string; @@ -81,6 +146,15 @@ export type RightPaneContent = 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 = { @@ -119,9 +193,7 @@ export type ShellData = { models: AgentChatModelInfo[]; modelState: AdeCodeModelState; rightPane: RightPaneContent; - tuiCount: number; contextPercent: number | null; - desktopDriving: boolean; streaming: boolean; }; 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/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 137a16944..b8db308d2 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -212,22 +212,50 @@ describe("runtime Linear issue tracker actions", () => { 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 }); }); @@ -568,7 +596,7 @@ describe("runtime AI actions", () => { } as unknown as Parameters<typeof getAdeActionDomainServices>[0]; const aiService = getAdeActionDomainServices(runtime).ai as Record<string, unknown>; - for (const action of ["getStatus", "storeApiKey", "deleteApiKey", "listApiKeys"]) { + for (const action of ["getStatus", "getOpenCodeRuntimeDiagnostics", "storeApiKey", "deleteApiKey", "listApiKeys"]) { expect(aiService[action]).toEqual(expect.any(Function)); expect(listAllowedAdeActionNames("ai", aiService)).toContain(action); } diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 14c103da3..83499bb34 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -441,6 +441,7 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri keybindings: ["get", "set"], ai: [ "getStatus", + "getOpenCodeRuntimeDiagnostics", "verifyApiKeyConnection", "storeApiKey", "deleteApiKey", @@ -2351,6 +2352,10 @@ function buildAiDomainService(runtime: AdeRuntime): OpaqueService | 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 }) => @@ -3211,9 +3216,23 @@ function buildLinearIssueTrackerDomainService(runtime: AdeRuntime): OpaqueServic 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); @@ -3249,6 +3268,17 @@ function buildLinearIssueTrackerDomainService(runtime: AdeRuntime): OpaqueServic }; } +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, 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/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/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index b1c691245..677399833 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -152,7 +152,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/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 7ab7f319b..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"); }); @@ -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 ade7e5d28..e5e089dee 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -545,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" }, @@ -567,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"; @@ -11433,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 = { @@ -11665,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") { @@ -11685,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 ( @@ -12740,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); } @@ -12794,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" @@ -12806,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({ @@ -12834,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; @@ -18387,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]); } @@ -18425,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/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index 4332ae163..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, diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx index 787710614..f2f132393 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx @@ -346,6 +346,23 @@ describe("CommandPalette", () => { 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, + }, + ], + }, }, ], })), @@ -378,14 +395,17 @@ describe("CommandPalette", () => { await waitFor(() => expect( - screen.getByRole("dialog", { name: "Open remote tab?" }), + screen.getByRole("dialog", { + name: "You already work on this repo locally", + }), ).toBeTruthy(), ); - expect(screen.getByText("3 changed files")).toBeTruthy(); - expect(screen.getAllByText("/Users/admin/Projects/ADE").length).toBeGreaterThan(0); + 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 remote tab" })); + fireEvent.click(screen.getByRole("button", { name: "Open on Mac Studio" })); await waitFor(() => expect(switchRemoteProject).toHaveBeenCalledWith( "target-1", diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 25621a33d..65cc0efde 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -258,7 +258,7 @@ describe("TopBar", () => { expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app")).toBeTruthy(); expect(screen.getByText("Remote App")).toBeTruthy(); - expect(screen.getByText("Mac Studio")).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(); }); 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/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index c9da2adac..ea08447ee 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -307,6 +307,7 @@ export type AgentChatEvent = outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; + contextWindow?: number; } | { type: "cloud_artifact"; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6f0fda920..883e91c18 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -285,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) diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index af47b318f..caa14d7a0 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -13,11 +13,13 @@ It is a client. The runtime, lanes, chats, transcripts, PRs, processes, and proo | `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. | +| `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/` | `Drawer`, `ChatView`, `Header`, `RightPane`, `SlashPalette`, `MentionPalette`, `ApprovalPrompt`. | +| `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. | @@ -63,7 +65,7 @@ For the embedded runtime there is no `projects.add` step — the in-process runt `apps/ade-cli/src/tuiClient/app.tsx` is the Ink root. Layout: -- **Header** — project name, active lane, chat session, model + reasoning effort badge, token / cost counter (`latestTokenStats`). +- **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`. @@ -73,7 +75,7 @@ Heartbeats are kept alive with `startTuiHeartbeat` so the runtime knows the chat ## 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. Server-provided `AgentChatSlashCommand`s from the active runtime are merged in via `getSlashCommands` (responses with `source: "local"` win over built-ins). +`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): @@ -147,6 +149,13 @@ ade --socket /tmp/ade-runtime-dev.sock code 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. diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 27e60a26c..336689d04 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -16,7 +16,7 @@ machinery layered on top. | `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. | 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/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 1850a7da3..5c56b4347 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -356,7 +356,7 @@ changing rather than which service backs it: | 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 | From 9a17e69c6bfed49c8f596ce33e7ec344e6f0edec Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 10:19:41 -0400 Subject: [PATCH 05/11] Fix static runtime dependency packaging --- apps/ade-cli/package-lock.json | 2154 +++++++++++++++++++++++++++++++- apps/ade-cli/package.json | 8 +- apps/ade-cli/tsup.config.ts | 14 +- 3 files changed, 2112 insertions(+), 64 deletions(-) diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index 0e75d5414..08626f2c8 100644 --- a/apps/ade-cli/package-lock.json +++ b/apps/ade-cli/package-lock.json @@ -8,9 +8,14 @@ "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", @@ -18,7 +23,8 @@ "react": "^18.3.1", "sql.js": "^1.13.0", "ws": "^8.20.0", - "yaml": "^2.8.2" + "yaml": "^2.8.2", + "zod": "^4.3.6" }, "bin": { "ade": "dist/cli.cjs" @@ -38,6 +44,15 @@ "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", @@ -75,6 +90,165 @@ "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", @@ -195,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", @@ -636,6 +819,18 @@ "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", @@ -701,6 +896,46 @@ "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", @@ -727,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", @@ -1221,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", @@ -1228,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", @@ -1292,6 +1568,39 @@ "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", @@ -1425,6 +1734,46 @@ "readable-stream": "^3.4.0" } }, + "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", + "dependencies": { + "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/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", @@ -1485,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", @@ -1524,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", @@ -1570,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" }, @@ -1793,6 +2179,28 @@ "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", @@ -1802,6 +2210,55 @@ "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", @@ -1813,7 +2270,6 @@ "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" }, @@ -1869,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", @@ -1899,6 +2374,26 @@ "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", @@ -1906,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", @@ -1954,6 +2458,36 @@ "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", @@ -2005,6 +2539,12 @@ "@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", @@ -2014,6 +2554,36 @@ "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", @@ -2023,12 +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", @@ -2052,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", @@ -2063,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", @@ -2102,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", @@ -2144,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", @@ -2184,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", @@ -2201,6 +2945,18 @@ "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", @@ -2208,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", @@ -2215,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", @@ -2496,15 +3293,23 @@ } }, "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", @@ -2537,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", @@ -2553,12 +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", @@ -2641,32 +3491,87 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/make-fetch-happen": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", - "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "license": "ISC", - "optional": true, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "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": { - "agentkeepalive": "^4.1.3", - "cacache": "^15.2.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^6.0.0", - "minipass": "^3.1.3", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^1.3.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.2", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.0.0", - "ssri": "^8.0.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">= 10" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mimic-fn": { @@ -2690,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", @@ -2840,8 +3751,7 @@ "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", @@ -3000,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", @@ -3060,6 +3993,15 @@ "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", @@ -3079,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", @@ -3121,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", @@ -3290,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", @@ -3300,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", @@ -3367,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" }, @@ -3376,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", @@ -3481,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", @@ -3505,8 +4567,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/scheduler": { "version": "0.23.2", @@ -3529,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", @@ -3536,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", @@ -3755,6 +4960,15 @@ "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", @@ -3975,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", @@ -3984,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", @@ -4094,6 +5323,20 @@ "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", @@ -4151,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", @@ -4165,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", @@ -4747,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" }, @@ -4994,16 +6254,31 @@ "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", @@ -5025,6 +6300,84 @@ } } }, + "@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", @@ -5060,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": { @@ -5291,6 +6651,12 @@ "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", @@ -5345,6 +6711,30 @@ "@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", @@ -5365,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", @@ -5687,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", @@ -5736,6 +7159,25 @@ "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", @@ -5818,6 +7260,32 @@ "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", @@ -5855,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", @@ -5887,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", @@ -5920,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" } @@ -6051,11 +7541,50 @@ "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", @@ -6066,7 +7595,6 @@ "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" } @@ -6099,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", @@ -6118,12 +7660,32 @@ "@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", @@ -6158,6 +7720,24 @@ "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", @@ -6197,21 +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", @@ -6224,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", @@ -6235,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", @@ -6261,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", @@ -6288,6 +7967,32 @@ "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", @@ -6316,6 +8021,11 @@ "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", @@ -6328,18 +8038,48 @@ "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", @@ -6510,10 +8250,14 @@ } }, "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", @@ -6532,11 +8276,20 @@ "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", @@ -6544,11 +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", @@ -6632,6 +8409,34 @@ "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", @@ -6642,6 +8447,11 @@ "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", @@ -6746,8 +8556,7 @@ "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", @@ -6857,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", @@ -6894,6 +8715,11 @@ "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", @@ -6905,6 +8731,16 @@ "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", @@ -6935,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", @@ -7029,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", @@ -7038,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", @@ -7085,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", @@ -7159,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", @@ -7167,8 +9067,7 @@ "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", @@ -7183,12 +9082,103 @@ "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", @@ -7318,6 +9308,11 @@ "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", @@ -7488,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", @@ -7555,6 +9560,16 @@ "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", @@ -7599,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", @@ -7609,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", @@ -7881,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" } @@ -8021,9 +10045,15 @@ "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 20124366c..410e3b1f1 100644 --- a/apps/ade-cli/package.json +++ b/apps/ade-cli/package.json @@ -24,9 +24,14 @@ "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", @@ -34,7 +39,8 @@ "react": "^18.3.1", "sql.js": "^1.13.0", "ws": "^8.20.0", - "yaml": "^2.8.2" + "yaml": "^2.8.2", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^20.11.30", diff --git a/apps/ade-cli/tsup.config.ts b/apps/ade-cli/tsup.config.ts index 74c24fc3d..efd367219 100644 --- a/apps/ade-cli/tsup.config.ts +++ b/apps/ade-cli/tsup.config.ts @@ -3,7 +3,19 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; -const external = ["node-pty", "sql.js", "node:sqlite", "@cursor/sdk", "sqlite3"]; +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"; From 7b39c5a8444c05f56564a1f29ba6ecc10eb053d3 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 10:34:08 -0400 Subject: [PATCH 06/11] Fix remote runtime native deps test fixture --- .../services/remoteRuntime/remoteBootstrap.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index caf10e2a4..1afd76db2 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -243,12 +243,18 @@ function ok(stdout = "") { return { stdout, stderr: "", code: 0 }; } -function createTempResources(archLabel = "linux-x64"): { resourcesPath: string; binaryPath: string; binarySha256: string; cleanup: () => void } { +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, @@ -434,7 +440,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { it("uses the matching isolated remote home for Alpha channel bootstrap", async () => { process.env.ADE_PACKAGE_CHANNEL = "alpha"; - const resources = createTempResources("darwin-arm64"); + const resources = createTempResources("darwin-arm64", { nativeDeps: true }); cleanupResources = resources.cleanup; const fakeSsh = createFakeSsh(); const registry = createRegistry(); From 301000f2bd9158559906cff55c5daeb746d44bd3 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 10:46:28 -0400 Subject: [PATCH 07/11] Harden project root restoration --- apps/ade-cli/src/cli.ts | 38 ++------- .../services/projects/projectRegistry.test.ts | 41 ++++++++- .../src/services/projects/projectRegistry.ts | 18 ++-- .../src/services/projects/projectRoots.ts | 40 +++++++++ apps/desktop/src/main/main.ts | 74 +++++----------- .../services/projects/projectService.test.ts | 75 +++++++++++++++++ .../main/services/projects/projectService.ts | 23 +++-- .../projects/startupProjectResolver.test.ts | 68 ++++++++++++++- .../projects/startupProjectResolver.ts | 84 ++++++++++++++++++- .../runtime/machineStateMigration.test.ts | 34 +++++++- .../services/runtime/machineStateMigration.ts | 23 ++++- 11 files changed, 415 insertions(+), 103 deletions(-) create mode 100644 apps/ade-cli/src/services/projects/projectRoots.ts create mode 100644 apps/desktop/src/main/services/projects/projectService.test.ts diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index cc4693497..bca89e4c9 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -12,6 +12,10 @@ import { runCursorCloud, } from "./cursorCloud"; import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; +import { + findAdeManagedWorktreeRoot, + realpathIfExists, +} from "./services/projects/projectRoots"; import { JsonRpcError, JsonRpcErrorCode, @@ -8838,43 +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. - } + const canonicalStart = realpathIfExists(startDir); const managedWorktree = findAdeManagedWorktreeRoot(canonicalStart); if (managedWorktree) return managedWorktree; diff --git a/apps/ade-cli/src/services/projects/projectRegistry.test.ts b/apps/ade-cli/src/services/projects/projectRegistry.test.ts index 6f044c2a3..6d081cb03 100644 --- a/apps/ade-cli/src/services/projects/projectRegistry.test.ts +++ b/apps/ade-cli/src/services/projects/projectRegistry.test.ts @@ -44,9 +44,10 @@ describe("ProjectRegistry", () => { it("filters already-persisted user home entries from project lists", () => { const homeDir = makeTempRoot("ade-project-registry-home-"); - const projectRoot = path.join(homeDir, "Projects", "ADE"); + const rawProjectRoot = path.join(homeDir, "Projects", "ADE"); const registryDir = path.join(homeDir, ".ade-runtime"); - fs.mkdirSync(projectRoot, { recursive: true }); + 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"); @@ -83,4 +84,40 @@ describe("ProjectRegistry", () => { 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 index 9be2d69fb..6fa960850 100644 --- a/apps/ade-cli/src/services/projects/projectRegistry.ts +++ b/apps/ade-cli/src/services/projects/projectRegistry.ts @@ -7,6 +7,7 @@ import { resolveMachineAdeLayout, type MachineAdeLayout, } from "./machineLayout"; +import { normalizeProjectRootPath } from "./projectRoots"; export type ProjectId = string; @@ -25,7 +26,7 @@ type ProjectRegistryFile = { }; function normalizeRoot(rootPath: string): string { - return path.resolve(rootPath); + return normalizeProjectRootPath(rootPath); } function isSamePath(left: string, right: string): boolean { @@ -77,10 +78,7 @@ function coerceRecord(value: unknown): ProjectRecord | null { typeof record.rootPath === "string" ? normalizeRoot(record.rootPath) : ""; if (!rootPath) return null; if (isDisallowedProjectRoot(rootPath)) return null; - const projectId = - typeof record.projectId === "string" && record.projectId.trim() - ? record.projectId.trim() - : deriveProjectId(rootPath); + const projectId = deriveProjectId(rootPath); const now = Date.now(); return { projectId, @@ -207,7 +205,15 @@ export class ProjectRegistry { .map(coerceRecord) .filter((entry): entry is ProjectRecord => entry != null) : []; - return { version: 1, projects }; + const seen = new Set<string>(); + 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(); 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/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index aa72cc2bc..e80cba424 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -57,7 +57,7 @@ import { } from "./services/projects/projectService"; import { inspectRecentProject, type RecentProjectInspection } from "./services/projects/recentProjectSummary"; import { resolveProjectIcon } from "./services/projects/projectIconResolver"; -import { resolveStartupProject } from "./services/projects/startupProjectResolver"; +import { normalizeStartupProjectState, resolveStartupProject } from "./services/projects/startupProjectResolver"; import { createAdeProjectService } from "./services/projects/adeProjectService"; import { createConfigReloadService } from "./services/projects/configReloadService"; import { IPC } from "../shared/ipc"; @@ -87,6 +87,7 @@ import { } 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"; @@ -107,7 +108,11 @@ import { } from "./services/adeActions/registry"; import { createUsageTrackingService } from "./services/usage/usageTrackingService"; import { createBudgetCapService } from "./services/usage/budgetCapService"; -import { markMachineStateMigrationComplete, runMachineStateMigration } from "./services/runtime/machineStateMigration"; +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"; @@ -958,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 ( @@ -969,58 +974,20 @@ 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 machineAdeLayout = resolveMachineAdeLayout(); const machineStateMigration = runMachineStateMigration({ layout: machineAdeLayout, recentProjects: cleanedRecentProjects, @@ -4995,7 +4962,8 @@ app.whenReady().then(async () => { ctx.hasUserSelectedProject = true; persistRecentProject(ctx.project, { recordLastProject: true, - recordRecent: false, + recordRecent: true, + preserveRecentOrder: isKnownRecentProject, }); bindWindowToProject(currentIpcWindowId(), repoRoot, { emit: true, foreground: true }); scheduleProjectContextRebalance(); 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 index 23a99e453..42823aef8 100644 --- a/apps/desktop/src/main/services/projects/startupProjectResolver.test.ts +++ b/apps/desktop/src/main/services/projects/startupProjectResolver.test.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveStartupProject } from "./startupProjectResolver"; +import { normalizeStartupProjectState, resolveStartupProject } from "./startupProjectResolver"; const normalizeProjectPath = (value: string) => path.resolve("/", value); @@ -48,3 +48,69 @@ describe("resolveStartupProject", () => { 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 index 52392f1b0..0023356be 100644 --- a/apps/desktop/src/main/services/projects/startupProjectResolver.ts +++ b/apps/desktop/src/main/services/projects/startupProjectResolver.ts @@ -1,4 +1,5 @@ -import type { RecentProject } from "../state/globalState"; +import path from "node:path"; +import type { GlobalState, RecentProject } from "../state/globalState"; export type StartupProjectSource = "env" | "pending-open" | "last-project" | "recent-project" | "none"; @@ -7,6 +8,87 @@ export type StartupProjectResolution = { 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; diff --git a/apps/desktop/src/main/services/runtime/machineStateMigration.test.ts b/apps/desktop/src/main/services/runtime/machineStateMigration.test.ts index ea2a3c1e1..c6d724e9e 100644 --- a/apps/desktop/src/main/services/runtime/machineStateMigration.test.ts +++ b/apps/desktop/src/main/services/runtime/machineStateMigration.test.ts @@ -8,6 +8,7 @@ import type { MachineAdeLayout } from "../../../../../ade-cli/src/services/proje import { MACHINE_STATE_MIGRATION_MARKER, markMachineStateMigrationComplete, + readMachineRegistryRecentProjects, runMachineStateMigration, } from "./machineStateMigration"; @@ -26,7 +27,7 @@ function makeLayout(root: string): MachineAdeLayout { function makeProject(root: string, name: string): string { const projectRoot = path.join(root, name); fs.mkdirSync(path.join(projectRoot, ".ade", "secrets"), { recursive: true }); - return projectRoot; + return fs.realpathSync.native(projectRoot); } describe("machine state migration", () => { @@ -142,6 +143,37 @@ describe("machine state migration", () => { 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")); diff --git a/apps/desktop/src/main/services/runtime/machineStateMigration.ts b/apps/desktop/src/main/services/runtime/machineStateMigration.ts index a6ac45b83..bad070e01 100644 --- a/apps/desktop/src/main/services/runtime/machineStateMigration.ts +++ b/apps/desktop/src/main/services/runtime/machineStateMigration.ts @@ -114,6 +114,27 @@ function stableRegistryProjectsForChannelHome(layout: MachineAdeLayout): RecentP } } +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[] = []; @@ -134,7 +155,7 @@ export function runMachineStateMigration(args: MachineStateMigrationArgs): Machi const migrationProjects = uniqueProjects([ ...args.recentProjects, - ...stableRegistryProjectsForChannelHome(args.layout), + ...readMachineRegistryRecentProjects(args.layout), ]); const hadExistingUserState = migrationProjects.length > 0 || fs.existsSync(args.layout.secretsDir); From c5045bdd23ecc1aeac5ba4fd75ee78d84b53be5d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 10:46:32 -0400 Subject: [PATCH 08/11] Address runtime release review findings --- .github/workflows/ci.yml | 2 +- .github/workflows/release-core.yml | 9 +++++++++ apps/ade-cli/scripts/build-static.mjs | 4 +++- apps/ade-cli/scripts/install-runtime.sh | 20 +++++++++++++++++-- .../scripts/materialize-runtime-resources.mjs | 13 ++++++++++-- 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c4aa1f51..0f5ed3675 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -266,7 +266,7 @@ jobs: - name: Materialize ADE runtime resources env: - ADE_RUNTIME_ARTIFACTS_DIR: ${{ github.workspace }}/apps/desktop/resources/runtime + ADE_RUNTIME_ARTIFACTS_DIR: ${{ github.workspace }}\apps\desktop\resources\runtime run: cd apps/desktop && npm run materialize:runtime-resources - name: Reset release output diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 4c8f03787..0b3659324 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -104,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 }} diff --git a/apps/ade-cli/scripts/build-static.mjs b/apps/ade-cli/scripts/build-static.mjs index a2b3e8264..ea9139b9d 100644 --- a/apps/ade-cli/scripts/build-static.mjs +++ b/apps/ade-cli/scripts/build-static.mjs @@ -18,7 +18,9 @@ function parseArgs(argv) { }; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; - if (token === "--target") { + if (token === "--") { + continue; + } else if (token === "--target") { args.target = argv[++i] ?? ""; } else if (token === "--out-dir") { args.outDir = path.resolve(argv[++i] ?? ""); diff --git a/apps/ade-cli/scripts/install-runtime.sh b/apps/ade-cli/scripts/install-runtime.sh index 309c52382..6ea64c870 100644 --- a/apps/ade-cli/scripts/install-runtime.sh +++ b/apps/ade-cli/scripts/install-runtime.sh @@ -46,6 +46,22 @@ download() { 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 @@ -96,9 +112,9 @@ 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 - "$dest_dir/ade" serve --install-service >/dev/null 2>&1 || true + try_install_service elif [ "$(uname -s)" = "Darwin" ]; then - "$dest_dir/ade" serve --install-service >/dev/null 2>&1 || true + try_install_service fi printf 'ADE runtime installed: %s\n' "$dest_dir/ade" diff --git a/apps/desktop/scripts/materialize-runtime-resources.mjs b/apps/desktop/scripts/materialize-runtime-resources.mjs index e7f1effbf..8485aa19a 100644 --- a/apps/desktop/scripts/materialize-runtime-resources.mjs +++ b/apps/desktop/scripts/materialize-runtime-resources.mjs @@ -17,6 +17,7 @@ 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; @@ -84,7 +85,7 @@ function nodeArchiveCandidates(version, target) { ]; } -async function downloadFile(url, destinationPath) { +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) => { @@ -95,7 +96,15 @@ async function downloadFile(url, destinationPath) { response.headers.location ) { response.resume(); - downloadFile(new URL(response.headers.location, url).toString(), destinationPath).then(resolve, reject); + 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) { From 026fe07abf36541d12a40ad6e950e5a7891d7762 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 11:39:44 -0400 Subject: [PATCH 09/11] Fix runtime service manager review findings --- .github/workflows/ci.yml | 9 ++- .github/workflows/release-core.yml | 7 +- .../ade-cli/src/serviceManager/common.test.ts | 78 +++++++++++++++++-- apps/ade-cli/src/serviceManager/common.ts | 24 ++++++ .../src/serviceManager/installSystemd.ts | 13 +++- .../src/serviceManager/installWindows.ts | 22 +++++- 6 files changed, 136 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f5ed3675..19292f3d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,7 +183,7 @@ jobs: - target: darwin-arm64 os: macos-15 - target: darwin-x64 - os: macos-13 + os: macos-15-intel - target: linux-x64 os: ubuntu-latest - target: linux-arm64 @@ -194,7 +194,12 @@ jobs: with: node-version: 22 cache: npm - cache-dependency-path: apps/ade-cli/package-lock.json + 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 diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 0b3659324..6ebe518fb 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -221,7 +221,7 @@ jobs: - target: darwin-arm64 os: macos-15 - target: darwin-x64 - os: macos-13 + os: macos-15-intel - target: linux-x64 os: ubuntu-latest - target: linux-arm64 @@ -290,8 +290,11 @@ jobs: cp "$CSC_LINK" "$CERT_PATH" elif [[ "$CSC_LINK" == file://* ]]; then cp "${CSC_LINK#file://}" "$CERT_PATH" - elif [[ "$CSC_LINK" == http://* || "$CSC_LINK" == https://* ]]; then + 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 diff --git a/apps/ade-cli/src/serviceManager/common.test.ts b/apps/ade-cli/src/serviceManager/common.test.ts index 6d42cd2a7..ccf31eb68 100644 --- a/apps/ade-cli/src/serviceManager/common.test.ts +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -5,21 +5,24 @@ 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, renderSystemdUnit, servicePath as systemdServicePath } from "./installSystemd"; +import { installSystemdService, renderSystemdEnvironment, renderSystemdUnit, servicePath as systemdServicePath } from "./installSystemd"; import { buildWindowsCreateTaskArgs, + buildWindowsDeleteTaskArgs, buildWindowsQueryTaskArgs, buildWindowsRunTaskArgs, installWindowsService, isSchtasksOutputRunning, parseSchtasksListStatus, TASK_NAME, + uninstallWindowsService, } from "./installWindows"; const originalArgv = [...process.argv]; @@ -177,12 +180,13 @@ describe("systemd service rendering", () => { ); }); - it("renders unit content with quoted ExecStart and escaped percent environment values", () => { + 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", + NODE_PATH: "/tmp/100%/node modules", + ADE_HOME: "/home/example/ade path\\with\"quotes", }, }); @@ -190,9 +194,16 @@ describe("systemd service rendering", () => { 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=\"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", () => { @@ -267,8 +278,8 @@ describe("Windows scheduled task helpers", () => { args: ["serve"], }; - it("builds schtasks create, run, and query arguments without invoking schtasks", () => { - const renderedCommand = renderCommand(serviceCommand); + it("builds schtasks create, run, query, and delete arguments without invoking schtasks", () => { + const renderedCommand = renderWindowsCommand(serviceCommand); expect(buildWindowsCreateTaskArgs(renderedCommand)).toEqual([ "/Create", @@ -282,6 +293,22 @@ describe("Windows scheduled task helpers", () => { ]); 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("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", () => { @@ -301,7 +328,7 @@ describe("Windows scheduled task helpers", () => { message: "ADE service scheduled task installed and started.", }); expect(calls).toEqual([ - { command: "schtasks.exe", args: buildWindowsCreateTaskArgs(renderCommand(serviceCommand)) }, + { command: "schtasks.exe", args: buildWindowsCreateTaskArgs(renderWindowsCommand(serviceCommand)) }, { command: "schtasks.exe", args: buildWindowsRunTaskArgs() }, ]); }); @@ -318,7 +345,7 @@ describe("Windows scheduled task helpers", () => { 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(renderCommand(serviceCommand)), + buildWindowsCreateTaskArgs(renderWindowsCommand(serviceCommand)), buildWindowsRunTaskArgs(), ]); }); @@ -335,6 +362,41 @@ describe("Windows scheduled task helpers", () => { 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( diff --git a/apps/ade-cli/src/serviceManager/common.ts b/apps/ade-cli/src/serviceManager/common.ts index 5d70bc303..465d0c7ee 100644 --- a/apps/ade-cli/src/serviceManager/common.ts +++ b/apps/ade-cli/src/serviceManager/common.ts @@ -76,6 +76,26 @@ 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]) @@ -106,6 +126,10 @@ 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(); diff --git a/apps/ade-cli/src/serviceManager/installSystemd.ts b/apps/ade-cli/src/serviceManager/installSystemd.ts index d5dad6c15..e88b0d2e0 100644 --- a/apps/ade-cli/src/serviceManager/installSystemd.ts +++ b/apps/ade-cli/src/serviceManager/installSystemd.ts @@ -23,9 +23,20 @@ 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]) => `Environment=${key}=${value.replace(/%/g, "%%")}`) + .map(([key, value]) => renderSystemdEnvironment(key, value)) .join("\n"); return `[Unit] Description=ADE service daemon diff --git a/apps/ade-cli/src/serviceManager/installWindows.ts b/apps/ade-cli/src/serviceManager/installWindows.ts index 0ed9ab9c4..bb9cdd6dc 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process"; import { ADE_RUNTIME_SERVICE_NAME, type AdeServiceCommand, - renderCommand, + renderWindowsCommand, resolveAdeServeCommand, serviceManagerResultText, type ServiceManagerResult, @@ -38,6 +38,10 @@ 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; @@ -49,7 +53,7 @@ export function isSchtasksOutputRunning(output: string): boolean { export function installWindowsService(deps: WindowsServiceManagerDeps = {}): ServiceManagerResult { const run = deps.spawnSync ?? spawnSync; - const command = renderCommand(deps.command ?? resolveAdeServeCommand()); + const command = renderWindowsCommand(deps.command ?? resolveAdeServeCommand()); const result = run("schtasks.exe", buildWindowsCreateTaskArgs(command), { encoding: "utf8" }); if (result.status !== 0) { return { @@ -79,8 +83,18 @@ export function installWindowsService(deps: WindowsServiceManagerDeps = {}): Ser }; } -export function uninstallWindowsService(): ServiceManagerResult { - spawnSync("schtasks.exe", ["/Delete", "/TN", TASK_NAME, "/F"], { stdio: "ignore" }); +export function uninstallWindowsService(deps: Pick<WindowsServiceManagerDeps, "spawnSync"> = {}): 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, From bfb8594568f91e743a300ad07a39572f529d4f3a Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 12:04:05 -0400 Subject: [PATCH 10/11] Fix standalone daemon spawn fallback --- .../tuiClient/__tests__/connection.test.ts | 115 +++++++++++++++++- apps/ade-cli/src/tuiClient/connection.ts | 6 +- 2 files changed, 118 insertions(+), 3 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts index 454ff3f14..47c27a32d 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -2,10 +2,23 @@ import fs from "node:fs"; import net from "node:net"; import os from "node:os"; import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +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 = { @@ -46,6 +59,46 @@ const project: ProjectLaunchContext = { 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<typeof vi.fn>; + onNotification: ReturnType<typeof vi.fn>; + close: ReturnType<typeof vi.fn>; +} { + 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; @@ -55,6 +108,14 @@ describe("connectToAde embedded mode", () => { 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 () => { @@ -151,4 +212,56 @@ describe("connectToAde embedded mode", () => { ]); 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/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts index 4a6ec3dc2..f1a996838 100644 --- a/apps/ade-cli/src/tuiClient/connection.ts +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -292,10 +292,12 @@ function resolveCliEntrypoint(): string | null { function spawnDaemon(socketPath: string): boolean { const cliEntrypoint = resolveCliEntrypoint(); - if (!cliEntrypoint) return false; + const daemonArgs = cliEntrypoint + ? [cliEntrypoint, "serve", "--socket", socketPath] + : ["serve", "--socket", socketPath]; const child = spawn( process.execPath, - [cliEntrypoint, "serve", "--socket", socketPath], + daemonArgs, { detached: true, stdio: "ignore", From 701efaf35875acacb691ea2f71cda95353cff0d7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 12:22:05 -0400 Subject: [PATCH 11/11] Scope Windows runtime task to current user --- .../ade-cli/src/serviceManager/common.test.ts | 23 +++++++++---- .../src/serviceManager/installWindows.ts | 33 +++++++++++++++++-- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/apps/ade-cli/src/serviceManager/common.test.ts b/apps/ade-cli/src/serviceManager/common.test.ts index ccf31eb68..9ce64046d 100644 --- a/apps/ade-cli/src/serviceManager/common.test.ts +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -21,6 +21,7 @@ import { installWindowsService, isSchtasksOutputRunning, parseSchtasksListStatus, + resolveWindowsTaskUser, TASK_NAME, uninstallWindowsService, } from "./installWindows"; @@ -277,11 +278,12 @@ describe("Windows scheduled task helpers", () => { 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)).toEqual([ + expect(buildWindowsCreateTaskArgs(renderedCommand, taskUser)).toEqual([ "/Create", "/SC", "ONLOGON", @@ -289,6 +291,9 @@ describe("Windows scheduled task helpers", () => { TASK_NAME, "/TR", renderedCommand, + "/RU", + taskUser, + "/IT", "/F", ]); expect(buildWindowsRunTaskArgs()).toEqual(["/Run", "/TN", TASK_NAME]); @@ -296,6 +301,12 @@ describe("Windows scheduled task helpers", () => { 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", @@ -318,7 +329,7 @@ describe("Windows scheduled task helpers", () => { { status: 0, stdout: "SUCCESS: attempted to run", stderr: "" }, ]); - const result = installWindowsService({ command: serviceCommand, spawnSync }); + const result = installWindowsService({ command: serviceCommand, spawnSync, userName: taskUser }); expect(result).toMatchObject({ ok: true, @@ -328,7 +339,7 @@ describe("Windows scheduled task helpers", () => { message: "ADE service scheduled task installed and started.", }); expect(calls).toEqual([ - { command: "schtasks.exe", args: buildWindowsCreateTaskArgs(renderWindowsCommand(serviceCommand)) }, + { command: "schtasks.exe", args: buildWindowsCreateTaskArgs(renderWindowsCommand(serviceCommand), taskUser) }, { command: "schtasks.exe", args: buildWindowsRunTaskArgs() }, ]); }); @@ -340,12 +351,12 @@ describe("Windows scheduled task helpers", () => { { status: 1, stdout: "", stderr: "ERROR: access is denied" }, ]); - const result = installWindowsService({ command: serviceCommand, spawnSync }); + 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)), + buildWindowsCreateTaskArgs(renderWindowsCommand(serviceCommand), taskUser), buildWindowsRunTaskArgs(), ]); }); @@ -356,7 +367,7 @@ describe("Windows scheduled task helpers", () => { { status: 1, stdout: "", stderr: "ERROR: create failed" }, ]); - const result = installWindowsService({ command: serviceCommand, spawnSync }); + const result = installWindowsService({ command: serviceCommand, spawnSync, userName: taskUser }); expect(result.ok).toBe(false); expect(result.message).toBe("ERROR: create failed"); diff --git a/apps/ade-cli/src/serviceManager/installWindows.ts b/apps/ade-cli/src/serviceManager/installWindows.ts index bb9cdd6dc..9606b1d8a 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import os from "node:os"; import { ADE_RUNTIME_SERVICE_NAME, type AdeServiceCommand, @@ -15,9 +16,22 @@ export const TASK_NAME = "ADE Runtime"; type WindowsServiceManagerDeps = { command?: AdeServiceCommand; spawnSync?: ServiceManagerSpawnSync; + userName?: string; }; -export function buildWindowsCreateTaskArgs(command: string): 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", @@ -26,6 +40,9 @@ export function buildWindowsCreateTaskArgs(command: string): string[] { TASK_NAME, "/TR", command, + "/RU", + userName, + "/IT", "/F", ]; } @@ -54,7 +71,19 @@ export function isSchtasksOutputRunning(output: string): boolean { export function installWindowsService(deps: WindowsServiceManagerDeps = {}): ServiceManagerResult { const run = deps.spawnSync ?? spawnSync; const command = renderWindowsCommand(deps.command ?? resolveAdeServeCommand()); - const result = run("schtasks.exe", buildWindowsCreateTaskArgs(command), { encoding: "utf8" }); + 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,