Skip to content
Merged
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
100 changes: 99 additions & 1 deletion src/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
StopReason,
} from "@agentclientprotocol/sdk";
import {
AgentInfo,
CanUseTool,
deleteSession,
getSessionMessages,
Expand Down Expand Up @@ -231,6 +232,14 @@ type Session = {
models: SessionModelState;
modelInfos: ModelInfo[];
configOptions: SessionConfigOption[];
/** Custom main-thread agent personas the user (or a plugin/project) has
* configured, discovered via `supportedAgents()` with Claude Code's built-in
* subagents filtered out. Empty when none are configured, in which case the
* "agent" config option is omitted entirely. */
agents: AgentInfo[];
/** The currently selected main-thread agent name, or "default" for the
* standard Claude Code agent (no `agent` flag applied). */
currentAgent: string;
abortController: AbortController;
/** Signal the consumer races `query.next()` against. Aborted by cancel()
* (after a grace period) to force the active turn to settle "cancelled" when
Expand Down Expand Up @@ -2692,6 +2701,8 @@ export class ClaudeAcpAgent {
session.models,
session.modelInfos,
currentEffort,
session.agents,
session.currentAgent,
);

// Sync effort with the SDK if it changed after the model switch
Expand Down Expand Up @@ -2719,6 +2730,19 @@ export class ClaudeAcpAgent {
},
});
}
} else if (configId === "agent") {
// Live agent switch — no subprocess restart needed. Apply the SDK flag
// first so a rejected control request leaves both `currentAgent` and the
// config option untouched (no UI/SDK desync). Passing `null` clears the
// flag layer back to the standard Claude Code agent; the change takes
// effect on the next turn (SDK >= 0.3.161).
await session.query.applyFlagSettings({
agent: value === DEFAULT_AGENT_ID ? null : value,
});
session.currentAgent = value;
session.configOptions = session.configOptions.map((o) =>
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
);
} else {
session.configOptions = session.configOptions.map((o) =>
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
Expand Down Expand Up @@ -3141,11 +3165,25 @@ export class ClaudeAcpAgent {
availableModes,
};

const agents = await discoverCustomAgents(q);
// Only adopt the requested agent as the selected value if it's one we
// actually surface in the picker. A built-in (filtered out above) or
// otherwise-unknown name would leave the config option's `currentValue`
// pointing at an entry not in its own `options` list, which clients render
// as a blank/invalid selection.
const requestedAgent = userProvidedOptions?.agent;
const currentAgent =
requestedAgent && agents.some((a) => a.name === requestedAgent)
? requestedAgent
: DEFAULT_AGENT_ID;

const configOptions = buildConfigOptions(
modes,
models,
allowedModels,
settingsManager.getSettings().effortLevel,
agents,
currentAgent,
);

// Apply the initial effort level to the SDK so it matches the UI default
Expand Down Expand Up @@ -3176,6 +3214,8 @@ export class ClaudeAcpAgent {
models,
modelInfos: allowedModels,
configOptions,
agents,
currentAgent,
abortController,
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
contextWindowSize:
Expand Down Expand Up @@ -3357,11 +3397,47 @@ function toSdkEffortLevel(value: string | undefined): Settings["effortLevel"] |
return value === undefined || value === "default" ? null : (value as Settings["effortLevel"]);
}

function buildConfigOptions(
// `supportedAgents()` always returns Claude Code's built-in subagents — the
// ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user
// has configured none of their own. Those aren't meaningful *main-thread*
// personas, so we filter them out and only surface the Agent picker when the
// user (or a plugin/project) has configured custom agents. Update this set if
// the SDK's built-in roster changes.
export const BUILTIN_AGENT_NAMES = new Set([
"claude",
"general-purpose",
"Explore",
"Plan",
"statusline-setup",
]);

// Value of the synthetic "Default" entry in the agent picker, which maps to the
// standard Claude Code agent (`applyFlagSettings({ agent: null })`). It is a
// reserved sentinel: a custom agent named exactly this would collide with it
// (two options sharing the value, selection silently routing to `null`), so we
// exclude that name from discovery.
export const DEFAULT_AGENT_ID = "default";

/** Discover user/plugin/project-configured main-thread agents, excluding the
* built-in subagents and the reserved "default" sentinel. Returns an empty
* list if discovery fails so a flaky control request never blocks session
* creation. */
export async function discoverCustomAgents(q: Query): Promise<AgentInfo[]> {
try {
const agents = await q.supportedAgents();
return agents.filter((a) => !BUILTIN_AGENT_NAMES.has(a.name) && a.name !== DEFAULT_AGENT_ID);
} catch {
return [];
}
}

export function buildConfigOptions(
modes: SessionModeState,
models: SessionModelState,
modelInfos: ModelInfo[],
currentEffortLevel?: string,
agents: AgentInfo[] = [],
currentAgent: string = DEFAULT_AGENT_ID,
): SessionConfigOption[] {
const options: SessionConfigOption[] = [
{
Expand Down Expand Up @@ -3425,6 +3501,28 @@ function buildConfigOptions(
});
}

// Only surface the Agent picker when there's a real choice — i.e. the user
// has configured at least one custom agent (built-ins are filtered out in
// discoverCustomAgents). With none configured, "Default" would be the only
// entry, so we omit the option entirely.
if (agents.length > 0) {
options.push({
id: "agent",
name: "Agent",
description: "Main-thread agent persona",
type: "select",
currentValue: currentAgent,
options: [
{ value: DEFAULT_AGENT_ID, name: "Default", description: "Standard Claude Code agent" },
...agents.map((a) => ({
value: a.name,
name: a.name,
description: a.description || undefined,
})),
],
});
}

return options;
}

Expand Down
Loading