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:
Mohamed Boudra
2026-04-03 10:10:20 +07:00
65 changed files with 4510 additions and 970 deletions
@@ -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();
}
}
-22
View File
@@ -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
+1 -1
View File
@@ -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,
);
});
+6
View File
@@ -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({
+3 -3
View File
@@ -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,
+15 -8
View File
@@ -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;
+1 -1
View File
@@ -609,7 +609,7 @@ program
// Agent runner
interface AgentConfig {
cli: "claude" | "codex";
cli: string;
model?: string;
effort?: string;
}
+1 -1
View File
@@ -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}`;