feat: add /remote-control command to start Remote Control for a session (#1197)

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 <noaheisenbruch@gmail.com>
Co-authored-by: Andrey Bragin <andrey.bragin@jetbrains.com>
This commit is contained in:
Charles McLaughlin
2026-10-16 12:07:25 -07:00
committed by GitHub
parent f5967fd6b4
commit db9f50f021
2 changed files with 419 additions and 1 deletions

View File

@@ -558,6 +558,53 @@ function supportsMcpOAuth(query: Query): query is McpOAuthQuery {
return typeof (query as Partial<McpOAuthQuery>).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<RemoteControlQuery>).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<boolean> {
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<void>;
/** 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<void> {
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<string, string | undefined>,
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 {

View File

@@ -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<string, unknown>, sessionOverrides: Record<string, any> = {}) {
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<typeof createAgent>["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<typeof createAgent>["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");
});
});