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
67 changes: 58 additions & 9 deletions packages/cli/src/commands/agent.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,71 @@
import { Command } from "commander";
import type { SessionState } from "@echohello/protocol";

import { connectCli, DEFAULT_HTTP_ENDPOINT } from "../connect.js";

export const agentCommand = new Command("agent")
.description("Inspect and operate on agents (TODO)")
.description("Inspect and operate on agent sessions")
.addCommand(
new Command("list")
.description("List agents (queries daemon via WS — not yet wired)")
.action(() => {
process.stderr.write("Not implemented yet.\n");
process.exit(1);
.description("List agent sessions known to the daemon")
.option("--workspace <id>", "filter by workspace id")
.option("--endpoint <url>", "daemon HTTP endpoint", DEFAULT_HTTP_ENDPOINT)
.action(async (opts) => {
const client = await connectCli(String(opts.endpoint), "cli-agent-list");
try {
const args = opts.workspace ? { workspaceId: String(opts.workspace) } : undefined;
const { sessions } = await client.rpc<typeof args, { sessions: SessionState[] }>(
"session.list",
args,
);
for (const session of sessions) {
process.stdout.write(
`${session.sessionId}\t${session.providerId}\t${session.status}\t${session.workspaceId}\n`,
);
}
} finally {
client.close();
}
}),
)
.addCommand(
new Command("send")
.description("Send a prompt to an agent (TODO)")
.description("Send a prompt to an agent session and stream the reply")
.argument("<session-id>", "session id")
.argument("<prompt...>", "prompt text")
.action(() => {
process.stderr.write("Not implemented yet.\n");
process.exit(1);
.option("--endpoint <url>", "daemon HTTP endpoint", DEFAULT_HTTP_ENDPOINT)
.option("--timeout <ms>", "stream timeout", "120000")
.action(async (sessionId: string, promptParts: string[], opts) => {
const client = await connectCli(String(opts.endpoint), "cli-agent-send");
const prompt = promptParts.join(" ");
const done = new Promise<void>((resolvePromise, rejectPromise) => {
const timer = setTimeout(
() => rejectPromise(new Error("timed out waiting for the agent")),
Number(opts.timeout),
);
client.onAgentEvent((event) => {
if (event.sessionId !== sessionId) return;
if (event.type === "message.delta") {
process.stdout.write(event.text);
} else if (event.type === "message.final") {
process.stdout.write(`\n${event.text}\n`);
} else if (event.type === "status" && event.status === "idle") {
clearTimeout(timer);
resolvePromise();
} else if (event.type === "error") {
clearTimeout(timer);
rejectPromise(new Error(event.message));
}
});
});
try {
client.sendCommand({ type: "session.send", sessionId, prompt, attachments: [] });
await done;
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
} finally {
client.close();
}
}),
);
6 changes: 4 additions & 2 deletions packages/cli/src/commands/daemon.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Command } from "commander";

import { DEFAULT_HTTP_ENDPOINT } from "../connect.js";

export const daemonCommand = new Command("daemon")
.description("Control the Supaplane daemon (start, stop, status, log, pair)")
.addCommand(
Expand All @@ -23,7 +25,7 @@ export const daemonCommand = new Command("daemon")
.addCommand(
new Command("status")
.description("Show daemon status (queries the health endpoint)")
.option("--endpoint <url>", "daemon HTTP endpoint", "http://127.0.0.1:6767")
.option("--endpoint <url>", "daemon HTTP endpoint", DEFAULT_HTTP_ENDPOINT)
.action(async (opts) => {
const res = await fetch(`${opts.endpoint}/api/health`);
if (!res.ok) {
Expand All @@ -37,7 +39,7 @@ export const daemonCommand = new Command("daemon")
.addCommand(
new Command("version")
.description("Print the daemon version + protocol version")
.option("--endpoint <url>", "daemon HTTP endpoint", "http://127.0.0.1:6767")
.option("--endpoint <url>", "daemon HTTP endpoint", DEFAULT_HTTP_ENDPOINT)
.action(async (opts) => {
const res = await fetch(`${opts.endpoint}/api/version`);
process.stdout.write(`${await res.text()}\n`);
Expand Down
32 changes: 27 additions & 5 deletions packages/cli/src/commands/provider.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
import { Command } from "commander";

import { connectCli, DEFAULT_HTTP_ENDPOINT } from "../connect.js";

export const providerCommand = new Command("provider")
.description("Inspect provider availability (TODO)")
.description("Inspect provider availability")
.addCommand(
new Command("list").description("List providers and their diagnostic status").action(() => {
process.stderr.write("Not implemented yet.\n");
process.exit(1);
}),
new Command("list")
.description("List providers and their diagnostic status")
.option("--endpoint <url>", "daemon HTTP endpoint", DEFAULT_HTTP_ENDPOINT)
.action(async (opts) => {
const client = await connectCli(String(opts.endpoint), "cli-provider-list");
try {
const { providers } = await client.rpc<never, { providers: string[] }>("provider.list");
for (const providerId of providers) {
let diagnostic = "no diagnostic";
try {
const result = await client.rpc<{ providerId: string }, { diagnostic: string }>(
"provider.diagnostic",
{ providerId },
);
diagnostic = result.diagnostic;
} catch (err) {
diagnostic = `unavailable: ${err instanceof Error ? err.message : String(err)}`;
}
process.stdout.write(`${providerId}\t${diagnostic}\n`);
}
} finally {
client.close();
}
}),
);
92 changes: 82 additions & 10 deletions packages/cli/src/commands/worktree.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,91 @@
import { Command } from "commander";
import type { WorkspaceState } from "@echohello/protocol";

import { connectCli, DEFAULT_HTTP_ENDPOINT } from "../connect.js";

interface WorktreeInfo {
path: string;
branch: string | null;
bare: boolean;
}

export const worktreeCommand = new Command("worktree")
.description("Manage git worktrees (TODO)")
.description("Manage git worktrees")
.addCommand(
new Command("create")
.description("Create a worktree for the given branch/PR")
.argument("<target>", "branch name or PR number")
.action(() => {
process.stderr.write("Not implemented yet.\n");
process.exit(1);
.description("Create a worktree for the given branch")
.argument("<name>", "branch name for the worktree")
.option("--cwd <path>", "repo to branch from (defaults to process cwd)", process.cwd())
.option("--endpoint <url>", "daemon HTTP endpoint", DEFAULT_HTTP_ENDPOINT)
.action(async (name: string, opts) => {
const client = await connectCli(String(opts.endpoint), "cli-worktree-create");
try {
const created = new Promise<WorkspaceState>((resolvePromise, rejectPromise) => {
const timer = setTimeout(
() => rejectPromise(new Error("timed out waiting for the daemon")),
30_000,
);
client.onWorkspaceState((workspace) => {
if (!workspace.cwd.endsWith(`-${sanitize(name)}`)) return;
clearTimeout(timer);
resolvePromise(workspace);
});
});
client.sendCommand({ type: "workspace.open", cwd: String(opts.cwd) });
const workspace = await waitForWorkspace(client);
client.sendCommand({
type: "git.checkout",
workspaceId: workspace.workspaceId,
target: { kind: "worktree", name },
});
const worktree = await created;
process.stdout.write(`${worktree.workspaceId}\t${worktree.cwd}\n`);
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
} finally {
client.close();
}
}),
)
.addCommand(
new Command("list").description("List worktrees known to the daemon").action(() => {
process.stderr.write("Not implemented yet.\n");
process.exit(1);
}),
new Command("list")
.description("List worktrees for a workspace")
.option("--cwd <path>", "repo path (defaults to process cwd)", process.cwd())
.option("--endpoint <url>", "daemon HTTP endpoint", DEFAULT_HTTP_ENDPOINT)
.action(async (opts) => {
const client = await connectCli(String(opts.endpoint), "cli-worktree-list");
try {
client.sendCommand({ type: "workspace.open", cwd: String(opts.cwd) });
const workspace = await waitForWorkspace(client);
const { worktrees } = await client.rpc<
{ workspaceId: string },
{ worktrees: WorktreeInfo[] }
>("worktree.list", { workspaceId: workspace.workspaceId });
for (const worktree of worktrees) {
process.stdout.write(`${worktree.path}\t${worktree.branch ?? "(detached)"}\n`);
}
} finally {
client.close();
}
}),
);

function sanitize(name: string): string {
return name.replace(/[^a-zA-Z0-9._-]/g, "-");
}

function waitForWorkspace(client: {
onWorkspaceState: (listener: (workspace: WorkspaceState) => void) => unknown;
}): Promise<WorkspaceState> {
return new Promise((resolvePromise, rejectPromise) => {
const timer = setTimeout(
() => rejectPromise(new Error("timed out waiting for workspace_state")),
10_000,
);
client.onWorkspaceState((workspace) => {
clearTimeout(timer);
resolvePromise(workspace);
});
});
}
30 changes: 30 additions & 0 deletions packages/cli/src/connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { SupaplaneClient } from "@echohello/client";

export const DEFAULT_HTTP_ENDPOINT = "http://127.0.0.1:17687";

/** Convert an HTTP daemon endpoint (or explicit ws:// URL) to a WS endpoint. */
export function toWsEndpoint(endpoint: string): string {
if (endpoint.startsWith("ws://") || endpoint.startsWith("wss://")) return endpoint;
return endpoint.replace(/^http/, "ws");
}

/** Connect a CLI client to the daemon, failing fast with a useful message. */
export async function connectCli(endpoint: string, clientId: string): Promise<SupaplaneClient> {
const client = new SupaplaneClient({
endpoint: toWsEndpoint(endpoint),
clientId,
clientType: "cli",
reconnect: false,
});
try {
await client.connect();
} catch (err) {
client.close();
const message = err instanceof Error ? err.message : String(err);
process.stderr.write(
`Failed to connect to daemon at ${endpoint}: ${message}\nIs the daemon running? (supaplane daemon start)\n`,
);
process.exit(1);
}
return client;
}
12 changes: 11 additions & 1 deletion packages/server/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ 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";
import { WorktreeService } from "./server/worktree-service.js";
import { SupaplaneWebsocketServer } from "./websocket-server.js";

export interface DaemonHandle {
Expand All @@ -24,6 +25,7 @@ export interface DaemonHandle {
wsServer: SupaplaneWebsocketServer;
agentManager: AgentManager;
workspaces: WorkspaceRegistry;
worktreeService: WorktreeService;
}

/**
Expand Down Expand Up @@ -71,6 +73,7 @@ export async function startDaemon(args?: {
agentManager.registerProvider(provider);
}
const workspaces = new WorkspaceRegistry();
const worktreeService = new WorktreeService({ workspaces, logger });

const wsServer = new SupaplaneWebsocketServer({
httpServer,
Expand All @@ -87,6 +90,7 @@ export async function startDaemon(args?: {
const dispatcher = new CommandDispatcher({
workspaces,
agents: agentManager,
worktrees: worktreeService,
broadcast: (event) => wsServer.broadcast(event),
logger,
});
Expand All @@ -102,7 +106,12 @@ export async function startDaemon(args?: {
}),
);

const rpcRouter = new RpcRouter({ workspaces, agents: agentManager, logger });
const rpcRouter = new RpcRouter({
workspaces,
agents: agentManager,
worktrees: worktreeService,
logger,
});
wsServer.setRpcHandler((req) => rpcRouter.handle(req));

await new Promise<void>((resolve, reject) => {
Expand All @@ -129,6 +138,7 @@ export async function startDaemon(args?: {
wsServer,
agentManager,
workspaces,
worktreeService,
async stop(): Promise<void> {
logger.info("stopping daemon");
await agentManager.disposeAll();
Expand Down
18 changes: 17 additions & 1 deletion packages/server/src/server/command-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {

import type { AgentManager } from "./agent/agent-manager.js";
import type { WorkspaceRegistry } from "./workspace-registry.js";
import type { WorktreeService } from "./worktree-service.js";

export interface CommandContext {
clientId: string;
Expand All @@ -18,6 +19,7 @@ export interface CommandContext {
export interface CommandDispatcherOptions {
workspaces: WorkspaceRegistry;
agents: AgentManager;
worktrees: WorktreeService;
broadcast: (event: ServerEvent) => void;
logger: Logger;
}
Expand All @@ -32,12 +34,14 @@ export interface CommandDispatcherOptions {
export class CommandDispatcher {
readonly #workspaces: WorkspaceRegistry;
readonly #agents: AgentManager;
readonly #worktrees: WorktreeService;
readonly #broadcast: (event: ServerEvent) => void;
readonly #logger: Logger;

constructor(options: CommandDispatcherOptions) {
this.#workspaces = options.workspaces;
this.#agents = options.agents;
this.#worktrees = options.worktrees;
this.#broadcast = options.broadcast;
this.#logger = options.logger.child({ module: "command-dispatcher" });
}
Expand Down Expand Up @@ -106,10 +110,22 @@ export class CommandDispatcher {
});
return;
}
case "git.checkout": {
void this.#guard(ctx, async () => {
const target =
cmd.target.kind === "pr"
? { kind: "pr" as const, number: cmd.target.number }
: { kind: cmd.target.kind, name: cmd.target.name };
const affected = await this.#worktrees.checkout(cmd.workspaceId, target);
for (const workspace of affected) {
this.#broadcast({ kind: "workspace_state", workspace });
}
});
return;
}
case "session.fork":
case "diff.open":
case "file.open":
case "git.checkout":
case "permission.resolve": {
this.#fail(
ctx,
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/server/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export { ClaudeAgentClient } from "./agent/providers/claude/claude-provider.js";
export { ClaudeToolCallMapper } from "./agent/providers/claude/tool-call-mapper.js";
export { CommandDispatcher, type CommandContext } from "./command-dispatcher.js";
export { WorkspaceRegistry } from "./workspace-registry.js";
export { WorktreeService, parseWorktreeList, type WorktreeInfo } from "./worktree-service.js";
export type {
AgentClient,
AgentEventSink,
Expand Down
Loading
Loading