diff --git a/bun.lock b/bun.lock index 8a94ef3..243d884 100644 --- a/bun.lock +++ b/bun.lock @@ -93,9 +93,11 @@ "name": "@echohello/server", "version": "0.0.0", "dependencies": { + "@agentclientprotocol/sdk": "^1.3.0", "@anthropic-ai/claude-agent-sdk": "^0.3.222", "@echohello/client": "workspace:*", "@echohello/protocol": "workspace:*", + "@opencode-ai/sdk": "^1.18.14", "dotenv": "^17.2.3", "express": "^5.1.0", "pino": "^10.2.0", @@ -146,6 +148,8 @@ "packages": { "@0no-co/graphql.web": ["@0no-co/graphql.web@1.3.2", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-Q1+pRlLhE31GOY/2c9BAEnFTNxO7Awtc6fhhEDlxyCBQ2N0IhD32cPVvPChrK9mwBNSgRdW/sF1kd2e0ojHj1Q=="], + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.3.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.222", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.222", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.222", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.222", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.222", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.222", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.222", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.222", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.222" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-muAyjIzXJjIpSrj91vSmnU76/z7rNlV8+lSuq48h4eUPSYVBSgOasgkfQAMUrIZJCfI15XF9/EYoRrx/eKD7Og=="], @@ -522,6 +526,8 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.14", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Yd8vPDT5DW++Hs2904Q/Vvk/m1U79aKFSRusgAfBQ+AGhfoT40LdeL5eiJtVy0xBUIZF/8DEZjkWaeof9Y59PA=="], + "@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="], "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.22.0", "", { "os": "android", "cpu": "arm" }, "sha512-il+0FB7BBUfuQaE0Lgd9zlgSjzu88ErN8vr4hintuTt1qRDcPtmzLyurail1gJZpJ1ljo7zA0cid/a/PaWMyZg=="], diff --git a/packages/server/package.json b/packages/server/package.json index 0ef7840..4c7a9aa 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -31,9 +31,11 @@ "lint": "oxlint src --config ../../oxlint.jsonc" }, "dependencies": { + "@agentclientprotocol/sdk": "^1.3.0", "@anthropic-ai/claude-agent-sdk": "^0.3.222", "@echohello/client": "workspace:*", "@echohello/protocol": "workspace:*", + "@opencode-ai/sdk": "^1.18.14", "dotenv": "^17.2.3", "express": "^5.1.0", "pino": "^10.2.0", diff --git a/packages/server/src/daemon.ts b/packages/server/src/daemon.ts index e251e84..fc38621 100644 --- a/packages/server/src/daemon.ts +++ b/packages/server/src/daemon.ts @@ -7,8 +7,9 @@ import { createHttpApp } from "./http-app.js"; import { createLogger } from "./logger.js"; import { resolveSupaplaneHome, SUPAPLANE_VERSION } from "./paths.js"; import { AgentManager } from "./server/agent/agent-manager.js"; +import { loadConfigOverlay, providerOverridesFromOverlay } from "./server/agent/config-overlay.js"; import { HandleStore } from "./server/agent/handle-store.js"; -import { ClaudeAgentClient } from "./server/agent/providers/claude/claude-provider.js"; +import { buildProviders } from "./server/agent/provider-factory.js"; import { CommandDispatcher } from "./server/command-dispatcher.js"; import { RpcRouter } from "./server/rpc-router.js"; import { WorkspaceRegistry } from "./server/workspace-registry.js"; @@ -65,7 +66,10 @@ export async function startDaemon(args?: { const handleStore = new HandleStore(supaplaneHome); const agentManager = new AgentManager({ handleStore, logger }); - agentManager.registerProvider(new ClaudeAgentClient()); + const overlay = await loadConfigOverlay(supaplaneHome); + for (const provider of buildProviders(providerOverridesFromOverlay(overlay), logger)) { + agentManager.registerProvider(provider); + } const workspaces = new WorkspaceRegistry(); const wsServer = new SupaplaneWebsocketServer({ diff --git a/packages/server/src/server/agent/config-overlay.test.ts b/packages/server/src/server/agent/config-overlay.test.ts new file mode 100644 index 0000000..3d2f02b --- /dev/null +++ b/packages/server/src/server/agent/config-overlay.test.ts @@ -0,0 +1,50 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { loadConfigOverlay, providerOverridesFromOverlay } from "./config-overlay.js"; + +describe("loadConfigOverlay", () => { + it("returns an empty overlay when config.json is absent", async () => { + const home = await mkdtemp(join(tmpdir(), "supaplane-overlay-")); + const overlay = await loadConfigOverlay(home); + expect(overlay.agents.providers).toEqual({}); + }); + + it("parses provider overrides from config.json", async () => { + const home = await mkdtemp(join(tmpdir(), "supaplane-overlay-")); + await writeFile( + join(home, "config.json"), + JSON.stringify({ + agents: { + providers: { + "gemini-cli": { extends: "acp", command: ["gemini", "--acp"] }, + "claude-work": { extends: "claude", label: "Work Claude" }, + }, + }, + }), + ); + const overlay = await loadConfigOverlay(home); + const overrides = providerOverridesFromOverlay(overlay); + expect(overrides).toHaveLength(2); + expect(overrides.find((o) => o.id === "gemini-cli")).toMatchObject({ + extends: "acp", + command: ["gemini", "--acp"], + }); + expect(overrides.find((o) => o.id === "claude-work")).toMatchObject({ + extends: "claude", + label: "Work Claude", + }); + }); + + it("rejects an invalid overlay", async () => { + const home = await mkdtemp(join(tmpdir(), "supaplane-overlay-")); + await writeFile( + join(home, "config.json"), + JSON.stringify({ agents: { providers: { bad: { extends: "nope" } } } }), + ); + await expect(loadConfigOverlay(home)).rejects.toThrow(); + }); +}); diff --git a/packages/server/src/server/agent/config-overlay.ts b/packages/server/src/server/agent/config-overlay.ts new file mode 100644 index 0000000..53f5ee6 --- /dev/null +++ b/packages/server/src/server/agent/config-overlay.ts @@ -0,0 +1,38 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { z } from "zod"; +import { ProviderOverrideSchema, type ProviderOverride } from "@echohello/protocol"; + +/** + * `~/.supaplane/config.json` overlay. Users extend/override agent providers + * here (see docs/providers.md). Hot-reload is intentionally out of scope — + * the overlay is read once at daemon boot. + */ +export const ConfigOverlaySchema = z.object({ + agents: z + .object({ + providers: z.record(z.string(), ProviderOverrideSchema.omit({ id: true })).default({}), + }) + .default({ providers: {} }), +}); +export type ConfigOverlay = z.infer; + +/** Load `/config.json`. Returns an empty overlay when absent. */ +export async function loadConfigOverlay(supaplaneHome: string): Promise { + let raw: string; + try { + raw = await readFile(join(supaplaneHome, "config.json"), "utf8"); + } catch { + return ConfigOverlaySchema.parse({}); + } + return ConfigOverlaySchema.parse(JSON.parse(raw)); +} + +/** Flatten the overlay's provider map into `ProviderOverride`s (map key becomes `id`). */ +export function providerOverridesFromOverlay(overlay: ConfigOverlay): ProviderOverride[] { + return Object.entries(overlay.agents.providers).map(([id, override]) => ({ + ...override, + id, + })); +} diff --git a/packages/server/src/server/agent/provider-factory.test.ts b/packages/server/src/server/agent/provider-factory.test.ts new file mode 100644 index 0000000..ed0015f --- /dev/null +++ b/packages/server/src/server/agent/provider-factory.test.ts @@ -0,0 +1,71 @@ +import type { Logger } from "pino"; +import { describe, expect, it } from "vitest"; +import type { ProviderOverride } from "@echohello/protocol"; + +import { buildProviders } from "./provider-factory.js"; + +const silentLogger = { + child: () => silentLogger, + warn: () => undefined, + info: () => undefined, +} as unknown as Logger; + +describe("buildProviders", () => { + it("registers the three built-ins by default", () => { + const providers = buildProviders([], silentLogger); + expect(providers.map((p) => p.providerId).sort()).toEqual(["claude", "cursor", "opencode"]); + }); + + it("aliases a built-in via extends with a new id", () => { + const overrides: ProviderOverride[] = [ + { id: "claude-work", extends: "claude", env: {}, providerParams: {} }, + ]; + const providers = buildProviders(overrides, silentLogger); + expect(providers.map((p) => p.providerId)).toContain("claude-work"); + expect(providers.map((p) => p.providerId)).toContain("claude"); + }); + + it("replaces a built-in when the override reuses its id", () => { + const overrides: ProviderOverride[] = [ + { + id: "claude", + extends: "claude", + command: ["/opt/custom/claude"], + env: {}, + providerParams: {}, + }, + ]; + const providers = buildProviders(overrides, silentLogger); + expect(providers.filter((p) => p.providerId === "claude")).toHaveLength(1); + }); + + it("builds a generic acp provider from extends: acp", () => { + const overrides: ProviderOverride[] = [ + { + id: "gemini-cli", + extends: "acp", + command: ["gemini", "--acp"], + env: {}, + providerParams: {}, + }, + ]; + const providers = buildProviders(overrides, silentLogger); + expect(providers.map((p) => p.providerId)).toContain("gemini-cli"); + }); + + it("skips extends: acp overrides without a command", () => { + const overrides: ProviderOverride[] = [ + { id: "broken", extends: "acp", env: {}, providerParams: {} }, + ]; + const providers = buildProviders(overrides, silentLogger); + expect(providers.map((p) => p.providerId)).not.toContain("broken"); + }); + + it("drops providers marked enabledByDefault: false", () => { + const overrides: ProviderOverride[] = [ + { id: "cursor", extends: "cursor", enabledByDefault: false, env: {}, providerParams: {} }, + ]; + const providers = buildProviders(overrides, silentLogger); + expect(providers.map((p) => p.providerId)).not.toContain("cursor"); + }); +}); diff --git a/packages/server/src/server/agent/provider-factory.ts b/packages/server/src/server/agent/provider-factory.ts new file mode 100644 index 0000000..b823674 --- /dev/null +++ b/packages/server/src/server/agent/provider-factory.ts @@ -0,0 +1,63 @@ +import type { Logger } from "pino"; +import type { ProviderOverride } from "@echohello/protocol"; + +import type { AgentClient } from "./agent-sdk-types.js"; +import { AcpAgentClient } from "./providers/acp/acp-provider.js"; +import { ClaudeAgentClient } from "./providers/claude/claude-provider.js"; +import { CursorAgentClient } from "./providers/cursor/cursor-provider.js"; +import { OpenCodeAgentClient } from "./providers/opencode/opencode-provider.js"; + +/** + * Builds the provider set: the three built-ins, overlaid with user overrides + * from `~/.supaplane/config.json`. Overrides with the same id replace the + * built-in; `extends: "acp"` creates a generic ACP provider from `command`. + */ +export function buildProviders(overrides: ProviderOverride[], logger: Logger): AgentClient[] { + const log = logger.child({ module: "provider-factory" }); + const providers = new Map(); + providers.set("claude", new ClaudeAgentClient()); + providers.set("opencode", new OpenCodeAgentClient()); + providers.set("cursor", new CursorAgentClient()); + + for (const override of overrides) { + if (override.enabledByDefault === false) { + providers.delete(override.id); + continue; + } + try { + providers.set(override.id, buildFromOverride(override)); + } catch (err) { + log.warn({ err, providerId: override.id }, "skipping invalid provider override"); + } + } + return [...providers.values()]; +} + +function buildFromOverride(override: ProviderOverride): AgentClient { + switch (override.extends) { + case "claude": + return new ClaudeAgentClient({ + id: override.id, + ...(override.command !== undefined ? { command: override.command } : {}), + }); + case "opencode": + return new OpenCodeAgentClient({ + id: override.id, + ...(override.command !== undefined ? { command: override.command } : {}), + }); + case "cursor": + return new CursorAgentClient({ + id: override.id, + ...(override.command !== undefined ? { command: override.command } : {}), + }); + case "acp": + if (override.command === undefined || override.command.length === 0) { + throw new Error(`provider ${override.id} extends "acp" but has no command`); + } + return new AcpAgentClient({ + id: override.id, + command: override.command, + env: override.env, + }); + } +} diff --git a/packages/server/src/server/agent/providers/acp/acp-provider.ts b/packages/server/src/server/agent/providers/acp/acp-provider.ts new file mode 100644 index 0000000..5cc4ff4 --- /dev/null +++ b/packages/server/src/server/agent/providers/acp/acp-provider.ts @@ -0,0 +1,303 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { Readable, Writable } from "node:stream"; + +import { + ClientApp, + ndJsonStream, + type ActiveSession, + type ClientConnection, + type SessionUpdate, + type ToolCall, + type ToolCallUpdate, +} from "@agentclientprotocol/sdk"; +import { ProviderError, type ProviderModel, type ProviderMode } from "@echohello/protocol"; + +import type { + AgentClient, + AgentEventSink, + AgentSession, + AgentSessionHandle, + CreateSessionArgs, + ImportableSession, + ResumeSessionArgs, + SessionScopedEvent, +} from "../../agent-sdk-types.js"; + +export interface AcpAgentClientOptions { + /** Provider id (e.g. "cursor", or any user-defined id via `extends: "acp"`). */ + id: string; + /** Binary + args that starts the agent in ACP mode, e.g. ["cursor-agent", "acp"]. */ + command: string[]; + /** Extra env overlaid on the daemon's environment for the agent process. */ + env?: Record; +} + +/** + * Generic ACP provider: spawns the agent binary and speaks Agent Client + * Protocol NDJSON over stdio. Cursor and user-defined `extends: "acp"` + * providers are this class with different `command`s. + * + * Subclasses may override `toolSnapshotTransformer` to reshape provider-native + * tool call payloads before they are emitted as `tool.start`/`tool.result`. + */ +export class AcpAgentClient implements AgentClient { + readonly providerId: string; + readonly #command: string[]; + readonly #env: Record | undefined; + + constructor(options: AcpAgentClientOptions) { + this.providerId = options.id; + this.#command = options.command; + this.#env = options.env; + } + + createSession(args: CreateSessionArgs, emit: AgentEventSink): Promise { + return this.#spawn(args.cwd, emit, args); + } + + resumeSession(args: ResumeSessionArgs, emit: AgentEventSink): Promise { + // ACP `session/load` support varies by agent; spawn fresh and let the + // agent's own history surface via its UI for now. + return this.#spawn(args.cwd, emit, { + cwd: args.cwd, + ...(args.overrides?.modelId !== undefined ? { modelId: args.overrides.modelId } : {}), + ...(args.overrides?.modeId !== undefined ? { modeId: args.overrides.modeId } : {}), + }); + } + + listModels(): Promise { + // ACP has no model-listing method; models are runtime/session-scoped. + return Promise.resolve([]); + } + + listModes(): Promise { + // Modes arrive on `session/new` (SessionModeState); surfaced post-connect. + return Promise.resolve([]); + } + + listImportableSessions(): Promise { + return Promise.resolve([]); + } + + /** Hook for subclasses: reshape ACP tool payloads before emission. */ + protected toolSnapshotTransformer( + snapshot: ToolCall | ToolCallUpdate, + ): ToolCall | ToolCallUpdate { + return snapshot; + } + + async #spawn( + cwd: string, + emit: AgentEventSink, + args: CreateSessionArgs, + ): Promise { + const [bin, ...binArgs] = this.#command; + if (bin === undefined) { + throw new ProviderError({ message: `${this.providerId} provider has no command configured` }); + } + + const child = spawn(bin, binArgs, { + cwd, + env: { ...process.env, ...this.#env }, + stdio: ["ignore", "pipe", "inherit"], + }); + child.on("error", (err) => { + emit({ type: "error", code: "provider_unavailable", message: err.message, ts: Date.now() }); + }); + if (child.stdout === null || child.stdin === null) { + throw new ProviderError({ message: `${this.providerId} agent stdio unavailable` }); + } + + const app = new ClientApp(); + this.#registerPermissionHandler(app, emit); + + const stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(child.stdout) as ReadableStream, + ); + const connection = app.connect(stream); + + let active: ActiveSession; + try { + active = await connection.agent.buildSession(cwd).start(); + } catch (err) { + child.kill(); + throw new ProviderError({ + code: "provider_unavailable", + message: `${this.providerId} agent failed to start an ACP session`, + cause: err, + }); + } + + void this.#pumpUpdates(active, emit); + + return { + session: new AcpAgentSession(connection, active, child, emit), + handle: { + provider: this.providerId, + sessionId: active.sessionId, + metadata: { + cwd, + ...(args.modelId !== undefined ? { modelId: args.modelId } : {}), + ...(args.modeId !== undefined ? { modeId: args.modeId } : {}), + }, + }, + }; + } + + #registerPermissionHandler(app: ClientApp, emit: AgentEventSink): void { + // Auto-approve once (local-first, user-supervised daemon) and surface the + // request as an event so renderers can show what was approved. + app.onRequest("session/request_permission", async (ctx) => { + const params = ctx.params; + const allow = + params.options.find((o) => o.kind === "allow_once") ?? + params.options.find((o) => o.kind === "allow_always") ?? + params.options[0]; + emit({ + type: "permission_request", + requestId: String(ctx.requestId ?? Date.now()), + reason: params.toolCall.title ?? "permission requested", + payload: params, + ts: Date.now(), + }); + if (allow === undefined) { + return { outcome: { outcome: "cancelled" } }; + } + return { outcome: { outcome: "selected", optionId: allow.optionId } }; + }); + } + + async #pumpUpdates(active: ActiveSession, emit: AgentEventSink): Promise { + try { + for (;;) { + const message = await active.nextUpdate(); + if (message.kind === "stop") { + emit({ type: "status", status: "idle", ts: Date.now() }); + continue; + } + for (const event of this.#mapUpdate(message.update)) { + emit(event); + } + } + } catch { + // Connection closed or session disposed. + } + } + + #mapUpdate(update: SessionUpdate): SessionScopedEvent[] { + const ts = Date.now(); + switch (update.sessionUpdate) { + case "agent_message_chunk": { + const text = contentText(update.content); + if (text.length === 0) return []; + return [{ type: "message.delta", partId: update.messageId ?? "msg", text, ts }]; + } + case "agent_thought_chunk": { + const text = contentText(update.content); + if (text.length === 0) return []; + return [ + { + type: "message.delta", + partId: update.messageId ?? "thought", + text, + reasoning: true, + ts, + }, + ]; + } + case "tool_call": { + const snapshot = this.toolSnapshotTransformer(update); + return [ + { + type: "tool.start", + toolCallId: snapshot.toolCallId, + name: toolName(snapshot), + input: toolInput(snapshot), + ts, + }, + ]; + } + case "tool_call_update": { + const snapshot = this.toolSnapshotTransformer(update); + if (snapshot.status !== "completed" && snapshot.status !== "failed") return []; + return [ + { + type: "tool.result", + toolCallId: snapshot.toolCallId, + output: toolOutput(snapshot), + durationMs: 0, + ts, + }, + ]; + } + default: + return []; + } + } +} + +class AcpAgentSession implements AgentSession { + readonly #connection: ClientConnection; + readonly #active: ActiveSession; + readonly #child: ChildProcess; + readonly #emit: AgentEventSink; + + constructor( + connection: ClientConnection, + active: ActiveSession, + child: ChildProcess, + emit: AgentEventSink, + ) { + this.#connection = connection; + this.#active = active; + this.#child = child; + this.#emit = emit; + } + + async send(prompt: string, attachments?: unknown[]): Promise { + void attachments; + this.#emit({ type: "status", status: "running", ts: Date.now() }); + await this.#active.prompt(prompt).catch((err: unknown) => { + this.#emit({ + type: "error", + code: "provider_error", + message: err instanceof Error ? err.message : String(err), + ts: Date.now(), + }); + }); + } + + async abort(): Promise { + await this.#connection.agent + .notify("session/cancel", { sessionId: this.#active.sessionId }) + .catch(() => undefined); + } + + dispose(): Promise { + this.#active.dispose(); + this.#connection.close(); + this.#child.kill(); + return Promise.resolve(); + } +} + +function contentText(content: unknown): string { + if (typeof content !== "object" || content === null) return ""; + const block = content as Record; + if (block.type === "text" && typeof block.text === "string") return block.text; + return ""; +} + +function toolName(snapshot: ToolCall | ToolCallUpdate): string { + return snapshot.title ?? "tool"; +} + +function toolInput(snapshot: ToolCall | ToolCallUpdate): unknown { + return "rawInput" in snapshot ? snapshot.rawInput : undefined; +} + +function toolOutput(snapshot: ToolCall | ToolCallUpdate): unknown { + if ("rawOutput" in snapshot && snapshot.rawOutput !== undefined) return snapshot.rawOutput; + return snapshot.content; +} diff --git a/packages/server/src/server/agent/providers/claude/claude-provider.ts b/packages/server/src/server/agent/providers/claude/claude-provider.ts index 69446c5..db17497 100644 --- a/packages/server/src/server/agent/providers/claude/claude-provider.ts +++ b/packages/server/src/server/agent/providers/claude/claude-provider.ts @@ -48,6 +48,8 @@ const MODE_TO_PERMISSION: Record = { }; export interface ClaudeAgentClientOptions { + /** Override the provider id (used by config overlays that alias the provider). */ + id?: string; /** Provider binary + args, e.g. `["claude"]`. */ command?: string[]; } @@ -58,10 +60,11 @@ export interface ClaudeAgentClientOptions { * async queue after the `system/init` message yields the upstream session id. */ export class ClaudeAgentClient implements AgentClient { - readonly providerId = "claude"; + readonly providerId: string; readonly #command: string[]; constructor(options: ClaudeAgentClientOptions = {}) { + this.providerId = options.id ?? "claude"; this.#command = options.command ?? ["claude"]; } diff --git a/packages/server/src/server/agent/providers/cursor/cursor-provider.ts b/packages/server/src/server/agent/providers/cursor/cursor-provider.ts new file mode 100644 index 0000000..1e733da --- /dev/null +++ b/packages/server/src/server/agent/providers/cursor/cursor-provider.ts @@ -0,0 +1,14 @@ +import { AcpAgentClient } from "../acp/acp-provider.js"; + +/** + * Cursor provider — ACP over `cursor-agent acp`. Plain `AcpAgentClient` + * with the cursor defaults; override the command via `~/.supaplane/config.json`. + */ +export class CursorAgentClient extends AcpAgentClient { + constructor(options: { id?: string; command?: string[] } = {}) { + super({ + id: options.id ?? "cursor", + command: options.command ?? ["cursor-agent", "acp"], + }); + } +} diff --git a/packages/server/src/server/agent/providers/opencode/opencode-provider.ts b/packages/server/src/server/agent/providers/opencode/opencode-provider.ts new file mode 100644 index 0000000..5f39dec --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/opencode-provider.ts @@ -0,0 +1,265 @@ +import { execFile } from "node:child_process"; + +import { createOpencode, type OpencodeClient } from "@opencode-ai/sdk"; +import { ProviderError, type ProviderModel, type ProviderMode } from "@echohello/protocol"; + +import type { + AgentClient, + AgentEventSink, + AgentSession, + AgentSessionHandle, + CreateSessionArgs, + ImportableSession, + ResumeSessionArgs, +} from "../../agent-sdk-types.js"; +import { OpenCodeToolCallMapper } from "./tool-call-mapper.js"; + +const OPENCODE_MODES: ProviderMode[] = [ + { id: "build", label: "Build", isUnattended: false, features: [] }, + { id: "plan", label: "Plan", isUnattended: false, features: ["read-only"] }, +]; + +export interface OpenCodeAgentClientOptions { + /** Override the provider id (used by config overlays that alias the provider). */ + id?: string; + /** Binary used for diagnostics (the SDK spawns its own server). */ + command?: string[]; +} + +interface SessionSubscriber { + emit: AgentEventSink; + mapper: OpenCodeToolCallMapper; + modelId?: string; +} + +/** + * OpenCode provider (`@opencode-ai/sdk`). Lazily spawns one shared + * `opencode serve` per provider instance; sessions are created against it + * per workspace cwd. Events stream over a single SSE subscription and are + * routed to per-session sinks by session id. + */ +export class OpenCodeAgentClient implements AgentClient { + readonly providerId: string; + readonly #command: string[]; + + #client: OpencodeClient | undefined; + #closeServer: (() => void) | undefined; + #eventPumpStarted = false; + readonly #subscribers = new Map(); + + constructor(options: OpenCodeAgentClientOptions = {}) { + this.providerId = options.id ?? "opencode"; + this.#command = options.command ?? ["opencode"]; + } + + async createSession(args: CreateSessionArgs, emit: AgentEventSink): Promise { + const client = await this.#ensureClient(); + const created = await client.session.create({ query: { directory: args.cwd } }); + const sessionId = created.data?.id; + if (sessionId === undefined) { + throw new ProviderError({ message: "opencode did not return a session id" }); + } + this.#subscribe(sessionId, emit, args.modelId); + return { + session: this.#makeSession(sessionId, emit), + handle: { + provider: this.providerId, + sessionId, + metadata: { + cwd: args.cwd, + ...(args.modelId !== undefined ? { modelId: args.modelId } : {}), + ...(args.modeId !== undefined ? { modeId: args.modeId } : {}), + }, + }, + }; + } + + async resumeSession(args: ResumeSessionArgs, emit: AgentEventSink): Promise { + const client = await this.#ensureClient(); + const existing = await client.session.get({ + path: { id: args.handle.sessionId }, + query: { directory: args.cwd }, + }); + if (existing.data === undefined) { + throw new ProviderError({ + code: "not_found", + message: `opencode session not found: ${args.handle.sessionId}`, + }); + } + this.#subscribe( + args.handle.sessionId, + emit, + typeof args.handle.metadata?.modelId === "string" ? args.handle.metadata.modelId : undefined, + ); + return { + session: this.#makeSession(args.handle.sessionId, emit), + handle: args.handle, + }; + } + + async listModels(): Promise { + try { + const client = await this.#ensureClient(); + const response = await client.provider.list(); + const providers = response.data?.all ?? []; + return providers.flatMap((p) => + Object.entries(p.models).map(([modelId, model]) => ({ + id: `${p.id}/${modelId}`, + label: model.name ?? modelId, + reasoning: model.reasoning, + vision: model.modalities?.input.includes("image") === true, + })), + ); + } catch { + return []; + } + } + + listModes(): Promise { + return Promise.resolve(OPENCODE_MODES); + } + + async listImportableSessions(): Promise { + try { + const client = await this.#ensureClient(); + const response = await client.session.list(); + return (response.data ?? []).map((s) => ({ + sessionId: s.id, + ...(s.title !== undefined && s.title.length > 0 ? { title: s.title } : {}), + ...(s.directory !== undefined ? { cwd: s.directory } : {}), + updatedAt: s.time.updated, + })); + } catch { + return []; + } + } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + const [bin, ...baseArgs] = this.#command; + if (bin === undefined) { + throw new ProviderError({ message: "opencode provider has no command configured" }); + } + return new Promise((resolvePromise, rejectPromise) => { + execFile(bin, [...baseArgs, "--version"], { timeout: 5000 }, (err, stdout, stderr) => { + if (err) { + rejectPromise( + new ProviderError({ + code: "provider_unavailable", + message: `opencode CLI unavailable: ${stderr || err.message}`, + cause: err, + }), + ); + return; + } + resolvePromise({ diagnostic: `opencode ${stdout.trim()}` }); + }); + }); + } + + async dispose(): Promise { + this.#subscribers.clear(); + this.#closeServer?.(); + this.#client = undefined; + this.#closeServer = undefined; + this.#eventPumpStarted = false; + } + + #subscribe(sessionId: string, emit: AgentEventSink, modelId?: string): void { + this.#subscribers.set(sessionId, { + emit, + mapper: new OpenCodeToolCallMapper(), + ...(modelId !== undefined ? { modelId } : {}), + }); + } + + #makeSession(sessionId: string, emit: AgentEventSink): AgentSession { + const requireClient = (): OpencodeClient => { + if (this.#client === undefined) { + throw new ProviderError({ message: "opencode server is not running" }); + } + return this.#client; + }; + return { + send: async (prompt: string, attachments?: unknown[]): Promise => { + void attachments; + const model = parseModelId(this.#subscribers.get(sessionId)?.modelId); + await requireClient().session.promptAsync({ + path: { id: sessionId }, + body: { + parts: [{ type: "text", text: prompt }], + ...(model !== undefined ? { model } : {}), + }, + }); + }, + abort: async (): Promise => { + await requireClient().session.abort({ path: { id: sessionId } }); + emit({ type: "status", status: "idle", ts: Date.now() }); + }, + dispose: (): Promise => { + this.#subscribers.delete(sessionId); + return Promise.resolve(); + }, + }; + } + + async #ensureClient(): Promise { + if (this.#client !== undefined) return this.#client; + try { + const { client, server } = await createOpencode({ hostname: "127.0.0.1" }); + this.#client = client; + this.#closeServer = () => server.close(); + this.#startEventPump(client); + return client; + } catch (err) { + throw new ProviderError({ + code: "provider_unavailable", + message: "failed to start opencode server", + cause: err, + }); + } + } + + #startEventPump(client: OpencodeClient): void { + if (this.#eventPumpStarted) return; + this.#eventPumpStarted = true; + void (async () => { + try { + const { stream } = await client.event.subscribe(); + for await (const event of stream) { + const sessionId = eventSessionId(event); + if (sessionId === undefined) continue; + const subscriber = this.#subscribers.get(sessionId); + if (subscriber === undefined) continue; + for (const mapped of subscriber.mapper.map(event)) { + subscriber.emit(mapped); + } + } + } catch { + // Server closed or stream dropped; sessions surface errors on next send. + } + })(); + } +} + +function parseModelId( + modelId: string | undefined, +): { providerID: string; modelID: string } | undefined { + if (modelId === undefined) return undefined; + const slash = modelId.indexOf("/"); + if (slash <= 0) return undefined; + return { providerID: modelId.slice(0, slash), modelID: modelId.slice(slash + 1) }; +} + +function eventSessionId(event: { + type: string; + properties: Record; +}): string | undefined { + const props = event.properties; + if (typeof props.sessionID === "string") return props.sessionID; + const part = props.part; + if (typeof part === "object" && part !== null) { + const id = (part as Record).sessionID; + if (typeof id === "string") return id; + } + return undefined; +} diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts new file mode 100644 index 0000000..f898688 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts @@ -0,0 +1,104 @@ +import type { Event, ToolPart } from "@opencode-ai/sdk"; +import { describe, expect, it } from "vitest"; + +import { OpenCodeToolCallMapper } from "./tool-call-mapper.js"; + +function partUpdated(part: unknown, delta?: string): Event { + return { + type: "message.part.updated", + properties: { part, ...(delta !== undefined ? { delta } : {}) }, + } as unknown as Event; +} + +function toolPart(status: string, extra: Record = {}): ToolPart { + return { + id: "part-1", + sessionID: "s-1", + messageID: "m-1", + type: "tool", + callID: "call-1", + tool: "bash", + state: { status, ...extra }, + } as unknown as ToolPart; +} + +describe("OpenCodeToolCallMapper", () => { + it("maps text part deltas to message.delta", () => { + const mapper = new OpenCodeToolCallMapper(); + const events = mapper.map( + partUpdated({ type: "text", id: "p1", text: "hello world" }, "hello"), + ); + expect(events).toEqual([ + { type: "message.delta", partId: "p1", text: "hello", ts: expect.any(Number) }, + ]); + }); + + it("maps reasoning parts to reasoning deltas", () => { + const mapper = new OpenCodeToolCallMapper(); + const events = mapper.map(partUpdated({ type: "reasoning", id: "p2", text: "thinking hard" })); + expect(events).toEqual([ + { + type: "message.delta", + partId: "p2", + text: "thinking hard", + reasoning: true, + ts: expect.any(Number), + }, + ]); + }); + + it("emits tool.start once per call id, then tool.result on completion", () => { + const mapper = new OpenCodeToolCallMapper(); + const running = toolPart("running", { input: { cmd: "ls" }, time: { start: 1000 } }); + expect(mapper.map(partUpdated(running))).toEqual([ + { + type: "tool.start", + toolCallId: "call-1", + name: "bash", + input: { cmd: "ls" }, + ts: expect.any(Number), + }, + ]); + expect(mapper.map(partUpdated(running))).toEqual([]); + + const completed = toolPart("completed", { + input: { cmd: "ls" }, + output: "file.txt", + time: { start: 1000, end: 1500 }, + }); + expect(mapper.map(partUpdated(completed))).toEqual([ + { + type: "tool.result", + toolCallId: "call-1", + output: "file.txt", + durationMs: 500, + ts: expect.any(Number), + }, + ]); + }); + + it("maps session.status and session.idle to status events", () => { + const mapper = new OpenCodeToolCallMapper(); + const busy = { + type: "session.status", + properties: { sessionID: "s-1", status: { type: "busy" } }, + } as unknown as Event; + expect(mapper.map(busy)).toEqual([ + { type: "status", status: "running", ts: expect.any(Number) }, + ]); + + const idle = { type: "session.idle", properties: { sessionID: "s-1" } } as unknown as Event; + expect(mapper.map(idle)).toEqual([{ type: "status", status: "idle", ts: expect.any(Number) }]); + }); + + it("maps session.error to an error event", () => { + const mapper = new OpenCodeToolCallMapper(); + const errorEvent = { + type: "session.error", + properties: { sessionID: "s-1", error: { name: "ApiError", data: { message: "boom" } } }, + } as unknown as Event; + expect(mapper.map(errorEvent)).toEqual([ + { type: "error", code: "provider_error", message: "boom", ts: expect.any(Number) }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts new file mode 100644 index 0000000..ce918a2 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts @@ -0,0 +1,107 @@ +import type { Event, Part, ToolPart } from "@opencode-ai/sdk"; + +import type { SessionScopedEvent } from "../../agent-sdk-types.js"; + +/** + * Maps OpenCode server-sent events onto Supaplane `SessionScopedEvent`s. + * Stateful: tool.start is emitted once per call id, and tool durations are + * taken from the part's own timestamps when present. + */ +export class OpenCodeToolCallMapper { + readonly #startedToolCalls = new Map(); + + map(event: Event): SessionScopedEvent[] { + const ts = Date.now(); + switch (event.type) { + case "message.part.updated": { + const { part, delta } = event.properties; + return this.#mapPart(part, delta, ts); + } + case "session.status": { + const status = event.properties.status; + if (status.type === "busy") return [{ type: "status", status: "running", ts }]; + if (status.type === "idle") return [{ type: "status", status: "idle", ts }]; + return [{ type: "status", status: "waiting", ts }]; + } + case "session.idle": + return [{ type: "status", status: "idle", ts }]; + case "session.error": { + const message = extractErrorMessage(event.properties.error); + return [{ type: "error", code: "provider_error", message, ts }]; + } + default: + return []; + } + } + + #mapPart(part: Part, delta: string | undefined, ts: number): SessionScopedEvent[] { + if (part.type === "text") { + const text = delta ?? part.text; + if (text.length === 0) return []; + return [{ type: "message.delta", partId: part.id, text, ts }]; + } + if (part.type === "reasoning") { + const text = delta ?? part.text; + if (text.length === 0) return []; + return [{ type: "message.delta", partId: part.id, text, reasoning: true, ts }]; + } + if (part.type === "tool") { + return this.#mapToolPart(part, ts); + } + return []; + } + + #mapToolPart(part: ToolPart, ts: number): SessionScopedEvent[] { + const state = part.state; + if (state.status === "running") { + if (this.#startedToolCalls.has(part.callID)) return []; + this.#startedToolCalls.set(part.callID, state.time.start); + return [ + { + type: "tool.start", + toolCallId: part.callID, + name: part.tool, + input: state.input, + ts, + }, + ]; + } + if (state.status === "completed") { + this.#startedToolCalls.delete(part.callID); + return [ + { + type: "tool.result", + toolCallId: part.callID, + output: state.output, + durationMs: Math.max(0, state.time.end - state.time.start), + ts, + }, + ]; + } + if (state.status === "error") { + this.#startedToolCalls.delete(part.callID); + return [ + { + type: "tool.result", + toolCallId: part.callID, + output: state.error, + durationMs: Math.max(0, state.time.end - state.time.start), + ts, + }, + ]; + } + return []; + } +} + +function extractErrorMessage(error: unknown): string { + if (typeof error !== "object" || error === null) return "unknown session error"; + const record = error as Record; + const data = record.data; + if (typeof data === "object" && data !== null) { + const message = (data as Record).message; + if (typeof message === "string") return message; + } + const name = record.name; + return typeof name === "string" ? name : "unknown session error"; +} diff --git a/packages/server/src/server/daemon-e2e/provider-overlay.test.ts b/packages/server/src/server/daemon-e2e/provider-overlay.test.ts new file mode 100644 index 0000000..cb6232a --- /dev/null +++ b/packages/server/src/server/daemon-e2e/provider-overlay.test.ts @@ -0,0 +1,59 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { SupaplaneClient } from "@echohello/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { startDaemon, type DaemonHandle } from "../../daemon.js"; + +describe("daemon e2e: provider config overlay", () => { + let daemon: DaemonHandle; + let client: SupaplaneClient; + + beforeEach(async () => { + const supaplaneHome = await mkdtemp(join(tmpdir(), "supaplane-e2e-overlay-")); + await writeFile( + join(supaplaneHome, "config.json"), + JSON.stringify({ + agents: { + providers: { + "claude-work": { extends: "claude", label: "Work Claude" }, + "gemini-cli": { extends: "acp", command: ["gemini", "--acp"] }, + }, + }, + }), + ); + daemon = await startDaemon({ + config: { listenPort: 0, logLevel: "error" }, + supaplaneHome, + }); + const { port } = daemon.httpServer.address() as AddressInfo; + client = new SupaplaneClient({ + endpoint: `ws://127.0.0.1:${port}`, + clientId: "e2e-overlay-client", + clientType: "cli", + reconnect: false, + }); + await client.connect(); + }); + + afterEach(async () => { + client.close(); + await daemon.stop(); + }); + + it("hello_ack advertises built-ins plus overlay providers", () => { + const providers = client.helloAck?.capabilities.providers ?? []; + expect(providers).toEqual(expect.arrayContaining(["claude", "opencode", "cursor"])); + expect(providers).toContain("claude-work"); + expect(providers).toContain("gemini-cli"); + }); + + it("provider.list rpc reflects the overlay", async () => { + const result = await client.rpc("provider.list"); + expect(result.providers).toContain("claude-work"); + expect(result.providers).toContain("gemini-cli"); + }); +}); diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index c8230c7..28d823c 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -8,6 +8,17 @@ export { resolveSupaplaneHome, SUPAPLANE_VERSION } from "../paths.js"; export { getOrCreateServerId } from "../server-id.js"; export { loadOrCreateDaemonKeyPair, type DaemonKeyPair } from "../daemon-keypair.js"; export { AgentManager } from "./agent/agent-manager.js"; +export { + loadConfigOverlay, + providerOverridesFromOverlay, + ConfigOverlaySchema, + type ConfigOverlay, +} from "./agent/config-overlay.js"; +export { buildProviders } from "./agent/provider-factory.js"; +export { AcpAgentClient } from "./agent/providers/acp/acp-provider.js"; +export { CursorAgentClient } from "./agent/providers/cursor/cursor-provider.js"; +export { OpenCodeAgentClient } from "./agent/providers/opencode/opencode-provider.js"; +export { OpenCodeToolCallMapper } from "./agent/providers/opencode/tool-call-mapper.js"; export { HandleStore, sanitizeCwd } from "./agent/handle-store.js"; export { ClaudeAgentClient } from "./agent/providers/claude/claude-provider.js"; export { ClaudeToolCallMapper } from "./agent/providers/claude/tool-call-mapper.js";