diff --git a/README.md b/README.md index f4b0e8dd..29964f76 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https - Interactive (and background) terminals - Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands) - Client MCP servers +- [Remote Control](https://code.claude.com/docs/en/remote-control) (`/remote-control`) Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/). diff --git a/src/acp-agent.ts b/src/acp-agent.ts index a6a01f05..55f3af76 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -165,6 +165,22 @@ type Session = { /** Accumulated task list for the session, keyed by task ID. Task IDs are * per-session, so this state must not be shared across sessions. */ taskState: TaskState; + /** Whether a Remote Control bridge is currently active for this session, so + * `/remote-control` toggles connect/disconnect. */ + remoteControlActive?: boolean; +}; + +/** Result of the SDK's `remote_control` control request. The method exists on + * the runtime `Query` object (since claude-agent-sdk 0.3.x) but is not yet in + * its published type definitions, so we narrow to it via this shim. */ +type RemoteControlResponse = { + session_url?: string; + connect_url?: string; + environment_id?: string; +}; + +type QueryWithRemoteControl = Query & { + enableRemoteControl(enabled: boolean, name?: string): Promise; }; /** Compute a stable fingerprint of the session-defining params so we can @@ -342,6 +358,29 @@ const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX; // message and without invoking the model. const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]); +// Commands that toggle the Remote Control bridge. These are intercepted before +// reaching the model: the SDK's terminal `local-jsx` UI for `/remote-control` +// can't render over ACP, so we drive the control request directly and surface +// the session URL back to the client instead. +const REMOTE_CONTROL_COMMANDS = new Set(["/remote-control", "/rc"]); + +// Advertised so ACP clients (e.g. Zed) accept these commands and forward them +// to prompt(); the SDK doesn't report the terminal-only `/remote-control` UI +// through supportedCommands. +const REMOTE_CONTROL_AVAILABLE_COMMANDS: AvailableCommand[] = [ + { + name: "remote-control", + description: + "Connect this session to claude.ai/code for Remote Control (run again to disconnect)", + input: { hint: "[name]" }, + }, + { + name: "rc", + description: "Alias for /remote-control", + input: { hint: "[name]" }, + }, +]; + // The Claude SDK persists local slash command invocations (e.g. `/model`) and // their output as user messages in the session transcript, wrapping the // payload in these XML-like markers that the CLI uses for its own display. @@ -729,12 +768,78 @@ export class ClaudeAcpAgent implements Agent { throw new Error("Method not implemented."); } + private async emitAgentText(sessionId: string, text: string): Promise { + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }, + }); + } + + /** Connect or disconnect a Remote Control bridge for this session, mirroring + * the official VS Code `/remote-control` behavior over ACP: send the + * `remote_control` control request to the Claude binary, then surface the + * returned session URL (or an error) back to the client. */ + private async toggleRemoteControl( + session: Session, + sessionId: string, + commandText: string, + ): Promise { + const query = session.query as QueryWithRemoteControl; + if (typeof query.enableRemoteControl !== "function") { + await this.emitAgentText( + sessionId, + "Remote Control isn't available in this Claude Code version. Upgrade to a build that supports it (claude-agent-sdk 0.3.x or later).", + ); + return { stopReason: "end_turn" }; + } + + const name = commandText.split(/\s+/).slice(1).join(" ").trim() || undefined; + const enabling = !session.remoteControlActive; + + try { + const response = await query.enableRemoteControl(enabling, name); + if (enabling) { + session.remoteControlActive = true; + const url = response?.session_url; + if (url) { + await this.emitAgentText( + sessionId, + `🔗 Remote Control is active. Continue this session from any device:\n\n${url}\n\nRun /remote-control (or /rc) again to disconnect.`, + ); + } else { + await this.emitAgentText( + sessionId, + "Remote Control was enabled, but no session URL was returned.", + ); + } + } else { + session.remoteControlActive = false; + await this.emitAgentText(sessionId, "Remote Control disconnected."); + } + } catch (error) { + session.remoteControlActive = false; + const message = error instanceof Error ? error.message : String(error); + await this.emitAgentText(sessionId, `Remote Control failed: ${message}`); + } + + return { stopReason: "end_turn" }; + } + async prompt(params: PromptRequest): Promise { const session = this.sessions[params.sessionId]; if (!session) { throw new Error("Session not found"); } + const firstPromptText = params.prompt[0]?.type === "text" ? params.prompt[0].text : ""; + const firstCommand = firstPromptText.startsWith("/") ? firstPromptText.split(/\s+/, 1)[0] : ""; + if (REMOTE_CONTROL_COMMANDS.has(firstCommand)) { + return await this.toggleRemoteControl(session, params.sessionId, firstPromptText); + } + session.cancelled = false; session.accumulatedUsage = { inputTokens: 0, @@ -2752,7 +2857,7 @@ async function getAvailableModels( }; } -function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] { +export function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] { const UNSUPPORTED_COMMANDS = [ "clear", "cost", @@ -2764,7 +2869,7 @@ function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] "todos", ]; - return commands + const mapped = commands .map((command) => { const input = command.argumentHint ? { @@ -2784,6 +2889,12 @@ function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] }; }) .filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name)); + + const advertised = new Set(mapped.map((command) => command.name)); + const remoteControl = REMOTE_CONTROL_AVAILABLE_COMMANDS.filter( + (command) => !advertised.has(command.name), + ); + return [...mapped, ...remoteControl]; } function formatUriAsLink(uri: string): string { diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 2b38c5e8..c4889176 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -32,6 +32,7 @@ import { ClaudeAcpAgent, claudeCliPath, describeAlwaysAllow, + getAvailableSlashCommands, streamEventToAcpNotifications, type SDKMessageFilter, } from "../acp-agent.js"; @@ -3911,3 +3912,121 @@ describe("streamEventToAcpNotifications", () => { expect(errors).toEqual([]); }); }); + +describe("/remote-control command", () => { + function createAgentWithCapture() { + const updates: string[] = []; + const mockClient = { + sessionUpdate: async (params: SessionNotification) => { + const u = params.update; + if (u.sessionUpdate === "agent_message_chunk" && u.content.type === "text") { + updates.push(u.content.text); + } + }, + } as unknown as AgentSideConnection; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + return { agent, updates }; + } + + function injectSession(agent: ClaudeAcpAgent, sessionId: string, enableRemoteControl: any) { + function* empty() {} + const gen = Object.assign(empty(), { + interrupt: vi.fn(), + close: vi.fn(), + supportedCommands: vi.fn().mockResolvedValue([]), + enableRemoteControl, + }); + agent.sessions[sessionId] = { + query: gen as any, + input: new Pushable(), + cancelled: false, + cwd: "/test", + sessionFingerprint: "{}", + modes: { currentModeId: "default", availableModes: [] }, + models: { currentModelId: "default", availableModels: [] }, + modelInfos: [], + settingsManager: { dispose: vi.fn() } as any, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + configOptions: [], + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + abortController: new AbortController(), + emitRawSDKMessages: false, + contextWindowSize: 200000, + taskState: new Map(), + }; + return agent.sessions[sessionId]!; + } + + it("connects, surfaces the session URL, then disconnects on a second invocation", async () => { + const { agent, updates } = createAgentWithCapture(); + const enableRemoteControl = vi + .fn() + .mockResolvedValueOnce({ session_url: "https://claude.ai/code/session_abc" }) + .mockResolvedValueOnce(undefined); + injectSession(agent, "s1", enableRemoteControl); + + const r1 = await agent.prompt({ + sessionId: "s1", + prompt: [{ type: "text", text: "/remote-control my-session" }], + }); + expect(r1.stopReason).toBe("end_turn"); + expect(enableRemoteControl).toHaveBeenNthCalledWith(1, true, "my-session"); + expect(updates.join("\n")).toContain("https://claude.ai/code/session_abc"); + expect(agent.sessions["s1"]!.remoteControlActive).toBe(true); + + const r2 = await agent.prompt({ + sessionId: "s1", + prompt: [{ type: "text", text: "/rc" }], + }); + expect(r2.stopReason).toBe("end_turn"); + expect(enableRemoteControl).toHaveBeenNthCalledWith(2, false, undefined); + expect(updates.join("\n")).toContain("Remote Control disconnected"); + expect(agent.sessions["s1"]!.remoteControlActive).toBe(false); + }); + + it("reports an error when the bridge fails", async () => { + const { agent, updates } = createAgentWithCapture(); + const enableRemoteControl = vi.fn().mockRejectedValue(new Error("Remote Control is disabled")); + injectSession(agent, "s1", enableRemoteControl); + + const r = await agent.prompt({ + sessionId: "s1", + prompt: [{ type: "text", text: "/remote-control" }], + }); + expect(r.stopReason).toBe("end_turn"); + expect(updates.join("\n")).toContain("Remote Control failed: Remote Control is disabled"); + expect(agent.sessions["s1"]!.remoteControlActive).toBe(false); + }); + + it("advertises remote-control and rc so clients forward them", () => { + const names = getAvailableSlashCommands([]).map((c) => c.name); + expect(names).toContain("remote-control"); + expect(names).toContain("rc"); + }); + + it("does not duplicate remote-control when the SDK already reports it", () => { + const names = getAvailableSlashCommands([ + { name: "remote-control", description: "from sdk" } as any, + ]).map((c) => c.name); + expect(names.filter((n) => n === "remote-control")).toHaveLength(1); + }); + + it("explains when the SDK build lacks Remote Control support", async () => { + const { agent, updates } = createAgentWithCapture(); + injectSession(agent, "s1", undefined); + + const r = await agent.prompt({ + sessionId: "s1", + prompt: [{ type: "text", text: "/rc" }], + }); + expect(r.stopReason).toBe("end_turn"); + expect(updates.join("\n")).toContain("Remote Control isn't available"); + }); +});