Files
claude-agent-acp/src/v2/remote-control.ts
Ben Brandt 617d5351ed refactor: move the Claude Code to ACP mapping out of acp-agent.ts (#1561)
Stacked on #1560.

The v2 agent in `src/v2/` imported the Claude Code to ACP mapping,
shared types and a few helpers from `acp-agent.ts` (17 imports), so it
loaded the whole v1 agent module. This moves that code into modules that
own it. `acp-agent.ts` imports it back and keeps re-exporting every name
it exported before, so `dist/acp-agent.js` deep imports still work.

What moved where:

- `src/sdk-to-acp.ts`: `toAcpNotifications`,
`streamEventToAcpNotifications`, `messageIdForGrouping`,
`stampParentToolUseId`, the `ToolUseCache` / `StreamedToolInputCache`
types and the streamed tool input scanner.
- `src/message-filters.ts`: `stripLocalCommandMetadata`,
`isLocalCommandMetadata`, `isSyntheticLoginMessage`,
`isReplayHiddenMetaMessage`, `LOCAL_ONLY_COMMANDS` and the transcript
marker scanner.
- `src/prompt-to-claude.ts`: `promptToClaude`,
`SUPPORTED_PROMPT_CONTENT_TYPES`.
- `src/slash-commands.ts`: `getAvailableSlashCommands`.
- `src/result-end.ts` (existing): `AUTONOMOUS_RESULT_ORIGINS`,
`isAbortedResult`.
- `src/logger.ts`, `src/acp-client.ts`, `src/new-session-meta.ts`: the
`Logger`, `AcpClient`, `NewSessionMeta` and `SDKMessageFilter` types.

About 1,360 lines leave `acp-agent.ts` (12,718 to 11,354). The code
moved as-is. Only a few comments that named `acp-agent.ts` as the home
of the shared code were reworded. `src/v2/`, `utils.ts`, `serve.ts`,
`lib.ts` and `session-index/connection.ts` now import from the new
modules. Tests import the moved code from the new modules too. None of
the new modules imports `acp-agent.ts`. `grep -rn acp-agent src/v2`
returns nothing, and a walk of the static imports in `dist/v2` reaches
119 modules, none of them `acp-agent.js`.

No behavior change. The export pins in
`src/tests/module-exports.test.ts` for `lib.js` and `acp-agent.js` pass
unchanged.

Test results:

- `npx tsc --noEmit`, `npm run check` and `npm run build` pass.
- `npx vitest run`: 3939 passed, 33 skipped. That is the 3935 before,
plus the 4 provider tests now on the base branch.
- Goldens unchanged.
- `node scripts/build-binary.mjs && node scripts/smoke-binary.mjs` pass.

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 08:40:42 +01:00

90 lines
3.7 KiB
TypeScript

/**
* The adapter's `/remote-control` on the v2 core, and the messages that
* a person types in another client of the session over Remote Control.
*
* The command, its listing, and its reply are the v1 core's
* (`remote-control.ts`). The command
* runs as a local command of its own turn, as v2 asks of one ("Local handling
* still uses the foreground lifecycle"): its prompt is inserted as a
* live-only `user_message`, the turn reports `running`, the reply goes out,
* and the turn ends `idle` with `end_turn`. It waits until no turn is open
* and no prompt waits in Claude Code's queue, and the prompts after it wait
* for it: v2 asks to hold a prompt rather than reject it while Claude works.
*/
import { randomUUID } from "node:crypto";
import type { Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type { Logger } from "../logger.js";
import {
adapterRemoteControlCommand,
isRemoteHumanMessage,
parseRemoteControlCommand,
runRemoteControlCommand,
} from "../remote-control.js";
/** What the command needs of its session. */
export interface RemoteControlHost {
readonly logger: Logger;
query(): Query | undefined;
/** Claude Code's terminal-bound commands, which it does not offer. */
terminalCommands(): readonly string[] | undefined;
/** Resolves `true` once no turn is open and no prompt waits in Claude Code, `false` once the session closed. */
idle(): Promise<boolean>;
/** Inserts the prompt `uuid` and opens its turn. */
begin(uuid: string): void;
/** Sends the reply `text`, as agent text of the message `messageId`, and ends the turn. */
end(text: string, messageId: string): void;
}
export class SessionRemoteControl {
/** The uuids of the prompts that this session sent, until Claude Code echoes them. */
private readonly sent = new Set<string>();
constructor(private readonly host: RemoteControlHost) {}
/** Whether `prompt` may be the adapter's command, which {@link run} then decides. */
mayRun(prompt: Parameters<typeof parseRemoteControlCommand>[0]): boolean {
return parseRemoteControlCommand(prompt) !== null;
}
/**
* Runs `prompt` as the adapter's command, as its turn, once the session is
* idle. Resolves `false` without running it when Claude Code serves the
* command itself or the query has no Remote Control: the prompt then goes
* to Claude Code. `stillPending` tells whether a cancel or close answered
* the prompt meanwhile.
*/
async run(
prompt: Parameters<typeof parseRemoteControlCommand>[0],
uuid: string,
stillPending: () => boolean,
): Promise<boolean> {
const query = this.host.query();
if (!query) return false;
const command = await adapterRemoteControlCommand(prompt, query, this.host.terminalCommands());
if (!command) return false;
if (!(await this.host.idle()) || !stillPending()) return true;
this.host.begin(uuid);
// The turn is open across the network call, so the session reads as busy.
const text = await runRemoteControlCommand(command.query, command.command, (message) =>
this.host.logger.error(message),
);
this.host.end(text, randomUUID());
return true;
}
/** The session sent the prompt `uuid` to Claude Code. */
pushed(uuid: string): void {
this.sent.add(uuid);
}
/**
* The content of a prompt echo to show as a user message: one that a
* person typed in another client of the session. `undefined` for the
* echo of a prompt that this session sent, and for any other echo.
*/
remoteMessage(message: SDKUserMessage): SDKUserMessage["message"]["content"] | undefined {
if (message.uuid && this.sent.delete(message.uuid)) return undefined;
return isRemoteHumanMessage(message) ? message.message.content : undefined;
}
}