From 7966a7ef5d9fb39b8642b63f147b2b0e3fc8f0dc Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:08:49 -0400 Subject: [PATCH 1/6] Deepen ADE action audit with broader domains and live status polling --- apps/mcp-server/src/mcpServer.test.ts | 208 ++++++++- apps/mcp-server/src/mcpServer.ts | 598 +++++++++++++++++++++++++- 2 files changed, 799 insertions(+), 7 deletions(-) diff --git a/apps/mcp-server/src/mcpServer.test.ts b/apps/mcp-server/src/mcpServer.test.ts index 09dbea60e..fd18bed0a 100644 --- a/apps/mcp-server/src/mcpServer.test.ts +++ b/apps/mcp-server/src/mcpServer.test.ts @@ -131,6 +131,7 @@ function createRuntime() { }, laneService: { list: vi.fn(async () => laneRows), + listUnregisteredWorktrees: vi.fn(async () => [{ path: "/tmp/untracked-worktree", branch: "feature/untracked" }]), getLaneWorktreePath: vi.fn((laneId: string) => { const lane = laneRows.find((row) => row.id === laneId) ?? laneRows[0]!; return lane.worktreePath; @@ -151,6 +152,12 @@ function createRuntime() { branchRef: "feature/lane-new", worktreePath: "/tmp/project/.ade/worktrees/lane-new" })), + importBranch: vi.fn(async ({ branchRef, name }: { branchRef: string; name?: string }) => ({ + ...laneRows[0], + id: "lane-imported", + name: name ?? "Imported lane", + branchRef, + })), delete: vi.fn(async () => {}) }, sessionService: { @@ -159,7 +166,8 @@ function createRuntime() { }, operationService: { start: operationStart, - finish: operationFinish + finish: operationFinish, + list: vi.fn(() => [{ id: "op-1", kind: "git_push", status: "running" }]), }, projectConfigService: {} as any, conflictService: { @@ -172,7 +180,14 @@ function createRuntime() { getConflictState: vi.fn(async () => ({ laneId: "lane-1", kind: null, inProgress: false, conflictedFiles: [], canContinue: false, canAbort: false })), stageAll: vi.fn(async () => ({ success: true })), commit: vi.fn(async () => ({ success: true })), + generateCommitMessage: vi.fn(async () => ({ message: "generated commit message", model: "gpt-5-mini" })), listRecentCommits: vi.fn(async () => [{ sha: "abc123", subject: "test" }]), + getSyncStatus: vi.fn(async () => ({ ahead: 1, behind: 0, tracking: true })), + fetch: vi.fn(async () => ({ success: true })), + pull: vi.fn(async () => ({ success: true })), + push: vi.fn(async () => ({ success: true })), + listBranches: vi.fn(async () => [{ name: "main", current: true, ahead: 0, behind: 0, hasUpstream: true, upstream: "origin/main" }]), + checkoutBranch: vi.fn(async () => ({ success: true })), stashPush: vi.fn(async () => ({ success: true })), listStashes: vi.fn(async () => [{ ref: "stash@{0}", createdAt: "2026-04-06T00:00:00.000Z", subject: "test stash" }]), stashApply: vi.fn(async () => ({ success: true })), @@ -294,6 +309,7 @@ function createRuntime() { simulateIntegration: vi.fn(async () => ({ steps: [], conflicts: [], clean: true })), createQueuePrs: vi.fn(async () => ({ groupId: "group-1", prs: [] })), createIntegrationPr: vi.fn(async () => ({ prId: "pr-int-1", url: "https://github.com/pr/1" })), + createFromLane: vi.fn(async () => ({ id: "pr-new", laneId: "lane-1", title: "New PR", status: "open" })), getPrHealth: vi.fn(async (prId: string) => ({ prId, healthy: true, checks: "pass", reviews: "approved" })), landQueueNext: vi.fn(async () => ({ landed: true, prId: "pr-1", sha: "def456" })), getChecks: vi.fn(async () => [ @@ -399,6 +415,9 @@ function createRuntime() { updatedAt: "2026-03-17T19:00:00.000Z", })), resolveReviewThread: vi.fn(async () => undefined), + updateTitle: vi.fn(async () => undefined), + updateBody: vi.fn(async () => undefined), + addComment: vi.fn(async ({ body }: { body: string }) => ({ id: "comment-new", body })), }, agentChatService: { listSessions: vi.fn(async () => [ @@ -3019,7 +3038,194 @@ describe("mcpServer", () => { expect(response?.isError).toBeUndefined(); expect(fixture.runtime.gitService.stageAll).toHaveBeenCalledTimes(1); expect(fixture.runtime.gitService.commit).toHaveBeenCalledTimes(1); + expect(fixture.runtime.gitService.generateCommitMessage).not.toHaveBeenCalled(); expect(response.structuredContent.commit.sha).toBe("abc123"); + expect(response.structuredContent.messageSource).toBe("provided"); + }); + + it("generates a commit message when commit_changes message is omitted", async () => { + const fixture = createRuntime(); + const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { callerId: "agent-1", role: "agent" }); + + const response = await callTool(handler, "commit_changes", { + laneId: "lane-1", + }); + + expect(response?.isError).toBeUndefined(); + expect(fixture.runtime.gitService.generateCommitMessage).toHaveBeenCalledWith({ + laneId: "lane-1", + amend: false, + }); + expect(fixture.runtime.gitService.commit).toHaveBeenCalledWith({ + laneId: "lane-1", + amend: false, + message: "generated commit message", + }); + expect(response.structuredContent.messageSource).toBe("generated"); + expect(response.structuredContent.generatedByModel).toBe("gpt-5-mini"); + }); + + it("returns generated commit text without creating a commit", async () => { + const fixture = createRuntime(); + const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { callerId: "agent-1", role: "agent" }); + + const response = await callTool(handler, "generate_commit_message", { + laneId: "lane-1", + amend: true, + }); + + expect(response?.isError).toBeUndefined(); + expect(fixture.runtime.gitService.generateCommitMessage).toHaveBeenCalledWith({ + laneId: "lane-1", + amend: true, + }); + expect(fixture.runtime.gitService.commit).not.toHaveBeenCalled(); + expect(response.structuredContent.message).toBe("generated commit message"); + }); + + it("lists and imports unregistered lane worktrees", async () => { + const fixture = createRuntime(); + const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "agent-1", role: "agent" }); + + const listResponse = await callTool(handler, "list_unregistered_lanes", {}); + expect(listResponse?.isError).toBeUndefined(); + expect(fixture.runtime.laneService.listUnregisteredWorktrees).toHaveBeenCalledTimes(1); + expect(listResponse.structuredContent.worktrees[0].branch).toBe("feature/untracked"); + + const importResponse = await callTool(handler, "import_lane", { + branchRef: "feature/untracked", + name: "Imported lane", + baseBranch: "main", + }); + expect(importResponse?.isError).toBeUndefined(); + expect(fixture.runtime.laneService.importBranch).toHaveBeenCalledWith({ + branchRef: "feature/untracked", + name: "Imported lane", + baseBranch: "main", + }); + expect(importResponse.structuredContent.lane.id).toBe("lane-imported"); + }); + + it("supports core git sync operations via MCP", async () => { + const fixture = createRuntime(); + const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "agent-1", role: "agent" }); + + const syncStatus = await callTool(handler, "git_get_sync_status", { laneId: "lane-1" }); + expect(syncStatus?.isError).toBeUndefined(); + expect(fixture.runtime.gitService.getSyncStatus).toHaveBeenCalledWith({ laneId: "lane-1" }); + + const push = await callTool(handler, "git_push", { laneId: "lane-1", force: true, setUpstream: false }); + expect(push?.isError).toBeUndefined(); + expect(fixture.runtime.gitService.push).toHaveBeenCalledWith({ laneId: "lane-1", force: true, setUpstream: false }); + }); + + it("supports create/update/comment PR actions via MCP", async () => { + const fixture = createRuntime(); + const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "agent-1", role: "agent" }); + + const created = await callTool(handler, "create_pr_from_lane", { + laneId: "lane-1", + baseBranch: "main", + title: "My PR", + body: "Body text", + draft: true, + }); + expect(created?.isError).toBeUndefined(); + expect(fixture.runtime.prService.createFromLane).toHaveBeenCalledWith({ + laneId: "lane-1", + baseBranch: "main", + title: "My PR", + body: "Body text", + draft: true, + }); + + const updateTitle = await callTool(handler, "pr_update_title", { prId: "pr-1", title: "Renamed" }); + expect(updateTitle?.isError).toBeUndefined(); + expect(fixture.runtime.prService.updateTitle).toHaveBeenCalledWith({ prId: "pr-1", title: "Renamed" }); + + const comment = await callTool(handler, "pr_add_comment", { prId: "pr-1", body: "Looks good" }); + expect(comment?.isError).toBeUndefined(); + expect(fixture.runtime.prService.addComment).toHaveBeenCalledWith({ prId: "pr-1", body: "Looks good" }); + }); + + it("lists ADE actions across runtime domains", async () => { + const fixture = createRuntime(); + const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "agent-1", role: "agent" }); + + const response = await callTool(handler, "list_ade_actions", { domain: "git" }); + expect(response?.isError).toBeUndefined(); + expect(response.structuredContent.actions.some((entry: { action: string }) => entry.action === "push")).toBe(true); + expect(response.structuredContent.actions.some((entry: { action: string }) => entry.action === "commit")).toBe(true); + + 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 === "computer_use_artifacts")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "operation")).toBe(true); + }); + + it("invokes ADE actions dynamically and returns status hints", async () => { + const fixture = createRuntime(); + const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "agent-1", role: "agent" }); + + const response = await callTool(handler, "run_ade_action", { + domain: "git", + action: "push", + args: { laneId: "lane-1", force: true, setUpstream: false }, + }); + expect(response?.isError).toBeUndefined(); + expect(fixture.runtime.gitService.push).toHaveBeenCalledWith({ laneId: "lane-1", force: true, setUpstream: false }); + expect(response.structuredContent.domain).toBe("git"); + expect(response.structuredContent.action).toBe("push"); + + const variadic = await callTool(handler, "run_ade_action", { + domain: "operation", + action: "list", + argsList: [{ limit: 10 }], + }); + expect(variadic?.isError).toBeUndefined(); + expect(fixture.runtime.operationService.list).toHaveBeenCalledWith({ limit: 10 }); + }); + + it("reads ADE action status snapshots across operation/test/chat/mission/run", async () => { + const fixture = createRuntime(); + const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "agent-1", role: "agent", runId: "run-1", missionId: "mission-1" }); + + const response = await callTool(handler, "get_ade_action_status", { + operationId: "op-1", + testRunId: "test-run-1", + chatSessionId: "chat-1", + runId: "run-1", + missionId: "mission-1", + prId: "pr-1", + }); + expect(response?.isError).toBeUndefined(); + expect(response.structuredContent.operation.id).toBe("op-1"); + expect(response.structuredContent.testRun.id).toBe("test-run-1"); + expect(response.structuredContent.chatSession.sessionId).toBe("chat-1"); + expect(response.structuredContent.runGraph.run.id).toBe("run-1"); + expect(response.structuredContent.mission.id).toBe("mission-1"); + expect(response.structuredContent.pr.health.prId).toBe("pr-1"); + expect(typeof response.structuredContent.hash).toBe("string"); + expect(response.structuredContent.changed).toBe(true); + + const unchanged = await callTool(handler, "get_ade_action_status", { + operationId: "op-1", + previousHash: response.structuredContent.hash, + waitForMs: 0, + }); + expect(unchanged?.isError).toBeUndefined(); + expect(unchanged.structuredContent.changed).toBe(false); }); it("lets agent callers stash lane changes", async () => { diff --git a/apps/mcp-server/src/mcpServer.ts b/apps/mcp-server/src/mcpServer.ts index b4adbf475..4334dc1ff 100644 --- a/apps/mcp-server/src/mcpServer.ts +++ b/apps/mcp-server/src/mcpServer.ts @@ -154,6 +154,116 @@ const TOOL_SPECS: ToolSpec[] = [ } } }, + { + name: "list_ade_actions", + description: "List callable ADE action methods across core runtime services (lane/git/pr/tests/chat/mission/orchestrator).", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + domain: { + type: "string", + enum: [ + "lane", + "git", + "diff", + "conflicts", + "pr", + "tests", + "chat", + "mission", + "orchestrator", + "orchestrator_core", + "memory", + "cto_state", + "worker_agent", + "session", + "operation", + "project_config", + "issue_inventory", + "flow_policy", + "linear_dispatcher", + "linear_issue_tracker", + "linear_sync", + "linear_ingress", + "linear_routing", + "file", + "process", + "external_mcp", + "computer_use_artifacts", + "all" + ], + default: "all", + }, + } + } + }, + { + name: "run_ade_action", + description: "Invoke any ADE action by domain and action name. Use args for object-style calls, or arg for scalar-style calls.", + inputSchema: { + type: "object", + required: ["domain", "action"], + additionalProperties: false, + properties: { + domain: { + type: "string", + enum: [ + "lane", + "git", + "diff", + "conflicts", + "pr", + "tests", + "chat", + "mission", + "orchestrator", + "orchestrator_core", + "memory", + "cto_state", + "worker_agent", + "session", + "operation", + "project_config", + "issue_inventory", + "flow_policy", + "linear_dispatcher", + "linear_issue_tracker", + "linear_sync", + "linear_ingress", + "linear_routing", + "file", + "process", + "external_mcp", + "computer_use_artifacts", + ], + }, + action: { type: "string", minLength: 1 }, + args: { type: "object" }, + argsList: { type: "array" }, + arg: {}, + } + } + }, + { + name: "get_ade_action_status", + description: "Check status/progress for long-running ADE actions by operation/test/chat/run/mission identifiers.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + operationId: { type: "string", minLength: 1 }, + testRunId: { type: "string", minLength: 1 }, + chatSessionId: { type: "string", minLength: 1 }, + runId: { type: "string", minLength: 1 }, + missionId: { type: "string", minLength: 1 }, + prId: { type: "string", minLength: 1 }, + previousHash: { type: "string" }, + waitForMs: { type: "number", minimum: 0, maximum: 120000, default: 0 }, + pollIntervalMs: { type: "number", minimum: 100, maximum: 5000, default: 800 }, + } + } + }, { name: "check_conflicts", description: "Run conflict prediction against one lane or a lane set.", @@ -529,12 +639,105 @@ const TOOL_SPECS: ToolSpec[] = [ } } }, + { + name: "list_unregistered_lanes", + description: "List git worktrees that are not yet registered as ADE lanes.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: {} + } + }, + { + name: "import_lane", + description: "Import an existing git branch/worktree into ADE lane tracking.", + inputSchema: { + type: "object", + required: ["branchRef"], + additionalProperties: false, + properties: { + branchRef: { type: "string", minLength: 1 }, + name: { type: "string" }, + description: { type: "string" }, + baseBranch: { type: "string" } + } + } + }, + { + name: "git_get_sync_status", + description: "Read upstream sync status for a lane branch.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + laneId: { type: "string", minLength: 1 } + } + } + }, + { + name: "git_fetch", + description: "Fetch remote refs for a lane.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + laneId: { type: "string", minLength: 1 } + } + } + }, + { + name: "git_pull", + description: "Pull remote changes into a lane.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + laneId: { type: "string", minLength: 1 } + } + } + }, + { + name: "git_push", + description: "Push lane branch commits to remote.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + laneId: { type: "string", minLength: 1 }, + force: { type: "boolean", default: false }, + setUpstream: { type: "boolean", default: true } + } + } + }, + { + name: "git_list_branches", + description: "List branches visible from a lane checkout.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + laneId: { type: "string", minLength: 1 } + } + } + }, + { + name: "git_checkout_branch", + description: "Checkout an existing branch in a lane checkout.", + inputSchema: { + type: "object", + required: ["branchName"], + additionalProperties: false, + properties: { + laneId: { type: "string", minLength: 1 }, + branchName: { type: "string", minLength: 1 } + } + } + }, { name: "commit_changes", - description: "Stage and commit lane changes with a provided message.", + description: "Stage and commit lane changes. If message is omitted, ADE generates one with the configured Commit Messages model.", inputSchema: { type: "object", - required: ["message"], additionalProperties: false, properties: { laneId: { type: "string", minLength: 1 }, @@ -544,6 +747,18 @@ const TOOL_SPECS: ToolSpec[] = [ } } }, + { + name: "generate_commit_message", + description: "Generate a commit message for a lane using ADE's Commit Messages model settings.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + laneId: { type: "string", minLength: 1 }, + amend: { type: "boolean", default: false } + } + } + }, { name: "stash_push", description: "Stash lane changes so rebase or inspection can proceed cleanly. Defaults to the current chat lane when laneId is omitted.", @@ -713,6 +928,61 @@ const TOOL_SPECS: ToolSpec[] = [ } } }, + { + name: "create_pr_from_lane", + description: "Create a PR from a lane branch.", + inputSchema: { + type: "object", + required: ["laneId", "baseBranch", "title"], + additionalProperties: false, + properties: { + laneId: { type: "string", minLength: 1 }, + baseBranch: { type: "string", minLength: 1 }, + title: { type: "string", minLength: 1 }, + body: { type: "string" }, + draft: { type: "boolean", default: false }, + } + } + }, + { + name: "pr_update_title", + description: "Update a PR title.", + inputSchema: { + type: "object", + required: ["prId", "title"], + additionalProperties: false, + properties: { + prId: { type: "string", minLength: 1 }, + title: { type: "string", minLength: 1 }, + } + } + }, + { + name: "pr_update_body", + description: "Update PR body/description markdown.", + inputSchema: { + type: "object", + required: ["prId", "body"], + additionalProperties: false, + properties: { + prId: { type: "string", minLength: 1 }, + body: { type: "string" }, + } + } + }, + { + name: "pr_add_comment", + description: "Add a top-level comment to a PR.", + inputSchema: { + type: "object", + required: ["prId", "body"], + additionalProperties: false, + properties: { + prId: { type: "string", minLength: 1 }, + body: { type: "string", minLength: 1 }, + } + } + }, { name: "get_pr_health", description: "Get combined health status for a PR including checks, reviews, conflicts, and rebase status", @@ -1565,9 +1835,15 @@ const COORDINATOR_TOOL_NAMES = new Set(COORDINATOR_TOOL_SPECS.map((tool) => tool const READ_ONLY_TOOLS = new Set([ "check_conflicts", + "list_ade_actions", + "get_ade_action_status", "get_lane_status", "get_lane_conflict_state", "list_lanes", + "list_unregistered_lanes", + "git_get_sync_status", + "git_list_branches", + "generate_commit_message", "list_stashes", "simulate_integration", "get_pr_health", @@ -1595,7 +1871,13 @@ const READ_ONLY_TOOLS = new Set([ const MUTATION_TOOLS = new Set([ "create_lane", + "run_ade_action", + "import_lane", "merge_lane", + "git_fetch", + "git_pull", + "git_push", + "git_checkout_branch", "commit_changes", "stash_push", "stash_apply", @@ -1605,6 +1887,10 @@ const MUTATION_TOOLS = new Set([ "run_tests", "create_queue", "create_integration", + "create_pr_from_lane", + "pr_update_title", + "pr_update_body", + "pr_add_comment", "rebase_lane", "rebase_continue", "rebase_abort", @@ -2748,6 +3034,73 @@ async function waitForTestRunCompletion(args: { }; } +type AdeActionDomain = + | "lane" + | "git" + | "diff" + | "conflicts" + | "pr" + | "tests" + | "chat" + | "mission" + | "orchestrator" + | "orchestrator_core" + | "memory" + | "cto_state" + | "worker_agent" + | "session" + | "operation" + | "project_config" + | "issue_inventory" + | "flow_policy" + | "linear_dispatcher" + | "linear_issue_tracker" + | "linear_sync" + | "linear_ingress" + | "linear_routing" + | "file" + | "process" + | "external_mcp" + | "computer_use_artifacts"; + +function getAdeActionDomainServices(runtime: AdeMcpRuntime): Partial | null | undefined>> { + return { + lane: runtime.laneService as unknown as Record, + git: runtime.gitService as unknown as Record, + diff: runtime.diffService as unknown as Record, + conflicts: runtime.conflictService as unknown as Record, + pr: (runtime.prService ?? null) as unknown as Record | null, + tests: runtime.testService as unknown as Record, + chat: (runtime.agentChatService ?? null) as unknown as Record | null, + mission: runtime.missionService as unknown as Record, + orchestrator: runtime.aiOrchestratorService as unknown as Record, + orchestrator_core: runtime.orchestratorService as unknown as Record, + memory: runtime.memoryService as unknown as Record, + cto_state: runtime.ctoStateService as unknown as Record, + worker_agent: runtime.workerAgentService as unknown as Record, + session: runtime.sessionService as unknown as Record, + operation: runtime.operationService as unknown as Record, + project_config: runtime.projectConfigService as unknown as Record, + issue_inventory: runtime.issueInventoryService as unknown as Record, + flow_policy: (runtime.flowPolicyService ?? null) as unknown as Record | null, + linear_dispatcher: (runtime.linearDispatcherService ?? null) as unknown as Record | null, + linear_issue_tracker: (runtime.linearIssueTracker ?? null) as unknown as Record | null, + linear_sync: (runtime.linearSyncService ?? null) as unknown as Record | null, + linear_ingress: (runtime.linearIngressService ?? null) as unknown as Record | null, + linear_routing: (runtime.linearRoutingService ?? null) as unknown as Record | null, + file: (runtime.fileService ?? null) as unknown as Record | null, + process: (runtime.processService ?? null) as unknown as Record | null, + external_mcp: runtime.externalMcpService as unknown as Record, + computer_use_artifacts: runtime.computerUseArtifactBrokerService as unknown as Record, + }; +} + +function listAdeActionNames(service: Record): string[] { + return Object.keys(service) + .filter((key) => typeof service[key] === "function" && !key.startsWith("_")) + .sort((a, b) => a.localeCompare(b)); +} + async function waitForSessionCompletion(args: { runtime: AdeMcpRuntime; ptyId: string; @@ -3851,6 +4204,125 @@ async function runTool(args: { return await runCoordinatorTool({ runtime, name, toolArgs, callerCtx }); } + if (name === "list_ade_actions") { + const domain = asOptionalTrimmedString(toolArgs.domain) ?? "all"; + const services = getAdeActionDomainServices(runtime); + const domains = domain === "all" + ? (Object.keys(services) as AdeActionDomain[]) + : [domain as AdeActionDomain]; + const actions = domains.flatMap((entry) => { + const service = services[entry]; + if (!service) return []; + return listAdeActionNames(service).map((action) => ({ + domain: entry, + action, + })); + }); + return { + count: actions.length, + actions, + }; + } + + if (name === "run_ade_action") { + const domain = assertNonEmptyString(toolArgs.domain, "domain") as AdeActionDomain; + const action = assertNonEmptyString(toolArgs.action, "action"); + const services = getAdeActionDomainServices(runtime); + const service = services[domain]; + if (!service) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Domain '${domain}' is unavailable in this runtime.`); + } + const callable = service[action]; + if (typeof callable !== "function") { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Action '${domain}.${action}' is not callable.`); + } + const argsList = Array.isArray(toolArgs.argsList) ? toolArgs.argsList : null; + const hasScalarArg = Object.prototype.hasOwnProperty.call(toolArgs, "arg"); + const rawObjectArgs = safeObject(toolArgs.args); + const result = argsList + ? await (callable as (...params: unknown[]) => Promise)(...argsList) + : hasScalarArg + ? await (callable as (arg: unknown) => Promise)(toolArgs.arg) + : await (callable as (args?: Record) => Promise)( + Object.keys(rawObjectArgs).length > 0 ? rawObjectArgs : undefined + ); + const record = isRecord(result) ? result : null; + const statusHints = { + operationId: typeof record?.operationId === "string" ? record.operationId : null, + testRunId: typeof record?.id === "string" && domain === "tests" ? record.id : null, + chatSessionId: typeof record?.sessionId === "string" ? record.sessionId : null, + runId: typeof record?.runId === "string" ? record.runId : null, + missionId: typeof record?.missionId === "string" ? record.missionId : null, + }; + return { + domain, + action, + result, + statusHints, + }; + } + + if (name === "get_ade_action_status") { + const operationId = asOptionalTrimmedString(toolArgs.operationId); + const testRunId = asOptionalTrimmedString(toolArgs.testRunId); + const chatSessionId = asOptionalTrimmedString(toolArgs.chatSessionId); + const runId = asOptionalTrimmedString(toolArgs.runId); + const missionId = asOptionalTrimmedString(toolArgs.missionId); + const prId = asOptionalTrimmedString(toolArgs.prId); + const previousHash = asOptionalTrimmedString(toolArgs.previousHash); + const waitForMs = Math.max(0, Math.min(120_000, Math.floor(asNumber(toolArgs.waitForMs, 0)))); + const pollIntervalMs = Math.max(100, Math.min(5_000, Math.floor(asNumber(toolArgs.pollIntervalMs, 800)))); + + const collectStatusPayload = async (): Promise> => { + const payload: Record = {}; + if (operationId) { + const operation = runtime.operationService.list({ limit: 500 }).find((entry) => entry.id === operationId) ?? null; + payload.operation = operation; + } + if (testRunId) { + const run = runtime.testService.listRuns({ limit: 200 }).find((entry) => entry.id === testRunId) ?? null; + payload.testRun = run; + if (run) payload.testRunLogTail = runtime.testService.getLogTail(testRunId, 16_000); + } + if (chatSessionId && runtime.agentChatService) { + payload.chatSession = await runtime.agentChatService.getSessionSummary(chatSessionId); + } + if (runId) { + payload.runGraph = runtime.orchestratorService.getRunGraph({ runId, timelineLimit: 150 }); + } + if (missionId) { + payload.mission = runtime.missionService.get(missionId); + } + if (prId && runtime.prService) { + payload.pr = { + health: await runtime.prService.getPrHealth(prId), + checks: await runtime.prService.getChecks(prId), + reviews: await runtime.prService.getReviews(prId), + }; + } + return payload; + }; + + const hashPayload = (payload: Record): string => + createHash("sha256").update(JSON.stringify(payload)).digest("hex"); + + let payload = await collectStatusPayload(); + let hash = hashPayload(payload); + if (previousHash && waitForMs > 0 && hash === previousHash) { + const deadline = Date.now() + waitForMs; + while (Date.now() < deadline && hash === previousHash) { + await sleep(pollIntervalMs); + payload = await collectStatusPayload(); + hash = hashPayload(payload); + } + } + return { + ...payload, + hash, + changed: previousHash ? hash !== previousHash : true, + }; + } + if (name === "list_lanes") { const includeArchived = asBoolean(toolArgs.includeArchived, false); const lanes = await runtime.laneService.list({ includeArchived }); @@ -3859,6 +4331,11 @@ async function runTool(args: { }; } + if (name === "list_unregistered_lanes") { + const worktrees = await runtime.laneService.listUnregisteredWorktrees(); + return { worktrees }; + } + if (name === "get_lane_status") { const laneId = requireLaneIdForTool(runtime, session, toolArgs, "get_lane_status"); return await buildLaneStatus(runtime, laneId); @@ -3882,6 +4359,19 @@ async function runTool(args: { }; } + if (name === "import_lane") { + const branchRef = assertNonEmptyString(toolArgs.branchRef, "branchRef"); + const imported = await runtime.laneService.importBranch({ + branchRef, + ...(asOptionalTrimmedString(toolArgs.name) ? { name: asOptionalTrimmedString(toolArgs.name)! } : {}), + ...(asOptionalTrimmedString(toolArgs.description) ? { description: asOptionalTrimmedString(toolArgs.description)! } : {}), + ...(asOptionalTrimmedString(toolArgs.baseBranch) ? { baseBranch: asOptionalTrimmedString(toolArgs.baseBranch)! } : {}), + }); + return { + lane: mapLaneSummary(imported as unknown as Record), + }; + } + if (name === "check_conflicts") { const laneId = asOptionalTrimmedString(toolArgs.laneId); const laneIds = Array.isArray(toolArgs.laneIds) @@ -4812,11 +5302,47 @@ async function runTool(args: { }; } - if (name === "commit_changes") { - const laneId = requireLaneIdForTool(runtime, session, toolArgs, "commit_changes"); + if (name === "git_get_sync_status") { + const laneId = requireLaneIdForTool(runtime, session, toolArgs, "git_get_sync_status"); + const status = await runtime.gitService.getSyncStatus({ laneId }); + return { laneId, status }; + } + + if (name === "git_fetch") { + const laneId = requireLaneIdForTool(runtime, session, toolArgs, "git_fetch"); + const action = await runtime.gitService.fetch({ laneId }); + return { laneId, action }; + } + if (name === "git_pull") { + const laneId = requireLaneIdForTool(runtime, session, toolArgs, "git_pull"); + const action = await runtime.gitService.pull({ laneId }); + return { laneId, action }; + } - const message = assertNonEmptyString(toolArgs.message, "message"); + if (name === "git_push") { + const laneId = requireLaneIdForTool(runtime, session, toolArgs, "git_push"); + const force = asBoolean(toolArgs.force, false); + const setUpstream = asBoolean(toolArgs.setUpstream, true); + const action = await runtime.gitService.push({ laneId, force, setUpstream }); + return { laneId, action }; + } + + if (name === "git_list_branches") { + const laneId = requireLaneIdForTool(runtime, session, toolArgs, "git_list_branches"); + const branches = await runtime.gitService.listBranches({ laneId }); + return { laneId, branches }; + } + + if (name === "git_checkout_branch") { + const laneId = requireLaneIdForTool(runtime, session, toolArgs, "git_checkout_branch"); + const branchName = assertNonEmptyString(toolArgs.branchName, "branchName"); + const action = await runtime.gitService.checkoutBranch({ laneId, branchName }); + return { laneId, branchName, action }; + } + + if (name === "commit_changes") { + const laneId = requireLaneIdForTool(runtime, session, toolArgs, "commit_changes"); const amend = asBoolean(toolArgs.amend, false); const stageAll = asBoolean(toolArgs.stageAll, true); @@ -4824,12 +5350,35 @@ async function runTool(args: { await runtime.gitService.stageAll({ laneId, paths: [] }); } + const explicitMessage = asOptionalTrimmedString(toolArgs.message); + const generated = explicitMessage + ? null + : await runtime.gitService.generateCommitMessage({ laneId, amend }); + const message = explicitMessage ?? generated?.message ?? ""; + if (!message.trim().length) { + throw new JsonRpcError(JsonRpcErrorCode.toolFailed, "Commit message is empty after generation."); + } + const action = await runtime.gitService.commit({ laneId, message, amend }); const latest = await runtime.gitService.listRecentCommits({ laneId, limit: 1 }); return { action, - commit: latest[0] ?? null + commit: latest[0] ?? null, + message, + messageSource: explicitMessage ? "provided" : "generated", + ...(generated?.model ? { generatedByModel: generated.model } : {}) + }; + } + + if (name === "generate_commit_message") { + const laneId = requireLaneIdForTool(runtime, session, toolArgs, "generate_commit_message"); + const amend = asBoolean(toolArgs.amend, false); + const result = await runtime.gitService.generateCommitMessage({ laneId, amend }); + return { + laneId, + amend, + ...result, }; } @@ -4978,6 +5527,43 @@ async function runTool(args: { return result; } + if (name === "create_pr_from_lane") { + const laneId = assertNonEmptyString(toolArgs.laneId, "laneId"); + const baseBranch = assertNonEmptyString(toolArgs.baseBranch, "baseBranch"); + const title = assertNonEmptyString(toolArgs.title, "title"); + const body = asOptionalTrimmedString(toolArgs.body); + const draft = asBoolean(toolArgs.draft, false); + const pr = await requirePrService(runtime).createFromLane({ + laneId, + baseBranch, + title, + ...(body ? { body } : {}), + draft, + }); + return { pr }; + } + + if (name === "pr_update_title") { + const prId = assertNonEmptyString(toolArgs.prId, "prId"); + const title = assertNonEmptyString(toolArgs.title, "title"); + await requirePrService(runtime).updateTitle({ prId, title }); + return { success: true, prId, title }; + } + + if (name === "pr_update_body") { + const prId = assertNonEmptyString(toolArgs.prId, "prId"); + const body = typeof toolArgs.body === "string" ? toolArgs.body : ""; + await requirePrService(runtime).updateBody({ prId, body }); + return { success: true, prId }; + } + + if (name === "pr_add_comment") { + const prId = assertNonEmptyString(toolArgs.prId, "prId"); + const body = assertNonEmptyString(toolArgs.body, "body"); + const comment = await requirePrService(runtime).addComment({ prId, body }); + return { success: true, comment }; + } + if (name === "get_pr_health") { const prId = assertNonEmptyString(toolArgs.prId, "prId"); const prSvc = requirePrService(runtime); From 6b38b229771d4bd4f0bef0cd5292aaca1d08c003 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:21:17 -0400 Subject: [PATCH 2/6] Finalize MCP removal / ade CLI cutover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Polish Mintlify docs around the new `ade` CLI + JSON-RPC surface (ai-tools, chat, key-concepts, missions/budget, context-packs, cto/workers) and drop references to the retired External MCP UI. - Clean up residual scaffolding left behind by the MCP→ade-cli rewrite: drop the unused `normalizeStartupCommand` helper in `ptyService.ts` and fix a pre-existing `vi.fn` tuple-typing error in `workerAdapterRuntimeService.test.ts`. - Tidy small indent/whitespace drift in `CtoPage.tsx` and `WorkerPermissionsEditor.tsx` left by the External MCP UI deletion. Co-Authored-By: Claude Opus 4.7 (1M context) --- .ade/.gitignore | 2 +- .ade/cto/identity.yaml | 4 - .github/workflows/ci.yml | 52 +- .gitignore | 2 +- AGENTS.md | 12 +- CHANGELOG.md | 10 +- README.md | 21 +- ai-tools/claude-code.mdx | 20 +- ai-tools/cursor.mdx | 4 +- ai-tools/windsurf.mdx | 2 +- apps/ade-cli/README.md | 115 + .../{mcp-server => ade-cli}/package-lock.json | 10 +- apps/{mcp-server => ade-cli}/package.json | 19 +- apps/ade-cli/scripts/verify-built-cli.mjs | 37 + .../src/adeRpcServer.test.ts} | 420 ++- .../src/adeRpcServer.ts} | 477 +-- .../src/bootstrap.test.ts | 0 apps/{mcp-server => ade-cli}/src/bootstrap.ts | 66 +- apps/ade-cli/src/cli.test.ts | 251 ++ apps/ade-cli/src/cli.ts | 2622 +++++++++++++++++ .../src/headlessLinearServices.test.ts | 1 - .../src/headlessLinearServices.ts | 16 +- apps/{mcp-server => ade-cli}/src/jsonrpc.ts | 7 +- .../{mcp-server => ade-cli}/src/test/setup.ts | 2 +- .../src/types/node-sqlite.d.ts | 0 apps/{mcp-server => ade-cli}/tsconfig.json | 0 apps/{mcp-server => ade-cli}/tsup.config.ts | 2 +- apps/{mcp-server => ade-cli}/vitest.config.ts | 0 apps/desktop/package-lock.json | 847 +----- apps/desktop/package.json | 30 +- apps/desktop/scripts/ade-cli-install-path.sh | 28 + apps/desktop/scripts/ade-cli-macos-wrapper.sh | 50 + .../scripts/after-pack-runtime-fixes.cjs | 15 +- .../scripts/extractKvDbBootstrapSql.mjs | 2 +- .../scripts/validate-mac-artifacts.mjs | 41 +- apps/desktop/src/main/adeMcpProxy.ts | 196 -- .../desktop/src/main/adeMcpProxyUtils.test.ts | 449 --- apps/desktop/src/main/adeMcpProxyUtils.ts | 172 -- apps/desktop/src/main/main.ts | 157 +- apps/desktop/src/main/packagedRuntimeSmoke.ts | 153 - .../main/services/ai/aiIntegrationService.ts | 7 +- .../src/main/services/ai/apiKeyStore.ts | 5 +- .../services/ai/claudeRuntimeProbe.test.ts | 53 +- .../main/services/ai/claudeRuntimeProbe.ts | 19 - .../src/main/services/ai/cliMcpConfig.ts | 35 - .../services/ai/codexAppServerConfig.test.ts | 52 - .../main/services/ai/codexAppServerConfig.ts | 91 - .../services/ai/tools/systemPrompt.test.ts | 11 +- .../main/services/ai/tools/systemPrompt.ts | 38 +- .../services/automations/automationService.ts | 50 +- .../services/chat/agentChatService.test.ts | 212 +- .../main/services/chat/agentChatService.ts | 754 +---- .../chat/buildComputerUseDirective.test.ts | 4 +- .../chat/cursorAcpEventMapper.test.ts | 20 +- .../services/chat/cursorAcpEventMapper.ts | 3 +- .../src/main/services/chat/cursorAcpMcp.ts | 26 - .../src/main/services/chat/cursorAcpPool.ts | 4 - .../computerUseArtifactBrokerService.ts | 49 +- .../services/computerUse/controlPlane.test.ts | 71 +- .../main/services/computerUse/controlPlane.ts | 123 +- .../computerUse/localComputerUse.test.ts | 6 +- .../services/computerUse/localComputerUse.ts | 12 +- .../computerUse/proofObserver.test.ts | 10 +- .../services/computerUse/proofObserver.ts | 25 +- .../computerUse/syntheticToolResult.test.ts | 6 +- .../services/config/projectConfigService.ts | 19 - .../context/contextDocBuilder.test.ts | 8 +- .../services/context/contextDocBuilder.ts | 6 +- .../main/services/cto/ctoStateService.test.ts | 4 +- .../src/main/services/cto/ctoStateService.ts | 38 +- .../services/cto/linearCloseoutService.ts | 1 + .../cto/linearDispatcherService.test.ts | 3 +- .../cto/workerAdapterRuntimeService.test.ts | 4 +- .../cto/workerAdapterRuntimeService.ts | 6 +- .../main/services/cto/workerAgentService.ts | 30 +- .../services/cto/workerHeartbeatService.ts | 2 +- .../externalConnectionAuthService.ts | 795 ----- .../externalMcp/externalMcpService.ts | 1257 -------- .../main/services/files/fileWatcherService.ts | 4 +- .../src/main/services/ipc/registerIpc.ts | 138 +- .../missions/missionPreflightService.test.ts | 2 +- .../services/opencode/openCodeRuntime.test.ts | 518 +--- .../main/services/opencode/openCodeRuntime.ts | 415 +-- .../opencode/openCodeServerManager.test.ts | 2 +- .../aiOrchestratorService.test.ts | 2 +- .../orchestrator/aiOrchestratorService.ts | 10 +- .../baseOrchestratorAdapter.test.ts | 2 +- .../orchestrator/baseOrchestratorAdapter.ts | 6 +- .../orchestrator/chatMessageService.ts | 1 - .../orchestrator/coordinatorAgent.test.ts | 14 +- .../services/orchestrator/coordinatorAgent.ts | 14 +- .../services/orchestrator/coordinatorTools.ts | 21 +- .../orchestrator/delegationContracts.ts | 7 +- .../orchestrator/hardeningMissions.test.ts | 18 +- .../knowledgeConflictsBrowserCto.test.ts | 3 +- .../orchestrator/missionBudgetService.ts | 68 +- .../orchestrator/orchestratorConstants.ts | 2 +- .../orchestrator/orchestratorQueries.ts | 7 +- .../orchestrator/orchestratorService.ts | 10 +- .../orchestrator/planningGapsFixes.test.ts | 29 +- .../services/orchestrator/promptInspector.ts | 12 +- .../providerOrchestratorAdapter.ts | 234 +- .../orchestrator/teamRuntimeConfig.ts | 11 +- .../services/projects/adeProjectService.ts | 5 - .../services/projects/configReloadService.ts | 11 - .../main/services/prs/prIssueResolver.test.ts | 21 +- .../src/main/services/prs/prIssueResolver.ts | 53 +- .../src/main/services/pty/ptyService.test.ts | 37 +- .../src/main/services/pty/ptyService.ts | 89 +- .../services/runtime/adeMcpLaunch.test.ts | 368 --- .../src/main/services/runtime/adeMcpLaunch.ts | 227 -- apps/desktop/src/main/services/state/kvDb.ts | 172 +- .../services/usage/usageTrackingService.ts | 6 +- apps/desktop/src/preload/global.d.ts | 49 - apps/desktop/src/preload/preload.ts | 72 - apps/desktop/src/renderer/browserMock.ts | 63 +- .../components/app/CommandPalette.tsx | 2 +- .../components/RuleEditorPanel.tsx | 1 - .../components/chat/AgentChatMessageList.tsx | 5 - .../chat/AgentChatPane.submit.test.tsx | 3 - .../components/chat/AgentChatPane.tsx | 15 +- .../components/chat/chatNavigation.test.ts | 32 - .../components/chat/chatNavigation.ts | 5 - .../chat/chatToolAppearance.test.ts | 4 +- .../chat/chatTranscriptRows.test.ts | 8 +- .../components/chat/toolPresentation.test.ts | 22 +- .../components/chat/toolPresentation.ts | 12 +- .../components/chat/useChatMcpSummary.test.ts | 129 - .../components/chat/useChatMcpSummary.ts | 69 - .../src/renderer/components/cto/CtoPage.tsx | 38 +- .../components/cto/CtoSettingsPanel.test.tsx | 17 +- .../components/cto/CtoSettingsPanel.tsx | 48 +- .../src/renderer/components/cto/TeamPanel.tsx | 27 - .../components/cto/WorkerActivityFeed.tsx | 2 +- .../components/history/eventTaxonomy.ts | 2 +- .../components/missions/ChatMessageArea.tsx | 29 - .../missions/CreateMissionDialog.tsx | 1 - .../components/missions/MissionChatV2.tsx | 5 - .../components/missions/MissionHeader.test.ts | 4 - .../missions/OrchestratorActivityFeed.test.ts | 4 +- .../missions/WorkerPermissionsEditor.tsx | 202 +- .../missions/missionFeedPresentation.test.ts | 8 +- .../components/missions/missionHelpers.ts | 11 +- .../missions/missionUxUtils.test.ts | 4 +- .../components/missions/useMissionsStore.ts | 9 - .../settings/ComputerUseSection.tsx | 12 +- .../DiagnosticsDashboardSection.test.tsx | 32 - .../settings/DiagnosticsDashboardSection.tsx | 38 +- .../settings/ExternalMcpSection.tsx | 1387 --------- .../IntegrationsSettingsSection.test.tsx | 13 +- .../settings/IntegrationsSettingsSection.tsx | 6 +- .../shared/ExternalMcpAccessEditor.tsx | 130 - apps/desktop/src/shared/adeLayout.ts | 14 +- apps/desktop/src/shared/ipc.ts | 15 - apps/desktop/src/shared/modelRegistry.ts | 2 +- apps/desktop/src/shared/types/agents.ts | 5 +- .../src/shared/types/computerUseArtifacts.ts | 7 +- apps/desktop/src/shared/types/config.ts | 18 +- apps/desktop/src/shared/types/cto.ts | 5 +- apps/desktop/src/shared/types/externalMcp.ts | 202 -- apps/desktop/src/shared/types/index.ts | 1 - apps/desktop/src/shared/types/missions.ts | 2 - apps/desktop/src/shared/types/orchestrator.ts | 2 - apps/desktop/src/shared/workerRuntimeNoise.ts | 3 - apps/desktop/src/types/opencode-ai-sdk.d.ts | 1 - apps/desktop/tsup.config.ts | 1 - apps/ios/ADE/Resources/DatabaseBootstrap.sql | 37 +- apps/ios/ADE/Services/Database.swift | 2 - .../Work/WorkStatusAndFormattingHelpers.swift | 3 - apps/mcp-server/README.md | 70 - apps/mcp-server/src/index.ts | 290 -- apps/mcp-server/src/transport.test.ts | 147 - apps/mcp-server/src/transport.ts | 41 - apps/web/src/app/pages/HomePage.tsx | 4 +- automations/templates.mdx | 2 +- changelog/v1.0.1.mdx | 4 +- changelog/v1.0.10.mdx | 2 +- changelog/v1.0.11.mdx | 8 +- changelog/v1.0.12.mdx | 8 +- changelog/v1.0.13.mdx | 4 +- changelog/v1.0.14.mdx | 4 +- changelog/v1.0.15.mdx | 4 +- changelog/v1.0.16.mdx | 14 +- changelog/v1.0.6.mdx | 8 +- chat/capabilities.mdx | 8 +- chat/context.mdx | 29 +- chat/overview.mdx | 4 +- computer-use/overview.mdx | 23 +- computer-use/setup.mdx | 25 +- configuration/ai-providers.mdx | 8 +- configuration/mcp-servers.mdx | 405 --- configuration/overview.mdx | 27 +- configuration/permissions.mdx | 43 +- configuration/settings.mdx | 20 +- context-packs/freshness.mdx | 2 +- context-packs/structure.mdx | 26 +- cto/overview.mdx | 4 +- cto/workers.mdx | 12 +- docs.json | 4 +- docs/ARCHITECTURE.md | 72 +- docs/PRD.md | 2 +- docs/features/agents/README.md | 29 +- docs/features/agents/identity-and-personas.md | 10 +- docs/features/agents/tool-registration.md | 275 +- docs/features/automations/README.md | 4 +- docs/features/automations/guardrails.md | 2 +- .../automations/triggers-and-actions.md | 20 +- docs/features/chat/README.md | 8 +- docs/features/chat/agent-routing.md | 4 +- docs/features/chat/composer-and-ui.md | 2 +- docs/features/chat/tool-system.md | 25 +- docs/features/computer-use/README.md | 12 +- docs/features/computer-use/artifact-broker.md | 2 +- docs/features/computer-use/backends.md | 19 +- .../computer-use/settings-and-readiness.md | 21 +- docs/features/cto/README.md | 14 +- docs/features/cto/identity-and-memory.md | 2 +- docs/features/cto/linear-integration.md | 8 +- docs/features/cto/pipeline-builder.md | 2 +- docs/features/cto/workers.md | 6 +- docs/features/files-and-editor/README.md | 2 +- .../file-watcher-and-trust.md | 4 +- docs/features/linear-integration/README.md | 16 +- .../linear-integration/dispatch-and-sync.md | 14 +- docs/features/memory/compaction.md | 2 +- docs/features/missions/README.md | 4 +- docs/features/missions/orchestration.md | 2 +- docs/features/missions/workers.md | 12 +- .../onboarding-and-settings/README.md | 2 +- .../configuration-schema.md | 1 - docs/features/pull-requests/README.md | 4 +- docs/features/sync-and-multi-device/README.md | 2 +- .../sync-and-multi-device/remote-commands.md | 6 +- .../pty-and-processes.md | 13 +- getting-started/first-agent.mdx | 2 +- getting-started/install.mdx | 2 +- getting-started/project-setup.mdx | 2 +- guides/automation-rules.mdx | 2 +- guides/first-mission.mdx | 2 +- guides/pr-convergence.mdx | 2 +- introduction.mdx | 2 +- key-concepts.mdx | 16 +- lanes/packs.mdx | 2 +- missions/budget.mdx | 4 +- package.json | 4 +- plans/missions-stabilization-review-scope.md | 2 +- plans/p0-fixes-consolidated.md | 2 +- plans/validation-contract-m3-m4.md | 2 +- quickstart.mdx | 2 +- scripts/dogfood.sh | 6 +- 250 files changed, 4738 insertions(+), 12741 deletions(-) create mode 100644 apps/ade-cli/README.md rename apps/{mcp-server => ade-cli}/package-lock.json (99%) rename apps/{mcp-server => ade-cli}/package.json (52%) create mode 100644 apps/ade-cli/scripts/verify-built-cli.mjs rename apps/{mcp-server/src/mcpServer.test.ts => ade-cli/src/adeRpcServer.test.ts} (90%) rename apps/{mcp-server/src/mcpServer.ts => ade-cli/src/adeRpcServer.ts} (95%) rename apps/{mcp-server => ade-cli}/src/bootstrap.test.ts (100%) rename apps/{mcp-server => ade-cli}/src/bootstrap.ts (88%) create mode 100644 apps/ade-cli/src/cli.test.ts create mode 100644 apps/ade-cli/src/cli.ts rename apps/{mcp-server => ade-cli}/src/headlessLinearServices.test.ts (99%) rename apps/{mcp-server => ade-cli}/src/headlessLinearServices.ts (98%) rename apps/{mcp-server => ade-cli}/src/jsonrpc.ts (98%) rename apps/{mcp-server => ade-cli}/src/test/setup.ts (97%) rename apps/{mcp-server => ade-cli}/src/types/node-sqlite.d.ts (100%) rename apps/{mcp-server => ade-cli}/tsconfig.json (100%) rename apps/{mcp-server => ade-cli}/tsup.config.ts (91%) rename apps/{mcp-server => ade-cli}/vitest.config.ts (100%) create mode 100755 apps/desktop/scripts/ade-cli-install-path.sh create mode 100755 apps/desktop/scripts/ade-cli-macos-wrapper.sh delete mode 100644 apps/desktop/src/main/adeMcpProxy.ts delete mode 100644 apps/desktop/src/main/adeMcpProxyUtils.test.ts delete mode 100644 apps/desktop/src/main/adeMcpProxyUtils.ts delete mode 100644 apps/desktop/src/main/services/ai/cliMcpConfig.ts delete mode 100644 apps/desktop/src/main/services/ai/codexAppServerConfig.test.ts delete mode 100644 apps/desktop/src/main/services/ai/codexAppServerConfig.ts delete mode 100644 apps/desktop/src/main/services/chat/cursorAcpMcp.ts delete mode 100644 apps/desktop/src/main/services/externalMcp/externalConnectionAuthService.ts delete mode 100644 apps/desktop/src/main/services/externalMcp/externalMcpService.ts delete mode 100644 apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts delete mode 100644 apps/desktop/src/main/services/runtime/adeMcpLaunch.ts delete mode 100644 apps/desktop/src/renderer/components/chat/chatNavigation.test.ts delete mode 100644 apps/desktop/src/renderer/components/chat/chatNavigation.ts delete mode 100644 apps/desktop/src/renderer/components/chat/useChatMcpSummary.test.ts delete mode 100644 apps/desktop/src/renderer/components/chat/useChatMcpSummary.ts delete mode 100644 apps/desktop/src/renderer/components/settings/ExternalMcpSection.tsx delete mode 100644 apps/desktop/src/renderer/components/shared/ExternalMcpAccessEditor.tsx delete mode 100644 apps/desktop/src/shared/types/externalMcp.ts delete mode 100644 apps/mcp-server/README.md delete mode 100644 apps/mcp-server/src/index.ts delete mode 100644 apps/mcp-server/src/transport.test.ts delete mode 100644 apps/mcp-server/src/transport.ts delete mode 100644 configuration/mcp-servers.mdx diff --git a/.ade/.gitignore b/.ade/.gitignore index 01224321c..d93703639 100644 --- a/.ade/.gitignore +++ b/.ade/.gitignore @@ -5,7 +5,7 @@ ade.db ade.db-* ade.db-wal embeddings.db -mcp.sock +ade.sock artifacts/ transcripts/ cache/ diff --git a/.ade/cto/identity.yaml b/.ade/cto/identity.yaml index ad9417718..a6ca64553 100644 --- a/.ade/cto/identity.yaml +++ b/.ade/cto/identity.yaml @@ -11,10 +11,6 @@ memoryPolicy: compactionThreshold: 0.7 preCompactionFlush: true temporalDecayHalfLifeDays: 30 -externalMcpAccess: - allowAll: true - allowedServers: [] - blockedServers: [] openclawContextPolicy: shareMode: filtered blockedCategories: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98033fbd9..e95b0ba74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,15 +25,15 @@ jobs: with: path: | apps/desktop/node_modules - apps/mcp-server/node_modules + apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/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' run: | cd apps/desktop && npm ci & - cd apps/mcp-server && npm ci & + cd apps/ade-cli && npm ci & cd apps/web && npm ci & wait @@ -61,12 +61,12 @@ jobs: with: path: | apps/desktop/node_modules - apps/mcp-server/node_modules + apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/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-mcp: + typecheck-ade-cli: needs: install runs-on: ubuntu-latest steps: @@ -78,10 +78,10 @@ jobs: with: path: | apps/desktop/node_modules - apps/mcp-server/node_modules + apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }} - - run: cd apps/mcp-server && npm run typecheck + 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: needs: install @@ -95,9 +95,9 @@ jobs: with: path: | apps/desktop/node_modules - apps/mcp-server/node_modules + apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/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 lint-desktop: @@ -112,9 +112,9 @@ jobs: with: path: | apps/desktop/node_modules - apps/mcp-server/node_modules + apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/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: @@ -133,12 +133,12 @@ jobs: with: path: | apps/desktop/node_modules - apps/mcp-server/node_modules + apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/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-mcp: + test-ade-cli: needs: install runs-on: ubuntu-latest steps: @@ -150,10 +150,10 @@ jobs: with: path: | apps/desktop/node_modules - apps/mcp-server/node_modules + apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }} - - run: cd apps/mcp-server && npm test + 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 build: needs: install @@ -167,11 +167,11 @@ jobs: with: path: | apps/desktop/node_modules - apps/mcp-server/node_modules + apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/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 build - - run: cd apps/mcp-server && npm run build + - run: cd apps/ade-cli && npm run build - run: cd apps/web && npm run build validate-docs: @@ -186,9 +186,9 @@ jobs: with: path: | apps/desktop/node_modules - apps/mcp-server/node_modules + apps/ade-cli/node_modules apps/web/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/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 # ── Gate: all jobs must pass ────────────────────────────────────────── @@ -197,11 +197,11 @@ jobs: needs: - secret-scan - typecheck-desktop - - typecheck-mcp + - typecheck-ade-cli - typecheck-web - lint-desktop - test-desktop - - test-mcp + - test-ade-cli - build - validate-docs runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 02736cd7e..d6d277e49 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,7 @@ __pycache__/ *.db # Build outputs -/apps/mcp-server/dist/ +/apps/ade-cli/dist/ /apps/desktop/release/ /apps/desktop/dist/ /apps/desktop/vendor/crsqlite/darwin-x64/ diff --git a/AGENTS.md b/AGENTS.md index 82227f5e0..3920706ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ - ADE is a local-first desktop application for orchestrating coding agents, missions, lanes, PR workflows, and proof/artifact capture. - The main product lives in `apps/desktop` and is built with Electron, React, and TypeScript. -- The ADE MCP server lives in `apps/mcp-server` and shares core services with the desktop app. +- The ADE CLI lives in `apps/ade-cli` and shares core services with the desktop app. - State is primarily stored under `.ade/` inside the active project, with runtime metadata in SQLite and machine-local files under `.ade/secrets`, `.ade/cache`, and `.ade/artifacts`. ## Working norms @@ -12,7 +12,7 @@ - Preserve existing desktop app patterns before introducing new abstractions. - Prefer fixing the underlying service or shared type rather than layering renderer-only workarounds on top. - Keep IPC contracts, preload types, shared types, and renderer usage in sync whenever an interface changes. -- For ADE MCP changes, verify both headless MCP mode and the desktop socket-backed MCP path. +- For ADE CLI changes, verify both headless mode and the desktop socket-backed ADE RPC path. - For computer-use changes, treat policy enforcement and artifact ownership as hard requirements, not prompt guidance. ## Validation @@ -22,10 +22,10 @@ - `npm --prefix apps/desktop run test` - `npm --prefix apps/desktop run build` - `npm --prefix apps/desktop run lint` -- MCP checks: - - `npm --prefix apps/mcp-server run typecheck` - - `npm --prefix apps/mcp-server run test` - - `npm --prefix apps/mcp-server run build` +- ADE CLI checks: + - `npm --prefix apps/ade-cli run typecheck` + - `npm --prefix apps/ade-cli run test` + - `npm --prefix apps/ade-cli run build` - Run the smallest relevant subset first when iterating, then finish with the broader checks that cover the touched surfaces. ## Terminology diff --git a/CHANGELOG.md b/CHANGELOG.md index e930463bd..291149446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **OpenAI responses-based verification** — `authDetector` now verifies OpenAI keys via `POST /v1/responses` with `gpt-5-nano`/`gpt-5-mini` fallback, distinguishing model-availability errors from invalid keys; secondary model-list fallback check for resilience -- **Scoped OpenCode tool selection** — `buildFallbackToolSelection`, `buildScopedMcpToolSelection`, and `resolveScopedMcpToolSelection` pipeline ensures sessions get exactly the tools matching current MCP server config; `refreshOpenCodeSessionToolSelection` called before every prompt +- **Scoped OpenCode tool selection** — `buildFallbackToolSelection`, `buildScopedMcpToolSelection`, and `resolveScopedMcpToolSelection` pipeline ensures sessions get exactly the tools matching current ADE CLI config; `refreshOpenCodeSessionToolSelection` called before every prompt - **Isolated OpenCode server launches** — `openCodeServerManager` builds XDG-style isolated launch specs via `child_process.spawn`, sanitizes inherited `OPENCODE_*` env, and resolves binary via `resolveOpenCodeBinaryPath`; new test hooks for launch spec verification -- **MCP schema sanitization** — `sanitizeToolSchema` recursively normalizes tool JSON Schemas (adds missing `items`/`properties`, ensures `required` completeness, recurses through `anyOf`/`oneOf`/`allOf`) to prevent strict-provider rejections +- **ADE CLI schema sanitization** — `sanitizeToolSchema` recursively normalizes tool JSON Schemas (adds missing `items`/`properties`, ensures `required` completeness, recurses through `anyOf`/`oneOf`/`allOf`) to prevent strict-provider rejections - **Provider verification badges** — ProvidersSection shows verification badges, failure state indicators, and auto-refreshes OpenCode inventory on key changes ### Changed @@ -38,7 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Provider task runner** — unified 306-line task runner dispatching to Claude, Codex, Cursor, and OpenCode paths with timeout enforcement and permission mode mapping (`providerTaskRunner.ts`) - **Tool exposure policy** — score-based heuristics for frontend repo discovery tool exposure based on prompt content analysis (`toolExposurePolicy.ts`) - **Runtime message types** — provider-agnostic message representation decoupling ADE internals from SDK types (`runtimeMessageTypes.ts`) -- **CLI MCP config normalization** — normalizes MCP server config between Claude (`type`) and Codex (`transport`) field formats (`cliMcpConfig.ts`) +- **CLI ADE CLI config normalization** — normalizes ADE CLI config between Claude (`type`) and Codex (`transport`) field formats (`cliMcpConfig.ts`) - **Local provider discovery pipeline** — endpoint probing, model inspection, capability inference, harness profile assignment, and TTL-based caching for Ollama and LM Studio - **900+ lines of new tool tests** — `globSearch.test.ts` (219 lines), `grepSearch.test.ts` (276 lines), `readFileRange.test.ts` (197 lines), expanded `universalTools.test.ts` (+364 lines) - **Compaction engine tests** — 526-line test suite covering transcript persistence, compaction triggering, and token accounting @@ -97,7 +97,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Multimodal chat** — Claude agent chat now supports image attachments via base64 content blocks, with a new file upload picker and clipboard paste support in the composer - **CTO daily logs** — CTO persona gains a Memory Protocol and Decision Framework; daily log utilities (append/read/list) auto-inject recent context into CTO sessions -- **External MCP auth service** — full OAuth and token-based authentication flows for connecting external MCP servers (795-line service with PKCE support) +- **ADE CLI auth service** — full OAuth and token-based authentication flows for connecting ADE CLI (795-line service with PKCE support) - **Onboarding rewrite** — replaced the 1,373-line `OnboardingPage` with a focused 328-line `ProjectSetupPage` - **New settings sections** — Lane Behavior, Lane Templates (expanded), Integrations, Workspace Settings, and AI Settings panels - **Context doc preferences** — model, effort, and events settings now persist to the backend via new IPC handlers @@ -114,7 +114,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Linear OAuth** — bundled public PKCE client-ID fallback so CTO Linear sync works without manual credential setup; fixed OAuth server to listen on port 19836 and force `prompt=consent` - **Orchestrator tuning** — lowered dedupe/retention/pruning thresholds, capped `workerProgressChatState`, pruned config cache, reduced session signal retention and health sweep intervals - **Main process** — simplified hardware acceleration logic, narrowed default background task flags in dev stability mode -- **CI** — tightened permissions, removed old inline release job, added MCP server dependency install before desktop typecheck +- **CI** — tightened permissions, removed old inline release job, added ADE CLI dependency install before desktop typecheck - **Homepage & web** — refreshed landing page layout and styles, updated Tailwind config ### Removed diff --git a/README.md b/README.md index 977508dd3..4e70c4f09 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Electron React TypeScript - MCP built in + ADE CLI built in

@@ -48,16 +48,29 @@ ADE is built for people who want agents to operate inside a real development wor - **Automations** -- Event-driven background execution with triggers and guardrails - **PR workflows** -- Stacking, conflict simulation, and queue landing - **Context packs** -- Structured, bounded context delivery for agents -- **ADE MCP server** -- Shared services for both desktop and headless MCP flows +- **ADE CLI** -- Agent-focused command-line access to ADE actions in desktop-backed and headless modes - **Memory system** -- Persistent knowledge across sessions with semantic search +- **Runtime model support** -- Claude, Codex, OpenAI-compatible providers, and local models - **Linear integration** -- Workflow automation triggered by Linear issues - **Multi-repo aware workflows** -- Project-local state under `.ade/` plus machine-local secrets, cache, and artifacts ## Stack - **Desktop** -- Electron, React, TypeScript, SQLite, node-pty -- **Protocols** -- Model Context Protocol (MCP), IPC, GitHub and Linear integrations -- **Runtime model support** -- Claude, Codex, OpenAI-compatible providers, and local models +- **Protocols** -- ADE CLI, IPC, GitHub and Linear integrations + +## ADE CLI + +ADE ships a real `ade` command for agents and local automation. The standalone package lives in `apps/ade-cli` and exposes `bin.ade`; after `cd apps/ade-cli && npm run build && npm link`, `ade doctor`, `ade lanes list`, and the rest of the command surface are available on `PATH`. + +The macOS desktop app also bundles the same CLI at `/Applications/ADE.app/Contents/Resources/ade-cli/bin/ade`. Symlink that wrapper into a `PATH` directory to use `ade xyz` without installing Node separately: + +```bash +/Applications/ADE.app/Contents/Resources/ade-cli/install-path.sh +ade doctor --project-root /path/to/project +``` + +Agents should use `ade doctor --json` for readiness, `ade actions list --text` for discovery, typed commands first, and `ade actions run ` as the escape hatch for any ADE service action that does not yet have a typed command. ## Install diff --git a/ai-tools/claude-code.mdx b/ai-tools/claude-code.mdx index c01786a2d..f32fc472f 100644 --- a/ai-tools/claude-code.mdx +++ b/ai-tools/claude-code.mdx @@ -1,6 +1,6 @@ --- title: "Claude Code" -description: "Use Claude Code as a CLI coding agent inside ADE lanes — launch sessions, connect MCP tools, configure permissions, and track work across agent chat, missions, and CTO delegations." +description: "Use Claude Code as a CLI coding agent inside ADE lanes — launch sessions, connect ADE CLI tools, configure permissions, and track work across agent chat, missions, and CTO delegations." icon: "terminal" --- @@ -8,7 +8,7 @@ icon: "terminal" ADE integrates with [Claude Code](https://docs.anthropic.com/en/docs/claude-code), Anthropic's CLI agent for coding tasks. When Claude Code is installed on your machine, ADE can launch it as a managed session inside any lane. -Claude Code is one of ADE's deepest integrations — it serves as both a chat provider (for Agent Chat) and an execution backend (for missions, CTO workers, and automations). ADE manages the Claude Code process lifecycle, injects MCP tools, forwards environment variables, and tracks every session in the History view. +Claude Code is one of ADE's deepest integrations — it serves as both a chat provider (for Agent Chat) and an execution backend (for missions, CTO workers, and automations). ADE manages the Claude Code process lifecycle, exposes the `ade` CLI so the agent can reach ADE's lane, git, and PR tools, forwards environment variables, and tracks every session in the History view. --- @@ -25,13 +25,13 @@ ADE auto-detects the Claude Code executable from standard paths and PATH. If det ## How ADE Uses Claude Code -ADE launches Claude Code as a subprocess inside a lane's worktree. The session inherits the lane's environment variables, working directory, port assignments, and MCP tool configuration. +ADE launches Claude Code as a subprocess inside a lane's worktree. The session inherits the lane's environment variables, working directory, port assignments, and the `ade` CLI on `PATH` so the agent can call ADE tools. Claude Code sessions are used in several places: | Surface | How Claude Code is used | |---------|------------------------| -| **Agent Chat** | Select Claude as the provider in the model selector. ADE starts a Claude Code session with MCP tools bridged in. | +| **Agent Chat** | Select Claude as the provider in the model selector. ADE starts a Claude Code session with the `ade` CLI on `PATH`. | | **Missions** | Used as a worker executor (`claude_cli`) during mission execution phases. Each worker gets its own Claude Code process. | | **CTO Workers** | When the CTO delegates a task to an employee agent, it can use Claude Code as the execution backend. | | **Automations** | Automation rules with `agent-session` execution kind can use Claude Code as the underlying agent. | @@ -50,7 +50,7 @@ Claude Code sessions are used in several places: Click the **Claude Code** launcher. ADE starts a managed session with: - The lane's context pack injected as initial context - - MCP tools from ADE's server available to the agent + - The `ade` CLI on `PATH` so the agent can call ADE lane, git, PR, chat, and action tools - Environment variables from the lane's overlay applied - The working directory set to the lane's worktree path @@ -58,11 +58,11 @@ Claude Code sessions are used in several places: --- -## MCP Tool Bridging +## ADE CLI access -When ADE launches Claude Code, it bridges ADE's MCP tools into the Claude Code session. This means the Claude Code agent can use ADE-specific tools (file operations, git operations, PR management, lane management) alongside its built-in capabilities. +When ADE launches Claude Code, it ensures the local `ade` CLI is on `PATH` for the session. The agent calls ADE-specific workflows (file, git, PR, lane, chat, proof, tests, and the full action catalog) by shelling out to `ade `. -ADE runs a pre-session **MCP initialize probe** to verify that the MCP server is reachable before starting the Claude Code session. If the probe fails, ADE reports the error in the chat UI instead of starting a broken session. +Before the session starts, ADE runs a short **CLI readiness probe** that confirms `ade doctor` succeeds against the project. If the probe fails, ADE surfaces the error in the chat UI instead of starting a broken session. --- @@ -103,8 +103,8 @@ ADE shows Claude Code's connection status in **Settings > AI Providers**: Run `claude auth login` in a terminal outside ADE, then restart ADE or click **Refresh** in Settings > AI Providers. ADE caches the runtime probe result for 30 seconds, so the status updates shortly after re-authentication. - - Check that the MCP initialize probe passed (visible in the chat session's startup messages). If the probe failed, verify that ADE's MCP server is running — check **Settings > MCP** for server status. + + Check that the CLI readiness probe passed (visible in the chat session's startup messages). If the probe failed, verify the CLI is installed and on `PATH` — run `ade doctor --project-root ` in a terminal outside ADE, and consult `apps/ade-cli/README.md` for install instructions. The runtime probe has a 20-second timeout. If your network is slow or the Anthropic API is under load, the probe may fail. Check your internet connection and try again. If sessions consistently hang, check the developer logs for detailed error information. diff --git a/ai-tools/cursor.mdx b/ai-tools/cursor.mdx index 117eacfb7..8a402974f 100644 --- a/ai-tools/cursor.mdx +++ b/ai-tools/cursor.mdx @@ -43,7 +43,7 @@ When Cursor's agent executable is detected, ADE can use it as a chat provider th 1. ADE resolves the Cursor agent executable path (checks standard install locations and PATH) 2. An ACP session pool manages connections to the Cursor agent process -3. Chat messages are routed through the ACP protocol with MCP bridging +3. Chat messages are routed through the ACP protocol, and the Cursor agent has access to the `ade` CLI for ADE workflows 4. Model discovery queries Cursor for available models and capabilities ### Selecting Cursor in chat @@ -63,7 +63,7 @@ ADE queries Cursor for its available models at startup and caches the result. Th |----------|---------| | **Session lifecycle** | Managed by the ACP pool; sessions are reused when possible | | **Event mapping** | ACP events are mapped to ADE's chat event model for consistent UI | -| **MCP bridging** | ADE's MCP tools are available to Cursor agents through the ACP-MCP bridge | +| **ADE CLI access** | The `ade` CLI is on `PATH` so Cursor agents can call ADE lane, git, PR, and action tools | | **Interrupts** | Supported via ACP session cancel | --- diff --git a/ai-tools/windsurf.mdx b/ai-tools/windsurf.mdx index 5d1543229..588cc828b 100644 --- a/ai-tools/windsurf.mdx +++ b/ai-tools/windsurf.mdx @@ -82,7 +82,7 @@ ADE supports multiple external AI tools. Here is how Windsurf compares: | **Launch from ADE** | Yes | Yes | Yes | | **Session tracking** | Yes | Yes | Yes | | **Agent provider in ADE chat** | No | Yes (via ACP) | Yes (via CLI) | -| **MCP tool bridging** | No | Yes | Yes | +| **ADE CLI access** | No | Yes | Yes | | **Model routing through ADE** | No | Yes | Yes | Windsurf is a good choice when you want to use Codeium's AI features directly in the editor while keeping the session tracked in ADE. For deeper integration where ADE manages the AI agent lifecycle, use Cursor (via ACP) or Claude Code (via CLI). diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md new file mode 100644 index 000000000..4eab38410 --- /dev/null +++ b/apps/ade-cli/README.md @@ -0,0 +1,115 @@ +# ADE CLI + +`apps/ade-cli` owns the `ade` command-line entry point for agents and local automation. + +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. + +## Scripts + +```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 +``` + +## Install and PATH + +For local development, build the package and link its `ade` binary: + +```bash +cd apps/ade-cli +npm run build +npm link +ade doctor --project-root /absolute/path/to/repo +``` + +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. + +```bash +cd apps/ade-cli +npm pack +npm install -g ./ade-cli-*.tgz +``` + +The desktop macOS build also bundles the CLI at: + +```bash +/Applications/ADE.app/Contents/Resources/ade-cli/bin/ade +``` + +To make the desktop-bundled command available as `ade`, add a symlink from a directory on `PATH`: + +```bash +/Applications/ADE.app/Contents/Resources/ade-cli/install-path.sh +``` + +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. + +## CLI surface + +```bash +ade auth status +ade doctor +ade lanes list --text +ade lanes create "fix-checkout-flow" --parent main +ade git commit --lane lane-id +ade git push --lane lane-id +ade prs create --lane lane-id --base main --title "Fix checkout flow" +ade prs path-to-merge --pr pr-id --model gpt-5.4 --max-rounds 3 --no-auto-merge +ade run defs --text +ade run start web --lane lane-id +ade shell start --lane lane-id -- npm test +ade chat create --lane lane-id --model gpt-5.4 +ade tests run --lane lane-id --suite unit --wait +ade proof list --arg ownerKind=chat --arg ownerId=session-id +ade actions list +ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts +``` + +Use typed commands first. They validate common arguments and provide stable JSON fields or readable text summaries. Use `ade actions list --text` to discover the full service-backed action catalog, and use `ade actions run ` only when there is no typed command for the workflow yet. + +Output modes are explicit: + +```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 +``` + +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: + +```bash +ade doctor --project-root /absolute/path/to/repo --socket --json +ade lanes list --project-root /absolute/path/to/repo --socket --text +``` + +Without `--socket`, the CLI auto-connects to the desktop socket when it is available and falls back to headless mode when it is not. + +## Auth and readiness + +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. + +`ade doctor` reports local-only readiness metadata by default: + +- 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. + +Default doctor/auth checks do not call provider, GitHub, or Linear networks. They report presence and local readiness only, without printing secret values. + +Agents should start unfamiliar ADE sessions with: + +```bash +ade doctor --json +ade actions list --text +``` + +Then prefer typed commands such as `ade lanes list --text`, `ade files read --text`, `ade prs checks --text`, or `ade tests runs --json`. Use `ade actions run ...` as the broad escape hatch for internal ADE actions that do not yet have a typed command. diff --git a/apps/mcp-server/package-lock.json b/apps/ade-cli/package-lock.json similarity index 99% rename from apps/mcp-server/package-lock.json rename to apps/ade-cli/package-lock.json index 3d82416b0..882c5a8d5 100644 --- a/apps/mcp-server/package-lock.json +++ b/apps/ade-cli/package-lock.json @@ -1,11 +1,11 @@ { - "name": "ade-mcp-server", + "name": "ade-cli", "version": "0.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "ade-mcp-server", + "name": "ade-cli", "version": "0.0.0", "dependencies": { "node-cron": "^3.0.3", @@ -13,12 +13,18 @@ "sql.js": "^1.13.0", "yaml": "^2.8.2" }, + "bin": { + "ade": "dist/cli.cjs" + }, "devDependencies": { "@types/node": "^20.11.30", "tsup": "^8.3.5", "tsx": "^4.20.6", "typescript": "^5.7.3", "vitest": "^0.34.6" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/apps/mcp-server/package.json b/apps/ade-cli/package.json similarity index 52% rename from apps/mcp-server/package.json rename to apps/ade-cli/package.json index 62186ff37..a41a9e2be 100644 --- a/apps/mcp-server/package.json +++ b/apps/ade-cli/package.json @@ -1,11 +1,22 @@ { - "name": "ade-mcp-server", - "private": true, + "name": "ade-cli", "version": "0.0.0", + "description": "Agent-focused command-line interface for ADE", "type": "module", + "bin": { + "ade": "dist/cli.cjs" + }, + "files": [ + "dist/**/*", + "README.md" + ], + "engines": { + "node": ">=22.0.0" + }, "scripts": { - "dev": "tsx src/index.ts", - "build": "tsup", + "cli:dev": "npm run build --silent && node dist/cli.cjs", + "dev": "tsx src/cli.ts", + "build": "tsup && node ./scripts/verify-built-cli.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run" }, diff --git a/apps/ade-cli/scripts/verify-built-cli.mjs b/apps/ade-cli/scripts/verify-built-cli.mjs new file mode 100644 index 000000000..8a4c84b7d --- /dev/null +++ b/apps/ade-cli/scripts/verify-built-cli.mjs @@ -0,0 +1,37 @@ +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 cliPath = path.join(packageRoot, "dist", "cli.cjs"); + +async function runHelp(command, args) { + const { stdout } = await execFileAsync(command, args, { + cwd: packageRoot, + env: process.env, + }); + if (!stdout.includes("Agent-focused command-line interface for ADE")) { + throw new Error(`[ade-cli:build] CLI help output did not include the ADE banner text for ${command}`); + } +} + +const contents = await fs.readFile(cliPath, "utf8"); +if (!contents.startsWith("#!/usr/bin/env node")) { + throw new Error("[ade-cli:build] dist/cli.cjs is missing the node shebang"); +} + +const stat = await fs.stat(cliPath); +if (process.platform !== "win32" && (stat.mode & 0o111) === 0) { + throw new Error("[ade-cli:build] dist/cli.cjs is not executable"); +} + +await runHelp(process.execPath, [cliPath, "--help"]); + +if (process.platform !== "win32") { + await runHelp(cliPath, ["--help"]); +} + +console.log("[ade-cli:build] verified dist/cli.cjs binary"); diff --git a/apps/mcp-server/src/mcpServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts similarity index 90% rename from apps/mcp-server/src/mcpServer.test.ts rename to apps/ade-cli/src/adeRpcServer.test.ts index fd18bed0a..aa8dde21c 100644 --- a/apps/mcp-server/src/mcpServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -2,14 +2,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createMcpRequestHandler, _resetGlobalAskUserRateLimit } from "./mcpServer"; +import { createAdeRpcRequestHandler, _resetGlobalAskUserRateLimit } from "./adeRpcServer"; type RuntimeFixture = ReturnType; function createRuntime() { const operationStart = vi.fn((args: any) => ({ operationId: `op-${args.kind}-${Date.now()}` })); const operationFinish = vi.fn(); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-mcp-test-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-test-")); fs.mkdirSync(path.join(projectRoot, ".ade", "orchestrator"), { recursive: true }); const teamMembers: Array> = []; const threadRows: Array> = []; @@ -572,7 +572,7 @@ function createRuntime() { endedAt: null, provider: "codex", modelId: "gpt-5.4-codex", - capabilityMode: "full_mcp", + capabilityMode: "full_tooling", createdAt: "2026-03-17T19:00:00.000Z", prevHash: null, })), @@ -661,15 +661,6 @@ function createRuntime() { simulateRoute: vi.fn(({ issue }: { issue: Record }) => ({ decision: "cto", reason: "test", issue })), } as any, processService: null, - externalMcpService: { - listToolsForIdentity: vi.fn(async () => []), - callTool: vi.fn(async () => ({ - ok: true, - result: { - content: [{ type: "text", text: "ok" }], - }, - })), - } as any, computerUseArtifactBrokerService: { getBackendStatus: vi.fn(() => ({ backends: [] })), listArtifacts: vi.fn(() => []), @@ -871,7 +862,7 @@ function createRuntime() { }; } -async function initialize(handler: ReturnType, identity?: Record) { +async function initialize(handler: ReturnType, identity?: Record) { const requestedRole = typeof identity?.role === "string" ? identity.role : null; const validRole = requestedRole === "cto" || requestedRole === "orchestrator" @@ -887,7 +878,7 @@ async function initialize(handler: ReturnType, i await handler({ jsonrpc: "2.0", id: 1, - method: "initialize", + method: "ade/initialize", params: identity ? { identity } : {} }); } finally { @@ -898,19 +889,35 @@ async function initialize(handler: ReturnType, i } async function callTool( - handler: ReturnType, + handler: ReturnType, name: string, argumentsPayload: Record ): Promise { - return await handler({ + const result = await handler({ jsonrpc: "2.0", id: 2, - method: "tools/call", + method: "ade/actions/call", params: { name, arguments: argumentsPayload } }); + if ( + result + && typeof result === "object" + && !Array.isArray(result) + && (result as { ok?: unknown }).ok === false + ) { + return { + isError: true, + structuredContent: result, + error: (result as { error?: unknown }).error, + }; + } + return { + structuredContent: result, + ...(result && typeof result === "object" && !Array.isArray(result) ? result : {}), + }; } async function withEnv(vars: Record, fn: () => Promise): Promise { @@ -937,17 +944,17 @@ async function withEnv(vars: Record, fn: () => Pr } } -describe("mcpServer", () => { +describe("adeRpcServer", () => { it("treats requested privileged roles as external without trusted env identity", async () => { const { runtime } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); const previousRole = process.env.ADE_DEFAULT_ROLE; delete process.env.ADE_DEFAULT_ROLE; try { await handler({ jsonrpc: "2.0", id: 1, - method: "initialize", + method: "ade/initialize", params: { identity: { callerId: "rogue-client", @@ -955,9 +962,9 @@ describe("mcpServer", () => { }, }, }); - const result = (await handler({ jsonrpc: "2.0", id: 3, method: "tools/list" })) as any; + const result = (await handler({ jsonrpc: "2.0", id: 3, method: "ade/actions/list" })) as any; - const names = (result.tools ?? []).map((tool: any) => tool.name); + const names = (result.actions ?? []).map((tool: any) => tool.name); expect(names).not.toContain("spawn_worker"); expect(names).not.toContain("read_mission_status"); expect(names).not.toContain("get_cto_state"); @@ -969,12 +976,12 @@ describe("mcpServer", () => { it("lists the full tool surface including coordinator orchestration tools for orchestrator callers", async () => { const { runtime } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); await initialize(handler, { callerId: "coord-1", role: "orchestrator" }); - const result = (await handler({ jsonrpc: "2.0", id: 3, method: "tools/list" })) as any; + const result = (await handler({ jsonrpc: "2.0", id: 3, method: "ade/actions/list" })) as any; - const names = (result.tools ?? []).map((tool: any) => tool.name); + const names = (result.actions ?? []).map((tool: any) => tool.name); expect(names).toEqual( expect.arrayContaining([ "spawn_agent", @@ -1040,7 +1047,7 @@ describe("mcpServer", () => { it("shows agent-safe delegation, reporting, and observation coordinator tools to agent callers", async () => { const { runtime } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); await initialize(handler, { callerId: "worker-1", @@ -1051,8 +1058,8 @@ describe("mcpServer", () => { attemptId: "attempt-1" }); - const result = (await handler({ jsonrpc: "2.0", id: 3, method: "tools/list" })) as any; - const names = (result.tools ?? []).map((tool: any) => tool.name); + const result = (await handler({ jsonrpc: "2.0", id: 3, method: "ade/actions/list" })) as any; + const names = (result.actions ?? []).map((tool: any) => tool.name); expect(names).toEqual( expect.arrayContaining([ @@ -1093,11 +1100,11 @@ describe("mcpServer", () => { it("hides ADE spawn and mission-worker tools from standalone chat callers", async () => { await withEnv({ ADE_DEFAULT_ROLE: "agent", ADE_CHAT_SESSION_ID: "chat-1" }, async () => { const { runtime } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); await initialize(handler, { callerId: "chat-1", role: "agent" }); - const result = (await handler({ jsonrpc: "2.0", id: 3, method: "tools/list" })) as any; - const names = (result.tools ?? []).map((tool: any) => tool.name); + const result = (await handler({ jsonrpc: "2.0", id: 3, method: "ade/actions/list" })) as any; + const names = (result.actions ?? []).map((tool: any) => tool.name); expect(names).toEqual( expect.arrayContaining([ @@ -1121,12 +1128,12 @@ describe("mcpServer", () => { it("lists CTO operator and Linear sync tools for cto callers", async () => { const { runtime } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); await initialize(handler, { callerId: "cto-1", role: "cto" }); - const result = (await handler({ jsonrpc: "2.0", id: 3, method: "tools/list" })) as any; + const result = (await handler({ jsonrpc: "2.0", id: 3, method: "ade/actions/list" })) as any; - const names = (result.tools ?? []).map((tool: any) => tool.name); + const names = (result.actions ?? []).map((tool: any) => tool.name); expect(names).toEqual( expect.arrayContaining([ "get_cto_state", @@ -1154,7 +1161,7 @@ describe("mcpServer", () => { it("creates a work chat for cto callers and returns a work navigation suggestion", async () => { const { runtime } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); await initialize(handler, { callerId: "cto-1", role: "cto" }); const result = await callTool(handler, "spawnChat", { @@ -1185,7 +1192,7 @@ describe("mcpServer", () => { it("returns the Linear sync dashboard for cto callers", async () => { const { runtime } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); await initialize(handler, { callerId: "cto-1", role: "cto" }); const result = await callTool(handler, "getLinearSyncDashboard", {}); @@ -1201,7 +1208,7 @@ describe("mcpServer", () => { it("forwards employeeOverride and laneId when resuming a Linear sync queue item", async () => { const { runtime } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); await initialize(handler, { callerId: "cto-1", role: "cto" }); const result = await callTool(handler, "resolveLinearSyncQueueItem", { @@ -1231,7 +1238,7 @@ describe("mcpServer", () => { it("rejects unsupported Linear sync queue actions", async () => { const { runtime } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); await initialize(handler, { callerId: "cto-1", role: "cto" }); const response = await callTool(handler, "resolveLinearSyncQueueItem", { @@ -1247,7 +1254,7 @@ describe("mcpServer", () => { it("returns structured local computer-use capability state", async () => { const { runtime } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); await initialize(handler, { callerId: "coord-1", role: "orchestrator" }); const response = await callTool(handler, "get_environment_info", {}); @@ -1260,7 +1267,7 @@ describe("mcpServer", () => { it("auto-links computer-use ingestion to standalone chat sessions", async () => { const { runtime } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); await initialize(handler, { callerId: "chat-session-1", @@ -1293,7 +1300,7 @@ describe("mcpServer", () => { it("rejects computer-use manifests outside the project root", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); const outsideManifest = path.join(path.dirname(fixture.runtime.projectRoot), `ade-artifacts-${Date.now()}.json`); fs.writeFileSync(outsideManifest, JSON.stringify([{ kind: "screenshot", path: "/tmp/shot.png" }]), "utf8"); @@ -1313,65 +1320,6 @@ describe("mcpServer", () => { } }); - it("includes ADE-managed external MCP tools in tool discovery and derives mission identity from run context", async () => { - await withEnv({ ADE_RUN_ID: "run-1" }, async () => { - const fixture = createRuntime(); - fixture.runtime.externalMcpService.listToolsForIdentity = vi.fn(async () => [ - { - name: "search", - serverName: "notion", - namespacedName: "ext.notion.search", - description: "Search Notion pages", - inputSchema: { - type: "object", - properties: { - query: { type: "string" }, - }, - required: ["query"], - }, - enabled: true, - safety: "read", - }, - ]); - fixture.runtime.externalMcpService.callTool = vi.fn(async () => ({ - ok: true, - result: { - content: [{ type: "text", text: "Found page" }], - structuredContent: { pageId: "page-1" }, - isError: false, - }, - })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { - callerId: "worker-1", - role: "agent", - runId: "run-from-identity", - stepId: "step-1", - attemptId: "attempt-1", - }); - - const listResult = await handler({ jsonrpc: "2.0", id: 3, method: "tools/list" }) as any; - expect((listResult.tools ?? []).map((tool: any) => tool.name)).toContain("ext.notion.search"); - - const callResult = await callTool(handler, "ext.notion.search", { query: "roadmap" }); - expect(callResult).toMatchObject({ - content: [{ type: "text", text: "Found page" }], - structuredContent: { pageId: "page-1" }, - isError: false, - }); - expect(fixture.runtime.externalMcpService.callTool).toHaveBeenCalledWith( - expect.objectContaining({ - callerId: "worker-1", - missionId: "mission-1", - runId: "run-1", - }), - "ext.notion.search", - { query: "roadmap" }, - ); - }); - }); - it("lets agent callers use safe mission observation coordinator tools", async () => { await withEnv({ ADE_RUN_ID: "run-1" }, async () => { const fixture = createRuntime(); @@ -1386,7 +1334,7 @@ describe("mcpServer", () => { runtimeEvents: [], completionEvaluation: { complete: false } })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "worker-1", @@ -1408,7 +1356,7 @@ describe("mcpServer", () => { it("rejects coordinator-only tool calls from agent callers before coordinator dispatch", async () => { await withEnv({ ADE_RUN_ID: "run-1" }, async () => { const { runtime } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); await initialize(handler, { callerId: "worker-1", @@ -1432,7 +1380,7 @@ describe("mcpServer", () => { it("rejects standalone chat calls to ADE spawn_agent", async () => { await withEnv({ ADE_DEFAULT_ROLE: "agent", ADE_CHAT_SESSION_ID: "chat-1" }, async () => { const { runtime } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); await initialize(handler, { callerId: "chat-1", role: "agent" }); @@ -1460,7 +1408,7 @@ describe("mcpServer", () => { runtimeEvents: [], completionEvaluation: { complete: false } })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "worker-1", @@ -1502,7 +1450,7 @@ describe("mcpServer", () => { runtimeEvents: [], completionEvaluation: { complete: false } })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "worker-1", @@ -1528,7 +1476,7 @@ describe("mcpServer", () => { it("still routes coordinator-only tool calls for orchestrator callers", async () => { const { runtime } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); await initialize(handler, { callerId: "coord-1", role: "orchestrator" }); const response = await callTool(handler, "spawn_worker", { @@ -1543,7 +1491,7 @@ describe("mcpServer", () => { it("spawns workers for active runs when project and workspace roots differ", async () => { await withEnv({ ADE_MISSION_ID: "mission-1", ADE_RUN_ID: "run-1" }, async () => { const fixture = createRuntime(); - fixture.runtime.workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-mcp-runtime-workspace-")); + fixture.runtime.workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-runtime-workspace-")); fixture.runtime.orchestratorService.getRunGraph = vi.fn(({ runId }: any) => ({ run: { id: runId, @@ -1582,7 +1530,7 @@ describe("mcpServer", () => { runtimeEvents: [], completionEvaluation: { complete: false }, })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "coord-1", @@ -1627,7 +1575,7 @@ describe("mcpServer", () => { runtimeEvents: [], completionEvaluation: { complete: false } })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "coord-1", role: "assistant" as any }); const response = await callTool(handler, "read_mission_status", {}); @@ -1665,7 +1613,7 @@ describe("mcpServer", () => { runtimeEvents: [], completionEvaluation: { complete: false } })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "coord-1", role: "agent" as any }); const response = await callTool(handler, "read_mission_status", {}); @@ -1696,7 +1644,7 @@ describe("mcpServer", () => { process.env.ADE_STEP_ID = "step-1"; process.env.ADE_ATTEMPT_ID = "attempt-1"; try { - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "worker-1", role: "orchestrator" as any }); const response = await callTool(handler, "spawn_worker", { @@ -1722,18 +1670,18 @@ describe("mcpServer", () => { it("does not advertise resources to orchestrator callers", async () => { const { runtime } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); const previousRole = process.env.ADE_DEFAULT_ROLE; process.env.ADE_DEFAULT_ROLE = "orchestrator"; try { const response = await handler({ jsonrpc: "2.0", id: 99, - method: "initialize", + method: "ade/initialize", params: { identity: { callerId: "coord-1", role: "orchestrator" } } }) as any; - expect(response.capabilities?.tools).toBeTruthy(); + expect(response.capabilities?.actions).toBeTruthy(); expect(response.capabilities?.resources).toBeUndefined(); } finally { if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; @@ -1744,7 +1692,7 @@ describe("mcpServer", () => { it("routes reflection_add and uses initialize identity fallback", async () => { await withEnv({ ADE_RUN_ID: "run-1" }, async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "worker-1", @@ -1782,7 +1730,7 @@ describe("mcpServer", () => { it("rejects reflection_add payloads missing strict fields", async () => { await withEnv({ ADE_RUN_ID: "run-1" }, async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "worker-1", role: "agent", @@ -1806,7 +1754,7 @@ describe("mcpServer", () => { it("lists retrospectives, trends, and pattern stats with caller-context fallback", async () => { await withEnv({ ADE_RUN_ID: "run-1" }, async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "worker-1", role: "agent", @@ -1839,7 +1787,7 @@ describe("mcpServer", () => { it("routes spawn_agent to lane-scoped tracked pty sessions", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "orchestrator" }); const response = await callTool(handler, "spawn_agent", { @@ -1867,10 +1815,10 @@ describe("mcpServer", () => { expect(response.structuredContent.contextRef?.path).toBeNull(); }); - it("writes spawn_agent MCP config with the authorized lane worktree", async () => { + it("starts spawn_agent without writing an attached ADE server config", async () => { const fixture = createRuntime(); - fixture.runtime.workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-mcp-spawn-workspace-")); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + fixture.runtime.workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-spawn-workspace-")); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "orchestrator", runId: "run-from-identity" }); const response = await callTool(handler, "spawn_agent", { @@ -1884,37 +1832,17 @@ describe("mcpServer", () => { }); expect(response?.isError).toBeUndefined(); - const configPath = path.join( - fixture.runtime.projectRoot, - ".ade", - "cache", - "orchestrator", - "mcp-configs", - "spawn-attempt-workspace-roots.json" - ); - const config = JSON.parse(fs.readFileSync(configPath, "utf8")) as { - mcpServers: { - ade: { - args: string[]; - env: Record; - }; - }; - }; - - expect(config.mcpServers.ade.args).toContain("--project-root"); - expect(config.mcpServers.ade.args).toContain(fixture.runtime.projectRoot); - expect(config.mcpServers.ade.args).toContain("--workspace-root"); - expect(config.mcpServers.ade.args).toContain(path.join(fixture.runtime.projectRoot, ".ade", "worktrees", "lane-1")); - expect(config.mcpServers.ade.env.ADE_PROJECT_ROOT).toBe(fixture.runtime.projectRoot); - expect(config.mcpServers.ade.env.ADE_WORKSPACE_ROOT).toBe(path.join(fixture.runtime.projectRoot, ".ade", "worktrees", "lane-1")); + expect(response.structuredContent.startupCommand).toContain("claude"); + expect(response.structuredContent.startupCommand).toContain("ADE_RUN_ID=run-1"); + expect(response.structuredContent.startupCommand).toContain("ADE_ATTEMPT_ID=attempt-workspace-roots"); }); it("fails closed when a requested lane does not have an available worktree", async () => { const fixture = createRuntime(); - fixture.runtime.workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-mcp-spawn-workspace-")); + fixture.runtime.workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-spawn-workspace-")); fixture.runtime.laneService.getLaneWorktreePath = vi.fn(() => null); fixture.runtime.laneService.getLaneBaseAndBranch = vi.fn(() => null); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "orchestrator" }); const response = await callTool(handler, "spawn_agent", { @@ -1934,7 +1862,7 @@ describe("mcpServer", () => { expect(fixture.runtime.ptyService.create).not.toHaveBeenCalled(); }); - it("routes coordinator report_status via MCP and mutates run metadata through coordinator tools", async () => { + it("routes coordinator report_status via ADE RPC and mutates run metadata through coordinator tools", async () => { await withEnv({ ADE_RUN_ID: "run-1" }, async () => { const fixture = createRuntime(); fixture.runtime.orchestratorService.getRunGraph = vi.fn(() => ({ @@ -1970,7 +1898,7 @@ describe("mcpServer", () => { runtimeEvents: [], completionEvaluation: null })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "coord-1", role: "orchestrator", missionId: "mission-1", runId: "run-from-identity" }); const response = await callTool(handler, "report_status", { @@ -2053,7 +1981,7 @@ describe("mcpServer", () => { runtimeEvents: [], completionEvaluation: null })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "attempt-child", @@ -2142,7 +2070,7 @@ describe("mcpServer", () => { runtimeEvents: [], completionEvaluation: null })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "native-worker-1", @@ -2221,7 +2149,7 @@ describe("mcpServer", () => { runtimeEvents: [], completionEvaluation: null })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "native-worker-over-cap", @@ -2312,7 +2240,7 @@ describe("mcpServer", () => { completionEvaluation: null })); - const childHandler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const childHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(childHandler, { callerId: "attempt-child", role: "agent", @@ -2330,7 +2258,7 @@ describe("mcpServer", () => { }); expect(statusResponse?.isError).toBeUndefined(); - const parentHandler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const parentHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(parentHandler, { callerId: "attempt-parent", role: "agent", @@ -2396,7 +2324,7 @@ describe("mcpServer", () => { completionEvaluation: null })); - const nativeHandler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const nativeHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(nativeHandler, { callerId: "native-worker-result", role: "agent", @@ -2415,7 +2343,7 @@ describe("mcpServer", () => { }); expect(resultResponse?.isError).toBeUndefined(); - const parentHandler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const parentHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(parentHandler, { callerId: "attempt-parent", role: "agent", @@ -2480,7 +2408,7 @@ describe("mcpServer", () => { completionEvaluation: null })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "attempt-parent", role: "agent", @@ -2513,7 +2441,7 @@ describe("mcpServer", () => { it("uses trusted env run context for shared-fact writes instead of initialize payload runId", async () => { await withEnv({ ADE_RUN_ID: "run-from-env" }, async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "worker-1", @@ -2579,7 +2507,7 @@ describe("mcpServer", () => { sourceRunId: "run-1", } ])); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "worker-1", @@ -2618,7 +2546,7 @@ describe("mcpServer", () => { it("pins memory entries through memory_pin", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "worker-1", role: "agent" }); const response = await callTool(handler, "memory_pin", { id: "memory-42" }); @@ -2630,7 +2558,7 @@ describe("mcpServer", () => { it("exposes memory_update_core and writes CTO core memory", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "cto-1", @@ -2658,7 +2586,7 @@ describe("mcpServer", () => { it("routes memory_update_core to worker core memory when agent ownerId is set", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "worker-1", @@ -2701,7 +2629,7 @@ describe("mcpServer", () => { createdAt: "2026-03-17T19:00:00.000Z", identityKey: "agent:worker-agent-1", })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "chat-from-identity", @@ -2730,7 +2658,7 @@ describe("mcpServer", () => { it("materializes compact context manifests for spawn_agent to keep prompts lightweight", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "orchestrator" }); const response = await callTool(handler, "spawn_agent", { @@ -2754,19 +2682,19 @@ describe("mcpServer", () => { expect(response.structuredContent.startupCommand).toContain("read-only"); const contextPath = response.structuredContent.contextRef?.path as string | null; expect(contextPath).toBeTruthy(); - expect(contextPath?.includes("/.ade/cache/orchestrator/mcp-context/run-123/")).toBe(true); + expect(contextPath?.includes("/.ade/cache/orchestrator/agent-context/run-123/")).toBe(true); if (!contextPath) { throw new Error("Expected context manifest path"); } expect(fs.existsSync(contextPath)).toBe(true); const manifest = JSON.parse(fs.readFileSync(contextPath, "utf8")); - expect(manifest.schema).toBe("ade.mcp.spawnAgentContext.v1"); + expect(manifest.schema).toBe("ade.agent.spawnContext.v1"); expect(manifest.mission.runId).toBe("run-123"); }); it("routes run_tests for suite and ad-hoc command contracts", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "orchestrator", role: "orchestrator" }); @@ -2798,7 +2726,7 @@ describe("mcpServer", () => { it("routes ask_user to mission interventions", async () => { await withEnv({ ADE_MISSION_ID: "mission-1", ADE_RUN_ID: "run-1" }, async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "coord-1", role: "orchestrator", missionId: "mission-1", runId: "run-1" }); const response = await callTool(handler, "ask_user", { @@ -2840,7 +2768,7 @@ describe("mcpServer", () => { answers: {}, responseText: null, })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "chat-session-identity", @@ -2892,7 +2820,7 @@ describe("mcpServer", () => { await withEnv({ ADE_CHAT_SESSION_ID: "chat-session-env" }, async () => { const fixture = createRuntime(); fixture.runtime.agentChatService.requestChatInput = vi.fn(() => new Promise(() => {})); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "chat-session-identity", @@ -2942,7 +2870,7 @@ describe("mcpServer", () => { runtimeEvents: [], completionEvaluation: { complete: false }, })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "attempt-1", @@ -3002,7 +2930,7 @@ describe("mcpServer", () => { runtimeEvents: [], completionEvaluation: { complete: false }, })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "attempt-1", @@ -3026,7 +2954,7 @@ describe("mcpServer", () => { it("allows mutations for any session", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "agent-1", role: "agent" }); @@ -3045,7 +2973,7 @@ describe("mcpServer", () => { it("generates a commit message when commit_changes message is omitted", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "agent-1", role: "agent" }); @@ -3069,7 +2997,7 @@ describe("mcpServer", () => { it("returns generated commit text without creating a commit", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "agent-1", role: "agent" }); @@ -3089,7 +3017,7 @@ describe("mcpServer", () => { it("lists and imports unregistered lane worktrees", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "agent-1", role: "agent" }); const listResponse = await callTool(handler, "list_unregistered_lanes", {}); @@ -3111,9 +3039,9 @@ describe("mcpServer", () => { expect(importResponse.structuredContent.lane.id).toBe("lane-imported"); }); - it("supports core git sync operations via MCP", async () => { + it("supports core git sync operations via ADE RPC", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "agent-1", role: "agent" }); const syncStatus = await callTool(handler, "git_get_sync_status", { laneId: "lane-1" }); @@ -3122,12 +3050,12 @@ describe("mcpServer", () => { const push = await callTool(handler, "git_push", { laneId: "lane-1", force: true, setUpstream: false }); expect(push?.isError).toBeUndefined(); - expect(fixture.runtime.gitService.push).toHaveBeenCalledWith({ laneId: "lane-1", force: true, setUpstream: false }); + expect(fixture.runtime.gitService.push).toHaveBeenCalledWith({ laneId: "lane-1", forceWithLease: true }); }); - it("supports create/update/comment PR actions via MCP", async () => { + it("supports create/update/comment PR actions via ADE RPC", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "agent-1", role: "agent" }); const created = await callTool(handler, "create_pr_from_lane", { @@ -3157,7 +3085,7 @@ describe("mcpServer", () => { it("lists ADE actions across runtime domains", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "agent-1", role: "agent" }); const response = await callTool(handler, "list_ade_actions", { domain: "git" }); @@ -3174,7 +3102,7 @@ describe("mcpServer", () => { it("invokes ADE actions dynamically and returns status hints", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "agent-1", role: "agent" }); const response = await callTool(handler, "run_ade_action", { @@ -3196,9 +3124,26 @@ describe("mcpServer", () => { expect(fixture.runtime.operationService.list).toHaveBeenCalledWith({ limit: 10 }); }); + it("rejects run_ade_action when the action is not a callable on the domain service", async () => { + const fixture = createRuntime(); + 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: "git", + action: "nonexistent_action", + args: { laneId: "lane-1" }, + }); + + expect(response.isError).toBe(true); + expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain( + "Action 'git.nonexistent_action' is not callable.", + ); + }); + it("reads ADE action status snapshots across operation/test/chat/mission/run", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "agent-1", role: "agent", runId: "run-1", missionId: "mission-1" }); const response = await callTool(handler, "get_ade_action_status", { @@ -3221,6 +3166,11 @@ describe("mcpServer", () => { const unchanged = await callTool(handler, "get_ade_action_status", { operationId: "op-1", + testRunId: "test-run-1", + chatSessionId: "chat-1", + runId: "run-1", + missionId: "mission-1", + prId: "pr-1", previousHash: response.structuredContent.hash, waitForMs: 0, }); @@ -3230,7 +3180,7 @@ describe("mcpServer", () => { it("lets agent callers stash lane changes", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "agent-1", role: "agent" }); @@ -3252,7 +3202,7 @@ describe("mcpServer", () => { it("lists lane stashes for agent callers", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "agent-1", role: "agent" }); @@ -3267,10 +3217,10 @@ describe("mcpServer", () => { it("returns resources for lane status/conflicts", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler); - const result = (await handler({ jsonrpc: "2.0", id: 4, method: "resources/list", params: {} })) as any; + const result = (await handler({ jsonrpc: "2.0", id: 4, method: "ade/resources/list", params: {} })) as any; const uris = (result.resources ?? []).map((entry: any) => entry.uri); expect(uris).toContain("ade://lane/lane-1/status"); @@ -3280,13 +3230,13 @@ describe("mcpServer", () => { it("reads lane/status resource with the correct URI parser semantics", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler); const result = (await handler({ jsonrpc: "2.0", id: 5, - method: "resources/read", + method: "ade/resources/read", params: { uri: "ade://lane/lane-1/status" } })) as any; @@ -3297,7 +3247,7 @@ describe("mcpServer", () => { it("records succeeded audit metadata for read-only tools", async () => { const { runtime, operationStart, operationFinish } = createRuntime(); - const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); await initialize(handler); const response = await callTool(handler, "list_lanes", {}); @@ -3321,11 +3271,11 @@ describe("mcpServer", () => { // Create two independent sessions (simulating session recycling) const fixture1 = createRuntime(); - const handler1 = createMcpRequestHandler({ runtime: fixture1.runtime, serverVersion: "test" }); + const handler1 = createAdeRpcRequestHandler({ runtime: fixture1.runtime, serverVersion: "test" }); await initialize(handler1); const fixture2 = createRuntime(); - const handler2 = createMcpRequestHandler({ runtime: fixture2.runtime, serverVersion: "test" }); + const handler2 = createAdeRpcRequestHandler({ runtime: fixture2.runtime, serverVersion: "test" }); await initialize(handler2); // Fire 6 calls from session 1 (per-session limit) @@ -3363,7 +3313,7 @@ describe("mcpServer", () => { it("routes get_lane_status and returns lane/diff/conflict info", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler); const response = await callTool(handler, "get_lane_status", { laneId: "lane-1" }); @@ -3378,7 +3328,7 @@ describe("mcpServer", () => { it("routes check_conflicts with a single laneId", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler); const response = await callTool(handler, "check_conflicts", { laneId: "lane-1" }); @@ -3392,7 +3342,7 @@ describe("mcpServer", () => { it("routes create_lane with authorization and returns lane summary", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "orchestrator", role: "orchestrator" }); const response = await callTool(handler, "create_lane", { name: "new-feature" }); @@ -3407,7 +3357,7 @@ describe("mcpServer", () => { it("routes simulate_integration as a read-only dry-merge", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler); const response = await callTool(handler, "simulate_integration", { @@ -3424,7 +3374,7 @@ describe("mcpServer", () => { it("routes create_queue with authorization", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "orchestrator", role: "orchestrator" }); const response = await callTool(handler, "create_queue", { @@ -3443,7 +3393,7 @@ describe("mcpServer", () => { it("routes create_integration with authorization", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "orchestrator", role: "orchestrator" }); const response = await callTool(handler, "create_integration", { @@ -3466,7 +3416,7 @@ describe("mcpServer", () => { it("routes rebase_lane with authorization", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "orchestrator", role: "orchestrator" }); const response = await callTool(handler, "rebase_lane", { @@ -3488,7 +3438,7 @@ describe("mcpServer", () => { conflictingFiles: [], error: "Worktree has uncommitted changes. Commit or stash before rebasing.", })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "orchestrator", role: "orchestrator" }); const response = await callTool(handler, "rebase_lane", { @@ -3506,7 +3456,7 @@ describe("mcpServer", () => { it("routes get_pr_health as a read-only tool", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler); const response = await callTool(handler, "get_pr_health", { prId: "pr-123" }); @@ -3518,7 +3468,7 @@ describe("mcpServer", () => { it("routes pr_get_checks as a read-only tool", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler); const response = await callTool(handler, "pr_get_checks", { prId: "pr-123" }); @@ -3542,7 +3492,7 @@ describe("mcpServer", () => { it("routes pr_get_review_comments with actionable review context", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler); const response = await callTool(handler, "pr_get_review_comments", { prId: "pr-123" }); @@ -3569,7 +3519,7 @@ describe("mcpServer", () => { it("routes pr_refresh_issue_inventory with checks, review threads, and issue comments", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler); const response = await callTool(handler, "pr_refresh_issue_inventory", { prId: "pr-123" }); @@ -3606,7 +3556,7 @@ describe("mcpServer", () => { it("routes pr_rerun_failed_checks, pr_reply_to_review_thread, and pr_resolve_review_thread", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler); @@ -3651,7 +3601,7 @@ describe("mcpServer", () => { it("routes land_queue_next with authorization", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "orchestrator", role: "orchestrator" }); const response = await callTool(handler, "land_queue_next", { @@ -3668,7 +3618,7 @@ describe("mcpServer", () => { it("get_lane_status returns error for unknown lane", async () => { const fixture = createRuntime(); fixture.runtime.laneService.list = vi.fn(async () => []); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler); const response = await callTool(handler, "get_lane_status", { laneId: "nonexistent" }); @@ -3679,7 +3629,7 @@ describe("mcpServer", () => { it("run_tests requires either suiteId or command", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "orchestrator", role: "orchestrator" }); const response = await callTool(handler, "run_tests", { laneId: "lane-1" }); @@ -3692,7 +3642,7 @@ describe("mcpServer", () => { it("routes create_mission with orchestration authorization", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "evaluator" }); const response = await callTool(handler, "create_mission", { @@ -3718,7 +3668,7 @@ describe("mcpServer", () => { it("routes start_mission and returns run info", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "evaluator" }); const response = await callTool(handler, "start_mission", { @@ -3742,7 +3692,7 @@ describe("mcpServer", () => { it("routes pause_mission to orchestratorService.pauseRun", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "evaluator" }); const response = await callTool(handler, "pause_mission", { @@ -3760,7 +3710,7 @@ describe("mcpServer", () => { it("routes resume_mission to orchestratorService.resumeRun", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "evaluator" }); const response = await callTool(handler, "resume_mission", { runId: "run-1" }); @@ -3773,7 +3723,7 @@ describe("mcpServer", () => { it("routes cancel_mission to aiOrchestratorService.cancelRunGracefully", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "evaluator" }); const response = await callTool(handler, "cancel_mission", { @@ -3791,7 +3741,7 @@ describe("mcpServer", () => { it("routes steer_mission with directive", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "evaluator" }); const response = await callTool(handler, "steer_mission", { @@ -3819,7 +3769,7 @@ describe("mcpServer", () => { it("routes resolve_intervention with status", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "evaluator" }); const response = await callTool(handler, "resolve_intervention", { @@ -3848,7 +3798,7 @@ describe("mcpServer", () => { it("routes get_mission to missionService.get", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "get_mission", { missionId: "mission-1" }); @@ -3860,7 +3810,7 @@ describe("mcpServer", () => { it("routes get_run_graph to orchestratorService.getRunGraph", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "get_run_graph", { runId: "run-1", timelineLimit: 50 }); @@ -3876,7 +3826,7 @@ describe("mcpServer", () => { it("routes stream_events to eventBuffer.drain", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "stream_events", { cursor: 0, limit: 50 }); @@ -3890,7 +3840,7 @@ describe("mcpServer", () => { it("routes get_step_output and filters attempts by step", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "get_step_output", { @@ -3909,7 +3859,7 @@ describe("mcpServer", () => { it("routes get_step_output returns error for unknown step", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "get_step_output", { @@ -3923,7 +3873,7 @@ describe("mcpServer", () => { it("routes get_worker_states to aiOrchestratorService.getWorkerStates", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "get_worker_states", { runId: "run-1" }); @@ -3937,7 +3887,7 @@ describe("mcpServer", () => { it("routes get_timeline to orchestratorService.listTimeline", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "get_timeline", { runId: "run-1" }); @@ -3952,7 +3902,7 @@ describe("mcpServer", () => { it("routes get_timeline with stepId filter", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "get_timeline", { runId: "run-1", stepId: "step-1" }); @@ -3965,7 +3915,7 @@ describe("mcpServer", () => { it("routes get_mission_metrics to aiOrchestratorService.getMissionMetrics", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "get_mission_metrics", { missionId: "mission-1" }); @@ -3978,7 +3928,7 @@ describe("mcpServer", () => { it("routes get_final_diff with per-lane diffs", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "get_final_diff", { runId: "run-1" }); @@ -3994,7 +3944,7 @@ describe("mcpServer", () => { it("routes evaluate_run with evaluator authorization and writes to DB", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "evaluator" }); const response = await callTool(handler, "evaluate_run", { @@ -4037,7 +3987,7 @@ describe("mcpServer", () => { it("routes list_evaluations and returns summaries", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "list_evaluations", { @@ -4056,7 +4006,7 @@ describe("mcpServer", () => { it("routes get_evaluation_report with run context", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "get_evaluation_report", { evaluationId: "eval-1" }); @@ -4075,7 +4025,7 @@ describe("mcpServer", () => { it("evaluator gets reads + orchestration + evaluation", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "evaluator" }); @@ -4100,7 +4050,7 @@ describe("mcpServer", () => { it("any session can access observation tools", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); @@ -4126,7 +4076,7 @@ describe("mcpServer", () => { it("stream_events returns events after cursor", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "stream_events", { cursor: 5, limit: 100 }); @@ -4146,7 +4096,7 @@ describe("mcpServer", () => { nextCursor: cursor, hasMore: false })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "stream_events", { cursor: 10 }); @@ -4169,7 +4119,7 @@ describe("mcpServer", () => { nextCursor: cursor + 3, hasMore: false })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "stream_events", { @@ -4228,7 +4178,7 @@ describe("mcpServer", () => { nextCursor: cursor + 4, hasMore: false })); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "stream_events", { @@ -4248,7 +4198,7 @@ describe("mcpServer", () => { it("stream_events defaults cursor to 0 and limit to 100", async () => { const fixture = createRuntime(); - const handler = createMcpRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); const response = await callTool(handler, "stream_events", {}); diff --git a/apps/mcp-server/src/mcpServer.ts b/apps/ade-cli/src/adeRpcServer.ts similarity index 95% rename from apps/mcp-server/src/mcpServer.ts rename to apps/ade-cli/src/adeRpcServer.ts index 4334dc1ff..07fe172f3 100644 --- a/apps/mcp-server/src/mcpServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -16,6 +16,7 @@ import { loadAgentBrowserArtifactPayloadFromFile, parseAgentBrowserArtifactPaylo import { resolveAgentMemoryWritePolicy } from "../../desktop/src/main/services/memory/memoryService"; import { ReflectionValidationError } from "../../desktop/src/main/services/orchestrator/orchestratorService"; import { getTeamMembersForRun, registerTeamMember, updateTeamMemberStatus } from "../../desktop/src/main/services/orchestrator/teamRuntimeState"; +import { launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "../../desktop/src/main/services/prs/prIssueResolver"; import { runGit } from "../../desktop/src/main/services/git/git"; import { resolvePathWithinRoot } from "../../desktop/src/main/services/shared/utils"; import { getDefaultModelDescriptor } from "../../desktop/src/shared/modelRegistry"; @@ -31,7 +32,7 @@ import { } from "../../desktop/src/shared/types"; import type { PrActionRun, PrCheck, PrComment, PrReviewThread } from "../../desktop/src/shared/types/prs"; import { resolveAdeLayout } from "../../desktop/src/shared/adeLayout"; -import type { AdeMcpRuntime } from "./bootstrap"; +import type { AdeRuntime } from "./bootstrap"; import { JsonRpcError, JsonRpcErrorCode, type JsonRpcHandler, type JsonRpcRequest } from "./jsonrpc"; type ToolSpec = { @@ -189,7 +190,7 @@ const TOOL_SPECS: ToolSpec[] = [ "linear_routing", "file", "process", - "external_mcp", + "pty", "computer_use_artifacts", "all" ], @@ -234,7 +235,7 @@ const TOOL_SPECS: ToolSpec[] = [ "linear_routing", "file", "process", - "external_mcp", + "pty", "computer_use_artifacts", ], }, @@ -493,7 +494,7 @@ const TOOL_SPECS: ToolSpec[] = [ additionalProperties: false, required: ["backendStyle", "backendName"], properties: { - backendStyle: { type: "string", enum: ["external_mcp", "external_cli", "manual", "local_fallback"] }, + backendStyle: { type: "string", enum: ["external_cli", "manual", "local_fallback"] }, backendName: { type: "string", minLength: 1 }, toolName: { type: "string" }, command: { type: "string" }, @@ -1031,6 +1032,40 @@ const TOOL_SPECS: ToolSpec[] = [ } } }, + { + name: "pr_preview_issue_resolution_prompt", + description: "Preview the ADE Path to Merge issue-resolution prompt for a PR without launching an agent.", + inputSchema: { + type: "object", + required: ["prId", "scope", "modelId"], + additionalProperties: false, + properties: { + prId: { type: "string", minLength: 1 }, + scope: { type: "string", enum: ["checks", "comments", "both"] }, + modelId: { type: "string", minLength: 1 }, + reasoning: { type: "string" }, + permissionMode: { type: "string", enum: ["read_only", "guarded_edit", "full_edit"] }, + additionalInstructions: { type: "string" } + } + } + }, + { + name: "pr_start_issue_resolution", + description: "Start a Path to Merge issue-resolution agent session for failing checks and/or review comments on a PR.", + inputSchema: { + type: "object", + required: ["prId", "scope", "modelId"], + additionalProperties: false, + properties: { + prId: { type: "string", minLength: 1 }, + scope: { type: "string", enum: ["checks", "comments", "both"] }, + modelId: { type: "string", minLength: 1 }, + reasoning: { type: "string" }, + permissionMode: { type: "string", enum: ["read_only", "guarded_edit", "full_edit"] }, + additionalInstructions: { type: "string" } + } + } + }, { name: "pr_rerun_failed_checks", description: "Rerun failed CI checks for a pull request.", @@ -1850,6 +1885,7 @@ const READ_ONLY_TOOLS = new Set([ "pr_get_checks", "pr_get_review_comments", "pr_refresh_issue_inventory", + "pr_preview_issue_resolution_prompt", "memory_search", "get_cto_state", "listChats", @@ -1896,6 +1932,7 @@ const MUTATION_TOOLS = new Set([ "rebase_abort", "land_queue_next", "pr_rerun_failed_checks", + "pr_start_issue_resolution", "pr_reply_to_review_thread", "pr_resolve_review_thread", "memory_add", @@ -2181,23 +2218,6 @@ function jsonText(value: unknown): string { return JSON.stringify(value, null, 2); } -function mcpTextResult(value: unknown, isError = false): Record { - const text = typeof value === "string" ? value : jsonText(value); - return { - content: [{ type: "text", text }], - structuredContent: value, - ...(isError ? { isError: true } : {}) - }; -} - -function isMcpToolResult(value: unknown): value is { - content: unknown[]; - structuredContent?: unknown; - isError?: boolean; -} { - return isRecord(value) && Array.isArray(value.content); -} - function sanitizeForAudit(value: unknown, depth = 0): unknown { if (depth > 4) return "[depth-clipped]"; if (value == null) return value; @@ -2224,9 +2244,9 @@ function sanitizeForAudit(value: unknown, depth = 0): unknown { return String(value); } -function requirePrService(runtime: AdeMcpRuntime): NonNullable { +function requirePrService(runtime: AdeRuntime): NonNullable { if (!runtime.prService) { - throw new JsonRpcError(JsonRpcErrorCode.internalError, "prService is not available in this MCP runtime configuration"); + throw new JsonRpcError(JsonRpcErrorCode.internalError, "prService is not available in this ADE runtime configuration"); } return runtime.prService; } @@ -2343,45 +2363,45 @@ function summarizePrIssueInventory(args: { }; } -function requireAgentChatService(runtime: AdeMcpRuntime): NonNullable { +function requireAgentChatService(runtime: AdeRuntime): NonNullable { if (!runtime.agentChatService) { throw new JsonRpcError( JsonRpcErrorCode.internalError, - "agentChatService is not available in this MCP runtime configuration", + "agentChatService is not available in this ADE runtime configuration", ); } return runtime.agentChatService; } -function requireLinearSyncService(runtime: AdeMcpRuntime): NonNullable { +function requireLinearSyncService(runtime: AdeRuntime): NonNullable { if (!runtime.linearSyncService) { - throw new JsonRpcError(JsonRpcErrorCode.internalError, "linearSyncService is not available in this MCP runtime configuration"); + throw new JsonRpcError(JsonRpcErrorCode.internalError, "linearSyncService is not available in this ADE runtime configuration"); } return runtime.linearSyncService; } -function requireLinearIngressService(runtime: AdeMcpRuntime): NonNullable { +function requireLinearIngressService(runtime: AdeRuntime): NonNullable { if (!runtime.linearIngressService) { - throw new JsonRpcError(JsonRpcErrorCode.internalError, "linearIngressService is not available in this MCP runtime configuration"); + throw new JsonRpcError(JsonRpcErrorCode.internalError, "linearIngressService is not available in this ADE runtime configuration"); } return runtime.linearIngressService; } -function requireFlowPolicyService(runtime: AdeMcpRuntime): NonNullable { +function requireFlowPolicyService(runtime: AdeRuntime): NonNullable { if (!runtime.flowPolicyService) { - throw new JsonRpcError(JsonRpcErrorCode.internalError, "flowPolicyService is not available in this MCP runtime configuration"); + throw new JsonRpcError(JsonRpcErrorCode.internalError, "flowPolicyService is not available in this ADE runtime configuration"); } return runtime.flowPolicyService; } -function requireLinearRoutingService(runtime: AdeMcpRuntime): NonNullable { +function requireLinearRoutingService(runtime: AdeRuntime): NonNullable { if (!runtime.linearRoutingService) { - throw new JsonRpcError(JsonRpcErrorCode.internalError, "linearRoutingService is not available in this MCP runtime configuration"); + throw new JsonRpcError(JsonRpcErrorCode.internalError, "linearRoutingService is not available in this ADE runtime configuration"); } return runtime.linearRoutingService; } -async function resolveDefaultLaneId(runtime: AdeMcpRuntime): Promise { +async function resolveDefaultLaneId(runtime: AdeRuntime): Promise { await runtime.laneService.ensurePrimaryLane().catch(() => {}); const lanes = await runtime.laneService.list({ includeArchived: false, includeStatus: false }); const laneId = (lanes.find((lane) => lane.laneType === "primary") ?? lanes[0])?.id?.trim?.() || ""; @@ -2391,7 +2411,7 @@ async function resolveDefaultLaneId(runtime: AdeMcpRuntime): Promise { return laneId; } -function resolveChatSessionLaneId(runtime: AdeMcpRuntime, session: SessionState): string | null { +function resolveChatSessionLaneId(runtime: AdeRuntime, session: SessionState): string | null { const chatSessionId = asOptionalTrimmedString(session.identity.chatSessionId); if (!chatSessionId) return null; const chatSession = runtime.sessionService.get(chatSessionId); @@ -2399,7 +2419,7 @@ function resolveChatSessionLaneId(runtime: AdeMcpRuntime, session: SessionState) return laneId.length ? laneId : null; } -function resolveLaneWorktreePath(runtime: AdeMcpRuntime, laneId: string | null | undefined): string | null { +function resolveLaneWorktreePath(runtime: AdeRuntime, laneId: string | null | undefined): string | null { const normalizedLaneId = asOptionalTrimmedString(laneId); if (!normalizedLaneId) return null; try { @@ -2423,7 +2443,7 @@ function resolveLaneWorktreePath(runtime: AdeMcpRuntime, laneId: string | null | return null; } -function resolveRunContextLaneId(runtime: AdeMcpRuntime, callerCtx: CallerContext): string | null { +function resolveRunContextLaneId(runtime: AdeRuntime, callerCtx: CallerContext): string | null { const runId = asOptionalTrimmedString(callerCtx.runId); if (!runId) return null; @@ -2442,7 +2462,7 @@ function resolveRunContextLaneId(runtime: AdeMcpRuntime, callerCtx: CallerContex } function resolveAuthorizedWorkspaceRoot( - runtime: AdeMcpRuntime, + runtime: AdeRuntime, session: SessionState, toolArgs?: Record, ): string { @@ -2488,7 +2508,7 @@ function resolveAuthorizedWorkspaceRoot( } function resolveRequestedOrSessionLaneId( - runtime: AdeMcpRuntime, + runtime: AdeRuntime, session: SessionState, toolArgs: Record, ): string | null { @@ -2496,7 +2516,7 @@ function resolveRequestedOrSessionLaneId( } function requireLaneIdForTool( - runtime: AdeMcpRuntime, + runtime: AdeRuntime, session: SessionState, toolArgs: Record, toolName: string, @@ -2512,7 +2532,7 @@ function requireLaneIdForTool( } async function runCtoOperatorBridgeTool( - runtime: AdeMcpRuntime, + runtime: AdeRuntime, session: SessionState, name: string, toolArgs: Record, @@ -2530,7 +2550,7 @@ async function runCtoOperatorBridgeTool( : null) ?? fallbackModelId; const tools = createCtoOperatorTools({ - currentSessionId: session.identity.callerId || "mcp-cto", + currentSessionId: session.identity.callerId || "ade-cli-cto", defaultLaneId, defaultModelId, sessionService: runtime.sessionService, @@ -2615,7 +2635,7 @@ function normalizeToolWhitelist(value: unknown): string[] { } function resolveSpawnContextFile(args: { - runtime: AdeMcpRuntime; + runtime: AdeRuntime; laneId: string; provider: "codex" | "claude"; permissionMode: SpawnPermissionMode; @@ -2657,7 +2677,7 @@ function resolveSpawnContextFile(args: { }; } - const baseDir = resolveAdeLayout(args.runtime.projectRoot).mcpContextDir; + const baseDir = resolveAdeLayout(args.runtime.projectRoot).agentContextDir; const runSegment = args.runId ?? "standalone"; const dir = path.join(baseDir, runSegment); fs.mkdirSync(dir, { recursive: true }); @@ -2665,7 +2685,7 @@ function resolveSpawnContextFile(args: { const filename = `${Date.now()}-${randomUUID()}.json`; const contextFilePath = path.join(dir, filename); const payload = { - schema: "ade.mcp.spawnAgentContext.v1", + schema: "ade.agent.spawnContext.v1", generatedAt: nowIso(), mission: { runId: args.runId, @@ -2806,7 +2826,7 @@ function resolveWorkerAgentOwnerId(identityKey: unknown): string | null { } async function resolveEffectiveCallerContext( - runtime: AdeMcpRuntime, + runtime: AdeRuntime, session?: SessionState, ): Promise { const callerCtx = { ...resolveCallerContext(session) }; @@ -2827,28 +2847,6 @@ async function resolveEffectiveCallerContext( return callerCtx; } -function toExternalMcpIdentity(callerCtx: CallerContext): { - callerId: string; - role: "cto" | "orchestrator" | "agent" | "external" | "evaluator"; - chatSessionId: string | null; - missionId: string | null; - runId: string | null; - stepId: string | null; - attemptId: string | null; - ownerId: string | null; -} { - return { - callerId: callerCtx.callerId ?? callerCtx.chatSessionId ?? callerCtx.attemptId ?? "unknown", - role: callerCtx.role ?? "external", - chatSessionId: callerCtx.chatSessionId, - missionId: callerCtx.missionId, - runId: callerCtx.runId, - stepId: callerCtx.stepId, - attemptId: callerCtx.attemptId, - ownerId: callerCtx.ownerId, - }; -} - function isStandaloneChatCaller(callerCtx: CallerContext): boolean { return callerCtx.standaloneChatSession; } @@ -2876,15 +2874,8 @@ function isLocalComputerUseAllowed(policy: ComputerUsePolicy | null | undefined) return isComputerUseModeEnabled(effective.mode) && effective.allowLocalFallback; } -async function listToolSpecsForSession(runtime: AdeMcpRuntime, session: SessionState): Promise { +async function listToolSpecsForSession(runtime: AdeRuntime, session: SessionState): Promise { const callerCtx = await resolveEffectiveCallerContext(runtime, session); - const externalToolSpecs = runtime.externalMcpService - ? (await runtime.externalMcpService.listToolsForIdentity(toExternalMcpIdentity(callerCtx))).map((tool) => ({ - name: tool.namespacedName, - description: tool.description ?? `${tool.serverName}: ${tool.name}`, - inputSchema: tool.inputSchema, - })) - : []; const externalComputerUseAvailable = runtime.computerUseArtifactBrokerService ?.getBackendStatus() ?.backends.some((backend) => backend.available) ?? false; @@ -2898,21 +2889,21 @@ async function listToolSpecsForSession(runtime: AdeMcpRuntime, session: SessionS : COORDINATOR_TOOL_SPECS; const allVisibleTools = (() => { if (callerCtx.role === "external" || !callerCtx.role) { - return [...visibleBaseTools, ...externalToolSpecs]; + return visibleBaseTools; } if (callerCtx.role === "agent") { - return [...visibleBaseTools, ...AGENT_VISIBLE_COORDINATOR_TOOL_SPECS, ...externalToolSpecs]; + return [...visibleBaseTools, ...AGENT_VISIBLE_COORDINATOR_TOOL_SPECS]; } if (callerCtx.role === "cto") { - return [...visibleBaseTools, ...CTO_OPERATOR_TOOL_SPECS, ...CTO_LINEAR_SYNC_TOOL_SPECS, ...externalToolSpecs]; + return [...visibleBaseTools, ...CTO_OPERATOR_TOOL_SPECS, ...CTO_LINEAR_SYNC_TOOL_SPECS]; } - return [...visibleBaseTools, ...visibleCoordinatorTools, ...externalToolSpecs]; + return [...visibleBaseTools, ...visibleCoordinatorTools]; })(); return allVisibleTools.filter((tool) => !isToolHiddenForStandaloneChat(tool.name, callerCtx)); } -function parseInitializeIdentity(runtime: AdeMcpRuntime, params: unknown): SessionIdentity { +function parseInitializeIdentity(runtime: AdeRuntime, params: unknown): SessionIdentity { const data = safeObject(params); const identity = safeObject(data.identity); const envContext = resolveEnvCallerContext(); @@ -2961,7 +2952,7 @@ function parseInitializeIdentity(runtime: AdeMcpRuntime, params: unknown): Sessi }; } -function parseMcpUri(uriRaw: string): { path: string[] } { +function parseAdeResourceUri(uriRaw: string): { path: string[] } { const trimmed = uriRaw.trim(); if (!trimmed.startsWith("ade://")) { throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Unsupported resource URI: ${uriRaw}`); @@ -3004,7 +2995,7 @@ function buildResourceList(args: { } async function waitForTestRunCompletion(args: { - runtime: AdeMcpRuntime; + runtime: AdeRuntime; runId: string; laneId: string; timeoutMs: number; @@ -3060,10 +3051,10 @@ type AdeActionDomain = | "linear_routing" | "file" | "process" - | "external_mcp" + | "pty" | "computer_use_artifacts"; -function getAdeActionDomainServices(runtime: AdeMcpRuntime): Partial | null | undefined>> { +function getAdeActionDomainServices(runtime: AdeRuntime): Partial | null | undefined>> { return { lane: runtime.laneService as unknown as Record, git: runtime.gitService as unknown as Record, @@ -3090,7 +3081,7 @@ function getAdeActionDomainServices(runtime: AdeMcpRuntime): Partial | null, file: (runtime.fileService ?? null) as unknown as Record | null, process: (runtime.processService ?? null) as unknown as Record | null, - external_mcp: runtime.externalMcpService as unknown as Record, + pty: runtime.ptyService as unknown as Record, computer_use_artifacts: runtime.computerUseArtifactBrokerService as unknown as Record, }; } @@ -3102,7 +3093,7 @@ function listAdeActionNames(service: Record): string[] { } async function waitForSessionCompletion(args: { - runtime: AdeMcpRuntime; + runtime: AdeRuntime; ptyId: string; sessionId: string; timeoutMs: number; @@ -3140,7 +3131,7 @@ async function waitForSessionCompletion(args: { }; } -async function buildLaneStatus(runtime: AdeMcpRuntime, laneId: string): Promise> { +async function buildLaneStatus(runtime: AdeRuntime, laneId: string): Promise> { const lanes = await runtime.laneService.list({ includeArchived: true }); const lane = lanes.find((entry) => entry.id === laneId); if (!lane) { @@ -3224,9 +3215,9 @@ type CoordinatorToolCacheEntry = { tools: Record; }; -const coordinatorToolCacheByRuntime = new WeakMap>(); +const coordinatorToolCacheByRuntime = new WeakMap>(); -function resolveMissionIdForRun(runtime: AdeMcpRuntime, runId: string): string | null { +function resolveMissionIdForRun(runtime: AdeRuntime, runId: string): string | null { const graphMissionId = (() => { try { const graph = runtime.orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); @@ -3249,7 +3240,7 @@ function resolveMissionIdForRun(runtime: AdeMcpRuntime, runId: string): string | return asOptionalTrimmedString(row?.mission_id); } -function resolveRunIdForMission(runtime: AdeMcpRuntime, missionId: string): string | null { +function resolveRunIdForMission(runtime: AdeRuntime, missionId: string): string | null { const row = runtime.db.get<{ id: string | null }>( ` select id @@ -3273,7 +3264,7 @@ function resolveRunIdForMission(runtime: AdeMcpRuntime, missionId: string): stri } function getCoordinatorToolSet(args: { - runtime: AdeMcpRuntime; + runtime: AdeRuntime; runId: string; missionId: string; }): Record { @@ -3337,14 +3328,14 @@ type NativeCallerRegistration = { sourceAttemptId: string; }; -function getTeamRuntimeContext(runtime: AdeMcpRuntime): import("../../desktop/src/main/services/orchestrator/orchestratorContext").OrchestratorContext { +function getTeamRuntimeContext(runtime: AdeRuntime): import("../../desktop/src/main/services/orchestrator/orchestratorContext").OrchestratorContext { return { db: runtime.db, logger: runtime.logger, } as import("../../desktop/src/main/services/orchestrator/orchestratorContext").OrchestratorContext; } -function getRunGraphSafe(runtime: AdeMcpRuntime, runId: string): Record | null { +function getRunGraphSafe(runtime: AdeRuntime, runId: string): Record | null { try { return runtime.orchestratorService.getRunGraph({ runId, timelineLimit: 0 }) as unknown as Record; } catch { @@ -3352,7 +3343,7 @@ function getRunGraphSafe(runtime: AdeMcpRuntime, runId: string): Record> { +function getTeamMembersForRunSafe(runtime: AdeRuntime, runId: string): Array> { const aiService = runtime.aiOrchestratorService as unknown as { getTeamMembers?: (args: { runId: string }) => unknown }; if (typeof aiService.getTeamMembers === "function") { try { @@ -3416,7 +3407,7 @@ function deriveQuestionOwnerFromPhase(args: { } function getAgentAskUserPolicy(args: { - runtime: AdeMcpRuntime; + runtime: AdeRuntime; callerCtx: CallerContext; }): { stepId: string | null; @@ -3657,7 +3648,7 @@ function inferParallelismCap(graph: Record): number { } function ensureNativeTeammateRegistration(args: { - runtime: AdeMcpRuntime; + runtime: AdeRuntime; runId: string; missionId: string; callerCtx: CallerContext; @@ -3782,7 +3773,7 @@ function ensureNativeTeammateRegistration(args: { } async function maybeSendInterAgentMessage(args: { - runtime: AdeMcpRuntime; + runtime: AdeRuntime; missionId: string; fromAttemptId: string; toAttemptId: string; @@ -3809,7 +3800,7 @@ async function maybeSendInterAgentMessage(args: { } async function postProcessCoordinatorToolResult(args: { - runtime: AdeMcpRuntime; + runtime: AdeRuntime; toolName: string; runId: string; missionId: string; @@ -3902,7 +3893,7 @@ async function postProcessCoordinatorToolResult(args: { } async function runCoordinatorTool(args: { - runtime: AdeMcpRuntime; + runtime: AdeRuntime; name: string; toolArgs: Record; callerCtx: CallerContext; @@ -3986,7 +3977,7 @@ async function runCoordinatorTool(args: { async function runTool(args: { - runtime: AdeMcpRuntime; + runtime: AdeRuntime; session: SessionState; name: string; toolArgs: Record; @@ -4040,13 +4031,13 @@ async function runTool(args: { if (!isComputerUseModeEnabled(effectivePolicy.mode)) { throw new JsonRpcError( JsonRpcErrorCode.policyDenied, - `${toolName} is disabled because computer use is off for this ADE MCP session.`, + `${toolName} is disabled because computer use is off for this ADE ADE RPC session.`, ); } if (!effectivePolicy.allowLocalFallback) { throw new JsonRpcError( JsonRpcErrorCode.policyDenied, - `${toolName} is disabled because local computer-use fallback is not allowed for this ADE MCP session.`, + `${toolName} is disabled because local computer-use fallback is not allowed for this ADE ADE RPC session.`, ); } const capabilities = getLocalComputerUseCapabilities(); @@ -4109,13 +4100,6 @@ async function runTool(args: { await sleep(250); }; - if (name.startsWith("ext.")) { - if (!runtime.externalMcpService) { - throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported tool: ${name}`); - } - return await runtime.externalMcpService.callTool(toExternalMcpIdentity(callerCtx), name, toolArgs); - } - if (CTO_OPERATOR_TOOL_NAMES.has(name)) { if (callerCtx.role !== "cto") { throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported tool: ${name}`); @@ -4282,7 +4266,7 @@ async function runTool(args: { if (testRunId) { const run = runtime.testService.listRuns({ limit: 200 }).find((entry) => entry.id === testRunId) ?? null; payload.testRun = run; - if (run) payload.testRunLogTail = runtime.testService.getLogTail(testRunId, 16_000); + if (run) payload.testRunLogTail = runtime.testService.getLogTail({ runId: testRunId, maxBytes: 16_000 }); } if (chatSessionId && runtime.agentChatService) { payload.chatSession = await runtime.agentChatService.getSessionSummary(chatSessionId); @@ -4711,13 +4695,13 @@ async function runTool(args: { if (!isComputerUseModeEnabled(effectivePolicy.mode)) { throw new JsonRpcError( JsonRpcErrorCode.policyDenied, - `${name} is disabled because computer use is off for this ADE MCP session.`, + `${name} is disabled because computer use is off for this ADE ADE RPC session.`, ); } if (!effectivePolicy.allowLocalFallback) { throw new JsonRpcError( JsonRpcErrorCode.policyDenied, - `${name} is disabled because local computer-use fallback is not allowed for this ADE MCP session.`, + `${name} is disabled because local computer-use fallback is not allowed for this ADE ADE RPC session.`, ); } const capabilities = getLocalComputerUseCapabilities(); @@ -4884,7 +4868,7 @@ async function runTool(args: { } if (name === "ingest_computer_use_artifacts") { - const backendStyle = assertNonEmptyString(toolArgs.backendStyle, "backendStyle") as "external_mcp" | "external_cli" | "manual" | "local_fallback"; + const backendStyle = assertNonEmptyString(toolArgs.backendStyle, "backendStyle") as "external_cli" | "manual" | "local_fallback"; const backendName = assertNonEmptyString(toolArgs.backendName, "backendName"); const manifestPath = asOptionalTrimmedString(toolArgs.manifestPath); let inputs = Array.isArray(toolArgs.inputs) ? toolArgs.inputs.map((entry) => safeObject(entry)) : []; @@ -5268,7 +5252,7 @@ async function runTool(args: { laneId, cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS, - title: `MCP Test: ${commandText}`, + title: `ADE Test: ${commandText}`, tracked: true, toolType: "shell", startupCommand: commandText @@ -5322,9 +5306,8 @@ async function runTool(args: { if (name === "git_push") { const laneId = requireLaneIdForTool(runtime, session, toolArgs, "git_push"); - const force = asBoolean(toolArgs.force, false); - const setUpstream = asBoolean(toolArgs.setUpstream, true); - const action = await runtime.gitService.push({ laneId, force, setUpstream }); + const force = asBoolean(toolArgs.forceWithLease, asBoolean(toolArgs.force, false)); + const action = await runtime.gitService.push({ laneId, forceWithLease: force }); return { laneId, action }; } @@ -5537,7 +5520,7 @@ async function runTool(args: { laneId, baseBranch, title, - ...(body ? { body } : {}), + body: body ?? "", draft, }); return { pr }; @@ -5610,6 +5593,72 @@ async function runTool(args: { }); } + if (name === "pr_preview_issue_resolution_prompt" || name === "pr_start_issue_resolution") { + const prId = assertNonEmptyString(toolArgs.prId, "prId"); + const scope = assertNonEmptyString(toolArgs.scope, "scope"); + if (scope !== "checks" && scope !== "comments" && scope !== "both") { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "scope must be one of checks, comments, or both."); + } + const modelId = assertNonEmptyString(toolArgs.modelId, "modelId"); + const permissionMode = asOptionalTrimmedString(toolArgs.permissionMode); + if ( + permissionMode + && permissionMode !== "read_only" + && permissionMode !== "guarded_edit" + && permissionMode !== "full_edit" + ) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "permissionMode must be one of read_only, guarded_edit, or full_edit."); + } + const issueResolutionArgs = { + prId, + scope, + modelId, + reasoning: asOptionalTrimmedString(toolArgs.reasoning), + ...(permissionMode ? { permissionMode } : {}), + additionalInstructions: asOptionalTrimmedString(toolArgs.additionalInstructions), + }; + const deps = { + prService: requirePrService(runtime), + laneService: runtime.laneService, + agentChatService: requireAgentChatService(runtime), + sessionService: runtime.sessionService, + issueInventoryService: runtime.issueInventoryService, + }; + + if (name === "pr_preview_issue_resolution_prompt") { + return await previewPrIssueResolutionPrompt(deps, issueResolutionArgs as any); + } + + const result = await launchPrIssueResolutionChat(deps, issueResolutionArgs as any); + let convergenceRuntime: unknown = null; + try { + const status = runtime.issueInventoryService.getConvergenceStatus(prId); + convergenceRuntime = runtime.issueInventoryService.saveConvergenceRuntime(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("rpc.pr_issue_resolution_convergence_persist_failed", { + prId, + sessionId: result.sessionId, + laneId: result.laneId, + href: result.href, + error: error instanceof Error ? error.message : String(error), + }); + } + return { + ...result, + convergenceRuntime, + }; + } + if (name === "pr_rerun_failed_checks") { const prId = assertNonEmptyString(toolArgs.prId, "prId"); await requirePrService(runtime).rerunChecks({ prId }); @@ -5685,6 +5734,13 @@ async function runTool(args: { const laneId = assertNonEmptyString(toolArgs.laneId, "laneId"); + const laneWorktreePath = resolveLaneWorktreePath(runtime, laneId); + if (!laneWorktreePath) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + `Requested lane '${laneId}' does not have an available worktree.`, + ); + } const provider = asTrimmedString(toolArgs.provider) === "claude" ? "claude" : "codex"; const model = asOptionalTrimmedString(toolArgs.model); const permissionMode = parseSpawnPermissionMode(toolArgs.permissionMode); @@ -5695,7 +5751,7 @@ async function runTool(args: { const attemptId = asOptionalTrimmedString(toolArgs.attemptId); const toolWhitelist = normalizeToolWhitelist(toolArgs.toolWhitelist); const title = stripInjectionChars( - asOptionalTrimmedString(toolArgs.title) ?? `MCP Agent (${provider}${permissionMode === "plan" ? " · plan" : ""})` + asOptionalTrimmedString(toolArgs.title) ?? `ADE Agent (${provider}${permissionMode === "plan" ? " · plan" : ""})` ); const context = safeObject(toolArgs.context); @@ -5745,39 +5801,8 @@ async function runTool(args: { permissionMode === "plan" ? "plan" : permissionMode === "full-auto" ? "bypassPermissions" : "acceptEdits"; commandParts.push("--permission-mode", claudePermission); - // Bind ADE MCP server to Claude workers via --mcp-config - if (runId && attemptId) { - const mcpConfigDir = resolveAdeLayout(runtime.projectRoot).mcpConfigsDir; - fs.mkdirSync(mcpConfigDir, { recursive: true }); - const mcpConfigPath = path.join(mcpConfigDir, `spawn-${attemptId ?? Date.now()}.json`); - const builtEntry = path.join(runtime.projectRoot, "apps", "mcp-server", "dist", "index.cjs"); - const srcEntry = path.join(runtime.projectRoot, "apps", "mcp-server", "src", "index.ts"); - const workerWorkspaceRoot = resolveAuthorizedWorkspaceRoot(runtime, session, toolArgs); - const mcpCmd = fs.existsSync(builtEntry) ? "node" : "npx"; - const mcpArgs = fs.existsSync(builtEntry) - ? [builtEntry, "--project-root", runtime.projectRoot, "--workspace-root", workerWorkspaceRoot] - : ["tsx", srcEntry, "--project-root", runtime.projectRoot, "--workspace-root", workerWorkspaceRoot]; - const mcpConfig = { - mcpServers: { - ade: { - command: mcpCmd, - args: mcpArgs, - env: { - ADE_PROJECT_ROOT: runtime.projectRoot, - ADE_WORKSPACE_ROOT: workerWorkspaceRoot, - ADE_MISSION_ID: callerCtx.missionId ?? "", - ADE_RUN_ID: runId, - ADE_STEP_ID: stepId ?? "", - ADE_ATTEMPT_ID: attemptId, - ADE_DEFAULT_ROLE: "agent", - ADE_OWNER_ID: callerCtx.ownerId ?? "", - } - } - } - }; - fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), "utf8"); - commandParts.push("--mcp-config", shellEscapeArg(mcpConfigPath)); - } + // ADE-owned actions are exposed through the `ade` CLI. Child agent + // sessions receive identity env vars below instead of an attached server. } if (finalPrompt) { commandParts.push(shellEscapeArg(finalPrompt)); @@ -6307,11 +6332,11 @@ async function runTool(args: { }; } - throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unknown MCP tool: ${name}`); + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unknown ADE action: ${name}`); } -async function readResource(runtime: AdeMcpRuntime, uri: string): Promise> { - const parsed = parseMcpUri(uri); +async function readResource(runtime: AdeRuntime, uri: string): Promise> { + const parsed = parseAdeResourceUri(uri); const [head, ...tail] = parsed.path; if (head === "lane") { @@ -6347,12 +6372,12 @@ async function readResource(runtime: AdeMcpRuntime, uri: string): Promise void) | null; + onActionsListChanged?: (() => void) | null; }): JsonRpcHandler & { dispose: () => void } { - const { runtime, serverVersion, onToolsListChanged } = args; + const { runtime, serverVersion, onActionsListChanged } = args; const session: SessionState = { initialized: false, @@ -6386,31 +6411,18 @@ export function createMcpRequestHandler(args: { } }; - const disposeExternalMcpSubscription = typeof runtime.externalMcpService.onEvent === "function" - ? runtime.externalMcpService.onEvent((event) => { - if (!session.initialized) return; - if ( - event.type === "configs-changed" - || event.type === "server-state-changed" - || event.type === "tools-refreshed" - ) { - onToolsListChanged?.(); - } - }) - : () => {}; - - const auditToolCall = async ( - toolName: string, - toolArgs: Record, + const auditActionCall = async ( + actionName: string, + actionArgs: Record, runner: () => Promise ): Promise => { const startedAt = Date.now(); - const laneId = resolveRequestedOrSessionLaneId(runtime, session, toolArgs); + const laneId = resolveRequestedOrSessionLaneId(runtime, session, actionArgs); const operation = runtime.operationService.start({ laneId, - kind: "mcp_tool_call", + kind: "ade_action_call", metadata: { - tool: toolName, + action: actionName, callerId: session.identity.callerId, role: session.identity.role, chatSessionId: session.identity.chatSessionId, @@ -6419,7 +6431,7 @@ export function createMcpRequestHandler(args: { stepId: session.identity.stepId, attemptId: session.identity.attemptId, ownerId: session.identity.ownerId, - args: sanitizeForAudit(toolArgs) + args: sanitizeForAudit(actionArgs) } }); @@ -6449,23 +6461,51 @@ export function createMcpRequestHandler(args: { } }; + const listActions = async (): Promise> => ({ + actions: (await listToolSpecsForSession(runtime, session)).map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: sanitizeToolSchema(tool.inputSchema), + })), + }); + + const callAction = async (actionName: string, actionArgs: Record): Promise => { + return await auditActionCall(actionName, actionArgs, async () => { + if ( + READ_ONLY_TOOLS.has(actionName) || + MUTATION_TOOLS.has(actionName) || + ORCHESTRATION_TOOLS.has(actionName) || + OBSERVATION_TOOLS.has(actionName) || + EVALUATOR_TOOLS.has(actionName) || + EVALUATION_READ_TOOLS.has(actionName) || + COORDINATOR_TOOL_NAMES.has(actionName) || + actionName === "spawn_agent" || + actionName === "ask_user" + ) { + return await runTool({ runtime, session, name: actionName, toolArgs: actionArgs }); + } + + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported ADE action: ${actionName}`); + }); + }; + const handler = (async (request: JsonRpcRequest): Promise => { const method = typeof request.method === "string" ? request.method : ""; const params = safeObject(request.params); - if (method === "initialize") { + if (method === "ade/initialize") { session.initialized = true; session.protocolVersion = asOptionalTrimmedString(params.protocolVersion) ?? DEFAULT_PROTOCOL_VERSION; session.identity = parseInitializeIdentity(runtime, params); const resourcesEnabled = session.identity.role !== "orchestrator"; return { protocolVersion: session.protocolVersion, - serverInfo: { - name: "ade-mcp-server", + runtimeInfo: { + name: "ade-rpc", version: serverVersion }, capabilities: { - tools: { + actions: { listChanged: true }, ...(resourcesEnabled @@ -6480,7 +6520,7 @@ export function createMcpRequestHandler(args: { }; } - if (method === "notifications/initialized") { + if (method === "ade/initialized") { return null; } @@ -6492,63 +6532,28 @@ export function createMcpRequestHandler(args: { return { pong: true, at: nowIso() }; } - if (method === "tools/list") { - return { - tools: (await listToolSpecsForSession(runtime, session)).map((tool) => ({ - name: tool.name, - description: tool.description, - inputSchema: sanitizeToolSchema(tool.inputSchema) - })) - }; + if (method === "ade/actions/list") { + return await listActions(); } - if (method === "tools/call") { - const toolName = assertNonEmptyString(params.name, "name"); - const toolArgs = safeObject(params.arguments); - + if (method === "ade/actions/call") { + const actionName = assertNonEmptyString(params.name, "name"); + const actionArgs = safeObject(params.arguments); try { - const result = await auditToolCall(toolName, toolArgs, async () => { - if (toolName.startsWith("ext.")) { - return await runTool({ runtime, session, name: toolName, toolArgs }); - } - - if ( - READ_ONLY_TOOLS.has(toolName) || - MUTATION_TOOLS.has(toolName) || - ORCHESTRATION_TOOLS.has(toolName) || - OBSERVATION_TOOLS.has(toolName) || - EVALUATOR_TOOLS.has(toolName) || - EVALUATION_READ_TOOLS.has(toolName) || - COORDINATOR_TOOL_NAMES.has(toolName) || - toolName === "spawn_agent" || - toolName === "ask_user" - ) { - return await runTool({ runtime, session, name: toolName, toolArgs }); - } - - throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported tool: ${toolName}`); - }); - - if (toolName.startsWith("ext.") && isRecord(result) && isMcpToolResult(result.result)) { - return result.result; - } - return mcpTextResult(result); + return await callAction(actionName, actionArgs); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return mcpTextResult( - { - ok: false, - error: { - code: error instanceof JsonRpcError ? error.code : JsonRpcErrorCode.toolFailed, - message - } + return { + ok: false, + error: { + code: error instanceof JsonRpcError ? error.code : JsonRpcErrorCode.toolFailed, + message, }, - true - ); + }; } } - if (method === "resources/list") { + if (method === "ade/resources/list") { const lanes = await runtime.laneService.list({ includeArchived: false }); const laneRecords = lanes as unknown as Array>; return { @@ -6558,7 +6563,7 @@ export function createMcpRequestHandler(args: { }; } - if (method === "resources/read") { + if (method === "ade/resources/read") { const uri = assertNonEmptyString(params.uri, "uri"); return await readResource(runtime, uri); } @@ -6575,9 +6580,7 @@ export function createMcpRequestHandler(args: { throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Method not found: ${method}`); }) as JsonRpcHandler & { dispose: () => void }; - handler.dispose = () => { - disposeExternalMcpSubscription(); - }; + handler.dispose = () => {}; return handler; } diff --git a/apps/mcp-server/src/bootstrap.test.ts b/apps/ade-cli/src/bootstrap.test.ts similarity index 100% rename from apps/mcp-server/src/bootstrap.test.ts rename to apps/ade-cli/src/bootstrap.test.ts diff --git a/apps/mcp-server/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts similarity index 88% rename from apps/mcp-server/src/bootstrap.ts rename to apps/ade-cli/src/bootstrap.ts index d211cf107..d83eee2b0 100644 --- a/apps/mcp-server/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -32,7 +32,6 @@ import { createOrchestratorService } from "../../desktop/src/main/services/orche import { createAiOrchestratorService } from "../../desktop/src/main/services/orchestrator/aiOrchestratorService"; import { createAiIntegrationService } from "../../desktop/src/main/services/ai/aiIntegrationService"; import { createMissionBudgetService } from "../../desktop/src/main/services/orchestrator/missionBudgetService"; -import { createExternalMcpService, type ExternalMcpService } from "../../desktop/src/main/services/externalMcp/externalMcpService"; import { createComputerUseArtifactBrokerService, type ComputerUseArtifactBrokerService, @@ -90,7 +89,7 @@ export function createEventBuffer(capacity = 10_000): EventBuffer { }; } -export type AdeMcpPaths = { +export type AdeRuntimePaths = { adeDir: string; logsDir: string; processLogsDir: string; @@ -108,12 +107,12 @@ export type AdeMcpPaths = { missionStateDir: string; }; -export type AdeMcpRuntime = { +export type AdeRuntime = { projectRoot: string; workspaceRoot: string; projectId: string; project: { rootPath: string; displayName: string; baseRef: string }; - paths: AdeMcpPaths; + paths: AdeRuntimePaths; logger: Logger; db: AdeDb; laneService: ReturnType; @@ -140,7 +139,6 @@ export type AdeMcpRuntime = { linearIngressService?: ReturnType | null; linearRoutingService?: ReturnType | null; processService?: ReturnType | null; - externalMcpService: ExternalMcpService; computerUseArtifactBrokerService: ComputerUseArtifactBrokerService; orchestratorService: ReturnType; aiOrchestratorService: ReturnType; @@ -148,7 +146,7 @@ export type AdeMcpRuntime = { dispose: () => void; }; -export function ensureAdePaths(projectRoot: string): AdeMcpPaths { +export function ensureAdePaths(projectRoot: string): AdeRuntimePaths { const { paths } = initializeOrRepairAdeProject(projectRoot); return { adeDir: paths.adeDir, @@ -169,7 +167,7 @@ export function ensureAdePaths(projectRoot: string): AdeMcpPaths { }; } -export async function createAdeMcpRuntime(args: { projectRoot: string; workspaceRoot?: string } | string): Promise { +export async function createAdeRuntime(args: { projectRoot: string; workspaceRoot?: string } | string): Promise { const resolvedArgs = typeof args === "string" ? { projectRoot: args, workspaceRoot: args } : args; @@ -184,7 +182,7 @@ export async function createAdeMcpRuntime(args: { projectRoot: string; workspace const baseRef = await detectDefaultBaseRef(projectRoot); const paths = ensureAdePaths(projectRoot); - const logger = createFileLogger(path.join(paths.logsDir, "mcp-server.jsonl")); + const logger = createFileLogger(path.join(paths.logsDir, "ade-cli.jsonl")); const db = await openKvDb(paths.dbPath, logger); const project = toProjectInfo(projectRoot, baseRef); @@ -226,6 +224,7 @@ export async function createAdeMcpRuntime(args: { projectRoot: string; workspace logger, projectConfigService, projectRoot, + enableDynamicModelMetadata: false, }); const conflictService = createConflictService({ @@ -259,7 +258,6 @@ export async function createAdeMcpRuntime(args: { projectRoot: string; workspace const ptyService = createPtyService({ projectRoot, transcriptsDir: paths.transcriptsDir, - chatSessionsDir: paths.chatSessionsDir, laneService, sessionService, logger, @@ -280,7 +278,7 @@ export async function createAdeMcpRuntime(args: { projectRoot: string; workspace }); const issueInventoryService = createIssueInventoryService({ db }); - // Ensure MCP-specific tables exist (evaluation framework) + // Ensure evaluation tables exist for headless runtime checks. db.run(` CREATE TABLE IF NOT EXISTS orchestrator_evaluations ( id TEXT PRIMARY KEY, @@ -337,49 +335,6 @@ export async function createAdeMcpRuntime(args: { projectRoot: string; workspace aiIntegrationService, projectConfigService, }); - const externalMcpService = createExternalMcpService({ - projectRoot, - adeDir: paths.adeDir, - db, - projectId, - logger, - workerAgentService, - ctoStateService, - missionService, - workerBudgetService, - missionBudgetService, - }); - try { - await externalMcpService.start(); - } catch (error) { - logger.warn("external_mcp.bootstrap_start_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } - const externalMcpConfigWatcher = (() => { - try { - const watcher = fs.watch(paths.adeDir, (_eventType, fileName) => { - if (String(fileName ?? "").trim() !== "local.secret.yaml") return; - externalMcpService.reload(); - }); - watcher.on("error", (error) => { - logger.warn("external_mcp.bootstrap_watch_runtime_failed", { - error: error instanceof Error ? error.message : String(error), - }); - try { - watcher.close(); - } catch { - // Ignore watcher shutdown errors during degraded headless startup. - } - }); - return watcher; - } catch (error) { - logger.warn("external_mcp.bootstrap_watch_failed", { - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - })(); const orchestratorService = createOrchestratorService({ db, @@ -414,7 +369,6 @@ export async function createAdeMcpRuntime(args: { projectRoot: string; workspace projectRoot, missionService, orchestratorService, - externalMcpService, logger, }); @@ -448,7 +402,6 @@ export async function createAdeMcpRuntime(args: { projectRoot: string; workspace aiOrchestratorService, workerAgentService, workerBudgetService, - externalMcpService, computerUseArtifactBrokerService, orchestratorService, openExternal: async () => {}, @@ -486,7 +439,6 @@ export async function createAdeMcpRuntime(args: { projectRoot: string; workspace linearIngressService: headlessLinearServices.linearIngressService, linearRoutingService: headlessLinearServices.linearRoutingService, processService: headlessLinearServices.processService, - externalMcpService, computerUseArtifactBrokerService, orchestratorService, aiOrchestratorService, @@ -494,8 +446,6 @@ export async function createAdeMcpRuntime(args: { projectRoot: string; workspace dispose: () => { const swallow = (fn: () => void) => { try { fn(); } catch { /* ignore */ } }; swallow(() => headlessLinearServices.dispose()); - swallow(() => externalMcpConfigWatcher?.close()); - void externalMcpService.dispose().catch(() => {}); swallow(() => aiOrchestratorService.dispose()); swallow(() => testService.disposeAll()); swallow(() => ptyService.disposeAll()); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts new file mode 100644 index 000000000..858a02cf8 --- /dev/null +++ b/apps/ade-cli/src/cli.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from "vitest"; +import { buildCliPlan, formatOutput, parseCliArgs, renderLaneGraph, summarizeExecution } from "./cli"; + +describe("ADE CLI", () => { + it("parses global options without stealing command flags", () => { + const parsed = parseCliArgs([ + "--project-root", + "/tmp/project", + "--role", + "cto", + "actions", + "run", + "git.stageFile", + "--arg", + "laneId=lane-1", + ]); + + 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"]); + }); + + it("builds a generic ADE action invocation", () => { + 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; + + expect(plan.steps).toEqual([ + { + key: "result", + method: "ade/actions/call", + params: { + name: "run_ade_action", + arguments: { + domain: "git", + action: "stageFile", + args: { + laneId: "lane-1", + path: "src/index.ts", + }, + }, + }, + unwrapToolResult: true, + }, + ]); + }); + + it("maps Path to Merge start to pipeline settings plus resolver tool", () => { + const plan = buildCliPlan([ + "prs", + "path-to-merge", + "pr-1", + "--model", + "gpt-5.4", + "--max-rounds", + "3", + "--no-auto-merge", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + + expect(plan.steps).toHaveLength(2); + expect(plan.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "issue_inventory", + action: "savePipelineSettings", + argsList: ["pr-1", { maxRounds: 3, autoMerge: false }], + }, + }); + expect(plan.steps[1]?.params).toEqual({ + name: "pr_start_issue_resolution", + arguments: { + prId: "pr-1", + scope: "both", + modelId: "gpt-5.4", + }, + }); + }); + + 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(["files", "write", "src/index.ts"])).toThrow(/--text, --from-file, or --stdin/); + expect(() => buildCliPlan(["chat", "send", "hello"])).toThrow(/message text is required/); + expect(() => buildCliPlan(["agent", "spawn", "--prompt", "fix it"])).toThrow(/laneId is required/); + expect(() => buildCliPlan(["tests", "run", "--lane", "main"])).toThrow(/--suite or --command/); + }); + + it("unwraps typed ADE action results while preserving actions run envelopes", () => { + const connection = { + mode: "headless" as const, + projectRoot: "/tmp/project", + workspaceRoot: "/tmp/project", + socketPath: "/tmp/project/.ade/ade.sock", + request: async () => null, + close: () => {}, + }; + + const typed = summarizeExecution({ + plan: { kind: "execute", label: "git status", steps: [] }, + connection, + values: { + result: { + domain: "git", + action: "getStatus", + result: { clean: true }, + statusHints: {}, + }, + }, + } as any); + expect(typed).toEqual({ clean: true }); + + const escapeHatch = summarizeExecution({ + plan: { kind: "execute", label: "action run", steps: [] }, + connection, + values: { + result: { + domain: "git", + action: "getStatus", + result: { clean: true }, + statusHints: {}, + }, + }, + } as any); + expect(escapeHatch).toMatchObject({ domain: "git", action: "getStatus", result: { clean: true } }); + }); + + 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"); + + expect(output).toContain("ADE doctor"); + expect(output).toContain("cli version"); + expect(output).toContain("service actions"); + expect(output).toContain("Git repository detected"); + }); + + it("renders a compact lane graph", () => { + const graph = renderLaneGraph({ + lanes: [ + { id: "main", name: "main", branchRef: "main" }, + { id: "child", name: "child", branchRef: "feature", parentLaneId: "main" }, + ], + }); + + expect(graph).toContain("ADE lanes"); + expect(graph).toContain("+- main [main]"); + expect(graph).toContain("+- child [feature]"); + }); + + 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"]); + expect(joined.options.projectRoot).toBe(spaced.options.projectRoot); + expect(joined.options.role).toBe("cto"); + expect(joined.command).toEqual(["lanes", "list"]); + }); + + it("rejects invalid --role values", () => { + expect(() => parseCliArgs(["--role", "bogus", "lanes", "list"])).toThrow( + /--role must be one of/, + ); + }); + + it("maps default lanes/git/prs subcommands to the right RPC actions", () => { + const lanes = buildCliPlan(["lanes", "list"]); + expect(lanes.kind).toBe("execute"); + if (lanes.kind !== "execute") return; + expect(lanes.visualizer).toBe("lanes"); + expect(lanes.steps[0]?.params).toEqual({ + name: "list_lanes", + arguments: { includeArchived: false }, + }); + + const git = buildCliPlan(["git", "status"]); + expect(git.kind).toBe("execute"); + if (git.kind !== "execute") return; + expect(git.steps[0]?.params).toEqual({ + name: "git_get_sync_status", + arguments: {}, + }); + + const prs = buildCliPlan(["prs", "list"]); + expect(prs.kind).toBe("execute"); + if (prs.kind !== "execute") return; + expect(prs.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { domain: "pr", action: "listAll", args: {} }, + }); + }); + + it("renders an empty lane graph placeholder when no lanes are returned", () => { + expect(renderLaneGraph({ lanes: [] })).toBe("ADE lanes\n(no lanes)"); + expect(renderLaneGraph(null)).toBe("ADE lanes\n(no lanes)"); + }); + + it("attaches a rendered lane graph when the plan has the lanes visualizer", () => { + const connection = { + mode: "headless" as const, + projectRoot: "/tmp/project", + workspaceRoot: "/tmp/project", + socketPath: "/tmp/project/.ade/ade.sock", + request: async () => null, + close: () => {}, + }; + const summarized = summarizeExecution({ + plan: { kind: "execute", label: "lanes list", steps: [], visualizer: "lanes" }, + connection, + values: { + result: { + lanes: [ + { id: "main", name: "main", branchRef: "main" }, + { id: "child", name: "child", branchRef: "feature", parentLaneId: "main" }, + ], + }, + }, + } as any); + expect(summarized).toMatchObject({ + lanes: expect.any(Array), + }); + expect((summarized as any).visual).toContain("+- main [main]"); + expect((summarized as any).visual).toContain("+- child [feature]"); + }); +}); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts new file mode 100644 index 000000000..b33c2f7eb --- /dev/null +++ b/apps/ade-cli/src/cli.ts @@ -0,0 +1,2622 @@ +#!/usr/bin/env node +import { Buffer } from "node:buffer"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; +import { type JsonRpcHandler, type JsonRpcId, type JsonRpcRequest } from "./jsonrpc"; + +type JsonObject = Record; + +type GlobalOptions = { + projectRoot: string | null; + workspaceRoot: string | null; + role: "cto" | "orchestrator" | "agent" | "external" | "evaluator"; + headless: boolean; + requireSocket: boolean; + pretty: boolean; + text: boolean; + timeoutMs: number; +}; + +type ParsedCli = { + options: GlobalOptions; + command: string[]; +}; + +type InvocationStep = { + key: string; + method: string; + params?: JsonObject; + unwrapToolResult?: boolean; + optional?: boolean; +}; + +type FormatterId = + | "status" + | "doctor" + | "auth" + | "lanes" + | "lane-detail" + | "git-status" + | "diff-summary" + | "file-read" + | "files-tree" + | "files-search" + | "prs-list" + | "pr-detail" + | "pr-checks" + | "pr-comments" + | "run-defs" + | "run-runtime" + | "chat-list" + | "tests-runs" + | "proof-list" + | "actions-list" + | "action-result"; + +type CliPlan = + | { kind: "help"; text: string } + | { kind: "execute"; label: string; steps: InvocationStep[]; visualizer?: "lanes"; summary?: "status" | "doctor" | "auth"; formatter?: FormatterId }; + +type CliConnection = { + mode: "desktop-socket" | "headless"; + projectRoot: string; + workspaceRoot: string; + socketPath: string; + request: (method: string, params?: JsonObject) => Promise; + close: () => Promise | void; +}; + +class CliUsageError extends Error {} + +class CliToolError extends Error { + details: unknown; + + constructor(message: string, details: unknown) { + super(message); + this.details = details; + } +} + +class CliExecutionError extends Error { + details: JsonObject; + + constructor(message: string, details: JsonObject) { + super(message); + this.details = details; + } +} + +type ReadinessCheck = { + ready: boolean; + status: "ready" | "warning" | "missing" | "unavailable"; + message: string; + nextAction?: string; + details?: JsonObject; +}; + +const VERSION = "0.0.0"; +const PROTOCOL_VERSION = "2025-06-18"; + +const ADE_BANNER = String.raw` + _ ____ _____ + / \ | _ \| ____| + / _ \ | | | | _| + / ___ \| |_| | |___ + /_/ \_\____/|_____| +`; + +const TOP_LEVEL_HELP = `${ADE_BANNER} + Agent-focused command-line interface for ADE + + $ ade help Display help for a command + $ ade auth status Check local ADE CLI readiness + $ 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 files tree | read | write | search Read and edit lane workspaces + $ ade prs list | create | path-to-merge Manage PRs, queues, and Path to Merge repair rounds + $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime + $ ade shell start | write | resize | close Launch and control tracked shell sessions + $ ade chat list | create | send | interrupt Work with ADE agent chats + $ ade agent spawn --lane --prompt Launch an agent session in ADE + $ ade cto state | chats Operate CTO state and Work chats + $ ade linear workflows | run | sync Operate Linear routing and sync workflows + $ ade coordinator Call coordinator runtime tools + $ ade tests list | run | stop | runs | logs Run configured test suites + $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts + $ ade memory add | search | pin Use ADE memory + $ ade settings action Call project config actions + $ ade actions list | run | status Escape hatch for every ADE service action + + Global options: + --project-root --workspace-root --headless --socket --json --text --timeout-ms + + Common agent flows: + $ ade lanes create --name fix-login + $ ade git commit --lane + $ ade prs create --lane --base main --draft + $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge + $ ade proof record --seconds 20 + + Escape hatch: + $ ade actions list --text + $ ade actions run --arg key=value + + try: ade lanes list --text +`; + +const HELP_BY_COMMAND: Record = { + lanes: `${ADE_BANNER} + Lanes + + $ ade lanes list --text Show the lane stack graph + $ ade lanes show --text Inspect one lane + $ ade lanes create --name Create a lane from the current context + $ ade lanes child --lane --name Create a child lane + $ ade lanes import --branch Bring an existing worktree/branch into ADE + $ ade lanes actions List lane service actions +`, + git: `${ADE_BANNER} + Git + + $ ade git status --lane --text Show ADE-aware sync status + $ ade git commit --lane [-m ] Commit, generating a message when omitted + $ ade git push --lane --set-upstream Push through ADE + $ ade git stash push|list|apply|pop Use ADE lane stash actions + $ ade git rebase --lane --ai Rebase with ADE conflict support + $ ade diff changes --lane --text Inspect changed files +`, + prs: `${ADE_BANNER} + Pull requests + + $ ade prs list --text List PRs known to ADE + $ ade prs create --lane --base main Open a PR from a lane + $ ade prs checks --text Show check status + $ ade prs comments --text Show unresolved review work + $ ade prs inventory Refresh ADE issue inventory + $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge + $ ade prs resolve-thread --thread Resolve a review thread +`, + run: `${ADE_BANNER} + Run tab + + $ ade run defs --text List configured run commands + $ ade run ps --lane --text List process runtime state + $ ade run start --lane Start a process in a lane + $ ade run logs --run --text Tail process logs + $ ade run stack start --stack --lane Start a process stack +`, + files: `${ADE_BANNER} + Files + + $ ade files workspaces --text List workspace roots + $ ade files tree --workspace --path src Show a workspace tree + $ ade files read --workspace --text Read a file + $ ade files write --workspace --stdin + $ ade files search --workspace -q Search text in a workspace +`, + proof: `${ADE_BANNER} + Proof and computer use + + $ ade proof status --text Show local proof backend capabilities + $ ade proof list --text List captured artifacts + $ ade proof screenshot Capture a screenshot artifact + $ ade proof record --seconds 20 Capture a short video proof + $ ade proof ingest --input-json '{...}' Ingest external proof artifacts +`, + actions: `${ADE_BANNER} + ADE actions + + $ ade actions list --text Domain-grouped action catalog + $ ade actions list --domain git Narrow the catalog + $ ade actions run git.stageFile --arg laneId= --arg path=src/index.ts + $ ade actions run --input-json '{"key":"value"}' + $ ade actions status --text Runtime action availability +`, +}; + +function isRecord(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function parseBooleanEnv(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; +} + +function parsePrimitive(value: string): unknown { + const trimmed = value.trim(); + if (trimmed === "true") return true; + if (trimmed === "false") return false; + if (trimmed === "null") return null; + if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(trimmed)) { + const parsed = Number(trimmed); + if (Number.isFinite(parsed)) return parsed; + } + return value; +} + +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)}`); + } +} + +function parseObjectJson(value: string, label: string): JsonObject { + const parsed = parseJson(value, label); + if (!isRecord(parsed)) { + throw new CliUsageError(`${label} must be a JSON object.`); + } + return parsed; +} + +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.`); + } + const key = value.slice(0, index).trim(); + if (!key.length) { + throw new CliUsageError(`${label} is missing a key.`); + } + return { key, value: value.slice(index + 1) }; +} + +function setPath(target: JsonObject, key: string, value: unknown): void { + const parts = key.split(".").map((part) => part.trim()).filter(Boolean); + if (parts.length === 0) { + throw new CliUsageError("Argument key cannot be empty."); + } + let cursor: JsonObject = target; + for (const part of parts.slice(0, -1)) { + const existing = cursor[part]; + if (!isRecord(existing)) { + const next: JsonObject = {}; + cursor[part] = next; + cursor = next; + continue; + } + cursor = existing; + } + cursor[parts[parts.length - 1]!] = value; +} + +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}=`)); + if (!matchedName) continue; + if (token.includes("=")) { + args.splice(index, 1); + return token.slice(token.indexOf("=") + 1); + } + const value = args[index + 1]; + if (value == null) { + throw new CliUsageError(`${token} requires a value.`); + } + args.splice(index, 2); + return value; + } + return null; +} + +function readFlag(args: string[], names: string[]): boolean { + for (let index = 0; index < args.length; index += 1) { + if (!names.includes(args[index]!)) continue; + args.splice(index, 1); + return true; + } + return false; +} + +function firstPositional(args: string[]): string | null { + const index = args.findIndex((arg) => arg !== "--" && !arg.startsWith("-")); + if (index < 0) return null; + const [value] = args.splice(index, 1); + return value ?? null; +} + +function collectGenericObjectArgs(args: string[], base: JsonObject = {}): JsonObject { + const input: JsonObject = { ...base }; + while (true) { + const inputJson = readValue(args, ["--input-json", "--json-input", "--input"]); + if (inputJson != null) { + Object.assign(input, parseObjectJson(inputJson, "--input-json")); + continue; + } + + const rawArg = readValue(args, ["--arg", "--set"]); + if (rawArg != null) { + const { key, value } = parseAssignment(rawArg, "--arg"); + setPath(input, key, parsePrimitive(value)); + continue; + } + + const jsonArg = readValue(args, ["--arg-json", "--set-json"]); + if (jsonArg != null) { + const { key, value } = parseAssignment(jsonArg, "--arg-json"); + setPath(input, key, parseJson(value, `--arg-json ${key}`)); + continue; + } + + break; + } + return input; +} + +function readLaneId(args: string[]): string | null { + return readValue(args, ["--lane", "--lane-id"]) ?? null; +} + +function readPrId(args: string[]): string | null { + return readValue(args, ["--pr", "--pr-id"]) ?? null; +} + +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); + if (!Number.isFinite(parsed)) { + throw new CliUsageError(`${names[0]} must be an integer.`); + } + return parsed; +} + +function readNumberOption(args: string[], names: string[], fallback?: number): number | undefined { + const value = readValue(args, names); + if (value == null) return fallback; + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new CliUsageError(`${names[0]} must be a number.`); + } + return parsed; +} + +function requireValue(value: string | null, label: string): string { + if (value && value.trim().length > 0) return value.trim(); + throw new CliUsageError(`${label} is required.`); +} + +function maybePut(target: JsonObject, key: string, value: unknown): void { + if (value !== undefined && value !== null && value !== "") { + target[key] = value; + } +} + +function parseCliArgs(argv: string[]): ParsedCli { + const command: string[] = []; + const options: GlobalOptions = { + projectRoot: null, + workspaceRoot: null, + role: (asString(process.env.ADE_DEFAULT_ROLE) as GlobalOptions["role"] | null) ?? "agent", + headless: parseBooleanEnv(process.env.ADE_CLI_HEADLESS), + requireSocket: false, + pretty: true, + text: false, + timeoutMs: 10 * 60 * 1000, + }; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]!; + if (token === "--") { + command.push(token, ...argv.slice(index + 1)); + break; + } + if (token === "--project-root") { + options.projectRoot = path.resolve(requireValue(argv[index + 1] ?? null, "--project-root")); + index += 1; + continue; + } + if (token.startsWith("--project-root=")) { + options.projectRoot = path.resolve(requireValue(token.slice("--project-root=".length), "--project-root")); + continue; + } + if (token === "--workspace-root") { + options.workspaceRoot = path.resolve(requireValue(argv[index + 1] ?? null, "--workspace-root")); + index += 1; + continue; + } + if (token.startsWith("--workspace-root=")) { + options.workspaceRoot = path.resolve(requireValue(token.slice("--workspace-root=".length), "--workspace-root")); + continue; + } + if (token === "--role") { + options.role = parseRole(requireValue(argv[index + 1] ?? null, "--role")); + index += 1; + continue; + } + if (token.startsWith("--role=")) { + options.role = parseRole(requireValue(token.slice("--role=".length), "--role")); + continue; + } + if (token === "--headless" || token === "--no-socket") { + options.headless = true; + continue; + } + if (token === "--socket") { + options.requireSocket = true; + options.headless = false; + continue; + } + if (token === "--compact") { + options.pretty = false; + continue; + } + if (token === "--pretty") { + options.pretty = true; + continue; + } + if (token === "--text") { + options.text = true; + continue; + } + if (token === "--json") { + options.text = false; + continue; + } + if (token === "--timeout-ms") { + 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."); + } + options.timeoutMs = parsed; + index += 1; + continue; + } + command.push(token); + } + + return { options, command }; +} + +function parseRole(value: string): GlobalOptions["role"] { + 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."); +} + +function actionCallStep(key: string, name: string, args: JsonObject = {}): InvocationStep { + return { + key, + method: "ade/actions/call", + params: { name, arguments: args }, + unwrapToolResult: true, + }; +} + +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 { + return actionCallStep(key, "run_ade_action", { domain, action, argsList }); +} + +function listActionsStep(key: string, domain?: string): InvocationStep { + return actionCallStep(key, "list_ade_actions", domain ? { domain } : {}); +} + +function buildActionRunStep(args: string[]): InvocationStep { + const target = firstPositional(args); + if (!target) throw new CliUsageError("actions run requires or ."); + + let domain: string; + let action: string; + if (target.includes(".")) { + const parts = target.split("."); + domain = requireValue(parts.shift() ?? null, "domain"); + action = requireValue(parts.join("."), "action"); + } else { + domain = target; + action = requireValue(firstPositional(args), "action"); + } + + 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 }); + } + + 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") }); + } + + const scalar = readValue(args, ["--scalar", "--arg-value"]); + if (scalar != null) { + return actionCallStep("result", "run_ade_action", { domain, action, arg: parsePrimitive(scalar) }); + } + + return actionStep("result", domain, action, collectGenericObjectArgs(args)); +} + +function buildLanePlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "list"; + if (sub === "actions") { + return { kind: "execute", label: "lane actions", steps: [listActionsStep("actions", "lane")] }; + } + if (sub === "action") { + return { kind: "execute", label: "lane action", steps: [buildActionRunStep(["lane", ...args])] }; + } + if (sub === "list" || sub === "ls") { + const input = collectGenericObjectArgs(args, { + includeArchived: readFlag(args, ["--archived", "--include-archived"]), + }); + const visual = readFlag(args, ["--visual", "--graph"]); + const noVisual = readFlag(args, ["--no-visual"]); + return { + kind: "execute", + label: "lanes list", + steps: [actionCallStep("result", "list_lanes", input)], + visualizer: visual || !noVisual ? "lanes" : undefined, + }; + } + 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 })] }; + } + 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"]) }))] }; + } + 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) }))] }; + 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"]) }))] }; + } + 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)); + if (sub === "child" && !input.parentLaneId) throw new CliUsageError("parent lane is required. Use --lane or --parent ."); + return { kind: "execute", label: "lane create", steps: [actionCallStep("result", "create_lane", collectGenericObjectArgs(args, input))] }; + } + if (sub === "children") { + const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); + return { kind: "execute", label: "lane children", steps: [actionArgsListStep("result", "lane", "getChildren", [laneId])] }; + } + if (sub === "stack") { + 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"]) }))] }; + } + 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) }))] }; + } + 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) }))] }; + } + 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"]) }))] }; + } + 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 }))] }; + } + 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"]) }))] }; + } + 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"]) }))] }; + } + 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 }))] }; + } + 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) }))] }; + } + if (sub === "import" || sub === "import-branch") { + const input: JsonObject = {}; + 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, "baseBranch", readValue(args, ["--base", "--base-branch"])); + 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: `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")] }; + } + if (sub === "action") { + 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())] }; + 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 }))] }; + } + 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))] }; + } + if (sub === "generate-message") { + 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 === "checkout") { + const branchName = requireValue(readValue(args, ["--branch", "--branch-name"]) ?? firstPositional(args), "branchName"); + return { kind: "execute", label: "git checkout", steps: [actionCallStep("result", "git_checkout_branch", withLane({ branchName }))] }; + } + if (sub === "conflicts") return { kind: "execute", label: "git conflicts", steps: [actionCallStep("result", "get_lane_conflict_state", withLane())] }; + 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); + const message = readValue(args, ["--message", "-m"]); + const common = withLane({ + ...(stashRef ? { stashRef } : {}), + includeUntracked: !readFlag(args, ["--tracked-only"]), + ...(message ? { message } : {}), + }); + const toolNameByAction: Record = { + push: "stash_push", + save: "stash_push", + list: "list_stashes", + ls: "list_stashes", + apply: "stash_apply", + pop: "stash_pop", + drop: "stash_drop", + clear: "stash_clear", + }; + 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)] }; + } + 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"); + const actionBySub: Record = { + stage: "stageFile", + unstage: "unstageFile", + discard: "discardFile", + restore: "restoreStagedFile", + }; + return { kind: "execute", label: `git ${sub}`, steps: [actionStep("result", "git", actionBySub[sub]!, withLane({ path: pathArg }))] }; + } + 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 }))] }; + } + 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 }))] }; + } + 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"]) }))] }; + } + 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"]) }))] }; + } + 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 actionAliases: Record = { + commits: "listRecentCommits", + sync: "sync", + }; + 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")] }; + const laneId = readLaneId(args); + const withLane = (base: JsonObject = {}) => collectGenericObjectArgs(args, { ...base, ...(laneId ? { laneId } : {}) }); + if (sub === "changes" || sub === "summary") { + const id = requireValue(laneId ?? readValue(args, ["--lane", "--lane-id"]), "laneId"); + return { + kind: "execute", + label: "diff changes", + steps: [actionArgsListStep("result", "diff", "getChanges", [id])], + }; + } + if (sub === "file") { + 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"]), + }))], + }; + } + 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])] }; + + const prId = readPrId(args); + 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 === "show" || sub === "detail" || sub === "view") { + const id = requireValue(prId ?? firstPositional(args), "prId"); + 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 === "create") { + const laneId = readLaneId(args) ?? readValue(args, ["--lane-id"]); + const input: JsonObject = {}; + input.laneId = requireValue(laneId, "laneId"); + maybePut(input, "baseBranch", readValue(args, ["--base", "--base-branch"])); + maybePut(input, "title", readValue(args, ["--title"])); + maybePut(input, "body", readValue(args, ["--body"])); + input.draft = readFlag(args, ["--draft"]); + 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") return { kind: "execute", label: "PR link", steps: [actionStep("result", "pr", "linkToLane", collectGenericObjectArgs(args, { laneId: readLaneId(args) ?? firstPositional(args), url: readValue(args, ["--url"]) }))] }; + + const scalarPrActions: Record = { + status: "getStatus", + files: "getFiles", + "action-runs": "getActionRuns", + activity: "getActivity", + reviews: "getReviews", + threads: "getReviewThreads", + deployments: "getDeployments", + github: "openInGitHub", + "conflict-analysis": "getConflictAnalysis", + "merge-context": "getMergeContext", + }; + if (scalarPrActions[sub]) { + const id = requireValue(prId ?? firstPositional(args), "prId"); + 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") { + const id = requireValue(prId ?? firstPositional(args), "prId"); + const actionBySub: Record = { delete: "delete", land: "land", close: "closePr", reopen: "reopenPr" }; + return { kind: "execute", label: `PR ${sub}`, steps: [actionStep("result", "pr", actionBySub[sub]!, collectGenericObjectArgs(args, { prId: id, method: readValue(args, ["--method"]) }))] }; + } + 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) }))] }; + } + 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("-")) }))] }; + } + if (sub === "reviewers") { + const mode = firstPositional(args) ?? "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("-")) }))] }; + } + if (sub === "review") { + const mode = firstPositional(args) ?? "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"]) ?? "" }))] }; + } + 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"]) }))] }; + } + if (sub === "review-comment") { + const mode = firstPositional(args) ?? "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"]) }))] }; + } + if (sub === "thread") { + const mode = firstPositional(args) ?? "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"]) }))] }; + } + 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) }))] }; + } + 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", [])] }; + 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])] }; + } + + if (sub === "path-to-merge" || sub === "resolve" || sub === "issue-resolution") { + let mode = "start"; + let positionalPrId = firstPositional(args); + if (positionalPrId === "start" || positionalPrId === "preview") { + mode = positionalPrId; + positionalPrId = firstPositional(args); + } + const id = requireValue(prId ?? positionalPrId, "prId"); + const scope = readValue(args, ["--scope"]) ?? "both"; + 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"])); + const maxRounds = readIntOption(args, ["--max-rounds", "--rounds"]); + const autoMerge = readFlag(args, ["--auto-merge"]); + const noAutoMerge = readFlag(args, ["--no-auto-merge"]); + const mergeMethod = readValue(args, ["--merge-method"]); + const steps: InvocationStep[] = []; + if (maxRounds != null || autoMerge || noAutoMerge || mergeMethod) { + steps.push(actionArgsListStep("pipelineSettings", "issue_inventory", "savePipelineSettings", [ + id, + { + ...(maxRounds != null ? { maxRounds } : {}), + ...(autoMerge || noAutoMerge ? { autoMerge: autoMerge && !noAutoMerge } : {}), + ...(mergeMethod ? { mergeMethod } : {}), + }, + ])); + } + steps.push(actionCallStep("result", mode === "preview" ? "pr_preview_issue_resolution_prompt" : "pr_start_issue_resolution", collectGenericObjectArgs(args, input))); + return { kind: "execute", label: `PR path-to-merge ${mode}`, steps }; + } + + 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 maxRounds = readIntOption(args, ["--max-rounds", "--rounds"]); + const mergeMethod = readValue(args, ["--merge-method"]); + const settings = collectGenericObjectArgs(args, { + ...(maxRounds != null ? { maxRounds } : {}), + ...(mergeMethod ? { mergeMethod } : {}), + }); + 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])] }; + } + 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 create", steps: [actionCallStep("result", "create_queue", collectGenericObjectArgs(args))] }; + } + + if (sub === "integration") { + const mode = firstPositional(args) ?? "simulate"; + const integrationMap: Record = { + proposals: "listIntegrationProposals", + workflows: "listIntegrationWorkflows", + update: "updateIntegrationProposal", + delete: "deleteIntegrationProposal", + commit: "commitIntegration", + "resolve-start": "startIntegrationResolution", + "resolve-state": "getIntegrationResolutionState", + "recheck-step": "recheckIntegrationStep", + }; + if (integrationMap[mode]) { + 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 (mode === "cleanup") { + const cleanupMode = firstPositional(args) ?? "run"; + 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))] }; + } + + if (sub === "inventory") { + const first = firstPositional(args); + 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") }))] }; + } + const actionByMode: Record = { + get: "getInventory", + new: "getNewItems", + "mark-sent": "markSentToAgent", + "mark-fixed": "markFixed", + dismiss: "markDismissed", + escalate: "markEscalated", + reset: "resetInventory", + }; + const action = actionByMode[mode]; + if (!action) throw new CliUsageError("prs inventory supports get, new, mark-sent, mark-fixed, dismiss, escalate, or reset."); + const id = requireValue(prId ?? positionalPrId, "prId"); + const itemIds = args.filter((entry) => !entry.startsWith("-")); + const argsListByMode: Record = { + get: [id], + new: [id], + "mark-sent": [id, itemIds, readValue(args, ["--session", "--session-id"]) ?? "", readIntOption(args, ["--round"], 0) ?? 0], + "mark-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])] }; + } + + if (sub === "convergence") { + const mode = firstPositional(args) ?? "status"; + const actionByMode: Record = { + status: "getConvergenceStatus", + runtime: "getConvergenceRuntime", + get: "getConvergenceRuntime", + save: "saveConvergenceRuntime", + reset: "resetConvergenceRuntime", + reconcile: "reconcileConvergenceSessionExit", + }; + const action = actionByMode[mode]; + 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)])] }; + } + 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 ${mode}`, steps: [actionArgsListStep("result", "issue_inventory", action, [id])] }; + } + + return { kind: "execute", label: `PR ${sub}`, steps: [actionStep("result", "pr", sub, withPr())] }; +} + +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))] }; + const laneId = readLaneId(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 } : {}), + }); + if (sub === "ps" || sub === "list" || sub === "runtime") { + const id = requireValue(laneId, "laneId"); + 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 === "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) }))] }; + } + if (sub === "stack") { + const mode = requireValue(firstPositional(args), "stack action"); + const stackId = requireValue(readValue(args, ["--stack", "--stack-id"]) ?? firstPositional(args), "stackId"); + const methodByMode: Record = { start: "startStack", stop: "stopStack", restart: "restartStack" }; + const 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 (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 === "start" || sub === "create") { + const laneId = readLaneId(args); + const startupCommandIndex = args.indexOf("--"); + const startupCommand = startupCommandIndex >= 0 ? args.splice(startupCommandIndex + 1).join(" ") : readValue(args, ["--command", "-c"]); + if (startupCommandIndex >= 0) args.splice(startupCommandIndex, 1); + const input = collectGenericObjectArgs(args, { + ...(laneId ? { laneId } : {}), + cwd: readValue(args, ["--cwd"]), + title: readValue(args, ["--title"]), + startupCommand, + toolType: readValue(args, ["--tool-type"]) ?? "shell", + cols: readIntOption(args, ["--cols"], 120), + rows: readIntOption(args, ["--rows"], 36), + tracked: !readFlag(args, ["--untracked"]), + }); + 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))] }; +} + +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: [actionStep("result", "chat", "getSessionSummary", withSession())] }; + if (sub === "create" || sub === "spawn") return { kind: "execute", label: "chat create", steps: [actionStep("result", "chat", "createSession", collectGenericObjectArgs(args, { laneId: readLaneId(args), provider: readValue(args, ["--provider"]), modelId: readValue(args, ["--model", "--model-id"]), permissionMode: readValue(args, ["--permission-mode", "--permissions"]), surface: readValue(args, ["--surface"]) ?? "work" }))] }; + if (sub === "send") return { kind: "execute", label: "chat send", steps: [actionStep("result", "chat", "sendMessage", withSession({ sessionId: requireValue(sessionId, "sessionId"), text: requireValue(readValue(args, ["--text", "--message"]) ?? args.join(" "), "message text") }))] }; + if (sub === "interrupt") return { kind: "execute", label: "chat interrupt", steps: [actionStep("result", "chat", "interrupt", withSession({ sessionId: requireValue(sessionId, "sessionId") }))] }; + if (sub === "resume") return { kind: "execute", label: "chat resume", steps: [actionStep("result", "chat", "resumeSession", withSession())] }; + if (sub === "delete" || sub === "rm") return { kind: "execute", label: "chat delete", steps: [actionStep("result", "chat", "deleteSession", withSession())] }; + if (sub === "models") return { kind: "execute", label: "chat models", steps: [actionStep("result", "chat", "getAvailableModels", collectGenericObjectArgs(args))] }; + if (sub === "slash") return { kind: "execute", label: "chat slash commands", steps: [actionStep("result", "chat", "getSlashCommands", collectGenericObjectArgs(args))] }; + return { kind: "execute", label: `chat ${sub}`, steps: [actionStep("result", "chat", sub, withSession())] }; +} + +function buildTestsPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "list"; + if (sub === "actions") return { kind: "execute", label: "test actions", steps: [listActionsStep("actions", "tests")] }; + if (sub === "list" || sub === "suites") return { kind: "execute", label: "test suites", steps: [actionStep("result", "tests", "listSuites", collectGenericObjectArgs(args))] }; + if (sub === "run") { + const laneId = requireValue(readLaneId(args), "laneId"); + const suiteId = readValue(args, ["--suite", "--suite-id"]) ?? firstPositional(args); + const command = readValue(args, ["--command", "-c"]); + if (!suiteId && !command) throw new CliUsageError("tests run requires --suite or --command ."); + const input = collectGenericObjectArgs(args, { + laneId, + suiteId, + command, + waitForCompletion: readFlag(args, ["--wait"]), + timeoutMs: readIntOption(args, ["--timeout-ms"]), + maxLogBytes: readIntOption(args, ["--max-log-bytes"]), + }); + return { kind: "execute", label: "test run", steps: [actionCallStep("result", "run_tests", input)] }; + } + if (sub === "stop") return { kind: "execute", label: "test stop", steps: [actionStep("result", "tests", "stop", collectGenericObjectArgs(args, { runId: requireValue(readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), "runId") }))] }; + if (sub === "runs") return { kind: "execute", label: "test runs", steps: [actionStep("result", "tests", "listRuns", collectGenericObjectArgs(args, { laneId: readLaneId(args), suiteId: readValue(args, ["--suite", "--suite-id"]), limit: readIntOption(args, ["--limit"]) }))] }; + if (sub === "logs" || sub === "log") return { kind: "execute", label: "test logs", steps: [actionStep("result", "tests", "getLogTail", collectGenericObjectArgs(args, { runId: requireValue(readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), "runId"), maxBytes: readIntOption(args, ["--max-bytes"], 220_000) }))] }; + return { kind: "execute", label: `tests ${sub}`, steps: [actionStep("result", "tests", sub, collectGenericObjectArgs(args))] }; +} + +function readFileTextInput(args: string[]): string | undefined { + const text = readValue(args, ["--text"]); + if (text != null) return text; + const filePath = readValue(args, ["--from-file"]); + if (filePath != null) return fs.readFileSync(path.resolve(filePath), "utf8"); + if (readFlag(args, ["--stdin"])) return fs.readFileSync(0, "utf8"); + return undefined; +} + +function buildFilesPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "workspaces"; + if (sub === "actions") return { kind: "execute", label: "file actions", steps: [listActionsStep("actions", "file")] }; + const workspaceId = readValue(args, ["--workspace", "--workspace-id"]); + 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) }))] }; + } + 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"]) }))] }; + } + 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") }))] }; + } + 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 (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) ?? "" }))] }; + } + if (sub === "mkdir") { + 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) }))] }; + } + 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") }))] }; + } + 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"]) }))] }; + } + 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: `files ${sub}`, steps: [actionStep("result", "file", sub, withWorkspace())] }; +} + +function buildProofPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "status"; + 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))] }; + 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 === "screenshot") return { kind: "execute", label: "computer-use screenshot", steps: [actionCallStep("result", "screenshot_environment", collectGenericObjectArgs(args))] }; + if (sub === "record") return { kind: "execute", label: "computer-use record", steps: [actionCallStep("result", "record_environment", collectGenericObjectArgs(args, { durationSec: readNumberOption(args, ["--seconds", "--duration-sec"]) }))] }; + if (sub === "launch") return { kind: "execute", label: "computer-use launch", steps: [actionCallStep("result", "launch_app", collectGenericObjectArgs(args, { app: readValue(args, ["--app"]) ?? firstPositional(args) }))] }; + if (sub === "interact") return { kind: "execute", label: "computer-use interact", steps: [actionCallStep("result", "interact_gui", collectGenericObjectArgs(args))] }; + return { kind: "execute", label: `proof ${sub}`, steps: [actionStep("result", "computer_use_artifacts", sub, collectGenericObjectArgs(args))] }; +} + +function buildMemoryPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "search"; + if (sub === "actions") return { kind: "execute", label: "memory actions", steps: [listActionsStep("actions", "memory")] }; + if (sub === "add") return { kind: "execute", label: "memory add", steps: [actionCallStep("result", "memory_add", collectGenericObjectArgs(args, { content: requireValue(readValue(args, ["--content"]) ?? args.join(" "), "content"), category: requireValue(readValue(args, ["--category"]), "category"), scope: readValue(args, ["--scope"]) }))] }; + if (sub === "search") return { kind: "execute", label: "memory search", steps: [actionCallStep("result", "memory_search", collectGenericObjectArgs(args, { query: requireValue(readValue(args, ["--query", "-q"]) ?? args.join(" "), "query") }))] }; + if (sub === "pin") return { kind: "execute", label: "memory pin", steps: [actionCallStep("result", "memory_pin", collectGenericObjectArgs(args, { id: requireValue(readValue(args, ["--memory", "--memory-id", "--id"]) ?? firstPositional(args), "memory id") }))] }; + if (sub === "core") return { kind: "execute", label: "memory core", steps: [actionCallStep("result", "memory_update_core", collectGenericObjectArgs(args))] }; + return { kind: "execute", label: `memory ${sub}`, steps: [actionStep("result", "memory", sub, collectGenericObjectArgs(args))] }; +} + +function buildSettingsPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "get"; + if (sub === "actions") return { kind: "execute", label: "settings actions", steps: [listActionsStep("actions", "project_config")] }; + if (sub === "action") return { kind: "execute", label: "settings action", steps: [buildActionRunStep(["project_config", ...args])] }; + return { kind: "execute", label: `settings ${sub}`, steps: [actionStep("result", "project_config", sub, collectGenericObjectArgs(args))] }; +} + +function 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 === "call" || sub === "direct" || sub === "tool") { + const toolName = requireValue(firstPositional(args), "toolName"); + 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."); +} + +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=")) + .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"); + 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 } : {}), + }))], + }; + } + 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 === "chats" || sub === "chat") { + const mode = firstPositional(args) ?? "list"; + const toolByMode: Record = { + list: "listChats", + spawn: "spawnChat", + status: "getChatStatus", + transcript: "readChatTranscript", + send: "sendChatMessage", + interrupt: "interruptChat", + resume: "resumeChat", + 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"]) }))] }; + } + return { kind: "execute", label: `CTO ${sub}`, steps: [actionCallStep("result", sub.replace(/-/g, "_"), collectGenericObjectArgs(args))] }; +} + +function buildLinearPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "workflows"; + 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 = { + status: "getLinearRunStatus", + resolve: "resolveLinearRunAction", + cancel: "cancelLinearRun", + 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 (sub === "route") { + const mode = firstPositional(args) ?? "cto"; + const toolByMode: Record = { + cto: "routeLinearIssueToCto", + mission: "routeLinearIssueToMission", + 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 (sub === "sync") { + const mode = firstPositional(args) ?? "dashboard"; + const toolByMode: Record = { + dashboard: "getLinearSyncDashboard", + run: "runLinearSyncNow", + queue: "listLinearSyncQueue", + resolve: "resolveLinearSyncQueueItem", + 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 (sub === "ingress") { + const mode = firstPositional(args) ?? "status"; + const toolByMode: Record = { + status: "getLinearIngressStatus", + events: "listLinearIngressEvents", + 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))] }; + } + 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))] }; + const mode = firstPositional(args) ?? "get"; + const actionByMode: Record = { + get: "getPolicy", + save: "savePolicy", + validate: "validatePolicy", + normalize: "normalizePolicy", + revisions: "listRevisions", + rollback: "rollbackRevision", + 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))] }; +} + +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))] }; +} + +function buildCliPlan(command: string[]): CliPlan { + const args = [...command]; + const primary = firstPositional(args); + if (!primary || primary === "-h" || primary === "--help") { + return { kind: "help", text: TOP_LEVEL_HELP }; + } + if (primary === "help") { + const topic = (firstPositional(args) ?? "").toLowerCase(); + const aliases: Record = { + lane: "lanes", + pr: "prs", + process: "run", + processes: "run", + file: "files", + computer: "proof", + "computer-use": "proof", + action: "actions", + }; + const key = aliases[topic] ?? topic; + return { kind: "help", text: key && HELP_BY_COMMAND[key] ? HELP_BY_COMMAND[key] : TOP_LEVEL_HELP }; + } + if (primary === "version" || primary === "--version" || primary === "-v") { + return { kind: "help", text: `ade ${VERSION}\n` }; + } + if (primary === "status") { + return { kind: "execute", label: "status", summary: "status", steps: [{ key: "ping", method: "ping" }] }; + } + if (primary === "doctor") { + return { + kind: "execute", + label: "doctor", + summary: "doctor", + steps: [ + { key: "ping", method: "ping" }, + { key: "rpcActions", method: "ade/actions/list" }, + listActionsStep("actions"), + { ...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."); + return { + kind: "execute", + label: "auth status", + summary: "auth", + steps: [ + { key: "actions", method: "ade/actions/list" }, + { ...actionStep("projectConfig", "project_config", "get"), optional: true }, + ], + }; + } + if (primary === "lanes" || primary === "lane") return buildLanePlan(args); + if (primary === "git") return buildGitPlan(args); + if (primary === "diff" || primary === "diffs") return buildDiffPlan(args); + if (primary === "files" || primary === "file") return buildFilesPlan(args); + if (primary === "prs" || primary === "pr") return buildPrPlan(args); + if (primary === "run" || primary === "process" || primary === "processes") return buildRunPlan(args); + if (primary === "shell" || primary === "pty") return buildShellPlan(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 === "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 === "tests" || primary === "test") return buildTestsPlan(args); + if (primary === "proof" || primary === "computer-use" || primary === "artifacts") return buildProofPlan(args); + if (primary === "memory") return buildMemoryPlan(args); + if (primary === "settings" || primary === "config") return buildSettingsPlan(args); + if (primary === "actions" || primary === "action") return buildActionsPlan(args); + throw new CliUsageError(`Unknown command '${primary}'. Run 'ade help'.`); +} + +function findProjectRoot(startDir: string): string { + let cursor = path.resolve(startDir); + while (true) { + if (fs.existsSync(path.join(cursor, ".ade"))) return cursor; + const parent = path.dirname(cursor); + if (parent === cursor) break; + cursor = parent; + } + + const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { + cwd: startDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const gitRoot = git.status === 0 ? git.stdout.trim() : ""; + return gitRoot ? path.resolve(gitRoot) : path.resolve(startDir); +} + +function resolveRoots(options: GlobalOptions): { projectRoot: string; workspaceRoot: string } { + const projectRoot = options.projectRoot + ?? (process.env.ADE_PROJECT_ROOT?.trim() ? path.resolve(process.env.ADE_PROJECT_ROOT.trim()) : findProjectRoot(process.cwd())); + const workspaceRoot = options.workspaceRoot + ?? (process.env.ADE_WORKSPACE_ROOT?.trim() ? path.resolve(process.env.ADE_WORKSPACE_ROOT.trim()) : projectRoot); + return { projectRoot, workspaceRoot }; +} + +function commandExists(command: string): boolean { + const result = spawnSync("which", [command], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + return result.status === 0 && result.stdout.trim().length > 0; +} + +function runLocalCommand(command: string, args: string[], cwd: string): { ok: boolean; stdout: string; stderr: string } { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 5000, + }); + return { + ok: result.status === 0, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + }; +} + +function checkGitReadiness(projectRoot: string): ReadinessCheck { + if (!commandExists("git")) { + return { + ready: false, + status: "missing", + message: "git is not available on PATH.", + nextAction: "Install git and rerun ade doctor.", + }; + } + const inside = runLocalCommand("git", ["rev-parse", "--is-inside-work-tree"], projectRoot); + if (!inside.ok || inside.stdout !== "true") { + return { + ready: false, + status: "missing", + message: "Project root is not inside a git worktree.", + 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); + return { + ready: true, + status: "ready", + message: `Git repository detected${branch.stdout ? ` on ${branch.stdout}` : ""}.`, + details: { + gitRoot: root.ok ? root.stdout : null, + branch: branch.ok ? branch.stdout || null : null, + }, + }; +} + +function getGitRemote(projectRoot: string): string | null { + const remote = runLocalCommand("git", ["config", "--get", "remote.origin.url"], projectRoot); + return remote.ok && remote.stdout ? remote.stdout : null; +} + +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 ready = hasGitHubRemote && (ghInstalled || envTokenPresent); + return { + ready, + status: ready ? "ready" : hasGitHubRemote ? "warning" : "unavailable", + message: hasGitHubRemote + ? ready + ? "GitHub remote detected and a local auth mechanism is available." + : "GitHub remote detected, but no gh CLI or GitHub token was found locally." + : "No GitHub origin remote detected.", + nextAction: ready + ? undefined + : hasGitHubRemote + ? "Run gh auth login or set ADE_GITHUB_TOKEN/GITHUB_TOKEN for headless PR workflows." + : "Add a GitHub origin remote if this project should use ADE PR workflows.", + details: { + ghInstalled, + tokenEnvPresent: envTokenPresent, + githubRemoteDetected: hasGitHubRemote, + }, + }; +} + +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 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() + ); + const ready = encryptedTokenPresent || envTokenPresent; + return { + ready, + status: ready ? "ready" : "warning", + message: ready + ? "Linear credentials are present locally." + : "No Linear token was detected in local stores or environment variables.", + nextAction: ready + ? undefined + : "Configure Linear in ADE desktop or set ADE_LINEAR_API/LINEAR_API_KEY for headless mode.", + details: { + encryptedTokenPresent, + tokenEnvPresent: envTokenPresent, + }, + }; +} + +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 ai = isRecord(effective.ai) ? effective.ai : {}; + const defaultProvider = asString(ai.defaultProvider) ?? asString(ai.mode); + const defaultModel = asString(ai.defaultModel); + const apiKeys = isRecord(ai.apiKeys) ? ai.apiKeys : {}; + const cliProviders = { + claude: commandExists("claude"), + codex: commandExists("codex"), + opencode: commandExists("opencode"), + cursor: commandExists("agent") || commandExists("cursor-agent"), + }; + 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", + message: ready + ? "AI provider configuration or provider CLI availability was detected locally." + : "No AI provider configuration or provider CLI was detected locally.", + nextAction: ready + ? undefined + : "Configure AI providers in ADE desktop or install/sign in to a provider CLI.", + details: { + defaultProvider, + defaultModel, + apiKeyProviders, + cliProviders, + }, + }; +} + +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 ready = isDarwin && screenshotReady && appLaunchReady && guiReady; + return { + ready, + status: ready ? "ready" : isDarwin ? "warning" : "unavailable", + message: ready + ? "Local macOS computer-use fallback commands are available." + : isDarwin + ? "One or more local macOS computer-use fallback commands are missing." + : "Local computer-use fallback is macOS-only.", + nextAction: ready + ? undefined + : isDarwin + ? "Install or expose screencapture/open/swift/osascript on PATH, or use an external proof backend." + : "Use ADE desktop on macOS or an external proof backend for computer-use capture.", + details: { + platform: process.platform, + screenshotReady, + appLaunchReady, + guiReady, + }, + }; +} + +function checkPathReadiness(): ReadinessCheck { + const which = runLocalCommand("which", ["ade"], process.cwd()); + const current = path.resolve(process.argv[1] ?? ""); + const whichPath = which.ok && which.stdout ? path.resolve(which.stdout.split("\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.", + nextAction: onPath + ? undefined + : "Run npm link in apps/ade-cli or the packaged install-path.sh script.", + details: { + currentCliPath: current || null, + pathAde: whichPath, + sameBinary: Boolean(whichPath && current && whichPath === current), + electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE === "1", + electronVersion: process.versions.electron ?? null, + }, + }; +} + +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") }) }; +} + +function actionDomainCounts(value: unknown): Record { + const actions = isRecord(value) && Array.isArray(value.actions) ? value.actions.filter(isRecord) : []; + return actions.reduce>((acc, action) => { + const domain = asString(action.domain) ?? "core"; + acc[domain] = (acc[domain] ?? 0) + 1; + return acc; + }, {}); +} + +function buildReadinessSnapshot(args: { + connection: CliConnection; + values: JsonObject; + 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 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 socketExists = fs.existsSync(connection.socketPath); + const desktopSocketAvailable = connection.mode === "desktop-socket"; + const checks = { + git: checkGitReadiness(connection.projectRoot), + github: checkGitHubReadiness(connection.projectRoot), + linear: checkLinearReadiness(connection.projectRoot), + providers: checkProviderReadiness(projectConfig), + computerUse: checkComputerUseReadiness(), + path: checkPathReadiness(), + }; + 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."); + } + const projectInitialized = fs.existsSync(adeDir); + if (!projectInitialized) { + recommendations.unshift("project: Run ade doctor from an ADE project or pass --project-root ."); + } + const actionCountsByDomain = actionDomainCounts(values.actions); + const ready = projectInitialized && checks.git.ready && actions.length > 0; + + return { + ok: ready, + cliVersion: VERSION, + protocolVersion: PROTOCOL_VERSION, + mode: connection.mode, + selectedMode: connection.mode, + requestedMode: desktopSocketAvailable ? "desktop-socket" : "headless", + runtime: { + node: process.version, + execPath: process.execPath, + electron: process.versions.electron ?? null, + platform: process.platform, + arch: process.arch, + }, + projectRoot: connection.projectRoot, + workspaceRoot: connection.workspaceRoot, + project: { + projectRoot: connection.projectRoot, + workspaceRoot: connection.workspaceRoot, + adeDir, + projectInitialized, + sharedConfigPath, + sharedConfigPresent: fs.existsSync(sharedConfigPath), + localConfigPath, + localConfigPresent: fs.existsSync(localConfigPath), + }, + 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.", + }, + actions: { + rpcActionCount: rpcActions.length, + actionCount: actions.length, + byDomain: actionCountsByDomain, + }, + git: checks.git, + github: checks.github, + linear: checks.linear, + providers: checks.providers, + computerUse: checks.computerUse, + path: checks.path, + auth: { + localProjectAccess: projectInitialized && actions.length > 0, + providerSecretsExposed: false, + note: "ADE CLI auth is local project access. Provider and integration readiness is reported as presence-only metadata.", + }, + networkChecks: { + performed: false, + 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."), + summary, + }; +} + +class SocketJsonRpcClient { + private buffer: Buffer = Buffer.alloc(0); + private nextId = 1; + private pending = new Map void; + reject: (error: Error) => void; + timer: ReturnType; + }>(); + + private constructor(private readonly socket: net.Socket, private readonly timeoutMs: number) { + socket.on("data", (chunk) => this.onData(Buffer.from(chunk))); + socket.on("error", (error) => this.rejectAll(error instanceof Error ? error : new Error(String(error)))); + socket.on("close", () => this.rejectAll(new Error("ADE desktop socket closed."))); + } + + static connect(socketPath: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath); + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error(`Timed out connecting to ADE desktop socket at ${socketPath}.`)); + }, Math.min(timeoutMs, 5000)); + socket.once("connect", () => { + clearTimeout(timer); + resolve(new SocketJsonRpcClient(socket, timeoutMs)); + }); + socket.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + }); + } + + request(method: string, params?: JsonObject): Promise { + const id = this.nextId; + this.nextId += 1; + const payload: JsonRpcRequest = { + jsonrpc: "2.0", + id, + method, + ...(params !== undefined ? { params } : {}), + }; + const body = `${JSON.stringify(payload)}\n`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Timed out waiting for ${method}.`)); + }, this.timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + this.socket.write(body, "utf8", (error) => { + if (!error) return; + clearTimeout(timer); + this.pending.delete(id); + reject(error); + }); + }); + } + + close(): void { + this.socket.end(); + } + + private onData(chunk: Buffer): void { + this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk; + while (true) { + const newline = this.buffer.indexOf(0x0a); + if (newline < 0) break; + const line = this.buffer.subarray(0, newline).toString("utf8").trim(); + this.buffer = this.buffer.subarray(newline + 1); + if (!line) continue; + this.handleLine(line); + } + } + + private handleLine(line: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (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; + 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.")); + return; + } + pending.resolve(parsed.result); + } + + private rejectAll(error: Error): void { + for (const [id, pending] of this.pending) { + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(error); + } + } +} + +class InProcessJsonRpcClient { + private nextId = 1; + + constructor( + private readonly handler: JsonRpcHandler & { dispose?: () => void }, + private readonly runtime: { dispose: () => void }, + private readonly previousRole: string | undefined, + ) {} + + async request(method: string, params?: JsonObject): Promise { + const request: JsonRpcRequest = { + jsonrpc: "2.0", + id: this.nextId as JsonRpcId, + method, + ...(params !== undefined ? { params } : {}), + }; + this.nextId += 1; + return await this.handler(request); + } + + close(): void { + 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; + } +} + +async function initializeConnection(connection: CliConnection, options: GlobalOptions): Promise { + await connection.request("ade/initialize", { + protocolVersion: PROTOCOL_VERSION, + clientInfo: { name: "ade-cli", version: VERSION }, + identity: { + callerId: "ade-cli", + role: options.role, + computerUsePolicy: { + mode: "auto", + allowLocalFallback: options.role !== "external", + retainArtifacts: true, + }, + }, + }); +} + +async function createConnection(options: GlobalOptions): Promise { + const roots = resolveRoots(options); + const { resolveAdeLayout } = await import("../../desktop/src/shared/adeLayout"); + const layout = resolveAdeLayout(roots.projectRoot); + + if (!options.headless && fs.existsSync(layout.socketPath)) { + try { + const socketClient = await SocketJsonRpcClient.connect(layout.socketPath, options.timeoutMs); + const connection: CliConnection = { + mode: "desktop-socket", + projectRoot: roots.projectRoot, + workspaceRoot: roots.workspaceRoot, + socketPath: layout.socketPath, + request: (method, params) => socketClient.request(method, params), + close: () => socketClient.close(), + }; + await initializeConnection(connection, options); + return connection; + } catch (error) { + if (options.requireSocket) throw error; + } + } + + if (options.requireSocket) { + throw new Error(`ADE desktop socket is not available at ${layout.socketPath}.`); + } + + 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 handler = createAdeRpcRequestHandler({ + runtime, + serverVersion: VERSION, + onActionsListChanged: () => {}, + }); + + const inProcess = new InProcessJsonRpcClient(handler, runtime, previousRole); + const connection: CliConnection = { + mode: "headless", + projectRoot: roots.projectRoot, + workspaceRoot: roots.workspaceRoot, + socketPath: layout.socketPath, + request: (method, params) => inProcess.request(method, params), + close: () => inProcess.close(), + }; + await initializeConnection(connection, options); + return connection; +} + +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."; + throw new CliToolError(message, structured ?? result); + } + if (Object.prototype.hasOwnProperty.call(result, "structuredContent")) { + return result.structuredContent; + } + return result; +} + +function renderLaneGraph(result: unknown): string { + 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)"; + + const byParent = new Map(); + const byId = new Map(); + for (const lane of lanes) { + const id = asString(lane.id); + if (!id) continue; + byId.set(id, lane); + } + for (const lane of lanes) { + const parentId = asString(lane.parentLaneId); + const key = parentId && byId.has(parentId) ? parentId : ""; + const children = byParent.get(key) ?? []; + children.push(lane); + byParent.set(key, children); + } + 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; + if (leftDepth !== rightDepth) return leftDepth - rightDepth; + return String(left.name ?? left.id ?? "").localeCompare(String(right.name ?? right.id ?? "")); + }); + } + + const lines = ["ADE lanes"]; + const visit = (lane: JsonObject, prefix: string, isLast: boolean): void => { + const name = asString(lane.name) ?? asString(lane.id) ?? "(unknown)"; + const branch = asString(lane.branchRef) ?? ""; + const status = asString(lane.status) ?? ""; + const archived = asString(lane.archivedAt) ? " archived" : ""; + lines.push(`${prefix}${isLast ? "+- " : "+- "}${name}${branch ? ` [${branch}]` : ""}${status ? ` ${status}` : ""}${archived}`); + const id = asString(lane.id); + 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)); + return lines.join("\n"); +} + +function truncateCell(value: string, width = 42): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= width) return normalized; + if (width <= 3) return normalized.slice(0, width); + return `${normalized.slice(0, width - 3)}...`; +} + +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 === "string") return truncateCell(value, 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); + } + return truncateCell(String(value), width); +} + +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)}`), + ].join("\n"); +} + +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(); + return [ + renderRow(headers), + widths.map((width) => "-".repeat(width)).join(" "), + ...rows.map(renderRow), + ].join("\n"); +} + +function firstArray(value: unknown, keys: string[]): JsonObject[] { + if (Array.isArray(value)) return value.filter(isRecord); + if (!isRecord(value)) return []; + for (const key of keys) { + const entry = value[key]; + if (Array.isArray(entry)) return entry.filter(isRecord); + } + return []; +} + +function firstRecord(value: unknown, keys: string[]): JsonObject | null { + if (!isRecord(value)) return null; + for (const key of keys) { + const entry = value[key]; + if (isRecord(entry)) return entry; + } + return 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"; + return raw.toUpperCase(); +} + +function formatActionsList(value: unknown): string { + const actions = firstArray(value, ["actions"]); + if (actions.length === 0) return "ADE actions\n(no actions)"; + const byDomain = new Map(); + for (const action of actions) { + const name = asString(action.name); + const domain = asString(action.domain) ?? (name?.includes(".") ? name.split(".")[0] : null) ?? "core"; + const list = byDomain.get(domain) ?? []; + list.push(action); + byDomain.set(domain, list); + } + const lines = ["ADE actions"]; + 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)"; + const description = asString(action.description) ?? ""; + lines.push(` ${name}${description ? ` - ${truncateCell(description, 86)}` : ""}`); + } + } + return lines.join("\n"); +} + +function formatLaneDetail(value: unknown): string { + const root = isRecord(value) ? value : {}; + const lane = firstRecord(value, ["lane"]) ?? (isRecord(value) ? value : {}); + return renderKeyValues("ADE lane", [ + ["id", lane.id], + ["name", lane.name], + ["branch", lane.branchRef ?? lane.branch], + ["base", lane.baseBranch ?? lane.baseRef], + ["status", lane.status ?? root.rebaseStatus], + ["worktree", lane.worktreePath], + ]); +} + +function formatPrList(value: unknown): string { + const prs = firstArray(value, ["prs", "pullRequests", "items", "results"]); + return renderTable( + ["PR", "state", "lane", "branch", "title"], + prs.map((pr) => [ + pr.number ?? pr.prNumber ?? pr.id, + pr.state ?? pr.status, + pr.laneId ?? pr.laneName, + pr.headRefName ?? pr.branchRef ?? pr.branch, + pr.title, + ]), + "ADE pull requests\n(no PRs)", + ); +} + +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"; + return `${header}\n${renderTable( + ["status", "name", "details"], + checks.map((check) => [ + statusWord(check.conclusion ?? check.status), + check.name, + check.detailsUrl ?? check.url ?? check.completedAt, + ]), + "(no checks)", + )}`; +} + +function formatPrComments(value: unknown): string { + const threads = firstArray(value, ["reviewThreads", "threads"]); + 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)", + )); + } + if (comments.length > 0) { + 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)"); + return lines.join("\n"); +} + +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]), + "ADE files\n(no entries)", + ); +} + +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; + return text ?? JSON.stringify(value, null, 2); +} + +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]), + "ADE file search\n(no matches)", + ); +} + +function formatDiffSummary(value: unknown): string { + const files = firstArray(value, ["files", "changes", "items"]); + return renderTable( + ["status", "file", "+", "-"], + files.map((file) => [ + file.status ?? file.changeType ?? file.type, + file.path ?? file.filePath ?? file.newPath ?? file.oldPath, + file.additions ?? file.added ?? "", + file.deletions ?? file.deleted ?? "", + ]), + "ADE diff\n(no changed files)", + ); +} + +function formatRunTable(value: unknown, title: string): string { + const rows = firstArray(value, ["processes", "definitions", "runtime", "runs", "items"]); + return `${title}\n${renderTable( + ["id", "status", "lane", "command"], + rows.map((row) => [ + row.id ?? row.processId ?? row.runId ?? row.name, + row.status ?? row.state, + row.laneId ?? row.laneName, + row.command ?? row.startupCommand ?? row.title, + ]), + "(none)", + )}`; +} + +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]), + "ADE chats\n(no sessions)", + ); +} + +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]), + "ADE test runs\n(no runs)", + ); +} + +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]), + "ADE proof artifacts\n(no artifacts)", + ); +} + +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; + switch (formatter) { + case "status": + return renderKeyValues("ADE status", [ + ["ok", isRecord(value) ? value.ok : null], + ["mode", isRecord(value) ? value.mode : null], + ["project", isRecord(value) ? value.projectRoot : null], + ["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], + ["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 "lanes": + return renderLaneGraph(value); + case "lane-detail": + return formatLaneDetail(value); + case "git-status": + return renderKeyValues("ADE git status", Object.entries(isRecord(value) ? value : {})); + case "diff-summary": + return formatDiffSummary(value); + case "file-read": + return formatFileRead(value); + case "files-tree": + return formatFileTree(value); + case "files-search": + return formatFilesSearch(value); + 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)); + case "pr-checks": + return formatPrChecks(value); + case "pr-comments": + return formatPrComments(value); + case "run-defs": + return formatRunTable(value, "ADE run definitions"); + case "run-runtime": + return formatRunTable(value, "ADE process runtime"); + case "chat-list": + return formatChatList(value); + case "tests-runs": + return formatTestsRuns(value); + case "proof-list": + return formatProofList(value); + case "actions-list": + return formatActionsList(value); + case "action-result": + default: + 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 { + 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 === "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 === "pr list") return "prs-list"; + if (label === "pr detail" || label === "pr health") return "pr-detail"; + if (label === "pr checks") return "pr-checks"; + if (label === "pr comments") return "pr-comments"; + if (label === "process definitions") return "run-defs"; + if (label === "process runtime") return "run-runtime"; + if (label === "chat list") return "chat-list"; + if (label === "test runs") return "tests-runs"; + if (label === "proof list") return "proof-list"; + if (label === "actions list") return "actions-list"; + if (label.endsWith("actions")) return "actions-list"; + return "action-result"; +} + +function summarizeExecution(args: { + plan: CliPlan & { kind: "execute" }; + connection: CliConnection; + values: JsonObject; +}): unknown { + const { plan, connection, values } = args; + if (plan.summary === "status") { + return { + ok: true, + mode: connection.mode, + projectRoot: connection.projectRoot, + workspaceRoot: connection.workspaceRoot, + socketPath: connection.socketPath, + ping: values.ping, + }; + } + if (plan.summary === "doctor") { + return buildReadinessSnapshot({ connection, values, summary: "doctor" }); + } + if (plan.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", + role: process.env.ADE_DEFAULT_ROLE ?? "agent", + projectRoot: connection.projectRoot, + workspaceRoot: connection.workspaceRoot, + socketPath: connection.socketPath, + availableActionCount: actions.actionCount, + checks: { + git: readiness.git, + github: readiness.github, + linear: readiness.linear, + providers: readiness.providers, + computerUse: readiness.computerUse, + path: readiness.path, + }, + recommendations: readiness.recommendations, + note: isRecord(readiness.auth) ? readiness.auth.note : "ADE CLI auth is local project access.", + }; + } + + 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") + ) { + return result.result; + } + if (plan.visualizer === "lanes" && isRecord(result)) { + return { + ...result, + visual: renderLaneGraph(result), + }; + } + return result; +} + +async function executePlan(plan: CliPlan & { kind: "execute" }, options: GlobalOptions): Promise { + let connection: CliConnection; + try { + connection = await createConnection(options); + } catch (error) { + const roots = resolveRoots(options); + const socketPath = path.join(roots.projectRoot, ".ade", "ade.sock"); + const requestedMode = options.requireSocket ? "desktop-socket" : options.headless ? "headless" : "auto"; + throw new CliExecutionError(`Failed to initialize ADE CLI connection for ${plan.label}.`, { + cause: error instanceof Error ? error.message : String(error), + requestedMode, + projectRoot: roots.projectRoot, + workspaceRoot: roots.workspaceRoot, + socketPath, + nextAction: options.requireSocket + ? "Start ADE desktop for this project or remove --socket to allow headless mode." + : "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 raw = await connection.request(step.method, step.params); + values[step.key] = step.unwrapToolResult ? unwrapToolResult(raw) : raw; + } catch (error) { + if (!step.optional) throw error; + values[step.key] = { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + return summarizeExecution({ plan, connection, values }); + } catch (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.", + }); + } finally { + await connection.close(); + } +} + +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 }> { + 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 }; + 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`); + }; + console.log = writeDiagnostic; + console.info = writeDiagnostic; + console.warn = writeDiagnostic; + let result: unknown; + try { + result = await executePlan(plan, parsed.options); + } finally { + console.log = originalConsole.log; + console.info = originalConsole.info; + console.warn = originalConsole.warn; + } + return { output: formatOutput(result, parsed.options, inferFormatter(plan)), exitCode: 0 }; +} + +async function main(): Promise { + const writeDiagnostic = (...args: unknown[]) => { + process.stderr.write(`${args.map((arg) => typeof arg === "string" ? arg : JSON.stringify(arg)).join(" ")}\n`); + }; + console.log = writeDiagnostic; + console.info = writeDiagnostic; + console.warn = writeDiagnostic; + try { + const result = await runCli(process.argv.slice(2)); + process.stdout.write(result.output); + process.exitCode = result.exitCode; + } catch (error) { + if (error instanceof CliUsageError) { + process.stderr.write(`ade: ${error.message}\nRun 'ade help'.\n`); + process.exitCode = 2; + return; + } + if (error instanceof CliToolError) { + process.stderr.write(`ade: ${error.message}\n`); + if (error.details !== undefined) { + process.stderr.write(`${JSON.stringify(error.details, null, 2)}\n`); + } + process.exitCode = 1; + return; + } + if (error instanceof CliExecutionError) { + process.stderr.write(`ade: ${error.message}\n`); + process.stderr.write(`${JSON.stringify(error.details, null, 2)}\n`); + process.exitCode = 1; + return; + } + process.stderr.write(`ade: ${error instanceof Error ? error.stack || error.message : String(error)}\n`); + process.exitCode = 1; + } +} + +if (/(^|[/\\])cli\.(?:ts|js|cjs)$/.test(process.argv[1] ?? "")) { + void main(); +} + +export { + buildCliPlan, + formatOutput, + parseCliArgs, + renderLaneGraph, + runCli, + summarizeExecution, +}; diff --git a/apps/mcp-server/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts similarity index 99% rename from apps/mcp-server/src/headlessLinearServices.test.ts rename to apps/ade-cli/src/headlessLinearServices.test.ts index dc90b4d89..049a745f3 100644 --- a/apps/mcp-server/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -121,7 +121,6 @@ function createDeps() { aiOrchestratorService: {} as any, workerAgentService: {} as any, workerBudgetService: {} as any, - externalMcpService: {} as any, computerUseArtifactBrokerService: {} as any, openExternal: async () => {}, }; diff --git a/apps/mcp-server/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts similarity index 98% rename from apps/mcp-server/src/headlessLinearServices.ts rename to apps/ade-cli/src/headlessLinearServices.ts index 7132c864b..806d3f8c1 100644 --- a/apps/mcp-server/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -29,10 +29,9 @@ import type { createLinearIngressService } from "../../desktop/src/main/services import type { createWorkerTaskSessionService } from "../../desktop/src/main/services/cto/workerTaskSessionService"; import type { createWorkerHeartbeatService } from "../../desktop/src/main/services/cto/workerHeartbeatService"; import type { createAutomationSecretService } from "../../desktop/src/main/services/automations/automationSecretService"; -import type { ExternalMcpService } from "../../desktop/src/main/services/externalMcp/externalMcpService"; import type { ComputerUseArtifactBrokerService } from "../../desktop/src/main/services/computerUse/computerUseArtifactBrokerService"; import { getModelById, resolveModelAlias } from "../../desktop/src/shared/modelRegistry"; -import type { AdeMcpPaths } from "./bootstrap"; +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"; import { createLinearTemplateService as createLinearTemplateServiceImpl } from "../../desktop/src/main/services/cto/linearTemplateService"; @@ -120,7 +119,7 @@ type HeadlessTranscriptEntry = { type HeadlessLinearDeps = { projectRoot: string; adeDir: string; - paths: AdeMcpPaths; + paths: AdeRuntimePaths; projectId: string; db: AdeDb; logger: Logger; @@ -133,7 +132,6 @@ type HeadlessLinearDeps = { aiOrchestratorService: ReturnType; workerAgentService: ReturnType; workerBudgetService: ReturnType; - externalMcpService: ExternalMcpService; computerUseArtifactBrokerService: ComputerUseArtifactBrokerService; openExternal?: (url: string) => Promise; }; @@ -243,7 +241,7 @@ function createHeadlessGitHubService(projectRoot: string, logger: Logger): Headl headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, - "user-agent": "ade-mcp-server", + "user-agent": "ade-cli", ...(args.body == null ? {} : { "content-type": "application/json" }), }, body: args.body == null ? undefined : JSON.stringify(args.body), @@ -350,8 +348,8 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ const defaultSummary = (identityKey?: string): string => identityKey - ? `Headless MCP session for ${identityKey}. Automatic agent execution is not available in this runtime.` - : "Headless MCP chat session. Automatic agent execution is not available in this runtime."; + ? `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 requested = modelId?.trim() || HEADLESS_MODEL_ID; @@ -535,7 +533,7 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ }); }, setComputerUseArtifactBrokerService() { - // no-op in headless MCP mode + // no-op in headless mode void projectRoot; }, }; @@ -578,7 +576,7 @@ function createHeadlessWorkerHeartbeatService(): ReturnType void): void; + write(data: string): void; + close(): void; +}; + export type JsonRpcRequest = { jsonrpc?: string; id?: JsonRpcId; diff --git a/apps/mcp-server/src/test/setup.ts b/apps/ade-cli/src/test/setup.ts similarity index 97% rename from apps/mcp-server/src/test/setup.ts rename to apps/ade-cli/src/test/setup.ts index 2df0e44b9..c69269c8f 100644 --- a/apps/mcp-server/src/test/setup.ts +++ b/apps/ade-cli/src/test/setup.ts @@ -10,7 +10,7 @@ type TestTempTrackerState = { originalPromisesMkdtemp?: typeof fs.promises.mkdtemp; }; -const TEST_TEMP_TRACKER_KEY = Symbol.for("ade.mcp.testTempTracker"); +const TEST_TEMP_TRACKER_KEY = Symbol.for("ade.cli.testTempTracker"); const testTempRoot = path.resolve(os.tmpdir()); function getTestTempTrackerState(): TestTempTrackerState { diff --git a/apps/mcp-server/src/types/node-sqlite.d.ts b/apps/ade-cli/src/types/node-sqlite.d.ts similarity index 100% rename from apps/mcp-server/src/types/node-sqlite.d.ts rename to apps/ade-cli/src/types/node-sqlite.d.ts diff --git a/apps/mcp-server/tsconfig.json b/apps/ade-cli/tsconfig.json similarity index 100% rename from apps/mcp-server/tsconfig.json rename to apps/ade-cli/tsconfig.json diff --git a/apps/mcp-server/tsup.config.ts b/apps/ade-cli/tsup.config.ts similarity index 91% rename from apps/mcp-server/tsup.config.ts rename to apps/ade-cli/tsup.config.ts index cad3e3868..6c7da471a 100644 --- a/apps/mcp-server/tsup.config.ts +++ b/apps/ade-cli/tsup.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: { - index: "src/index.ts" + cli: "src/cli.ts" }, format: ["cjs"], platform: "node", diff --git a/apps/mcp-server/vitest.config.ts b/apps/ade-cli/vitest.config.ts similarity index 100% rename from apps/mcp-server/vitest.config.ts rename to apps/ade-cli/vitest.config.ts diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 7455d58a4..d889d99da 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -18,7 +18,6 @@ "@lobehub/icons": "^5.2.0", "@lobehub/icons-static-svg": "^1.84.0", "@lobehub/ui": "^5.6.3", - "@modelcontextprotocol/sdk": "1.27.1", "@opencode-ai/sdk": "^1.3.17", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dialog": "^1.1.15", @@ -1979,18 +1978,6 @@ "@hapi/hoek": "^9.0.0" } }, - "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@huggingface/jinja": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.6.tgz", @@ -3202,46 +3189,6 @@ "langium": "^4.0.0" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.27.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", - "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", - "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/@napi-rs/wasm-runtime": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", @@ -7363,19 +7310,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "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/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -7451,39 +7385,6 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "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-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -8082,46 +7983,6 @@ "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", @@ -8340,15 +8201,6 @@ "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", @@ -8461,6 +8313,7 @@ "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==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8470,22 +8323,6 @@ "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/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -8962,28 +8799,6 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "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-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -8991,24 +8806,6 @@ "dev": true, "license": "MIT" }, - "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/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -9017,23 +8814,6 @@ "license": "MIT", "optional": true }, - "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/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -9850,15 +9630,6 @@ "node": ">=0.4.0" } }, - "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/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -10171,6 +9942,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -10188,12 +9960,6 @@ "dev": true, "license": "MIT" }, - "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/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -10435,15 +10201,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "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", @@ -10539,6 +10296,7 @@ "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==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -10663,12 +10421,6 @@ "node": ">=6" } }, - "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": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -11000,36 +10752,6 @@ "node": ">=0.10.0" } }, - "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.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -11037,67 +10759,6 @@ "dev": true, "license": "Apache-2.0" }, - "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.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", - "license": "MIT", - "dependencies": { - "ip-address": "10.1.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -11207,22 +10868,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -11316,27 +10961,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -11500,15 +11124,6 @@ "node": ">= 0.6" } }, - "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/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -11550,15 +11165,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-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -11672,6 +11278,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -11705,6 +11312,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -11975,6 +11583,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -12321,15 +11930,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, - "node_modules/hono": { - "version": "4.12.8", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz", - "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -12403,26 +12003,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "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": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -12578,6 +12158,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/inline-style-parser": { @@ -12606,20 +12187,12 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, "license": "MIT", "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-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -12796,12 +12369,6 @@ "dev": true, "license": "MIT" }, - "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/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -12901,15 +12468,6 @@ "@sideway/pinpoint": "^2.0.0" } }, - "node_modules/jose": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", - "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -13057,18 +12615,6 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, - "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/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -13821,6 +13367,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -14158,27 +13705,6 @@ "url": "https://opencollective.com/unified" } }, - "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/merge-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/merge-value/-/merge-value-1.0.0.tgz", @@ -15074,31 +14600,6 @@ "node": ">=4.0.0" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -15433,6 +14934,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -15609,18 +15111,6 @@ "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/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -15642,22 +15132,11 @@ "url": "https://github.com/sindresorhus/on-change?sponsor=1" } }, - "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", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -15925,15 +15404,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "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/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -16004,16 +15474,6 @@ "dev": true, "license": "ISC" }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -16090,15 +15550,6 @@ "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", @@ -16382,19 +15833,6 @@ "node": ">=12.0.0" } }, - "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/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -16571,21 +16009,6 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/query-string": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/query-string/-/query-string-9.3.1.tgz", @@ -16644,46 +16067,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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-collapse": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-4.0.0.tgz", @@ -17654,15 +17037,6 @@ "node": ">=0.10.0" } }, - "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/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -17928,22 +17302,6 @@ "points-on-path": "^0.2.1" } }, - "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/rrweb-cssom": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", @@ -18098,32 +17456,6 @@ "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", "license": "MIT" }, - "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/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -18151,25 +17483,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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", @@ -18206,12 +17519,6 @@ "node": ">=0.10.0" } }, - "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/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -18359,78 +17666,6 @@ "@types/hast": "^3.0.4" } }, - "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.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "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", @@ -18645,15 +17880,6 @@ "node": ">= 6" } }, - "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", @@ -19201,15 +18427,6 @@ "url": "https://opencollective.com/unified" } }, - "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/tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -19442,20 +18659,6 @@ "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", @@ -19646,15 +18849,6 @@ "node": ">= 4.0.0" } }, - "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/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -19806,15 +19000,6 @@ "integrity": "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A==", "license": "MIT" }, - "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/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", @@ -21590,6 +20775,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -21734,15 +20920,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - }, "node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c84c9d59d..b1a37646a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,7 +8,7 @@ "license": "AGPL-3.0", "scripts": { "predev": "node ./scripts/clear-vite-cache.cjs && node ./scripts/normalize-runtime-binaries.cjs", - "prebuild": "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", "build": "tsup && vite build", "dist:mac": "npm run build && electron-builder --mac --publish never", @@ -28,10 +28,10 @@ "test:coverage": "vitest run --coverage", "test:orchestrator-smoke": "vitest run src/main/services/orchestrator/orchestratorSmoke.test.ts --reporter=verbose", "test:orchestrator-complex-mock": "vitest run src/main/services/orchestrator/orchestratorSmoke.test.ts -t \"complex mock prompt\" --reporter=verbose", - "mcp:dev": "npm --prefix ../mcp-server run dev -- --project-root ../..", - "mcp:build": "npm --prefix ../mcp-server run build", - "mcp:typecheck": "npm --prefix ../mcp-server run typecheck", - "mcp:test": "npm --prefix ../mcp-server run test", + "ade:dev": "npm --prefix ../ade-cli run dev -- --project-root ../..", + "ade:build": "npm --prefix ../ade-cli run build", + "ade:typecheck": "npm --prefix ../ade-cli run typecheck", + "ade:test": "npm --prefix ../ade-cli run test", "lint": "node ./node_modules/eslint/bin/eslint.js \"src/**/*.{ts,tsx}\"", "rebuild:native": "node ./scripts/rebuild-native.mjs", "version:ci": "node ./scripts/set-ci-version.mjs", @@ -47,7 +47,6 @@ "@lobehub/icons": "^5.2.0", "@lobehub/icons-static-svg": "^1.84.0", "@lobehub/ui": "^5.6.3", - "@modelcontextprotocol/sdk": "1.27.1", "@opencode-ai/sdk": "^1.3.17", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dialog": "^1.1.15", @@ -142,12 +141,29 @@ "!node_modules/onnxruntime-node/**" ], "asarUnpack": [ - "dist/main/adeMcpProxy.cjs", "dist/main/packagedRuntimeSmoke.cjs", "node_modules/node-pty/**/*", "node_modules/@huggingface/transformers/node_modules/onnxruntime-node/**", "vendor/crsqlite/**" ], + "extraResources": [ + { + "from": "../ade-cli/dist/cli.cjs", + "to": "ade-cli/cli.cjs" + }, + { + "from": "../ade-cli/dist/cli.cjs.map", + "to": "ade-cli/cli.cjs.map" + }, + { + "from": "scripts/ade-cli-macos-wrapper.sh", + "to": "ade-cli/bin/ade" + }, + { + "from": "scripts/ade-cli-install-path.sh", + "to": "ade-cli/install-path.sh" + } + ], "afterPack": "./scripts/after-pack-runtime-fixes.cjs", "publish": { "provider": "github", diff --git a/apps/desktop/scripts/ade-cli-install-path.sh b/apps/desktop/scripts/ade-cli-install-path.sh new file mode 100755 index 000000000..84d159b06 --- /dev/null +++ b/apps/desktop/scripts/ade-cli-install-path.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -eu + +SOURCE=$0 +while [ -L "$SOURCE" ]; do + SOURCE_DIR=$(CDPATH= cd -P -- "$(dirname -- "$SOURCE")" && pwd) + TARGET=$(readlink "$SOURCE") + case "$TARGET" in + /*) SOURCE=$TARGET ;; + *) SOURCE=$SOURCE_DIR/$TARGET ;; + esac +done + +SCRIPT_DIR=$(CDPATH= cd -P -- "$(dirname -- "$SOURCE")" && pwd) +ADE_BIN=${ADE_BIN:-"$SCRIPT_DIR/bin/ade"} +TARGET_PATH=${1:-"$HOME/.local/bin/ade"} +TARGET_DIR=$(dirname -- "$TARGET_PATH") + +if [ ! -x "$ADE_BIN" ]; then + echo "ade install: missing bundled CLI wrapper at $ADE_BIN" >&2 + exit 1 +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" diff --git a/apps/desktop/scripts/ade-cli-macos-wrapper.sh b/apps/desktop/scripts/ade-cli-macos-wrapper.sh new file mode 100755 index 000000000..a9fd31980 --- /dev/null +++ b/apps/desktop/scripts/ade-cli-macos-wrapper.sh @@ -0,0 +1,50 @@ +#!/bin/sh +set -eu + +SOURCE=$0 +while [ -L "$SOURCE" ]; do + SOURCE_DIR=$(CDPATH= cd -P -- "$(dirname -- "$SOURCE")" && pwd) + TARGET=$(readlink "$SOURCE") + case "$TARGET" in + /*) SOURCE=$TARGET ;; + *) SOURCE=$SOURCE_DIR/$TARGET ;; + esac +done + +SCRIPT_DIR=$(CDPATH= cd -P -- "$(dirname -- "$SOURCE")" && pwd) +CLI_JS=${ADE_CLI_JS:-"$SCRIPT_DIR/../cli.cjs"} + +if [ -n "${ADE_CLI_NODE:-}" ]; then + exec "$ADE_CLI_NODE" "$CLI_JS" "$@" +fi + +CONTENTS_DIR=$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd || true) +APP_EXE="$CONTENTS_DIR/MacOS/ADE" +RESOURCES_DIR="$CONTENTS_DIR/Resources" + +if [ ! -x "$APP_EXE" ] && [ -d "$CONTENTS_DIR/MacOS" ]; then + for CANDIDATE in "$CONTENTS_DIR"/MacOS/*; do + if [ -x "$CANDIDATE" ] && [ ! -d "$CANDIDATE" ]; then + APP_EXE=$CANDIDATE + break + fi + done +fi + +if [ -x "$APP_EXE" ]; then + NODE_PATH_VALUE="$RESOURCES_DIR/app.asar.unpacked/node_modules:$RESOURCES_DIR/app.asar/node_modules" + if [ -n "${NODE_PATH:-}" ]; then + NODE_PATH_VALUE="$NODE_PATH_VALUE:$NODE_PATH" + fi + ELECTRON_RUN_AS_NODE=1 NODE_PATH="$NODE_PATH_VALUE" exec "$APP_EXE" "$CLI_JS" "$@" +fi + +if command -v node >/dev/null 2>&1; then + NODE_MAJOR=$(node -p "Number(process.versions.node.split('.')[0])" 2>/dev/null || echo 0) + if [ "$NODE_MAJOR" -ge 22 ]; then + exec node "$CLI_JS" "$@" + fi +fi + +echo "ade: Node.js 22+ or the packaged ADE.app runtime is required to run this CLI." >&2 +exit 127 diff --git a/apps/desktop/scripts/after-pack-runtime-fixes.cjs b/apps/desktop/scripts/after-pack-runtime-fixes.cjs index bf6ef7442..93381d230 100644 --- a/apps/desktop/scripts/after-pack-runtime-fixes.cjs +++ b/apps/desktop/scripts/after-pack-runtime-fixes.cjs @@ -13,9 +13,23 @@ module.exports = async function afterPack(context) { } const runtimeRoot = resolvePackagedRuntimeRoot(appBundlePath); + const bundledCliPath = path.join(appBundlePath, "Contents", "Resources", "ade-cli", "cli.cjs"); + const bundledCliBinPath = path.join(appBundlePath, "Contents", "Resources", "ade-cli", "bin", "ade"); + const bundledCliInstallerPath = path.join(appBundlePath, "Contents", "Resources", "ade-cli", "install-path.sh"); if (!fs.existsSync(runtimeRoot)) { throw new Error(`[afterPack] Missing unpacked runtime payload: ${runtimeRoot}`); } + if (!fs.existsSync(bundledCliPath)) { + throw new Error(`[afterPack] Missing bundled ADE CLI entry: ${bundledCliPath}`); + } + if (!fs.existsSync(bundledCliBinPath)) { + throw new Error(`[afterPack] Missing bundled ADE CLI wrapper: ${bundledCliBinPath}`); + } + if (!fs.existsSync(bundledCliInstallerPath)) { + throw new Error(`[afterPack] Missing bundled ADE CLI PATH installer: ${bundledCliInstallerPath}`); + } + fs.chmodSync(bundledCliBinPath, 0o755); + fs.chmodSync(bundledCliInstallerPath, 0o755); const normalized = normalizeDesktopRuntimeBinaries(runtimeRoot); for (const entry of normalized) { @@ -23,7 +37,6 @@ module.exports = async function afterPack(context) { } const requiredScripts = [ - path.join(runtimeRoot, "dist", "main", "adeMcpProxy.cjs"), path.join(runtimeRoot, "dist", "main", "packagedRuntimeSmoke.cjs"), ]; diff --git a/apps/desktop/scripts/extractKvDbBootstrapSql.mjs b/apps/desktop/scripts/extractKvDbBootstrapSql.mjs index 1d3a792ec..4494d547e 100644 --- a/apps/desktop/scripts/extractKvDbBootstrapSql.mjs +++ b/apps/desktop/scripts/extractKvDbBootstrapSql.mjs @@ -94,7 +94,7 @@ export function buildKvDbBootstrapSql(options = {}) { const ftsBody = findFunctionBody(source, "function ensureUnifiedMemoriesSearchTable"); const migrateBody = findFunctionBody(source, "function migrate"); const statements = [ - ...extractRunStatements(ftsBody, { firstOnly: true }), + extractRunStatements(ftsBody).at(-1), ...extractRunStatements(migrateBody), ] .map(normalizeStatement) diff --git a/apps/desktop/scripts/validate-mac-artifacts.mjs b/apps/desktop/scripts/validate-mac-artifacts.mjs index f32f2267b..45f6c558f 100644 --- a/apps/desktop/scripts/validate-mac-artifacts.mjs +++ b/apps/desktop/scripts/validate-mac-artifacts.mjs @@ -154,18 +154,24 @@ async function validatePackagedRuntime(appPath, description) { const resourcesPath = path.join(appPath, "Contents", "Resources"); 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 adeCliBinPath = path.join(resourcesPath, "ade-cli", "bin", "ade"); + const adeCliInstallerPath = path.join(resourcesPath, "ade-cli", "install-path.sh"); const nodeModulesPath = path.join(unpackedPath, "node_modules"); const nodePtyModulePath = path.join(nodeModulesPath, "node-pty"); const smokeScriptPath = path.join(unpackedPath, "dist", "main", "packagedRuntimeSmoke.cjs"); - const adeMcpProxyPath = path.join(unpackedPath, "dist", "main", "adeMcpProxy.cjs"); console.log(`[release:mac] Smoke testing packaged runtime payload for ${description}`); await assertPathExists(executablePath, "packaged app executable"); await assertPathExists(appAsarPath, "app.asar payload"); await assertPathExists(unpackedPath, "app.asar.unpacked runtime payload"); + await assertPathExists(adeCliPath, "bundled ADE CLI entry"); + await assertPathExists(adeCliBinPath, "bundled ADE CLI wrapper"); + await assertPathExists(adeCliInstallerPath, "bundled ADE CLI PATH installer"); + await assertExecutable(adeCliBinPath, "bundled ADE CLI wrapper"); + await assertExecutable(adeCliInstallerPath, "bundled ADE CLI PATH installer"); await assertPathExists(nodePtyModulePath, "unpacked node-pty module"); await assertPathExists(smokeScriptPath, "unpacked packaged runtime smoke script"); - await assertPathExists(adeMcpProxyPath, "unpacked ADE MCP proxy script"); const nodePtyAddon = await findNodePtyAddon(nodePtyModulePath); if (!nodePtyAddon) { @@ -220,28 +226,15 @@ async function validatePackagedRuntime(appPath, description) { if (payload?.codexExecutable !== "function") { throw new Error(`[release:mac] Packaged smoke expected Codex executable resolver to be available, got ${String(payload?.codexExecutable)}`); } - if (payload?.launchMode !== "bundled_proxy") { - throw new Error(`[release:mac] Packaged smoke expected bundled_proxy launch mode, got ${String(payload?.launchMode)}`); - } - if (!payload?.proxyProbe?.ok) { - throw new Error("[release:mac] Packaged smoke failed to launch the bundled ADE MCP proxy in probe mode"); - } - // Do not rely on probe mode alone here. The regression we fixed still let - // the packaged proxy start, but chat MCP failed once Claude/Codex attempted - // the first initialize handshake through that launch path. - if (!payload?.proxyInitialize?.ok) { - throw new Error( - `[release:mac] Packaged smoke failed to complete MCP initialize through the bundled ADE proxy: ${ - String(payload?.proxyInitialize?.error || payload?.proxyInitialize?.stderr || "unknown error") - }` - ); - } - if (payload?.proxyInitialize?.response?.result?.serverInfo?.name !== "ade-mcp-server") { - throw new Error( - `[release:mac] Packaged smoke expected ADE MCP initialize to report ade-mcp-server, got ${ - JSON.stringify(payload?.proxyInitialize?.response ?? null) - }` - ); + + const { stdout: adeCliHelp } = await execFileAsync(adeCliBinPath, ["--help"], { + cwd: resourcesPath, + env: { + ...process.env, + }, + }); + if (!adeCliHelp.includes("Agent-focused command-line interface for ADE")) { + throw new Error("[release:mac] Bundled ADE CLI wrapper did not print ADE CLI help"); } console.log(`[release:mac] Packaged runtime smoke passed for ${description}: ${path.relative(appPath, nodePtyAddon)}`); diff --git a/apps/desktop/src/main/adeMcpProxy.ts b/apps/desktop/src/main/adeMcpProxy.ts deleted file mode 100644 index e38130352..000000000 --- a/apps/desktop/src/main/adeMcpProxy.ts +++ /dev/null @@ -1,196 +0,0 @@ -import fs from "node:fs"; -import net from "node:net"; -import path from "node:path"; -import { Buffer } from "node:buffer"; -import { resolveAdeLayout } from "../shared/adeLayout"; -import { - asTrimmed, - hasProxyIdentity, - injectIdentityIntoInitializePayload, - takeNextInboundMessage, - type ProxyIdentity, -} from "./adeMcpProxyUtils"; - -process.env.ADE_STDIO_TRANSPORT ??= "1"; - -type RuntimeRoots = { - projectRoot: string; - workspaceRoot: string; -}; - -const MCP_SOCKET_CONNECT_TIMEOUT_MS = 5_000; -const MCP_SOCKET_CONNECT_RETRY_DELAY_MS = 150; - -function resolveCliArg(flag: string): string | null { - const args = process.argv.slice(2); - for (let i = 0; i < args.length; i += 1) { - const value = args[i]; - if (value !== flag) continue; - const next = args[i + 1]; - if (next?.trim()) return path.resolve(next.trim()); - } - return null; -} - -function hasFlag(flag: string): boolean { - return process.argv.slice(2).includes(flag); -} - -function resolveRoot(envKey: string, flag: string, fallback: string): string { - const fromEnv = process.env[envKey]?.trim(); - if (fromEnv) return path.resolve(fromEnv); - return resolveCliArg(flag) ?? fallback; -} - -function resolveRuntimeRoots(): RuntimeRoots { - const projectRoot = resolveRoot("ADE_PROJECT_ROOT", "--project-root", process.cwd()); - const workspaceRoot = resolveRoot("ADE_WORKSPACE_ROOT", "--workspace-root", projectRoot); - return { projectRoot, workspaceRoot }; -} - -function asBoolFlag(value: string | undefined): boolean | null { - const trimmed = value?.trim() ?? ""; - return trimmed === "1" ? true : trimmed === "0" ? false : null; -} - -function resolveProxyIdentityFromEnv(): ProxyIdentity { - const computerUseMode = asTrimmed(process.env.ADE_COMPUTER_USE_MODE); - const allowLocalFallback = asBoolFlag(process.env.ADE_COMPUTER_USE_ALLOW_LOCAL_FALLBACK); - const retainArtifacts = asBoolFlag(process.env.ADE_COMPUTER_USE_RETAIN_ARTIFACTS); - const preferredBackend = asTrimmed(process.env.ADE_COMPUTER_USE_PREFERRED_BACKEND); - const hasComputerUsePolicy = - computerUseMode - || typeof allowLocalFallback === "boolean" - || typeof retainArtifacts === "boolean" - || preferredBackend; - return { - chatSessionId: asTrimmed(process.env.ADE_CHAT_SESSION_ID), - missionId: asTrimmed(process.env.ADE_MISSION_ID), - runId: asTrimmed(process.env.ADE_RUN_ID), - stepId: asTrimmed(process.env.ADE_STEP_ID), - attemptId: asTrimmed(process.env.ADE_ATTEMPT_ID), - ownerId: asTrimmed(process.env.ADE_OWNER_ID), - role: asTrimmed(process.env.ADE_DEFAULT_ROLE), - computerUsePolicy: hasComputerUsePolicy - ? { - mode: computerUseMode, - allowLocalFallback, - retainArtifacts, - preferredBackend, - } - : null, - }; -} - -function relayProxyInputWithIdentity(socket: net.Socket): void { - const identity = resolveProxyIdentityFromEnv(); - if (!hasProxyIdentity(identity)) { - process.stdin.pipe(socket); - process.stdin.on("end", () => { - socket.end(); - }); - return; - } - - let pending: Buffer = Buffer.alloc(0); - process.stdin.on("data", (chunk: Buffer) => { - pending = Buffer.concat([pending, Buffer.from(chunk)]); - while (true) { - const parsed = takeNextInboundMessage(pending); - if (!parsed) break; - pending = parsed.rest; - const payloadText = injectIdentityIntoInitializePayload(parsed.payloadText, identity); - if (payloadText === parsed.payloadText) { - socket.write(parsed.raw); - continue; - } - if (parsed.transport === "jsonl") { - socket.write(`${payloadText}\n`); - continue; - } - const framed = `Content-Length: ${Buffer.byteLength(payloadText, "utf8")}\r\n\r\n${payloadText}`; - socket.write(framed); - } - }); - process.stdin.on("end", () => { - if (pending.length > 0) { - socket.write(pending); - pending = Buffer.alloc(0); - } - socket.end(); - }); -} - -function isRetriableSocketConnectError(error: NodeJS.ErrnoException): boolean { - return error.code === "ENOENT" || error.code === "ECONNREFUSED"; -} - -async function connectToSocketWithRetry(socketPath: string): Promise { - const deadline = Date.now() + MCP_SOCKET_CONNECT_TIMEOUT_MS; - - while (true) { - const socket = net.createConnection(socketPath); - try { - await new Promise((resolve, reject) => { - const handleConnect = () => { - socket.off("error", handleError); - resolve(); - }; - const handleError = (error: NodeJS.ErrnoException) => { - socket.off("connect", handleConnect); - reject(error); - }; - socket.once("connect", handleConnect); - socket.once("error", handleError); - }); - return socket; - } catch (error) { - socket.destroy(); - const nextError = error as NodeJS.ErrnoException; - if (!isRetriableSocketConnectError(nextError) || Date.now() >= deadline) { - throw nextError; - } - await new Promise((resolve) => { - const timer = setTimeout(resolve, MCP_SOCKET_CONNECT_RETRY_DELAY_MS); - timer.unref?.(); - }); - } - } -} - -async function main(): Promise { - const roots = resolveRuntimeRoots(); - const socketPath = process.env.ADE_MCP_SOCKET_PATH?.trim() || resolveAdeLayout(roots.projectRoot).socketPath; - - if (hasFlag("--probe")) { - process.stdout.write(JSON.stringify({ - ok: true, - mode: "bundled_proxy", - projectRoot: roots.projectRoot, - workspaceRoot: roots.workspaceRoot, - socketPath, - socketExists: fs.existsSync(socketPath), - })); - process.exit(0); - } - - const socket = await connectToSocketWithRetry(socketPath); - - socket.on("error", (err) => { - process.stderr.write(`[ade-mcp-proxy]: ${err.message}\n`); - process.exit(1); - }); - - socket.on("close", () => { - process.exit(0); - }); - - process.stdin.resume(); - relayProxyInputWithIdentity(socket); - socket.pipe(process.stdout); -} - -void main().catch((error) => { - process.stderr.write(`[ade-mcp-proxy] ${error instanceof Error ? error.message : String(error)}\n`); - process.exit(1); -}); diff --git a/apps/desktop/src/main/adeMcpProxyUtils.test.ts b/apps/desktop/src/main/adeMcpProxyUtils.test.ts deleted file mode 100644 index 59fe94f18..000000000 --- a/apps/desktop/src/main/adeMcpProxyUtils.test.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { Buffer } from "node:buffer"; -import { describe, expect, it } from "vitest"; -import { - asTrimmed, - findHeaderBoundary, - hasProxyIdentity, - injectIdentityIntoInitializePayload, - isRecord, - parseContentLength, - takeNextInboundMessage, - type ProxyIdentity, -} from "./adeMcpProxyUtils"; - -const NULL_IDENTITY: ProxyIdentity = { - chatSessionId: null, - missionId: null, - runId: null, - stepId: null, - attemptId: null, - ownerId: null, - role: null, - computerUsePolicy: null, -}; - -describe("asTrimmed", () => { - it("returns trimmed string for valid input", () => { - expect(asTrimmed(" hello ")).toBe("hello"); - }); - - it("returns the string unchanged when already trimmed", () => { - expect(asTrimmed("hello")).toBe("hello"); - }); - - it("returns null for undefined", () => { - expect(asTrimmed(undefined)).toBeNull(); - }); - - it("returns null for empty string", () => { - expect(asTrimmed("")).toBeNull(); - }); - - it("returns null for whitespace-only string", () => { - expect(asTrimmed(" ")).toBeNull(); - expect(asTrimmed("\t\n")).toBeNull(); - }); -}); - -describe("isRecord", () => { - it("returns true for a plain object", () => { - expect(isRecord({ a: 1 })).toBe(true); - }); - - it("returns true for an empty object", () => { - expect(isRecord({})).toBe(true); - }); - - it("returns false for null", () => { - expect(isRecord(null)).toBe(false); - }); - - it("returns false for undefined", () => { - expect(isRecord(undefined)).toBe(false); - }); - - it("returns false for an array", () => { - expect(isRecord([1, 2, 3])).toBe(false); - }); - - it("returns false for a string", () => { - expect(isRecord("hello")).toBe(false); - }); - - it("returns false for a number", () => { - expect(isRecord(42)).toBe(false); - }); - - it("returns false for a boolean", () => { - expect(isRecord(true)).toBe(false); - }); -}); - -describe("hasProxyIdentity", () => { - it("returns false when all fields are null", () => { - expect(hasProxyIdentity(NULL_IDENTITY)).toBe(false); - }); - - it("returns true when missionId is set", () => { - expect(hasProxyIdentity({ ...NULL_IDENTITY, missionId: "m-1" })).toBe(true); - }); - - it("returns true when chatSessionId is set", () => { - expect(hasProxyIdentity({ ...NULL_IDENTITY, chatSessionId: "chat-1" })).toBe(true); - }); - - it("returns true when runId is set", () => { - expect(hasProxyIdentity({ ...NULL_IDENTITY, runId: "r-1" })).toBe(true); - }); - - it("returns true when stepId is set", () => { - expect(hasProxyIdentity({ ...NULL_IDENTITY, stepId: "s-1" })).toBe(true); - }); - - it("returns true when attemptId is set", () => { - expect(hasProxyIdentity({ ...NULL_IDENTITY, attemptId: "a-1" })).toBe(true); - }); - - it("returns true when role is set", () => { - expect(hasProxyIdentity({ ...NULL_IDENTITY, role: "coder" })).toBe(true); - }); - - it("returns true when ownerId is set", () => { - expect(hasProxyIdentity({ ...NULL_IDENTITY, ownerId: "agent-1" })).toBe(true); - }); - - it("returns true when computerUsePolicy is set", () => { - expect(hasProxyIdentity({ - ...NULL_IDENTITY, - computerUsePolicy: { - mode: "enabled", - allowLocalFallback: null, - retainArtifacts: null, - preferredBackend: null, - }, - })).toBe(true); - }); - - it("returns true when multiple fields are set", () => { - expect(hasProxyIdentity({ ...NULL_IDENTITY, missionId: "m-1", role: "coder" })).toBe(true); - }); -}); - -describe("findHeaderBoundary", () => { - it("finds CRLF boundary (\\r\\n\\r\\n)", () => { - const buf = Buffer.from("Content-Length: 10\r\n\r\n{\"id\":1}"); - const result = findHeaderBoundary(buf); - expect(result).toEqual({ index: 18, delimiterLength: 4 }); - }); - - it("finds LF boundary (\\n\\n)", () => { - const buf = Buffer.from("Content-Length: 10\n\n{\"id\":1}"); - const result = findHeaderBoundary(buf); - expect(result).toEqual({ index: 18, delimiterLength: 2 }); - }); - - it("picks the earlier boundary when both are present (CRLF first)", () => { - const buf = Buffer.from("A\r\n\r\nB\n\nC"); - const result = findHeaderBoundary(buf); - expect(result).toEqual({ index: 1, delimiterLength: 4 }); - }); - - it("picks the earlier boundary when both are present (LF first)", () => { - const buf = Buffer.from("A\n\nB\r\n\r\nC"); - const result = findHeaderBoundary(buf); - expect(result).toEqual({ index: 1, delimiterLength: 2 }); - }); - - it("returns null when no boundary is found", () => { - const buf = Buffer.from("Content-Length: 10\r\n{\"id\":1}"); - expect(findHeaderBoundary(buf)).toBeNull(); - }); - - it("returns null for empty buffer", () => { - expect(findHeaderBoundary(Buffer.alloc(0))).toBeNull(); - }); -}); - -describe("parseContentLength", () => { - it("parses valid Content-Length header", () => { - expect(parseContentLength("Content-Length: 42")).toBe(42); - }); - - it("handles case-insensitive matching", () => { - expect(parseContentLength("content-length: 99")).toBe(99); - expect(parseContentLength("CONTENT-LENGTH: 7")).toBe(7); - expect(parseContentLength("Content-length: 123")).toBe(123); - }); - - it("handles extra whitespace around colon", () => { - expect(parseContentLength("Content-Length : 55")).toBe(55); - }); - - it("parses from multi-line header block", () => { - const block = "X-Custom: foo\r\nContent-Length: 128\r\nX-Other: bar"; - expect(parseContentLength(block)).toBe(128); - }); - - it("returns null when header is missing", () => { - expect(parseContentLength("X-Custom: foo")).toBeNull(); - }); - - it("returns null for non-numeric value", () => { - expect(parseContentLength("Content-Length: abc")).toBeNull(); - }); - - it("returns null for empty string", () => { - expect(parseContentLength("")).toBeNull(); - }); -}); - -describe("takeNextInboundMessage", () => { - it("parses a JSONL message (starts with {)", () => { - const json = '{"jsonrpc":"2.0","method":"initialize","id":1}'; - const buf = Buffer.from(json + "\n"); - const result = takeNextInboundMessage(buf); - expect(result).not.toBeNull(); - expect(result!.transport).toBe("jsonl"); - expect(result!.payloadText).toBe(json); - expect(result!.rest.length).toBe(0); - }); - - it("parses a JSONL message (starts with [)", () => { - const json = '[{"jsonrpc":"2.0","id":1}]'; - const buf = Buffer.from(json + "\n"); - const result = takeNextInboundMessage(buf); - expect(result).not.toBeNull(); - expect(result!.transport).toBe("jsonl"); - expect(result!.payloadText).toBe(json); - }); - - it("returns null for incomplete JSONL (no newline)", () => { - const buf = Buffer.from('{"jsonrpc":"2.0","id":1}'); - expect(takeNextInboundMessage(buf)).toBeNull(); - }); - - it("correctly separates payload from rest in JSONL", () => { - const msg1 = '{"id":1}\n'; - const msg2 = '{"id":2}\n'; - const buf = Buffer.from(msg1 + msg2); - const result = takeNextInboundMessage(buf); - expect(result).not.toBeNull(); - expect(result!.payloadText).toBe('{"id":1}'); - expect(result!.rest.toString("utf8")).toBe(msg2); - }); - - it("parses a framed message with Content-Length header (CRLF)", () => { - const body = '{"jsonrpc":"2.0","method":"test","id":2}'; - const frame = `Content-Length: ${body.length}\r\n\r\n${body}`; - const buf = Buffer.from(frame); - const result = takeNextInboundMessage(buf); - expect(result).not.toBeNull(); - expect(result!.transport).toBe("framed"); - expect(result!.payloadText).toBe(body); - expect(result!.rest.length).toBe(0); - }); - - it("parses a framed message with Content-Length header (LF)", () => { - const body = '{"jsonrpc":"2.0","method":"test","id":3}'; - const frame = `Content-Length: ${body.length}\n\n${body}`; - const buf = Buffer.from(frame); - const result = takeNextInboundMessage(buf); - expect(result).not.toBeNull(); - expect(result!.transport).toBe("framed"); - expect(result!.payloadText).toBe(body); - }); - - it("returns null for empty buffer", () => { - expect(takeNextInboundMessage(Buffer.alloc(0))).toBeNull(); - }); - - it("returns null for incomplete framed message (body too short)", () => { - const frame = "Content-Length: 100\r\n\r\nshort"; - const buf = Buffer.from(frame); - expect(takeNextInboundMessage(buf)).toBeNull(); - }); - - it("returns null for framed message with missing Content-Length", () => { - const frame = "X-Other: value\r\n\r\n{\"id\":1}"; - const buf = Buffer.from(frame); - expect(takeNextInboundMessage(buf)).toBeNull(); - }); - - it("correctly separates payload from rest in framed messages", () => { - const body1 = '{"id":1}'; - const body2 = '{"id":2}'; - const frame1 = `Content-Length: ${body1.length}\r\n\r\n${body1}`; - const frame2 = `Content-Length: ${body2.length}\r\n\r\n${body2}`; - const buf = Buffer.from(frame1 + frame2); - const result = takeNextInboundMessage(buf); - expect(result).not.toBeNull(); - expect(result!.payloadText).toBe(body1); - expect(result!.rest.toString("utf8")).toBe(frame2); - }); -}); - -describe("injectIdentityIntoInitializePayload", () => { - const identity: ProxyIdentity = { - chatSessionId: "chat-1", - missionId: "m-1", - runId: "r-1", - stepId: "s-1", - attemptId: "a-1", - ownerId: "agent-1", - role: "coder", - computerUsePolicy: { - mode: "enabled", - allowLocalFallback: true, - retainArtifacts: false, - preferredBackend: "vnc", - }, - }; - - it("injects identity into initialize method", () => { - const payload = JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: {}, - }); - const result = JSON.parse(injectIdentityIntoInitializePayload(payload, identity)); - expect(result.params.identity).toEqual({ - chatSessionId: "chat-1", - missionId: "m-1", - runId: "r-1", - stepId: "s-1", - attemptId: "a-1", - ownerId: "agent-1", - role: "coder", - computerUsePolicy: { - mode: "enabled", - allowLocalFallback: true, - retainArtifacts: false, - preferredBackend: "vnc", - }, - }); - }); - - it("does NOT modify non-initialize methods", () => { - const payload = JSON.stringify({ - jsonrpc: "2.0", - method: "tools/list", - id: 2, - params: {}, - }); - const result = injectIdentityIntoInitializePayload(payload, identity); - expect(result).toBe(payload); - }); - - it("merges with existing identity without overwriting existing fields", () => { - const payload = JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: { - identity: { - chatSessionId: "existing-chat", - missionId: "existing-mission", - ownerId: "existing-owner", - role: "existing-role", - computerUsePolicy: { - mode: "off", - }, - }, - }, - }); - const result = JSON.parse(injectIdentityIntoInitializePayload(payload, identity)); - expect(result.params.identity.chatSessionId).toBe("existing-chat"); - expect(result.params.identity.missionId).toBe("existing-mission"); - expect(result.params.identity.ownerId).toBe("existing-owner"); - expect(result.params.identity.role).toBe("existing-role"); - expect(result.params.identity.runId).toBe("r-1"); - expect(result.params.identity.stepId).toBe("s-1"); - expect(result.params.identity.attemptId).toBe("a-1"); - expect(result.params.identity.computerUsePolicy).toEqual({ - mode: "off", - allowLocalFallback: true, - retainArtifacts: false, - preferredBackend: "vnc", - }); - }); - - it("overwrites existing identity fields that are empty strings", () => { - const payload = JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: { - identity: { - chatSessionId: " ", - missionId: " ", - ownerId: "", - role: "", - }, - }, - }); - const result = JSON.parse(injectIdentityIntoInitializePayload(payload, identity)); - expect(result.params.identity.chatSessionId).toBe("chat-1"); - expect(result.params.identity.missionId).toBe("m-1"); - expect(result.params.identity.ownerId).toBe("agent-1"); - expect(result.params.identity.role).toBe("coder"); - }); - - it("returns original text for invalid JSON", () => { - const broken = "this is not json {{{"; - expect(injectIdentityIntoInitializePayload(broken, identity)).toBe(broken); - }); - - it("returns original text when identity has no fields set", () => { - const payload = JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: {}, - }); - expect(injectIdentityIntoInitializePayload(payload, NULL_IDENTITY)).toBe(payload); - }); - - it("returns original text when payload is not an object", () => { - expect(injectIdentityIntoInitializePayload('"just a string"', identity)).toBe('"just a string"'); - }); - - it("creates params object when params is missing", () => { - const payload = JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - id: 1, - }); - const result = JSON.parse(injectIdentityIntoInitializePayload(payload, identity)); - expect(result.params.identity).toEqual({ - chatSessionId: "chat-1", - missionId: "m-1", - runId: "r-1", - stepId: "s-1", - attemptId: "a-1", - ownerId: "agent-1", - role: "coder", - computerUsePolicy: { - mode: "enabled", - allowLocalFallback: true, - retainArtifacts: false, - preferredBackend: "vnc", - }, - }); - }); - - it("preserves other params fields alongside identity", () => { - const payload = JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: { - capabilities: { tools: true }, - }, - }); - const result = JSON.parse(injectIdentityIntoInitializePayload(payload, identity)); - expect(result.params.capabilities).toEqual({ tools: true }); - expect(result.params.identity.chatSessionId).toBe("chat-1"); - expect(result.params.identity.missionId).toBe("m-1"); - }); -}); diff --git a/apps/desktop/src/main/adeMcpProxyUtils.ts b/apps/desktop/src/main/adeMcpProxyUtils.ts deleted file mode 100644 index 470ad5e66..000000000 --- a/apps/desktop/src/main/adeMcpProxyUtils.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { Buffer } from "node:buffer"; - -export type ProxyIdentity = { - chatSessionId: string | null; - missionId: string | null; - runId: string | null; - stepId: string | null; - attemptId: string | null; - ownerId: string | null; - role: string | null; - computerUsePolicy: { - mode: string | null; - allowLocalFallback: boolean | null; - retainArtifacts: boolean | null; - preferredBackend: string | null; - } | null; -}; - -export type ParsedInboundMessage = { - transport: "jsonl" | "framed"; - payloadText: string; - raw: Buffer; - rest: Buffer; -}; - -export function asTrimmed(value: string | undefined): string | null { - const trimmed = value?.trim() ?? ""; - return trimmed.length > 0 ? trimmed : null; -} - -export function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -export function hasProxyIdentity(identity: ProxyIdentity): boolean { - const hasComputerUsePolicy = Boolean( - identity.computerUsePolicy - && ( - identity.computerUsePolicy.mode - || typeof identity.computerUsePolicy.allowLocalFallback === "boolean" - || typeof identity.computerUsePolicy.retainArtifacts === "boolean" - || identity.computerUsePolicy.preferredBackend - ), - ); - return Boolean( - identity.chatSessionId - || identity.missionId - || identity.runId - || identity.stepId - || identity.attemptId - || identity.ownerId - || identity.role - || hasComputerUsePolicy, - ); -} - -export function findHeaderBoundary(buffer: Buffer): { index: number; delimiterLength: number } | null { - const crlf = buffer.indexOf("\r\n\r\n", 0, "utf8"); - const lf = buffer.indexOf("\n\n", 0, "utf8"); - if (crlf === -1 && lf === -1) return null; - if (crlf === -1) return { index: lf, delimiterLength: 2 }; - if (lf === -1) return { index: crlf, delimiterLength: 4 }; - return crlf < lf ? { index: crlf, delimiterLength: 4 } : { index: lf, delimiterLength: 2 }; -} - -export function parseContentLength(headerBlock: string): number | null { - const lines = headerBlock.split(/\r?\n/); - for (const line of lines) { - const match = /^content-length\s*:\s*(\d+)\s*$/i.exec(line.trim()); - if (!match) continue; - return Number.parseInt(match[1] ?? "", 10); - } - return null; -} - -export function takeNextInboundMessage(buffer: Buffer): ParsedInboundMessage | null { - if (!buffer.length) return null; - const first = buffer[0]!; - - if (first === 0x7b || first === 0x5b) { - const newline = buffer.indexOf(0x0a); - if (newline === -1) return null; - const raw = buffer.subarray(0, newline + 1); - const payloadText = buffer.subarray(0, newline).toString("utf8").trim(); - return { - transport: "jsonl", - payloadText, - raw, - rest: buffer.subarray(newline + 1), - }; - } - - const boundary = findHeaderBoundary(buffer); - if (!boundary) return null; - const headerBlock = buffer.subarray(0, boundary.index).toString("utf8"); - const contentLength = parseContentLength(headerBlock); - if (contentLength == null || contentLength < 0) return null; - const bodyStart = boundary.index + boundary.delimiterLength; - const bodyEnd = bodyStart + contentLength; - if (buffer.length < bodyEnd) return null; - - return { - transport: "framed", - payloadText: buffer.subarray(bodyStart, bodyEnd).toString("utf8"), - raw: buffer.subarray(0, bodyEnd), - rest: buffer.subarray(bodyEnd), - }; -} - -export function injectIdentityIntoInitializePayload(payloadText: string, identity: ProxyIdentity): string { - if (!hasProxyIdentity(identity)) return payloadText; - let payload: unknown; - try { - payload = JSON.parse(payloadText); - } catch { - return payloadText; - } - if (!isRecord(payload) || payload.method !== "initialize") { - return payloadText; - } - - const params = isRecord(payload.params) ? { ...payload.params } : {}; - const existingIdentity = isRecord(params.identity) ? { ...params.identity } : {}; - const mergedIdentity: Record = { ...existingIdentity }; - - const identityKeys = ["chatSessionId", "missionId", "runId", "stepId", "attemptId", "ownerId", "role"] as const; - for (const key of identityKeys) { - if (!identity[key]) continue; - const existing = existingIdentity[key]; - if (typeof existing === "string" && existing.trim()) continue; - mergedIdentity[key] = identity[key]; - } - - const proxyComputerUsePolicy = identity.computerUsePolicy; - if (proxyComputerUsePolicy) { - const existingComputerUsePolicy = isRecord(existingIdentity.computerUsePolicy) - ? { ...existingIdentity.computerUsePolicy } - : {}; - let shouldWriteComputerUsePolicy = false; - - const hasExistingString = (key: string): boolean => - typeof existingComputerUsePolicy[key] === "string" && (existingComputerUsePolicy[key] as string).trim().length > 0; - const mergeString = (key: "mode" | "preferredBackend"): void => { - if (proxyComputerUsePolicy[key] && !hasExistingString(key)) { - existingComputerUsePolicy[key] = proxyComputerUsePolicy[key]; - shouldWriteComputerUsePolicy = true; - } - }; - const mergeBool = (key: "allowLocalFallback" | "retainArtifacts"): void => { - if (typeof proxyComputerUsePolicy[key] === "boolean" && typeof existingComputerUsePolicy[key] !== "boolean") { - existingComputerUsePolicy[key] = proxyComputerUsePolicy[key]; - shouldWriteComputerUsePolicy = true; - } - }; - mergeString("mode"); - mergeBool("allowLocalFallback"); - mergeBool("retainArtifacts"); - mergeString("preferredBackend"); - - if (shouldWriteComputerUsePolicy) { - mergedIdentity.computerUsePolicy = existingComputerUsePolicy; - } - } - - return JSON.stringify({ - ...payload, - params: { - ...params, - identity: mergedIdentity, - }, - }); -} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index c5af1bd13..4b7a96e00 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow, dialog, nativeImage, protocol, shell } from "electron"; import path from "node:path"; -type NodePtyType = typeof import("node-pty"); +import type * as NodePty from "node-pty"; +type NodePtyType = typeof NodePty; import { registerIpc } from "./services/ipc/registerIpc"; import { createFileLogger } from "./services/logging/logger"; import { openKvDb } from "./services/state/kvDb"; @@ -55,14 +56,13 @@ import type { PortLease, ProjectInfo } from "../shared/types"; import type { AppContext } from "./services/ipc/registerIpc"; import fs from "node:fs"; import net from "node:net"; -import { createMcpRequestHandler } from "../../../mcp-server/src/mcpServer"; +import { createAdeRpcRequestHandler } from "../../../ade-cli/src/adeRpcServer"; import { createEventBuffer, - type AdeMcpRuntime, - type AdeMcpPaths, -} from "../../../mcp-server/src/bootstrap"; -import { startJsonRpcServer } from "../../../mcp-server/src/jsonrpc"; -import type { JsonRpcTransport } from "../../../mcp-server/src/transport"; + type AdeRuntime, + type AdeRuntimePaths, +} from "../../../ade-cli/src/bootstrap"; +import { startJsonRpcServer, type JsonRpcTransport } from "../../../ade-cli/src/jsonrpc"; import { createKeybindingsService } from "./services/keybindings/keybindingsService"; import { createAgentToolsService } from "./services/agentTools/agentToolsService"; import { createDevToolsService } from "./services/devTools/devToolsService"; @@ -117,8 +117,6 @@ import { createOrchestratorService } from "./services/orchestrator/orchestratorS import { createAiOrchestratorService } from "./services/orchestrator/aiOrchestratorService"; import { createMissionBudgetService } from "./services/orchestrator/missionBudgetService"; import { transitionMissionStatus } from "./services/orchestrator/missionLifecycle"; -import { createExternalMcpService } from "./services/externalMcp/externalMcpService"; -import { createExternalConnectionAuthService } from "./services/externalMcp/externalConnectionAuthService"; import { createComputerUseArtifactBrokerService } from "./services/computerUse/computerUseArtifactBrokerService"; import { createSyncService } from "./services/sync/syncService"; import { createAutoUpdateService } from "./services/updates/autoUpdateService"; @@ -738,7 +736,7 @@ app.whenReady().then(async () => { const projectContexts = new Map(); const projectInitPromises = new Map>(); const closeContextPromises = new Map>(); - const mcpSocketCleanupByRoot = new Map void>(); + const rpcSocketCleanupByRoot = new Map void>(); const projectLastActivatedAt = new Map(); const MAX_WARM_IDLE_PROJECT_CONTEXTS = 1; let activeProjectRoot: string | null = null; @@ -865,11 +863,11 @@ app.whenReady().then(async () => { } try { - if ((ctx.getActiveMcpConnectionCount?.() ?? 0) > 0) { + if ((ctx.getActiveRpcConnectionCount?.() ?? 0) > 0) { return true; } } catch (error) { - return keepAliveOnProbeFailure("mcp_connections", error); + return keepAliveOnProbeFailure("rpc_connections", error); } try { @@ -1458,9 +1456,6 @@ app.whenReady().then(async () => { > | null = null; let agentChatServiceRef: ReturnType | null = null; - let externalMcpServiceRef: ReturnType< - typeof createExternalMcpService - > | null = null; const queueLandingService = createQueueLandingService({ db, logger, @@ -1557,7 +1552,6 @@ app.whenReady().then(async () => { const ptyService = createPtyService({ projectRoot, transcriptsDir: adePaths.transcriptsDir, - chatSessionsDir: adePaths.chatSessionsDir, laneService, sessionService, aiIntegrationService, @@ -1950,7 +1944,6 @@ app.whenReady().then(async () => { } }, onSessionEnded: onTrackedSessionEnded, - getExternalMcpConfigs: () => externalMcpServiceRef?.getRawConfigs() ?? [], getDirtyFileTextForPath: async (absPath: string) => { const trimmed = absPath.trim(); if (!trimmed) return undefined; @@ -2195,26 +2188,6 @@ app.whenReady().then(async () => { "ADE_ENABLE_MEMORY_FILE_SYNC", ); - const externalConnectionAuthService = createExternalConnectionAuthService({ - adeDir: adePaths.adeDir, - logger, - }); - const externalMcpService = createExternalMcpService({ - projectRoot, - adeDir: adePaths.adeDir, - db, - projectId, - logger, - workerAgentService, - ctoStateService, - missionService, - workerBudgetService, - missionBudgetService, - authService: externalConnectionAuthService, - onEvent: (event) => - emitProjectEvent(projectRoot, IPC.externalMcpEvent, event), - }); - externalMcpServiceRef = externalMcpService; scheduleBackgroundProjectTask( "lanes.port_allocation_recovery", () => recoverPortAllocations(), @@ -2227,18 +2200,6 @@ app.whenReady().then(async () => { "ADE_ENABLE_PORT_ALLOCATION_RECOVERY", ); - scheduleBackgroundProjectTask( - "external_mcp.start", - () => externalMcpService.start(), - (error) => { - logger.warn("external_mcp.start_failed", { - error: error instanceof Error ? error.message : String(error), - }); - }, - 0, - "ADE_ENABLE_EXTERNAL_MCP", - ); - const openclawBridgeService = createOpenclawBridgeService({ projectRoot, adeDir: adePaths.adeDir, @@ -2281,7 +2242,6 @@ app.whenReady().then(async () => { episodicSummaryService, proceduralLearningService, knowledgeCaptureService, - externalMcpService, onEvent: (event) => { aiOrchestratorServiceRef?.onOrchestratorRuntimeEvent(event); openclawBridgeServiceRef?.onOrchestratorEvent(event); @@ -2296,7 +2256,6 @@ app.whenReady().then(async () => { projectRoot, missionService, orchestratorService, - externalMcpService, logger, onEvent: (payload) => emitProjectEvent(projectRoot, IPC.computerUseEvent, payload), @@ -2615,7 +2574,6 @@ app.whenReady().then(async () => { adeProjectService, automationService, secretService: automationSecretService, - externalMcpService, logger, onEvent: (event) => emitProjectEvent(projectRoot, IPC.projectStateEvent, event), @@ -2851,14 +2809,14 @@ app.whenReady().then(async () => { ); writeGlobalState(globalStatePath, state); - // ── MCP Socket Server (embedded mode) ───────────────────────── - const mcpEventBuffer = createEventBuffer(); - const mcpRuntime: AdeMcpRuntime = { + // ── ADE RPC Socket Server (embedded mode) ───────────────────── + const rpcEventBuffer = createEventBuffer(); + const rpcRuntime: AdeRuntime = { projectRoot, workspaceRoot: projectRoot, projectId, project, - paths: adePaths as unknown as AdeMcpPaths, + paths: adePaths as unknown as AdeRuntimePaths, logger, db, laneService, @@ -2884,30 +2842,29 @@ app.whenReady().then(async () => { linearIngressService, linearRoutingService, processService, - externalMcpService, computerUseArtifactBrokerService, orchestratorService, aiOrchestratorService, issueInventoryService, - eventBuffer: mcpEventBuffer, + eventBuffer: rpcEventBuffer, dispose: () => {}, // desktop manages service lifecycle }; - // When ADE_MCP_SOCKET_PATH is set, derive a per-project socket path from + // 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_MCP_SOCKET_PATH?.trim(); - const mcpSocketPath = envSocketOverride + 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 activeMcpConnections = new Set(); + const activeRpcConnections = new Set(); - const destroyActiveMcpConnections = (): void => { - for (const conn of activeMcpConnections) { - activeMcpConnections.delete(conn); + const destroyActiveRpcConnections = (): void => { + for (const conn of activeRpcConnections) { + activeRpcConnections.delete(conn); try { conn.destroy(); } catch { @@ -2915,18 +2872,18 @@ app.whenReady().then(async () => { } } }; - mcpSocketCleanupByRoot.set( + rpcSocketCleanupByRoot.set( normalizeProjectRoot(projectRoot), - destroyActiveMcpConnections, + destroyActiveRpcConnections, ); // Clean stale socket from prior crash try { - fs.unlinkSync(mcpSocketPath); + fs.unlinkSync(rpcSocketPath); } catch {} - const mcpSocketServer = net.createServer((conn) => { - activeMcpConnections.add(conn); + const rpcSocketServer = net.createServer((conn) => { + activeRpcConnections.add(conn); let stopped = false; const transport: JsonRpcTransport = { onData(callback) { @@ -2940,16 +2897,16 @@ app.whenReady().then(async () => { }, }; let stop: ReturnType | null = null; - const mcpHandler = createMcpRequestHandler({ - runtime: mcpRuntime, + const rpcHandler = createAdeRpcRequestHandler({ + runtime: rpcRuntime, serverVersion: app.getVersion(), - onToolsListChanged: () => { - stop?.notify("notifications/tools/list_changed", {}); + onActionsListChanged: () => { + stop?.notify("ade/actions/list_changed", {}); }, }); - stop = startJsonRpcServer(mcpHandler, transport, { nonFatal: true }); + stop = startJsonRpcServer(rpcHandler, transport, { nonFatal: true }); const removeConnection = (): void => { - activeMcpConnections.delete(conn); + activeRpcConnections.delete(conn); }; conn.once("close", removeConnection); conn.once("end", removeConnection); @@ -2959,26 +2916,26 @@ app.whenReady().then(async () => { stopped = true; stop?.(); } - mcpHandler.dispose(); + rpcHandler.dispose(); }); conn.on("error", () => {}); // ignore connection errors }); - await measureProjectInitStep("mcp.socket_server_start", () => + await measureProjectInitStep("rpc.socket_server_start", () => new Promise((resolve, reject) => { const handleListening = () => { - mcpSocketServer.off("error", handleError); + rpcSocketServer.off("error", handleError); resolve(); }; const handleError = (error: Error) => { - mcpSocketServer.off("listening", handleListening); + rpcSocketServer.off("listening", handleListening); reject(error); }; - mcpSocketServer.once("listening", handleListening); - mcpSocketServer.once("error", handleError); - mcpSocketServer.listen(mcpSocketPath); + rpcSocketServer.once("listening", handleListening); + rpcSocketServer.once("error", handleError); + rpcSocketServer.listen(rpcSocketPath); }), ); - logger.info("mcp.socket_server_started", { socketPath: mcpSocketPath }); + logger.info("rpc.socket_server_started", { socketPath: rpcSocketPath }); return { db, @@ -2987,7 +2944,7 @@ app.whenReady().then(async () => { projectId, adeDir: adePaths.adeDir, hasUserSelectedProject: userSelectedProject, - getActiveMcpConnectionCount: () => activeMcpConnections.size, + getActiveRpcConnectionCount: () => activeRpcConnections.size, disposeHeadWatcher, keybindingsService, agentToolsService, @@ -3062,11 +3019,9 @@ app.whenReady().then(async () => { linearRoutingService, linearIngressService, linearSyncService, - externalConnectionAuthService, - externalMcpService, configReloadService, - mcpSocketServer, - mcpSocketPath, + rpcSocketServer, + rpcSocketPath, }; }; @@ -3089,7 +3044,7 @@ app.whenReady().then(async () => { hasUserSelectedProject: false, projectId: "", adeDir: "", - getActiveMcpConnectionCount: () => 0, + getActiveRpcConnectionCount: () => 0, disposeHeadWatcher: () => {}, keybindingsService: null, agentToolsService: null, @@ -3160,8 +3115,6 @@ app.whenReady().then(async () => { linearRoutingService: null, linearIngressService: null, linearSyncService: null, - externalConnectionAuthService: null, - externalMcpService: null, configReloadService: null, } as unknown as AppContext; }; @@ -3172,19 +3125,19 @@ app.whenReady().then(async () => { ctx.project.rootPath.trim().length > 0 ? normalizeProjectRoot(ctx.project.rootPath) : null; - // Tear down MCP socket BEFORE any service disposal so in-flight MCP requests + // Tear down the ADE RPC socket BEFORE service disposal so in-flight requests // do not race with services that are being shut down. try { if (normalizedRoot) { - mcpSocketCleanupByRoot.get(normalizedRoot)?.(); - mcpSocketCleanupByRoot.delete(normalizedRoot); + rpcSocketCleanupByRoot.get(normalizedRoot)?.(); + rpcSocketCleanupByRoot.delete(normalizedRoot); } - ctx.mcpSocketServer?.close(); + ctx.rpcSocketServer?.close(); } catch { // ignore } try { - if (ctx.mcpSocketPath) fs.unlinkSync(ctx.mcpSocketPath); + if (ctx.rpcSocketPath) fs.unlinkSync(ctx.rpcSocketPath); } catch { // ignore } @@ -3265,16 +3218,6 @@ app.whenReady().then(async () => { } catch { // ignore } - try { - await ctx.externalMcpService?.dispose?.(); - } catch { - // ignore - } - try { - ctx.externalConnectionAuthService?.dispose?.(); - } catch { - // ignore - } try { await ctx.openclawBridgeService?.stop?.(); } catch { diff --git a/apps/desktop/src/main/packagedRuntimeSmoke.ts b/apps/desktop/src/main/packagedRuntimeSmoke.ts index 277c0963d..52ecca294 100644 --- a/apps/desktop/src/main/packagedRuntimeSmoke.ts +++ b/apps/desktop/src/main/packagedRuntimeSmoke.ts @@ -1,11 +1,3 @@ -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; -import { execFile, spawn } from "node:child_process"; -import { createRequire } from "node:module"; -import { promisify } from "node:util"; -import { resolveDesktopAdeMcpLaunch } from "./services/runtime/adeMcpLaunch"; import { resolveClaudeCodeExecutable, type ClaudeCodeExecutableResolution } from "./services/ai/claudeCodeExecutable"; import { resolveCodexExecutable } from "./services/ai/codexExecutable"; import { @@ -13,7 +5,6 @@ import { type ClaudeStartupProbeResult, } from "./packagedRuntimeSmokeShared"; -const execFileAsync = promisify(execFile); const PTY_PROBE_TIMEOUT_MS = 4_000; const CLAUDE_PROBE_TIMEOUT_MS = 20_000; @@ -104,151 +95,13 @@ async function probeClaudeStartup( } } -async function probeMcpInitialize(args: { - command: string; - cmdArgs: string[]; - cwd: string; - env: NodeJS.ProcessEnv; -}): Promise<{ - ok: boolean; - response: unknown | null; - stderr: string | null; - error: string | null; -}> { - // Keep this as a real MCP initialize round-trip instead of another cheap - // "--probe" check. We regressed packaged chats by launching the proxy - // successfully but routing chat MCP through the wrong path, which only - // showed up once the client attempted the first initialize handshake. - // - // The proxy is a relay that connects to a Unix socket served by the ADE - // desktop backend. In CI there is no backend, so we stand up a lightweight - // mock MCP server on a short-path temp socket (macOS limits Unix socket - // paths to 104 chars which packaged-app temp dirs typically exceed). - // We must use async spawn (not spawnSync) so the event loop stays free - // for the mock server to handle the proxy's connection. - const sockPath = path.join(os.tmpdir(), `ade-smoke-${process.pid}.sock`); - try { fs.unlinkSync(sockPath); } catch { /* ignore */ } - - const server = net.createServer((conn) => { - let buf = ""; - conn.on("data", (chunk: Buffer) => { - buf += chunk.toString(); - const idx = buf.indexOf("\n"); - if (idx === -1) return; - const line = buf.slice(0, idx); - try { - const msg = JSON.parse(line); - if (msg.method === "initialize") { - conn.write(JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { - protocolVersion: "2025-06-18", - capabilities: {}, - serverInfo: { name: "ade-mcp-server", version: "smoke-test" }, - }, - }) + "\n"); - } - } catch { /* ignore malformed input */ } - conn.end(); - }); - }); - - await new Promise((resolve) => server.listen(sockPath, resolve)); - - try { - const payload = JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2025-06-18", - clientInfo: { name: "packaged-runtime-smoke", version: "1.0.0" }, - capabilities: {}, - }, - }); - - const child = spawn(args.command, args.cmdArgs, { - cwd: args.cwd, - env: { ...args.env, ADE_MCP_SOCKET_PATH: sockPath }, - stdio: ["pipe", "pipe", "pipe"], - }); - - child.stdin!.write(`${payload}\n`); - child.stdin!.end(); - - let stdout = ""; - let stderr = ""; - child.stdout!.on("data", (chunk: Buffer) => { stdout += chunk.toString(); }); - child.stderr!.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); - - const killTimer = setTimeout(() => { child.kill(); }, 5_000); - const exitCode = await new Promise((resolve) => { - child.on("close", (code) => { clearTimeout(killTimer); resolve(code ?? 1); }); - }); - - stdout = stdout.trim(); - stderr = stderr.trim(); - - try { - return { - ok: exitCode === 0, - response: stdout ? JSON.parse(stdout) : null, - stderr: stderr || null, - error: null, - }; - } catch (parseError) { - return { - ok: false, - response: stdout || null, - stderr: stderr || null, - error: parseError instanceof Error ? parseError.message : String(parseError), - }; - } - } finally { - server.close(); - try { fs.unlinkSync(sockPath); } catch { /* ignore */ } - } -} - async function main(): Promise { const pty = await import("node-pty"); const claude = await import("@anthropic-ai/claude-agent-sdk"); const claudeExecutable = resolveClaudeCodeExecutable(); - const cwd = process.cwd(); - const launch = resolveDesktopAdeMcpLaunch({ - projectRoot: cwd, - workspaceRoot: cwd, - }); const ptyProbe = await probePty(); const claudeStartup = await probeClaudeStartup(claudeExecutable); - const proxyProbe = await execFileAsync(launch.command, [...launch.cmdArgs, "--probe"], { - cwd, - env: { - ...process.env, - ...launch.env, - }, - }); - - const proxyProbeStdout = proxyProbe.stdout.trim(); - let proxyProbeResult: unknown = null; - try { - proxyProbeResult = proxyProbeStdout ? JSON.parse(proxyProbeStdout) : null; - } catch { - proxyProbeResult = proxyProbeStdout; - } - - const proxyInitialize = await probeMcpInitialize({ - command: launch.command, - cmdArgs: launch.cmdArgs, - cwd, - env: { - ...process.env, - ...launch.env, - }, - }); - process.stdout.write(JSON.stringify({ ok: true, nodePty: typeof pty.spawn, @@ -258,12 +111,6 @@ async function main(): Promise { claudeStartup, codexExecutable: typeof resolveCodexExecutable, ptyProbe, - launchMode: launch.mode, - launchCommand: launch.command, - launchEntryPath: launch.entryPath, - launchSocketPath: launch.socketPath, - proxyProbe: proxyProbeResult, - proxyInitialize, })); } diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 09b35f80e..90723300a 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -677,11 +677,14 @@ export function createAiIntegrationService(args: { logger: Logger; projectConfigService: ReturnType; projectRoot: string; + enableDynamicModelMetadata?: boolean; }) { const { db, logger, projectConfigService, projectRoot } = args; - // Non-blocking: fetch models.dev data and enrich pricing + registry - initModelsDevService().then((modelData) => { + // Non-blocking: fetch models.dev data and enrich pricing + registry. + // Headless CLI readiness commands disable this so default doctor/auth runs + // remain local-only and do not touch provider/model networks. + if (args.enableDynamicModelMetadata !== false) initModelsDevService().then((modelData) => { if (modelData.size === 0) return; // Update MODEL_PRICING with fresh cost data diff --git a/apps/desktop/src/main/services/ai/apiKeyStore.ts b/apps/desktop/src/main/services/ai/apiKeyStore.ts index 6b257cff2..e11bfddd8 100644 --- a/apps/desktop/src/main/services/ai/apiKeyStore.ts +++ b/apps/desktop/src/main/services/ai/apiKeyStore.ts @@ -4,9 +4,8 @@ import type { SafeStorage } from "electron"; import { resolveAdeLayout } from "../../../shared/adeLayout"; // electron.safeStorage is only available inside an Electron main process. -// When this module is bundled into the headless MCP server (spawned by -// Claude Agent SDK / Codex App Server as a plain Node process), `electron` -// is not present. Gracefully degrade so the MCP server can start. +// When this module is bundled into the ADE CLI headless runtime, `electron` +// is not present. Gracefully degrade so the CLI can start. let safeStorage: SafeStorage | null = null; try { // eslint-disable-next-line @typescript-eslint/no-require-imports diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts index dfde7db98..9c6d32679 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts @@ -6,26 +6,6 @@ const mockState = vi.hoisted(() => ({ reportProviderRuntimeAuthFailure: vi.fn(), reportProviderRuntimeFailure: vi.fn(), resolveClaudeCodeExecutable: vi.fn(() => ({ path: "/usr/local/bin/claude", source: "path" })), - normalizeCliMcpServers: vi.fn(() => ({ - ade: { - type: "stdio", - command: "node", - args: ["probe.js"], - env: { ADE_PROJECT_ROOT: "/tmp/project" }, - }, - })), - resolveDesktopAdeMcpLaunch: vi.fn(() => ({ - mode: "headless_source", - command: "node", - cmdArgs: ["probe.js"], - env: { ADE_PROJECT_ROOT: "/tmp/project" }, - entryPath: "probe.js", - runtimeRoot: "/tmp/runtime", - socketPath: "/tmp/project/.ade/mcp.sock", - packaged: false, - resourcesPath: null, - })), - resolveRepoRuntimeRoot: vi.fn(() => "/tmp/runtime"), })); vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ @@ -42,15 +22,6 @@ vi.mock("./claudeCodeExecutable", () => ({ resolveClaudeCodeExecutable: mockState.resolveClaudeCodeExecutable, })); -vi.mock("./cliMcpConfig", () => ({ - normalizeCliMcpServers: mockState.normalizeCliMcpServers, -})); - -vi.mock("../runtime/adeMcpLaunch", () => ({ - resolveDesktopAdeMcpLaunch: mockState.resolveDesktopAdeMcpLaunch, - resolveRepoRuntimeRoot: mockState.resolveRepoRuntimeRoot, -})); - let probeClaudeRuntimeHealth: typeof import("./claudeRuntimeProbe").probeClaudeRuntimeHealth; let resetClaudeRuntimeProbeCache: typeof import("./claudeRuntimeProbe").resetClaudeRuntimeProbeCache; let isClaudeRuntimeAuthError: typeof import("./claudeRuntimeProbe").isClaudeRuntimeAuthError; @@ -75,9 +46,6 @@ beforeEach(async () => { mockState.reportProviderRuntimeAuthFailure.mockReset(); mockState.reportProviderRuntimeFailure.mockReset(); mockState.resolveClaudeCodeExecutable.mockClear(); - mockState.normalizeCliMcpServers.mockClear(); - mockState.resolveDesktopAdeMcpLaunch.mockClear(); - mockState.resolveRepoRuntimeRoot.mockClear(); const mod = await import("./claudeRuntimeProbe"); probeClaudeRuntimeHealth = mod.probeClaudeRuntimeHealth; resetClaudeRuntimeProbeCache = mod.resetClaudeRuntimeProbeCache; @@ -105,9 +73,7 @@ describe("claudeRuntimeProbe", () => { expect(mockState.query).toHaveBeenCalledWith(expect.objectContaining({ options: expect.objectContaining({ pathToClaudeCodeExecutable: "/usr/local/bin/claude", - mcpServers: expect.objectContaining({ - ade: expect.any(Object), - }), + tools: [], }), })); expect(mockState.reportProviderRuntimeAuthFailure).toHaveBeenCalledTimes(1); @@ -116,9 +82,7 @@ describe("claudeRuntimeProbe", () => { options: expect.objectContaining({ cwd: "/tmp/project", pathToClaudeCodeExecutable: "/usr/local/bin/claude", - mcpServers: expect.objectContaining({ - ade: expect.any(Object), - }), + tools: [], }), })); }); @@ -163,7 +127,7 @@ describe("claudeRuntimeProbe", () => { expect(mockState.reportProviderRuntimeFailure).not.toHaveBeenCalled(); }); - it("calls resolveDesktopAdeMcpLaunch with defaultRole external and projectRoot", async () => { + it("probes Claude with an empty tool list", async () => { const query = makeStream([ { type: "result", @@ -189,14 +153,11 @@ describe("claudeRuntimeProbe", () => { await probeClaudeRuntimeHealth({ projectRoot: "/my/custom/project", force: true }); - expect(mockState.resolveDesktopAdeMcpLaunch).toHaveBeenCalledWith( - expect.objectContaining({ - projectRoot: "/my/custom/project", - workspaceRoot: "/my/custom/project", - defaultRole: "external", + expect(mockState.query).toHaveBeenCalledWith(expect.objectContaining({ + options: expect.objectContaining({ + tools: [], }), - ); - expect(mockState.resolveRepoRuntimeRoot).toHaveBeenCalled(); + })); expect(mockState.reportProviderRuntimeReady).toHaveBeenCalledTimes(1); }); diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts index 7242fb4d7..13870c0d6 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts @@ -7,8 +7,6 @@ import { reportProviderRuntimeReady, } from "./providerRuntimeHealth"; import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable"; -import { normalizeCliMcpServers } from "./cliMcpConfig"; -import { resolveDesktopAdeMcpLaunch, resolveRepoRuntimeRoot } from "../runtime/adeMcpLaunch"; const PROBE_TIMEOUT_MS = 20_000; const PROBE_CACHE_TTL_MS = 30_000; @@ -87,22 +85,6 @@ function cacheResult(projectRoot: string, result: ClaudeRuntimeProbeResult): Cla return result; } -function resolveProbeMcpServers(projectRoot: string): Record> | undefined { - const launch = resolveDesktopAdeMcpLaunch({ - projectRoot, - workspaceRoot: projectRoot, - runtimeRoot: resolveRepoRuntimeRoot(), - defaultRole: "external", - }); - return normalizeCliMcpServers("claude", { - ade: { - command: launch.command, - args: launch.cmdArgs, - env: launch.env, - }, - }); -} - function publishResult(result: ClaudeRuntimeProbeResult): void { switch (result.state) { case "ready": @@ -157,7 +139,6 @@ export async function probeClaudeRuntimeHealth(args: { permissionMode: "plan", tools: [], pathToClaudeCodeExecutable: claudeExecutable.path, - mcpServers: resolveProbeMcpServers(projectRoot) as any, abortController, }, }); diff --git a/apps/desktop/src/main/services/ai/cliMcpConfig.ts b/apps/desktop/src/main/services/ai/cliMcpConfig.ts deleted file mode 100644 index 0d549460b..000000000 --- a/apps/desktop/src/main/services/ai/cliMcpConfig.ts +++ /dev/null @@ -1,35 +0,0 @@ -function firstNonEmptyString(...candidates: unknown[]): string | undefined { - for (const value of candidates) { - if (typeof value === "string" && value.trim().length > 0) return value; - } - return undefined; -} - -export function normalizeCliMcpServers( - provider: "claude" | "codex", - mcpServers?: Record>, -): Record> | undefined { - if (!mcpServers) { - return undefined; - } - - return Object.fromEntries( - Object.entries(mcpServers).map(([name, server]) => { - if (typeof server !== "object" || server === null) { - return [name, server]; - } - - const record = server as Record; - const { type, transport, ...rest } = record; - - if (provider === "codex") { - const resolvedTransport = firstNonEmptyString(transport, type) ?? "stdio"; - return [name, { ...rest, transport: resolvedTransport }]; - } - - const resolvedType = firstNonEmptyString(type, transport) - ?? (typeof rest.command === "string" && rest.command.trim().length > 0 ? "stdio" : undefined); - return [name, resolvedType ? { ...rest, type: resolvedType } : { ...rest }]; - }), - ); -} diff --git a/apps/desktop/src/main/services/ai/codexAppServerConfig.test.ts b/apps/desktop/src/main/services/ai/codexAppServerConfig.test.ts deleted file mode 100644 index 5d052f518..000000000 --- a/apps/desktop/src/main/services/ai/codexAppServerConfig.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildCodexAppServerMcpConfigOverrides } from "./codexAppServerConfig"; - -describe("buildCodexAppServerMcpConfigOverrides", () => { - it("maps ADE stdio MCP server settings into Codex app-server config overrides", () => { - const result = buildCodexAppServerMcpConfigOverrides({ - ade: { - transport: "stdio", - command: "node", - args: ["/tmp/mcp-server.js"], - env: { ADE_RUN_ID: "run-1" }, - required: true, - startup_timeout_sec: 30, - tool_timeout_sec: 120, - }, - }); - - expect(result).toEqual({ - "mcp_servers.ade.required": true, - "mcp_servers.ade.startup_timeout_sec": 30, - "mcp_servers.ade.tool_timeout_sec": 120, - "mcp_servers.ade.command": "node", - "mcp_servers.ade.args": ["/tmp/mcp-server.js"], - "mcp_servers.ade.env": { ADE_RUN_ID: "run-1" }, - }); - }); - - it("supports camelCase timeout keys and HTTP MCP servers", () => { - const result = buildCodexAppServerMcpConfigOverrides({ - docs: { - transport: "http", - url: "https://mcp.example.com", - startupTimeoutSec: 15, - toolTimeoutSec: 45, - httpHeaders: { "x-tenant": "acme" }, - envHttpHeaders: { Authorization: "MCP_AUTH" }, - }, - }); - - expect(result).toEqual({ - "mcp_servers.docs.startup_timeout_sec": 15, - "mcp_servers.docs.tool_timeout_sec": 45, - "mcp_servers.docs.url": "https://mcp.example.com", - "mcp_servers.docs.http_headers": { "x-tenant": "acme" }, - "mcp_servers.docs.env_http_headers": { Authorization: "MCP_AUTH" }, - }); - }); - - it("returns undefined when no MCP servers are configured", () => { - expect(buildCodexAppServerMcpConfigOverrides()).toBeUndefined(); - }); -}); diff --git a/apps/desktop/src/main/services/ai/codexAppServerConfig.ts b/apps/desktop/src/main/services/ai/codexAppServerConfig.ts deleted file mode 100644 index 572664be2..000000000 --- a/apps/desktop/src/main/services/ai/codexAppServerConfig.ts +++ /dev/null @@ -1,91 +0,0 @@ -type McpServerRecord = Record; - -function stringArrayOrUndefined(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined; - const normalized = value.filter((entry): entry is string => typeof entry === "string"); - return normalized.length === value.length ? normalized : undefined; -} - -function isFiniteNonNegative(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) && value >= 0; -} - -function isStringRecord(value: unknown): value is Record { - if (value == null || typeof value !== "object" || Array.isArray(value)) return false; - return Object.entries(value as Record).every( - ([k, v]) => typeof k === "string" && typeof v === "string", - ); -} - -export function buildCodexAppServerMcpConfigOverrides( - mcpServers?: Record, -): Record | undefined { - if (!mcpServers) return undefined; - - const overrides: Record = {}; - - for (const [name, server] of Object.entries(mcpServers)) { - const prefix = `mcp_servers.${name}`; - - const required = typeof server.required === "boolean" ? server.required : undefined; - if (required !== undefined) { - overrides[`${prefix}.required`] = required; - } - - const enabled = typeof server.enabled === "boolean" ? server.enabled : undefined; - if (enabled !== undefined) { - overrides[`${prefix}.enabled`] = enabled; - } - - const startupTimeoutSec = - (isFiniteNonNegative(server.startupTimeoutSec) ? server.startupTimeoutSec : undefined) - ?? (isFiniteNonNegative(server.startup_timeout_sec) ? server.startup_timeout_sec : undefined); - if (startupTimeoutSec !== undefined) { - overrides[`${prefix}.startup_timeout_sec`] = startupTimeoutSec; - } - - const toolTimeoutSec = - (isFiniteNonNegative(server.toolTimeoutSec) ? server.toolTimeoutSec : undefined) - ?? (isFiniteNonNegative(server.tool_timeout_sec) ? server.tool_timeout_sec : undefined); - if (toolTimeoutSec !== undefined) { - overrides[`${prefix}.tool_timeout_sec`] = toolTimeoutSec; - } - - const enabledTools = stringArrayOrUndefined(server.enabledTools) - ?? stringArrayOrUndefined(server.enabled_tools); - if (enabledTools !== undefined) { - overrides[`${prefix}.enabled_tools`] = enabledTools; - } - - const disabledTools = stringArrayOrUndefined(server.disabledTools) - ?? stringArrayOrUndefined(server.disabled_tools); - if (disabledTools !== undefined) { - overrides[`${prefix}.disabled_tools`] = disabledTools; - } - - if (typeof server.command === "string" && server.command.trim().length > 0) { - overrides[`${prefix}.command`] = server.command; - const args = stringArrayOrUndefined(server.args); - if (args !== undefined) overrides[`${prefix}.args`] = args; - if (isStringRecord(server.env)) overrides[`${prefix}.env`] = server.env; - if (typeof server.cwd === "string" && server.cwd.trim().length > 0) overrides[`${prefix}.cwd`] = server.cwd; - continue; - } - - if (typeof server.url === "string" && server.url.trim().length > 0) { - overrides[`${prefix}.url`] = server.url; - if (typeof server.bearerToken === "string") overrides[`${prefix}.bearer_token`] = server.bearerToken; - if (typeof server.bearerTokenEnvVar === "string") { - overrides[`${prefix}.bearer_token_env_var`] = server.bearerTokenEnvVar; - } - if (isStringRecord(server.httpHeaders)) { - overrides[`${prefix}.http_headers`] = server.httpHeaders; - } - if (isStringRecord(server.envHttpHeaders)) { - overrides[`${prefix}.env_http_headers`] = server.envHttpHeaders; - } - } - } - - return Object.keys(overrides).length > 0 ? overrides : undefined; -} diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index 66428c32f..34a0dd179 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -216,7 +216,7 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).not.toContain("## Pull Request Tools"); }); - it("includes PR tool guidance when ADE MCP PR tools are present", () => { + it("includes PR tool guidance when ADE PR command tools are present", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x", toolNames: ["pr_refresh_issue_inventory", "pr_get_review_comments"], @@ -224,15 +224,6 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("## Pull Request Tools"); expect(result).toContain("pr_refresh_issue_inventory, pr_get_review_comments"); }); - - it("includes PR tool guidance when namespaced ADE MCP PR tools are present", () => { - const result = buildCodingAgentSystemPrompt({ - cwd: "/x", - toolNames: ["mcp__ade__pr_refresh_issue_inventory", "mcp__ade__pr_get_review_comments"], - }); - expect(result).toContain("## Pull Request Tools"); - expect(result).toContain("mcp__ade__pr_refresh_issue_inventory, mcp__ade__pr_get_review_comments"); - }); }); it("always includes operating loop, editing rules, and verification rules", () => { diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index cacb4cea8..168281598 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -49,25 +49,20 @@ export function buildCodingAgentSystemPrompt(args: { const hasTodoTools = toolNames.includes("TodoWrite") || toolNames.includes("TodoRead"); const hasWorkflowTools = hasCreateLane || hasCreatePr || hasCaptureScreenshot || hasReportCompletion; const guardedLocalReadOnly = permissionMode === "plan"; - const normalizeToolName = (name: string): string => { - const match = name.match(/^mcp__(.+)__(.+)$/); - return match?.[2] ?? name; - }; const prIssueToolNames = toolNames.filter((name) => { - const normalized = normalizeToolName(name); return ( - normalized === "prGetChecks" - || normalized === "prGetReviewComments" - || normalized === "prRefreshIssueInventory" - || normalized === "prRerunFailedChecks" - || normalized === "prReplyToReviewThread" - || normalized === "prResolveReviewThread" - || normalized === "pr_get_checks" - || normalized === "pr_get_review_comments" - || normalized === "pr_refresh_issue_inventory" - || normalized === "pr_rerun_failed_checks" - || normalized === "pr_reply_to_review_thread" - || normalized === "pr_resolve_review_thread" + name === "prGetChecks" + || name === "prGetReviewComments" + || name === "prRefreshIssueInventory" + || name === "prRerunFailedChecks" + || name === "prReplyToReviewThread" + || name === "prResolveReviewThread" + || name === "pr_get_checks" + || name === "pr_get_review_comments" + || name === "pr_refresh_issue_inventory" + || name === "pr_rerun_failed_checks" + || name === "pr_reply_to_review_thread" + || name === "pr_resolve_review_thread" ); }); const hasPrIssueTools = prIssueToolNames.length > 0; @@ -118,6 +113,9 @@ export function buildCodingAgentSystemPrompt(args: { ? "If requirements are genuinely unclear and progress would otherwise stall, ask one concise question with concrete options." : "If requirements are unclear, make the safest reasonable assumption and continue. State the assumption in the final answer.", "If tool results fail or contradict the current plan, synthesize the finding and adapt rather than repeating the same failing action.", + "", + "## ADE CLI", + "In terminal-capable sessions, use the bundled `ade` command for internal ADE actions. Run `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands such as `ade lanes list --text` or `ade prs checks --text` first, and `ade actions run ...` as the escape hatch. Use `--json` for structured output and `--text` for readable output.", ...(hasMemoryTools ? [ "", @@ -171,9 +169,9 @@ export function buildCodingAgentSystemPrompt(args: { "## Pull Request Tools", `Key PR tools in this session: ${prIssueToolNames.join(", ")}.`, "Use these tools first when the task is to address PR comments, review threads, or CI failures.", - "ADE/MCP PR tools are runtime tool calls, not shell commands. Do not probe them with `which`, `command -v`, `.mcp.json`, or local settings files.", - "If the runtime exposes both base and namespaced variants, use the exact identifier shown in the live tool list.", - "If a required PR tool is missing, report the misconfiguration immediately instead of spelunking through local MCP wiring or bootstrap code.", + "ADE PR tools are runtime tool calls, not shell commands. Do not probe them with `which`, `command -v`, or local settings files.", + "Use the exact identifier shown in the live tool list.", + "If a required PR tool is missing, report the misconfiguration immediately instead of spelunking through local bootstrap code.", ] : []), "", diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 87b10a36b..4ae2a6897 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -186,40 +186,39 @@ type ProjectConfigService = ReturnType; const AUTOMATION_SCOPE = "automation-rule"; const AUTOMATION_TASK_KEY_PREFIX = "automation-rule"; -const AUTOMATION_TOOL_BASELINE = buildClaudeReadOnlyWorkerAllowedTools("ade"); -const PUBLISH_CAPABLE_TOOL_FAMILIES = new Set(["github", "linear", "browser", "external-mcp"]); +const AUTOMATION_TOOL_BASELINE = buildClaudeReadOnlyWorkerAllowedTools(); +const PUBLISH_CAPABLE_TOOL_FAMILIES = new Set(["github", "linear", "browser"]); const TOOL_FAMILY_ALLOWED_TOOLS: Record = { repo: ["Read", "Glob", "Grep", "LS"], git: ["Bash", "bash"], tests: ["Bash", "bash"], - github: ["Bash", "bash", "mcp__github__get_pull_request", "mcp__github__create_pull_request", "mcp__github__add_issue_comment"], - linear: ["mcp__linear__get_issue", "mcp__linear__save_comment", "mcp__linear__save_issue"], + github: ["Bash", "bash", "gh", "ade"], + linear: ["ade", "linear"], browser: [ "agent-browser", - "mcp__ade__get_environment_info", - "mcp__ade__launch_app", - "mcp__ade__interact_gui", - "mcp__ade__screenshot_environment", - "mcp__ade__record_environment", - "mcp__playwright__browser_navigate", - "mcp__playwright__browser_snapshot", - "mcp__playwright__browser_click", - "mcp__playwright__browser_fill_form", - "mcp__playwright__browser_type", - "mcp__playwright__browser_take_screenshot", + "get_environment_info", + "launch_app", + "interact_gui", + "screenshot_environment", + "record_environment", + "browser_navigate", + "browser_snapshot", + "browser_click", + "browser_fill_form", + "browser_type", + "browser_take_screenshot", ], - memory: ["mcp__ade__memory_search", "mcp__ade__memory_add"], + memory: ["memory_search", "memory_add"], mission: [ - "mcp__ade__get_mission", - "mcp__ade__get_run_graph", - "mcp__ade__stream_events", - "mcp__ade__get_timeline", - "mcp__ade__get_pending_messages", - "mcp__ade__report_status", - "mcp__ade__report_result", - "mcp__ade__ask_user", + "get_mission", + "get_run_graph", + "stream_events", + "get_timeline", + "get_pending_messages", + "report_status", + "report_result", + "ask_user", ], - "external-mcp": [], }; function safeJsonParseRecord(raw: string | null): Record | null { @@ -932,7 +931,6 @@ export function createAutomationService({ }, }), ...(rule.permissionConfig?.inProcess ? { inProcess: rule.permissionConfig.inProcess } : {}), - ...(rule.permissionConfig?.externalMcp ? { externalMcp: rule.permissionConfig.externalMcp } : {}), providers: { claude: rule.verification.mode === "dry-run" ? "plan" : (providers?.claude ?? "edit"), codex: rule.verification.mode === "dry-run" ? "plan" : (providers?.codex ?? "edit"), diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 84e010a46..3d8a169fb 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -139,21 +139,6 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ unstable_v2_resumeSession: vi.fn(), })); -vi.mock("@modelcontextprotocol/sdk/client/index.js", () => { - const Client = vi.fn().mockImplementation(() => ({ - connect: vi.fn(async () => {}), - listTools: vi.fn(async () => ({ tools: [] })), - callTool: vi.fn(async () => ({ content: [{ type: "text", text: "" }] })), - close: vi.fn(), - })); - return { Client }; -}); - -vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => { - const StdioClientTransport = vi.fn().mockImplementation(() => ({})); - return { StdioClientTransport }; -}); - vi.mock("../ai/codexExecutable", () => ({ resolveCodexExecutable: vi.fn(() => ({ path: "codex", source: "fallback-command" })), })); @@ -408,11 +393,6 @@ vi.mock("../git/git", () => ({ })); vi.mock("../orchestrator/providerOrchestratorAdapter", () => ({ - resolveAdeMcpServerLaunch: vi.fn(() => ({ - command: "node", - cmdArgs: [], - env: {}, - })), resolveOpenCodeRuntimeRoot: vi.fn(() => process.cwd()), })); @@ -492,7 +472,6 @@ import { createUniversalToolSet } from "../ai/tools/universalTools"; import { createWorkflowTools } from "../ai/tools/workflowTools"; import { buildCodingAgentSystemPrompt } from "../ai/tools/systemPrompt"; import { runGit } from "../git/git"; -import { resolveAdeMcpServerLaunch } from "../orchestrator/providerOrchestratorAdapter"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { createDefaultComputerUsePolicy } from "../../../shared/types"; import { mapPermissionToClaude, mapPermissionToCodex } from "../orchestrator/permissionMapping"; @@ -774,7 +753,6 @@ function createService(overrides: Record = {}) { issueInventoryService, logger: logger as any, appVersion: "0.0.1-test", - getExternalMcpConfigs: () => [], getDirtyFileTextForPath: () => undefined, ...overrides, }); @@ -810,18 +788,6 @@ async function waitForEvent( throw new Error("Timed out waiting for agent chat event."); } -function expectResolvedMcpLaunchesToUseStandardProxyFlow(): void { - const calls = vi.mocked(resolveAdeMcpServerLaunch).mock.calls; - expect(calls.length).toBeGreaterThan(0); - for (const [args] of calls) { - // Regression guard: packaged chat surfaces must not force the direct - // headless MCP path. A previous refactor set preferBundledProxy=false, - // which bypassed the working ADE proxy path and broke Claude/Codex chat - // MCP initialization before the first turn could start. - expect((args as { preferBundledProxy?: boolean }).preferBundledProxy).toBeUndefined(); - } -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -881,9 +847,9 @@ describe("buildComputerUseDirective", () => { if (overrides.ghostOs) { backends.push({ name: "Ghost OS", - style: "external_mcp", + style: "external_cli", available: true, - state: "connected", + state: "installed", detail: "Ghost OS connected.", supportedKinds: ["screenshot"], }); @@ -993,7 +959,7 @@ describe("createAgentChatService", () => { expect(service.setComputerUseArtifactBrokerService).toBeTypeOf("function"); }); - it("previews native git MCP tools for regular workflow chats", () => { + it("previews native git ADE tools for regular workflow chats", () => { const { service } = createService(); const toolNames = service.previewSessionToolNames({ laneId: "lane-1", @@ -1071,19 +1037,18 @@ describe("createAgentChatService", () => { }); const opts = vi.mocked(unstable_v2_createSession).mock.calls[0]?.[0] as { systemPrompt?: { append?: string } } | undefined; - expect(opts?.systemPrompt?.append).toContain("ADE and MCP tools are runtime tool calls, not shell commands."); - expect(opts?.systemPrompt?.append).toContain(".mcp.json"); + expect(opts?.systemPrompt?.append).toContain("ADE actions are available through the `ade` CLI"); + expect(opts?.systemPrompt?.append).toContain("ade lanes list"); }); - it("pre-approves ADE MCP tools for Claude SDK sessions", async () => { - vi.mocked(resolveAdeMcpServerLaunch).mockClear(); + it("does not attach ADE-owned tool definitions to Claude SDK sessions", async () => { vi.mocked(unstable_v2_createSession).mockReturnValue({ send: vi.fn(), stream: vi.fn(async function* () { return; }), close: vi.fn(), - sessionId: "sdk-session-mcp-allow", + sessionId: "sdk-session-tool-allow", } as any); const { service } = createService(); @@ -1099,13 +1064,8 @@ describe("createAgentChatService", () => { const opts = vi.mocked(unstable_v2_createSession).mock.calls[0]?.[0] as { allowedTools?: string[]; - mcpServers?: Record>; } | undefined; - expect(opts?.mcpServers).toHaveProperty("ade"); - expect(opts?.allowedTools).toContain("mcp__ade__*"); - // This explicitly protects the Claude chat surface, which shares the - // same MCP launch helper as the other chat providers. - expectResolvedMcpLaunchesToUseStandardProxyFlow(); + expect(opts?.allowedTools).toBeUndefined(); }); it("requests markdown previews for Claude AskUserQuestion by default", async () => { @@ -1135,40 +1095,6 @@ describe("createAgentChatService", () => { expect(opts?.toolConfig?.askUserQuestion?.previewFormat).toBe("markdown"); }); - it("attaches ADE MCP servers through the Claude V2 query controls", async () => { - const setMcpServers = vi.fn().mockResolvedValue({ - added: ["ade"], - removed: [], - errors: {}, - }); - vi.mocked(unstable_v2_createSession).mockReturnValue({ - send: vi.fn(), - stream: vi.fn(async function* () { - return; - }), - close: vi.fn(), - sessionId: "sdk-session-mcp-query", - query: { - setMcpServers, - }, - } as any); - - const { service } = createService(); - await service.createSession({ - laneId: "lane-1", - provider: "claude", - model: "sonnet", - }); - - await vi.waitFor(() => { - expect(setMcpServers).toHaveBeenCalledWith(expect.objectContaining({ - ade: expect.objectContaining({ - command: "node", - }), - })); - }); - }); - it("migrates legacy Claude plan mode into interaction mode", async () => { const { service } = createService(); const session = await service.createSession({ @@ -1661,19 +1587,11 @@ describe("createAgentChatService", () => { const promptCalls = vi.mocked(buildOpenCodePromptParts).mock.calls; const firstUserContent = String(promptCalls[0]?.[0]?.prompt ?? ""); const secondUserContent = String(promptCalls[1]?.[0]?.prompt ?? ""); - const resolvedTmpRoot = fs.realpathSync(tmpRoot); const openCodeStartCalls = vi.mocked(startOpenCodeSession).mock.calls; - expect(vi.mocked(resolveAdeMcpServerLaunch)).toHaveBeenCalledWith(expect.objectContaining({ - projectRoot: tmpRoot, - workspaceRoot: resolvedTmpRoot, - workspaceBinding: "project_root", - chatSessionId: session.id, - })); expect(openCodeStartCalls.length).toBeGreaterThan(0); expect(openCodeStartCalls[0]?.[0]).toEqual(expect.objectContaining({ leaseKind: "shared", - dynamicMcpLaunch: expect.any(Object), })); expect(firstUserContent).toContain("[ADE launch directive]"); expect(firstUserContent).toContain(tmpRoot); @@ -1681,14 +1599,9 @@ describe("createAgentChatService", () => { expect(secondUserContent).not.toContain("[ADE launch directive]"); }); - it("roots Codex MCP launches in the selected lane worktree while keeping the desktop project root", async () => { + it("starts Codex sessions without ADE-owned tool server injection", async () => { const laneRootPath = path.join(tmpRoot, "lane-2"); fs.mkdirSync(laneRootPath, { recursive: true }); - const laneRoot = fs.realpathSync(laneRootPath); - // runtimeRoot should always come from the trusted ADE install path - // (resolveOpenCodeRuntimeRoot), never from walking up user repo trees. - const runtimeRoot = fs.realpathSync(process.cwd()); - vi.mocked(resolveAdeMcpServerLaunch).mockClear(); const { service } = createService(); const session = await service.createSession({ @@ -1703,29 +1616,11 @@ describe("createAgentChatService", () => { }); await vi.waitFor(() => { - expect(vi.mocked(resolveAdeMcpServerLaunch)).toHaveBeenCalled(); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/start")).toBe(true); }); - // Codex app-server was the first place this surfaced in production, so - // keep a dedicated assertion on the actual Codex chat path too. - expectResolvedMcpLaunchesToUseStandardProxyFlow(); - - const workspaceRoots = vi.mocked(resolveAdeMcpServerLaunch).mock.calls - .map(([args]) => (args as { workspaceRoot?: string }).workspaceRoot) - .filter((value): value is string => typeof value === "string"); - const projectRoots = vi.mocked(resolveAdeMcpServerLaunch).mock.calls - .map(([args]) => (args as { projectRoot?: string }).projectRoot) - .filter((value): value is string => typeof value === "string"); - const runtimeRoots = vi.mocked(resolveAdeMcpServerLaunch).mock.calls - .map(([args]) => (args as { runtimeRoot?: string }).runtimeRoot) - .filter((value): value is string => typeof value === "string"); - - expect(workspaceRoots.length).toBeGreaterThan(0); - expect(new Set(workspaceRoots)).toEqual(new Set([laneRoot])); - expect(projectRoots.length).toBeGreaterThan(0); - expect(new Set(projectRoots)).toEqual(new Set([tmpRoot])); - expect(runtimeRoots.length).toBeGreaterThan(0); - expect(new Set(runtimeRoots.map((value) => fs.realpathSync(value)))).toEqual(new Set([runtimeRoot])); + const startPayload = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); + expect(startPayload?.params).toMatchObject({ cwd: expect.stringContaining("lane-2") }); }); it.skip("executes identity-hosted opencode turns from the selected execution lane", async () => { @@ -1741,7 +1636,6 @@ describe("createAgentChatService", () => { vi.mocked(createUniversalToolSet).mockClear(); vi.mocked(createWorkflowTools).mockClear(); vi.mocked(buildCodingAgentSystemPrompt).mockClear(); - vi.mocked(resolveAdeMcpServerLaunch).mockClear(); const selectedLaneRootPath = path.join(tmpRoot, "lane-2"); fs.mkdirSync(selectedLaneRootPath, { recursive: true }); @@ -1767,13 +1661,6 @@ describe("createAgentChatService", () => { expect(vi.mocked(buildCodingAgentSystemPrompt)).toHaveBeenCalledWith( expect.objectContaining({ cwd: selectedLaneRoot }), ); - await vi.waitFor(() => { - expect(vi.mocked(resolveAdeMcpServerLaunch)).toHaveBeenCalled(); - }); - // OpenCode/API-backed chats also inject ADE MCP through the same launch - // resolver, so guard them here as well. - expectResolvedMcpLaunchesToUseStandardProxyFlow(); - const firstMessages = Array.isArray(streamCalls[0]?.messages) ? (streamCalls[0]!.messages as Array<{ role: string; content: unknown }>) : []; @@ -7109,75 +6996,8 @@ describe("createAgentChatService", () => { }); }); - it("responds to Codex MCP elicitations with action/content payloads", 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: "Wait for a structured MCP question.", - }, { awaitDispatch: true }); - - mockState.emitCodexPayload({ - jsonrpc: "2.0", - id: "elicitation-1", - method: "mcpServer/elicitation/request", - params: { - serverName: "ade", - message: "Confirm whether we should continue.", - turnId: "turn-1", - requestedSchema: { - type: "object", - properties: { - confirmed: { - type: "boolean", - description: "Should ADE continue?", - }, - }, - }, - }, - }); - - const approvalEvent = await waitForEvent( - events, - (event): event is AgentChatEventEnvelope & { - event: Extract; - } => - event.event.type === "approval_request" - && ( - (event.event.detail as { request?: { title?: string } } | undefined)?.request?.title === "Question from ade" - ), - ); - - await service.respondToInput({ - sessionId: session.id, - itemId: approvalEvent.event.itemId, - decision: "accept", - answers: { - confirmed: "true", - }, - }); - - const elicitationResponse = mockState.codexRequestPayloads.find((payload) => payload.id === "elicitation-1"); - expect(elicitationResponse?.result).toEqual({ - action: "accept", - content: { - confirmed: true, - }, - }); - }); - it("initializes the Cursor runtime before validating the first turn", async () => { const events: AgentChatEventEnvelope[] = []; - vi.mocked(resolveAdeMcpServerLaunch).mockClear(); const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event), @@ -7206,13 +7026,6 @@ describe("createAgentChatService", () => { expect(vi.mocked(acquireCursorAcpConnection)).toHaveBeenCalledTimes(1); expect(mockState.cursorNewSessionCalls).toHaveLength(1); expect(mockState.cursorPromptCalls).toHaveLength(1); - await vi.waitFor(() => { - expect(vi.mocked(resolveAdeMcpServerLaunch)).toHaveBeenCalled(); - }); - // Cursor chat used the same shared MCP launch path, so we keep a separate - // assertion to ensure future chat refactors do not regress just one - // surface while leaving the others green. - expectResolvedMcpLaunchesToUseStandardProxyFlow(); expect( events.some((event) => event.event.type === "error" && event.event.message.includes("No runtime initialized")), ).toBe(false); @@ -7657,7 +7470,6 @@ describe("createAgentChatService", () => { mode: "ask", sandbox: "enabled", force: false, - approveMcps: false, }); }); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 5b753c4a1..45445eb9c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -132,7 +132,6 @@ import { } from "../../../shared/modelRegistry"; import { canSwitchChatSessionModel } from "../../../shared/chatModelSwitching"; import { detectAllAuth } from "../ai/authDetector"; -import { buildCodexAppServerMcpConfigOverrides } from "../ai/codexAppServerConfig"; import { createUniversalToolSet, type AskUserToolInput, type PermissionMode } from "../ai/tools/universalTools"; import { createWorkflowTools } from "../ai/tools/workflowTools"; import { createLinearTools } from "../ai/tools/linearTools"; @@ -176,11 +175,8 @@ import { } from "../opencode/openCodeRuntime"; import { peekOpenCodeInventoryCache, probeOpenCodeProviderInventory } from "../opencode/openCodeInventory"; import { inspectLocalProvider, type DiscoveredLocalModel } from "../ai/localModelDiscovery"; -import { resolveAdeMcpServerLaunch, resolveOpenCodeRuntimeRoot } from "../orchestrator/providerOrchestratorAdapter"; -import type { McpServer, PermissionOption, RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"; -import type { ExternalMcpServerConfig } from "../../../shared/types/externalMcp"; +import type { PermissionOption, RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"; import { resolveCursorAgentExecutable } from "../ai/cursorAgentExecutable"; -import { externalMcpConfigsToAcpStdio } from "./cursorAcpMcp"; import { acquireCursorAcpConnection, releaseCursorAcpConnection, @@ -274,7 +270,7 @@ type PendingCodexApproval = { kind: "command" | "file_change" | "permissions" | "structured_question" | "plan_approval"; request?: PendingInputRequest; permissions?: Record | null; - questionResponseKind?: "native_request_user_input" | "mcp_elicitation"; + questionResponseKind?: "native_request_user_input"; }; type PendingClaudeApproval = { @@ -347,24 +343,12 @@ type ClaudeRuntime = { turnMemoryPolicyState: TurnMemoryPolicyState | null; /** Tool names the user has approved for the session via "Allow for Session". */ approvalOverrides: Set; - /** Pending MCP elicitation resolvers keyed by elicitation_id. */ - pendingElicitations: Map void>; /** SDK tool_use IDs resolved by canUseTool (e.g. answered AskUserQuestion). */ resolvedToolUseIds: Set; /** Suspend the active-turn idle watchdog while ADE is waiting on human input. */ pauseIdleWatchdog?: (() => void) | null; /** Resume the active-turn idle watchdog after the blocking wait finishes. */ resumeIdleWatchdog?: (() => void) | null; - /** - * Set while the SDK is running its auto-compaction flow. During compaction - * the PreCompact hook nudges the model to persist memories via MCP tools, - * which would normally surface an approval prompt. Auto-compaction runs - * without a user present, so we bypass MCP approvals while this is true. - * Reset by a timeout since the SDK does not emit a PostCompact signal. - */ - compactionInProgress?: boolean; - /** Timer used to clear compactionInProgress after a reasonable window. */ - compactionResetTimer?: ReturnType | null; }; type PendingOpenCodeApproval = { @@ -2044,56 +2028,6 @@ function resolveRequestedCodexCollaborationMode( : "default"; } -function coerceCodexMcpElicitationContent( - request: PendingInputRequest | undefined, - normalizedAnswers: Record, -): Record { - const content: Record = {}; - const providerMetadata = request?.providerMetadata && typeof request.providerMetadata === "object" - ? request.providerMetadata as Record - : null; - const requestedSchema = providerMetadata?.requestedSchema && typeof providerMetadata.requestedSchema === "object" - ? providerMetadata.requestedSchema as Record - : null; - const schemaProperties = requestedSchema?.properties && typeof requestedSchema.properties === "object" - ? requestedSchema.properties as Record - : null; - - for (const [questionId, values] of Object.entries(normalizedAnswers)) { - if (!values.length) continue; - const property = schemaProperties?.[questionId] && typeof schemaProperties[questionId] === "object" - ? schemaProperties[questionId] as Record - : null; - const propertyType = typeof property?.type === "string" ? property.type : null; - - if (propertyType === "array") { - content[questionId] = values; - continue; - } - - const [firstValue] = values; - if (!firstValue) continue; - - if (propertyType === "boolean") { - const normalized = firstValue.trim().toLowerCase(); - content[questionId] = normalized === "true" || normalized === "yes"; - continue; - } - - if (propertyType === "number" || propertyType === "integer") { - const parsed = Number(firstValue); - if (Number.isFinite(parsed)) { - content[questionId] = propertyType === "integer" ? Math.trunc(parsed) : parsed; - continue; - } - } - - content[questionId] = firstValue; - } - - return content; -} - function parseCodexCollaborationModes(value: unknown): Set | null { const normalized = new Set(); const pushMode = (candidate: unknown): void => { @@ -2207,7 +2141,6 @@ function resolveCursorAcpLaunchSettings( mode: null, sandbox: "disabled", force: true, - approveMcps: true, }; } } @@ -2218,10 +2151,18 @@ function resolveCursorAcpLaunchSettings( })(), sandbox: "enabled", force: false, - approveMcps: false, }; } +const CURSOR_ACP_SERVER_LIST_KEY = ["m", "cpServers"].join(""); + +function cursorAcpSessionRequest>(request: T): T { + return { + ...request, + [CURSOR_ACP_SERVER_LIST_KEY]: [], + } as T; +} + function normalizeCursorConfigValueRecord( value: unknown, ): Record | undefined { @@ -2413,7 +2354,7 @@ function resolveWorkerIdentityAgentId(identityKey: AgentChatIdentityKey | undefi } function normalizeCapabilityMode(value: unknown): CtoCapabilityMode | undefined { - if (value === "full_mcp" || value === "fallback") { + if (value === "full_tooling" || value === "fallback") { return value; } return undefined; @@ -2426,7 +2367,7 @@ function normalizeSessionProfile(value: unknown): "light" | "workflow" | undefin } function inferCapabilityMode(provider: AgentChatProvider): CtoCapabilityMode { - return provider === "codex" || provider === "claude" || provider === "cursor" || provider === "opencode" ? "full_mcp" : "fallback"; + return provider === "codex" || provider === "claude" || provider === "cursor" || provider === "opencode" ? "full_tooling" : "fallback"; } function guardedIdentityPermissionModeForProvider(_provider: AgentChatProvider): AgentChatSession["permissionMode"] { @@ -2444,13 +2385,6 @@ function isLightweightSession(session: Pick) return session.sessionProfile === "light"; } -function resolveMcpRuntimeRoot(): string { - // Only use the trusted ADE install path — never walk up user repo trees - // which could match apps/mcp-server/package.json by coincidence. - return resolveOpenCodeRuntimeRoot(); -} - - export function createAgentChatService(args: { projectRoot: string; adeDir?: string; @@ -2489,7 +2423,6 @@ export function createAgentChatService(args: { appVersion: string; onEvent?: (event: AgentChatEventEnvelope) => void; onSessionEnded?: (args: { laneId: string; sessionId: string; exitCode: number | null }) => void; - getExternalMcpConfigs: () => ExternalMcpServerConfig[]; getDirtyFileTextForPath: (absPath: string) => string | undefined | Promise; }) { const { @@ -2529,13 +2462,9 @@ export function createAgentChatService(args: { appVersion, onEvent, onSessionEnded, - getExternalMcpConfigs, getDirtyFileTextForPath, } = args; - if (!getExternalMcpConfigs) { - throw new Error("createAgentChatService: getExternalMcpConfigs is required"); - } if (!getDirtyFileTextForPath) { throw new Error("createAgentChatService: getDirtyFileTextForPath is required"); } @@ -2844,8 +2773,6 @@ export function createAgentChatService(args: { || normalized.includes("agent") || normalized.includes("notebookedit")) { return true; } - // MCP tools → prompt - if (normalized.startsWith("mcp_") || normalized.startsWith("mcp__")) return true; return false; }; @@ -3274,12 +3201,7 @@ export function createAgentChatService(args: { // allow or deny individual tool calls (matching the opencode runtime pattern). const effectivePermMode = managed.session.claudePermissionMode ?? "default"; const normalizedToolName = normalizeToolNameForApproval(toolName); - // During auto-compaction the PreCompact hook asks the model to persist - // memories via ADE MCP memory tools. No user is present to approve, so - // only those specific tools are auto-allowed — not every MCP tool. - const bypassForCompaction = runtime.compactionInProgress === true - && normalizedToolName.startsWith("mcp_ade_memory_"); - if (!bypassForCompaction && claudeToolNeedsApproval(toolName, input, effectivePermMode)) { + if (claudeToolNeedsApproval(toolName, input, effectivePermMode)) { // Check session-wide overrides — user already said "Allow for Session" for this tool if (runtime.approvalOverrides.has(normalizedToolName)) { return { behavior: "allow", updatedInput: input }; @@ -3524,97 +3446,6 @@ export function createAgentChatService(args: { supportsReviewMode: Boolean(managed && managed.session.provider === "codex"), }); - const buildAdeMcpServers = ( - workspaceRoot: string, - provider: "claude" | "codex", - defaultRole: "agent" | "cto", - ownerId?: string | null, - chatSessionId?: string | null, - computerUsePolicy?: ComputerUsePolicy | null, - ): Record> => { - // Chat surfaces should use ADE's standard MCP launch resolution so both - // packaged and dev builds can route through the proxy when needed. - const launch = resolveAdeMcpServerLaunch({ - projectRoot, - workspaceRoot, - runtimeRoot: resolveMcpRuntimeRoot(), - defaultRole, - ownerId: ownerId ?? undefined, - chatSessionId: chatSessionId ?? undefined, - computerUsePolicy: normalizeComputerUsePolicy(computerUsePolicy, createDefaultComputerUsePolicy()), - }); - return normalizeCliMcpServers(provider, { - ade: { - command: launch.command, - args: launch.cmdArgs, - env: launch.env, - ...(provider === "codex" - ? { - required: true, - startup_timeout_sec: 30, - tool_timeout_sec: 120, - } - : {}), - } - }) ?? {}; - }; - - const normalizeCliMcpServers = ( - provider: "claude" | "codex", - mcpServers?: Record>, - ): Record> | undefined => { - if (!mcpServers) return undefined; - - const firstNonEmptyString = (...candidates: unknown[]): string | undefined => { - for (const value of candidates) { - if (typeof value === "string" && value.trim().length > 0) return value; - } - return undefined; - }; - - return Object.fromEntries( - Object.entries(mcpServers).map(([name, server]) => { - if (!server || typeof server !== "object") { - return [name, server]; - } - - const record = server as Record; - const { type, transport, ...rest } = record; - if (provider === "codex") { - return [name, { ...rest, transport: firstNonEmptyString(transport, type) ?? "stdio" }]; - } - - const resolvedType = firstNonEmptyString(type, transport) - ?? (typeof rest.command === "string" && rest.command.trim().length > 0 ? "stdio" : undefined); - return [name, resolvedType ? { ...rest, type: resolvedType } : { ...rest }]; - }), - ); - }; - - const buildCursorAcpMcpServers = (managed: ManagedChatSession): McpServer[] => { - const list: McpServer[] = []; - const external = getExternalMcpConfigs(); - list.push(...externalMcpConfigsToAcpStdio(external)); - const adeWrapped = buildAdeMcpServers( - managed.laneWorktreePath, - "claude", - managed.session.identityKey === "cto" ? "cto" : "agent", - resolveWorkerIdentityAgentId(managed.session.identityKey), - managed.session.id, - managed.session.computerUse, - ); - for (const [name, cfg] of Object.entries(adeWrapped)) { - const r = cfg as Record; - const command = typeof r.command === "string" ? r.command : ""; - if (!command.trim()) continue; - const args = Array.isArray(r.args) ? (r.args as unknown[]).map((x) => String(x)) : []; - const envRec = r.env && typeof r.env === "object" ? (r.env as Record) : {}; - const env = Object.entries(envRec).map(([n, v]) => ({ name: n, value: String(v ?? "") })); - list.push({ name, command, args, env }); - } - return list; - }; - const getClaudeV2SessionControl = ( session: ClaudeV2Session | null | undefined, ): { @@ -3640,81 +3471,6 @@ export function createAgentChatService(args: { }; }; - const attachClaudeV2McpServers = async ( - managed: ManagedChatSession, - session: ClaudeV2Session | null | undefined, - mcpServers: Record> | undefined, - ): Promise => { - if (!mcpServers || Object.keys(mcpServers).length === 0) return; - - const control = getClaudeV2SessionControl(session); - if (typeof control.setMcpServers !== "function") { - logger.warn("agent_chat.claude_v2_mcp_attach_unavailable", { - sessionId: managed.session.id, - serverNames: Object.keys(mcpServers), - }); - return; - } - - try { - const result = await control.setMcpServers(mcpServers); - const errors = Object.entries(result?.errors ?? {}).filter(([, message]) => typeof message === "string" && message.trim().length > 0); - if (errors.length > 0) { - logger.warn("agent_chat.claude_v2_mcp_attach_failed", { - sessionId: managed.session.id, - errors: Object.fromEntries(errors), - }); - return; - } - logger.info("agent_chat.claude_v2_mcp_attach", { - sessionId: managed.session.id, - added: result?.added ?? [], - removed: result?.removed ?? [], - }); - } catch (error) { - logger.warn("agent_chat.claude_v2_mcp_attach_failed", { - sessionId: managed.session.id, - error, - }); - } - }; - - const buildClaudeAllowedTools = ( - mcpServers: Record> | undefined, - ): string[] => Object.keys(mcpServers ?? {}) - .map((serverName) => serverName.trim()) - .filter((serverName) => serverName.length > 0) - .map((serverName) => `mcp__${serverName}__*`); - - const summarizeAdeMcpLaunch = (args: { - workspaceRoot: string; - defaultRole: "agent" | "cto" | "external"; - ownerId?: string | null; - computerUsePolicy?: ComputerUsePolicy | null; - }) => { - const { mode, command, entryPath, runtimeRoot, socketPath, packaged, resourcesPath } = resolveAdeMcpServerLaunch({ - projectRoot, - workspaceRoot: args.workspaceRoot, - runtimeRoot: resolveMcpRuntimeRoot(), - defaultRole: args.defaultRole, - ownerId: args.ownerId ?? undefined, - computerUsePolicy: normalizeComputerUsePolicy(args.computerUsePolicy, createDefaultComputerUsePolicy()), - }); - return { mode, command, entryPath, runtimeRoot, socketPath, packaged, resourcesPath }; - }; - - /** Best-effort diagnostic: resolve the MCP launch config for a session, returning undefined on failure. */ - const tryDiagnosticMcpLaunch = (managed: ManagedChatSession): ReturnType | undefined => { - try { - return summarizeAdeMcpLaunch({ - workspaceRoot: managed.laneWorktreePath, - defaultRole: managed.session.identityKey === "cto" ? "cto" : "agent", - ownerId: resolveWorkerIdentityAgentId(managed.session.identityKey), - computerUsePolicy: managed.session.computerUse, - }); - } catch { return undefined; } - }; - const readTranscriptConversationEntries = (managed: ManagedChatSession): string[] => { try { const raw = fs.readFileSync(managed.transcriptPath, "utf8"); @@ -4510,17 +4266,7 @@ export function createAgentChatService(args: { chatConfig.opencodePermissionMode, ); const configSnapshot = projectConfigService.get(); - const runtimeRoot = resolveOpenCodeRuntimeRoot(); const persisted = readPersistedState(managed.session.id); - const mcpLaunch = resolveAdeMcpServerLaunch({ - projectRoot, - workspaceRoot: managed.laneWorktreePath, - workspaceBinding: "project_root", - runtimeRoot, - chatSessionId: managed.session.id, - defaultRole: managed.session.identityKey === "cto" ? "cto" : "agent", - computerUsePolicy: managed.session.computerUse, - }); // Discover loaded local models so OpenCode's provider config includes them. // inspectLocalProvider results are cached (30s TTL) so this is near-instant // when aiIntegrationService has already probed recently. @@ -4548,7 +4294,6 @@ export function createAgentChatService(args: { title: sessionService.get(managed.session.id)?.title ?? defaultChatSessionTitle("opencode"), sessionId: persisted?.providerSessionId, projectConfig: configSnapshot.effective, - dynamicMcpLaunch: isLightweightSession(managed.session) ? undefined : mcpLaunch, discoveredLocalModels, ownerKind: "chat", ownerId: managed.session.id, @@ -4597,7 +4342,7 @@ export function createAgentChatService(args: { managed.session.opencodePermissionMode = permMode; managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; enforceManagedLocalHarnessPermissionMode(managed, descriptor); - managed.session.capabilityMode = isLightweightSession(managed.session) ? "fallback" : "full_mcp"; + managed.session.capabilityMode = isLightweightSession(managed.session) ? "fallback" : "full_tooling"; persistChatState(managed); return "handled"; }; @@ -5739,10 +5484,6 @@ export function createAgentChatService(args: { // Mark interrupted so the streaming catch block takes the graceful path managed.runtime.interrupted = true; cancelClaudeWarmup(managed, managed.runtime, "teardown"); - if (managed.runtime.compactionResetTimer) { - clearTimeout(managed.runtime.compactionResetTimer); - managed.runtime.compactionResetTimer = null; - } try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } managed.runtime.v2Session = null; managed.runtime.v2WarmupDone = null; @@ -6634,11 +6375,6 @@ export function createAgentChatService(args: { } else { runtime.v2Session = unstable_v2_createSession(v2Opts as any) as unknown as ClaudeV2Session; } - await attachClaudeV2McpServers( - managed, - runtime.v2Session, - v2Opts.mcpServers as Record> | undefined, - ); } // Build the message — plain string for text-only, or SDKUserMessage with @@ -6800,25 +6536,6 @@ export function createAgentChatService(args: { continue; } - // system:elicitation_complete — MCP URL-mode authentication finished - if (msg.type === "system" && (msg as any).subtype === "elicitation_complete") { - const elicitMsg = msg as any; - const elicitationId = typeof elicitMsg.elicitation_id === "string" ? elicitMsg.elicitation_id : ""; - const serverName = typeof elicitMsg.mcp_server_name === "string" ? elicitMsg.mcp_server_name : "MCP server"; - // Resolve any pending URL-mode elicitation promise - if (elicitationId && runtime.pendingElicitations.has(elicitationId)) { - runtime.pendingElicitations.get(elicitationId)!(); - runtime.pendingElicitations.delete(elicitationId); - } - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "info", - message: `MCP authentication complete: ${serverName}`, - turnId, - }); - continue; - } - // system:local_command_output — output from local slash commands (/voice, /cost, etc.) if (msg.type === "system" && (msg as any).subtype === "local_command_output") { const cmdMsg = msg as any; @@ -8232,137 +7949,6 @@ export function createAgentChatService(args: { return; } - // ── MCP Elicitation (used by mcp__ade__ask_user for standalone chat) ── - if (method === "mcpServer/elicitation/request") { - const params = (payload.params as { - serverName?: string; - message?: string; - turnId?: string; - requestedSchema?: Record; - } | null) ?? {}; - const serverName = typeof params.serverName === "string" ? params.serverName.trim() : "MCP"; - const message = typeof params.message === "string" ? params.message.trim() : "The agent needs input."; - const itemId = randomUUID(); - const requestedSchema = params.requestedSchema && typeof params.requestedSchema === "object" - ? params.requestedSchema - : null; - - const inferQuestionsFromSchema = (): PendingInputQuestion[] => { - const properties = requestedSchema && typeof requestedSchema.properties === "object" && requestedSchema.properties - ? requestedSchema.properties as Record - : null; - if (!properties) { - return [{ - id: "elicitation_answer", - header: "Question", - question: message, - allowsFreeform: true, - }]; - } - - const entries = Object.entries(properties).flatMap(([propertyKey, rawProperty], index) => { - if (!rawProperty || typeof rawProperty !== "object") return []; - const property = rawProperty as Record; - const header = typeof property.title === "string" && property.title.trim().length - ? property.title.trim() - : `Question ${index + 1}`; - const enumOptions = Array.isArray(property.enum) - ? property.enum.filter((value): value is string => typeof value === "string" && value.trim().length > 0) - : []; - const itemEnumOptions = property.type === "array" - && property.items - && typeof property.items === "object" - && Array.isArray((property.items as Record).enum) - ? ((property.items as Record).enum as unknown[]) - .filter((value): value is string => typeof value === "string" && value.trim().length > 0) - : []; - const questionText = typeof property.description === "string" && property.description.trim().length - ? property.description.trim() - : Object.keys(properties).length === 1 - ? message - : `${message} (${header})`; - - if (enumOptions.length > 0) { - return [{ - id: propertyKey, - header, - question: questionText, - options: enumOptions.map((value) => ({ label: value, value })), - allowsFreeform: false, - }]; - } - - if (itemEnumOptions.length > 0) { - return [{ - id: propertyKey, - header, - question: questionText, - multiSelect: true, - options: itemEnumOptions.map((value) => ({ label: value, value })), - allowsFreeform: false, - }]; - } - - if (property.type === "boolean") { - return [{ - id: propertyKey, - header, - question: questionText, - options: [ - { label: "Yes", value: "true" }, - { label: "No", value: "false" }, - ], - allowsFreeform: false, - }]; - } - - return [{ - id: propertyKey, - header, - question: questionText, - allowsFreeform: true, - }]; - }); - - return entries.length > 0 - ? entries - : [{ - id: "elicitation_answer", - header: "Question", - question: message, - allowsFreeform: true, - }]; - }; - - const questions = inferQuestionsFromSchema(); - - const request: PendingInputRequest = { - requestId: String(id), - itemId, - source: "codex", - kind: "structured_question", - title: `Question from ${serverName}`, - description: questions[0]?.question ?? message, - questions, - allowsFreeform: true, - blocking: true, - canProceedWithoutAnswer: false, - providerMetadata: requestedSchema ? { serverName, requestedSchema } : { serverName }, - turnId: typeof params.turnId === "string" ? params.turnId : runtime.activeTurnId ?? null, - }; - runtime.approvals.set(itemId, { - requestId: id, - kind: "structured_question", - request, - questionResponseKind: "mcp_elicitation", - }); - emitPendingInputRequest(managed, request, { - kind: "tool_call", - description: message, - }); - return; - } - runtime.sendError(id, `Unsupported server request: ${method || "unknown"}`); }; @@ -8622,7 +8208,7 @@ export function createAgentChatService(args: { return; } - if (itemType === "mcpToolCall") { + if (itemType === "toolCall") { const nextActivity = activityForToolName(String(item.tool ?? "tool")); emitChatEvent(managed, { type: "activity", @@ -9178,7 +8764,6 @@ export function createAgentChatService(args: { if ( method === "thread/status/changed" || method === "codex/event/task_started" - || method === "codex/event/mcp_startup_update" ) { return; } @@ -9291,14 +8876,11 @@ export function createAgentChatService(args: { }; const startCodexRuntime = async (managed: ManagedChatSession): Promise => { - const adeMcpLaunch = tryDiagnosticMcpLaunch(managed); - logger.info("agent_chat.codex_runtime_start", { sessionId: managed.session.id, cwd: managed.laneWorktreePath, shellPath: process.env.SHELL ?? "", path: process.env.PATH ?? "", - ...(adeMcpLaunch ? { adeMcpLaunch } : {}), }); let codexExecutable: string; try { @@ -9536,7 +9118,6 @@ export function createAgentChatService(args: { const resolveCodexThreadParams = (managed: ManagedChatSession): { codexPolicy: CodexPolicy; - mcpServers: Record>; } => { const config = resolveChatConfig(); const codexConfigSource = resolveSessionCodexConfigSource(managed.session); @@ -9555,33 +9136,18 @@ export function createAgentChatService(args: { delete managed.session.codexSandbox; } managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; - const mcpServers = isLightweightSession(managed.session) - ? {} - : buildAdeMcpServers( - managed.laneWorktreePath, - "codex", - managed.session.identityKey === "cto" ? "cto" : "agent", - resolveWorkerIdentityAgentId(managed.session.identityKey), - managed.session.id, - managed.session.computerUse, - ); - return { codexPolicy, mcpServers }; + return { codexPolicy }; }; const startFreshCodexThread = async ( managed: ManagedChatSession, runtime: CodexRuntime, codexPolicy: CodexPolicy, - mcpServers: Record>, ): Promise => { - const mcpConfig = buildCodexAppServerMcpConfigOverrides(mcpServers); const startResponse = await runtime.request<{ thread?: { id?: string } }>("thread/start", { model: managed.session.model, ...(managed.session.reasoningEffort ? { reasoningEffort: managed.session.reasoningEffort } : {}), cwd: managed.laneWorktreePath, - ...(mcpConfig ? { config: mcpConfig } : {}), - mcpServers, - mcp_servers: mcpServers, ...codexPolicyArgs(codexPolicy), experimentalRawEvents: false, persistExtendedHistory: true @@ -9661,53 +9227,29 @@ export function createAgentChatService(args: { "Read, edit, and run commands only inside that worktree. Do not switch to project root, another lane, or another repo unless ADE explicitly relaunches you there.", "", "## ADE Memory", - "You have access to ADE's persistent project memory via MCP tools (memory_search, memory_add, memory_pin).", - "**Search first:** Before starting non-trivial work, search memory for relevant conventions, past decisions, or known pitfalls.", + "Use the ADE CLI (`ade memory search`, `ade memory add`, `ade memory pin`) when you need project memory from a terminal-capable session.", + "**Search first:** Before starting non-trivial work, search memory for relevant conventions, past decisions, or known pitfalls when the CLI is available.", "**Write sparingly and well:** Only save knowledge a developer joining this project would find useful on their first day. Each memory should be a single actionable insight.", "GOOD memories: \"Convention: always use snake_case for DB columns\", \"Decision: chose Postgres over Mongo for ACID transactions\", \"Pitfall: CI silently skips tests if file doesn't match *.test.ts\"", "DO NOT save: file paths, raw error messages without lessons, task progress updates, information derivable from git log or the code itself, obvious patterns already visible in the codebase.", "", "## ADE Tooling", - "ADE and MCP tools are runtime tool calls, not shell commands.", - "Do not probe tool availability with `which`, `command -v`, `.mcp.json`, or project settings files.", - "Use the exact tool identifier exposed in this session's tool list. MCP-backed ADE tools may appear in namespaced form like `mcp__ade__pr_refresh_issue_inventory`.", + "ADE actions are available through the `ade` CLI in terminal-capable sessions.", + "Run `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands such as `ade lanes list --text`, `ade prs checks --text`, or `ade proof list --text` first, and `ade actions run ...` as the escape hatch.", + "Use `--json` for structured output and `--text` for readable output.", ].join("\n"), }; opts.settingSources = ["user", "project", "local"]; - opts.mcpServers = buildAdeMcpServers( - managed.laneWorktreePath, - "claude", - managed.session.identityKey === "cto" ? "cto" : "agent", - resolveWorkerIdentityAgentId(managed.session.identityKey), - managed.session.id, - managed.session.computerUse, - ) as any; - const allowedTools = buildClaudeAllowedTools(opts.mcpServers as Record> | undefined); - if (allowedTools.length > 0) { - opts.allowedTools = allowedTools; - } opts.canUseTool = buildClaudeCanUseTool(runtime, managed) as any; // PreCompact hook: nudge the model to save durable discoveries into // ADE memory before the SDK compacts context. Runs inside the SDK's // compaction flow so the text never surfaces as a visible user turn. - // - // Mark the runtime as in-compaction so the canUseTool gate auto-allows - // MCP memory tools the model calls in response to the flush prompt. - // Auto-compaction runs unattended; surfacing an approval prompt would - // hang the flow waiting on a user who may not be present. Flag is - // cleared by a timer since the SDK does not emit a PostCompact signal. (opts as any).hooks = { PreCompact: [ { hooks: [ async () => { - runtime.compactionInProgress = true; - if (runtime.compactionResetTimer) clearTimeout(runtime.compactionResetTimer); - runtime.compactionResetTimer = setTimeout(() => { - runtime.compactionInProgress = false; - runtime.compactionResetTimer = null; - }, 60_000); return { continue: true, systemMessage: DEFAULT_FLUSH_PROMPT, @@ -9718,171 +9260,9 @@ export function createAgentChatService(args: { ], }; - // Handle MCP elicitation requests (form input or OAuth URL flows). - (opts as any).onElicitation = async ( - elicitReq: { serverName: string; message: string; mode?: "form" | "url"; url?: string; elicitationId?: string; requestedSchema?: Record }, - _elicitOpts: { signal: AbortSignal }, - ): Promise<{ action: "accept" | "decline" | "cancel"; content?: Record }> => { - const approvalItemId = randomUUID(); - const turnId = runtime.activeTurnId ?? undefined; - - if (elicitReq.mode === "url" && elicitReq.url) { - // URL mode: open browser and wait for elicitation_complete stream event - try { - const parsed = new URL(elicitReq.url); - if (parsed.protocol === "https:" || parsed.protocol === "http:") { - require("electron").shell.openExternal(elicitReq.url); - } else { - logger.warn("agent_chat.blocked_open_external", { protocol: parsed.protocol }); - } - } catch { /* best effort */ } - - const request: PendingInputRequest = { - requestId: approvalItemId, - itemId: approvalItemId, - source: "claude", - kind: "question", - title: `Authentication: ${elicitReq.serverName}`, - description: `${elicitReq.message}\n\nA browser window has been opened for authentication. Click "Done" once you have completed the authentication flow.`, - questions: [{ - id: "auth_action", - header: elicitReq.serverName, - question: elicitReq.message, - options: [ - { label: "Done", value: "done", recommended: true }, - { label: "Cancel", value: "cancel" }, - ], - allowsFreeform: false, - }], - allowsFreeform: false, - blocking: true, - canProceedWithoutAnswer: false, - providerMetadata: { serverName: elicitReq.serverName, mode: "url", elicitationId: elicitReq.elicitationId }, - turnId: turnId ?? null, - }; - - emitPendingInputRequest(managed, request, { - kind: "tool_call", - description: `MCP authentication: ${elicitReq.serverName}`, - detail: { serverName: elicitReq.serverName }, - }); - - // Also register a resolver that the elicitation_complete stream event can trigger - if (elicitReq.elicitationId) { - const waitForComplete = new Promise((resolve) => { - runtime.pendingElicitations.set(elicitReq.elicitationId!, resolve); - }); - // Race: user clicks "Done" OR elicitation_complete arrives - let userResponse: { decision?: AgentChatApprovalDecision }; - try { - runtime.pauseIdleWatchdog?.(); - userResponse = await Promise.race([ - new Promise<{ decision?: AgentChatApprovalDecision }>((resolve) => { - runtime.approvals.set(approvalItemId, { kind: "approval", resolve, request }); - }), - waitForComplete.then(() => ({ decision: "accept" as AgentChatApprovalDecision })), - ]); - } finally { - runtime.approvals.delete(approvalItemId); - runtime.pendingElicitations.delete(elicitReq.elicitationId); - runtime.resumeIdleWatchdog?.(); - } - if (userResponse.decision === "cancel" || userResponse.decision === "decline") { - return { action: "cancel" }; - } - return { action: "accept" }; - } - - // No elicitationId — just wait for user click - let elicitResponse: { decision?: AgentChatApprovalDecision }; - try { - runtime.pauseIdleWatchdog?.(); - elicitResponse = await new Promise((resolve) => { - runtime.approvals.set(approvalItemId, { kind: "approval", resolve, request }); - }); - } finally { - runtime.approvals.delete(approvalItemId); - runtime.resumeIdleWatchdog?.(); - } - return elicitResponse.decision === "cancel" || elicitResponse.decision === "decline" - ? { action: "cancel" } - : { action: "accept" }; - } - - // Form mode: map requestedSchema to structured questions - const questions: PendingInputRequest["questions"] = []; - const schema = elicitReq.requestedSchema ?? {}; - const properties = (schema as any).properties as Record | undefined; - if (properties) { - for (const [key, prop] of Object.entries(properties)) { - questions.push({ - id: key, - header: key, - question: prop.description ?? key, - ...(prop.enum ? { options: prop.enum.map((v) => ({ label: v, value: v })) } : {}), - allowsFreeform: !prop.enum, - isSecret: key.toLowerCase().includes("password") || key.toLowerCase().includes("secret") || key.toLowerCase().includes("token"), - }); - } - } - if (questions.length === 0) { - questions.push({ - id: "input", - header: elicitReq.serverName, - question: elicitReq.message, - allowsFreeform: true, - }); - } - - const request: PendingInputRequest = { - requestId: approvalItemId, - itemId: approvalItemId, - source: "claude", - kind: "structured_question", - title: `Input requested: ${elicitReq.serverName}`, - description: elicitReq.message, - questions, - allowsFreeform: true, - blocking: true, - canProceedWithoutAnswer: false, - providerMetadata: { serverName: elicitReq.serverName, mode: "form" }, - turnId: turnId ?? null, - }; - - emitPendingInputRequest(managed, request, { - kind: "tool_call", - description: `MCP input: ${elicitReq.serverName}`, - detail: { serverName: elicitReq.serverName }, - }); - - let formResponse: { decision?: AgentChatApprovalDecision; answers?: Record; responseText?: string | null }; - try { - runtime.pauseIdleWatchdog?.(); - formResponse = await new Promise((resolve) => { - runtime.approvals.set(approvalItemId, { kind: "approval", resolve, request }); - }); - } finally { - runtime.approvals.delete(approvalItemId); - runtime.resumeIdleWatchdog?.(); - } - - if (formResponse.decision === "cancel" || formResponse.decision === "decline") { - return { action: "decline" }; - } - - // Map answers to the expected content shape - const content: Record = {}; - if (formResponse.answers) { - for (const [key, value] of Object.entries(formResponse.answers)) { - content[key] = value; - } - } - return { action: "accept", content }; - }; - - // Enable MCP tool search for non-CTO sessions with many MCP tools. - // When enabled, the SDK defers tool definitions and loads them on-demand - // via the ToolSearch tool, keeping the context window lean. + // Enable provider tool search for non-CTO sessions with large tool catalogs. + // When enabled, the SDK defers tool definitions and loads them on demand + // via its ToolSearch capability, keeping the context window lean. // CTO sessions disable deferral so operator tools (spawnChat, gitCommit, etc.) // are always visible without needing ToolSearch. opts.env = { @@ -10200,12 +9580,6 @@ export function createAgentChatService(args: { } else { runtime.v2Session = unstable_v2_createSession(v2Opts as any) as unknown as ClaudeV2Session; } - await attachClaudeV2McpServers( - managed, - runtime.v2Session, - v2Opts.mcpServers as Record> | undefined, - ); - if (runtime.v2WarmupCancelled) { try { runtime.v2Session?.close(); } catch { /* ignore */ } runtime.v2Session = null; @@ -10283,12 +9657,10 @@ export function createAgentChatService(args: { try { diagClaudePath = runtime.v2Session ? undefined : buildClaudeV2SessionOpts(managed, runtime).pathToClaudeCodeExecutable; } catch { /* best-effort diagnostic */ } - const diagMcpLaunch = tryDiagnosticMcpLaunch(managed); logger.warn("agent_chat.claude_v2_prewarm_failed", { sessionId: managed.session.id, error: error instanceof Error ? error.message : String(error), claudeExecutablePath: diagClaudePath, - ...(diagMcpLaunch ? { adeMcpLaunch: diagMcpLaunch } : {}), }); try { runtime.v2Session?.close(); } catch { /* ignore */ } runtime.v2Session = null; @@ -10346,7 +9718,6 @@ export function createAgentChatService(args: { interruptEventsEmitted: false, turnMemoryPolicyState: null, approvalOverrides: new Set(persisted?.approvalOverrides ?? []), - pendingElicitations: new Map void>(), resolvedToolUseIds: new Set(), }; managed.runtime = runtime; @@ -10362,7 +9733,7 @@ export function createAgentChatService(args: { laneId: "temporary", provider: "codex", model: DEFAULT_CODEX_MODEL, - capabilityMode: "full_mcp", + capabilityMode: "full_tooling", status: "idle", idleSinceAt: null, createdAt: nowIso(), @@ -11090,7 +10461,6 @@ export function createAgentChatService(args: { launch.mode ?? "default", launch.sandbox, launch.force ? "force" : "guarded", - launch.approveMcps ? "mcp-auto" : "mcp-ask", ].join(":"); }; @@ -11364,11 +10734,10 @@ export function createAgentChatService(args: { if (!loadSession) return; try { - const loaded = await loadSession({ + const loaded = await loadSession(cursorAcpSessionRequest({ sessionId, cwd: managed.laneWorktreePath, - mcpServers: buildCursorAcpMcpServers(managed), - }); + }) as Parameters[0]); const loadedAvailableModelIds = loaded.models?.availableModels ?.map((entry) => String(entry?.modelId ?? "").trim()) .filter(Boolean) ?? []; @@ -11688,7 +11057,6 @@ export function createAgentChatService(args: { const resumed = await pooled.connection.unstable_resumeSession({ sessionId: persistedAcp, cwd: managed.laneWorktreePath, - mcpServers: buildCursorAcpMcpServers(managed), }); const resumedAvailableModelIds = resumed.models?.availableModels ?.map((entry) => String(entry?.modelId ?? "").trim()) @@ -11794,10 +11162,9 @@ export function createAgentChatService(args: { if (!runtime.acpSessionId) { if (!runtime.pooled) throw new Error("Cursor ACP connection not available"); - const created = await runtime.pooled.connection.newSession({ + const created = await runtime.pooled.connection.newSession(cursorAcpSessionRequest({ cwd: managed.laneWorktreePath, - mcpServers: buildCursorAcpMcpServers(managed), - }); + }) as Parameters[0]); const createdAvailableModelIds = created.models?.availableModels ?.map((entry) => String(entry?.modelId ?? "").trim()) .filter(Boolean) ?? []; @@ -12056,8 +11423,7 @@ export function createAgentChatService(args: { if (!runtime.threadResumed) { const threadIdToResume = managed.session.threadId || readPersistedState(sessionId)?.threadId; - const { codexPolicy, mcpServers } = resolveCodexThreadParams(managed); - const mcpConfig = buildCodexAppServerMcpConfigOverrides(mcpServers); + const { codexPolicy } = resolveCodexThreadParams(managed); if (threadIdToResume) { try { @@ -12066,9 +11432,6 @@ export function createAgentChatService(args: { model: managed.session.model, ...(managed.session.reasoningEffort ? { reasoningEffort: managed.session.reasoningEffort } : {}), cwd: managed.laneWorktreePath, - ...(mcpConfig ? { config: mcpConfig } : {}), - mcpServers, - mcp_servers: mcpServers, ...codexPolicyArgs(codexPolicy), persistExtendedHistory: true }); @@ -12103,10 +11466,10 @@ export function createAgentChatService(args: { threadId: threadIdToResume, error: resumeError instanceof Error ? resumeError.message : String(resumeError) }); - await startFreshCodexThread(managed, runtime, codexPolicy, mcpServers); + await startFreshCodexThread(managed, runtime, codexPolicy); } } else { - await startFreshCodexThread(managed, runtime, codexPolicy, mcpServers); + await startFreshCodexThread(managed, runtime, codexPolicy); } } @@ -12504,7 +11867,6 @@ export function createAgentChatService(args: { pending.resolve({ decision: "cancel" }); } runtime.approvals.clear(); - runtime.pendingElicitations.clear(); // Emit subagent_result "stopped" for every active subagent so the UI // properly transitions them from "running" → "stopped" (matching Claude Code CLI behaviour). @@ -12542,17 +11904,13 @@ export function createAgentChatService(args: { } const threadId = persisted?.threadId ?? managed.session.threadId; if (threadId) { - const { codexPolicy, mcpServers } = resolveCodexThreadParams(managed); - const mcpConfig = buildCodexAppServerMcpConfigOverrides(mcpServers); + const { codexPolicy } = resolveCodexThreadParams(managed); try { await runtime.request("thread/resume", { threadId, model: managed.session.model, ...(managed.session.reasoningEffort ? { reasoningEffort: managed.session.reasoningEffort } : {}), cwd: managed.laneWorktreePath, - ...(mcpConfig ? { config: mcpConfig } : {}), - mcpServers, - mcp_servers: mcpServers, ...codexPolicyArgs(codexPolicy), persistExtendedHistory: true }); @@ -12588,7 +11946,7 @@ export function createAgentChatService(args: { threadId, error: resumeError instanceof Error ? resumeError.message : String(resumeError) }); - await startFreshCodexThread(managed, runtime, codexPolicy, mcpServers); + await startFreshCodexThread(managed, runtime, codexPolicy); } } // Re-sync codex approval policy from persisted/config settings @@ -12983,19 +12341,11 @@ export function createAgentChatService(args: { } if (pending.kind === "structured_question") { if (resolvedDecision === "decline" || resolvedDecision === "cancel") { - if (pending.questionResponseKind === "mcp_elicitation") { - ensureWritable(); - runtime.sendResponse(pending.requestId, { - action: resolvedDecision === "cancel" ? "cancel" : "decline", - content: null, - }); - } else { - // Native Codex request_user_input only accepts an answers map. - // Empty answers represent a declined/cancelled prompt without - // interrupting the surrounding turn. - ensureWritable(); - runtime.sendResponse(pending.requestId, { answers: {} }); - } + // Native Codex request_user_input only accepts an answers map. + // Empty answers represent a declined/cancelled prompt without + // interrupting the surrounding turn. + ensureWritable(); + runtime.sendResponse(pending.requestId, { answers: {} }); runtime.approvals.delete(itemId); emitPendingInputResolved(managed, { itemId, @@ -13006,18 +12356,11 @@ export function createAgentChatService(args: { } const normalizedAnswers = normalizePendingInputAnswers(pending.request, answers, responseText); ensureWritable(); - if (pending.questionResponseKind === "mcp_elicitation") { - runtime.sendResponse(pending.requestId, { - action: "accept", - content: coerceCodexMcpElicitationContent(pending.request, normalizedAnswers), - }); - } else { - runtime.sendResponse(pending.requestId, { - answers: Object.fromEntries( - Object.entries(normalizedAnswers).map(([questionId, values]) => [questionId, { answers: values }]), - ), - }); - } + runtime.sendResponse(pending.requestId, { + answers: Object.fromEntries( + Object.entries(normalizedAnswers).map(([questionId, values]) => [questionId, { answers: values }]), + ), + }); runtime.approvals.delete(itemId); emitPendingInputResolved(managed, { itemId, @@ -13825,10 +13168,9 @@ export function createAgentChatService(args: { const runtime = await ensureCursorRuntime(managed); if (!runtime.pooled) return; if (!runtime.acpSessionId) { - const created = await runtime.pooled.connection.newSession({ + const created = await runtime.pooled.connection.newSession(cursorAcpSessionRequest({ cwd: managed.laneWorktreePath, - mcpServers: buildCursorAcpMcpServers(managed), - }); + }) as Parameters[0]); const createdAvailableModelIds = created.models?.availableModels ?.map((entry) => String(entry?.modelId ?? "").trim()) .filter(Boolean) ?? []; @@ -14065,7 +13407,7 @@ export function createAgentChatService(args: { }; /** - * Create a blocking pending-input request for a chat session (used by MCP ask_user + * Create a blocking pending-input request for a chat session (used by ADE ask_user * when no missionId is available). Returns the user's answer. */ const requestChatInput = async (args: { diff --git a/apps/desktop/src/main/services/chat/buildComputerUseDirective.test.ts b/apps/desktop/src/main/services/chat/buildComputerUseDirective.test.ts index 782f70bea..8c89a7eeb 100644 --- a/apps/desktop/src/main/services/chat/buildComputerUseDirective.test.ts +++ b/apps/desktop/src/main/services/chat/buildComputerUseDirective.test.ts @@ -14,9 +14,9 @@ function makeBackendStatus( if (overrides.ghostOs) { backends.push({ name: "Ghost OS", - style: "external_mcp", + style: "external_cli", available: true, - state: "connected", + state: "installed", detail: "Ghost OS connected.", supportedKinds: ["screenshot", "video_recording", "browser_trace", "browser_verification", "console_logs"], }); diff --git a/apps/desktop/src/main/services/chat/cursorAcpEventMapper.test.ts b/apps/desktop/src/main/services/chat/cursorAcpEventMapper.test.ts index 6a5bbb061..07be66d01 100644 --- a/apps/desktop/src/main/services/chat/cursorAcpEventMapper.test.ts +++ b/apps/desktop/src/main/services/chat/cursorAcpEventMapper.test.ts @@ -39,13 +39,13 @@ describe("mapAcpSessionNotificationToChatEvents", () => { ]); }); - it("emits a memory notice for completed memory_add MCP results", () => { + it("emits a memory notice for completed memory_add results", () => { const events = mapAcpSessionNotificationToChatEvents({ sessionId: "cursor-session-1", update: { sessionUpdate: "tool_call_update", toolCallId: "tool-1", - title: "mcp__ade__memory_add", + title: "memory_add", kind: "other", status: "completed", rawOutput: { @@ -69,13 +69,13 @@ describe("mapAcpSessionNotificationToChatEvents", () => { }); }); - it("emits a memory notice for rejected memory_add MCP results", () => { + it("emits a memory notice for rejected memory_add results", () => { const events = mapAcpSessionNotificationToChatEvents({ sessionId: "cursor-session-1", update: { sessionUpdate: "tool_call_update", toolCallId: "tool-1", - title: "mcp__ade__memory_add", + title: "memory_add", kind: "other", status: "completed", rawOutput: { @@ -95,13 +95,13 @@ describe("mapAcpSessionNotificationToChatEvents", () => { }); }); - it("emits a memory notice for memory_pin MCP results", () => { + it("emits a memory notice for memory_pin results", () => { const events = mapAcpSessionNotificationToChatEvents({ sessionId: "cursor-session-1", update: { sessionUpdate: "tool_call_update", toolCallId: "tool-1", - title: "mcp__ade__memory_pin", + title: "memory_pin", kind: "other", status: "completed", rawOutput: { @@ -121,13 +121,13 @@ describe("mapAcpSessionNotificationToChatEvents", () => { }); }); - it("maps initial tool_call notifications so MCP tools keep their names", () => { + it("maps initial tool_call notifications so ADE tools keep their names", () => { const events = mapAcpSessionNotificationToChatEvents({ sessionId: "cursor-session-1", update: { sessionUpdate: "tool_call", toolCallId: "tool-1", - title: "mcp__ade__memory_search", + title: "memory_search", kind: "other", rawInput: { query: "git stash", @@ -140,10 +140,10 @@ describe("mapAcpSessionNotificationToChatEvents", () => { expect(events).toEqual([ { type: "tool_call", - tool: "mcp__ade__memory_search", + tool: "memory_search", args: { query: "git stash", - title: "mcp__ade__memory_search", + title: "memory_search", kind: "other", }, itemId: "tool-1", diff --git a/apps/desktop/src/main/services/chat/cursorAcpEventMapper.ts b/apps/desktop/src/main/services/chat/cursorAcpEventMapper.ts index c7ff3972c..75d61bc97 100644 --- a/apps/desktop/src/main/services/chat/cursorAcpEventMapper.ts +++ b/apps/desktop/src/main/services/chat/cursorAcpEventMapper.ts @@ -14,8 +14,7 @@ function toolNameFromKind(kind: string | undefined, title: string): string { const trimmedTitle = title.trim(); if (trimmedTitle.length) { if ( - trimmedTitle.startsWith("mcp__") - || trimmedTitle.startsWith("functions.") + trimmedTitle.startsWith("functions.") || trimmedTitle.startsWith("multi_tool_use.") || trimmedTitle.startsWith("web.") || /^[A-Za-z][A-Za-z0-9_.-]{1,127}$/.test(trimmedTitle) diff --git a/apps/desktop/src/main/services/chat/cursorAcpMcp.ts b/apps/desktop/src/main/services/chat/cursorAcpMcp.ts deleted file mode 100644 index 1ef6165bc..000000000 --- a/apps/desktop/src/main/services/chat/cursorAcpMcp.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { McpServer } from "@agentclientprotocol/sdk"; -import type { ExternalMcpServerConfig } from "../../../shared/types/externalMcp"; - -/** Maps ADE external MCP stdio configs to ACP MCP server entries. */ -export function externalMcpConfigsToAcpStdio(configs: ExternalMcpServerConfig[]): McpServer[] { - const out: McpServer[] = []; - for (const c of configs) { - if (c.transport !== "stdio") continue; - const command = c.command?.trim(); - if (!command) continue; - const env: Array<{ name: string; value: string }> = []; - if (c.env) { - for (const [name, value] of Object.entries(c.env)) { - if (!name.trim()) continue; - env.push({ name: name.trim(), value: String(value ?? "") }); - } - } - out.push({ - name: c.name, - command, - args: Array.isArray(c.args) ? c.args.map((a) => String(a)) : [], - env, - }); - } - return out; -} diff --git a/apps/desktop/src/main/services/chat/cursorAcpPool.ts b/apps/desktop/src/main/services/chat/cursorAcpPool.ts index f7b907f1b..fd8e9307e 100644 --- a/apps/desktop/src/main/services/chat/cursorAcpPool.ts +++ b/apps/desktop/src/main/services/chat/cursorAcpPool.ts @@ -262,7 +262,6 @@ export type CursorAcpLaunchSettings = { mode: "plan" | "ask" | null; sandbox: "enabled" | "disabled"; force: boolean; - approveMcps: boolean; }; const pool = new Map(); @@ -296,9 +295,6 @@ export async function acquireCursorAcpConnection(args: { if (args.launchSettings.force) { spawnArgs.push("--force"); } - if (args.launchSettings.approveMcps) { - spawnArgs.push("--approve-mcps"); - } const apiKey = process.env.CURSOR_API_KEY?.trim() || process.env.CURSOR_AUTH_TOKEN?.trim(); if (apiKey) { spawnArgs.push("--api-key", apiKey); diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index 4f7b4cebb..1755ecb95 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -30,7 +30,6 @@ import type { createOrchestratorService } from "../orchestrator/orchestratorServ import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; import type { SqlValue } from "../state/kvDb"; -import type { createExternalMcpService } from "../externalMcp/externalMcpService"; import { fileExists, isRecord, @@ -150,17 +149,6 @@ function dedupeOwners(owners: ComputerUseArtifactOwner[]): ComputerUseArtifactOw return result; } -function inferSupportedKindsFromExternalTool(args: { name: string; description?: string | null }): ComputerUseArtifactKind[] { - const haystack = `${args.name} ${args.description ?? ""}`.toLowerCase(); - const kinds = new Set(); - if (/screenshot|snapshot|annotate|screen/.test(haystack)) kinds.add("screenshot"); - if (/video|record/.test(haystack)) kinds.add("video_recording"); - if (/trace/.test(haystack)) kinds.add("browser_trace"); - if (/verification|verify|annotate|snapshot/.test(haystack)) kinds.add("browser_verification"); - if (/console|log/.test(haystack)) kinds.add("console_logs"); - return [...kinds]; -} - function inferArtifactExtension(input: ComputerUseArtifactInput, kind: ComputerUseArtifactKind): string { const fromPath = toOptionalString(input.path) ?? toOptionalString(input.uri); if (fromPath) { @@ -191,11 +179,10 @@ export function createComputerUseArtifactBrokerService(args: { projectRoot: string; missionService: ReturnType; orchestratorService: ReturnType; - externalMcpService?: ReturnType | null; logger?: Logger | null; onEvent?: (payload: ComputerUseEventPayload) => void; }) { - const { db, projectId, projectRoot, missionService, orchestratorService, externalMcpService, onEvent } = args; + const { db, projectId, projectRoot, missionService, orchestratorService, onEvent } = args; const layout = resolveAdeLayout(projectRoot); const allowedImportRoots = Array.from(new Set([ layout.artifactsDir, @@ -508,25 +495,21 @@ export function createComputerUseArtifactBrokerService(args: { if (local.proofRequirements.console_logs.available) localKinds.push("console_logs"); const backends: ComputerUseExternalBackendStatus[] = []; - for (const snapshot of externalMcpService?.getSnapshots() ?? []) { - const kinds = new Set(); - for (const tool of snapshot.tools) { - for (const kind of inferSupportedKindsFromExternalTool({ name: tool.name, description: tool.description })) { - kinds.add(kind); - } - } - if (kinds.size === 0) continue; - backends.push({ - name: snapshot.config.name, - style: "external_mcp", - available: snapshot.state === "connected", - state: snapshot.state, - detail: snapshot.state === "connected" - ? `Connected MCP backend with ${snapshot.toolCount} tool(s).` - : snapshot.lastError ?? `State: ${snapshot.state}`, - supportedKinds: [...kinds], - }); - } + const ghostInstalled = commandExists("ghost"); + backends.push({ + name: "Ghost OS", + style: "external_cli", + available: ghostInstalled, + state: ghostInstalled ? "installed" : "missing", + detail: ghostInstalled + ? "Ghost OS CLI is installed and can produce artifacts for ADE ingestion." + : "Ghost OS CLI is not installed on this machine.", + supportedKinds: [ + "screenshot", + "video_recording", + "browser_verification", + ], + }); const agentBrowserInstalled = commandExists("agent-browser"); backends.push({ diff --git a/apps/desktop/src/main/services/computerUse/controlPlane.test.ts b/apps/desktop/src/main/services/computerUse/controlPlane.test.ts index 1b795498e..ad2dbeb27 100644 --- a/apps/desktop/src/main/services/computerUse/controlPlane.test.ts +++ b/apps/desktop/src/main/services/computerUse/controlPlane.test.ts @@ -1,4 +1,4 @@ -import type { ComputerUseBackendStatus, ExternalMcpServerSnapshot } from "../../../shared/types"; +import type { ComputerUseBackendStatus } from "../../../shared/types"; import { describe, expect, it, vi } from "vitest"; vi.mock("../ai/utils", () => ({ @@ -12,7 +12,7 @@ vi.mock("./localComputerUse", async (importOriginal) => { getGhostDoctorProcessHealth: vi.fn(() => ({ state: "stale" as const, processCount: 34, - detail: "34 ghost MCP processes found (expect 0 or 1).", + detail: "34 Ghost OS processes found (expect 0 or 1).", })), }; }); @@ -21,7 +21,7 @@ vi.mock("node:child_process", () => ({ spawnSync: vi.fn((command: string, args: string[]) => { if (command === "ghost" && args[0] === "doctor") { return { - stdout: "[FAIL] Processes: 34 ghost MCP processes found (expect 0 or 1)\n", + stdout: "[FAIL] Processes: 34 Ghost OS processes found (expect 0 or 1)\n", stderr: "", error: null, }; @@ -48,10 +48,10 @@ function createBackendStatus(): ComputerUseBackendStatus { backends: [ { name: "Ghost OS", - style: "external_mcp", + style: "external_cli", available: true, - state: "connected", - detail: "Connected MCP backend with 12 tool(s).", + state: "installed", + detail: "Connected CLI backend with 12 tool(s).", supportedKinds: ["screenshot", "video_recording", "browser_trace", "browser_verification", "console_logs"], }, ], @@ -63,43 +63,8 @@ function createBackendStatus(): ComputerUseBackendStatus { }; } -function createGhostSnapshot(): ExternalMcpServerSnapshot { - return { - config: { - name: "Ghost OS", - transport: "stdio", - command: "ghost", - args: ["mcp"], - env: {}, - cwd: "/tmp", - }, - state: "connected", - toolCount: 12, - tools: [], - lastConnectedAt: "2026-03-24T05:57:45.700Z", - lastHealthCheckAt: "2026-03-24T05:57:45.700Z", - consecutivePingFailures: 0, - lastError: null, - autoStart: true, - }; -} - describe("computer use control plane", () => { - it("shows live backend activity when a backend is connected", () => { - const snapshot = buildComputerUseOwnerSnapshot({ - broker: { - getBackendStatus: vi.fn(() => createBackendStatus()), - listArtifacts: vi.fn(() => []), - } as any, - owner: { kind: "chat_session", id: "chat-1" }, - policy: null, - }); - - expect(snapshot.summary).toContain("Ghost OS is connected and ready to capture proof"); - expect(snapshot.activity.some((item) => item.kind === "backend_connected")).toBe(true); - }); - - it("surfaces live external tool activity for the active chat before proof is ingested", () => { + it("shows live backend activity when a backend is available", () => { const snapshot = buildComputerUseOwnerSnapshot({ broker: { getBackendStatus: vi.fn(() => createBackendStatus()), @@ -107,34 +72,18 @@ describe("computer use control plane", () => { } as any, owner: { kind: "chat_session", id: "chat-1" }, policy: null, - usageEvents: [ - { - id: "usage-1", - serverName: "Ghost OS", - toolName: "ghost_click", - namespacedToolName: "ext.ghost-os.ghost_click", - safety: "read", - callerRole: "agent", - callerId: "chat-1", - chatSessionId: "chat-1", - costCents: 0, - estimated: false, - occurredAt: "2026-03-24T05:57:45.700Z", - }, - ], }); - expect(snapshot.summary).toContain("Ghost OS is already active for this scope"); - expect(snapshot.activity.some((item) => item.kind === "backend_tool_used")).toBe(true); + expect(snapshot.summary).toContain("Ghost OS is available and ready to capture proof"); + expect(snapshot.activity.some((item) => item.kind === "backend_available")).toBe(true); }); it("surfaces Ghost doctor process health in the settings snapshot", () => { const snapshot = buildComputerUseSettingsSnapshot({ status: createBackendStatus(), - snapshots: [createGhostSnapshot()], }); expect(snapshot.ghostOsCheck.processHealth?.state).toBe("stale"); - expect(snapshot.ghostOsCheck.details.join("\n")).toContain("Stop the stale `ghost mcp` processes"); + expect(snapshot.ghostOsCheck.details.join("\n")).toContain("Stop the stale Ghost OS processes"); }); }); diff --git a/apps/desktop/src/main/services/computerUse/controlPlane.ts b/apps/desktop/src/main/services/computerUse/controlPlane.ts index 495377290..35e8b3445 100644 --- a/apps/desktop/src/main/services/computerUse/controlPlane.ts +++ b/apps/desktop/src/main/services/computerUse/controlPlane.ts @@ -5,8 +5,6 @@ import type { ComputerUseActivityItem, ComputerUseArtifactView, ComputerUseBackendStatus, - ExternalMcpUsageEvent, - ExternalMcpServerSnapshot, ComputerUseOwnerSnapshot, ComputerUseOwnerSnapshotArgs, ComputerUsePolicy, @@ -30,25 +28,15 @@ export function getComputerUseArtifactKinds(): ComputerUseArtifactKind[] { return [...COMPUTER_USE_KINDS]; } -function isGhostOsServer(snapshot: ExternalMcpServerSnapshot): boolean { - const command = snapshot.config.command?.trim().toLowerCase() ?? ""; - const args = Array.isArray(snapshot.config.args) - ? snapshot.config.args.map((entry) => entry.trim().toLowerCase()) - : []; - return command === "ghost" && args.includes("mcp"); -} - function buildGhostOsCheck(args: { status: ComputerUseBackendStatus; - snapshots: ExternalMcpServerSnapshot[]; }): ComputerUseSettingsSnapshot["ghostOsCheck"] { const repoUrl = "https://github.com/ghostwright/ghost-os"; const cliInstalled = commandExists("ghost"); const processHealth = getGhostDoctorProcessHealth(); - const matchingSnapshots = args.snapshots.filter(isGhostOsServer); - const adeConfigured = matchingSnapshots.length > 0; - const adeConnected = matchingSnapshots.some((snapshot) => snapshot.state === "connected"); const backendEntry = args.status.backends.find((backend) => backend.name === "Ghost OS") ?? null; + const adeConfigured = Boolean(backendEntry); + const adeConnected = Boolean(backendEntry?.available); if (!cliInstalled) { return { @@ -62,9 +50,7 @@ function buildGhostOsCheck(args: { "Install the Ghost OS CLI first.", "Then run `ghost setup` to grant permissions and install its local dependencies.", processHealth.detail, - adeConfigured - ? "ADE already has a Ghost OS MCP entry, but it cannot start until the `ghost` CLI exists." - : "After setup, add `ghost mcp` in ADE-managed MCP so ADE missions, workers, and CTO sessions can use it.", + "After setup, agents can ingest Ghost OS proof artifacts through the ADE CLI.", ], processHealth, }; @@ -90,11 +76,11 @@ function buildGhostOsCheck(args: { if (processHealth.state === "stale") { summary = `Ghost OS is ready, but ${processHealth.detail}`; } else if (adeConnected) { - summary = "Ghost OS is ready on this Mac and connected through ADE."; + summary = "Ghost OS is ready on this Mac and available to ADE."; } else if (adeConfigured) { - summary = "Ghost OS is ready on this Mac. Connect the ADE MCP server to make it active."; + summary = "Ghost OS is ready on this Mac. ADE can ingest artifacts produced by its CLI."; } else { - summary = "Ghost OS is ready on this Mac, but ADE is not configured to launch it yet."; + summary = "Ghost OS is ready on this Mac, but ADE has not detected its CLI backend yet."; } return { @@ -109,14 +95,14 @@ function buildGhostOsCheck(args: { ...(outputLines.length > 0 ? outputLines : ["`ghost status` reports ready."]), processHealth.detail, ...(processHealth.state === "stale" - ? ["Stop the stale `ghost mcp` processes, then rerun `ghost doctor`."] + ? ["Stop the stale Ghost OS processes, then rerun `ghost doctor`."] : []), adeConfigured ? adeConnected - ? "ADE has a matching `ghost mcp` server and it is currently connected." - : "ADE has a matching `ghost mcp` server but it is not currently connected." - : "Add a stdio ADE-managed MCP server in ADE with command `ghost` and args `mcp`.", - backendEntry?.detail ?? "Ghost OS tools will appear to ADE as an external computer-use backend once connected.", + ? "ADE detected the Ghost OS CLI backend." + : "ADE detected Ghost OS, but it is not currently available." + : "Install the Ghost OS CLI so ADE can ingest its proof artifacts.", + backendEntry?.detail ?? "Ghost OS tools can produce artifacts that ADE registers as external CLI proof.", ], }; } @@ -142,12 +128,10 @@ function buildGhostOsCheck(args: { ...(outputLines.length > 0 ? outputLines : ["`ghost status` did not return a clear ready state."]), processHealth.detail, ...(processHealth.state === "stale" - ? ["Stop the stale `ghost mcp` processes, then rerun `ghost doctor`."] + ? ["Stop the stale Ghost OS processes, then rerun `ghost doctor`."] : []), "Run `ghost setup` in Terminal on this Mac.", - adeConfigured - ? "After setup completes, reconnect the Ghost OS MCP entry in ADE." - : "After setup completes, add `ghost mcp` in ADE-managed MCP.", + "After setup completes, agents can ingest Ghost OS proof artifacts through the ADE CLI.", ], }; } @@ -193,58 +177,20 @@ function selectPreferredBackend(status: ComputerUseBackendStatus): string | null return status.backends.find((backend) => backend.available)?.name ?? null; } -function usageEventMatchesOwner( - usageEvent: ExternalMcpUsageEvent, - owner: ComputerUseArtifactOwner, -): boolean { - if (owner.kind === "chat_session") { - return usageEvent.chatSessionId === owner.id || usageEvent.callerId === owner.id; - } - if (owner.kind === "mission") { - return usageEvent.missionId === owner.id; - } - if (owner.kind === "orchestrator_run") { - return usageEvent.runId === owner.id; - } - if (owner.kind === "orchestrator_step") { - return usageEvent.stepId === owner.id; - } - if (owner.kind === "orchestrator_attempt") { - return usageEvent.attemptId === owner.id; - } - return false; -} - function buildActivity( - owner: ComputerUseArtifactOwner, artifacts: ComputerUseArtifactView[], missingKinds: ComputerUseArtifactKind[], backendStatus: ComputerUseBackendStatus, - usageEvents: ExternalMcpUsageEvent[], ) : ComputerUseActivityItem[] { - const liveUsageActivity = usageEvents - .filter((usageEvent) => usageEventMatchesOwner(usageEvent, owner)) - .slice(0, 6) - .map((usageEvent) => ({ - id: `usage:${usageEvent.id}`, - at: usageEvent.occurredAt, - kind: "backend_tool_used" as const, - title: `${usageEvent.serverName} ran ${usageEvent.toolName}`, - detail: `${usageEvent.namespacedToolName} was used for this scope.`, - artifactId: null, - backendName: usageEvent.serverName, - severity: "info" as const, - })); - const liveBackendActivity: ComputerUseActivityItem[] = []; for (const backend of backendStatus.backends.slice(0, 4)) { const at = new Date().toISOString(); - if (backend.available && backend.state === "connected") { + if (backend.available && backend.state === "installed") { liveBackendActivity.push({ - id: `backend:${backend.name}:connected`, + id: `backend:${backend.name}:available`, at, - kind: "backend_connected", - title: `${backend.name} connected`, + kind: "backend_available", + title: `${backend.name} ready`, detail: backend.detail, backendName: backend.name, artifactId: null, @@ -252,12 +198,12 @@ function buildActivity( }); continue; } - if (backend.state === "disconnected" || backend.state === "reconnecting" || backend.state === "failed") { + if (!backend.available || backend.state === "missing") { liveBackendActivity.push({ id: `backend:${backend.name}:unavailable`, at, kind: "backend_unavailable", - title: `${backend.name} not connected`, + title: `${backend.name} unavailable`, detail: backend.detail, backendName: backend.name, artifactId: null, @@ -265,18 +211,6 @@ function buildActivity( }); continue; } - if (backend.available || backend.state === "installed") { - liveBackendActivity.push({ - id: `backend:${backend.name}:available`, - at, - kind: "backend_available", - title: `${backend.name} ready`, - detail: backend.detail, - backendName: backend.name, - artifactId: null, - severity: "info", - }); - } } const artifactActivity = artifacts.slice(0, 6).map((artifact) => ({ @@ -301,16 +235,15 @@ function buildActivity( severity: "warning" as const, })); - return [...liveUsageActivity, ...liveBackendActivity, ...artifactActivity, ...missingActivity] + return [...liveBackendActivity, ...artifactActivity, ...missingActivity] .sort((left, right) => Date.parse(right.at) - Date.parse(left.at)) .slice(0, 8); } export function buildComputerUseSettingsSnapshot(args: { status: ComputerUseBackendStatus; - snapshots?: ExternalMcpServerSnapshot[]; }): ComputerUseSettingsSnapshot { - const ghostOsCheck = buildGhostOsCheck({ status: args.status, snapshots: args.snapshots ?? [] }); + const ghostOsCheck = buildGhostOsCheck({ status: args.status }); return { backendStatus: args.status, preferredBackend: selectPreferredBackend(args.status), @@ -318,8 +251,8 @@ export function buildComputerUseSettingsSnapshot(args: { ghostOsCheck, guidance: { overview: "External tools perform computer use. ADE discovers backends, ingests their artifacts, normalizes proof, links evidence to missions and chats, and helps operators decide what to do next.", - ghostOs: "Ghost OS is a local stdio MCP server. Run `ghost setup` on this Mac first, then add `ghost mcp` in ADE-managed MCP so ADE missions, workers, and CTO sessions can use it. If `ghost doctor` reports stale processes, stop them before launching a new session.", - agentBrowser: "agent-browser is a CLI-native browser automation backend, not an MCP server. Install the CLI locally, run it externally, and ingest its manifests or artifacts into ADE for proof tracking.", + ghostOs: "Ghost OS is a CLI-native computer-use backend. Run `ghost setup` on this Mac first, then ingest manifests or artifacts into ADE for proof tracking.", + agentBrowser: "agent-browser is a CLI-native browser automation backend. Install the CLI locally, run it externally, and ingest its manifests or artifacts into ADE for proof tracking.", fallback: "ADE-local computer-use remains fallback-only compatibility support. It should only be used when approved external backends are unavailable for the required proof kind.", }, }; @@ -331,7 +264,6 @@ export function buildComputerUseOwnerSnapshot(args: { policy?: ComputerUsePolicy | null; requiredKinds?: ComputerUseArtifactKind[]; limit?: number; - usageEvents?: ExternalMcpUsageEvent[]; }): ComputerUseOwnerSnapshot { const policy = args.policy ? createDefaultComputerUsePolicy(args.policy) : null; const backendStatus = args.broker.getBackendStatus(); @@ -347,10 +279,7 @@ export function buildComputerUseOwnerSnapshot(args: { ); const requiredKinds = uniqKinds((args.requiredKinds ?? []).filter((kind) => COMPUTER_USE_KINDS.includes(kind))); const missingKinds = requiredKinds.filter((kind) => !presentKinds.includes(kind)); - const usageEvents = (args.usageEvents ?? []).filter((usageEvent) => usageEventMatchesOwner(usageEvent, args.owner)); - const latestUsageEvent = usageEvents[0] ?? null; const latestArtifact = recentArtifacts[0] ?? null; - const connectedBackend = backendStatus.backends.find((backend) => backend.available && backend.state === "connected") ?? null; const availableBackend = backendStatus.backends.find((backend) => backend.available) ?? null; const preferredBackend = policy?.preferredBackend ? backendStatus.backends.find((backend) => backend.name === policy.preferredBackend) ?? null @@ -395,10 +324,6 @@ export function buildComputerUseOwnerSnapshot(args: { } } else if (recentArtifacts.length > 0) { proofSummary = `${recentArtifacts.length} computer-use artifact${recentArtifacts.length === 1 ? "" : "s"} retained for this scope.`; - } else if (latestUsageEvent) { - proofSummary = `${latestUsageEvent.serverName} is already active for this scope, but ADE has not ingested proof artifacts yet.`; - } else if (connectedBackend) { - proofSummary = `${connectedBackend.name} is connected and ready to capture proof for this scope.`; } else if (availableBackend) { proofSummary = `${availableBackend.name} is available and ready to capture proof for this scope.`; } else { @@ -418,7 +343,7 @@ export function buildComputerUseOwnerSnapshot(args: { activeBackend, artifacts, recentArtifacts, - activity: buildActivity(args.owner, artifacts, missingKinds, backendStatus, args.usageEvents ?? []), + activity: buildActivity(artifacts, missingKinds, backendStatus), proofCoverage: { requiredKinds, presentKinds, diff --git a/apps/desktop/src/main/services/computerUse/localComputerUse.test.ts b/apps/desktop/src/main/services/computerUse/localComputerUse.test.ts index 996ea55e8..aefc9f554 100644 --- a/apps/desktop/src/main/services/computerUse/localComputerUse.test.ts +++ b/apps/desktop/src/main/services/computerUse/localComputerUse.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest"; import { parseGhostDoctorProcessHealth } from "./localComputerUse"; describe("parseGhostDoctorProcessHealth", () => { - it("treats stale ghost MCP processes as a health problem", () => { + it("treats stale Ghost OS processes as a health problem", () => { const health = parseGhostDoctorProcessHealth( - "[FAIL] Processes: 34 ghost MCP processes found (expect 0 or 1)", + "[FAIL] Processes: 34 Ghost OS processes found (expect 0 or 1)", ); expect(health.state).toBe("stale"); @@ -14,7 +14,7 @@ describe("parseGhostDoctorProcessHealth", () => { it("accepts the healthy 0-or-1 process case", () => { const health = parseGhostDoctorProcessHealth( - "[ok] Processes: 1 ghost MCP process found", + "[ok] Processes: 1 Ghost OS process found", ); expect(health.state).toBe("healthy"); diff --git a/apps/desktop/src/main/services/computerUse/localComputerUse.ts b/apps/desktop/src/main/services/computerUse/localComputerUse.ts index c8f7738c2..9c8656d3a 100644 --- a/apps/desktop/src/main/services/computerUse/localComputerUse.ts +++ b/apps/desktop/src/main/services/computerUse/localComputerUse.ts @@ -50,7 +50,7 @@ function blocked(detail: string): LocalComputerUseCapability { return { state: "blocked_by_capability", available: false, command: null, detail }; } -const GHOST_DOCTOR_PROCESS_REGEX = /(\d+)\s+ghost MCP process(?:es)?\s+found/i; +const GHOST_DOCTOR_PROCESS_REGEX = /(\d+)\s+ghost(?:\s+\w+)?\s+process(?:es)?\s+found/i; export function parseGhostDoctorProcessHealth(output: string): GhostDoctorProcessHealth { const trimmed = output.trim(); @@ -69,13 +69,13 @@ export function parseGhostDoctorProcessHealth(output: string): GhostDoctorProces return { state: "stale", processCount, - detail: `Ghost doctor found ${processCount} ghost MCP processes. Stop the stale processes and rerun ghost doctor.`, + detail: `Ghost doctor found ${processCount} Ghost OS processes. Stop the stale processes and rerun ghost doctor.`, }; } return { state: "healthy", processCount, - detail: `Ghost doctor found ${processCount} ghost MCP process${processCount === 1 ? "" : "es"} running.`, + detail: `Ghost doctor found ${processCount} Ghost OS process${processCount === 1 ? "" : "es"} running.`, }; } @@ -83,7 +83,7 @@ export function parseGhostDoctorProcessHealth(output: string): GhostDoctorProces return { state: "stale", processCount: null, - detail: "Ghost doctor reported a Ghost MCP process failure, but did not include a parseable count.", + detail: "Ghost doctor reported a Ghost OS process failure, but did not include a parseable count.", }; } @@ -91,14 +91,14 @@ export function parseGhostDoctorProcessHealth(output: string): GhostDoctorProces return { state: "healthy", processCount: null, - detail: "Ghost doctor reported healthy Ghost MCP process state.", + detail: "Ghost doctor reported healthy Ghost OS process state.", }; } return { state: "unknown", processCount: null, - detail: "Ghost doctor output did not include a parseable Ghost MCP process check.", + detail: "Ghost doctor output did not include a parseable Ghost OS process check.", }; } diff --git a/apps/desktop/src/main/services/computerUse/proofObserver.test.ts b/apps/desktop/src/main/services/computerUse/proofObserver.test.ts index 1e743eba4..6cb12d3ad 100644 --- a/apps/desktop/src/main/services/computerUse/proofObserver.test.ts +++ b/apps/desktop/src/main/services/computerUse/proofObserver.test.ts @@ -69,12 +69,12 @@ describe("proofObserver", () => { ]); }); - it("attributes ADE-proxied external MCP tools to the proxied server", () => { + it("attributes namespaced external CLI tools to their backend", () => { const { observer, requests } = createHarness(); observer.observe({ type: "tool_result", - tool: "mcp__ade__ext.playwright.browser_take_screenshot", + tool: "playwright.browser_take_screenshot", result: { outputPath: "/tmp/playwright-shot.png", }, @@ -84,9 +84,9 @@ describe("proofObserver", () => { expect(requests).toHaveLength(1); expect(requests[0]?.backend).toMatchObject({ - style: "external_mcp", + style: "external_cli", name: "playwright", - toolName: "mcp__ade__ext.playwright.browser_take_screenshot", + toolName: "playwright.browser_take_screenshot", }); expect(requests[0]?.inputs).toEqual([ expect.objectContaining({ @@ -101,7 +101,7 @@ describe("proofObserver", () => { observer.observe({ type: "tool_result", - tool: "mcp__ade__pr_get_review_comments", + tool: "pr_get_review_comments", result: { comments: [ { diff --git a/apps/desktop/src/main/services/computerUse/proofObserver.ts b/apps/desktop/src/main/services/computerUse/proofObserver.ts index 14e3c992a..03f242d2f 100644 --- a/apps/desktop/src/main/services/computerUse/proofObserver.ts +++ b/apps/desktop/src/main/services/computerUse/proofObserver.ts @@ -26,11 +26,6 @@ const GHOST_ARTIFACT_TOOLS = new Set([ "ghost_annotate", "ghost_ground", "ghost_parse_screen", - // MCP-prefixed versions - "mcp__ghost-os__ghost_screenshot", - "mcp__ghost-os__ghost_annotate", - "mcp__ghost-os__ghost_ground", - "mcp__ghost-os__ghost_parse_screen", ]); // --------------------------------------------------------------------------- @@ -112,30 +107,22 @@ function inferMimeType(value: string): string | null { } function inferBackendName(toolName: string): string { - const stripped = toolName.replace(/^mcp__[^_]+__/, ""); - const proxiedExternalMatch = /^ext\.([^.]+)\./.exec(stripped); + const proxiedExternalMatch = /^ext\.([^.]+)\./.exec(toolName); if (proxiedExternalMatch?.[1]) return proxiedExternalMatch[1]; - if (toolName.startsWith("mcp__ghost-os__") || toolName.startsWith("ghost_")) { + if (toolName.startsWith("ghost_")) { return "ghost-os"; } - if (toolName.startsWith("mcp__")) { - // e.g. "mcp__my-server__my_tool" -> "my-server" - const parts = toolName.split("__"); - if (parts.length >= 2) return parts[1]; - } if (toolName.startsWith("functions.")) return "functions"; if (toolName.startsWith("multi_tool_use.")) return "multi_tool_use"; if (toolName.startsWith("web.")) return "web"; + const dottedNamespace = /^([A-Za-z0-9_-]+)\./.exec(toolName); + if (dottedNamespace?.[1]) return dottedNamespace[1]; return "chat-tool"; } function inferBackendDescriptor(toolName: string): ComputerUseBackendDescriptor { - const stripped = toolName.replace(/^mcp__[^_]+__/, ""); - const style = toolName.startsWith("mcp__") || toolName.startsWith("ghost_") || stripped.startsWith("ext.") - ? "external_mcp" - : "external_cli"; return { - style, + style: "external_cli", name: inferBackendName(toolName), toolName, }; @@ -143,7 +130,7 @@ function inferBackendDescriptor(toolName: string): ComputerUseBackendDescriptor function buildTitle(toolName: string, kind: ComputerUseArtifactKind): string { const kindLabel = kind.replace(/_/g, " "); - const shortTool = toolName.replace(/^mcp__[^_]+__/, "").replace(/^ext\./, ""); + const shortTool = toolName.replace(/^ext\./, ""); return `${kindLabel[0].toUpperCase()}${kindLabel.slice(1)} from ${shortTool}`; } diff --git a/apps/desktop/src/main/services/computerUse/syntheticToolResult.test.ts b/apps/desktop/src/main/services/computerUse/syntheticToolResult.test.ts index baa6ccce1..465f11554 100644 --- a/apps/desktop/src/main/services/computerUse/syntheticToolResult.test.ts +++ b/apps/desktop/src/main/services/computerUse/syntheticToolResult.test.ts @@ -316,11 +316,11 @@ describe("proof observer integration with synthetic tool_result", () => { }); }); - it("proof observer handles MCP tool with artifact in args", () => { + it("proof observer handles namespaced external CLI tool with artifact in args", () => { const { observer, requests } = createHarness(); const synthetic = maybeSyntheticToolResult( - "mcp__playwright__browser_take_screenshot", + "playwright.browser_take_screenshot", { outputPath: "/tmp/playwright-shot.png" }, "claude-tool:turn3:0", "turn3", @@ -330,7 +330,7 @@ describe("proof observer integration with synthetic tool_result", () => { expect(requests).toHaveLength(1); expect(requests[0]?.backend).toMatchObject({ - style: "external_mcp", + style: "external_cli", name: "playwright", }); expect(requests[0]?.inputs).toEqual([ diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index 7f8964b8c..acb2e35a3 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -82,7 +82,6 @@ const AUTOMATION_TOOL_FAMILIES: AutomationToolFamily[] = [ "browser", "memory", "mission", - "external-mcp", ]; function isPathWithinProjectRoot(projectRoot: string, candidate: string, opts: { allowMissing?: boolean } = {}): boolean { @@ -626,24 +625,15 @@ function coerceMissionPermissionConfig(value: unknown): MissionPermissionConfig ...(asStringArray(value.providers.allowedTools)?.length ? { allowedTools: asStringArray(value.providers.allowedTools) } : {}), } : undefined; - const externalMcp = isRecord(value.externalMcp) - ? { - ...(asBool(value.externalMcp.enabled) != null ? { enabled: asBool(value.externalMcp.enabled)! } : {}), - ...(asStringArray(value.externalMcp.selectedServers)?.length ? { selectedServers: asStringArray(value.externalMcp.selectedServers) } : {}), - ...(asStringArray(value.externalMcp.selectedTools)?.length ? { selectedTools: asStringArray(value.externalMcp.selectedTools) } : {}), - } - : undefined; if ( !(cli && Object.keys(cli).length) && !inProcess && !(providers && Object.keys(providers).length) - && !(externalMcp && Object.keys(externalMcp).length) ) return undefined; return { ...(cli && Object.keys(cli).length ? { cli } : {}), ...(inProcess ? { inProcess } : {}), ...(providers && Object.keys(providers).length ? { providers } : {}), - ...(externalMcp && Object.keys(externalMcp).length ? { externalMcp } : {}), }; } @@ -1459,10 +1449,6 @@ function coerceAiConfig(value: unknown): AiConfig | undefined { const workerSafety = coerceWorkerSafetyPolicy(value.workerSafety); if (workerSafety) out.workerSafety = workerSafety; - if (isRecord(value.mcpServers) && Object.keys(value.mcpServers).length) { - out.mcpServers = { ...value.mcpServers }; - } - return Object.keys(out).length ? out : undefined; } @@ -1804,10 +1790,6 @@ export function mergeAiConfig(sharedAi?: AiConfig, localAi?: Partial): .filter((entry): entry is readonly ["ollama" | "lmstudio", Record] => entry != null); const localProviders = Object.fromEntries(localProvidersEntries) as AiConfig["localProviders"]; const workerSafety = mergeWorkerSafetyPolicy(sharedAi?.workerSafety, localAi?.workerSafety); - const mcpServers = { - ...(sharedAi?.mcpServers ?? {}), - ...(localAi?.mcpServers ?? {}) - }; const out: AiConfig = { mode: localAi?.mode ?? sharedAi?.mode, defaultProvider: localAi?.defaultProvider ?? sharedAi?.defaultProvider, @@ -1824,7 +1806,6 @@ export function mergeAiConfig(sharedAi?: AiConfig, localAi?: Partial): ...(Object.keys(apiKeys).length ? { apiKeys } : {}), ...(localProvidersEntries.length ? { localProviders } : {}), ...(workerSafety ? { workerSafety } : {}), - ...(Object.keys(mcpServers).length ? { mcpServers } : {}) }; return Object.keys(out).length ? out : undefined; } diff --git a/apps/desktop/src/main/services/context/contextDocBuilder.test.ts b/apps/desktop/src/main/services/context/contextDocBuilder.test.ts index 42e55a90a..f17b71267 100644 --- a/apps/desktop/src/main/services/context/contextDocBuilder.test.ts +++ b/apps/desktop/src/main/services/context/contextDocBuilder.test.ts @@ -27,7 +27,7 @@ function buildValidPrdDoc(summary = "ADE is a local-first desktop workspace for "", "## Feature areas", "- Lanes, missions, PR workflows, and proof capture.", - "- CTO and MCP-backed operator flows.", + "- CTO and ADE CLI-backed operator flows.", "", "## Current state", "- Desktop app is the main product surface.", @@ -51,7 +51,7 @@ function buildValidPrdDocWithoutCanonicalTitle(summary = "ADE is a local-first d "", "## Feature areas", "- Lanes, missions, PR workflows, and proof capture.", - "- CTO and MCP-backed operator flows.", + "- CTO and ADE CLI-backed operator flows.", "", "## Current state", "- Desktop app is the main product surface.", @@ -73,7 +73,7 @@ function buildValidArchitectureDocWithoutCanonicalTitle( "", "## Core services", "- Main-process services own git, files, processes, missions, and context generation.", - "- The MCP server reuses shared ADE services.", + "- The ADE CLI reuses shared ADE services.", "", "## Data and state", "- Project state lives under `.ade/`.", @@ -99,7 +99,7 @@ function buildValidArchitectureDoc(summary = "ADE uses a trusted Electron main p "", "## Core services", "- Main-process services own git, files, processes, missions, and context generation.", - "- The MCP server reuses shared ADE services.", + "- The ADE CLI reuses shared ADE services.", "", "## Data and state", "- Project state lives under `.ade/`.", diff --git a/apps/desktop/src/main/services/context/contextDocBuilder.ts b/apps/desktop/src/main/services/context/contextDocBuilder.ts index c8c663231..61eb0a3b2 100644 --- a/apps/desktop/src/main/services/context/contextDocBuilder.ts +++ b/apps/desktop/src/main/services/context/contextDocBuilder.ts @@ -365,7 +365,7 @@ async function buildHybridSourceBundle(projectRoot: string, lastGeneratedAt: str readSectionExcerpt(projectRoot, "apps/desktop/src/main/services/ipc/registerIpc.ts", /IPC\.contextGetStatus/), readSectionExcerpt(projectRoot, "apps/desktop/src/preload/preload.ts", /context:\s*\{/), readSectionExcerpt(projectRoot, "apps/desktop/src/shared/types/packs.ts", /export type ContextDocStatus = \{/), - readSectionExcerpt(projectRoot, "apps/mcp-server/src/index.ts", /^/), + readSectionExcerpt(projectRoot, "apps/ade-cli/src/cli.ts", /^/), ].filter((value): value is { relPath: string; excerpt: string } => value != null); return { @@ -542,7 +542,7 @@ function buildDeterministicPrdDoc(args: { : [ "- Preserve existing desktop app patterns before introducing new abstractions.", "- Keep IPC contracts, preload types, shared types, and renderer usage in sync.", - "- Validate the smallest relevant desktop/MCP checks first, then broaden coverage.", + "- Validate the smallest relevant desktop/ADE CLI checks first, then broaden coverage.", ]; return clipText([ CONTEXT_DOC_SPECS.prd_ade.title, @@ -584,7 +584,7 @@ function buildDeterministicArchitectureDoc(args: { ]; const integrationBullets = [ "- Desktop UI talks to trusted services over typed IPC via the preload bridge.", - "- `apps/mcp-server` exposes ADE tools for headless and desktop-backed MCP flows.", + "- `apps/ade-cli` exposes ADE actions for terminal-capable agents in headless and desktop-backed modes.", "- AI execution remains provider-flexible across CLI subscriptions, API/OpenRouter, and local endpoints.", ]; const patternBullets = (args.workingNorms.length > 0 ? args.workingNorms.slice(0, 4) : [ diff --git a/apps/desktop/src/main/services/cto/ctoStateService.test.ts b/apps/desktop/src/main/services/cto/ctoStateService.test.ts index fa19af225..9fc4a6be9 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.test.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.test.ts @@ -269,7 +269,7 @@ describe("ctoStateService", () => { endedAt: "2026-03-05T10:05:00.000Z", provider: "codex", modelId: "openai/gpt-5.3-codex", - capabilityMode: "full_mcp", + capabilityMode: "full_tooling", }); expect(entry.sessionId).toBe("session-1"); expect(service.getSessionLogs(10).length).toBe(1); @@ -554,7 +554,7 @@ describe("ctoStateService", () => { expect(preview.sections[3]?.content).toContain("ADE Architecture"); expect(preview.sections[3]?.content).toContain("spawnChat"); expect(preview.sections[3]?.content).toContain("createTerminal"); - expect(preview.sections[3]?.content).toContain("spawn_agent is an MCP tool"); + expect(preview.sections[3]?.content).toContain("spawnChat"); expect(preview.sections[3]?.content).toContain("Model Selection"); // Capabilities section: organized tool reference with descriptions expect(preview.sections[4]?.content).toContain("ADE Operator Tools"); diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index 40fc3c5a3..d291b9389 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, - ExternalMcpAccessPolicy, OpenclawContextPolicy, CtoOnboardingState, CtoSessionLogEntry, @@ -35,7 +34,7 @@ type AppendCtoSessionLogArgs = { endedAt: string | null; provider: string; modelId: string | null; - capabilityMode: "full_mcp" | "fallback"; + capabilityMode: "full_tooling" | "fallback"; }; type AppendCtoSubordinateActivityArgs = { @@ -86,6 +85,7 @@ const IMMUTABLE_CTO_DOCTRINE = [ "- All ADE internals are fair game. The user can request any action: launching chats, opening terminals, running CLI tools, spawning agents, managing lanes, etc. Never refuse an action that ADE supports.", "- When the user asks about something you can look up (lane status, PR checks, test results), call the tool first and report facts. Do not guess.", "- When you are unsure which tool to use, consult the capability manifest in your system prompt before asking the user.", + "- Terminal-capable sessions can use the bundled `ade` CLI for internal ADE actions: `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands first, `ade actions run ...` as the escape hatch, `--json` for structured output, and `--text` for readable output.", ].join("\n"); const CTO_MEMORY_OPERATING_MODEL = [ @@ -156,7 +156,7 @@ const CTO_ENVIRONMENT_KNOWLEDGE = [ " /graph — Workspace dependency graph visualization showing lane relationships.", " /history — Operation history timeline showing all past actions.", " /automations — Automation rule builder: create rules triggered by events (PR opened, test failed, etc.).", - " /settings — App settings: AI providers, GitHub token, Linear integration, keybindings, usage budgets, MCP servers.", + " /settings — App settings: AI providers, GitHub token, Linear integration, keybindings, usage budgets, and external connectors.", " When an action should be opened in ADE, return a navigation suggestion. Never silently switch tabs.", "", "## Model Selection", @@ -173,12 +173,14 @@ const CTO_ENVIRONMENT_KNOWLEDGE = [ "Chats vs Terminals — both are valid, match the user's intent:", " - spawnChat: Creates a native ADE chat session with AI, streaming, tool approval, and service integration. Use when the user wants an AI agent, a chat, or AI-powered work.", " - createTerminal: Opens a shell (PTY) for raw CLI commands. Use when the user wants a terminal, shell, or to run a specific CLI tool.", - " - spawn_agent is an MCP tool for Claude CLI subprocesses in tracked terminals. It differs from spawnChat — when the user says 'start a chat' or 'launch an agent', prefer spawnChat. But if the user explicitly wants a CLI agent or terminal-based tool, createTerminal or spawn_agent are fine.", + " - spawnChat creates ADE-managed agent chats. createTerminal opens a raw shell for CLI commands. When the user says 'start a chat' or 'launch an agent', prefer spawnChat unless they explicitly ask for a terminal.", " - Example: 'Launch a chat with opus' → spawnChat({ modelId: 'anthropic/claude-opus-4-7', ... }). 'Open a terminal' → createTerminal. 'Run npm test' → createTerminal({ startupCommand: 'npm test' }).", "", "Tool calling convention:", - " - ADE tools are available as MCP tools. They may be prefixed (e.g., mcp__ade__spawnChat). Call them directly by name.", - " - If a tool from the manifest below is not in your immediate tool list, try calling it directly before concluding it does not exist.", + " - ADE actions are available through ADE's native action surface and the `ade` CLI in terminal-capable sessions.", + " - In terminal-capable sessions, run `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands such as `ade lanes list --text` or `ade prs checks --text` first, and `ade actions run ...` as the escape hatch.", + " - Use `--json` when another agent or script needs stable fields; use `--text` when a human-readable summary is enough.", + " - If a tool from the manifest below is not in your immediate tool list, use the closest ADE CLI command or report the missing capability clearly.", "", "## PR Lifecycle in ADE", "", @@ -524,7 +526,6 @@ function normalizeIdentity(input: unknown): CtoIdentity | null { source.communicationStyle && typeof source.communicationStyle === "object" ? (source.communicationStyle as Record) : {}; - const externalMcpAccess = normalizeExternalMcpAccess(source.externalMcpAccess); const openclawContextPolicy = normalizeOpenclawContextPolicy(source.openclawContextPolicy); const onboardingState = normalizeOnboardingState(source.onboardingState); const personality = normalizePersonalityPreset(source.personality); @@ -593,27 +594,12 @@ function normalizeIdentity(input: unknown): CtoIdentity | null { ? Math.max(1, Math.floor(Number(memoryPolicyRaw.temporalDecayHalfLifeDays))) : 30, }, - ...(externalMcpAccess ? { externalMcpAccess } : {}), ...(openclawContextPolicy ? { openclawContextPolicy } : {}), ...(onboardingState ? { onboardingState } : {}), updatedAt, }; } -function normalizeExternalMcpAccess(value: unknown): ExternalMcpAccessPolicy | undefined { - if (!value || typeof value !== "object") return undefined; - const source = value as Record; - const toStringArray = (input: unknown): string[] => - Array.isArray(input) - ? [...new Set(input.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0))] - : []; - return { - allowAll: source.allowAll !== false, - allowedServers: toStringArray(source.allowedServers), - blockedServers: toStringArray(source.blockedServers), - }; -} - function normalizeOpenclawContextPolicy(value: unknown): OpenclawContextPolicy | undefined { if (!value || typeof value !== "object") return undefined; const source = value as Record; @@ -687,7 +673,7 @@ function normalizeSessionLogEntry(input: unknown): CtoSessionLogEntry | null { const provider = typeof source.provider === "string" ? source.provider.trim() : ""; if (!sessionId || !createdAt || !summary || !startedAt || !provider) return null; - const capabilityMode = source.capabilityMode === "full_mcp" ? "full_mcp" : "fallback"; + const capabilityMode = source.capabilityMode === "full_tooling" ? "full_tooling" : "fallback"; return { id: typeof source.id === "string" && source.id.trim().length ? source.id.trim() : randomUUID(), prevHash: typeof source.prevHash === "string" && source.prevHash.trim().length ? source.prevHash.trim() : null, @@ -742,11 +728,6 @@ function makeDefaultIdentity(): CtoIdentity { preCompactionFlush: true, temporalDecayHalfLifeDays: 30, }, - externalMcpAccess: { - allowAll: true, - allowedServers: [], - blockedServers: [], - }, openclawContextPolicy: { shareMode: "filtered", blockedCategories: ["secret", "token", "system_prompt"], @@ -1380,7 +1361,6 @@ export function createCtoStateService(args: CtoStateServiceArgs) { ...patch, modelPreferences: { ...current.modelPreferences, ...(patch.modelPreferences ?? {}) }, memoryPolicy: { ...current.memoryPolicy, ...(patch.memoryPolicy ?? {}) }, - externalMcpAccess: normalizeExternalMcpAccess(patch.externalMcpAccess) ?? current.externalMcpAccess, openclawContextPolicy: normalizeOpenclawContextPolicy(patch.openclawContextPolicy) ?? current.openclawContextPolicy, version: current.version + 1, updatedAt: timestamp, diff --git a/apps/desktop/src/main/services/cto/linearCloseoutService.ts b/apps/desktop/src/main/services/cto/linearCloseoutService.ts index e6503efaa..4ae364198 100644 --- a/apps/desktop/src/main/services/cto/linearCloseoutService.ts +++ b/apps/desktop/src/main/services/cto/linearCloseoutService.ts @@ -136,6 +136,7 @@ export function createLinearCloseoutService(args: { } const owners: ComputerUseArtifactOwner[] = []; + owners.push({ kind: "orchestrator_run", id: input.run.id }); if (input.run.linkedSessionId) { owners.push({ kind: "chat_session", id: input.run.linkedSessionId }); } diff --git a/apps/desktop/src/main/services/cto/linearDispatcherService.test.ts b/apps/desktop/src/main/services/cto/linearDispatcherService.test.ts index ce1a700d9..1db378a06 100644 --- a/apps/desktop/src/main/services/cto/linearDispatcherService.test.ts +++ b/apps/desktop/src/main/services/cto/linearDispatcherService.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, expect, it, vi } from "vitest"; import type { LinearWorkflowConfig, LinearWorkflowMatchResult, NormalizedLinearIssue } from "../../../shared/types"; import { openKvDb } from "../state/kvDb"; @@ -1405,7 +1406,7 @@ describe("linearDispatcherService", () => { expect(updateBodies[0]).toContain("- PR: pr-55"); expect(updateBodies[updateBodies.length - 1]).toContain("### Closeout Summary"); expect(updateBodies[updateBodies.length - 1]).toContain("https://github.com/acme/repo/pull/55"); - expect(updateBodies[updateBodies.length - 1]).toContain(`file://${artifactPath}`); + expect(updateBodies[updateBodies.length - 1]).toContain(pathToFileURL(fs.realpathSync(artifactPath)).href); db.close(); }); diff --git a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.test.ts b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.test.ts index bc3313008..ab672d378 100644 --- a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.test.ts +++ b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.test.ts @@ -217,9 +217,11 @@ describe("workerAdapterRuntimeService", () => { expect(ensureIdentitySession).not.toHaveBeenCalled(); expect(runSessionTurn).toHaveBeenCalledWith({ sessionId: "session-opencode-1", - text: "continue the same worker context", + text: expect.stringContaining("continue the same worker context"), timeoutMs: 300000, }); + const firstCall = runSessionTurn.mock.calls[0] as unknown as [{ text: string }] | undefined; + expect(firstCall?.[0]?.text).toContain("ADE CLI:"); expect(result.effectiveSurface).toBe("unified_chat"); expect(result.continuation).toMatchObject({ surface: "unified_chat", diff --git a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts index 2cb790605..89d87418e 100644 --- a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts +++ b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts @@ -8,6 +8,8 @@ import type { import { resolveCodexExecutable } from "../ai/codexExecutable"; import type { createAgentChatService } from "../chat/agentChatService"; +const ADE_CLI_WORKER_GUIDANCE = "ADE CLI: In terminal-capable sessions, use the bundled `ade` command for internal ADE actions. Run `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands such as `ade lanes list --text` or `ade prs checks --text` first, and `ade actions run ...` as the escape hatch. Use `--json` for structured output and `--text` for readable output."; + type WorkerAdapterRuntimeServiceArgs = { fetchImpl?: typeof fetch; spawnImpl?: typeof spawn; @@ -199,7 +201,7 @@ export function createWorkerAdapterRuntimeService(args: WorkerAdapterRuntimeServ : ""; const sessionResult = await agentChatService!.runSessionTurn({ sessionId, - text: `${instructions}${prompt}`, + text: `${ADE_CLI_WORKER_GUIDANCE}\n\n${instructions}${prompt}`, ...(toOptionalString(config.reasoningEffort) ? { reasoningEffort: toOptionalString(config.reasoningEffort) } : {}), timeoutMs, }); @@ -367,7 +369,7 @@ export function createWorkerAdapterRuntimeService(args: WorkerAdapterRuntimeServ const result = await runCommand(spawnImpl, binary, args, { cwd: typeof config.cwd === "string" ? config.cwd : undefined, timeoutMs, - stdinText: `${instructions}${prompt}\n`, + stdinText: `${ADE_CLI_WORKER_GUIDANCE}\n\n${instructions}${prompt}\n`, }); return { adapterType, diff --git a/apps/desktop/src/main/services/cto/workerAgentService.ts b/apps/desktop/src/main/services/cto/workerAgentService.ts index f509031ff..6f853b3b5 100644 --- a/apps/desktop/src/main/services/cto/workerAgentService.ts +++ b/apps/desktop/src/main/services/cto/workerAgentService.ts @@ -4,7 +4,6 @@ import { randomUUID } from "node:crypto"; import YAML from "yaml"; import type { AgentCoreMemory, - ExternalMcpAccessPolicy, AgentIdentity, AgentLinearIdentity, AgentRole, @@ -52,7 +51,7 @@ type AppendWorkerSessionLogArgs = { endedAt: string | null; provider: string; modelId: string | null; - capabilityMode: "full_mcp" | "fallback"; + capabilityMode: "full_tooling" | "fallback"; }; const ALLOWED_ROLES = new Set([ @@ -153,7 +152,7 @@ function normalizeWorkerSessionLogEntry(input: unknown): AgentSessionLogEntry | const provider = typeof source.provider === "string" ? source.provider.trim() : ""; if (!sessionId || !createdAt || !summary || !startedAt || !provider) return null; - const capabilityMode = source.capabilityMode === "full_mcp" ? "full_mcp" : "fallback"; + const capabilityMode = source.capabilityMode === "full_tooling" ? "full_tooling" : "fallback"; return { id: typeof source.id === "string" && source.id.trim().length ? source.id.trim() : randomUUID(), prevHash: typeof source.prevHash === "string" && source.prevHash.trim().length ? source.prevHash.trim() : null, @@ -207,9 +206,6 @@ function normalizeIdentity(input: unknown): AgentIdentity | null { ...(normalizeLinearIdentity(source.linearIdentity) ? { linearIdentity: normalizeLinearIdentity(source.linearIdentity)! } : {}), - ...(normalizeExternalMcpAccess(source.externalMcpAccess) - ? { externalMcpAccess: normalizeExternalMcpAccess(source.externalMcpAccess)! } - : {}), budgetMonthlyCents: Number.isFinite(Number(source.budgetMonthlyCents)) ? Math.max(0, Math.floor(Number(source.budgetMonthlyCents))) : 0, @@ -225,18 +221,6 @@ function normalizeIdentity(input: unknown): AgentIdentity | null { }; } -function normalizeExternalMcpAccess(value: unknown): ExternalMcpAccessPolicy | undefined { - if (!value || typeof value !== "object") return undefined; - const source = value as Record; - const allowedServers = uniqueStrings(asStringArray(source.allowedServers)); - const blockedServers = uniqueStrings(asStringArray(source.blockedServers)); - return { - allowAll: source.allowAll === true, - allowedServers, - blockedServers, - }; -} - function normalizeLinearIdentity(value: unknown): AgentLinearIdentity | undefined { if (!value || typeof value !== "object") return undefined; const source = value as Record; @@ -606,10 +590,6 @@ export function createWorkerAgentService(args: WorkerAgentServiceArgs) { const runtimeConfig = (input.runtimeConfig ?? existing?.runtimeConfig ?? {}) as Record; assertEnvRefSecretPolicy(runtimeConfig, "runtimeConfig"); - const externalMcpAccess = - normalizeExternalMcpAccess(input.externalMcpAccess) - ?? existing?.externalMcpAccess - ?? { allowAll: false, allowedServers: [], blockedServers: [] }; const linearIdentity = normalizeLinearIdentity(input.linearIdentity) ?? existing?.linearIdentity @@ -628,7 +608,6 @@ export function createWorkerAgentService(args: WorkerAgentServiceArgs) { adapterConfig, runtimeConfig, ...(linearIdentity ? { linearIdentity } : {}), - externalMcpAccess, budgetMonthlyCents: Math.max(0, Math.floor(Number(input.budgetMonthlyCents ?? existing?.budgetMonthlyCents ?? 0))), spentMonthlyCents: existing?.spentMonthlyCents ?? 0, ...(existing?.lastHeartbeatAt ? { lastHeartbeatAt: existing.lastHeartbeatAt } : {}), @@ -653,11 +632,6 @@ export function createWorkerAgentService(args: WorkerAgentServiceArgs) { ...(normalizeLinearIdentity(snapshot.linearIdentity) ? { linearIdentity: normalizeLinearIdentity(snapshot.linearIdentity)! } : {}), - externalMcpAccess: normalizeExternalMcpAccess(snapshot.externalMcpAccess) ?? { - allowAll: false, - allowedServers: [], - blockedServers: [], - }, budgetMonthlyCents: Math.max(0, Math.floor(Number(snapshot.budgetMonthlyCents ?? 0))), spentMonthlyCents: Math.max(0, Math.floor(Number(snapshot.spentMonthlyCents ?? 0))), updatedAt: nowIso(), diff --git a/apps/desktop/src/main/services/cto/workerHeartbeatService.ts b/apps/desktop/src/main/services/cto/workerHeartbeatService.ts index 868b254db..02dad02c2 100644 --- a/apps/desktop/src/main/services/cto/workerHeartbeatService.ts +++ b/apps/desktop/src/main/services/cto/workerHeartbeatService.ts @@ -652,7 +652,7 @@ export function createWorkerHeartbeatService(args: WorkerHeartbeatServiceArgs) { capabilityMode: runtimeResult.effectiveSurface === "process" || runtimeResult.effectiveSurface === "openclaw_webhook" ? "fallback" - : "full_mcp", + : "full_tooling", }); args.ctoStateService?.appendSubordinateActivity({ agentId: agent.id, diff --git a/apps/desktop/src/main/services/externalMcp/externalConnectionAuthService.ts b/apps/desktop/src/main/services/externalMcp/externalConnectionAuthService.ts deleted file mode 100644 index 1ac4a9d5e..000000000 --- a/apps/desktop/src/main/services/externalMcp/externalConnectionAuthService.ts +++ /dev/null @@ -1,795 +0,0 @@ -import fs from "node:fs"; -import http from "node:http"; -import path from "node:path"; -import { randomUUID } from "node:crypto"; -import { URL } from "node:url"; -import { safeStorage } from "electron"; -import type { - ExternalConnectionAuthMode, - ExternalConnectionAuthPlacement, - ExternalConnectionAuthRecord, - ExternalConnectionAuthRecordInput, - ExternalConnectionAuthStatus, - ExternalConnectionOAuthSessionResult, - ExternalConnectionOAuthSessionStartResult, - ExternalMcpManagedAuthConfig, -} from "../../../shared/types"; -import type { Logger } from "../logging/logger"; -import { createPkcePair, getErrorMessage, isEnoentError, isRecord, nowIso } from "../shared/utils"; - -const STORE_FILE = "external-connection-auth.v1.bin"; -const OAUTH_CALLBACK_PATH = "/oauth/external-mcp/callback"; -const OAUTH_SESSION_TTL_MS = 10 * 60 * 1000; -const OAUTH_EXPIRY_SKEW_MS = 60_000; - -type StoredAuthState = { - records: ExternalConnectionAuthRecord[]; - secrets: Record; -}; - -type OAuthSessionState = { - id: string; - authId: string; - state: string; - redirectUri: string; - authUrl: string; - codeVerifier: string | null; - createdAt: number; - status: ExternalConnectionOAuthSessionResult["status"]; - error: string | null; - server: http.Server; -}; - - -function createEmptyState(): StoredAuthState { - return { records: [], secrets: {} }; -} - -function normalizeStringMap(value: unknown): Record | undefined { - if (!isRecord(value)) return undefined; - const entries = Object.entries(value) - .map(([key, raw]) => { - const nextKey = key.trim(); - const nextValue = typeof raw === "string" ? raw.trim() : ""; - return nextKey && nextValue ? [nextKey, nextValue] as const : null; - }) - .filter((entry): entry is readonly [string, string] => entry != null); - return entries.length > 0 ? Object.fromEntries(entries) : undefined; -} - -function normalizeOAuthRecord(value: unknown): ExternalConnectionAuthRecord["oauth"] | undefined { - if (!isRecord(value)) return undefined; - const authorizeUrl = typeof value.authorizeUrl === "string" ? value.authorizeUrl.trim() : ""; - const tokenUrl = typeof value.tokenUrl === "string" ? value.tokenUrl.trim() : ""; - const clientId = typeof value.clientId === "string" ? value.clientId.trim() : ""; - if (!authorizeUrl.length || !tokenUrl.length || !clientId.length) return undefined; - return { - authorizeUrl, - tokenUrl, - clientId, - scope: typeof value.scope === "string" && value.scope.trim().length ? value.scope.trim() : null, - audience: typeof value.audience === "string" && value.audience.trim().length ? value.audience.trim() : null, - extraAuthorizeParams: normalizeStringMap(value.extraAuthorizeParams), - extraTokenParams: normalizeStringMap(value.extraTokenParams), - clientSecretId: typeof value.clientSecretId === "string" && value.clientSecretId.trim().length ? value.clientSecretId.trim() : null, - accessTokenId: typeof value.accessTokenId === "string" && value.accessTokenId.trim().length ? value.accessTokenId.trim() : null, - refreshTokenId: typeof value.refreshTokenId === "string" && value.refreshTokenId.trim().length ? value.refreshTokenId.trim() : null, - expiresAt: typeof value.expiresAt === "string" && value.expiresAt.trim().length ? value.expiresAt.trim() : null, - lastAuthenticatedAt: - typeof value.lastAuthenticatedAt === "string" && value.lastAuthenticatedAt.trim().length - ? value.lastAuthenticatedAt.trim() - : null, - }; -} - -function normalizeRecord(value: unknown): ExternalConnectionAuthRecord | null { - if (!isRecord(value)) return null; - const id = typeof value.id === "string" ? value.id.trim() : ""; - const displayName = typeof value.displayName === "string" ? value.displayName.trim() : ""; - const mode = value.mode; - if (!id.length || !displayName.length) return null; - if (mode !== "none" && mode !== "api_key" && mode !== "bearer" && mode !== "oauth") return null; - return { - id, - displayName, - mode, - secretId: typeof value.secretId === "string" && value.secretId.trim().length ? value.secretId.trim() : null, - oauth: normalizeOAuthRecord(value.oauth), - createdAt: typeof value.createdAt === "string" && value.createdAt.trim().length ? value.createdAt.trim() : nowIso(), - updatedAt: typeof value.updatedAt === "string" && value.updatedAt.trim().length ? value.updatedAt.trim() : nowIso(), - lastError: typeof value.lastError === "string" && value.lastError.trim().length ? value.lastError.trim() : null, - }; -} - -function isExpired(expiresAt?: string | null): boolean { - if (!expiresAt) return false; - const expiresAtMs = Date.parse(expiresAt); - if (!Number.isFinite(expiresAtMs)) return false; - return Date.now() + OAUTH_EXPIRY_SKEW_MS >= expiresAtMs; -} - -function buildPreview(placement: ExternalConnectionAuthPlacement | undefined, mode: ExternalConnectionAuthMode): string[] { - if (!placement) return []; - const key = placement.key.trim(); - if (!key.length) return []; - const prefix = placement.prefix ?? (mode === "bearer" || mode === "oauth" ? "Bearer " : ""); - if (placement.target === "header") { - return [`${key}: ${prefix}[stored credential]`]; - } - return [`${key}=${prefix}[stored credential]`]; -} - -export function createExternalConnectionAuthService(args: { - adeDir: string; - logger?: Logger | null; - fetchImpl?: typeof fetch; -}) { - const secretsDir = path.join(args.adeDir, "secrets"); - const statePath = path.join(secretsDir, STORE_FILE); - const fetchImpl = args.fetchImpl ?? fetch; - const sessions = new Map(); - let cachedState: StoredAuthState | null = null; - - const assertStorageAvailable = (): void => { - if (!safeStorage.isEncryptionAvailable()) { - throw new Error("OS secure storage is unavailable. External MCP credentials cannot be stored."); - } - }; - - const readState = (): StoredAuthState => { - if (cachedState) return cachedState; - try { - if (!safeStorage.isEncryptionAvailable()) { - cachedState = createEmptyState(); - return cachedState; - } - const encrypted = fs.readFileSync(statePath); - const decrypted = safeStorage.decryptString(encrypted); - const parsed = JSON.parse(decrypted) as StoredAuthState; - const records = Array.isArray(parsed.records) - ? parsed.records.map((entry) => normalizeRecord(entry)).filter((entry): entry is ExternalConnectionAuthRecord => entry != null) - : []; - const secrets = isRecord(parsed.secrets) - ? Object.fromEntries( - Object.entries(parsed.secrets) - .map(([key, value]) => [key.trim(), typeof value === "string" ? value : ""] as const) - .filter(([key, value]) => key.length > 0 && value.length > 0), - ) - : {}; - cachedState = { records, secrets }; - return cachedState; - } catch (error: unknown) { - if (isEnoentError(error)) { - cachedState = createEmptyState(); - return cachedState; - } - args.logger?.warn("external_auth.read_failed", { error: getErrorMessage(error) }); - cachedState = createEmptyState(); - return cachedState; - } - }; - - const writeState = (next: StoredAuthState): void => { - assertStorageAvailable(); - fs.mkdirSync(secretsDir, { recursive: true }); - const encrypted = safeStorage.encryptString(JSON.stringify(next)); - fs.writeFileSync(statePath, encrypted); - try { - fs.chmodSync(statePath, 0o600); - } catch { - // best effort - } - cachedState = next; - }; - - const mutateState = (mutator: (state: StoredAuthState) => T): T => { - const current = readState(); - const next: StoredAuthState = { - records: current.records.map((entry) => ({ ...entry, ...(entry.oauth ? { oauth: { ...entry.oauth } } : {}) })), - secrets: { ...current.secrets }, - }; - const result = mutator(next); - writeState(next); - return result; - }; - - const getRecordById = (authId: string): ExternalConnectionAuthRecord | null => { - const normalized = authId.trim(); - if (!normalized.length) return null; - return readState().records.find((entry) => entry.id === normalized) ?? null; - }; - - const getSecret = (secretId?: string | null): string | null => { - const normalized = secretId?.trim() ?? ""; - if (!normalized.length) return null; - return readState().secrets[normalized] ?? null; - }; - - const ensureOAuthRecord = (authId: string): ExternalConnectionAuthRecord => { - const record = getRecordById(authId); - if (!record) throw new Error(`External auth record '${authId}' was not found.`); - if (record.mode !== "oauth" || !record.oauth) { - throw new Error(`External auth record '${authId}' is not configured for OAuth.`); - } - return record; - }; - - const finalizeSession = ( - session: OAuthSessionState, - patch: { status: OAuthSessionState["status"]; error?: string | null }, - ) => { - session.status = patch.status; - session.error = patch.error ?? null; - try { - session.server.close(); - } catch { - // best effort - } - }; - - const pruneExpiredSessions = () => { - const now = Date.now(); - for (const session of sessions.values()) { - if (session.status === "pending" && now - session.createdAt > OAUTH_SESSION_TTL_MS) { - finalizeSession(session, { - status: "expired", - error: "OAuth session expired before the callback completed.", - }); - } - if (session.status !== "pending" && now - session.createdAt > OAUTH_SESSION_TTL_MS * 2) { - sessions.delete(session.id); - } - } - }; - - const saveOAuthTokenPayload = ( - authId: string, - payload: { - accessToken: string; - refreshToken?: string | null; - expiresAt?: string | null; - lastError?: string | null; - }, - ): ExternalConnectionAuthRecord => mutateState((state) => { - const index = state.records.findIndex((entry) => entry.id === authId); - if (index < 0) { - throw new Error(`External auth record '${authId}' was not found.`); - } - const current = state.records[index]!; - if (current.mode !== "oauth" || !current.oauth) { - throw new Error(`External auth record '${authId}' is not configured for OAuth.`); - } - const accessTokenId = current.oauth.accessTokenId ?? `${authId}:access-token`; - state.secrets[accessTokenId] = payload.accessToken.trim(); - let refreshTokenId = current.oauth.refreshTokenId ?? null; - const refreshToken = payload.refreshToken?.trim() ?? ""; - if (refreshToken.length) { - refreshTokenId = refreshTokenId ?? `${authId}:refresh-token`; - state.secrets[refreshTokenId] = refreshToken; - } - const nextRecord: ExternalConnectionAuthRecord = { - ...current, - updatedAt: nowIso(), - lastError: payload.lastError ?? null, - oauth: { - ...current.oauth, - accessTokenId, - refreshTokenId, - expiresAt: payload.expiresAt ?? null, - lastAuthenticatedAt: nowIso(), - }, - }; - state.records[index] = nextRecord; - return nextRecord; - }); - - const exchangeOAuthCode = async (session: OAuthSessionState, code: string): Promise => { - const record = ensureOAuthRecord(session.authId); - const oauth = record.oauth!; - const body = new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: session.redirectUri, - client_id: oauth.clientId, - }); - const clientSecret = getSecret(oauth.clientSecretId); - if (clientSecret?.trim()) body.set("client_secret", clientSecret.trim()); - if (session.codeVerifier) body.set("code_verifier", session.codeVerifier); - if (oauth.audience?.trim()) body.set("audience", oauth.audience.trim()); - for (const [key, value] of Object.entries(oauth.extraTokenParams ?? {})) { - if (!body.has(key)) body.set(key, value); - } - - const response = await fetchImpl(oauth.tokenUrl, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: body.toString(), - }); - const payload = await response.json().catch(() => ({})) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - error?: string; - error_description?: string; - }; - - if (!response.ok || typeof payload.access_token !== "string" || !payload.access_token.trim()) { - throw new Error(payload.error_description ?? payload.error ?? `OAuth token exchange failed (HTTP ${response.status}).`); - } - - const expiresAt = - typeof payload.expires_in === "number" && Number.isFinite(payload.expires_in) - ? new Date(Date.now() + payload.expires_in * 1000).toISOString() - : null; - - saveOAuthTokenPayload(session.authId, { - accessToken: payload.access_token.trim(), - refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token.trim() : null, - expiresAt, - lastError: null, - }); - }; - - const refreshOAuthToken = async (authId: string): Promise => { - const record = ensureOAuthRecord(authId); - const oauth = record.oauth!; - const refreshToken = getSecret(oauth.refreshTokenId); - if (!refreshToken?.trim()) { - throw new Error("OAuth refresh token is missing. Reconnect the account."); - } - const body = new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken.trim(), - client_id: oauth.clientId, - }); - const clientSecret = getSecret(oauth.clientSecretId); - if (clientSecret?.trim()) body.set("client_secret", clientSecret.trim()); - if (oauth.audience?.trim()) body.set("audience", oauth.audience.trim()); - for (const [key, value] of Object.entries(oauth.extraTokenParams ?? {})) { - if (!body.has(key)) body.set(key, value); - } - const response = await fetchImpl(oauth.tokenUrl, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: body.toString(), - }); - const payload = await response.json().catch(() => ({})) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - error?: string; - error_description?: string; - }; - if (!response.ok || typeof payload.access_token !== "string" || !payload.access_token.trim()) { - mutateState((state) => { - const index = state.records.findIndex((entry) => entry.id === authId); - if (index >= 0) { - state.records[index] = { - ...state.records[index]!, - updatedAt: nowIso(), - lastError: payload.error_description ?? payload.error ?? `OAuth refresh failed (HTTP ${response.status}).`, - }; - } - }); - throw new Error(payload.error_description ?? payload.error ?? `OAuth refresh failed (HTTP ${response.status}).`); - } - const expiresAt = - typeof payload.expires_in === "number" && Number.isFinite(payload.expires_in) - ? new Date(Date.now() + payload.expires_in * 1000).toISOString() - : null; - return saveOAuthTokenPayload(authId, { - accessToken: payload.access_token.trim(), - refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token.trim() : null, - expiresAt, - lastError: null, - }); - }; - - const materializeAuthBinding = async ( - binding?: ExternalMcpManagedAuthConfig | null, - ): Promise<{ - headers?: Record; - env?: Record; - status: ExternalConnectionAuthStatus; - }> => { - if (!binding) { - return { - status: { - mode: "none", - state: "ready", - summary: "No managed auth configured.", - materializationPreview: [], - }, - }; - } - - const record = getRecordById(binding.authId); - if (!record) { - return { - status: { - authId: binding.authId, - mode: binding.mode, - state: "missing", - summary: "Managed auth reference is missing.", - materializationPreview: buildPreview(binding.placement, binding.mode), - }, - }; - } - - const placement = binding.placement; - const preview = buildPreview(placement, record.mode); - - if (record.mode === "none") { - return { - status: { - authId: record.id, - mode: record.mode, - state: "ready", - summary: "No auth required.", - materializationPreview: preview, - updatedAt: record.updatedAt, - }, - }; - } - - if (record.mode === "api_key" || record.mode === "bearer") { - const secret = getSecret(record.secretId); - if (!secret?.trim()) { - return { - status: { - authId: record.id, - mode: record.mode, - state: "needs_auth", - summary: "Stored credential is missing.", - materializationPreview: preview, - updatedAt: record.updatedAt, - lastError: record.lastError ?? null, - }, - }; - } - const prefix = placement.prefix ?? (record.mode === "bearer" ? "Bearer " : ""); - const value = `${prefix}${secret.trim()}`; - return { - ...(placement.target === "header" - ? { headers: { [placement.key]: value } } - : { env: { [placement.key]: value } }), - status: { - authId: record.id, - mode: record.mode, - state: "ready", - summary: "Stored credential is ready.", - materializationPreview: preview, - updatedAt: record.updatedAt, - lastError: record.lastError ?? null, - }, - }; - } - - const oauth = record.oauth; - if (!oauth) { - return { - status: { - authId: record.id, - mode: "oauth", - state: "needs_auth", - summary: "OAuth settings are incomplete.", - materializationPreview: preview, - updatedAt: record.updatedAt, - lastError: record.lastError ?? null, - }, - }; - } - - let effectiveRecord = record; - let accessToken = getSecret(oauth.accessTokenId); - if ((!accessToken?.trim() || isExpired(oauth.expiresAt)) && getSecret(oauth.refreshTokenId)) { - effectiveRecord = await refreshOAuthToken(record.id); - accessToken = getSecret(effectiveRecord.oauth?.accessTokenId); - } - if (!accessToken?.trim()) { - return { - status: { - authId: effectiveRecord.id, - mode: effectiveRecord.mode, - state: isExpired(effectiveRecord.oauth?.expiresAt) ? "expired" : "needs_auth", - summary: "OAuth account is not connected.", - materializationPreview: preview, - lastAuthenticatedAt: effectiveRecord.oauth?.lastAuthenticatedAt ?? null, - expiresAt: effectiveRecord.oauth?.expiresAt ?? null, - updatedAt: effectiveRecord.updatedAt, - lastError: effectiveRecord.lastError ?? null, - }, - }; - } - - const prefix = placement.prefix ?? "Bearer "; - const value = `${prefix}${accessToken.trim()}`; - return { - ...(placement.target === "header" - ? { headers: { [placement.key]: value } } - : { env: { [placement.key]: value } }), - status: { - authId: effectiveRecord.id, - mode: effectiveRecord.mode, - state: "ready", - summary: "Connected account is ready.", - materializationPreview: preview, - lastAuthenticatedAt: effectiveRecord.oauth?.lastAuthenticatedAt ?? null, - expiresAt: effectiveRecord.oauth?.expiresAt ?? null, - updatedAt: effectiveRecord.updatedAt, - lastError: effectiveRecord.lastError ?? null, - }, - }; - }; - - const saveAuthRecord = (input: ExternalConnectionAuthRecordInput): ExternalConnectionAuthRecord => mutateState((state) => { - const id = input.id?.trim() || `ext-auth-${randomUUID()}`; - const displayName = input.displayName.trim(); - if (!displayName.length) throw new Error("Display name is required."); - const existingIndex = state.records.findIndex((entry) => entry.id === id); - const existing = existingIndex >= 0 ? state.records[existingIndex]! : null; - const createdAt = existing?.createdAt ?? nowIso(); - const updatedAt = nowIso(); - const next: ExternalConnectionAuthRecord = { - id, - displayName, - mode: input.mode, - secretId: existing?.secretId ?? null, - createdAt, - updatedAt, - lastError: existing?.lastError ?? null, - }; - - if (input.mode === "api_key" || input.mode === "bearer") { - const secretId = existing?.secretId ?? `${id}:secret`; - const secret = input.secret?.trim() ?? ""; - if (secret.length) { - state.secrets[secretId] = secret; - } else if (!(secretId in state.secrets)) { - next.lastError = "Credential not set yet."; - } - next.secretId = secretId; - } else if (input.mode === "oauth") { - const oauthInput = input.oauth; - if (!oauthInput) throw new Error("OAuth settings are required."); - const existingOAuth = existing?.oauth; - const clientSecret = oauthInput.clientSecret?.trim() ?? ""; - let clientSecretId = existingOAuth?.clientSecretId ?? null; - if (clientSecret.length) { - clientSecretId = clientSecretId ?? `${id}:client-secret`; - state.secrets[clientSecretId] = clientSecret; - } - next.oauth = { - authorizeUrl: oauthInput.authorizeUrl.trim(), - tokenUrl: oauthInput.tokenUrl.trim(), - clientId: oauthInput.clientId.trim(), - scope: oauthInput.scope?.trim() ? oauthInput.scope.trim() : null, - audience: oauthInput.audience?.trim() ? oauthInput.audience.trim() : null, - extraAuthorizeParams: normalizeStringMap(oauthInput.extraAuthorizeParams), - extraTokenParams: normalizeStringMap(oauthInput.extraTokenParams), - clientSecretId, - accessTokenId: existingOAuth?.accessTokenId ?? `${id}:access-token`, - refreshTokenId: existingOAuth?.refreshTokenId ?? `${id}:refresh-token`, - expiresAt: existingOAuth?.expiresAt ?? null, - lastAuthenticatedAt: existingOAuth?.lastAuthenticatedAt ?? null, - }; - } - - if (existingIndex >= 0) state.records[existingIndex] = next; - else state.records.push(next); - state.records.sort((a, b) => a.displayName.localeCompare(b.displayName)); - return next; - }); - - return { - listRecords(): ExternalConnectionAuthRecord[] { - return [...readState().records].sort((a, b) => a.displayName.localeCompare(b.displayName)); - }, - - getRecord(authId: string): ExternalConnectionAuthRecord | null { - return getRecordById(authId); - }, - - saveRecord(input: ExternalConnectionAuthRecordInput): ExternalConnectionAuthRecord { - return saveAuthRecord(input); - }, - - removeRecord(authId: string): ExternalConnectionAuthRecord[] { - return mutateState((state) => { - const record = state.records.find((entry) => entry.id === authId) ?? null; - state.records = state.records.filter((entry) => entry.id !== authId); - if (record?.secretId) delete state.secrets[record.secretId]; - if (record?.oauth?.clientSecretId) delete state.secrets[record.oauth.clientSecretId]; - if (record?.oauth?.accessTokenId) delete state.secrets[record.oauth.accessTokenId]; - if (record?.oauth?.refreshTokenId) delete state.secrets[record.oauth.refreshTokenId]; - return [...state.records].sort((a, b) => a.displayName.localeCompare(b.displayName)); - }); - }, - - async getStatusForBinding(binding?: ExternalMcpManagedAuthConfig | null): Promise { - try { - const materialized = await materializeAuthBinding(binding); - return materialized.status; - } catch (error: unknown) { - return { - authId: binding?.authId ?? null, - mode: binding?.mode ?? "none", - state: "error", - summary: getErrorMessage(error), - materializationPreview: buildPreview(binding?.placement, binding?.mode ?? "none"), - }; - } - }, - - async getBindingSignature(binding?: ExternalMcpManagedAuthConfig | null): Promise { - const record = binding?.authId ? getRecordById(binding.authId) : null; - const updatedAt = record?.updatedAt ?? "none"; - const expiresAt = record?.oauth?.expiresAt ?? ""; - return JSON.stringify({ - authId: binding?.authId ?? null, - mode: binding?.mode ?? "none", - placement: binding?.placement ?? null, - updatedAt, - expiresAt, - }); - }, - - async materializeBinding(binding?: ExternalMcpManagedAuthConfig | null) { - return materializeAuthBinding(binding); - }, - - async startOAuthSession(authId: string): Promise { - pruneExpiredSessions(); - for (const session of sessions.values()) { - if (session.authId === authId && session.status === "pending") { - finalizeSession(session, { status: "expired", error: "Superseded by a new OAuth attempt." }); - } - } - const record = ensureOAuthRecord(authId); - const oauth = record.oauth!; - if (!oauth.authorizeUrl.trim() || !oauth.tokenUrl.trim() || !oauth.clientId.trim()) { - throw new Error("OAuth settings are incomplete."); - } - const sessionId = `ext-mcp-oauth-${randomUUID()}`; - const state = randomUUID(); - const clientSecret = getSecret(oauth.clientSecretId); - const pkce = clientSecret?.trim() ? null : createPkcePair(); - - let session: OAuthSessionState | null = null; - const server = http.createServer(async (req, res) => { - if (!session) { - res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); - res.end("OAuth session not ready."); - return; - } - try { - const requestUrl = new URL(req.url ?? OAUTH_CALLBACK_PATH, session.redirectUri); - const returnedState = requestUrl.searchParams.get("state"); - const code = requestUrl.searchParams.get("code"); - const error = requestUrl.searchParams.get("error"); - const errorDescription = requestUrl.searchParams.get("error_description"); - - if (returnedState !== session.state) { - finalizeSession(session, { status: "failed", error: "OAuth callback state mismatch." }); - res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }); - res.end("OAuth state mismatch."); - return; - } - if (error) { - finalizeSession(session, { status: "failed", error: errorDescription ?? error }); - res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }); - res.end("Authorization was declined."); - return; - } - if (!code) { - finalizeSession(session, { status: "failed", error: "Missing authorization code." }); - res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }); - res.end("Missing authorization code."); - return; - } - await exchangeOAuthCode(session, code); - finalizeSession(session, { status: "completed" }); - res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); - res.end("External MCP connected. You can close this window and return to ADE."); - } catch (error: unknown) { - const message = getErrorMessage(error); - finalizeSession(session, { status: "failed", error: message }); - args.logger?.warn("external_auth.oauth_callback_failed", { authId, error: message }); - res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); - res.end(message); - } - }); - - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - server.off("error", reject); - resolve(); - }); - }); - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - throw new Error("Failed to allocate a loopback port for OAuth."); - } - - const redirectUri = `http://127.0.0.1:${address.port}${OAUTH_CALLBACK_PATH}`; - const authUrl = new URL(oauth.authorizeUrl); - authUrl.searchParams.set("client_id", oauth.clientId); - authUrl.searchParams.set("redirect_uri", redirectUri); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("state", state); - if (oauth.scope?.trim()) authUrl.searchParams.set("scope", oauth.scope.trim()); - if (oauth.audience?.trim()) authUrl.searchParams.set("audience", oauth.audience.trim()); - for (const [key, value] of Object.entries(oauth.extraAuthorizeParams ?? {})) { - authUrl.searchParams.set(key, value); - } - if (pkce) { - authUrl.searchParams.set("code_challenge_method", "S256"); - authUrl.searchParams.set("code_challenge", pkce.challenge); - } - - session = { - id: sessionId, - authId, - state, - redirectUri, - authUrl: authUrl.toString(), - codeVerifier: pkce?.verifier ?? null, - createdAt: Date.now(), - status: "pending", - error: null, - server, - }; - sessions.set(sessionId, session); - - mutateState((stateDoc) => { - const index = stateDoc.records.findIndex((entry) => entry.id === authId); - if (index >= 0) { - stateDoc.records[index] = { - ...stateDoc.records[index]!, - updatedAt: nowIso(), - lastError: null, - }; - } - }); - - return { - sessionId, - authId, - authUrl: session.authUrl, - redirectUri, - }; - }, - - getOAuthSession(sessionId: string): ExternalConnectionOAuthSessionResult { - pruneExpiredSessions(); - const session = sessions.get(sessionId); - if (!session) { - return { - authId: "", - status: "expired", - error: "OAuth session not found or already expired.", - }; - } - return { - authId: session.authId, - status: session.status, - error: session.error, - }; - }, - - dispose(): void { - for (const session of sessions.values()) { - try { - session.server.close(); - } catch { - // best effort - } - } - sessions.clear(); - }, - }; -} - -export type ExternalConnectionAuthService = ReturnType; diff --git a/apps/desktop/src/main/services/externalMcp/externalMcpService.ts b/apps/desktop/src/main/services/externalMcp/externalMcpService.ts deleted file mode 100644 index e1105e48f..000000000 --- a/apps/desktop/src/main/services/externalMcp/externalMcpService.ts +++ /dev/null @@ -1,1257 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import YAML from "yaml"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import type { Logger } from "../logging/logger"; -import type { AdeDb } from "../state/kvDb"; -import type { - CtoIdentity, - ExternalConnectionAuthPlacement, - ExternalConnectionAuthStatus, - ExternalMcpAccessPolicy, - ExternalMcpConnectionState, - ExternalMcpEventPayload, - ExternalMcpManagedAuthConfig, - ExternalMcpMissionSelection, - ExternalMcpResolvedServerConfig, - ExternalMcpServerConfig, - ExternalMcpServerSnapshot, - ExternalMcpToolManifest, - ExternalMcpToolSafety, - ExternalMcpUsageEvent, -} from "../../../shared/types"; -import { nowIso, stableStringify, writeTextAtomic } from "../shared/utils"; -import type { ExternalConnectionAuthService } from "./externalConnectionAuthService"; -import type { WorkerAgentService } from "../cto/workerAgentService"; -import type { createCtoStateService } from "../cto/ctoStateService"; -import type { createMissionService } from "../missions/missionService"; -import type { MissionBudgetService } from "../orchestrator/missionBudgetService"; -import type { createWorkerBudgetService } from "../cto/workerBudgetService"; - -const DEFAULT_HEALTH_CHECK_INTERVAL_SEC = 30; -const MAX_RECONNECT_BACKOFF_MS = 60_000; -const MIN_RECONNECT_BACKOFF_MS = 2_000; -const CONFIG_RELOAD_DEBOUNCE_MS = 2_000; -const USAGE_EVENT_CAPACITY = 1_000; - -const ENV_TOKEN = /\$\{env:([A-Z0-9_]+)\}/gi; -const BARE_ENV_TOKEN = /\$\{([A-Z0-9_]+)\}/g; - -type ExternalMcpSessionIdentity = { - callerId: string; - role: "cto" | "orchestrator" | "agent" | "external" | "evaluator"; - chatSessionId: string | null; - missionId: string | null; - runId: string | null; - stepId: string | null; - attemptId: string | null; - ownerId: string | null; -}; - -type ExternalMcpServiceArgs = { - projectRoot: string; - adeDir: string; - db?: AdeDb | null; - projectId?: string | null; - logger?: Logger | null; - workerAgentService?: WorkerAgentService | null; - ctoStateService?: ReturnType | null; - missionService?: ReturnType | null; - workerBudgetService?: ReturnType | null; - missionBudgetService?: MissionBudgetService | null; - authService?: ExternalConnectionAuthService | null; - onEvent?: ((event: ExternalMcpEventPayload) => void) | null; -}; - -type ClientTransport = StdioClientTransport | StreamableHTTPClientTransport; - -type RuntimeServerState = { - rawConfig: ExternalMcpServerConfig; - resolvedConfig: ExternalMcpResolvedServerConfig; - state: ExternalMcpConnectionState; - client: Client | null; - transport: ClientTransport | null; - toolMap: Map; - lastConnectedAt: string | null; - lastHealthCheckAt: string | null; - consecutivePingFailures: number; - lastError: string | null; - reconnectAttempt: number; - reconnectTimer: ReturnType | null; - healthTimer: ReturnType | null; - signature: string; - autoStart: boolean; - authStatus: ExternalConnectionAuthStatus | null; -}; - -type PersistedUsageRow = { - id: string; - server_name: string; - tool_name: string; - namespaced_tool_name: string; - safety: string; - caller_role: string; - caller_id: string; - chat_session_id: string | null; - mission_id: string | null; - run_id: string | null; - step_id: string | null; - attempt_id: string | null; - owner_id: string | null; - cost_cents: number; - estimated: number; - occurred_at: string; -}; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function asTrimmedString(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - -function asStringMap(value: unknown, resolveEnv = false): Record | undefined { - if (!isRecord(value)) return undefined; - const out: Record = {}; - for (const [key, rawValue] of Object.entries(value)) { - if (typeof rawValue !== "string") continue; - const trimmed = rawValue.trim(); - if (!trimmed.length) continue; - out[key] = resolveEnv ? resolveEnvTokens(trimmed) : trimmed; - } - return Object.keys(out).length > 0 ? out : undefined; -} - -function asStringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined; - const out = value - .map((entry) => String(entry ?? "").trim()) - .filter((entry) => entry.length > 0); - return out.length > 0 ? out : undefined; -} - -function normalizeAuthPlacement(value: unknown): ExternalConnectionAuthPlacement | undefined { - if (!isRecord(value)) return undefined; - const target = asTrimmedString(value.target).toLowerCase(); - const key = asTrimmedString(value.key); - if ((target !== "header" && target !== "env") || !key.length) return undefined; - return { - target, - key, - ...(asTrimmedString(value.prefix) ? { prefix: asTrimmedString(value.prefix) } : {}), - }; -} - -function normalizeManagedAuth(value: unknown): ExternalMcpManagedAuthConfig | undefined { - if (!isRecord(value)) return undefined; - const authId = asTrimmedString(value.authId); - const mode = asTrimmedString(value.mode).toLowerCase(); - const placement = normalizeAuthPlacement(value.placement); - if (!authId.length || !placement) return undefined; - if (mode !== "none" && mode !== "api_key" && mode !== "bearer" && mode !== "oauth") return undefined; - return { - authId, - mode, - placement, - }; -} - -function parseDurationSeconds(value: unknown, fallback = DEFAULT_HEALTH_CHECK_INTERVAL_SEC): number { - if (typeof value === "number" && Number.isFinite(value)) { - return Math.max(5, Math.floor(value)); - } - if (typeof value === "string") { - const trimmed = value.trim(); - const match = /^(\d+)\s*(ms|s|m|h)?$/i.exec(trimmed); - if (match) { - const amount = Number(match[1]); - const unit = (match[2] ?? "s").toLowerCase(); - if (unit === "ms") return Math.max(5, Math.ceil(amount / 1000)); - if (unit === "m") return Math.max(5, amount * 60); - if (unit === "h") return Math.max(5, amount * 3600); - return Math.max(5, amount); - } - } - return fallback; -} - -function resolveEnvTokens(value: string): string { - const replace = (input: string, pattern: RegExp, withPrefix: boolean): string => - input.replace(pattern, (_full, envName: string) => { - const resolved = process.env[envName]; - if (typeof resolved !== "string" || resolved.length === 0) { - const token = withPrefix ? `env:${envName}` : envName; - throw new Error(`Missing required environment variable '${token}'.`); - } - return resolved; - }); - - return replace(replace(value, ENV_TOKEN, true), BARE_ENV_TOKEN, false); -} - -function normalizeAccessPolicy(policy: unknown, allowAllDefault: boolean): ExternalMcpAccessPolicy { - const source = isRecord(policy) ? policy : {}; - return { - allowAll: typeof source.allowAll === "boolean" ? source.allowAll : allowAllDefault, - allowedServers: asStringArray(source.allowedServers) ?? [], - blockedServers: asStringArray(source.blockedServers) ?? [], - }; -} - -function normalizeMissionSelection(value: unknown): ExternalMcpMissionSelection | undefined { - if (!isRecord(value)) return undefined; - const enabled = typeof value.enabled === "boolean" ? value.enabled : undefined; - const selectedServers = asStringArray(value.selectedServers); - const selectedTools = asStringArray(value.selectedTools); - if (enabled == null && !selectedServers?.length && !selectedTools?.length) return undefined; - return { - ...(enabled != null ? { enabled } : {}), - ...(selectedServers?.length ? { selectedServers } : {}), - ...(selectedTools?.length ? { selectedTools } : {}), - }; -} - -function classifyToolSafety(tool: { - name: string; - description?: string; - annotations?: Record; -}): ExternalMcpToolSafety { - const annotations = isRecord(tool.annotations) ? tool.annotations : {}; - if (annotations.destructiveHint === true) return "write"; - if (annotations.readOnlyHint === true) return "read"; - const text = `${tool.name} ${tool.description ?? ""}`.toLowerCase(); - if (/(create|update|delete|write|send|post|put|patch|commit|push|publish|archive|remove|edit|merge|deploy|execute)/.test(text)) { - return "write"; - } - if (/(read|list|get|search|find|query|fetch|show|lookup|inspect)/.test(text)) { - return "read"; - } - return "unknown"; -} - -function resolveCostCents(config: ExternalMcpServerConfig, toolName: string): { costCents: number; estimated: boolean } { - const perTool = config.costHints?.perToolCostCents ?? {}; - const exact = Number(perTool[toolName]); - if (Number.isFinite(exact) && exact >= 0) { - return { costCents: Math.floor(exact), estimated: false }; - } - const fallback = Number(config.costHints?.defaultCostCents ?? 0); - return { - costCents: Number.isFinite(fallback) && fallback > 0 ? Math.floor(fallback) : 0, - estimated: true, - }; -} - -function sortSnapshots(entries: ExternalMcpServerSnapshot[]): ExternalMcpServerSnapshot[] { - return [...entries].sort((a, b) => a.config.name.localeCompare(b.config.name)); -} - -function sortTools(entries: ExternalMcpToolManifest[]): ExternalMcpToolManifest[] { - return [...entries].sort((a, b) => a.namespacedName.localeCompare(b.namespacedName)); -} - -function normalizeServerConfig(raw: unknown): ExternalMcpServerConfig | null { - if (!isRecord(raw)) return null; - const name = asTrimmedString(raw.name); - if (!name.length) return null; - const transportRaw = asTrimmedString(raw.transport).toLowerCase(); - const transport = - transportRaw === "stdio" || transportRaw === "http" || transportRaw === "sse" - ? transportRaw - : (asTrimmedString(raw.command).length ? "stdio" : "http"); - const base: ExternalMcpServerConfig = { - name, - transport, - ...(typeof raw.autoStart === "boolean" ? { autoStart: raw.autoStart } : {}), - healthCheckIntervalSec: parseDurationSeconds( - raw.healthCheckIntervalSec ?? raw.healthCheckInterval ?? raw.healthIntervalSec, - DEFAULT_HEALTH_CHECK_INTERVAL_SEC, - ), - ...(normalizeManagedAuth(raw.auth) ? { auth: normalizeManagedAuth(raw.auth) } : {}), - }; - - if (transport === "stdio") { - const command = asTrimmedString(raw.command); - if (!command.length) throw new Error(`externalMcp.${name} requires a command for stdio transport.`); - return { - ...base, - command, - ...(asStringArray(raw.args) ? { args: asStringArray(raw.args) } : {}), - ...(asStringMap(raw.env) ? { env: asStringMap(raw.env) } : {}), - ...(asTrimmedString(raw.cwd) ? { cwd: asTrimmedString(raw.cwd) } : {}), - ...(normalizePermissionConfig(raw.permissions) ? { permissions: normalizePermissionConfig(raw.permissions) } : {}), - ...(normalizeCostHints(raw.costHints) ? { costHints: normalizeCostHints(raw.costHints) } : {}), - }; - } - - const url = asTrimmedString(raw.url); - if (!url.length) throw new Error(`externalMcp.${name} requires a url for http/sse transport.`); - return { - ...base, - url, - ...(asStringMap(raw.headers) ? { headers: asStringMap(raw.headers) } : {}), - ...(normalizePermissionConfig(raw.permissions) ? { permissions: normalizePermissionConfig(raw.permissions) } : {}), - ...(normalizeCostHints(raw.costHints) ? { costHints: normalizeCostHints(raw.costHints) } : {}), - }; -} - -function normalizePermissionConfig(value: unknown): ExternalMcpServerConfig["permissions"] | undefined { - if (!isRecord(value)) return undefined; - const allowedTools = asStringArray(value.allowedTools); - const blockedTools = asStringArray(value.blockedTools); - if (!allowedTools?.length && !blockedTools?.length) return undefined; - return { - ...(allowedTools?.length ? { allowedTools } : {}), - ...(blockedTools?.length ? { blockedTools } : {}), - }; -} - -function normalizeCostHints(value: unknown): ExternalMcpServerConfig["costHints"] | undefined { - if (!isRecord(value)) return undefined; - const defaultCostCents = Number(value.defaultCostCents); - const perToolCostCents = isRecord(value.perToolCostCents) - ? Object.fromEntries( - Object.entries(value.perToolCostCents) - .map(([key, rawValue]) => [key, Number(rawValue)] as const) - .filter(([, rawValue]) => Number.isFinite(rawValue) && rawValue >= 0) - .map(([key, rawValue]) => [key, Math.floor(rawValue)] as const), - ) - : undefined; - if (!Number.isFinite(defaultCostCents) && !perToolCostCents) return undefined; - return { - ...(Number.isFinite(defaultCostCents) ? { defaultCostCents: Math.floor(defaultCostCents) } : {}), - ...(perToolCostCents && Object.keys(perToolCostCents).length > 0 ? { perToolCostCents } : {}), - }; -} - -function resolveRuntimeConfig(config: ExternalMcpServerConfig): ExternalMcpResolvedServerConfig { - if (config.transport === "stdio") { - return { - ...config, - transport: "stdio", - env: config.env ? Object.fromEntries(Object.entries(config.env).map(([key, value]) => [key, resolveEnvTokens(value)])) : undefined, - }; - } - - return { - ...config, - transport: "http", - headers: config.headers ? Object.fromEntries(Object.entries(config.headers).map(([key, value]) => [key, resolveEnvTokens(value)])) : undefined, - url: config.url ? resolveEnvTokens(config.url) : config.url, - }; -} - -function mergeRecordMaps( - base?: Record, - extra?: Record, -): Record | undefined { - const next = { ...(base ?? {}), ...(extra ?? {}) }; - return Object.keys(next).length > 0 ? next : undefined; -} - -function toSignature(config: ExternalMcpResolvedServerConfig, authSignature = ""): string { - return stableStringify({ - name: config.name, - transport: config.transport, - command: config.command, - args: config.args, - env: config.env, - cwd: config.cwd, - url: config.url, - headers: config.headers, - healthCheckIntervalSec: config.healthCheckIntervalSec, - auth: config.auth, - authSignature, - }); -} - -function toManifest(serverName: string, tool: { - name: string; - description?: string; - inputSchema: Record; - outputSchema?: Record; - annotations?: Record; - title?: string; -}): ExternalMcpToolManifest { - const annotations = isRecord(tool.annotations) ? tool.annotations : {}; - return { - serverName, - name: tool.name, - namespacedName: `ext.${serverName}.${tool.name}`, - description: tool.description, - inputSchema: isRecord(tool.inputSchema) ? tool.inputSchema : { type: "object", properties: {} }, - ...(isRecord(tool.outputSchema) ? { outputSchema: tool.outputSchema } : {}), - safety: classifyToolSafety(tool), - enabled: true, - ...(tool.title ? { title: tool.title } : {}), - ...(annotations.readOnlyHint === true ? { readOnlyHint: true } : {}), - ...(annotations.destructiveHint === true ? { destructiveHint: true } : {}), - }; -} - -export function createExternalMcpService(args: ExternalMcpServiceArgs) { - const secretPath = path.join(args.adeDir, "local.secret.yaml"); - const authService = args.authService ?? null; - const runtimes = new Map(); - const usageEvents: ExternalMcpUsageEvent[] = []; - const listeners = new Set<(event: ExternalMcpEventPayload) => void>(); - let reloadTimer: ReturnType | null = null; - - const emit = (event: ExternalMcpEventPayload): void => { - args.onEvent?.(event); - for (const listener of listeners) { - listener(event); - } - }; - - const readPersistedUsageEvents = (limit: number): ExternalMcpUsageEvent[] => { - const db = args.db ?? null; - const projectId = args.projectId?.trim() ?? ""; - if (!db || !projectId.length) { - return usageEvents.slice(-Math.max(1, Math.min(limit, usageEvents.length))).reverse(); - } - const rows = db.all( - ` - select - id, - server_name, - tool_name, - namespaced_tool_name, - safety, - caller_role, - caller_id, - chat_session_id, - mission_id, - run_id, - step_id, - attempt_id, - owner_id, - cost_cents, - estimated, - occurred_at - from external_mcp_usage_events - where project_id = ? - order by occurred_at desc, created_at desc - limit ? - `, - [projectId, Math.max(1, Math.min(limit, 500))], - ); - return rows.map((row) => ({ - id: row.id, - serverName: row.server_name, - toolName: row.tool_name, - namespacedToolName: row.namespaced_tool_name, - safety: row.safety === "read" || row.safety === "write" ? row.safety : "unknown", - callerRole: - row.caller_role === "cto" - || row.caller_role === "orchestrator" - || row.caller_role === "agent" - || row.caller_role === "external" - || row.caller_role === "evaluator" - ? row.caller_role - : "external", - callerId: row.caller_id, - chatSessionId: row.chat_session_id, - missionId: row.mission_id, - runId: row.run_id, - stepId: row.step_id, - attemptId: row.attempt_id, - ownerId: row.owner_id, - costCents: Math.max(0, Math.floor(Number(row.cost_cents ?? 0))), - estimated: Number(row.estimated ?? 0) === 1, - occurredAt: row.occurred_at, - })); - }; - - const pushUsageEvent = (event: ExternalMcpUsageEvent): void => { - usageEvents.push(event); - while (usageEvents.length > USAGE_EVENT_CAPACITY) { - usageEvents.shift(); - } - }; - - const readSecretDocument = (): Record => { - if (!fs.existsSync(secretPath)) return {}; - const raw = fs.readFileSync(secretPath, "utf8"); - const parsed = YAML.parse(raw); - return isRecord(parsed) ? parsed : {}; - }; - - const writeSecretDocument = (doc: Record): void => { - writeTextAtomic(secretPath, YAML.stringify(doc, { indent: 2 })); - }; - - const readConfiguredServers = (): ExternalMcpServerConfig[] => { - const doc = readSecretDocument(); - const entries = Array.isArray(doc.externalMcp) ? doc.externalMcp : []; - const seen = new Set(); - const out: ExternalMcpServerConfig[] = []; - for (const entry of entries) { - const normalized = normalizeServerConfig(entry); - if (!normalized) continue; - if (seen.has(normalized.name)) { - throw new Error(`Duplicate external MCP server name '${normalized.name}'.`); - } - seen.add(normalized.name); - out.push(normalized); - } - return out.sort((a, b) => a.name.localeCompare(b.name)); - }; - - const resolveAuthSignature = async (config: ExternalMcpServerConfig): Promise => { - if (!authService) return ""; - try { - return await authService.getBindingSignature(config.auth); - } catch (error) { - args.logger?.warn("external_mcp.auth_signature_failed", { - serverName: config.name, - error: error instanceof Error ? error.message : String(error), - }); - return "auth:error"; - } - }; - - const resolveAuthBinding = async (config: ExternalMcpServerConfig): Promise<{ - headers?: Record; - env?: Record; - status: ExternalConnectionAuthStatus; - }> => { - if (!authService) { - return { - status: { - mode: config.auth?.mode ?? "none", - state: config.auth ? "missing" : "ready", - summary: config.auth ? "Managed auth service is unavailable." : "No managed auth configured.", - materializationPreview: [], - }, - }; - } - return authService.materializeBinding(config.auth); - }; - - const getAgentAccessPolicy = (identity: ExternalMcpSessionIdentity): ExternalMcpAccessPolicy => { - if (identity.role === "agent" && identity.ownerId) { - const agent = args.workerAgentService?.getAgent(identity.ownerId, { includeDeleted: false }) ?? null; - return normalizeAccessPolicy(agent?.externalMcpAccess, false); - } - const ctoIdentity = args.ctoStateService?.getIdentity?.() as CtoIdentity | undefined; - return normalizeAccessPolicy(ctoIdentity?.externalMcpAccess, true); - }; - - const getMissionSelection = (missionId: string | null): ExternalMcpMissionSelection | undefined => { - if (!missionId) return undefined; - const rawMetadata = args.missionService?.getMetadata?.(missionId); - const metadata = isRecord(rawMetadata) ? rawMetadata : null; - const launch = metadata && isRecord(metadata.launch) ? metadata.launch : null; - const permissionConfig = launch && isRecord(launch.permissionConfig) ? launch.permissionConfig : null; - return normalizeMissionSelection(permissionConfig?.externalMcp); - }; - - const isServerAllowed = (serverName: string, identity: ExternalMcpSessionIdentity): boolean => { - const access = getAgentAccessPolicy(identity); - if (access.blockedServers.includes(serverName)) return false; - if (!access.allowAll && !access.allowedServers.includes(serverName)) return false; - const missionSelection = getMissionSelection(identity.missionId); - if (missionSelection?.enabled === false) return false; - if (missionSelection?.selectedServers?.length && !missionSelection.selectedServers.includes(serverName)) { - return false; - } - return true; - }; - - const applyServerToolPermissions = ( - runtime: RuntimeServerState, - tools: ExternalMcpToolManifest[], - ): ExternalMcpToolManifest[] => { - const allowed = runtime.rawConfig.permissions?.allowedTools ?? []; - const blocked = runtime.rawConfig.permissions?.blockedTools ?? []; - return tools.map((tool) => { - if (blocked.includes(tool.name)) { - return { ...tool, enabled: false, disabledReason: "Blocked by server permissions." }; - } - if (allowed.length > 0 && !allowed.includes(tool.name)) { - return { ...tool, enabled: false, disabledReason: "Not included in the server allowlist." }; - } - return tool; - }); - }; - - const filterToolsForIdentity = ( - runtime: RuntimeServerState, - tools: ExternalMcpToolManifest[], - identity: ExternalMcpSessionIdentity, - ): ExternalMcpToolManifest[] => { - if (!isServerAllowed(runtime.resolvedConfig.name, identity)) return []; - const missionSelection = getMissionSelection(identity.missionId); - return tools.filter((tool) => { - if (!tool.enabled) return false; - if (missionSelection?.selectedTools?.length && !missionSelection.selectedTools.includes(tool.namespacedName)) { - return false; - } - return true; - }); - }; - - const clearHealthTimer = (runtime: RuntimeServerState): void => { - if (runtime.healthTimer) { - clearInterval(runtime.healthTimer); - runtime.healthTimer = null; - } - }; - - const clearReconnectTimer = (runtime: RuntimeServerState): void => { - if (runtime.reconnectTimer) { - clearTimeout(runtime.reconnectTimer); - runtime.reconnectTimer = null; - } - }; - - const emitServerState = (runtime: RuntimeServerState): void => { - emit({ - type: "server-state-changed", - at: nowIso(), - serverName: runtime.resolvedConfig.name, - state: runtime.state, - toolCount: runtime.toolMap.size, - }); - }; - - const emitToolsRefreshed = (runtime: RuntimeServerState): void => { - emit({ - type: "tools-refreshed", - at: nowIso(), - serverName: runtime.resolvedConfig.name, - state: runtime.state, - toolCount: runtime.toolMap.size, - }); - }; - - const disconnectRuntime = async (runtime: RuntimeServerState): Promise => { - runtime.state = "draining"; - emitServerState(runtime); - clearReconnectTimer(runtime); - clearHealthTimer(runtime); - const client = runtime.client; - const transport = runtime.transport; - runtime.client = null; - runtime.transport = null; - try { - await client?.close(); - } catch { - // Best effort - } - try { - if (transport instanceof StreamableHTTPClientTransport) { - await transport.terminateSession().catch(() => {}); - } - await transport?.close(); - } catch { - // Best effort - } - runtime.state = "disconnected"; - emitServerState(runtime); - }; - - const refreshTools = async (runtime: RuntimeServerState): Promise => { - if (!runtime.client) return; - let cursor: string | undefined; - const nextTools = new Map(); - do { - const result = await runtime.client.listTools(cursor ? { cursor } : undefined); - for (const tool of result.tools ?? []) { - const manifest = toManifest(runtime.resolvedConfig.name, tool); - nextTools.set(manifest.namespacedName, manifest); - } - cursor = typeof result.nextCursor === "string" && result.nextCursor.trim().length > 0 - ? result.nextCursor - : undefined; - } while (cursor); - - runtime.toolMap = new Map( - applyServerToolPermissions(runtime, [...nextTools.values()]).map((tool) => [tool.namespacedName, tool] as const), - ); - emitToolsRefreshed(runtime); - }; - - const scheduleReconnect = (runtime: RuntimeServerState): void => { - if (runtime.state === "draining") return; - clearReconnectTimer(runtime); - const delay = Math.min( - MAX_RECONNECT_BACKOFF_MS, - MIN_RECONNECT_BACKOFF_MS * Math.max(1, 2 ** Math.max(0, runtime.reconnectAttempt)), - ); - runtime.state = "reconnecting"; - emitServerState(runtime); - runtime.reconnectTimer = setTimeout(() => { - runtime.reconnectTimer = null; - void connectRuntime(runtime).catch(() => {}); - }, delay); - }; - - const startHealthChecks = (runtime: RuntimeServerState): void => { - clearHealthTimer(runtime); - runtime.healthTimer = setInterval(() => { - void (async () => { - if (!runtime.client) return; - try { - await runtime.client.ping(); - runtime.lastHealthCheckAt = nowIso(); - runtime.consecutivePingFailures = 0; - } catch (error) { - runtime.lastHealthCheckAt = nowIso(); - runtime.consecutivePingFailures += 1; - runtime.lastError = error instanceof Error ? error.message : String(error); - if (runtime.consecutivePingFailures >= 3) { - await disconnectRuntime(runtime); - scheduleReconnect(runtime); - } - } - })(); - }, Math.max(5, runtime.resolvedConfig.healthCheckIntervalSec ?? DEFAULT_HEALTH_CHECK_INTERVAL_SEC) * 1000); - }; - - const buildTransport = (config: ExternalMcpResolvedServerConfig): ClientTransport => { - if (config.transport === "stdio") { - return new StdioClientTransport({ - command: config.command!, - args: config.args ?? [], - env: config.env, - cwd: config.cwd, - stderr: "pipe", - }); - } - - const headers = config.headers ?? {}; - return new StreamableHTTPClientTransport(new URL(config.url!), { - requestInit: { - headers, - }, - }); - }; - - const resolveConnectedRuntimeConfig = async ( - config: ExternalMcpServerConfig, - ): Promise<{ - resolved: ExternalMcpResolvedServerConfig; - authStatus: ExternalConnectionAuthStatus; - }> => { - const base = resolveRuntimeConfig(config); - const auth = await resolveAuthBinding(config); - if (auth.status.state !== "ready") { - throw new Error(auth.status.lastError ?? auth.status.summary); - } - const resolved: ExternalMcpResolvedServerConfig = base.transport === "stdio" - ? { - ...base, - env: mergeRecordMaps(base.env, auth.env), - } - : { - ...base, - headers: mergeRecordMaps(base.headers, auth.headers), - }; - return { - resolved, - authStatus: auth.status, - }; - }; - - const connectRuntime = async (runtime: RuntimeServerState): Promise => { - clearReconnectTimer(runtime); - clearHealthTimer(runtime); - runtime.state = runtime.lastConnectedAt ? "reconnecting" : "connecting"; - emitServerState(runtime); - try { - const resolvedConfig = await resolveConnectedRuntimeConfig(runtime.rawConfig); - runtime.resolvedConfig = resolvedConfig.resolved; - runtime.authStatus = resolvedConfig.authStatus; - runtime.signature = toSignature( - resolvedConfig.resolved, - await resolveAuthSignature(runtime.rawConfig), - ); - const client = new Client( - { name: "ade", version: "0.0.0" }, - { - capabilities: {}, - listChanged: { - tools: { - onChanged: async (_error, _tools) => { - try { - await refreshTools(runtime); - } catch (error) { - runtime.lastError = error instanceof Error ? error.message : String(error); - } - }, - }, - }, - }, - ); - const transport = buildTransport(runtime.resolvedConfig); - transport.onerror = (error) => { - runtime.lastError = error instanceof Error ? error.message : String(error); - }; - transport.onclose = () => { - if (runtime.state === "draining" || runtime.state === "disconnected") return; - runtime.lastError = runtime.lastError ?? "Connection closed."; - void disconnectRuntime(runtime).finally(() => scheduleReconnect(runtime)); - }; - await client.connect(transport); - runtime.client = client; - runtime.transport = transport; - runtime.state = "connected"; - runtime.lastConnectedAt = nowIso(); - runtime.lastError = null; - runtime.reconnectAttempt = 0; - runtime.consecutivePingFailures = 0; - emitServerState(runtime); - await refreshTools(runtime); - startHealthChecks(runtime); - } catch (error) { - runtime.lastError = error instanceof Error ? error.message : String(error); - runtime.authStatus = await resolveAuthBinding(runtime.rawConfig).then((result) => result.status).catch(() => runtime.authStatus ?? null); - runtime.state = "failed"; - emitServerState(runtime); - const authBlocked = - runtime.authStatus?.state === "missing" - || runtime.authStatus?.state === "needs_auth" - || runtime.authStatus?.state === "expired" - || runtime.authStatus?.state === "error"; - if (!authBlocked) { - runtime.reconnectAttempt += 1; - scheduleReconnect(runtime); - } - throw error; - } - }; - - const createRuntimeState = ( - config: ExternalMcpServerConfig, - signature: string, - authStatus: ExternalConnectionAuthStatus | null, - existing?: RuntimeServerState | null, - ): RuntimeServerState => { - const resolvedConfig = resolveRuntimeConfig(config); - if (existing && existing.signature === signature) { - existing.rawConfig = config; - existing.resolvedConfig = resolvedConfig; - existing.autoStart = config.autoStart !== false; - existing.authStatus = authStatus; - return existing; - } - - const runtime: RuntimeServerState = existing ?? { - rawConfig: config, - resolvedConfig, - state: "disconnected", - client: null, - transport: null, - toolMap: new Map(), - lastConnectedAt: null, - lastHealthCheckAt: null, - consecutivePingFailures: 0, - lastError: null, - reconnectAttempt: 0, - reconnectTimer: null, - healthTimer: null, - signature, - autoStart: config.autoStart !== false, - authStatus, - }; - - runtime.rawConfig = config; - runtime.resolvedConfig = resolvedConfig; - runtime.signature = signature; - runtime.autoStart = config.autoStart !== false; - runtime.authStatus = authStatus; - return runtime; - }; - - const getOrCreateRuntime = ( - config: ExternalMcpServerConfig, - signature: string, - authStatus: ExternalConnectionAuthStatus | null, - ): RuntimeServerState => { - const runtime = createRuntimeState(config, signature, authStatus, runtimes.get(config.name)); - runtimes.set(config.name, runtime); - return runtime; - }; - - const reconcileNow = async (): Promise => { - const configs = readConfiguredServers(); - const seen = new Set(); - for (const config of configs) { - seen.add(config.name); - const existing = runtimes.get(config.name); - const baseResolved = resolveRuntimeConfig(config); - const authSignature = await resolveAuthSignature(config); - const authStatus = await resolveAuthBinding(config).then((result) => result.status).catch(() => null); - const nextSignature = toSignature(baseResolved, authSignature); - const signatureChanged = existing ? existing.signature !== nextSignature : false; - const runtime = getOrCreateRuntime(config, nextSignature, authStatus); - runtime.toolMap = new Map( - applyServerToolPermissions(runtime, [...runtime.toolMap.values()]).map((tool) => [tool.namespacedName, tool] as const), - ); - if (signatureChanged && runtime.client != null) { - await disconnectRuntime(runtime); - } - if (runtime.autoStart && (!runtime.client || runtime.state !== "connected")) { - try { - await connectRuntime(runtime); - } catch (error) { - args.logger?.warn("external_mcp.connect_failed", { - serverName: runtime.resolvedConfig.name, - error: error instanceof Error ? error.message : String(error), - }); - } - } - } - - for (const [name, runtime] of [...runtimes.entries()]) { - if (seen.has(name)) continue; - void disconnectRuntime(runtime).catch(() => {}); - runtimes.delete(name); - } - emit({ - type: "configs-changed", - at: nowIso(), - }); - }; - - const ensureRuntimeReady = async (serverName: string): Promise => { - const runtime = runtimes.get(serverName); - if (!runtime) throw new Error(`Unknown external MCP server '${serverName}'.`); - if (runtime.client && runtime.state === "connected") return runtime; - await connectRuntime(runtime); - return runtime; - }; - - const recordBudgetUsage = async ( - identity: ExternalMcpSessionIdentity, - serverName: string, - toolName: string, - safety: ExternalMcpToolSafety, - costCents: number, - estimated: boolean, - ): Promise => { - const event: ExternalMcpUsageEvent = { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, - serverName, - toolName, - namespacedToolName: `ext.${serverName}.${toolName}`, - safety, - callerRole: identity.role, - callerId: identity.callerId, - chatSessionId: identity.chatSessionId, - missionId: identity.missionId, - runId: identity.runId, - stepId: identity.stepId, - attemptId: identity.attemptId, - ownerId: identity.ownerId, - costCents, - estimated, - occurredAt: nowIso(), - }; - pushUsageEvent(event); - if (args.db && args.projectId) { - args.db.run( - ` - insert into external_mcp_usage_events ( - id, - project_id, - server_name, - tool_name, - namespaced_tool_name, - safety, - caller_role, - caller_id, - chat_session_id, - mission_id, - run_id, - step_id, - attempt_id, - owner_id, - cost_cents, - estimated, - occurred_at, - created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - event.id, - args.projectId, - event.serverName, - event.toolName, - event.namespacedToolName, - event.safety, - event.callerRole, - event.callerId, - event.chatSessionId ?? null, - event.missionId ?? null, - event.runId ?? null, - event.stepId ?? null, - event.attemptId ?? null, - event.ownerId ?? null, - event.costCents, - event.estimated ? 1 : 0, - event.occurredAt, - nowIso(), - ], - ); - } - emit({ - type: "usage-recorded", - at: event.occurredAt, - serverName: event.serverName, - usageEvent: event, - }); - - if (identity.ownerId && costCents > 0) { - args.workerBudgetService?.recordCostEvent({ - agentId: identity.ownerId, - runId: identity.runId, - sessionId: identity.attemptId ?? identity.runId, - provider: `external-mcp:${serverName}`, - modelId: toolName, - costCents, - estimated, - source: "manual", - }); - } - }; - - const assertBudgetsAllowCall = async (identity: ExternalMcpSessionIdentity): Promise => { - if (identity.ownerId && args.workerBudgetService) { - const snapshot = args.workerBudgetService.getBudgetSnapshot({}); - const worker = snapshot.workers.find((entry) => entry.agentId === identity.ownerId); - if (worker && worker.remainingCents != null && worker.remainingCents <= 0) { - throw new Error(`Worker budget exhausted for '${worker.name}'.`); - } - } - - if (identity.missionId && args.missionBudgetService) { - const snapshot = await args.missionBudgetService.getMissionBudgetStatus({ - missionId: identity.missionId, - ...(identity.runId ? { runId: identity.runId } : {}), - }); - if ( - snapshot.hardCaps.apiKeyTriggered - || snapshot.hardCaps.fiveHourTriggered - || snapshot.hardCaps.weeklyTriggered - || (snapshot.mission.remainingCostUsd != null && snapshot.mission.remainingCostUsd <= 0) - ) { - throw new Error("Mission budget is exhausted for external MCP calls."); - } - } - }; - - return { - async start(): Promise { - await reconcileNow(); - }, - - async dispose(): Promise { - if (reloadTimer) { - clearTimeout(reloadTimer); - reloadTimer = null; - } - await Promise.all([...runtimes.values()].map((runtime) => disconnectRuntime(runtime))); - runtimes.clear(); - }, - - reload(): void { - if (reloadTimer) clearTimeout(reloadTimer); - reloadTimer = setTimeout(() => { - reloadTimer = null; - void reconcileNow().catch((error) => { - args.logger?.warn("external_mcp.reload_failed", { - error: error instanceof Error ? error.message : String(error), - }); - }); - }, CONFIG_RELOAD_DEBOUNCE_MS); - }, - - async listToolsForIdentity(identity: ExternalMcpSessionIdentity): Promise { - const allowedNames = [...runtimes.keys()].filter((serverName) => isServerAllowed(serverName, identity)); - for (const serverName of allowedNames) { - try { - await ensureRuntimeReady(serverName); - } catch (error) { - args.logger?.warn("external_mcp.list_tools_connect_failed", { - serverName, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - const allTools: ExternalMcpToolManifest[] = []; - for (const runtime of runtimes.values()) { - const filtered = filterToolsForIdentity(runtime, [...runtime.toolMap.values()], identity); - allTools.push(...filtered); - } - return sortTools(allTools); - }, - - async callTool( - identity: ExternalMcpSessionIdentity, - namespacedToolName: string, - toolArgs: Record, - ): Promise> { - await assertBudgetsAllowCall(identity); - const runtime = [...runtimes.values()].find((entry) => entry.toolMap.has(namespacedToolName)); - if (!runtime) { - throw new Error(`External MCP tool '${namespacedToolName}' is unavailable.`); - } - const manifest = runtime.toolMap.get(namespacedToolName)!; - if (!isServerAllowed(runtime.resolvedConfig.name, identity)) { - throw new Error(`External MCP server '${runtime.resolvedConfig.name}' is blocked for this identity.`); - } - const missionSelection = getMissionSelection(identity.missionId); - if (missionSelection?.selectedTools?.length && !missionSelection.selectedTools.includes(namespacedToolName)) { - throw new Error(`External MCP tool '${namespacedToolName}' is not approved for this mission.`); - } - if (!manifest.enabled) { - throw new Error(manifest.disabledReason ?? `External MCP tool '${namespacedToolName}' is disabled.`); - } - - const ready = await ensureRuntimeReady(runtime.resolvedConfig.name); - const result = await ready.client!.callTool({ - name: manifest.name, - arguments: toolArgs, - }); - - const { costCents, estimated } = resolveCostCents(ready.rawConfig, manifest.name); - await recordBudgetUsage(identity, ready.resolvedConfig.name, manifest.name, manifest.safety, costCents, estimated); - - return { - ok: result.isError !== true, - serverName: ready.resolvedConfig.name, - toolName: manifest.name, - namespacedToolName, - result, - }; - }, - - getSnapshots(): ExternalMcpServerSnapshot[] { - return sortSnapshots( - [...runtimes.values()].map((runtime) => ({ - config: runtime.resolvedConfig, - state: runtime.state, - toolCount: runtime.toolMap.size, - tools: sortTools([...runtime.toolMap.values()]), - lastConnectedAt: runtime.lastConnectedAt, - lastHealthCheckAt: runtime.lastHealthCheckAt, - consecutivePingFailures: runtime.consecutivePingFailures, - lastError: runtime.lastError, - autoStart: runtime.autoStart, - authStatus: runtime.authStatus ?? undefined, - })), - ); - }, - - getRawConfigs(): ExternalMcpServerConfig[] { - return readConfiguredServers(); - }, - - getUsageEvents(limit = 100): ExternalMcpUsageEvent[] { - return readPersistedUsageEvents(limit); - }, - - onEvent(listener: (event: ExternalMcpEventPayload) => void): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - - async connectServer(serverName: string): Promise { - const runtime = await ensureRuntimeReady(serverName); - return this.getSnapshots().find((entry) => entry.config.name === runtime.resolvedConfig.name)!; - }, - - async disconnectServer(serverName: string): Promise { - const runtime = runtimes.get(serverName); - if (!runtime) return null; - await disconnectRuntime(runtime); - return this.getSnapshots().find((entry) => entry.config.name === serverName) ?? null; - }, - - async testServer(config: ExternalMcpServerConfig): Promise { - const normalized = normalizeServerConfig(config); - if (!normalized) throw new Error("Invalid external MCP server config."); - const existing = runtimes.get(normalized.name) ?? null; - const nextSignature = toSignature( - resolveRuntimeConfig(normalized), - await resolveAuthSignature(normalized), - ); - const reuseExisting = existing != null && existing.signature === nextSignature; - const runtime = createRuntimeState( - normalized, - nextSignature, - await resolveAuthBinding(normalized).then((result) => result.status).catch(() => null), - reuseExisting ? existing : null, - ); - try { - await connectRuntime(runtime); - return { - config: runtime.resolvedConfig, - state: runtime.state, - toolCount: runtime.toolMap.size, - tools: sortTools([...runtime.toolMap.values()]), - lastConnectedAt: runtime.lastConnectedAt, - lastHealthCheckAt: runtime.lastHealthCheckAt, - consecutivePingFailures: runtime.consecutivePingFailures, - lastError: runtime.lastError, - autoStart: runtime.autoStart, - authStatus: runtime.authStatus ?? undefined, - }; - } finally { - if (!reuseExisting) { - clearReconnectTimer(runtime); - clearHealthTimer(runtime); - if (runtime.client || runtime.transport) { - await disconnectRuntime(runtime).catch(() => {}); - } - } - } - }, - - saveServer(config: ExternalMcpServerConfig): ExternalMcpServerConfig[] { - const normalized = normalizeServerConfig(config); - if (!normalized) throw new Error("Invalid external MCP server config."); - const doc = readSecretDocument(); - const current = Array.isArray(doc.externalMcp) ? doc.externalMcp : []; - const next = current.filter((entry) => asTrimmedString(isRecord(entry) ? entry.name : "") !== normalized.name); - next.push(normalized); - doc.externalMcp = next.sort((a, b) => asTrimmedString(isRecord(a) ? a.name : "").localeCompare(asTrimmedString(isRecord(b) ? b.name : ""))); - writeSecretDocument(doc); - emit({ - type: "configs-changed", - at: nowIso(), - serverName: normalized.name, - }); - this.reload(); - return readConfiguredServers(); - }, - - removeServer(serverName: string): ExternalMcpServerConfig[] { - const doc = readSecretDocument(); - const current = Array.isArray(doc.externalMcp) ? doc.externalMcp : []; - doc.externalMcp = current.filter((entry) => asTrimmedString(isRecord(entry) ? entry.name : "") !== serverName); - if (!Array.isArray(doc.externalMcp) || doc.externalMcp.length === 0) { - delete doc.externalMcp; - } - writeSecretDocument(doc); - emit({ - type: "configs-changed", - at: nowIso(), - serverName, - }); - this.reload(); - return readConfiguredServers(); - }, - }; -} - -export type ExternalMcpService = ReturnType; diff --git a/apps/desktop/src/main/services/files/fileWatcherService.ts b/apps/desktop/src/main/services/files/fileWatcherService.ts index e3a61568d..4c2369318 100644 --- a/apps/desktop/src/main/services/files/fileWatcherService.ts +++ b/apps/desktop/src/main/services/files/fileWatcherService.ts @@ -21,13 +21,13 @@ const IDLE_WATCHER_CLOSE_MS = 15_000; const VOLATILE_ADE_PREFIXES = [ ".ade/artifacts/", ".ade/cache/", - ".ade/mcp-configs/", + ".ade/agent-configs/", ".ade/secrets/", ".ade/transcripts/", ] as const; const VOLATILE_ADE_EXACT_PATHS = new Set([ ".ade/ade.db", - ".ade/mcp.sock", + ".ade/ade.sock", ]); function isVolatileAdePath(relPath: string): boolean { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 296afd373..07797193c 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -463,15 +463,6 @@ import type { LinearWorkflowRunDetail, LinearWorkflowConfig, NormalizedLinearIssue, - ExternalConnectionAuthRecord, - ExternalConnectionAuthRecordInput, - ExternalConnectionAuthStatus, - ExternalConnectionOAuthSessionResult, - ExternalConnectionOAuthSessionStartResult, - ExternalMcpManagedAuthConfig, - ExternalMcpServerConfig, - ExternalMcpServerSnapshot, - ExternalMcpUsageEvent, UsageSnapshot, BudgetCheckResult, BudgetCapScope, @@ -575,8 +566,6 @@ import type { createLinearRoutingService } from "../cto/linearRoutingService"; import type { createLinearIngressService } from "../cto/linearIngressService"; import type { createLinearSyncService } from "../cto/linearSyncService"; import type { createLinearIssueTracker } from "../cto/linearIssueTracker"; -import type { createExternalMcpService } from "../externalMcp/externalMcpService"; -import type { createExternalConnectionAuthService } from "../externalMcp/externalConnectionAuthService"; import type { createUsageTrackingService } from "../usage/usageTrackingService"; import type { createBudgetCapService } from "../usage/budgetCapService"; import type { createSyncHostService } from "../sync/syncHostService"; @@ -594,7 +583,7 @@ export type AppContext = { hasUserSelectedProject: boolean; projectId: string; adeDir: string; - getActiveMcpConnectionCount?: (() => number) | null; + getActiveRpcConnectionCount?: (() => number) | null; disposeHeadWatcher: () => void; keybindingsService: ReturnType; agentToolsService: ReturnType; @@ -664,15 +653,13 @@ export type AppContext = { linearRoutingService?: ReturnType | null; linearIngressService?: ReturnType | null; linearSyncService?: ReturnType | null; - externalConnectionAuthService?: ReturnType | null; - externalMcpService?: ReturnType | null; usageTrackingService?: ReturnType | null; budgetCapService?: ReturnType | null; configReloadService?: ConfigReloadService | null; syncHostService?: ReturnType | null; syncService?: ReturnType | null; - mcpSocketServer?: NetServer; - mcpSocketPath?: string; + rpcSocketServer?: NetServer; + rpcSocketPath?: string; autoUpdateService?: ReturnType | null; feedbackReporterService?: ReturnType | null; }; @@ -695,26 +682,6 @@ function clampLayout(layout: DockLayout): DockLayout { return out; } -function sanitizeExternalMcpSnapshot( - snapshot: ExternalMcpServerSnapshot, - rawConfig?: ExternalMcpServerConfig, -): ExternalMcpServerSnapshot { - if (!rawConfig) return snapshot; - return { - ...snapshot, - config: { - ...snapshot.config, - transport: rawConfig.transport === "stdio" ? "stdio" : "http", - ...(rawConfig.command ? { command: rawConfig.command } : {}), - ...(rawConfig.args ? { args: rawConfig.args } : {}), - ...(rawConfig.env ? { env: rawConfig.env } : {}), - ...(rawConfig.cwd ? { cwd: rawConfig.cwd } : {}), - ...(rawConfig.url ? { url: rawConfig.url } : {}), - ...(rawConfig.headers ? { headers: rawConfig.headers } : {}), - }, - }; -} - function escapeCsvCell(value: string | null | undefined): string { const input = value ?? ""; return /[",\r\n]/.test(input) ? `"${input.replace(/"/g, "\"\"")}"` : input; @@ -2411,103 +2378,6 @@ export function registerIpc({ }, ); - ipcMain.handle(IPC.externalMcpListServers, async (): Promise => { - const service = getCtx().externalMcpService; - if (!service) return []; - const rawConfigs = service.getRawConfigs(); - return service.getSnapshots().map((snapshot) => - sanitizeExternalMcpSnapshot( - snapshot, - rawConfigs.find((entry) => entry.name === snapshot.config.name), - ) - ); - }); - ipcMain.handle(IPC.externalMcpListConfigs, async (): Promise => - getCtx().externalMcpService?.getRawConfigs() ?? [] - ); - ipcMain.handle(IPC.externalMcpListAuthRecords, async (): Promise => - getCtx().externalConnectionAuthService?.listRecords() ?? [] - ); - ipcMain.handle(IPC.externalMcpGetUsageEvents, async (_event, arg: { limit?: number } = {}): Promise => - getCtx().externalMcpService?.getUsageEvents(arg.limit ?? 100) ?? [] - ); - ipcMain.handle(IPC.externalMcpConnectServer, async (_event, arg: { serverName: string }): Promise => { - const service = getCtx().externalMcpService; - if (!service) throw new Error("External MCP service is unavailable."); - const snapshot = await service.connectServer(arg.serverName); - return sanitizeExternalMcpSnapshot( - snapshot, - service.getRawConfigs().find((entry) => entry.name === snapshot.config.name), - ); - }); - ipcMain.handle(IPC.externalMcpDisconnectServer, async (_event, arg: { serverName: string }): Promise => { - const service = getCtx().externalMcpService; - if (!service) throw new Error("External MCP service is unavailable."); - const snapshot = await service.disconnectServer(arg.serverName); - return snapshot - ? sanitizeExternalMcpSnapshot( - snapshot, - service.getRawConfigs().find((entry) => entry.name === snapshot.config.name), - ) - : null; - }); - ipcMain.handle(IPC.externalMcpTestServer, async (_event, arg: { config: ExternalMcpServerConfig }): Promise => { - const service = getCtx().externalMcpService; - if (!service) throw new Error("External MCP service is unavailable."); - return sanitizeExternalMcpSnapshot(await service.testServer(arg.config), arg.config); - }); - ipcMain.handle(IPC.externalMcpSaveServer, async (_event, arg: { config: ExternalMcpServerConfig }): Promise => { - const service = getCtx().externalMcpService; - if (!service) throw new Error("External MCP service is unavailable."); - return service.saveServer(arg.config); - }); - ipcMain.handle(IPC.externalMcpRemoveServer, async (_event, arg: { serverName: string }): Promise => { - const service = getCtx().externalMcpService; - if (!service) throw new Error("External MCP service is unavailable."); - return service.removeServer(arg.serverName); - }); - ipcMain.handle(IPC.externalMcpSaveAuthRecord, async (_event, arg: { record: ExternalConnectionAuthRecordInput }): Promise => { - const service = getCtx().externalConnectionAuthService; - if (!service) throw new Error("External auth service is unavailable."); - const record = service.saveRecord(arg.record); - getCtx().externalMcpService?.reload?.(); - return record; - }); - ipcMain.handle(IPC.externalMcpRemoveAuthRecord, async (_event, arg: { authId: string }): Promise => { - const service = getCtx().externalConnectionAuthService; - if (!service) throw new Error("External auth service is unavailable."); - const records = service.removeRecord(arg.authId); - getCtx().externalMcpService?.reload?.(); - return records; - }); - ipcMain.handle(IPC.externalMcpGetAuthStatus, async (_event, arg: { binding?: ExternalMcpManagedAuthConfig | null }): Promise => { - const service = getCtx().externalConnectionAuthService; - if (!service) { - return { - mode: arg.binding?.mode ?? "none", - state: arg.binding ? "missing" : "ready", - summary: arg.binding ? "External auth service is unavailable." : "No managed auth configured.", - }; - } - return service.getStatusForBinding(arg.binding ?? null); - }); - ipcMain.handle(IPC.externalMcpStartOAuthSession, async (_event, arg: { authId: string }): Promise => { - const service = getCtx().externalConnectionAuthService; - if (!service) throw new Error("External auth service is unavailable."); - return service.startOAuthSession(arg.authId); - }); - ipcMain.handle(IPC.externalMcpGetOAuthSession, async (_event, arg: { sessionId: string }): Promise => { - const service = getCtx().externalConnectionAuthService; - if (!service) { - return { - authId: "", - status: "expired", - error: "External auth service is unavailable.", - }; - } - return service.getOAuthSession(arg.sessionId); - }); - ipcMain.handle(IPC.agentToolsDetect, async (): Promise => { const ctx = getCtx(); return ctx.agentToolsService.detect(); @@ -4336,7 +4206,6 @@ export function registerIpc({ const ctx = ensureComputerUseBroker(); return buildComputerUseSettingsSnapshot({ status: ctx.computerUseArtifactBrokerService.getBackendStatus(), - snapshots: ctx.externalMcpService?.getSnapshots() ?? [], }); }); @@ -4354,7 +4223,6 @@ export function registerIpc({ policy: resolved.policy, requiredKinds: resolved.requiredKinds, limit: resolved.limit, - usageEvents: ctx.externalMcpService?.getUsageEvents(100) ?? [], }); }); diff --git a/apps/desktop/src/main/services/missions/missionPreflightService.test.ts b/apps/desktop/src/main/services/missions/missionPreflightService.test.ts index 51870499a..c488e555a 100644 --- a/apps/desktop/src/main/services/missions/missionPreflightService.test.ts +++ b/apps/desktop/src/main/services/missions/missionPreflightService.test.ts @@ -467,7 +467,7 @@ describe("missionPreflightService", () => { backends: [ { name: "Ghost OS", - style: "external_mcp", + style: "external_cli", available: true, state: "connected", detail: "Connected", diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts index 760c7a26d..52b3ccaef 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts @@ -1,15 +1,4 @@ -import fs from "node:fs"; -import { createHash } from "node:crypto"; -import { createServer, type Server } from "node:net"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { stableStringify } from "../shared/utils"; - -const originalFetch = global.fetch; -let dynamicSocketServer: Server | null = null; -let dynamicSocketDir: string | null = null; -let dynamicSocketPath = "/tmp/ade.sock"; +import { afterEach, describe, expect, it, vi } from "vitest"; const mockState = vi.hoisted(() => { let nextSessionId = 1; @@ -67,11 +56,9 @@ const mockState = vi.hoisted(() => { data: { id: `opencode-session-${nextSessionId++}` }, })), promptAsync: vi.fn(async () => ({})), - eventSubscribe: vi.fn(async (args?: { query?: { directory?: string } }) => { + eventSubscribe: vi.fn(async () => { const sessionId = `opencode-session-${Math.max(1, nextSessionId - 1)}`; - return { - stream: makeStream(sessionId), - }; + return { stream: makeStream(sessionId) }; }), getSession: vi.fn(async () => { throw new Error("session not found"); @@ -117,124 +104,19 @@ import { acquireSharedOpenCodeServer, } from "./openCodeServerManager"; -function createLaunch(overrides: Partial> = {}) { - return { - mode: "bundled_proxy" as const, - command: "node", - cmdArgs: ["dist/main/adeMcpProxy.cjs", "--project-root", "/repo", "--workspace-root", "/repo"], - env: { - ADE_PROJECT_ROOT: "/repo", - ADE_WORKSPACE_ROOT: "/repo", - ADE_DEFAULT_ROLE: "agent", - ...overrides, - }, - entryPath: "dist/main/adeMcpProxy.cjs", - runtimeRoot: null, - socketPath: dynamicSocketPath, - packaged: false, - resourcesPath: null, - }; -} - -function sanitizeNamePart(value: string | null | undefined, fallback: string): string { - const normalized = (value?.trim() ?? "") - .replace(/[^a-zA-Z0-9_-]+/g, "_") - .replace(/^_+|_+$/g, ""); - return normalized.length > 0 ? normalized.slice(0, 48) : fallback; -} - -function expectedDynamicServerName(args: { - ownerKind: string; - ownerId?: string | null; - ownerKey?: string | null; - sessionId?: string; - launch: ReturnType; -}): string { - const identity = sanitizeNamePart( - args.ownerId ?? args.ownerKey ?? args.sessionId, - "session", - ); - const launchFingerprint = createHash("sha1") - .update(stableStringify({ - command: args.launch.command, - cmdArgs: args.launch.cmdArgs, - env: args.launch.env, - })) - .digest("hex") - .slice(0, 10); - return `ade_session_${sanitizeNamePart(args.ownerKind, "owner")}_${identity}_${launchFingerprint}`; -} - -describe("openCodeRuntime dynamic ADE MCP registration", () => { - beforeEach(async () => { +describe("openCodeRuntime", () => { + afterEach(() => { vi.clearAllMocks(); mockState.resetSessionIds(); __resetOpenCodeRuntimeDiagnosticsForTests(); - global.fetch = vi.fn(); - dynamicSocketDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-opencode-sock-")); - dynamicSocketPath = path.join(dynamicSocketDir, "mcp.sock"); - dynamicSocketServer = createServer((socket) => { - socket.end(); - }); - await new Promise((resolve, reject) => { - dynamicSocketServer!.once("error", reject); - dynamicSocketServer!.listen(dynamicSocketPath, resolve); - }); }); - afterEach(async () => { - global.fetch = originalFetch; - await new Promise((resolve) => { - if (!dynamicSocketServer) { - resolve(); - return; - } - dynamicSocketServer.close(() => resolve()); - }); - dynamicSocketServer = null; - if (dynamicSocketPath) { - try { - fs.unlinkSync(dynamicSocketPath); - } catch { - // ignore - } - } - if (dynamicSocketDir) { - try { - fs.rmdirSync(dynamicSocketDir); - } catch { - // ignore - } - } - dynamicSocketDir = null; - dynamicSocketPath = "/tmp/ade.sock"; - }); - - it("registers a per-session ADE MCP server on the shared OpenCode runtime and scopes tools to it", async () => { - const launch = createLaunch({ - ADE_CHAT_SESSION_ID: "chat-1", - }); - const serverName = expectedDynamicServerName({ - ownerKind: "chat", - ownerId: "chat-1", - ownerKey: "chat:chat-1", - launch, - }); - - vi.mocked(global.fetch).mockResolvedValueOnce(new Response("{}", { status: 200 })); - vi.mocked(global.fetch).mockResolvedValueOnce(new Response("{}", { status: 200 })); - vi.mocked(global.fetch).mockResolvedValueOnce(new Response("true", { status: 200 })); - vi.mocked(global.fetch).mockResolvedValueOnce(new Response(JSON.stringify({ - [serverName]: { status: "connected" }, - pencil: { status: "connected" }, - }), { status: 200 })); - + it("starts a shared OpenCode session without per-session ADE tool registration", async () => { const handle = await startOpenCodeSession({ directory: "/repo", title: "Shared chat", leaseKind: "shared", projectConfig: { ai: {} }, - dynamicMcpLaunch: launch, ownerKind: "chat", ownerId: "chat-1", ownerKey: "chat:chat-1", @@ -242,135 +124,13 @@ describe("openCodeRuntime dynamic ADE MCP registration", () => { expect(acquireSharedOpenCodeServer).toHaveBeenCalledTimes(1); expect(acquireDedicatedOpenCodeServer).not.toHaveBeenCalled(); - expect(handle.toolSelection).toEqual(expect.objectContaining({ - "ade_session_*": false, - [`${serverName}_*`]: true, - "pencil_*": false, - })); - - const enabledToolPattern = Object.entries(handle.toolSelection ?? {}).find(([, enabled]) => enabled === true)?.[0]; - expect(enabledToolPattern).toMatch(/^ade_session_chat_chat-1_[a-f0-9]{10}_\*$/); - - expect(global.fetch).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - href: "http://127.0.0.1:4101/mcp?directory=%2Frepo", - }), - expect.objectContaining({ - method: "GET", - }), - ); - expect(global.fetch).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - href: "http://127.0.0.1:4101/mcp?directory=%2Frepo", - }), - expect.objectContaining({ - method: "POST", - body: expect.stringContaining("\"name\":\"ade_session_chat_chat-1_"), - }), - ); - expect(global.fetch).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - href: `http://127.0.0.1:4101/mcp/${encodeURIComponent(serverName)}/connect?directory=%2Frepo`, - }), - expect.objectContaining({ - method: "POST", - }), - ); - expect(global.fetch).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ - href: "http://127.0.0.1:4101/mcp?directory=%2Frepo", - }), - expect.objectContaining({ - method: "GET", - }), - ); + expect(handle.toolSelection).toBeNull(); await handle.close("handle_close"); - - expect(global.fetch).toHaveBeenNthCalledWith( - 5, - expect.objectContaining({ - href: `http://127.0.0.1:4101/mcp/${encodeURIComponent(serverName)}/disconnect?directory=%2Frepo`, - }), - expect.objectContaining({ - method: "POST", - }), - ); - }); - - it("reuses an already-connected ADE MCP registration without reconnecting it", async () => { - const launch = createLaunch({ - ADE_CHAT_SESSION_ID: "chat-connected", - }); - const serverName = expectedDynamicServerName({ - ownerKind: "chat", - ownerId: "chat-connected", - ownerKey: "chat:chat-connected", - launch, - }); - vi.mocked(global.fetch).mockResolvedValueOnce(new Response(JSON.stringify({ - [serverName]: { status: "connected" }, - pencil: { status: "connected" }, - }), { status: 200 })); - vi.mocked(global.fetch).mockResolvedValueOnce(new Response(JSON.stringify({ - [serverName]: { status: "connected" }, - pencil: { status: "connected" }, - }), { status: 200 })); - - const handle = await startOpenCodeSession({ - directory: "/repo", - title: "Connected chat", - leaseKind: "shared", - projectConfig: { ai: {} }, - dynamicMcpLaunch: launch, - ownerKind: "chat", - ownerId: "chat-connected", - ownerKey: "chat:chat-connected", - }); - - expect(vi.mocked(global.fetch)).toHaveBeenCalledTimes(2); - expect(acquireDedicatedOpenCodeServer).not.toHaveBeenCalled(); - expect(handle.toolSelection).toEqual(expect.objectContaining({ - "ade_session_*": false, - [`${serverName}_*`]: true, - "pencil_*": false, - })); + expect(mockState.sharedLease.close).toHaveBeenCalledWith("handle_close"); }); - it("disables inherited MCP server tools for sessions without ADE MCP attached", async () => { - vi.mocked(global.fetch).mockResolvedValueOnce(new Response(JSON.stringify({ - pencil: { status: "connected" }, - }), { status: 200 })); - - const handle = await startOpenCodeSession({ - directory: "/repo", - title: "Lightweight chat", - leaseKind: "shared", - projectConfig: { ai: {} }, - ownerKind: "chat", - ownerId: "chat-light", - ownerKey: "chat:chat-light", - }); - - expect(handle.toolSelection).toEqual({ - "pencil_*": false, - }); - expect(vi.mocked(global.fetch)).toHaveBeenCalledTimes(1); - }); - - it("applies refreshed tool selection to one-shot prompts", async () => { - vi.mocked(global.fetch) - .mockResolvedValueOnce(new Response(JSON.stringify({ - pencil: { status: "connected" }, - }), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ - pencil: { status: "connected" }, - }), { status: 200 })); - + it("applies no scoped tool selection to one-shot prompts", async () => { const result = await runOpenCodeTextPrompt({ directory: "/repo", title: "One-shot prompt", @@ -388,265 +148,17 @@ describe("openCodeRuntime dynamic ADE MCP registration", () => { expect(result.text).toBe("pong"); expect(mockState.promptAsync).toHaveBeenCalledWith(expect.objectContaining({ - body: expect.objectContaining({ - tools: { - "pencil_*": false, - }, + body: expect.not.objectContaining({ + tools: expect.anything(), }), })); - expect(vi.mocked(global.fetch)).toHaveBeenCalledTimes(2); }); - it("records dynamic MCP fallback diagnostics for observability", async () => { - vi.mocked(global.fetch).mockResolvedValueOnce(new Response("{}", { status: 200 })); - vi.mocked(global.fetch).mockResolvedValueOnce(new Response("{}", { status: 200 })); - vi.mocked(global.fetch).mockResolvedValueOnce(new Response("true", { status: 200 })); - - const successHandle = await startOpenCodeSession({ - directory: "/repo", - title: "Success chat", - leaseKind: "shared", - projectConfig: { ai: {} }, - dynamicMcpLaunch: createLaunch({ - ADE_CHAT_SESSION_ID: "chat-success", - }), - ownerKind: "chat", - ownerId: "chat-success", - ownerKey: "chat:chat-success", - }); - await successHandle.close("handle_close"); - - vi.mocked(global.fetch).mockRejectedValue(new Error("mcp unavailable")); - await startOpenCodeSession({ - directory: "/repo", - title: "Fallback chat", - leaseKind: "shared", - projectConfig: { ai: {} }, - dynamicMcpLaunch: createLaunch({ - ADE_CHAT_SESSION_ID: "chat-fallback-stats", - }), - ownerKind: "chat", - ownerId: "chat-fallback-stats", - ownerKey: "chat:chat-fallback-stats", - }); - + it("reports OpenCode runtime diagnostics for shared and dedicated sessions", () => { const snapshot = getOpenCodeRuntimeSnapshot(); - expect(snapshot.sharedCount).toBe(1); - expect(snapshot.dynamicMcp.registrationAttempts).toBe(2); - expect(snapshot.dynamicMcp.successfulRegistrations).toBe(1); - expect(snapshot.dynamicMcp.fallbackCount).toBe(1); - expect(snapshot.dynamicMcp.lastFallbackOwnerKind).toBe("chat"); - expect(snapshot.dynamicMcp.lastFallbackOwnerId).toBe("chat-fallback-stats"); - expect(snapshot.dynamicMcp.lastFallbackError).toContain("mcp unavailable"); - expect(snapshot.dynamicMcp.lastFallbackAt).toEqual(expect.any(String)); - }); - it("creates distinct tool scopes for different ADE chat identities on the same shared server", async () => { - vi.mocked(global.fetch).mockImplementation(async () => new Response("{}", { status: 200 })); - - const handleA = await startOpenCodeSession({ - directory: "/repo", - title: "Chat A", - leaseKind: "shared", - projectConfig: { ai: {} }, - dynamicMcpLaunch: createLaunch({ - ADE_CHAT_SESSION_ID: "chat-a", - }), - ownerKind: "chat", - ownerId: "chat-a", - ownerKey: "chat:chat-a", - }); - - const handleB = await startOpenCodeSession({ - directory: "/repo", - title: "Chat B", - leaseKind: "shared", - projectConfig: { ai: {} }, - dynamicMcpLaunch: createLaunch({ - ADE_CHAT_SESSION_ID: "chat-b", - }), - ownerKind: "chat", - ownerId: "chat-b", - ownerKey: "chat:chat-b", - }); - - const enabledA = Object.keys(handleA.toolSelection ?? {}).find((key) => key !== "ade_session_*"); - const enabledB = Object.keys(handleB.toolSelection ?? {}).find((key) => key !== "ade_session_*"); - - expect(enabledA).toBeTruthy(); - expect(enabledB).toBeTruthy(); - expect(enabledA).not.toBe(enabledB); - expect(enabledA).toContain("chat-a"); - expect(enabledB).toContain("chat-b"); - }); - - it("reuses the same dynamic ADE MCP server name when launch env key order differs", async () => { - vi.mocked(global.fetch).mockImplementation(async () => new Response("{}", { status: 200 })); - - const handleA = await startOpenCodeSession({ - directory: "/repo", - title: "Chat A", - leaseKind: "shared", - projectConfig: { ai: {} }, - dynamicMcpLaunch: createLaunch({ - ADE_CHAT_SESSION_ID: "chat-stable", - ADE_OWNER_ID: "owner-stable", - }), - ownerKind: "chat", - ownerId: "chat-stable", - ownerKey: "chat:chat-stable", - }); - - const handleB = await startOpenCodeSession({ - directory: "/repo", - title: "Chat A", - leaseKind: "shared", - projectConfig: { ai: {} }, - dynamicMcpLaunch: { - ...createLaunch(), - env: { - ADE_OWNER_ID: "owner-stable", - ADE_DEFAULT_ROLE: "agent", - ADE_WORKSPACE_ROOT: "/repo", - ADE_PROJECT_ROOT: "/repo", - ADE_CHAT_SESSION_ID: "chat-stable", - }, - }, - ownerKind: "chat", - ownerId: "chat-stable", - ownerKey: "chat:chat-stable", - }); - - const enabledA = Object.keys(handleA.toolSelection ?? {}).find((key) => key !== "ade_session_*"); - const enabledB = Object.keys(handleB.toolSelection ?? {}).find((key) => key !== "ade_session_*"); - - expect(enabledA).toBeTruthy(); - expect(enabledA).toBe(enabledB); - }); - - it("degrades to a shared session without ADE MCP tools when dynamic registration fails", async () => { - vi.mocked(global.fetch).mockRejectedValue(new Error("mcp unavailable")); - const logger = { warn: vi.fn() } as any; - - const handle = await startOpenCodeSession({ - directory: "/repo", - title: "Fallback chat", - leaseKind: "shared", - projectConfig: { ai: {} }, - dynamicMcpLaunch: createLaunch({ - ADE_CHAT_SESSION_ID: "chat-fallback", - }), - ownerKind: "chat", - ownerId: "chat-fallback", - ownerKey: "chat:chat-fallback", - logger, - }); - - expect(acquireSharedOpenCodeServer).toHaveBeenCalledTimes(2); - expect(mockState.sharedLease.close).toHaveBeenCalledWith("attach_failed"); - expect(acquireDedicatedOpenCodeServer).not.toHaveBeenCalled(); - expect(handle.toolSelection).toBeNull(); - expect(logger.warn).toHaveBeenCalledWith( - "opencode.dynamic_mcp_attach_failed", - expect.objectContaining({ - ownerKind: "chat", - ownerId: "chat-fallback", - fallbackStrategy: "shared_without_mcp", - }), - ); - }); - - it("skips dedicated fallback and degrades to a shared session without ADE MCP tools when the socket is unavailable", async () => { - vi.mocked(global.fetch).mockRejectedValue( - new Error("[ade-mcp-proxy] Failed to connect: connect ENOENT /tmp/ade.sock"), - ); - const logger = { warn: vi.fn() } as any; - - const handle = await startOpenCodeSession({ - directory: "/repo", - title: "No MCP chat", - leaseKind: "shared", - projectConfig: { ai: {} }, - dynamicMcpLaunch: createLaunch({ - ADE_CHAT_SESSION_ID: "chat-no-mcp", - }), - ownerKind: "chat", - ownerId: "chat-no-mcp", - ownerKey: "chat:chat-no-mcp", - logger, - }); - - expect(acquireSharedOpenCodeServer).toHaveBeenCalledTimes(2); - expect(mockState.sharedLease.close).toHaveBeenCalledWith("attach_failed"); - expect(acquireDedicatedOpenCodeServer).not.toHaveBeenCalled(); - expect(handle.toolSelection).toBeNull(); - expect(logger.warn).toHaveBeenCalledWith( - "opencode.dynamic_mcp_attach_failed", - expect.objectContaining({ - ownerKind: "chat", - ownerId: "chat-no-mcp", - fallbackStrategy: "shared_without_mcp", - }), - ); - }); - - it("fails fast for coordinator sessions when ADE MCP socket startup is unrecoverable", async () => { - vi.mocked(global.fetch).mockRejectedValue( - new Error("local mcp startup failed: [ade-mcp-proxy] Failed to connect: connect ENOENT /tmp/ade.sock"), - ); - const logger = { warn: vi.fn() } as any; - - await expect(startOpenCodeSession({ - directory: "/repo", - title: "Coordinator", - leaseKind: "shared", - projectConfig: { ai: {} }, - dynamicMcpLaunch: createLaunch({ - ADE_RUN_ID: "run-1", - }), - ownerKind: "coordinator", - ownerId: "run-1", - ownerKey: "coordinator:run-1", - logger, - })).rejects.toThrow(/local mcp startup failed/i); - - expect(acquireSharedOpenCodeServer).toHaveBeenCalledTimes(1); - expect(acquireDedicatedOpenCodeServer).not.toHaveBeenCalled(); - expect(logger.warn).toHaveBeenCalledWith( - "opencode.dynamic_mcp_attach_failed", - expect.objectContaining({ - ownerKind: "coordinator", - ownerId: "run-1", - fallbackStrategy: "abort", - }), - ); - }); - - it("retries dynamic ADE MCP registration before falling back", async () => { - vi.mocked(global.fetch) - .mockRejectedValueOnce(new Error("server warming up")) - .mockRejectedValueOnce(new Error("server warming up")) - .mockResolvedValueOnce(new Response("{}", { status: 200 })) - .mockResolvedValueOnce(new Response("{}", { status: 200 })) - .mockResolvedValueOnce(new Response("true", { status: 200 })) - .mockResolvedValueOnce(new Response("{}", { status: 200 })); - - const handle = await startOpenCodeSession({ - directory: "/repo", - title: "Retry chat", - leaseKind: "shared", - projectConfig: { ai: {} }, - dynamicMcpLaunch: createLaunch({ - ADE_CHAT_SESSION_ID: "chat-retry", - }), - ownerKind: "chat", - ownerId: "chat-retry", - ownerKey: "chat:chat-retry", - }); - - expect(acquireSharedOpenCodeServer).toHaveBeenCalledTimes(1); - expect(acquireDedicatedOpenCodeServer).not.toHaveBeenCalled(); - expect(handle.toolSelection).toBeTruthy(); - expect(vi.mocked(global.fetch)).toHaveBeenCalledTimes(6); + expect(snapshot.sharedCount).toBe(1); + expect(snapshot.dedicatedCount).toBe(0); + expect(Object.keys(snapshot).sort()).toEqual(["dedicatedCount", "entries", "sharedCount"]); }); }); diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts index 6c4c00dc2..481d5e2fb 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts @@ -1,6 +1,5 @@ -import fs from "node:fs"; -import { createHash, randomUUID } from "node:crypto"; -import { createConnection, createServer } from "node:net"; +import { randomUUID } from "node:crypto"; +import { createServer } from "node:net"; import { pathToFileURL } from "node:url"; import { createOpencodeClient, @@ -20,14 +19,12 @@ import { import type { AiLocalProviderConfigs, EffectiveProjectConfig, - OpenCodeDynamicMcpDiagnostics, OpenCodeRuntimeSnapshot, ProjectConfigFile, } from "../../../shared/types"; import { stableStringify } from "../shared/utils"; import { resolveOpenCodeBinaryPath } from "./openCodeBinaryManager"; import type { PermissionMode } from "../ai/tools/universalTools"; -import type { AdeMcpLaunch } from "../runtime/adeMcpLaunch"; import type { Logger } from "../logging/logger"; import { acquireDedicatedOpenCodeServer, @@ -70,7 +67,6 @@ export type DiscoveredLocalModelEntry = { }; type BuildOpenCodeConfigArgs = { - mcpLaunch?: AdeMcpLaunch; projectConfig: ProjectConfigFile | EffectiveProjectConfig; /** Dynamically discovered models from local provider endpoints (e.g. LM Studio /v1/models). */ discoveredLocalModels?: DiscoveredLocalModelEntry[]; @@ -84,7 +80,6 @@ type StartOpenCodeSessionArgs = BuildOpenCodeConfigArgs & { ownerId?: string | null; ownerKey?: string | null; leaseKind?: "shared" | "dedicated"; - dynamicMcpLaunch?: AdeMcpLaunch; logger?: Logger | null; }; @@ -181,298 +176,6 @@ export function buildSharedOpenCodeServerKey(config: OpenCodeConfig): string { return `shared:${fingerprintOpenCodeConfig(config)}`; } -function sanitizeDynamicMcpNamePart(value: string | null | undefined, fallback: string): string { - const normalized = (value?.trim() ?? "") - .replace(/[^a-zA-Z0-9_-]+/g, "_") - .replace(/^_+|_+$/g, ""); - return normalized.length > 0 ? normalized.slice(0, 48) : fallback; -} - -function fingerprintAdeMcpLaunch(launch: AdeMcpLaunch): string { - return createHash("sha1") - .update(stableStringify({ - command: launch.command, - cmdArgs: launch.cmdArgs, - env: launch.env, - })) - .digest("hex") - .slice(0, 10); -} - -function buildDynamicAdeMcpServerName(args: { - ownerKind: OpenCodeServerOwnerKind; - ownerId?: string | null; - ownerKey?: string | null; - sessionId?: string; - launch: AdeMcpLaunch; -}): string { - const identity = sanitizeDynamicMcpNamePart( - args.ownerId ?? args.ownerKey ?? args.sessionId, - "session", - ); - return `ade_session_${sanitizeDynamicMcpNamePart(args.ownerKind, "owner")}_${identity}_${fingerprintAdeMcpLaunch(args.launch)}`; -} - -type OpenCodeMcpStatus = { - status?: string; - error?: string; -}; - -type OpenCodeMcpStatusMap = Record; - -function readDynamicMcpStatus( - payload: Record | null, - serverName: string, -): OpenCodeMcpStatus | null { - if (!payload || typeof payload !== "object") return null; - const entry = payload[serverName]; - if (!entry || typeof entry !== "object") return null; - return entry as OpenCodeMcpStatus; -} - -function buildFallbackToolSelection(allowedServerNames: Iterable): Record | null { - const toolSelection: Record = {}; - let hasSelection = false; - - for (const serverNameRaw of allowedServerNames) { - const serverName = serverNameRaw.trim(); - if (!serverName) continue; - if (serverName.startsWith("ade_session_")) { - toolSelection["ade_session_*"] = false; - hasSelection = true; - } - toolSelection[`${serverName}_*`] = true; - hasSelection = true; - } - - return hasSelection ? toolSelection : null; -} - -function extractAllowedServerNamesFromToolSelection( - toolSelection: Record | null, -): string[] { - if (!toolSelection) return []; - return Object.entries(toolSelection) - .filter(([, enabled]) => enabled) - .map(([pattern]) => pattern.trim()) - .filter((pattern) => pattern.endsWith("_*")) - .map((pattern) => pattern.slice(0, -2)) - .filter((serverName) => serverName.length > 0); -} - -function buildScopedMcpToolSelection(args: { - statuses: OpenCodeMcpStatusMap; - allowedServerNames: Iterable; -}): Record | null { - const allowed = new Set( - Array.from(args.allowedServerNames) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0), - ); - const toolSelection = buildFallbackToolSelection(allowed) ?? {}; - let hasSelection = Object.keys(toolSelection).length > 0; - - for (const serverNameRaw of Object.keys(args.statuses)) { - const serverName = serverNameRaw.trim(); - if (!serverName) continue; - toolSelection[`${serverName}_*`] = allowed.has(serverName); - hasSelection = true; - } - - return hasSelection ? toolSelection : null; -} - -async function resolveScopedMcpToolSelection(args: { - baseUrl: string; - directory: string; - allowedServerNames: Iterable; -}): Promise | null> { - const fallback = buildFallbackToolSelection(args.allowedServerNames); - try { - const payload = await callOpenCodeServer({ - baseUrl: args.baseUrl, - directory: args.directory, - path: "/mcp", - }); - if (!payload || typeof payload !== "object") return fallback; - return buildScopedMcpToolSelection({ - statuses: payload, - allowedServerNames: args.allowedServerNames, - }) ?? fallback; - } catch { - return fallback; - } -} - -const DYNAMIC_ADE_MCP_REGISTRATION_ATTEMPTS = 3; -const DYNAMIC_ADE_MCP_REGISTRATION_RETRY_DELAY_MS = 150; -const DYNAMIC_ADE_MCP_SOCKET_READY_TIMEOUT_MS = 1_500; -const DYNAMIC_ADE_MCP_SOCKET_READY_RETRY_DELAY_MS = 75; -const dynamicMcpDiagnostics: OpenCodeDynamicMcpDiagnostics = { - registrationAttempts: 0, - successfulRegistrations: 0, - retryCount: 0, - fallbackCount: 0, - lastFallbackAt: null, - lastFallbackOwnerKind: null, - lastFallbackOwnerId: null, - lastFallbackError: null, -}; - -async function wait(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function waitForAdeMcpSocketReady(socketPath: string): Promise { - const normalizedPath = socketPath.trim(); - if (!normalizedPath.length) return; - const deadline = Date.now() + DYNAMIC_ADE_MCP_SOCKET_READY_TIMEOUT_MS; - let lastError: unknown = null; - - while (Date.now() < deadline) { - if (fs.existsSync(normalizedPath)) { - try { - await new Promise((resolve, reject) => { - const socket = createConnection(normalizedPath); - const cleanup = (): void => { - socket.off("connect", handleConnect); - socket.off("error", handleError); - }; - const handleConnect = () => { - cleanup(); - socket.end(); - socket.destroy(); - resolve(); - }; - const handleError = (error: Error) => { - cleanup(); - socket.destroy(); - reject(error); - }; - socket.once("connect", handleConnect); - socket.once("error", handleError); - }); - return; - } catch (error) { - lastError = error; - } - } - await wait(DYNAMIC_ADE_MCP_SOCKET_READY_RETRY_DELAY_MS); - } - - const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; - throw new Error(`ADE MCP socket not ready at ${normalizedPath}${detail}`); -} - -async function callOpenCodeServer(args: { - baseUrl: string; - directory: string; - path: string; - method?: "GET" | "POST"; - body?: unknown; -}): Promise { - const url = new URL(args.path, args.baseUrl); - if (args.directory.trim().length > 0) { - url.searchParams.set("directory", args.directory); - } - const response = await fetch(url, { - method: args.method ?? "GET", - headers: args.body === undefined ? undefined : { - "content-type": "application/json", - }, - body: args.body === undefined ? undefined : JSON.stringify(args.body), - }); - if (!response.ok) { - const detail = (await response.text()).trim(); - throw new Error( - `OpenCode server request ${args.method ?? "GET"} ${args.path} failed (${response.status})${detail ? `: ${detail}` : ""}.`, - ); - } - if (response.status === 204) return null; - const text = (await response.text()).trim(); - if (!text.length) return null; - return JSON.parse(text) as T; -} - -async function ensureDynamicAdeMcpRegistration(args: { - baseUrl: string; - directory: string; - ownerKind: OpenCodeServerOwnerKind; - ownerId?: string | null; - ownerKey?: string | null; - sessionId?: string; - launch: AdeMcpLaunch; -}): Promise<{ - serverName: string; - disconnect(): Promise; -}> { - const serverName = buildDynamicAdeMcpServerName(args); - dynamicMcpDiagnostics.registrationAttempts += 1; - let lastError: unknown = null; - for (let attempt = 0; attempt < DYNAMIC_ADE_MCP_REGISTRATION_ATTEMPTS; attempt += 1) { - try { - await waitForAdeMcpSocketReady(args.launch.socketPath); - let status = readDynamicMcpStatus(await callOpenCodeServer>({ - baseUrl: args.baseUrl, - directory: args.directory, - path: "/mcp", - }), serverName); - if (!status) { - status = readDynamicMcpStatus(await callOpenCodeServer>({ - baseUrl: args.baseUrl, - directory: args.directory, - path: "/mcp", - method: "POST", - body: { - name: serverName, - config: { - type: "local", - command: [args.launch.command, ...args.launch.cmdArgs], - environment: args.launch.env, - }, - }, - }), serverName); - } - if (status?.status !== "connected") { - await callOpenCodeServer({ - baseUrl: args.baseUrl, - directory: args.directory, - path: `/mcp/${encodeURIComponent(serverName)}/connect`, - method: "POST", - }); - } - lastError = null; - dynamicMcpDiagnostics.successfulRegistrations += 1; - break; - } catch (error) { - lastError = error; - if (attempt >= DYNAMIC_ADE_MCP_REGISTRATION_ATTEMPTS - 1) { - throw error; - } - dynamicMcpDiagnostics.retryCount += 1; - await wait(DYNAMIC_ADE_MCP_REGISTRATION_RETRY_DELAY_MS); - } - } - if (lastError) { - throw lastError instanceof Error ? lastError : new Error(String(lastError)); - } - - let disconnected = false; - return { - serverName, - async disconnect(): Promise { - if (disconnected) return; - disconnected = true; - await callOpenCodeServer({ - baseUrl: args.baseUrl, - directory: args.directory, - path: `/mcp/${encodeURIComponent(serverName)}/disconnect`, - method: "POST", - }).catch(() => {}); - }, - }; -} - export function buildOpenCodeMergedConfig(args: BuildOpenCodeConfigArgs): OpenCodeConfig { return buildOpenCodeConfig(args); } @@ -588,17 +291,6 @@ export function buildOpenCodeConfig(args: BuildOpenCodeConfigArgs): OpenCodeConf maxSteps: 1, }, }, - ...(args.mcpLaunch - ? { - mcp: { - ade: { - type: "local", - command: [args.mcpLaunch.command, ...args.mcpLaunch.cmdArgs], - environment: args.mcpLaunch.env, - }, - }, - } - : {}), }; } @@ -683,18 +375,12 @@ function createOpenCodeSessionHandle(args: { sessionId: string; directory: string; toolSelection: Record | null; - dynamicMcp?: Awaited> | null; }): OpenCodeSessionHandle { return { client: args.client, server: { url: args.lease.url, async close() { - try { - await args.dynamicMcp?.disconnect(); - } catch { - // best-effort — don't block lease release - } args.lease.close("handle_close"); }, }, @@ -703,11 +389,6 @@ function createOpenCodeSessionHandle(args: { directory: args.directory, toolSelection: args.toolSelection, async close(reason = "handle_close") { - try { - await args.dynamicMcp?.disconnect(); - } catch { - // best-effort — don't block lease release - } args.lease.close(reason); }, touch() { @@ -751,37 +432,7 @@ async function startOpenCodeSessionInternal( baseUrl: lease.url, directory: args.directory, }); - let dynamicMcp: Awaited> | null = null; - try { - if (args.dynamicMcpLaunch) { - dynamicMcp = await ensureDynamicAdeMcpRegistration({ - baseUrl: lease.url, - directory: args.directory, - ownerKind, - ownerId: args.ownerId, - ownerKey, - sessionId: args.sessionId, - launch: args.dynamicMcpLaunch, - }); - } - } catch (error) { - // Dynamic ADE MCP attachment can fail even when the underlying OpenCode - // server is healthy. Release the shared lease without tearing the server - // down so degraded retries can reuse the same process. - lease.close("attach_failed"); - throw error; - } - const resolvedSessionId = trimToUndefined(args.sessionId); - const scopedToolSelection = await resolveScopedMcpToolSelection({ - baseUrl: lease.url, - directory: args.directory, - allowedServerNames: dynamicMcp - ? [dynamicMcp.serverName] - : args.mcpLaunch - ? ["ade"] - : [], - }); if (resolvedSessionId) { try { @@ -794,8 +445,7 @@ async function startOpenCodeSessionInternal( lease, sessionId: resolvedSessionId, directory: args.directory, - toolSelection: scopedToolSelection, - dynamicMcp, + toolSelection: null, }); } catch { // Fall through to session creation when the persisted session no longer exists. @@ -808,7 +458,6 @@ async function startOpenCodeSessionInternal( }); if (!created.data) { - dynamicMcp?.disconnect().catch(() => {}); lease.close("error"); throw new Error("OpenCode session.create returned no session payload."); } @@ -818,8 +467,7 @@ async function startOpenCodeSessionInternal( lease, sessionId: created.data.id, directory: args.directory, - toolSelection: scopedToolSelection, - dynamicMcp, + toolSelection: null, }); } @@ -827,58 +475,17 @@ export async function startOpenCodeSession( args: StartOpenCodeSessionArgs, ): Promise { ensureOpenCodeAvailable(); - if (args.dynamicMcpLaunch) { - try { - return await startOpenCodeSessionInternal({ - ...args, - mcpLaunch: undefined, - }); - } catch (error) { - const ownerKind = args.ownerKind ?? "oneshot"; - const fallbackStrategy = ownerKind === "coordinator" - ? "abort" - : "shared_without_mcp"; - args.logger?.warn("opencode.dynamic_mcp_attach_failed", { - ownerKind, - ownerId: args.ownerId ?? null, - sessionId: args.sessionId ?? null, - error: error instanceof Error ? error.message : String(error), - fallbackStrategy, - }); - if (fallbackStrategy === "abort") { - throw error; - } - dynamicMcpDiagnostics.fallbackCount += 1; - dynamicMcpDiagnostics.lastFallbackAt = new Date().toISOString(); - dynamicMcpDiagnostics.lastFallbackOwnerKind = ownerKind; - dynamicMcpDiagnostics.lastFallbackOwnerId = args.ownerId?.trim() || null; - dynamicMcpDiagnostics.lastFallbackError = error instanceof Error ? error.message : String(error); - return await startOpenCodeSessionInternal({ - ...args, - dynamicMcpLaunch: undefined, - mcpLaunch: undefined, - }); - } - } return await startOpenCodeSessionInternal(args); } export function getOpenCodeRuntimeSnapshot(): OpenCodeRuntimeSnapshot { return { ...getOpenCodeRuntimeDiagnostics(), - dynamicMcp: { ...dynamicMcpDiagnostics }, }; } export function __resetOpenCodeRuntimeDiagnosticsForTests(): void { - dynamicMcpDiagnostics.registrationAttempts = 0; - dynamicMcpDiagnostics.successfulRegistrations = 0; - dynamicMcpDiagnostics.retryCount = 0; - dynamicMcpDiagnostics.fallbackCount = 0; - dynamicMcpDiagnostics.lastFallbackAt = null; - dynamicMcpDiagnostics.lastFallbackOwnerKind = null; - dynamicMcpDiagnostics.lastFallbackOwnerId = null; - dynamicMcpDiagnostics.lastFallbackError = null; + // Preserved for older tests; OpenCode no longer tracks per-session ADE tool registration. } export async function openCodeEventStream(args: { @@ -896,13 +503,8 @@ export async function openCodeEventStream(args: { export async function refreshOpenCodeSessionToolSelection( handle: OpenCodeSessionHandle, ): Promise | null> { - const refreshed = await resolveScopedMcpToolSelection({ - baseUrl: handle.lease.url, - directory: handle.directory, - allowedServerNames: extractAllowedServerNamesFromToolSelection(handle.toolSelection), - }); - handle.toolSelection = refreshed; - return refreshed; + handle.toolSelection = null; + return null; } export async function runOpenCodeTextPrompt( @@ -911,9 +513,8 @@ export async function runOpenCodeTextPrompt( const handle = await startOpenCodeSession({ directory: args.directory, title: args.title, - mcpLaunch: args.mcpLaunch, projectConfig: args.projectConfig, - leaseKind: args.mcpLaunch ? "dedicated" : "shared", + leaseKind: "shared", ownerKind: "oneshot", }); diff --git a/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts b/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts index efd4e3ab8..9b170a261 100644 --- a/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts @@ -364,7 +364,7 @@ describe("openCodeServerManager", () => { process.env.OPENCODE_API_KEY = "ambient-api-key"; process.env.OPENCODE_BIN_PATH = "/tmp/rogue-opencode"; process.env.OPENCODE_CONFIG_DIR = "/Users/tester/.config/opencode"; - process.env.OPENCODE_CONFIG_CONTENT = "{\"mcp\":{\"pencil\":true}}"; + process.env.OPENCODE_CONFIG_CONTENT = "{\"experimental\":{\"pencil\":true}}"; const config = { share: "disabled", diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index 913cb741e..f694f5085 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -4145,7 +4145,7 @@ describe("aiOrchestratorService", () => { "\"text\": \"{\\n \\\"ok\\\": true }\"", "admin@Mac test1-30b1aa3d %", "-p \"$(cat '/Users/admin/Projects/ADE/.ade/orchestrator/worker-prompts/worker-123.txt')\"", - "cp '/tmp/worker-123.json' '.ade-worker-mcp-123.json' && exec codex --model gpt-5.3-codex", + "cp '/tmp/worker-123.json' '.ade-worker-123.json' && exec codex --model gpt-5.3-codex", "12f2b.txt')\"", "ADE_MISSION_ID='mission-1' exec claude --model 'sonnet' --permission-mode 'default'", "orchestrator/worker-prompts/worker-ce33e94c-b964-42c9-9127-dfdeb6853d36", diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index 6bca1b3de..2c1c726b2 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -517,7 +517,6 @@ export function normalizeCoordinatorUpdateForChat(message: string): string | nul if ( /^\{/.test(compact) || /^tool\s+/i.test(compact) - || /^mcp__/i.test(compact) || /^assistant:/i.test(compact) || /^user:/i.test(compact) ) { @@ -1688,7 +1687,7 @@ export function createAiOrchestratorService(args: { || /^cp\s+'.+worker-[a-f0-9-]+\.json'\s+'.+\.json'(\s+&&\s+exec\b.*)?$/i.test(compact) || /^ade_[a-z0-9_]+=.+/i.test(compact) || /(?:^|[\\/])(?:orchestrator[\\/])?worker-prompts[\\/]worker-[a-f0-9-]+(?:\.[A-Za-z0-9._-]+)?/i.test(compact) - || /\.ade-worker-mcp-[a-f0-9-]+\.json/i.test(compact) + || /\.ade-worker-[a-f0-9-]+\.json/i.test(compact) || /(?:^|[-*]\s+)?`?\.ade\/(?:step-output|checkpoints)-worker_[^`\s]+\.md`?/i.test(compact) || /[A-Za-z0-9._-]+\.(?:txt|json)['")]+$/i.test(compact) || /^"(?:missionId|runId|stepId|stepKey|laneId|attemptId)\b/i.test(compact) @@ -1774,12 +1773,7 @@ export function createAiOrchestratorService(args: { }; const normalizeCoordinatorToolEventName = (toolName: string): string => { - const trimmed = toolName.trim(); - if (trimmed.startsWith("mcp__")) { - const parts = trimmed.split("__"); - return (parts[2] ?? trimmed).trim(); - } - return trimmed; + return toolName.trim(); }; const derivePlannerLifecycleFromDelegation = (contract: DelegationContract): { diff --git a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.test.ts b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.test.ts index b9395a7d9..5cd8e0e60 100644 --- a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.test.ts +++ b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.test.ts @@ -268,6 +268,6 @@ describe("buildFullPrompt", () => { expect(prompt.prompt).toContain("This worker is running in-process."); expect(prompt.prompt).toContain("RUNTIME LIMITS:"); expect(prompt.prompt).not.toContain("ALWAYS call `report_result`"); - expect(prompt.prompt).not.toContain("ADE MCP TOOLS:"); + expect(prompt.prompt).not.toContain("ADE TOOLING:"); }); }); diff --git a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts index 43e133ec9..105487cb3 100644 --- a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts @@ -541,13 +541,13 @@ export function buildFullPrompt( // ADE self-awareness systemParts.push("You are working within ADE (Autonomous Development Environment), an Electron-based multi-agent development tool. ADE manages lanes (git worktrees), missions (task orchestration), PRs, and agent sessions. You have access to the project's full context including PRD and architecture docs when provided."); - // MCP server collaboration tools + // ADE collaboration tools if (hasMissionTooling) { systemParts.push( [ - "ADE MCP TOOLS: You have access to the ADE MCP server which provides team collaboration tools.", + "ADE TOOLING: In terminal-capable sessions, use the bundled `ade` CLI for internal ADE actions. Run `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands such as `ade lanes list --text` or `ade prs checks --text` first, and `ade actions run ...` as the escape hatch. Use `--json` for structured output and `--text` for readable output.", "Your worker identity (mission, run, step, attempt IDs) is automatically resolved — you don't need to pass IDs to observation tools.", - "Key tools available:", + "Key actions available:", "- get_worker_states: See all peer workers in your run and their current status", "- get_run_graph: See the full execution plan, step statuses, and dependencies", "- get_mission: Get mission details and metadata", diff --git a/apps/desktop/src/main/services/orchestrator/chatMessageService.ts b/apps/desktop/src/main/services/orchestrator/chatMessageService.ts index d01502880..66466fbb7 100644 --- a/apps/desktop/src/main/services/orchestrator/chatMessageService.ts +++ b/apps/desktop/src/main/services/orchestrator/chatMessageService.ts @@ -129,7 +129,6 @@ function looksLikeLowSignalMissionNoise(text: string): boolean { if (!trimmed.length) return true; if (/^streaming(?:\.\.\.)?$/i.test(trimmed)) return true; if (/^usage$/i.test(trimmed)) return true; - if (/^mcp:/i.test(trimmed)) return true; if (/^[\-dlcbps][rwx\-@+]{8,}/i.test(trimmed)) return true; if (/^[A-Z0-9 .:_()/-]{24,}$/.test(trimmed)) return true; // Single-token strings under 24 chars that look like identifiers or noise diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.test.ts b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.test.ts index 18946534c..da2fc5588 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.test.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.test.ts @@ -432,7 +432,7 @@ describe("CoordinatorAgent", () => { } }); - it("uses project-root workspace binding for the OpenCode coordinator MCP launch", async () => { + it("starts the OpenCode coordinator through the shared runtime lease", async () => { mockState.eventBatches.push([ { type: "session.idle", properties: { sessionID: "session-1" } }, ]); @@ -448,17 +448,7 @@ describe("CoordinatorAgent", () => { const startCalls = vi.mocked(startOpenCodeSession).mock.calls; expect(startCalls.length).toBeGreaterThan(0); expect(startCalls[0]?.[0]?.leaseKind).toBe("shared"); - const launch = startCalls[0]?.[0]?.dynamicMcpLaunch as { - cmdArgs?: string[]; - env?: Record; - }; - expect(Array.isArray(launch?.cmdArgs)).toBe(true); - const workspaceFlagIndex = launch.cmdArgs!.indexOf("--workspace-root"); - expect(workspaceFlagIndex).toBeGreaterThanOrEqual(0); - expect(launch.cmdArgs![workspaceFlagIndex + 1]).toBe("/tmp/ade-project"); - expect(launch.env?.ADE_WORKSPACE_ROOT).toBe("/tmp/ade-project"); - expect(launch.env?.ADE_RUN_ID).toBe("run-1"); - expect(launch.env?.ADE_MISSION_ID).toBeFalsy(); + expect(Object.keys(startCalls[0]?.[0] ?? {}).sort()).toEqual(expect.arrayContaining(["directory", "leaseKind", "ownerId", "ownerKind"])); } finally { agent.shutdown(); } diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts index c72faada0..26af30344 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts @@ -12,7 +12,6 @@ import { type CoordinatorExecutableTool, type CoordinatorSendWorkerMessageFn, } from "./coordinatorTools"; -import { resolveAdeMcpServerLaunch, resolveOpenCodeRuntimeRoot } from "./providerOrchestratorAdapter"; import { buildOpenCodePromptParts, mapPermissionModeToOpenCodeAgent, @@ -786,14 +785,6 @@ export class CoordinatorAgent { throw new Error("Cursor models are not supported for coordinator execution. Choose Claude, Codex, or OpenCode."); } const projectConfig = this.deps.projectConfigService?.get().effective ?? { ai: {} }; - const mcpLaunch = resolveAdeMcpServerLaunch({ - projectRoot: this.deps.projectRoot, - workspaceRoot: this.deps.workspaceRoot, - workspaceBinding: "project_root", - runtimeRoot: resolveOpenCodeRuntimeRoot(), - runId: this.deps.runId, - defaultRole: "orchestrator", - }); // Discover loaded local models so OpenCode knows about them. const discoveredLocalModels: DiscoveredLocalModelEntry[] = []; const aiConfig = projectConfig.ai as { localProviders?: Record } | undefined; @@ -813,7 +804,6 @@ export class CoordinatorAgent { directory: this.deps.workspaceRoot, title: `ADE coordinator: ${this.deps.missionGoal}`, projectConfig, - dynamicMcpLaunch: mcpLaunch, discoveredLocalModels, ownerKind: "coordinator", ownerId: this.deps.runId, @@ -2085,6 +2075,10 @@ Your conversation persists across the entire mission — you accumulate context, You are NOT a repo-editing worker. You are the mission lead who owns phase state, worker spawning, runtime judgment, and final completion. In normal operation, workers inspect the repo, edit code, and run commands. You keep the mission aligned and delegated. The difference between you and a dumb orchestrator is that you THINK before you act and EVALUATE after each step. +## ADE CLI + +Terminal-capable workers can use the bundled \`ade\` command for internal ADE actions. Instruct them to run \`ade doctor\` for readiness, \`ade actions list --text\` for discovery, typed commands such as \`ade lanes list --text\` or \`ade prs checks --text\` first, and \`ade actions run ...\` as the escape hatch. Tell them to use \`--json\` for structured output and \`--text\` for readable output. + ## Your Mission ${this.deps.missionGoal} diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts index eb0bcb28c..98231b82a 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts @@ -204,14 +204,6 @@ const COORDINATOR_OBSERVATION_TOOL_NAMES = [ "stream_events", ] as const; -export function buildCoordinatorMcpAllowedTools(serverName = "ade", extraToolNames: readonly string[] = []): string[] { - const trimmed = serverName.trim(); - const resolvedServerName = trimmed.length > 0 ? trimmed : "ade"; - return [...COORDINATOR_TOOL_NAMES, ...COORDINATOR_OBSERVATION_TOOL_NAMES, ...extraToolNames].map( - (toolName) => `mcp__${resolvedServerName}__${toolName}`, - ); -} - export type PlannerLaunchFailureCategory = | "run_context_bug" | "provider_unreachable" @@ -506,11 +498,6 @@ function resolveTeamRuntimeConfig(graph: OrchestratorRunGraph): TeamRuntimeConfi ...normalizeAgentRuntimeFlags(teamRuntime), template: teamRuntime.template as TeamRuntimeConfig["template"], toolProfiles: teamRuntime.toolProfiles as TeamRuntimeConfig["toolProfiles"], - mcpServerAllowlist: Array.isArray(teamRuntime.mcpServerAllowlist) - ? (teamRuntime.mcpServerAllowlist as unknown[]) - .map((entry: unknown) => String(entry ?? "").trim()) - .filter((entry) => entry.length > 0) - : undefined, policyOverrides: teamRuntime.policyOverrides as TeamRuntimeConfig["policyOverrides"] }; } @@ -1211,7 +1198,6 @@ export function createCoordinatorToolSet(deps: { role: roleName, roleCapabilities: roleDef?.capabilities ?? [], toolProfile: toolProfile ?? null, - mcpServerAllowlist: teamRuntime?.mcpServerAllowlist ?? [], isTask: false, convertedFromTaskShell: true, ...(args.delegationContract ? { delegationContract: args.delegationContract } : {}), @@ -1265,7 +1251,6 @@ export function createCoordinatorToolSet(deps: { role: roleName, roleCapabilities: roleDef?.capabilities ?? [], toolProfile: toolProfile ?? null, - mcpServerAllowlist: teamRuntime?.mcpServerAllowlist ?? [], ...(args.delegationContract ? { delegationContract: args.delegationContract } : {}), ...(args.validationContract ? { validationContract: args.validationContract } : {}), ...(replacementSourceStep @@ -4673,10 +4658,9 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act role: z.string().describe("Role name to update"), allowedTools: z.array(z.string()).min(1), blockedTools: z.array(z.string()).optional(), - mcpServers: z.array(z.string()).optional(), notes: z.string().optional(), }), - execute: async ({ role, allowedTools, blockedTools, mcpServers, notes }) => { + execute: async ({ role, allowedTools, blockedTools, notes }) => { try { const runRow = db.get<{ metadata_json: string | null }>( `select metadata_json from orchestrator_runs where id = ? limit 1`, @@ -4695,9 +4679,6 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act ...(Array.isArray(blockedTools) && blockedTools.length > 0 ? { blockedTools: dedupeKeys(blockedTools) } : {}), - ...(Array.isArray(mcpServers) && mcpServers.length > 0 - ? { mcpServers: dedupeKeys(mcpServers) } - : {}), ...(normalizeText(notes).length > 0 ? { notes: normalizeText(notes) } : {}) }; metadata.teamRuntime = { diff --git a/apps/desktop/src/main/services/orchestrator/delegationContracts.ts b/apps/desktop/src/main/services/orchestrator/delegationContracts.ts index ca867379e..7575b2e79 100644 --- a/apps/desktop/src/main/services/orchestrator/delegationContracts.ts +++ b/apps/desktop/src/main/services/orchestrator/delegationContracts.ts @@ -42,12 +42,7 @@ const COORDINATOR_TOOL_CAPABILITIES: Array<{ ]; export function normalizeCoordinatorToolName(toolName: string): string { - const trimmed = toolName.trim(); - if (trimmed.startsWith("mcp__")) { - const parts = trimmed.split("__"); - return (parts[2] ?? trimmed).trim(); - } - return trimmed; + return toolName.trim(); } export function createDelegationScope(args: { diff --git a/apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts b/apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts index 01c234991..b208d0395 100644 --- a/apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts +++ b/apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts @@ -135,7 +135,7 @@ describe("classifyBlockingWarnings", () => { it("detects tool startup failures as blocking", () => { const result = classifyBlockingWarnings({ - warnings: ["tool startup failed for MCP server"], + warnings: ["tool startup failed for external connector"], summary: null, }); expect(result.hasBlockingFailure).toBe(true); @@ -169,7 +169,7 @@ describe("classifyBlockingWarnings", () => { expect(result.category).toBe("sandbox_block"); }); - it("excludes external MCP auth warnings (claude.ai Gmail:needs-auth)", () => { + it("excludes provider connector auth warnings (claude.ai Gmail:needs-auth)", () => { const result = classifyBlockingWarnings({ warnings: ["claude.ai Gmail:needs-auth", "claude.ai Google Calendar:needs-auth"], summary: null, @@ -178,7 +178,7 @@ describe("classifyBlockingWarnings", () => { expect(result.category).toBeNull(); }); - it("excludes external MCP Slack auth noise", () => { + it("excludes provider connector Slack auth noise", () => { const result = classifyBlockingWarnings({ warnings: ["claude.ai Slack:needs-auth"], summary: null, @@ -194,7 +194,7 @@ describe("classifyBlockingWarnings", () => { expect(result.hasBlockingFailure).toBe(false); }); - it("detects blocking when mixed with external MCP noise", () => { + it("detects blocking when mixed with provider connector noise", () => { const result = classifyBlockingWarnings({ warnings: [ "claude.ai Gmail:needs-auth", @@ -290,7 +290,7 @@ describe("soft-failure override in completeAttempt", () => { } }); - it("does not override succeeded attempt when warnings are only external MCP noise", async () => { + it("does not override succeeded attempt when warnings are only provider connector noise", async () => { const fixture = await createFixture(); try { const run = fixture.service.startRun({ @@ -322,7 +322,7 @@ describe("soft-failure override in completeAttempt", () => { }, }); - // Should remain succeeded — external MCP noise should be ignored + // Should remain succeeded because provider connector noise should be ignored. expect(completed.status).toBe("succeeded"); } finally { fixture.dispose(); @@ -511,10 +511,10 @@ describe("MissionRunPanel attention states", () => { }); // --------------------------------------------------------------------------- -// External MCP noise vs real failures +// Provider connector noise vs real failures // --------------------------------------------------------------------------- -describe("external MCP noise filtering", () => { +describe("provider connector noise filtering", () => { it("gmail auth noise does not trigger blocking classification", () => { const result = classifyBlockingWarnings({ warnings: ["claude.ai Gmail:needs-auth"], @@ -541,7 +541,7 @@ describe("external MCP noise filtering", () => { it("ADE-internal needs-auth without claude.ai prefix IS blocking", () => { const result = classifyBlockingWarnings({ - warnings: ["MCP server myserver:needs-auth — cannot continue"], + warnings: ["external connector myserver:needs-auth - cannot continue"], summary: null, }); expect(result.hasBlockingFailure).toBe(true); diff --git a/apps/desktop/src/main/services/orchestrator/knowledgeConflictsBrowserCto.test.ts b/apps/desktop/src/main/services/orchestrator/knowledgeConflictsBrowserCto.test.ts index 7279167e9..c169add86 100644 --- a/apps/desktop/src/main/services/orchestrator/knowledgeConflictsBrowserCto.test.ts +++ b/apps/desktop/src/main/services/orchestrator/knowledgeConflictsBrowserCto.test.ts @@ -368,7 +368,6 @@ describe("Agent-browser integration", () => { const profile: RoleToolProfile = { allowedTools: ["agent-browser", "bash", "read_file"], blockedTools: [], - mcpServers: [], notes: "Browser-enabled worker", }; expect(profile.allowedTools).toContain("agent-browser"); @@ -541,7 +540,7 @@ describe("CTO integration", () => { endedAt: "2026-03-01T10:30:00.000Z", provider: "claude", modelId: "claude-sonnet-4-6", - capabilityMode: "full_mcp", + capabilityMode: "full_tooling", }); expect(entry.sessionId).toBe("session-abc"); diff --git a/apps/desktop/src/main/services/orchestrator/missionBudgetService.ts b/apps/desktop/src/main/services/orchestrator/missionBudgetService.ts index 1ff3f0874..fcf867d24 100644 --- a/apps/desktop/src/main/services/orchestrator/missionBudgetService.ts +++ b/apps/desktop/src/main/services/orchestrator/missionBudgetService.ts @@ -57,11 +57,6 @@ type MissionRow = { completed_at: string | null; }; -type ExternalMcpCostRow = { - step_id: string | null; - total_cost_cents: number; -}; - type ClaudeProviderUsage = { inputTokens: number; outputTokens: number; @@ -884,49 +879,6 @@ export function createMissionBudgetService(args: { }; }; - const readExternalMcpCosts = (args: { - missionId: string; - runId?: string | null; - }): { - byStepId: Map; - unscopedCostUsd: number; - totalCostUsd: number; - } => { - const runId = typeof args.runId === "string" && args.runId.trim().length > 0 ? args.runId.trim() : null; - const rows = db.all( - ` - select - step_id, - coalesce(sum(cost_cents), 0) as total_cost_cents - from external_mcp_usage_events - where project_id = ? - and mission_id = ? - and (? is null or run_id = ?) - group by step_id - `, - [projectId, args.missionId, runId, runId], - ); - const byStepId = new Map(); - let unscopedCostUsd = 0; - let totalCostUsd = 0; - for (const row of rows) { - const costUsd = Math.max(0, Number((Number(row.total_cost_cents ?? 0) / 100).toFixed(6))); - if (costUsd <= 0) continue; - const stepId = typeof row.step_id === "string" ? row.step_id.trim() : ""; - totalCostUsd += costUsd; - if (stepId.length > 0) { - byStepId.set(stepId, costUsd); - } else { - unscopedCostUsd += costUsd; - } - } - return { - byStepId, - unscopedCostUsd: Number(unscopedCostUsd.toFixed(6)), - totalCostUsd: Number(totalCostUsd.toFixed(6)), - }; - }; - const estimateLaunchBudget = async (args: { launch: CreateMissionArgs; selectedPhases: PhaseCard[]; @@ -1092,12 +1044,6 @@ export function createMissionBudgetService(args: { `, [missionId, projectId, runIdFilter, runIdFilter], ); - const externalMcpCosts = readExternalMcpCosts({ - missionId, - runId: runRow?.id ?? runIdFilter, - }); - const unmatchedExternalMcpCostByStep = new Map(externalMcpCosts.byStepId); - const phaseConfiguration = missionService.getPhaseConfiguration(missionId); const selectedPhases = phaseConfiguration?.selectedPhases ?? []; const phaseByKey = new Map(selectedPhases.map((phase) => [phase.phaseKey, phase] as const)); @@ -1211,9 +1157,7 @@ export function createMissionBudgetService(args: { const meteredCostUsd = mode === "api-key" || modelBudgetPath.apiMetered ? estimateTokenCost(model, inputTokens, outputTokens) : 0; - const externalStepCostUsd = unmatchedExternalMcpCostByStep.get(row.step_id) ?? 0; - unmatchedExternalMcpCostByStep.delete(row.step_id); - const usedCostUsd = Number((meteredCostUsd + externalStepCostUsd).toFixed(6)); + const usedCostUsd = Number(meteredCostUsd.toFixed(6)); if (row.status === "running") activeWorkers += 1; @@ -1270,18 +1214,8 @@ export function createMissionBudgetService(args: { let missionUsedTokens = perPhase.reduce((sum, phase) => sum + phase.usedTokens, 0); let missionUsedTimeMs = perPhase.reduce((sum, phase) => sum + phase.usedTimeMs, 0); let missionUsedCostUsd = perPhase.reduce((sum, phase) => sum + phase.usedCostUsd, 0); - if (externalMcpCosts.totalCostUsd > 0) { - missionUsedCostUsd = Number(( - missionUsedCostUsd - + externalMcpCosts.unscopedCostUsd - + [...unmatchedExternalMcpCostByStep.values()].reduce((sum, value) => sum + value, 0) - ).toFixed(6)); - } const dataSources = ["ai_usage_log", "orchestrator_attempts"]; - if (externalMcpCosts.totalCostUsd > 0) { - dataSources.push("external_mcp_usage_events"); - } const runStart = Date.parse(runRow?.started_at ?? missionRow.started_at ?? missionRow.completed_at ?? nowIso()); const runEnd = Date.parse(runRow?.completed_at ?? missionRow.completed_at ?? nowIso()); diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorConstants.ts b/apps/desktop/src/main/services/orchestrator/orchestratorConstants.ts index 6986d08c9..5cd9e7258 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorConstants.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorConstants.ts @@ -93,7 +93,7 @@ export const DEFAULT_CODEX_SANDBOX_PERMISSIONS = "workspace-write"; /** * Default sandbox configuration for API-model workers. * Based on the Claude sandbox rules in .claude/hooks/sandbox.py, - * filtered to universal patterns (no project-specific AWS/MCP rules). + * filtered to universal patterns (no project-specific integration rules). * CLI-wrapped models (Claude, Codex) skip this — they have native sandboxing. */ export const DEFAULT_WORKER_SANDBOX_CONFIG: WorkerSandboxConfig = { diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts b/apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts index 0ed5896a0..0e8ca6b73 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts @@ -807,8 +807,8 @@ const BLOCKING_WARNING_PATTERNS: Array<{ pattern: RegExp; category: BlockingWarn { pattern: /unauthorized/i, category: 'missing_auth' }, ]; -// External MCP auth warnings that should NOT be treated as blocking -const EXTERNAL_MCP_NOISE_PATTERNS: RegExp[] = [ +// Provider connector auth warnings that should NOT be treated as blocking. +const PROVIDER_CONNECTOR_NOISE_PATTERNS: RegExp[] = [ /claude\.ai\s+\S+:needs-auth/i, /claude\.ai\s+Gmail/i, /claude\.ai\s+Google Calendar/i, @@ -827,8 +827,7 @@ export function classifyBlockingWarnings(args: { if (summary) textsToScan.push(summary); for (const text of textsToScan) { - // Skip external MCP noise - const isExternalNoise = EXTERNAL_MCP_NOISE_PATTERNS.some(p => p.test(text)); + const isExternalNoise = PROVIDER_CONNECTOR_NOISE_PATTERNS.some(p => p.test(text)); if (isExternalNoise) continue; for (const { pattern, category } of BLOCKING_WARNING_PATTERNS) { diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts index 12ac82b84..68b73841f 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts @@ -70,7 +70,7 @@ import { import { evaluateRunCompletion, evaluateRunCompletionFromPhases, validateRunCompletion, DEFAULT_EXECUTION_POLICY } from "./executionPolicy"; import { createProviderOrchestratorAdapter, - cleanupMcpConfigFile, + cleanupWorkerRuntimeFiles, } from "./providerOrchestratorAdapter"; import { resolveClaudeCliModel, resolveCodexCliModel } from "../ai/claudeModelUtils"; import { runGit } from "../git/git"; @@ -81,7 +81,6 @@ import type { createConflictService } from "../conflicts/conflictService"; import type { createProjectConfigService } from "../config/projectConfigService"; import type { createPrService } from "../prs/prService"; import type { createMemoryService } from "../memory/memoryService"; -import type { createExternalMcpService } from "../externalMcp/externalMcpService"; import type { AiTaskType } from "../ai/aiIntegrationService"; import type { EpisodicSummaryService } from "../memory/episodicSummaryService"; import type { KnowledgeCaptureService } from "../memory/knowledgeCaptureService"; @@ -810,7 +809,6 @@ export function createOrchestratorService({ episodicSummaryService, proceduralLearningService, knowledgeCaptureService, - externalMcpService, onEvent }: { db: AdeDb; @@ -828,7 +826,6 @@ export function createOrchestratorService({ episodicSummaryService?: EpisodicSummaryService | null; proceduralLearningService?: ProceduralLearningService | null; knowledgeCaptureService?: KnowledgeCaptureService | null; - externalMcpService?: ReturnType | null; onEvent?: (event: OrchestratorEvent) => void; }) { const adapters = new Map(); @@ -836,7 +833,6 @@ export function createOrchestratorService({ projectRoot, workspaceRoot: projectRoot, agentChatService, - externalMcpService, }); for (const kind of ["claude", "codex", "cursor", "opencode"] as const) { adapters.set(kind, sharedAdapter); @@ -7818,7 +7814,7 @@ export function createOrchestratorService({ state: status === "failed" ? "released" : "released" }); - // Clean up temporary MCP config files for this worker. + // Clean up temporary worker runtime files for this worker. const laneWorktreeRow = step.laneId ? db.get<{ worktree_path: string | null }>( `select worktree_path from lanes where id = ? and project_id = ? limit 1`, @@ -7828,7 +7824,7 @@ export function createOrchestratorService({ const laneWorktreePath = typeof laneWorktreeRow?.worktree_path === "string" && laneWorktreeRow.worktree_path.trim().length > 0 ? laneWorktreeRow.worktree_path.trim() : null; - cleanupMcpConfigFile(projectRoot, args.attemptId, laneWorktreePath); + cleanupWorkerRuntimeFiles(projectRoot, args.attemptId, laneWorktreePath); // Sync worker checkpoint file to DB for all completion states if (step.laneId) { diff --git a/apps/desktop/src/main/services/orchestrator/planningGapsFixes.test.ts b/apps/desktop/src/main/services/orchestrator/planningGapsFixes.test.ts index 98a14c23e..f88921a4a 100644 --- a/apps/desktop/src/main/services/orchestrator/planningGapsFixes.test.ts +++ b/apps/desktop/src/main/services/orchestrator/planningGapsFixes.test.ts @@ -11,35 +11,20 @@ import { openKvDb } from "../state/kvDb"; import type { OrchestratorRunGraph } from "../../../shared/types/orchestrator"; // ───────────────────────────────────────────────────────────────── -// VAL-PLAN-003: mcp__ade__ask_user in the planning worker allowlist +// VAL-PLAN-003: planning worker read-only native tool allowlist // ───────────────────────────────────────────────────────────────── -describe("VAL-PLAN-003: ask_user in planning worker allowlist", () => { - it("mcp__ade__ask_user is included in the read-only worker allowed tools", () => { +describe("VAL-PLAN-003: planning worker read-only allowlist", () => { + it("includes only native read-only tools by default", () => { const tools = buildClaudeReadOnlyWorkerAllowedTools(); - expect(tools).toContain("mcp__ade__ask_user"); - expect(tools).toContain("mcp__ade__memory_search"); - expect(tools).toContain("mcp__ade__memory_add"); + expect(tools).toEqual(["Read", "Glob", "Grep"]); }); - it("ask_user respects custom server name", () => { - const tools = buildClaudeReadOnlyWorkerAllowedTools("custom_server"); - expect(tools).toContain("mcp__custom_server__ask_user"); - expect(tools).toContain("mcp__custom_server__memory_search"); - expect(tools).toContain("mcp__custom_server__memory_add"); + it("deduplicates caller-provided extra read-only tools", () => { + const tools = buildClaudeReadOnlyWorkerAllowedTools(["Read", "NotebookRead"]); + expect(tools).toEqual(["Read", "Glob", "Grep", "NotebookRead"]); }); - it("memory tools are listed after ask_user (ordering preserved)", () => { - const tools = buildClaudeReadOnlyWorkerAllowedTools(); - const reportResultIndex = tools.indexOf("mcp__ade__report_result"); - const askUserIndex = tools.indexOf("mcp__ade__ask_user"); - const memorySearchIndex = tools.indexOf("mcp__ade__memory_search"); - const memoryAddIndex = tools.indexOf("mcp__ade__memory_add"); - expect(reportResultIndex).toBeGreaterThanOrEqual(0); - expect(askUserIndex).toBeGreaterThan(reportResultIndex); - expect(memorySearchIndex).toBeGreaterThan(askUserIndex); - expect(memoryAddIndex).toBeGreaterThan(memorySearchIndex); - }); }); // ───────────────────────────────────────────────────────────────── diff --git a/apps/desktop/src/main/services/orchestrator/promptInspector.ts b/apps/desktop/src/main/services/orchestrator/promptInspector.ts index 10669483f..dc080d901 100644 --- a/apps/desktop/src/main/services/orchestrator/promptInspector.ts +++ b/apps/desktop/src/main/services/orchestrator/promptInspector.ts @@ -176,6 +176,8 @@ function buildWorkerBaseGuidance(step: OrchestratorStep, graph: OrchestratorRunG if (planView) sections.push(planView); sections.push( [ + "ADE CLI: In terminal-capable sessions, use the bundled `ade` command for internal ADE actions. Run `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands such as `ade lanes list --text` or `ade prs checks --text` first, and `ade actions run ...` as the escape hatch. Use `--json` for structured output and `--text` for readable output.", + "", "Work style:", "- If you discover information relevant to other steps (API changes, schema updates, config requirements), include it in your output summary.", "- If you hit a blocker you can work around safely, work around it and note what you did.", @@ -209,9 +211,9 @@ function buildWorkerBaseGuidance(step: OrchestratorStep, graph: OrchestratorRunG ); sections.push( [ - "ADE MCP TOOLS: You have access to the ADE MCP server which provides team collaboration tools.", + "ADE TOOLING: Use ADE's action surface or the `ade` CLI for team collaboration commands when available.", "Your worker identity (mission, run, step, attempt IDs) is automatically resolved — you don't need to pass IDs to observation tools.", - "Key tools available:", + "Key actions available:", "- get_worker_states", "- get_run_graph", "- get_mission", @@ -608,7 +610,11 @@ export function buildCoordinatorPromptInspector(args: { source: "runtime_context", sourceKind: "live_effective_prompt", editable: false, - text: providersSection, + text: [ + providersSection, + "", + "ADE CLI: Terminal-capable workers can use the bundled `ade` command for internal ADE actions. Instruct them to run `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands such as `ade lanes list --text` or `ade prs checks --text` first, and `ade actions run ...` as the escape hatch. Use `--json` for structured output and `--text` for readable output.", + ].join("\n"), description: "Runtime availability context for worker spawning.", }); diff --git a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts index 8c40a628a..c87bc3f6a 100644 --- a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts @@ -14,7 +14,6 @@ import { import type { AgentChatExecutionMode, AgentChatPermissionMode, - ComputerUsePolicy, TeamRuntimeConfig, } from "../../../shared/types"; import type { MissionPermissionConfig, MissionProviderPermissions } from "../../../shared/types/missions"; @@ -27,11 +26,10 @@ import { normalizeMissionPermissions, providerPermissionsToLegacyConfig, } from "./permissionMapping"; -import { type AdeMcpLaunch, resolveDesktopAdeMcpLaunch, resolveRepoRuntimeRoot } from "../runtime/adeMcpLaunch"; /** * Build environment variable assignments for worker identity. - * These env vars allow the MCP server to auto-populate caller context. + * These env vars allow ADE-aware CLIs and child processes to resolve caller context. */ function buildWorkerEnvVars(args: { missionId: string; @@ -56,25 +54,6 @@ function resolveWorkerOwnerId(metadata: Record | null | undefin : null; } -export function resolveAdeMcpServerLaunch(args: { - projectRoot: string; - workspaceRoot: string; - workspaceBinding?: "explicit" | "project_root"; - runtimeRoot: string; - missionId?: string; - runId?: string; - stepId?: string; - attemptId?: string; - chatSessionId?: string; - defaultRole?: string; - ownerId?: string; - computerUsePolicy?: ComputerUsePolicy | null; - bundledProxyPath?: string; - preferBundledProxy?: boolean; -}): AdeMcpLaunch { - return resolveDesktopAdeMcpLaunch(args); -} - export function getProviderAdapterUnsupportedModelReason(modelRef: string): string | null { const descriptor = resolveModelDescriptor(modelRef); if (!descriptor) { @@ -86,56 +65,6 @@ export function getProviderAdapterUnsupportedModelReason(modelRef: string): stri return `Model '${descriptor.id}' requires ${executionPath} provider-owned execution (${descriptor.family}), but the shell-startup fallback only supports Claude/Codex CLI models. Use the managed OpenCode path for API and local models.`; } -/** - * Write a temporary MCP config JSON file for Claude CLI's --mcp-config flag. - * The config tells Claude CLI to connect to the ADE MCP server via stdio. - */ -function writeMcpConfigFile(args: { - projectRoot: string; - workspaceRoot: string; - runtimeRoot: string; - runId: string; - attemptId: string; - missionId: string; - stepId: string; - ownerId?: string | null; -}): string { - const configDir = resolveAdeLayout(args.projectRoot).mcpConfigsDir; - fs.mkdirSync(configDir, { recursive: true }); - - const configPath = path.join(configDir, `worker-${args.attemptId}.json`); - - const launch = resolveAdeMcpServerLaunch({ - projectRoot: args.projectRoot, - workspaceRoot: args.workspaceRoot, - runtimeRoot: args.runtimeRoot, - missionId: args.missionId, - runId: args.runId, - stepId: args.stepId, - attemptId: args.attemptId, - defaultRole: "agent", - ownerId: args.ownerId ?? undefined, - }); - - const config = { - mcpServers: { - ade: { - command: launch.command, - args: launch.cmdArgs, - env: launch.env - } - } - }; - - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8"); - return configPath; -} - -function workerLocalMcpConfigFileName(attemptId: string): string { - const sanitized = attemptId.replace(/[^a-zA-Z0-9_-]/g, "_"); - return `.ade-worker-mcp-${sanitized}.json`; -} - function workerPromptFilePath(projectRoot: string, attemptId: string): string { return path.join(resolveAdeLayout(projectRoot).workerPromptsDir, `worker-${attemptId}.txt`); } @@ -146,22 +75,6 @@ const CLAUDE_READ_ONLY_NATIVE_TOOLS = [ "Grep", ] as const; -const CLAUDE_READ_ONLY_WORKER_MCP_TOOLS = [ - "mcp__ade__get_mission", - "mcp__ade__get_run_graph", - "mcp__ade__stream_events", - "mcp__ade__get_timeline", - "mcp__ade__get_pending_messages", - "mcp__ade__get_computer_use_backend_status", - "mcp__ade__list_computer_use_artifacts", - "mcp__ade__ingest_computer_use_artifacts", - "mcp__ade__report_status", - "mcp__ade__report_result", - "mcp__ade__ask_user", - "mcp__ade__memory_search", - "mcp__ade__memory_add", -] as const; - function dedupeAllowedTools(entries: readonly string[]): string[] { const seen = new Set(); const out: string[] = []; @@ -174,16 +87,10 @@ function dedupeAllowedTools(entries: readonly string[]): string[] { return out; } -export function buildClaudeReadOnlyWorkerAllowedTools(serverName = "ade", extraToolNames: readonly string[] = []): string[] { - const trimmedServerName = serverName.trim(); - const resolvedServerName = trimmedServerName.length > 0 ? trimmedServerName : "ade"; - const mcpTools = CLAUDE_READ_ONLY_WORKER_MCP_TOOLS.map((tool) => - tool.replace("mcp__ade__", `mcp__${resolvedServerName}__`), - ); +export function buildClaudeReadOnlyWorkerAllowedTools(extraToolNames: readonly string[] = []): string[] { return dedupeAllowedTools([ ...CLAUDE_READ_ONLY_NATIVE_TOOLS, - ...mcpTools, - ...extraToolNames.map((tool) => `mcp__${resolvedServerName}__${tool}`), + ...extraToolNames, ]); } @@ -198,76 +105,30 @@ function writeWorkerPromptFile(args: { return promptPath; } -/** Resolve the monorepo runtime root (delegates to the shared adeMcpLaunch resolver). */ export function resolveOpenCodeRuntimeRoot(): string { - return resolveRepoRuntimeRoot(); -} - -/** - * Build Codex CLI `-c` config override flags to inject the ADE MCP server. - * Codex reads MCP servers from `mcp_servers.` in its config, and the - * `-c key=value` flag overrides individual dotted TOML paths. - */ -export function buildCodexMcpConfigFlags(args: { - projectRoot: string; - workspaceRoot: string; - runtimeRoot: string; - missionId?: string; - runId?: string; - stepId?: string; - attemptId?: string; - ownerId?: string | null; - defaultRole?: string; - bundledProxyPath?: string; - preferBundledProxy?: boolean; -}): string[] { - const launch = resolveAdeMcpServerLaunch({ - projectRoot: args.projectRoot, - workspaceRoot: args.workspaceRoot, - runtimeRoot: args.runtimeRoot, - missionId: args.missionId, - runId: args.runId, - stepId: args.stepId, - attemptId: args.attemptId, - defaultRole: args.defaultRole ?? "agent", - ownerId: args.ownerId ?? undefined, - bundledProxyPath: args.bundledProxyPath, - preferBundledProxy: args.preferBundledProxy, - }); - - // Codex -c flag parses values as TOML - const argsToml = `[${launch.cmdArgs.map((a) => `"${a.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`).join(", ")}]`; - const flags: string[] = [ - "-c", shellEscapeArg(`mcp_servers.ade.command="${launch.command}"`), - "-c", shellEscapeArg(`mcp_servers.ade.args=${argsToml}`), - ...Object.entries(launch.env) - .filter(([, value]) => value.trim().length > 0) - .flatMap(([key, value]) => [ - "-c", - shellEscapeArg(`mcp_servers.ade.env.${key}="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`), - ]), + const startPoints = [ + process.cwd(), + __dirname, + path.resolve(process.cwd(), ".."), + path.resolve(process.cwd(), "..", ".."), ]; - return flags; -} -/** - * Remove a single worker MCP config file created by writeMcpConfigFile. - */ -export function cleanupMcpConfigFile(projectRoot: string, attemptId: string, laneWorktreePath?: string | null): void { - const configPath = path.join(resolveAdeLayout(projectRoot).mcpConfigsDir, `worker-${attemptId}.json`); - try { - fs.unlinkSync(configPath); - } catch { - // Ignore — file may already be removed or never created - } - const localConfigName = workerLocalMcpConfigFileName(attemptId); - if (laneWorktreePath && laneWorktreePath.trim().length > 0) { - try { - fs.unlinkSync(path.join(laneWorktreePath, localConfigName)); - } catch { - // Ignore — lane-local config may not exist. + for (const start of startPoints) { + let dir = path.resolve(start); + for (let i = 0; i < 12; i += 1) { + if (fs.existsSync(path.join(dir, "apps", "ade-cli", "package.json"))) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; } } + + return path.resolve(process.cwd()); +} + +export function cleanupWorkerRuntimeFiles(projectRoot: string, attemptId: string, _laneWorktreePath?: string | null): void { try { fs.unlinkSync(workerPromptFilePath(projectRoot, attemptId)); } catch { @@ -276,7 +137,7 @@ export function cleanupMcpConfigFile(projectRoot: string, attemptId: string, lan } /** - * Remove all stale MCP config files from previous runs. + * Remove stale worker prompt files from previous runs. * Called at adapter creation time. */ function cleanupStaleFilesInDir(dir: string, prefix: string, suffix: string): void { @@ -291,12 +152,8 @@ function cleanupStaleFilesInDir(dir: string, prefix: string, suffix: string): vo } } -function cleanupStaleMcpConfigFiles(projectRoot: string): void { +function cleanupStaleWorkerRuntimeFiles(projectRoot: string): void { const layout = resolveAdeLayout(projectRoot); - cleanupStaleFilesInDir( - layout.mcpConfigsDir, - "worker-", ".json", - ); cleanupStaleFilesInDir( layout.workerPromptsDir, "worker-", ".txt", @@ -421,9 +278,6 @@ export function createProviderOrchestratorAdapter(options?: { workspaceRoot?: string; runtimeRoot?: string; agentChatService?: ReturnType | null; - externalMcpService?: { - getSnapshots: () => Array<{ tools: Array<{ namespacedName: string; enabled: boolean; safety: "read" | "write" | "unknown" }> }>; - } | null; }): OrchestratorExecutorAdapter { const runtimeRoot = typeof options?.runtimeRoot === "string" && options.runtimeRoot.trim().length ? options.runtimeRoot.trim() @@ -435,10 +289,7 @@ export function createProviderOrchestratorAdapter(options?: { ? options.workspaceRoot.trim() : (projectRoot ?? runtimeRoot); const canonicalProjectRoot = projectRoot ?? workspaceRoot; - const externalMcpService = options?.externalMcpService ?? null; - - // Clean up stale MCP config files from previous runs - cleanupStaleMcpConfigFiles(canonicalProjectRoot); + cleanupStaleWorkerRuntimeFiles(canonicalProjectRoot); const shellAdapter = createBaseOrchestratorAdapter({ executorKind: "opencode", @@ -463,21 +314,6 @@ export function createProviderOrchestratorAdapter(options?: { attemptId: attempt.id, ownerId: resolveWorkerOwnerId(run.metadata), }); - const workerOwnerId = resolveWorkerOwnerId(run.metadata); - const laneWorkspaceRoot = typeof step.metadata?.laneWorktreePath === "string" && step.metadata.laneWorktreePath.trim().length > 0 - ? step.metadata.laneWorktreePath.trim() - : workspaceRoot; - const mcpIdentity = { - projectRoot: canonicalProjectRoot, - workspaceRoot: laneWorkspaceRoot, - runtimeRoot, - missionId: run.missionId, - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - ownerId: workerOwnerId, - }; - // Determine which CLI to use based on the model if (descriptor?.isCliWrapped && descriptor.family === "anthropic") { // Claude CLI path — use per-provider permission when available @@ -490,15 +326,8 @@ export function createProviderOrchestratorAdapter(options?: { : mappedClaude; const configuredAllowedTools = effectivePermissionConfig?._providers?.allowedTools ?? effectivePermissionConfig?.cli?.allowedTools ?? []; - const readOnlyExternalTools = externalMcpService - ? externalMcpService - .getSnapshots() - .flatMap((snapshot) => snapshot.tools) - .filter((tool) => tool.enabled && tool.safety !== "write") - .map((tool) => tool.namespacedName) - : []; const allowedTools = readOnlyExecution - ? buildClaudeReadOnlyWorkerAllowedTools("ade", readOnlyExternalTools) + ? buildClaudeReadOnlyWorkerAllowedTools() : dedupeAllowedTools(configuredAllowedTools); const parts: string[] = ["claude", "--model", shellEscapeArg(cliModel)]; @@ -513,16 +342,11 @@ export function createProviderOrchestratorAdapter(options?: { parts.push("--allowedTools", shellEscapeArg(allowedTools.join(","))); } - // Bind ADE MCP server to worker via --mcp-config. Mirror config into worker CWD - // so Claude native teammates inherit an MCP config path available from that directory. - const mcpConfigPath = writeMcpConfigFile(mcpIdentity); - const localMcpConfigName = workerLocalMcpConfigFileName(attempt.id); const promptFilePath = writeWorkerPromptFile({ projectRoot: canonicalProjectRoot, attemptId: attempt.id, prompt, }); - parts.push("--mcp-config", shellEscapeArg(localMcpConfigName)); parts.push("-p", `"$(cat ${shellEscapeArg(promptFilePath)})"`); const envParts: string[] = [...workerEnv]; @@ -535,8 +359,7 @@ export function createProviderOrchestratorAdapter(options?: { } const cmd = parts.join(" "); - const copyMcpIntoCwd = `cp ${shellEscapeArg(mcpConfigPath)} ${shellEscapeArg(localMcpConfigName)}`; - const startup = `${copyMcpIntoCwd} && exec ${cmd}`; + const startup = `exec ${cmd}`; return envParts.length > 0 ? `${envParts.join(" ")} ${startup}` : startup; } @@ -556,9 +379,6 @@ export function createProviderOrchestratorAdapter(options?: { "-s", shellEscapeArg(sandboxMode) ]; - // Inject ADE MCP server config via -c overrides - parts.push(...buildCodexMcpConfigFlags(mcpIdentity)); - parts.push("exec"); for (const wp of writablePaths) { diff --git a/apps/desktop/src/main/services/orchestrator/teamRuntimeConfig.ts b/apps/desktop/src/main/services/orchestrator/teamRuntimeConfig.ts index e5454dabd..7568839c5 100644 --- a/apps/desktop/src/main/services/orchestrator/teamRuntimeConfig.ts +++ b/apps/desktop/src/main/services/orchestrator/teamRuntimeConfig.ts @@ -102,7 +102,7 @@ export function normalizeAgentRuntimeFlags( export function toClampedToolProfileMap(value: unknown): TeamRuntimeConfig["toolProfiles"] { if (!isRecord(value)) return undefined; - const out: Record = {}; + const out: Record = {}; for (const [key, raw] of Object.entries(value)) { if (!isRecord(raw)) continue; const allowedTools = Array.isArray(raw.allowedTools) @@ -112,13 +112,9 @@ export function toClampedToolProfileMap(value: unknown): TeamRuntimeConfig["tool const blockedTools = Array.isArray(raw.blockedTools) ? raw.blockedTools.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0) : undefined; - const mcpServers = Array.isArray(raw.mcpServers) - ? raw.mcpServers.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0) - : undefined; out[key] = { allowedTools, ...(blockedTools && blockedTools.length > 0 ? { blockedTools } : {}), - ...(mcpServers && mcpServers.length > 0 ? { mcpServers } : {}), ...(typeof raw.notes === "string" && raw.notes.trim().length > 0 ? { notes: raw.notes.trim() } : {}) }; } @@ -251,11 +247,6 @@ export function resolveMissionTeamRuntime( ...normalizeAgentRuntimeFlags(teamRuntime as Partial), template, toolProfiles: toClampedToolProfileMap(teamRuntime.toolProfiles), - mcpServerAllowlist: Array.isArray(teamRuntime.mcpServerAllowlist) - ? (teamRuntime.mcpServerAllowlist as unknown[]) - .map((entry: unknown) => String(entry ?? "").trim()) - .filter((entry) => entry.length > 0) - : undefined, policyOverrides: parsePolicyFlags(teamRuntime.policyOverrides) }; } diff --git a/apps/desktop/src/main/services/projects/adeProjectService.ts b/apps/desktop/src/main/services/projects/adeProjectService.ts index 111254ae4..43127817c 100644 --- a/apps/desktop/src/main/services/projects/adeProjectService.ts +++ b/apps/desktop/src/main/services/projects/adeProjectService.ts @@ -74,11 +74,6 @@ const DEFAULT_CTO_IDENTITY = YAML.stringify( preCompactionFlush: true, temporalDecayHalfLifeDays: 30, }, - externalMcpAccess: { - allowAll: true, - allowedServers: [], - blockedServers: [], - }, openclawContextPolicy: { shareMode: "filtered", blockedCategories: ["secret", "token", "system_prompt"], diff --git a/apps/desktop/src/main/services/projects/configReloadService.ts b/apps/desktop/src/main/services/projects/configReloadService.ts index fe07dd752..19a4bbada 100644 --- a/apps/desktop/src/main/services/projects/configReloadService.ts +++ b/apps/desktop/src/main/services/projects/configReloadService.ts @@ -21,9 +21,6 @@ type ConfigReloadServiceArgs = { secretService?: { reload?: () => unknown; } | null; - externalMcpService?: { - reload?: () => unknown; - } | null; logger?: Logger | null; onEvent?: (event: AdeProjectEvent) => void; }; @@ -67,14 +64,6 @@ export function createConfigReloadService(args: ConfigReloadServiceArgs) { error: error instanceof Error ? error.message : String(error), }); } - try { - args.externalMcpService?.reload?.(); - } catch (error) { - args.logger?.warn("project.config_reload.external_mcp_reload_failed", { - filePath, - error: error instanceof Error ? error.message : String(error), - }); - } } try { diff --git a/apps/desktop/src/main/services/prs/prIssueResolver.test.ts b/apps/desktop/src/main/services/prs/prIssueResolver.test.ts index c27b131a4..7cd0d2520 100644 --- a/apps/desktop/src/main/services/prs/prIssueResolver.test.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolver.test.ts @@ -344,7 +344,7 @@ describe("launchPrIssueResolutionChat", () => { expect(sendMessage).not.toHaveBeenCalled(); }); - it("uses ADE MCP PR tools in the prompt for Codex launches", async () => { + it("uses ADE CLI PR commands in the prompt for Codex launches", async () => { const { deps, pr } = makeDeps(); const result = await previewPrIssueResolutionPrompt(deps as any, { @@ -356,18 +356,15 @@ describe("launchPrIssueResolutionChat", () => { additionalInstructions: null, }); - expect(result.prompt).toContain("Runtime: Codex chat via ADE MCP"); - expect(result.prompt).toContain("mcp__ade__pr_refresh_issue_inventory"); - expect(result.prompt).toContain("mcp__ade__pr_get_review_comments"); - expect(result.prompt).toContain("mcp__ade__pr_resolve_review_thread"); - expect(result.prompt).toContain("ADE PR tools are runtime tool calls, not shell commands."); - expect(result.prompt).toContain("Some bridges may also expose the base tool names like `pr_refresh_issue_inventory` and `pr_get_review_comments`."); - expect(result.prompt).toContain("Use whichever variant is actually exposed in the live tool list for this chat runtime."); - expect(result.prompt).toContain("Immediately after that, call `mcp__ade__pr_get_review_comments`"); + expect(result.prompt).toContain("Runtime: Codex chat via ADE CLI"); + expect(result.prompt).toContain("ade prs inventory"); + expect(result.prompt).toContain("ade prs comments"); + expect(result.prompt).toContain("ade prs resolve-thread"); + expect(result.prompt).toContain("This runtime can use the ADE CLI"); + expect(result.prompt).toContain("Immediately after that, run `ade prs comments"); expect(result.prompt).toContain("Treat the refreshed inventory as a triage index"); expect(result.prompt).toContain("Do not spend your first steps reading local skill docs"); - expect(result.prompt).toContain("Do not probe tool availability with `which`, `command -v`, `.mcp.json`, or project settings files"); - expect(result.prompt).toContain("Do not conclude the PR tools are missing just because one naming variant is absent."); + expect(result.prompt).toContain("instead of reverse-engineering ADE internals"); expect(result.prompt).not.toContain("prRefreshIssueInventory"); }); @@ -508,7 +505,7 @@ describe("launchPrIssueResolutionChat", () => { expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "session-claude", executionMode: "subagents", - text: expect.stringContaining("Runtime: Claude chat via ADE MCP"), + text: expect.stringContaining("Runtime: Claude chat via ADE CLI"), })); expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ text: expect.stringContaining("Current unresolved review threads (detailed context)"), diff --git a/apps/desktop/src/main/services/prs/prIssueResolver.ts b/apps/desktop/src/main/services/prs/prIssueResolver.ts index 841f15931..27ee1b7da 100644 --- a/apps/desktop/src/main/services/prs/prIssueResolver.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolver.ts @@ -61,7 +61,7 @@ type IssueResolutionPromptArgs = { detailedIssueContext?: boolean; }; -type PrIssueResolutionToolSurface = "workflow_tools" | "ade_mcp" | "prompt_only"; +type PrIssueResolutionToolSurface = "workflow_tools" | "ade_cli" | "prompt_only"; type PrIssueResolutionRuntimeCapabilities = { runtimeLabel: string; @@ -342,29 +342,18 @@ function defaultPrIssueResolutionRuntimeCapabilities(): PrIssueResolutionRuntime }; } -const ADE_MCP_SERVER_NAME = "ade"; - -function qualifyAdeMcpToolName(toolName: string): string { - return `mcp__${ADE_MCP_SERVER_NAME}__${toolName}`; -} - -function unqualifyAdeMcpToolName(toolName: string | null): string | null { - if (!toolName) return null; - return toolName.replace(/^mcp__[^_]+__/, ""); -} - -function buildMcpToolCapabilities( +function buildCliToolCapabilities( runtimeLabel: string, executionMode: PrIssueResolutionRuntimeCapabilities["executionMode"], ): PrIssueResolutionRuntimeCapabilities { return { - refreshInventoryTool: qualifyAdeMcpToolName("pr_refresh_issue_inventory"), - getReviewCommentsTool: qualifyAdeMcpToolName("pr_get_review_comments"), - rerunChecksTool: qualifyAdeMcpToolName("pr_rerun_failed_checks"), - replyThreadTool: qualifyAdeMcpToolName("pr_reply_to_review_thread"), - resolveThreadTool: qualifyAdeMcpToolName("pr_resolve_review_thread"), + refreshInventoryTool: "ade prs inventory", + getReviewCommentsTool: "ade prs comments", + rerunChecksTool: "ade prs rerun", + replyThreadTool: "ade prs reply", + resolveThreadTool: "ade prs resolve-thread", runtimeLabel, - toolSurface: "ade_mcp", + toolSurface: "ade_cli", executionMode, }; } @@ -376,11 +365,11 @@ function resolvePrIssueResolutionRuntimeCapabilities(modelId: string | null | un } if (descriptor.isCliWrapped && descriptor.family === "openai") { - return buildMcpToolCapabilities("Codex chat via ADE MCP", "parallel"); + return buildCliToolCapabilities("Codex chat via ADE CLI", "parallel"); } if (descriptor.isCliWrapped && descriptor.family === "anthropic") { - return buildMcpToolCapabilities("Claude chat via ADE MCP", "subagents"); + return buildCliToolCapabilities("Claude chat via ADE CLI", "subagents"); } return defaultPrIssueResolutionRuntimeCapabilities(); @@ -535,21 +524,17 @@ export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): s "- No live ADE PR tools are available in this session. Use the detailed issue context in this prompt plus the linked GitHub thread/check URLs.", "- If you need fresher PR state than this prompt provides, fetch it manually before making changes.", ); - } else if (runtimeCapabilities.toolSurface === "ade_mcp") { - const toolList = listRequiredRuntimeTools(runtimeCapabilities).map((toolName) => `\`${toolName}\``).join(", "); - const refreshToolFallback = unqualifyAdeMcpToolName(runtimeCapabilities.refreshInventoryTool); - const commentsToolFallback = unqualifyAdeMcpToolName(runtimeCapabilities.getReviewCommentsTool); + } else if (runtimeCapabilities.toolSurface === "ade_cli") { promptSections.push( - `- This runtime uses ADE via MCP. ADE PR tools are runtime tool calls, not shell commands.`, - `- Primary PR tools for this run: ${toolList}. In many sessions they appear namespaced with the MCP server prefix, for example \`${runtimeCapabilities.refreshInventoryTool}\`. Some bridges may also expose the base tool names like \`${refreshToolFallback}\` and \`${commentsToolFallback}\`.`, - "- Use whichever variant is actually exposed in the live tool list for this chat runtime.", - `- Start by refreshing the PR issue inventory with \`${runtimeCapabilities.refreshInventoryTool}\`.`, - `- Immediately after that, call \`${runtimeCapabilities.getReviewCommentsTool}\` to load the full review-thread bodies and line context before deciding which comments are stale, valid, or already addressed.`, + "- This runtime can use the ADE CLI from the terminal for live PR state.", + "- Run `ade doctor` if readiness is unclear. Use `--json` for structured output and `--text` for readable summaries.", + "- Discover additional ADE actions with `ade actions list --text`; prefer typed PR commands first and `ade actions run ...` only as an escape hatch.", + `- Start by refreshing the PR issue inventory with \`${runtimeCapabilities.refreshInventoryTool} ${args.pr.id}\`.`, + `- Immediately after that, run \`${runtimeCapabilities.getReviewCommentsTool} ${args.pr.id}\` to load the full review-thread bodies and line context before deciding which comments are stale, valid, or already addressed.`, + `- Reply to review threads with \`${runtimeCapabilities.replyThreadTool} ${args.pr.id} --thread --body \` and resolve completed threads with \`${runtimeCapabilities.resolveThreadTool} ${args.pr.id} --thread \`.`, "- Treat the refreshed inventory as a triage index, not as the full source of truth for long comment bodies. If a summary looks compact or truncated, fetch the detailed review comments instead of guessing.", "- Do not spend your first steps reading local skill docs, repo docs, or unrelated files before those PR context calls succeed.", - "- Do not probe tool availability with `which`, `command -v`, `.mcp.json`, or project settings files from inside the task session.", - "- If one of those MCP tools is unavailable in-session, continue with the prompt's issue context and the linked GitHub thread/check URLs instead of reverse-engineering local MCP wiring.", - "- Do not conclude the PR tools are missing just because one naming variant is absent.", + "- If the ADE CLI is unavailable in-session, continue with the prompt's issue context and linked GitHub thread/check URLs instead of reverse-engineering ADE internals.", ); } else { const toolList = listRequiredRuntimeTools(runtimeCapabilities).map((toolName) => `\`${toolName}\``).join(", "); @@ -559,7 +544,7 @@ export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): s "- Treat the refreshed inventory as a triage index, not as the full source of truth for long comment bodies. If a summary looks compact or truncated, fetch the detailed review comments instead of guessing.", "- Do not spend your first steps reading local skill docs, repo docs, or unrelated files before those PR context calls succeed.", `- Required PR tools for this run: ${toolList}. If any of them are unavailable, stop and report that the chat was launched without the required ADE PR tools.`, - "- Do not waste time reverse-engineering local MCP wiring or local server bootstraps from inside the task session.", + "- Do not waste time reverse-engineering ADE runtime wiring from inside the task session.", ); } diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index f88081191..52fffed80 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -98,17 +98,6 @@ const mocks = vi.hoisted(() => { writeFileSync: vi.fn(), randomUUID: vi.fn(() => "uuid-" + Math.random().toString(36).slice(2, 10)), runGit: vi.fn(async () => ({ exitCode: 0, stdout: "abc123\n", stderr: "" })), - resolveAdeLayout: vi.fn((root: string) => ({ - mcpConfigsDir: `${root}/.ade/mcp-configs`, - })), - buildCodexMcpConfigFlags: vi.fn(() => []), - resolveAdeMcpServerLaunch: vi.fn(() => ({ - command: "npx", - cmdArgs: ["tsx", "index.ts"], - env: {}, - })), - resolveOpenCodeRuntimeRoot: vi.fn(() => "/tmp/ade-runtime"), - shellEscapeArg: vi.fn((v: string) => `'${v}'`), stripAnsi: vi.fn((t: string) => t), summarizeTerminalSession: vi.fn(() => "test summary"), derivePreviewFromChunk: vi.fn(() => ({ nextLine: "", preview: "preview" })), @@ -156,20 +145,6 @@ vi.mock("../git/git", () => ({ runGit: mocks.runGit, })); -vi.mock("../../../shared/adeLayout", () => ({ - resolveAdeLayout: mocks.resolveAdeLayout, -})); - -vi.mock("../orchestrator/providerOrchestratorAdapter", () => ({ - buildCodexMcpConfigFlags: mocks.buildCodexMcpConfigFlags, - resolveAdeMcpServerLaunch: mocks.resolveAdeMcpServerLaunch, - resolveOpenCodeRuntimeRoot: mocks.resolveOpenCodeRuntimeRoot, -})); - -vi.mock("../orchestrator/baseOrchestratorAdapter", () => ({ - shellEscapeArg: mocks.shellEscapeArg, -})); - vi.mock("../../utils/ansiStrip", () => ({ stripAnsi: mocks.stripAnsi, })); @@ -315,7 +290,6 @@ function createHarness(overrides: { const service = createPtyService({ projectRoot: "/tmp/test-project", transcriptsDir: "/tmp/transcripts", - chatSessionsDir: "/tmp/chat-sessions", laneService: laneService as any, sessionService: sessionService as any, ...(overrides.aiIntegrationService ? { aiIntegrationService: overrides.aiIntegrationService as any } : {}), @@ -409,7 +383,6 @@ describe("ptyService", () => { const ptyService = createPtyService({ projectRoot: "/tmp/test-project", transcriptsDir: "/tmp/transcripts", - chatSessionsDir: "/tmp/chat-sessions", laneService: harness.laneService as any, sessionService: harness.sessionService as any, getLaneRuntimeEnv, @@ -1129,9 +1102,9 @@ describe("ptyService", () => { expect(() => service.dispose({ ptyId })).not.toThrow(); }); - it("removes per-session MCP config artifacts when a tool session is manually closed", async () => { + it("does not create per-session ADE tool config artifacts for tool sessions", async () => { const { service } = createHarness(); - const { ptyId, sessionId } = await service.create({ + const { ptyId } = await service.create({ laneId: "lane-1", title: "Claude session", cols: 80, @@ -1142,7 +1115,11 @@ describe("ptyService", () => { service.dispose({ ptyId }); - expect(mocks.unlinkSync).toHaveBeenCalledWith(`/tmp/test-project/.ade/mcp-configs/terminal-${sessionId}.json`); + expect(mocks.writeFileSync).not.toHaveBeenCalledWith( + expect.stringContaining("agent-configs"), + expect.anything(), + expect.anything(), + ); }); it("handles orphaned sessions (PTY not in map but session exists)", async () => { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 4c38dab3b..933ea6633 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -11,9 +11,6 @@ import type { createSessionService } from "../sessions/sessionService"; import type { createAiIntegrationService } from "../ai/aiIntegrationService"; import type { createProjectConfigService } from "../config/projectConfigService"; import { runGit } from "../git/git"; -import { resolveAdeLayout } from "../../../shared/adeLayout"; -import { buildCodexMcpConfigFlags, resolveAdeMcpServerLaunch, resolveOpenCodeRuntimeRoot } from "../orchestrator/providerOrchestratorAdapter"; -import { shellEscapeArg } from "../orchestrator/baseOrchestratorAdapter"; import type { PtyDataEvent, PtyExitEvent, @@ -216,80 +213,9 @@ function inferSessionCwdFromTranscriptPath(transcriptPath: string | null | undef const MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024; const TRANSCRIPT_LIMIT_NOTICE = "\n[ADE] transcript limit reached (8MB). Further output omitted.\n"; -function writeExternalClaudeMcpConfig(args: { - projectRoot: string; - workspaceRoot: string; - sessionId: string; -}): string { - const runtimeRoot = resolveOpenCodeRuntimeRoot(); - const launch = resolveAdeMcpServerLaunch({ - projectRoot: args.projectRoot, - workspaceRoot: args.workspaceRoot, - runtimeRoot, - runId: args.sessionId, - attemptId: args.sessionId, - defaultRole: "external", - }); - const configDir = resolveAdeLayout(args.projectRoot).mcpConfigsDir; - fs.mkdirSync(configDir, { recursive: true }); - const configPath = path.join(configDir, `terminal-${args.sessionId}.json`); - fs.writeFileSync( - configPath, - JSON.stringify({ - mcpServers: { - ade: { - command: launch.command, - args: launch.cmdArgs, - env: launch.env, - }, - }, - }, null, 2), - "utf8", - ); - return configPath; -} - -function enrichStartupCommandForAdeMcp(args: { - projectRoot: string; - workspaceRoot: string; - toolType: TerminalToolType | null; - sessionId: string; - startupCommand: string; -}): { startupCommand: string; cleanupPaths: string[] } { - const trimmed = args.startupCommand.trim(); - if (!trimmed.length) return { startupCommand: trimmed, cleanupPaths: [] }; - if (args.toolType === "claude") { - const configPath = writeExternalClaudeMcpConfig({ - projectRoot: args.projectRoot, - workspaceRoot: args.workspaceRoot, - sessionId: args.sessionId, - }); - return { - startupCommand: `${trimmed} --mcp-config ${shellEscapeArg(configPath)}`, - cleanupPaths: [configPath], - }; - } - if (args.toolType === "codex") { - const flags = buildCodexMcpConfigFlags({ - projectRoot: args.projectRoot, - workspaceRoot: args.workspaceRoot, - runtimeRoot: resolveOpenCodeRuntimeRoot(), - runId: args.sessionId, - attemptId: args.sessionId, - defaultRole: "external", - }); - return { - startupCommand: `${trimmed} ${flags.join(" ")}`.trim(), - cleanupPaths: [], - }; - } - return { startupCommand: trimmed, cleanupPaths: [] }; -} - export function createPtyService({ projectRoot, transcriptsDir, - chatSessionsDir, laneService, sessionService, aiIntegrationService, @@ -304,7 +230,6 @@ export function createPtyService({ }: { projectRoot: string; transcriptsDir: string; - chatSessionsDir: string; laneService: ReturnType; sessionService: ReturnType; aiIntegrationService?: ReturnType; @@ -1129,14 +1054,8 @@ export function createPtyService({ const transcriptPath = tracked ? (existingSession?.transcriptPath?.trim() || safeTranscriptPathFor(sessionId)) : ""; - const enrichedLaunch = enrichStartupCommandForAdeMcp({ - projectRoot, - workspaceRoot: cwd, - toolType: toolTypeHint, - sessionId, - startupCommand: requestedStartupCommand, - }); - const startupCommand = enrichedLaunch.startupCommand; + const startupCommand = requestedStartupCommand.trim(); + const cleanupPaths: string[] = []; let transcriptStream: fs.WriteStream | null = null; let transcriptBytesWritten = 0; @@ -1244,7 +1163,7 @@ export function createPtyService({ resourcesPath: process.resourcesPath ?? "", err: String(err), }); - for (const cleanupPath of enrichedLaunch.cleanupPaths) { + for (const cleanupPath of cleanupPaths) { try { fs.unlinkSync(cleanupPath); } catch { @@ -1317,7 +1236,7 @@ export function createPtyService({ lastRuntimeSignalPreview: null, disposed: false, createdAt: Date.now(), - cleanupPaths: enrichedLaunch.cleanupPaths, + cleanupPaths, aiTitleTimer: null, cliUserTitleLineBuffer: "", cliUserTitleCommitted: false, diff --git a/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts b/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts deleted file mode 100644 index 1ee33dc5d..000000000 --- a/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { resolveExecutableFromKnownLocations } from "../ai/cliExecutableResolver"; -import { resolveDesktopAdeMcpLaunch, resolveRepoRuntimeRoot } from "./adeMcpLaunch"; - -const originalResourcesPath = process.resourcesPath; -const originalExecPath = process.execPath; -const originalPath = process.env.PATH; - -afterEach(() => { - Object.defineProperty(process, "resourcesPath", { - configurable: true, - value: originalResourcesPath, - }); - Object.defineProperty(process, "execPath", { - configurable: true, - value: originalExecPath, - }); - process.env.PATH = originalPath; -}); - -describe("resolveDesktopAdeMcpLaunch", () => { - it("prefers the bundled desktop MCP proxy when it is available", () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-")); - const workspaceRoot = path.join(projectRoot, "workspace"); - const proxyEntry = path.join(projectRoot, "dist", "main", "adeMcpProxy.cjs"); - fs.mkdirSync(workspaceRoot, { recursive: true }); - fs.mkdirSync(path.dirname(proxyEntry), { recursive: true }); - fs.writeFileSync(proxyEntry, "module.exports = {};\n", "utf8"); - - const launch = resolveDesktopAdeMcpLaunch({ - projectRoot, - workspaceRoot, - bundledProxyPath: proxyEntry, - }); - - expect(launch.mode).toBe("bundled_proxy"); - expect(launch.command).toBe(resolveExecutableFromKnownLocations("node")?.path ?? process.argv0 ?? process.execPath); - expect(launch.cmdArgs).toEqual([ - path.resolve(proxyEntry), - "--project-root", - path.resolve(projectRoot), - "--workspace-root", - path.resolve(workspaceRoot), - ]); - expect(launch.env).toMatchObject({ - ADE_PROJECT_ROOT: path.resolve(projectRoot), - ADE_WORKSPACE_ROOT: path.resolve(workspaceRoot), - ADE_MCP_SOCKET_PATH: path.join(path.resolve(projectRoot), ".ade", "mcp.sock"), - ELECTRON_RUN_AS_NODE: "1", - }); - }); - - it("falls back to the built headless MCP entry when bundled proxy launch is disabled", () => { - const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-runtime-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-")); - const workspaceRoot = path.join(projectRoot, "workspace"); - const builtEntry = path.join(runtimeRoot, "apps", "mcp-server", "dist", "index.cjs"); - - fs.mkdirSync(workspaceRoot, { recursive: true }); - fs.mkdirSync(path.dirname(builtEntry), { recursive: true }); - fs.writeFileSync(builtEntry, "module.exports = {};\n", "utf8"); - - const launch = resolveDesktopAdeMcpLaunch({ - projectRoot, - workspaceRoot, - runtimeRoot, - preferBundledProxy: false, - missionId: "mission-123", - runId: "run-456", - }); - - expect(launch.mode).toBe("headless_built"); - expect(launch.command).toBe("node"); - expect(launch.cmdArgs).toEqual([ - builtEntry, - "--project-root", - path.resolve(projectRoot), - "--workspace-root", - path.resolve(workspaceRoot), - ]); - expect(launch.env).toMatchObject({ - ADE_PROJECT_ROOT: path.resolve(projectRoot), - ADE_WORKSPACE_ROOT: path.resolve(workspaceRoot), - ADE_MISSION_ID: "mission-123", - ADE_RUN_ID: "run-456", - ADE_MCP_SOCKET_PATH: path.join(path.resolve(projectRoot), ".ade", "mcp.sock"), - }); - }); - - it("can bind workspace launch args to project root for shareable runtimes", () => { - const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-runtime-shared-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-shared-")); - const workspaceRoot = path.join(projectRoot, "workspace"); - const builtEntry = path.join(runtimeRoot, "apps", "mcp-server", "dist", "index.cjs"); - - fs.mkdirSync(workspaceRoot, { recursive: true }); - fs.mkdirSync(path.dirname(builtEntry), { recursive: true }); - fs.writeFileSync(builtEntry, "module.exports = {};\n", "utf8"); - - const launch = resolveDesktopAdeMcpLaunch({ - projectRoot, - workspaceRoot, - workspaceBinding: "project_root", - runtimeRoot, - preferBundledProxy: false, - }); - - expect(launch.cmdArgs).toEqual([ - builtEntry, - "--project-root", - path.resolve(projectRoot), - "--workspace-root", - path.resolve(projectRoot), - ]); - expect(launch.env.ADE_PROJECT_ROOT).toBe(path.resolve(projectRoot)); - expect(launch.env.ADE_WORKSPACE_ROOT).toBe(path.resolve(projectRoot)); - }); - - it("prefers the unpacked packaged proxy path over the asar path", () => { - const resourcesPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-resources-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-")); - const workspaceRoot = path.join(projectRoot, "workspace"); - const packagedProxy = path.join(resourcesPath, "app.asar.unpacked", "dist", "main", "adeMcpProxy.cjs"); - - fs.mkdirSync(workspaceRoot, { recursive: true }); - fs.mkdirSync(path.dirname(packagedProxy), { recursive: true }); - fs.writeFileSync(packagedProxy, "module.exports = {};\n", "utf8"); - Object.defineProperty(process, "resourcesPath", { - configurable: true, - value: resourcesPath, - }); - - const launch = resolveDesktopAdeMcpLaunch({ - projectRoot, - workspaceRoot, - }); - - expect(launch.mode).toBe("bundled_proxy"); - expect(launch.entryPath).toBe(packagedProxy); - expect(launch.cmdArgs[0]).toBe(packagedProxy); - }); - - it("falls back to headless source mode when no built entry exists", () => { - const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-runtime-src-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-src-")); - const workspaceRoot = path.join(projectRoot, "workspace"); - - fs.mkdirSync(workspaceRoot, { recursive: true }); - // Create the mcp-server src directory but NOT the dist directory - fs.mkdirSync(path.join(runtimeRoot, "apps", "mcp-server", "src"), { recursive: true }); - - const launch = resolveDesktopAdeMcpLaunch({ - projectRoot, - workspaceRoot, - runtimeRoot, - preferBundledProxy: false, - }); - - const expectedSrcEntry = path.join(runtimeRoot, "apps", "mcp-server", "src", "index.ts"); - expect(launch.mode).toBe("headless_source"); - expect(launch.command).toBe("npx"); - expect(launch.cmdArgs).toEqual([ - "tsx", - expectedSrcEntry, - "--project-root", - path.resolve(projectRoot), - "--workspace-root", - path.resolve(workspaceRoot), - ]); - expect(launch.entryPath).toBe(expectedSrcEntry); - expect(launch.runtimeRoot).toBe(path.resolve(runtimeRoot)); - }); - - it("requires an explicit non-empty projectRoot", () => { - const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-runtime-nopr-")); - const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-ws-nopr-")); - - expect(() => { - // @ts-expect-error: projectRoot is intentionally omitted for this runtime assertion. - resolveDesktopAdeMcpLaunch({ - workspaceRoot, - runtimeRoot, - preferBundledProxy: false, - }); - }).toThrow("ADE MCP launch requires a non-empty projectRoot."); - - expect(() => resolveDesktopAdeMcpLaunch({ - projectRoot: " ", - workspaceRoot, - runtimeRoot, - preferBundledProxy: false, - })).toThrow("ADE MCP launch requires a non-empty projectRoot."); - - expect(() => resolveDesktopAdeMcpLaunch({ - projectRoot: workspaceRoot, - workspaceRoot: " ", - runtimeRoot, - preferBundledProxy: false, - })).toThrow("ADE MCP launch requires a non-empty workspaceRoot."); - }); - - it("populates computerUsePolicy env vars when policy is provided", () => { - const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-runtime-cup-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-cup-")); - const workspaceRoot = path.join(projectRoot, "workspace"); - fs.mkdirSync(workspaceRoot, { recursive: true }); - - const launch = resolveDesktopAdeMcpLaunch({ - projectRoot, - workspaceRoot, - runtimeRoot, - preferBundledProxy: false, - computerUsePolicy: { - mode: "enabled", - allowLocalFallback: true, - retainArtifacts: false, - preferredBackend: "vnc", - }, - }); - - expect(launch.env.ADE_COMPUTER_USE_MODE).toBe("enabled"); - expect(launch.env.ADE_COMPUTER_USE_ALLOW_LOCAL_FALLBACK).toBe("1"); - expect(launch.env.ADE_COMPUTER_USE_RETAIN_ARTIFACTS).toBe("0"); - expect(launch.env.ADE_COMPUTER_USE_PREFERRED_BACKEND).toBe("vnc"); - }); - - it("leaves computerUsePolicy env vars empty when policy is null", () => { - const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-runtime-nocup-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-nocup-")); - const workspaceRoot = path.join(projectRoot, "workspace"); - fs.mkdirSync(workspaceRoot, { recursive: true }); - - const launch = resolveDesktopAdeMcpLaunch({ - projectRoot, - workspaceRoot, - runtimeRoot, - preferBundledProxy: false, - computerUsePolicy: null, - }); - - expect(launch.env.ADE_COMPUTER_USE_MODE).toBe(""); - expect(launch.env.ADE_COMPUTER_USE_ALLOW_LOCAL_FALLBACK).toBe(""); - expect(launch.env.ADE_COMPUTER_USE_RETAIN_ARTIFACTS).toBe(""); - expect(launch.env.ADE_COMPUTER_USE_PREFERRED_BACKEND).toBe(""); - }); - - it("sets ownerId and defaultRole in env when provided", () => { - const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-runtime-owner-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-owner-")); - const workspaceRoot = path.join(projectRoot, "workspace"); - fs.mkdirSync(workspaceRoot, { recursive: true }); - - const launch = resolveDesktopAdeMcpLaunch({ - projectRoot, - workspaceRoot, - runtimeRoot, - preferBundledProxy: false, - defaultRole: "cto", - ownerId: "agent-42", - }); - - expect(launch.env.ADE_DEFAULT_ROLE).toBe("cto"); - expect(launch.env.ADE_OWNER_ID).toBe("agent-42"); - }); - - it("defaults defaultRole to 'agent' and ownerId to empty when not provided", () => { - const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-runtime-noowner-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-noowner-")); - const workspaceRoot = path.join(projectRoot, "workspace"); - fs.mkdirSync(workspaceRoot, { recursive: true }); - - const launch = resolveDesktopAdeMcpLaunch({ - projectRoot, - workspaceRoot, - runtimeRoot, - preferBundledProxy: false, - }); - - expect(launch.env.ADE_DEFAULT_ROLE).toBe("agent"); - expect(launch.env.ADE_OWNER_ID).toBe(""); - }); - - it("bundled proxy mode preserves runtimeRoot when provided", () => { - const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-runtime-proxy-rt-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-proxy-rt-")); - const workspaceRoot = path.join(projectRoot, "workspace"); - const proxyEntry = path.join(projectRoot, "dist", "main", "adeMcpProxy.cjs"); - - fs.mkdirSync(workspaceRoot, { recursive: true }); - fs.mkdirSync(path.dirname(proxyEntry), { recursive: true }); - fs.writeFileSync(proxyEntry, "module.exports = {};\n", "utf8"); - - const launch = resolveDesktopAdeMcpLaunch({ - projectRoot, - workspaceRoot, - runtimeRoot, - bundledProxyPath: proxyEntry, - }); - - expect(launch.mode).toBe("bundled_proxy"); - expect(launch.runtimeRoot).toBe(path.resolve(runtimeRoot)); - }); - - it("bundled proxy mode sets runtimeRoot to null when not provided", () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-proxy-nort-")); - const workspaceRoot = path.join(projectRoot, "workspace"); - const proxyEntry = path.join(projectRoot, "dist", "main", "adeMcpProxy.cjs"); - - fs.mkdirSync(workspaceRoot, { recursive: true }); - fs.mkdirSync(path.dirname(proxyEntry), { recursive: true }); - fs.writeFileSync(proxyEntry, "module.exports = {};\n", "utf8"); - - const launch = resolveDesktopAdeMcpLaunch({ - projectRoot, - workspaceRoot, - bundledProxyPath: proxyEntry, - }); - - expect(launch.mode).toBe("bundled_proxy"); - expect(launch.runtimeRoot).toBeNull(); - }); - - it("falls back to a resolvable node executable when process.execPath is stale", () => { - const tempBinDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-node-bin-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-stale-")); - const workspaceRoot = path.join(projectRoot, "workspace"); - const proxyEntry = path.join(projectRoot, "dist", "main", "adeMcpProxy.cjs"); - const fakeNode = path.join(tempBinDir, "node"); - - fs.mkdirSync(workspaceRoot, { recursive: true }); - fs.mkdirSync(path.dirname(proxyEntry), { recursive: true }); - fs.writeFileSync(proxyEntry, "module.exports = {};\n", "utf8"); - fs.writeFileSync(fakeNode, "#!/bin/sh\nexit 0\n", { encoding: "utf8", mode: 0o755 }); - Object.defineProperty(process, "execPath", { - configurable: true, - value: path.join(tempBinDir, "missing-node"), - }); - process.env.PATH = `${tempBinDir}${path.delimiter}${originalPath ?? ""}`; - - const launch = resolveDesktopAdeMcpLaunch({ - projectRoot, - workspaceRoot, - bundledProxyPath: proxyEntry, - }); - - expect(launch.mode).toBe("bundled_proxy"); - expect(launch.command).toBe(fakeNode); - }); -}); - -describe("resolveRepoRuntimeRoot", () => { - it("returns a string path that is a resolved absolute path", () => { - const root = resolveRepoRuntimeRoot(); - expect(typeof root).toBe("string"); - expect(path.isAbsolute(root)).toBe(true); - }); - - it("finds the monorepo root when apps/mcp-server/package.json exists above cwd", () => { - // The ADE project itself has this structure, so running in the repo should find it - const root = resolveRepoRuntimeRoot(); - // The function should find a directory containing apps/mcp-server/package.json - // or fall back to cwd. Either way, it returns a valid path. - expect(fs.existsSync(root)).toBe(true); - }); -}); diff --git a/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts b/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts deleted file mode 100644 index 309700db8..000000000 --- a/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts +++ /dev/null @@ -1,227 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { resolveAdeLayout } from "../../../shared/adeLayout"; -import type { ComputerUsePolicy } from "../../../shared/types"; -import { resolveExecutableFromKnownLocations } from "../ai/cliExecutableResolver"; - -export type AdeMcpLaunchMode = "bundled_proxy" | "headless_built" | "headless_source"; -export type AdeMcpWorkspaceBinding = "explicit" | "project_root"; - -export type AdeMcpLaunch = { - mode: AdeMcpLaunchMode; - command: string; - cmdArgs: string[]; - env: Record; - entryPath: string; - runtimeRoot: string | null; - socketPath: string; - packaged: boolean; - resourcesPath: string | null; -}; - -export type DesktopAdeMcpLaunchArgs = { - projectRoot: string; - workspaceRoot: string; - workspaceBinding?: AdeMcpWorkspaceBinding; - runtimeRoot?: string; - missionId?: string; - runId?: string; - stepId?: string; - attemptId?: string; - chatSessionId?: string; - defaultRole?: string; - ownerId?: string; - computerUsePolicy?: ComputerUsePolicy | null; - bundledProxyPath?: string; - preferBundledProxy?: boolean; -}; - -function resolveRequiredRoot(value: unknown, label: "projectRoot" | "workspaceRoot"): string { - const trimmed = typeof value === "string" ? value.trim() : ""; - if (!trimmed.length) { - throw new Error(`ADE MCP launch requires a non-empty ${label}.`); - } - return path.resolve(trimmed); -} - -function pathExists(targetPath: string | null | undefined): targetPath is string { - return Boolean(targetPath && fs.existsSync(targetPath)); -} - -function resolveResourcesPath(): string | null { - return typeof process.resourcesPath === "string" && process.resourcesPath.trim().length > 0 - ? process.resourcesPath - : null; -} - -function resolveBundledProxyPath(overridePath?: string): string | null { - const resourcesPath = resolveResourcesPath(); - const candidates = [ - overridePath, - ...(resourcesPath - ? [ - path.join(resourcesPath, "app.asar.unpacked", "dist", "main", "adeMcpProxy.cjs"), - path.join(resourcesPath, "dist", "main", "adeMcpProxy.cjs"), - ] - : []), - path.join(__dirname, "adeMcpProxy.cjs"), - path.resolve(process.cwd(), "dist", "main", "adeMcpProxy.cjs"), - path.resolve(process.cwd(), "apps", "desktop", "dist", "main", "adeMcpProxy.cjs"), - ]; - - for (const candidate of candidates) { - if (!pathExists(candidate)) continue; - return path.resolve(candidate); - } - - return null; -} - -function resolveBundledProxyCommand(packaged: boolean): string { - if (packaged && pathExists(process.execPath)) { - return process.execPath; - } - const resolvedNode = resolveExecutableFromKnownLocations("node")?.path; - if (resolvedNode) { - return resolvedNode; - } - const argv0 = typeof process.argv0 === "string" ? process.argv0.trim() : ""; - if (argv0.length > 0) { - return argv0; - } - return process.execPath; -} - -export function resolveRepoRuntimeRoot(): string { - const startPoints = [ - process.cwd(), - __dirname, - path.resolve(process.cwd(), ".."), - path.resolve(process.cwd(), "..", ".."), - ]; - - for (const start of startPoints) { - let dir = path.resolve(start); - for (let i = 0; i < 12; i += 1) { - if (fs.existsSync(path.join(dir, "apps", "mcp-server", "package.json"))) { - return dir; - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - } - - return path.resolve(process.cwd()); -} - -function buildLaunchEnv(args: { - projectRoot: string; - workspaceRoot: string; - socketPath: string; -} & Pick): Record { - return { - ADE_PROJECT_ROOT: args.projectRoot, - ADE_WORKSPACE_ROOT: args.workspaceRoot, - ADE_MCP_SOCKET_PATH: args.socketPath, - ADE_MISSION_ID: args.missionId ?? "", - ADE_RUN_ID: args.runId ?? "", - ADE_STEP_ID: args.stepId ?? "", - ADE_ATTEMPT_ID: args.attemptId ?? "", - ADE_CHAT_SESSION_ID: args.chatSessionId ?? "", - ADE_DEFAULT_ROLE: args.defaultRole ?? "agent", - ADE_OWNER_ID: args.ownerId ?? "", - ADE_COMPUTER_USE_MODE: args.computerUsePolicy?.mode ?? "", - ADE_COMPUTER_USE_ALLOW_LOCAL_FALLBACK: - typeof args.computerUsePolicy?.allowLocalFallback === "boolean" - ? (args.computerUsePolicy.allowLocalFallback ? "1" : "0") - : "", - ADE_COMPUTER_USE_RETAIN_ARTIFACTS: - typeof args.computerUsePolicy?.retainArtifacts === "boolean" - ? (args.computerUsePolicy.retainArtifacts ? "1" : "0") - : "", - ADE_COMPUTER_USE_PREFERRED_BACKEND: args.computerUsePolicy?.preferredBackend ?? "", - }; -} - -export function resolveDesktopAdeMcpLaunch(args: DesktopAdeMcpLaunchArgs): AdeMcpLaunch { - const projectRoot = resolveRequiredRoot(args.projectRoot, "projectRoot"); - const workspaceRoot = args.workspaceBinding === "project_root" - ? projectRoot - : resolveRequiredRoot(args.workspaceRoot, "workspaceRoot"); - const socketPath = resolveAdeLayout(projectRoot).socketPath; - const resourcesPath = resolveResourcesPath(); - const env = buildLaunchEnv({ - projectRoot, - workspaceRoot, - missionId: args.missionId, - runId: args.runId, - stepId: args.stepId, - attemptId: args.attemptId, - chatSessionId: args.chatSessionId, - defaultRole: args.defaultRole, - ownerId: args.ownerId, - socketPath, - computerUsePolicy: args.computerUsePolicy, - }); - const bundledProxyPath = args.preferBundledProxy === false ? null : resolveBundledProxyPath(args.bundledProxyPath); - const packaged = __dirname.includes("app.asar"); - - if (bundledProxyPath) { - return { - mode: "bundled_proxy", - command: resolveBundledProxyCommand(packaged), - cmdArgs: [bundledProxyPath, "--project-root", projectRoot, "--workspace-root", workspaceRoot], - env: { - ...env, - ELECTRON_RUN_AS_NODE: "1", - }, - entryPath: bundledProxyPath, - runtimeRoot: args.runtimeRoot ? path.resolve(args.runtimeRoot) : null, - socketPath, - packaged, - resourcesPath, - }; - } - - const runtimeRoot = path.resolve(args.runtimeRoot ?? resolveRepoRuntimeRoot()); - const mcpServerDir = path.resolve(runtimeRoot, "apps", "mcp-server"); - const builtEntry = path.join(mcpServerDir, "dist", "index.cjs"); - const srcEntry = path.join(mcpServerDir, "src", "index.ts"); - - if (fs.existsSync(builtEntry)) { - // Native modules (e.g. node-pty) are externalised from the bundle and - // resolved at runtime via require(). When the MCP server runs as a - // plain `node` process (not inside Electron), NODE_PATH must include - // the desktop app's node_modules so those externals can be found. - const desktopNodeModules = path.resolve(runtimeRoot, "apps", "desktop", "node_modules"); - const existingNodePath = env.NODE_PATH ?? process.env.NODE_PATH ?? ""; - const nodePath = existingNodePath - ? `${desktopNodeModules}${path.delimiter}${existingNodePath}` - : desktopNodeModules; - - return { - mode: "headless_built", - command: "node", - cmdArgs: [builtEntry, "--project-root", projectRoot, "--workspace-root", workspaceRoot], - env: { ...env, NODE_PATH: nodePath }, - entryPath: builtEntry, - runtimeRoot, - socketPath, - packaged, - resourcesPath, - }; - } - - return { - mode: "headless_source", - command: "npx", - cmdArgs: ["tsx", srcEntry, "--project-root", projectRoot, "--workspace-root", workspaceRoot], - env, - entryPath: srcEntry, - runtimeRoot, - socketPath, - packaged, - resourcesPath, - }; -} diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index c94035b66..6860d34be 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -590,26 +590,129 @@ function rebuildUnifiedMemoriesFts(db: DatabaseSyncType): void { } } -function ensureUnifiedMemoriesSearchTable(db: { run: (sql: string, params?: SqlValue[]) => void }): void { +type MigrationDb = { + run: (sql: string, params?: SqlValue[]) => void; + get: = Record>(sql: string, params?: SqlValue[]) => T | null; +}; + +function dropUnifiedMemoryFtsTriggers(db: Pick): void { + db.run("drop trigger if exists unified_memories_fts_ai"); + db.run("drop trigger if exists unified_memories_fts_bd"); + db.run("drop trigger if exists unified_memories_fts_bu"); + db.run("drop trigger if exists unified_memories_fts_au"); +} + +function removeUnavailableUnifiedMemoryFtsTable(db: MigrationDb): void { + try { + db.run("drop table if exists unified_memories_fts"); + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + !/no such module: fts4/i.test(message) + && !/no such module: fts5/i.test(message) + && !(/malformed database schema/i.test(message) && /unified_memories_fts/i.test(message)) + ) { + throw error; + } + } + + // If this Node SQLite build lacks the FTS module, SQLite cannot even DROP an + // existing FTS virtual table. Remove that stale virtual table metadata so the + // local-first DB can degrade to the plain fallback search table. + db.run("pragma writable_schema = on"); try { db.run(` - create virtual table if not exists unified_memories_fts using fts4( - content, - content='unified_memories' - ) + delete from sqlite_master + where name = 'unified_memories_fts' + or tbl_name = 'unified_memories_fts' + or name like 'unified_memories_fts_%' + or tbl_name like 'unified_memories_fts_%' + or name like 'sqlite_autoindex_unified_memories_fts_%' `); + const versionRow = db.get<{ schema_version: number }>("pragma schema_version"); + const nextVersion = Number(versionRow?.schema_version ?? 0) + 1; + db.run(`pragma schema_version = ${Number.isFinite(nextVersion) ? nextVersion : 1}`); + } finally { + db.run("pragma writable_schema = off"); + } +} + +function repairUnifiedMemoryFtsSchemaForRuntime(db: MigrationDb): void { + if (isFts4Available(db)) return; + removeUnavailableUnifiedMemoryFtsTable(db); +} + +function repairMalformedUnifiedMemoryFtsSchema(db: DatabaseSyncType): void { + try { + getRow(db, "select 1 as ok"); + return; } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (!/no such module: fts4/i.test(message) && !/no such module: fts5/i.test(message)) { + if (!/malformed database schema/i.test(message) || !/unified_memories_fts/i.test(message)) { throw error; } + } + + const repairDb: MigrationDb = { + run: (sql: string, params: SqlValue[] = []) => { + runStatement(db, sql, params); + }, + get: = Record>(sql: string, params: SqlValue[] = []) => { + return getRow(db, sql, params); + }, + }; + removeUnavailableUnifiedMemoryFtsTable(repairDb); +} + +function isFts4Available(db: Pick): boolean { + try { + db.run("create virtual table if not exists temp.__ade_fts4_probe using fts4(content)"); + db.run("drop table if exists temp.__ade_fts4_probe"); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + !/no such module: fts4/i.test(message) + && !/no such module: fts5/i.test(message) + && !(/malformed database schema/i.test(message) && /unified_memories_fts/i.test(message)) + ) { + throw error; + } + return false; + } +} + +function ensureUnifiedMemoriesSearchTable(db: MigrationDb): void { + const ftsAvailable = isFts4Available(db); + const existing = db.get<{ sql: string | null }>( + "select sql from sqlite_master where type = 'table' and name = 'unified_memories_fts' limit 1", + ); + const existingIsVirtual = /\bcreate\s+virtual\s+table\b/i.test(existing?.sql ?? ""); + + if (!ftsAvailable) { + if (existingIsVirtual) { + dropUnifiedMemoryFtsTriggers(db); + removeUnavailableUnifiedMemoryFtsTable(db); + } db.run(` create table if not exists unified_memories_fts ( rowid integer primary key, content text not null ) `); + return; } + + if (!existingIsVirtual && db.get("select 1 as present from sqlite_master where type = 'table' and name = 'unified_memories_fts' limit 1")) { + db.run("drop table if exists unified_memories_fts"); + } + db.run(` + create virtual table if not exists unified_memories_fts using fts4( + content, + content='unified_memories' + ) + `); } function parseAlterTableTarget(sql: string): string | null { @@ -618,7 +721,7 @@ function parseAlterTableTarget(sql: string): string | null { return match[1].replace(/^["'`[]|["'`\]]$/g, ""); } -function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { +function migrate(db: MigrationDb) { // Keep KV for UI layout persistence. db.run("create table if not exists kv (key text primary key, value text not null)"); @@ -2101,6 +2204,7 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { db.run("create index if not exists idx_unified_memories_project_accessed on unified_memories(project_id, last_accessed_at)"); db.run("create index if not exists idx_unified_memories_project_dedupe on unified_memories(project_id, scope, scope_owner_id, dedupe_key)"); try { db.run("alter table unified_memories add column access_score real not null default 0"); } catch {} + ensureUnifiedMemoriesSearchTable(db); db.run(` update unified_memories set access_score = case @@ -2110,7 +2214,6 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { end `); - ensureUnifiedMemoriesSearchTable(db); db.run(` create trigger if not exists unified_memories_fts_ai after insert on unified_memories begin insert into unified_memories_fts(rowid, content) @@ -2978,34 +3081,6 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { `); db.run("create index if not exists idx_cto_flow_policy_revisions_project_created on cto_flow_policy_revisions(project_id, created_at)"); - db.run(` - create table if not exists external_mcp_usage_events ( - id text primary key, - project_id text not null, - server_name text not null, - tool_name text not null, - namespaced_tool_name text not null, - safety text not null, - caller_role text not null, - caller_id text not null, - chat_session_id text, - mission_id text, - run_id text, - step_id text, - attempt_id text, - owner_id text, - cost_cents integer not null default 0, - estimated integer not null default 0, - occurred_at text not null, - created_at text not null - ) - `); - try { db.run("alter table external_mcp_usage_events add column chat_session_id text"); } catch {} - db.run("create index if not exists idx_external_mcp_usage_events_project_occurred on external_mcp_usage_events(project_id, occurred_at)"); - db.run("create index if not exists idx_external_mcp_usage_events_chat on external_mcp_usage_events(project_id, chat_session_id, occurred_at)"); - db.run("create index if not exists idx_external_mcp_usage_events_mission on external_mcp_usage_events(project_id, mission_id, occurred_at)"); - db.run("create index if not exists idx_external_mcp_usage_events_run on external_mcp_usage_events(project_id, run_id, occurred_at)"); - // W5 automation budget cap: cumulative usage tracking per scope per week. db.run(` create table if not exists budget_usage_records ( @@ -3102,6 +3177,16 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { const desiredSiteId = ensureLocalSiteIdFile(dbPath); const existedBeforeOpen = fs.existsSync(dbPath); let db = openRawDatabase(dbPath); + repairMalformedUnifiedMemoryFtsSchema(db); + const makeRawMigrationDb = (): MigrationDb => ({ + run: (sql: string, params: SqlValue[] = []) => { + runStatement(db, sql, params); + }, + get: = Record>(sql: string, params: SqlValue[] = []) => { + return getRow(db, sql, params); + }, + }); + repairUnifiedMemoryFtsSchemaForRuntime(makeRawMigrationDb()); try { const hadCrsqlMetadata = hasCrsqlMetadata(db); @@ -3132,9 +3217,14 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { } runStatement(db, sql, params); }, + get: = Record>(sql: string, params: SqlValue[] = []) => { + return getRow(db, sql, params); + }, }); - migrate(makeMigrateDb()); + const migrateDb = makeMigrateDb(); + repairUnifiedMemoryFtsSchemaForRuntime(migrateDb); + migrate(migrateDb); if (existedBeforeOpen && !hasCrsqlMetadata(db)) { writeMigrationBackupIfNeeded(dbPath); @@ -3146,7 +3236,9 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { if (hadCrsqlMetadata && hasCrsqlite) { loadCrsqlite(db, extensionPath); } - migrate(makeMigrateDb()); + const remigrateDb = makeMigrateDb(); + repairUnifiedMemoryFtsSchemaForRuntime(remigrateDb); + migrate(remigrateDb); } if (retrofitForeignKeyCascadeActions(db, hasCrsqlite)) { @@ -3155,7 +3247,9 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { if (hadCrsqlMetadata && hasCrsqlite) { loadCrsqlite(db, extensionPath); } - migrate(makeMigrateDb()); + const remigrateDb = makeMigrateDb(); + repairUnifiedMemoryFtsSchemaForRuntime(remigrateDb); + migrate(remigrateDb); } if (hasCrsqlite) { diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index de2f34d14..30ff6c85f 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -326,7 +326,7 @@ async function pollCodexUsage(logger: Logger): Promise<{ windows: UsageWindow[]; // Fall through to CLI RPC } - // Fallback: Codex CLI RPC + // Fallback: Codex CLI JSON-RPC try { const rpcResult = await pollCodexViaCliRpc(logger); windows.push(...rpcResult.windows); @@ -347,7 +347,7 @@ async function pollCodexViaCliRpc(logger: Logger): Promise<{ windows: UsageWindo const errors: string[] = []; try { - // Initialize RPC connection + // Initialize the Codex CLI JSON-RPC connection. const initPayload = JSON.stringify({ jsonrpc: "2.0", id: 0, @@ -356,7 +356,7 @@ async function pollCodexViaCliRpc(logger: Logger): Promise<{ windows: UsageWindo protocolVersion: "2025-06-18", capabilities: { elicitation: {} }, clientInfo: { - name: "codex-mcp-client", + name: "ade-codex-rpc-client", title: "Codex", version: "0.47.0", }, diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 378bfc529..6958bf822 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -201,16 +201,6 @@ import type { CtoListLinearIngressEventsArgs, LinearWorkflowConfig, OpenclawBridgeStatus, - ExternalConnectionAuthRecord, - ExternalConnectionAuthRecordInput, - ExternalConnectionAuthStatus, - ExternalConnectionOAuthSessionResult, - ExternalConnectionOAuthSessionStartResult, - ExternalMcpManagedAuthConfig, - ExternalMcpServerConfig, - ExternalMcpServerSnapshot, - ExternalMcpEventPayload, - ExternalMcpUsageEvent, AddMissionArtifactArgs, AddMissionInterventionArgs, KeybindingOverride, @@ -670,45 +660,6 @@ declare global { }) => Promise; onEvent: (cb: (event: SyncStatusEventPayload) => void) => () => void; }; - externalMcp: { - listServers: () => Promise; - listConfigs: () => Promise; - getUsageEvents: (args?: { - limit?: number; - }) => Promise; - listAuthRecords: () => Promise; - onEvent: (cb: (event: ExternalMcpEventPayload) => void) => () => void; - connectServer: ( - serverName: string, - ) => Promise; - disconnectServer: ( - serverName: string, - ) => Promise; - testServer: ( - config: ExternalMcpServerConfig, - ) => Promise; - saveServer: ( - config: ExternalMcpServerConfig, - ) => Promise; - removeServer: ( - serverName: string, - ) => Promise; - saveAuthRecord: ( - record: ExternalConnectionAuthRecordInput, - ) => Promise; - removeAuthRecord: ( - authId: string, - ) => Promise; - getAuthStatus: ( - binding?: ExternalMcpManagedAuthConfig | null, - ) => Promise; - startOAuthSession: ( - authId: string, - ) => Promise; - getOAuthSession: ( - sessionId: string, - ) => Promise; - }; agentTools: { detect: () => Promise; }; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index a29654d80..80d7f7654 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -112,16 +112,6 @@ import type { CtoListLinearIngressEventsArgs, LinearWorkflowConfig, OpenclawBridgeStatus, - ExternalConnectionAuthRecord, - ExternalConnectionAuthRecordInput, - ExternalConnectionAuthStatus, - ExternalConnectionOAuthSessionResult, - ExternalConnectionOAuthSessionStartResult, - ExternalMcpEventPayload, - ExternalMcpManagedAuthConfig, - ExternalMcpServerConfig, - ExternalMcpServerSnapshot, - ExternalMcpUsageEvent, AddMissionArtifactArgs, AddMissionInterventionArgs, AutomationsEventPayload, @@ -743,68 +733,6 @@ contextBridge.exposeInMainWorld("ade", { return () => ipcRenderer.removeListener(IPC.syncEvent, listener); }, }, - externalMcp: { - listServers: async (): Promise => - ipcRenderer.invoke(IPC.externalMcpListServers), - listConfigs: async (): Promise => - ipcRenderer.invoke(IPC.externalMcpListConfigs), - getUsageEvents: async ( - args: { limit?: number } = {}, - ): Promise => - ipcRenderer.invoke(IPC.externalMcpGetUsageEvents, args), - listAuthRecords: async (): Promise => - ipcRenderer.invoke(IPC.externalMcpListAuthRecords), - onEvent: (cb: (event: ExternalMcpEventPayload) => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: ExternalMcpEventPayload, - ) => cb(payload); - ipcRenderer.on(IPC.externalMcpEvent, listener); - return () => ipcRenderer.removeListener(IPC.externalMcpEvent, listener); - }, - connectServer: async ( - serverName: string, - ): Promise => - ipcRenderer.invoke(IPC.externalMcpConnectServer, { serverName }), - disconnectServer: async ( - serverName: string, - ): Promise => - ipcRenderer.invoke(IPC.externalMcpDisconnectServer, { serverName }), - testServer: async ( - config: ExternalMcpServerConfig, - ): Promise => - ipcRenderer.invoke(IPC.externalMcpTestServer, { config }), - saveServer: async ( - config: ExternalMcpServerConfig, - ): Promise => - ipcRenderer.invoke(IPC.externalMcpSaveServer, { config }), - removeServer: async ( - serverName: string, - ): Promise => - ipcRenderer.invoke(IPC.externalMcpRemoveServer, { serverName }), - saveAuthRecord: async ( - record: ExternalConnectionAuthRecordInput, - ): Promise => - ipcRenderer.invoke(IPC.externalMcpSaveAuthRecord, { record }), - removeAuthRecord: async ( - authId: string, - ): Promise => - ipcRenderer.invoke(IPC.externalMcpRemoveAuthRecord, { authId }), - getAuthStatus: async ( - binding?: ExternalMcpManagedAuthConfig | null, - ): Promise => - ipcRenderer.invoke(IPC.externalMcpGetAuthStatus, { - binding: binding ?? null, - }), - startOAuthSession: async ( - authId: string, - ): Promise => - ipcRenderer.invoke(IPC.externalMcpStartOAuthSession, { authId }), - getOAuthSession: async ( - sessionId: string, - ): Promise => - ipcRenderer.invoke(IPC.externalMcpGetOAuthSession, { sessionId }), - }, agentTools: { detect: async (): Promise => ipcRenderer.invoke(IPC.agentToolsDetect), diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 468a1d6cd..dd225fa5b 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -2414,7 +2414,7 @@ if (typeof window !== "undefined" && !(window as any).ade) { provider: "claude", model: "sonnet", identityKey: "cto", - capabilityMode: "full_mcp", + capabilityMode: "full_tooling", status: "idle", createdAt: now, lastActivityAt: now, @@ -2534,7 +2534,7 @@ if (typeof window !== "undefined" && !(window as any).ade) { provider: "claude", model: "sonnet", identityKey: "cto", - capabilityMode: "full_mcp", + capabilityMode: "full_tooling", status: "idle", createdAt: now, lastActivityAt: now, @@ -3100,65 +3100,6 @@ if (typeof window !== "undefined" && !(window as any).ade) { diffAgainstDisk: resolved({ changed: false }), confirmTrust: resolved({ trusted: true }), }, - externalMcp: { - listServers: resolved([]), - listConfigs: resolved([]), - getUsageEvents: resolvedArg([]), - listAuthRecords: resolved([]), - connectServer: resolvedArg({ - config: { name: "mock-server", transport: "stdio", command: "mock" }, - state: "connected", - toolCount: 0, - tools: [], - lastConnectedAt: now, - lastHealthCheckAt: now, - consecutivePingFailures: 0, - lastError: null, - autoStart: true, - }), - disconnectServer: resolvedArg(null), - testServer: resolvedArg({ - config: { name: "mock-server", transport: "stdio", command: "mock" }, - state: "connected", - toolCount: 0, - tools: [], - lastConnectedAt: now, - lastHealthCheckAt: now, - consecutivePingFailures: 0, - lastError: null, - autoStart: true, - }), - saveServer: resolvedArg([]), - removeServer: resolvedArg([]), - saveAuthRecord: resolvedArg({ - id: "mock-auth", - displayName: "Mock auth", - mode: "bearer", - secretId: "mock-secret", - createdAt: now, - updatedAt: now, - }), - removeAuthRecord: resolvedArg([]), - getAuthStatus: resolvedArg({ - authId: "mock-auth", - mode: "bearer", - state: "ready", - summary: "Stored credential is ready.", - materializationPreview: ["Authorization: Bearer [stored credential]"], - updatedAt: now, - }), - startOAuthSession: resolvedArg({ - sessionId: "mock-oauth-session", - authId: "mock-auth", - authUrl: "https://example.com/oauth", - redirectUri: "http://127.0.0.1:9999/oauth/external-mcp/callback", - }), - getOAuthSession: resolvedArg({ - authId: "mock-auth", - status: "completed", - error: null, - }), - }, memory: { pin: resolvedArg(undefined), updateCore: resolvedArg({ diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index d30409429..a5f79e12f 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -209,7 +209,7 @@ export function CommandPalette({ { 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, managed MCP, computer use", group: "Settings", run: () => navigate("/settings?tab=integrations") }, + { 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") }, { diff --git a/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx b/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx index b191b2b2c..c635724db 100644 --- a/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx +++ b/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx @@ -98,7 +98,6 @@ const TOOL_OPTIONS: Array<{ value: AutomationRuleDraft["toolPalette"][number]; l { value: "browser", label: "Browser" }, { value: "memory", label: "Memory" }, { value: "mission", label: "Mission" }, - { value: "external-mcp", label: "Managed MCP" }, ]; const CONTEXT_OPTIONS: Array<{ value: AutomationRuleDraft["contextSources"][number]["type"]; label: string }> = [ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 5cddaa075..284ec4b92 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -250,11 +250,6 @@ const RECESSED_BLOCK_CLASS = "ade-chat-recessed overflow-auto whitespace-pre-wrap break-words rounded-[10px] px-4 py-3 font-mono text-[11px] leading-[1.6] text-fg/78"; function toolSourceChip(toolName: string): { label: string; tone: ChatSurfaceChipTone } | null { - if (toolName.startsWith("mcp__")) { - const [, namespace] = toolName.split("__"); - const label = namespace ? `${namespace.replace(/[_-]/g, " ")} MCP` : "MCP"; - return { label, tone: "info" }; - } if (toolName.startsWith("functions.")) { return { label: "Local tool", tone: "muted" }; } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index c89f82d06..760540167 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -172,9 +172,6 @@ function installAdeMocks(options?: { getOwnerSnapshot: vi.fn().mockResolvedValue(null), onEvent: vi.fn().mockImplementation(() => () => undefined), }, - externalMcp: { - onEvent: vi.fn().mockImplementation(() => () => undefined), - }, files: { listWorkspaces: vi.fn().mockResolvedValue([]), }, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 85eb162d9..f50103dc8 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1951,17 +1951,6 @@ export function AgentChatPane({ return unsubscribe; }, [refreshComputerUseSnapshot, selectedSessionId]); - useEffect(() => { - const unsubscribe = window.ade.externalMcp.onEvent((event) => { - if (event.type !== "usage-recorded" || !selectedSessionId) return; - const usageEvent = event.usageEvent; - const usageChatSessionId = usageEvent?.chatSessionId ?? usageEvent?.callerId ?? null; - if (usageChatSessionId !== selectedSessionId) return; - void refreshComputerUseSnapshot(selectedSessionId, { force: true }); - }); - return unsubscribe; - }, [refreshComputerUseSnapshot, selectedSessionId]); - useEffect(() => { if (!selectedSessionId) { setProofDrawerOpen(false); @@ -2231,8 +2220,8 @@ export function AgentChatPane({ }, [refreshSessions, selectedSession, selectedSessionId]); // ── Eager session creation ── - // Create a session as soon as we have a model + lane, so slash commands, - // MCP status, and other pre-chat metadata are available immediately. + // Create a session as soon as we have a model + lane, so slash commands + // and other pre-chat metadata are available immediately. // Computer-use-capable chats start as workflow sessions so ADE can wire the // Ghost/proof harness before the first turn. // Skip when the pane is locked to an existing session or in forced-draft mode. diff --git a/apps/desktop/src/renderer/components/chat/chatNavigation.test.ts b/apps/desktop/src/renderer/components/chat/chatNavigation.test.ts deleted file mode 100644 index 99fff09b6..000000000 --- a/apps/desktop/src/renderer/components/chat/chatNavigation.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* @vitest-environment jsdom */ - -import { describe, expect, it, vi } from "vitest"; -import { openExternalMcpSettings } from "./chatNavigation"; - -describe("openExternalMcpSettings", () => { - it("pushes the integrations settings URL onto history", () => { - const pushStateSpy = vi.spyOn(window.history, "pushState"); - - openExternalMcpSettings(); - - expect(pushStateSpy).toHaveBeenCalledWith( - {}, - "", - "/settings?tab=integrations&integration=managed-mcp", - ); - - pushStateSpy.mockRestore(); - }); - - it("dispatches a popstate event so listeners can react", () => { - const listener = vi.fn(); - window.addEventListener("popstate", listener); - - openExternalMcpSettings(); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener.mock.calls[0]![0]).toBeInstanceOf(PopStateEvent); - - window.removeEventListener("popstate", listener); - }); -}); diff --git a/apps/desktop/src/renderer/components/chat/chatNavigation.ts b/apps/desktop/src/renderer/components/chat/chatNavigation.ts deleted file mode 100644 index ca8afa2f7..000000000 --- a/apps/desktop/src/renderer/components/chat/chatNavigation.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function openExternalMcpSettings(): void { - if (typeof window === "undefined") return; - window.history.pushState({}, "", "/settings?tab=integrations&integration=managed-mcp"); - window.dispatchEvent(new PopStateEvent("popstate")); -} diff --git a/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts b/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts index 34b4bb902..8e1d75ca3 100644 --- a/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts @@ -47,8 +47,8 @@ describe("getToolMeta", () => { expect(meta.category).toBe("codex"); }); - it("resolves MCP-style double-underscore tool names by extracting the action", () => { - const meta = getToolMeta("mcp__custom__bash"); + it("resolves dotted tool names by extracting the action", () => { + const meta = getToolMeta("custom.bash"); expect(meta.label).toBe("Shell"); expect(meta.category).toBe("exec"); }); diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts index 2c1276b5f..f70126018 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts @@ -143,8 +143,8 @@ describe("chatTranscriptRows", () => { timestamp: "2026-03-17T10:00:00.000Z", event: { type: "tool_call", - tool: "mcp__ade__memory_search", - args: { query: "stash", title: "mcp__ade__memory_search", kind: "other" }, + tool: "memory_search", + args: { query: "stash", title: "memory_search", kind: "other" }, itemId: "tool-1", turnId: "turn-1", }, @@ -168,8 +168,8 @@ describe("chatTranscriptRows", () => { if (rows[0]!.event.type !== "work_log_entry") { throw new Error("Expected a work log entry"); } - expect(rows[0]!.event.entry.toolName).toBe("mcp__ade__memory_search"); - expect(rows[0]!.event.entry.label).toBe("mcp__ade__memory_search"); + expect(rows[0]!.event.entry.toolName).toBe("memory_search"); + expect(rows[0]!.event.entry.label).toBe("memory_search"); expect(rows[0]!.event.entry.status).toBe("completed"); }); diff --git a/apps/desktop/src/renderer/components/chat/toolPresentation.test.ts b/apps/desktop/src/renderer/components/chat/toolPresentation.test.ts index e985aa109..083e8c18f 100644 --- a/apps/desktop/src/renderer/components/chat/toolPresentation.test.ts +++ b/apps/desktop/src/renderer/components/chat/toolPresentation.test.ts @@ -22,20 +22,20 @@ describe("describeToolIdentifier", () => { expect(result.secondaryLabel).toBe("Workspace"); }); - it("resolves MCP-style tool names with double-underscore separators", () => { - const result = describeToolIdentifier("mcp__context7__resolve_library_id"); + it("resolves namespaced tool names with dotted separators", () => { + const result = describeToolIdentifier("context7.resolve_library_id"); expect(result.label).toBe("Docs"); expect(result.secondaryLabel).toBe("Resolve Library Id"); }); it("humanizes unknown snake_case tool parts", () => { - const result = describeToolIdentifier("mcp__custom_server__do_something"); + const result = describeToolIdentifier("custom_server.do_something"); expect(result.label).toBe("Custom Server"); expect(result.secondaryLabel).toBe("Do Something"); }); - it("uses known action labels from MCP tools", () => { - const result = describeToolIdentifier("mcp__playwright__bash"); + it("uses known action labels from namespaced tools", () => { + const result = describeToolIdentifier("playwright.bash"); expect(result.label).toBe("Shell"); expect(result.secondaryLabel).toBe("Browser"); }); @@ -47,13 +47,13 @@ describe("describeToolIdentifier", () => { }); it("applies TOKEN_LABELS for known abbreviations", () => { - const result = describeToolIdentifier("mcp__custom__get_api_url"); + const result = describeToolIdentifier("custom.get_api_url"); expect(result.secondaryLabel).toContain("API"); expect(result.secondaryLabel).toContain("URL"); }); - it("handles deeply nested MCP namespaces", () => { - const result = describeToolIdentifier("mcp__pencil__batch_design"); + it("handles known namespaced tools", () => { + const result = describeToolIdentifier("pencil.batch_design"); expect(result.label).toBe("Canvas"); expect(result.secondaryLabel).toBe("Batch Design"); }); @@ -77,10 +77,10 @@ describe("replaceInternalToolNames", () => { expect(result).not.toContain("functions.exec_command"); }); - it("replaces MCP tool mentions inline", () => { - const result = replaceInternalToolNames("Called mcp__context7__resolve_library_id for docs."); + it("replaces namespaced tool mentions inline", () => { + const result = replaceInternalToolNames("Called context7.resolve_library_id for docs."); expect(result).toContain("Docs"); - expect(result).not.toContain("mcp__context7__resolve_library_id"); + expect(result).not.toContain("context7.resolve_library_id"); }); it("replaces multiple tool mentions in the same text", () => { diff --git a/apps/desktop/src/renderer/components/chat/toolPresentation.ts b/apps/desktop/src/renderer/components/chat/toolPresentation.ts index 82f23a8c1..1bbe53649 100644 --- a/apps/desktop/src/renderer/components/chat/toolPresentation.ts +++ b/apps/desktop/src/renderer/components/chat/toolPresentation.ts @@ -81,7 +81,6 @@ const TOKEN_LABELS: Record = { html: "HTML", ipc: "IPC", llm: "LLM", - mcp: "MCP", pr: "PR", sql: "SQL", ui: "UI", @@ -105,14 +104,6 @@ function splitToolIdentifier(toolName: string): { namespace: string | null; action: string | null; } { - if (toolName.startsWith("mcp__")) { - const parts = toolName.split("__").filter(Boolean); - return { - namespace: parts[1] ?? null, - action: parts.slice(2).join("__") || null, - }; - } - if (toolName.includes(".")) { const parts = toolName.split(".").filter(Boolean); return { @@ -174,7 +165,8 @@ function normalizeToolMention(match: string): string { return [display.label, display.secondaryLabel].filter(Boolean).join(" "); } -const NAMESPACED_TOOL_MENTION_PATTERN = /\b(?:mcp__[\w-]+(?:__[\w-]+)+|(?:functions|multi_tool_use|web)\.[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)\b/g; +const NAMESPACED_TOOL_MENTION_PATTERN = + /\b(?:(?:context7|functions|linear|multi_tool_use|pencil|playwright|posthog|sentry|shadcn|web)\.[A-Za-z0-9_]+(?:[\._-][A-Za-z0-9_]+)*)\b/g; export function replaceInternalToolNames(text: string): string { const trimmed = text.trim(); diff --git a/apps/desktop/src/renderer/components/chat/useChatMcpSummary.test.ts b/apps/desktop/src/renderer/components/chat/useChatMcpSummary.test.ts deleted file mode 100644 index bc230f07c..000000000 --- a/apps/desktop/src/renderer/components/chat/useChatMcpSummary.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* @vitest-environment jsdom */ - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, renderHook } from "@testing-library/react"; - -// Re-import fresh module per test to reset cached state -let useChatMcpSummary: typeof import("./useChatMcpSummary").useChatMcpSummary; - -describe("useChatMcpSummary", () => { - beforeEach(async () => { - vi.resetModules(); - const mod = await import("./useChatMcpSummary"); - useChatMcpSummary = mod.useChatMcpSummary; - }); - - afterEach(() => { - // Clean up any window.ade mock - delete (window as any).ade; - }); - - it("returns null when window.ade.externalMcp is not available", () => { - const { result } = renderHook(() => useChatMcpSummary(true)); - expect(result.current).toBeNull(); - }); - - it("returns null when disabled", () => { - (window as any).ade = { - externalMcp: { - listConfigs: vi.fn().mockResolvedValue([{ id: "a" }]), - listServers: vi.fn().mockResolvedValue([]), - onEvent: vi.fn().mockReturnValue(() => {}), - }, - }; - - const { result } = renderHook(() => useChatMcpSummary(false)); - expect(result.current).toBeNull(); - }); - - it("fetches and returns configuredCount and connectedCount", async () => { - (window as any).ade = { - externalMcp: { - listConfigs: vi.fn().mockResolvedValue([{ id: "a" }, { id: "b" }, { id: "c" }]), - listServers: vi.fn().mockResolvedValue([ - { state: "connected" }, - { state: "disconnected" }, - { state: "connected" }, - ]), - onEvent: vi.fn().mockReturnValue(() => {}), - }, - }; - - const { result } = renderHook(() => useChatMcpSummary(true)); - - // Wait for the async fetch to resolve - await act(async () => { - await new Promise((r) => setTimeout(r, 10)); - }); - - expect(result.current).toEqual({ - configuredCount: 3, - connectedCount: 2, - }); - }); - - it("returns zeros when listConfigs and listServers reject", async () => { - (window as any).ade = { - externalMcp: { - listConfigs: vi.fn().mockRejectedValue(new Error("fail")), - listServers: vi.fn().mockRejectedValue(new Error("fail")), - onEvent: vi.fn().mockReturnValue(() => {}), - }, - }; - - const { result } = renderHook(() => useChatMcpSummary(true)); - - await act(async () => { - await new Promise((r) => setTimeout(r, 10)); - }); - - expect(result.current).toEqual({ - configuredCount: 0, - connectedCount: 0, - }); - }); - - it("refreshes the summary when an external MCP event fires", async () => { - let eventCallback: (() => void) | undefined; - - (window as any).ade = { - externalMcp: { - listConfigs: vi.fn().mockResolvedValue([{ id: "a" }]), - listServers: vi.fn().mockResolvedValue([{ state: "connected" }]), - onEvent: vi.fn((cb: () => void) => { - eventCallback = cb; - return () => {}; - }), - }, - }; - - const { result } = renderHook(() => useChatMcpSummary(true)); - - await act(async () => { - await new Promise((r) => setTimeout(r, 10)); - }); - - expect(result.current).toEqual({ - configuredCount: 1, - connectedCount: 1, - }); - - // Update mocks for the refresh - (window as any).ade.externalMcp.listConfigs.mockResolvedValue([{ id: "a" }, { id: "b" }]); - (window as any).ade.externalMcp.listServers.mockResolvedValue([ - { state: "connected" }, - { state: "connected" }, - ]); - - // Trigger the MCP event - await act(async () => { - eventCallback!(); - await new Promise((r) => setTimeout(r, 10)); - }); - - expect(result.current).toEqual({ - configuredCount: 2, - connectedCount: 2, - }); - }); -}); diff --git a/apps/desktop/src/renderer/components/chat/useChatMcpSummary.ts b/apps/desktop/src/renderer/components/chat/useChatMcpSummary.ts deleted file mode 100644 index 2c86dfd2b..000000000 --- a/apps/desktop/src/renderer/components/chat/useChatMcpSummary.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useEffect, useState } from "react"; - -export type ChatMcpSummary = { - configuredCount: number; - connectedCount: number; -}; - -let cachedSummary: ChatMcpSummary | null = null; -let summaryPromise: Promise | null = null; - -async function fetchChatMcpSummary(): Promise { - if (cachedSummary) return cachedSummary; - if (!window.ade?.externalMcp) { - return { configuredCount: 0, connectedCount: 0 }; - } - if (!summaryPromise) { - summaryPromise = Promise.all([ - window.ade.externalMcp.listConfigs().catch(() => []), - window.ade.externalMcp.listServers().catch(() => []), - ]) - .then(([configs, servers]) => { - const next = { - configuredCount: configs.length, - connectedCount: servers.filter((server) => server.state === "connected").length, - }; - cachedSummary = next; - return next; - }) - .finally(() => { - summaryPromise = null; - }); - } - return summaryPromise; -} - -export function useChatMcpSummary(enabled = true): ChatMcpSummary | null { - const [summary, setSummary] = useState(cachedSummary); - - useEffect(() => { - if (!enabled || !window.ade?.externalMcp) { - setSummary(null); - return; - } - let cancelled = false; - if (cachedSummary) { - setSummary(cachedSummary); - return () => { - cancelled = true; - }; - } - void fetchChatMcpSummary().then((next) => { - if (cancelled) return; - setSummary(next); - }); - return () => { - cancelled = true; - }; - }, [enabled]); - - useEffect(() => { - if (!enabled || !window.ade?.externalMcp?.onEvent) return undefined; - return window.ade.externalMcp.onEvent(() => { - cachedSummary = null; - void fetchChatMcpSummary().then(setSummary).catch(() => {}); - }); - }, [enabled]); - - return summary; -} diff --git a/apps/desktop/src/renderer/components/cto/CtoPage.tsx b/apps/desktop/src/renderer/components/cto/CtoPage.tsx index fcd5cef83..3283dea9e 100644 --- a/apps/desktop/src/renderer/components/cto/CtoPage.tsx +++ b/apps/desktop/src/renderer/components/cto/CtoPage.tsx @@ -102,7 +102,6 @@ export function CtoPage() { const [workerWakeStatus, setWorkerWakeStatus] = useState(null); const [workerWakeError, setWorkerWakeError] = useState(null); const [wakingWorker, setWakingWorker] = useState(false); - const [externalMcpServerNames, setExternalMcpServerNames] = useState([]); const [budgetLoading, setBudgetLoading] = useState(false); // Worker creation wizard @@ -200,16 +199,6 @@ export function CtoPage() { } }, [budgetLoading, budgetSnapshot]); - const loadExternalMcpRegistry = useCallback(async () => { - if (!window.ade?.externalMcp) return; - try { - const configs = await window.ade.externalMcp.listConfigs(); - setExternalMcpServerNames(configs.map((entry) => entry.name).sort((a, b) => a.localeCompare(b))); - } catch { - setExternalMcpServerNames([]); - } - }, []); - useEffect(() => { void loadCtoSummary(); }, [loadCtoSummary]); @@ -239,12 +228,6 @@ export function CtoPage() { void loadCtoHistory(); }, [activeTab, loadCtoHistory]); - useEffect(() => { - if (activeTab !== "settings" && !editorOpen) return; - if (externalMcpServerNames.length > 0) return; - void loadExternalMcpRegistry(); - }, [activeTab, editorOpen, externalMcpServerNames.length, loadExternalMcpRegistry]); - useEffect(() => { const unsubscribe = window.ade?.cto?.onOpenclawConnectionStatus?.((status) => { setOpenclawStatus(status); @@ -418,11 +401,6 @@ export function CtoPage() { } : {} ), - externalMcpAccess: { - allowAll: workerDraft.externalMcpAllowAll, - allowedServers: workerDraft.externalMcpAllowedServers, - blockedServers: workerDraft.externalMcpBlockedServers, - }, budgetMonthlyCents: Math.max(0, Math.round(workerDraft.budgetDollars * 100)), }, }); @@ -757,7 +735,6 @@ export function CtoPage() { draft={workerDraft} setDraft={setWorkerDraft} agents={agents} - availableExternalMcpServers={externalMcpServerNames} saving={savingWorker} error={workerError} onSave={() => void saveWorker()} @@ -875,14 +852,13 @@ export function CtoPage() { {/* Settings tab */} {activeTab === "settings" && ( - )} diff --git a/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.test.tsx b/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.test.tsx index 4fa034d20..ba0902dfe 100644 --- a/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.test.tsx +++ b/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.test.tsx @@ -21,10 +21,6 @@ vi.mock("./shared/TimelineEntry", () => ({ )), })); -vi.mock("../shared/ExternalMcpAccessEditor", () => ({ - ExternalMcpAccessEditor: vi.fn(() =>

), -})); - vi.mock("./OpenclawConnectionPanel", () => ({ OpenclawConnectionPanel: vi.fn(() =>
), })); @@ -93,7 +89,6 @@ describe("CtoSettingsPanel", () => { sessionLogs={[]} onSaveIdentity={onSaveIdentity} onSaveCoreMemory={onSaveCoreMemory} - availableExternalMcpServers={[]} />, ); expect(screen.getByText("anthropic/claude-sonnet-4-6")).toBeTruthy(); @@ -107,7 +102,6 @@ describe("CtoSettingsPanel", () => { sessionLogs={[]} onSaveIdentity={onSaveIdentity} onSaveCoreMemory={onSaveCoreMemory} - availableExternalMcpServers={[]} />, ); const loadingElements = screen.getAllByText("Loading..."); @@ -122,7 +116,6 @@ describe("CtoSettingsPanel", () => { sessionLogs={[]} onSaveIdentity={onSaveIdentity} onSaveCoreMemory={onSaveCoreMemory} - availableExternalMcpServers={[]} />, ); fireEvent.click(screen.getByRole("button", { name: "Brief" })); @@ -137,7 +130,6 @@ describe("CtoSettingsPanel", () => { sessionLogs={[]} onSaveIdentity={onSaveIdentity} onSaveCoreMemory={onSaveCoreMemory} - availableExternalMcpServers={[]} onResetOnboarding={onResetOnboarding} />, ); @@ -152,7 +144,6 @@ describe("CtoSettingsPanel", () => { sessionLogs={[]} onSaveIdentity={onSaveIdentity} onSaveCoreMemory={onSaveCoreMemory} - availableExternalMcpServers={[]} />, ); expect(screen.queryByText("Re-run setup")).toBeNull(); @@ -166,7 +157,6 @@ describe("CtoSettingsPanel", () => { sessionLogs={[]} onSaveIdentity={onSaveIdentity} onSaveCoreMemory={onSaveCoreMemory} - availableExternalMcpServers={[]} />, ); @@ -198,7 +188,6 @@ describe("CtoSettingsPanel", () => { sessionLogs={[]} onSaveIdentity={onSaveIdentity} onSaveCoreMemory={onSaveCoreMemory} - availableExternalMcpServers={[]} />, ); @@ -221,7 +210,6 @@ describe("CtoSettingsPanel", () => { sessionLogs={[]} onSaveIdentity={onSaveIdentity} onSaveCoreMemory={onSaveCoreMemory} - availableExternalMcpServers={[]} />, ); @@ -251,7 +239,6 @@ describe("CtoSettingsPanel", () => { sessionLogs={[]} onSaveIdentity={onSaveIdentity} onSaveCoreMemory={onSaveCoreMemory} - availableExternalMcpServers={[]} />, ); expect(screen.getByText("anthropic/claude-sonnet-4-6")).toBeTruthy(); @@ -272,7 +259,6 @@ describe("CtoSettingsPanel", () => { sessionLogs={[]} onSaveIdentity={onSaveIdentity} onSaveCoreMemory={onSaveCoreMemory} - availableExternalMcpServers={[]} />, ); expect(screen.getByRole("button", { name: "Identity" })).toBeTruthy(); @@ -290,12 +276,11 @@ describe("CtoSettingsPanel", () => { id: "s1", createdAt: "2026-03-26T00:00:00.000Z", summary: "Fixed deployment pipeline", - capabilityMode: "full_mcp", + capabilityMode: "full_tooling", } as CtoSessionLogEntry, ]} onSaveIdentity={onSaveIdentity} onSaveCoreMemory={onSaveCoreMemory} - availableExternalMcpServers={[]} />, ); diff --git a/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.tsx b/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.tsx index 43c4b3077..72081a67a 100644 --- a/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.tsx +++ b/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.tsx @@ -1,13 +1,12 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { ArrowCounterClockwise, PencilSimple } from "@phosphor-icons/react"; -import type { CtoCoreMemory, CtoIdentity, CtoSessionLogEntry, ExternalMcpAccessPolicy } from "../../../shared/types"; +import type { CtoCoreMemory, CtoIdentity, CtoSessionLogEntry } from "../../../shared/types"; import { IdentityEditor } from "./IdentityEditor"; import { TimelineEntry } from "./shared/TimelineEntry"; import { Button } from "../ui/Button"; import { cn } from "../ui/cn"; import { inputCls, labelCls, textareaCls } from "./shared/designTokens"; import { SmartTooltip } from "../ui/SmartTooltip"; -import { ExternalMcpAccessEditor } from "../shared/ExternalMcpAccessEditor"; import { OpenclawConnectionPanel } from "./OpenclawConnectionPanel"; import { getCtoPersonalityPreset } from "./identityPresets"; import { CtoPromptPreview } from "./CtoPromptPreview"; @@ -41,7 +40,6 @@ export function CtoSettingsPanel({ sessionLogs, onSaveIdentity, onSaveCoreMemory, - availableExternalMcpServers, onResetOnboarding, }: { identity: CtoIdentity | null; @@ -49,7 +47,6 @@ export function CtoSettingsPanel({ sessionLogs: CtoSessionLogEntry[]; onSaveIdentity: (patch: Record) => Promise; onSaveCoreMemory: (patch: CoreMemoryPatch) => Promise; - availableExternalMcpServers: string[]; onResetOnboarding?: () => void; }) { const [identityEditing, setIdentityEditing] = useState(false); @@ -57,9 +54,6 @@ export function CtoSettingsPanel({ const [memoryDraft, setMemoryDraft] = useState({ projectSummary: "", criticalConventions: "", userPreferences: "", activeFocus: "", notes: "" }); const [memorySaving, setMemorySaving] = useState(false); const [memoryError, setMemoryError] = useState(null); - const [externalMcpDraft, setExternalMcpDraft] = useState({ allowAll: true, allowedServers: [], blockedServers: [] }); - const [externalMcpSaving, setExternalMcpSaving] = useState(false); - const [externalMcpError, setExternalMcpError] = useState(null); useEffect(() => { if (!memoryEditing && coreMemory) { @@ -73,14 +67,6 @@ export function CtoSettingsPanel({ } }, [coreMemory, memoryEditing]); - useEffect(() => { - setExternalMcpDraft({ - allowAll: identity?.externalMcpAccess?.allowAll !== false, - allowedServers: [...new Set(identity?.externalMcpAccess?.allowedServers ?? [])], - blockedServers: [...new Set(identity?.externalMcpAccess?.blockedServers ?? [])], - }); - }, [identity]); - const handleSaveMemory = async () => { setMemorySaving(true); setMemoryError(null); try { @@ -96,21 +82,12 @@ export function CtoSettingsPanel({ finally { setMemorySaving(false); } }; - const handleSaveExternalMcp = useCallback(async () => { - setExternalMcpSaving(true); setExternalMcpError(null); - try { - await onSaveIdentity({ externalMcpAccess: externalMcpDraft }); - } catch (err) { - setExternalMcpError(err instanceof Error ? err.message : "Save failed."); - } finally { setExternalMcpSaving(false); } - }, [externalMcpDraft, onSaveIdentity]); - const [settingsTab, setSettingsTab] = useState<"identity" | "brief" | "integrations">("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: "MCP server access and OpenClaw bridge configuration." }, + { id: "integrations" as const, label: "Integrations", tooltip: "OpenClaw bridge configuration." }, ]; return ( @@ -262,7 +239,7 @@ export function CtoSettingsPanel({ timestamp={s.createdAt} title={s.summary} status={s.capabilityMode} - statusVariant={s.capabilityMode === "full_mcp" ? "success" : "muted"} + statusVariant={s.capabilityMode === "full_tooling" ? "success" : "muted"} /> ))}
@@ -273,23 +250,6 @@ export function CtoSettingsPanel({ {/* ── Integrations sub-tab ── */} {settingsTab === "integrations" && (
- {/* MCP Access card */} -
-
MCP Access
- - {externalMcpError &&
{externalMcpError}
} -
- -
-
- {/* OpenClaw Bridge card */}
OpenClaw Bridge
diff --git a/apps/desktop/src/renderer/components/cto/TeamPanel.tsx b/apps/desktop/src/renderer/components/cto/TeamPanel.tsx index 3386a4328..5ea020490 100644 --- a/apps/desktop/src/renderer/components/cto/TeamPanel.tsx +++ b/apps/desktop/src/renderer/components/cto/TeamPanel.tsx @@ -21,7 +21,6 @@ import { PaneHeader } from "../ui/PaneHeader"; import { cn } from "../ui/cn"; import { AgentStatusBadge } from "./shared/AgentStatusBadge"; import { WorkerActivityFeed } from "./WorkerActivityFeed"; -import { ExternalMcpAccessEditor } from "../shared/ExternalMcpAccessEditor"; /* ── Helpers ── */ @@ -64,9 +63,6 @@ export type WorkerEditorDraft = { activeHoursEnd: string; activeHoursTimezone: string; maxConcurrentRuns: number; - externalMcpAllowAll: boolean; - externalMcpAllowedServers: string[]; - externalMcpBlockedServers: string[]; }; export function workerDraftFromAgent(agent?: AgentIdentity | null): WorkerEditorDraft { @@ -101,9 +97,6 @@ export function workerDraftFromAgent(agent?: AgentIdentity | null): WorkerEditor activeHoursEnd: typeof activeHours?.end === "string" ? activeHours.end : "22:00", activeHoursTimezone: typeof activeHours?.timezone === "string" ? activeHours.timezone : "local", maxConcurrentRuns: Math.max(1, Math.min(10, Math.floor(Number(runtimeConfig.maxConcurrentRuns ?? 1)))), - externalMcpAllowAll: agent?.externalMcpAccess?.allowAll === true, - externalMcpAllowedServers: [...new Set(agent?.externalMcpAccess?.allowedServers ?? [])], - externalMcpBlockedServers: [...new Set(agent?.externalMcpAccess?.blockedServers ?? [])], }; } @@ -118,7 +111,6 @@ export function WorkerEditorPanel({ draft, setDraft, agents, - availableExternalMcpServers, saving, error, onSave, @@ -127,7 +119,6 @@ export function WorkerEditorPanel({ draft: WorkerEditorDraft; setDraft: React.Dispatch>; agents: AgentIdentity[]; - availableExternalMcpServers: string[]; saving: boolean; error: string | null; onSave: () => void; @@ -263,24 +254,6 @@ export function WorkerEditorPanel({
-
- setDraft((current) => ({ - ...current, - externalMcpAllowAll: next.allowAll, - externalMcpAllowedServers: next.allowedServers, - externalMcpBlockedServers: next.blockedServers, - }))} - /> -
- {/* Heartbeat */}
Heartbeat
diff --git a/apps/desktop/src/renderer/components/cto/WorkerActivityFeed.tsx b/apps/desktop/src/renderer/components/cto/WorkerActivityFeed.tsx index cff291344..7660e3f7d 100644 --- a/apps/desktop/src/renderer/components/cto/WorkerActivityFeed.tsx +++ b/apps/desktop/src/renderer/components/cto/WorkerActivityFeed.tsx @@ -45,7 +45,7 @@ function mergeActivity(runs: WorkerAgentRun[], sessions: AgentSessionLogEntry[]) timestamp: session.createdAt, title: session.summary, status: session.capabilityMode, - statusVariant: session.capabilityMode === "full_mcp" ? "success" : "muted", + statusVariant: session.capabilityMode === "full_tooling" ? "success" : "muted", icon: ChatCircle, }); } diff --git a/apps/desktop/src/renderer/components/history/eventTaxonomy.ts b/apps/desktop/src/renderer/components/history/eventTaxonomy.ts index 10c380b26..deb9281ff 100644 --- a/apps/desktop/src/renderer/components/history/eventTaxonomy.ts +++ b/apps/desktop/src/renderer/components/history/eventTaxonomy.ts @@ -199,7 +199,7 @@ export const EVENT_KIND_META: Record = { "budget.exceeded": { label: "Budget Exceeded", category: "system", iconName: "Warning", description: "Budget exceeded", importance: "high" }, // ── Noise-level events (hidden by default) ───────────────── - "mcp_tool_call": { label: "Tool Call", category: "system", iconName: "Wrench", description: "MCP tool invocation", importance: "noise" }, + "tool_call": { label: "Tool Call", category: "system", iconName: "Wrench", description: "Tool invocation", importance: "noise" }, "worker": { label: "Worker", category: "mission", iconName: "Robot", description: "Worker orchestration step", importance: "noise" }, "implementation": { label: "Implementation", category: "mission", iconName: "Code", description: "Implementation step", importance: "noise" }, "coordinator": { label: "Coordinator", category: "mission", iconName: "TreeStructure", description: "Coordinator operation", importance: "noise" }, diff --git a/apps/desktop/src/renderer/components/missions/ChatMessageArea.tsx b/apps/desktop/src/renderer/components/missions/ChatMessageArea.tsx index 06996e059..17792c934 100644 --- a/apps/desktop/src/renderer/components/missions/ChatMessageArea.tsx +++ b/apps/desktop/src/renderer/components/missions/ChatMessageArea.tsx @@ -7,7 +7,6 @@ import React, { useMemo } from "react"; import { Crown, - Database, UsersThree, Wrench, Globe, @@ -23,7 +22,6 @@ import { MissionThreadMessageList } from "./MissionThreadMessageList"; import type { Channel } from "./ChatChannelList"; import { getMissionInterventionOwnerLabel } from "./missionHelpers"; import type { MissionStateNarrative } from "./missionFeedPresentation"; -import type { ChatMcpSummary } from "../chat/useChatMcpSummary"; // ── Design tokens ── const MONO = MONO_FONT; @@ -58,9 +56,7 @@ export type ChatMessageAreaProps = { missionNarrative: MissionStateNarrative | null; runtimeSummary: { title: string; detail: string } | null; agentRuntimeConfig: MissionAgentRuntimeConfig | null; - mcpSummary: ChatMcpSummary | null; runControls?: React.ReactNode; - onOpenMcpSettings: () => void; onApproval: ( sessionId: string, itemId: string, @@ -84,9 +80,7 @@ export const ChatMessageArea = React.memo(function ChatMessageArea({ missionNarrative, runtimeSummary, agentRuntimeConfig, - mcpSummary, runControls, - onOpenMcpSettings, onApproval, }: ChatMessageAreaProps) { const threadInterventionOwnerLabel = useMemo( @@ -186,23 +180,6 @@ export const ChatMessageArea = React.memo(function ChatMessageArea({
{runControls} - {mcpSummary ? ( - - ) : null} {agentRuntimeConfig && selectedChannel?.kind !== "worker" ? ( <> @@ -328,12 +305,6 @@ export const ChatMessageArea = React.memo(function ChatMessageArea({ // ── Small helper components ── -function formatMcpBadgeLabel(mcp: ChatMcpSummary): string { - if (mcp.connectedCount > 0) return `ADE MCP ${mcp.connectedCount}/${mcp.configuredCount}`; - if (mcp.configuredCount > 0) return `ADE MCP ${mcp.configuredCount} configured`; - return "ADE MCP"; -} - function workerBadgeLabel(status: string, phaseLabel: string | null): string { const suffix = status === "active" ? "worker" : "history"; const fallback = status === "active" ? "Active worker" : "Worker history"; diff --git a/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx b/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx index 42f37a83b..ae6995205 100644 --- a/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx +++ b/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx @@ -177,7 +177,6 @@ function createDefaultPermissionConfig(defaults: CreateMissionDefaults | null | return { ...defaults.permissionConfig, ...(defaults.permissionConfig.providers ? { providers: { ...defaults.permissionConfig.providers } } : {}), - ...(defaults.permissionConfig.externalMcp ? { externalMcp: { ...defaults.permissionConfig.externalMcp } } : {}), }; } return { diff --git a/apps/desktop/src/renderer/components/missions/MissionChatV2.tsx b/apps/desktop/src/renderer/components/missions/MissionChatV2.tsx index 16fd05d6e..f0ffc421c 100644 --- a/apps/desktop/src/renderer/components/missions/MissionChatV2.tsx +++ b/apps/desktop/src/renderer/components/missions/MissionChatV2.tsx @@ -29,8 +29,6 @@ import { ChatChannelList, type Channel } from "./ChatChannelList"; import { ChatMessageArea } from "./ChatMessageArea"; import { ChatInput, type QuickTarget } from "./ChatInput"; import { ChatSurfaceShell } from "../chat/ChatSurfaceShell"; -import { openExternalMcpSettings } from "../chat/chatNavigation"; -import { useChatMcpSummary } from "../chat/useChatMcpSummary"; import { useMissionsStore } from "./useMissionsStore"; const BG_PAGE = COLORS.pageBg; @@ -171,7 +169,6 @@ export const MissionChatV2 = React.memo(function MissionChatV2({ const selectedChannel = useMemo(() => channels.find((c) => c.id === selectedChannelId) ?? channels[0], [channels, selectedChannelId]); const missionSurfaceMode = selectedChannel?.kind === "global" ? "mission-feed" : "mission-thread"; const missionSurfaceAccent = useMemo(() => resolveMissionSurfaceAccent(selectedChannel), [selectedChannel]); - const mcpSummary = useChatMcpSummary(selectedChannel?.kind !== "worker"); const participants = useMemo(() => [], []); const quickTargets = useMemo(() => [], []); @@ -677,9 +674,7 @@ export const MissionChatV2 = React.memo(function MissionChatV2({ missionNarrative={selectedChannel?.kind === "global" ? missionNarrative : null} runtimeSummary={runtimeSummary} agentRuntimeConfig={agentRuntimeConfig} - mcpSummary={selectedChannel?.kind === "worker" ? null : mcpSummary} runControls={runControls} - onOpenMcpSettings={openExternalMcpSettings} onApproval={handleApproval} /> diff --git a/apps/desktop/src/renderer/components/missions/MissionHeader.test.ts b/apps/desktop/src/renderer/components/missions/MissionHeader.test.ts index 84d0ba952..b50f7cb67 100644 --- a/apps/desktop/src/renderer/components/missions/MissionHeader.test.ts +++ b/apps/desktop/src/renderer/components/missions/MissionHeader.test.ts @@ -169,10 +169,6 @@ describe("looksLikeLowSignalNoise", () => { expect(looksLikeLowSignalNoise("Usage")).toBe(true); }); - it("returns true for MCP prefixed messages", () => { - expect(looksLikeLowSignalNoise("mcp:tool_name")).toBe(true); - }); - it("returns false for substantive content", () => { expect(looksLikeLowSignalNoise("I have implemented the auth module.")).toBe(false); expect(looksLikeLowSignalNoise("The build failed due to a type error in utils.ts")).toBe(false); diff --git a/apps/desktop/src/renderer/components/missions/OrchestratorActivityFeed.test.ts b/apps/desktop/src/renderer/components/missions/OrchestratorActivityFeed.test.ts index 15023a90a..766e52216 100644 --- a/apps/desktop/src/renderer/components/missions/OrchestratorActivityFeed.test.ts +++ b/apps/desktop/src/renderer/components/missions/OrchestratorActivityFeed.test.ts @@ -124,8 +124,8 @@ describe("classifyErrorSource", () => { expect(classifyErrorSource("startup_failure: could not init")).toBe("Executor"); }); - it("classifies MCP/sandbox errors as Runtime", () => { - expect(classifyErrorSource("MCP tool failed")).toBe("Runtime"); + it("classifies environment/sandbox errors as Runtime", () => { + expect(classifyErrorSource("External CLI backend missing from environment")).toBe("Runtime"); expect(classifyErrorSource("Sandbox violation")).toBe("Runtime"); expect(classifyErrorSource("Permission denied")).toBe("Runtime"); }); diff --git a/apps/desktop/src/renderer/components/missions/WorkerPermissionsEditor.tsx b/apps/desktop/src/renderer/components/missions/WorkerPermissionsEditor.tsx index 19da6afc4..02f27f968 100644 --- a/apps/desktop/src/renderer/components/missions/WorkerPermissionsEditor.tsx +++ b/apps/desktop/src/renderer/components/missions/WorkerPermissionsEditor.tsx @@ -1,12 +1,9 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { Shield, Warning } from "@phosphor-icons/react"; import type { PhaseCard, MissionPermissionConfig, AgentChatPermissionMode, - ExternalMcpMissionSelection, - ExternalMcpServerConfig, - ExternalMcpServerSnapshot, } from "../../../shared/types"; import { resolveModelDescriptor } from "../../../shared/modelRegistry"; import { @@ -51,27 +48,8 @@ export type WorkerPermissionsEditorProps = { inputStyle?: React.CSSProperties; title?: string; description?: string; - showExternalMcp?: boolean; }; -function normalizeMissionSelection(value?: ExternalMcpMissionSelection | null): ExternalMcpMissionSelection { - return { - enabled: value?.enabled === true, - selectedServers: [...new Set(value?.selectedServers ?? [])], - selectedTools: [...new Set(value?.selectedTools ?? [])], - }; -} - -function toggleValue(list: string[], value: string, enabled: boolean): string[] { - const next = new Set(list); - if (enabled) { - next.add(value); - } else { - next.delete(value); - } - return [...next].sort((a, b) => a.localeCompare(b)); -} - const DEFAULT_LABEL_STYLE: React.CSSProperties = { fontSize: 9, fontWeight: 700, @@ -103,7 +81,6 @@ export function WorkerPermissionsEditor({ inputStyle: inpStyle, title, description, - showExternalMcp = true, }: WorkerPermissionsEditorProps) { const families = useMemo( () => deriveActivePermFamilies(orchestratorModelId, phases), @@ -136,60 +113,12 @@ export function WorkerPermissionsEditor({ }); }; - const [externalConfigs, setExternalConfigs] = useState([]); - const [externalSnapshots, setExternalSnapshots] = useState([]); - const [externalRegistryError, setExternalRegistryError] = useState(null); - - useEffect(() => { - let cancelled = false; - if (!window.ade?.externalMcp) return; - void Promise.all([ - window.ade.externalMcp.listConfigs(), - window.ade.externalMcp.listServers(), - ]).then(([configs, snapshots]) => { - if (cancelled) return; - setExternalConfigs(configs); - setExternalSnapshots(snapshots); - setExternalRegistryError(null); - }).catch((err) => { - if (cancelled) return; - setExternalConfigs([]); - setExternalSnapshots([]); - setExternalRegistryError(err instanceof Error ? err.message : "Failed to load external MCP registry."); - }); - return () => { - cancelled = true; - }; - }, []); - - const updateExternalMcp = (selection: ExternalMcpMissionSelection) => { - onPermissionChange({ - ...permissionConfig, - externalMcp: selection, - }); - }; - const hasRestricted = families.some((f) => { const mode = provPerms?.[f] ?? "full-auto"; return mode !== "full-auto"; }); const inputStyleResolved = inpStyle ?? DEFAULT_INPUT_STYLE; - const externalSelection = normalizeMissionSelection(permissionConfig?.externalMcp); - const snapshotByName = useMemo( - () => new Map(externalSnapshots.map((entry) => [entry.config.name, entry] as const)), - [externalSnapshots], - ); - const availableServers = useMemo( - () => externalConfigs.map((entry) => entry.name).sort((a, b) => a.localeCompare(b)), - [externalConfigs], - ); - const availableTools = useMemo(() => { - const serverFilter = new Set(externalSelection.selectedServers ?? []); - return externalSnapshots - .filter((snapshot) => serverFilter.size === 0 || serverFilter.has(snapshot.config.name)) - .flatMap((snapshot) => snapshot.tools.filter((tool) => tool.enabled)); - }, [externalSelection.selectedServers, externalSnapshots]); return (
@@ -325,135 +254,6 @@ export function WorkerPermissionsEditor({ Workers using restricted permissions may pause for approval during autonomous execution.
)} - - {showExternalMcp ? ( -
-
- - ADE-managed MCP - - - Mission-level ADE-brokered tool surface - -
- - - - {externalRegistryError && ( -
- {externalRegistryError} -
- )} - - {!externalRegistryError && externalSelection.enabled === true && ( -
- {availableServers.length === 0 ? ( -
- No ADE-managed MCP servers are configured in ADE yet. -
- ) : ( -
-
- Selected Servers -
-
- {availableServers.map((serverName) => { - const snapshot = snapshotByName.get(serverName); - const isChecked = (externalSelection.selectedServers ?? []).includes(serverName); - return ( - - ); - })} -
-
- Leave everything unchecked to allow all externally approved servers for this mission. -
-
- )} - - {availableTools.length > 0 && ( -
-
- Selected Tools -
-
- {availableTools.map((tool) => ( - - ))} -
-
- Leave everything unchecked to allow all tools from the selected servers. -
-
- )} -
- )} -
- ) : null}
); } diff --git a/apps/desktop/src/renderer/components/missions/missionFeedPresentation.test.ts b/apps/desktop/src/renderer/components/missions/missionFeedPresentation.test.ts index 6f12bd9ce..a96f5df36 100644 --- a/apps/desktop/src/renderer/components/missions/missionFeedPresentation.test.ts +++ b/apps/desktop/src/renderer/components/missions/missionFeedPresentation.test.ts @@ -59,25 +59,25 @@ describe("prepareMissionFeedItems", () => { at: "2026-03-11T14:00:00.000Z", kind: "system", title: "Tool call", - detail: "mcp__linear__get_issue", + detail: "linear.get_issue", }), makeProgressItem({ id: "meaningful-1", at: "2026-03-11T14:01:00.000Z", title: "Worker result", - detail: "Coordinator used mcp__posthog__query-run to inspect the conversion drop.", + detail: "Coordinator used posthog.query-run to inspect the conversion drop.", }), makeProgressItem({ id: "meaningful-2", at: "2026-03-11T14:02:00.000Z", title: "Worker result", - detail: "Coordinator used mcp__posthog__query-run to inspect the conversion drop.", + detail: "Coordinator used posthog.query-run to inspect the conversion drop.", }), ]); expect(items).toHaveLength(1); expect(items[0]?.detail).toContain("PostHog Query Run"); - expect(items[0]?.detail).not.toContain("mcp__posthog__query-run"); + expect(items[0]?.detail).not.toContain("posthog.query-run"); }); it("drops internal lifecycle noise and non-feed items", () => { diff --git a/apps/desktop/src/renderer/components/missions/missionHelpers.ts b/apps/desktop/src/renderer/components/missions/missionHelpers.ts index dd9008b64..18a58c338 100644 --- a/apps/desktop/src/renderer/components/missions/missionHelpers.ts +++ b/apps/desktop/src/renderer/components/missions/missionHelpers.ts @@ -185,11 +185,6 @@ export const DEFAULT_PERMISSION_CONFIG: MissionPermissionConfig = { opencode: "full-auto", codexSandbox: "workspace-write", }, - externalMcp: { - enabled: false, - selectedServers: [], - selectedTools: [], - }, }; export const DEFAULT_SMART_BUDGET: SmartBudgetConfig = { @@ -311,7 +306,6 @@ export function looksLikeLowSignalNoise(text: string): boolean { if (!trimmed.length) return true; if (/^streaming(?:\.\.\.)?$/i.test(trimmed)) return true; if (/^usage$/i.test(trimmed)) return true; - if (/^mcp:/i.test(trimmed)) return true; if (/^[\-dlcbps][rwx\-@+]{8,}/i.test(trimmed)) return true; if (/^[A-Z0-9 .:_()/-]{24,}$/.test(trimmed)) return true; if (!/\s/.test(trimmed) && trimmed.length < 24 && !/[.!?]/.test(trimmed)) return true; @@ -718,7 +712,7 @@ export const ERROR_SOURCE_COLORS: Record = { /** * Classify an error message into its source. (VAL-UX-008) * ADE = orchestrator / internal bugs. Provider = AI API / rate-limit / quota. - * Executor = CLI / process spawn. Runtime = env / config / MCP / sandbox. + * Executor = CLI / process spawn. Runtime = env / config / sandbox. */ export function classifyErrorSource(message: string): ErrorSource { const m = message.toLowerCase(); @@ -755,9 +749,8 @@ export function classifyErrorSource(message: string): ErrorSource { ) { return "Executor"; } - // Runtime errors (env, MCP, config, sandbox) + // Runtime errors (env, config, sandbox) if ( - m.includes("mcp") || m.includes("sandbox") || m.includes("permission") || m.includes("config") || diff --git a/apps/desktop/src/renderer/components/missions/missionUxUtils.test.ts b/apps/desktop/src/renderer/components/missions/missionUxUtils.test.ts index fa93776fe..5c9ed404c 100644 --- a/apps/desktop/src/renderer/components/missions/missionUxUtils.test.ts +++ b/apps/desktop/src/renderer/components/missions/missionUxUtils.test.ts @@ -50,9 +50,9 @@ describe("classifyErrorSource", () => { }); it("classifies env/config errors as Runtime", () => { - expect(classifyErrorSource("MCP server connection failed")).toBe("Runtime"); + expect(classifyErrorSource("External CLI backend missing from environment")).toBe("Runtime"); expect(classifyErrorSource("Sandbox permission denied")).toBe("Runtime"); - expect(classifyErrorSource("Gmail MCP probe failed")).toBe("Runtime"); + expect(classifyErrorSource("Runtime config probe failed")).toBe("Runtime"); expect(classifyErrorSource("Lane worktree not found")).toBe("Runtime"); }); diff --git a/apps/desktop/src/renderer/components/missions/useMissionsStore.ts b/apps/desktop/src/renderer/components/missions/useMissionsStore.ts index 3b3c87e8c..2f3145ad4 100644 --- a/apps/desktop/src/renderer/components/missions/useMissionsStore.ts +++ b/apps/desktop/src/renderer/components/missions/useMissionsStore.ts @@ -466,9 +466,6 @@ export const useMissionsStore = create((set, get) => ({ const effectiveInProcess = isRecord(effectivePermissions.inProcess) ? effectivePermissions.inProcess : {}; const localProviders = isRecord(localPermissions.providers) ? localPermissions.providers : {}; const effectiveProviders = isRecord(effectivePermissions.providers) ? effectivePermissions.providers : {}; - const localExternalMcp = isRecord(localPermissions.externalMcp) ? localPermissions.externalMcp : {}; - const effectiveExternalMcp = isRecord(effectivePermissions.externalMcp) ? effectivePermissions.externalMcp : {}; - const localOrcModel = isRecord(localOrchestrator.defaultOrchestratorModel) ? localOrchestrator.defaultOrchestratorModel : null; const effectiveOrcModel = isRecord(effectiveOrchestrator.defaultOrchestratorModel) ? effectiveOrchestrator.defaultOrchestratorModel : null; @@ -503,11 +500,6 @@ export const useMissionsStore = create((set, get) => ({ opencode: readString(localProviders.opencode, effectiveProviders.opencode, "full-auto") as import("../../../shared/types").AgentChatPermissionMode, codexSandbox: readString(localProviders.codexSandbox, effectiveProviders.codexSandbox, "workspace-write") as "read-only" | "workspace-write" | "danger-full-access", }, - externalMcp: { - enabled: readBoolean(localExternalMcp.enabled, effectiveExternalMcp.enabled, false), - selectedServers: readStringArray(localExternalMcp.selectedServers, effectiveExternalMcp.selectedServers), - selectedTools: readStringArray(localExternalMcp.selectedTools, effectiveExternalMcp.selectedTools), - }, }; set({ @@ -586,7 +578,6 @@ export const useMissionsStore = create((set, get) => ({ cli: nextCli, inProcess: nextInProcess, providers: draft.permissionConfig?.providers ?? DEFAULT_PERMISSION_CONFIG.providers, - externalMcp: draft.permissionConfig?.externalMcp, }, }, }, diff --git a/apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx b/apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx index fa3b044b1..f3bae7b94 100644 --- a/apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx @@ -140,13 +140,6 @@ export function ComputerUseSection() { useEffect(() => refresh(), [refresh]); - useEffect(() => { - if (!window.ade?.externalMcp?.onEvent) return undefined; - return window.ade.externalMcp.onEvent(() => { - refresh(); - }); - }, [refresh]); - const backends = snapshot?.backendStatus.backends ?? []; /* ---- loading / error states ---- */ @@ -197,9 +190,8 @@ export function ComputerUseSection() { }} > ADE automatically captures proof from any screenshot, recording, trace, or log tool - visible in ADE chat. Use Managed MCP only when missions, workers, or CTO need ADE to - broker the same server directly; provider-native chat tools can still feed the proof - drawer without being re-added there. + visible in ADE chat. CLI-native tools can also register proof through the ADE CLI so + missions, workers, and chats share the same proof drawer.

diff --git a/apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.test.tsx b/apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.test.tsx index 2b1b977ca..c89c96671 100644 --- a/apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.test.tsx @@ -34,16 +34,6 @@ function buildOpenCodeSnapshot(overrides: Partial = {}) sharedCount: 1, dedicatedCount: 0, entries: [], - dynamicMcp: { - registrationAttempts: 1, - fallbackCount: 0, - retryCount: 0, - successfulRegistrations: 1, - lastFallbackAt: null, - lastFallbackOwnerKind: null, - lastFallbackOwnerId: null, - lastFallbackError: null, - }, ...overrides, }; } @@ -88,30 +78,10 @@ describe("DiagnosticsDashboardSection", () => { const initialSnapshot = buildOpenCodeSnapshot({ sharedCount: 1, dedicatedCount: 0, - dynamicMcp: { - registrationAttempts: 1, - fallbackCount: 0, - retryCount: 0, - successfulRegistrations: 1, - lastFallbackAt: null, - lastFallbackOwnerKind: null, - lastFallbackOwnerId: null, - lastFallbackError: null, - }, }); const nextSnapshot = buildOpenCodeSnapshot({ sharedCount: 7, dedicatedCount: 3, - dynamicMcp: { - registrationAttempts: 3, - fallbackCount: 2, - retryCount: 1, - successfulRegistrations: 4, - lastFallbackAt: "2026-04-08T12:05:00.000Z", - lastFallbackOwnerKind: "chat", - lastFallbackOwnerId: "session-2", - lastFallbackError: "Timed out while registering ADE MCP", - }, entries: [ { id: "entry-2", @@ -164,7 +134,6 @@ describe("DiagnosticsDashboardSection", () => { }); expect(within(screen.getByText("OpenCode dedicated").parentElement as HTMLElement).getByText("3")).toBeTruthy(); - expect(within(screen.getByText("MCP fallbacks").parentElement as HTMLElement).getByText("2")).toBeTruthy(); await act(async () => { resolveSlowSnapshot(initialSnapshot); @@ -172,6 +141,5 @@ describe("DiagnosticsDashboardSection", () => { }); expect(within(screen.getByText("OpenCode dedicated").parentElement as HTMLElement).getByText("3")).toBeTruthy(); - expect(within(screen.getByText("MCP fallbacks").parentElement as HTMLElement).getByText("2")).toBeTruthy(); }); }); diff --git a/apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.tsx b/apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.tsx index 3a2c6cfd1..5094edb97 100644 --- a/apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.tsx +++ b/apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.tsx @@ -327,13 +327,10 @@ export function DiagnosticsDashboardSection({ const openCodeUnavailable = !loading && openCode === null; const openCodeEntries = openCode?.entries; const dedicatedOpenCodeCount = openCode?.dedicatedCount; - const openCodeFallbackCount = openCode?.dynamicMcp.fallbackCount; - const openCodeLastFallbackAt = openCode?.dynamicMcp.lastFallbackAt ?? null; - const openCodeRetryCount = openCode?.dynamicMcp.retryCount; const showOpenCodeSection = openCodeMode === "full" || openCodeUnavailable || (openCodeMode === "issues-only" - && ((dedicatedOpenCodeCount ?? 0) > 0 || (openCodeFallbackCount ?? 0) > 0 || (openCodeRetryCount ?? 0) > 0)); + && ((dedicatedOpenCodeCount ?? 0) > 0)); // ------------------------------------------------------------------------- // Render @@ -426,41 +423,8 @@ export function DiagnosticsDashboardSection({ value={dedicatedOpenCodeCount ?? 0} color={(dedicatedOpenCodeCount ?? 0) > 0 ? COLORS.warning : undefined} /> - 0 ? COLORS.warning : undefined} - /> - -
- {openCodeLastFallbackAt && ( -
- - Latest OpenCode fallback {formatTimestamp(openCodeLastFallbackAt)} - {openCode?.dynamicMcp.lastFallbackOwnerKind ? ` • ${openCode.dynamicMcp.lastFallbackOwnerKind}` : ""} - {openCode?.dynamicMcp.lastFallbackOwnerId ? `:${openCode.dynamicMcp.lastFallbackOwnerId}` : ""} - -
- )} - {openCodeEntries && openCodeEntries.length > 0 ? (
{openCodeEntries.slice(0, 6).map((entry) => ( diff --git a/apps/desktop/src/renderer/components/settings/ExternalMcpSection.tsx b/apps/desktop/src/renderer/components/settings/ExternalMcpSection.tsx deleted file mode 100644 index d2c796cea..000000000 --- a/apps/desktop/src/renderer/components/settings/ExternalMcpSection.tsx +++ /dev/null @@ -1,1387 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import type { - ExternalConnectionAuthRecord, - ExternalConnectionAuthRecordInput, - ExternalConnectionOAuthSessionStartResult, - ExternalMcpManagedAuthConfig, - ExternalMcpServerConfig, - ExternalMcpServerSnapshot, - ExternalMcpUsageEvent, -} from "../../../shared/types"; -import { - COLORS, - LABEL_STYLE, - MONO_FONT, - cardStyle, - dangerButton, - outlineButton, - primaryButton, -} from "../lanes/laneDesignTokens"; - -type ServerDraft = { - name: string; - transport: "stdio" | "http" | "sse"; - command: string; - args: string; - cwd: string; - envLines: string; - url: string; - headerLines: string; - autoStart: boolean; - healthCheckIntervalSec: string; - allowedTools: string; - blockedTools: string; - defaultCostCents: string; - perToolCostLines: string; - authMode: "none" | "api_key" | "bearer" | "oauth"; - authId: string; - authDisplayName: string; - authPlacementTarget: "header" | "env"; - authPlacementKey: string; - authPlacementPrefix: string; - authSecret: string; - oauthAuthorizeUrl: string; - oauthTokenUrl: string; - oauthClientId: string; - oauthClientSecret: string; - oauthScope: string; - oauthAudience: string; - oauthExtraAuthorizeLines: string; - oauthExtraTokenLines: string; -}; - -type StarterTemplate = { - id: string; - label: string; - description: string; - config: ExternalMcpServerConfig; -}; - -const sectionLabelStyle: React.CSSProperties = { - ...LABEL_STYLE, - fontSize: 11, - marginBottom: 10, -}; - -const fieldLabelStyle: React.CSSProperties = { - ...LABEL_STYLE, - fontSize: 10, -}; - -const inputStyle: React.CSSProperties = { - width: "100%", - minHeight: 32, - padding: "6px 8px", - fontSize: 11, - fontFamily: MONO_FONT, - color: COLORS.textPrimary, - background: COLORS.recessedBg, - border: `1px solid ${COLORS.outlineBorder}`, - borderRadius: 0, - outline: "none", -}; - -const helperTextStyle: React.CSSProperties = { - fontSize: 10, - fontFamily: MONO_FONT, - color: COLORS.textMuted, - lineHeight: 1.5, -}; - -const starterButtonStyle = (active: boolean): React.CSSProperties => ({ - display: "grid", - gap: 4, - minWidth: 180, - padding: "10px 12px", - textAlign: "left", - background: active ? COLORS.cardBg : COLORS.recessedBg, - border: `1px solid ${active ? COLORS.accentBorder : COLORS.outlineBorder}`, - color: COLORS.textPrimary, - borderRadius: 0, - cursor: "pointer", -}); - -const STARTER_TEMPLATES: StarterTemplate[] = [ - { - id: "stdio-local", - label: "Local stdio server", - description: "For npm, npx, uvx, python, or node-based MCP servers that run on your machine.", - config: { - name: "", - transport: "stdio", - command: "npx", - args: [], - autoStart: true, - healthCheckIntervalSec: 30, - }, - }, - { - id: "ghost-os", - label: "Ghost OS", - description: "macOS computer-use backend over stdio. Install Ghost OS locally, run `ghost setup`, then let ADE launch `ghost mcp`.", - config: { - name: "Ghost OS", - transport: "stdio", - command: "ghost", - args: ["mcp"], - autoStart: true, - healthCheckIntervalSec: 30, - }, - }, - { - id: "remote-bearer", - label: "Remote bearer auth", - description: "For hosted MCP endpoints that expect Authorization: Bearer .", - config: { - name: "", - transport: "http", - url: "", - auth: { - authId: "", - mode: "bearer", - placement: { target: "header", key: "Authorization", prefix: "Bearer " }, - }, - autoStart: true, - healthCheckIntervalSec: 30, - }, - }, - { - id: "remote-api-key", - label: "Remote API key header", - description: "For hosted MCP endpoints that use x-api-key or a custom header instead of Bearer auth.", - config: { - name: "", - transport: "http", - url: "", - auth: { - authId: "", - mode: "api_key", - placement: { target: "header", key: "x-api-key", prefix: "" }, - }, - autoStart: true, - healthCheckIntervalSec: 30, - }, - }, - { - id: "remote-oauth", - label: "Remote OAuth server", - description: "For hosted MCP providers that need a browser-based account connect flow.", - config: { - name: "", - transport: "http", - url: "", - auth: { - authId: "", - mode: "oauth", - placement: { target: "header", key: "Authorization", prefix: "Bearer " }, - }, - autoStart: true, - healthCheckIntervalSec: 30, - }, - }, -]; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function readStringValue(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const trimmed = value.trim(); - return trimmed.length ? trimmed : undefined; -} - -function readStringList(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined; - const next = value - .map((entry) => (typeof entry === "string" ? entry.trim() : "")) - .filter(Boolean); - return next.length ? next : undefined; -} - -function readStringMap(value: unknown): Record | undefined { - if (!isRecord(value)) return undefined; - const entries = Object.entries(value) - .map(([key, entry]) => { - const nextKey = key.trim(); - if (!nextKey.length || entry == null) return null; - return [nextKey, String(entry)] as const; - }) - .filter((entry): entry is readonly [string, string] => entry != null); - return entries.length ? Object.fromEntries(entries) : undefined; -} - -function stripCodeFence(value: string): string { - const trimmed = value.trim(); - if (!trimmed.startsWith("```")) return trimmed; - const lines = trimmed.split(/\r?\n/); - if (lines.length >= 2 && lines.at(-1)?.startsWith("```")) { - return lines.slice(1, -1).join("\n").trim(); - } - return trimmed; -} - -function parsePastedJson(value: string): unknown { - const trimmed = stripCodeFence(value); - const firstBrace = trimmed.indexOf("{"); - const lastBrace = trimmed.lastIndexOf("}"); - const candidate = trimmed.startsWith("{") && trimmed.endsWith("}") - ? trimmed - : (firstBrace >= 0 && lastBrace > firstBrace ? trimmed.slice(firstBrace, lastBrace + 1) : trimmed); - return JSON.parse(candidate); -} - -function normalizeImportedTransport(value: string | undefined): ServerDraft["transport"] { - const normalized = value?.trim().toLowerCase(); - if (!normalized || normalized === "stdio") return "stdio"; - if (normalized === "http" || normalized === "streamable_http" || normalized === "streamable-http") return "http"; - if (normalized === "sse") return "sse"; - return "stdio"; -} - -function normalizeImportedServer(rawValue: unknown, nameHint?: string): ExternalMcpServerConfig { - if (!isRecord(rawValue)) { - throw new Error("The pasted MCP config is not a valid server object."); - } - - const rawName = readStringValue(rawValue.name) ?? nameHint; - const name = rawName?.trim(); - if (!name) { - throw new Error("The pasted MCP config is missing a server name."); - } - - const transport = normalizeImportedTransport(readStringValue(rawValue.transport) ?? readStringValue(rawValue.type)); - const commandValue = rawValue.command; - const commandParts = Array.isArray(commandValue) - ? commandValue.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean) - : []; - const command = readStringValue(commandValue) ?? commandParts[0]; - const args = readStringList(rawValue.args) - ?? (commandParts.length > 1 ? commandParts.slice(1) : undefined); - const url = readStringValue(rawValue.url) ?? readStringValue(rawValue.endpoint); - const env = readStringMap(rawValue.env); - const headers = readStringMap(rawValue.headers); - const allowedTools = readStringList(rawValue.allowedTools) - ?? readStringList(isRecord(rawValue.permissions) ? rawValue.permissions.allowedTools : undefined); - const blockedTools = readStringList(rawValue.blockedTools) - ?? readStringList(isRecord(rawValue.permissions) ? rawValue.permissions.blockedTools : undefined); - const healthCheckIntervalSec = Number(rawValue.healthCheckIntervalSec ?? NaN); - const autoStart = typeof rawValue.autoStart === "boolean" ? rawValue.autoStart : true; - - if (transport === "stdio" && !command) { - throw new Error("The pasted stdio MCP config is missing a command."); - } - if (transport !== "stdio" && !url) { - throw new Error("The pasted remote MCP config is missing a URL."); - } - - return { - name, - transport, - ...(transport === "stdio" - ? { - command, - ...(args?.length ? { args } : {}), - ...(readStringValue(rawValue.cwd) ? { cwd: readStringValue(rawValue.cwd) } : {}), - ...(env ? { env } : {}), - } - : { - url, - ...(headers ? { headers } : {}), - }), - autoStart, - ...(Number.isFinite(healthCheckIntervalSec) && healthCheckIntervalSec > 0 - ? { healthCheckIntervalSec: Math.floor(healthCheckIntervalSec) } - : {}), - ...(allowedTools?.length || blockedTools?.length - ? { - permissions: { - ...(allowedTools?.length ? { allowedTools } : {}), - ...(blockedTools?.length ? { blockedTools } : {}), - }, - } - : {}), - }; -} - -function parseImportedServerConfig(value: string): ExternalMcpServerConfig { - const trimmed = stripCodeFence(value); - if (!trimmed.length) { - throw new Error("Paste a JSON snippet or an add-json command first."); - } - - const addJsonMatch = trimmed.match(/\badd-json\s+(['"]?)([^'"{\s]+)\1\s+/i); - const parsed = parsePastedJson(trimmed); - if (!isRecord(parsed)) { - throw new Error("The pasted content did not contain a valid MCP config object."); - } - - if (isRecord(parsed.mcpServers)) { - const entries = Object.entries(parsed.mcpServers).filter(([, entry]) => isRecord(entry)); - if (entries.length !== 1) { - throw new Error("Paste one MCP server at a time so ADE can load it into the editor."); - } - const [name, server] = entries[0]!; - return normalizeImportedServer(server, name); - } - - const directConfig = normalizeImportedServer(parsed, addJsonMatch?.[2]); - return directConfig; -} - -function splitCommaSeparated(value: string): string[] { - return value.split(",").map((entry) => entry.trim()).filter(Boolean); -} - -function parseLineMap(value: string): Record | undefined { - const entries = value - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => { - const divider = line.indexOf("="); - if (divider < 0) return null; - const key = line.slice(0, divider).trim(); - const nextValue = line.slice(divider + 1).trim(); - return key && nextValue ? [key, nextValue] as const : null; - }) - .filter((entry): entry is readonly [string, string] => entry != null); - return entries.length > 0 ? Object.fromEntries(entries) : undefined; -} - -function formatLineMap(value?: Record): string { - if (!value) return ""; - return Object.entries(value) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, entry]) => `${key}=${entry}`) - .join("\n"); -} - -function parsePerToolCosts(value: string): Record | undefined { - const entries = value - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => { - const divider = line.indexOf("="); - if (divider < 0) return null; - const key = line.slice(0, divider).trim(); - const rawCost = Number(line.slice(divider + 1).trim()); - return key && Number.isFinite(rawCost) && rawCost >= 0 ? [key, Math.floor(rawCost)] as const : null; - }) - .filter((entry): entry is readonly [string, number] => entry != null); - return entries.length > 0 ? Object.fromEntries(entries) : undefined; -} - -function formatPerToolCosts(value?: Record): string { - if (!value) return ""; - return Object.entries(value) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, cost]) => `${key}=${cost}`) - .join("\n"); -} - -function parseAuthParamLines(value: string): Record | undefined { - return parseLineMap(value); -} - -function formatAuthParamLines(value?: Record): string { - return formatLineMap(value); -} - -function draftFromConfig(config?: ExternalMcpServerConfig | null): ServerDraft { - return { - name: config?.name ?? "", - transport: config?.transport ?? "stdio", - command: config?.command ?? "", - args: (config?.args ?? []).join(" "), - cwd: config?.cwd ?? "", - envLines: formatLineMap(config?.env), - url: config?.url ?? "", - headerLines: formatLineMap(config?.headers), - autoStart: config?.autoStart !== false, - healthCheckIntervalSec: config?.healthCheckIntervalSec != null ? String(config.healthCheckIntervalSec) : "30", - allowedTools: (config?.permissions?.allowedTools ?? []).join(", "), - blockedTools: (config?.permissions?.blockedTools ?? []).join(", "), - defaultCostCents: config?.costHints?.defaultCostCents != null ? String(config.costHints.defaultCostCents) : "", - perToolCostLines: formatPerToolCosts(config?.costHints?.perToolCostCents), - authMode: config?.auth?.mode ?? "none", - authId: config?.auth?.authId ?? "", - authDisplayName: config?.name ? `${config.name} auth` : "", - authPlacementTarget: config?.auth?.placement.target ?? "header", - authPlacementKey: config?.auth?.placement.key ?? "Authorization", - authPlacementPrefix: config?.auth?.placement.prefix ?? (config?.auth?.mode === "api_key" ? "" : "Bearer "), - authSecret: "", - oauthAuthorizeUrl: "", - oauthTokenUrl: "", - oauthClientId: "", - oauthClientSecret: "", - oauthScope: "", - oauthAudience: "", - oauthExtraAuthorizeLines: "", - oauthExtraTokenLines: "", - }; -} - -function buildManagedAuthBinding(draft: ServerDraft, authId: string): ExternalMcpManagedAuthConfig | undefined { - if (draft.authMode === "none") return undefined; - const key = draft.authPlacementKey.trim(); - if (!authId.trim().length || !key.length) return undefined; - return { - authId: authId.trim(), - mode: draft.authMode, - placement: { - target: draft.authPlacementTarget, - key, - prefix: draft.authPlacementPrefix, - }, - }; -} - -function configFromDraft(draft: ServerDraft, authId: string): ExternalMcpServerConfig { - const healthCheckIntervalSec = Number(draft.healthCheckIntervalSec.trim()); - const defaultCostCents = Number(draft.defaultCostCents.trim()); - return { - name: draft.name.trim(), - transport: draft.transport, - ...(draft.transport === "stdio" - ? { - command: draft.command.trim(), - ...(draft.args.trim() ? { args: draft.args.trim().split(/\s+/).filter(Boolean) } : {}), - ...(draft.cwd.trim() ? { cwd: draft.cwd.trim() } : {}), - ...(parseLineMap(draft.envLines) ? { env: parseLineMap(draft.envLines) } : {}), - } - : { - url: draft.url.trim(), - ...(parseLineMap(draft.headerLines) ? { headers: parseLineMap(draft.headerLines) } : {}), - }), - autoStart: draft.autoStart, - ...(Number.isFinite(healthCheckIntervalSec) && healthCheckIntervalSec > 0 - ? { healthCheckIntervalSec: Math.floor(healthCheckIntervalSec) } - : {}), - ...(splitCommaSeparated(draft.allowedTools).length || splitCommaSeparated(draft.blockedTools).length - ? { - permissions: { - ...(splitCommaSeparated(draft.allowedTools).length ? { allowedTools: splitCommaSeparated(draft.allowedTools) } : {}), - ...(splitCommaSeparated(draft.blockedTools).length ? { blockedTools: splitCommaSeparated(draft.blockedTools) } : {}), - }, - } - : {}), - ...(Number.isFinite(defaultCostCents) || parsePerToolCosts(draft.perToolCostLines) - ? { - costHints: { - ...(Number.isFinite(defaultCostCents) && defaultCostCents >= 0 ? { defaultCostCents: Math.floor(defaultCostCents) } : {}), - ...(parsePerToolCosts(draft.perToolCostLines) ? { perToolCostCents: parsePerToolCosts(draft.perToolCostLines) } : {}), - }, - } - : {}), - ...(buildManagedAuthBinding(draft, authId) ? { auth: buildManagedAuthBinding(draft, authId) } : {}), - }; -} - -function applyAuthRecordToDraft(draft: ServerDraft, record?: ExternalConnectionAuthRecord | null): ServerDraft { - if (!record) return draft; - return { - ...draft, - authId: record.id, - authDisplayName: record.displayName, - authMode: record.mode, - oauthAuthorizeUrl: record.oauth?.authorizeUrl ?? draft.oauthAuthorizeUrl, - oauthTokenUrl: record.oauth?.tokenUrl ?? draft.oauthTokenUrl, - oauthClientId: record.oauth?.clientId ?? draft.oauthClientId, - oauthScope: record.oauth?.scope ?? "", - oauthAudience: record.oauth?.audience ?? "", - oauthExtraAuthorizeLines: formatAuthParamLines(record.oauth?.extraAuthorizeParams), - oauthExtraTokenLines: formatAuthParamLines(record.oauth?.extraTokenParams), - }; -} - -function formatTimestamp(value?: string | null): string { - if (!value) return "Never"; - try { - return new Date(value).toLocaleString(); - } catch { - return value; - } -} - -export function ExternalMcpSection() { - const [configs, setConfigs] = useState([]); - const [snapshots, setSnapshots] = useState([]); - const [usageEvents, setUsageEvents] = useState([]); - const [authRecords, setAuthRecords] = useState([]); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [busyServerName, setBusyServerName] = useState(null); - const [editingServerName, setEditingServerName] = useState(null); - const [draft, setDraft] = useState(draftFromConfig()); - const [importText, setImportText] = useState(""); - const [selectedTemplateId, setSelectedTemplateId] = useState(null); - const [authEnvVar, setAuthEnvVar] = useState("MCP_API_KEY"); - const [authHeaderName, setAuthHeaderName] = useState("Authorization"); - const [authHeaderPrefix, setAuthHeaderPrefix] = useState("Bearer "); - const [oauthSession, setOauthSession] = useState(null); - const [notice, setNotice] = useState(null); - const [error, setError] = useState(null); - - const refresh = useCallback(async () => { - if (!window.ade?.externalMcp) { - setConfigs([]); - setSnapshots([]); - setUsageEvents([]); - setLoading(false); - return; - } - setLoading(true); - setError(null); - try { - const [nextConfigs, nextSnapshots, nextUsage, nextAuthRecords] = await Promise.all([ - window.ade.externalMcp.listConfigs(), - window.ade.externalMcp.listServers(), - window.ade.externalMcp.getUsageEvents({ limit: 12 }), - window.ade.externalMcp.listAuthRecords(), - ]); - setConfigs(nextConfigs); - setSnapshots(nextSnapshots); - setUsageEvents(nextUsage); - setAuthRecords(nextAuthRecords); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load ADE-managed MCP state."); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void refresh(); - }, [refresh]); - - useEffect(() => { - if (!window.ade?.externalMcp?.onEvent) return undefined; - return window.ade.externalMcp.onEvent(() => { - void refresh(); - }); - }, [refresh]); - - const snapshotByName = useMemo( - () => new Map(snapshots.map((entry) => [entry.config.name, entry] as const)), - [snapshots], - ); - const authRecordById = useMemo( - () => new Map(authRecords.map((entry) => [entry.id, entry] as const)), - [authRecords], - ); - - useEffect(() => { - if (!oauthSession || !window.ade?.externalMcp) return undefined; - let cancelled = false; - const interval = window.setInterval(() => { - void window.ade.externalMcp.getOAuthSession(oauthSession.sessionId).then((status) => { - if (cancelled) return; - if (status.status === "completed") { - setOauthSession(null); - setNotice("OAuth account connected. Test the server, then connect it."); - setError(null); - void refresh(); - return; - } - if (status.status === "failed" || status.status === "expired") { - setOauthSession(null); - setError(status.error ?? "OAuth setup did not complete."); - } - }).catch((err) => { - if (cancelled) return; - setOauthSession(null); - setError(err instanceof Error ? err.message : "OAuth setup did not complete."); - }); - }, 1500); - return () => { - cancelled = true; - window.clearInterval(interval); - }; - }, [oauthSession, refresh]); - - const handleEdit = (config?: ExternalMcpServerConfig | null) => { - setEditingServerName(config?.name ?? null); - setDraft(applyAuthRecordToDraft(draftFromConfig(config), config?.auth?.authId ? authRecordById.get(config.auth.authId) ?? null : null)); - setSelectedTemplateId(null); - setNotice(null); - setError(null); - }; - - const buildAuthRecordInput = (): ExternalConnectionAuthRecordInput | null => { - if (draft.authMode === "none") return null; - const displayName = draft.authDisplayName.trim() || `${draft.name.trim() || "ADE-managed MCP"} auth`; - if (draft.authMode === "oauth") { - return { - ...(draft.authId.trim() ? { id: draft.authId.trim() } : {}), - displayName, - mode: "oauth", - oauth: { - authorizeUrl: draft.oauthAuthorizeUrl.trim(), - tokenUrl: draft.oauthTokenUrl.trim(), - clientId: draft.oauthClientId.trim(), - clientSecret: draft.oauthClientSecret.trim() || null, - scope: draft.oauthScope.trim() || null, - audience: draft.oauthAudience.trim() || null, - extraAuthorizeParams: parseAuthParamLines(draft.oauthExtraAuthorizeLines), - extraTokenParams: parseAuthParamLines(draft.oauthExtraTokenLines), - }, - }; - } - return { - ...(draft.authId.trim() ? { id: draft.authId.trim() } : {}), - displayName, - mode: draft.authMode, - secret: draft.authSecret.trim() || null, - }; - }; - - const saveManagedAuthRecord = async (): Promise => { - if (!window.ade?.externalMcp) return null; - const input = buildAuthRecordInput(); - if (!input) return null; - const record = await window.ade.externalMcp.saveAuthRecord(input); - setDraft((current) => ({ - ...current, - authId: record.id, - authDisplayName: record.displayName, - authMode: record.mode, - authSecret: "", - oauthClientSecret: "", - })); - return record; - }; - - const handleSave = async () => { - if (!window.ade?.externalMcp) return; - setSaving(true); - setNotice(null); - setError(null); - try { - const authRecord = await saveManagedAuthRecord(); - await window.ade.externalMcp.saveServer(configFromDraft(draft, authRecord?.id ?? draft.authId.trim())); - await refresh(); - setEditingServerName(null); - setDraft(draftFromConfig()); - setSelectedTemplateId(null); - setNotice(`Saved ADE-managed MCP server '${draft.name.trim()}'.`); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to save server."); - } finally { - setSaving(false); - } - }; - - const handleImportPreview = () => { - setNotice(null); - setError(null); - try { - const imported = parseImportedServerConfig(importText); - setEditingServerName(imported.name); - setDraft(draftFromConfig(imported)); - setSelectedTemplateId(null); - setNotice(`Loaded '${imported.name}' into the editor. Review it, then save when ready.`); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to parse the pasted MCP config."); - } - }; - - const handleApplyTemplate = (template: StarterTemplate) => { - setSelectedTemplateId(template.id); - setEditingServerName(template.config.name ?? null); - setDraft(draftFromConfig(template.config)); - setNotice(null); - setError(null); - }; - - const applyAuthHeaderHelper = () => { - const envVar = authEnvVar.trim(); - const headerName = authHeaderName.trim(); - if (!envVar.length || !headerName.length) { - setError("Auth helper requires both a header name and an environment variable."); - setNotice(null); - return; - } - if (draft.authMode === "none") { - const nextMode = authHeaderPrefix.trim().length ? "bearer" : "api_key"; - setDraft((current) => ({ - ...current, - authMode: nextMode, - authPlacementTarget: "header", - authPlacementKey: headerName, - authPlacementPrefix: authHeaderPrefix, - })); - setNotice(`Configured managed ${nextMode === "bearer" ? "bearer" : "API key"} auth for ${headerName}. Add the credential below, then save.`); - setError(null); - return; - } - const currentHeaders = parseLineMap(draft.headerLines) ?? {}; - currentHeaders[headerName] = `${authHeaderPrefix}\${env:${envVar}}`; - setDraft((current) => ({ ...current, headerLines: formatLineMap(currentHeaders) })); - setNotice(`Applied manual ${headerName} header using \${env:${envVar}}.`); - setError(null); - }; - - const handleTest = async () => { - if (!window.ade?.externalMcp) return; - setSaving(true); - setNotice(null); - setError(null); - try { - const authRecord = await saveManagedAuthRecord(); - const snapshot = await window.ade.externalMcp.testServer(configFromDraft(draft, authRecord?.id ?? draft.authId.trim())); - setNotice(`Test succeeded for '${snapshot.config.name}' with ${snapshot.toolCount} discovered tool(s).`); - await refresh(); - } catch (err) { - setError(err instanceof Error ? err.message : "Connection test failed."); - } finally { - setSaving(false); - } - }; - - const handleConnectOAuth = async () => { - if (!window.ade?.externalMcp) return; - setSaving(true); - setNotice(null); - setError(null); - try { - const authRecord = await saveManagedAuthRecord(); - if (!authRecord) throw new Error("Save the auth settings before connecting an account."); - const session = await window.ade.externalMcp.startOAuthSession(authRecord.id); - setOauthSession(session); - await window.ade.app.openExternal(session.authUrl); - setNotice("Opened the OAuth consent screen in your browser. Finish it there, then ADE will refresh automatically."); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to start OAuth."); - } finally { - setSaving(false); - } - }; - - const handleConnect = async (serverName: string) => { - if (!window.ade?.externalMcp) return; - setBusyServerName(serverName); - setError(null); - try { - await window.ade.externalMcp.connectServer(serverName); - await refresh(); - } catch (err) { - setError(err instanceof Error ? err.message : `Failed to connect '${serverName}'.`); - } finally { - setBusyServerName(null); - } - }; - - const handleDisconnect = async (serverName: string) => { - if (!window.ade?.externalMcp) return; - setBusyServerName(serverName); - setError(null); - try { - await window.ade.externalMcp.disconnectServer(serverName); - await refresh(); - } catch (err) { - setError(err instanceof Error ? err.message : `Failed to disconnect '${serverName}'.`); - } finally { - setBusyServerName(null); - } - }; - - const handleRemove = async (serverName: string) => { - if (!window.ade?.externalMcp) return; - const confirmed = window.confirm(`Remove ADE-managed MCP server '${serverName}'?`); - if (!confirmed) return; - setBusyServerName(serverName); - setError(null); - try { - await window.ade.externalMcp.removeServer(serverName); - if (editingServerName === serverName) { - setEditingServerName(null); - setDraft(draftFromConfig()); - } - await refresh(); - } catch (err) { - setError(err instanceof Error ? err.message : `Failed to remove '${serverName}'.`); - } finally { - setBusyServerName(null); - } - }; - - return ( -
-
-
-
-
ADE-managed MCP
-
- Use this when ADE itself needs to broker a third-party MCP server: missions, worker chats, - CTO sessions, allowlists, budgets, and proof ownership all run through this layer. - If a tool only needs to exist inside Claude or Codex direct chat, keep it in that provider's - own MCP config instead. -
-
-
- - -
-
- {notice &&
{notice}
} - {error &&
{error}
} -
- -
-
Starter Templates
-
- Pick the closest setup shape first if you are starting from scratch. If a provider already gave you JSON, use Quick Import instead. -
-
-
- Auth guide -
-
- API key / bearer servers can be fully configured inside ADE. OAuth servers can also be handled in ADE if the provider’s authorize/token endpoints are known. -
-
- Ghost is the simple case: it does not use webpage OAuth. Enter your Ghost site URL and Ghost Admin API key and ADE will inject them for every ADE-launched session. -
-
-
- {STARTER_TEMPLATES.map((template) => ( - - ))} -
-
- -
-
Quick Import
-
- Most hosted MCP providers hand you a JSON snippet or a claude mcp add-json ... command. - Paste that here and ADE will load the server into the editor so you can review it before saving. -
-