Files
claude-agent-acp/src/permissions/presentation.ts
Ben Brandt cb285cd285 feat: serve ACP v2 with a direct v2 agent and remove the v1 bridge (#1560)
## Summary

ACP v2 was served by a bridge (`src/v2/agent.ts`) that converted each v2
request to v1 and ran it through `ClaudeAcpAgent`. Every v2 session
therefore ran the v1 core's turn machinery: held prompts, echo matching,
trailing-idle debt and the hold for background subagents. v2 asks for
none of it. This PR serves v2 with its own agent in `src/v2/`, which
follows Claude Code's own events. A prompt is answered when Claude Code
takes it in, and `state_update` reports foreground work. Background
subagents and tasks are listed in `background` and do not hold the turn.

- **New v2 agent (`src/v2/`).** `V2Agent` and `V2Session` cover:
  - the prompt lifecycle and cancellation
- subagents as child sessions, following the subagents RFD, plus
background lists
  - replay, restarts for provider and route changes, auth and providers
- elicitation, config options, `/remote-control`, and the
`--hide-claude-auth` guard
- **Bridge removed.** `ClaudeAcpAgent` serves ACP v1 only: its `v2`
option, foreground/background hooks and `this.v2` branches are gone. v1
behavior is unchanged, and no v1 test expectation or golden file
changed. The control flow of `acp-agent.ts` is simpler and the file is
about 450 lines shorter.
- **Shared modules.** Protocol-neutral logic moved out of `acp-agent.ts`
into modules both versions use, including:
- `session-model.ts`, `session-effort.ts`, `session-config-options.ts`
and `session-env.ts`
  - `providers.ts`, `auth-status-reporter.ts` and `context-window.ts`
  - `result-end.ts`: one set of rules for reading a result in both cores
  - `prompt-suggestions.ts` and `mcp-servers.ts`
- **JetBrains AIR on v2.** An AIR v2 client gets the AIR extensions that
ACP v2 does not cover:
  - `recommendedValue`
  - the permission presentation
  - question custom answers
  - prompt suggestions
- `agentFileChangeReport`, sent at the end of the turn, before its idle

ACP v2 itself covers subagent sessions, background subagents, async
tasks, plan files, diff patches, compaction, custom instructions and
session failures. See "ACP v2" in `docs/air-extensions.md`.
- **Ported from main.** Main's v2 changes made on the bridge are ported
to the new agent, and main's v2 tests run against it:
  - workflow cards and phases (#1550, #1552)
  - task tool calls (#1556)
  - plan removal on reset (#1554)
  - subagent checklist plan ids (#1558)
  - the Fable alias (#1548)

## Behavior changes for v2 clients

Each one follows the v2 draft spec, where the bridge did not:

- **Background subagents.** They no longer hold the prompt open. They
are listed in `background`, and their work is reported in their child
session's state.
- **`requires_action`.** While the root waits on foreground subagents,
it reports `requires_action` only when every one of them waits on the
user.
- **Resume of an active session.** Its work keeps running, and the new
MCP servers replace the old ones (`setMcpServers`). The bridge tore the
session down.
- **Cancel.** The idle after a cancel waits for Claude Code's aborted
result and carries its usage. A stopped child's open tool calls end
before its idle.
- **`/remote-control`.** It runs as its own turn. Sent while Claude
works, it waits for that work to end.
- **Accepted URL elicitation.** It keeps the work `requires_action`
until Claude Code reports the elicitation complete.
- **Exited Claude Code.** A session whose Claude Code process exited
stays active and answers -32603 with what happened, until it is resumed.
- **Provider change.** `providers/set`/`disable` restarts idle sessions
before it answers. A busy session restarts at its next turn boundary,
and its prompts are not held meanwhile, which the provider RFD allows.

## Workarounds

- **`/remote-control` waits outside the prompt queue.** The adapter's
own `/clear` and plan prompts, which continue a turn in a fresh context,
use their own push queue (`ownPushes`). They must not wait behind a
`/remote-control` that waits for that same turn to end.
- **Restart on additional directories.** A resume with other additional
directories restarts the query at its next turn boundary. The SDK cannot
change the directories of a running query.

## Rollback

`CLAUDE_AGENT_ACP_PROTOCOL=v1` negotiates every client down to v1. The
bridge and its `CLAUDE_AGENT_ACP_V2_CORE` switch are removed.

## Not served on v2 yet

- AIR's tool call keys (`commandTitle`, `subagent`, `skill`, `mcp`), the
session index, and API retry warnings.
- A `replayFrom` cursor other than `start`, which is rejected with
-32602, and steering, which is v1 only.

## Packaging

- `lib.js` exports are unchanged.
- `acp-agent.js` exports no name fewer than before. It adds
`AUTONOMOUS_RESULT_ORIGINS`, `getAvailableSlashCommands` and
`isAbortedResult`, which the v2 agent uses, and the export pin test is
updated.
- Internal `dist/v2/*` modules changed. They are reachable only through
the `"./*"` export and are not documented.

## Testing

- `npm run format:check`, `lint`, `build` and `test:run` pass: 3935
passed, 33 skipped.
- `npm run test:integration` against the real CLI passes: 3960 passed, 8
skipped. This includes `src/tests/v2-integration.test.ts`.
- The v2 golden scenarios (`acp-scenarios-v2.test.ts`) match without
being regenerated.
- The binary builds and `scripts/smoke-binary.mjs` passes.
- Two tests unchanged from main can time out under heavy load: the
session-index file watcher test and v1's "lagged trailer" test. Both
pass on rerun.

## Follow-up

- Split `src/` into v1, v2 and shared modules.
- Move the SDK-to-ACP mapping that `src/v2/` still imports from
`acp-agent.ts` into a shared module.
- Add a lint rule that keeps the boundary.

---------

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

184 lines
7.1 KiB
TypeScript

import type { RequestPermissionRequest, ToolCallContent } from "@agentclientprotocol/sdk/unstable";
import { AIR_PERMISSION_KEY, withAirMeta } from "../air-extension.js";
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
/**
* A permission request as the agent builds it. For a v2 client it also
* carries the heading and the explanation of the prompt, which ACP v2 sends as
* the request's `title` and `description`. v1 has no field for them; AIR gets
* them in `_meta`.
*/
export type AcpPermissionRequest = RequestPermissionRequest & {
/** The heading of the prompt. It is not the title of the tool call. */
title?: string;
/** Why the permission is asked. */
description?: string;
};
export interface ClaudePermissionPresentationInput {
toolName: string;
input: Record<string, unknown>;
toolUseID: string;
cwd?: string;
capabilities?: ClientCapabilities;
/** The exact patch of the change to approve, for a negotiated `diffPatch` client or a v2 client. */
previewContent?: ToolCallContent[];
blockedPath?: string;
title?: string;
displayName?: string;
description?: string;
decisionReason?: string;
defaultToNo?: boolean;
}
function humanText(value: unknown, maxLength: number, singleLine = false): string | undefined {
if (typeof value !== "string") return undefined;
const withoutControls = Array.from(value)
.filter((character) => {
const code = character.charCodeAt(0);
return !(
code <= 0x08 ||
code === 0x0b ||
code === 0x0c ||
(code >= 0x0e && code <= 0x1f) ||
code === 0x7f
);
})
.join("");
const normalized = singleLine
? withoutControls.replace(/\s+/gu, " ").trim()
: withoutControls.trim();
return normalized && normalized.length <= maxLength ? normalized : undefined;
}
function compactText(value: unknown): string | undefined {
return humanText(value, 160, true);
}
/**
* The permission request presentation.
*
* The request `toolCall` carries `toolCallId`, `title`, and `rawInput`. The
* adapter emits the `tool_call` before the request, so the client already
* holds the rest. The request adds only what it shows new: the exact preview
* patch, and the blocked path when the tool call has no such location.
*
* The heading and the explanation of the prompt go to AIR in `_meta`, and to a
* v2 client as `title` and `description`.
*/
export function buildClaudePermissionPresentation(
value: ClaudePermissionPresentationInput,
): Pick<AcpPermissionRequest, "toolCall" | "_meta" | "title" | "description"> {
const capabilities = value.capabilities ?? new ClientCapabilities();
const renderer = new AcpToolCallRenderer(capabilities);
const toolUse = { id: value.toolUseID, name: value.toolName, input: value.input };
const facts = renderer.facts(toolUse, value.cwd);
const host =
value.toolName === "SandboxNetworkAccess" ? compactText(value.input.host) : undefined;
const isComputerUse = value.toolName.startsWith("mcp__computer-use__");
const subjectTitle = host ?? (isComputerUse ? compactText(value.displayName) : undefined);
// Reuse the exact standard tool-call heading as the permission heading so
// the approval never maintains a second, divergent name for the operation.
// decisionReason is temporarily exposed as the permission description so
// its actual SDK values can be inspected; it remains diagnostic policy text.
const toolCallTitle = subjectTitle ?? facts.title;
const permissionTitle = value.toolName === "ExitPlanMode" ? "Ready to code?" : toolCallTitle;
// Shell titles are executable input: compacting whitespace changes quoted
// arguments and comment boundaries, and length limits can hide the command.
const title =
value.toolName === "Bash" || value.toolName === "PowerShell"
? permissionTitle
: (humanText(permissionTitle, 4_000, true) ?? "Use tool?");
const decisionReason = humanText(value.decisionReason, 4_000);
const description = decisionReason ? `Reason: ${decisionReason}` : undefined;
const blockedPath = humanText(value.blockedPath, 4_096, true);
const extraLocations =
blockedPath && !(facts.locations ?? []).some((location) => location.path === blockedPath)
? [{ path: blockedPath }]
: undefined;
return {
toolCall: renderer.permissionToolCall(toolUse, {
cwd: value.cwd,
title: toolCallTitle,
previewContent: value.previewContent,
extraLocations,
// The upstream request shows the input of a network or computer-use
// request that has no content of its own.
fallbackContent:
host || isComputerUse
? [
{
type: "content" as const,
content: {
type: "text" as const,
text: `\`\`\`json\n${JSON.stringify(value.input, null, 2)}\n\`\`\``,
},
},
]
: undefined,
}),
// Only AIR gets the permission presentation.
...(title && capabilities.air.client
? { _meta: airPermissionMeta({ title, description, defaultToNo: value.defaultToNo }) }
: {}),
...(capabilities.v2 ? { title, ...(description ? { description } : {}) } : {}),
};
}
/**
* The `_meta.jetbrains.air.permission` of a permission request, from the
* heading and explanation that {@link buildClaudePermissionPresentation}
* built. The v2 core adds it for an AIR client, whose tool calls it
* renders without the AIR tool call contract.
*/
export function airPermissionMeta({
title,
description,
defaultToNo,
}: {
title: string;
description?: string;
defaultToNo?: boolean;
}): Record<string, unknown> {
return withAirMeta(undefined, AIR_PERMISSION_KEY, {
version: 1,
title,
...(description ? { description } : {}),
// The CLI's own hint, forwarded so a client that can pre-select an
// option keeps the decline focused; the option order already leads with
// the reject options when this is set.
...(defaultToNo === true ? { defaultToNo: true } : {}),
});
}
/**
* The `_meta.claudeCode` of a permission request's tool call: which MCP
* server serves an `mcp__*` tool and where its definition came from
* (`mcpServer`, SDK 0.3.274+), so a client can key trust on `source`, and,
* for clients other than AIR, the tool name and the Agent or Task call of a
* subagent's request. Undefined when there is nothing to add.
*/
export function permissionToolCallMeta({
toolName,
parentToolUseId,
mcpServer,
airClient,
}: {
toolName: string;
parentToolUseId: string | undefined;
mcpServer: { name: string; source: string } | undefined;
airClient: boolean;
}): { claudeCode: Record<string, unknown> } | undefined {
if (!mcpServer && !(parentToolUseId && !airClient)) return undefined;
// The name is the config key as authored: untrusted text, so it rides
// `_meta` rather than the title.
return {
claudeCode: {
...(airClient ? {} : { toolName }),
...(parentToolUseId && !airClient ? { parentToolUseId } : {}),
...(mcpServer ? { mcpServer: { name: mcpServer.name, source: mcpServer.source } } : {}),
},
};
}