Merge remote-tracking branch 'origin/main' into dev
# Conflicts: # nix/package.nix # packages/app/src/components/icons/opencode-icon.tsx # packages/app/src/components/provider-icons.ts # packages/app/src/panels/agent-panel.tsx # packages/cli/src/commands/provider/ls.ts # packages/server/src/server/agent/provider-manifest.ts # packages/server/src/server/agent/provider-registry.ts # packages/server/src/server/agent/providers/claude-agent.test.ts # packages/server/src/server/agent/providers/claude-agent.ts # packages/server/src/server/persistence-hooks.ts # packages/server/src/server/session.ts
This commit is contained in:
@@ -11,6 +11,9 @@ export interface AgentModeVisuals {
|
||||
|
||||
export interface AgentProviderModeDefinition extends AgentMode, AgentModeVisuals {}
|
||||
|
||||
// TODO: `modes` should not be static. Providers (especially ACP) report their
|
||||
// own modes at runtime via session/new. We should fetch modes from the provider
|
||||
// as source of truth and enrich with UI metadata (icons, colorTier) on top.
|
||||
export interface AgentProviderDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -80,6 +83,30 @@ const CODEX_MODES: AgentProviderModeDefinition[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const COPILOT_MODES: AgentProviderModeDefinition[] = [
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
label: "Agent",
|
||||
description: "Default agent mode for conversational interactions",
|
||||
icon: "ShieldAlert",
|
||||
colorTier: "moderate",
|
||||
},
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
|
||||
label: "Plan",
|
||||
description: "Plan mode for creating and executing multi-step plans",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "planning",
|
||||
},
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||
label: "Autopilot",
|
||||
description: "Autonomous mode that runs until task completion without user interaction",
|
||||
icon: "ShieldOff",
|
||||
colorTier: "dangerous",
|
||||
},
|
||||
];
|
||||
|
||||
const OPENCODE_MODES: AgentProviderModeDefinition[] = [
|
||||
{
|
||||
id: "build",
|
||||
@@ -110,6 +137,18 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
defaultModel: "haiku",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "claude-acp",
|
||||
label: "Claude ACP",
|
||||
description: "Claude Code via Agent Client Protocol with streaming, permissions, and session resume",
|
||||
defaultModeId: "default",
|
||||
modes: CLAUDE_MODES,
|
||||
voice: {
|
||||
enabled: true,
|
||||
defaultModeId: "default",
|
||||
defaultModel: "haiku",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
@@ -143,6 +182,13 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
},
|
||||
{
|
||||
id: "copilot",
|
||||
label: "Copilot",
|
||||
description: "GitHub Copilot via Agent Client Protocol with dynamic modes and session support",
|
||||
defaultModeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
modes: COPILOT_MODES,
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
label: "OpenCode",
|
||||
|
||||
@@ -7,11 +7,13 @@ import type {
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.js";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { AiderAgentClient } from "./providers/aider-agent.js";
|
||||
import { AmpAgentClient } from "./providers/amp-agent.js";
|
||||
import { ClaudeACPAgentClient } from "./providers/claude-acp-agent.js";
|
||||
import { ClaudeAgentClient } from "./providers/claude-agent.js";
|
||||
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
|
||||
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
|
||||
import { GeminiAgentClient } from "./providers/gemini-agent.js";
|
||||
import { AmpAgentClient } from "./providers/amp-agent.js";
|
||||
import { AiderAgentClient } from "./providers/aider-agent.js";
|
||||
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
|
||||
|
||||
import {
|
||||
@@ -33,55 +35,61 @@ type BuildProviderRegistryOptions = {
|
||||
runtimeSettings?: AgentProviderRuntimeSettingsMap;
|
||||
};
|
||||
|
||||
type ProviderClientFactory = (
|
||||
logger: Logger,
|
||||
runtimeSettings?: AgentProviderRuntimeSettingsMap,
|
||||
) => AgentClient;
|
||||
|
||||
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
claude: (logger, runtimeSettings) =>
|
||||
new ClaudeAgentClient({
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.claude,
|
||||
}),
|
||||
"claude-acp": (logger, runtimeSettings) =>
|
||||
new ClaudeACPAgentClient({
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.["claude-acp"],
|
||||
}),
|
||||
codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings?.codex),
|
||||
gemini: (_logger, runtimeSettings) => new GeminiAgentClient(runtimeSettings?.gemini),
|
||||
amp: (_logger, runtimeSettings) => new AmpAgentClient(runtimeSettings?.amp),
|
||||
aider: (_logger, runtimeSettings) => new AiderAgentClient(runtimeSettings?.aider),
|
||||
copilot: (logger, runtimeSettings) =>
|
||||
new CopilotACPAgentClient({
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.copilot,
|
||||
}),
|
||||
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
|
||||
};
|
||||
|
||||
function getProviderClientFactory(provider: string): ProviderClientFactory {
|
||||
const factory = PROVIDER_CLIENT_FACTORIES[provider];
|
||||
if (!factory) {
|
||||
throw new Error(`No provider client factory registered for '${provider}'`);
|
||||
}
|
||||
return factory;
|
||||
}
|
||||
|
||||
export function buildProviderRegistry(
|
||||
logger: Logger,
|
||||
options?: BuildProviderRegistryOptions,
|
||||
): Record<AgentProvider, ProviderDefinition> {
|
||||
const runtimeSettings = options?.runtimeSettings;
|
||||
const claudeClient = new ClaudeAgentClient({
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.claude,
|
||||
});
|
||||
const codexClient = new CodexAppServerAgentClient(logger, runtimeSettings?.codex);
|
||||
const geminiClient = new GeminiAgentClient(runtimeSettings?.gemini);
|
||||
const ampClient = new AmpAgentClient(runtimeSettings?.amp);
|
||||
const aiderClient = new AiderAgentClient(runtimeSettings?.aider);
|
||||
const opencodeClient = new OpenCodeAgentClient(logger, runtimeSettings?.opencode);
|
||||
|
||||
return {
|
||||
claude: {
|
||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "claude")!,
|
||||
createClient: (logger: Logger) =>
|
||||
new ClaudeAgentClient({ logger, runtimeSettings: runtimeSettings?.claude }),
|
||||
fetchModels: (options) => claudeClient.listModels(options),
|
||||
},
|
||||
codex: {
|
||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "codex")!,
|
||||
createClient: (logger: Logger) =>
|
||||
new CodexAppServerAgentClient(logger, runtimeSettings?.codex),
|
||||
fetchModels: (options) => codexClient.listModels(options),
|
||||
},
|
||||
gemini: {
|
||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "gemini")!,
|
||||
createClient: () => new GeminiAgentClient(runtimeSettings?.gemini),
|
||||
fetchModels: (options) => geminiClient.listModels(options),
|
||||
},
|
||||
amp: {
|
||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "amp")!,
|
||||
createClient: () => new AmpAgentClient(runtimeSettings?.amp),
|
||||
fetchModels: (options) => ampClient.listModels(options),
|
||||
},
|
||||
aider: {
|
||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "aider")!,
|
||||
createClient: () => new AiderAgentClient(runtimeSettings?.aider),
|
||||
fetchModels: (options) => aiderClient.listModels(options),
|
||||
},
|
||||
opencode: {
|
||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "opencode")!,
|
||||
createClient: (logger: Logger) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
|
||||
fetchModels: (options) => opencodeClient.listModels(options),
|
||||
},
|
||||
};
|
||||
return Object.fromEntries(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => {
|
||||
const createClient = getProviderClientFactory(definition.id);
|
||||
const modelClient = createClient(logger, runtimeSettings);
|
||||
return [
|
||||
definition.id,
|
||||
{
|
||||
...definition,
|
||||
createClient: (providerLogger: Logger) => createClient(providerLogger, runtimeSettings),
|
||||
fetchModels: (listOptions?: ListModelsOptions) => modelClient.listModels(listOptions),
|
||||
} satisfies ProviderDefinition,
|
||||
];
|
||||
}),
|
||||
) as Record<AgentProvider, ProviderDefinition>;
|
||||
}
|
||||
|
||||
// Deprecated: Use buildProviderRegistry instead
|
||||
@@ -92,14 +100,9 @@ export function createAllClients(
|
||||
options?: BuildProviderRegistryOptions,
|
||||
): Record<AgentProvider, AgentClient> {
|
||||
const registry = buildProviderRegistry(logger, options);
|
||||
return {
|
||||
claude: registry.claude.createClient(logger),
|
||||
codex: registry.codex.createClient(logger),
|
||||
gemini: registry.gemini.createClient(logger),
|
||||
amp: registry.amp.createClient(logger),
|
||||
aider: registry.aider.createClient(logger),
|
||||
opencode: registry.opencode.createClient(logger),
|
||||
};
|
||||
return Object.fromEntries(
|
||||
Object.entries(registry).map(([provider, definition]) => [provider, definition.createClient(logger)]),
|
||||
) as Record<AgentProvider, AgentClient>;
|
||||
}
|
||||
|
||||
export async function shutdownProviders(
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ACPAgentSession,
|
||||
deriveModelDefinitionsFromACP,
|
||||
deriveModesFromACP,
|
||||
mapACPUsage,
|
||||
} from "./acp-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
function createSession(): ACPAgentSession {
|
||||
return new ACPAgentSession(
|
||||
{
|
||||
provider: "claude-acp",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
},
|
||||
{
|
||||
provider: "claude-acp",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["claude", "--acp"],
|
||||
defaultModes: [],
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("mapACPUsage", () => {
|
||||
test("maps ACP usage fields into Paseo usage", () => {
|
||||
expect(
|
||||
mapACPUsage({
|
||||
inputTokens: 11,
|
||||
outputTokens: 7,
|
||||
totalTokens: 18,
|
||||
cachedReadTokens: 5,
|
||||
}),
|
||||
).toEqual({
|
||||
inputTokens: 11,
|
||||
outputTokens: 7,
|
||||
cachedInputTokens: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveModesFromACP", () => {
|
||||
test("prefers explicit ACP mode state", () => {
|
||||
const result = deriveModesFromACP(
|
||||
[{ id: "fallback", label: "Fallback" }],
|
||||
{
|
||||
availableModes: [
|
||||
{ id: "default", name: "Always Ask", description: "Prompt before tools" },
|
||||
{ id: "plan", name: "Plan", description: "Read only" },
|
||||
],
|
||||
currentModeId: "plan",
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
modes: [
|
||||
{ id: "default", label: "Always Ask", description: "Prompt before tools" },
|
||||
{ id: "plan", label: "Plan", description: "Read only" },
|
||||
],
|
||||
currentModeId: "plan",
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to config options when explicit mode state is absent", () => {
|
||||
const result = deriveModesFromACP(
|
||||
[{ id: "fallback", label: "Fallback" }],
|
||||
null,
|
||||
[
|
||||
{
|
||||
id: "mode",
|
||||
name: "Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: "acceptEdits",
|
||||
options: [
|
||||
{ value: "default", name: "Always Ask" },
|
||||
{ value: "acceptEdits", name: "Accept File Edits" },
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
modes: [
|
||||
{ id: "default", label: "Always Ask", description: undefined },
|
||||
{ id: "acceptEdits", label: "Accept File Edits", description: undefined },
|
||||
],
|
||||
currentModeId: "acceptEdits",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveModelDefinitionsFromACP", () => {
|
||||
test("attaches shared thinking options to ACP model state", () => {
|
||||
const result = deriveModelDefinitionsFromACP("claude-acp", {
|
||||
availableModels: [
|
||||
{ modelId: "haiku", name: "Haiku", description: "Fast" },
|
||||
{ modelId: "sonnet", name: "Sonnet", description: "Balanced" },
|
||||
],
|
||||
currentModelId: "haiku",
|
||||
}, [
|
||||
{
|
||||
id: "reasoning",
|
||||
name: "Reasoning",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: "medium",
|
||||
options: [
|
||||
{ value: "low", name: "Low" },
|
||||
{ value: "medium", name: "Medium" },
|
||||
{ value: "high", name: "High" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
provider: "claude-acp",
|
||||
id: "haiku",
|
||||
label: "Haiku",
|
||||
description: "Fast",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low", description: undefined, isDefault: false, metadata: undefined },
|
||||
{ id: "medium", label: "Medium", description: undefined, isDefault: true, metadata: undefined },
|
||||
{ id: "high", label: "High", description: undefined, isDefault: false, metadata: undefined },
|
||||
],
|
||||
defaultThinkingOptionId: "medium",
|
||||
},
|
||||
{
|
||||
provider: "claude-acp",
|
||||
id: "sonnet",
|
||||
label: "Sonnet",
|
||||
description: "Balanced",
|
||||
isDefault: false,
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low", description: undefined, isDefault: false, metadata: undefined },
|
||||
{ id: "medium", label: "Medium", description: undefined, isDefault: true, metadata: undefined },
|
||||
{ id: "high", label: "High", description: undefined, isDefault: false, metadata: undefined },
|
||||
],
|
||||
defaultThinkingOptionId: "medium",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentSession", () => {
|
||||
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
|
||||
const session = createSession();
|
||||
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
|
||||
(session as any).sessionId = "session-1";
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event as { type: string; item?: { type: string; text?: string } });
|
||||
});
|
||||
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "assistant-1",
|
||||
content: { type: "text", text: "Hey!" },
|
||||
} as any,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "assistant-1",
|
||||
content: { type: "text", text: " How are you?" },
|
||||
} as any,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: "thought-1",
|
||||
content: { type: "text", text: "Thinking" },
|
||||
} as any,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: "thought-1",
|
||||
content: { type: "text", text: " more" },
|
||||
} as any,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "user-1",
|
||||
content: { type: "text", text: "hel" },
|
||||
} as any,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "user-1",
|
||||
content: { type: "text", text: "lo" },
|
||||
} as any,
|
||||
});
|
||||
|
||||
const timeline = events
|
||||
.filter((event) => event.type === "timeline")
|
||||
.map((event) => event.item)
|
||||
.filter(Boolean);
|
||||
|
||||
expect(timeline).toEqual([
|
||||
{ type: "assistant_message", text: "Hey!" },
|
||||
{ type: "assistant_message", text: " How are you?" },
|
||||
{ type: "reasoning", text: "Thinking" },
|
||||
{ type: "reasoning", text: " more" },
|
||||
{ type: "user_message", text: "hel", messageId: "user-1" },
|
||||
{ type: "user_message", text: "hello", messageId: "user-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("startTurn returns before the ACP prompt settles and completes later via subscribers", async () => {
|
||||
const session = createSession();
|
||||
const events: Array<{ type: string; turnId?: string }> = [];
|
||||
let resolvePrompt!: (value: any) => void;
|
||||
const prompt = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvePrompt = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
(session as any).sessionId = "session-1";
|
||||
(session as any).connection = { prompt };
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event as { type: string; turnId?: string });
|
||||
});
|
||||
|
||||
const { turnId } = await session.startTurn("hello");
|
||||
|
||||
expect(prompt).toHaveBeenCalledOnce();
|
||||
expect(events.find((event) => event.type === "turn_started")).toMatchObject({
|
||||
type: "turn_started",
|
||||
turnId,
|
||||
});
|
||||
expect((session as any).activeForegroundTurnId).toBe(turnId);
|
||||
|
||||
resolvePrompt({ stopReason: "end_turn", usage: { outputTokens: 3 } });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(events.find((event) => event.type === "turn_completed")).toMatchObject({
|
||||
type: "turn_completed",
|
||||
turnId,
|
||||
});
|
||||
expect((session as any).activeForegroundTurnId).toBeNull();
|
||||
});
|
||||
|
||||
test("startTurn converts background prompt rejections into turn_failed events", async () => {
|
||||
const session = createSession();
|
||||
const events: Array<{ type: string; turnId?: string; error?: string }> = [];
|
||||
let rejectPrompt!: (error: Error) => void;
|
||||
const prompt = vi.fn(
|
||||
() =>
|
||||
new Promise((_, reject) => {
|
||||
rejectPrompt = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
(session as any).sessionId = "session-1";
|
||||
(session as any).connection = { prompt };
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event as { type: string; turnId?: string; error?: string });
|
||||
});
|
||||
|
||||
const { turnId } = await session.startTurn("hello");
|
||||
|
||||
rejectPrompt(new Error("prompt failed"));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const turnFailedEvent = events.find((event) => event.type === "turn_failed");
|
||||
expect(turnFailedEvent).toMatchObject({
|
||||
type: "turn_failed",
|
||||
turnId,
|
||||
error: "prompt failed",
|
||||
});
|
||||
expect((session as any).activeForegroundTurnId).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { ACPAgentClient } from "./acp-agent.js";
|
||||
|
||||
const CLAUDE_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
const CLAUDE_ACP_MODES: AgentMode[] = [
|
||||
{
|
||||
id: "default",
|
||||
label: "Always Ask",
|
||||
description: "Prompts for permission the first time a tool is used",
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
label: "Accept File Edits",
|
||||
description: "Automatically approves edit-focused tools without prompting",
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
label: "Plan Mode",
|
||||
description: "Analyze the codebase without executing tools or edits",
|
||||
},
|
||||
{
|
||||
id: "bypassPermissions",
|
||||
label: "Bypass",
|
||||
description: "Skip all permission prompts (use with caution)",
|
||||
},
|
||||
];
|
||||
|
||||
type ClaudeACPAgentClientOptions = {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
};
|
||||
|
||||
export class ClaudeACPAgentClient extends ACPAgentClient {
|
||||
constructor(options: ClaudeACPAgentClientOptions) {
|
||||
super({
|
||||
provider: "claude-acp",
|
||||
logger: options.logger,
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
defaultCommand: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
|
||||
defaultModes: CLAUDE_ACP_MODES,
|
||||
capabilities: CLAUDE_ACP_CAPABILITIES,
|
||||
});
|
||||
}
|
||||
|
||||
override async isAvailable(): Promise<boolean> {
|
||||
if (!(await super.isAvailable())) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(process.env["CLAUDE_CODE_OAUTH_TOKEN"] || process.env["ANTHROPIC_API_KEY"]);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { ModelInfo, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
|
||||
@@ -242,167 +241,23 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
describe("ClaudeAgentClient.listModels", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
function createSupportedModelsQueryMock(models: ModelInfo[]) {
|
||||
return {
|
||||
supportedModels: vi.fn(async () => models),
|
||||
return: vi.fn(async () => ({ done: true, value: undefined })),
|
||||
};
|
||||
}
|
||||
|
||||
test("returns models with required fields", async () => {
|
||||
test("returns hardcoded claude models", async () => {
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const models = await client.listModels();
|
||||
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-4-6[1m]",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
]);
|
||||
|
||||
for (const model of models) {
|
||||
expect(model.provider).toBe("claude");
|
||||
expect(typeof model.id).toBe("string");
|
||||
expect(model.id.length).toBeGreaterThan(0);
|
||||
expect(typeof model.label).toBe("string");
|
||||
expect(model.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const modelIds = models.map((model) => model.id);
|
||||
expect(
|
||||
modelIds.some(
|
||||
(id) =>
|
||||
id.includes("claude") ||
|
||||
id.includes("sonnet") ||
|
||||
id.includes("opus") ||
|
||||
id.includes("haiku"),
|
||||
),
|
||||
).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("prefers provider-discovered Claude defaults and effort levels", async () => {
|
||||
const queryMock = createSupportedModelsQueryMock([
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
},
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus",
|
||||
description: "Opus 4.6 · Most capable for complex work",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
displayName: "Haiku",
|
||||
description: "Haiku 4.5 · Fastest for quick answers",
|
||||
},
|
||||
] satisfies ModelInfo[]);
|
||||
const queryFactory = vi.fn(() => queryMock);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: queryFactory as never,
|
||||
});
|
||||
|
||||
const models = await client.listModels({ cwd: process.cwd() });
|
||||
|
||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||
expect(queryMock.supportedModels).toHaveBeenCalledTimes(1);
|
||||
expect(queryMock.return).toHaveBeenCalledTimes(1);
|
||||
expect(models).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "claude-sonnet-4-6",
|
||||
isDefault: true,
|
||||
label: "Sonnet 4.6",
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "claude-opus-4-6",
|
||||
label: "Opus 4.6",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "claude-haiku-4-5",
|
||||
label: "Haiku 4.5",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves SDK ids even when descriptions are weak", async () => {
|
||||
const queryMock = createSupportedModelsQueryMock([
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Recommended model",
|
||||
},
|
||||
] satisfies ModelInfo[]);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: vi.fn(() => queryMock) as never,
|
||||
});
|
||||
|
||||
const models = await client.listModels({ cwd: process.cwd() });
|
||||
|
||||
expect(models).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "default",
|
||||
label: "Default (recommended)",
|
||||
description: "Recommended model",
|
||||
}),
|
||||
]);
|
||||
expect(queryMock.return).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("keeps the Claude control-plane query open until supportedModels resolves", async () => {
|
||||
const queryMock = createSupportedModelsQueryMock([
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
},
|
||||
] satisfies ModelInfo[]);
|
||||
let promptIterator: AsyncIterator<SDKUserMessage, void> | null = null;
|
||||
let promptNextPromise: Promise<IteratorResult<SDKUserMessage, void>> | null = null;
|
||||
let promptClosedBeforeModelsResolved = false;
|
||||
|
||||
queryMock.supportedModels = vi.fn(async () => {
|
||||
promptNextPromise = promptIterator?.next() ?? null;
|
||||
if (!promptNextPromise) {
|
||||
throw new Error("Prompt iterator not captured");
|
||||
}
|
||||
promptNextPromise.then(() => {
|
||||
promptClosedBeforeModelsResolved = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(promptClosedBeforeModelsResolved).toBe(false);
|
||||
return [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
},
|
||||
] satisfies ModelInfo[];
|
||||
});
|
||||
|
||||
const queryFactory = vi.fn(({ prompt }) => {
|
||||
promptIterator = prompt[Symbol.asyncIterator]();
|
||||
return queryMock;
|
||||
});
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: queryFactory as never,
|
||||
});
|
||||
|
||||
const models = await client.listModels({ cwd: process.cwd() });
|
||||
|
||||
expect(models).toHaveLength(1);
|
||||
expect(promptNextPromise).not.toBeNull();
|
||||
await expect(promptNextPromise).resolves.toEqual({ done: true, value: undefined });
|
||||
expect(queryMock.return).toHaveBeenCalledTimes(1);
|
||||
const defaultModel = models.find((m) => m.isDefault);
|
||||
expect(defaultModel?.id).toBe("claude-opus-4-6");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type AgentDefinition,
|
||||
type CanUseTool,
|
||||
type McpServerConfig as ClaudeSdkMcpServerConfig,
|
||||
type ModelInfo,
|
||||
|
||||
type Options,
|
||||
type PermissionMode,
|
||||
type PermissionResult,
|
||||
@@ -34,9 +34,9 @@ import {
|
||||
mapTaskNotificationUserContentToToolCall,
|
||||
} from "./claude/task-notification-tool-call.js";
|
||||
import {
|
||||
normalizeClaudeModelIdFromText,
|
||||
resolveClaudeModelsFromSdkModels,
|
||||
} from "./claude/sdk-model-resolver.js";
|
||||
getClaudeModels,
|
||||
normalizeClaudeRuntimeModelId,
|
||||
} from "./claude/claude-models.js";
|
||||
import { parsePartialJsonObject } from "./claude/partial-json.js";
|
||||
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
|
||||
|
||||
@@ -1046,35 +1046,8 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
const input = createAsyncMessageInput<SDKUserMessage>();
|
||||
const claudeQuery = this.queryFactory({
|
||||
prompt: input.iterable,
|
||||
options: applyRuntimeSettingsToClaudeOptions(
|
||||
{
|
||||
cwd: options?.cwd ?? process.cwd(),
|
||||
permissionMode: "plan",
|
||||
includePartialMessages: false,
|
||||
settingSources: CLAUDE_SETTING_SOURCES,
|
||||
},
|
||||
this.runtimeSettings,
|
||||
),
|
||||
});
|
||||
|
||||
try {
|
||||
const supportedModels = await claudeQuery.supportedModels();
|
||||
return resolveClaudeModelsFromSdkModels(supportedModels as ModelInfo[]);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to query Claude supportedModels()");
|
||||
throw error;
|
||||
} finally {
|
||||
input.end();
|
||||
try {
|
||||
await claudeQuery.return?.();
|
||||
} catch {
|
||||
// ignore control-plane shutdown errors
|
||||
}
|
||||
}
|
||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
return getClaudeModels();
|
||||
}
|
||||
|
||||
async listPersistedAgents(
|
||||
@@ -2695,7 +2668,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.currentMode = message.permissionMode;
|
||||
this.persistence = null;
|
||||
if (message.model) {
|
||||
const normalizedRuntimeModel = normalizeClaudeModelIdFromText(message.model);
|
||||
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
|
||||
this.logger.debug(
|
||||
{ runtimeModel: message.model, normalizedRuntimeModel },
|
||||
"Captured runtime model from SDK init",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude-models.js";
|
||||
|
||||
describe("getClaudeModels", () => {
|
||||
it("returns all claude models", () => {
|
||||
const models = getClaudeModels();
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-4-6[1m]",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks exactly one model as default", () => {
|
||||
const models = getClaudeModels();
|
||||
const defaults = models.filter((m) => m.isDefault);
|
||||
expect(defaults).toHaveLength(1);
|
||||
expect(defaults[0]!.id).toBe("claude-opus-4-6");
|
||||
});
|
||||
|
||||
it("returns fresh copies each call", () => {
|
||||
const a = getClaudeModels();
|
||||
const b = getClaudeModels();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a[0]).not.toBe(b[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeClaudeRuntimeModelId", () => {
|
||||
it("returns exact match for known model IDs", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5")).toBe("claude-haiku-4-5");
|
||||
});
|
||||
|
||||
it("normalizes dated model IDs to base model", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5");
|
||||
});
|
||||
|
||||
it("preserves [1m] suffix from runtime model strings", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
|
||||
});
|
||||
|
||||
it("returns null for empty/null/undefined", () => {
|
||||
expect(normalizeClaudeRuntimeModelId(null)).toBeNull();
|
||||
expect(normalizeClaudeRuntimeModelId(undefined)).toBeNull();
|
||||
expect(normalizeClaudeRuntimeModelId("")).toBeNull();
|
||||
expect(normalizeClaudeRuntimeModelId(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unrecognized strings", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("gpt-5")).toBeNull();
|
||||
expect(normalizeClaudeRuntimeModelId("random")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AgentModelDefinition } from "../../agent-sdk-types.js";
|
||||
|
||||
const CLAUDE_THINKING_OPTIONS = [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
] as const;
|
||||
|
||||
const CLAUDE_MODELS: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-4-6[1m]",
|
||||
label: "Opus 4.6 1M",
|
||||
description: "Opus 4.6 with 1M context window",
|
||||
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-4-6",
|
||||
label: "Opus 4.6",
|
||||
description: "Opus 4.6 · Most capable for complex work",
|
||||
isDefault: true,
|
||||
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-sonnet-4-6",
|
||||
label: "Sonnet 4.6",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-haiku-4-5",
|
||||
label: "Haiku 4.5",
|
||||
description: "Haiku 4.5 · Fastest for quick answers",
|
||||
},
|
||||
];
|
||||
|
||||
export function getClaudeModels(): AgentModelDefinition[] {
|
||||
return CLAUDE_MODELS.map((model) => ({ ...model }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a runtime model string (from SDK init message) to a known model ID.
|
||||
* Handles the `[1m]` suffix that the SDK appends for 1M context sessions.
|
||||
*/
|
||||
export function normalizeClaudeRuntimeModelId(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for exact match first (handles claude-opus-4-6[1m] directly)
|
||||
if (CLAUDE_MODELS.some((model) => model.id === trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Match: claude-{family}-{major}-{minor}[1m]? possibly followed by a date suffix
|
||||
const runtimeMatch = trimmed.match(
|
||||
/(?:claude-)?(opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
|
||||
);
|
||||
if (!runtimeMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const family = runtimeMatch[1]!.toLowerCase();
|
||||
const major = runtimeMatch[2]!;
|
||||
const minor = runtimeMatch[3]!;
|
||||
const suffix = runtimeMatch[4] ?? "";
|
||||
return `claude-${family}-${major}-${minor}${suffix}`;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import {
|
||||
parseClaudeSdkModelDescriptorForTest,
|
||||
resolveClaudeModelsFromSdkModels,
|
||||
} from "./sdk-model-resolver.js";
|
||||
|
||||
describe("resolveClaudeModelsFromSdkModels", () => {
|
||||
const sdkModels: ModelInfo[] = [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
},
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus",
|
||||
description: "Opus 4.6 · Most capable for complex work",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
supportsFastMode: true,
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
displayName: "Haiku",
|
||||
description: "Haiku 4.5 · Fastest for quick answers",
|
||||
},
|
||||
];
|
||||
|
||||
it("parses family and version from SDK descriptions", () => {
|
||||
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[0]!)).toEqual({
|
||||
family: "sonnet",
|
||||
version: "4.6",
|
||||
});
|
||||
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[1]!)).toEqual({
|
||||
family: "opus",
|
||||
version: "4.6",
|
||||
});
|
||||
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[2]!)).toEqual({
|
||||
family: "sonnet",
|
||||
version: "4.6",
|
||||
});
|
||||
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[3]!)).toEqual({
|
||||
family: "haiku",
|
||||
version: "4.5",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps SDK models to parsed Claude model ids", () => {
|
||||
const models = resolveClaudeModelsFromSdkModels(sdkModels);
|
||||
|
||||
expect(models).toEqual([
|
||||
expect.objectContaining({
|
||||
provider: "claude",
|
||||
id: "claude-sonnet-4-6",
|
||||
label: "Sonnet 4.6",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
provider: "claude",
|
||||
id: "claude-opus-4-6",
|
||||
label: "Opus 4.6",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
provider: "claude",
|
||||
id: "claude-haiku-4-5",
|
||||
label: "Haiku 4.5",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import type { AgentModelDefinition, AgentSelectOption } from "../../agent-sdk-types.js";
|
||||
|
||||
type ParsedClaudeSdkModelDescriptor = {
|
||||
family: "opus" | "sonnet" | "haiku";
|
||||
version: string;
|
||||
};
|
||||
|
||||
// Claude may advertise effort levels that are not usable for all account types.
|
||||
const DISABLED_CLAUDE_THINKING_EFFORT_LEVELS: readonly string[] = ["max"];
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeClaudeVersionId(version: string): string {
|
||||
return version.replace(/\./g, "-");
|
||||
}
|
||||
|
||||
function buildClaudeModelId(parsed: ParsedClaudeSdkModelDescriptor): string {
|
||||
return `claude-${parsed.family}-${normalizeClaudeVersionId(parsed.version)}`;
|
||||
}
|
||||
|
||||
function parseClaudeSdkDescriptor(model: ModelInfo): ParsedClaudeSdkModelDescriptor | null {
|
||||
const description = normalizeWhitespace(model.description ?? "");
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = description.match(/\b(opus|sonnet|haiku)\s+(\d+(?:\.\d+)*)\b/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const family = match[1].toLowerCase() as ParsedClaudeSdkModelDescriptor["family"];
|
||||
const version = match[2]!;
|
||||
return { family, version };
|
||||
}
|
||||
|
||||
export function normalizeClaudeModelIdFromText(value: string | null | undefined): string | null {
|
||||
const normalized = normalizeWhitespace(value ?? "");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeMatch = normalized.match(/\b(opus|sonnet|haiku)[-_ ]+(\d+(?:[-.]\d+)*)\b/i);
|
||||
if (!runtimeMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const family = runtimeMatch[1]!.toLowerCase() as ParsedClaudeSdkModelDescriptor["family"];
|
||||
const version = runtimeMatch[2]!.replace(/-/g, ".");
|
||||
return buildClaudeModelId({ family, version });
|
||||
}
|
||||
|
||||
function buildModelLabel(model: ModelInfo): string {
|
||||
const parsed = parseClaudeSdkDescriptor(model);
|
||||
if (!parsed) {
|
||||
return normalizeWhitespace(model.displayName || model.value);
|
||||
}
|
||||
return `${titleCase(parsed.family)} ${parsed.version}`;
|
||||
}
|
||||
|
||||
function buildThinkingOptions(model: ModelInfo): {
|
||||
thinkingOptions?: AgentSelectOption[];
|
||||
defaultThinkingOptionId?: string;
|
||||
} {
|
||||
const effortLevels = (model.supportedEffortLevels ?? []).filter(
|
||||
(level) => !DISABLED_CLAUDE_THINKING_EFFORT_LEVELS.includes(level),
|
||||
);
|
||||
if (!model.supportsEffort || effortLevels.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const thinkingOptions: AgentSelectOption[] = effortLevels.map((level) => ({
|
||||
id: level,
|
||||
label: titleCase(level),
|
||||
}));
|
||||
|
||||
return {
|
||||
thinkingOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveClaudeModelsFromSdkModels(models: ModelInfo[]): AgentModelDefinition[] {
|
||||
const resolved = new Map<string, AgentModelDefinition>();
|
||||
|
||||
for (const model of models) {
|
||||
const thinking = buildThinkingOptions(model);
|
||||
const parsed = parseClaudeSdkDescriptor(model);
|
||||
const id = parsed ? buildClaudeModelId(parsed) : model.value;
|
||||
const existing = resolved.get(id);
|
||||
resolved.set(id, {
|
||||
provider: "claude",
|
||||
id,
|
||||
label: buildModelLabel(model),
|
||||
description: normalizeWhitespace(model.description ?? model.displayName ?? model.value),
|
||||
isDefault:
|
||||
existing?.isDefault === true || model.value.trim().toLowerCase() === "default" || undefined,
|
||||
...(thinking.thinkingOptions || existing?.thinkingOptions
|
||||
? { thinkingOptions: thinking.thinkingOptions ?? existing?.thinkingOptions }
|
||||
: {}),
|
||||
...(thinking.defaultThinkingOptionId || existing?.defaultThinkingOptionId
|
||||
? {
|
||||
defaultThinkingOptionId:
|
||||
thinking.defaultThinkingOptionId ?? existing?.defaultThinkingOptionId,
|
||||
}
|
||||
: {}),
|
||||
metadata: {
|
||||
sdkValues: Array.from(
|
||||
new Set([...(Array.isArray(existing?.metadata?.sdkValues) ? existing.metadata.sdkValues : []), model.value]),
|
||||
),
|
||||
sdkDisplayNames: Array.from(
|
||||
new Set([
|
||||
...(Array.isArray(existing?.metadata?.sdkDisplayNames) ? existing.metadata.sdkDisplayNames : []),
|
||||
model.displayName,
|
||||
].filter((entry): entry is string => typeof entry === "string" && entry.length > 0)),
|
||||
),
|
||||
sdkDescriptions: Array.from(
|
||||
new Set([
|
||||
...(Array.isArray(existing?.metadata?.sdkDescriptions) ? existing.metadata.sdkDescriptions : []),
|
||||
model.description,
|
||||
].filter((entry): entry is string => typeof entry === "string" && entry.length > 0)),
|
||||
),
|
||||
supportsEffort: model.supportsEffort === true,
|
||||
supportedEffortLevels: model.supportedEffortLevels,
|
||||
supportsAdaptiveThinking: model.supportsAdaptiveThinking === true,
|
||||
supportsFastMode: model.supportsFastMode === true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(resolved.values());
|
||||
}
|
||||
|
||||
export function parseClaudeSdkModelDescriptorForTest(
|
||||
model: ModelInfo,
|
||||
): ParsedClaudeSdkModelDescriptor | null {
|
||||
return parseClaudeSdkDescriptor(model);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { ACPAgentClient } from "./acp-agent.js";
|
||||
|
||||
const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
const COPILOT_MODES: AgentMode[] = [
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
label: "Agent",
|
||||
description: "Default agent mode for conversational interactions",
|
||||
},
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
|
||||
label: "Plan",
|
||||
description: "Plan mode for creating and executing multi-step plans",
|
||||
},
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||
label: "Autopilot",
|
||||
description: "Autonomous mode that runs until task completion without user interaction",
|
||||
},
|
||||
];
|
||||
|
||||
type CopilotACPAgentClientOptions = {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
};
|
||||
|
||||
export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
constructor(options: CopilotACPAgentClientOptions) {
|
||||
super({
|
||||
provider: "copilot",
|
||||
logger: options.logger,
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
defaultCommand: ["copilot", "--acp"],
|
||||
defaultModes: COPILOT_MODES,
|
||||
capabilities: COPILOT_CAPABILITIES,
|
||||
});
|
||||
}
|
||||
|
||||
override async isAvailable(): Promise<boolean> {
|
||||
return super.isAvailable();
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,6 @@ import { ScheduleService } from "./schedule/service.js";
|
||||
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
|
||||
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
|
||||
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
||||
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
|
||||
import { getOrCreateServerId } from "./server-id.js";
|
||||
import { resolveDaemonVersion } from "./daemon-version.js";
|
||||
@@ -318,27 +317,6 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/pairing", async (_req, res) => {
|
||||
try {
|
||||
const offer = await generateLocalPairingOffer({
|
||||
paseoHome: config.paseoHome,
|
||||
relayEnabled: config.relayEnabled,
|
||||
relayEndpoint: config.relayEndpoint,
|
||||
relayPublicEndpoint: config.relayPublicEndpoint,
|
||||
appBaseUrl: config.appBaseUrl,
|
||||
logger,
|
||||
});
|
||||
res.json(offer);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to generate pairing offer");
|
||||
res.status(500).json({
|
||||
relayEnabled: false,
|
||||
url: null,
|
||||
qr: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/files/download", async (req, res) => {
|
||||
const token =
|
||||
typeof req.query.token === "string" && req.query.token.trim().length > 0
|
||||
|
||||
@@ -65,7 +65,7 @@ export function loadConfig(
|
||||
options?.cli?.allowedHosts,
|
||||
]);
|
||||
|
||||
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
|
||||
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? false;
|
||||
|
||||
const relayEnabled = options?.cli?.relayEnabled ?? persisted.daemon?.relay?.enabled ?? true;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
dotenv.config({ path: resolve(serverRoot, ".env.test"), override: true });
|
||||
|
||||
export interface AgentTestConfig {
|
||||
provider: "claude" | "codex" | "opencode";
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingOptionId?: string;
|
||||
modes: {
|
||||
@@ -32,6 +32,14 @@ export const agentConfigs = {
|
||||
ask: "default",
|
||||
},
|
||||
},
|
||||
"claude-acp": {
|
||||
provider: "claude-acp",
|
||||
model: "haiku",
|
||||
modes: {
|
||||
full: "bypassPermissions",
|
||||
ask: "default",
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
@@ -41,6 +49,14 @@ export const agentConfigs = {
|
||||
ask: "auto",
|
||||
},
|
||||
},
|
||||
copilot: {
|
||||
provider: "copilot",
|
||||
model: "claude-haiku-4.5",
|
||||
modes: {
|
||||
full: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||
ask: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
},
|
||||
},
|
||||
opencode: {
|
||||
provider: "opencode",
|
||||
model: "opencode/glm-5-free",
|
||||
@@ -96,11 +112,15 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
|
||||
isCommandAvailable("claude") &&
|
||||
(Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY))
|
||||
);
|
||||
case "claude-acp":
|
||||
return Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY);
|
||||
case "codex":
|
||||
return (
|
||||
isCommandAvailable("codex") &&
|
||||
(existsSync(join(homedir(), ".codex", "auth.json")) || Boolean(process.env.OPENAI_API_KEY))
|
||||
);
|
||||
case "copilot":
|
||||
return isCommandAvailable("copilot");
|
||||
case "opencode":
|
||||
return isCommandAvailable("opencode");
|
||||
}
|
||||
@@ -109,4 +129,10 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
|
||||
/**
|
||||
* Helper to run a test for each provider.
|
||||
*/
|
||||
export const allProviders: AgentProvider[] = ["claude", "codex", "opencode"];
|
||||
export const allProviders: AgentProvider[] = [
|
||||
"claude",
|
||||
"claude-acp",
|
||||
"codex",
|
||||
"copilot",
|
||||
"opencode",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { ClaudeACPAgentClient } from "../agent/providers/claude-acp-agent.js";
|
||||
import type { SessionOutboundMessage } from "../messages.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { getAskModeConfig, getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-real-claude-acp-"));
|
||||
}
|
||||
|
||||
describe("daemon E2E (real claude-acp)", () => {
|
||||
test.runIf(isProviderAvailable("claude-acp"))(
|
||||
"smoke test in full-access mode",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { "claude-acp": new ClaudeACPAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({
|
||||
subscribe: { subscriptionId: "claude-acp-real-smoke" },
|
||||
});
|
||||
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "claude-acp-real-smoke",
|
||||
...getFullAccessConfig("claude-acp"),
|
||||
});
|
||||
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
"Reply with exactly: PINEAPPLE",
|
||||
);
|
||||
|
||||
const finish = await client.waitForFinish(agent.id, 240_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.persistence).toBeTruthy();
|
||||
expect(finish.final?.persistence?.provider).toBe("claude-acp");
|
||||
expect(finish.final?.persistence?.sessionId).toBeTruthy();
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
const assistantText = timeline.entries
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is typeof entry & {
|
||||
item: { type: "assistant_message"; text: string };
|
||||
} => entry.item.type === "assistant_message",
|
||||
)
|
||||
.map((entry) => entry.item.text)
|
||||
.join("\n");
|
||||
|
||||
expect(assistantText).toContain("PINEAPPLE");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close().catch(() => undefined);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
420_000,
|
||||
);
|
||||
|
||||
test.runIf(isProviderAvailable("claude-acp"))(
|
||||
"permission flow in ask mode",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { "claude-acp": new ClaudeACPAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const targetFile = path.join(cwd, "permission-target.txt");
|
||||
|
||||
try {
|
||||
writeFileSync(targetFile, "ACP_PERMISSION_CONTENT\n", "utf8");
|
||||
|
||||
await client.connect();
|
||||
await client.fetchAgents({
|
||||
subscribe: { subscriptionId: "claude-acp-real-permission" },
|
||||
});
|
||||
|
||||
const unsubscribe = client.subscribeRawMessages((message) => {
|
||||
messages.push(message);
|
||||
});
|
||||
|
||||
try {
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "claude-acp-real-permission",
|
||||
...getAskModeConfig("claude-acp"),
|
||||
});
|
||||
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
[
|
||||
`Use the Bash tool to run exactly: cat ${JSON.stringify(targetFile)}.`,
|
||||
"If approval is required, wait for approval.",
|
||||
"After the command succeeds, reply with exactly: ACP_PERMISSION_DONE",
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
const permissionState = await client.waitForFinish(agent.id, 30_000);
|
||||
expect(permissionState.status).toBe("permission");
|
||||
expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0);
|
||||
|
||||
const permission = permissionState.final!.pendingPermissions[0]!;
|
||||
await client.respondToPermission(agent.id, permission.id, {
|
||||
behavior: "allow",
|
||||
});
|
||||
|
||||
const finalState = await client.waitForFinish(agent.id, 60_000);
|
||||
expect(finalState.status).toBe("idle");
|
||||
|
||||
const hasPermissionResolved = messages.some((message) => {
|
||||
if (message.type !== "agent_stream") {
|
||||
return false;
|
||||
}
|
||||
if (message.payload.agentId !== agent.id) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
message.payload.event.type === "permission_resolved" &&
|
||||
message.payload.event.requestId === permission.id &&
|
||||
message.payload.event.resolution.behavior === "allow"
|
||||
);
|
||||
});
|
||||
expect(hasPermissionResolved).toBe(true);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close().catch(() => undefined);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
420_000,
|
||||
);
|
||||
});
|
||||
@@ -37,6 +37,12 @@ export {
|
||||
quoteWindowsCommand,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
|
||||
// Provider manifest (source of truth for provider definitions)
|
||||
export {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
} from "./agent/provider-manifest.js";
|
||||
|
||||
// Agent SDK types for CLI commands
|
||||
export type {
|
||||
AgentMode,
|
||||
|
||||
@@ -96,4 +96,25 @@ describe("file explorer service", () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects symlinked files that resolve outside the workspace", async () => {
|
||||
const root = await createTempDir("paseo-file-explorer-");
|
||||
const outsideRoot = await createTempDir("paseo-file-explorer-outside-");
|
||||
|
||||
try {
|
||||
const externalFile = path.join(outsideRoot, "secret.txt");
|
||||
await writeFile(externalFile, "top secret\n", "utf-8");
|
||||
await symlink(externalFile, path.join(root, "secret-link.txt"));
|
||||
|
||||
await expect(
|
||||
readExplorerFile({
|
||||
root,
|
||||
relativePath: "secret-link.txt",
|
||||
}),
|
||||
).rejects.toThrow("Access outside of workspace is not allowed");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(outsideRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,11 +210,25 @@ async function resolveScopedPath({ root, relativePath = "." }: ScopedPathParams)
|
||||
const requestedPath = path.resolve(normalizedRoot, relativePath);
|
||||
const relative = path.relative(normalizedRoot, requestedPath);
|
||||
|
||||
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
|
||||
return requestedPath;
|
||||
if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) {
|
||||
throw new Error("Access outside of workspace is not allowed");
|
||||
}
|
||||
|
||||
throw new Error("Access outside of workspace is not allowed");
|
||||
const realRoot = await fs.realpath(normalizedRoot);
|
||||
|
||||
try {
|
||||
const realPath = await fs.realpath(requestedPath);
|
||||
const realRelative = path.relative(realRoot, realPath);
|
||||
if (realRelative !== "" && (realRelative.startsWith("..") || path.isAbsolute(realRelative))) {
|
||||
throw new Error("Access outside of workspace is not allowed");
|
||||
}
|
||||
return requestedPath;
|
||||
} catch (error) {
|
||||
if (isMissingEntryError(error)) {
|
||||
return requestedPath;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function buildEntryPayload({
|
||||
|
||||
@@ -71,11 +71,11 @@ const LoopRecordSchema = z.object({
|
||||
name: z.string().nullable(),
|
||||
prompt: z.string(),
|
||||
cwd: z.string(),
|
||||
provider: z.enum(["claude", "codex", "opencode"]),
|
||||
provider: z.string(),
|
||||
model: z.string().nullable(),
|
||||
workerProvider: z.enum(["claude", "codex", "opencode"]).nullable(),
|
||||
workerProvider: z.string().nullable(),
|
||||
workerModel: z.string().nullable(),
|
||||
verifierProvider: z.enum(["claude", "codex", "opencode"]).nullable(),
|
||||
verifierProvider: z.string().nullable(),
|
||||
verifierModel: z.string().nullable(),
|
||||
verifyPrompt: z.string().nullable(),
|
||||
verifyChecks: z.array(z.string()),
|
||||
|
||||
@@ -1,9 +1,44 @@
|
||||
import type pino from "pino";
|
||||
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import { isValidAgentProvider } from "./agent/provider-manifest.js";
|
||||
|
||||
type LoggerLike = {
|
||||
child(bindings: Record<string, unknown>): LoggerLike;
|
||||
error(...args: any[]): void;
|
||||
};
|
||||
|
||||
function getLogger(logger: LoggerLike): LoggerLike {
|
||||
return logger.child({ module: "persistence" });
|
||||
}
|
||||
|
||||
type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
|
||||
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
|
||||
|
||||
/**
|
||||
* Attach AgentStorage persistence to an AgentManager instance so every
|
||||
* agent_state snapshot is flushed to disk.
|
||||
*/
|
||||
export function attachAgentStoragePersistence(
|
||||
logger: LoggerLike,
|
||||
agentManager: AgentManagerStateSource,
|
||||
storage: AgentStoragePersistence,
|
||||
): () => void {
|
||||
const log = getLogger(logger);
|
||||
const unsubscribe = agentManager.subscribe((event) => {
|
||||
if (event.type !== "agent_state") {
|
||||
return;
|
||||
}
|
||||
void storage.applySnapshot(event.agent).catch((error) => {
|
||||
log.error({ err: error, agentId: event.agent.id }, "Failed to persist agent snapshot");
|
||||
});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSessionConfig> {
|
||||
return {
|
||||
cwd: record.cwd,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { readFile } from "fs/promises";
|
||||
import { exec } from "child_process";
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { promisify } from "util";
|
||||
import { join, resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -172,6 +172,7 @@ import {
|
||||
} from "./worktree-session.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
||||
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
|
||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
||||
@@ -2877,6 +2878,9 @@ export class Session {
|
||||
}
|
||||
|
||||
private assertSafeGitRef(ref: string, label: string): void {
|
||||
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) {
|
||||
throw new Error(`Invalid ${label}: ${ref}`);
|
||||
}
|
||||
assertWorktreeSafeGitRef(ref, label);
|
||||
}
|
||||
|
||||
@@ -3048,7 +3052,7 @@ export class Session {
|
||||
private async checkoutExistingBranch(cwd: string, branch: string): Promise<void> {
|
||||
this.assertSafeGitRef(branch, "branch");
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branch}`, { cwd });
|
||||
await execFileAsync("git", ["rev-parse", "--verify", branch], { cwd });
|
||||
} catch (error) {
|
||||
throw new Error(`Branch not found: ${branch}`);
|
||||
}
|
||||
@@ -3062,7 +3066,7 @@ export class Session {
|
||||
}
|
||||
|
||||
await this.ensureCleanWorkingTree(cwd);
|
||||
await execAsync(`git checkout ${branch}`, { cwd });
|
||||
await execFileAsync("git", ["checkout", branch], { cwd });
|
||||
}
|
||||
|
||||
private async createBranchFromBase(params: {
|
||||
@@ -3072,9 +3076,10 @@ export class Session {
|
||||
}): Promise<void> {
|
||||
const { cwd, baseBranch, newBranchName } = params;
|
||||
this.assertSafeGitRef(baseBranch, "base branch");
|
||||
this.assertSafeGitRef(newBranchName, "new branch");
|
||||
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${baseBranch}`, { cwd });
|
||||
await execFileAsync("git", ["rev-parse", "--verify", baseBranch], { cwd });
|
||||
} catch (error) {
|
||||
throw new Error(`Base branch not found: ${baseBranch}`);
|
||||
}
|
||||
@@ -3085,14 +3090,15 @@ export class Session {
|
||||
}
|
||||
|
||||
await this.ensureCleanWorkingTree(cwd);
|
||||
await execAsync(`git checkout -b ${newBranchName} ${baseBranch}`, {
|
||||
await execFileAsync("git", ["checkout", "-b", newBranchName, baseBranch], {
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
|
||||
private async doesLocalBranchExist(cwd: string, branch: string): Promise<boolean> {
|
||||
this.assertSafeGitRef(branch, "branch");
|
||||
try {
|
||||
await execAsync(`git show-ref --verify --quiet refs/heads/${branch}`, {
|
||||
await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
||||
cwd,
|
||||
});
|
||||
return true;
|
||||
@@ -3495,10 +3501,11 @@ export class Session {
|
||||
|
||||
try {
|
||||
const resolvedCwd = expandTilde(cwd);
|
||||
this.assertSafeGitRef(branchName, "branch");
|
||||
|
||||
// Try local branch first
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branchName}`, {
|
||||
await execFileAsync("git", ["rev-parse", "--verify", branchName], {
|
||||
cwd: resolvedCwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -3519,7 +3526,7 @@ export class Session {
|
||||
|
||||
// Try remote branch (origin/{branchName})
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify origin/${branchName}`, {
|
||||
await execFileAsync("git", ["rev-parse", "--verify", `origin/${branchName}`], {
|
||||
cwd: resolvedCwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
|
||||
const shouldRun = process.env.PASEO_VOICE_ROUNDTRIP_E2E === "1" && Boolean(openaiApiKey);
|
||||
const speechTest = shouldRun ? test : test.skip;
|
||||
|
||||
type VoiceRoundtripProvider = "claude" | "codex" | "opencode";
|
||||
type VoiceRoundtripProvider = string;
|
||||
|
||||
function getVoiceRoundtripConfig(provider: VoiceRoundtripProvider): {
|
||||
provider: VoiceRoundtripProvider;
|
||||
|
||||
@@ -609,7 +609,7 @@ program
|
||||
// Agent runner
|
||||
|
||||
interface AgentConfig {
|
||||
cli: "claude" | "codex";
|
||||
cli: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type TaskStatus = "draft" | "open" | "in_progress" | "done" | "failed";
|
||||
|
||||
export type AgentType = "claude" | "codex";
|
||||
export type AgentType = string;
|
||||
|
||||
export type ModelName = "haiku" | "sonnet" | "opus" | `gpt-${string}`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user