From db9f50f02145d088c0f6080672969a0673ba6359 Mon Sep 17 00:00:00 2001 From: Charles McLaughlin Date: Fri, 16 Oct 2026 12:07:25 -0700 Subject: [PATCH] feat: add /remote-control command to start Remote Control for a session (#1197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `/remote-control [name]` command (alias `/rc`) so a session hosted over ACP can be continued from claude.ai/code or the Claude mobile app. Addresses #459. The CLI's own `/remote-control` is terminal-bound and isn't advertised to SDK hosts, and `remoteControlAtStartup` only applies to interactive sessions. The SDK does expose a runtime `enableRemoteControl(enabled, name?)` on the query object, which returns the session URL. See anthropics/claude-agent-sdk-typescript#460. - The adapter handles the command itself in `prompt()`, calls `enableRemoteControl(true, name)`, and replies with the session URL as an agent message. The turn ends without going to the model. - Failures (for example Remote Control disabled by policy) are reported to the user in the same way. - The command is added to `available_commands_update` only when the query exposes the method. - `enableRemoteControl` is not in the published `Query` types yet, so it is typed locally the same way as `mcpAuthenticate`. ## Testing - `npm run check`, `npm run build` and `npm run test:run` pass, including new tests in `src/tests/remote-control.test.ts`. - Ran the built agent through `examples/simple-client.ts` against a real Claude Max session: the command was advertised, `/remote-control` returned a working `claude.ai/code` URL, and prompts sent from the Claude iOS app ran in the session and were answered. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Noah Eisenbruch Co-authored-by: Andrey Bragin --- src/acp-agent.ts | 163 +++++++++++++++++++- src/tests/remote-control.test.ts | 257 +++++++++++++++++++++++++++++++ 2 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 src/tests/remote-control.test.ts diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 9bb28f3e..6b773492 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -558,6 +558,53 @@ function supportsMcpOAuth(query: Query): query is McpOAuthQuery { return typeof (query as Partial).mcpAuthenticate === "function"; } +/** Runtime Remote Control control exposed by the pinned Agent SDK. Like + * `mcpAuthenticate`, it is not in the public `Query` declaration yet. The CLI's + * own `/remote-control` is terminal-bound and not offered to SDK hosts, so the + * adapter handles the command itself and answers with the session URL. */ +type RemoteControlQuery = Query & { + enableRemoteControl( + enabled: boolean, + name?: string, + ): Promise<{ session_url?: string; connect_url?: string }>; +}; + +function supportsRemoteControl(query: Query): query is RemoteControlQuery { + return typeof (query as Partial).enableRemoteControl === "function"; +} + +const REMOTE_CONTROL_COMMAND_NAME = "remote-control"; + +type RemoteControlCommand = { action: "connect"; name?: string } | { action: "disconnect" }; + +/** Parses `/remote-control [name]` and `/remote-control off` from a single-text + * prompt. Connecting is idempotent, so the command never needs to know whether + * Remote Control is already on. */ +function parseRemoteControlCommand(prompt: PromptRequest["prompt"]): RemoteControlCommand | null { + const [first] = prompt; + if (prompt.length !== 1 || first?.type !== "text") return null; + const match = /^\/remote-control(?:\s+([\s\S]*))?$/.exec(first.text.trim()); + if (!match) return null; + const argument = match[1]?.trim(); + if (argument?.toLowerCase() === "off") return { action: "disconnect" }; + return argument ? { action: "connect", name: argument } : { action: "connect" }; +} + +/** Whether Claude Code offers a slash command of this name to an ACP client. + * This is the rule `getAvailableSlashCommands` applies when it lists the + * commands, so the adapter stands in for the command exactly when the client + * sees the adapter's entry: Claude Code's terminal-bound commands are not + * offered, and a user's own command or skill of the name is never shadowed. */ +function claudeCodeOffersCommand( + commands: readonly SlashCommand[], + terminalCommands: readonly string[] | undefined, + name: string, +): boolean { + return commands.some( + (command) => command.name === name && !terminalCommands?.includes(command.name), + ); +} + /** Wait for a polling interval, resolving false when the session is aborted. */ function waitUnlessAborted(milliseconds: number, signal: AbortSignal): Promise { if (signal.aborted) return Promise.resolve(false); @@ -1024,6 +1071,9 @@ export type Session = { * terminal (e.g. /doctor, /color). ACP clients aren't that terminal, so * these are filtered out of `available_commands_update` payloads. */ terminalSlashCommands?: string[]; + /** The `/remote-control` command that is running, from its request to its + * reply. Prompts wait for it, and a second command is refused meanwhile. */ + remoteControlCommand?: Promise; /** The resolved SKILL.md paths of the skill commands, keyed by the cwd and * the command name. A `commands_changed` clears it, because the skill * files can have changed then. */ @@ -3754,6 +3804,57 @@ export class ClaudeAcpAgent { throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE); } + const remoteControlCommand = parseRemoteControlCommand(params.prompt); + if ( + remoteControlCommand && + supportsRemoteControl(session.query) && + !claudeCodeOffersCommand( + await session.query.supportedCommands(), + session.terminalSlashCommands, + REMOTE_CONTROL_COMMAND_NAME, + ) + ) { + // The reply bypasses the turn queue, so it would land inside the output of + // a turn that is still running, and v2 would report no `running` state + // around it. Refuse instead of interleaving. `foregroundWork` also covers + // a cycle that Claude Code started on its own, such as its follow-up + // after a background subagent finished, which is not in `turnQueue`. + if ( + this.foregroundWork.has(params.sessionId) || + (session.turnQueue ?? []).some((turn) => !turn.settled) + ) { + throw RequestError.invalidRequest( + undefined, + "Remote Control can't be changed while Claude is working. Try again when the turn ends.", + ); + } + if (session.remoteControlCommand) { + throw RequestError.invalidRequest( + undefined, + "Remote Control is already being changed. Try again in a moment.", + ); + } + // Answered by the adapter without a model turn; v2 clients treat an + // `ended` without a prior `inserted` as a refused prompt. + events.inserted(randomUUID()); + // The request goes over the network. Until it and its reply are done, + // `remoteControlCommand` holds back the prompts that follow, which would + // otherwise start a turn that the reply lands in. + const command = this.runRemoteControlCommand( + params.sessionId, + session.query, + remoteControlCommand, + ); + session.remoteControlCommand = command; + try { + await command; + } finally { + session.remoteControlCommand = undefined; + } + events.ended({ stopReason: "end_turn" }); + return; + } + const subscriptionGuard = this.runClaudeSubscriptionGuard(params.sessionId, session); if (subscriptionGuard) await subscriptionGuard; @@ -3794,6 +3895,12 @@ export class ClaudeAcpAgent { session.promptSuggestionExpected = false; } + // A `/remote-control` command still in flight answers into the session's + // output; wait for its reply before this prompt's turn can begin. Nothing + // below awaits before the turn is queued, so no command can start between. + // A failure of the command's reply is reported to the command's own prompt. + if (session.remoteControlCommand) await session.remoteControlCommand.catch(() => {}); + // A `session/close` during the awaits above tore the session down, and // its consumer is gone, so the prompt would never be inserted. End it as // the close ends a queued turn: `cancelled` on v1, -32800 on v2. A stream @@ -3846,6 +3953,38 @@ export class ClaudeAcpAgent { ); } + /** Connects Remote Control (again, if it is already on) or disconnects it. */ + private async runRemoteControlCommand( + sessionId: string, + query: RemoteControlQuery, + command: RemoteControlCommand, + ): Promise { + let text: string; + if (command.action === "disconnect") { + try { + await query.enableRemoteControl(false); + text = "Remote Control is off. Run /remote-control to reconnect."; + } catch (error) { + this.logger.error(`Failed to disable Remote Control: ${error}`); + text = `Could not stop Remote Control: ${error instanceof Error ? error.message : error}`; + } + } else { + try { + const result = await query.enableRemoteControl(true, command.name); + text = result.session_url + ? `Remote Control is active. Continue this session from claude.ai/code or the Claude mobile app:\n\n${result.session_url}\n\nRun /remote-control off to disconnect.` + : "Remote Control is active, but Claude Code did not return a session URL."; + } catch (error) { + this.logger.error(`Failed to enable Remote Control: ${error}`); + text = `Could not start Remote Control: ${error instanceof Error ? error.message : error}`; + } + } + await this.client.sessionUpdate({ + sessionId, + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } }, + }); + } + /** `--hide-claude-auth` applies only to the CLI's own login. A provider * override (`providers/set` or gateway `authenticate`) routes traffic away * from it, so the subscription guard is off while one is active. */ @@ -6139,6 +6278,7 @@ export class ClaudeAcpAgent { session.terminalSlashCommands, this.toolCallCapabilities.air.client ? session.cwd : undefined, session.skillPaths, + session.query, ), }, }); @@ -10253,6 +10393,7 @@ export class ClaudeAcpAgent { session.terminalSlashCommands, this.toolCallCapabilities.air.client ? session.cwd : undefined, (session.skillPaths ??= new Map()), + session.query, ); } @@ -12409,6 +12550,7 @@ function getAvailableSlashCommands( airSkillCwd?: string, // The resolved SKILL.md paths of the session, keyed by the cwd and the name. skillPaths?: Map, + query?: Query, ): AvailableCommand[] { const skillPath = (name: string, cwd: string): string | undefined => { const key = `${cwd}\0${name}`; @@ -12429,6 +12571,18 @@ function getAvailableSlashCommands( "todos", ]; + const remoteControlCommands: AvailableCommand[] = + query && supportsRemoteControl(query) + ? [ + { + name: REMOTE_CONTROL_COMMAND_NAME, + description: + "Continue this session from claude.ai/code or the Claude mobile app; `off` disconnects", + input: { hint: "[session name | off]" }, + }, + ] + : []; + // The adapter replaces the terminal text of `/mcp` and runs its actions, // even when Claude Code tags the command terminal-bound. const advertised = commands @@ -12466,7 +12620,14 @@ function getAvailableSlashCommands( }; }) .filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name)); - return [...advertised, MCP_AVAILABLE_COMMAND]; + + return [ + ...advertised, + MCP_AVAILABLE_COMMAND, + ...remoteControlCommands.filter( + (command) => !claudeCodeOffersCommand(commands, terminalCommands, command.name), + ), + ]; } function formatUriAsLink(uri: string): string { diff --git a/src/tests/remote-control.test.ts b/src/tests/remote-control.test.ts new file mode 100644 index 00000000..47266726 --- /dev/null +++ b/src/tests/remote-control.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, vi } from "vitest"; +import type { AgentSideConnection as AcpClient } from "@agentclientprotocol/sdk"; +import { ClaudeAcpAgent } from "../acp-agent.js"; +import { mockSessionState } from "./session-doubles.js"; + +function createAgent(query: Record, sessionOverrides: Record = {}) { + const sessionUpdate = vi.fn(async () => {}); + const client = { sessionUpdate } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} }); + agent.sessions["session-1"] = mockSessionState({ + query: { supportedCommands: async () => [], ...query }, + ...sessionOverrides, + }); + return { agent, sessionUpdate }; +} + +const prompt = (text: string) => ({ + sessionId: "session-1", + prompt: [{ type: "text" as const, text }], +}); + +const messageText = (sessionUpdate: ReturnType["sessionUpdate"]) => + (sessionUpdate.mock.calls as any[][]).at(-1)?.[0].update.content.text; + +describe("/remote-control", () => { + it("starts Remote Control and reports the URL", async () => { + const enableRemoteControl = vi.fn(async () => ({ + session_url: "https://claude.ai/code/session_123", + })); + const { agent, sessionUpdate } = createAgent({ enableRemoteControl }); + + const response = await agent.prompt(prompt("/remote-control")); + + expect(response.stopReason).toBe("end_turn"); + expect(enableRemoteControl).toHaveBeenCalledWith(true, undefined); + expect(messageText(sessionUpdate)).toContain("https://claude.ai/code/session_123"); + }); + + it("passes the argument as the session name", async () => { + const enableRemoteControl = vi.fn(async () => ({ session_url: "https://claude.ai/code/s" })); + const { agent } = createAgent({ enableRemoteControl }); + + await agent.prompt(prompt("/remote-control My laptop session")); + + expect(enableRemoteControl).toHaveBeenCalledWith(true, "My laptop session"); + }); + + it("reconnects and renames on every run instead of toggling", async () => { + const enableRemoteControl = vi.fn(async () => ({ session_url: "https://claude.ai/code/s" })); + const { agent } = createAgent({ enableRemoteControl }); + + await agent.prompt(prompt("/remote-control")); + await agent.prompt(prompt("/remote-control new-name")); + + expect(enableRemoteControl).toHaveBeenNthCalledWith(1, true, undefined); + expect(enableRemoteControl).toHaveBeenNthCalledWith(2, true, "new-name"); + }); + + it("disconnects with `off`", async () => { + const enableRemoteControl = vi.fn(async () => ({})); + const { agent, sessionUpdate } = createAgent({ enableRemoteControl }); + + const response = await agent.prompt(prompt("/remote-control off")); + + expect(response.stopReason).toBe("end_turn"); + expect(enableRemoteControl).toHaveBeenCalledExactlyOnceWith(false); + expect(messageText(sessionUpdate)).toContain("Remote Control is off"); + }); + + it.each([ + ["/remote-control", "Could not start Remote Control"], + ["/remote-control off", "Could not stop Remote Control"], + ])("reports the failure of %s to the user", async (command, expected) => { + const enableRemoteControl = vi.fn(async () => { + throw new Error("Remote Control is disabled by policy"); + }); + const { agent, sessionUpdate } = createAgent({ enableRemoteControl }); + + const response = await agent.prompt(prompt(command)); + + expect(response.stopReason).toBe("end_turn"); + expect(messageText(sessionUpdate)).toContain(expected); + expect(messageText(sessionUpdate)).toContain("Remote Control is disabled by policy"); + }); + + it.each(["/remote-control", "/remote-control off"])( + "refuses %s while a turn is still running", + async (command) => { + const enableRemoteControl = vi.fn(async () => ({})); + const { agent, sessionUpdate } = createAgent( + { enableRemoteControl }, + { turnQueue: [{ settled: false }] }, + ); + + await expect(agent.prompt(prompt(command))).rejects.toThrow(/while Claude is working/); + + expect(enableRemoteControl).not.toHaveBeenCalled(); + expect(sessionUpdate).not.toHaveBeenCalled(); + }, + ); + + it("refuses the command while Claude Code runs a cycle of its own", async () => { + const enableRemoteControl = vi.fn(async () => ({})); + const { agent, sessionUpdate } = createAgent({ enableRemoteControl }); + (agent as any).startForeground("session-1", "autonomous-cycle"); + + await expect(agent.prompt(prompt("/remote-control"))).rejects.toThrow( + /while Claude is working/, + ); + + expect(enableRemoteControl).not.toHaveBeenCalled(); + expect(sessionUpdate).not.toHaveBeenCalled(); + }); + + it("answers while only settled turns remain in the queue", async () => { + const enableRemoteControl = vi.fn(async () => ({ session_url: "https://claude.ai/code/s" })); + const { agent } = createAgent({ enableRemoteControl }, { turnQueue: [{ settled: true }] }); + + await agent.prompt(prompt("/remote-control")); + + expect(enableRemoteControl).toHaveBeenCalledOnce(); + }); + + it.each([ + ["/rc", []], + ["/remote-control", [{ name: "remote-control", description: "", argumentHint: "" }]], + ])("leaves %s to Claude Code", async (command, advertised) => { + const enableRemoteControl = vi.fn(async () => ({})); + const push = vi.fn(); + async function* noMessages() {} + const { agent } = createAgent( + Object.assign(noMessages(), { + enableRemoteControl, + supportedCommands: async () => advertised, + }) as any, + { input: { push } }, + ); + + await agent.startTurn(prompt(command), { + inserted() {}, + ended() {}, + failed() {}, + }); + + expect(enableRemoteControl).not.toHaveBeenCalled(); + expect(push).toHaveBeenCalledOnce(); + }); + + it("handles the command when Claude Code lists it as terminal-only", async () => { + const enableRemoteControl = vi.fn(async () => ({ session_url: "https://claude.ai/code/s" })); + const supportedCommands = async () => [ + { name: "remote-control", description: "", argumentHint: "" }, + ]; + const { agent, sessionUpdate } = createAgent( + { enableRemoteControl, supportedCommands }, + { terminalSlashCommands: ["remote-control"] }, + ); + + await agent.prompt(prompt("/remote-control")); + await (agent as any).sendAvailableCommandsUpdate("session-1"); + + expect(enableRemoteControl).toHaveBeenCalledOnce(); + const advertised = (sessionUpdate.mock.calls as any[][]) + .map(([notification]) => notification.update.availableCommands) + .find(Boolean); + expect(advertised.filter((command: any) => command.name === "remote-control")).toHaveLength(1); + }); + + describe("while the request is in flight", () => { + const events = { inserted() {}, ended() {}, failed() {} }; + + function pendingRequest() { + let finish!: (result: { session_url: string }) => void; + const enableRemoteControl = vi.fn( + () => + new Promise<{ session_url: string }>((resolve) => { + finish = resolve; + }), + ); + return { enableRemoteControl, finish: (result: { session_url: string }) => finish(result) }; + } + + function createPendingAgent() { + const request = pendingRequest(); + const push = vi.fn(); + const created = createAgent( + Object.assign((async function* () {})(), { + enableRemoteControl: request.enableRemoteControl, + supportedCommands: async () => [], + }) as any, + { input: { push } }, + ); + return { ...created, ...request, push }; + } + + it("holds a following prompt back until the reply is sent", async () => { + const { agent, sessionUpdate, enableRemoteControl, finish, push } = createPendingAgent(); + + const command = agent.startTurn(prompt("/remote-control"), events); + await vi.waitFor(() => expect(enableRemoteControl).toHaveBeenCalled()); + const following = agent.startTurn(prompt("hello"), events); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(push).not.toHaveBeenCalled(); + + finish({ session_url: "https://claude.ai/code/s" }); + await Promise.all([command, following]); + + expect(sessionUpdate).toHaveBeenCalledOnce(); + expect(push).toHaveBeenCalledOnce(); + expect(sessionUpdate.mock.invocationCallOrder[0]).toBeLessThan( + push.mock.invocationCallOrder[0], + ); + }); + + it("refuses a second command", async () => { + const { agent, enableRemoteControl, finish } = createPendingAgent(); + + const command = agent.startTurn(prompt("/remote-control"), events); + await vi.waitFor(() => expect(enableRemoteControl).toHaveBeenCalled()); + + await expect(agent.startTurn(prompt("/remote-control off"), events)).rejects.toThrow( + /already being changed/, + ); + expect(enableRemoteControl).toHaveBeenCalledOnce(); + + finish({ session_url: "https://claude.ai/code/s" }); + await command; + }); + + it("lets prompts through again once the command is done", async () => { + const { agent, finish, enableRemoteControl, push } = createPendingAgent(); + + const command = agent.startTurn(prompt("/remote-control"), events); + await vi.waitFor(() => expect(enableRemoteControl).toHaveBeenCalled()); + finish({ session_url: "https://claude.ai/code/s" }); + await command; + await agent.startTurn(prompt("hello"), events); + + expect(push).toHaveBeenCalledOnce(); + }); + }); + + it("advertises the command only when the query supports it", async () => { + const supportedCommands = async () => [{ name: "compact", description: "", argumentHint: "" }]; + const supported = createAgent({ enableRemoteControl: vi.fn(), supportedCommands }); + const unsupported = createAgent({ supportedCommands }); + + await (supported.agent as any).sendAvailableCommandsUpdate("session-1"); + await (unsupported.agent as any).sendAvailableCommandsUpdate("session-1"); + + const names = (update: ReturnType["sessionUpdate"]) => + (update.mock.calls as any[][])[0][0].update.availableCommands.map((c: any) => c.name); + expect(names(supported.sessionUpdate)).toContain("remote-control"); + expect(names(unsupported.sessionUpdate)).not.toContain("remote-control"); + }); +});