feat(server): add Pi agent provider and re-enable Copilot (#191)

* fix(app): reorder settings sections for better grouping

* feat(app): add setup hint and paseo.sh link on mobile welcome screen

New app store users land on the welcome screen with no context. Show a
brief explanation that the desktop app or server is needed, plus a link
to paseo.sh — only on mobile (iOS/Android), hidden on web/desktop.

* docs(release): add pre-release sanity check and clarify changelog scope

Add a Codex 5.4 review step before cutting releases to catch breaking
changes and backward-compatibility issues (mobile apps lag behind
desktop/daemon updates). Clarify that the changelog always covers the
delta from the previous stable release, not from the last RC.

* feat(server): add Pi agent provider and re-enable Copilot

Add Pi (pi.dev) as a new ACP-based agent provider with bundled pi-acp
adapter. Pi-acp reports thinking levels as ACP modes and bash tool calls
with kind "other", so the Pi provider uses generic ACP extension hooks
to remap these to the correct Paseo concepts:

- sessionResponseTransformer: remaps ACP modes → configOptions[thought_level]
- thinkingOptionWriter: writes thinking via setSessionMode instead of
  set_config_option (which pi-acp doesn't support)
- toolSnapshotTransformer: remaps bash tool kind from "other" to "execute"
- modelTransformer: cleans slash-prefixed model labels

Also re-enables the Copilot provider (disabled since 44da0c67) and
removes the claude-acp provider.

* fix: update lockfile signatures and Nix hash

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Mohamed Boudra
2026-04-04 15:42:24 +08:00
committed by GitHub
co-authored by github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent 9154f8fc4d
commit 65573499af
14 changed files with 861 additions and 251 deletions
+1 -1
View File
@@ -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).
+35
View File
@@ -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",
@@ -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 (
<Svg width={size} height={size} viewBox="0 0 800 800" fill={color}>
<Path
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
fill={color}
fillRule="evenodd"
/>
<Path d="M517.36 400 H634.72 V634.72 H517.36 Z" fill={color} />
</Svg>
);
}
@@ -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<string, typeof Bot> = {
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 {
+1
View File
@@ -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",
@@ -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 {
@@ -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<string, ProviderClientFactory> = {
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 {
@@ -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<any> {
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<void> {}
}
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<any> {
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<void> {}
}
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<any> {
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<void> {}
}
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 } }> = [];
@@ -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<void>;
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<void>;
capabilities: AgentCapabilityFlags;
handle?: AgentPersistenceHandle;
launchEnv?: Record<string, string>;
@@ -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<void>;
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<AgentMode[]> {
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<void>;
private readonly launchEnv?: Record<string, string>;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly pendingPermissions = new Map<string, PendingPermission>();
@@ -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<AgentSlashCommand[]> {
return this.cachedCommands;
}
async setMode(modeId: string): Promise<void> {
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<RequestPermissionResponse> {
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<RequestPermissionResponse>((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;
@@ -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<boolean> {
if (!(await super.isAvailable())) {
return false;
}
return Boolean(process.env["CLAUDE_CODE_OAUTH_TOKEN"] || process.env["ANTHROPIC_API_KEY"]);
}
}
@@ -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<string, ToolKind> = {
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<boolean> {
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"))
);
}
}
@@ -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<string, AgentTestConfig>;
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",
];
@@ -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,
);
});
@@ -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,
);
});