mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
## What This PR adds the AIR client capability `backgroundSubagents`. A client declares it in `initialize.clientCapabilities._meta.jetbrains.air.capabilities`. With the capability, an ACP v1 prompt turn ends at its result. This is also true while background subagents of the turn still run, including a subagent that SendMessage resumed. Subagent updates, permission requests and elicitations then go out after the prompt response. Their routing does not change. The agent advertises the capability only to ACP v1 AIR clients. ## Why The hold of #864/#866 keeps `session/prompt` open until the subagents finish. The Claude CLI ends the model turn at `result`, and only `idle` waits for the background agents. In AIR the hold hides the end of the answer, and the next user message steers into an old turn. One SendMessage-resumed subagent kept a turn open for about two hours. ## Unchanged - A client without the capability keeps the hold exactly. So #866 stays fixed for clients that stop listening at the prompt response. - ACP v2 keeps the hold too. ## Related changes - Out of a turn, the ExitPlanMode request of such a client does not offer the clear-context option. The fresh query must continue an ACP turn, and there is none. - An ExitPlanMode interruption of an autonomous cycle with no held turn now clears its markers. ## Docs `docs/air-extensions.md` has a new "Background subagents" section. ## Tests New capability cases: - a fresh subagent; - a SendMessage-resumed subagent; - a permission request after the prompt response, with the `parentToolUseId` attribution; - an autonomous followup that does not end the queued next prompt; - ExitPlanMode out of a turn, with and without a reset; - the exclusion of a running subagent from the incomplete-tool check. The existing tests cover the path without the capability. When the gate is disabled, 7 of the 8 new cases fail. The eighth is the in-turn control case and passes in both configurations. `npm run build`, `npm run check` and `npm run test:run` pass. The exception is 4 `providers.test.ts` cases. They depend on the environment (a local proxy `baseUrl`) and fail on `main` too.
179 lines
5.9 KiB
TypeScript
179 lines
5.9 KiB
TypeScript
import type { PermissionOption } from "@agentclientprotocol/sdk/unstable";
|
|
import {
|
|
allowOnce,
|
|
exactLocalAllowRule,
|
|
isMcpAllowChangeSet,
|
|
PERMISSION_OPTION_ID,
|
|
plainString,
|
|
type PermissionOptionContext,
|
|
reject,
|
|
withGeneratedUpdate,
|
|
withOptionalUpdate,
|
|
} from "./shared.js";
|
|
|
|
export function buildWebFetchPermissionOptions(
|
|
context: PermissionOptionContext,
|
|
): PermissionOption[] {
|
|
const url = plainString(context.input.url);
|
|
if (url && context.allowPersistentOptions !== false) {
|
|
try {
|
|
const hostname = new URL(url).hostname;
|
|
if (hostname) {
|
|
return withGeneratedUpdate(`Yes, and don't ask again for ${hostname}`);
|
|
}
|
|
} catch {
|
|
// Invalid input cannot produce Claude Code's domain-specific option.
|
|
}
|
|
}
|
|
return [allowOnce(), reject("No")];
|
|
}
|
|
|
|
export function buildSkillPermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
|
const skill = plainString(context.input.skill);
|
|
const options = [allowOnce()];
|
|
if (skill && context.allowPersistentOptions !== false) {
|
|
options.push({
|
|
optionId: PERMISSION_OPTION_ID.allowSkillExact,
|
|
name: `Yes, and don't ask again for ${skill}`,
|
|
kind: "allow_always",
|
|
});
|
|
const spaceIndex = skill.indexOf(" ");
|
|
if (spaceIndex > 0) {
|
|
const prefix = `${skill.slice(0, spaceIndex)}:*`;
|
|
options.push({
|
|
optionId: PERMISSION_OPTION_ID.allowSkillPrefix,
|
|
name: `Yes, and don't ask again for ${prefix} commands`,
|
|
kind: "allow_always",
|
|
});
|
|
}
|
|
}
|
|
options.push(reject("No"));
|
|
return options;
|
|
}
|
|
|
|
export function buildEnterPlanModePermissionOptions(): PermissionOption[] {
|
|
return [allowOnce("Yes, enter plan mode"), reject("No, start implementing now")];
|
|
}
|
|
|
|
export function buildExitPlanModePermissionOptions(
|
|
context: PermissionOptionContext,
|
|
): PermissionOption[] {
|
|
const modes = new Set(context.availableModes);
|
|
const options: PermissionOption[] = [];
|
|
// Claude Code offers bypass only when the user launched with it, so it can
|
|
// prefer bypass outright. The adapter advertises bypass by default, so Auto
|
|
// leads unless the session was in bypass before entering plan mode. Both stay
|
|
// selectable: they have different permission semantics.
|
|
const elevatedModes = (
|
|
context.prePlanMode === "bypassPermissions"
|
|
? (["bypassPermissions", "auto"] as const)
|
|
: (["auto", "bypassPermissions"] as const)
|
|
).filter((mode) => modes.has(mode));
|
|
const preferredMode = elevatedModes[0] ?? "acceptEdits";
|
|
if (context.allowContextReset !== false && plainString(context.input.plan)) {
|
|
const usage =
|
|
context.contextUsedPercent === undefined ? "" : ` (${context.contextUsedPercent}% used)`;
|
|
options.push(
|
|
preferredMode === "auto"
|
|
? {
|
|
optionId: PERMISSION_OPTION_ID.exitPlanClearAuto,
|
|
name: `Yes, clear context${usage} and use auto mode`,
|
|
kind: "allow_always",
|
|
}
|
|
: preferredMode === "bypassPermissions"
|
|
? {
|
|
optionId: PERMISSION_OPTION_ID.exitPlanClearBypass,
|
|
name: `Yes, clear context${usage} and bypass permissions`,
|
|
kind: "allow_always",
|
|
}
|
|
: {
|
|
optionId: PERMISSION_OPTION_ID.exitPlanClearAcceptEdits,
|
|
name: `Yes, clear context${usage} and auto-accept edits`,
|
|
kind: "allow_always",
|
|
},
|
|
);
|
|
}
|
|
for (const mode of elevatedModes) {
|
|
options.push(
|
|
mode === "auto"
|
|
? {
|
|
optionId: PERMISSION_OPTION_ID.exitPlanAuto,
|
|
name: "Yes, and use auto mode",
|
|
kind: "allow_always",
|
|
}
|
|
: {
|
|
optionId: PERMISSION_OPTION_ID.exitPlanBypass,
|
|
name: "Yes, and bypass permissions",
|
|
kind: "allow_always",
|
|
},
|
|
);
|
|
}
|
|
if (elevatedModes.length === 0) {
|
|
options.push({
|
|
optionId: PERMISSION_OPTION_ID.exitPlanAcceptEdits,
|
|
name: "Yes, auto-accept edits",
|
|
kind: "allow_always",
|
|
});
|
|
}
|
|
options.push({
|
|
optionId: PERMISSION_OPTION_ID.exitPlanDefault,
|
|
name: "Yes, manually approve edits",
|
|
kind: "allow_once",
|
|
});
|
|
options.push(reject("No, keep planning"));
|
|
return options;
|
|
}
|
|
|
|
export function buildSandboxNetworkPermissionOptions(
|
|
context: PermissionOptionContext,
|
|
): PermissionOption[] {
|
|
const host = plainString(context.input.host);
|
|
const name =
|
|
host && exactLocalAllowRule(context.durableChangeSet, context.toolName, host)
|
|
? `Yes, and don't ask again for ${host}`
|
|
: undefined;
|
|
return withOptionalUpdate(context.durableChangeSet, name, "Yes", "No");
|
|
}
|
|
|
|
export function buildFallbackPermissionOptions(
|
|
context: PermissionOptionContext,
|
|
): PermissionOption[] {
|
|
const toolLabel = plainString(context.displayName) ?? context.toolName;
|
|
if (context.toolName.startsWith("mcp__")) {
|
|
const changeSet =
|
|
context.allowPersistentOptions !== false &&
|
|
isMcpAllowChangeSet(context.durableChangeSet, context.toolName)
|
|
? context.durableChangeSet
|
|
: undefined;
|
|
return withOptionalUpdate(
|
|
changeSet,
|
|
changeSet ? `Yes, and don't ask again for ${toolLabel} commands` : undefined,
|
|
);
|
|
}
|
|
if (context.allowPersistentOptions !== false) {
|
|
return withGeneratedUpdate(`Yes, and don't ask again for ${toolLabel} commands`);
|
|
}
|
|
return [allowOnce(), reject("No")];
|
|
}
|
|
|
|
const COMPUTER_USE_MCP_TOOL_PREFIX = "mcp__computer-use__";
|
|
|
|
export function isComputerUseMcpTool(toolName: string): boolean {
|
|
return toolName.startsWith(COMPUTER_USE_MCP_TOOL_PREFIX);
|
|
}
|
|
|
|
export function buildComputerUseMcpPermissionOptions(
|
|
context: PermissionOptionContext,
|
|
): PermissionOption[] {
|
|
const toolLabel = plainString(context.displayName) ?? context.toolName;
|
|
const changeSet =
|
|
context.allowPersistentOptions !== false &&
|
|
isMcpAllowChangeSet(context.durableChangeSet, context.toolName)
|
|
? context.durableChangeSet
|
|
: undefined;
|
|
return withOptionalUpdate(
|
|
changeSet,
|
|
changeSet ? `Yes, and don't ask again for ${toolLabel}` : undefined,
|
|
);
|
|
}
|