Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions packages/server/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down
50 changes: 50 additions & 0 deletions packages/server/src/server/agent/config-overlay.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
38 changes: 38 additions & 0 deletions packages/server/src/server/agent/config-overlay.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ConfigOverlaySchema>;

/** Load `<supaplaneHome>/config.json`. Returns an empty overlay when absent. */
export async function loadConfigOverlay(supaplaneHome: string): Promise<ConfigOverlay> {
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,
}));
}
71 changes: 71 additions & 0 deletions packages/server/src/server/agent/provider-factory.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
63 changes: 63 additions & 0 deletions packages/server/src/server/agent/provider-factory.ts
Original file line number Diff line number Diff line change
@@ -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<string, AgentClient>();
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,
});
}
}
Loading