Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).

Expand Down
115 changes: 113 additions & 2 deletions src/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RemoteControlResponse | undefined>;
};

/** Compute a stable fingerprint of the session-defining params so we can
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -729,12 +768,78 @@ export class ClaudeAcpAgent implements Agent {
throw new Error("Method not implemented.");
}

private async emitAgentText(sessionId: string, text: string): Promise<void> {
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<PromptResponse> {
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<PromptResponse> {
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,
Expand Down Expand Up @@ -2752,7 +2857,7 @@ async function getAvailableModels(
};
}

function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] {
export function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] {
const UNSUPPORTED_COMMANDS = [
"clear",
"cost",
Expand All @@ -2764,7 +2869,7 @@ function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[]
"todos",
];

return commands
const mapped = commands
.map((command) => {
const input = command.argumentHint
? {
Expand All @@ -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 {
Expand Down
119 changes: 119 additions & 0 deletions src/tests/acp-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
ClaudeAcpAgent,
claudeCliPath,
describeAlwaysAllow,
getAvailableSlashCommands,
streamEventToAcpNotifications,
type SDKMessageFilter,
} from "../acp-agent.js";
Expand Down Expand Up @@ -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");
});
});