diff --git a/src/main/app-controls/AppControlsMcpIngress.test.ts b/src/main/app-controls/AppControlsMcpIngress.test.ts index 669c8ca5..63c0772f 100644 --- a/src/main/app-controls/AppControlsMcpIngress.test.ts +++ b/src/main/app-controls/AppControlsMcpIngress.test.ts @@ -1,7 +1,34 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ScheduledTask, ScheduledTaskInput, Thread } from "@/shared/contracts"; +import type { + AgentStatusesResponse, + CloseThreadPayload, + InterruptThreadPayload, + ListProjectTreeResult, + McpProbeResult, + PrData, + Project, + ProviderUsagePayload, + ProviderUsageResponse, + ReadProjectFileResult, + RemoteThreadCommand, + ScheduledTask, + ScheduledTaskInput, + SearchProjectFilesPayload, + SearchProjectFilesResult, + SearchProjectTreeResult, + SendThreadInputPayload, + StartThreadPayload, + StartThreadResult, + Thread, + ThreadRuntimeSnapshot, +} from "@/shared/contracts"; +import type { RemoteProjectCommand, RemoteProjectCommandResult } from "@/shared/remote"; +import { defaultSharedSettings } from "@/shared/settings"; import type { ScheduleService } from "../schedules/ScheduleService"; -import { AppControlsMcpIngress } from "./AppControlsMcpIngress"; +import type { CreateAppThreadRequest, CreateAppThreadResult } from "../threads/appThreadLauncher"; +import { AppControlsMcpIngress, type AppControlsMcpIngressDeps } from "./AppControlsMcpIngress"; + +type SC = AppControlsMcpIngressDeps["supervisor"]; let ingress: AppControlsMcpIngress | null = null; @@ -10,54 +37,302 @@ afterEach(() => { ingress = null; }); +const thread = { + id: "thread-1", + projectId: "project-1", + title: "Caller", + agentKind: "codex", + config: { model: "gpt-5.6", effort: "high" }, + status: "working", + attention: "none", + presentationMode: "gui", + archived: false, + done: false, + starred: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +} as Thread; + +function deps(overrides: Partial = {}): AppControlsMcpIngressDeps { + return { + scheduleService: { + list: vi.fn<() => ScheduledTask[]>(() => []), + create: vi.fn<(input: ScheduledTaskInput) => ScheduledTask>( + (input) => ({ id: "created", ...input }) as ScheduledTask, + ), + } as unknown as ScheduleService, + getThread: (id) => (id === thread.id ? thread : null), + getThreads: () => [thread], + getProjects: () => [{ id: "project-1", name: "Alpha" } as Project], + getProject: (id) => (id === "project-1" ? ({ id, name: "Alpha" } as Project) : null), + getProjectNotes: () => null, + directoryExists: () => true, + applyProjectCommand: vi.fn< + (command: RemoteProjectCommand) => Promise + >(async () => ({ projects: [] })), + updateProject: vi.fn<(project: Project) => void>(), + settings: { + read: () => defaultSharedSettings, + write: vi.fn<(next: typeof defaultSharedSettings) => void>(), + }, + getAppInfo: () => ({ version: "0.0.0-test", platform: "linux", hasRendererWindow: false }), + supervisor: { + getThreadSnapshots: vi.fn<() => Promise>(async () => [ + { threadId: "thread-1", status: "working", attention: "none", canResumeWithConfig: false }, + ]), + startThread: vi.fn<(payload: StartThreadPayload) => Promise>( + async (payload) => ({ threadId: payload.threadId ?? "resumed" }), + ), + sendThreadInput: vi.fn<(payload: SendThreadInputPayload) => Promise>( + async () => undefined, + ), + interruptThread: vi.fn<(payload: InterruptThreadPayload) => Promise>( + async () => undefined, + ), + closeThread: vi.fn<(payload: CloseThreadPayload) => Promise>(async () => undefined), + getProviderUsage: vi.fn<(payload: ProviderUsagePayload) => Promise>( + async () => ({ snapshots: [], fromCache: true }), + ), + refreshProviderUsage: vi.fn< + (payload: ProviderUsagePayload) => Promise + >(async () => ({ snapshots: [], fromCache: false })), + searchProjectFiles: vi.fn< + (payload: SearchProjectFilesPayload) => Promise + >(async () => ({ entries: [], totalIndexed: 0 })), + readTerminalScrollback: vi.fn<() => Promise>(async () => ""), + setPendingSteer: vi.fn<() => Promise>(async () => undefined), + clearPendingSteer: vi.fn<() => Promise>(async () => undefined), + stageThreadInput: vi.fn<() => Promise>(async () => undefined), + rollbackThreadConversation: vi.fn<() => Promise>(async () => undefined), + getAgentStatuses: vi.fn<() => Promise>(async () => ({ + windows: [], + wsl: [], + fromCache: true, + })), + refreshAgentStatuses: vi.fn<() => Promise>(async () => ({ + windows: [], + wsl: [], + fromCache: false, + })), + listProjectTree: vi.fn<() => Promise>(async () => ({ + directoryPath: "", + entries: [], + })), + readProjectFile: vi.fn<() => Promise>(async () => ({ + path: "", + status: "ready", + modifiedAtMs: 0, + content: "", + })), + searchProjectTree: vi.fn<() => Promise>(async () => ({ + entries: [], + })), + gitProjectSnapshot: vi.fn(async () => ({ + status: null, + branches: null, + worktrees: null, + ghAvailable: null, + })), + getGitDiff: vi.fn(async () => ({ diff: "" })), + getGitDiffBatch: vi.fn(async () => ({ staged: {}, unstaged: {} })), + gitStage: vi.fn(async () => undefined), + gitUnstage: vi.fn(async () => undefined), + gitStageAll: vi.fn(async () => undefined), + gitUnstageAll: vi.fn(async () => undefined), + gitRevert: vi.fn(async () => undefined), + gitRevertAll: vi.fn(async () => undefined), + gitCommit: vi.fn(async () => ({ hash: "abc", message: "m" })), + gitListBranches: vi.fn(async () => ({ + current: "main", + branches: [], + })), + gitSwitchBranch: vi.fn(async () => ({ + branch: "main", + created: false, + tracking: "", + ahead: 0, + behind: 0, + })), + gitFetch: vi.fn(async () => undefined), + gitPull: vi.fn(async () => undefined), + gitPullRebase: vi.fn(async () => undefined), + gitPush: vi.fn(async () => undefined), + gitListWorktrees: vi.fn(async () => ({ worktrees: [] })), + gitRemoveWorktree: vi.fn(async () => undefined), + gitWorktreeStatusBatch: vi.fn(async () => ({ statuses: {} })), + gitGetWorktreeSourceBranch: vi.fn(async () => ({ + sourceBranch: "main", + commitsAhead: 0, + sourceAhead: 0, + })), + gitMergeToSource: vi.fn(async () => ({ + merged: true, + fastForward: false, + newSourceCommit: "def", + })), + gitPullFromSource: vi.fn(async () => ({ + merged: true, + fastForward: false, + })), + gitAbortMerge: vi.fn(async () => ({})), + gitFinishMerge: vi.fn(async () => ({ success: true })), + ghCheckAvailable: vi.fn(async () => ({ available: true })), + ghListPullRequests: vi.fn(async () => ({ pullRequests: [] })), + ghGetPrDetails: vi.fn(async () => ({ + details: { + number: 1, + title: "", + body: "", + baseBranch: "main", + headBranch: "feature", + additions: 0, + deletions: 0, + changedFiles: 0, + commits: [], + comments: [], + reviews: [], + checks: [], + }, + })), + ghGetPrChecks: vi.fn(async () => ({ checks: [] })), + ghGetPrFiles: vi.fn(async () => ({ files: [] })), + ghGetPrDiff: vi.fn(async () => ({ diff: "" })), + ghCreatePr: vi.fn( + async () => + ({ + number: 1, + state: "open", + title: "", + url: "", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-01-01T00:00:00.000Z", + }) as PrData, + ), + ghPostPrComment: vi.fn(async () => ({ + id: "c1", + author: { login: "octocat" }, + body: "", + createdAt: "2026-01-01T00:00:00.000Z", + })), + ghMergePr: vi.fn(async () => undefined), + ghClosePr: vi.fn(async () => undefined), + ghReopenPr: vi.fn(async () => undefined), + ghMarkPrReady: vi.fn(async () => undefined), + ghUpdatePrBranch: vi.fn(async () => undefined), + probeMcpServer: vi.fn( + async () => + ({ + status: "unavailable", + toolCount: 0, + latencyMs: 0, + environment: { runtime: "host", projectScoped: false }, + error: { code: "probe-unavailable", message: "n/a" }, + }) as McpProbeResult, + ), + reloadAgentMcpServers: vi.fn(async () => undefined), + getMcpOauthStatus: vi.fn(async () => ({ authenticatedUrls: [] })), + scanSkills: vi.fn(async () => ({ + skills: [], + effectiveSkillIds: [], + invocation: null, + issues: [], + canLinkToGlobal: false, + })), + setSkillEnabled: vi.fn(async () => undefined), + }, + createThread: vi.fn<(request: CreateAppThreadRequest) => Promise>( + async (request) => ({ + threadId: "created-thread", + title: request.title ?? "New thread", + projectId: request.projectId, + }), + ), + emitRemoteThreadCommand: vi.fn<(command: RemoteThreadCommand) => boolean>(() => true), + updateThreadRow: vi.fn<(threadId: string, mutate: (thread: Thread) => Thread) => void>(), + openThreadInUi: vi.fn<(threadId: string) => boolean>(() => true), + notifyUser: vi.fn<() => { delivered: boolean; note?: string }>(() => ({ delivered: true })), + checkForUpdate: vi.fn<() => Promise<{ supported: boolean }>>(async () => ({ supported: true })), + ...overrides, + }; +} + +async function callTool(url: string, token: string, name: string, args: Record) { + const response = await fetch(`${url}/mcp?thread=thread-1`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name, arguments: args }, + }), + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + result?: { isError?: boolean; content?: Array<{ text?: string }> }; + }; + const text = body.result?.content?.[0]?.text; + return { + isError: body.result?.isError === true, + payload: text ? (JSON.parse(text) as Record) : undefined, + }; +} + describe("AppControlsMcpIngress", () => { it("serves schedule tools and applies calling-thread defaults over Streamable HTTP", async () => { - const create = vi.fn<(input: ScheduledTaskInput) => ScheduledTask>( - (input) => ({ id: "created", ...input }) as ScheduledTask, - ); - const service = { - list: vi.fn<() => ScheduledTask[]>(() => []), - create, - } as unknown as ScheduleService; - const thread = { - id: "thread-1", - agentKind: "codex", - config: { model: "gpt-5.6", effort: "high" }, - } as Thread; - ingress = new AppControlsMcpIngress(service, (id) => (id === thread.id ? thread : null)); + const d = deps(); + ingress = new AppControlsMcpIngress(d); const info = await ingress.start(); - const response = await fetch(`${info.url}/mcp?thread=thread-1`, { - method: "POST", - headers: { - Authorization: `Bearer ${info.token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "create_schedule", - arguments: { - name: "Daily brief", - prompt: "Summarize priorities", - recurrence: { kind: "hourly", minute: 15 }, - }, - }, - }), + const { isError } = await callTool(info.url, info.token, "create_schedule", { + name: "Daily brief", + prompt: "Summarize priorities", + recurrence: { kind: "hourly", minute: 15 }, }); - expect(response.status).toBe(200); - const body = (await response.json()) as { result?: { isError?: boolean } }; - expect(body.result?.isError).not.toBe(true); - expect(create).toHaveBeenCalledWith({ + expect(isError).toBe(false); + expect(d.scheduleService.create).toHaveBeenCalledWith({ name: "Daily brief", prompt: "Summarize priorities", recurrence: { kind: "hourly", minute: 15 }, enabled: true, agentKind: "codex", + projectId: "project-1", config: { model: "gpt-5.6", effort: "high" }, }); }); + + it("lists threads joined with live runtime snapshots", async () => { + ingress = new AppControlsMcpIngress(deps()); + const info = await ingress.start(); + + const { isError, payload } = await callTool(info.url, info.token, "list_threads", {}); + expect(isError).toBe(false); + expect(payload?.count).toBe(1); + const threads = payload?.threads as Array>; + expect(threads[0]?.threadId).toBe("thread-1"); + expect(threads[0]?.status).toBe("working"); + expect(threads[0]?.projectName).toBe("Alpha"); + }); + + it("create_thread inherits the calling thread's agent config", async () => { + const d = deps(); + ingress = new AppControlsMcpIngress(d); + const info = await ingress.start(); + + const { isError, payload } = await callTool(info.url, info.token, "create_thread", { + projectId: "project-1", + prompt: "Investigate the flaky test", + }); + expect(isError).toBe(false); + expect(payload?.threadId).toBe("created-thread"); + expect(d.createThread).toHaveBeenCalledWith({ + projectId: "project-1", + prompt: "Investigate the flaky test", + agentKind: "codex", + model: "gpt-5.6", + effort: "high", + }); + }); }); diff --git a/src/main/app-controls/AppControlsMcpIngress.ts b/src/main/app-controls/AppControlsMcpIngress.ts index 01803fe7..4dda3a9a 100644 --- a/src/main/app-controls/AppControlsMcpIngress.ts +++ b/src/main/app-controls/AppControlsMcpIngress.ts @@ -1,35 +1,74 @@ -import type { Thread } from "@/shared/contracts"; -import type { ScheduleService } from "../schedules/ScheduleService"; +import type { Project, ProjectNotes, RemoteThreadCommand, Thread } from "@/shared/contracts"; +import type { SupervisorEvent } from "@/shared/ipc"; +import type { RemoteProjectCommand, RemoteProjectCommandResult } from "@/shared/remote"; import { StreamableHttpMcpIngress, type StreamableHttpMcpIngressInfo, } from "../mcp/StreamableHttpMcpIngress"; +import type { ScheduleService } from "../schedules/ScheduleService"; +import type { CreateAppThreadRequest, CreateAppThreadResult } from "../threads/appThreadLauncher"; +import { ThreadStateBroker } from "../threads/threadStateBroker"; import { APP_CONTROLS_MCP_INSTRUCTIONS, + APP_CONTROLS_MCP_SERVER_INFO, TOOLS, dispatchTool, formatToolResult, isKnownToolName, + type AppControlsAppInfo, + type AppControlsNotifyResult, + type AppControlsSettingsGateway, + type AppControlsSupervisorCaller, type AppControlsToolContext, + type AppControlsUpdateCheck, } from "./mcp/toolRegistry"; export type AppControlsMcpIngressInfo = StreamableHttpMcpIngressInfo; +/** Main-side seams the app-controls MCP server acts through. */ +export interface AppControlsMcpIngressDeps { + scheduleService: ScheduleService; + getThread(threadId: string): Thread | null; + getThreads(): Thread[]; + getProjects(): Project[]; + getProject(projectId: string): Project | null; + getProjectNotes(projectId: string): ProjectNotes | null; + directoryExists(path: string): boolean; + applyProjectCommand(command: RemoteProjectCommand): Promise; + updateProject(project: Project): void; + settings: AppControlsSettingsGateway; + getAppInfo(): AppControlsAppInfo; + supervisor: AppControlsSupervisorCaller; + createThread(request: CreateAppThreadRequest): Promise; + emitRemoteThreadCommand(command: RemoteThreadCommand): boolean; + updateThreadRow(threadId: string, mutate: (thread: Thread) => Thread): void; + openThreadInUi(threadId: string): boolean; + notifyUser(input: { title: string; body: string; threadId: string }): AppControlsNotifyResult; + checkForUpdate(): Promise; +} + export class AppControlsMcpIngress { private readonly ingress: StreamableHttpMcpIngress; + /** Persistent live-status cache + wait surface, fed by {@link observeSupervisorEvent}. */ + private readonly threadStates = new ThreadStateBroker(); - constructor(scheduleService: ScheduleService, getThread: (threadId: string) => Thread | null) { + constructor(deps: AppControlsMcpIngressDeps) { this.ingress = new StreamableHttpMcpIngress({ - serverInfo: { name: "poracode", version: "1.0.0" }, + serverInfo: { ...APP_CONTROLS_MCP_SERVER_INFO }, instructions: APP_CONTROLS_MCP_INSTRUCTIONS, tools: TOOLS, isKnownToolName, - buildContext: (identity) => ({ identity, scheduleService, getThread }), + buildContext: (identity) => ({ ...deps, identity, threadStates: this.threadStates }), dispatchTool, formatToolResult, }); } + /** Wire into the supervisor event tap (main.ts / headless host `onEvent`). */ + observeSupervisorEvent(event: SupervisorEvent): void { + this.threadStates.observe(event); + } + start(): Promise { return this.ingress.start(); } diff --git a/src/main/app-controls/index.ts b/src/main/app-controls/index.ts index 53a64633..20fa2cfd 100644 --- a/src/main/app-controls/index.ts +++ b/src/main/app-controls/index.ts @@ -1 +1,3 @@ export * from "./AppControlsMcpIngress"; +export { buildSharedAppControlsIngressDeps } from "./ingressDeps"; +export { createAppControlsSupervisorCaller } from "./supervisorCaller"; diff --git a/src/main/app-controls/ingressDeps.ts b/src/main/app-controls/ingressDeps.ts new file mode 100644 index 00000000..c9062ee6 --- /dev/null +++ b/src/main/app-controls/ingressDeps.ts @@ -0,0 +1,137 @@ +import { mkdirSync, statSync } from "node:fs"; +import type { Project, RemoteThreadCommand, Thread } from "@/shared/contracts"; +import { + type RemoteProjectCommand, + type RemoteProjectCommandResult, + remoteProjectCommandResultSchema, +} from "@/shared/remote"; +import type { SharedSettings } from "@/shared/settings"; +import { + dbDeleteProject, + dbDeleteThread, + dbGetProject, + dbGetProjects, + dbGetThread, + dbGetThreads, + dbUpsertProject, + dbUpsertThread, +} from "@/main/db"; +import { hasPersistedProjectExperiment } from "@/main/remote/experimentOwnership"; +import { applyRemoteProjectCommand } from "@/main/remote/projectCommands"; +import { sortOrderForThread } from "@/main/remote/server/snapshots"; +import { ensureHomeProjectRow } from "@/main/schedules"; +import { + createAppThread, + resolveAddWorktreeArgs, + type CreateAppThreadRequest, + type CreateAppThreadResult, +} from "@/main/threads/appThreadLauncher"; +import type { SupervisorCall } from "./supervisorCaller"; + +/** Host seams the shared app-controls ingress deps depend on. Only the fields + * that genuinely differ between the desktop and headless hosts are parameters. */ +export interface AppControlsIngressDepsParams { + /** Typed supervisor RPC entrypoint (`supervisorClient.call`). */ + call: SupervisorCall; + /** Mirror a thread command to the renderer store; `false` when no UI is up. */ + sendThreadCommand: (command: RemoteThreadCommand) => boolean; + /** Read the current shared settings from disk. */ + getSharedSettings: () => SharedSettings; + /** Notify listeners that the project list changed (desktop vs headless surfaces). */ + publishProjectsChanged: () => void; +} + +/** The subset of {@link AppControlsMcpIngressDeps} both hosts build identically. */ +export interface SharedAppControlsIngressDeps { + directoryExists(path: string): boolean; + applyProjectCommand(command: RemoteProjectCommand): Promise; + updateProject(project: Project): void; + createThread(request: CreateAppThreadRequest): Promise; + updateThreadRow(threadId: string, mutate: (thread: Thread) => Thread): void; +} + +/** + * Build the host-agnostic app-controls ingress deps. The DB-direct project / + * thread wiring is identical across the desktop and headless hosts; the only + * host-specific seams (`sendThreadCommand`, `getSharedSettings`, and the + * projects-changed publish) are taken as parameters so behavior is preserved: + * the desktop still mirrors to its renderer, the headless host stays DB-direct. + */ +export function buildSharedAppControlsIngressDeps( + params: AppControlsIngressDepsParams, +): SharedAppControlsIngressDeps { + const { call, sendThreadCommand, getSharedSettings, publishProjectsChanged } = params; + return { + directoryExists: (path) => { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } + }, + applyProjectCommand: async (command) => { + const result = await applyRemoteProjectCommand(command, { + getProjects: dbGetProjects, + hasProjectExperiment: (projectId) => hasPersistedProjectExperiment(projectId), + listProjectThreadIds: (projectId) => + dbGetThreads() + .filter((thread) => thread.projectId === projectId) + .map((thread) => thread.id), + upsertProject: (project, sortOrder) => dbUpsertProject(project, sortOrder), + deleteProject: (projectId) => dbDeleteProject(projectId), + closeThread: (threadId) => + call("closeThread", { threadId }) + .then(() => undefined) + .catch(() => undefined), + cloneRepo: (input) => call("cloneRepo", input), + makeDirectory: (path) => { + mkdirSync(path); + }, + platform: process.platform, + now: () => new Date().toISOString(), + }); + const parsed = remoteProjectCommandResultSchema.parse(result); + publishProjectsChanged(); + return parsed; + }, + updateProject: (project) => { + dbUpsertProject(project, -Date.parse(project.createdAt)); + publishProjectsChanged(); + }, + createThread: (request) => + createAppThread( + { + startThread: (payload) => call("startThread", payload), + getAgentStatuses: (wslDistros) => call("getAgentStatuses", { wslDistros }), + addWorktree: ({ location, branch, settings }) => + call("gitAddWorktree", { + projectLocation: location, + ...resolveAddWorktreeArgs(settings, location, branch), + }), + removeWorktree: ({ location, path }) => + call("gitRemoveWorktree", { + projectLocation: location, + path, + force: true, + deleteBranch: true, + }).then(() => undefined), + sendThreadCommand, + ensureHomeProject: ensureHomeProjectRow, + getProject: dbGetProject, + getSharedSettings, + upsertThread: dbUpsertThread, + deleteThread: dbDeleteThread, + threadExists: (threadId) => dbGetThread(threadId) != null, + }, + request, + ), + updateThreadRow: (threadId, mutate) => { + // Sort order genuinely needs the full list (sortOrderForThread indexes + // into it), so the single row is read from that same snapshot. + const threads = dbGetThreads(); + const current = threads.find((entry) => entry.id === threadId); + if (!current) return; + dbUpsertThread(mutate(current), sortOrderForThread(threads, threadId)); + }, + }; +} diff --git a/src/main/app-controls/mcp/toolRegistry.test.ts b/src/main/app-controls/mcp/toolRegistry.test.ts index 426ea204..438af611 100644 --- a/src/main/app-controls/mcp/toolRegistry.test.ts +++ b/src/main/app-controls/mcp/toolRegistry.test.ts @@ -1,15 +1,112 @@ import { describe, expect, it, vi } from "vitest"; -import type { ScheduledTask, ScheduledTaskInput, Thread } from "@/shared/contracts"; +import type { + AgentStatusesResponse, + CloseThreadPayload, + GitProjectSnapshotResult, + GitWorktreeInfo, + InterruptThreadPayload, + ListProjectTreeResult, + McpProbeResult, + PrComment, + PrData, + PrDetails, + Project, + ProjectNotes, + ProviderUsagePayload, + ProviderUsageResponse, + ReadProjectFileResult, + RemoteThreadCommand, + ScheduledTask, + ScheduledTaskInput, + SearchProjectFilesPayload, + SearchProjectFilesResult, + SearchProjectTreeResult, + SendThreadInputPayload, + SkillScanResult, + StartThreadPayload, + StartThreadResult, + Thread, + ThreadRuntimeSnapshot, +} from "@/shared/contracts"; +import type { RemoteProjectCommand, RemoteProjectCommandResult } from "@/shared/remote"; +import { defaultSharedSettings, type SharedSettings } from "@/shared/settings"; import type { ScheduleService } from "../../schedules/ScheduleService"; -import { dispatchTool, type AppControlsToolContext } from "./toolRegistry"; +import type { + CreateAppThreadRequest, + CreateAppThreadResult, +} from "../../threads/appThreadLauncher"; +import { ThreadStateBroker } from "../../threads/threadStateBroker"; +import { + dispatchTool, + type AppControlsSupervisorCaller, + type AppControlsToolContext, +} from "./toolRegistry"; + +type SC = AppControlsSupervisorCaller; const thread = { id: "thread-1", + projectId: "project-1", agentKind: "codex", config: { model: "gpt-5.6", effort: "high", fast: true }, } as Thread; -function context(tasks: ScheduledTask[] = []) { +function makeThread(overrides: Partial): Thread { + return { + id: "t", + projectId: "project-1", + title: "Thread", + agentKind: "codex", + config: { model: "gpt-5.6" }, + status: "idle", + attention: "none", + canResumeWithConfig: false, + archived: false, + done: false, + starred: false, + presentationMode: "gui", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + } as Thread; +} + +function context( + options: { + tasks?: ScheduledTask[]; + threads?: Thread[]; + projects?: Project[]; + snapshots?: ThreadRuntimeSnapshot[]; + createThread?: (request: CreateAppThreadRequest) => Promise; + projectNotes?: Record; + directoryExists?: (path: string) => boolean; + settings?: SharedSettings; + usageResponse?: ProviderUsageResponse; + fileSearchResult?: SearchProjectFilesResult; + appInfo?: { version: string; platform: string; hasRendererWindow: boolean }; + /** Whether a renderer receives emitRemoteThreadCommand (desktop) or not (headless). */ + rendererConnected?: boolean; + /** Whether a UI is available for openThreadInUi. */ + uiConnected?: boolean; + scrollback?: string; + agentStatuses?: AgentStatusesResponse; + projectTree?: ListProjectTreeResult; + readFile?: ReadProjectFileResult; + searchTree?: SearchProjectTreeResult; + /** Whether an OS notification was actually shown (desktop) or not (headless). */ + notificationDelivered?: boolean; + gitSnapshot?: GitProjectSnapshotResult; + worktrees?: GitWorktreeInfo[]; + sourceBranch?: string | null; + ghAvailable?: boolean; + prDetails?: PrDetails; + prDiff?: string; + probeResult?: McpProbeResult; + authenticatedUrls?: string[]; + skillScan?: SkillScanResult; + } = {}, +) { + const tasks = options.tasks ?? []; const service = { list: vi.fn<() => ScheduledTask[]>(() => tasks), get: vi.fn<(id: string) => ScheduledTask | null>( @@ -26,15 +123,258 @@ function context(tasks: ScheduledTask[] = []) { ), delete: vi.fn<(id: string) => void>(), } as unknown as ScheduleService; + const threads = options.threads ?? [thread]; + const usageResponse = options.usageResponse ?? { snapshots: [], fromCache: true }; + const fileSearchResult = options.fileSearchResult ?? { entries: [], totalIndexed: 0 }; + const supervisor = { + getThreadSnapshots: vi.fn<() => Promise>( + async () => options.snapshots ?? [], + ), + startThread: vi.fn<(payload: StartThreadPayload) => Promise>( + async (payload) => ({ threadId: payload.threadId ?? "resumed" }), + ), + sendThreadInput: vi.fn<(payload: SendThreadInputPayload) => Promise>( + async () => undefined, + ), + interruptThread: vi.fn<(payload: InterruptThreadPayload) => Promise>( + async () => undefined, + ), + closeThread: vi.fn<(payload: CloseThreadPayload) => Promise>(async () => undefined), + getProviderUsage: vi.fn<(payload: ProviderUsagePayload) => Promise>( + async () => usageResponse, + ), + refreshProviderUsage: vi.fn<(payload: ProviderUsagePayload) => Promise>( + async () => usageResponse, + ), + searchProjectFiles: vi.fn< + (payload: SearchProjectFilesPayload) => Promise + >(async () => fileSearchResult), + readTerminalScrollback: vi.fn<() => Promise>(async () => options.scrollback ?? ""), + setPendingSteer: vi.fn<() => Promise>(async () => undefined), + clearPendingSteer: vi.fn<() => Promise>(async () => undefined), + stageThreadInput: vi.fn<() => Promise>(async () => undefined), + rollbackThreadConversation: vi.fn<() => Promise>(async () => undefined), + getAgentStatuses: vi.fn<() => Promise>( + async () => options.agentStatuses ?? { windows: [], wsl: [], fromCache: true }, + ), + refreshAgentStatuses: vi.fn<() => Promise>( + async () => options.agentStatuses ?? { windows: [], wsl: [], fromCache: false }, + ), + listProjectTree: vi.fn<() => Promise>( + async () => options.projectTree ?? { directoryPath: "", entries: [] }, + ), + readProjectFile: vi.fn<() => Promise>( + async () => options.readFile ?? { path: "", status: "ready", modifiedAtMs: 0, content: "" }, + ), + searchProjectTree: vi.fn<() => Promise>( + async () => options.searchTree ?? { entries: [] }, + ), + gitProjectSnapshot: vi.fn( + async () => + options.gitSnapshot ?? { + status: null, + branches: null, + worktrees: null, + ghAvailable: null, + }, + ), + getGitDiff: vi.fn(async () => ({ diff: "diff-one-file" })), + getGitDiffBatch: vi.fn(async () => ({ staged: {}, unstaged: {} })), + gitStage: vi.fn(async () => undefined), + gitUnstage: vi.fn(async () => undefined), + gitStageAll: vi.fn(async () => undefined), + gitUnstageAll: vi.fn(async () => undefined), + gitRevert: vi.fn(async () => undefined), + gitRevertAll: vi.fn(async () => undefined), + gitCommit: vi.fn(async (payload) => ({ + hash: "abc123", + message: payload.message, + })), + gitListBranches: vi.fn(async () => ({ current: "main", branches: [] })), + gitSwitchBranch: vi.fn(async (payload) => ({ + branch: payload.branch, + created: payload.createNew ?? false, + tracking: "", + ahead: 0, + behind: 0, + })), + gitFetch: vi.fn(async () => undefined), + gitPull: vi.fn(async () => undefined), + gitPullRebase: vi.fn(async () => undefined), + gitPush: vi.fn(async () => undefined), + gitListWorktrees: vi.fn(async () => ({ + worktrees: options.worktrees ?? [], + })), + gitRemoveWorktree: vi.fn(async () => undefined), + gitWorktreeStatusBatch: vi.fn(async () => ({ statuses: {} })), + gitGetWorktreeSourceBranch: vi.fn(async () => ({ + sourceBranch: options.sourceBranch === undefined ? "main" : options.sourceBranch, + commitsAhead: 0, + sourceAhead: 0, + })), + gitMergeToSource: vi.fn(async () => ({ + merged: true, + fastForward: false, + newSourceCommit: "def456", + })), + gitPullFromSource: vi.fn(async () => ({ + merged: true, + fastForward: false, + })), + gitAbortMerge: vi.fn(async () => ({})), + gitFinishMerge: vi.fn(async () => ({ success: true })), + ghCheckAvailable: vi.fn(async () => ({ + available: options.ghAvailable ?? true, + })), + ghListPullRequests: vi.fn(async () => ({ pullRequests: [] })), + ghGetPrDetails: vi.fn(async () => ({ + details: + options.prDetails ?? + ({ + number: 7, + title: "PR", + body: "", + baseBranch: "main", + headBranch: "feature", + additions: 0, + deletions: 0, + changedFiles: 0, + commits: [], + comments: [], + reviews: [], + checks: [], + } as PrDetails), + })), + ghGetPrChecks: vi.fn(async () => ({ checks: [] })), + ghGetPrFiles: vi.fn(async () => ({ files: [] })), + ghGetPrDiff: vi.fn(async () => ({ diff: options.prDiff ?? "pr-diff" })), + ghCreatePr: vi.fn( + async () => + ({ + number: 7, + state: "open", + title: "PR", + url: "https://example.test/pr/7", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-01-01T00:00:00.000Z", + }) as PrData, + ), + ghPostPrComment: vi.fn( + async () => + ({ + id: "c1", + author: { login: "octocat" }, + body: "hi", + createdAt: "2026-01-01T00:00:00.000Z", + }) as PrComment, + ), + ghMergePr: vi.fn(async () => undefined), + ghClosePr: vi.fn(async () => undefined), + ghReopenPr: vi.fn(async () => undefined), + ghMarkPrReady: vi.fn(async () => undefined), + ghUpdatePrBranch: vi.fn(async () => undefined), + probeMcpServer: vi.fn( + async () => + options.probeResult ?? + ({ + status: "available", + toolCount: 2, + tools: ["a", "b"], + latencyMs: 5, + environment: { runtime: "host", projectScoped: false }, + } as McpProbeResult), + ), + reloadAgentMcpServers: vi.fn(async () => undefined), + getMcpOauthStatus: vi.fn(async () => ({ + authenticatedUrls: options.authenticatedUrls ?? [], + })), + scanSkills: vi.fn( + async () => + options.skillScan ?? { + skills: [], + effectiveSkillIds: [], + invocation: null, + issues: [], + canLinkToGlobal: false, + }, + ), + setSkillEnabled: vi.fn(async () => undefined), + }; + const notifyUser = vi.fn<() => { delivered: boolean; note?: string }>(() => + options.notificationDelivered === false + ? { delivered: false, note: "No display connected." } + : { delivered: true }, + ); + const checkForUpdate = vi.fn<() => Promise<{ supported: boolean; currentVersion?: string }>>( + async () => ({ supported: true, currentVersion: "9.9.9" }), + ); + const createThread = + options.createThread ?? + vi.fn<(request: CreateAppThreadRequest) => Promise>(async (request) => ({ + threadId: "new-thread", + title: request.title ?? "New thread", + projectId: request.projectId, + })); + const emitRemoteThreadCommand = vi.fn<(command: RemoteThreadCommand) => boolean>( + () => options.rendererConnected ?? true, + ); + const updatedRows: Thread[] = []; + const updateThreadRow = vi.fn<(threadId: string, mutate: (thread: Thread) => Thread) => void>( + (threadId, mutate) => { + const current = threads.find((entry) => entry.id === threadId); + if (!current) return; + updatedRows.push(mutate(current)); + }, + ); + const openThreadInUi = vi.fn<(threadId: string) => boolean>(() => options.uiConnected ?? true); + const applyProjectCommand = vi.fn< + (command: RemoteProjectCommand) => Promise + >(async () => ({ projects: options.projects ?? [] })); + const updateProject = vi.fn<(project: Project) => void>(); + const settingsWrite = vi.fn<(next: SharedSettings) => void>(); + const settingsValue = options.settings ?? defaultSharedSettings; const ctx: AppControlsToolContext = { identity: { threadId: thread.id, title: "Schedule this" }, scheduleService: service, - getThread: (id) => (id === thread.id ? thread : null), + getThread: (id) => threads.find((entry) => entry.id === id) ?? null, + getThreads: () => threads, + getProjects: () => options.projects ?? [], + getProject: (id) => options.projects?.find((project) => project.id === id) ?? null, + getProjectNotes: (id) => options.projectNotes?.[id] ?? null, + directoryExists: options.directoryExists ?? (() => true), + applyProjectCommand, + updateProject, + settings: { read: () => settingsValue, write: settingsWrite }, + getAppInfo: () => + options.appInfo ?? { version: "9.9.9", platform: "darwin", hasRendererWindow: true }, + supervisor, + createThread, + emitRemoteThreadCommand, + updateThreadRow, + openThreadInUi, + notifyUser, + checkForUpdate, + threadStates: new ThreadStateBroker(), + }; + return { + ctx, + service, + supervisor, + createThread, + emitRemoteThreadCommand, + updateThreadRow, + updatedRows, + openThreadInUi, + notifyUser, + checkForUpdate, + applyProjectCommand, + updateProject, + settingsWrite, }; - return { ctx, service }; } -describe("Poracode app control tools", () => { +describe("Poracode app control tools — schedules", () => { it("creates a schedule with the calling thread's agent defaults", async () => { const { ctx, service } = context(); await dispatchTool( @@ -53,6 +393,8 @@ describe("Poracode app control tools", () => { recurrence: { kind: "weekly", days: [1, 2, 3, 4, 5], time: "08:00" }, enabled: true, agentKind: "codex", + // project-1 is a real project id, so the schedule inherits it. + projectId: "project-1", config: { model: "gpt-5.6", effort: "high", fast: true }, }); }); @@ -67,7 +409,7 @@ describe("Poracode app control tools", () => { recurrence: { kind: "hourly", minute: 0 }, enabled: true, } as ScheduledTask; - const { ctx, service } = context([task]); + const { ctx, service } = context({ tasks: [task] }); await dispatchTool("update_schedule", { id: task.id, name: "New name", enabled: false }, ctx); @@ -82,3 +424,1322 @@ describe("Poracode app control tools", () => { ); }); }); + +describe("Poracode app control tools — threads", () => { + it("lists all threads merged with live runtime snapshots and applies filters", async () => { + const threads = [ + makeThread({ id: "a", projectId: "project-1", status: "idle" }), + makeThread({ id: "b", projectId: "project-2", status: "idle" }), + ]; + const projects = [ + { id: "project-1", name: "Alpha" } as Project, + { id: "project-2", name: "Beta" } as Project, + ]; + const snapshots: ThreadRuntimeSnapshot[] = [ + { threadId: "a", status: "working", attention: "none", canResumeWithConfig: false }, + ]; + const { ctx } = context({ threads, projects, snapshots }); + + const all = (await dispatchTool("list_threads", {}, ctx)) as { + count: number; + threads: Array<{ threadId: string; status: string; projectName?: string }>; + }; + expect(all.count).toBe(2); + // Live snapshot status overrides the persisted row. + expect(all.threads.find((t) => t.threadId === "a")?.status).toBe("working"); + expect(all.threads.find((t) => t.threadId === "a")?.projectName).toBe("Alpha"); + + const filtered = (await dispatchTool("list_threads", { projectId: "project-2" }, ctx)) as { + count: number; + }; + expect(filtered.count).toBe(1); + }); + + it("create_thread inherits the calling thread's agent, model, effort, and fast", async () => { + const { ctx, createThread } = context(); + await dispatchTool("create_thread", { projectId: "project-9", prompt: "Do the thing" }, ctx); + expect(createThread).toHaveBeenCalledWith({ + projectId: "project-9", + prompt: "Do the thing", + agentKind: "codex", + model: "gpt-5.6", + effort: "high", + fast: true, + }); + }); + + it("create_thread requests a worktree only when enabled", async () => { + const { ctx, createThread } = context(); + await dispatchTool( + "create_thread", + { projectId: "p", prompt: "x", worktree: { enabled: true, branch: "feature/x" } }, + ctx, + ); + expect(createThread).toHaveBeenCalledWith( + expect.objectContaining({ worktree: { branch: "feature/x" } }), + ); + }); + + it("update_thread dispatches the matching remote thread commands", async () => { + const threads = [makeThread({ id: "a" })]; + const { ctx, emitRemoteThreadCommand } = context({ threads }); + const result = (await dispatchTool( + "update_thread", + { threadId: "a", rename: "Renamed", done: true, archived: true }, + ctx, + )) as { applied: string[] }; + expect(result.applied).toEqual(["rename", "done", "archived"]); + expect(emitRemoteThreadCommand).toHaveBeenCalledWith({ + kind: "rename", + threadId: "a", + title: "Renamed", + }); + expect(emitRemoteThreadCommand).toHaveBeenCalledWith({ + kind: "set-done", + threadId: "a", + done: true, + }); + expect(emitRemoteThreadCommand).toHaveBeenCalledWith({ kind: "archive", threadId: "a" }); + }); + + it("send_to_thread interrupts first when requested then sends", async () => { + const threads = [makeThread({ id: "a" })]; + const { ctx, supervisor } = context({ threads }); + await dispatchTool( + "send_to_thread", + { threadId: "a", message: "hello", interruptFirst: true }, + ctx, + ); + expect(supervisor.interruptThread).toHaveBeenCalledWith({ threadId: "a" }); + expect(supervisor.sendThreadInput).toHaveBeenCalledWith( + expect.objectContaining({ threadId: "a", prompt: "hello" }), + ); + expect(supervisor.startThread).not.toHaveBeenCalled(); + }); + + it("send_to_thread resumes a thread whose live session is gone, delivering the message as the first input", async () => { + const threads = [ + makeThread({ + id: "a", + projectId: "p1", + agentKind: "codex", + presentationMode: "gui", + sessionRef: { providerSessionId: "sess-1", discoveredAt: "2026-01-01T00:00:00.000Z" }, + worktreePath: "/work/alpha/.poracode/worktrees/wt", + }), + ]; + const projects = [ + { id: "p1", name: "Alpha", location: { kind: "posix", path: "/work/alpha" } } as Project, + ]; + const { ctx, supervisor } = context({ threads, projects }); + // The supervisor no longer has a live session for this thread. + supervisor.sendThreadInput.mockRejectedValueOnce(new Error("Unknown thread session: a")); + + const result = (await dispatchTool( + "send_to_thread", + { threadId: "a", message: "resume please" }, + ctx, + )) as { delivered: boolean; resumed?: boolean }; + + expect(result.delivered).toBe(true); + expect(result.resumed).toBe(true); + expect(supervisor.startThread).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: "a", + prompt: "resume please", + agentKind: "codex", + presentationMode: "gui", + sessionRef: { providerSessionId: "sess-1", discoveredAt: "2026-01-01T00:00:00.000Z" }, + projectLocation: expect.objectContaining({ kind: "posix" }), + }), + ); + }); + + it("send_to_thread refuses a non-resumable dead thread and points to create_thread", async () => { + const threads = [makeThread({ id: "a", projectId: "p1", canResumeWithConfig: false })]; + const projects = [ + { id: "p1", name: "Alpha", location: { kind: "posix", path: "/work/alpha" } } as Project, + ]; + const { ctx, supervisor } = context({ threads, projects }); + supervisor.sendThreadInput.mockRejectedValueOnce(new Error("Unknown thread session: a")); + + await expect( + dispatchTool("send_to_thread", { threadId: "a", message: "hi" }, ctx), + ).rejects.toThrow(/create_thread/); + expect(supervisor.startThread).not.toHaveBeenCalled(); + }); + + it("update_thread falls back to a direct DB row write when no renderer is connected", async () => { + const threads = [makeThread({ id: "a", title: "Old", status: "finished" })]; + const { ctx, emitRemoteThreadCommand, updateThreadRow, updatedRows } = context({ + threads, + rendererConnected: false, + }); + const result = (await dispatchTool( + "update_thread", + { threadId: "a", rename: "New", done: true, acknowledge: true }, + ctx, + )) as { applied: string[]; note?: string }; + + expect(result.applied).toEqual(["rename", "done", "acknowledge"]); + expect(result.note).toMatch(/No Poracode UI is connected/); + // Commands are still emitted (attempted), but no renderer received them. + expect(emitRemoteThreadCommand).toHaveBeenCalled(); + expect(updateThreadRow).toHaveBeenCalledWith("a", expect.any(Function)); + const row = updatedRows.at(-1)!; + expect(row.title).toBe("New"); + expect(row.done).toBe(true); + // acknowledge cleared the finished marker (finished → idle). + expect(row.status).toBe("idle"); + }); + + it("update_thread persists the DB row even when a renderer is connected", async () => { + const threads = [makeThread({ id: "a" })]; + const { ctx, updateThreadRow } = context({ threads, rendererConnected: true }); + const result = (await dispatchTool("update_thread", { threadId: "a", rename: "New" }, ctx)) as { + applied: string[]; + note?: string; + }; + expect(result.applied).toEqual(["rename"]); + expect(result.note).toBeUndefined(); + expect(updateThreadRow).toHaveBeenCalledWith("a", expect.any(Function)); + }); + + it("open_thread notes when no UI is connected instead of reporting success", async () => { + const threads = [makeThread({ id: "a" })]; + const { ctx } = context({ threads, uiConnected: false }); + const result = (await dispatchTool("open_thread", { threadId: "a" }, ctx)) as { + opened: boolean; + note?: string; + }; + expect(result.opened).toBe(false); + expect(result.note).toMatch(/No Poracode UI is connected/); + }); + + it("rejects unknown thread ids with a clear error", async () => { + const { ctx } = context({ threads: [] }); + await expect(dispatchTool("get_thread", { threadId: "missing" }, ctx)).rejects.toThrow( + /Thread not found/, + ); + }); + + it("refuses to stop/interrupt/wait on the calling thread", async () => { + const threads = [makeThread({ id: "thread-1" })]; + const { ctx } = context({ threads }); + await expect(dispatchTool("stop_thread", { threadId: "thread-1" }, ctx)).rejects.toThrow( + /your own thread/, + ); + await expect(dispatchTool("interrupt_thread", { threadId: "thread-1" }, ctx)).rejects.toThrow( + /your own thread/, + ); + await expect(dispatchTool("wait_for_thread", { threadIds: ["thread-1"] }, ctx)).rejects.toThrow( + /your own thread/, + ); + }); + + it("wait_for_thread returns immediately when a thread is already settled", async () => { + const threads = [makeThread({ id: "a", status: "idle" })]; + const { ctx } = context({ threads }); + const result = (await dispatchTool( + "wait_for_thread", + { threadIds: ["a"], timeoutSeconds: 5 }, + ctx, + )) as { timedOut: boolean; settled: string[] }; + expect(result.timedOut).toBe(false); + expect(result.settled).toEqual(["a"]); + }); + + it("open_thread asks the renderer to open the thread", async () => { + const threads = [makeThread({ id: "a" })]; + const { ctx, openThreadInUi } = context({ threads }); + await dispatchTool("open_thread", { threadId: "a" }, ctx); + expect(openThreadInUi).toHaveBeenCalledWith("a"); + }); +}); + +describe("Poracode app control tools — projects", () => { + const projects = [ + { id: "p1", name: "Alpha", location: { kind: "posix", path: "/work/alpha" } } as Project, + { id: "p2", name: "Beta", location: { kind: "posix", path: "/work/beta" } } as Project, + ]; + + it("lists projects with thread counts", async () => { + const threads = [ + makeThread({ id: "a", projectId: "p1", archived: false, done: false }), + makeThread({ id: "b", projectId: "p1", done: true }), + ]; + const { ctx } = context({ projects, threads }); + const result = (await dispatchTool("list_projects", {}, ctx)) as { + count: number; + projects: Array<{ id: string; path: string; threadCount: number; openThreadCount: number }>; + }; + expect(result.count).toBe(2); + const alpha = result.projects.find((p) => p.id === "p1"); + expect(alpha?.path).toBe("/work/alpha"); + expect(alpha?.threadCount).toBe(2); + expect(alpha?.openThreadCount).toBe(1); + }); + + it("create_project rejects a directory that does not exist", async () => { + const { ctx, applyProjectCommand } = context({ + projects, + directoryExists: () => false, + }); + await expect(dispatchTool("create_project", { path: "/nope/missing" }, ctx)).rejects.toThrow( + /does not exist/, + ); + expect(applyProjectCommand).not.toHaveBeenCalled(); + }); + + it("create_project registers an existing directory via the shared command", async () => { + const created = { id: "p3", name: "Gamma" } as Project; + const { ctx, applyProjectCommand } = context({ projects, directoryExists: () => true }); + applyProjectCommand.mockResolvedValueOnce({ projects, project: created }); + const result = (await dispatchTool( + "create_project", + { path: "/work/gamma", name: "Gamma" }, + ctx, + )) as { created: boolean; project: Project | null }; + expect(applyProjectCommand).toHaveBeenCalledWith({ + kind: "add-existing", + path: "/work/gamma", + name: "Gamma", + }); + expect(result.created).toBe(true); + expect(result.project?.id).toBe("p3"); + }); + + it("update_project renames while preserving other fields", async () => { + const { ctx, updateProject } = context({ projects }); + await dispatchTool("update_project", { projectId: "p1", name: "Renamed" }, ctx); + expect(updateProject).toHaveBeenCalledWith( + expect.objectContaining({ id: "p1", name: "Renamed" }), + ); + }); +}); + +describe("Poracode app control tools — settings", () => { + function settingsWithSecret(): SharedSettings { + return { + ...defaultSharedSettings, + themeMode: "light", + acpRegistryInstalledAgents: { registryAgent: { source: "x" } as never }, + agentInstances: { + claudeProfile: { + id: "claudeProfile", + driver: "claude", + environment: { + ANTHROPIC_API_KEY: { value: "enc:super-secret-token", sensitive: true }, + PLAIN: { value: "visible" }, + }, + }, + acpAgent: { id: "acpAgent", driver: "acp-generic" }, + }, + mcpServers: [ + { + id: "http-server", + name: "remote-api", + description: "", + enabled: true, + timeoutMs: 30_000, + transport: { + type: "http", + url: "https://example.test/mcp?token=url-secret-value&v=2", + headers: { Authorization: "Bearer http-secret-token", "X-Api-Key": "http-key-value" }, + }, + }, + { + id: "stdio-server", + name: "local-tool", + description: "", + enabled: true, + timeoutMs: 30_000, + transport: { + type: "stdio", + command: "run-tool", + args: ["--api-key=arg-secret-value", "--verbose"], + env: { SERVICE_TOKEN: "stdio-secret-env" }, + }, + }, + ], + }; + } + + it("get_settings never leaks secret environment values", async () => { + const { ctx } = context({ settings: settingsWithSecret() }); + const result = (await dispatchTool("get_settings", {}, ctx)) as { + settings: { agentInstances: Record }> }; + }; + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("super-secret-token"); + expect(serialized).not.toContain("visible"); + const env = result.settings.agentInstances.claudeProfile?.environment; + expect(env).toEqual({ + ANTHROPIC_API_KEY: { sensitive: true }, + PLAIN: { sensitive: false }, + }); + }); + + it("get_settings never leaks MCP server transport headers or stdio env values", async () => { + const { ctx } = context({ settings: settingsWithSecret() }); + const result = (await dispatchTool("get_settings", {}, ctx)) as { + settings: { mcpServers: Array<{ transport: Record }> }; + }; + const serialized = JSON.stringify(result); + // No secret value leaks anywhere in the serialized output. + expect(serialized).not.toContain("http-secret-token"); + expect(serialized).not.toContain("http-key-value"); + expect(serialized).not.toContain("stdio-secret-env"); + // Key names are preserved; values are masked. + const [httpServer, stdioServer] = result.settings.mcpServers; + expect(httpServer?.transport.headers).toEqual({ + Authorization: "«redacted»", + "X-Api-Key": "«redacted»", + }); + expect(stdioServer?.transport.env).toEqual({ SERVICE_TOKEN: "«redacted»" }); + }); + + it("get_settings masks secret-shaped stdio args and URL query values", async () => { + const { ctx } = context({ settings: settingsWithSecret() }); + const result = (await dispatchTool("get_settings", {}, ctx)) as { + settings: { mcpServers: Array<{ transport: Record }> }; + }; + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("arg-secret-value"); + expect(serialized).not.toContain("url-secret-value"); + const [httpServer, stdioServer] = result.settings.mcpServers; + expect(httpServer?.transport.url).toBe( + "https://example.test/mcp?token=«redacted»&v=«redacted»", + ); + expect(stdioServer?.transport.args).toEqual(["--api-key=«redacted»", "--verbose"]); + }); + + it("get_settings section=mcpServers returns the same redacted servers", async () => { + const { ctx } = context({ settings: settingsWithSecret() }); + const result = (await dispatchTool("get_settings", { section: "mcpServers" }, ctx)) as { + section: string; + value: Array<{ transport: Record }>; + }; + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("http-secret-token"); + expect(serialized).not.toContain("stdio-secret-env"); + expect(result.value[0]?.transport.headers).toEqual({ + Authorization: "«redacted»", + "X-Api-Key": "«redacted»", + }); + expect(result.value[1]?.transport.env).toEqual({ SERVICE_TOKEN: "«redacted»" }); + }); + + it("get_settings returns a single named section", async () => { + const { ctx } = context({ settings: settingsWithSecret() }); + const result = (await dispatchTool("get_settings", { section: "themeMode" }, ctx)) as { + section: string; + value: unknown; + }; + expect(result.section).toBe("themeMode"); + expect(result.value).toBe("light"); + }); + + it("update_settings refuses secret-bearing fields", async () => { + const { ctx, settingsWrite } = context({ settings: settingsWithSecret() }); + await expect( + dispatchTool("update_settings", { patch: { agentInstances: {} } }, ctx), + ).rejects.toThrow(/managed elsewhere/); + expect(settingsWrite).not.toHaveBeenCalled(); + }); + + it("update_settings refuses mcpServers and points to the dedicated tools", async () => { + const { ctx, settingsWrite } = context({ settings: settingsWithSecret() }); + await expect( + dispatchTool("update_settings", { patch: { mcpServers: [] } }, ctx), + ).rejects.toThrow(/add_mcp_server, update_mcp_server, and remove_mcp_server/); + expect(settingsWrite).not.toHaveBeenCalled(); + }); + + it("update_settings deep-merges and preserves supervisor-managed fields", async () => { + const { ctx, settingsWrite } = context({ settings: settingsWithSecret() }); + await dispatchTool( + "update_settings", + { patch: { themeMode: "dark", notificationStatuses: { error: false } } }, + ctx, + ); + expect(settingsWrite).toHaveBeenCalledTimes(1); + const written = settingsWrite.mock.calls[0]![0]; + // Patched field applied. + expect(written.themeMode).toBe("dark"); + // Deep-merge kept sibling keys of the nested object. + expect(written.notificationStatuses).toEqual({ + done: true, + needsAttention: true, + error: false, + }); + // Guard preserved supervisor-managed instances + encrypted env from disk. + expect(written.agentInstances.acpAgent).toBeDefined(); + expect(written.agentInstances.claudeProfile?.environment?.ANTHROPIC_API_KEY?.value).toBe( + "enc:super-secret-token", + ); + expect(Object.keys(written.acpRegistryInstalledAgents)).toEqual(["registryAgent"]); + }); +}); + +describe("Poracode app control tools — usage", () => { + it("passes providerId through and honors refresh", async () => { + const usageResponse = { snapshots: [{ providerId: "claude" } as never], fromCache: false }; + const { ctx, supervisor } = context({ usageResponse }); + const result = (await dispatchTool( + "get_usage", + { providerId: "claude", refresh: true }, + ctx, + )) as { fromCache: boolean; count: number }; + expect(supervisor.refreshProviderUsage).toHaveBeenCalledWith({ providerIds: ["claude"] }); + expect(supervisor.getProviderUsage).not.toHaveBeenCalled(); + expect(result.count).toBe(1); + expect(result.fromCache).toBe(false); + }); + + it("reads cached usage for all providers by default", async () => { + const { ctx, supervisor } = context(); + await dispatchTool("get_usage", {}, ctx); + expect(supervisor.getProviderUsage).toHaveBeenCalledWith({}); + expect(supervisor.refreshProviderUsage).not.toHaveBeenCalled(); + }); +}); + +describe("Poracode app control tools — search", () => { + const projects = [ + { id: "p1", name: "Alpha", location: { kind: "posix", path: "/work/alpha" } } as Project, + ]; + + it("scopes threads + projects by default and skips files without a projectId", async () => { + const threads = [ + makeThread({ id: "a", title: "Fix the flaky login test" }), + makeThread({ id: "b", title: "Unrelated" }), + ]; + const { ctx, supervisor } = context({ threads, projects }); + const result = (await dispatchTool("search", { query: "login" }, ctx)) as { + threads: Array<{ threadId: string }>; + projects: unknown[]; + files?: unknown; + }; + expect(result.threads.map((t) => t.threadId)).toEqual(["a"]); + expect(result.files).toBeUndefined(); + expect(supervisor.searchProjectFiles).not.toHaveBeenCalled(); + }); + + it("file scope requires a projectId", async () => { + const { ctx } = context({ projects }); + await expect(dispatchTool("search", { query: "x", scope: "files" }, ctx)).rejects.toThrow( + /projectId is required/, + ); + }); + + it("file scope calls the supervisor with the project location", async () => { + const fileSearchResult = { + entries: [{ path: "src/a.ts", name: "a.ts", type: "file" as const }], + totalIndexed: 42, + }; + const { ctx, supervisor } = context({ projects, fileSearchResult }); + const result = (await dispatchTool( + "search", + { query: "a.ts", scope: "files", projectId: "p1" }, + ctx, + )) as { files: { count: number; totalIndexed: number } }; + expect(supervisor.searchProjectFiles).toHaveBeenCalledWith( + expect.objectContaining({ + projectLocation: { kind: "posix", path: "/work/alpha" }, + query: "a.ts", + }), + ); + expect(result.files.count).toBe(1); + expect(result.files.totalIndexed).toBe(42); + }); +}); + +describe("Poracode app control tools — app", () => { + it("reports read-only app facts without secrets", async () => { + const projects = [ + { id: "p1", name: "Alpha", location: { kind: "posix", path: "/work/alpha" } } as Project, + ]; + const threads = [makeThread({ id: "a", archived: false, done: false })]; + const { ctx } = context({ + projects, + threads, + appInfo: { version: "1.2.3", platform: "linux", hasRendererWindow: false }, + }); + const result = (await dispatchTool("get_app_info", {}, ctx)) as { + version: string; + platform: string; + renderer: string; + projectCount: number; + threadCount: number; + mcpServer: { name: string; version: string }; + }; + expect(result.version).toBe("1.2.3"); + expect(result.platform).toBe("linux"); + expect(result.renderer).toBe("headless"); + expect(result.projectCount).toBe(1); + expect(result.threadCount).toBe(1); + expect(result.mcpServer.name).toBe("poracode"); + }); + + it("notify_user reports non-delivery when no display is connected", async () => { + const { ctx, notifyUser } = context({ notificationDelivered: false }); + const result = (await dispatchTool("notify_user", { title: "Done", body: "Ready" }, ctx)) as { + delivered: boolean; + note?: string; + }; + expect(notifyUser).toHaveBeenCalledWith({ title: "Done", body: "Ready", threadId: "thread-1" }); + expect(result.delivered).toBe(false); + expect(result.note).toMatch(/No display/); + }); + + it("check_for_update returns the update-check result from the context", async () => { + const { ctx, checkForUpdate } = context(); + const result = (await dispatchTool("check_for_update", {}, ctx)) as { + supported: boolean; + currentVersion?: string; + }; + expect(checkForUpdate).toHaveBeenCalled(); + expect(result.supported).toBe(true); + expect(result.currentVersion).toBe("9.9.9"); + }); +}); + +describe("Poracode app control tools — terminal / steer / rollback", () => { + it("read_terminal returns the tail and flags truncation", async () => { + const scrollback = "x".repeat(60_000); + const threads = [makeThread({ id: "a" })]; + const { ctx } = context({ threads, scrollback }); + const result = (await dispatchTool("read_terminal", { threadId: "a" }, ctx)) as { + text: string; + truncated?: boolean; + length: number; + }; + expect(result.truncated).toBe(true); + expect(result.length).toBe(50_000); + expect(result.text.length).toBe(50_000); + }); + + it("read_terminal notes when the thread has no live terminal session", async () => { + const threads = [makeThread({ id: "a" })]; + const { ctx } = context({ threads, scrollback: "" }); + const result = (await dispatchTool("read_terminal", { threadId: "a" }, ctx)) as { + note?: string; + text?: string; + }; + expect(result.text).toBeUndefined(); + expect(result.note).toMatch(/no live terminal session/); + }); + + it("steer_thread queues guidance with the thread's config", async () => { + const threads = [makeThread({ id: "a", config: { model: "gpt-5.6" } })]; + const { ctx, supervisor } = context({ threads }); + await dispatchTool("steer_thread", { threadId: "a", prompt: "focus on tests" }, ctx); + expect(supervisor.setPendingSteer).toHaveBeenCalledWith({ + threadId: "a", + prompt: "focus on tests", + config: { model: "gpt-5.6" }, + }); + }); + + it("steer_thread clears the pending steer when clear is set", async () => { + const threads = [makeThread({ id: "a" })]; + const { ctx, supervisor } = context({ threads }); + await dispatchTool("steer_thread", { threadId: "a", clear: true }, ctx); + expect(supervisor.clearPendingSteer).toHaveBeenCalledWith({ threadId: "a" }); + expect(supervisor.setPendingSteer).not.toHaveBeenCalled(); + }); + + it("steer_thread refuses the calling thread", async () => { + const threads = [makeThread({ id: "thread-1" })]; + const { ctx } = context({ threads }); + await expect( + dispatchTool("steer_thread", { threadId: "thread-1", prompt: "x" }, ctx), + ).rejects.toThrow(/your own thread/); + }); + + it("stage_thread_input types into the composer without submitting", async () => { + const threads = [makeThread({ id: "a" })]; + const { ctx, supervisor } = context({ threads }); + const result = (await dispatchTool( + "stage_thread_input", + { threadId: "a", prompt: "draft text" }, + ctx, + )) as { staged: boolean }; + expect(supervisor.stageThreadInput).toHaveBeenCalledWith({ + threadId: "a", + prompt: "draft text", + }); + expect(result.staged).toBe(true); + }); + + it("rollback_thread refuses the calling thread", async () => { + const threads = [makeThread({ id: "thread-1" })]; + const { ctx } = context({ threads }); + await expect( + dispatchTool("rollback_thread", { threadId: "thread-1", numTurns: 2 }, ctx), + ).rejects.toThrow(/your own thread/); + }); + + it("rollback_thread discards the requested turns with the thread config", async () => { + const threads = [makeThread({ id: "a", config: { model: "gpt-5.6" } })]; + const { ctx, supervisor } = context({ threads }); + await dispatchTool("rollback_thread", { threadId: "a", numTurns: 3 }, ctx); + expect(supervisor.rollbackThreadConversation).toHaveBeenCalledWith({ + threadId: "a", + numTurns: 3, + config: { model: "gpt-5.6" }, + }); + }); +}); + +describe("Poracode app control tools — agents", () => { + it("list_installed_agents projects native + WSL inventory and passes project distros", async () => { + const projects = [ + { id: "p1", name: "Alpha", location: { kind: "wsl", distro: "Ubuntu" } } as Project, + ]; + const agentStatuses: AgentStatusesResponse = { + windows: [ + { + kind: "codex", + label: "Codex", + installed: true, + version: "1.2.3", + authState: "authenticated", + capabilities: {} as never, + } as never, + ], + wsl: [], + fromCache: true, + }; + const { ctx, supervisor } = context({ projects, agentStatuses }); + const result = (await dispatchTool("list_installed_agents", {}, ctx)) as { + fromCache: boolean; + native?: Array<{ kind: string; version?: string }>; + }; + expect(supervisor.getAgentStatuses).toHaveBeenCalledWith({ wslDistros: ["Ubuntu"] }); + expect(result.fromCache).toBe(true); + expect(result.native?.[0]?.kind).toBe("codex"); + expect(result.native?.[0]?.version).toBe("1.2.3"); + }); + + it("list_installed_agents refresh forces a fresh detection sweep", async () => { + const { ctx, supervisor } = context(); + await dispatchTool("list_installed_agents", { refresh: true }, ctx); + expect(supervisor.refreshAgentStatuses).toHaveBeenCalled(); + expect(supervisor.getAgentStatuses).not.toHaveBeenCalled(); + }); +}); + +describe("Poracode app control tools — files", () => { + const projects = [ + { id: "p1", name: "Alpha", location: { kind: "posix", path: "/work/alpha" } } as Project, + ]; + + it("list_project_files lists one directory level for a project", async () => { + const projectTree: ListProjectTreeResult = { + directoryPath: "src", + entries: [{ path: "src/a.ts", name: "a.ts", type: "file" }], + }; + const { ctx, supervisor } = context({ projects, projectTree }); + const result = (await dispatchTool( + "list_project_files", + { projectId: "p1", directoryPath: "src" }, + ctx, + )) as { count: number; entries: Array<{ path: string }> }; + expect(supervisor.listProjectTree).toHaveBeenCalledWith({ + projectLocation: { kind: "posix", path: "/work/alpha" }, + directoryPath: "src", + }); + expect(result.count).toBe(1); + expect(result.entries[0]?.path).toBe("src/a.ts"); + }); + + it("read_project_file truncates long content and flags it", async () => { + const readFile: ReadProjectFileResult = { + path: "big.txt", + status: "ready", + modifiedAtMs: 0, + content: "y".repeat(120_000), + }; + const { ctx } = context({ projects, readFile }); + const result = (await dispatchTool( + "read_project_file", + { projectId: "p1", path: "big.txt" }, + ctx, + )) as { content: string; truncated?: boolean }; + expect(result.truncated).toBe(true); + expect(result.content.length).toBe(100_000); + }); + + it("read_project_file returns a status note for binary files", async () => { + const readFile: ReadProjectFileResult = { path: "logo.png", status: "binary", modifiedAtMs: 0 }; + const { ctx } = context({ projects, readFile }); + const result = (await dispatchTool( + "read_project_file", + { projectId: "p1", path: "logo.png" }, + ctx, + )) as { status: string; content?: string; note?: string }; + expect(result.status).toBe("binary"); + expect(result.content).toBeUndefined(); + expect(result.note).toMatch(/binary/); + }); + + it("read_project_file reads from a worktree when worktreePath is given", async () => { + const readFile: ReadProjectFileResult = { + path: "a.ts", + status: "ready", + modifiedAtMs: 0, + content: "ok", + }; + const worktrees: GitWorktreeInfo[] = [ + { + path: "/work/alpha/.poracode/worktrees/wt", + branch: "feature/x", + commit: "a".repeat(40), + isMain: false, + }, + ]; + const { ctx, supervisor } = context({ projects, readFile, worktrees }); + await dispatchTool( + "read_project_file", + { projectId: "p1", path: "a.ts", worktreePath: "/work/alpha/.poracode/worktrees/wt" }, + ctx, + ); + expect(supervisor.readProjectFile).toHaveBeenCalledWith({ + projectLocation: { kind: "posix", path: "/work/alpha/.poracode/worktrees/wt" }, + path: "a.ts", + }); + }); + + it("read_project_file rejects a worktreePath outside the project's worktree set", async () => { + const { ctx } = context({ projects, worktrees: [] }); + await expect( + dispatchTool( + "read_project_file", + { projectId: "p1", path: "a.ts", worktreePath: "/somewhere/else" }, + ctx, + ), + ).rejects.toThrow(/No worktree found/); + }); + + it("find_files fuzzy-searches filenames with the default limit", async () => { + const searchTree: SearchProjectTreeResult = { + entries: [{ path: "src/a.ts", name: "a.ts", type: "file" }], + }; + const { ctx, supervisor } = context({ projects, searchTree }); + const result = (await dispatchTool("find_files", { projectId: "p1", query: "a.ts" }, ctx)) as { + count: number; + }; + expect(supervisor.searchProjectTree).toHaveBeenCalledWith({ + projectLocation: { kind: "posix", path: "/work/alpha" }, + query: "a.ts", + limit: 30, + }); + expect(result.count).toBe(1); + }); + + it("file tools reject an unknown projectId", async () => { + const { ctx } = context({ projects }); + await expect(dispatchTool("list_project_files", { projectId: "missing" }, ctx)).rejects.toThrow( + /Project not found/, + ); + }); +}); + +describe("Poracode app control tools — git", () => { + const projects = [ + { id: "p1", name: "Alpha", location: { kind: "posix", path: "/work/alpha" } } as Project, + ]; + + it("git_status returns the combined project snapshot", async () => { + const gitSnapshot: GitProjectSnapshotResult = { + status: null, + branches: { current: "main", branches: [] }, + worktrees: [], + ghAvailable: null, + }; + const { ctx, supervisor } = context({ projects, gitSnapshot }); + const result = (await dispatchTool("git_status", { projectId: "p1" }, ctx)) as { + snapshot: GitProjectSnapshotResult; + }; + expect(supervisor.gitProjectSnapshot).toHaveBeenCalledWith({ + projectLocation: { kind: "posix", path: "/work/alpha" }, + includeGhCheck: false, + }); + expect(result.snapshot.branches?.current).toBe("main"); + }); + + it("git_discard requires exactly one of paths or all", async () => { + const { ctx, supervisor } = context({ projects }); + await expect(dispatchTool("git_discard", { projectId: "p1" }, ctx)).rejects.toThrow( + /exactly one of paths or all/, + ); + await expect( + dispatchTool("git_discard", { projectId: "p1", all: true, paths: ["a.ts"] }, ctx), + ).rejects.toThrow(/exactly one of paths or all/); + expect(supervisor.gitRevertAll).not.toHaveBeenCalled(); + expect(supervisor.gitRevert).not.toHaveBeenCalled(); + }); + + it("git_discard all reverts every change; paths reverts each file", async () => { + const { ctx, supervisor } = context({ projects }); + await dispatchTool("git_discard", { projectId: "p1", all: true }, ctx); + expect(supervisor.gitRevertAll).toHaveBeenCalledWith({ + projectLocation: { kind: "posix", path: "/work/alpha" }, + }); + await dispatchTool("git_discard", { projectId: "p1", paths: ["a.ts", "b.ts"] }, ctx); + expect(supervisor.gitRevert).toHaveBeenCalledTimes(2); + expect(supervisor.gitRevert).toHaveBeenCalledWith( + expect.objectContaining({ filePath: "a.ts" }), + ); + }); + + it("git_status resolves a worktree location when worktreePath is given", async () => { + const worktrees: GitWorktreeInfo[] = [ + { + path: "/work/alpha/.poracode/worktrees/wt", + branch: "feature/x", + commit: "a".repeat(40), + isMain: false, + }, + ]; + const { ctx, supervisor } = context({ projects, worktrees }); + await dispatchTool( + "git_status", + { projectId: "p1", worktreePath: "/work/alpha/.poracode/worktrees/wt" }, + ctx, + ); + expect(supervisor.gitProjectSnapshot).toHaveBeenCalledWith({ + projectLocation: { kind: "posix", path: "/work/alpha/.poracode/worktrees/wt" }, + includeGhCheck: false, + }); + }); + + it("git tools reject a worktreePath that is not one of the project's worktrees", async () => { + const { ctx, supervisor } = context({ projects, worktrees: [] }); + await expect( + dispatchTool( + "git_discard", + { projectId: "p1", worktreePath: "/somewhere/else", all: true }, + ctx, + ), + ).rejects.toThrow(/No worktree found/); + expect(supervisor.gitRevertAll).not.toHaveBeenCalled(); + }); + + it("remove_worktree refuses while an open thread references it, listing the blockers", async () => { + const worktreePath = "/work/alpha/.poracode/worktrees/wt"; + const threads = [makeThread({ id: "blk", worktreePath, archived: false })]; + const { ctx, supervisor } = context({ projects, threads }); + await expect( + dispatchTool("remove_worktree", { projectId: "p1", worktreePath }, ctx), + ).rejects.toThrow(/blk/); + expect(supervisor.gitRemoveWorktree).not.toHaveBeenCalled(); + }); + + it("remove_worktree proceeds when only archived threads reference it", async () => { + const worktreePath = "/work/alpha/.poracode/worktrees/wt"; + const threads = [makeThread({ id: "old", worktreePath, archived: true })]; + const { ctx, supervisor } = context({ projects, threads }); + await dispatchTool("remove_worktree", { projectId: "p1", worktreePath }, ctx); + expect(supervisor.gitRemoveWorktree).toHaveBeenCalledWith({ + projectLocation: { kind: "posix", path: "/work/alpha" }, + path: worktreePath, + force: false, + deleteBranch: false, + }); + }); + + it("merge_worktree resolves the worktree branch, source branch, and expected commit", async () => { + const worktreePath = "/work/alpha/.poracode/worktrees/wt"; + const worktrees: GitWorktreeInfo[] = [ + { path: worktreePath, branch: "feature/x", commit: "a".repeat(40), isMain: false }, + ]; + const { ctx, supervisor } = context({ projects, worktrees, sourceBranch: "develop" }); + await dispatchTool("merge_worktree", { projectId: "p1", worktreePath, action: "merge" }, ctx); + expect(supervisor.gitGetWorktreeSourceBranch).toHaveBeenCalledWith({ + projectLocation: { kind: "posix", path: "/work/alpha" }, + branch: "feature/x", + }); + expect(supervisor.gitMergeToSource).toHaveBeenCalledWith({ + projectLocation: { kind: "posix", path: "/work/alpha" }, + worktreeLocation: { kind: "posix", path: worktreePath }, + worktreeBranch: "feature/x", + sourceBranch: "develop", + expectedWorktreeCommit: "a".repeat(40), + }); + }); + + it("merge_worktree abort skips source-branch resolution", async () => { + const worktreePath = "/work/alpha/.poracode/worktrees/wt"; + const { ctx, supervisor } = context({ projects }); + await dispatchTool("merge_worktree", { projectId: "p1", worktreePath, action: "abort" }, ctx); + expect(supervisor.gitAbortMerge).toHaveBeenCalledWith({ + worktreeLocation: { kind: "posix", path: worktreePath }, + }); + expect(supervisor.gitGetWorktreeSourceBranch).not.toHaveBeenCalled(); + }); +}); + +describe("Poracode app control tools — github", () => { + const projects = [ + { id: "p1", name: "Alpha", location: { kind: "posix", path: "/work/alpha" } } as Project, + ]; + + it("gh_get_pr resolves checks by the PR head branch and caps the diff", async () => { + const prDetails = { + number: 7, + title: "PR", + body: "", + baseBranch: "main", + headBranch: "feature/x", + additions: 0, + deletions: 0, + changedFiles: 0, + commits: [], + comments: [], + reviews: [], + checks: [], + } as PrDetails; + const { ctx, supervisor } = context({ + projects, + prDetails, + prDiff: "z".repeat(90_000), + }); + const result = (await dispatchTool( + "gh_get_pr", + { projectId: "p1", prNumber: 7, include: ["details", "checks", "diff"] }, + ctx, + )) as { diff: string; diffTruncated?: boolean; checks: unknown }; + expect(supervisor.ghGetPrChecks).toHaveBeenCalledWith({ + projectLocation: { kind: "posix", path: "/work/alpha" }, + branch: "feature/x", + }); + expect(result.diffTruncated).toBe(true); + expect(result.diff.length).toBe(80_000); + }); + + it("github tools fail clearly when the gh CLI is unavailable", async () => { + const { ctx, supervisor } = context({ projects, ghAvailable: false }); + await expect(dispatchTool("gh_list_prs", { projectId: "p1" }, ctx)).rejects.toThrow( + /gh.*not available|not available.*gh/i, + ); + expect(supervisor.ghListPullRequests).not.toHaveBeenCalled(); + }); + + it("gh_create_pr can run from a worktree checkout", async () => { + const worktrees: GitWorktreeInfo[] = [ + { + path: "/work/alpha/.poracode/worktrees/wt", + branch: "feature/x", + commit: "a".repeat(40), + isMain: false, + }, + ]; + const { ctx, supervisor } = context({ projects, worktrees }); + await dispatchTool( + "gh_create_pr", + { + projectId: "p1", + worktreePath: "/work/alpha/.poracode/worktrees/wt", + branch: "feature/x", + title: "New", + body: "Body", + }, + ctx, + ); + expect(supervisor.ghCreatePr).toHaveBeenCalledWith( + expect.objectContaining({ + projectLocation: { kind: "posix", path: "/work/alpha/.poracode/worktrees/wt" }, + branch: "feature/x", + }), + ); + }); + + it("gh_create_pr rejects a worktreePath outside the project's worktree set", async () => { + const { ctx, supervisor } = context({ projects, worktrees: [] }); + await expect( + dispatchTool( + "gh_create_pr", + { + projectId: "p1", + worktreePath: "/somewhere/else", + branch: "feature/x", + title: "New", + body: "Body", + }, + ctx, + ), + ).rejects.toThrow(/No worktree found/); + expect(supervisor.ghCreatePr).not.toHaveBeenCalled(); + }); +}); + +describe("Poracode app control tools — mcp servers", () => { + function settingsWithMcpSecret(): SharedSettings { + return { + ...defaultSharedSettings, + mcpServers: [ + { + id: "http-server", + name: "remote-api", + description: "", + enabled: true, + timeoutMs: 30_000, + transport: { + type: "http", + url: "https://example.test/mcp", + headers: { Authorization: "Bearer http-secret-token" }, + }, + }, + ], + }; + } + + it("list_mcp_servers redacts transport secrets and reports OAuth status", async () => { + const { ctx } = context({ + settings: settingsWithMcpSecret(), + authenticatedUrls: ["https://example.test/mcp"], + }); + const result = (await dispatchTool("list_mcp_servers", {}, ctx)) as { + servers: Array<{ transport: { headers?: Record }; authenticated?: boolean }>; + }; + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("http-secret-token"); + expect(result.servers[0]?.transport.headers).toEqual({ Authorization: "«redacted»" }); + expect(result.servers[0]?.authenticated).toBe(true); + }); + + it("probe_mcp_server never echoes back submitted secret header values", async () => { + const { ctx, supervisor } = context({}); + const result = (await dispatchTool( + "probe_mcp_server", + { + config: { + id: "candidate", + name: "candidate-server", + transport: { + type: "http", + url: "https://probe.test/mcp", + headers: { Authorization: "Bearer probe-secret-token" }, + }, + }, + }, + ctx, + )) as { server: { transport: { headers?: Record } }; result: unknown }; + // The supervisor receives the real config (to actually connect)... + expect(supervisor.probeMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + server: expect.objectContaining({ + transport: expect.objectContaining({ + headers: { Authorization: "Bearer probe-secret-token" }, + }), + }), + }), + ); + // ...but the tool result never echoes the secret back. + expect(JSON.stringify(result)).not.toContain("probe-secret-token"); + expect(result.server.transport.headers).toEqual({ Authorization: "«redacted»" }); + }); + + it("probe_mcp_server rejects an invalid candidate config", async () => { + const { ctx } = context({}); + await expect(dispatchTool("probe_mcp_server", { config: { name: "x" } }, ctx)).rejects.toThrow( + /Invalid MCP server config/, + ); + }); + + it("add_mcp_server generates an id, appends, and returns a redacted summary", async () => { + const { ctx, settingsWrite, supervisor } = context({}); + const result = (await dispatchTool( + "add_mcp_server", + { + server: { + name: "my-tool", + transport: { type: "stdio", command: "run-tool", args: [], env: {} }, + }, + reloadCallingThread: true, + }, + ctx, + )) as { added: boolean; server: { id: string; name: string }; reloadedCallingThread: boolean }; + + expect(result.added).toBe(true); + expect(result.server.name).toBe("my-tool"); + expect(result.server.id).toMatch(/[0-9a-f-]{36}/); + const written = settingsWrite.mock.calls[0]![0]; + expect(written.mcpServers.map((s) => s.name)).toEqual(["my-tool"]); + // reloadCallingThread hot-reloads the calling thread's provider sessions. + expect(result.reloadedCallingThread).toBe(true); + expect(supervisor.reloadAgentMcpServers).toHaveBeenCalledWith({ agentKind: "codex" }); + }); + + it("add_mcp_server rejects a duplicate server name", async () => { + const { ctx, settingsWrite } = context({ settings: settingsWithMcpSecret() }); + await expect( + dispatchTool( + "add_mcp_server", + { + server: { + name: "Remote-API", + transport: { type: "stdio", command: "run", args: [], env: {} }, + }, + }, + ctx, + ), + ).rejects.toThrow(/already exists/); + expect(settingsWrite).not.toHaveBeenCalled(); + }); + + it("add_mcp_server rejects a name reserved by a built-in server", async () => { + const { ctx, settingsWrite } = context({}); + await expect( + dispatchTool( + "add_mcp_server", + { + server: { + name: "poracode", + transport: { type: "stdio", command: "run", args: [], env: {} }, + }, + }, + ctx, + ), + ).rejects.toThrow(/reserved/); + expect(settingsWrite).not.toHaveBeenCalled(); + }); + + it("update_mcp_server preserves the stored secret when the patch echoes «redacted»", async () => { + const { ctx, settingsWrite } = context({ settings: settingsWithMcpSecret() }); + const result = (await dispatchTool( + "update_mcp_server", + { + id: "http-server", + patch: { + description: "renamed desc", + transport: { + type: "http", + url: "https://example.test/mcp", + headers: { Authorization: "«redacted»" }, + }, + }, + }, + ctx, + )) as { updated: boolean; server: { transport: { headers?: Record } } }; + + expect(result.updated).toBe(true); + // The stored settings retain the ORIGINAL secret, not the «redacted» marker. + const written = settingsWrite.mock.calls[0]![0]; + const stored = written.mcpServers.find((s) => s.id === "http-server"); + expect(stored?.transport).toMatchObject({ + type: "http", + headers: { Authorization: "Bearer http-secret-token" }, + }); + expect(stored?.description).toBe("renamed desc"); + // The returned summary re-redacts the preserved secret. + expect(JSON.stringify(result)).not.toContain("http-secret-token"); + expect(result.server.transport.headers).toEqual({ Authorization: "«redacted»" }); + }); + + it("update_mcp_server toggles enabled as a one-field patch", async () => { + const { ctx, settingsWrite } = context({ settings: settingsWithMcpSecret() }); + await dispatchTool("update_mcp_server", { id: "http-server", patch: { enabled: false } }, ctx); + const written = settingsWrite.mock.calls[0]![0]; + const stored = written.mcpServers.find((s) => s.id === "http-server"); + expect(stored?.enabled).toBe(false); + // Untouched transport secret is preserved. + expect(stored?.transport).toMatchObject({ + type: "http", + headers: { Authorization: "Bearer http-secret-token" }, + }); + }); + + it("remove_mcp_server errors on an unknown id", async () => { + const { ctx, settingsWrite } = context({ settings: settingsWithMcpSecret() }); + await expect(dispatchTool("remove_mcp_server", { id: "does-not-exist" }, ctx)).rejects.toThrow( + /Unknown MCP server id/, + ); + expect(settingsWrite).not.toHaveBeenCalled(); + }); + + it("remove_mcp_server deletes the server by id", async () => { + const { ctx, settingsWrite } = context({ settings: settingsWithMcpSecret() }); + const result = (await dispatchTool("remove_mcp_server", { id: "http-server" }, ctx)) as { + removed: boolean; + }; + expect(result.removed).toBe(true); + const written = settingsWrite.mock.calls[0]![0]; + expect(written.mcpServers).toEqual([]); + }); +}); + +describe("Poracode app control tools — skills", () => { + const projects = [ + { id: "p1", name: "Alpha", location: { kind: "posix", path: "/work/alpha" } } as Project, + ]; + + it("list_skills groups global and project scopes", async () => { + const skillScan: SkillScanResult = { + skills: [ + { + id: "g1", + name: "Global Skill", + description: "", + folderName: "g1", + absolutePath: "/skills/g1", + skillFilePath: "/skills/g1/SKILL.md", + rootPath: "/skills", + providerId: "claude", + providerLabel: "Claude", + scope: "global", + scopeLabel: "Global", + origin: "external", + enabled: true, + mutable: true, + valid: true, + linked: false, + }, + { + id: "p1s", + name: "Project Skill", + description: "", + folderName: "p1s", + absolutePath: "/work/alpha/.skills/p1s", + skillFilePath: "/work/alpha/.skills/p1s/SKILL.md", + rootPath: "/work/alpha/.skills", + providerId: "claude", + providerLabel: "Claude", + scope: "project", + scopeLabel: "Project", + origin: "external", + enabled: false, + mutable: true, + valid: true, + linked: false, + }, + ] as SkillScanResult["skills"], + effectiveSkillIds: ["g1"], + invocation: "slash", + issues: [], + canLinkToGlobal: true, + }; + const { ctx, supervisor } = context({ projects, skillScan }); + const result = (await dispatchTool("list_skills", { projectId: "p1" }, ctx)) as { + global: Array<{ id: string }>; + project: Array<{ id: string }>; + }; + expect(supervisor.scanSkills).toHaveBeenCalledWith({ + projectLocation: { kind: "posix", path: "/work/alpha" }, + }); + expect(result.global.map((s) => s.id)).toEqual(["g1"]); + expect(result.project.map((s) => s.id)).toEqual(["p1s"]); + }); + + it("set_skill_enabled toggles by absolutePath", async () => { + const { ctx, supervisor } = context({ projects }); + await dispatchTool("set_skill_enabled", { absolutePath: "/skills/g1", enabled: false }, ctx); + expect(supervisor.setSkillEnabled).toHaveBeenCalledWith({ + absolutePath: "/skills/g1", + enabled: false, + }); + }); +}); diff --git a/src/main/app-controls/mcp/toolRegistry.ts b/src/main/app-controls/mcp/toolRegistry.ts index 83760e18..b3c7fd81 100644 --- a/src/main/app-controls/mcp/toolRegistry.ts +++ b/src/main/app-controls/mcp/toolRegistry.ts @@ -1,107 +1,76 @@ -import { z } from "zod"; -import type { McpThreadIdentity } from "@/shared/browserMcpThread"; -import { isHomeProjectId } from "@/shared/homeScope"; -import { - agentKindSchema, - scheduleRecurrenceSchema, - type ScheduledTask, - type ScheduledTaskInput, - type Thread, -} from "@/shared/contracts"; import type { StreamableHttpMcpToolResult, StreamableHttpMcpToolSpec, } from "../../mcp/StreamableHttpMcpIngress"; -import type { ScheduleService } from "../../schedules/ScheduleService"; +import { agentTools } from "./tools/agents"; +import { appTools } from "./tools/app"; +import { fileTools } from "./tools/files"; +import { gitTools } from "./tools/git"; +import { githubTools } from "./tools/github"; +import { mcpServerTools } from "./tools/mcpServers"; +import { projectTools } from "./tools/projects"; +import { scheduleTools } from "./tools/schedules"; +import { searchTools } from "./tools/search"; +import { settingsTools } from "./tools/settings"; +import { skillTools } from "./tools/skills"; +import { threadTools } from "./tools/threads"; +import { usageTools } from "./tools/usage"; +import type { AppControlsToolContext, ToolDomain, ToolHandler } from "./tools/types"; -export interface AppControlsToolContext { - identity: McpThreadIdentity; - scheduleService: ScheduleService; - getThread(threadId: string): Thread | null; -} - -const createArgsSchema = z.object({ - name: z.string().trim().min(1).max(120), - prompt: z.string().trim().min(1).max(50_000), - recurrence: scheduleRecurrenceSchema, - enabled: z.boolean().optional().default(true), - agentKind: agentKindSchema.optional(), - model: z.string().min(1).optional(), - effort: z.string().min(1).optional(), -}); - -const updateArgsSchema = z.object({ - id: z.string().uuid(), - name: z.string().trim().min(1).max(120).optional(), - prompt: z.string().trim().min(1).max(50_000).optional(), - recurrence: scheduleRecurrenceSchema.optional(), - enabled: z.boolean().optional(), - agentKind: agentKindSchema.optional(), - model: z.string().min(1).optional(), - effort: z.string().min(1).nullable().optional(), -}); - -const idArgsSchema = z.object({ id: z.string().uuid() }); +export { APP_CONTROLS_MCP_SERVER_INFO } from "./tools/serverInfo"; +export type { + AppControlsToolContext, + AppControlsSupervisorCaller, + AppControlsAppInfo, + AppControlsNotifyResult, + AppControlsSettingsGateway, + AppControlsUpdateCheck, +} from "./tools/types"; export const APP_CONTROLS_MCP_INSTRUCTIONS = - "Use these Poracode controls to list, create, update, run, and delete the user's device schedules. Explain consequential changes before making them. Schedules run only while the device is awake and Poracode is open."; + "Poracode app controls. Read and control the running app: device schedules " + + "(list/create/update/run/delete), app threads (list/get/read/create/send/interrupt/stop/wait/" + + "update/open), projects (list/get/create/update), app settings (get/update), provider usage " + + "(get_usage), cross-app search (search), and app info (get_app_info). You can also read a " + + "terminal thread's scrollback, queue steer guidance, stage composer input, or roll back turns; " + + "read project files (list/read/find); list installed CLI agents; and notify the user or check " + + "for app updates. You can also drive a project's git (status/diff/stage/commit/branch/sync and " + + "worktree list/merge/remove), its GitHub pull requests via the gh CLI (list/get/create/comment/" + + "merge/update), the user's configured MCP servers (list/probe/add/update/remove — MCP servers " + + "are managed with these dedicated tools, not update_settings), and installed skills (list/" + + "enable). Threads and projects are " + + "the user's own work, visible in their sidebar; treat them as shared state. Explain " + + "consequential or destructive actions — stopping or interrupting another thread, archiving, " + + "marking done, creating a project, or changing settings — to the user before doing them, and " + + "never delete their work without asking. update_settings changes apply immediately app-wide. " + + "Secrets are never exposed: get_settings redacts profile credentials and update_settings " + + "refuses to touch them. Schedules run only while the device is awake and Poracode is open. " + + "You cannot stop, interrupt, or wait on your own thread."; -export const TOOLS: readonly StreamableHttpMcpToolSpec[] = [ - { - name: "list_schedules", - description: "List the user's Poracode schedules and their current status.", - inputSchema: { type: "object", properties: {}, additionalProperties: false }, - }, - { - name: "create_schedule", - description: - "Create a device schedule. The current agent and model are used unless overridden.", - inputSchema: { - type: "object", - additionalProperties: false, - required: ["name", "prompt", "recurrence"], - properties: { - name: { type: "string", minLength: 1, maxLength: 120 }, - prompt: { type: "string", minLength: 1, maxLength: 50000 }, - recurrence: recurrenceJsonSchema(), - enabled: { type: "boolean", default: true }, - agentKind: { type: "string" }, - model: { type: "string" }, - effort: { type: "string" }, - }, - }, - }, - { - name: "update_schedule", - description: "Update selected fields on an existing Poracode schedule.", - inputSchema: { - type: "object", - additionalProperties: false, - required: ["id"], - properties: { - id: { type: "string", format: "uuid" }, - name: { type: "string", minLength: 1, maxLength: 120 }, - prompt: { type: "string", minLength: 1, maxLength: 50000 }, - recurrence: recurrenceJsonSchema(), - enabled: { type: "boolean" }, - agentKind: { type: "string" }, - model: { type: "string" }, - effort: { type: ["string", "null"] }, - }, - }, - }, - { - name: "run_schedule", - description: "Run an existing schedule now without changing its next scheduled run.", - inputSchema: idJsonSchema(), - }, - { - name: "delete_schedule", - description: "Permanently delete an existing schedule from this device.", - inputSchema: idJsonSchema(), - }, +const DOMAINS: readonly ToolDomain[] = [ + scheduleTools, + threadTools, + projectTools, + settingsTools, + usageTools, + searchTools, + appTools, + fileTools, + agentTools, + gitTools, + githubTools, + mcpServerTools, + skillTools, ]; +export const TOOLS: readonly StreamableHttpMcpToolSpec[] = DOMAINS.flatMap( + (domain) => domain.specs, +); + +const HANDLERS: Record = Object.fromEntries( + DOMAINS.flatMap((domain) => Object.entries(domain.handlers)), +); + const TOOL_NAMES = new Set(TOOLS.map((tool) => tool.name)); export function isKnownToolName(name: string): boolean { @@ -113,125 +82,11 @@ export async function dispatchTool( args: Record, ctx: AppControlsToolContext, ): Promise { - if (name === "list_schedules") return ctx.scheduleService.list(); - if (name === "create_schedule") { - const parsed = createArgsSchema.parse(args); - const sourceThread = ctx.identity.threadId ? ctx.getThread(ctx.identity.threadId) : null; - const agentKind = parsed.agentKind ?? sourceThread?.agentKind; - const model = parsed.model ?? sourceThread?.config.model; - if (!agentKind || !model) { - throw new Error("agentKind and model are required when the calling thread is unavailable."); - } - // Default the run's project to the calling thread's project (so a schedule - // created from inside a project runs there). Home-scope threads leave it - // null, which the coordinator resolves to the built-in Home project. - const projectId = - sourceThread && !isHomeProjectId(sourceThread.projectId) ? sourceThread.projectId : null; - const input: ScheduledTaskInput = { - name: parsed.name, - prompt: parsed.prompt, - recurrence: parsed.recurrence, - enabled: parsed.enabled, - agentKind, - ...(projectId ? { projectId } : {}), - config: { - model, - ...((parsed.effort ?? sourceThread?.config.effort) - ? { effort: parsed.effort ?? sourceThread?.config.effort } - : {}), - ...(sourceThread?.config.fast !== undefined ? { fast: sourceThread.config.fast } : {}), - }, - }; - return ctx.scheduleService.create(input); - } - if (name === "update_schedule") { - const parsed = updateArgsSchema.parse(args); - const current = requireSchedule(ctx, parsed.id); - return ctx.scheduleService.update(parsed.id, { - name: parsed.name ?? current.name, - prompt: parsed.prompt ?? current.prompt, - recurrence: parsed.recurrence ?? current.recurrence, - enabled: parsed.enabled ?? current.enabled, - agentKind: parsed.agentKind ?? current.agentKind, - config: { - model: parsed.model ?? current.config.model, - ...(parsed.effort === null - ? {} - : parsed.effort !== undefined - ? { effort: parsed.effort } - : current.config.effort - ? { effort: current.config.effort } - : {}), - ...(current.config.fast !== undefined ? { fast: current.config.fast } : {}), - }, - }); - } - if (name === "run_schedule") { - return ctx.scheduleService.runNow(idArgsSchema.parse(args).id); - } - if (name === "delete_schedule") { - const { id } = idArgsSchema.parse(args); - requireSchedule(ctx, id); - ctx.scheduleService.delete(id); - return { deleted: true, id }; - } - throw new Error(`Unknown tool: ${name}`); + const handler = HANDLERS[name]; + if (!handler) throw new Error(`Unknown tool: ${name}`); + return handler(args, ctx); } export function formatToolResult(_name: string, result: unknown): StreamableHttpMcpToolResult { return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } - -function requireSchedule(ctx: AppControlsToolContext, id: string): ScheduledTask { - const task = ctx.scheduleService.get(id); - if (!task) throw new Error("Scheduled task not found."); - return task; -} - -function idJsonSchema(): Record { - return { - type: "object", - additionalProperties: false, - required: ["id"], - properties: { id: { type: "string", format: "uuid" } }, - }; -} - -function recurrenceJsonSchema(): Record { - return { - oneOf: [ - { - type: "object", - additionalProperties: false, - required: ["kind", "minute"], - properties: { - kind: { const: "hourly" }, - minute: { type: "integer", minimum: 0, maximum: 59 }, - }, - }, - { - type: "object", - additionalProperties: false, - required: ["kind", "days", "time"], - properties: { - kind: { const: "weekly" }, - days: { - type: "array", - minItems: 1, - items: { type: "integer", minimum: 0, maximum: 6 }, - }, - time: { type: "string", pattern: "^([01]\\d|2[0-3]):[0-5]\\d$" }, - }, - }, - { - type: "object", - additionalProperties: false, - required: ["kind", "runAt"], - properties: { - kind: { const: "once" }, - runAt: { type: "string", format: "date-time" }, - }, - }, - ], - }; -} diff --git a/src/main/app-controls/mcp/tools/agents.ts b/src/main/app-controls/mcp/tools/agents.ts new file mode 100644 index 00000000..5978f95f --- /dev/null +++ b/src/main/app-controls/mcp/tools/agents.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import type { AgentStatus } from "@/shared/contracts"; +import type { AppControlsToolContext, ToolDomain } from "./types"; + +const listArgsSchema = z.object({ refresh: z.boolean().optional() }); + +export const agentTools: ToolDomain = { + specs: [ + { + name: "list_installed_agents", + description: + "List the CLI-agent inventory across the native host and any WSL distros the user's projects use: each agent's kind, whether it is installed, its version, executable path, environment, and auth state. Reads the cached inventory by default; set refresh:true to force a fresh detection sweep (slower).", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { refresh: { type: "boolean" } }, + }, + }, + ], + handlers: { + list_installed_agents: async (args, ctx) => { + const { refresh } = listArgsSchema.parse(args); + // Detection scans the native host plus every WSL distro backing a project, + // exactly like the app's own callers (see buildAgentStatuses / MainView). + const wslDistros = wslDistrosFromProjects(ctx); + const payload = { wslDistros }; + const response = refresh + ? await ctx.supervisor.refreshAgentStatuses(payload) + : await ctx.supervisor.getAgentStatuses(payload); + const native = response.windows.map(agentView); + const wsl = response.wsl.map(agentView); + return { + fromCache: response.fromCache, + ...(native.length > 0 ? { native } : {}), + ...(wsl.length > 0 ? { wsl } : {}), + }; + }, + }, +}; + +/** Distinct WSL distros backing the user's projects (native env needs none). */ +function wslDistrosFromProjects(ctx: AppControlsToolContext): string[] { + return [ + ...new Set( + ctx + .getProjects() + .flatMap((project) => (project.location.kind === "wsl" ? [project.location.distro] : [])), + ), + ]; +} + +/** Provider-agnostic projection of one detected agent (no secrets). */ +function agentView(status: AgentStatus) { + return { + kind: status.kind, + label: status.label, + installed: status.installed, + authState: status.authState, + ...(status.version ? { version: status.version } : {}), + ...(status.executablePath ? { path: status.executablePath } : {}), + ...(status.envKind ? { env: status.envKind } : {}), + ...(status.envDistro ? { distro: status.envDistro } : {}), + }; +} diff --git a/src/main/app-controls/mcp/tools/app.ts b/src/main/app-controls/mcp/tools/app.ts new file mode 100644 index 00000000..8fd70c28 --- /dev/null +++ b/src/main/app-controls/mcp/tools/app.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import { BUILT_IN_MCP_SERVER_NAMES } from "@/shared/contracts"; +import { APP_CONTROLS_MCP_SERVER_INFO } from "./serverInfo"; +import type { ToolDomain } from "./types"; + +const notifyArgsSchema = z.object({ + title: z.string().trim().min(1).max(200), + body: z.string().trim().min(1).max(2_000).optional(), +}); + +export const appTools: ToolDomain = { + specs: [ + { + name: "get_app_info", + description: + "Read-only overview of the running Poracode app: version, platform, UI locale setting, project/thread counts, whether a desktop renderer window is connected (vs headless server), and this MCP server's name/version.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + { + name: "notify_user", + description: + "Show the user a native OS notification (e.g. to flag that a long task needs their attention). Clicking it focuses the app on the calling thread. Desktop-only: on a headless server the response reports that nothing was shown.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["title"], + properties: { + title: { type: "string", minLength: 1, maxLength: 200 }, + body: { type: "string", minLength: 1, maxLength: 2000 }, + }, + }, + }, + { + name: "check_for_update", + description: + "Trigger the desktop app's read-only update check and report the current version plus the most recent known update status. Does not download or install anything. Reports not-supported on a headless server or in dev.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + ], + handlers: { + get_app_info: (_args, ctx) => { + const info = ctx.getAppInfo(); + const threads = ctx.getThreads(); + return { + version: info.version, + platform: info.platform, + locale: ctx.settings.read().locale, + projectCount: ctx.getProjects().length, + threadCount: threads.length, + openThreadCount: threads.filter((thread) => !thread.archived && !thread.done).length, + renderer: info.hasRendererWindow ? "desktop" : "headless", + mcpServer: { + name: BUILT_IN_MCP_SERVER_NAMES["app-controls"], + version: APP_CONTROLS_MCP_SERVER_INFO.version, + }, + }; + }, + notify_user: (args, ctx) => { + const { title, body } = notifyArgsSchema.parse(args); + const result = ctx.notifyUser({ + title, + body: body ?? "", + threadId: ctx.identity.threadId ?? "", + }); + return { + delivered: result.delivered, + ...(result.note ? { note: result.note } : {}), + }; + }, + check_for_update: (_args, ctx) => ctx.checkForUpdate(), + }, +}; diff --git a/src/main/app-controls/mcp/tools/files.ts b/src/main/app-controls/mcp/tools/files.ts new file mode 100644 index 00000000..aeafac73 --- /dev/null +++ b/src/main/app-controls/mcp/tools/files.ts @@ -0,0 +1,152 @@ +import { z } from "zod"; +import type { ProjectFileReadStatus } from "@/shared/contracts"; +import { + projectIdProp, + requireProject, + resolveLocation, + worktreePathProp, + type ToolDomain, +} from "./types"; + +/** Cap on file content returned by `read_project_file`. */ +const READ_FILE_MAX_CHARS = 100_000; +/** Default / max results for `find_files`. */ +const FIND_DEFAULT_LIMIT = 30; +const FIND_MAX_LIMIT = 100; + +const listArgsSchema = z.object({ + projectId: z.string().min(1), + directoryPath: z.string().optional(), +}); +const readArgsSchema = z.object({ + projectId: z.string().min(1), + path: z.string().min(1), + worktreePath: z.string().min(1).optional(), +}); +const findArgsSchema = z.object({ + projectId: z.string().min(1), + query: z.string().trim().min(1).max(500), + limit: z.number().int().min(1).max(FIND_MAX_LIMIT).optional(), +}); + +export const fileTools: ToolDomain = { + specs: [ + { + name: "list_project_files", + description: + "List one directory level of a project's file tree (directories first). Omit directoryPath to list the project root; pass a project-relative path to list a subdirectory. Read-only.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId"], + properties: { + projectId: projectIdProp, + directoryPath: { type: "string" }, + }, + }, + }, + { + name: "read_project_file", + description: + "Read a text file in a project by its project-relative path. Pass worktreePath to read the file from one of the project's git worktrees instead of the main checkout. Only the first 100000 characters are returned (truncation is flagged); binary, too-large, or missing files return a status note instead of content.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId", "path"], + properties: { + projectId: projectIdProp, + path: { type: "string", minLength: 1 }, + worktreePath: worktreePathProp, + }, + }, + }, + { + name: "find_files", + description: + "Fuzzy filename search within a project (matches paths/names, not file contents). Returns up to `limit` matches (default 30, max 100). Read-only.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId", "query"], + properties: { + projectId: projectIdProp, + query: { type: "string", minLength: 1, maxLength: 500 }, + limit: { type: "integer", minimum: 1, maximum: FIND_MAX_LIMIT }, + }, + }, + }, + ], + handlers: { + list_project_files: async (args, ctx) => { + const { projectId, directoryPath } = listArgsSchema.parse(args); + const project = requireProject(ctx, projectId); + const result = await ctx.supervisor.listProjectTree({ + projectLocation: project.location, + directoryPath: directoryPath ?? "", + }); + return { + projectId, + directoryPath: result.directoryPath, + count: result.entries.length, + entries: result.entries.map((entry) => ({ + path: entry.path, + name: entry.name, + type: entry.type, + })), + }; + }, + read_project_file: async (args, ctx) => { + const { projectId, path, worktreePath } = readArgsSchema.parse(args); + // Validates worktreePath against the project's worktree set before use. + const projectLocation = await resolveLocation(ctx, projectId, worktreePath); + const result = await ctx.supervisor.readProjectFile({ projectLocation, path }); + if (result.status !== "ready" || result.content === undefined) { + return { projectId, path, status: result.status, note: statusNote(result.status) }; + } + const truncated = result.content.length > READ_FILE_MAX_CHARS; + const content = truncated ? result.content.slice(0, READ_FILE_MAX_CHARS) : result.content; + return { + projectId, + path, + status: result.status, + ...(truncated + ? { truncated: true, note: `Showing the first ${READ_FILE_MAX_CHARS} characters.` } + : {}), + content, + }; + }, + find_files: async (args, ctx) => { + const { projectId, query, limit } = findArgsSchema.parse(args); + const project = requireProject(ctx, projectId); + const result = await ctx.supervisor.searchProjectTree({ + projectLocation: project.location, + query, + limit: limit ?? FIND_DEFAULT_LIMIT, + }); + return { + projectId, + query, + count: result.entries.length, + entries: result.entries.map((entry) => ({ + path: entry.path, + name: entry.name, + type: entry.type, + })), + }; + }, + }, +}; + +/** Human-readable explanation for a non-`ready` file read result. */ +function statusNote(status: ProjectFileReadStatus): string { + switch (status) { + case "binary": + return "This is a binary file, so its contents cannot be returned as text."; + case "too_large": + return "This file is too large to read."; + case "unsupported": + return "This file uses an unsupported text encoding."; + default: + return "The file could not be read as text."; + } +} diff --git a/src/main/app-controls/mcp/tools/git.ts b/src/main/app-controls/mcp/tools/git.ts new file mode 100644 index 00000000..78016c67 --- /dev/null +++ b/src/main/app-controls/mcp/tools/git.ts @@ -0,0 +1,464 @@ +import { z } from "zod"; +import { normalizeWorktreePathForComparison, resolveProjectLocation } from "@/shared/worktree"; +import { + capDiff, + projectIdProp, + requireProject, + resolveLocation, + resolveWorktreeInfo, + worktreePathProp, + type ToolDomain, +} from "./types"; + +/** Matches a full git object id (SHA-1 or SHA-256), the only value git accepts as an expected commit. */ +const FULL_COMMIT_OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i; + +const projectArgs = { + projectId: z.string().min(1), + worktreePath: z.string().min(1).optional(), +}; + +const statusArgsSchema = z.object(projectArgs); +const diffArgsSchema = z.object({ + ...projectArgs, + filePath: z.string().min(1).optional(), + staged: z.boolean().optional(), +}); +const stageArgsSchema = z.object({ + ...projectArgs, + paths: z.array(z.string().min(1)).min(1).optional(), + all: z.boolean().optional(), + unstage: z.boolean().optional(), +}); +const commitArgsSchema = z.object({ + ...projectArgs, + message: z.string().min(1), + addAll: z.boolean().optional(), +}); +const discardArgsSchema = z.object({ + ...projectArgs, + paths: z.array(z.string().min(1)).min(1).optional(), + all: z.boolean().optional(), +}); +const branchArgsSchema = z.object({ + ...projectArgs, + action: z.enum(["list", "switch", "create"]), + branch: z.string().min(1).optional(), + includeRemote: z.boolean().optional(), +}); +const syncArgsSchema = z.object({ + ...projectArgs, + action: z.enum(["fetch", "pull", "pull_rebase", "push"]), + remote: z.string().min(1).optional(), + branch: z.string().min(1).optional(), + setUpstream: z.boolean().optional(), +}); +const listWorktreesArgsSchema = z.object({ projectId: z.string().min(1) }); +const removeWorktreeArgsSchema = z.object({ + projectId: z.string().min(1), + worktreePath: z.string().min(1), +}); +const mergeWorktreeArgsSchema = z.object({ + projectId: z.string().min(1), + worktreePath: z.string().min(1), + action: z.enum(["merge", "pull_from_source", "abort", "finish"]), +}); + +export const gitTools: ToolDomain = { + specs: [ + { + name: "git_status", + description: + "Read a project's git state: current branch, ahead/behind, staged and unstaged changes, branches, and worktrees. Pass worktreePath to inspect one of the project's worktrees instead of the main checkout. Read-only.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId"], + properties: { + projectId: projectIdProp, + worktreePath: worktreePathProp, + }, + }, + }, + { + name: "git_diff", + description: + "Show uncommitted changes as a unified diff. Pass filePath for one file (set staged to diff the staged copy), or omit it for every changed file. Output is capped at 80000 characters (truncation is flagged). Read-only.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId"], + properties: { + projectId: projectIdProp, + worktreePath: worktreePathProp, + filePath: { type: "string", minLength: 1 }, + staged: { type: "boolean" }, + }, + }, + }, + { + name: "git_stage", + description: + "Stage or unstage changes for the next commit. Pass paths for specific files or all: true for every change; set unstage: true to move them out of the index instead. Non-destructive (does not touch file contents).", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId"], + properties: { + projectId: projectIdProp, + worktreePath: worktreePathProp, + paths: { type: "array", items: { type: "string", minLength: 1 }, minItems: 1 }, + all: { type: "boolean" }, + unstage: { type: "boolean" }, + }, + }, + }, + { + name: "git_commit", + description: + "Create a git commit with the given message. Set addAll: true to stage every change first; otherwise only already-staged changes are committed. Consequential — explain the commit to the user first.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId", "message"], + properties: { + projectId: projectIdProp, + worktreePath: worktreePathProp, + message: { type: "string", minLength: 1 }, + addAll: { type: "boolean" }, + }, + }, + }, + { + name: "git_discard", + description: + "DESTRUCTIVE: permanently delete uncommitted changes, reverting files to their last committed state. Discarded work cannot be recovered. Confirm with the user before calling. Pass either paths (specific files) or all: true — never both, and never neither.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId"], + properties: { + projectId: projectIdProp, + worktreePath: worktreePathProp, + paths: { type: "array", items: { type: "string", minLength: 1 }, minItems: 1 }, + all: { type: "boolean" }, + }, + }, + }, + { + name: "git_branch", + description: + "List branches (action: list, includeRemote to include remotes), switch to an existing branch (action: switch), or create and switch to a new branch (action: create). branch is required for switch/create.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId", "action"], + properties: { + projectId: projectIdProp, + worktreePath: worktreePathProp, + action: { type: "string", enum: ["list", "switch", "create"] }, + branch: { type: "string", minLength: 1 }, + includeRemote: { type: "boolean" }, + }, + }, + }, + { + name: "git_sync", + description: + "Exchange commits with a remote: fetch, pull (merge), pull_rebase, or push. push is consequential — it publishes local commits to the remote; explain it to the user first. Pass remote/branch/setUpstream as needed (remote defaults to origin).", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId", "action"], + properties: { + projectId: projectIdProp, + worktreePath: worktreePathProp, + action: { type: "string", enum: ["fetch", "pull", "pull_rebase", "push"] }, + remote: { type: "string", minLength: 1 }, + branch: { type: "string", minLength: 1 }, + setUpstream: { type: "boolean" }, + }, + }, + }, + { + name: "list_worktrees", + description: + "List a project's git worktrees (path, branch, commit, whether it is the main checkout), each enriched with a short change-count status when available. Read-only.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId"], + properties: { projectId: projectIdProp }, + }, + }, + { + name: "remove_worktree", + description: + "DESTRUCTIVE: delete a worktree directory from disk. Refuses when an open (non-archived) thread still references the worktree — archive or move those threads first. Confirm with the user before calling.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId", "worktreePath"], + properties: { + projectId: projectIdProp, + worktreePath: worktreePathProp, + }, + }, + }, + { + name: "merge_worktree", + description: + "Drive a worktree's merge flow against its source branch: merge (merge the worktree branch into its source), pull_from_source (bring source changes into the worktree), abort (abandon an in-progress merge), or finish (commit a resolved merge). The source branch and expected commit are resolved automatically. merge/pull_from_source change git history — explain them to the user first.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId", "worktreePath", "action"], + properties: { + projectId: projectIdProp, + worktreePath: worktreePathProp, + action: { type: "string", enum: ["merge", "pull_from_source", "abort", "finish"] }, + }, + }, + }, + ], + handlers: { + git_status: async (args, ctx) => { + const { projectId, worktreePath } = statusArgsSchema.parse(args); + const projectLocation = await resolveLocation(ctx, projectId, worktreePath); + const snapshot = await ctx.supervisor.gitProjectSnapshot({ + projectLocation, + includeGhCheck: false, + }); + return { projectId, ...(worktreePath ? { worktreePath } : {}), snapshot }; + }, + git_diff: async (args, ctx) => { + const { projectId, worktreePath, filePath, staged } = diffArgsSchema.parse(args); + const projectLocation = await resolveLocation(ctx, projectId, worktreePath); + if (filePath) { + const { diff } = await ctx.supervisor.getGitDiff({ + projectLocation, + filePath, + staged: staged ?? false, + }); + return { projectId, filePath, ...capDiff(diff) }; + } + const batch = await ctx.supervisor.getGitDiffBatch({ projectLocation, untrackedPaths: [] }); + const combined = [ + ...Object.entries(batch.staged).map(([path, diff]) => `# staged: ${path}\n${diff}`), + ...Object.entries(batch.unstaged).map(([path, diff]) => `# unstaged: ${path}\n${diff}`), + ].join("\n"); + return { + projectId, + stagedFiles: Object.keys(batch.staged), + unstagedFiles: Object.keys(batch.unstaged), + ...capDiff(combined), + }; + }, + git_stage: async (args, ctx) => { + const { projectId, worktreePath, paths, all, unstage } = stageArgsSchema.parse(args); + if (!all && (!paths || paths.length === 0)) { + throw new Error("Pass paths (specific files) or all: true."); + } + const projectLocation = await resolveLocation(ctx, projectId, worktreePath); + if (all) { + if (unstage) await ctx.supervisor.gitUnstageAll({ projectLocation }); + else await ctx.supervisor.gitStageAll({ projectLocation }); + return { projectId, action: unstage ? "unstage" : "stage", all: true }; + } + for (const filePath of paths ?? []) { + if (unstage) await ctx.supervisor.gitUnstage({ projectLocation, filePath }); + else await ctx.supervisor.gitStage({ projectLocation, filePath }); + } + return { projectId, action: unstage ? "unstage" : "stage", paths }; + }, + git_commit: async (args, ctx) => { + const { projectId, worktreePath, message, addAll } = commitArgsSchema.parse(args); + const projectLocation = await resolveLocation(ctx, projectId, worktreePath); + const result = await ctx.supervisor.gitCommit({ + projectLocation, + message, + addAll: addAll ?? false, + }); + return { projectId, committed: true, ...result }; + }, + git_discard: async (args, ctx) => { + const { projectId, worktreePath, paths, all } = discardArgsSchema.parse(args); + const hasPaths = (paths?.length ?? 0) > 0; + if (hasPaths === Boolean(all)) { + throw new Error( + "git_discard permanently deletes uncommitted changes. Pass exactly one of paths or all: true.", + ); + } + const projectLocation = await resolveLocation(ctx, projectId, worktreePath); + if (all) { + await ctx.supervisor.gitRevertAll({ projectLocation }); + return { projectId, discarded: "all" }; + } + for (const filePath of paths ?? []) { + await ctx.supervisor.gitRevert({ projectLocation, filePath }); + } + return { projectId, discardedPaths: paths }; + }, + git_branch: async (args, ctx) => { + const { projectId, worktreePath, action, branch, includeRemote } = + branchArgsSchema.parse(args); + const projectLocation = await resolveLocation(ctx, projectId, worktreePath); + if (action === "list") { + const result = await ctx.supervisor.gitListBranches({ + projectLocation, + includeRemote: includeRemote ?? true, + }); + return { projectId, ...result }; + } + if (!branch) throw new Error(`branch is required for action "${action}".`); + const result = await ctx.supervisor.gitSwitchBranch({ + projectLocation, + branch, + createNew: action === "create", + }); + return { projectId, switched: true, ...result }; + }, + git_sync: async (args, ctx) => { + const { projectId, worktreePath, action, remote, branch, setUpstream } = + syncArgsSchema.parse(args); + const projectLocation = await resolveLocation(ctx, projectId, worktreePath); + const remoteArg = remote ? { remote } : {}; + switch (action) { + case "fetch": + await ctx.supervisor.gitFetch({ + projectLocation, + remote: remote ?? "origin", + prune: false, + }); + break; + case "pull": + await ctx.supervisor.gitPull({ projectLocation, ...remoteArg }); + break; + case "pull_rebase": + await ctx.supervisor.gitPullRebase({ projectLocation, ...remoteArg }); + break; + case "push": + await ctx.supervisor.gitPush({ + projectLocation, + ...remoteArg, + ...(branch ? { branch } : {}), + ...(setUpstream === undefined ? {} : { setUpstream }), + }); + break; + } + return { projectId, action, done: true }; + }, + list_worktrees: async (args, ctx) => { + const { projectId } = listWorktreesArgsSchema.parse(args); + const project = requireProject(ctx, projectId); + const { worktrees } = await ctx.supervisor.gitListWorktrees({ + projectLocation: project.location, + }); + const paths = worktrees.map((worktree) => worktree.path); + let statuses: Record< + string, + { branch: string; ahead: number; behind: number; changed: number } + > = {}; + if (paths.length > 0) { + try { + const batch = await ctx.supervisor.gitWorktreeStatusBatch({ + projectLocation: project.location, + worktreePaths: paths, + }); + statuses = Object.fromEntries( + Object.entries(batch.statuses).map(([path, status]) => [ + path, + { + branch: status.branch, + ahead: status.ahead, + behind: status.behind, + changed: status.staged.length + status.unstaged.length, + }, + ]), + ); + } catch { + // Status enrichment is best-effort; fall back to the plain worktree list. + } + } + return { + projectId, + count: worktrees.length, + worktrees: worktrees.map((worktree) => ({ + path: worktree.path, + branch: worktree.branch, + commit: worktree.commit, + isMain: worktree.isMain, + ...(statuses[worktree.path] ? { status: statuses[worktree.path] } : {}), + })), + }; + }, + remove_worktree: async (args, ctx) => { + const { projectId, worktreePath } = removeWorktreeArgsSchema.parse(args); + const project = requireProject(ctx, projectId); + const target = normalizeWorktreePathForComparison(worktreePath, false); + const blocking = ctx + .getThreads() + .filter( + (thread) => + !thread.archived && + thread.worktreePath && + normalizeWorktreePathForComparison(thread.worktreePath, false) === target, + ) + .map((thread) => thread.id); + if (blocking.length > 0) { + throw new Error( + `Cannot remove this worktree: it is still referenced by open thread(s): ${blocking.join( + ", ", + )}. Archive or move them first, then retry.`, + ); + } + await ctx.supervisor.gitRemoveWorktree({ + projectLocation: project.location, + path: worktreePath, + force: false, + deleteBranch: false, + }); + return { projectId, worktreePath, removed: true }; + }, + merge_worktree: async (args, ctx) => { + const { projectId, worktreePath, action } = mergeWorktreeArgsSchema.parse(args); + const project = requireProject(ctx, projectId); + const worktreeLocation = resolveProjectLocation(project.location, worktreePath); + if (action === "abort") { + const result = await ctx.supervisor.gitAbortMerge({ worktreeLocation }); + return { projectId, worktreePath, action, ...result }; + } + if (action === "finish") { + const result = await ctx.supervisor.gitFinishMerge({ worktreeLocation }); + return { projectId, worktreePath, action, ...result }; + } + // merge / pull_from_source both need the worktree branch + its source branch. + const info = await resolveWorktreeInfo(ctx, project.location, worktreePath); + const { sourceBranch } = await ctx.supervisor.gitGetWorktreeSourceBranch({ + projectLocation: project.location, + branch: info.branch, + }); + if (!sourceBranch) { + throw new Error( + `Could not determine a source branch for worktree branch "${info.branch}". It may have no upstream/parent to merge with.`, + ); + } + if (action === "pull_from_source") { + const result = await ctx.supervisor.gitPullFromSource({ + worktreeLocation, + sourceBranch, + preserveLocalChanges: false, + }); + return { projectId, worktreePath, action, sourceBranch, ...result }; + } + const result = await ctx.supervisor.gitMergeToSource({ + projectLocation: project.location, + worktreeLocation, + worktreeBranch: info.branch, + sourceBranch, + ...(FULL_COMMIT_OID.test(info.commit) ? { expectedWorktreeCommit: info.commit } : {}), + }); + return { projectId, worktreePath, action, sourceBranch, ...result }; + }, + }, +}; diff --git a/src/main/app-controls/mcp/tools/github.ts b/src/main/app-controls/mcp/tools/github.ts new file mode 100644 index 00000000..872a3c55 --- /dev/null +++ b/src/main/app-controls/mcp/tools/github.ts @@ -0,0 +1,266 @@ +import { z } from "zod"; +import type { ProjectLocation } from "@/shared/contracts"; +import { + capDiff, + projectIdProp, + requireProject, + resolveLocation, + worktreePathProp, + type AppControlsToolContext, + type ToolDomain, +} from "./types"; + +const listPrsArgsSchema = z.object({ projectId: z.string().min(1) }); +const getPrArgsSchema = z.object({ + projectId: z.string().min(1), + prNumber: z.number().int().min(1), + include: z.array(z.enum(["details", "checks", "files", "diff"])).optional(), +}); +const createPrArgsSchema = z.object({ + projectId: z.string().min(1), + worktreePath: z.string().min(1).optional(), + branch: z.string().min(1), + baseBranch: z.string().min(1).optional(), + title: z.string().min(1), + body: z.string(), + draft: z.boolean().optional(), +}); +const commentArgsSchema = z.object({ + projectId: z.string().min(1), + prNumber: z.number().int().min(1), + body: z.string().min(1), +}); +const mergePrArgsSchema = z.object({ + projectId: z.string().min(1), + prNumber: z.number().int().min(1), + method: z.enum(["merge", "squash", "rebase"]).optional(), +}); +const updatePrArgsSchema = z.object({ + projectId: z.string().min(1), + prNumber: z.number().int().min(1), + action: z.enum(["close", "reopen", "ready", "update_branch"]), + rebase: z.boolean().optional(), +}); + +export const githubTools: ToolDomain = { + specs: [ + { + name: "gh_list_prs", + description: + "List the project's GitHub pull requests (with author, additions/deletions, review status, and the viewer's login) via the `gh` CLI. Read-only.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId"], + properties: { projectId: projectIdProp }, + }, + }, + { + name: "gh_get_pr", + description: + "Read one pull request. Returns its details by default; pass include to add checks, files, and/or the unified diff (diff is capped at 80000 characters). Read-only.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId", "prNumber"], + properties: { + projectId: projectIdProp, + prNumber: { type: "integer", minimum: 1 }, + include: { + type: "array", + items: { type: "string", enum: ["details", "checks", "files", "diff"] }, + }, + }, + }, + }, + { + name: "gh_create_pr", + description: + "Create a GitHub pull request from branch into baseBranch (defaults to the repo default). Consequential — this publishes a pull request others can see; explain it to the user first. Pass worktreePath to run from one of the project's worktrees.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId", "branch", "title", "body"], + properties: { + projectId: projectIdProp, + worktreePath: worktreePathProp, + branch: { type: "string", minLength: 1 }, + baseBranch: { type: "string", minLength: 1 }, + title: { type: "string", minLength: 1 }, + body: { type: "string" }, + draft: { type: "boolean" }, + }, + }, + }, + { + name: "gh_pr_comment", + description: + "Post a comment on a pull request. Consequential — the comment is publicly visible; explain it to the user first.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId", "prNumber", "body"], + properties: { + projectId: projectIdProp, + prNumber: { type: "integer", minimum: 1 }, + body: { type: "string", minLength: 1 }, + }, + }, + }, + { + name: "gh_merge_pr", + description: + "HIGHLY CONSEQUENTIAL: merge a pull request into its base branch (method: merge, squash, or rebase; default merge). This changes the shared repository and usually cannot be undone. Require explicit user confirmation before calling.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId", "prNumber"], + properties: { + projectId: projectIdProp, + prNumber: { type: "integer", minimum: 1 }, + method: { type: "string", enum: ["merge", "squash", "rebase"] }, + }, + }, + }, + { + name: "gh_update_pr", + description: + "Change a pull request's state: close, reopen, ready (mark a draft ready for review), or update_branch (merge/rebase the base into the PR branch; set rebase: true to rebase). Consequential — explain it to the user first.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId", "prNumber", "action"], + properties: { + projectId: projectIdProp, + prNumber: { type: "integer", minimum: 1 }, + action: { type: "string", enum: ["close", "reopen", "ready", "update_branch"] }, + rebase: { type: "boolean" }, + }, + }, + }, + ], + handlers: { + gh_list_prs: async (args, ctx) => { + const { projectId } = listPrsArgsSchema.parse(args); + const projectLocation = requireProject(ctx, projectId).location; + await requireGh(ctx, projectLocation); + const result = await ctx.supervisor.ghListPullRequests({ projectLocation }); + return { projectId, count: result.pullRequests.length, ...result }; + }, + gh_get_pr: async (args, ctx) => { + const { projectId, prNumber, include } = getPrArgsSchema.parse(args); + const projectLocation = requireProject(ctx, projectId).location; + await requireGh(ctx, projectLocation); + const want = new Set(include ?? ["details"]); + const { details } = await ctx.supervisor.ghGetPrDetails({ projectLocation, prNumber }); + const out: Record = { projectId, prNumber, details }; + // Details land the head branch checks need; the rest run concurrently. + const [checks, files, diff] = await Promise.all([ + want.has("checks") + ? ctx.supervisor.ghGetPrChecks({ projectLocation, branch: details.headBranch }) + : undefined, + want.has("files") ? ctx.supervisor.ghGetPrFiles({ projectLocation, prNumber }) : undefined, + want.has("diff") ? ctx.supervisor.ghGetPrDiff({ projectLocation, prNumber }) : undefined, + ]); + if (checks) out.checks = checks.checks; + if (files) out.files = files.files; + if (diff) { + const capped = capDiff(diff.diff); + out.diff = capped.diff; + if (capped.truncated) { + out.diffTruncated = true; + out.diffNote = capped.note; + } + } + return out; + }, + gh_create_pr: async (args, ctx) => { + const { projectId, worktreePath, branch, baseBranch, title, body, draft } = + createPrArgsSchema.parse(args); + const project = requireProject(ctx, projectId); + // Validates worktreePath against the project's worktree set before use. + const projectLocation = await resolveLocation(ctx, projectId, worktreePath); + await requireGh(ctx, projectLocation); + // The gh RPC needs a concrete base branch. When the caller omits it and the + // PR is from a worktree, infer it from the worktree's source branch. + let resolvedBase = baseBranch; + if (!resolvedBase && worktreePath) { + const { sourceBranch } = await ctx.supervisor.gitGetWorktreeSourceBranch({ + projectLocation: project.location, + branch, + }); + resolvedBase = sourceBranch ?? undefined; + } + if (!resolvedBase) { + throw new Error( + "baseBranch is required: could not infer the base branch to open the PR against. Pass baseBranch explicitly.", + ); + } + const pr = await ctx.supervisor.ghCreatePr({ + projectLocation, + branch, + baseBranch: resolvedBase, + title, + body, + isDraft: draft ?? false, + }); + return { projectId, created: true, pr }; + }, + gh_pr_comment: async (args, ctx) => { + const { projectId, prNumber, body } = commentArgsSchema.parse(args); + const projectLocation = requireProject(ctx, projectId).location; + await requireGh(ctx, projectLocation); + const comment = await ctx.supervisor.ghPostPrComment({ projectLocation, prNumber, body }); + return { projectId, prNumber, posted: true, comment }; + }, + gh_merge_pr: async (args, ctx) => { + const { projectId, prNumber, method } = mergePrArgsSchema.parse(args); + const projectLocation = requireProject(ctx, projectId).location; + await requireGh(ctx, projectLocation); + await ctx.supervisor.ghMergePr({ + projectLocation, + prNumber, + method: method ?? "merge", + admin: false, + }); + return { projectId, prNumber, merged: true, method: method ?? "merge" }; + }, + gh_update_pr: async (args, ctx) => { + const { projectId, prNumber, action, rebase } = updatePrArgsSchema.parse(args); + const projectLocation = requireProject(ctx, projectId).location; + await requireGh(ctx, projectLocation); + switch (action) { + case "close": + await ctx.supervisor.ghClosePr({ projectLocation, prNumber }); + break; + case "reopen": + await ctx.supervisor.ghReopenPr({ projectLocation, prNumber }); + break; + case "ready": + await ctx.supervisor.ghMarkPrReady({ projectLocation, prNumber }); + break; + case "update_branch": + await ctx.supervisor.ghUpdatePrBranch({ + projectLocation, + prNumber, + rebase: rebase ?? false, + }); + break; + } + return { projectId, prNumber, action, done: true }; + }, + }, +}; + +/** Fail fast with a clear message when the `gh` CLI is not available for this runtime. */ +async function requireGh( + ctx: AppControlsToolContext, + projectLocation: ProjectLocation, +): Promise { + const { available } = await ctx.supervisor.ghCheckAvailable({ projectLocation }); + if (!available) { + throw new Error( + "The GitHub CLI (gh) is not available or not authenticated for this project's runtime. Install and sign in with `gh auth login` to use GitHub tools.", + ); + } +} diff --git a/src/main/app-controls/mcp/tools/mcpServers.ts b/src/main/app-controls/mcp/tools/mcpServers.ts new file mode 100644 index 00000000..8c326eab --- /dev/null +++ b/src/main/app-controls/mcp/tools/mcpServers.ts @@ -0,0 +1,286 @@ +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { + isReservedMcpServerName, + isValidMcpServerName, + type McpServer, + mcpServerSchema, + mcpTransportSchema, +} from "@/shared/contracts"; +import type { SharedSettings } from "@/shared/settings"; +import { mergeManagedSharedSettings } from "../../../sharedSettingsFile"; +import { redactMcpServer, restoreRedactedTransport } from "./settings"; +import type { AppControlsToolContext, ToolDomain } from "./types"; + +const listArgsSchema = z.object({}); +const probeArgsSchema = z.object({ config: z.record(z.string(), z.unknown()) }); +const addArgsSchema = z.object({ + server: z.record(z.string(), z.unknown()), + reloadCallingThread: z.boolean().optional(), +}); +const updateArgsSchema = z.object({ + id: z.string().min(1), + patch: z.object({ + name: z.string().optional(), + description: z.string().optional(), + enabled: z.boolean().optional(), + timeoutMs: z.number().optional(), + transport: z.unknown().optional(), + }), + reloadCallingThread: z.boolean().optional(), +}); +const removeArgsSchema = z.object({ + id: z.string().min(1), + reloadCallingThread: z.boolean().optional(), +}); + +const NEXT_LAUNCH_NOTE = + "Running threads keep their current MCP set and pick this change up on their next launch; " + + "pass reloadCallingThread: true to hot-reload the calling thread's provider sessions now " + + "(only providers that support live MCP reloads apply it immediately)."; + +export const mcpServerTools: ToolDomain = { + specs: [ + { + name: "list_mcp_servers", + description: + "List the user's configured MCP servers (id, name, description, enabled, transport type, and whether OAuth is authenticated). Secret transport header/env values are redacted — only their key names are shown. Read-only.", + inputSchema: { type: "object", additionalProperties: false, properties: {} }, + }, + { + name: "probe_mcp_server", + description: + "Validate a candidate MCP server config by connecting to it and reporting its reachability, latency, and advertised tools. Provide the full server config (id, name, transport). Read-only — the config is validated, not saved, and any secret header/env values are redacted from the echoed config.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["config"], + properties: { config: { type: "object" } }, + }, + }, + { + name: "add_mcp_server", + description: + "Add a new MCP server to the user's shared settings. CONSEQUENTIAL: a stdio server's command will run on the user's machine the next time a thread launches — explain what the server is and does, and confirm with the user before adding. Provide the full server config (name, transport, optional description/enabled/timeoutMs); an id is generated when omitted. Reserved built-in names and duplicate ids/names are rejected. Returns the redacted server summary.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["server"], + properties: { + server: { type: "object" }, + reloadCallingThread: { type: "boolean" }, + }, + }, + }, + { + name: "update_mcp_server", + description: + "Update one configured MCP server by id, shallow-patching name/description/enabled/timeoutMs/transport. To toggle a server on or off, patch only `enabled`. When a transport value read from list_mcp_servers/get_settings is still redacted (the «redacted» marker), the existing stored secret is preserved instead of being overwritten, so you can safely echo back a redacted read. The merged result is validated before saving. Returns the redacted server summary.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["id", "patch"], + properties: { + id: { type: "string" }, + patch: { type: "object" }, + reloadCallingThread: { type: "boolean" }, + }, + }, + }, + { + name: "remove_mcp_server", + description: + "Remove one configured MCP server by id. DESTRUCTIVE: the server's entire configuration — including its stored credentials — is deleted and cannot be recovered. Confirm with the user before removing. Errors when the id is unknown.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["id"], + properties: { + id: { type: "string" }, + reloadCallingThread: { type: "boolean" }, + }, + }, + }, + ], + handlers: { + list_mcp_servers: async (_args, ctx) => { + listArgsSchema.parse(_args); + const servers = ctx.settings.read().mcpServers; + const { authenticatedUrls } = await ctx.supervisor.getMcpOauthStatus(); + const authenticated = new Set(authenticatedUrls); + return { + count: servers.length, + servers: servers.map((server) => summarizeServer(server, authenticated)), + }; + }, + probe_mcp_server: async (args, ctx) => { + const { config } = probeArgsSchema.parse(args); + const server = parseServer(config, "Invalid MCP server config"); + const result = await ctx.supervisor.probeMcpServer({ server }); + // Never echo the submitted secrets back — return only a redacted summary. + return { server: summarizeServer(server), result }; + }, + add_mcp_server: async (args, ctx) => { + const { server: raw, reloadCallingThread } = addArgsSchema.parse(args); + const candidate: Record = { ...raw }; + if (typeof candidate.id !== "string" || candidate.id.trim() === "") { + candidate.id = randomUUID(); + } + if (typeof candidate.name === "string" && !isValidMcpServerName(candidate.name)) { + throw new Error(nameError(candidate.name)); + } + const server = parseServer(candidate, "Invalid MCP server config"); + + const settings = ctx.settings.read(); + const existing = settings.mcpServers; + if (existing.some((entry) => entry.id === server.id)) { + throw new Error(`An MCP server with id "${server.id}" already exists.`); + } + if (existing.some((entry) => sameName(entry.name, server.name))) { + throw new Error(`An MCP server named "${server.name}" already exists.`); + } + + writeMcpServers(ctx, settings, [...existing, server]); + const reloadedCallingThread = await maybeReloadCallingThread(ctx, reloadCallingThread); + return { + added: true, + server: summarizeServer(server), + reloadedCallingThread, + note: NEXT_LAUNCH_NOTE, + }; + }, + update_mcp_server: async (args, ctx) => { + const { id, patch, reloadCallingThread } = updateArgsSchema.parse(args); + const settings = ctx.settings.read(); + const existing = settings.mcpServers.find((entry) => entry.id === id); + if (!existing) { + throw new Error(`Unknown MCP server id: ${id}. Call list_mcp_servers to see valid ids.`); + } + if (patch.name !== undefined && !isValidMcpServerName(patch.name)) { + throw new Error(nameError(patch.name)); + } + + let transport = existing.transport; + if (patch.transport !== undefined) { + const parsedTransport = mcpTransportSchema.safeParse(patch.transport); + if (!parsedTransport.success) { + throw new Error(formatSchemaError("Invalid MCP transport", parsedTransport.error)); + } + // Preserve any real stored secret the agent echoed back as «redacted». + transport = restoreRedactedTransport(parsedTransport.data, existing.transport); + } + + const merged: McpServer = { + ...existing, + ...(patch.name !== undefined ? { name: patch.name } : {}), + ...(patch.description !== undefined ? { description: patch.description } : {}), + ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}), + ...(patch.timeoutMs !== undefined ? { timeoutMs: patch.timeoutMs } : {}), + transport, + }; + const server = parseServer(merged, "Invalid MCP server config"); + if ( + settings.mcpServers.some( + (entry) => entry.id !== server.id && sameName(entry.name, server.name), + ) + ) { + throw new Error(`An MCP server named "${server.name}" already exists.`); + } + + writeMcpServers( + ctx, + settings, + settings.mcpServers.map((entry) => (entry.id === server.id ? server : entry)), + ); + const reloadedCallingThread = await maybeReloadCallingThread(ctx, reloadCallingThread); + return { + updated: true, + server: summarizeServer(server), + reloadedCallingThread, + note: NEXT_LAUNCH_NOTE, + }; + }, + remove_mcp_server: async (args, ctx) => { + const { id, reloadCallingThread } = removeArgsSchema.parse(args); + const settings = ctx.settings.read(); + const next = settings.mcpServers.filter((entry) => entry.id !== id); + if (next.length === settings.mcpServers.length) { + throw new Error(`Unknown MCP server id: ${id}. Call list_mcp_servers to see valid ids.`); + } + writeMcpServers(ctx, settings, next); + const reloadedCallingThread = await maybeReloadCallingThread(ctx, reloadCallingThread); + return { removed: true, id, reloadedCallingThread, note: NEXT_LAUNCH_NOTE }; + }, + }, +}; + +/** Validate an untrusted server config, throwing a readable aggregate error. */ +function parseServer(value: unknown, prefix: string): McpServer { + const parsed = mcpServerSchema.safeParse(value); + if (!parsed.success) throw new Error(formatSchemaError(prefix, parsed.error)); + return parsed.data; +} + +/** Flatten Zod issues into a single-line `path: message; …` string. */ +function formatSchemaError(prefix: string, error: z.ZodError): string { + return `${prefix}: ${error.issues + .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) + .join("; ")}`; +} + +/** Clear message for a name that fails the pattern or collides with a built-in. */ +function nameError(name: string): string { + return isReservedMcpServerName(name) + ? `MCP server name "${name}" is reserved by a built-in server. Choose a different name.` + : `Invalid MCP server name "${name}". Names must start with a letter or digit and contain only letters, digits, ".", "_", or "-".`; +} + +/** Case-insensitive name comparison, matching launch-time server merge semantics. */ +function sameName(a: string, b: string): boolean { + return a.trim().toLowerCase() === b.trim().toLowerCase(); +} + +/** Persist a new server list through the guarded settings write gateway. */ +function writeMcpServers( + ctx: AppControlsToolContext, + onDisk: SharedSettings, + mcpServers: McpServer[], +): void { + // Defense in depth: re-pin supervisor-managed fields exactly like update_settings. + const merged = mergeManagedSharedSettings(onDisk, { ...onDisk, mcpServers }); + ctx.settings.write(merged); +} + +/** + * Optionally hot-reload the calling thread's provider live sessions so the MCP + * change applies without a relaunch. The supervisor RPC is agent-kind scoped + * and a no-op for providers without live MCP reload, so failures are swallowed. + * Returns whether the reload RPC was dispatched. + */ +async function maybeReloadCallingThread( + ctx: AppControlsToolContext, + reload: boolean | undefined, +): Promise { + if (reload !== true) return false; + const threadId = ctx.identity.threadId; + if (!threadId) return false; + const thread = ctx.getThread(threadId); + if (!thread) return false; + await ctx.supervisor.reloadAgentMcpServers({ agentKind: thread.agentKind }); + return true; +} + +/** A secret-free view of one MCP server (transport values masked, key names kept). */ +function summarizeServer(server: McpServer, authenticated?: Set): Record { + const redacted = redactMcpServer(server); + // Match against the original URL: redaction may mask query-string values. + const url = server.transport.type === "stdio" ? undefined : server.transport.url; + return { + id: redacted.id, + name: redacted.name, + description: redacted.description, + enabled: redacted.enabled, + transport: redacted.transport, + ...(url !== undefined ? { authenticated: authenticated?.has(url) ?? false } : {}), + }; +} diff --git a/src/main/app-controls/mcp/tools/projects.ts b/src/main/app-controls/mcp/tools/projects.ts new file mode 100644 index 00000000..49040cac --- /dev/null +++ b/src/main/app-controls/mcp/tools/projects.ts @@ -0,0 +1,141 @@ +import { z } from "zod"; +import type { Project, ProjectLocation, Thread } from "@/shared/contracts"; +import { projectIdProp, requireProject, type ToolDomain } from "./types"; + +const projectIdArgsSchema = z.object({ projectId: z.string().min(1) }); +const createArgsSchema = z.object({ + path: z.string().trim().min(1), + name: z.string().trim().min(1).max(120).optional(), +}); +const updateArgsSchema = z.object({ + projectId: z.string().min(1), + name: z.string().trim().min(1).max(120).optional(), +}); + +export const projectTools: ToolDomain = { + specs: [ + { + name: "list_projects", + description: + "List all of the user's projects with id, name, absolute path, runtime kind (windows/wsl/posix), and open/total thread counts.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + { + name: "get_project", + description: + "Full detail for one project: its row, a summary of its threads, and any project notes (doc + todos).", + inputSchema: projectIdJsonSchema(), + }, + { + name: "create_project", + description: + "Register an existing folder on this device as a Poracode project. The directory must already exist; the path must be absolute.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["path"], + properties: { + path: { type: "string", minLength: 1 }, + name: { type: "string", minLength: 1, maxLength: 120 }, + }, + }, + }, + { + name: "update_project", + description: "Update an existing project's editable metadata (currently: rename).", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId"], + properties: { + projectId: projectIdProp, + name: { type: "string", minLength: 1, maxLength: 120 }, + }, + }, + }, + ], + handlers: { + list_projects: (_args, ctx) => { + const threads = ctx.getThreads(); + const projects = ctx.getProjects().map((project) => projectView(project, threads)); + return { count: projects.length, projects }; + }, + get_project: (args, ctx) => { + const { projectId } = projectIdArgsSchema.parse(args); + const project = requireProject(ctx, projectId); + const allThreads = ctx.getThreads(); + const threads = allThreads.filter((thread) => thread.projectId === projectId); + const notes = ctx.getProjectNotes(projectId); + return { + ...projectView(project, allThreads), + createdAt: project.createdAt, + ...(project.disabled ? { disabled: true } : {}), + threads: threads.map((thread) => ({ + threadId: thread.id, + title: thread.title, + status: thread.status, + agentKind: thread.agentKind, + archived: thread.archived, + done: thread.done, + })), + notes: notes + ? { + todoCount: notes.todos.length, + hasDoc: notes.doc != null, + updatedAt: notes.updatedAt, + } + : null, + }; + }, + create_project: async (args, ctx) => { + const { path, name } = createArgsSchema.parse(args); + if (!ctx.directoryExists(path)) { + throw new Error(`Directory does not exist on disk: ${path}`); + } + const result = await ctx.applyProjectCommand({ + kind: "add-existing", + path, + ...(name ? { name } : {}), + }); + return { created: true, project: result.project ?? null }; + }, + update_project: (args, ctx) => { + const { projectId, name } = updateArgsSchema.parse(args); + const project = requireProject(ctx, projectId); + if (name === undefined) { + throw new Error("Provide at least one field to update (name)."); + } + const next: Project = { ...project, name }; + ctx.updateProject(next); + return { updated: true, project: { id: next.id, name: next.name } }; + }, + }, +}; + +/** Path string for a project location (linux path for WSL, plain path otherwise). */ +export function locationPath(location: ProjectLocation): string { + return location.kind === "wsl" ? location.linuxPath : location.path; +} + +function projectView(project: Project, allThreads: readonly Thread[]) { + const projectThreads = allThreads.filter((thread) => thread.projectId === project.id); + const openThreads = projectThreads.filter((thread) => !thread.archived && !thread.done); + return { + id: project.id, + name: project.name, + path: locationPath(project.location), + kind: project.location.kind, + ...(project.location.kind === "wsl" ? { distro: project.location.distro } : {}), + threadCount: projectThreads.length, + openThreadCount: openThreads.length, + }; +} + +function projectIdJsonSchema(): Record { + return { + type: "object", + additionalProperties: false, + required: ["projectId"], + properties: { projectId: projectIdProp }, + }; +} diff --git a/src/main/app-controls/mcp/tools/schedules.ts b/src/main/app-controls/mcp/tools/schedules.ts new file mode 100644 index 00000000..1b23b6a2 --- /dev/null +++ b/src/main/app-controls/mcp/tools/schedules.ts @@ -0,0 +1,206 @@ +import { z } from "zod"; +import { isHomeProjectId } from "@/shared/homeScope"; +import { + agentKindSchema, + scheduleRecurrenceSchema, + type ScheduledTask, + type ScheduledTaskInput, +} from "@/shared/contracts"; +import type { AppControlsToolContext, ToolDomain } from "./types"; + +const createArgsSchema = z.object({ + name: z.string().trim().min(1).max(120), + prompt: z.string().trim().min(1).max(50_000), + recurrence: scheduleRecurrenceSchema, + enabled: z.boolean().optional().default(true), + agentKind: agentKindSchema.optional(), + model: z.string().min(1).optional(), + effort: z.string().min(1).optional(), +}); + +const updateArgsSchema = z.object({ + id: z.string().uuid(), + name: z.string().trim().min(1).max(120).optional(), + prompt: z.string().trim().min(1).max(50_000).optional(), + recurrence: scheduleRecurrenceSchema.optional(), + enabled: z.boolean().optional(), + agentKind: agentKindSchema.optional(), + model: z.string().min(1).optional(), + effort: z.string().min(1).nullable().optional(), +}); + +const idArgsSchema = z.object({ id: z.string().uuid() }); + +export const scheduleTools: ToolDomain = { + specs: [ + { + name: "list_schedules", + description: "List the user's Poracode schedules and their current status.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + { + name: "create_schedule", + description: + "Create a device schedule. The current agent and model are used unless overridden.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["name", "prompt", "recurrence"], + properties: { + name: { type: "string", minLength: 1, maxLength: 120 }, + prompt: { type: "string", minLength: 1, maxLength: 50000 }, + recurrence: recurrenceJsonSchema(), + enabled: { type: "boolean" }, + agentKind: { type: "string", minLength: 1 }, + model: { type: "string", minLength: 1 }, + effort: { type: "string", minLength: 1 }, + }, + }, + }, + { + name: "update_schedule", + description: "Update selected fields on an existing Poracode schedule.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["id"], + properties: { + id: { type: "string", format: "uuid" }, + name: { type: "string", minLength: 1, maxLength: 120 }, + prompt: { type: "string", minLength: 1, maxLength: 50000 }, + recurrence: recurrenceJsonSchema(), + enabled: { type: "boolean" }, + agentKind: { type: "string", minLength: 1 }, + model: { type: "string", minLength: 1 }, + effort: { type: ["string", "null"], minLength: 1 }, + }, + }, + }, + { + name: "run_schedule", + description: "Run an existing schedule now without changing its next scheduled run.", + inputSchema: idJsonSchema(), + }, + { + name: "delete_schedule", + description: "Permanently delete an existing schedule from this device.", + inputSchema: idJsonSchema(), + }, + ], + handlers: { + list_schedules: (_args, ctx) => ctx.scheduleService.list(), + create_schedule: (args, ctx) => { + const parsed = createArgsSchema.parse(args); + const sourceThread = ctx.identity.threadId ? ctx.getThread(ctx.identity.threadId) : null; + const agentKind = parsed.agentKind ?? sourceThread?.agentKind; + const model = parsed.model ?? sourceThread?.config.model; + if (!agentKind || !model) { + throw new Error("agentKind and model are required when the calling thread is unavailable."); + } + // Default the run's project to the calling thread's project (so a schedule + // created from inside a project runs there). Home-scope threads leave it + // null, which the coordinator resolves to the built-in Home project. + const projectId = + sourceThread && !isHomeProjectId(sourceThread.projectId) ? sourceThread.projectId : null; + const input: ScheduledTaskInput = { + name: parsed.name, + prompt: parsed.prompt, + recurrence: parsed.recurrence, + enabled: parsed.enabled, + agentKind, + ...(projectId ? { projectId } : {}), + config: { + model, + ...((parsed.effort ?? sourceThread?.config.effort) + ? { effort: parsed.effort ?? sourceThread?.config.effort } + : {}), + ...(sourceThread?.config.fast !== undefined ? { fast: sourceThread.config.fast } : {}), + }, + }; + return ctx.scheduleService.create(input); + }, + update_schedule: (args, ctx) => { + const parsed = updateArgsSchema.parse(args); + const current = requireSchedule(ctx, parsed.id); + return ctx.scheduleService.update(parsed.id, { + name: parsed.name ?? current.name, + prompt: parsed.prompt ?? current.prompt, + recurrence: parsed.recurrence ?? current.recurrence, + enabled: parsed.enabled ?? current.enabled, + agentKind: parsed.agentKind ?? current.agentKind, + config: { + model: parsed.model ?? current.config.model, + ...(parsed.effort === null + ? {} + : parsed.effort !== undefined + ? { effort: parsed.effort } + : current.config.effort + ? { effort: current.config.effort } + : {}), + ...(current.config.fast !== undefined ? { fast: current.config.fast } : {}), + }, + }); + }, + run_schedule: (args, ctx) => ctx.scheduleService.runNow(idArgsSchema.parse(args).id), + delete_schedule: (args, ctx) => { + const { id } = idArgsSchema.parse(args); + requireSchedule(ctx, id); + ctx.scheduleService.delete(id); + return { deleted: true, id }; + }, + }, +}; + +function requireSchedule(ctx: AppControlsToolContext, id: string): ScheduledTask { + const task = ctx.scheduleService.get(id); + if (!task) throw new Error("Scheduled task not found."); + return task; +} + +function idJsonSchema(): Record { + return { + type: "object", + additionalProperties: false, + required: ["id"], + properties: { id: { type: "string", format: "uuid" } }, + }; +} + +function recurrenceJsonSchema(): Record { + return { + oneOf: [ + { + type: "object", + additionalProperties: false, + required: ["kind", "minute"], + properties: { + kind: { const: "hourly" }, + minute: { type: "integer", minimum: 0, maximum: 59 }, + }, + }, + { + type: "object", + additionalProperties: false, + required: ["kind", "days", "time"], + properties: { + kind: { const: "weekly" }, + days: { + type: "array", + minItems: 1, + items: { type: "integer", minimum: 0, maximum: 6 }, + }, + time: { type: "string", pattern: "^([01]\\d|2[0-3]):[0-5]\\d$" }, + }, + }, + { + type: "object", + additionalProperties: false, + required: ["kind", "runAt"], + properties: { + kind: { const: "once" }, + runAt: { type: "string", format: "date-time" }, + }, + }, + ], + }; +} diff --git a/src/main/app-controls/mcp/tools/search.ts b/src/main/app-controls/mcp/tools/search.ts new file mode 100644 index 00000000..3f87d6d6 --- /dev/null +++ b/src/main/app-controls/mcp/tools/search.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; +import { requireProject, type AppControlsToolContext, type ToolDomain } from "./types"; +import { locationPath } from "./projects"; + +/** Hard cap on file-search results returned to the caller. */ +const FILE_RESULTS_MAX = 50; + +const searchArgsSchema = z.object({ + query: z.string().trim().min(1).max(500), + scope: z.enum(["threads", "projects", "files", "all"]).optional(), + projectId: z.string().min(1).optional(), +}); + +export const searchTools: ToolDomain = { + specs: [ + { + name: "search", + description: + "Search across the app. scope 'threads' matches thread titles; 'projects' matches project names/paths; 'files' matches file names in a project (requires projectId); 'all' (default) runs threads + projects, plus files when a projectId is given.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["query"], + properties: { + query: { type: "string", minLength: 1, maxLength: 500 }, + scope: { type: "string", enum: ["threads", "projects", "files", "all"] }, + projectId: { type: "string" }, + }, + }, + }, + ], + handlers: { + search: async (args, ctx) => { + const { query, scope, projectId } = searchArgsSchema.parse(args); + const effectiveScope = scope ?? "all"; + const needle = query.toLowerCase(); + const result: Record = { query, scope: effectiveScope }; + + if (effectiveScope === "threads" || effectiveScope === "all") { + result.threads = ctx + .getThreads() + .filter((thread) => thread.title.toLowerCase().includes(needle)) + .map((thread) => ({ + threadId: thread.id, + title: thread.title, + projectId: thread.projectId, + status: thread.status, + })); + } + + if (effectiveScope === "projects" || effectiveScope === "all") { + result.projects = ctx + .getProjects() + .filter((project) => { + const path = locationPath(project.location).toLowerCase(); + return project.name.toLowerCase().includes(needle) || path.includes(needle); + }) + .map((project) => ({ + id: project.id, + name: project.name, + path: locationPath(project.location), + })); + } + + const wantsFiles = effectiveScope === "files" || (effectiveScope === "all" && projectId); + if (effectiveScope === "files" && !projectId) { + throw new Error("A projectId is required to search files."); + } + if (wantsFiles && projectId) { + result.files = await searchFiles(ctx, projectId, query); + } + + return result; + }, + }, +}; + +async function searchFiles( + ctx: AppControlsToolContext, + projectId: string, + query: string, +): Promise> { + const project = requireProject(ctx, projectId); + const response = await ctx.supervisor.searchProjectFiles({ + projectLocation: project.location, + query, + limit: FILE_RESULTS_MAX, + }); + const truncated = response.entries.length >= FILE_RESULTS_MAX; + return { + projectId, + totalIndexed: response.totalIndexed, + count: response.entries.length, + ...(truncated ? { truncated: true, note: `Showing first ${FILE_RESULTS_MAX} matches.` } : {}), + entries: response.entries.map((entry) => ({ + path: entry.path, + name: entry.name, + type: entry.type, + })), + }; +} diff --git a/src/main/app-controls/mcp/tools/serverInfo.ts b/src/main/app-controls/mcp/tools/serverInfo.ts new file mode 100644 index 00000000..9625e38f --- /dev/null +++ b/src/main/app-controls/mcp/tools/serverInfo.ts @@ -0,0 +1,5 @@ +/** Single source for the app-controls MCP server identity (advertised + reported). */ +export const APP_CONTROLS_MCP_SERVER_INFO = { + name: "poracode", + version: "1.0.0", +} as const; diff --git a/src/main/app-controls/mcp/tools/settings.ts b/src/main/app-controls/mcp/tools/settings.ts new file mode 100644 index 00000000..15d9844e --- /dev/null +++ b/src/main/app-controls/mcp/tools/settings.ts @@ -0,0 +1,272 @@ +import { z } from "zod"; +import type { McpServer, McpTransport } from "@/shared/contracts"; +import { normalizeSharedSettings, type SharedSettings } from "@/shared/settings"; +import { mergeManagedSharedSettings } from "../../../sharedSettingsFile"; +import type { ToolDomain } from "./types"; + +/** Placeholder substituted for every secret-bearing value returned by get_settings. */ +export const REDACTED_VALUE = "«redacted»"; + +/** + * Settings keys that carry (or gate) secrets and must never be written through + * this agent-facing tool. `agentInstances` holds Claude-profile environments + * with sealed API keys/tokens; profile secrets are edited only via the + * dedicated encrypting path. `mcpServers` transport values carry secrets and a + * deep-merged array write would clobber other servers' real (unredacted) + * values — it is edited only through add/update/remove_mcp_server. The + * remaining keys are supervisor-managed. + */ +const PROTECTED_SETTINGS_KEYS: ReadonlySet = new Set([ + "agentInstances", + "acpRegistryInstalledAgents", + "agentHookSupport", + "mcpServers", +]); + +const getArgsSchema = z.object({ section: z.string().min(1).optional() }); +const updateArgsSchema = z.object({ + patch: z.record(z.string(), z.unknown()), +}); + +export const settingsTools: ToolDomain = { + specs: [ + { + name: "get_settings", + description: + "Read the app's shared settings (whole object, or a single top-level section). Secret-bearing values are redacted: agent profile environment variables return only their name and sensitive flag, and MCP server transport headers/env return their key names with values masked — never the values themselves.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { section: { type: "string" } }, + }, + }, + { + name: "update_settings", + description: + "Deep-merge a partial patch into the app's shared settings. Changes apply immediately app-wide. Cannot modify secret-bearing or supervisor-managed fields (agent profiles/instances, installed ACP agents, hook support). MCP servers are managed with the dedicated add_mcp_server/update_mcp_server/remove_mcp_server tools, not this one.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["patch"], + properties: { patch: { type: "object" } }, + }, + }, + ], + handlers: { + get_settings: (args, ctx) => { + const { section } = getArgsSchema.parse(args); + const redacted = redactSharedSettings(ctx.settings.read()); + if (section === undefined) return { settings: redacted }; + if (!Object.prototype.hasOwnProperty.call(redacted, section)) { + throw new Error( + `Unknown settings section: ${section}. Valid sections: ${Object.keys(redacted) + .sort() + .join(", ")}.`, + ); + } + return { section, value: (redacted as Record)[section] }; + }, + update_settings: (args, ctx) => { + const { patch } = updateArgsSchema.parse(args); + const rejected = Object.keys(patch).filter((key) => PROTECTED_SETTINGS_KEYS.has(key)); + if (rejected.length > 0) { + const hint = rejected.includes("mcpServers") + ? " Manage MCP servers with add_mcp_server, update_mcp_server, and remove_mcp_server." + : ""; + throw new Error( + `These settings are managed elsewhere and cannot be changed with this tool: ${rejected.join( + ", ", + )}.${hint}`, + ); + } + const onDisk = ctx.settings.read(); + const candidate = deepMerge(onDisk as Record, patch); + const normalized = normalizeSharedSettings(candidate); + // Defense in depth: re-pin supervisor-managed fields and encrypted + // profile environments regardless of what the patch attempted. + const merged = mergeManagedSharedSettings(onDisk, normalized); + ctx.settings.write(merged); + return { updated: true, appliedKeys: Object.keys(patch).sort() }; + }, + }, +}; + +/** Whether a value is a plain object (mergeable), as opposed to an array/primitive. */ +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Recursively merge `patch` into a shallow copy of `base`; arrays replace. */ +function deepMerge( + base: Record, + patch: Record, +): Record { + const result: Record = { ...base }; + for (const [key, value] of Object.entries(patch)) { + const current = result[key]; + result[key] = + isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value; + } + return result; +} + +/** + * Strip secret values from settings before returning them. + * + * Two value-bearing credential surfaces exist in {@link SharedSettings}: + * - `agentInstances[].environment`: Claude-profile API keys/tokens (sealed or + * plaintext), replaced by a name+sensitive-flag summary. + * - `mcpServers[].transport`: HTTP/SSE `headers` and stdio `env` frequently + * carry bearer tokens / API keys. Their key names are preserved (so an agent + * can see what is configured) but every value is masked. + * + * An agent can therefore see what is configured without ever reading a secret. + */ +export function redactSharedSettings(settings: SharedSettings): Record { + const agentInstances = Object.fromEntries( + Object.entries(settings.agentInstances).map(([id, instance]) => { + if (!instance.environment) return [id, instance]; + const environment = Object.fromEntries( + Object.entries(instance.environment).map(([name, variable]) => [ + name, + { sensitive: variable.sensitive === true }, + ]), + ); + return [id, { ...instance, environment }]; + }), + ); + const mcpServers = settings.mcpServers.map(redactMcpServer); + return { ...settings, agentInstances, mcpServers }; +} + +/** Mask the credential-bearing values of one MCP server's transport. */ +export function redactMcpServer(server: McpServer): McpServer { + return { ...server, transport: redactMcpTransport(server.transport) }; +} + +/** Replace every transport header/env value with a masked marker, keeping key names. */ +function redactMcpTransport(transport: McpTransport): McpTransport { + if (transport.type === "stdio") { + return { + ...transport, + args: transport.args.map(redactSecretArg), + env: maskValues(transport.env), + }; + } + return { + ...transport, + url: redactUrlQuery(transport.url), + headers: maskValues(transport.headers), + }; +} + +const SECRET_ARG_PATTERN = /^(--?[^=]*(?:key|token|secret|password|auth|credential)[^=]*)=.+$/i; + +/** Mask the value of secret-shaped `--flag=value` args, keeping the flag name. */ +function redactSecretArg(arg: string): string { + const match = SECRET_ARG_PATTERN.exec(arg); + return match ? `${match[1]}=${REDACTED_VALUE}` : arg; +} + +/** Mask every query-string value in a URL (tokens are commonly passed there), keeping keys. */ +function redactUrlQuery(url: string): string { + const queryStart = url.indexOf("?"); + if (queryStart === -1) return url; + const query = url + .slice(queryStart + 1) + .split("&") + .map((pair) => { + const eq = pair.indexOf("="); + return eq === -1 ? pair : `${pair.slice(0, eq)}=${REDACTED_VALUE}`; + }) + .join("&"); + return `${url.slice(0, queryStart)}?${query}`; +} + +/** Map every value of a string record to the redaction marker, preserving keys. */ +function maskValues(record: Record): Record { + return Object.fromEntries(Object.keys(record).map((key) => [key, REDACTED_VALUE])); +} + +/** + * Inverse of {@link redactMcpTransport}: wherever an incoming transport still + * carries the {@link REDACTED_VALUE} marker (because an agent echoed back a + * redacted read), substitute the real value stored on the existing transport. + * Only matching same-type transports can restore values; a transport-type + * change keeps the incoming (already-validated) values verbatim. + */ +export function restoreRedactedTransport(next: McpTransport, existing: McpTransport): McpTransport { + if (next.type === "stdio") { + if (existing.type !== "stdio") return next; + return { + ...next, + args: next.args.map((arg) => restoreRedactedArg(arg, existing.args)), + env: restoreRedactedRecord(next.env, existing.env), + }; + } + if (existing.type === "stdio") return next; + return { + ...next, + url: restoreRedactedUrl(next.url, existing.url), + headers: restoreRedactedRecord(next.headers, existing.headers), + }; +} + +/** Restore any redaction-marked values in a string record from the stored record. */ +function restoreRedactedRecord( + next: Record, + existing: Record, +): Record { + return Object.fromEntries( + Object.entries(next).map(([key, value]) => [ + key, + value === REDACTED_VALUE && Object.prototype.hasOwnProperty.call(existing, key) + ? existing[key]! + : value, + ]), + ); +} + +/** Restore a `--flag=«redacted»` arg from the stored arg carrying the same flag. */ +function restoreRedactedArg(arg: string, existingArgs: readonly string[]): string { + const marker = `=${REDACTED_VALUE}`; + if (!arg.endsWith(marker)) return arg; + const prefix = arg.slice(0, arg.length - REDACTED_VALUE.length); // includes trailing "=" + return existingArgs.find((candidate) => candidate.startsWith(prefix)) ?? arg; +} + +/** Restore redaction-marked URL query values from the stored URL's matching keys. */ +function restoreRedactedUrl(next: string, existing: string): string { + const queryStart = next.indexOf("?"); + if (queryStart === -1) return next; + const existingQuery = parseQuery(existing); + const query = next + .slice(queryStart + 1) + .split("&") + .map((pair) => { + const eq = pair.indexOf("="); + if (eq === -1) return pair; + const key = pair.slice(0, eq); + const value = pair.slice(eq + 1); + if (value === REDACTED_VALUE && Object.prototype.hasOwnProperty.call(existingQuery, key)) { + return `${key}=${existingQuery[key]}`; + } + return pair; + }) + .join("&"); + return `${next.slice(0, queryStart)}?${query}`; +} + +/** Parse a URL's query string into a key→value record (first value wins). */ +function parseQuery(url: string): Record { + const queryStart = url.indexOf("?"); + if (queryStart === -1) return {}; + const out: Record = {}; + for (const pair of url.slice(queryStart + 1).split("&")) { + const eq = pair.indexOf("="); + if (eq === -1) continue; + const key = pair.slice(0, eq); + if (!Object.prototype.hasOwnProperty.call(out, key)) out[key] = pair.slice(eq + 1); + } + return out; +} diff --git a/src/main/app-controls/mcp/tools/skills.ts b/src/main/app-controls/mcp/tools/skills.ts new file mode 100644 index 00000000..92532ab4 --- /dev/null +++ b/src/main/app-controls/mcp/tools/skills.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; +import type { ProjectLocation, ScanSkillsPayload, SkillEntry } from "@/shared/contracts"; +import { requireProject, type AppControlsToolContext, type ToolDomain } from "./types"; + +const listArgsSchema = z.object({ projectId: z.string().min(1).optional() }); +const setEnabledArgsSchema = z.object({ + absolutePath: z.string().min(1), + enabled: z.boolean(), + projectId: z.string().min(1).optional(), +}); + +export const skillTools: ToolDomain = { + specs: [ + { + name: "list_skills", + description: + "List installed agent skills. Returns global skills always; pass projectId to also include that project's project-scoped skills. Read-only.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { projectId: { type: "string" } }, + }, + }, + { + name: "set_skill_enabled", + description: + "Enable or disable one skill by its absolutePath (from list_skills). Pass projectId when the skill is project-scoped so it is toggled in that project's scope.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["absolutePath", "enabled"], + properties: { + absolutePath: { type: "string", minLength: 1 }, + enabled: { type: "boolean" }, + projectId: { type: "string" }, + }, + }, + }, + ], + handlers: { + list_skills: async (args, ctx) => { + const { projectId } = listArgsSchema.parse(args); + const scope = projectId ? projectScope(ctx, projectId) : {}; + const result = await ctx.supervisor.scanSkills(scope); + const skills = result.skills.map(summarizeSkill); + return { + ...(projectId ? { projectId } : {}), + count: skills.length, + global: skills.filter((skill) => skill.scope === "global"), + project: skills.filter((skill) => skill.scope === "project"), + effectiveSkillIds: result.effectiveSkillIds, + }; + }, + set_skill_enabled: async (args, ctx) => { + const { absolutePath, enabled, projectId } = setEnabledArgsSchema.parse(args); + const scope = projectId ? projectScope(ctx, projectId) : {}; + await ctx.supervisor.setSkillEnabled({ absolutePath, enabled, ...scope }); + return { absolutePath, enabled }; + }, + }, +}; + +/** Build the project-scoped `scanSkills`/`setSkillEnabled` target for a projectId. */ +function projectScope( + ctx: AppControlsToolContext, + projectId: string, +): Pick { + const location: ProjectLocation = requireProject(ctx, projectId).location; + return { + projectLocation: location, + ...(location.kind === "wsl" ? { wslDistro: location.distro } : {}), + }; +} + +/** A compact, read-friendly view of one skill entry. */ +function summarizeSkill(skill: SkillEntry): { + id: string; + name: string; + description: string; + scope: SkillEntry["scope"]; + enabled: boolean; + valid: boolean; + provider: string; + absolutePath: string; +} { + return { + id: skill.id, + name: skill.name, + description: skill.description, + scope: skill.scope, + enabled: skill.enabled, + valid: skill.valid, + provider: skill.providerLabel, + absolutePath: skill.absolutePath, + }; +} diff --git a/src/main/app-controls/mcp/tools/threads.ts b/src/main/app-controls/mcp/tools/threads.ts new file mode 100644 index 00000000..b44c2048 --- /dev/null +++ b/src/main/app-controls/mcp/tools/threads.ts @@ -0,0 +1,683 @@ +import { z } from "zod"; +import type { + AgentKind, + Project, + RemoteThreadCommand, + StartThreadPayload, + Thread, + ThreadRuntimeSnapshot, + ThreadStatus, +} from "@/shared/contracts"; +import { + agentKindSchema, + DEFAULT_TERMINAL_SIZE, + resolveMcpLaunchSnapshot, +} from "@/shared/contracts"; +import { buildWorktreeLocation } from "@/shared/worktree"; +import { dbGetThreadRuntimeItemsPage } from "../../../db"; +import { + assertNotSelf, + projectIdProp, + requireThread, + threadIdProp, + type AppControlsToolContext, + type ToolDomain, +} from "./types"; + +/** Statuses `wait_for_thread` treats as settled (turn finished or needs the caller). */ +const SETTLED_STATUSES: ReadonlySet = new Set([ + "idle", + "finished", + "needs_approval", + "needs_reply", + "error", + "inactive", +]); + +/** Default / hard-capped `wait_for_thread` budget (seconds). */ +const WAIT_DEFAULT_SECONDS = 600; +const WAIT_MAX_SECONDS = 1_800; + +/** Default / capped transcript page size for `read_thread`. */ +const READ_DEFAULT_LIMIT = 30; +const READ_MAX_LIMIT = 100; +/** Per-item text cap so a transcript can't blow up the caller's context. */ +const ITEM_TEXT_MAX_CHARS = 2_000; +/** Trailing slice of terminal scrollback returned by `read_terminal`. */ +const TERMINAL_SCROLLBACK_MAX_CHARS = 50_000; +/** Hard cap on how many turns `rollback_thread` will discard in one call. */ +const ROLLBACK_MAX_TURNS = 20; + +const listArgsSchema = z.object({ + projectId: z.string().min(1).optional(), + status: z.string().min(1).optional(), +}); +const threadIdArgsSchema = z.object({ threadId: z.string().min(1) }); +const readArgsSchema = z.object({ + threadId: z.string().min(1), + limit: z.number().int().min(1).max(READ_MAX_LIMIT).optional(), + before: z.number().int().nonnegative().optional(), +}); +const createArgsSchema = z.object({ + projectId: z.string().min(1), + prompt: z.string().trim().min(1).max(50_000), + agentKind: agentKindSchema.optional(), + model: z.string().min(1).optional(), + effort: z.string().min(1).optional(), + title: z.string().trim().min(1).max(200).optional(), + worktree: z + .object({ enabled: z.boolean(), branch: z.string().trim().min(1).max(255).optional() }) + .optional(), +}); +const sendArgsSchema = z.object({ + threadId: z.string().min(1), + message: z.string().trim().min(1).max(50_000), + interruptFirst: z.boolean().optional(), +}); +const waitArgsSchema = z.object({ + threadIds: z.array(z.string().min(1)).min(1).max(8), + timeoutSeconds: z.number().int().min(1).max(WAIT_MAX_SECONDS).optional(), +}); +const steerArgsSchema = z.object({ + threadId: z.string().min(1), + prompt: z.string().trim().min(1).max(50_000).optional(), + clear: z.boolean().optional(), +}); +const stageArgsSchema = z.object({ + threadId: z.string().min(1), + prompt: z.string().trim().min(1).max(50_000), +}); +const rollbackArgsSchema = z.object({ + threadId: z.string().min(1), + numTurns: z.number().int().min(1).max(ROLLBACK_MAX_TURNS), +}); +const updateArgsSchema = z.object({ + threadId: z.string().min(1), + rename: z.string().trim().min(1).max(200).optional(), + group: z.string().trim().min(1).max(200).optional(), + done: z.boolean().optional(), + starred: z.boolean().optional(), + archived: z.boolean().optional(), + acknowledge: z.boolean().optional(), +}); + +export const threadTools: ToolDomain = { + specs: [ + { + name: "list_threads", + description: + "List all app threads (across projects) with their live status, attention, project, agent, model, and worktree. Optionally filter by projectId or status.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + projectId: projectIdProp, + status: { type: "string" }, + }, + }, + }, + { + name: "get_thread", + description: + "Full detail for one thread: persisted row, live runtime status/config, pending steer (staged message), and turn timing.", + inputSchema: threadIdJsonSchema(), + }, + { + name: "read_thread", + description: + "Read a thread's recorded transcript (most recent first-page by default). Works even when the thread's session is inactive; returns a note when nothing has been recorded yet.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["threadId"], + properties: { + threadId: threadIdProp, + limit: { type: "integer", minimum: 1, maximum: READ_MAX_LIMIT }, + before: { type: "integer", minimum: 0 }, + }, + }, + }, + { + name: "create_thread", + description: + "Create and launch a new app thread in a project (visible in the user's sidebar). The calling thread's agent and model are used unless overridden. Optionally run it in a fresh git worktree.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["projectId", "prompt"], + properties: { + projectId: projectIdProp, + prompt: { type: "string", minLength: 1, maxLength: 50000 }, + agentKind: { type: "string", minLength: 1 }, + model: { type: "string", minLength: 1 }, + effort: { type: "string", minLength: 1 }, + title: { type: "string", minLength: 1, maxLength: 200 }, + worktree: { + type: "object", + additionalProperties: false, + required: ["enabled"], + properties: { + enabled: { type: "boolean" }, + branch: { type: "string", minLength: 1, maxLength: 255 }, + }, + }, + }, + }, + }, + { + name: "send_to_thread", + description: + "Send a message to another thread. If the thread has no live session (it was stopped, unloaded, or the app restarted), it is resumed from its persisted config and session reference and the message is delivered as the first input of the resumed session. A thread with no resumable session cannot receive messages — use create_thread instead. Set interruptFirst to interrupt a working turn before sending (ignored when resuming, since nothing is running).", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["threadId", "message"], + properties: { + threadId: threadIdProp, + message: { type: "string", minLength: 1, maxLength: 50000 }, + interruptFirst: { type: "boolean" }, + }, + }, + }, + { + name: "interrupt_thread", + description: + "Interrupt another thread's current turn. The thread stays open and addressable.", + inputSchema: threadIdJsonSchema(), + }, + { + name: "stop_thread", + description: + "Stop another thread's runtime session (frees resources). The thread row and its transcript remain in the app.", + inputSchema: threadIdJsonSchema(), + }, + { + name: "wait_for_thread", + description: + "Block until one of the listed threads leaves its working state or needs attention (idle, finished, needs_approval, needs_reply, error, or inactive), or the timeout elapses. Event-driven; returns each thread's final status.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["threadIds"], + properties: { + threadIds: { type: "array", minItems: 1, maxItems: 8, items: { type: "string" } }, + timeoutSeconds: { type: "integer", minimum: 1, maximum: WAIT_MAX_SECONDS }, + }, + }, + }, + { + name: "update_thread", + description: + "Update a thread's metadata: rename, assign a sidebar group, mark done/not-done, star/unstar, archive/unarchive, or acknowledge a finished thread.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["threadId"], + properties: { + threadId: threadIdProp, + rename: { type: "string", minLength: 1, maxLength: 200 }, + group: { type: "string", minLength: 1, maxLength: 200 }, + done: { type: "boolean" }, + starred: { type: "boolean" }, + archived: { type: "boolean" }, + acknowledge: { type: "boolean" }, + }, + }, + }, + { + name: "open_thread", + description: "Open and focus a thread in the Poracode UI for the user.", + inputSchema: threadIdJsonSchema(), + }, + { + name: "read_terminal", + description: + "Read the PTY scrollback of a terminal-presentation thread (the raw terminal output the user sees). Returns a note when the thread has no live terminal session (e.g. a structured/GUI thread, or its session isn't running). Only the last 50000 characters are returned; the response flags truncation.", + inputSchema: threadIdJsonSchema(), + }, + { + name: "steer_thread", + description: + "Queue guidance for another thread that is injected when its running agent next yields, without interrupting the current turn. Reuses the thread's persisted config. Pass a prompt to queue guidance, or clear:true to clear the queued steer. You cannot steer your own thread.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["threadId"], + properties: { + threadId: threadIdProp, + prompt: { type: "string", minLength: 1, maxLength: 50000 }, + clear: { type: "boolean" }, + }, + }, + }, + { + name: "stage_thread_input", + description: + "Type text into a thread's composer WITHOUT submitting it, so the user can review and send it themselves. Only supported for terminal-presentation threads (structured/GUI threads and not-yet-ready sessions are rejected); newlines are collapsed to a single line so the text can't accidentally submit.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["threadId", "prompt"], + properties: { + threadId: threadIdProp, + prompt: { type: "string", minLength: 1, maxLength: 50000 }, + }, + }, + }, + { + name: "rollback_thread", + description: + "DESTRUCTIVE: permanently discard the last N turns of another thread's conversation (the provider's checkpoint rollback). This cannot be undone — always confirm the exact thread and turn count with the user before calling. Fails while the agent is working or when the provider does not support rollback. You cannot roll back your own thread. numTurns must be between 1 and 20.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["threadId", "numTurns"], + properties: { + threadId: threadIdProp, + numTurns: { type: "integer", minimum: 1, maximum: ROLLBACK_MAX_TURNS }, + }, + }, + }, + ], + handlers: { + list_threads: async (args, ctx) => { + const { projectId, status } = listArgsSchema.parse(args); + const projectsById = new Map(ctx.getProjects().map((project) => [project.id, project])); + const snapshots = await snapshotMap(ctx); + const threads = ctx + .getThreads() + .filter((thread) => (projectId ? thread.projectId === projectId : true)) + .map((thread) => + threadView(thread, projectsById.get(thread.projectId), snapshots.get(thread.id)), + ) + .filter((view) => (status ? view.status === status : true)); + return { count: threads.length, threads }; + }, + get_thread: async (args, ctx) => { + const { threadId } = threadIdArgsSchema.parse(args); + const thread = requireThread(ctx, threadId); + const project = ctx.getProject(thread.projectId) ?? undefined; + const snapshots = await snapshotMap(ctx); + const pendingSteer = ctx.threadStates.getPendingSteer(threadId); + return { + ...threadView(thread, project, snapshots.get(threadId)), + pendingSteer: pendingSteer + ? { + id: pendingSteer.id, + prompt: truncate(pendingSteer.prompt, ITEM_TEXT_MAX_CHARS), + stagedAt: new Date(pendingSteer.stagedAt).toISOString(), + } + : null, + }; + }, + read_thread: (args, ctx) => { + const { threadId, limit, before } = readArgsSchema.parse(args); + requireThread(ctx, threadId); + const page = dbGetThreadRuntimeItemsPage(threadId, before, limit ?? READ_DEFAULT_LIMIT); + if (page.items.length === 0) { + return { + threadId, + messageCount: 0, + note: "No transcript has been recorded for this thread yet.", + }; + } + return { + threadId, + messageCount: page.items.length, + ...(page.nextCursor != null ? { nextCursor: page.nextCursor } : {}), + items: page.items.map((item) => ({ + type: item.type, + state: item.state, + ...(itemText(item) ? { text: truncate(itemText(item), ITEM_TEXT_MAX_CHARS) } : {}), + })), + }; + }, + create_thread: async (args, ctx) => { + const parsed = createArgsSchema.parse(args); + const sourceThread = ctx.identity.threadId ? ctx.getThread(ctx.identity.threadId) : null; + const agentKind: AgentKind | undefined = parsed.agentKind ?? sourceThread?.agentKind; + const model = parsed.model ?? sourceThread?.config.model; + if (!agentKind || !model) { + throw new Error( + "agentKind and model are required when they can't be inherited from the calling thread.", + ); + } + const effort = parsed.effort ?? sourceThread?.config.effort; + return ctx.createThread({ + projectId: parsed.projectId, + prompt: parsed.prompt, + agentKind, + model, + ...(effort ? { effort } : {}), + ...(sourceThread?.config.fast !== undefined ? { fast: sourceThread.config.fast } : {}), + ...(parsed.title ? { title: parsed.title } : {}), + ...(parsed.worktree?.enabled + ? { worktree: parsed.worktree.branch ? { branch: parsed.worktree.branch } : {} } + : {}), + }); + }, + send_to_thread: async (args, ctx) => { + const { threadId, message, interruptFirst } = sendArgsSchema.parse(args); + const thread = requireThread(ctx, threadId); + if (interruptFirst) assertNotSelf(ctx, threadId, "interrupt"); + // Fast path: the thread has a live runtime session, so deliver directly. + // If the session is gone (stopped/unloaded/supervisor restart) the + // supervisor throws `Unknown thread session`; fall through to resume. + try { + if (interruptFirst) await ctx.supervisor.interruptThread({ threadId }); + await ctx.supervisor.sendThreadInput({ threadId, prompt: message, config: thread.config }); + return { threadId, delivered: true, interruptedFirst: interruptFirst === true }; + } catch (error) { + if (!isUnknownSessionError(error)) throw error; + } + // No live session — resume the thread the same way the app revives an + // inactive thread (startThread with the persisted config + sessionRef), + // delivering the message as the resumed session's first input. + if (!thread.sessionRef && !thread.canResumeWithConfig) { + throw new Error( + `Thread ${threadId} has no live or resumable session, so it cannot receive a message. ` + + "Use create_thread to start a new thread instead.", + ); + } + await ctx.supervisor.startThread(buildResumeStartPayload(ctx, thread, message)); + return { threadId, delivered: true, resumed: true, interruptedFirst: false }; + }, + interrupt_thread: async (args, ctx) => { + const { threadId } = threadIdArgsSchema.parse(args); + requireThread(ctx, threadId); + assertNotSelf(ctx, threadId, "interrupt"); + await ctx.supervisor.interruptThread({ threadId }); + return { threadId, interrupted: true }; + }, + stop_thread: async (args, ctx) => { + const { threadId } = threadIdArgsSchema.parse(args); + requireThread(ctx, threadId); + assertNotSelf(ctx, threadId, "stop"); + await ctx.supervisor.closeThread({ threadId }); + return { threadId, stopped: true }; + }, + wait_for_thread: async (args, ctx) => { + const { threadIds, timeoutSeconds } = waitArgsSchema.parse(args); + for (const threadId of threadIds) { + requireThread(ctx, threadId); + assertNotSelf(ctx, threadId, "wait on"); + } + const timeoutMs = (timeoutSeconds ?? WAIT_DEFAULT_SECONDS) * 1_000; + const poll = () => { + const snap = waitSnapshot(ctx, threadIds); + return snap.settled.length > 0 ? snap : undefined; + }; + const settled = await ctx.threadStates.waitUntil(threadIds, timeoutMs, poll, 1_000); + if (settled) return { timedOut: false, ...settled }; + return { timedOut: true, ...waitSnapshot(ctx, threadIds) }; + }, + update_thread: (args, ctx) => { + const parsed = updateArgsSchema.parse(args); + requireThread(ctx, parsed.threadId); + const applied: string[] = []; + // Persist every mutation before mirroring it to the renderer. The + // renderer periodically writes a complete dbSyncAll snapshot, so a stale + // renderer must not be the only owner of an MCP-issued update. + const rowMutations: Array<(thread: Thread) => Thread> = []; + let deliveredToRenderer = true; + const threadId = parsed.threadId; + const stamp = (): string => new Date().toISOString(); + // Apply one optional metadata field: mirror it to the renderer and queue + // the matching row mutation. Skipped when the field was not provided. + const applyField = ( + value: T | undefined, + label: string, + build: (value: T) => { command: RemoteThreadCommand; mutate: (thread: Thread) => Thread }, + ): void => { + if (value === undefined) return; + const { command, mutate } = build(value); + if (!ctx.emitRemoteThreadCommand(command)) deliveredToRenderer = false; + rowMutations.push(mutate); + applied.push(label); + }; + + // Ordered so `applied` preserves rename→group→done→starred→archived. + applyField(parsed.rename, "rename", (title) => ({ + command: { kind: "rename", threadId, title }, + mutate: (thread) => ({ ...thread, title, updatedAt: stamp() }), + })); + applyField(parsed.group, "group", (group) => ({ + command: { kind: "set-group", threadId, groupId: group, groupName: group }, + mutate: (thread) => ({ ...thread, groupId: group, groupName: group }), + })); + applyField(parsed.done, "done", (done) => ({ + command: { kind: "set-done", threadId, done }, + mutate: (thread) => + done + ? { ...thread, done: true, doneAt: stamp(), starred: false } + : { ...thread, done: false, doneAt: undefined }, + })); + applyField(parsed.starred, "starred", (starred) => ({ + command: { kind: "set-starred", threadId, starred }, + mutate: (thread) => ({ ...thread, starred }), + })); + applyField(parsed.archived, "archived", (archived) => ({ + command: { kind: archived ? "archive" : "unarchive", threadId }, + mutate: (thread) => ({ ...thread, archived, updatedAt: stamp() }), + })); + if (parsed.acknowledge) { + const command: RemoteThreadCommand = { kind: "acknowledge", threadId }; + if (!ctx.emitRemoteThreadCommand(command)) deliveredToRenderer = false; + // Mirror the renderer/remote-server semantics: acknowledging only clears + // a finished thread's completion marker (status finished → idle). + rowMutations.push((thread) => + thread.status === "finished" ? { ...thread, status: "idle" } : thread, + ); + applied.push("acknowledge"); + } + if (applied.length === 0) { + throw new Error("Provide at least one field to update."); + } + ctx.updateThreadRow(parsed.threadId, (thread) => + rowMutations.reduce((next, mutate) => mutate(next), thread), + ); + if (deliveredToRenderer) return { threadId: parsed.threadId, applied }; + return { + threadId: parsed.threadId, + applied, + note: "No Poracode UI is connected; the update was applied directly to the stored thread row.", + }; + }, + open_thread: (args, ctx) => { + const { threadId } = threadIdArgsSchema.parse(args); + requireThread(ctx, threadId); + if (ctx.openThreadInUi(threadId)) return { threadId, opened: true }; + return { + threadId, + opened: false, + note: "No Poracode UI is connected, so the thread could not be opened.", + }; + }, + read_terminal: async (args, ctx) => { + const { threadId } = threadIdArgsSchema.parse(args); + requireThread(ctx, threadId); + const scrollback = await ctx.supervisor.readTerminalScrollback({ threadId }); + if (!scrollback) { + return { + threadId, + note: "This thread has no live terminal session (it is a structured/GUI thread, or its terminal session is not running), so there is no scrollback to read.", + }; + } + const truncated = scrollback.length > TERMINAL_SCROLLBACK_MAX_CHARS; + const text = truncated ? scrollback.slice(-TERMINAL_SCROLLBACK_MAX_CHARS) : scrollback; + return { + threadId, + length: text.length, + ...(truncated + ? { + truncated: true, + note: `Showing the last ${TERMINAL_SCROLLBACK_MAX_CHARS} characters of the scrollback.`, + } + : {}), + text, + }; + }, + steer_thread: async (args, ctx) => { + const { threadId, prompt, clear } = steerArgsSchema.parse(args); + const thread = requireThread(ctx, threadId); + assertNotSelf(ctx, threadId, "steer"); + if (clear) { + await ctx.supervisor.clearPendingSteer({ threadId }); + return { threadId, cleared: true }; + } + if (!prompt) { + throw new Error("Provide a prompt to queue, or set clear:true to clear the pending steer."); + } + await ctx.supervisor.setPendingSteer({ threadId, prompt, config: thread.config }); + return { threadId, steered: true }; + }, + stage_thread_input: async (args, ctx) => { + const { threadId, prompt } = stageArgsSchema.parse(args); + requireThread(ctx, threadId); + await ctx.supervisor.stageThreadInput({ threadId, prompt }); + return { + threadId, + staged: true, + note: "Text was typed into the thread's composer for the user to review and submit.", + }; + }, + rollback_thread: async (args, ctx) => { + const { threadId, numTurns } = rollbackArgsSchema.parse(args); + const thread = requireThread(ctx, threadId); + assertNotSelf(ctx, threadId, "roll back"); + await ctx.supervisor.rollbackThreadConversation({ + threadId, + numTurns, + config: thread.config, + }); + return { threadId, rolledBack: true, numTurns }; + }, + }, +}; + +/** True when the supervisor rejected a call because the thread has no live session. */ +function isUnknownSessionError(error: unknown): boolean { + return error instanceof Error && /unknown thread session/i.test(error.message); +} + +/** + * Build the `startThread` payload that resumes an inactive thread, mirroring the + * app's own resume path (`performInitialThreadLaunch` / `createAppThread`): the + * persisted config + sessionRef are reused and the message becomes the resumed + * session's first prompt. The MCP launch snapshot is re-resolved from current + * settings + the project's overrides, exactly like a fresh launch. + */ +function buildResumeStartPayload( + ctx: AppControlsToolContext, + thread: Thread, + prompt: string, +): StartThreadPayload { + const project = ctx.getProject(thread.projectId); + if (!project) { + throw new Error( + `Cannot resume thread ${thread.id}: its project ${thread.projectId} no longer exists.`, + ); + } + const projectLocation = thread.worktreePath + ? buildWorktreeLocation(project.location, thread.worktreePath) + : project.location; + return { + threadId: thread.id, + projectLocation, + agentKind: thread.agentKind, + ...(thread.agentInstanceId ? { agentInstanceId: thread.agentInstanceId } : {}), + config: thread.config, + prompt, + initialSize: DEFAULT_TERMINAL_SIZE, + ...(thread.sessionRef ? { sessionRef: thread.sessionRef } : {}), + ...(thread.presentationMode ? { presentationMode: thread.presentationMode } : {}), + ...resolveMcpLaunchSnapshot(ctx.settings.read(), project.mcpServers ?? []), + }; +} + +/** Fetch the live runtime snapshots and index them by thread id. */ +async function snapshotMap( + ctx: AppControlsToolContext, +): Promise> { + try { + const snapshots = await ctx.supervisor.getThreadSnapshots(); + return new Map(snapshots.map((snapshot) => [snapshot.threadId, snapshot])); + } catch { + // A supervisor round-trip failure degrades to DB-only status. + return new Map(); + } +} + +/** Merge a persisted thread row with its live runtime snapshot into a flat view. */ +function threadView( + thread: Thread, + project: Project | undefined, + snapshot: ThreadRuntimeSnapshot | undefined, +) { + const status = snapshot?.status ?? thread.status; + const attention = snapshot?.attention ?? thread.attention; + return { + threadId: thread.id, + title: thread.title, + projectId: thread.projectId, + ...(project ? { projectName: project.name } : {}), + agentKind: thread.agentKind, + model: thread.config.model, + ...(thread.config.effort ? { effort: thread.config.effort } : {}), + status, + attention, + presentationMode: thread.presentationMode ?? "terminal", + archived: thread.archived, + done: thread.done, + starred: thread.starred, + ...(thread.groupName ? { group: thread.groupName } : {}), + ...(thread.worktreePath ? { worktreePath: thread.worktreePath } : {}), + ...(thread.worktreeBranch ? { worktreeBranch: thread.worktreeBranch } : {}), + ...((snapshot?.errorMessage ?? thread.errorMessage) + ? { errorMessage: snapshot?.errorMessage ?? thread.errorMessage } + : {}), + ...(thread.activeTurnStartedAt ? { activeTurnStartedAt: thread.activeTurnStartedAt } : {}), + ...(thread.lastTurnStartedAt ? { lastTurnStartedAt: thread.lastTurnStartedAt } : {}), + ...(thread.lastTurnEndedAt ? { lastTurnEndedAt: thread.lastTurnEndedAt } : {}), + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + }; +} + +/** Current status/settled snapshot for a set of thread ids (used by wait_for_thread). */ +function waitSnapshot( + ctx: AppControlsToolContext, + threadIds: string[], +): { statuses: Record; settled: string[] } { + const statuses: Record = {}; + const settled: string[] = []; + for (const threadId of threadIds) { + // The live cache reflects the freshest `thread-state`; DB is the fallback + // for threads that have emitted nothing this session. + const live = ctx.threadStates.getLiveState(threadId); + const thread = ctx.getThread(threadId); + const status = live?.status ?? thread?.status ?? "inactive"; + const attention = live?.attention ?? thread?.attention ?? "none"; + statuses[threadId] = { status, attention }; + if (SETTLED_STATUSES.has(status)) settled.push(threadId); + } + return { statuses, settled }; +} + +/** Best-effort plain-text projection of a persisted runtime item's streams. */ +function itemText(item: { streams: Record }): string { + return Object.values(item.streams).join("").trim(); +} + +function truncate(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max)}…` : text; +} + +function threadIdJsonSchema(): Record { + return { + type: "object", + additionalProperties: false, + required: ["threadId"], + properties: { threadId: threadIdProp }, + }; +} diff --git a/src/main/app-controls/mcp/tools/types.ts b/src/main/app-controls/mcp/tools/types.ts new file mode 100644 index 00000000..f7da5e40 --- /dev/null +++ b/src/main/app-controls/mcp/tools/types.ts @@ -0,0 +1,429 @@ +import type { McpThreadIdentity } from "@/shared/browserMcpThread"; +import { normalizeWorktreePathForComparison, resolveProjectLocation } from "@/shared/worktree"; +import type { + AgentStatusesResponse, + ClearPendingSteerPayload, + CloseThreadPayload, + GetAgentStatusesPayload, + GetGitBranchesPayload, + GetGitDiffBatchPayload, + GetGitDiffPayload, + GetGitStatusPayload, + GhCheckAvailableResult, + GhClosePrPayload, + GhCreatePrPayload, + GhGetPrChecksPayload, + GhGetPrChecksResult, + GhGetPrDetailsPayload, + GhGetPrDetailsResult, + GhGetPrDiffPayload, + GhGetPrDiffResult, + GhGetPrFilesPayload, + GhGetPrFilesResult, + GhListPullRequestsPayload, + GhListPullRequestsResult, + GhMarkPrReadyPayload, + GhMergePrPayload, + GhPostPrCommentPayload, + GhReopenPrPayload, + GhUpdatePrBranchPayload, + GitAbortMergePayload, + GitAbortMergeResult, + GitBranchListResult, + GitCommitPayload, + GitCommitResult, + GitDiffBatchResult, + GitDiffResult, + GitFetchPayload, + GitFinishMergePayload, + GitFinishMergeResult, + GitGetWorktreeSourceBranchPayload, + GitGetWorktreeSourceBranchResult, + GitListWorktreesPayload, + GitMergeToSourcePayload, + GitMergeToSourceResult, + GitProjectSnapshotPayload, + GitProjectSnapshotResult, + GitPullFromSourcePayload, + GitPullFromSourceResult, + GitPullPayload, + GitPushPayload, + GitRemoveWorktreePayload, + GitRevertAllPayload, + GitRevertPayload, + GitStageAllPayload, + GitStagePayload, + GitSwitchBranchPayload, + GitSwitchBranchResult, + GitUnstageAllPayload, + GitUnstagePayload, + GitWorktreeInfo, + GitWorktreeListResult, + GitWorktreeStatusBatchPayload, + GitWorktreeStatusBatchResult, + InterruptThreadPayload, + ListProjectTreePayload, + ListProjectTreeResult, + McpOauthStatusResult, + McpProbePayload, + McpProbeResult, + PrComment, + PrData, + Project, + ProjectLocation, + ProjectNotes, + ProviderUsagePayload, + ProviderUsageResponse, + ReadProjectFilePayload, + ReadProjectFileResult, + ReloadAgentMcpServersPayload, + RemoteThreadCommand, + RollbackThreadConversationPayload, + ScanSkillsPayload, + SearchProjectFilesPayload, + SearchProjectFilesResult, + SearchProjectTreePayload, + SearchProjectTreeResult, + SendThreadInputPayload, + SetPendingSteerPayload, + SetSkillEnabledPayload, + SkillScanResult, + StageThreadInputPayload, + StartThreadPayload, + StartThreadResult, + Thread, + ThreadRuntimeSnapshot, +} from "@/shared/contracts"; +import type { RemoteProjectCommand, RemoteProjectCommandResult } from "@/shared/remote"; +import type { SharedSettings } from "@/shared/settings"; +import type { StreamableHttpMcpToolSpec } from "../../../mcp/StreamableHttpMcpIngress"; +import type { ScheduleService } from "../../../schedules/ScheduleService"; +import type { + CreateAppThreadRequest, + CreateAppThreadResult, +} from "../../../threads/appThreadLauncher"; +import type { ThreadStateBroker } from "../../../threads/threadStateBroker"; + +/** + * Typed subset of `supervisorClient.call` the thread-management tools use. Kept + * to the handful of thread RPCs the domain needs so the tool layer never sees + * the full supervisor procedure surface. + */ +export interface AppControlsSupervisorCaller { + getThreadSnapshots(): Promise; + /** Start (or resume) a thread's runtime session. Used to revive a thread with no live session. */ + startThread(payload: StartThreadPayload): Promise; + sendThreadInput(payload: SendThreadInputPayload): Promise; + interruptThread(payload: InterruptThreadPayload): Promise; + closeThread(payload: CloseThreadPayload): Promise; + getProviderUsage(payload: ProviderUsagePayload): Promise; + refreshProviderUsage(payload: ProviderUsagePayload): Promise; + searchProjectFiles(payload: SearchProjectFilesPayload): Promise; + /** Read a terminal-native thread's PTY scrollback (empty string when none). */ + readTerminalScrollback(payload: { threadId: string }): Promise; + /** Queue steer guidance injected when the running agent next yields. */ + setPendingSteer(payload: SetPendingSteerPayload): Promise; + /** Clear a thread's queued steer guidance. */ + clearPendingSteer(payload: ClearPendingSteerPayload): Promise; + /** Type text into a terminal thread's composer without submitting it. */ + stageThreadInput(payload: StageThreadInputPayload): Promise; + /** Permanently discard the last N turns of a thread's conversation. */ + rollbackThreadConversation(payload: RollbackThreadConversationPayload): Promise; + /** Cached installed-agent inventory across native + WSL environments. */ + getAgentStatuses(payload: GetAgentStatusesPayload): Promise; + /** Force a fresh detection sweep of installed agents. */ + refreshAgentStatuses(payload: GetAgentStatusesPayload): Promise; + /** List one directory level of a project's file tree. */ + listProjectTree(payload: ListProjectTreePayload): Promise; + /** Read a single project file's contents. */ + readProjectFile(payload: ReadProjectFilePayload): Promise; + /** Fuzzy filename search within a project. */ + searchProjectTree(payload: SearchProjectTreePayload): Promise; + /** Combined status/branches/worktrees snapshot for a project or worktree. */ + gitProjectSnapshot(payload: GitProjectSnapshotPayload): Promise; + /** Unified diff for one file (staged or working tree). */ + getGitDiff(payload: GetGitDiffPayload): Promise; + /** Unified diffs for every changed file, keyed by path. */ + getGitDiffBatch(payload: GetGitDiffBatchPayload): Promise; + /** Stage one path. */ + gitStage(payload: GitStagePayload): Promise; + /** Unstage one path. */ + gitUnstage(payload: GitUnstagePayload): Promise; + /** Stage every change. */ + gitStageAll(payload: GitStageAllPayload): Promise; + /** Unstage every change. */ + gitUnstageAll(payload: GitUnstageAllPayload): Promise; + /** Discard uncommitted changes to one path (destructive). */ + gitRevert(payload: GitRevertPayload): Promise; + /** Discard all uncommitted changes (destructive). */ + gitRevertAll(payload: GitRevertAllPayload): Promise; + /** Create a commit. */ + gitCommit(payload: GitCommitPayload): Promise; + /** List local (and optionally remote) branches. */ + gitListBranches(payload: GetGitBranchesPayload): Promise; + /** Switch to (or create) a branch. */ + gitSwitchBranch(payload: GitSwitchBranchPayload): Promise; + /** Fetch from a remote. */ + gitFetch(payload: GitFetchPayload): Promise; + /** Pull (merge) from the tracking remote. */ + gitPull(payload: GitPullPayload): Promise; + /** Pull with rebase from the tracking remote. */ + gitPullRebase(payload: GitPullPayload): Promise; + /** Push commits to a remote (publishes work). */ + gitPush(payload: GitPushPayload): Promise; + /** List a project's git worktrees. */ + gitListWorktrees(payload: GitListWorktreesPayload): Promise; + /** Remove a worktree directory (destructive). */ + gitRemoveWorktree(payload: GitRemoveWorktreePayload): Promise; + /** Batch status for several worktree paths. */ + gitWorktreeStatusBatch( + payload: GitWorktreeStatusBatchPayload, + ): Promise; + /** Resolve a worktree branch's inferred source branch + ahead counts. */ + gitGetWorktreeSourceBranch( + payload: GitGetWorktreeSourceBranchPayload, + ): Promise; + /** Merge a worktree branch into its source branch. */ + gitMergeToSource(payload: GitMergeToSourcePayload): Promise; + /** Pull the source branch into a worktree. */ + gitPullFromSource(payload: GitPullFromSourcePayload): Promise; + /** Abort an in-progress merge in a worktree. */ + gitAbortMerge(payload: GitAbortMergePayload): Promise; + /** Finish (commit) a resolved merge in a worktree. */ + gitFinishMerge(payload: GitFinishMergePayload): Promise; + /** Check whether the `gh` CLI is available for a project's runtime. */ + ghCheckAvailable(payload: GetGitStatusPayload): Promise; + /** List a project's pull requests. */ + ghListPullRequests(payload: GhListPullRequestsPayload): Promise; + /** Read a pull request's details. */ + ghGetPrDetails(payload: GhGetPrDetailsPayload): Promise; + /** Read a pull request's CI checks (by head branch). */ + ghGetPrChecks(payload: GhGetPrChecksPayload): Promise; + /** List a pull request's changed files. */ + ghGetPrFiles(payload: GhGetPrFilesPayload): Promise; + /** Read a pull request's unified diff. */ + ghGetPrDiff(payload: GhGetPrDiffPayload): Promise; + /** Create a pull request (publishes it). */ + ghCreatePr(payload: GhCreatePrPayload): Promise; + /** Post a comment on a pull request. */ + ghPostPrComment(payload: GhPostPrCommentPayload): Promise; + /** Merge a pull request. */ + ghMergePr(payload: GhMergePrPayload): Promise; + /** Close a pull request. */ + ghClosePr(payload: GhClosePrPayload): Promise; + /** Reopen a closed pull request. */ + ghReopenPr(payload: GhReopenPrPayload): Promise; + /** Mark a draft pull request ready for review. */ + ghMarkPrReady(payload: GhMarkPrReadyPayload): Promise; + /** Update a pull request's branch with its base. */ + ghUpdatePrBranch(payload: GhUpdatePrBranchPayload): Promise; + /** Probe a candidate MCP server config for reachability + tools. */ + probeMcpServer(payload: McpProbePayload): Promise; + /** Re-resolve + apply an agent kind's MCP set to its live sessions (hot-reload). */ + reloadAgentMcpServers(payload: ReloadAgentMcpServersPayload): Promise; + /** OAuth authentication status for configured MCP servers. */ + getMcpOauthStatus(): Promise; + /** Scan installed skills (global + optional project scope). */ + scanSkills(payload: ScanSkillsPayload): Promise; + /** Enable or disable one skill. */ + setSkillEnabled(payload: SetSkillEnabledPayload): Promise; +} + +/** Result of the renderer-owned OS-notification callback (honest headless fallback). */ +export interface AppControlsNotifyResult { + /** True only when an OS notification was actually shown. */ + delivered: boolean; + /** Explains why nothing was shown when `delivered` is false. */ + note?: string; +} + +/** Result of the desktop-only update-check callback. */ +export interface AppControlsUpdateCheck { + /** True when an updater is available (desktop); false headless / dev. */ + supported: boolean; + /** The running app version. */ + currentVersion?: string; + /** Most recent known updater status type (may predate this call). */ + status?: string; + /** Version offered by the most recent known check, when one is available. */ + availableVersion?: string; + note?: string; +} + +/** Read-only app facts surfaced by `get_app_info`; injected so tools stay pure. */ +export interface AppControlsAppInfo { + /** App version (Electron `app.getVersion()` on desktop, package version headless). */ + version: string; + /** Host platform (`process.platform`). */ + platform: string; + /** True on desktop with a live renderer window; false for a headless server. */ + hasRendererWindow: boolean; +} + +/** Read + guarded-write access to the shared settings file (source of truth). */ +export interface AppControlsSettingsGateway { + /** Full, normalized settings from disk. */ + read(): SharedSettings; + /** + * Persist a full settings object and broadcast the change the same way a + * normal settings save does (renderer IPC + power-save refresh on desktop). + * The caller has already applied `mergeManagedSharedSettings` guards. + */ + write(next: SharedSettings): void; +} + +/** Everything a tool handler needs to act on the app on the caller's behalf. */ +export interface AppControlsToolContext { + /** Calling thread + its task title, decoded from the MCP endpoint URL. */ + identity: McpThreadIdentity; + scheduleService: ScheduleService; + getThread(threadId: string): Thread | null; + getThreads(): Thread[]; + getProjects(): Project[]; + getProject(projectId: string): Project | null; + /** Per-project notes (doc + todos), or null when none are recorded. */ + getProjectNotes(projectId: string): ProjectNotes | null; + /** True when `path` resolves to an existing directory on disk. */ + directoryExists(path: string): boolean; + /** + * Run a project command (add-existing/remove) through the shared remote + * project-command handler and notify listeners exactly as the remote + * `/api/projects/command` route does. + */ + applyProjectCommand(command: RemoteProjectCommand): Promise; + /** Persist an edited project row (e.g. rename) and notify project-change listeners. */ + updateProject(project: Project): void; + /** Shared settings read + guarded write (used by get_settings / update_settings). */ + settings: AppControlsSettingsGateway; + /** Read-only app facts for `get_app_info`. */ + getAppInfo(): AppControlsAppInfo; + supervisor: AppControlsSupervisorCaller; + /** Create + launch a first-class app thread (see appThreadLauncher). */ + createThread(request: CreateAppThreadRequest): Promise; + /** + * Forward a metadata mutation to the renderer-owned thread store. Returns + * `true` when a renderer received it, `false` when no UI is connected (e.g. a + * headless host, or the desktop main window is closed) — the caller then + * falls back to writing the DB row directly via {@link updateThreadRow}. + */ + emitRemoteThreadCommand(command: RemoteThreadCommand): boolean; + /** + * Headless / no-renderer fallback: read the current thread row, apply + * `mutate`, and persist it (preserving sort order). Source of truth when no + * renderer store is present. No-op when the thread row no longer exists. + */ + updateThreadRow(threadId: string, mutate: (thread: Thread) => Thread): void; + /** Ask the renderer to open/focus a thread in the UI. Returns `false` when no UI is connected. */ + openThreadInUi(threadId: string): boolean; + /** + * Show an OS notification to the user. Desktop-only: the headless host has no + * display, so it reports non-delivery instead of silently succeeding. + */ + notifyUser(input: { title: string; body: string; threadId: string }): AppControlsNotifyResult; + /** + * Trigger the desktop app's update check (read-only). Headless / dev report a + * clear not-supported result rather than pretending to check. + */ + checkForUpdate(): Promise; + /** Live status cache + event-driven wait surface (persistent, ingress-owned). */ + threadStates: ThreadStateBroker; +} + +/** One tool's handler; receives validated raw args and the request context. */ +export type ToolHandler = ( + args: Record, + ctx: AppControlsToolContext, +) => Promise | unknown; + +/** A self-contained group of tools (specs + dispatch handlers) for one domain. */ +export interface ToolDomain { + specs: readonly StreamableHttpMcpToolSpec[]; + handlers: Record; +} + +/** Resolve a threadId arg to a DB row, or throw a clear not-found error. */ +export function requireThread(ctx: AppControlsToolContext, threadId: string): Thread { + const thread = ctx.getThread(threadId); + if (!thread) { + throw new Error(`Thread not found: ${threadId}. Call list_threads to see valid thread ids.`); + } + return thread; +} + +/** + * Reject actions that would deadlock the calling thread against itself (a + * thread cannot stop/interrupt/wait on its own running turn — it is the turn). + */ +export function assertNotSelf(ctx: AppControlsToolContext, threadId: string, action: string): void { + if (ctx.identity.threadId && ctx.identity.threadId === threadId) { + throw new Error( + `You cannot ${action} your own thread — it is the thread making this call, so the action ` + + "would deadlock. Target a different thread.", + ); + } +} + +/** Resolve a projectId to its DB row, or throw a clear not-found error. */ +export function requireProject(ctx: AppControlsToolContext, projectId: string): Project { + const project = ctx.getProject(projectId); + if (!project) { + throw new Error( + `Project not found: ${projectId}. Call list_projects to see valid project ids.`, + ); + } + return project; +} + +/** + * Resolve a projectId (+ optional worktreePath) to the effective on-disk + * location, rejecting any worktreePath outside the project's worktree set so + * every tool that accepts one stays project-scoped. + */ +export async function resolveLocation( + ctx: AppControlsToolContext, + projectId: string, + worktreePath: string | undefined, +): Promise { + const projectLocation = requireProject(ctx, projectId).location; + if (worktreePath !== undefined) { + await resolveWorktreeInfo(ctx, projectLocation, worktreePath); + } + return resolveProjectLocation(projectLocation, worktreePath); +} + +/** Find a worktree's branch + head commit from the project's worktree list. */ +export async function resolveWorktreeInfo( + ctx: AppControlsToolContext, + location: ProjectLocation, + worktreePath: string, +): Promise { + const { worktrees } = await ctx.supervisor.gitListWorktrees({ projectLocation: location }); + const target = normalizeWorktreePathForComparison(worktreePath, false); + const match = worktrees.find( + (worktree) => normalizeWorktreePathForComparison(worktree.path, false) === target, + ); + if (!match) { + throw new Error( + `No worktree found at ${worktreePath}. Call list_worktrees to see the project's worktrees.`, + ); + } + return match; +} + +/** Shared cap on diff/PR-diff text returned to the caller (across all files). */ +export const DIFF_MAX_CHARS = 80_000; + +/** Truncate diff text to the shared cap, flagging when it was cut. */ +export function capDiff(diff: string): { diff: string; truncated?: true; note?: string } { + if (diff.length <= DIFF_MAX_CHARS) return { diff }; + return { + diff: diff.slice(0, DIFF_MAX_CHARS), + truncated: true, + note: `Diff truncated to the first ${DIFF_MAX_CHARS} characters.`, + }; +} + +/** Shared JSON-schema property fragments spread into tool input specs. */ +export const projectIdProp = { type: "string" }; +export const worktreePathProp = { type: "string", minLength: 1 }; +export const threadIdProp = { type: "string" }; diff --git a/src/main/app-controls/mcp/tools/usage.ts b/src/main/app-controls/mcp/tools/usage.ts new file mode 100644 index 00000000..933b74db --- /dev/null +++ b/src/main/app-controls/mcp/tools/usage.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import type { ProviderUsagePayload } from "@/shared/contracts"; +import type { ToolDomain } from "./types"; + +const getUsageArgsSchema = z.object({ + providerId: z.string().min(1).optional(), + refresh: z.boolean().optional(), +}); + +export const usageTools: ToolDomain = { + specs: [ + { + name: "get_usage", + description: + "Get provider usage/quota snapshots (plan limits, remaining, reset windows). Optionally restrict to one providerId, and set refresh=true to force a live refresh instead of serving the cached snapshot.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + providerId: { type: "string" }, + refresh: { type: "boolean" }, + }, + }, + }, + ], + handlers: { + get_usage: async (args, ctx) => { + const { providerId, refresh } = getUsageArgsSchema.parse(args); + const payload: ProviderUsagePayload = providerId ? { providerIds: [providerId] } : {}; + const response = refresh + ? await ctx.supervisor.refreshProviderUsage(payload) + : await ctx.supervisor.getProviderUsage(payload); + return { + fromCache: response.fromCache, + count: response.snapshots.length, + snapshots: response.snapshots, + }; + }, + }, +}; diff --git a/src/main/app-controls/supervisorCaller.ts b/src/main/app-controls/supervisorCaller.ts new file mode 100644 index 00000000..4f26f265 --- /dev/null +++ b/src/main/app-controls/supervisorCaller.ts @@ -0,0 +1,87 @@ +import type { + IpcProcedurePayload, + IpcProcedureResult, + SupervisorProcedureName, +} from "@/shared/ipc"; +import type { AppControlsSupervisorCaller } from "./mcp/toolRegistry"; + +/** The typed `supervisorClient.call` both hosts hand to the app-controls MCP. */ +export interface SupervisorCall { + ( + name: Name, + payload: IpcProcedurePayload, + ): Promise>; +} + +/** + * Build the app-controls supervisor caller from a supervisor client's `call`. + * Both the desktop (`main.ts`) and headless (`createHeadlessRemoteHost.ts`) + * hosts wire the identical set of thin RPC wrappers, so this is the single + * copy — each method just forwards to a named supervisor procedure. + */ +export function createAppControlsSupervisorCaller( + call: SupervisorCall, +): AppControlsSupervisorCaller { + return { + getThreadSnapshots: () => call("getThreadSnapshots", {}), + startThread: (payload) => call("startThread", payload), + sendThreadInput: (payload) => call("sendThreadInput", payload), + interruptThread: (payload) => call("interruptThread", payload), + closeThread: (payload) => call("closeThread", payload), + getProviderUsage: (payload) => call("getProviderUsage", payload), + refreshProviderUsage: (payload) => call("refreshProviderUsage", payload), + searchProjectFiles: (payload) => call("searchProjectFiles", payload), + readTerminalScrollback: (payload) => call("readTerminalScrollback", payload), + setPendingSteer: (payload) => call("setPendingSteer", payload), + clearPendingSteer: (payload) => call("clearPendingSteer", payload), + stageThreadInput: (payload) => call("stageThreadInput", payload), + rollbackThreadConversation: (payload) => call("rollbackThreadConversation", payload), + getAgentStatuses: (payload) => call("getAgentStatuses", payload), + refreshAgentStatuses: (payload) => call("refreshAgentStatuses", payload), + listProjectTree: (payload) => call("listProjectTree", payload), + readProjectFile: (payload) => call("readProjectFile", payload), + searchProjectTree: (payload) => call("searchProjectTree", payload), + gitProjectSnapshot: (payload) => call("gitProjectSnapshot", payload), + getGitDiff: (payload) => call("getGitDiff", payload), + getGitDiffBatch: (payload) => call("getGitDiffBatch", payload), + gitStage: (payload) => call("gitStage", payload), + gitUnstage: (payload) => call("gitUnstage", payload), + gitStageAll: (payload) => call("gitStageAll", payload), + gitUnstageAll: (payload) => call("gitUnstageAll", payload), + gitRevert: (payload) => call("gitRevert", payload), + gitRevertAll: (payload) => call("gitRevertAll", payload), + gitCommit: (payload) => call("gitCommit", payload), + gitListBranches: (payload) => call("gitListBranches", payload), + gitSwitchBranch: (payload) => call("gitSwitchBranch", payload), + gitFetch: (payload) => call("gitFetch", payload), + gitPull: (payload) => call("gitPull", payload), + gitPullRebase: (payload) => call("gitPullRebase", payload), + gitPush: (payload) => call("gitPush", payload), + gitListWorktrees: (payload) => call("gitListWorktrees", payload), + gitRemoveWorktree: (payload) => call("gitRemoveWorktree", payload), + gitWorktreeStatusBatch: (payload) => call("gitWorktreeStatusBatch", payload), + gitGetWorktreeSourceBranch: (payload) => call("gitGetWorktreeSourceBranch", payload), + gitMergeToSource: (payload) => call("gitMergeToSource", payload), + gitPullFromSource: (payload) => call("gitPullFromSource", payload), + gitAbortMerge: (payload) => call("gitAbortMerge", payload), + gitFinishMerge: (payload) => call("gitFinishMerge", payload), + ghCheckAvailable: (payload) => call("ghCheckAvailable", payload), + ghListPullRequests: (payload) => call("ghListPullRequests", payload), + ghGetPrDetails: (payload) => call("ghGetPrDetails", payload), + ghGetPrChecks: (payload) => call("ghGetPrChecks", payload), + ghGetPrFiles: (payload) => call("ghGetPrFiles", payload), + ghGetPrDiff: (payload) => call("ghGetPrDiff", payload), + ghCreatePr: (payload) => call("ghCreatePr", payload), + ghPostPrComment: (payload) => call("ghPostPrComment", payload), + ghMergePr: (payload) => call("ghMergePr", payload), + ghClosePr: (payload) => call("ghClosePr", payload), + ghReopenPr: (payload) => call("ghReopenPr", payload), + ghMarkPrReady: (payload) => call("ghMarkPrReady", payload), + ghUpdatePrBranch: (payload) => call("ghUpdatePrBranch", payload), + probeMcpServer: (payload) => call("probeMcpServer", payload), + reloadAgentMcpServers: (payload) => call("reloadAgentMcpServers", payload), + getMcpOauthStatus: () => call("getMcpOauthStatus", {}), + scanSkills: (payload) => call("scanSkills", payload), + setSkillEnabled: (payload) => call("setSkillEnabled", payload), + }; +} diff --git a/src/main/ipc/localHandlers.ts b/src/main/ipc/localHandlers.ts index 1b268787..ff9bfe3f 100644 --- a/src/main/ipc/localHandlers.ts +++ b/src/main/ipc/localHandlers.ts @@ -48,6 +48,7 @@ import { } from "../profile"; import { applyClaudeProfileEnvironment, + mergeManagedSharedSettings, readSharedSettingsFile, writeSharedSettingsFile, } from "../sharedSettingsFile"; @@ -66,7 +67,6 @@ import { type WindowChromeResult, } from "@/shared/ipc"; import { supportsNativeWindowMaterial, syncNativeThemeForMaterial } from "../window/windowMaterial"; -import type { AgentInstanceConfig } from "@/shared/contracts"; import type { SharedSettings } from "@/shared/settings"; import { headersToRecord, readBoundedResponseBody } from "@/shared/http"; import type { PoracodePaths } from "@/shared/poracodePaths"; @@ -334,40 +334,11 @@ export function createLocalIpcHandlers( getSharedSettings: () => readSharedSettingsFile(options.requirePoracodePaths().settingsPath), setSharedSettings: (settings) => { const settingsPath = options.requirePoracodePaths().settingsPath; - // Preserve supervisor-managed fields so the renderer's persist cycle - // doesn't clobber writes made out-of-band by the supervisor. - const onDisk = readSharedSettingsFile(settingsPath); - const rendererManagedInstances = Object.fromEntries( - Object.entries(settings.agentInstances) - .filter(([, instance]) => instance.driver !== "acp-generic") - .map(([id, instance]): [string, AgentInstanceConfig] => { - // A Claude profile's `environment` is owned by the encrypting - // `setClaudeProfileEnvironment` path. Pin it to disk so the - // renderer's plaintext-capable persist cycle can never write a - // secret in the clear or clear a saved one. Other drivers keep - // their existing renderer-managed behavior. - if (instance.driver !== "claude") return [id, instance]; - const onDiskEnv = onDisk.agentInstances[id]?.environment; - const next: AgentInstanceConfig = { ...instance }; - if (onDiskEnv) next.environment = onDiskEnv; - else delete next.environment; - return [id, next]; - }), - ); - const supervisorManagedInstances = Object.fromEntries( - Object.entries(onDisk.agentInstances).filter( - ([, instance]) => instance.driver === "acp-generic", - ), - ); - const merged: SharedSettings = { - ...settings, - acpRegistryInstalledAgents: onDisk.acpRegistryInstalledAgents, - agentInstances: { - ...rendererManagedInstances, - ...supervisorManagedInstances, - }, - agentHookSupport: onDisk.agentHookSupport, - }; + // Preserve supervisor-managed fields and encrypted Claude-profile + // environments so the renderer's persist cycle doesn't clobber writes + // made out-of-band by the supervisor. (Shared with the app-controls MCP + // `update_settings` tool via `mergeManagedSharedSettings`.) + const merged = mergeManagedSharedSettings(readSharedSettingsFile(settingsPath), settings); writeSharedSettingsFile(settingsPath, merged); options.updatePowerSaveBlocker(); options.onSharedSettingsChanged?.(merged); diff --git a/src/main/main.ts b/src/main/main.ts index 5230544b..5ee26419 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -15,6 +15,7 @@ import { closeDatabase, dbDeleteThread, dbGetProject, + dbGetProjectNotes, dbGetProjects, dbGetThread, dbGetThreads, @@ -26,7 +27,6 @@ import { onProjectThreadDataChanged, } from "./db"; import { cleanupOrphanedAttachments, preparePoracodeDataRoot } from "./poracodeData"; -import { handleOrchestratorThreadCreated } from "./orchestratorThreadBridge"; import { createLocalIpcHandlers, showAddFilesDialog } from "./ipc/localHandlers"; import { registerIpcHandlers } from "./ipc/registerHandlers"; import { createSleepInhibitor } from "./sleepInhibitor"; @@ -50,6 +50,7 @@ import { } from "./computer-use"; import { SupervisorClient } from "./supervisor/SupervisorClient"; import { createAutoUpdaterController } from "./updates/autoUpdater"; +import { showOsNotification } from "./osNotifications"; import { createMainWindow } from "./window/createMainWindow"; import { createQuickComposerWindow, @@ -70,9 +71,12 @@ import { quickComposerSubmissionSchema, type QuickComposerSubmission, type SupervisorEvent, + type UpdateStatus, } from "@/shared/ipc"; import type { SharedSettings } from "@/shared/settings"; -import { readSharedSettingsFile } from "./sharedSettingsFile"; +import type { RemoteThreadCommand } from "@/shared/contracts"; +import { readSharedSettingsFile, writeSharedSettingsFile } from "./sharedSettingsFile"; +import { remoteProjectCommandResultSchema } from "@/shared/remote"; import { WindowsJobObjectManager } from "./windowsJobObject"; import { captureMainException, initializeMainSentry } from "./diagnostics/sentry"; import { configureSecretStorageKey } from "@/shared/secretStorage"; @@ -84,7 +88,11 @@ import { ensureHomeProjectRow, ScheduleRunCoordinator, } from "./schedules"; -import { AppControlsMcpIngress } from "./app-controls"; +import { + AppControlsMcpIngress, + buildSharedAppControlsIngressDeps, + createAppControlsSupervisorCaller, +} from "./app-controls"; import { legacyProductNameFor, resolveLegacyElectronUserDataDir } from "./legacyDataMigration"; import { refreshMacDockIcon } from "./macDockIcon"; import { repairLegacyMacAppPath } from "./macAppPathMigration"; @@ -689,21 +697,9 @@ if (!hasSingleInstanceLock) { captureMainException(error, tags); }, onEvent: (event) => { - // Orchestrator create_thread requests are consumed here (DB upsert, - // renderer mirror, startThread call-back) and never fanned out. - if (event.type === "orchestrator-thread-created") { - void handleOrchestratorThreadCreated(event, { - startThread: (payload) => supervisorClient.call("startThread", payload), - sendThreadCommand: (command) => { - if (!mainWindow) return false; - mainWindow.webContents.send(IPC_EVENT_CHANNELS.remoteThreadCommand, command); - return true; - }, - }); - return; - } persistSupervisorEvent(event); handleSupervisorEventForSleep(event); + appControlsMcpIngress?.observeSupervisorEvent(event); scheduleRunCoordinator?.observeSupervisorEvent(event); remoteAccessController?.handleSupervisorEvent(event); mainWindow?.webContents.send(IPC_EVENT_CHANNELS.supervisorEvent, event); @@ -713,30 +709,6 @@ if (!hasSingleInstanceLock) { workingThreads.clear(); updatePowerSaveBlocker(); }, - // A fresh supervisor process has an empty orchestrator child registry; - // push the persisted child rows back so Crossagents `get_thread` / - // `list_threads` keep resolving children created before the restart. - onStarted: () => { - const children = dbGetThreads().filter( - (thread) => thread.parentThreadId && !thread.archived, - ); - if (children.length === 0) return; - void supervisorClient - .call("seedOrchestratorChildren", { - children: children.map((thread) => ({ - threadId: thread.id, - parentThreadId: thread.parentThreadId!, - agentKind: thread.agentKind, - title: thread.title, - ...(thread.worktreePath ? { worktreePath: thread.worktreePath } : {}), - ...(thread.worktreeBranch ? { worktreeBranch: thread.worktreeBranch } : {}), - createdAt: thread.createdAt, - })), - }) - .catch((error) => { - console.warn("[main] failed to seed orchestrator child threads:", error); - }); - }, }); const scheduleCoordinator = new ScheduleRunCoordinator({ startThread: (payload) => supervisorClient.call("startThread", payload), @@ -761,10 +733,89 @@ if (!hasSingleInstanceLock) { onStartupInterrupted: (scheduleId) => dbInterruptScheduleRuns(scheduleId, new Date().toISOString()), }); - appControlsMcpIngress = new AppControlsMcpIngress(scheduleService, dbGetThread); + const emitRemoteThreadCommand = (command: RemoteThreadCommand): boolean => { + if (!mainWindow) return false; + mainWindow.webContents.send(IPC_EVENT_CHANNELS.remoteThreadCommand, command); + return true; + }; + const publishProjectsChanged = (): void => { + const projects = dbGetProjects(); + remoteAccessController?.getServer()?.publishSupervisorEvent({ + type: "remote-projects-changed", + projects: remoteProjectCommandResultSchema.parse({ projects }).projects, + }); + mainWindow?.webContents.send(IPC_EVENT_CHANNELS.projectStateChanged, { projects }); + }; + // Latest updater status, captured from the auto-updater's status stream so + // the app-controls `check_for_update` tool can report the most recent + // result (the check itself is fire-and-forget and event-driven). + let lastUpdateStatus: UpdateStatus | null = null; + appControlsMcpIngress = new AppControlsMcpIngress({ + scheduleService, + getThread: dbGetThread, + getThreads: dbGetThreads, + getProjects: dbGetProjects, + getProject: dbGetProject, + getProjectNotes: dbGetProjectNotes, + ...buildSharedAppControlsIngressDeps({ + call: (name, payload) => supervisorClient.call(name, payload), + // The desktop mirrors thread commands to its renderer store. + sendThreadCommand: emitRemoteThreadCommand, + getSharedSettings: () => readSharedSettingsFile(requirePoracodePaths().settingsPath), + publishProjectsChanged, + }), + settings: { + read: () => readSharedSettingsFile(requirePoracodePaths().settingsPath), + write: (next) => { + writeSharedSettingsFile(requirePoracodePaths().settingsPath, next); + updatePowerSaveBlocker(); + handleSharedSettingsChanged(next); + mainWindow?.webContents.send(IPC_EVENT_CHANNELS.sharedSettingsChanged, next); + }, + }, + getAppInfo: () => ({ + version: app.getVersion(), + platform: process.platform, + hasRendererWindow: mainWindow !== null && !mainWindow.isDestroyed(), + }), + supervisor: createAppControlsSupervisorCaller((name, payload) => + supervisorClient.call(name, payload), + ), + emitRemoteThreadCommand, + // The desktop always has (or ensures) a main window to open into. + openThreadInUi: (threadId) => { + openThreadFromTray(threadId); + return true; + }, + notifyUser: ({ title, body, threadId }) => { + const delivered = showOsNotification({ title, body, threadId }, () => mainWindow); + return delivered + ? { delivered: true } + : { + delivered: false, + note: "The operating system did not show the notification (notifications may be unsupported or disabled).", + }; + }, + checkForUpdate: async () => { + await autoUpdaterController.checkForUpdate(); + const status = lastUpdateStatus; + const availableVersion = + status && (status.type === "update-available" || status.type === "downloaded") + ? status.version + : undefined; + return { + supported: true, + currentVersion: app.getVersion(), + ...(status ? { status: status.type } : {}), + ...(availableVersion ? { availableVersion } : {}), + note: "A background update check was triggered; its result surfaces in the app's update UI. status/availableVersion reflect the most recent known check, which may predate this call.", + }; + }, + }); const autoUpdaterController = createAutoUpdaterController( (status) => { + lastUpdateStatus = status; mainWindow?.webContents.send(IPC_EVENT_CHANNELS.updateStatus, status); }, channel, diff --git a/src/main/orchestratorThreadBridge.test.ts b/src/main/orchestratorThreadBridge.test.ts deleted file mode 100644 index c38d1e2b..00000000 --- a/src/main/orchestratorThreadBridge.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RemoteThreadCommand, StartThreadPayload, Thread } from "@/shared/contracts"; -import type { SupervisorEvent } from "@/shared/ipc"; -import { handleOrchestratorThreadCreated } from "./orchestratorThreadBridge"; - -vi.mock("./db", () => ({ - dbGetThread: vi.fn<(threadId: string) => Thread | null>(), - dbUpsertThread: vi.fn<(thread: Thread, sortOrder: number) => void>(), - dbSetThreadGroup: vi.fn<(threadId: string, groupId: string, groupName: string) => void>(), - dbDeleteThread: vi.fn<(threadId: string) => void>(), -})); - -const db = vi.mocked(await import("./db")); - -type CreatedEvent = Extract; - -const NOW = "2026-07-01T00:00:00.000Z"; - -function makeParent(overrides: Partial = {}): Thread { - return { - id: "parent-1", - projectId: "project-1", - title: "Parent orchestrator", - agentKind: "claude" as Thread["agentKind"], - config: { model: "claude-fable-5" }, - status: "working", - attention: "none", - canResumeWithConfig: false, - archived: false, - done: false, - starred: false, - createdAt: NOW, - updatedAt: NOW, - ...overrides, - }; -} - -function makeEvent(overrides: Partial = {}): CreatedEvent { - const thread: Omit = { - id: "child-1", - title: "PORA-123", - agentKind: "codex" as Thread["agentKind"], - config: { model: "gpt-5.5" }, - status: "launching", - attention: "none", - canResumeWithConfig: false, - archived: false, - done: false, - starred: false, - presentationMode: "gui", - parentThreadId: "parent-1", - createdAt: NOW, - updatedAt: NOW, - }; - const start: StartThreadPayload = { - threadId: "child-1", - projectLocation: { kind: "posix", path: "/tmp/project" }, - agentKind: "codex" as Thread["agentKind"], - config: { model: "gpt-5.5" }, - prompt: "do the ticket", - initialSize: { cols: 120, rows: 30 }, - presentationMode: "gui", - }; - return { - type: "orchestrator-thread-created", - parentThreadId: "parent-1", - thread, - start, - ...overrides, - }; -} - -function makeDeps(options?: { rendererUp?: boolean; startThreadError?: Error }) { - const commands: RemoteThreadCommand[] = []; - const startThread = vi.fn<() => Promise>(async () => { - if (options?.startThreadError) throw options.startThreadError; - return {}; - }); - return { - commands, - startThread, - deps: { - startThread, - sendThreadCommand: (command: RemoteThreadCommand) => { - if (options?.rendererUp === false) return false; - commands.push(command); - return true; - }, - }, - }; -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("handleOrchestratorThreadCreated grouping", () => { - it("groups the child with a groupless parent under a group keyed by the parent", async () => { - db.dbGetThread.mockImplementation((id: string) => (id === "parent-1" ? makeParent() : null)); - const { deps, commands } = makeDeps(); - - await handleOrchestratorThreadCreated(makeEvent(), deps); - - // Child row persisted with the parent's projectId and the family group. - expect(db.dbUpsertThread).toHaveBeenCalledWith( - expect.objectContaining({ - id: "child-1", - projectId: "project-1", - parentThreadId: "parent-1", - groupId: "parent-1", - groupName: "Parent orchestrator", - }), - expect.any(Number), - ); - // Parent pulled into the group via the renderer-owned metadata path. - expect(commands).toContainEqual({ - kind: "set-group", - threadId: "parent-1", - groupId: "parent-1", - groupName: "Parent orchestrator", - }); - expect(db.dbSetThreadGroup).not.toHaveBeenCalled(); - // The renderer mirror of the child carries the same group. - const start = commands.find((c) => c.kind === "start"); - expect(start).toMatchObject({ - threadId: "child-1", - groupId: "parent-1", - groupName: "Parent orchestrator", - parentThreadId: "parent-1", - launchRuntime: false, - focus: false, - }); - }); - - it("reuses the parent's existing group without re-assigning the parent", async () => { - db.dbGetThread.mockImplementation((id: string) => - id === "parent-1" ? makeParent({ groupId: "group-9", groupName: "My group" }) : null, - ); - const { deps, commands } = makeDeps(); - - await handleOrchestratorThreadCreated(makeEvent(), deps); - - expect(commands.some((c) => c.kind === "set-group")).toBe(false); - expect(db.dbUpsertThread).toHaveBeenCalledWith( - expect.objectContaining({ id: "child-1", groupId: "group-9", groupName: "My group" }), - expect.any(Number), - ); - }); - - it("writes the parent's group straight to the DB when no renderer window is up", async () => { - db.dbGetThread.mockImplementation((id: string) => (id === "parent-1" ? makeParent() : null)); - const { deps } = makeDeps({ rendererUp: false }); - - await handleOrchestratorThreadCreated(makeEvent(), deps); - - expect(db.dbSetThreadGroup).toHaveBeenCalledWith("parent-1", "parent-1", "Parent orchestrator"); - }); - - it("rolls back a fresh row when the supervisor launch fails", async () => { - db.dbGetThread.mockImplementation((id: string) => (id === "parent-1" ? makeParent() : null)); - const { deps, commands } = makeDeps({ startThreadError: new Error("boom") }); - - await handleOrchestratorThreadCreated(makeEvent(), deps); - - expect(db.dbDeleteThread).toHaveBeenCalledWith("child-1"); - expect(commands).toContainEqual({ kind: "delete", threadId: "child-1" }); - }); - - it("drops the child when the parent row is gone", async () => { - db.dbGetThread.mockReturnValue(null); - const { deps, commands } = makeDeps(); - - await handleOrchestratorThreadCreated(makeEvent(), deps); - - expect(db.dbUpsertThread).not.toHaveBeenCalled(); - expect(commands).toEqual([]); - }); -}); diff --git a/src/main/orchestratorThreadBridge.ts b/src/main/orchestratorThreadBridge.ts deleted file mode 100644 index 40c2d96f..00000000 --- a/src/main/orchestratorThreadBridge.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { RemoteThreadCommand, StartThreadPayload, Thread } from "@/shared/contracts"; -import type { SupervisorEvent } from "@/shared/ipc"; -import { dbDeleteThread, dbGetThread, dbSetThreadGroup, dbUpsertThread } from "./db"; - -type OrchestratorThreadCreatedEvent = Extract< - SupervisorEvent, - { type: "orchestrator-thread-created" } ->; - -export interface OrchestratorThreadBridgeDeps { - /** Launch the child session in the supervisor (main → supervisor request). */ - startThread(payload: StartThreadPayload): Promise; - /** Mirror a thread command to the renderer store; false when no window is up. */ - sendThreadCommand(command: RemoteThreadCommand): boolean; -} - -/** - * Main-process half of the Crossagents MCP orchestrator lane's `create_thread`. - * Mirrors the proven remote (mobile) start ordering exactly: - * - * 1. Resolve `projectId` from the PARENT thread's DB row (the supervisor - * doesn't know project ids) and complete the child `Thread` record — - * including a sidebar group shared with the parent so the family renders - * together (the parent is pulled into the group on its first child). - * 2. `dbUpsertThread` — persist the row first, so the thread survives even if - * a later step fails mid-flight. - * 3. Mirror to the renderer (`remoteThreadCommand` "start" with - * `launchRuntime: false` — the launch happens below, not in the renderer — - * and `focus: false` so orchestrator fan-out doesn't steal the user's view). - * 4. `startThread` back into the supervisor. On failure the fresh row is - * deleted and the renderer told to drop it; the supervisor-side - * `create_thread` call then times out and surfaces a tool error. - */ -export async function handleOrchestratorThreadCreated( - event: OrchestratorThreadCreatedEvent, - deps: OrchestratorThreadBridgeDeps, -): Promise { - const parent = dbGetThread(event.parentThreadId); - if (!parent) { - console.warn( - `[main] orchestrator create_thread: parent thread ${event.parentThreadId} not found in DB; dropping child ${event.thread.id}`, - ); - return; - } - - // Children join a sidebar group shared with their parent so the family - // renders together (reusing the regular groupId grouping). The parent - // anchors the group: its existing group wins; otherwise a new group keyed - // by the parent's id is named after the parent. - const groupId = parent.groupId ?? parent.id; - const groupName = parent.groupName ?? parent.title; - if (!parent.groupId) { - // The renderer owns thread metadata — route the parent's group assignment - // through its store (persisted via dbSyncAll). Only when no window is up - // (headless) write the row directly. - const applied = deps.sendThreadCommand({ - kind: "set-group", - threadId: parent.id, - groupId, - groupName, - }); - if (!applied) dbSetThreadGroup(parent.id, groupId, groupName); - } - - const thread: Thread = { ...event.thread, projectId: parent.projectId, groupId, groupName }; - const existing = dbGetThread(thread.id) != null; - // New rows sort to the top via a descending timestamp (same convention as - // the remote-access server's sortOrderForThread). - dbUpsertThread(thread, -Date.now()); - - deps.sendThreadCommand({ - kind: "start", - groupId, - groupName, - threadId: thread.id, - projectId: thread.projectId, - agentKind: thread.agentKind, - config: thread.config, - prompt: event.start.prompt, - // Forward the title only when the orchestrator gave a custom one (e.g. a - // ticket key) — a forwarded title is authoritative and disables AI title - // generation in the renderer; a prompt-derived title still gets one. - ...(event.hasCustomTitle ? { title: thread.title } : {}), - presentationMode: "gui", - ...(thread.worktreePath ? { worktreePath: thread.worktreePath } : {}), - ...(thread.worktreeBranch ? { worktreeBranch: thread.worktreeBranch } : {}), - ...(event.isNewWorktree ? { isNewWorktree: true } : {}), - launchRuntime: false, - focus: false, - parentThreadId: event.parentThreadId, - }); - - try { - await deps.startThread(event.start); - } catch (error) { - console.warn(`[main] orchestrator child thread ${thread.id} failed to launch:`, error); - if (!existing) { - dbDeleteThread(thread.id); - deps.sendThreadCommand({ kind: "delete", threadId: thread.id }); - } - } -} diff --git a/src/main/preload.ts b/src/main/preload.ts index 096f0a1f..5f434212 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -10,6 +10,7 @@ import { type BrowserEvent, type PoracodeBridge, type PoracodeWindowKind, + type ProjectStateChangedEvent, type QuickComposerSubmission, type SupervisorEvent, type ThreadOpenRequestedEvent, @@ -169,6 +170,15 @@ const bridge: PoracodeBridge = { ipcRenderer.removeListener(IPC_EVENT_CHANNELS.sharedSettingsChanged, handler); }; }, + onProjectStateChanged(listener) { + const handler = (_event: Electron.IpcRendererEvent, payload: ProjectStateChangedEvent) => { + listener(payload); + }; + ipcRenderer.on(IPC_EVENT_CHANNELS.projectStateChanged, handler); + return () => { + ipcRenderer.removeListener(IPC_EVENT_CHANNELS.projectStateChanged, handler); + }; + }, onThreadOpenRequested(listener) { const handler = (_event: Electron.IpcRendererEvent, payload: ThreadOpenRequestedEvent) => { listener(payload); diff --git a/src/main/schedules/ScheduleRunCoordinator.ts b/src/main/schedules/ScheduleRunCoordinator.ts index 5a31166e..5f35ecf3 100644 --- a/src/main/schedules/ScheduleRunCoordinator.ts +++ b/src/main/schedules/ScheduleRunCoordinator.ts @@ -1,6 +1,5 @@ import { randomUUID } from "node:crypto"; import type { - AgentKind, AgentStatusesResponse, Project, ProjectLocation, @@ -14,13 +13,9 @@ import type { } from "@/shared/contracts"; import type { SharedSettings } from "@/shared/settings"; import { DEFAULT_TERMINAL_SIZE, resolveMcpLaunchSnapshot } from "@/shared/contracts"; -import { getProjectAgentStatuses } from "@/shared/agentStatus"; -import { - resolveUnrestrictedPermissionConfig, - type UnrestrictedPermissionConfig, -} from "@/shared/agents/unrestrictedPermissions"; import type { SupervisorEvent } from "@/shared/ipc"; import type { ScheduleRunPatch } from "../db/scheduleRuns"; +import { resolveUnrestrictedThreadPermissions } from "../threads/threadLaunchConfig"; /** * A `thread-state` transition ends the run only once the turn fully settles. @@ -70,7 +65,7 @@ interface PendingRun { * instead of a headless one-shot prompt, and records a run-history row linked * to that thread. * - * Start ordering mirrors {@link handleOrchestratorThreadCreated}: persist the + * Start ordering mirrors the proven remote-start path: persist the * thread row first, mirror it to the renderer (`launchRuntime: false`, * `focus: false`), then call the supervisor's `startThread`. The returned * promise settles when the thread's turn ends (observed via `thread-state`), @@ -231,35 +226,14 @@ export class ScheduleRunCoordinator { model: task.config.model, ...(task.config.effort !== undefined ? { effort: task.config.effort } : {}), ...(task.config.fast !== undefined ? { fast: task.config.fast } : {}), - ...(await this.resolveUnrestrictedPermissions(task.agentKind, location)), + ...(await resolveUnrestrictedThreadPermissions( + this.deps.getAgentStatuses, + task.agentKind, + location, + )), }; } - /** - * Scheduled runs execute unattended — nobody is around to answer approval - * prompts — so every run launches with the provider's most-permissive - * advertised policy (same capabilities-driven resolution the subagent lane - * uses; no provider-specific branching here). If the capability lookup - * fails or the agent is unknown, fall back to provider defaults rather - * than failing the run. - */ - private async resolveUnrestrictedPermissions( - agentKind: AgentKind, - location: ProjectLocation, - ): Promise { - try { - const statuses = await this.deps.getAgentStatuses( - location.kind === "wsl" ? [location.distro] : [], - ); - const agents = getProjectAgentStatuses(location, statuses.windows, statuses.wsl); - const agent = agents.find((status) => status.kind === agentKind); - if (!agent) return {}; - return resolveUnrestrictedPermissionConfig(agent.capabilities); - } catch { - return {}; - } - } - private nowIso(): string { return new Date((this.deps.now ?? Date.now)()).toISOString(); } diff --git a/src/main/sharedSettingsFile.ts b/src/main/sharedSettingsFile.ts index 179d4247..49ad233d 100644 --- a/src/main/sharedSettingsFile.ts +++ b/src/main/sharedSettingsFile.ts @@ -10,6 +10,7 @@ import { defaultSharedSettings, normalizeSharedSettings, type SharedSettings, + type SharedSettingsInput, } from "@/shared/settings"; function serializeSharedSettings(settings: SharedSettings): string { @@ -48,6 +49,54 @@ export function patchSharedSettingsFile( return next; } +/** + * Re-merge supervisor-managed fields and encrypted Claude-profile environments + * from the on-disk settings into an `incoming` (renderer- or tool-originated) + * settings object, so a plaintext-capable write can never clobber a secret or a + * field the supervisor owns. Preserves: + * - `acpRegistryInstalledAgents` and `agentHookSupport` (supervisor-managed), + * - `acp-generic` agent instances (supervisor-managed), and + * - each Claude profile's `environment` (owned by the encrypting + * `setClaudeProfileEnvironment` path). + * Extracted verbatim from the IPC `setSharedSettings` path so every write — + * including the app-controls MCP `update_settings` tool — applies one guard. + * `incoming` is assumed already normalized. + */ +export function mergeManagedSharedSettings( + onDisk: SharedSettings, + incoming: SharedSettingsInput, +): SharedSettings { + const rendererManagedInstances = Object.fromEntries( + Object.entries(incoming.agentInstances) + .filter(([, instance]) => instance.driver !== "acp-generic") + .map(([id, instance]): [string, AgentInstanceConfig] => { + // A Claude profile's `environment` is owned by the encrypting + // `setClaudeProfileEnvironment` path; pin it to disk so a plaintext- + // capable write can never leak or clear a saved secret. + if (instance.driver !== "claude") return [id, instance]; + const onDiskEnv = onDisk.agentInstances[id]?.environment; + const next: AgentInstanceConfig = { ...instance }; + if (onDiskEnv) next.environment = onDiskEnv; + else delete next.environment; + return [id, next]; + }), + ); + const supervisorManagedInstances = Object.fromEntries( + Object.entries(onDisk.agentInstances).filter( + ([, instance]) => instance.driver === "acp-generic", + ), + ); + return { + ...incoming, + acpRegistryInstalledAgents: onDisk.acpRegistryInstalledAgents, + agentInstances: { + ...rendererManagedInstances, + ...supervisorManagedInstances, + }, + agentHookSupport: onDisk.agentHookSupport, + }; +} + /** * Apply a Claude profile's environment edit, sealing any `sensitive` value that * is not already sealed. `baseDir` is the settings directory (passed through to diff --git a/src/main/threads/appThreadLauncher.ts b/src/main/threads/appThreadLauncher.ts new file mode 100644 index 00000000..1b29217d --- /dev/null +++ b/src/main/threads/appThreadLauncher.ts @@ -0,0 +1,238 @@ +import { randomUUID } from "node:crypto"; +import type { + AgentKind, + AgentStatusesResponse, + Project, + ProjectLocation, + RemoteThreadCommand, + StartThreadPayload, + Thread, + ThreadConfig, +} from "@/shared/contracts"; +import { DEFAULT_TERMINAL_SIZE, resolveMcpLaunchSnapshot } from "@/shared/contracts"; +import type { SharedSettings } from "@/shared/settings"; +import { isHomeProjectId } from "@/shared/homeScope"; +import { makeThreadTitle } from "@/shared/threadTitle"; +import { buildWorktreeLocation, resolveWorktreePlacement } from "@/shared/worktree"; +import { generateWorktreeBranch } from "@/shared/worktreeBranch"; +import { resolveUnrestrictedThreadPermissions } from "./threadLaunchConfig"; + +/** Host surface the launcher needs — the same main-side seams schedules use. */ +export interface AppThreadLauncherDeps { + /** Launch the child session in the supervisor (main → supervisor request). */ + startThread(payload: StartThreadPayload): Promise; + /** Cached agent detection for the given WSL distros (supervisor request). */ + getAgentStatuses(wslDistros: string[]): Promise; + /** + * Create a git worktree (new branch) in the project's repo; returns its path. + * Receives the already-read shared settings so a single launch reads them once. + */ + addWorktree(input: { + location: ProjectLocation; + branch: string; + settings: SharedSettings; + }): Promise<{ path: string }>; + /** Roll back a worktree created by a launch that then failed (best effort). */ + removeWorktree(input: { location: ProjectLocation; path: string }): Promise; + /** Mirror the new thread to the renderer store; false when no window is up. */ + sendThreadCommand(command: RemoteThreadCommand): boolean; + /** Resolve (creating if absent) the persisted home-scope project row. */ + ensureHomeProject(): Project; + /** Resolve a persisted project row by id, or null when it no longer exists. */ + getProject(projectId: string): Project | null; + /** Current global MCP/worktree settings. */ + getSharedSettings(): SharedSettings; + upsertThread(thread: Thread, sortOrder: number): void; + deleteThread(threadId: string): void; + threadExists(threadId: string): boolean; + now?: () => number; + newId?: () => string; +} + +/** Arguments accepted by the app-controls `create_thread` tool. */ +export interface CreateAppThreadRequest { + projectId: string; + prompt: string; + agentKind: AgentKind; + model: string; + effort?: string; + fast?: boolean; + title?: string; + worktree?: { branch?: string }; +} + +export interface CreateAppThreadResult { + threadId: string; + title: string; + projectId: string; + worktreePath?: string; + branch?: string; +} + +/** + * Create a REAL first-class app thread (persisted row, sidebar-visible, + * optionally worktree-backed) and initiate its launch, then return the new id. + * + * Mirrors {@link ScheduleRunCoordinator.runScheduleAsThread}'s proven start + * ordering — persist the row first, mirror it to the renderer (`launchRuntime: + * false`, `focus: false`), then call the supervisor's `startThread` — but does + * NOT wait for the opening turn to finish (matching the orchestrator's + * `create_thread`). The renderer owns thread metadata, so the mirror command is + * how the desktop store learns about the row; a `false` return (no window) is + * expected on headless hosts and never fails the launch. + */ +export async function createAppThread( + deps: AppThreadLauncherDeps, + request: CreateAppThreadRequest, +): Promise { + const project = resolveProject(deps, request.projectId); + const threadId = (deps.newId ?? randomUUID)(); + const nowIso = new Date((deps.now ?? Date.now)()).toISOString(); + // Read shared settings once and flow the value to every consumer below + // (worktree placement + the launch MCP snapshot). + const settings = deps.getSharedSettings(); + + // A worktree is created up front so the row and launch both target it. + let worktreePath: string | undefined; + let branch: string | undefined; + if (request.worktree) { + branch = request.worktree.branch?.trim() || generateWorktreeBranch(); + const created = await deps.addWorktree({ location: project.location, branch, settings }); + worktreePath = created.path; + } + const threadLocation = worktreePath + ? buildWorktreeLocation(project.location, worktreePath) + : project.location; + + const config: ThreadConfig = { + model: request.model, + ...(request.effort ? { effort: request.effort } : {}), + ...(request.fast !== undefined ? { fast: request.fast } : {}), + ...(await resolveUnrestrictedThreadPermissions( + deps.getAgentStatuses, + request.agentKind, + threadLocation, + )), + }; + + const customTitle = request.title?.trim(); + const title = customTitle || makeThreadTitle(request.prompt) || "New thread"; + const thread: Thread = { + id: threadId, + projectId: project.id, + title, + agentKind: request.agentKind, + config, + status: "launching", + attention: "none", + canResumeWithConfig: false, + archived: false, + done: false, + starred: false, + presentationMode: "gui", + threadStatusSource: "server", + ...(worktreePath ? { worktreePath } : {}), + ...(branch ? { worktreeBranch: branch } : {}), + createdAt: nowIso, + updatedAt: nowIso, + activeTurnStartedAt: nowIso, + }; + + const existed = deps.threadExists(threadId); + // New rows sort to the top via a descending timestamp (same convention as the + // orchestrator bridge, schedules, and the remote-access server). + deps.upsertThread(thread, -Date.now()); + + deps.sendThreadCommand({ + kind: "start", + threadId, + projectId: project.id, + agentKind: request.agentKind, + config, + prompt: request.prompt, + ...(customTitle ? { title: customTitle } : {}), + presentationMode: "gui", + launchRuntime: false, + focus: false, + ...(worktreePath ? { worktreePath } : {}), + ...(branch ? { worktreeBranch: branch } : {}), + ...(worktreePath ? { isNewWorktree: true } : {}), + }); + + const startPayload: StartThreadPayload = { + threadId, + projectLocation: threadLocation, + agentKind: request.agentKind, + config, + prompt: request.prompt, + initialSize: DEFAULT_TERMINAL_SIZE, + presentationMode: "gui", + ...resolveMcpLaunchSnapshot(settings, project.mcpServers ?? []), + }; + + try { + await deps.startThread(startPayload); + } catch (error) { + // Roll back the fresh row (follow the orchestrator/schedule bridge): drop it + // from the DB and tell the renderer to forget it, but keep a pre-existing + // row. Also remove a worktree WE created this call so a retry isn't poisoned. + if (!existed) { + deps.deleteThread(threadId); + deps.sendThreadCommand({ kind: "delete", threadId }); + } + if (worktreePath) { + await deps + .removeWorktree({ location: project.location, path: worktreePath }) + .catch(() => undefined); + } + throw error instanceof Error ? error : new Error(String(error)); + } + + return { + threadId, + title, + projectId: project.id, + ...(worktreePath ? { worktreePath } : {}), + ...(branch ? { branch } : {}), + }; +} + +/** + * Worktree placement resolved from global settings, matching the supervisor's + * `createWorktree` pipeline (per-project overrides live in the renderer DB and + * aren't visible here). Callers pass the returned fields straight to the + * `gitAddWorktree` supervisor RPC. + */ +export function resolveAddWorktreeArgs( + settings: SharedSettings, + location: ProjectLocation, + branch: string, +): { + branch: string; + createBranch: true; + transferUncommitted: false; + keepChangesInSource: false; + worktreeRoot?: string; + worktreeOmitRepoDir?: true; +} { + const placement = resolveWorktreePlacement(settings, undefined, location); + return { + branch, + createBranch: true, + transferUncommitted: false, + keepChangesInSource: false, + ...(placement.root ? { worktreeRoot: placement.root } : {}), + ...(placement.omitRepoDir ? { worktreeOmitRepoDir: true as const } : {}), + }; +} + +/** + * Resolve the project the new thread lives in. The built-in Home scope resolves + * to the persisted Home row; any other id must reference an existing project. + */ +function resolveProject(deps: AppThreadLauncherDeps, projectId: string): Project { + if (isHomeProjectId(projectId)) return deps.ensureHomeProject(); + const project = deps.getProject(projectId); + if (!project) throw new Error(`Project not found: ${projectId}`); + return project; +} diff --git a/src/main/threads/threadLaunchConfig.ts b/src/main/threads/threadLaunchConfig.ts new file mode 100644 index 00000000..cdaac3be --- /dev/null +++ b/src/main/threads/threadLaunchConfig.ts @@ -0,0 +1,31 @@ +import type { AgentKind, AgentStatusesResponse, ProjectLocation } from "@/shared/contracts"; +import { getProjectAgentStatuses } from "@/shared/agentStatus"; +import { + resolveUnrestrictedPermissionConfig, + type UnrestrictedPermissionConfig, +} from "@/shared/agents/unrestrictedPermissions"; + +/** + * Resolve the most-permissive advertised permission policy for an agent in a + * given location. Threads launched from automation (schedules, the app-controls + * MCP) run their opening turn unattended — nobody is around to answer approval + * prompts — so they launch with the provider's unrestricted posture (the same + * capabilities-driven resolution the subagent lane uses; no provider-specific + * branching). On a lookup failure or unknown agent, fall back to provider + * defaults rather than failing the launch. + */ +export async function resolveUnrestrictedThreadPermissions( + getAgentStatuses: (wslDistros: string[]) => Promise, + agentKind: AgentKind, + location: ProjectLocation, +): Promise { + try { + const statuses = await getAgentStatuses(location.kind === "wsl" ? [location.distro] : []); + const agents = getProjectAgentStatuses(location, statuses.windows, statuses.wsl); + const agent = agents.find((status) => status.kind === agentKind); + if (!agent) return {}; + return resolveUnrestrictedPermissionConfig(agent.capabilities); + } catch { + return {}; + } +} diff --git a/src/main/threads/threadStateBroker.ts b/src/main/threads/threadStateBroker.ts new file mode 100644 index 00000000..aef3dd3b --- /dev/null +++ b/src/main/threads/threadStateBroker.ts @@ -0,0 +1,109 @@ +import type { PendingSteerState, ThreadAttention, ThreadStatus } from "@/shared/contracts"; +import type { SupervisorEvent } from "@/shared/ipc"; + +/** Latest live runtime bits observed for a thread from `thread-state` events. */ +export interface LiveThreadState { + status: ThreadStatus; + attention: ThreadAttention; + errorMessage?: string; +} + +interface Waiter { + threadIds: ReadonlySet; + wake(): void; +} + +/** + * Persistent, main-side tap on the supervisor event stream that the always-on + * app-controls MCP server reads from. It caches the latest live status and the + * staged pending-steer slot per thread (neither is exposed by a request/reply + * RPC), and drives event-driven `wait_for_thread` waits without polling the + * supervisor. Fed by {@link observe} from the same `onEvent` tap that persists + * events, so the DB row is already updated by the time a wake fires. + */ +export class ThreadStateBroker { + private readonly liveStates = new Map(); + private readonly pendingSteer = new Map(); + private readonly waiters = new Set(); + + /** Wire into the supervisor event tap (main.ts / headless host `onEvent`). */ + observe(event: SupervisorEvent): void { + switch (event.type) { + case "thread-state": + this.liveStates.set(event.threadId, { + status: event.status, + attention: event.attention, + ...(event.errorMessage ? { errorMessage: event.errorMessage } : {}), + }); + this.wake(event.threadId); + return; + case "thread-pending-steer": + if (event.pending) this.pendingSteer.set(event.threadId, event.pending); + else this.pendingSteer.delete(event.threadId); + this.wake(event.threadId); + return; + case "thread-exited": + this.liveStates.delete(event.threadId); + this.pendingSteer.delete(event.threadId); + this.wake(event.threadId); + return; + default: + return; + } + } + + /** Latest live status/attention seen for a thread, or `undefined` if none yet. */ + getLiveState(threadId: string): LiveThreadState | undefined { + return this.liveStates.get(threadId); + } + + /** Currently staged steer message for a thread, or `null` when the slot is empty. */ + getPendingSteer(threadId: string): PendingSteerState | null { + return this.pendingSteer.get(threadId) ?? null; + } + + /** + * Event-driven wait: re-evaluate `poll` on each `thread-state` / + * `thread-pending-steer` / `thread-exited` wake for `threadIds` (plus a + * coarse re-check cap) until it returns a value, or the deadline elapses. + * Returns the polled value, or `undefined` on timeout. No tight polling. + */ + async waitUntil( + threadIds: string[], + timeoutMs: number, + poll: () => T | undefined, + maxChunkMs = 1_000, + ): Promise { + const deadline = Date.now() + Math.max(0, timeoutMs); + for (;;) { + const value = poll(); + if (value !== undefined) return value; + const remaining = deadline - Date.now(); + if (remaining <= 0) return undefined; + await this.waitForWake(threadIds, Math.min(maxChunkMs, remaining)); + } + } + + private waitForWake(threadIds: string[], timeoutMs: number): Promise { + return new Promise((resolve) => { + let timer: ReturnType | undefined; + const waiter: Waiter = { + threadIds: new Set(threadIds), + wake: () => { + if (timer) clearTimeout(timer); + this.waiters.delete(waiter); + resolve(); + }, + }; + this.waiters.add(waiter); + timer = setTimeout(() => waiter.wake(), Math.max(0, timeoutMs)); + }); + } + + private wake(threadId: string): void { + if (this.waiters.size === 0) return; + for (const waiter of this.waiters) { + if (waiter.threadIds.has(threadId)) waiter.wake(); + } + } +} diff --git a/src/renderer/app.test.tsx b/src/renderer/app.test.tsx index f7ed4f80..7824069f 100644 --- a/src/renderer/app.test.tsx +++ b/src/renderer/app.test.tsx @@ -19,6 +19,7 @@ import { openThread, unloadThread } from "@/renderer/actions/threadActions"; const { bridge, quickComposerSubmitListeners, + projectStateChangedListeners, remoteThreadCommandListeners, supervisorEventListeners, threadOpenRequestedListeners, @@ -27,11 +28,13 @@ const { const quickListeners: Array<(submission: QuickComposerSubmission) => void> = []; const supervisorListeners: Array<(event: SupervisorEvent) => void> = []; const threadOpenListeners: Array<(event: ThreadOpenRequestedEvent) => void> = []; + const projectListeners: Array<(event: { projects: unknown[] }) => void> = []; return { remoteThreadCommandListeners: listeners, quickComposerSubmitListeners: quickListeners, supervisorEventListeners: supervisorListeners, threadOpenRequestedListeners: threadOpenListeners, + projectStateChangedListeners: projectListeners, bridge: { windowKind: "main", pickFolder: vi.fn<() => Promise>().mockResolvedValue(null), @@ -175,6 +178,12 @@ const { return () => undefined; }), onSharedSettingsChanged: vi.fn<() => () => void>(() => () => undefined), + onProjectStateChanged: vi.fn< + (listener: (event: { projects: unknown[] }) => void) => () => void + >((listener) => { + projectListeners.push(listener); + return () => undefined; + }), onThreadOpenRequested: vi.fn< (listener: (event: ThreadOpenRequestedEvent) => void) => () => void >((listener) => { @@ -653,6 +662,23 @@ describe("App", () => { expect(bridge.startThread).not.toHaveBeenCalled(); }); + it("adopts project changes made outside the renderer before the next store sync", () => { + const project = { + id: "mcp-project-1", + name: "MCP project", + location: { kind: "windows" as const, path: "C:\\mcp-project" }, + createdAt: "2026-07-21T00:00:00.000Z", + }; + useAppStore.setState({ projects: [] }); + render(); + + act(() => { + projectStateChangedListeners.at(-1)?.({ projects: [project] }); + }); + + expect(useAppStore.getState().projects).toEqual([project]); + }); + it("creates and queues the thread submitted by the quick composer", async () => { useAppStore.persist.hasHydrated = vi.fn<() => boolean>().mockReturnValue(true); useAppStore.persist.onHydrate = vi.fn<() => () => void>(() => () => undefined); diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index c769f8bd..2a23f53c 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -408,6 +408,11 @@ const mainWindowCleanups: Array<() => void> = isMainWindow readBridge().onSharedSettingsChanged((settings) => { applyExternalSharedSettings(normalizeSharedSettings(settings)); }), + // Main-process project mutations must reach this whole-store snapshot + // before its next dbSyncAll persistence write. + readBridge().onProjectStateChanged(({ projects }) => { + useAppStore.setState({ projects }); + }), readBridge().onThreadOpenRequested(({ threadId }) => { openThread(threadId, { focusComposer: true }); }), diff --git a/src/renderer/components/mcp/McpServersManager.test.tsx b/src/renderer/components/mcp/McpServersManager.test.tsx index f5c22c4f..5f8d9a4d 100644 --- a/src/renderer/components/mcp/McpServersManager.test.tsx +++ b/src/renderer/components/mcp/McpServersManager.test.tsx @@ -154,7 +154,7 @@ describe("McpServersManager", () => { const row = document.querySelector('[data-built-in-mcp-server="app-controls"]'); expect(row).not.toBeNull(); - fireEvent.click(within(row as HTMLElement).getByRole("button", { name: "5 tools" })); + fireEvent.click(within(row as HTMLElement).getByRole("button", { name: "57 tools" })); const dialog = screen.getByRole("dialog", { name: "App Controls" }); expect(within(dialog).getByText("list_schedules")).toBeInTheDocument(); diff --git a/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx b/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx index d20ce695..757655c8 100644 --- a/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx +++ b/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx @@ -597,6 +597,7 @@ export function SingleAgentSettings(props: { agentKinds: [props.agentKind], envs: [scopeEnvForStatus(status)], }) + .catch(() => undefined) .finally(clearPending); }, }); diff --git a/src/server/createHeadlessRemoteHost.test.ts b/src/server/createHeadlessRemoteHost.test.ts index eecb4154..bbefa963 100644 --- a/src/server/createHeadlessRemoteHost.test.ts +++ b/src/server/createHeadlessRemoteHost.test.ts @@ -36,6 +36,9 @@ vi.mock("@/main/db", () => ({ project.id === projectId, ) ?? null, ), + dbGetProjectNotes: vi.fn<() => string>(() => ""), + dbUpsertProject: vi.fn<() => void>(), + dbDeleteProject: vi.fn<() => void>(), dbGetThreads: vi.fn<() => unknown[]>(() => []), dbGetThread: vi.fn<() => unknown>(() => null), dbGetThreadRuntimeItems: vi.fn<() => unknown[]>(() => []), diff --git a/src/server/createHeadlessRemoteHost.ts b/src/server/createHeadlessRemoteHost.ts index ebce058e..b10befec 100644 --- a/src/server/createHeadlessRemoteHost.ts +++ b/src/server/createHeadlessRemoteHost.ts @@ -2,6 +2,7 @@ import { closeDatabase, dbDeleteThread, dbGetProject, + dbGetProjectNotes, dbGetProjects, dbGetThread, dbGetThreads, @@ -13,7 +14,11 @@ import { initDatabase, } from "@/main/db"; import { preparePoracodeDataRoot } from "@/main/poracodeData"; -import { patchSharedSettingsFile, readSharedSettingsFile } from "@/main/sharedSettingsFile"; +import { + patchSharedSettingsFile, + readSharedSettingsFile, + writeSharedSettingsFile, +} from "@/main/sharedSettingsFile"; import { SupervisorClient } from "@/main/supervisor/SupervisorClient"; import { createPersistentRemoteAuthStore, @@ -34,14 +39,18 @@ import { } from "@/main/remote/config"; import type { SupervisorEvent } from "@/shared/ipc"; import { resolveMcpLaunchSnapshot } from "@/shared/contracts"; -import { pickRemoteSettings } from "@/shared/remote"; +import { pickRemoteSettings, remoteProjectCommandResultSchema } from "@/shared/remote"; import { configureSecretStorageKey } from "@/shared/secretStorage"; import { createDeviceScheduleService, ensureHomeProjectRow, ScheduleRunCoordinator, } from "@/main/schedules"; -import { AppControlsMcpIngress } from "@/main/app-controls"; +import { + AppControlsMcpIngress, + buildSharedAppControlsIngressDeps, + createAppControlsSupervisorCaller, +} from "@/main/app-controls"; import { startRelayHost, type RelayHostHandle } from "./relay/relayHost"; /** @@ -191,6 +200,7 @@ export async function createHeadlessRemoteHost( ...(options.reportError ? { reportError: (error) => options.reportError?.(error) } : {}), onEvent: (event) => { options.onSupervisorEvent?.(event); + appControlsMcpIngress?.observeSupervisorEvent(event); scheduleRunCoordinator?.observeSupervisorEvent(event); serverRef?.publishSupervisorEvent(event); pushCoordinator.handleSupervisorEvent(event); @@ -222,7 +232,61 @@ export async function createHeadlessRemoteHost( onStartupInterrupted: (scheduleId) => dbInterruptScheduleRuns(scheduleId, new Date().toISOString()), }); - appControlsMcpIngress = new AppControlsMcpIngress(scheduleService, dbGetThread); + const publishHeadlessProjectsChanged = (): void => { + serverRef?.publishSupervisorEvent({ + type: "remote-projects-changed", + projects: remoteProjectCommandResultSchema.parse({ projects: dbGetProjects() }).projects, + }); + }; + appControlsMcpIngress = new AppControlsMcpIngress({ + scheduleService, + getThread: dbGetThread, + getThreads: () => dbGetThreads(), + getProjects: () => dbGetProjects(), + getProject: dbGetProject, + getProjectNotes: dbGetProjectNotes, + ...buildSharedAppControlsIngressDeps({ + call: (name, payload) => supervisorClient.call(name, payload), + // Headless has no desktop renderer to mirror to; the DB thread row is the + // source of truth and connected remote clients pick it up. + sendThreadCommand: () => false, + getSharedSettings, + publishProjectsChanged: publishHeadlessProjectsChanged, + }), + settings: { + read: () => readSharedSettingsFile(paths.settingsPath), + // Headless has no desktop renderer to notify; connected remote clients + // re-read settings on their next request. The DB/settings file is the + // source of truth. + write: (next) => writeSharedSettingsFile(paths.settingsPath, next), + }, + getAppInfo: () => ({ + version: options.appVersion, + platform: process.platform, + hasRendererWindow: false, + }), + supervisor: createAppControlsSupervisorCaller((name, payload) => + supervisorClient.call(name, payload), + ), + // No renderer headless: metadata mutations and UI focus route through the + // desktop store, which isn't present here. `emitRemoteThreadCommand` reports + // `false` so the tool falls back to writing the DB row directly (the + // headless source of truth); `openThreadInUi` reports `false` (nothing to + // focus). Connected remote clients pick up the DB change on their next poll. + emitRemoteThreadCommand: () => false, + openThreadInUi: () => false, + // The headless host has no display and no auto-updater, so both report an + // honest not-available result instead of silently succeeding. + notifyUser: () => ({ + delivered: false, + note: "No Poracode desktop app is connected, so no OS notification could be shown.", + }), + checkForUpdate: async () => ({ + supported: false, + currentVersion: options.appVersion, + note: "Update checks are not available on the headless server; update the host from the desktop app.", + }), + }); // In dev, advertise loopback by default so the iOS simulator's WebView can // reach the server (iOS ATS `NSAllowsLocalNetworking` permits loopback but not diff --git a/src/shared/contracts/mcpServer.ts b/src/shared/contracts/mcpServer.ts index 9f446fe7..fad3d8fb 100644 --- a/src/shared/contracts/mcpServer.ts +++ b/src/shared/contracts/mcpServer.ts @@ -78,14 +78,6 @@ export const BUILT_IN_MCP_SERVER_TOOL_NAMES = { "run_agent", "get_status", "cancel", - "create_thread", - "list_threads", - "get_thread", - "read_thread", - "send_to_thread", - "wait_for_thread", - "interrupt_thread", - "close_thread", ], chrome: [ "chrome_status", @@ -129,6 +121,58 @@ export const BUILT_IN_MCP_SERVER_TOOL_NAMES = { "update_schedule", "run_schedule", "delete_schedule", + "list_threads", + "get_thread", + "read_thread", + "create_thread", + "send_to_thread", + "interrupt_thread", + "stop_thread", + "wait_for_thread", + "update_thread", + "open_thread", + "read_terminal", + "steer_thread", + "stage_thread_input", + "rollback_thread", + "list_projects", + "get_project", + "create_project", + "update_project", + "get_settings", + "update_settings", + "get_usage", + "search", + "get_app_info", + "notify_user", + "check_for_update", + "list_project_files", + "read_project_file", + "find_files", + "list_installed_agents", + "git_status", + "git_diff", + "git_stage", + "git_commit", + "git_discard", + "git_branch", + "git_sync", + "list_worktrees", + "remove_worktree", + "merge_worktree", + "gh_list_prs", + "gh_get_pr", + "gh_create_pr", + "gh_pr_comment", + "gh_merge_pr", + "gh_update_pr", + "list_mcp_servers", + "probe_mcp_server", + "add_mcp_server", + "update_mcp_server", + "remove_mcp_server", + "list_skills", + "set_skill_enabled", ], } as const satisfies Record; diff --git a/src/shared/contracts/thread.ts b/src/shared/contracts/thread.ts index 41dcbda0..745648f7 100644 --- a/src/shared/contracts/thread.ts +++ b/src/shared/contracts/thread.ts @@ -57,37 +57,14 @@ export const threadSchema = z.object({ errorMessage: z.string().optional(), slashCommands: z.array(agentSlashCommandSchema).optional(), /** - * Id of the orchestrator thread that created this thread via the Crossagents - * MCP `create_thread` tool. Persisted so the supervisor can re-address child - * threads after a restart; absent for user-created threads. + * Id of the thread that created this thread as a child (e.g. via the + * `poracode` MCP `create_thread` tool). Persisted so child threads render + * grouped with their parent in the sidebar; absent for user-created threads. */ parentThreadId: z.string().min(1).optional(), }); export type Thread = z.infer; -/** - * Persisted orchestrator-child row pushed main → supervisor at supervisor - * (re)start so the Crossagents orchestrator lane can re-address child threads - * created before the restart (`get_thread`/`list_threads`/`read_thread` - * instead of "Unknown thread_id"). Live transcript/final-result state is - * in-memory only and does not survive the restart. - */ -export const orchestratorChildSeedSchema = z.object({ - threadId: z.string().min(1), - parentThreadId: z.string().min(1), - agentKind: agentKindSchema, - title: z.string().min(1), - worktreePath: z.string().optional(), - worktreeBranch: z.string().optional(), - createdAt: z.string().min(1), -}); -export type OrchestratorChildSeed = z.infer; - -export const seedOrchestratorChildrenPayloadSchema = z.object({ - children: z.array(orchestratorChildSeedSchema), -}); -export type SeedOrchestratorChildrenPayload = z.infer; - export interface ThreadRuntimeSnapshot { threadId: string; status: z.infer; diff --git a/src/shared/ipc/bridge.ts b/src/shared/ipc/bridge.ts index f1fa4050..98d6a6e6 100644 --- a/src/shared/ipc/bridge.ts +++ b/src/shared/ipc/bridge.ts @@ -12,6 +12,7 @@ import { } from "./procedureMap"; import type { BrowserEvent, + ProjectStateChangedEvent, SupervisorEvent, ThreadOpenRequestedEvent, UpdateStatus, @@ -57,6 +58,7 @@ export type PoracodeBridge = PoracodeInvokeBridge & { onRemoteThreadCommand(listener: (command: RemoteThreadCommand) => void): () => void; /** Shared settings rewritten outside this renderer (e.g. by a remote client). */ onSharedSettingsChanged(listener: (settings: SharedSettings) => void): () => void; + onProjectStateChanged(listener: (event: ProjectStateChangedEvent) => void): () => void; onThreadOpenRequested(listener: (event: ThreadOpenRequestedEvent) => void): () => void; submitQuickComposer(submission: QuickComposerSubmission): Promise; dismissQuickComposer(): Promise; @@ -117,6 +119,7 @@ export const IPC_EVENT_CHANNELS = { browserEvent: createChannel("browserEvent"), remoteThreadCommand: createChannel("remoteThreadCommand"), sharedSettingsChanged: createChannel("sharedSettingsChanged"), + projectStateChanged: createChannel("projectStateChanged"), threadOpenRequested: createChannel("threadOpenRequested"), quickComposerSubmit: createChannel("quickComposerSubmit"), quickComposerDismissRequested: createChannel("quickComposerDismissRequested"), diff --git a/src/shared/ipc/events.ts b/src/shared/ipc/events.ts index 78d1e3ab..8cbd8dba 100644 --- a/src/shared/ipc/events.ts +++ b/src/shared/ipc/events.ts @@ -4,9 +4,8 @@ import type { AgentSlashCommand, AgentStatus, PendingSteerState, + Project, RuntimeEvent, - StartThreadPayload, - Thread, ThreadAttention, ThreadConfig, ThreadStatus, @@ -72,32 +71,6 @@ export type SupervisorEvent = pending: PendingSteerState | null; } | { type: "thread-exited"; threadId: string; exitCode: number | null } - /** - * Emitted by the supervisor's orchestrator lane (Crossagents MCP - * `create_thread`) when an agent thread asks for a first-class child thread. - * The main process owns the rest of the flow, mirroring the remote-start - * path: it resolves `projectId` from the parent's DB row, upserts the child - * row, mirrors it to the renderer (`remoteThreadCommand` "start" with - * `launchRuntime: false`), then calls the supervisor's `startThread` with - * `start`. This event is consumed in main and never forwarded to the - * renderer or remote clients. - */ - | { - type: "orchestrator-thread-created"; - parentThreadId: string; - /** Child thread row, complete except `projectId` (main fills it from the parent's row). */ - thread: Omit; - /** Ready-to-send supervisor `startThread` payload for the child. */ - start: StartThreadPayload; - /** True when `create_thread` just created the child's git worktree. */ - isNewWorktree?: boolean; - /** - * True when the caller supplied an explicit title (vs. a prompt-derived - * one). Main forwards the title to the renderer only in this case, so a - * custom title stays authoritative and suppresses AI title generation. - */ - hasCustomTitle?: boolean; - } | { type: "thread-osc-notification"; threadId: string; @@ -165,6 +138,11 @@ export type ThreadOpenRequestedEvent = { threadId: string; }; +/** Project rows changed outside the renderer's persisted app-store snapshot. */ +export type ProjectStateChangedEvent = { + projects: Project[]; +}; + export type UpdateStatus = | { type: "checking" } | { type: "update-available"; version: string } diff --git a/src/shared/ipc/index.ts b/src/shared/ipc/index.ts index e11fb42d..6d4144da 100644 --- a/src/shared/ipc/index.ts +++ b/src/shared/ipc/index.ts @@ -35,6 +35,7 @@ export { isAgentStatusSupervisorEvent, type AgentStatusSupervisorEvent, type BrowserEvent, + type ProjectStateChangedEvent, type ThreadOpenRequestedEvent, type SupervisorEvent, type SupervisorReply, diff --git a/src/shared/ipc/procedures/thread.ts b/src/shared/ipc/procedures/thread.ts index 6b5dde30..eda35d13 100644 --- a/src/shared/ipc/procedures/thread.ts +++ b/src/shared/ipc/procedures/thread.ts @@ -13,7 +13,6 @@ import { resizeTerminalPayloadSchema, resolveThreadServerRequestPayloadSchema, rollbackThreadConversationPayloadSchema, - seedOrchestratorChildrenPayloadSchema, sendThreadInputPayloadSchema, setAcpRegistryAgentAuthPayloadSchema, setPendingSteerPayloadSchema, @@ -48,7 +47,6 @@ import type { ResizeTerminalPayload, ResolveThreadServerRequestPayload, RollbackThreadConversationPayload, - SeedOrchestratorChildrenPayload, SendThreadInputPayload, SetAcpRegistryAgentAuthPayload, SetPendingSteerPayload, @@ -179,11 +177,6 @@ export const threadProcedures = { "supervisor", sendThreadInputPayloadSchema, ), - seedOrchestratorChildren: definePayloadProcedure< - SeedOrchestratorChildrenPayload, - void, - "supervisor" - >("seedOrchestratorChildren", "supervisor", seedOrchestratorChildrenPayloadSchema), interruptThread: definePayloadProcedure( "interruptThread", "supervisor", diff --git a/src/supervisor/agents/opencode/sdkSession.ts b/src/supervisor/agents/opencode/sdkSession.ts index 2b70d18e..8fcfc909 100644 --- a/src/supervisor/agents/opencode/sdkSession.ts +++ b/src/supervisor/agents/opencode/sdkSession.ts @@ -138,6 +138,8 @@ export class OpencodeSdkSession implements StructuredSessionHandle { private disposed = false; private pendingRequests = new Map(); private currentSlashCommands: AgentSlashCommand[] | undefined; + /** True between `startTurn`'s promptAsync and the next SSE idle signal. */ + private turnActive = false; /** Live MCP set; starts from the launch input, replaced by settings saves. */ private mcpServers: readonly ResolvedMcpServer[] | undefined; @@ -334,6 +336,7 @@ export class OpencodeSdkSession implements StructuredSessionHandle { if (!shouldRetryOpenCodePromptWithTextFallback(cause, parts)) throw cause; await sendParts(await buildOpenCodeTextFallbackParts(parts)); } + this.turnActive = true; } catch (cause) { throw new Error(classifyOpenCodeError({ cause, operation: "session.promptAsync" }), { cause, @@ -816,6 +819,7 @@ export class OpencodeSdkSession implements StructuredSessionHandle { ...(this.pendingRequestStatus() ?? upd), ...this.sessionRefUpdate(), }); + if (upd.status === "idle") this.emitTurnCompletedIfActive(); return; } @@ -824,6 +828,7 @@ export class OpencodeSdkSession implements StructuredSessionHandle { ...(this.pendingRequestStatus() ?? { status: "idle", attention: "none" }), ...this.sessionRefUpdate(), }); + this.emitTurnCompletedIfActive(); return; } @@ -876,6 +881,26 @@ export class OpencodeSdkSession implements StructuredSessionHandle { } } + /** + * Emit a `turn.completed` runtime event when a turn is active and the + * session transitions to idle. Provides a redundant settling signal for + * the SubagentRunManager (which also settles on the `onUpdate` idle + * callback) and aligns OpenCode with Codex/ACP, which always emit + * `turn.completed` from their canonical mappers. + */ + private emitTurnCompletedIfActive(): void { + if (!this.turnActive) return; + this.turnActive = false; + this.emitRuntimeEvents([ + { + type: "turn.completed", + threadId: this.threadId, + turnId: `opencode-${this.sessionId ?? "unknown"}`, + state: "completed", + }, + ]); + } + private emitRuntimeEvents(events: RuntimeEvent[]): void { if (events.length === 0) return; if (!this.listener?.onRuntimeEvent) { diff --git a/src/supervisor/crossagentMcp/CrossagentMcpIngress.test.ts b/src/supervisor/crossagentMcp/CrossagentMcpIngress.test.ts index 9d268706..92fe41dd 100644 --- a/src/supervisor/crossagentMcp/CrossagentMcpIngress.test.ts +++ b/src/supervisor/crossagentMcp/CrossagentMcpIngress.test.ts @@ -1,28 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { OrchestratorThreadManager } from "./OrchestratorThreadManager"; import { CROSSAGENT_PROVIDER_SESSION_ID_ARG, CrossagentMcpIngress } from "./CrossagentMcpIngress"; import type { SubagentRunManager } from "./SubagentRunManager"; import { CROSSAGENT_MCP_INSTRUCTIONS_BASE } from "./toolRegistry"; import type { SpawnableAgent, SpawnAgentRequest } from "./types"; -/** Inert orchestrator lane — these tests only exercise the ephemeral-run tools. */ -function makeInertOrchestrator(): OrchestratorThreadManager { - return new OrchestratorThreadManager({ - adapters: new Map(), - emit: () => {}, - host: { - getParentContext: () => undefined, - getThreadState: () => undefined, - readThreadHistory: async () => undefined, - sendThreadInput: async () => {}, - interruptThread: async () => {}, - closeThread: async () => {}, - }, - createWorktree: async () => ({ path: "/unused" }), - removeWorktree: async () => {}, - }); -} - const AGENTS: SpawnableAgent[] = [ { provider: { value: "codex", label: "Codex" }, @@ -87,7 +68,6 @@ describe("CrossagentMcpIngress", () => { spawned = rm.spawned; ingress = new CrossagentMcpIngress({ runManager: rm.runManager, - orchestrator: makeInertOrchestrator(), getSpawnableAgents: async () => AGENTS, resolveProviderSessionThreadId: (sessionId) => PROVIDER_SESSION_THREADS[sessionId], getRoutingGuide: () => "PREFER codex for search.", @@ -174,7 +154,6 @@ describe("CrossagentMcpIngress", () => { const rm = makeRunManager(); const restarted = new CrossagentMcpIngress({ runManager: rm.runManager, - orchestrator: makeInertOrchestrator(), getSpawnableAgents: async () => AGENTS, resolveProviderSessionThreadId: (sessionId) => PROVIDER_SESSION_THREADS[sessionId], }); @@ -262,20 +241,12 @@ describe("CrossagentMcpIngress", () => { const names = (body.result.tools as Array<{ name: string }>).map((t) => t.name).sort(); expect(names).toEqual([ "cancel", - "close_thread", - "create_thread", "get_agent", "get_status", - "get_thread", - "interrupt_thread", "list_agents", - "list_threads", - "read_thread", "run_agent", - "send_to_thread", "spawn_agent", "wait_for_agent", - "wait_for_thread", ]); }); diff --git a/src/supervisor/crossagentMcp/CrossagentMcpIngress.ts b/src/supervisor/crossagentMcp/CrossagentMcpIngress.ts index e7236ba4..da6872c6 100644 --- a/src/supervisor/crossagentMcp/CrossagentMcpIngress.ts +++ b/src/supervisor/crossagentMcp/CrossagentMcpIngress.ts @@ -1,7 +1,6 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { randomBytes, randomUUID } from "node:crypto"; import type { CrossagentMcpHttpConfig } from "@/supervisor/agents/crossagentMcp"; -import type { OrchestratorThreadManager } from "./OrchestratorThreadManager"; import type { SubagentRunManager } from "./SubagentRunManager"; import { buildSubagentInstructions, dispatchTool, isKnownToolName, TOOLS } from "./toolRegistry"; import { errorResult } from "./toolResult"; @@ -14,8 +13,6 @@ export interface CrossagentMcpIngressInfo { export interface CrossagentMcpIngressDeps { runManager: SubagentRunManager; - /** Orchestrator lane: create/manage first-class child threads. */ - orchestrator: OrchestratorThreadManager; /** Catalog of installed + authenticated agents the caller may spawn. */ getSpawnableAgents: () => Promise; /** Resolve a trusted provider-native session id to its live Poracode parent. */ @@ -365,7 +362,6 @@ export class CrossagentMcpIngress { const result = await dispatchTool(name, args, { parentThreadId: threadId, runManager: this.deps.runManager, - orchestrator: this.deps.orchestrator, listSpawnableAgents: this.deps.getSpawnableAgents, }); return { jsonrpc: "2.0", id, result }; diff --git a/src/supervisor/crossagentMcp/OrchestratorThreadManager.test.ts b/src/supervisor/crossagentMcp/OrchestratorThreadManager.test.ts deleted file mode 100644 index 26d927b3..00000000 --- a/src/supervisor/crossagentMcp/OrchestratorThreadManager.test.ts +++ /dev/null @@ -1,826 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - AgentCapability, - ProjectLocation, - SendThreadInputPayload, - ThreadConfig, -} from "@/shared/contracts"; -import type { SupervisorEvent } from "@/shared/ipc/events"; -import type { AgentAdapter, ThreadHistory } from "@/supervisor/agents/base"; -import { - MAX_CONCURRENT_CHILD_THREADS_PER_PARENT, - OrchestratorThreadError, - OrchestratorThreadManager, -} from "./OrchestratorThreadManager"; -import type { OrchestratorThreadState } from "./OrchestratorThreadManager"; - -const PARENT = "parent-thread"; -const PROJECT: ProjectLocation = { kind: "posix", path: "/tmp/project" }; - -type CreatedEvent = Extract; - -interface Harness { - manager: OrchestratorThreadManager; - emitted: SupervisorEvent[]; - states: Map; - sent: SendThreadInputPayload[]; - interrupted: string[]; - closed: string[]; - worktrees: Array<{ location: ProjectLocation; branch: string; baseBranch?: string }>; - removedWorktrees: Array<{ location: ProjectLocation; path: string }>; - setHistory(history: ThreadHistory | undefined): void; - lastCreated(): CreatedEvent; -} - -function makeState(overrides: Partial = {}): OrchestratorThreadState { - return { - status: "launching", - attention: "none", - config: { model: "gpt-5.5" }, - supportsSteer: false, - ...overrides, - }; -} - -function makeHarness(options?: { - /** Simulate main launching the child as soon as the create event is emitted. */ - autoLaunch?: boolean; - parentConfig?: ThreadConfig; - launchTimeoutMs?: number; - /** Simulate the create handoff (emit → main) hard-failing after the worktree exists. */ - failEmit?: boolean; - /** Simulate git failing to create the worktree (e.g. branch collision). */ - createWorktreeError?: Error; - /** Status-pipeline capabilities preferred over the adapter's in-memory ones. */ - statusCapabilities?: AgentCapability; -}): Harness { - const emitted: SupervisorEvent[] = []; - const states = new Map(); - const sent: SendThreadInputPayload[] = []; - const interrupted: string[] = []; - const closed: string[] = []; - const worktrees: Array<{ location: ProjectLocation; branch: string; baseBranch?: string }> = []; - const removedWorktrees: Array<{ location: ProjectLocation; path: string }> = []; - let history: ThreadHistory | undefined; - - const structured = { - kind: "codex", - label: "Codex", - capabilities: { - models: [{ id: "gpt-5.5", label: "GPT-5.5" }], - efforts: ["low", "high"], - fastModels: ["gpt-5.5"], - approvalPolicies: [ - { id: "on-request", label: "On Request" }, - { id: "never", label: "Full Access" }, - ], - sandboxModes: [ - { id: "workspace-write", label: "Workspace Write" }, - { id: "danger-full-access", label: "Full Access" }, - ], - defaultApprovalPolicy: "on-request", - defaultSandboxMode: "workspace-write", - bypassPermissions: { approvalPolicy: "never", sandboxMode: "danger-full-access" }, - }, - createStructuredSession: async () => ({}), - } as unknown as AgentAdapter; - const oneShot = { - kind: "commandcode", - label: "Command Code", - capabilities: { - models: [{ id: "cc-1", label: "CC One" }], - efforts: [], - approvalPolicies: [], - sandboxModes: [], - bypassPermissions: { approvalPolicy: "yolo" }, - }, - buildSubagentOneShotCommand: () => ({ command: "x", args: [] }), - } as unknown as AgentAdapter; - - const parentConfig: ThreadConfig = options?.parentConfig ?? { - model: "parent-model", - approvalPolicy: "never", - sandboxMode: "workspace-write", - crossagentMcp: true, - browserMcp: true, - computerUse: true, - chromeMcp: true, - }; - - const statusCapabilities = options?.statusCapabilities; - const manager = new OrchestratorThreadManager({ - adapters: new Map([ - ["codex" as never, structured], - ["commandcode" as never, oneShot], - ]), - ...(statusCapabilities ? { getStatusCapabilities: () => statusCapabilities } : {}), - ...(options?.launchTimeoutMs !== undefined ? { launchTimeoutMs: options.launchTimeoutMs } : {}), - emit: (event) => { - emitted.push(event); - if (event.type === "orchestrator-thread-created" && options?.failEmit) { - throw new Error("main bridge unavailable"); - } - if (options?.autoLaunch !== false && event.type === "orchestrator-thread-created") { - // Simulate main's round-trip: the session appears in the TSM. - states.set(event.thread.id, makeState()); - } - }, - host: { - getParentContext: (threadId) => - threadId === PARENT - ? { - projectLocation: PROJECT, - config: parentConfig, - mcpLaunchSnapshot: { mcpServers: [], disabledBuiltInMcpServerIds: [] }, - } - : undefined, - getThreadState: (threadId) => states.get(threadId), - readThreadHistory: async () => history, - sendThreadInput: async (payload) => { - sent.push(payload); - }, - interruptThread: async (threadId) => { - interrupted.push(threadId); - }, - closeThread: async (threadId) => { - // Mirror the real host: closing removes the session from the TSM map, - // which is what frees the child's cap slot (getThreadState → undefined). - closed.push(threadId); - states.delete(threadId); - }, - }, - createWorktree: async ({ location, branch, baseBranch }) => { - if (options?.createWorktreeError) throw options.createWorktreeError; - worktrees.push({ location, branch, ...(baseBranch ? { baseBranch } : {}) }); - return { path: `/tmp/worktrees/${branch}` }; - }, - removeWorktree: async ({ location, path }) => { - removedWorktrees.push({ location, path }); - }, - }); - - return { - manager, - emitted, - states, - sent, - interrupted, - closed, - worktrees, - removedWorktrees, - setHistory: (next) => { - history = next; - }, - lastCreated: () => { - const event = [...emitted] - .reverse() - .find((entry): entry is CreatedEvent => entry.type === "orchestrator-thread-created"); - if (!event) throw new Error("no orchestrator-thread-created event emitted"); - return event; - }, - }; -} - -async function createChild( - h: Harness, - request: Partial[1]> = {}, -): Promise { - const { threadId } = await h.manager.createThread(PARENT, { - agent: "codex", - prompt: "do the ticket", - ...request, - }); - return threadId; -} - -describe("OrchestratorThreadManager.createThread", () => { - it("emits a complete thread row + start payload and resolves once the session appears", async () => { - const h = makeHarness(); - const result = await h.manager.createThread(PARENT, { agent: "codex", prompt: "fix bug #1" }); - - const event = h.lastCreated(); - expect(event.parentThreadId).toBe(PARENT); - expect(event.thread.id).toBe(result.threadId); - expect(event.thread.title).toBe("fix bug #1"); - expect(event.thread.agentKind).toBe("codex"); - expect(event.thread.status).toBe("launching"); - expect(event.thread.presentationMode).toBe("gui"); - expect(event.thread.parentThreadId).toBe(PARENT); - expect(event.start.threadId).toBe(result.threadId); - expect(event.start.prompt).toBe("fix bug #1"); - expect(event.start.presentationMode).toBe("gui"); - expect(event.start.projectLocation).toEqual(PROJECT); - expect(result.title).toBe("fix bug #1"); - expect(result.worktreePath).toBeUndefined(); - }); - - it("uses the target unrestricted posture with only non-recursive MCPs", async () => { - const h = makeHarness(); - await createChild(h, { effort: "high", fast: true }); - const { start, thread } = h.lastCreated(); - for (const config of [start.config, thread.config]) { - expect(config).not.toHaveProperty("crossagentMcp"); - expect(config).toMatchObject({ - browserMcp: true, - computerUse: true, - chromeMcp: true, - }); - expect(config.model).toBe("gpt-5.5"); - expect(config.effort).toBe("high"); - expect(config.fast).toBe(true); - expect(config.approvalPolicy).toBe("never"); - expect(config.sandboxMode).toBe("danger-full-access"); - } - }); - - it("creates a worktree with a generated poracode/ branch and launches inside it", async () => { - const h = makeHarness(); - const result = await createChild(h, { worktree: true }); - expect(h.worktrees).toHaveLength(1); - const branch = h.worktrees[0]!.branch; - expect(branch).toMatch(/^poracode\/[a-z]+-[a-z]+-[0-9a-f]{8}$/); - const event = h.lastCreated(); - expect(event.isNewWorktree).toBe(true); - expect(event.thread.worktreePath).toBe(`/tmp/worktrees/${branch}`); - expect(event.thread.worktreeBranch).toBe(branch); - expect(event.start.projectLocation).toEqual({ - kind: "posix", - path: `/tmp/worktrees/${branch}`, - }); - expect(result).toBeDefined(); - }); - - it("uses the custom branch + base_branch when given (branch implies worktree)", async () => { - const h = makeHarness(); - await createChild(h, { branch: "feature/PROJ-42", baseBranch: "develop" }); - expect(h.worktrees).toEqual([ - { location: PROJECT, branch: "feature/PROJ-42", baseBranch: "develop" }, - ]); - }); - - it("validates against status-pipeline capabilities, not the adapter's in-memory ones", async () => { - // Live adapter capabilities would reject this model (only gpt-5.5 there); - // the status pipeline — the source the roster advertised from — wins. - const h = makeHarness({ - statusCapabilities: { - models: [{ id: "gpt-6", label: "GPT-6" }], - efforts: ["high"], - modelEfforts: {}, - modes: [], - approvalPolicies: [{ id: "never", label: "Full Access" }], - sandboxModes: [], - supportsResume: false, - supportsDirectInput: true, - bypassPermissions: { approvalPolicy: "never" }, - } as unknown as AgentCapability, - }); - await createChild(h, { model: "gpt-6", effort: "high" }); - expect(h.lastCreated().start.config.model).toBe("gpt-6"); - await expect( - h.manager.createThread(PARENT, { agent: "codex", model: "gpt-5.5", prompt: "x" }), - ).rejects.toThrow("Unknown model: gpt-5.5"); - }); - - it("rejects unknown agents, one-shot-only agents, and empty prompts", async () => { - const h = makeHarness(); - await expect(h.manager.createThread(PARENT, { agent: "nope", prompt: "x" })).rejects.toThrow( - OrchestratorThreadError, - ); - await expect( - h.manager.createThread(PARENT, { agent: "commandcode", prompt: "x" }), - ).rejects.toThrow(/one-shot/); - await expect(h.manager.createThread(PARENT, { agent: "codex", prompt: " " })).rejects.toThrow( - OrchestratorThreadError, - ); - }); - - it("rejects when the parent thread is gone", async () => { - const h = makeHarness(); - await expect( - h.manager.createThread("other-parent", { agent: "codex", prompt: "x" }), - ).rejects.toThrow(/no longer active/); - }); - - it("enforces the live-children cap, and close_thread frees a slot", async () => { - const h = makeHarness(); - const ids: string[] = []; - for (let i = 0; i < MAX_CONCURRENT_CHILD_THREADS_PER_PARENT; i++) { - ids.push(await createChild(h)); - } - await expect(createChild(h)).rejects.toThrow(/live child thread/); - // close_thread tears down a child's session, which frees its slot. - await h.manager.closeThread(PARENT, ids[0]!); - expect(h.closed).toEqual([ids[0]]); - await expect(createChild(h)).resolves.toBeDefined(); - }); - - it("resolves when the session appears only after a thread-state wake", async () => { - const h = makeHarness({ autoLaunch: false }); - const pending = h.manager.createThread(PARENT, { agent: "codex", prompt: "x" }); - const event = h.lastCreated(); - // First wake without a live session: the create must keep pending. - h.manager.observeSupervisorEvent({ - type: "thread-state", - threadId: event.thread.id, - status: "launching", - attention: "none", - canResumeWithConfig: false, - }); - h.states.set(event.thread.id, makeState()); - h.manager.observeSupervisorEvent({ - type: "thread-state", - threadId: event.thread.id, - status: "working", - attention: "working", - canResumeWithConfig: false, - }); - await expect(pending).resolves.toBeDefined(); - }); - - it("keeps the child + its worktree when launch is not yet confirmed (may still be starting)", async () => { - const h = makeHarness({ autoLaunch: false, launchTimeoutMs: 25 }); - await expect( - h.manager.createThread(PARENT, { agent: "codex", prompt: "x", worktree: true }), - ).rejects.toThrow(/has not confirmed launch/); - // The record is KEPT so list_threads can surface it if it appears late, and - // the worktree is preserved — the caller must not recreate it blindly. - expect(h.manager.listThreads(PARENT)).toHaveLength(1); - expect(h.removedWorktrees).toEqual([]); - }); - - it("rolls back the worktree and forgets the child when the create handoff hard-fails", async () => { - const h = makeHarness({ failEmit: true }); - await expect( - h.manager.createThread(PARENT, { agent: "codex", prompt: "x", worktree: true }), - ).rejects.toThrow(/main bridge unavailable/); - // A hard pre-session failure removes the worktree WE created and forgets the child. - expect(h.manager.listThreads(PARENT)).toEqual([]); - expect(h.removedWorktrees).toHaveLength(1); - }); - - it("turns a branch collision into actionable guidance and creates no child", async () => { - const h = makeHarness({ - createWorktreeError: new Error("fatal: a branch named 'PROJ-1' already exists"), - }); - await expect( - h.manager.createThread(PARENT, { agent: "codex", prompt: "x", branch: "PROJ-1" }), - ).rejects.toThrow(/already exists|different `branch`/); - expect(h.manager.listThreads(PARENT)).toEqual([]); - }); -}); - -describe("OrchestratorThreadManager registry scoping", () => { - it("scopes list_threads to the parent's own children", async () => { - const h = makeHarness(); - const childId = await createChild(h); - expect(h.manager.listThreads(PARENT).map((t) => t.threadId)).toEqual([childId]); - expect(h.manager.listThreads("other-parent")).toEqual([]); - }); - - it("rejects access to another parent's child across all tools", async () => { - const h = makeHarness(); - const childId = await createChild(h); - expect(() => h.manager.getThread("intruder", childId)).toThrow(/Unknown thread_id/); - await expect(h.manager.readThread("intruder", childId, 20)).rejects.toThrow( - /Unknown thread_id/, - ); - await expect(h.manager.sendToThread("intruder", childId, "hi", false)).rejects.toThrow( - /Unknown thread_id/, - ); - await expect(h.manager.waitForThreads("intruder", [childId], 10)).rejects.toThrow( - /Unknown thread_id/, - ); - await expect(h.manager.interruptThread("intruder", childId)).rejects.toThrow( - /Unknown thread_id/, - ); - }); - - it("reports live status and worktree metadata in summaries", async () => { - const h = makeHarness(); - const childId = await createChild(h, { branch: "feature/x" }); - h.states.set(childId, makeState({ status: "working", attention: "working" })); - const [summary] = h.manager.listThreads(PARENT); - expect(summary).toMatchObject({ - threadId: childId, - agent: "codex", - status: "working", - attention: "working", - branch: "feature/x", - worktreePath: "/tmp/worktrees/feature/x", - }); - }); - - it("reports inactive once the session is gone", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.states.delete(childId); - expect(h.manager.getThread(PARENT, childId).status).toBe("inactive"); - }); -}); - -describe("OrchestratorThreadManager.rehydrateChildren", () => { - const seed = { - threadId: "restored-child", - parentThreadId: PARENT, - agentKind: "codex" as const, - title: "PORA-123", - worktreePath: "/tmp/worktrees/pora-123", - worktreeBranch: "poracode/pora-123", - createdAt: "2026-07-01T00:00:00.000Z", - }; - - it("re-addresses a persisted child after a restart wiped the registry", async () => { - const h = makeHarness(); - h.manager.rehydrateChildren([seed]); - - expect(h.manager.listThreads(PARENT)).toEqual([ - { - threadId: "restored-child", - title: "PORA-123", - agent: "codex", - status: "inactive", - attention: "none", - createdAt: seed.createdAt, - worktreePath: seed.worktreePath, - branch: seed.worktreeBranch, - }, - ]); - expect(h.manager.getThread(PARENT, "restored-child").status).toBe("inactive"); - // No session and no buffered transcript → the graceful note, not a throw. - await expect(h.manager.readThread(PARENT, "restored-child", 20)).resolves.toMatchObject({ - status: "inactive", - source: "note", - }); - // Its session died with the old supervisor, so input is still rejected — - // but with the closed-session message, not "Unknown thread_id". - await expect(h.manager.sendToThread(PARENT, "restored-child", "hi", false)).rejects.toThrow( - /is not running/, - ); - }); - - it("keeps ownership scoping and picks up live state when the child is relaunched", async () => { - const h = makeHarness(); - h.manager.rehydrateChildren([seed]); - expect(() => h.manager.getThread("intruder", "restored-child")).toThrow(/Unknown thread_id/); - - h.states.set("restored-child", makeState({ status: "working", attention: "working" })); - expect(h.manager.getThread(PARENT, "restored-child").status).toBe("working"); - }); - - it("never overwrites a record created by a live create_thread call", async () => { - const h = makeHarness(); - const childId = await createChild(h, { title: "live title" }); - h.manager.rehydrateChildren([{ ...seed, threadId: childId, title: "stale seed title" }]); - expect(h.manager.getThread(PARENT, childId).title).toBe("live title"); - }); -}); - -describe("OrchestratorThreadManager.waitForThreads", () => { - it("returns immediately when a thread is already settled", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.states.set(childId, makeState({ status: "idle" })); - const result = await h.manager.waitForThreads(PARENT, [childId], 60_000); - expect(result).toEqual({ - statuses: { [childId]: { status: "idle", attention: "none" } }, - settled: [childId], - timedOut: false, - }); - }); - - it("times out while all listed threads stay busy", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.states.set(childId, makeState({ status: "working" })); - const result = await h.manager.waitForThreads(PARENT, [childId], 30); - expect(result.timedOut).toBe(true); - expect(result.settled).toEqual([]); - expect(result.statuses[childId]!.status).toBe("working"); - }); - - it("wakes when ANY listed thread settles via a thread-state event", async () => { - const h = makeHarness(); - const a = await createChild(h); - const b = await createChild(h); - h.states.set(a, makeState({ status: "working" })); - h.states.set(b, makeState({ status: "working" })); - - const pending = h.manager.waitForThreads(PARENT, [a, b], 10_000); - h.states.set(b, makeState({ status: "needs_approval", attention: "needs_approval" })); - h.manager.observeSupervisorEvent({ - type: "thread-state", - threadId: b, - status: "needs_approval", - attention: "needs_approval", - canResumeWithConfig: false, - }); - const result = await pending; - expect(result.timedOut).toBe(false); - expect(result.settled).toEqual([b]); - expect(result.statuses).toEqual({ - [a]: { status: "working", attention: "none" }, - [b]: { status: "needs_approval", attention: "needs_approval" }, - }); - }); - - it("validates the 1..8 thread_ids bound", async () => { - const h = makeHarness(); - await expect(h.manager.waitForThreads(PARENT, [], 10)).rejects.toThrow(/between 1 and 8/); - }); -}); - -describe("OrchestratorThreadManager.sendToThread", () => { - it("starts a new turn when the child is idle", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.states.set(childId, makeState({ status: "idle", config: { model: "child-model" } })); - const result = await h.manager.sendToThread(PARENT, childId, "next step", false); - expect(result.delivery).toBe("started_turn"); - expect(h.sent).toEqual([ - { threadId: childId, prompt: "next step", config: { model: "child-model" } }, - ]); - }); - - it("steers when working and the session supports steer", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.states.set(childId, makeState({ status: "working", supportsSteer: true })); - const result = await h.manager.sendToThread(PARENT, childId, "also do X", false); - expect(result.delivery).toBe("steered"); - expect(h.sent).toHaveLength(1); - }); - - it("errors when working without steer support and interrupt=false", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.states.set(childId, makeState({ status: "working", supportsSteer: false })); - await expect(h.manager.sendToThread(PARENT, childId, "msg", false)).rejects.toThrow( - /does not support steering/, - ); - expect(h.sent).toEqual([]); - }); - - it("interrupts then sends when interrupt=true", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.states.set(childId, makeState({ status: "working", supportsSteer: false })); - const pending = h.manager.sendToThread(PARENT, childId, "stop and pivot", true); - // Simulate the interrupt landing: the thread leaves `working`. - h.states.set(childId, makeState({ status: "idle" })); - h.manager.observeSupervisorEvent({ - type: "thread-state", - threadId: childId, - status: "idle", - attention: "none", - canResumeWithConfig: false, - }); - const result = await pending; - expect(result.delivery).toBe("interrupted_and_sent"); - expect(h.interrupted).toEqual([childId]); - expect(h.sent).toHaveLength(1); - }); - - it("errors when the child session is gone", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.states.delete(childId); - await expect(h.manager.sendToThread(PARENT, childId, "msg", false)).rejects.toThrow( - /not running/, - ); - }); -}); - -describe("OrchestratorThreadManager.readThread", () => { - it("returns the transcript tail with truncated bodies", async () => { - const h = makeHarness(); - const childId = await createChild(h); - const long = "x".repeat(10_000); - h.setHistory({ - providerSessionId: "session-1", - messages: [ - { messageId: "m1", role: "user", parts: [{ type: "text", text: "hello" }], info: {} }, - { messageId: "m2", role: "assistant", parts: [{ type: "text", text: long }], info: {} }, - ], - }); - const result = await h.manager.readThread(PARENT, childId, 20); - if (result.source !== "native") throw new Error("expected native transcript"); - expect(result.messageCount).toBe(2); - expect(result.messages[0]).toEqual({ role: "user", text: "hello" }); - expect(result.messages[1]!.text.length).toBeLessThan(5_000); - expect(result.messages[1]!.text).toContain("[truncated]"); - }); - - it("slices to the requested tail, clamped to 1..100", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.setHistory({ - providerSessionId: "session-1", - messages: Array.from({ length: 5 }, (_, i) => ({ - messageId: `m${i}`, - role: "assistant" as const, - parts: [`message ${i}`], - info: {}, - })), - }); - const result = await h.manager.readThread(PARENT, childId, 2); - if (result.source !== "native") throw new Error("expected native transcript"); - expect(result.messages.map((m) => m.text)).toEqual(["message 3", "message 4"]); - }); - - it("serves the buffered transcript when the adapter lacks native readThread", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.setHistory(undefined); - // Feed a couple of runtime items so the structured buffer is non-empty. - h.manager.observeSupervisorEvent({ - type: "thread-runtime-events", - threadId: childId, - events: [ - { - type: "item.completed", - threadId: childId, - itemId: "a1", - payload: { content: [{ kind: "text", text: "did the thing" }] }, - }, - ], - }); - const result = await h.manager.readThread(PARENT, childId, 20); - if (result.source !== "buffer") throw new Error("expected buffered transcript"); - expect(result.entries.at(-1)).toEqual({ kind: "assistant", text: "did the thing" }); - }); - - it("returns a note when nothing has been recorded and there is no native transcript", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.setHistory(undefined); - const result = await h.manager.readThread(PARENT, childId, 20); - expect(result.source).toBe("note"); - }); -}); - -describe("OrchestratorThreadManager.interruptThread + output tail", () => { - it("interrupts an owned live child", async () => { - const h = makeHarness(); - const childId = await createChild(h); - await h.manager.interruptThread(PARENT, childId); - expect(h.interrupted).toEqual([childId]); - }); - - it("errors when interrupting a closed child", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.states.delete(childId); - await expect(h.manager.interruptThread(PARENT, childId)).rejects.toThrow(/not running/); - }); - - it("keeps a bounded assistant-output tail from runtime events", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.manager.observeSupervisorEvent({ - type: "thread-runtime-events", - threadId: childId, - events: [ - { - type: "content.delta", - threadId: childId, - itemId: "m", - stream: "assistant_text", - delta: "working on it: ", - }, - { - type: "content.delta", - threadId: childId, - itemId: "m", - stream: "assistant_text", - delta: "a".repeat(5_000), - }, - ], - }); - const summary = h.manager.getThread(PARENT, childId); - expect(summary.recentOutput).toBeDefined(); - expect(summary.recentOutput!.length).toBeLessThanOrEqual(2_000); - expect(summary.recentOutput!.endsWith("a")).toBe(true); - }); -}); - -describe("OrchestratorThreadManager failure reason + final result", () => { - it("captures the failure reason from thread-state and clears it when work resumes", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.states.set(childId, makeState({ status: "error", attention: "error" })); - h.manager.observeSupervisorEvent({ - type: "thread-state", - threadId: childId, - status: "error", - attention: "error", - canResumeWithConfig: false, - errorMessage: "provider auth failed", - }); - expect(h.manager.getThread(PARENT, childId).error).toBe("provider auth failed"); - - // A fresh turn clears the stale reason so it doesn't linger. - h.states.set(childId, makeState({ status: "working", attention: "working" })); - h.manager.observeSupervisorEvent({ - type: "thread-state", - threadId: childId, - status: "working", - attention: "working", - canResumeWithConfig: false, - }); - expect(h.manager.getThread(PARENT, childId).error).toBeUndefined(); - }); - - it("captures a durable final_result that survives close_thread", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.manager.observeSupervisorEvent({ - type: "thread-runtime-events", - threadId: childId, - events: [ - { - type: "content.delta", - threadId: childId, - itemId: "m", - stream: "assistant_text", - delta: "Ticket done: shipped the fix.", - }, - { type: "turn.completed", threadId: childId, turnId: "t1", state: "completed" }, - ], - }); - await h.manager.closeThread(PARENT, childId); - const summary = h.manager.getThread(PARENT, childId); - // Session is gone, but the collected result remains for the orchestrator. - expect(summary.status).toBe("inactive"); - expect(summary.finalResult).toBe("Ticket done: shipped the fix."); - }); - - it("captures the parent conclusion instead of a later nested subagent message", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.manager.observeSupervisorEvent({ - type: "thread-runtime-events", - threadId: childId, - events: [ - { - type: "item.started", - threadId: childId, - itemId: "parent-answer", - itemType: "assistant_message", - }, - { - type: "content.delta", - threadId: childId, - itemId: "parent-answer", - stream: "assistant_text", - delta: "Parent final answer.", - }, - { type: "item.completed", threadId: childId, itemId: "parent-answer" }, - { - type: "item.started", - threadId: childId, - itemId: "nested-progress", - itemType: "assistant_message", - parentItemId: "subagent-tool", - }, - { - type: "content.delta", - threadId: childId, - itemId: "nested-progress", - stream: "assistant_text", - delta: "Agent: trailing child label", - }, - { type: "item.completed", threadId: childId, itemId: "nested-progress" }, - { type: "turn.completed", threadId: childId, turnId: "t1", state: "completed" }, - ], - }); - - const summary = h.manager.getThread(PARENT, childId); - expect(summary.recentOutput).toBe("Parent final answer."); - expect(summary.finalResult).toBe("Parent final answer."); - }); -}); - -describe("OrchestratorThreadManager.sendToThread approval handling", () => { - it("refuses to send into a needs_approval child but answers a needs_reply", async () => { - const h = makeHarness(); - const childId = await createChild(h); - h.states.set(childId, makeState({ status: "needs_approval", attention: "needs_approval" })); - await expect(h.manager.sendToThread(PARENT, childId, "approved", false)).rejects.toThrow( - /approval/, - ); - expect(h.sent).toEqual([]); - - // needs_reply is a question to the caller → answering starts a turn. - h.states.set( - childId, - makeState({ - status: "needs_reply", - attention: "needs_reply", - config: { model: "child-model" }, - }), - ); - const result = await h.manager.sendToThread(PARENT, childId, "use option B", false); - expect(result.delivery).toBe("started_turn"); - expect(h.sent).toHaveLength(1); - }); -}); diff --git a/src/supervisor/crossagentMcp/OrchestratorThreadManager.ts b/src/supervisor/crossagentMcp/OrchestratorThreadManager.ts deleted file mode 100644 index 4d7e3cb7..00000000 --- a/src/supervisor/crossagentMcp/OrchestratorThreadManager.ts +++ /dev/null @@ -1,897 +0,0 @@ -import { randomUUID } from "node:crypto"; -import type { - AgentCapability, - AgentKind, - OrchestratorChildSeed, - ProjectLocation, - RuntimeEvent, - SendThreadInputPayload, - StartThreadPayload, - Thread, - ThreadAttention, - ThreadConfig, - ThreadStatus, - McpLaunchSnapshot, -} from "@/shared/contracts"; -import { capabilitiesForPresentation, validateAgentModelSelection } from "@/shared/agentSelection"; -import { DEFAULT_TERMINAL_SIZE } from "@/shared/contracts"; -import type { SupervisorEvent } from "@/shared/ipc/events"; -import { makeThreadTitle } from "@/shared/threadTitle"; -import { buildWorktreeLocation } from "@/shared/worktree"; -import { generateWorktreeBranch } from "@/shared/worktreeBranch"; -import type { AgentAdapter, ThreadHistory } from "@/supervisor/agents/base"; -import { ChildTranscriptBuffer } from "./childTranscriptBuffer"; -import type { TranscriptEntry } from "./childTranscriptBuffer"; -import { truncate } from "./toolResult"; -import { buildUnrestrictedChildConfig, resolveSubagentExecution } from "./types"; - -/** - * Max live (session-active) orchestrator child threads per parent. Higher than - * the ephemeral subagent cap (4) because the motivating use case is an - * orchestrator working a batch of tickets, one worktree-backed thread each. - */ -export const MAX_CONCURRENT_CHILD_THREADS_PER_PARENT = 8; - -/** How long `create_thread` waits for the main process to launch the child. */ -export const CHILD_THREAD_LAUNCH_TIMEOUT_MS = 30_000; - -/** Rolling per-child assistant-output tail kept for `get_thread`. */ -const ASSISTANT_TAIL_MAX_CHARS = 2_000; - -/** Per-message body cap for `read_thread` so transcripts can't blow up the caller's context. */ -const MESSAGE_TEXT_MAX_CHARS = 4_000; - -/** - * Cap on the durable `finalResult` snapshot captured at turn end. Generous - * (unlike the rolling tail) so a child's concluding answer survives intact for - * the orchestrator to collect — even after the child's session is closed. - */ -const FINAL_RESULT_MAX_CHARS = 16_000; - -/** Statuses `wait_for_thread` treats as settled (turn finished or needs the caller). */ -const SETTLED_STATUSES: ReadonlySet = new Set([ - "idle", - "finished", - "needs_approval", - "needs_reply", - "error", - "inactive", -]); - -/** A synchronous validation/orchestration failure surfaced as an MCP tool error. */ -export class OrchestratorThreadError extends Error {} - -/** Live-session view of a child thread, resolved from the thread session manager. */ -export interface OrchestratorThreadState { - status: ThreadStatus; - attention: ThreadAttention; - config: ThreadConfig; - /** Whether the session supports non-interrupting steer while working. */ - supportsSteer: boolean; -} - -/** - * Host surface the orchestrator manager needs from the supervisor's thread - * session manager. Kept minimal (thin hooks only) so the TSM stays lean. - */ -export interface OrchestratorThreadHost { - /** Resolve a live parent thread's project and non-recursive MCP context. */ - getParentContext(threadId: string): - | { - projectLocation: ProjectLocation; - config: ThreadConfig; - mcpLaunchSnapshot: McpLaunchSnapshot; - } - | undefined; - /** Live runtime state of a thread; `undefined` once its session is gone. */ - getThreadState(threadId: string): OrchestratorThreadState | undefined; - /** Provider transcript when the session's adapter supports `readThread`. */ - readThreadHistory(threadId: string): Promise; - /** Start/steer a turn on a live thread (routes through the TSM's normal input path). */ - sendThreadInput(payload: SendThreadInputPayload): Promise; - /** Interrupt a live thread's current turn (the thread stays addressable). */ - interruptThread(threadId: string): Promise; - /** - * Close a child's runtime session (frees its capacity slot). The persisted - * thread row + its git worktree remain — this does not delete the child's work. - */ - closeThread(threadId: string): Promise; -} - -export interface OrchestratorThreadManagerDeps { - adapters: Map; - host: OrchestratorThreadHost; - /** - * Resolved provider capabilities from the agent-status pipeline — the same - * source the composer and the `list_agents`/`get_agent` roster are served - * from. Preferred over the adapter's in-memory capabilities so `create_thread` - * accepts exactly the models the roster advertised. `undefined` (no cache - * entry / not authenticated) falls back to the live adapter capabilities. - */ - getStatusCapabilities?: (kind: AgentKind) => AgentCapability | undefined; - /** Supervisor event channel — carries `orchestrator-thread-created` to main. */ - emit(event: SupervisorEvent): void; - /** Create a git worktree (new branch) in the parent's repo; returns its path. */ - createWorktree(input: { - location: ProjectLocation; - branch: string; - baseBranch?: string; - }): Promise<{ path: string }>; - /** - * Remove a git worktree (force + delete branch) — used only to roll back a - * worktree created by a `create_thread` call that then failed to launch. - */ - removeWorktree(input: { location: ProjectLocation; path: string }): Promise; - /** Test hook: overrides {@link CHILD_THREAD_LAUNCH_TIMEOUT_MS}. */ - launchTimeoutMs?: number; -} - -/** Arguments accepted by the `create_thread` tool. */ -export interface CreateChildThreadRequest { - agent: string; - prompt: string; - title?: string; - model?: string; - effort?: string; - fast?: boolean; - worktree?: boolean; - branch?: string; - baseBranch?: string; -} - -export interface CreateChildThreadResult { - threadId: string; - title: string; - worktreePath?: string; - branch?: string; -} - -export interface ChildThreadSummary { - threadId: string; - title: string; - agent: string; - status: ThreadStatus; - attention: ThreadAttention; - createdAt: string; - worktreePath?: string; - branch?: string; - /** Latest failure reason from the child's `thread-state` (cleared while it works). */ - error?: string; - /** When the child's current turn started (progress signal). */ - turnStartedAt?: string; -} - -/** Per-thread settled-state bits returned by `wait_for_thread`. */ -export interface ThreadStatusBits { - status: ThreadStatus; - attention: ThreadAttention; - error?: string; -} - -interface ChildThreadRecord { - threadId: string; - parentThreadId: string; - agent: string; - title: string; - worktreePath?: string; - branch?: string; - lastStatus: ThreadStatus; - lastAttention: ThreadAttention; - /** Latest failure reason from `thread-state` (cleared when the child works again). */ - lastError: string | undefined; - /** Durable snapshot of the child's concluding assistant message (survives close). */ - finalResult: string | undefined; - /** ISO timestamp the child was created. */ - createdAt: string; - /** ISO timestamp the child's current/last turn began (set on → working). */ - turnStartedAt: string | undefined; - /** Compact structured transcript for `read_thread` (works without native readThread). */ - transcript: ChildTranscriptBuffer; -} - -interface StatusWaiter { - threadIds: ReadonlySet; - wake(): void; -} - -/** Internal marker so `createThread` can distinguish a launch timeout from a hard failure. */ -class LaunchTimeoutError extends OrchestratorThreadError {} - -/** - * Orchestrator lane of the Crossagents MCP: lets a parent thread create and - * manage REAL first-class app threads (persisted rows, sidebar-visible, - * optionally worktree-backed), as opposed to the ephemeral in-memory runs - * owned by {@link SubagentRunManager}. - * - * Thread creation is main-orchestrated: this manager emits an - * `orchestrator-thread-created` supervisor event carrying the child row + a - * ready `startThread` payload; the main process resolves the projectId from - * the parent's DB row, upserts the child, mirrors it to the renderer, and - * calls back into the supervisor's `startThread` — the exact ordering the - * proven remote (mobile) start path uses. `create_thread` then resolves once - * the child's session appears in the thread session manager. - * - * The parent→children registry is in-memory, but child parenthood is also - * persisted on the thread rows: at every supervisor (re)start, main pushes the - * persisted child rows back via {@link rehydrateChildren} so a resumed parent - * can still address its children (their in-memory transcript/final-result - * snapshots do not survive the restart). Child threads themselves are durable - * app threads and survive independently either way. - */ -export class OrchestratorThreadManager { - private readonly children = new Map(); - private readonly waiters = new Set(); - - constructor(private readonly deps: OrchestratorThreadManagerDeps) {} - - /** Agent kinds eligible for `create_thread` (full structured/GUI sessions). */ - private structuredAgentKinds(): string[] { - const kinds: string[] = []; - for (const [kind, adapter] of this.deps.adapters) { - if (resolveSubagentExecution(adapter) === "structured") kinds.push(kind); - } - return kinds; - } - - /** - * Create a first-class child thread (optionally in a fresh git worktree) and - * initiate its launch. Resolves once the child's session is live in the - * thread session manager; does NOT wait for the child's first turn to finish. - */ - async createThread( - parentThreadId: string, - request: CreateChildThreadRequest, - ): Promise { - const prompt = request.prompt?.trim(); - if (!prompt) throw new OrchestratorThreadError("prompt is required"); - const adapter = this.deps.adapters.get(request.agent as AgentKind); - if (!adapter) { - throw new OrchestratorThreadError( - `Unknown provider: ${request.agent}. Call list_agents for the available providers.`, - ); - } - if (resolveSubagentExecution(adapter) !== "structured") { - throw new OrchestratorThreadError( - `Provider ${request.agent} only supports one-shot subagent runs and cannot host a full thread. ` + - `Providers eligible for create_thread: ${this.structuredAgentKinds().join(", ") || "none"}.`, - ); - } - const parent = this.deps.host.getParentContext(parentThreadId); - if (!parent) throw new OrchestratorThreadError("Parent thread is no longer active"); - const live = this.liveChildCount(parentThreadId); - if (live >= MAX_CONCURRENT_CHILD_THREADS_PER_PARENT) { - throw new OrchestratorThreadError( - `You already have ${live} live child thread(s) — the max is ${MAX_CONCURRENT_CHILD_THREADS_PER_PARENT}. ` + - "A finished child keeps holding its slot until you free it: call close_thread on a completed thread " + - "(check list_threads / wait_for_thread for which are done) to make room, then retry create_thread. " + - "Waiting does not free a slot — only close_thread (or the child's session ending) does.", - ); - } - - const capabilities = capabilitiesForPresentation( - this.deps.getStatusCapabilities?.(adapter.kind) ?? adapter.capabilities, - "gui", - ); - const model = request.model ?? capabilities.models[0]?.id; - if (!model) { - throw new OrchestratorThreadError(`Provider ${request.agent} has no available models`); - } - const selectionError = validateAgentModelSelection(capabilities, { - model, - ...(request.effort ? { reasoning: request.effort } : {}), - ...(request.fast === true ? { fast: true } : {}), - }); - if (selectionError) throw new OrchestratorThreadError(selectionError); - - // A custom branch/base_branch implies the caller wants a worktree. - let worktreePath: string | undefined; - let branch: string | undefined; - if (request.worktree || request.branch || request.baseBranch) { - branch = request.branch?.trim() || generateWorktreeBranch(); - const baseBranch = request.baseBranch?.trim(); - try { - const created = await this.deps.createWorktree({ - location: parent.projectLocation, - branch, - ...(baseBranch ? { baseBranch } : {}), - }); - worktreePath = created.path; - } catch (error) { - // No child thread exists yet — turn the raw git failure into actionable - // guidance (branch collisions poison ticket-keyed retries otherwise). - throw describeWorktreeFailure(error, branch); - } - } - - const threadId = randomUUID(); - const customTitle = request.title?.trim(); - const title = customTitle || makeThreadTitle(prompt) || "New thread"; - // Child uses the target provider's unrestricted posture and inherits the - // parent's non-recursive MCPs. Crossagents MCP is intentionally omitted so - // children cannot spawn grandchildren. - const childConfig = buildUnrestrictedChildConfig( - { - model, - ...(request.effort ? { effort: request.effort } : {}), - ...(request.fast === true ? { fast: true } : {}), - }, - capabilities, - parent.config, - ); - const childLocation = worktreePath - ? buildWorktreeLocation(parent.projectLocation, worktreePath) - : parent.projectLocation; - - const now = new Date().toISOString(); - const thread: Omit = { - id: threadId, - title, - agentKind: request.agent as AgentKind, - config: childConfig, - status: "launching", - attention: "none", - canResumeWithConfig: false, - archived: false, - done: false, - starred: false, - presentationMode: "gui", - threadStatusSource: "server", - ...(worktreePath ? { worktreePath } : {}), - ...(branch ? { worktreeBranch: branch } : {}), - parentThreadId, - createdAt: now, - updatedAt: now, - activeTurnStartedAt: now, - }; - const start: StartThreadPayload = { - threadId, - projectLocation: childLocation, - agentKind: request.agent as AgentKind, - config: childConfig, - prompt, - initialSize: DEFAULT_TERMINAL_SIZE, - presentationMode: "gui", - ...parent.mcpLaunchSnapshot, - }; - - const record: ChildThreadRecord = { - threadId, - parentThreadId, - agent: request.agent, - title, - ...(worktreePath ? { worktreePath } : {}), - ...(branch ? { branch } : {}), - lastStatus: "launching", - lastAttention: "none", - lastError: undefined, - finalResult: undefined, - createdAt: now, - turnStartedAt: undefined, - transcript: new ChildTranscriptBuffer(), - }; - this.children.set(threadId, record); - - // Hard failure in the create/launch handoff (emit throws) → no session can - // appear: roll back the worktree WE created this call and forget the record. - try { - this.deps.emit({ - type: "orchestrator-thread-created", - parentThreadId, - thread, - start, - ...(worktreePath ? { isNewWorktree: true } : {}), - ...(customTitle ? { hasCustomTitle: true } : {}), - }); - } catch (error) { - await this.cleanupFailedLaunch( - threadId, - worktreePath ? { location: parent.projectLocation, path: worktreePath } : undefined, - ); - throw error; - } - - try { - await this.waitForLaunch( - threadId, - this.deps.launchTimeoutMs ?? CHILD_THREAD_LAUNCH_TIMEOUT_MS, - ); - } catch (error) { - if (error instanceof LaunchTimeoutError) { - // The child may still come up (main can finish startThread after we stop - // waiting). KEEP the record so list_threads can surface it if it appears, - // and KEEP the worktree. A never-launched record can't wedge the cap: - // liveChildCount is gated on getThreadState, undefined until a session exists. - throw new OrchestratorThreadError( - `Child thread ${threadId} has not confirmed launch yet — it may still be starting in the app. ` + - "Do NOT recreate it blindly: check list_threads first (it appears there once/if it launches) " + - "so you don't create a duplicate." + - (branch ? ` Its worktree (branch "${branch}") is preserved.` : ""), - ); - } - // Any other failure before a session exists → treat as a hard failure. - await this.cleanupFailedLaunch( - threadId, - worktreePath ? { location: parent.projectLocation, path: worktreePath } : undefined, - ); - throw error; - } - - return { - threadId, - title, - ...(worktreePath ? { worktreePath } : {}), - ...(branch ? { branch } : {}), - }; - } - - /** - * Re-register persisted child threads after a supervisor restart wiped the - * in-memory registry. Records created by live `create_thread` calls win over - * seeds; a seeded child starts `inactive` with an empty transcript (its - * session died with the previous supervisor) and its live state is resolved - * through `getThreadState` as usual if it is relaunched. - */ - rehydrateChildren(children: ReadonlyArray): void { - for (const child of children) { - if (this.children.has(child.threadId)) continue; - this.children.set(child.threadId, { - threadId: child.threadId, - parentThreadId: child.parentThreadId, - agent: child.agentKind, - title: child.title, - ...(child.worktreePath ? { worktreePath: child.worktreePath } : {}), - ...(child.worktreeBranch ? { branch: child.worktreeBranch } : {}), - lastStatus: "inactive", - lastAttention: "none", - lastError: undefined, - finalResult: undefined, - createdAt: child.createdAt, - turnStartedAt: undefined, - transcript: new ChildTranscriptBuffer(), - }); - } - } - - /** Forget a failed child and roll back the worktree this call created (best effort). */ - private async cleanupFailedLaunch( - threadId: string, - worktree: { location: ProjectLocation; path: string } | undefined, - ): Promise { - this.children.delete(threadId); - if (!worktree) return; - try { - await this.deps.removeWorktree(worktree); - } catch (cleanupError) { - // Never mask the original failure with a cleanup error. - console.warn( - `[orchestrator] failed to remove worktree ${worktree.path} after a failed launch:`, - cleanupError, - ); - } - } - - /** - * Close a finished child's runtime session to free a capacity slot. The - * persisted thread row + its worktree stay in the app for the human, and the - * record is retained so post-close get_thread/read_thread still return the - * captured final result. - */ - async closeThread(parentThreadId: string, threadId: string): Promise { - this.requireChild(parentThreadId, threadId); - await this.deps.host.closeThread(threadId); - } - - /** Children of `parentThreadId` with their current status. */ - listThreads(parentThreadId: string): ChildThreadSummary[] { - const out: ChildThreadSummary[] = []; - for (const record of this.children.values()) { - if (record.parentThreadId !== parentThreadId) continue; - out.push(this.summarize(record)); - } - return out; - } - - /** Status + attention + recent output tail + durable final result for one owned child. */ - getThread( - parentThreadId: string, - threadId: string, - ): ChildThreadSummary & { recentOutput?: string; finalResult?: string } { - const record = this.requireChild(parentThreadId, threadId); - // Derive the recent-output tail lazily from the transcript's last assistant - // message (a cold read) instead of maintaining a second per-token accumulator. - const tail = record.transcript.lastAssistantMessage().slice(-ASSISTANT_TAIL_MAX_CHARS).trim(); - const finalResult = record.finalResult?.trim(); - return { - ...this.summarize(record), - ...(tail ? { recentOutput: tail } : {}), - ...(finalResult ? { finalResult } : {}), - }; - } - - /** - * Transcript tail for an owned child. Prefers the session's native - * `readThread` (highest fidelity, e.g. opencode) while it's live; otherwise - * serves the buffered structured transcript built from the child's runtime - * events — so Claude/Codex/ACP children (and closed sessions) still return - * something useful. Falls back to a graceful note only when nothing is buffered. - * Message/entry bodies are truncated so the result can't blow up the caller's context. - */ - async readThread( - parentThreadId: string, - threadId: string, - lastMessages: number, - ): Promise< - | { status: ThreadStatus; source: "note"; note: string } - | { - status: ThreadStatus; - source: "native"; - messageCount: number; - messages: Array<{ role: "user" | "assistant"; text: string }>; - } - | { - status: ThreadStatus; - source: "buffer"; - entryCount: number; - entries: TranscriptEntry[]; - } - > { - const record = this.requireChild(parentThreadId, threadId); - const status = this.currentStatus(record); - const count = Math.min(Math.max(Math.trunc(lastMessages) || 20, 1), 100); - const history = this.deps.host.getThreadState(threadId) - ? await this.deps.host.readThreadHistory(threadId) - : undefined; - if (history) { - const messages = history.messages.slice(-count).map((message) => ({ - role: message.role, - text: truncate(renderHistoryParts(message.parts), MESSAGE_TEXT_MAX_CHARS), - })); - return { status, source: "native", messageCount: history.messages.length, messages }; - } - const entries = record.transcript.snapshot(count); - if (entries.length > 0) { - return { status, source: "buffer", entryCount: record.transcript.size, entries }; - } - return { - status, - source: "note", - note: - "No transcript is available yet for this thread (it has produced no recorded messages, tool calls, or errors). " + - "Use get_thread for its status and recent output.", - }; - } - - /** - * Deliver a message to an owned child: start a turn when it's settled, steer - * when it's working and the session supports steering, or interrupt-then-send - * when `interrupt` is set. Errors when the child is busy and can't be steered. - */ - async sendToThread( - parentThreadId: string, - threadId: string, - message: string, - interrupt: boolean, - ): Promise<{ delivery: "started_turn" | "steered" | "interrupted_and_sent" }> { - this.requireChild(parentThreadId, threadId); - const prompt = message.trim(); - if (!prompt) throw new OrchestratorThreadError("message is required"); - const state = this.deps.host.getThreadState(threadId); - if (!state) { - throw new OrchestratorThreadError( - `Thread ${threadId} is not running (its session was closed in the app), so it cannot receive input.`, - ); - } - if (state.status === "needs_approval") { - // A tool-permission prompt is pending; this tool has no requestId to - // resolve it. Sending here would silently start a NEW turn (false success) - // rather than approving the tool. `needs_reply` is different (a question) — - // that falls through to the normal start-a-turn path below. - throw new OrchestratorThreadError( - `Thread ${threadId} is blocked waiting for a tool-permission approval that send_to_thread cannot resolve. ` + - "A human must approve or deny it in the app UI. Subagent threads request Full access, but the " + - "provider can still surface an approval prompt that this tool cannot answer. This is not a message prompt.", - ); - } - const payload: SendThreadInputPayload = { threadId, prompt, config: state.config }; - const busy = state.status === "working" || state.status === "launching"; - if (busy && interrupt) { - await this.deps.host.interruptThread(threadId); - await this.waitWhileWorking(threadId, 5_000); - await this.deps.host.sendThreadInput(payload); - return { delivery: "interrupted_and_sent" }; - } - if (busy) { - if (state.status === "working" && state.supportsSteer) { - await this.deps.host.sendThreadInput(payload); - return { delivery: "steered" }; - } - throw new OrchestratorThreadError( - `Thread ${threadId} is ${state.status} and does not support steering. ` + - "Retry with interrupt=true, or wait_for_thread first.", - ); - } - await this.deps.host.sendThreadInput(payload); - return { delivery: "started_turn" }; - } - - /** - * Block until ANY listed child settles (idle | finished | needs_approval | - * needs_reply | error, or its session is gone) or the timeout elapses. - * Returns immediately when one is already settled. Event-driven (woken by - * `thread-state` supervisor events), no tight polling. - */ - async waitForThreads( - parentThreadId: string, - threadIds: string[], - timeoutMs: number, - ): Promise<{ - statuses: Record; - settled: string[]; - timedOut: boolean; - }> { - if (threadIds.length < 1 || threadIds.length > 8) { - throw new OrchestratorThreadError("thread_ids must list between 1 and 8 threads"); - } - const records = threadIds.map((id) => this.requireChild(parentThreadId, id)); - const settledResult = await this.waitUntil(threadIds, timeoutMs, () => { - const snap = this.snapshotStatuses(records); - return snap.settled.length > 0 ? snap : undefined; - }); - if (settledResult) return { ...settledResult, timedOut: false }; - return { ...this.snapshotStatuses(records), timedOut: true }; - } - - /** Current status/settled snapshot for a set of child records. */ - private snapshotStatuses(records: ChildThreadRecord[]): { - statuses: Record; - settled: string[]; - } { - const statuses: Record = {}; - const settled: string[] = []; - for (const record of records) { - const bits = this.statusBits(record); - statuses[record.threadId] = bits; - if (SETTLED_STATUSES.has(bits.status)) settled.push(record.threadId); - } - return { statuses, settled }; - } - - /** Interrupt an owned child's current turn; the thread stays addressable. */ - async interruptThread(parentThreadId: string, threadId: string): Promise { - this.requireChild(parentThreadId, threadId); - if (!this.deps.host.getThreadState(threadId)) { - throw new OrchestratorThreadError( - `Thread ${threadId} is not running (its session was closed in the app).`, - ); - } - await this.deps.host.interruptThread(threadId); - } - - /** - * Tap on the supervisor's outbound event stream (wired in the runtime's - * `emit` plumbing): tracks child status transitions, wakes waiters, and keeps - * a bounded assistant-output tail per child. Cheap no-op for non-child events. - */ - observeSupervisorEvent(event: SupervisorEvent): void { - switch (event.type) { - case "thread-state": { - const record = this.children.get(event.threadId); - if (record) { - const wasWorking = record.lastStatus === "working"; - record.lastStatus = event.status; - record.lastAttention = event.attention; - if (event.status === "working" || event.status === "launching") { - // A fresh turn started — stale failure reasons no longer apply. - record.lastError = undefined; - if (event.status === "working" && !wasWorking) { - record.turnStartedAt = new Date().toISOString(); - } - } else { - if (event.errorMessage) record.lastError = event.errorMessage; - // Fallback capture of the final result if no turn.completed was seen. - if (event.status === "idle" || event.status === "finished") { - this.captureFinalResult(record); - } - } - } - // Common case is zero waiters (no orchestrator wait in flight) — skip - // the loop entirely. wake() only deletes the current waiter, which is - // safe to do while iterating a Set directly (no defensive copy needed). - if (this.waiters.size > 0) { - for (const waiter of this.waiters) { - if (waiter.threadIds.has(event.threadId)) waiter.wake(); - } - } - return; - } - case "thread-runtime-event": - this.trackRuntimeEvents(event.threadId, [event.event]); - return; - case "thread-runtime-events": - this.trackRuntimeEvents(event.threadId, event.events); - return; - case "thread-runtime-events-multi": - for (const batch of event.batches) this.trackRuntimeEvents(batch.threadId, batch.events); - return; - default: - return; - } - } - - private trackRuntimeEvents(threadId: string, events: ReadonlyArray): void { - const record = this.children.get(threadId); - if (!record) return; - for (const event of events) { - record.transcript.ingest(event); - if (event.type === "turn.completed") this.captureFinalResult(record); - } - } - - /** Snapshot the child's concluding assistant message as a durable final result. */ - private captureFinalResult(record: ChildThreadRecord): void { - const text = record.transcript.lastAssistantMessage().trim(); - if (text) record.finalResult = truncate(text, FINAL_RESULT_MAX_CHARS); - } - - private summarize(record: ChildThreadRecord): ChildThreadSummary { - const live = this.deps.host.getThreadState(record.threadId); - return { - threadId: record.threadId, - title: record.title, - agent: record.agent, - status: live?.status ?? "inactive", - attention: live?.attention ?? record.lastAttention, - createdAt: record.createdAt, - ...(record.worktreePath ? { worktreePath: record.worktreePath } : {}), - ...(record.branch ? { branch: record.branch } : {}), - ...(record.lastError ? { error: record.lastError } : {}), - ...(record.turnStartedAt ? { turnStartedAt: record.turnStartedAt } : {}), - }; - } - - /** Status + attention + failure reason bits used by `wait_for_thread` (subset of the summary). */ - private statusBits(record: ChildThreadRecord): ThreadStatusBits { - const { status, attention, error } = this.summarize(record); - return { status, attention, ...(error ? { error } : {}) }; - } - - /** Live session status, or `inactive` once the child's session is gone. */ - private currentStatus(record: ChildThreadRecord): ThreadStatus { - return this.deps.host.getThreadState(record.threadId)?.status ?? "inactive"; - } - - private liveChildCount(parentThreadId: string): number { - let count = 0; - for (const record of this.children.values()) { - if (record.parentThreadId !== parentThreadId) continue; - if (this.deps.host.getThreadState(record.threadId)) count += 1; - } - return count; - } - - private requireChild(parentThreadId: string, threadId: string): ChildThreadRecord { - const record = this.children.get(threadId); - if (!record || record.parentThreadId !== parentThreadId) { - throw new OrchestratorThreadError( - `Unknown thread_id: ${threadId}. Only threads you created via create_thread are addressable (see list_threads).`, - ); - } - return record; - } - - /** - * Resolve once the child's session appears in the thread session manager - * (the launch loops back through main → supervisor `startThread`). Woken by - * the child's first `thread-state` event, with a coarse 500ms re-check as a - * safety net; throws with diagnostics on timeout. - */ - private async waitForLaunch(threadId: string, timeoutMs: number): Promise { - // 500ms re-check caps the wait between event wakes as a safety net for the - // race where the session appears before a waiter is registered. - const launched = await this.waitUntil( - [threadId], - timeoutMs, - () => (this.deps.host.getThreadState(threadId) ? true : undefined), - 500, - ); - if (!launched) { - throw new LaunchTimeoutError( - `Child thread did not confirm launch within ${Math.round(timeoutMs / 1000)}s. ` + - "The desktop app may be slow or unavailable, or the parent thread's project could not be resolved.", - ); - } - } - - /** Best-effort wait for a thread to leave `working` (used after an interrupt). */ - private async waitWhileWorking(threadId: string, timeoutMs: number): Promise { - await this.waitUntil([threadId], timeoutMs, () => { - const state = this.deps.host.getThreadState(threadId); - return !state || state.status !== "working" ? true : undefined; - }); - } - - /** - * Event-driven wait: re-evaluate `poll` on each `thread-state` wake for the - * given ids (plus an optional `maxChunkMs` re-check cap) until it returns a - * value, or the deadline elapses. Returns the polled value, or `undefined` on - * timeout. Shared loop scaffolding for the wait_for/launch/while-working paths. - */ - private async waitUntil( - threadIds: string[], - timeoutMs: number, - poll: () => T | undefined, - maxChunkMs = Number.POSITIVE_INFINITY, - ): Promise { - const deadline = Date.now() + Math.max(0, timeoutMs); - for (;;) { - const value = poll(); - if (value !== undefined) return value; - const remaining = deadline - Date.now(); - if (remaining <= 0) return undefined; - await this.waitForWake(threadIds, Math.min(maxChunkMs, remaining)); - } - } - - /** Sleep until a `thread-state` event lands for one of `threadIds`, or `timeoutMs`. */ - private waitForWake(threadIds: string[], timeoutMs: number): Promise { - return new Promise((resolve) => { - let timer: ReturnType | undefined; - const waiter: StatusWaiter = { - threadIds: new Set(threadIds), - wake: () => { - if (timer) clearTimeout(timer); - this.waiters.delete(waiter); - resolve(); - }, - }; - this.waiters.add(waiter); - timer = setTimeout(() => waiter.wake(), Math.max(0, timeoutMs)); - }); - } -} - -/** - * Turn a raw `createWorktree` git failure into actionable guidance. Branch - * collisions are the common trap: ticket-keyed branch names permanently poison - * retries, so name the branch and tell the model how to recover. - */ -function describeWorktreeFailure(error: unknown, branch: string): OrchestratorThreadError { - const message = error instanceof Error ? error.message : String(error); - const collision = - /already exists|already checked out|already used by worktree|cannot lock ref|not a valid branch name|fatal: a branch named/i.test( - message, - ); - if (collision) { - return new OrchestratorThreadError( - `Could not create a worktree for branch "${branch}" — it likely already exists ` + - "(often from a previous thread for the same ticket). Retry create_thread with a different `branch` name, " + - "or close/clean up the earlier thread that owns this branch first. " + - `Underlying git error: ${message}`, - ); - } - return new OrchestratorThreadError( - `Could not create a worktree for branch "${branch}": ${message}. ` + - "Retry create_thread with a different `branch` name.", - ); -} - -/** Best-effort plain-text projection of provider-shaped history parts. */ -function renderHistoryParts(parts: ReadonlyArray): string { - const chunks: string[] = []; - for (const part of parts) { - if (typeof part === "string") { - if (part.trim()) chunks.push(part); - continue; - } - if (part && typeof part === "object") { - const text = (part as { text?: unknown }).text; - if (typeof text === "string" && text.trim()) { - chunks.push(text); - continue; - } - const summary = JSON.stringify(part); - if (summary && summary !== "{}") chunks.push(truncate(summary, 500)); - } - } - return chunks.join("\n"); -} diff --git a/src/supervisor/crossagentMcp/childTranscriptBuffer.ts b/src/supervisor/crossagentMcp/childTranscriptBuffer.ts deleted file mode 100644 index f7a96d68..00000000 --- a/src/supervisor/crossagentMcp/childTranscriptBuffer.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type { RuntimeEvent } from "@/shared/contracts"; -import { truncate } from "./toolResult"; - -/** Max finalized entries retained for `read_thread`'s buffered transcript. */ -const MAX_ENTRIES = 200; -/** Total-character budget across retained entries (evicts oldest past this). */ -const MAX_TOTAL_CHARS = 64_000; -/** Per-entry body cap so one huge message can't dominate the buffer. */ -const PER_ENTRY_MAX_CHARS = 4_000; -/** Per-tool summary cap (command/path/query lines). */ -const TOOL_SUMMARY_MAX_CHARS = 500; -/** Cap on the live "last assistant message" accumulator (feeds finalResult). */ -const LIVE_ASSISTANT_MAX_CHARS = 20_000; - -/** Item types whose completion is recorded as a compact tool row. */ -const TOOL_TYPES: ReadonlySet = new Set([ - "tool_call", - "mcp_tool_call", - "dynamic_tool_call", - "image_view", - "command_execution", - "file_change", - "web_search", -]); - -/** - * One finalized row of the compact structured transcript. Provider-agnostic — - * derived from canonical runtime events, never provider-native payloads. - */ -export type TranscriptEntry = - | { kind: "assistant"; text: string } - | { kind: "user"; text: string } - | { kind: "tool"; name: string; status?: string; summary?: string } - | { kind: "error"; message: string } - | { kind: "turn_end"; state: string }; - -interface OpenItem { - type: string; - text: string; - nested: boolean; - name?: string; - status?: string; - summary?: string; -} - -const emptyOpenItem = (): OpenItem => ({ type: "assistant_message", text: "", nested: false }); - -/** - * Per-child ring buffer of a compact, structured transcript built from the - * canonical runtime-event stream. Serves `read_thread` for agents whose adapter - * has no native `readThread` (Claude/Codex/ACP) and after the session closes, - * and tracks the concluding assistant message so the manager can snapshot a - * durable final result on turn completion. - * - * Bounded by both entry count and total characters so it can't grow without - * limit for a long-running child. - */ -export class ChildTranscriptBuffer { - private readonly entries: TranscriptEntry[] = []; - private totalChars = 0; - private readonly open = new Map(); - private liveAssistantItemId: string | undefined; - private liveAssistantText = ""; - - /** Fold one canonical runtime event into the buffer. Ignores unrelated kinds. */ - ingest(event: RuntimeEvent): void { - switch (event.type) { - case "item.started": { - const item: OpenItem = { - type: event.itemType, - text: extractContentText(event.payload), - nested: "parentItemId" in event && typeof event.parentItemId === "string", - }; - if (TOOL_TYPES.has(event.itemType)) mergeToolMeta(item, event.itemType, event.payload); - this.open.set(event.itemId, item); - if (event.itemType === "assistant_message" && item.text && !item.nested) { - this.setAssistant(event.itemId, item.text); - } - return; - } - case "item.updated": { - const item = this.open.get(event.itemId); - if (!item) return; - this.applyPayload(event.itemId, item, event.payload); - return; - } - case "item.completed": { - const item = this.open.get(event.itemId) ?? emptyOpenItem(); - this.applyPayload(event.itemId, item, event.payload); - this.finalize(item); - this.open.delete(event.itemId); - return; - } - case "content.delta": { - if (event.stream !== "assistant_text") return; - const item = this.open.get(event.itemId) ?? emptyOpenItem(); - item.type = "assistant_message"; - item.text = capTail(item.text + event.delta, LIVE_ASSISTANT_MAX_CHARS); - this.open.set(event.itemId, item); - if (!item.nested) this.appendAssistantDelta(event.itemId, event.delta); - return; - } - case "error": { - const message = event.message.trim(); - if (message) this.push({ kind: "error", message: truncate(message, PER_ENTRY_MAX_CHARS) }); - return; - } - case "turn.completed": { - for (const item of this.open.values()) this.finalize(item); - this.open.clear(); - if (event.state !== "completed") this.push({ kind: "turn_end", state: event.state }); - return; - } - default: - return; - } - } - - /** Text of the most recent assistant message (the concluding message of the last turn). */ - lastAssistantMessage(): string { - return this.liveAssistantText; - } - - /** Oldest→newest slice of the finalized transcript, capped to `limit` entries. */ - snapshot(limit: number): TranscriptEntry[] { - const n = Math.min(Math.max(Math.trunc(limit) || 1, 1), MAX_ENTRIES); - return this.entries.slice(-n); - } - - /** Number of finalized entries currently retained. */ - get size(): number { - return this.entries.length; - } - - /** Fold an item.updated/completed payload into an open item (text, tool meta, assistant tracking). */ - private applyPayload(itemId: string, item: OpenItem, payload: unknown): void { - const text = extractContentText(payload); - if (text) item.text = text; - if (TOOL_TYPES.has(item.type)) mergeToolMeta(item, item.type, payload); - if (item.type === "assistant_message" && text && !item.nested) { - this.setAssistant(itemId, text); - } - } - - private setAssistant(itemId: string, text: string): void { - this.liveAssistantItemId = itemId; - this.liveAssistantText = capTail(text, LIVE_ASSISTANT_MAX_CHARS); - } - - private appendAssistantDelta(itemId: string, delta: string): void { - if (itemId !== this.liveAssistantItemId) { - this.liveAssistantItemId = itemId; - this.liveAssistantText = delta; - } else { - this.liveAssistantText += delta; - } - this.liveAssistantText = capTail(this.liveAssistantText, LIVE_ASSISTANT_MAX_CHARS); - } - - private finalize(item: OpenItem): void { - const text = item.text.trim(); - if (item.type === "assistant_message") { - if (text) this.push({ kind: "assistant", text: truncate(text, PER_ENTRY_MAX_CHARS) }); - return; - } - if (item.type === "user_message") { - if (text) this.push({ kind: "user", text: truncate(text, PER_ENTRY_MAX_CHARS) }); - return; - } - if (item.type === "error") { - if (text) this.push({ kind: "error", message: truncate(text, PER_ENTRY_MAX_CHARS) }); - return; - } - if (TOOL_TYPES.has(item.type)) { - this.push({ - kind: "tool", - name: item.name ?? item.type, - ...(item.status ? { status: item.status } : {}), - ...(item.summary ? { summary: truncate(item.summary, TOOL_SUMMARY_MAX_CHARS) } : {}), - }); - } - } - - private push(entry: TranscriptEntry): void { - this.entries.push(entry); - this.totalChars += entryChars(entry); - while ( - this.entries.length > 0 && - (this.entries.length > MAX_ENTRIES || this.totalChars > MAX_TOTAL_CHARS) - ) { - const removed = this.entries.shift(); - if (!removed) break; - this.totalChars -= entryChars(removed); - } - } -} - -function entryChars(entry: TranscriptEntry): number { - switch (entry.kind) { - case "assistant": - case "user": - return entry.text.length; - case "error": - return entry.message.length; - case "tool": - return (entry.name.length + (entry.summary?.length ?? 0)) | 0; - case "turn_end": - return entry.state.length; - } -} - -function mergeToolMeta(item: OpenItem, type: string, payload: unknown): void { - const p = payload && typeof payload === "object" ? (payload as Record) : {}; - const status = typeof p.status === "string" ? p.status : undefined; - if (status) item.status = status; - if (type === "command_execution") { - item.name = "command"; - if (typeof p.command === "string" && p.command) item.summary = p.command; - return; - } - if (type === "file_change") { - item.name = "file_change"; - const path = typeof p.path === "string" ? p.path : undefined; - const changeKind = typeof p.changeKind === "string" ? p.changeKind : undefined; - if (path) item.summary = changeKind ? `${changeKind} ${path}` : path; - return; - } - if (type === "web_search") { - item.name = "web_search"; - if (typeof p.query === "string" && p.query) item.summary = p.query; - return; - } - const name = typeof p.name === "string" && p.name ? p.name : undefined; - const title = typeof p.title === "string" && p.title ? p.title : undefined; - if (name) item.name = name; - else if (title) item.name = title; - if (title && title !== item.name) item.summary = title; -} - -/** Join canonical text content blocks (`{ kind: "text", text }`) into a plain string. */ -function extractContentText(payload: unknown): string { - if (!payload || typeof payload !== "object") return ""; - const content = (payload as { content?: unknown }).content; - if (!Array.isArray(content)) return ""; - const parts: string[] = []; - for (const block of content) { - if (block && typeof block === "object" && (block as { kind?: unknown }).kind === "text") { - const text = (block as { text?: unknown }).text; - if (typeof text === "string" && text) parts.push(text); - } - } - return parts.join(""); -} - -function capTail(text: string, maxChars: number): string { - return text.length > maxChars ? text.slice(-maxChars) : text; -} diff --git a/src/supervisor/crossagentMcp/orchestratorTools.ts b/src/supervisor/crossagentMcp/orchestratorTools.ts deleted file mode 100644 index d4668795..00000000 --- a/src/supervisor/crossagentMcp/orchestratorTools.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { OrchestratorThreadError } from "./OrchestratorThreadManager"; -import type { - ChildThreadSummary, - CreateChildThreadRequest, - OrchestratorThreadManager, -} from "./OrchestratorThreadManager"; -import { errorResult, jsonResult, parseWaitTimeoutMs } from "./toolResult"; -import type { McpToolResult, ToolSpec } from "./types"; - -/** - * Orchestrator-lane tool catalog: create/manage REAL first-class app threads - * (persisted, sidebar-visible, optionally worktree-backed), as opposed to the - * ephemeral spawn_agent/run_agent lane. Dispatched by the shared tool registry. - */ -export const ORCHESTRATOR_TOOLS: ToolSpec[] = [ - { - name: "create_thread", - description: - "Create a REAL app thread (visible to the user in the sidebar) using provider/model/reasoning/Fast/permissions values from get_agent, optionally in its own fresh git worktree, and start it on the prompt. Returns as soon as the thread is launched — it keeps working in the background; monitor it with wait_for_thread / get_thread. Use this lane for long-lived parallel work items (e.g. one ticket per thread); use spawn_agent/run_agent for quick ephemeral helpers instead. Caps & rules: at most 8 LIVE child threads per parent — a slot frees only via close_thread or the child's session ending (a finished-but-not-closed child still holds its slot). Passing `branch` or `base_branch` implies `worktree: true`. Subagent permissions default to Full access. On close_thread the sidebar thread row + its worktree remain for the human (closing frees the slot, it does NOT delete work).", - inputSchema: { - type: "object", - required: ["provider", "prompt"], - properties: { - provider: { - type: "string", - description: "Provider id from list_agents (structured-execution providers only).", - }, - prompt: { type: "string", description: "Self-contained task for the new thread." }, - title: { - type: "string", - description: "Optional thread title shown in the sidebar. Defaults from the prompt.", - }, - model: { - type: "string", - description: "Model value from get_agent. Omit for its defaultModel.", - }, - reasoning: { - type: "string", - description: "Reasoning value listed on the selected get_agent model.", - }, - fast: { - type: "boolean", - description: "Enable Fast when the selected model reports fast.available=true.", - }, - permissions: { - type: "string", - enum: ["full-access"], - description: "Permission preset from get_agent. Defaults to full-access.", - }, - worktree: { - type: "boolean", - description: - "Create a dedicated git worktree (new branch) for this thread so its changes stay isolated. Default false.", - }, - branch: { - type: "string", - description: - "Custom branch name for the worktree (implies worktree=true). Auto-generated when omitted.", - }, - base_branch: { - type: "string", - description: - "Branch/ref to fork the worktree from (implies worktree=true). Defaults to the current HEAD.", - }, - }, - }, - }, - { - name: "list_threads", - description: - "List the threads you created via create_thread, with their current status and attention state.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "get_thread", - description: - "Check one of your created threads without blocking: current status, attention, error reason (when it failed), created_at / turn_started_at timestamps, a short tail of recent assistant output, and — once a turn has finished — its durable final_result (the concluding answer, kept even after close_thread). Cheap first step when a thread reports error/needs_reply: read the error + recent output here before escalating to read_thread.", - inputSchema: { - type: "object", - required: ["thread_id"], - properties: { thread_id: { type: "string" } }, - }, - }, - { - name: "read_thread", - description: - "Read the tail of a created thread's transcript (last N entries, bodies truncated). Prefers the agent's native transcript when the session is live; otherwise returns a compact structured transcript (assistant/user text, tool calls, errors) reconstructed from the thread's events — so it works for every agent and even after close_thread. The user can watch the thread live in the app, so avoid polling this aggressively; use get_thread for a quick status/error check first.", - inputSchema: { - type: "object", - required: ["thread_id"], - properties: { - thread_id: { type: "string" }, - last_messages: { - type: "number", - description: "How many trailing messages to return (default 20, max 100).", - }, - }, - }, - }, - { - name: "send_to_thread", - description: - "Send a message to a created thread. Starts a new turn on a settled thread, answers a thread that is waiting on your reply (needs_reply), or steers a working thread when the agent supports it; with interrupt=true, interrupts the current turn first and then sends. It does NOT resolve needs_approval tool-permission prompts (a human must approve those in the app) and errors if the thread is stuck there. Errors when the thread is busy and cannot be steered.", - inputSchema: { - type: "object", - required: ["thread_id", "message"], - properties: { - thread_id: { type: "string" }, - message: { type: "string", description: "Message to deliver to the thread." }, - interrupt: { - type: "boolean", - description: "Interrupt the thread's current turn before sending. Default false.", - }, - }, - }, - }, - { - name: "wait_for_thread", - description: - "Block until ANY of the listed created threads settles (idle | finished | needs_approval | needs_reply | error | inactive) or the timeout elapses. Returns `statuses` (per thread: status, attention, and error when it failed), `settled` (the ids that are settled — i.e. which threads woke you), and `timed_out`. Returns immediately when one is already settled; call again with the still-running ids to keep waiting. Waiting does NOT free a create_thread slot — close_thread does.", - inputSchema: { - type: "object", - required: ["thread_ids"], - properties: { - thread_ids: { - type: "array", - items: { type: "string" }, - minItems: 1, - maxItems: 8, - description: "Thread ids from create_thread (1-8).", - }, - timeout_s: { - type: "number", - description: - "Max seconds to wait (default 120, capped at 240). On timeout, call wait_for_thread again to keep waiting.", - }, - }, - }, - }, - { - name: "interrupt_thread", - description: - "Interrupt a created thread's current turn. The thread stays alive and addressable (send_to_thread can start a new turn).", - inputSchema: { - type: "object", - required: ["thread_id"], - properties: { thread_id: { type: "string" } }, - }, - }, - { - name: "close_thread", - description: - "Close a finished child thread's runtime session to FREE ITS CAPACITY SLOT so you can create_thread again (you can have at most 8 live children per parent, and a finished-but-not-closed child keeps holding its slot). Do this once you've collected a thread's result (get_thread returns its final_result even after close). The sidebar thread row and its git worktree REMAIN for the human — this frees the slot, it does not delete the work. Mirrors closing a background worker when its task is done.", - inputSchema: { - type: "object", - required: ["thread_id"], - properties: { thread_id: { type: "string" } }, - }, - }, -]; - -const ORCHESTRATOR_TOOL_NAMES = new Set(ORCHESTRATOR_TOOLS.map((tool) => tool.name)); - -export function isOrchestratorToolName(name: string): boolean { - return ORCHESTRATOR_TOOL_NAMES.has(name); -} - -export interface OrchestratorToolContext { - parentThreadId: string; - orchestrator: OrchestratorThreadManager; -} - -function parseCreateThreadRequest(args: Record): CreateChildThreadRequest { - const agent = typeof args.provider === "string" ? args.provider : ""; - const prompt = typeof args.prompt === "string" ? args.prompt : ""; - if (!agent) throw new OrchestratorThreadError("provider is required"); - if (!prompt.trim()) throw new OrchestratorThreadError("prompt is required"); - if (args.permissions !== undefined && args.permissions !== "full-access") { - throw new OrchestratorThreadError("permissions must be full-access for subagents"); - } - return { - agent, - prompt, - ...(typeof args.title === "string" ? { title: args.title } : {}), - ...(typeof args.model === "string" ? { model: args.model } : {}), - ...(typeof args.reasoning === "string" ? { effort: args.reasoning } : {}), - ...(args.fast === true ? { fast: true } : {}), - ...(args.worktree === true ? { worktree: true } : {}), - ...(typeof args.branch === "string" && args.branch.trim() ? { branch: args.branch } : {}), - ...(typeof args.base_branch === "string" && args.base_branch.trim() - ? { baseBranch: args.base_branch } - : {}), - }; -} - -function requireThreadId(args: Record): string { - const threadId = typeof args.thread_id === "string" ? args.thread_id : ""; - if (!threadId) throw new OrchestratorThreadError("thread_id is required"); - return threadId; -} - -/** - * Dispatch an orchestrator-lane tools/call. Mirrors the base registry's - * contract: never throws — validation failures return isError results. - */ -export async function dispatchOrchestratorTool( - name: string, - args: Record, - ctx: OrchestratorToolContext, -): Promise { - try { - switch (name) { - case "create_thread": { - const request = parseCreateThreadRequest(args); - const created = await ctx.orchestrator.createThread(ctx.parentThreadId, request); - return jsonResult({ - thread_id: created.threadId, - title: created.title, - ...(created.worktreePath ? { worktree_path: created.worktreePath } : {}), - ...(created.branch ? { branch: created.branch } : {}), - }); - } - case "list_threads": - return jsonResult(ctx.orchestrator.listThreads(ctx.parentThreadId).map(toWireSummary)); - case "get_thread": { - const summary = ctx.orchestrator.getThread(ctx.parentThreadId, requireThreadId(args)); - return jsonResult({ - ...toWireSummary(summary), - ...(summary.recentOutput ? { recent_output: summary.recentOutput } : {}), - ...(summary.finalResult ? { final_result: summary.finalResult } : {}), - }); - } - case "read_thread": { - const lastMessages = - typeof args.last_messages === "number" && Number.isFinite(args.last_messages) - ? args.last_messages - : 20; - const result = await ctx.orchestrator.readThread( - ctx.parentThreadId, - requireThreadId(args), - lastMessages, - ); - if (result.source === "native") { - return jsonResult({ - status: result.status, - source: "native", - message_count: result.messageCount, - messages: result.messages, - }); - } - if (result.source === "buffer") { - return jsonResult({ - status: result.status, - source: "buffer", - entry_count: result.entryCount, - entries: result.entries, - }); - } - return jsonResult({ status: result.status, source: "note", note: result.note }); - } - case "send_to_thread": { - const message = typeof args.message === "string" ? args.message : ""; - if (!message.trim()) return errorResult("message is required"); - const { delivery } = await ctx.orchestrator.sendToThread( - ctx.parentThreadId, - requireThreadId(args), - message, - args.interrupt === true, - ); - return jsonResult({ ok: true, delivery }); - } - case "wait_for_thread": { - const threadIds = Array.isArray(args.thread_ids) - ? args.thread_ids.filter((id): id is string => typeof id === "string" && id.length > 0) - : []; - const { statuses, settled, timedOut } = await ctx.orchestrator.waitForThreads( - ctx.parentThreadId, - threadIds, - parseWaitTimeoutMs(args.timeout_s), - ); - return jsonResult({ statuses, settled, timed_out: timedOut }); - } - case "interrupt_thread": { - await ctx.orchestrator.interruptThread(ctx.parentThreadId, requireThreadId(args)); - return jsonResult({ ok: true }); - } - case "close_thread": { - await ctx.orchestrator.closeThread(ctx.parentThreadId, requireThreadId(args)); - return jsonResult({ ok: true }); - } - default: - return errorResult(`Unknown tool: ${name}`); - } - } catch (error) { - return errorResult(error instanceof Error ? error.message : String(error)); - } -} - -function toWireSummary(summary: ChildThreadSummary): Record { - return { - thread_id: summary.threadId, - title: summary.title, - provider: summary.agent, - status: summary.status, - attention: summary.attention, - created_at: summary.createdAt, - ...(summary.worktreePath ? { worktree_path: summary.worktreePath } : {}), - ...(summary.branch ? { branch: summary.branch } : {}), - ...(summary.error ? { error: summary.error } : {}), - ...(summary.turnStartedAt ? { turn_started_at: summary.turnStartedAt } : {}), - }; -} diff --git a/src/supervisor/crossagentMcp/toolRegistry.test.ts b/src/supervisor/crossagentMcp/toolRegistry.test.ts index 3cf75124..baf11bec 100644 --- a/src/supervisor/crossagentMcp/toolRegistry.test.ts +++ b/src/supervisor/crossagentMcp/toolRegistry.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import type { AgentKind, AgentStatus } from "@/shared/contracts"; import type { AgentAdapter } from "@/supervisor/agents/base"; -import type { OrchestratorThreadManager } from "./OrchestratorThreadManager"; import type { SubagentRunManager } from "./SubagentRunManager"; import { buildSpawnableAgents, classifyModelTier, dispatchTool, TOOLS } from "./toolRegistry"; import type { SubagentToolContext } from "./toolRegistry"; @@ -154,25 +153,13 @@ describe("buildSpawnableAgents", () => { }); }); -const ORCHESTRATOR_TOOL_NAMES = [ - "create_thread", - "list_threads", - "get_thread", - "read_thread", - "send_to_thread", - "wait_for_thread", - "interrupt_thread", - "close_thread", -] as const; - -function makeToolContext(orchestrator: Partial): { +function makeToolContext(): { ctx: SubagentToolContext; } { return { ctx: { parentThreadId: "parent-1", runManager: {} as unknown as SubagentRunManager, - orchestrator: orchestrator as unknown as OrchestratorThreadManager, listSpawnableAgents: async () => [], }, }; @@ -202,7 +189,7 @@ describe("provider discovery", () => { }; it("lists compact summaries and resolves full options by id", async () => { - const { ctx } = makeToolContext({}); + const { ctx } = makeToolContext(); ctx.listSpawnableAgents = async () => [provider]; const listed = await dispatchTool("list_agents", {}, ctx); @@ -221,7 +208,7 @@ describe("provider discovery", () => { }); it("returns a tool error for an unknown provider id", async () => { - const { ctx } = makeToolContext({}); + const { ctx } = makeToolContext(); ctx.listSpawnableAgents = async () => [provider]; const result = await dispatchTool("get_agent", { id: "missing" }, ctx); expect(result.isError).toBe(true); @@ -229,10 +216,9 @@ describe("provider discovery", () => { }); }); -describe("orchestrator tool registration", () => { - it("registers all orchestrator tools alongside the existing run tools", () => { +describe("subagent tool registration", () => { + it("registers the ephemeral subagent-run tools and no full-thread tools", () => { const names = new Set(TOOLS.map((tool) => tool.name)); - for (const name of ORCHESTRATOR_TOOL_NAMES) expect(names.has(name)).toBe(true); for (const name of [ "list_agents", "get_agent", @@ -244,165 +230,39 @@ describe("orchestrator tool registration", () => { ]) { expect(names.has(name)).toBe(true); } + // Full-thread orchestration moved to the `poracode` (app-controls) MCP. + for (const name of [ + "create_thread", + "list_threads", + "get_thread", + "read_thread", + "send_to_thread", + "wait_for_thread", + "interrupt_thread", + "close_thread", + ]) { + expect(names.has(name)).toBe(false); + } }); - it("declares required fields on the new tool schemas", () => { + it("declares required fields on the subagent tool schemas", () => { const byName = new Map(TOOLS.map((tool) => [tool.name, tool])); - expect(byName.get("create_thread")!.inputSchema).toMatchObject({ + expect(byName.get("spawn_agent")!.inputSchema).toMatchObject({ required: ["provider", "prompt"], }); - expect(byName.get("spawn_agent")!.inputSchema).toMatchObject({ + expect(byName.get("run_agent")!.inputSchema).toMatchObject({ required: ["provider", "prompt"], }); expect(byName.get("get_agent")!.inputSchema).toMatchObject({ required: ["id"] }); - expect(byName.get("get_thread")!.inputSchema).toMatchObject({ required: ["thread_id"] }); - expect(byName.get("read_thread")!.inputSchema).toMatchObject({ required: ["thread_id"] }); - expect(byName.get("send_to_thread")!.inputSchema).toMatchObject({ - required: ["thread_id", "message"], - }); - expect(byName.get("wait_for_thread")!.inputSchema).toMatchObject({ - required: ["thread_ids"], - }); - expect(byName.get("interrupt_thread")!.inputSchema).toMatchObject({ - required: ["thread_id"], - }); + expect(byName.get("wait_for_agent")!.inputSchema).toMatchObject({ required: ["run_id"] }); + expect(byName.get("get_status")!.inputSchema).toMatchObject({ required: ["run_id"] }); + expect(byName.get("cancel")!.inputSchema).toMatchObject({ required: ["run_id"] }); }); -}); -describe("orchestrator tool dispatch", () => { - it("routes create_thread to the manager and returns snake_case fields", async () => { - const calls: Array<{ parent: string; request: unknown }> = []; - const { ctx } = makeToolContext({ - createThread: async (parent: string, request: unknown) => { - calls.push({ parent, request }); - return { - threadId: "child-1", - title: "Ticket", - worktreePath: "/tmp/wt/x", - branch: "poracode/x", - }; - }, - } as Partial); - const result = await dispatchTool( - "create_thread", - { - provider: "codex", - model: "gpt-5.5", - reasoning: "high", - fast: true, - permissions: "full-access", - prompt: "do it", - worktree: true, - }, - ctx, - ); - expect(result.isError).toBeUndefined(); - expect(JSON.parse(resultText(result))).toEqual({ - thread_id: "child-1", - title: "Ticket", - worktree_path: "/tmp/wt/x", - branch: "poracode/x", - }); - expect(calls).toEqual([ - { - parent: "parent-1", - request: { - agent: "codex", - model: "gpt-5.5", - effort: "high", - fast: true, - prompt: "do it", - worktree: true, - }, - }, - ]); - }); - - it("returns tool errors (not throws) for missing required arguments", async () => { - const { ctx } = makeToolContext({}); - for (const [name, args] of [ - ["create_thread", { prompt: "x" }], - ["create_thread", { provider: "codex" }], - ["get_thread", {}], - ["read_thread", {}], - ["send_to_thread", { thread_id: "t" }], - ["interrupt_thread", {}], - ] as const) { - const result = await dispatchTool(name, args as Record, ctx); - expect(result.isError).toBe(true); - } - }); - - it("maps wait_for_thread args (id filtering + timeout clamp) onto the manager", async () => { - const calls: Array<{ ids: string[]; timeoutMs: number }> = []; - const { ctx } = makeToolContext({ - waitForThreads: async (_parent: string, ids: string[], timeoutMs: number) => { - calls.push({ ids, timeoutMs }); - return { - statuses: { a: { status: "idle" as const, attention: "none" as const } }, - settled: ["a"], - timedOut: false, - }; - }, - } as Partial); - const result = await dispatchTool( - "wait_for_thread", - { thread_ids: ["a", 42, "", "b"], timeout_s: 9_999 }, - ctx, - ); - expect(JSON.parse(resultText(result))).toEqual({ - statuses: { a: { status: "idle", attention: "none" } }, - settled: ["a"], - timed_out: false, - }); - expect(calls).toEqual([{ ids: ["a", "b"], timeoutMs: 240_000 }]); - }); - - it("surfaces manager errors as MCP tool errors", async () => { - const { ctx } = makeToolContext({ - getThread: () => { - throw new Error("Unknown thread_id: nope"); - }, - } as Partial); - const result = await dispatchTool("get_thread", { thread_id: "nope" }, ctx); + it("returns an isError result (not a throw) for removed full-thread tools", async () => { + const { ctx } = makeToolContext(); + const result = await dispatchTool("create_thread", { prompt: "x" }, ctx); expect(result.isError).toBe(true); - expect(resultText(result)).toContain("Unknown thread_id"); - }); - - it("routes send_to_thread and interrupt_thread with the caller's parent id", async () => { - const sends: unknown[] = []; - const interrupts: unknown[] = []; - const { ctx } = makeToolContext({ - sendToThread: async (...args: unknown[]) => { - sends.push(args); - return { delivery: "steered" as const }; - }, - interruptThread: async (...args: unknown[]) => { - interrupts.push(args); - }, - } as Partial); - const sendResult = await dispatchTool( - "send_to_thread", - { thread_id: "child-1", message: "hi", interrupt: true }, - ctx, - ); - expect(JSON.parse(resultText(sendResult))).toEqual({ ok: true, delivery: "steered" }); - expect(sends).toEqual([["parent-1", "child-1", "hi", true]]); - - const interruptResult = await dispatchTool("interrupt_thread", { thread_id: "child-1" }, ctx); - expect(JSON.parse(resultText(interruptResult))).toEqual({ ok: true }); - expect(interrupts).toEqual([["parent-1", "child-1"]]); - }); - - it("routes close_thread with the caller's parent id", async () => { - const closes: unknown[] = []; - const { ctx } = makeToolContext({ - closeThread: async (...args: unknown[]) => { - closes.push(args); - }, - } as Partial); - const result = await dispatchTool("close_thread", { thread_id: "child-1" }, ctx); - expect(JSON.parse(resultText(result))).toEqual({ ok: true }); - expect(closes).toEqual([["parent-1", "child-1"]]); + expect(resultText(result)).toContain("Unknown tool"); }); }); diff --git a/src/supervisor/crossagentMcp/toolRegistry.ts b/src/supervisor/crossagentMcp/toolRegistry.ts index 1e98ad3c..08e3a787 100644 --- a/src/supervisor/crossagentMcp/toolRegistry.ts +++ b/src/supervisor/crossagentMcp/toolRegistry.ts @@ -2,12 +2,6 @@ import type { AgentKind, AgentStatus } from "@/shared/contracts"; import { capabilitiesForPresentation, modelSelectionFor } from "@/shared/agentSelection"; import { formatReasoningLabel } from "@/shared/modelLabels"; import type { AgentAdapter } from "@/supervisor/agents/base"; -import type { OrchestratorThreadManager } from "./OrchestratorThreadManager"; -import { - dispatchOrchestratorTool, - isOrchestratorToolName, - ORCHESTRATOR_TOOLS, -} from "./orchestratorTools"; import { SubagentSpawnError } from "./SubagentRunManager"; import type { SubagentRunManager } from "./SubagentRunManager"; import { errorResult, jsonResult, parseWaitTimeoutMs, TIMEOUT_S_DESCRIPTION } from "./toolResult"; @@ -48,16 +42,13 @@ export function classifyModelTier(modelId: string, modelLabel: string): ModelTie /** Base routing guidance always included in the MCP `initialize` instructions. */ export const CROSSAGENT_MCP_INSTRUCTIONS_BASE = [ - "Use the Crossagents MCP server to delegate work to the other AI agents connected to this Poracode session.", + "Use the Crossagents MCP server to delegate lightweight, ephemeral work to the other AI agents connected to this Poracode session.", "Call list_agents first for the compact provider roster, then call get_agent with the chosen provider id for its models, reasoning options, Fast availability, and permissions preset.", - "There are two delegation lanes.", - "Lightweight subagent runs (spawn_agent, run_agent, wait_for_agent, get_status, cancel): quick, ephemeral helpers whose output streams into your own thread and disappears afterwards — best for search, summarization, bulk edits, and one-off checks.", - "Full threads (create_thread, list_threads, get_thread, read_thread, send_to_thread, wait_for_thread, interrupt_thread): long-lived first-class app threads the user sees in the sidebar, each optionally in its own git worktree — best for parallel work items (e.g. one ticket or feature per thread) that need isolation, review, or follow-up conversation.", + "This server hosts one delegation lane: ephemeral subagent runs (spawn_agent, run_agent, wait_for_agent, get_status, cancel) — quick helpers whose output streams into your own thread and disappears afterwards, best for search, summarization, bulk edits, and one-off checks.", + "Use run_agent for a single blocking delegation; use spawn_agent + wait_for_agent for long tasks or to fan out several agents in parallel, then collect them with wait_for_agent.", + "Give each subagent a self-contained prompt — it does not share your conversation context.", "Routing: prefer fast/cheap agents+models for search, bulk edits, and summarization; reserve the strongest agents for implementation and review.", - "Run independent tasks concurrently as parallel spawn_agent or create_thread calls, then collect them with wait_for_agent / wait_for_thread.", - "Use run_agent for a single blocking delegation; use spawn_agent + wait_for_agent for long tasks or fan-out.", - "Give each subagent or child thread a self-contained prompt — it does not share your conversation context.", - "The human can watch child threads live in the app UI, so do not poll read_thread/get_thread aggressively; rely on wait_for_thread and read transcripts only when you need the details.", + "For long-lived, first-class app threads the user sees in the sidebar (optionally in their own git worktree) — e.g. one ticket or feature per thread — use the always-on `poracode` MCP server's thread tools (create_thread, list_threads, get_thread, read_thread, send_to_thread, wait_for_thread, interrupt_thread, stop_thread) instead.", ].join(" "); export function buildSubagentInstructions(routingGuide?: string): string { @@ -179,8 +170,9 @@ const BASE_TOOLS: ToolSpec[] = [ }, ]; -/** Full catalog: ephemeral subagent runs + the orchestrator thread lane. */ -export const TOOLS: ToolSpec[] = [...BASE_TOOLS, ...ORCHESTRATOR_TOOLS]; +/** Catalog: the ephemeral subagent-run lane. Full-thread orchestration lives + * in the always-on `poracode` (app-controls) MCP server's thread tools. */ +export const TOOLS: ToolSpec[] = BASE_TOOLS; export const TOOL_NAMES = new Set(TOOLS.map((t) => t.name)); @@ -269,7 +261,6 @@ function summarizeAgent(agent: SpawnableAgent): SpawnableAgentSummary { export interface SubagentToolContext { parentThreadId: string; runManager: SubagentRunManager; - orchestrator: OrchestratorThreadManager; listSpawnableAgents: () => Promise; } @@ -338,12 +329,6 @@ export async function dispatchTool( return jsonResult({ ok: true }); } default: - if (isOrchestratorToolName(name)) { - return await dispatchOrchestratorTool(name, args, { - parentThreadId: ctx.parentThreadId, - orchestrator: ctx.orchestrator, - }); - } return errorResult(`Unknown tool: ${name}`); } } catch (error) { diff --git a/src/supervisor/github.test.ts b/src/supervisor/github.test.ts index bcf379fe..aff6fac1 100644 --- a/src/supervisor/github.test.ts +++ b/src/supervisor/github.test.ts @@ -101,6 +101,19 @@ describe("GitHubService", () => { expect(result).toEqual({ available: false }); }); + + it("does not inherit GH_REPO when running a project-scoped gh command", async () => { + const prior = process.env.GH_REPO; + process.env.GH_REPO = "ambient/override"; + execFileAsyncMock.mockResolvedValue({ stdout: "gh version 2.50.0\n" }); + + await new GitHubService().checkGhAvailable(location); + + const options = execFileAsyncMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv }; + expect(options.env?.GH_REPO).toBeUndefined(); + if (prior === undefined) delete process.env.GH_REPO; + else process.env.GH_REPO = prior; + }); }); describe("getPrForBranch", () => { @@ -289,12 +302,14 @@ describe("GitHubService", () => { expect.stringContaining("statusCheckRollup"), ], loginEnv: true, + env: { GH_REPO: "" }, }, { command: "gh", cwd: "/home/demo/repo", args: ["api", "user", "--jq", ".login"], loginEnv: true, + env: { GH_REPO: "" }, }, ], }); @@ -561,12 +576,14 @@ describe("GitHubService", () => { cwd: "/home/demo/repo", args: expect.arrayContaining(["pr", "list", "open"]), loginEnv: true, + env: { GH_REPO: "" }, }), { command: "gh", cwd: "/home/demo/repo", args: ["api", "user", "--jq", ".login"], loginEnv: true, + env: { GH_REPO: "" }, }, ], }); diff --git a/src/supervisor/github.ts b/src/supervisor/github.ts index d654661a..7110d293 100644 --- a/src/supervisor/github.ts +++ b/src/supervisor/github.ts @@ -131,6 +131,17 @@ interface RunGhOptions { timeoutMs?: number; } +/** + * `gh` gives GH_REPO precedence over the repository at cwd. Project-scoped + * operations must never inherit that process-wide override, or a command can + * be sent to an unrelated repository. + */ +function projectGhEnvironment(overrides?: Record): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, ...overrides }; + delete env.GH_REPO; + return env; +} + async function runGh( location: ProjectLocation, args: string[], @@ -148,7 +159,9 @@ async function runGh( args, loginEnv: true, timeoutMs, - ...(options?.env ? { env: options.env } : {}), + // The bridge cannot delete a forwarded variable, so explicitly clear it + // inside the WSL process. Local invocations remove it below. + env: { ...options?.env, GH_REPO: "" }, }); if (result.ok) return result.stdout; throw processResultToError(result); @@ -160,7 +173,7 @@ async function runGh( windowsHide: true, timeout: timeoutMs, ...(cwd ? { cwd } : {}), - env: spec.env ? { ...process.env, ...spec.env } : process.env, + env: projectGhEnvironment({ ...spec.env, ...options?.env }), }); return stdout; } @@ -180,6 +193,8 @@ async function runGhBatch( cwd: location.linuxPath, args, loginEnv: true, + // Keep batched read calls scoped to the project just like processExec. + env: { GH_REPO: "" }, })), }); return result.results; diff --git a/src/supervisor/ipcHandlers.ts b/src/supervisor/ipcHandlers.ts index f81de2cc..c95f1eaa 100644 --- a/src/supervisor/ipcHandlers.ts +++ b/src/supervisor/ipcHandlers.ts @@ -45,9 +45,6 @@ export function createSupervisorIpcHandlers(runtime: SupervisorRuntime): Supervi getThreadSnapshots: () => threads.getThreadSnapshots(), startThread: (payload) => threads.startThread(payload), sendThreadInput: (payload) => threads.sendThreadInput(payload), - seedOrchestratorChildren: async (payload) => { - runtime.seedOrchestratorChildren(payload); - }, interruptThread: (payload) => threads.interruptThread(payload), rollbackThreadConversation: (payload) => threads.rollbackThreadConversation(payload), setPendingSteer: (payload) => threads.setPendingSteer(payload), diff --git a/src/supervisor/runtime/threadSessionManager.ts b/src/supervisor/runtime/threadSessionManager.ts index 3a752b33..1575f9f8 100644 --- a/src/supervisor/runtime/threadSessionManager.ts +++ b/src/supervisor/runtime/threadSessionManager.ts @@ -32,7 +32,6 @@ import { } from "@/shared/contracts"; import { type AgentAdapter, - type ThreadHistory, createKnownSessionRef, defaultFormatPromptSegments, getRefreshedWindowsPath, @@ -377,39 +376,6 @@ export class ThreadSessionManager { this.runtimeEventRouter.append(parentThreadId, event); } - /** - * Orchestrator host hook: a thread's live runtime state plus whether its - * session supports non-interrupting steer. `undefined` once the session is - * gone. Consumed by the Crossagents MCP orchestrator lane. - */ - getOrchestratorThreadState(threadId: string): - | { - status: import("@/shared/contracts").ThreadStatus; - attention: import("@/shared/contracts").ThreadAttention; - config: ThreadConfig; - supportsSteer: boolean; - } - | undefined { - const session = this.sessions.get(threadId); - if (!session) return undefined; - return { - status: session.status, - attention: session.attention, - config: session.config, - supportsSteer: Boolean(session.structuredSession?.steerTurn), - }; - } - - /** - * Orchestrator host hook: read a thread's provider transcript when its - * session's adapter supports `readThread`; `undefined` otherwise. - */ - async readThreadHistory(threadId: string): Promise { - const session = this.sessions.get(threadId); - if (!session?.structuredSession?.readThread) return undefined; - return await session.structuredSession.readThread(); - } - /** * Renderer-facing: subscribe a sub-agent overlay. Returns the buffered * child-event history so the renderer can hydrate the overlay; subsequent diff --git a/src/supervisor/supervisorRuntime.ts b/src/supervisor/supervisorRuntime.ts index 2c8c80c4..6cde4fb3 100644 --- a/src/supervisor/supervisorRuntime.ts +++ b/src/supervisor/supervisorRuntime.ts @@ -22,7 +22,6 @@ import type { RemoveExperimentWorktreesResult, RelocateProjectPayload, RelocateProjectResult, - SeedOrchestratorChildrenPayload, } from "@/shared/contracts"; import type { SupervisorEvent } from "@/shared/ipc"; import { msg } from "@/shared/messages"; @@ -52,7 +51,6 @@ import { GenerationService } from "./runtime/generationService"; import { type SessionRuntime, type ShellSessionRuntime } from "./runtime/sessionTypes"; import { ThreadSessionManager, writeSubmittedPrompt } from "./runtime/threadSessionManager"; import { CliHookPluginCoordinator } from "./runtime/cliHookPluginCoordinator"; -import { OrchestratorThreadManager } from "./crossagentMcp/OrchestratorThreadManager"; import { CrossagentMcpIngress } from "./crossagentMcp/CrossagentMcpIngress"; import { SubagentRunManager } from "./crossagentMcp/SubagentRunManager"; import { buildSpawnableAgents } from "./crossagentMcp/toolRegistry"; @@ -111,7 +109,6 @@ export class SupervisorRuntime { readonly skillsService: SkillsService; private readonly crossagentMcpIngress: CrossagentMcpIngress; private readonly subagentRunManager: SubagentRunManager; - private readonly orchestratorThreadManager: OrchestratorThreadManager; private wslHookBridge: WslBridgeServer | undefined; readonly sessions: Map; @@ -312,65 +309,8 @@ export class SupervisorRuntime { this.threadSessionManager.appendSubagentRuntimeEvent(parentThreadId, event), }, }); - // Orchestrator lane of the Crossagents MCP: creates first-class child - // threads. Creation is main-orchestrated — the manager emits an - // `orchestrator-thread-created` supervisor event; main upserts the DB row, - // mirrors it to the renderer, and calls startThread back into this - // process. Host closures resolve the thread session manager lazily, same - // as the run manager above. - this.orchestratorThreadManager = new OrchestratorThreadManager({ - adapters: this.adapters, - getStatusCapabilities: (kind) => this.agentStatusService.getCachedCapabilities(kind), - emit: (event) => this.emit(event), - host: { - getParentContext: (threadId) => - this.threadSessionManager.getSubagentParentContext(threadId), - getThreadState: (threadId) => - this.threadSessionManager.getOrchestratorThreadState(threadId), - readThreadHistory: (threadId) => this.threadSessionManager.readThreadHistory(threadId), - sendThreadInput: (payload) => this.threadSessionManager.sendThreadInput(payload), - interruptThread: (threadId) => this.threadSessionManager.interruptThread({ threadId }), - // Tear down the child's runtime session to free a cap slot. Reuses the - // same closeThread that removes the session from the TSM map (which is - // what makes getOrchestratorThreadState — and thus the live-child count - // — drop the child). The persisted thread row + worktree remain. - closeThread: (threadId) => this.threadSessionManager.closeThread({ threadId }), - }, - // Same worktree pipeline the renderer-driven `gitAddWorktree` procedure - // uses, with placement resolved from global settings (per-project - // overrides live in the renderer DB and aren't visible here). - createWorktree: async ({ location, branch, baseBranch }) => { - const placement = resolveWorktreePlacement( - this.sharedSettingsCache.read(), - undefined, - location, - ); - const result = await this.gitService.addWorktree( - location, - undefined, - branch, - true, - baseBranch, - undefined, - false, - false, - { - ...(placement.root ? { root: placement.root } : {}), - ...(placement.omitRepoDir ? { omitRepoDir: true } : {}), - }, - ); - return { path: result.path }; - }, - // Roll back a worktree created by a create_thread call that then failed to - // launch (force removal + branch delete), so a ticket-keyed retry isn't - // poisoned by a leftover branch. - removeWorktree: async ({ location, path }) => { - await this.gitService.removeWorktree(location, path, true, true); - }, - }); this.crossagentMcpIngress = new CrossagentMcpIngress({ runManager: this.subagentRunManager, - orchestrator: this.orchestratorThreadManager, getSpawnableAgents: async () => { const { windows } = await this.agentStatusService.getAgentStatuses({ wslDistros: [] }); return buildSpawnableAgents(this.adapters, windows); @@ -390,12 +330,7 @@ export class SupervisorRuntime { }); this.threadSessionManager = new ThreadSessionManager({ - // Tap the outbound stream so the orchestrator lane can track child - // thread-state transitions (wait_for_thread) without polling. - emit: (event) => { - this.orchestratorThreadManager.observeSupervisorEvent(event); - emit(event); - }, + emit, isDev: this.isDev, logsDir: this.logsDir, settingsPath: this.settingsPath, @@ -737,15 +672,6 @@ export class SupervisorRuntime { return {}; } - /** - * Re-register persisted orchestrator child threads after a supervisor - * restart (main pushes them at every supervisor start) so the Crossagents - * orchestrator lane can keep addressing children created before the restart. - */ - seedOrchestratorChildren(payload: SeedOrchestratorChildrenPayload): void { - this.orchestratorThreadManager.rehydrateChildren(payload.children); - } - dispose(): void { void this.disposeAsync(); } diff --git a/tests/integration/crossagent-mcp.integration.test.ts b/tests/integration/crossagent-mcp.integration.test.ts index 21c5ade5..780e1ec2 100644 --- a/tests/integration/crossagent-mcp.integration.test.ts +++ b/tests/integration/crossagent-mcp.integration.test.ts @@ -5,7 +5,6 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { AgentKind, ProjectLocation, RuntimeEvent } from "@/shared/contracts"; import type { AgentAdapter } from "@/supervisor/agents/base"; import { createAgentRegistry } from "@/supervisor/agents/registry"; -import { OrchestratorThreadManager } from "@/supervisor/crossagentMcp/OrchestratorThreadManager"; import { CrossagentMcpIngress } from "@/supervisor/crossagentMcp/CrossagentMcpIngress"; import { SubagentRunManager } from "@/supervisor/crossagentMcp/SubagentRunManager"; import type { SpawnableAgent } from "@/supervisor/crossagentMcp/types"; @@ -81,20 +80,6 @@ describe("Crossagents MCP (live)", () => { ingress = new CrossagentMcpIngress({ runManager, - orchestrator: new OrchestratorThreadManager({ - adapters: new Map(), - emit: () => {}, - host: { - getParentContext: () => undefined, - getThreadState: () => undefined, - readThreadHistory: async () => undefined, - sendThreadInput: async () => {}, - interruptThread: async () => {}, - closeThread: async () => {}, - }, - createWorktree: async () => ({ path: "/unused" }), - removeWorktree: async () => {}, - }), getSpawnableAgents: async () => spawnable, getRoutingGuide: () => ROUTING_GUIDE, }); diff --git a/tests/integration/opencode-crossagent-mcp.integration.test.ts b/tests/integration/opencode-crossagent-mcp.integration.test.ts index 7b932a7f..c47a7af1 100644 --- a/tests/integration/opencode-crossagent-mcp.integration.test.ts +++ b/tests/integration/opencode-crossagent-mcp.integration.test.ts @@ -15,7 +15,6 @@ import { CROSSAGENT_PROVIDER_SESSION_ID_ARG, CrossagentMcpIngress, } from "@/supervisor/crossagentMcp/CrossagentMcpIngress"; -import { OrchestratorThreadManager } from "@/supervisor/crossagentMcp/OrchestratorThreadManager"; import { SubagentRunManager } from "@/supervisor/crossagentMcp/SubagentRunManager"; const PARENT_THREAD_ID = "oc-int-parent-thread"; @@ -51,20 +50,6 @@ describe("opencode hosts Crossagents MCP on its shared server (live)", () => { }); ingress = new CrossagentMcpIngress({ runManager, - orchestrator: new OrchestratorThreadManager({ - adapters: new Map(), - emit: () => {}, - host: { - getParentContext: () => undefined, - getThreadState: () => undefined, - readThreadHistory: async () => undefined, - sendThreadInput: async () => {}, - interruptThread: async () => {}, - closeThread: async () => {}, - }, - createWorktree: async () => ({ path: "/unused" }), - removeWorktree: async () => {}, - }), getSpawnableAgents: async () => [], resolveProviderSessionThreadId: (sessionId) => providerSessions.get(sessionId), });