diff --git a/nix/package.nix b/nix/package.nix
index ddf5dc6bf..4ccab141f 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
- npmDepsHash = "sha256-rbw+tXNKO7lNRNG6cdlaLyLLaEEdMzDD/Ob7/8noeKw=";
+ npmDepsHash = "sha256-pZGDc2JVlxKz6TvuM7ALD2RzXq/ANzvmJiV+bge/SL4=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
diff --git a/package-lock.json b/package-lock.json
index b07de6976..20b31864d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28264,6 +28264,40 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pi-acp": {
+ "version": "0.0.24",
+ "resolved": "https://registry.npmjs.org/pi-acp/-/pi-acp-0.0.24.tgz",
+ "integrity": "sha512-iFoQLH9nd3e2fpvemFV/0SUPeT9ecGHyhBiAe1JW7kHBWfmBQREeRn7O+hQyWA7heMVv+C9CObk4HDLVRy7JaQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@agentclientprotocol/sdk": "^0.12.0",
+ "zod": "^3.25.0"
+ },
+ "bin": {
+ "pi-acp": "dist/index.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/pi-acp/node_modules/@agentclientprotocol/sdk": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.12.0.tgz",
+ "integrity": "sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ },
+ "node_modules/pi-acp/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -35471,6 +35505,7 @@
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
+ "pi-acp": "^0.0.24",
"pino": "^10.2.0",
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
diff --git a/packages/app/src/components/icons/pi-icon.tsx b/packages/app/src/components/icons/pi-icon.tsx
new file mode 100644
index 000000000..6dc97fa29
--- /dev/null
+++ b/packages/app/src/components/icons/pi-icon.tsx
@@ -0,0 +1,19 @@
+import Svg, { Path } from "react-native-svg";
+
+interface PiIconProps {
+ size?: number;
+ color?: string;
+}
+
+export function PiIcon({ size = 16, color = "currentColor" }: PiIconProps) {
+ return (
+
+ );
+}
diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts
index a8bd2ec04..6c8a689c5 100644
--- a/packages/app/src/components/provider-icons.ts
+++ b/packages/app/src/components/provider-icons.ts
@@ -3,13 +3,14 @@ import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
+import { PiIcon } from "@/components/icons/pi-icon";
const PROVIDER_ICONS: Record = {
claude: ClaudeIcon as unknown as typeof Bot,
- "claude-acp": ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
copilot: CopilotIcon as unknown as typeof Bot,
opencode: OpenCodeIcon as unknown as typeof Bot,
+ pi: PiIcon as unknown as typeof Bot,
};
export function getProviderIcon(provider: string): typeof Bot {
diff --git a/packages/server/package.json b/packages/server/package.json
index 1ecfc3244..e430ef3ed 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -81,6 +81,7 @@
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
+ "pi-acp": "^0.0.24",
"pino": "^10.2.0",
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts
index 687a90bb8..f3a39dbe1 100644
--- a/packages/server/src/server/agent/provider-manifest.ts
+++ b/packages/server/src/server/agent/provider-manifest.ts
@@ -83,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",
@@ -125,6 +149,13 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModel: "gpt-5.1-codex-mini",
},
},
+ {
+ 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",
@@ -136,6 +167,13 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModeId: "build",
},
},
+ {
+ id: "pi",
+ label: "Pi",
+ description: "Minimal terminal-based coding agent with multi-provider LLM support",
+ defaultModeId: null,
+ modes: [],
+ },
];
export function getAgentProviderDefinition(provider: string): AgentProviderDefinition {
diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts
index 10c534d74..65bccc859 100644
--- a/packages/server/src/server/agent/provider-registry.ts
+++ b/packages/server/src/server/agent/provider-registry.ts
@@ -12,6 +12,8 @@ import type { Logger } from "pino";
import { ClaudeAgentClient } from "./providers/claude-agent.js";
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
+import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
+import { PiACPAgentClient } from "./providers/pi-acp-agent.js";
import {
AGENT_PROVIDER_DEFINITIONS,
@@ -45,8 +47,14 @@ const PROVIDER_CLIENT_FACTORIES: Record = {
runtimeSettings: runtimeSettings?.claude,
}),
codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings?.codex),
+ copilot: (logger, runtimeSettings) =>
+ new CopilotACPAgentClient({
+ logger,
+ runtimeSettings: runtimeSettings?.copilot,
+ }),
opencode: (logger, runtimeSettings) =>
new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
+ pi: (logger, runtimeSettings) => new PiACPAgentClient({ logger, runtimeSettings: runtimeSettings?.pi }),
};
function getProviderClientFactory(provider: string): ProviderClientFactory {
diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts
index 55fbac92b..4fc9365f4 100644
--- a/packages/server/src/server/agent/providers/acp-agent.test.ts
+++ b/packages/server/src/server/agent/providers/acp-agent.test.ts
@@ -1,11 +1,14 @@
import { describe, expect, test, vi } from "vitest";
import {
+ ACPAgentClient,
ACPAgentSession,
+ type SessionStateResponse,
deriveModelDefinitionsFromACP,
deriveModesFromACP,
mapACPUsage,
} from "./acp-agent.js";
+import { transformPiModels, transformPiSessionResponse } from "./pi-acp-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
function createSession(): ACPAgentSession {
@@ -98,6 +101,32 @@ describe("deriveModesFromACP", () => {
currentModeId: "acceptEdits",
});
});
+
+ test("returns an empty mode list when fallback modes are empty and config only exposes thought levels", () => {
+ const result = deriveModesFromACP(
+ [],
+ null,
+ [
+ {
+ id: "thought_level",
+ name: "Thinking",
+ category: "thought_level",
+ type: "select",
+ currentValue: "medium",
+ options: [
+ { value: "low", name: "Low" },
+ { value: "medium", name: "Medium" },
+ { value: "high", name: "High" },
+ ],
+ },
+ ],
+ );
+
+ expect(result).toEqual({
+ modes: [],
+ currentModeId: null,
+ });
+ });
});
describe("deriveModelDefinitionsFromACP", () => {
@@ -154,7 +183,349 @@ describe("deriveModelDefinitionsFromACP", () => {
});
});
+describe("ACPAgentClient modelTransformer", () => {
+ test("applies modelTransformer after deriving ACP models", async () => {
+ class TestACPAgentClient extends ACPAgentClient {
+ protected override async spawnProcess(): Promise {
+ return {
+ child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
+ connection: {
+ newSession: vi.fn().mockResolvedValue({
+ models: {
+ availableModels: [
+ {
+ modelId: "openrouter/openai/gpt-4.1-mini",
+ name: "openrouter/openai/gpt-4.1-mini",
+ description: null,
+ },
+ ],
+ currentModelId: "openrouter/openai/gpt-4.1-mini",
+ },
+ configOptions: [],
+ }),
+ },
+ initialize: { agentCapabilities: {} },
+ };
+ }
+
+ protected override async closeProbe(): Promise {}
+ }
+
+ const client = new TestACPAgentClient({
+ provider: "pi",
+ logger: createTestLogger(),
+ defaultCommand: ["pi-acp"],
+ modelTransformer: transformPiModels,
+ });
+
+ await expect(client.listModels()).resolves.toEqual([
+ {
+ provider: "pi",
+ id: "openrouter/openai/gpt-4.1-mini",
+ label: "gpt-4.1-mini",
+ description: "openrouter/openai/gpt-4.1-mini",
+ isDefault: true,
+ thinkingOptions: undefined,
+ defaultThinkingOptionId: undefined,
+ },
+ ]);
+ });
+});
+
+describe("ACPAgentClient sessionResponseTransformer", () => {
+ class TestACPAgentClient extends ACPAgentClient {
+ protected override async spawnProcess(): Promise {
+ const response: SessionStateResponse = {
+ sessionId: "session-1",
+ modes: {
+ availableModes: [
+ { id: "off", name: "Thinking: Off", description: "No extra reasoning" },
+ { id: "medium", name: "Thinking: Medium", description: "Balanced reasoning" },
+ { id: "high", name: "Thinking: High", description: "Deeper reasoning" },
+ ],
+ currentModeId: "medium",
+ },
+ models: {
+ availableModels: [
+ {
+ modelId: "openrouter/openai/gpt-4.1-mini",
+ name: "openrouter/openai/gpt-4.1-mini",
+ description: null,
+ },
+ ],
+ currentModelId: "openrouter/openai/gpt-4.1-mini",
+ },
+ configOptions: [],
+ };
+
+ return {
+ child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
+ connection: {
+ newSession: vi.fn().mockResolvedValue(response),
+ },
+ initialize: { agentCapabilities: {} },
+ };
+ }
+
+ protected override async closeProbe(): Promise {}
+ }
+
+ test("remaps Pi thinking modes into thinking options for list probes", async () => {
+ const client = new TestACPAgentClient({
+ provider: "pi",
+ logger: createTestLogger(),
+ defaultCommand: ["pi-acp"],
+ defaultModes: [],
+ modelTransformer: transformPiModels,
+ sessionResponseTransformer: transformPiSessionResponse,
+ });
+
+ await expect(client.listModes()).resolves.toEqual([]);
+ await expect(client.listModels()).resolves.toEqual([
+ {
+ provider: "pi",
+ id: "openrouter/openai/gpt-4.1-mini",
+ label: "gpt-4.1-mini",
+ description: "openrouter/openai/gpt-4.1-mini",
+ isDefault: true,
+ thinkingOptions: [
+ {
+ id: "off",
+ label: "Off",
+ description: "No extra reasoning",
+ isDefault: false,
+ metadata: undefined,
+ },
+ {
+ id: "medium",
+ label: "Medium",
+ description: "Balanced reasoning",
+ isDefault: true,
+ metadata: undefined,
+ },
+ {
+ id: "high",
+ label: "High",
+ description: "Deeper reasoning",
+ isDefault: false,
+ metadata: undefined,
+ },
+ ],
+ defaultThinkingOptionId: "medium",
+ },
+ ]);
+ });
+});
+
+describe("ACPAgentClient listModes", () => {
+ test("returns an empty array when no ACP modes are reported and fallback modes are empty", async () => {
+ class TestACPAgentClient extends ACPAgentClient {
+ protected override async spawnProcess(): Promise {
+ return {
+ child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
+ connection: {
+ newSession: vi.fn().mockResolvedValue({
+ modes: null,
+ configOptions: [
+ {
+ id: "thought_level",
+ name: "Thinking",
+ category: "thought_level",
+ type: "select",
+ currentValue: "medium",
+ options: [
+ { value: "low", name: "Low" },
+ { value: "medium", name: "Medium" },
+ { value: "high", name: "High" },
+ ],
+ },
+ ],
+ }),
+ },
+ initialize: { agentCapabilities: {} },
+ };
+ }
+
+ protected override async closeProbe(): Promise {}
+ }
+
+ const client = new TestACPAgentClient({
+ provider: "pi",
+ logger: createTestLogger(),
+ defaultCommand: ["pi-acp"],
+ defaultModes: [],
+ });
+
+ await expect(client.listModes()).resolves.toEqual([]);
+ });
+});
+
+describe("transformPiModels", () => {
+ test("keeps slash-free labels unchanged", () => {
+ expect(
+ transformPiModels([
+ {
+ provider: "pi",
+ id: "gpt-4.1-mini",
+ label: "GPT 4.1 Mini",
+ description: "Fast",
+ },
+ ]),
+ ).toEqual([
+ {
+ provider: "pi",
+ id: "gpt-4.1-mini",
+ label: "GPT 4.1 Mini",
+ description: "Fast",
+ },
+ ]);
+ });
+
+ test("uses the last path segment as label and preserves existing descriptions", () => {
+ expect(
+ transformPiModels([
+ {
+ provider: "pi",
+ id: "openrouter/openai/gpt-4.1-mini",
+ label: "openrouter/openai/gpt-4.1-mini",
+ description: undefined,
+ },
+ {
+ provider: "pi",
+ id: "anthropic/claude-sonnet-4",
+ label: "anthropic/claude-sonnet-4",
+ description: "Balanced",
+ },
+ ]),
+ ).toEqual([
+ {
+ provider: "pi",
+ id: "openrouter/openai/gpt-4.1-mini",
+ label: "gpt-4.1-mini",
+ description: "openrouter/openai/gpt-4.1-mini",
+ },
+ {
+ provider: "pi",
+ id: "anthropic/claude-sonnet-4",
+ label: "claude-sonnet-4",
+ description: "Balanced",
+ },
+ ]);
+ });
+});
+
+describe("ACPAgentSession slash commands", () => {
+ test("caches ACP available commands for listCommands", async () => {
+ const session = createSession();
+
+ expect(await session.listCommands()).toEqual([]);
+
+ (session as any).translateSessionUpdate({
+ sessionUpdate: "available_commands_update",
+ availableCommands: [
+ {
+ name: "research_codebase",
+ description: "Search the workspace for relevant files",
+ },
+ {
+ name: "create_plan",
+ description: "Draft a plan for the requested work",
+ },
+ ],
+ });
+
+ expect(await session.listCommands()).toEqual([
+ {
+ name: "research_codebase",
+ description: "Search the workspace for relevant files",
+ argumentHint: "",
+ },
+ {
+ name: "create_plan",
+ description: "Draft a plan for the requested work",
+ argumentHint: "",
+ },
+ ]);
+ });
+});
+
describe("ACPAgentSession", () => {
+ test("applies sessionResponseTransformer before deriving modes and thinking state", () => {
+ const session = new ACPAgentSession(
+ {
+ provider: "pi",
+ cwd: "/tmp/paseo-acp-test",
+ },
+ {
+ provider: "pi",
+ logger: createTestLogger(),
+ defaultCommand: ["pi-acp"],
+ defaultModes: [],
+ modelTransformer: transformPiModels,
+ sessionResponseTransformer: transformPiSessionResponse,
+ capabilities: {
+ supportsStreaming: true,
+ supportsSessionPersistence: true,
+ supportsDynamicModes: true,
+ supportsMcpServers: false,
+ supportsReasoningStream: true,
+ supportsToolInvocations: true,
+ },
+ },
+ );
+
+ (session as any).applySessionState({
+ sessionId: "session-1",
+ modes: {
+ availableModes: [
+ { id: "low", name: "Thinking: Low", description: "Faster" },
+ { id: "medium", name: "Thinking: Medium", description: "Balanced" },
+ ],
+ currentModeId: "medium",
+ },
+ models: {
+ availableModels: [
+ {
+ modelId: "openrouter/openai/gpt-4.1-mini",
+ name: "openrouter/openai/gpt-4.1-mini",
+ description: null,
+ },
+ ],
+ currentModelId: "openrouter/openai/gpt-4.1-mini",
+ },
+ configOptions: [],
+ } satisfies SessionStateResponse);
+
+ expect((session as any).availableModes).toEqual([]);
+ expect((session as any).thinkingOptionId).toBe("medium");
+ expect((session as any).availableModels).toEqual([
+ {
+ provider: "pi",
+ id: "openrouter/openai/gpt-4.1-mini",
+ label: "gpt-4.1-mini",
+ description: "openrouter/openai/gpt-4.1-mini",
+ isDefault: true,
+ thinkingOptions: [
+ {
+ id: "low",
+ label: "Low",
+ description: "Faster",
+ isDefault: false,
+ metadata: undefined,
+ },
+ {
+ id: "medium",
+ label: "Medium",
+ description: "Balanced",
+ isDefault: true,
+ metadata: undefined,
+ },
+ ],
+ defaultThinkingOptionId: "medium",
+ },
+ ]);
+ });
+
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 } }> = [];
diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts
index c288a9315..269d8ced2 100644
--- a/packages/server/src/server/agent/providers/acp-agent.ts
+++ b/packages/server/src/server/agent/providers/acp-agent.ts
@@ -71,9 +71,11 @@ import type {
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
+ AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
+ ListModesOptions,
ListModelsOptions,
ListPersistedAgentsOptions,
McpServerConfig,
@@ -111,6 +113,14 @@ type ACPAgentClientOptions = {
runtimeSettings?: ProviderRuntimeSettings;
defaultCommand: [string, ...string[]];
defaultModes?: AgentMode[];
+ modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
+ sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
+ toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
+ thinkingOptionWriter?: (
+ connection: ClientSideConnection,
+ sessionId: string,
+ thinkingOptionId: string,
+ ) => Promise;
capabilities?: AgentCapabilityFlags;
};
@@ -120,6 +130,14 @@ type ACPAgentSessionOptions = {
runtimeSettings?: ProviderRuntimeSettings;
defaultCommand: [string, ...string[]];
defaultModes: AgentMode[];
+ modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
+ sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
+ toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
+ thinkingOptionWriter?: (
+ connection: ClientSideConnection,
+ sessionId: string,
+ thinkingOptionId: string,
+ ) => Promise;
capabilities: AgentCapabilityFlags;
handle?: AgentPersistenceHandle;
launchEnv?: Record;
@@ -131,7 +149,7 @@ type SpawnedACPProcess = {
initialize: InitializeResponse;
};
-type ACPToolSnapshot = {
+export type ACPToolSnapshot = {
toolCallId: string;
title: string;
kind?: ToolKind | null;
@@ -154,7 +172,7 @@ type MessageAssemblyState = {
text: string;
};
-type SessionStateResponse = NewSessionResponse | LoadSessionResponse | ResumeSessionResponse;
+export type SessionStateResponse = NewSessionResponse | LoadSessionResponse | ResumeSessionResponse;
type TerminalExit = {
exitCode?: number | null;
@@ -271,6 +289,16 @@ export class ACPAgentClient implements AgentClient {
protected readonly runtimeSettings?: ProviderRuntimeSettings;
protected readonly defaultCommand: [string, ...string[]];
protected readonly defaultModes: AgentMode[];
+ private readonly modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
+ private readonly sessionResponseTransformer?: (
+ response: SessionStateResponse,
+ ) => SessionStateResponse;
+ private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
+ private readonly thinkingOptionWriter?: (
+ connection: ClientSideConnection,
+ sessionId: string,
+ thinkingOptionId: string,
+ ) => Promise;
constructor(options: ACPAgentClientOptions) {
this.provider = options.provider;
@@ -279,6 +307,10 @@ export class ACPAgentClient implements AgentClient {
this.runtimeSettings = options.runtimeSettings;
this.defaultCommand = options.defaultCommand;
this.defaultModes = options.defaultModes ?? [];
+ this.modelTransformer = options.modelTransformer;
+ this.sessionResponseTransformer = options.sessionResponseTransformer;
+ this.toolSnapshotTransformer = options.toolSnapshotTransformer;
+ this.thinkingOptionWriter = options.thinkingOptionWriter;
}
async createSession(
@@ -294,6 +326,10 @@ export class ACPAgentClient implements AgentClient {
runtimeSettings: this.runtimeSettings,
defaultCommand: this.defaultCommand,
defaultModes: this.defaultModes,
+ modelTransformer: this.modelTransformer,
+ sessionResponseTransformer: this.sessionResponseTransformer,
+ toolSnapshotTransformer: this.toolSnapshotTransformer,
+ thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities,
launchEnv: launchContext?.env,
},
@@ -329,6 +365,10 @@ export class ACPAgentClient implements AgentClient {
runtimeSettings: this.runtimeSettings,
defaultCommand: this.defaultCommand,
defaultModes: this.defaultModes,
+ modelTransformer: this.modelTransformer,
+ sessionResponseTransformer: this.sessionResponseTransformer,
+ toolSnapshotTransformer: this.toolSnapshotTransformer,
+ thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities,
handle,
launchEnv: launchContext?.env,
@@ -345,7 +385,33 @@ export class ACPAgentClient implements AgentClient {
cwd,
mcpServers: [],
});
- return deriveModelDefinitionsFromACP(this.provider, response.models, response.configOptions);
+ const transformed = this.transformSessionResponse(response);
+ const models = deriveModelDefinitionsFromACP(
+ this.provider,
+ transformed.models,
+ transformed.configOptions,
+ );
+ return this.modelTransformer ? this.modelTransformer(models) : models;
+ } finally {
+ await this.closeProbe(probe);
+ }
+ }
+
+ async listModes(options?: ListModesOptions): Promise {
+ const cwd = options?.cwd ?? process.cwd();
+ const probe = await this.spawnProcess(undefined);
+ try {
+ const response = await probe.connection.newSession({
+ cwd,
+ mcpServers: [],
+ });
+ const transformed = this.transformSessionResponse(response);
+ const modeInfo = deriveModesFromACP(
+ this.defaultModes,
+ transformed.modes,
+ transformed.configOptions,
+ );
+ return modeInfo.modes;
} finally {
await this.closeProbe(probe);
}
@@ -502,6 +568,10 @@ export class ACPAgentClient implements AgentClient {
throw new Error(`Expected ${this.provider} config, received ${config.provider}`);
}
}
+
+ protected transformSessionResponse(response: SessionStateResponse): SessionStateResponse {
+ return this.sessionResponseTransformer ? this.sessionResponseTransformer(response) : response;
+ }
}
export class ACPAgentSession implements AgentSession, ACPClient {
@@ -512,6 +582,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly defaultCommand: [string, ...string[]];
private readonly defaultModes: AgentMode[];
+ protected readonly modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
+ private readonly sessionResponseTransformer?: (
+ response: SessionStateResponse,
+ ) => SessionStateResponse;
+ private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
+ private readonly thinkingOptionWriter?: (
+ connection: ClientSideConnection,
+ sessionId: string,
+ thinkingOptionId: string,
+ ) => Promise;
private readonly launchEnv?: Record;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly pendingPermissions = new Map();
@@ -528,11 +608,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private sessionId: string | null = null;
private currentMode: string | null = null;
private availableModes: AgentMode[];
+ private availableModels: AgentModelDefinition[] = [];
private currentModel: string | null = null;
private thinkingOptionId: string | null = null;
private currentTitle: string | null = null;
private lastActivityAt: string | null = null;
private configOptions: SessionConfigOption[] = [];
+ private cachedCommands: AgentSlashCommand[] = [];
private currentTurnUsage: AgentUsage | undefined;
private activeForegroundTurnId: string | null = null;
private closed = false;
@@ -549,6 +631,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.runtimeSettings = options.runtimeSettings;
this.defaultCommand = options.defaultCommand;
this.defaultModes = options.defaultModes;
+ this.modelTransformer = options.modelTransformer;
+ this.sessionResponseTransformer = options.sessionResponseTransformer;
+ this.toolSnapshotTransformer = options.toolSnapshotTransformer;
+ this.thinkingOptionWriter = options.thinkingOptionWriter;
this.availableModes = options.defaultModes;
this.launchEnv = options.launchEnv;
this.initialHandle = options.handle;
@@ -788,6 +874,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return this.currentMode;
}
+ async listCommands(): Promise {
+ return this.cachedCommands;
+ }
+
async setMode(modeId: string): Promise {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
@@ -825,7 +915,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return;
}
- if (this.agentCapabilities?.sessionCapabilities && "unstable_setSessionModel" in this.connection) {
+ const modelExists = this.availableModels.some((model) => model.id === modelId);
+ if (!modelExists && this.availableModels.length > 0) {
+ throw new Error(`Unknown ${this.provider} model '${modelId}'`);
+ }
+
+ if ("unstable_setSessionModel" in this.connection) {
try {
await this.connection.unstable_setSessionModel({
sessionId: this.sessionId,
@@ -859,6 +954,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return;
}
+ if (this.thinkingOptionWriter) {
+ await this.thinkingOptionWriter(this.connection, this.sessionId, thinkingOptionId);
+ this.thinkingOptionId = thinkingOptionId;
+ return;
+ }
+
const option = this.getSelectConfigOption("thought_level");
if (!option) {
throw new Error(`${this.provider} does not expose ACP thought-level selection`);
@@ -984,11 +1085,17 @@ export class ACPAgentSession implements AgentSession, ACPClient {
params: RequestPermissionRequest,
): Promise {
const requestId = randomUUID();
+ let toolSnapshot =
+ this.toolCalls.get(params.toolCall.toolCallId) ??
+ mergeToolSnapshot(params.toolCall.toolCallId, params.toolCall);
+ if (this.toolSnapshotTransformer) {
+ toolSnapshot = this.toolSnapshotTransformer(toolSnapshot);
+ }
const request = mapPermissionRequest(
this.provider,
requestId,
params,
- this.toolCalls.get(params.toolCall.toolCallId) ?? mergeToolSnapshot(params.toolCall.toolCallId, params.toolCall),
+ toolSnapshot,
);
const promise = new Promise((resolve, reject) => {
@@ -1182,13 +1289,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private applySessionState(response: SessionStateResponse): void {
- this.configOptions = response.configOptions ?? [];
+ const transformed = this.sessionResponseTransformer
+ ? this.sessionResponseTransformer(response)
+ : response;
- const modeInfo = deriveModesFromACP(this.defaultModes, response.modes, this.configOptions);
- this.availableModes = modeInfo.modes.length > 0 ? modeInfo.modes : this.defaultModes;
+ this.configOptions = transformed.configOptions ?? [];
+
+ const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions);
+ this.availableModes = modeInfo.modes;
+ this.availableModels = this.deriveAvailableModels(transformed.models);
this.currentMode = modeInfo.currentModeId ?? this.currentMode;
- this.currentModel = response.models?.currentModelId ?? deriveCurrentConfigValue(this.configOptions, "model");
+ this.currentModel =
+ transformed.models?.currentModelId ?? deriveCurrentConfigValue(this.configOptions, "model");
this.thinkingOptionId =
deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId;
}
@@ -1231,13 +1344,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return item ? [this.wrapTimeline(item)] : [];
}
case "tool_call": {
- const snapshot = mergeToolSnapshot(update.toolCallId, update);
+ let snapshot = mergeToolSnapshot(update.toolCallId, update);
+ if (this.toolSnapshotTransformer) {
+ snapshot = this.toolSnapshotTransformer(snapshot);
+ }
this.toolCalls.set(update.toolCallId, snapshot);
return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))];
}
case "tool_call_update": {
const previous = this.toolCalls.get(update.toolCallId);
- const snapshot = mergeToolSnapshot(update.toolCallId, update, previous);
+ let snapshot = mergeToolSnapshot(update.toolCallId, update, previous);
+ if (this.toolSnapshotTransformer) {
+ snapshot = this.toolSnapshotTransformer(snapshot);
+ }
this.toolCalls.set(update.toolCallId, snapshot);
return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))];
}
@@ -1256,6 +1375,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.handleUsageUpdate(update);
return [];
case "available_commands_update":
+ this.cachedCommands = update.availableCommands.map((command) => ({
+ name: command.name,
+ description: command.description,
+ argumentHint: "",
+ }));
return [];
default:
return [];
@@ -1298,15 +1422,21 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private handleConfigOptionUpdate(update: ConfigOptionUpdate): void {
this.configOptions = update.configOptions;
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
- if (modeInfo.modes.length > 0) {
- this.availableModes = modeInfo.modes;
- }
+ this.availableModes = modeInfo.modes;
+ this.availableModels = this.deriveAvailableModels(null);
this.currentMode = modeInfo.currentModeId ?? this.currentMode;
this.currentModel = deriveCurrentConfigValue(this.configOptions, "model") ?? this.currentModel;
this.thinkingOptionId =
deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId;
}
+ private deriveAvailableModels(
+ models: SessionModelState | null | undefined,
+ ): AgentModelDefinition[] {
+ const availableModels = deriveModelDefinitionsFromACP(this.provider, models, this.configOptions);
+ return this.modelTransformer ? this.modelTransformer(availableModels) : availableModels;
+ }
+
private handleSessionInfoUpdate(update: SessionInfoUpdate): void {
if ("title" in update) {
this.currentTitle = update.title ?? null;
diff --git a/packages/server/src/server/agent/providers/claude-acp-agent.ts b/packages/server/src/server/agent/providers/claude-acp-agent.ts
deleted file mode 100644
index 944f06fac..000000000
--- a/packages/server/src/server/agent/providers/claude-acp-agent.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-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 {
- if (!(await super.isAvailable())) {
- return false;
- }
- return Boolean(process.env["CLAUDE_CODE_OAUTH_TOKEN"] || process.env["ANTHROPIC_API_KEY"]);
- }
-}
diff --git a/packages/server/src/server/agent/providers/pi-acp-agent.ts b/packages/server/src/server/agent/providers/pi-acp-agent.ts
new file mode 100644
index 000000000..dc4637330
--- /dev/null
+++ b/packages/server/src/server/agent/providers/pi-acp-agent.ts
@@ -0,0 +1,147 @@
+import { createRequire } from "node:module";
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { homedir } from "node:os";
+import type { Logger } from "pino";
+import type {
+ ClientSideConnection,
+ SessionConfigOption,
+ ToolKind,
+} from "@agentclientprotocol/sdk";
+
+import type { AgentCapabilityFlags, AgentModelDefinition } from "../agent-sdk-types.js";
+import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
+import { isCommandAvailable } from "../provider-launch-config.js";
+import {
+ ACPAgentClient,
+ type ACPToolSnapshot,
+ type SessionStateResponse,
+} from "./acp-agent.js";
+
+const require = createRequire(import.meta.url);
+const resolvedPiAcpPath = require.resolve("pi-acp");
+
+const PI_CAPABILITIES: AgentCapabilityFlags = {
+ supportsStreaming: true,
+ supportsSessionPersistence: true,
+ supportsDynamicModes: true,
+ supportsMcpServers: false,
+ supportsReasoningStream: true,
+ supportsToolInvocations: true,
+};
+
+// Pi tool kind corrections: pi-acp maps 'bash' to kind 'other' instead of 'execute'.
+const PI_TOOL_KIND_MAP: Record = {
+ bash: "execute",
+};
+
+type PiACPAgentClientOptions = {
+ logger: Logger;
+ runtimeSettings?: ProviderRuntimeSettings;
+};
+
+function normalizePiModelLabel(label: string): string {
+ return label.trim().replace(/[_\s]+/g, " ");
+}
+
+export function transformPiModels(models: AgentModelDefinition[]): AgentModelDefinition[] {
+ return models.map((model) => {
+ if (!model.label.includes("/")) {
+ return model;
+ }
+
+ const segments = model.label.split("/").filter((segment) => segment.length > 0);
+ const rawLabel = segments.at(-1);
+ if (!rawLabel) {
+ return model;
+ }
+
+ return {
+ ...model,
+ label: normalizePiModelLabel(rawLabel),
+ description: model.description ?? model.label,
+ };
+ });
+}
+
+function transformPiToolSnapshot(snapshot: ACPToolSnapshot): ACPToolSnapshot {
+ if (snapshot.kind === "other" && snapshot.title && PI_TOOL_KIND_MAP[snapshot.title]) {
+ return { ...snapshot, kind: PI_TOOL_KIND_MAP[snapshot.title] };
+ }
+ return snapshot;
+}
+
+/**
+ * Pi-acp reports thinking levels (off/minimal/low/medium/high/xhigh) as ACP
+ * session modes rather than as configOptions with category 'thought_level'.
+ * This transformer remaps them so the base ACP class treats them as thinking
+ * options instead of permission modes.
+ */
+export function transformPiSessionResponse(
+ response: SessionStateResponse,
+): SessionStateResponse {
+ const modes = response.modes;
+ if (!modes?.availableModes?.length) {
+ return response;
+ }
+
+ const thinkingOption: SessionConfigOption = {
+ id: "thought_level",
+ name: "Thinking",
+ type: "select",
+ category: "thought_level",
+ currentValue: modes.currentModeId ?? "medium",
+ options: modes.availableModes.map((mode) => ({
+ value: mode.id,
+ name: mode.name.replace(/^Thinking:\s*/i, ""),
+ description: mode.description,
+ })),
+ };
+
+ return {
+ ...response,
+ modes: undefined,
+ configOptions: [
+ thinkingOption,
+ ...(response.configOptions ?? []),
+ ],
+ };
+}
+
+export class PiACPAgentClient extends ACPAgentClient {
+ constructor(options: PiACPAgentClientOptions) {
+ super({
+ provider: "pi",
+ logger: options.logger,
+ runtimeSettings: options.runtimeSettings,
+ defaultCommand: [process.execPath, resolvedPiAcpPath],
+ defaultModes: [],
+ modelTransformer: transformPiModels,
+ sessionResponseTransformer: transformPiSessionResponse,
+ toolSnapshotTransformer: transformPiToolSnapshot,
+ thinkingOptionWriter: async (
+ connection: ClientSideConnection,
+ sessionId: string,
+ thinkingOptionId: string,
+ ) => {
+ await connection.setSessionMode({ sessionId, modeId: thinkingOptionId });
+ },
+ capabilities: PI_CAPABILITIES,
+ });
+ }
+
+ override async isAvailable(): Promise {
+ if (!existsSync(resolvedPiAcpPath)) {
+ return false;
+ }
+ if (!isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi")) {
+ return false;
+ }
+ return (
+ Boolean(process.env.OPENAI_API_KEY) ||
+ Boolean(process.env.ANTHROPIC_API_KEY) ||
+ Boolean(process.env.OPENROUTER_API_KEY) ||
+ existsSync(join(homedir(), ".pi", "agent", "auth.json"))
+ );
+ }
+}
diff --git a/packages/server/src/server/daemon-e2e/agent-configs.ts b/packages/server/src/server/daemon-e2e/agent-configs.ts
index 82bfd805a..4e7265b4f 100644
--- a/packages/server/src/server/daemon-e2e/agent-configs.ts
+++ b/packages/server/src/server/daemon-e2e/agent-configs.ts
@@ -15,9 +15,9 @@ dotenv.config({ path: resolve(serverRoot, ".env.test"), override: true });
export interface AgentTestConfig {
provider: string;
- model: string;
+ model?: string;
thinkingOptionId?: string;
- modes: {
+ modes?: {
full: string; // No permissions required
ask: string; // Requires permission approval
};
@@ -32,14 +32,6 @@ 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",
@@ -65,6 +57,10 @@ export const agentConfigs = {
ask: "default",
},
},
+ pi: {
+ provider: "pi",
+ thinkingOptionId: "medium",
+ },
} as const satisfies Record;
export type AgentProvider = keyof typeof agentConfigs;
@@ -77,9 +73,9 @@ export function getFullAccessConfig(provider: AgentProvider) {
const thinkingOptionId = "thinkingOptionId" in config ? config.thinkingOptionId : undefined;
return {
provider: config.provider,
- model: config.model,
+ ...(config.model ? { model: config.model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
- modeId: config.modes.full,
+ ...(config.modes?.full ? { modeId: config.modes.full } : {}),
};
}
@@ -91,9 +87,9 @@ export function getAskModeConfig(provider: AgentProvider) {
const thinkingOptionId = "thinkingOptionId" in config ? config.thinkingOptionId : undefined;
return {
provider: config.provider,
- model: config.model,
+ ...(config.model ? { model: config.model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
- modeId: config.modes.ask,
+ ...(config.modes?.ask ? { modeId: config.modes.ask } : {}),
};
}
@@ -112,8 +108,6 @@ 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") &&
@@ -123,6 +117,14 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
return isCommandAvailable("copilot");
case "opencode":
return isCommandAvailable("opencode");
+ case "pi":
+ return (
+ isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi") &&
+ (Boolean(process.env.OPENAI_API_KEY) ||
+ Boolean(process.env.ANTHROPIC_API_KEY) ||
+ Boolean(process.env.OPENROUTER_API_KEY) ||
+ existsSync(join(homedir(), ".pi", "agent", "auth.json")))
+ );
}
}
@@ -131,8 +133,8 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
*/
export const allProviders: AgentProvider[] = [
"claude",
- "claude-acp",
"codex",
"copilot",
"opencode",
+ "pi",
];
diff --git a/packages/server/src/server/daemon-e2e/claude-acp.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-acp.real.e2e.test.ts
deleted file mode 100644
index 49f158613..000000000
--- a/packages/server/src/server/daemon-e2e/claude-acp.real.e2e.test.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-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,
- );
-});
diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts
new file mode 100644
index 000000000..a0f5ac8b1
--- /dev/null
+++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, test } from "vitest";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import pino from "pino";
+
+import { PiACPAgentClient } from "../agent/providers/pi-acp-agent.js";
+import { DaemonClient } from "../test-utils/daemon-client.js";
+import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
+import { isProviderAvailable } from "./agent-configs.js";
+
+process.env.PASEO_SUPERVISED = "0";
+
+function tmpCwd(): string {
+ return mkdtempSync(path.join(tmpdir(), "daemon-real-pi-"));
+}
+
+describe("daemon E2E (real pi)", () => {
+ test.runIf(isProviderAvailable("pi"))(
+ "smoke test with thinking option configured separately from modes",
+ async () => {
+ const logger = pino({ level: "silent" });
+ const cwd = tmpCwd();
+ const daemon = await createTestPaseoDaemon({
+ agentClients: { pi: new PiACPAgentClient({ logger }) },
+ logger,
+ });
+ const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
+
+ try {
+ await client.connect();
+ await client.fetchAgents({
+ subscribe: { subscriptionId: "pi-real-smoke" },
+ });
+
+ const agent = await client.createAgent({
+ cwd,
+ title: "pi-real-smoke",
+ provider: "pi",
+ thinkingOptionId: "medium",
+ });
+
+ 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("pi");
+ 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.replace(/\s+/g, "")).toContain("PINEAPPLE");
+ } finally {
+ await client.close().catch(() => undefined);
+ await daemon.close().catch(() => undefined);
+ rmSync(cwd, { recursive: true, force: true });
+ }
+ },
+ 420_000,
+ );
+});