Run Pi agents through the installed Pi CLI (#1097)

* Run Pi through its RPC interface

Replace the embedded Pi runtime packages with a process-backed runtime that starts pi --mode rpc, keeps the runtime port thin, and leaves provider behavior in the Paseo session and mapper modules.

Add focused adapter and mapper tests plus real Pi E2E coverage for the installed binary path.

* Keep Pi provider code in one module

* Add Pi session imports

* Enable Paseo MCP tools in Pi agents

Route injected MCP servers through Pi's MCP adapter and make structured MCP results visible to model-only clients.

Normalize Pi MCP proxy calls so Paseo timelines show the underlying tool names.

* Fix Pi import symlink tests on Windows

Create directory links explicitly in symlink cwd fixtures so Windows server tests exercise the intended path-equivalence behavior.

* Use native realpath for import cwd matching

Resolve import cwd matchers with native realpath so Windows symlink and junction sessions compare against the same path shape used by persisted provider data.

* Fallback realpath for Windows import matching

Use plain realpath when native realpath cannot resolve a cwd so symlink-equivalent persisted sessions still match on Windows runners.

* Add daemon system prompt support for Pi

* Fix Windows realpath import matching

* Remove Pi RPC migration plan doc
This commit is contained in:
Mohamed Boudra
2026-05-19 23:56:07 +08:00
committed by GitHub
parent 0e1c590a3b
commit 4cf985c254
36 changed files with 4319 additions and 3784 deletions
+8 -2
View File
@@ -14,9 +14,15 @@ The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `Gen
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Pi direct embeds Pi's SDK through the `@earendil-works/pi-*` packages. Keep those dependencies in sync with the Pi package-manager behavior expected by current user installs: Pi 0.75+ loads user-scoped npm packages from `~/.pi/agent/npm/`, while older `@mariozechner/pi-coding-agent` releases looked in npm's global package root and can miss or load stale extensions.
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
+56 -1527
View File
File diff suppressed because it is too large Load Diff
+39 -2
View File
@@ -1,5 +1,16 @@
import { describe, expect, it } from "vitest";
import { resolveImportCwd } from "./import.js";
import { describe, expect, it, vi } from "vitest";
import { resolveImportCwd, runImportCommand } from "./import.js";
const importAgent = vi.fn();
const close = vi.fn();
vi.mock("../../utils/client.js", () => ({
connectToDaemon: vi.fn(async () => ({
importAgent,
close,
})),
getDaemonHost: vi.fn(() => "ws://127.0.0.1:6767"),
}));
describe("resolveImportCwd", () => {
it("uses the invoking process cwd when --cwd is omitted", () => {
@@ -19,4 +30,30 @@ describe("resolveImportCwd", () => {
}),
);
});
it("accepts pi as an import provider", async () => {
importAgent.mockResolvedValueOnce({
id: "agent-1",
status: "idle",
provider: "pi",
cwd: "/tmp/project",
title: "Imported Pi session",
});
const result = await runImportCommand(
"pi-session-1",
{
provider: "pi",
cwd: "/tmp/project",
},
{} as never,
);
expect(importAgent).toHaveBeenCalledWith({
provider: "pi",
sessionId: "pi-session-1",
cwd: "/tmp/project",
});
expect(result.data.provider).toBe("pi");
});
});
+5 -3
View File
@@ -5,13 +5,15 @@ import type { CommandError, CommandOptions, SingleResult } from "../../output/in
import { agentRunSchema, type AgentRunResult } from "./run.js";
import type { AgentSnapshotPayload } from "@getpaseo/server";
const IMPORT_PROVIDERS = new Set(["claude", "codex", "opencode", "acp"]);
const IMPORT_PROVIDER_LIST = ["claude", "codex", "opencode", "pi", "acp"] as const;
const IMPORT_PROVIDERS = new Set<string>(IMPORT_PROVIDER_LIST);
const IMPORT_PROVIDER_HELP = IMPORT_PROVIDER_LIST.join(", ");
export function addImportOptions(cmd: Command): Command {
return cmd
.description("Import an existing provider session as a Paseo agent")
.argument("<id>", "Provider session/thread ID to import")
.requiredOption("--provider <provider>", "Agent provider: claude, codex, opencode, or acp")
.requiredOption("--provider <provider>", `Agent provider: ${IMPORT_PROVIDER_HELP}`)
.option("--cwd <path>", "Working directory for providers that require it")
.option(
"--label <key=value>",
@@ -54,7 +56,7 @@ function parseImportProvider(provider: string | undefined): string {
throw {
code: "INVALID_PROVIDER",
message: `Unsupported provider: ${normalizedProvider}`,
details: "Supported providers: claude, codex, opencode, acp",
details: `Supported providers: ${IMPORT_PROVIDER_HELP}`,
} satisfies CommandError;
}
-3
View File
@@ -63,9 +63,6 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@earendil-works/pi-agent-core": "^0.75.3",
"@earendil-works/pi-ai": "^0.75.3",
"@earendil-works/pi-coding-agent": "^0.75.3",
"@getpaseo/highlight": "0.1.78",
"@getpaseo/relay": "0.1.78",
"@isaacs/ttlcache": "^2.1.4",
@@ -425,7 +425,7 @@ test("createAgent injects daemon append system prompt at runtime only", async ()
expect(record?.config).not.toHaveProperty("daemonAppendSystemPrompt");
});
test("daemon append system prompt is not injected into Pi", async () => {
test("daemon append system prompt is injected into Pi configs", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -449,7 +449,7 @@ test("daemon append system prompt is not injected into Pi", async () => {
systemPrompt: "Agent instructions.",
});
expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBeUndefined();
expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions.");
});
test("setAgentMode persists the selected mode across session reload", async () => {
@@ -1073,6 +1073,7 @@ export class AgentManager {
}
const { archivedAt } = await this.markRecordArchived(stored);
agent.updatedAt = new Date(archivedAt);
await this.closeAgent(agentId);
await this.cascadeArchiveChildren(agentId);
@@ -3456,23 +3457,16 @@ export class AgentManager {
}
private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig {
if (config.provider === "pi") {
const next = { ...config };
delete next.daemonAppendSystemPrompt;
return next;
}
const daemonAppendSystemPrompt = this.appendSystemPrompt.trim();
const next = { ...config };
delete next.daemonAppendSystemPrompt;
const trimmed = this.appendSystemPrompt.trim();
if (!trimmed) {
const next = { ...config };
delete next.daemonAppendSystemPrompt;
return next;
}
return {
...config,
daemonAppendSystemPrompt: trimmed,
};
return daemonAppendSystemPrompt
? {
...next,
daemonAppendSystemPrompt,
}
: next;
}
private buildLaunchContext(agentId: string): AgentLaunchContext {
@@ -1,4 +1,7 @@
import { beforeEach, expect, test, vi } from "vitest";
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type { FetchRecentProviderSessionsRequestMessage } from "../../shared/messages.js";
@@ -10,6 +13,8 @@ import {
normalizeImportAgentRequest,
} from "./import-sessions.js";
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
const TEST_CAPABILITIES = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -275,6 +280,39 @@ test("listImportableProviderSessions filters out metadata generation sessions",
expect(result.filteredAlreadyImportedCount).toBe(0);
});
test("listImportableProviderSessions keeps realpath-equivalent cwd matches", async () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-import-cwd-"));
const realCwd = path.join(root, "real-project");
const linkedCwd = path.join(root, "linked-project");
mkdirSync(realCwd, { recursive: true });
symlinkSync(realCwd, linkedCwd, directorySymlinkType);
const persistedCwd = realpathSync(linkedCwd);
const result = await listImportableProviderSessions({
request: makeRequest({ cwd: linkedCwd, providers: ["pi"] }),
agentManager: {
listAgents: () => [],
listImportablePersistedAgents: async () => [
makeDescriptor({
provider: "pi",
sessionId: "pi-session",
nativeHandle: "pi-handle",
cwd: persistedCwd,
title: "Pi session",
lastActivityAt: "2026-04-30T12:00:00.000Z",
firstPrompt: "remember this",
}),
],
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
providerRegistry: { pi: { label: "Pi" } },
});
expect(result.entries.map((entry) => entry.providerHandleId)).toEqual(["pi-handle"]);
});
test("listImportableProviderSessions rejects invalid since values", async () => {
await expect(
listImportableProviderSessions({
@@ -20,7 +20,7 @@ import type {
RecentProviderSessionDescriptorPayload,
} from "../../shared/messages.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import { createPathEquivalenceMatcher } from "../../utils/path.js";
import { createRealpathAwarePathMatcher } from "../../utils/path.js";
type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>;
@@ -112,7 +112,7 @@ export async function listImportableProviderSessions(
});
let filteredAlreadyImportedCount = 0;
const candidates: PersistedAgentDescriptor[] = [];
const matchesRequestCwd = request.cwd ? createPathEquivalenceMatcher(request.cwd) : null;
const matchesRequestCwd = request.cwd ? createRealpathAwarePathMatcher(request.cwd) : null;
for (const descriptor of descriptors) {
if (matchesRequestCwd && !matchesRequestCwd(descriptor.cwd)) {
continue;
@@ -1987,6 +1987,7 @@ describe("provider listing MCP tool", () => {
});
const tool = registeredTool(server, "list_providers");
const response = await tool.handler({});
const modelVisibleText = String(response.content[0]?.text);
expect(response.structuredContent).toEqual({
providers: [
@@ -2008,6 +2009,9 @@ describe("provider listing MCP tool", () => {
},
],
});
expect(modelVisibleText).toContain("providers_count=2");
expect(modelVisibleText).toContain("providers_ids=claude,zai");
expect(modelVisibleText).toContain('"providers"');
});
it("returns disabled providers with metadata without checking availability", async () => {
+90 -34
View File
@@ -3,7 +3,11 @@ import { z } from "zod";
import { ensureValidJson } from "../json-utils.js";
import type { Logger } from "pino";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
import type {
CallToolResult,
ServerNotification,
ServerRequest,
} from "@modelcontextprotocol/sdk/types.js";
import type { AgentProvider } from "./agent-sdk-types.js";
import type { AgentManager, WaitForAgentResult } from "./agent-manager.js";
@@ -154,6 +158,54 @@ function mapModeAcrossProviders(
return sourceMode;
}
function addModelVisibleStructuredContent(result: CallToolResult): CallToolResult {
if (result.structuredContent === undefined || result.content.length > 0) {
return result;
}
return {
...result,
content: [
{
type: "text",
text: formatStructuredContentForModel(result.structuredContent),
},
],
};
}
function formatStructuredContentForModel(structuredContent: unknown): string {
if (
!structuredContent ||
typeof structuredContent !== "object" ||
Array.isArray(structuredContent)
) {
return JSON.stringify(structuredContent, null, 2);
}
const record = structuredContent as Record<string, unknown>;
const summary: string[] = [];
for (const [key, value] of Object.entries(record)) {
if (!Array.isArray(value)) {
continue;
}
summary.push(`${key}_count=${value.length}`);
const ids = value
.map((item) =>
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>).id
: null,
)
.filter((id): id is string => typeof id === "string" && id.length > 0);
if (ids.length === value.length && ids.length > 0) {
summary.push(`${key}_ids=${ids.join(",")}`);
}
}
const json = JSON.stringify(structuredContent, null, 2);
return summary.length > 0 ? `${summary.join("\n")}\n\n${json}` : json;
}
type McpToolContext = RequestHandlerExtra<ServerRequest, ServerNotification>;
function parseTimestamp(value: string | null | undefined): number {
@@ -454,6 +506,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
name: "agent-mcp",
version: "2.0.0",
});
const registerRawTool = server.registerTool.bind(server);
const registerTool: McpServer["registerTool"] = (name, config, handler) =>
registerRawTool(name, config, (async (args: never, extra: never) =>
addModelVisibleStructuredContent(await handler(args, extra))) as typeof handler);
const resolveCallerAgent = () => {
if (!callerAgentId) {
@@ -750,7 +806,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
};
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
server.registerTool(
registerTool(
"speak",
{
title: "Speak",
@@ -954,7 +1010,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
};
};
server.registerTool(
registerTool(
"create_agent",
{
title: "Create agent",
@@ -1099,7 +1155,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"wait_for_agent",
{
title: "Wait for agent",
@@ -1176,7 +1232,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"send_agent_prompt",
{
title: "Send agent prompt",
@@ -1277,7 +1333,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"get_agent_status",
{
title: "Get agent status",
@@ -1327,7 +1383,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_agents",
{
title: "List agents",
@@ -1386,7 +1442,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"cancel_agent",
{
title: "Cancel agent run",
@@ -1410,7 +1466,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"archive_agent",
{
title: "Archive agent",
@@ -1433,7 +1489,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"kill_agent",
{
title: "Kill agent",
@@ -1455,7 +1511,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"update_agent",
{
title: "Update agent",
@@ -1513,7 +1569,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_terminals",
{
title: "List terminals",
@@ -1561,7 +1617,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"create_terminal",
{
title: "Create terminal",
@@ -1596,7 +1652,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"kill_terminal",
{
title: "Kill terminal",
@@ -1627,7 +1683,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"capture_terminal",
{
title: "Capture terminal",
@@ -1671,7 +1727,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"send_terminal_keys",
{
title: "Send terminal keys",
@@ -1707,7 +1763,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"create_schedule",
{
title: "Create schedule",
@@ -1788,7 +1844,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_schedules",
{
title: "List schedules",
@@ -1813,7 +1869,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"inspect_schedule",
{
title: "Inspect schedule",
@@ -1836,7 +1892,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"pause_schedule",
{
title: "Pause schedule",
@@ -1861,7 +1917,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"resume_schedule",
{
title: "Resume schedule",
@@ -1886,7 +1942,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"delete_schedule",
{
title: "Delete schedule",
@@ -1911,7 +1967,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"update_schedule",
{
title: "Update schedule",
@@ -1973,7 +2029,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"schedule_logs",
{
title: "Schedule logs",
@@ -1998,7 +2054,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_providers",
{
title: "List providers",
@@ -2021,7 +2077,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_models",
{
title: "List models",
@@ -2058,7 +2114,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"inspect_provider",
{
title: "Inspect provider",
@@ -2122,7 +2178,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_worktrees",
{
title: "List worktrees",
@@ -2153,7 +2209,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"create_worktree",
{
title: "Create worktree",
@@ -2218,7 +2274,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"archive_worktree",
{
title: "Archive worktree",
@@ -2310,7 +2366,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"get_agent_activity",
{
title: "Get agent activity",
@@ -2366,7 +2422,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"set_agent_mode",
{
title: "Set agent session mode",
@@ -2390,7 +2446,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_pending_permissions",
{
title: "List pending permissions",
@@ -2424,7 +2480,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"respond_to_permission",
{
title: "Respond to permission",
@@ -193,8 +193,8 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({
},
}));
vi.mock("./providers/pi-direct-agent.js", () => ({
PiDirectAgentClient: class PiDirectAgentClient {
vi.mock("./providers/pi/agent.js", () => ({
PiRpcAgentClient: class PiRpcAgentClient {
readonly capabilities = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -29,7 +29,7 @@ import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js";
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import { OpenCodeServerManager } from "./providers/opencode/server-manager.js";
import { PiDirectAgentClient } from "./providers/pi-direct-agent.js";
import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
import {
AGENT_PROVIDER_DEFINITIONS,
@@ -121,7 +121,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
}),
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings),
pi: (logger, runtimeSettings) =>
new PiDirectAgentClient({
new PiRpcAgentClient({
logger,
runtimeSettings,
}),
@@ -36,7 +36,7 @@ import {
transformCopilotSessionResponse,
writeCopilotProviderMode,
} from "./copilot-acp-agent.js";
import { transformPiModels } from "./pi-direct-agent.js";
import { transformPiModels } from "./pi/agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals } from "../../test-utils/class-mocks.js";
@@ -1,373 +0,0 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, test, vi } from "vitest";
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
import pino from "pino";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import {
PiDirectAgentClient,
PiDirectAgentSession,
type PiDirectSessionRuntimeAdapter,
type PiDirectSessionAdapter,
} from "./pi-direct-agent.js";
function createPiAssistantErrorMessage(errorMessage: string): AssistantMessage {
return {
role: "assistant",
content: [],
api: "openai-responses",
provider: "github-copilot",
model: "gpt-5.4",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
stopReason: "error",
errorMessage,
timestamp: Date.now(),
};
}
function createPiSession(
prompt: () => Promise<void>,
options: {
compact?: () => Promise<void>;
messages?: PiDirectSessionAdapter["messages"];
errorMessage?: string | null;
} = {},
): PiDirectSessionAdapter {
return {
sessionId: "pi-session-1",
thinkingLevel: "medium",
model: undefined,
messages: options.messages ?? [],
extensionRunner: undefined,
promptTemplates: [],
resourceLoader: {
getSkills: () => ({ skills: [] }),
},
agent: {
state: {
systemPrompt: "",
errorMessage: options.errorMessage ?? null,
},
},
sessionManager: {
getSessionFile: () => "/tmp/pi-session.json",
getCwd: () => "/tmp/paseo-pi-test",
},
subscribe: vi.fn(),
prompt,
compact: options.compact ?? vi.fn(async () => undefined),
abort: vi.fn(),
dispose: vi.fn(),
getSessionStats: vi.fn(() => ({})),
setModel: vi.fn(),
setThinkingLevel: vi.fn(),
};
}
function createPiRuntime(
session: PiDirectSessionAdapter,
dispose: () => Promise<void> = vi.fn(async () => undefined),
): PiDirectSessionRuntimeAdapter {
return {
session,
dispose,
};
}
function createPiModel(provider: string, id: string): Model<Api> {
return {
provider,
id,
name: id,
api: "openai-completions",
baseUrl: "https://example.invalid/v1",
reasoning: true,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
} as Model<Api>;
}
describe("PiDirectAgentSession", () => {
test("treats SDK request abort rejections as turn cancellations", async () => {
const session = new PiDirectAgentSession(
createPiRuntime(createPiSession(() => Promise.reject(new Error("Request was aborted.")))),
{ find: vi.fn(), getAll: vi.fn(() => []) },
{
provider: "pi",
cwd: "/tmp/paseo-pi-test",
},
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const { turnId } = await session.startTurn("hello");
await Promise.resolve();
expect(events).toEqual([
{
type: "turn_canceled",
provider: "pi",
turnId,
reason: "Request was aborted.",
},
]);
});
test("compacts stale Copilot 413 sessions before prompting again", async () => {
const callOrder: string[] = [];
const sdkSession = createPiSession(
vi.fn(async () => {
callOrder.push("prompt");
}),
{
messages: [createPiAssistantErrorMessage("413 failed to parse request")],
errorMessage: "413 failed to parse request",
compact: vi.fn(async () => {
callOrder.push("compact");
}),
},
);
const session = new PiDirectAgentSession(
createPiRuntime(sdkSession),
{ find: vi.fn(), getAll: vi.fn(() => []) },
{
provider: "pi",
cwd: "/tmp/paseo-pi-test",
},
);
await session.startTurn("continue");
expect(sdkSession.compact).toHaveBeenCalledTimes(1);
expect(sdkSession.prompt).toHaveBeenCalledTimes(1);
expect(callOrder).toEqual(["compact", "prompt"]);
});
test("does not compact other Pi errors before prompting", async () => {
const sdkSession = createPiSession(
vi.fn(async () => undefined),
{
messages: [createPiAssistantErrorMessage("413 unrelated provider error")],
compact: vi.fn(async () => {
throw new Error("should not compact");
}),
},
);
const session = new PiDirectAgentSession(
createPiRuntime(sdkSession),
{ find: vi.fn(), getAll: vi.fn(() => []) },
{
provider: "pi",
cwd: "/tmp/paseo-pi-test",
},
);
await session.startTurn("continue");
expect(sdkSession.compact).not.toHaveBeenCalled();
expect(sdkSession.prompt).toHaveBeenCalledTimes(1);
});
test("setModel creates a minimal model for new ids under a known provider", async () => {
const sdkSession = createPiSession(async () => undefined);
const session = new PiDirectAgentSession(
createPiRuntime(sdkSession),
{
find: vi.fn(() => undefined),
getAll: vi.fn(() => [createPiModel("openrouter", "known-model")]),
},
{
provider: "pi",
cwd: "/tmp/paseo-pi-test",
},
);
await session.setModel("openrouter/blabal");
expect(sdkSession.setModel).toHaveBeenCalledWith({
id: "blabal",
name: "blabal",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://example.invalid/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
compat: undefined,
});
});
});
describe("PiDirectAgentClient", () => {
test("lists only Pi models with configured auth", async () => {
const client = new PiDirectAgentClient({
logger: pino({ level: "silent" }),
});
const registry = {
find: vi.fn(),
getAll: vi.fn(() => [createPiModel("amazon-bedrock", "claude-sonnet-4")]),
getAvailable: vi.fn(() => [createPiModel("anthropic", "claude-opus-4-5")]),
};
(client as unknown as { modelRegistry: typeof registry }).modelRegistry = registry;
const models = await client.listModels({ cwd: "/tmp/paseo-pi-test", force: false });
expect(registry.getAvailable).toHaveBeenCalledTimes(1);
expect(registry.getAll).not.toHaveBeenCalled();
expect(models.map((model) => model.id)).toEqual(["anthropic/claude-opus-4-5"]);
});
test("loads project extensions before listing available models", async () => {
const testRoot = await mkdtemp(join(tmpdir(), "paseo-pi-extension-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const agentDir = join(testRoot, "agent");
const cwd = join(testRoot, "project");
const extensionDir = join(cwd, ".pi", "extensions");
process.env.PI_CODING_AGENT_DIR = agentDir;
await mkdir(extensionDir, { recursive: true });
await writeFile(
join(extensionDir, "dummy-provider.ts"),
`
export default function(pi) {
pi.registerProvider("paseo-dummy", {
baseUrl: "https://example.invalid/v1",
apiKey: "paseo-test-key",
api: "openai-responses",
models: [
{
id: "extension-model",
name: "Extension Model",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096
}
]
});
}
`,
"utf-8",
);
const client = new PiDirectAgentClient({
logger: pino({ level: "silent" }),
});
const models = await client.listModels({ cwd, force: false });
expect(models.map((model) => model.id)).toContain("paseo-dummy/extension-model");
} finally {
if (previousAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousAgentDir;
}
await rm(testRoot, { recursive: true, force: true });
}
});
test("creates sessions with project extension models and exposes extension commands", async () => {
const testRoot = await mkdtemp(join(tmpdir(), "paseo-pi-extension-session-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const agentDir = join(testRoot, "agent");
const cwd = join(testRoot, "project");
const extensionDir = join(cwd, ".pi", "extensions");
const shutdownMarker = join(testRoot, "shutdown.txt");
process.env.PI_CODING_AGENT_DIR = agentDir;
await mkdir(extensionDir, { recursive: true });
await writeFile(
join(extensionDir, "dummy-command.ts"),
`
import { writeFileSync } from "node:fs";
export default function(pi) {
pi.registerProvider("paseo-dummy", {
baseUrl: "https://example.invalid/v1",
apiKey: "paseo-test-key",
api: "openai-responses",
models: [
{
id: "extension-model",
name: "Extension Model",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096
}
]
});
pi.registerCommand("dummy-command", {
description: "Dummy extension command",
handler: async () => {}
});
pi.on("session_shutdown", async () => {
writeFileSync(${JSON.stringify(shutdownMarker)}, "closed");
});
}
`,
"utf-8",
);
const client = new PiDirectAgentClient({
logger: pino({ level: "silent" }),
});
const session = await client.createSession({
provider: "pi",
cwd,
model: "paseo-dummy/extension-model",
});
let closed = false;
try {
await expect(session.listCommands()).resolves.toContainEqual({
name: "dummy-command",
description: "Dummy extension command",
argumentHint: "",
});
await session.close();
closed = true;
await expect(readFile(shutdownMarker, "utf-8")).resolves.toBe("closed");
} finally {
if (!closed) {
await session.close();
}
}
} finally {
if (previousAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousAgentDir;
}
await rm(testRoot, { recursive: true, force: true });
}
});
});
File diff suppressed because it is too large Load Diff
@@ -1,115 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import {
applyPiSessionRecoveryPolicy,
type PiSessionRecoveryPolicySession,
} from "./pi-session-recovery-policy.js";
function createRecoverySession(
options: {
messages?: PiSessionRecoveryPolicySession["messages"];
errorMessage?: string | null;
compact?: () => Promise<void>;
} = {},
): PiSessionRecoveryPolicySession {
return {
messages: options.messages ?? [],
agent: {
state: {
errorMessage: options.errorMessage ?? null,
},
},
compact: options.compact ?? vi.fn(async () => undefined),
};
}
describe("applyPiSessionRecoveryPolicy", () => {
test("compacts sessions that ended on Pi Copilot short 413 overflow", async () => {
const compact = vi.fn(async () => undefined);
const session = createRecoverySession({
messages: [
{
role: "assistant",
stopReason: "error",
errorMessage: "413 failed to parse request",
},
],
compact,
});
const result = await applyPiSessionRecoveryPolicy(session);
expect(result).toEqual({ applied: true, policyId: "piCopilot413" });
expect(compact).toHaveBeenCalledTimes(1);
});
test("uses Pi agent error state when message history has no matching assistant error", async () => {
const compact = vi.fn(async () => undefined);
const session = createRecoverySession({
messages: [{ role: "user" }],
errorMessage: " 413 failed to parse request ",
compact,
});
const result = await applyPiSessionRecoveryPolicy(session);
expect(result).toEqual({ applied: true, policyId: "piCopilot413" });
expect(compact).toHaveBeenCalledTimes(1);
});
test("does not compact unrelated Pi errors", async () => {
const compact = vi.fn(async () => undefined);
const session = createRecoverySession({
messages: [
{
role: "assistant",
stopReason: "error",
errorMessage: "413 unrelated provider error",
},
],
compact,
});
const result = await applyPiSessionRecoveryPolicy(session);
expect(result).toEqual({ applied: false });
expect(compact).not.toHaveBeenCalled();
});
test("treats already-compacted sessions as recovered", async () => {
const session = createRecoverySession({
messages: [
{
role: "assistant",
stopReason: "error",
errorMessage: "413 failed to parse request",
},
],
compact: vi.fn(async () => {
throw new Error("Already compacted");
}),
});
await expect(applyPiSessionRecoveryPolicy(session)).resolves.toEqual({
applied: true,
policyId: "piCopilot413",
});
});
test("surfaces unexpected compaction failures", async () => {
const session = createRecoverySession({
messages: [
{
role: "assistant",
stopReason: "error",
errorMessage: "413 failed to parse request",
},
],
compact: vi.fn(async () => {
throw new Error("disk exploded");
}),
});
await expect(applyPiSessionRecoveryPolicy(session)).rejects.toThrow("disk exploded");
});
});
@@ -1,91 +0,0 @@
import { toDiagnosticErrorMessage } from "./diagnostic-utils.js";
export interface PiSessionRecoveryPolicySession {
readonly messages: readonly PiRecoveryMessage[];
readonly agent: {
readonly state: {
readonly errorMessage?: string | null;
};
};
compact(): Promise<unknown>;
}
interface PiRecoveryMessage {
readonly role: string;
readonly stopReason?: string;
readonly errorMessage?: string | null;
}
type PiSessionRecoveryPolicyId = "piCopilot413";
interface PiSessionRecoveryPolicy {
readonly id: PiSessionRecoveryPolicyId;
shouldRecover(session: PiSessionRecoveryPolicySession): boolean;
recover(session: PiSessionRecoveryPolicySession): Promise<void>;
}
export interface PiSessionRecoveryResult {
readonly applied: boolean;
readonly policyId?: PiSessionRecoveryPolicyId;
}
// COMPAT(piCopilot413): added 2026-05-13 for Pi <= 0.73.1; target removal
// 2026-11-13, once upstream @earendil-works/pi-ai recognizes this overflow.
const PI_COPILOT_SHORT_413_OVERFLOW_PATTERN = /^413\s+failed to parse request$/i;
const PI_SESSION_RECOVERY_POLICIES: readonly PiSessionRecoveryPolicy[] = [
{
id: "piCopilot413",
shouldRecover: shouldCompactForPiCopilot413,
recover: compactPiSession,
},
];
export async function applyPiSessionRecoveryPolicy(
session: PiSessionRecoveryPolicySession,
): Promise<PiSessionRecoveryResult> {
const policy = PI_SESSION_RECOVERY_POLICIES.find((entry) => entry.shouldRecover(session));
if (!policy) {
return { applied: false };
}
await policy.recover(session);
return { applied: true, policyId: policy.id };
}
function shouldCompactForPiCopilot413(session: PiSessionRecoveryPolicySession): boolean {
return (
isPiCopilotShort413Overflow(getLatestAssistantErrorMessage(session)) ||
isPiCopilotShort413Overflow(session.agent.state.errorMessage)
);
}
function isPiCopilotShort413Overflow(errorMessage: string | null | undefined): boolean {
const normalized = errorMessage?.trim();
return normalized ? PI_COPILOT_SHORT_413_OVERFLOW_PATTERN.test(normalized) : false;
}
function getLatestAssistantErrorMessage(session: PiSessionRecoveryPolicySession): string | null {
for (let index = session.messages.length - 1; index >= 0; index -= 1) {
const message = session.messages[index];
if (message.role !== "assistant") {
continue;
}
return message.stopReason === "error" ? (message.errorMessage?.trim() ?? null) : null;
}
return null;
}
async function compactPiSession(session: PiSessionRecoveryPolicySession): Promise<void> {
try {
await session.compact();
} catch (error) {
if (!isHarmlessPiCompactionError(error)) {
throw error;
}
}
}
function isHarmlessPiCompactionError(error: unknown): boolean {
return /already compacted|nothing to compact/i.test(toDiagnosticErrorMessage(error));
}
@@ -0,0 +1,489 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { describe, expect, test } from "vitest";
import type { AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js";
import { FakePi } from "./test-utils/fake-pi.js";
function createClient(pi = new FakePi()): PiRpcAgentClient {
return new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtime: pi,
});
}
function createClientWithPiAgentDir(agentDir: string): PiRpcAgentClient {
return new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtime: new FakePi(),
runtimeSettings: { env: { PI_CODING_AGENT_DIR: agentDir } },
});
}
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
return {
provider: "pi",
cwd: "/tmp/paseo-pi-rpc-test",
...overrides,
};
}
async function createSession(pi = new FakePi()): Promise<{
pi: FakePi;
session: PiRpcAgentSession;
events: SessionEvents;
}> {
const client = createClient(pi);
const session = (await client.createSession(createConfig())) as PiRpcAgentSession;
const events = new SessionEvents(session);
return { pi, session, events };
}
class SessionEvents {
private readonly events: AgentStreamEvent[] = [];
private readonly waiters: Array<{
predicate: (event: AgentStreamEvent) => boolean;
resolve: (event: AgentStreamEvent) => void;
}> = [];
constructor(session: PiRpcAgentSession) {
session.subscribe((event) => {
this.events.push(event);
for (let index = 0; index < this.waiters.length; index += 1) {
const waiter = this.waiters[index];
if (waiter.predicate(event)) {
this.waiters.splice(index, 1);
index -= 1;
waiter.resolve(event);
}
}
});
}
timelineItems() {
return this.events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline",
)
.map((event) => event.item);
}
nextTurnCompletion(): Promise<Extract<AgentStreamEvent, { type: "turn_completed" }>> {
return this.nextEvent(
(event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> =>
event.type === "turn_completed",
);
}
nextTurnFailure(): Promise<Extract<AgentStreamEvent, { type: "turn_failed" }>> {
return this.nextEvent(
(event): event is Extract<AgentStreamEvent, { type: "turn_failed" }> =>
event.type === "turn_failed",
);
}
private nextEvent<T extends AgentStreamEvent>(
predicate: (event: AgentStreamEvent) => event is T,
): Promise<T> {
const existing = this.events.find(predicate);
if (existing) {
return Promise.resolve(existing);
}
return new Promise((resolve) => {
this.waiters.push({
predicate,
resolve: (event) => resolve(event as T),
});
});
}
}
describe("PiRpcAgentSession", () => {
test("streams assistant text, reasoning, and tool calls from Pi events", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
await session.startTurn("hello");
fakeSession.emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: { type: "text_delta", delta: "hello" },
});
fakeSession.emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: { type: "thinking_delta", delta: "thinking" },
});
fakeSession.emit({
type: "tool_execution_start",
toolCallId: "tool-1",
toolName: "bash",
args: { command: "echo hi" },
});
fakeSession.emit({
type: "tool_execution_end",
toolCallId: "tool-1",
toolName: "bash",
result: { output: "hi\n", exitCode: 0 },
isError: false,
});
fakeSession.finishTurn();
await events.nextTurnCompletion();
expect(events.timelineItems()).toEqual([
{ type: "assistant_message", text: "hello" },
{ type: "reasoning", text: "thinking" },
{
type: "tool_call",
callId: "tool-1",
name: "bash",
status: "running",
detail: { type: "shell", command: "echo hi" },
error: null,
},
{
type: "tool_call",
callId: "tool-1",
name: "bash",
status: "completed",
detail: { type: "shell", command: "echo hi", output: "hi\n", exitCode: 0 },
error: null,
},
]);
});
test("resumes by launching Pi with the persisted session file and cwd metadata", async () => {
const pi = new FakePi();
const client = createClient(pi);
await client.resumeSession(
{
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session.jsonl",
metadata: {
cwd: "/workspace/project",
model: "openrouter/model-a",
thinkingOptionId: "high",
},
},
{},
);
expect(pi.recordedLaunches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
session: "/tmp/native-pi-session.jsonl",
argv: [
"pi",
"--mode",
"rpc",
"--model",
"openrouter/model-a",
"--thinking",
"high",
"--session",
"/tmp/native-pi-session.jsonl",
],
}),
]);
});
test("creates Pi sessions with agent and daemon system prompts appended", async () => {
const pi = new FakePi();
const client = createClient(pi);
await client.createSession(
createConfig({
systemPrompt: "Agent prompt",
daemonAppendSystemPrompt: "Daemon prompt",
}),
);
expect(pi.recordedLaunches[0]).toEqual(
expect.objectContaining({
cwd: "/tmp/paseo-pi-rpc-test",
systemPrompt: "Agent prompt\n\nDaemon prompt",
argv: [
"pi",
"--mode",
"rpc",
"--thinking",
"medium",
"--append-system-prompt",
"Agent prompt\n\nDaemon prompt",
],
}),
);
});
test("resumes Pi sessions with daemon system prompts appended", async () => {
const pi = new FakePi();
const client = createClient(pi);
await client.resumeSession(
{
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session.jsonl",
metadata: {
cwd: "/workspace/project",
model: "openrouter/model-a",
thinkingOptionId: "high",
systemPrompt: "Agent prompt",
},
},
{
daemonAppendSystemPrompt: "Daemon prompt",
},
);
expect(pi.recordedLaunches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
session: "/tmp/native-pi-session.jsonl",
systemPrompt: "Agent prompt\n\nDaemon prompt",
argv: [
"pi",
"--mode",
"rpc",
"--model",
"openrouter/model-a",
"--thinking",
"high",
"--session",
"/tmp/native-pi-session.jsonl",
"--append-system-prompt",
"Agent prompt\n\nDaemon prompt",
],
}),
]);
});
test("updates model and thinking through Pi runtime commands", async () => {
const { pi, session } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.setModelResult = { provider: "openrouter", id: "model-a", name: "Model A" };
await session.setModel("openrouter/model-a");
await session.setThinkingOption("high");
expect(fakeSession.setModelRequests).toEqual([{ provider: "openrouter", modelId: "model-a" }]);
expect(fakeSession.setThinkingLevelRequests).toEqual(["high"]);
});
test("fails the active turn when the Pi process exits mid-turn", async () => {
const { pi, session, events } = await createSession();
await session.startTurn("hello");
pi.latestSession().emit({ type: "process_exit", error: "Pi exited" });
await expect(events.nextTurnFailure()).resolves.toMatchObject({
error: "Pi exited",
});
});
});
describe("PiRpcAgentClient", () => {
test("lists models from a short-lived Pi session in the requested cwd", async () => {
const pi = new FakePi();
const client = createClient(pi);
const modelsPromise = client.listModels({ cwd: "/workspace/with-extension", force: false });
pi.latestSession().models = [
{
provider: "openrouter",
id: "google/gemini-2.5-flash-lite",
name: "google/gemini-2.5-flash-lite",
reasoning: true,
},
];
await expect(modelsPromise).resolves.toMatchObject([
{
provider: "pi",
id: "openrouter/google/gemini-2.5-flash-lite",
label: "gemini-2.5-flash-lite",
defaultThinkingOptionId: "medium",
},
]);
expect(pi.recordedLaunches[0]).toMatchObject({ cwd: "/workspace/with-extension" });
});
test("maps extension, prompt, and skill commands to Paseo slash commands", async () => {
const { pi, session } = await createSession();
pi.latestSession().commands = [
{ name: "review", description: "Review changes", source: "extension" },
{ name: "fix-tests", description: "Fix tests", source: "prompt" },
{ name: "skill:docs", description: "Read docs", source: "skill" },
];
await expect(session.listCommands()).resolves.toEqual([
{ name: "review", description: "Review changes", argumentHint: "" },
{ name: "fix-tests", description: "Fix tests", argumentHint: "" },
{ name: "skill:docs", description: "Read docs", argumentHint: "" },
]);
});
test("injects MCP servers through pi-mcp-adapter when the extension is loaded", async () => {
const pi = new FakePi();
pi.queueCommands([
{
name: "mcp",
description: "Show MCP server status",
source: "extension",
sourceInfo: { source: "npm:pi-mcp-adapter" },
},
]);
const client = createClient(pi);
const session = await client.createSession(
createConfig({
mcpServers: {
paseo: {
type: "http",
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
},
localSecret: {
type: "stdio",
command: "node",
args: ["secret-server.js"],
env: { SECRET_NUMBER: "314159" },
},
},
}),
);
expect(pi.recordedLaunches).toHaveLength(2);
expect(pi.recordedLaunches[0]).toMatchObject({
cwd: "/tmp/paseo-pi-rpc-test",
argv: ["pi", "--mode", "rpc"],
});
const actualLaunch = pi.recordedLaunches[1];
expect(actualLaunch.argv).toEqual([
"pi",
"--mode",
"rpc",
"--thinking",
"medium",
"--mcp-config",
actualLaunch.mcpConfigPath,
]);
expect(session.capabilities.supportsMcpServers).toBe(true);
const configPath = actualLaunch.mcpConfigPath;
expect(configPath).toEqual(expect.any(String));
const injectedConfig = JSON.parse(readFileSync(configPath!, "utf8")) as {
mcpServers: Record<string, unknown>;
};
expect(injectedConfig).toEqual({
mcpServers: {
paseo: {
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
auth: false,
oauth: false,
},
localSecret: {
command: "node",
args: ["secret-server.js"],
env: { SECRET_NUMBER: "314159" },
},
},
});
await session.close();
expect(existsSync(configPath!)).toBe(false);
});
test("does not pass MCP config when pi-mcp-adapter is not loaded", async () => {
const pi = new FakePi();
pi.queueCommands([]);
const client = createClient(pi);
const session = await client.createSession(
createConfig({
mcpServers: {
paseo: {
type: "http",
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
},
},
}),
);
expect(pi.recordedLaunches).toHaveLength(2);
expect(pi.recordedLaunches[1]?.argv).toEqual(["pi", "--mode", "rpc", "--thinking", "medium"]);
expect(pi.recordedLaunches[1]?.mcpConfigPath).toBeUndefined();
expect(session.capabilities.supportsMcpServers).toBe(false);
});
test("lists persisted Pi sessions from the configured Pi agent directory", async () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-client-"));
const cwd = path.join(root, "workspace");
const agentDir = path.join(root, "agent");
const sessionsDir = path.join(agentDir, "sessions", "--workspace--");
mkdirSync(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "20260101_session.jsonl");
writeFileSync(
sessionFile,
[
JSON.stringify({
type: "session",
version: 3,
id: "pi-session",
timestamp: "2026-01-01T00:00:00.000Z",
cwd,
}),
JSON.stringify({
type: "message",
id: "entry-1",
parentId: null,
timestamp: "2026-01-01T00:00:01.000Z",
message: { role: "user", content: "remember this" },
}),
].join("\n") + "\n",
"utf8",
);
const client = createClientWithPiAgentDir(agentDir);
await expect(client.listPersistedAgents({ cwd })).resolves.toMatchObject([
{
provider: "pi",
sessionId: "pi-session",
cwd,
persistence: {
provider: "pi",
sessionId: "pi-session",
nativeHandle: sessionFile,
metadata: { provider: "pi", cwd },
},
timeline: [{ type: "user_message", text: "remember this" }],
},
]);
});
});
describe("transformPiModels", () => {
test("normalizes labels that include the upstream provider prefix", () => {
expect(
transformPiModels([
{
provider: "pi",
id: "openrouter/google/gemini-2.5-flash-lite",
label: "openrouter/google/gemini_2.5 flash lite",
},
]),
).toEqual([
{
provider: "pi",
id: "openrouter/google/gemini-2.5-flash-lite",
label: "gemini 2.5 flash lite",
description: "openrouter/google/gemini_2.5 flash lite",
},
]);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,188 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import pino from "pino";
import { describe, expect, test } from "vitest";
import { PiCliRuntime } from "./cli-runtime.js";
import type { PiRuntimeLaunch } from "./runtime.js";
type PiChild = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
killedSignals: Array<NodeJS.Signals | number | undefined>;
};
function createPiChild(): PiChild {
const child = Object.assign(new EventEmitter(), {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
exitCode: null,
signalCode: null,
killedSignals: [],
}) as PiChild;
child.kill = ((signal?: NodeJS.Signals | number) => {
child.killedSignals.push(signal);
queueMicrotask(() => child.emit("exit", null, signal ?? null));
return true;
}) as ChildProcessWithoutNullStreams["kill"];
return child;
}
function createRuntime(child: PiChild, launches: PiRuntimeLaunch[] = []): PiCliRuntime {
return new PiCliRuntime({
logger: pino({ level: "silent" }),
command: ["pi"],
spawnProcess: (launch) => {
launches.push(launch);
return child;
},
});
}
function replyToCommands(
child: PiChild,
handler: (command: Record<string, unknown>) => unknown,
): void {
let buffer = "";
child.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) break;
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const command = JSON.parse(line) as Record<string, unknown>;
const result = handler(command);
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: result,
})}\n`,
);
}
});
}
describe("PiCliRuntime", () => {
test("starts pi in rpc mode and resolves command responses", async () => {
const child = createPiChild();
replyToCommands(child, (command) =>
command.type === "get_state"
? {
sessionId: "pi-session-1",
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
messageCount: 0,
pendingMessageCount: 0,
}
: {},
);
const launches: PiRuntimeLaunch[] = [];
const runtime = createRuntime(child, launches);
const session = await runtime.startSession({ cwd: "/workspace/project" });
await expect(session.getState()).resolves.toMatchObject({
sessionId: "pi-session-1",
thinkingLevel: "medium",
});
expect(launches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
argv: ["pi", "--mode", "rpc"],
}),
]);
});
test("passes an MCP config path to Pi", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
const launches: PiRuntimeLaunch[] = [];
const runtime = createRuntime(child, launches);
await runtime.startSession({
cwd: "/workspace/project",
mcpConfigPath: "/tmp/paseo-pi-mcp/mcp.json",
});
expect(launches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
mcpConfigPath: "/tmp/paseo-pi-mcp/mcp.json",
argv: ["pi", "--mode", "rpc", "--mcp-config", "/tmp/paseo-pi-mcp/mcp.json"],
}),
]);
});
test("passes an appended system prompt to Pi", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
const launches: PiRuntimeLaunch[] = [];
const runtime = createRuntime(child, launches);
await runtime.startSession({
cwd: "/workspace/project",
systemPrompt: " Use the daemon prompt. ",
});
expect(launches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
systemPrompt: "Use the daemon prompt.",
argv: ["pi", "--mode", "rpc", "--append-system-prompt", "Use the daemon prompt."],
}),
]);
});
test("delivers events separately from command responses", async () => {
const child = createPiChild();
replyToCommands(child, () => ({ models: [] }));
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const events: unknown[] = [];
session.onEvent((event) => events.push(event));
child.stdout.write(`${JSON.stringify({ type: "turn_start" })}\n`);
await session.getAvailableModels();
expect(events).toEqual([{ type: "turn_start" }]);
});
test("keeps unicode line separators inside one JSONL record", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const events: unknown[] = [];
session.onEvent((event) => events.push(event));
child.stdout.write(`${JSON.stringify({ type: "message", text: "a\u2028b\u2029c" })}\n`);
expect(events).toEqual([{ type: "message", text: "a\u2028b\u2029c" }]);
});
test("rejects pending commands when the Pi process exits", async () => {
const child = createPiChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const state = session.getState();
child.stderr.write("boom");
child.emit("exit", 1, null);
await expect(state).rejects.toThrow("boom");
});
test("disposes the Pi process", async () => {
const child = createPiChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await session.close();
expect(child.killedSignals).toContain("SIGTERM");
});
});
@@ -0,0 +1,282 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import {
buildPiLaunch,
type PiRuntime,
type PiRuntimeLaunch,
type PiRuntimeSession,
type PiStartSessionInput,
} from "./runtime.js";
import type {
PiAgentMessage,
PiModel,
PiRpcCommand,
PiRpcResponse,
PiRpcSlashCommand,
PiRuntimeEvent,
PiSessionState,
PiSessionStats,
} from "./rpc-types.js";
const DEFAULT_PI_COMMAND: [string, ...string[]] = [
process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi",
];
const DEFAULT_TIMEOUT_MS = 30_000;
const STDERR_BUFFER_LIMIT = 8192;
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
export interface PiCliRuntimeOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
command?: [string, ...string[]];
spawnProcess?: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
}
export class PiCliRuntime implements PiRuntime {
private readonly command: [string, ...string[]];
private readonly spawnProcess: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
constructor(private readonly options: PiCliRuntimeOptions) {
this.command = options.command ?? DEFAULT_PI_COMMAND;
this.spawnProcess =
options.spawnProcess ??
((launch) => {
const [command, ...args] = launch.argv;
return spawn(command, args, {
cwd: launch.cwd,
env: launch.env ? { ...process.env, ...launch.env } : process.env,
stdio: ["pipe", "pipe", "pipe"],
});
});
}
async startSession(input: PiStartSessionInput): Promise<PiRuntimeSession> {
const launch = buildPiLaunch({
command: this.command,
runtimeSettings: this.options.runtimeSettings,
session: input,
});
return new PiCliRuntimeSession(launch, this.spawnProcess(launch), this.options.logger);
}
}
class PiCliRuntimeSession implements PiRuntimeSession {
private readonly pending = new Map<string, PendingRequest>();
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
private stderrBuffer = "";
private nextRequestId = 1;
private disposed = false;
private stdoutBuffer = "";
constructor(
_launch: PiRuntimeLaunch,
private readonly child: ChildProcessWithoutNullStreams,
private readonly logger: Logger,
) {
child.stdout.on("data", (chunk) => {
this.handleStdoutChunk(chunk.toString());
});
child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
if (this.stderrBuffer.length > STDERR_BUFFER_LIMIT) {
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
}
});
child.on("error", (error) => {
this.failAll(error instanceof Error ? error : new Error(String(error)));
});
child.on("exit", (code, signal) => {
const error = new Error(
`Pi RPC process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
this.emit({ type: "process_exit", error: error.message });
this.failAll(error);
});
}
onEvent(callback: (event: PiRuntimeEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void> {
await this.request({ type: "prompt", message, ...(images?.length ? { images } : {}) });
}
async abort(): Promise<void> {
await this.request({ type: "abort" });
}
async getState(): Promise<PiSessionState> {
return (await this.request({ type: "get_state" })) as PiSessionState;
}
async getMessages(): Promise<PiAgentMessage[]> {
const data = (await this.request({ type: "get_messages" })) as { messages?: PiAgentMessage[] };
return data.messages ?? [];
}
async getAvailableModels(): Promise<PiModel[]> {
const data = (await this.request({ type: "get_available_models" })) as { models?: PiModel[] };
return data.models ?? [];
}
async setModel(provider: string, modelId: string): Promise<PiModel> {
return (await this.request({ type: "set_model", provider, modelId })) as PiModel;
}
async setThinkingLevel(level: string): Promise<void> {
await this.request({ type: "set_thinking_level", level: level as never });
}
async getSessionStats(): Promise<PiSessionStats> {
return (await this.request({ type: "get_session_stats" })) as PiSessionStats;
}
async getCommands(): Promise<PiRpcSlashCommand[]> {
const data = (await this.request({ type: "get_commands" })) as {
commands?: PiRpcSlashCommand[];
};
return data.commands ?? [];
}
cancelExtensionUiRequest(id: string): void {
this.writeJsonLine({ type: "extension_ui_response", id, cancelled: true });
}
async close(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
try {
this.child.stdin.end();
} catch {
// ignore
}
const result = await terminateWithTreeKill(this.child, {
gracefulTimeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS,
forceTimeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS,
onForceSignal: () => {
this.logger.warn(
{ timeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS },
"Pi RPC process did not exit after SIGTERM; sending SIGKILL",
);
},
});
if (result === "kill-timeout") {
this.logger.warn(
{ timeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS },
"Pi RPC process did not report exit after SIGKILL",
);
}
}
private request(command: PiRpcCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
if (this.disposed) {
return Promise.reject(new Error("Pi RPC session is closed"));
}
const id = `req_${this.nextRequestId}`;
this.nextRequestId += 1;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(
new Error(`Pi RPC request timed out for ${command.type}\n${this.stderrBuffer}`.trim()),
);
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.writeJsonLine({ ...command, id });
});
}
private writeJsonLine(value: unknown): void {
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
return;
}
this.child.stdin.write(`${JSON.stringify(value)}\n`);
}
private handleStdoutChunk(chunk: string): void {
this.stdoutBuffer += chunk;
for (;;) {
const newlineIndex = this.stdoutBuffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = this.stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
if (line.trim()) {
this.handleLine(line);
}
}
}
private handleLine(line: string): void {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
this.logger.warn({ error, line }, "Ignoring non-JSON Pi RPC stdout line");
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return;
}
const message = parsed as Record<string, unknown>;
if (message.type === "response") {
this.handleResponse(message as unknown as PiRpcResponse);
return;
}
this.emit(message as PiRuntimeEvent);
}
private handleResponse(response: PiRpcResponse): void {
const id = response.id;
if (!id) {
return;
}
const pending = this.pending.get(id);
if (!pending) {
return;
}
clearTimeout(pending.timer);
this.pending.delete(id);
if (!response.success) {
pending.reject(new Error(response.error ?? `Pi RPC ${response.command} failed`));
return;
}
pending.resolve(response.data);
}
private emit(event: PiRuntimeEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
private failAll(error: Error): void {
if (this.disposed) {
return;
}
this.disposed = true;
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
}
@@ -0,0 +1,127 @@
import { describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { PiAgentMessage } from "./rpc-types.js";
import { streamPiHistory } from "./history-mapper.js";
async function collectHistory(messages: PiAgentMessage[]): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of streamPiHistory("pi", messages)) {
events.push(event);
}
return events;
}
describe("Pi history mapper", () => {
test("replays user, assistant, reasoning, and completed tool calls", async () => {
await expect(
collectHistory([
{
role: "user",
content: [
{ type: "text", text: "read this" },
{ type: "image", data: "base64", mimeType: "image/png" },
{ type: "text", text: "then answer" },
],
},
{
role: "assistant",
content: [
{ type: "thinking", thinking: "checking file" },
{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "note.txt" } },
{ type: "text", text: "done" },
],
},
{
role: "toolResult",
toolCallId: "tool-1",
toolName: "read",
content: [{ type: "text", text: "file contents" }],
},
]),
).resolves.toEqual([
{
type: "timeline",
provider: "pi",
item: {
type: "user_message",
text: "read this\n\nthen answer",
messageId: "pi-user-0",
},
},
{
type: "timeline",
provider: "pi",
item: { type: "reasoning", text: "checking file" },
},
{
type: "timeline",
provider: "pi",
item: {
type: "tool_call",
callId: "tool-1",
name: "read",
status: "running",
detail: {
type: "read",
filePath: "note.txt",
content: undefined,
offset: undefined,
limit: undefined,
},
error: null,
},
},
{
type: "timeline",
provider: "pi",
item: { type: "assistant_message", text: "done" },
},
{
type: "timeline",
provider: "pi",
item: {
type: "tool_call",
callId: "tool-1",
name: "read",
status: "completed",
detail: {
type: "read",
filePath: "note.txt",
content: "file contents",
offset: undefined,
limit: undefined,
},
error: null,
},
},
]);
});
test("replays bash execution records as completed shell calls", async () => {
await expect(
collectHistory([
{
role: "bashExecution",
command: "echo hi",
output: "hi\n",
exitCode: 0,
timestamp: 123,
},
]),
).resolves.toEqual([
{
type: "timeline",
provider: "pi",
item: {
type: "tool_call",
callId: "pi-bash-123",
name: "bash",
status: "completed",
detail: { type: "shell", command: "echo hi", output: "hi\n", exitCode: 0 },
error: null,
},
},
]);
});
});
@@ -0,0 +1,169 @@
import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js";
import type { PiAgentMessage, PiImageContent, PiTextContent } from "./rpc-types.js";
import {
extractTextFromToolResult,
mapToolDetail,
parseToolArgs,
parseToolResult,
type PiTrackedToolCall,
} from "./tool-call-mapper.js";
function isTextContentBlock(block: unknown): block is PiTextContent {
return (
typeof block === "object" &&
block !== null &&
!Array.isArray(block) &&
Reflect.get(block, "type") === "text" &&
typeof Reflect.get(block, "text") === "string"
);
}
export function getUserMessageText(content: string | (PiTextContent | PiImageContent)[]): string {
if (typeof content === "string") {
return content;
}
const textParts: string[] = [];
for (const block of content) {
if (isTextContentBlock(block)) {
textParts.push(block.text);
}
}
return textParts.join("\n\n");
}
export async function* streamPiHistory(
provider: string,
messages: PiAgentMessage[],
): AsyncGenerator<AgentStreamEvent> {
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
let userIndex = 0;
for (const message of messages) {
if (message.role === "user") {
const text = getUserMessageText(message.content);
if (text) {
yield {
type: "timeline",
provider,
item: {
type: "user_message",
text,
messageId: `pi-user-${userIndex}`,
},
};
}
userIndex += 1;
continue;
}
if (message.role === "assistant") {
for (const content of message.content) {
if (content.type === "text" && content.text) {
yield {
type: "timeline",
provider,
item: { type: "assistant_message", text: content.text },
};
continue;
}
if (content.type === "thinking" && content.thinking) {
yield {
type: "timeline",
provider,
item: { type: "reasoning", text: content.thinking },
};
continue;
}
if (content.type === "toolCall") {
const tracked = parseToolArgs(content.name, content.arguments);
pendingToolCalls.set(content.id, tracked);
yield {
type: "timeline",
provider,
item: {
type: "tool_call",
callId: content.id,
name: tracked.toolName,
status: "running",
detail: mapToolDetail(tracked, null),
error: null,
},
};
}
}
continue;
}
if (message.role === "toolResult") {
const tracked =
pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
pendingToolCalls.delete(message.toolCallId);
const result = parseToolResult({ content: message.content });
const detail = mapToolDetail(tracked, result);
yield {
type: "timeline",
provider,
item: toToolResultTimelineItem({
callId: message.toolCallId,
name: tracked.toolName,
isError: Boolean(message.isError),
detail,
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
}),
};
continue;
}
if (message.role === "bashExecution") {
const callId = `pi-bash-${message.timestamp}`;
const detail: ToolCallDetail = {
type: "shell",
command: message.command,
output: message.output,
exitCode: message.exitCode ?? null,
};
yield {
type: "timeline",
provider,
item: {
type: "tool_call",
callId,
name: "bash",
status: message.cancelled ? "canceled" : "completed",
detail,
error: null,
},
};
}
}
}
function toToolResultTimelineItem(input: {
callId: string;
name: string;
isError: boolean;
detail: ToolCallDetail;
errorText: string;
}): AgentTimelineItem {
if (input.isError) {
return {
type: "tool_call",
callId: input.callId,
name: input.name,
status: "failed",
detail: input.detail,
error: input.errorText,
};
}
return {
type: "tool_call",
callId: input.callId,
name: input.name,
status: "completed",
detail: input.detail,
error: null,
};
}
@@ -0,0 +1,172 @@
export type PiThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export interface PiImageContent {
type: "image";
data: string;
mimeType: string;
}
export interface PiTextContent {
type: "text";
text: string;
}
export interface PiThinkingContent {
type: "thinking";
thinking: string;
}
export interface PiToolCallContent {
type: "toolCall";
id: string;
name: string;
arguments: unknown;
}
export type PiAssistantContent = PiTextContent | PiThinkingContent | PiToolCallContent;
export type PiAgentMessage =
| {
role: "user";
content: string | Array<PiTextContent | PiImageContent>;
}
| {
role: "assistant";
content: PiAssistantContent[];
errorMessage?: string | null;
stopReason?: string;
}
| {
role: "toolResult";
toolCallId: string;
toolName: string;
content: unknown;
isError?: boolean;
}
| {
role: "bashExecution";
command: string;
output?: string;
exitCode?: number | null;
cancelled?: boolean;
timestamp: number;
};
export interface PiModel {
provider: string;
id: string;
name?: string;
reasoning?: boolean;
contextWindow?: number;
maxTokens?: number;
api?: string;
baseUrl?: string;
input?: string[];
cost?: Record<string, unknown>;
compat?: unknown;
}
export interface PiSessionState {
model?: PiModel | null;
thinkingLevel: PiThinkingLevel;
isStreaming: boolean;
isCompacting: boolean;
sessionFile?: string;
sessionId: string;
sessionName?: string;
messageCount: number;
pendingMessageCount: number;
}
export interface PiSessionStats {
tokens?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
cost?: number;
contextUsage?: {
tokens?: number | null;
contextWindow?: number | null;
percent?: number | null;
};
}
export interface PiRpcSlashCommand {
name: string;
description?: string;
source: "extension" | "prompt" | "skill";
sourceInfo?: Record<string, unknown>;
}
export type PiRpcCommand =
| { id?: string; type: "prompt"; message: string; images?: PiImageContent[] }
| { id?: string; type: "abort" }
| { id?: string; type: "get_state" }
| { id?: string; type: "get_messages" }
| { id?: string; type: "get_available_models" }
| { id?: string; type: "set_model"; provider: string; modelId: string }
| { id?: string; type: "set_thinking_level"; level: PiThinkingLevel }
| { id?: string; type: "get_session_stats" }
| { id?: string; type: "get_commands" };
export interface PiRpcResponse {
id?: string;
type: "response";
command: string;
success: boolean;
data?: unknown;
error?: string;
}
export type PiAssistantMessageEvent =
| { type: "text_delta"; delta?: string }
| { type: "thinking_delta"; delta?: string }
| { type: "start" | "text_start" | "text_end" | "thinking_start" | "thinking_end" | "done" };
export type PiAgentSessionEvent =
| { type: "agent_start" }
| { type: "turn_start" }
| {
type: "message_update";
message: PiAgentMessage;
assistantMessageEvent: PiAssistantMessageEvent;
}
| {
type: "tool_execution_start";
toolCallId: string;
toolName: string;
args: unknown;
}
| {
type: "tool_execution_update";
toolCallId: string;
toolName: string;
args?: unknown;
partialResult: unknown;
}
| {
type: "tool_execution_end";
toolCallId: string;
toolName: string;
result: unknown;
isError?: boolean;
}
| { type: "compaction_start"; reason?: "manual" | "threshold" | "overflow" | string }
| { type: "compaction_end"; reason?: string; errorMessage?: string; aborted?: boolean }
| { type: "agent_end"; messages?: PiAgentMessage[] };
export type PiRuntimeEvent =
| PiAgentSessionEvent
| {
type: "extension_ui_request";
id: string;
method: string;
[key: string]: unknown;
}
| {
type: "process_exit";
error: string;
};
@@ -0,0 +1,106 @@
import type {
PiAgentMessage,
PiModel,
PiRpcSlashCommand,
PiRuntimeEvent,
PiSessionState,
PiSessionStats,
} from "./rpc-types.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
export interface PiRuntimeLaunch {
cwd: string;
argv: string[];
env?: Record<string, string>;
model?: string;
thinkingOptionId?: string;
session?: string;
systemPrompt?: string;
mcpConfigPath?: string;
}
export interface PiStartSessionInput {
cwd: string;
model?: string;
thinkingOptionId?: string;
session?: string;
systemPrompt?: string;
mcpConfigPath?: string;
}
export interface PiRuntimeSession {
onEvent(callback: (event: PiRuntimeEvent) => void): () => void;
prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void>;
abort(): Promise<void>;
getState(): Promise<PiSessionState>;
getMessages(): Promise<PiAgentMessage[]>;
getAvailableModels(): Promise<PiModel[]>;
setModel(provider: string, modelId: string): Promise<PiModel>;
setThinkingLevel(level: string): Promise<void>;
getSessionStats(): Promise<PiSessionStats>;
getCommands(): Promise<PiRpcSlashCommand[]>;
cancelExtensionUiRequest(id: string): void;
close(): Promise<void>;
}
export interface PiRuntime {
startSession(input: PiStartSessionInput): Promise<PiRuntimeSession>;
}
export function buildPiLaunch(input: {
command: [string, ...string[]];
runtimeSettings?: ProviderRuntimeSettings;
session: PiStartSessionInput;
}): PiRuntimeLaunch {
const command =
input.runtimeSettings?.command?.mode === "replace" && input.runtimeSettings.command.argv[0]
? input.runtimeSettings.command.argv
: input.command;
const argv = [...command];
if (!hasModeRpc(argv)) {
argv.push("--mode", "rpc");
}
if (input.session.model) {
argv.push("--model", input.session.model);
}
if (input.session.thinkingOptionId) {
argv.push("--thinking", input.session.thinkingOptionId);
}
if (input.session.session) {
argv.push("--session", input.session.session);
}
const systemPrompt = input.session.systemPrompt?.trim();
if (systemPrompt) {
argv.push("--append-system-prompt", systemPrompt);
}
if (input.session.mcpConfigPath) {
argv.push("--mcp-config", input.session.mcpConfigPath);
}
return {
cwd: input.session.cwd,
argv,
env: input.runtimeSettings?.env,
model: input.session.model,
thinkingOptionId: input.session.thinkingOptionId,
session: input.session.session,
systemPrompt,
mcpConfigPath: input.session.mcpConfigPath,
};
}
function hasModeRpc(argv: string[]): boolean {
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--mode" && argv[i + 1] === "rpc") {
return true;
}
if (argv[i] === "--mode=rpc") {
return true;
}
}
return false;
}
@@ -0,0 +1,297 @@
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { listPiPersistedAgents } from "./session-descriptor.js";
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
function tempRoot(): string {
return mkdtempSync(path.join(tmpdir(), "paseo-pi-sessions-"));
}
function writeJsonl(filePath: string, entries: unknown[]): string {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
return filePath;
}
function piSession(overrides: {
id: string;
cwd: string;
timestamp?: string;
entries?: unknown[];
}): unknown[] {
return [
{
type: "session",
version: 3,
id: overrides.id,
timestamp: overrides.timestamp ?? "2026-01-01T00:00:00.000Z",
cwd: overrides.cwd,
},
...(overrides.entries ?? []),
];
}
function message(input: {
role: "user" | "assistant";
content: unknown;
timestamp: string;
}): unknown {
return {
type: "message",
id: `entry-${input.timestamp}`,
parentId: null,
timestamp: input.timestamp,
message: {
role: input.role,
content: input.content,
},
};
}
function sessionInfo(name: string, timestamp: string): unknown {
return {
type: "session_info",
id: `info-${timestamp}`,
parentId: null,
timestamp,
name,
};
}
describe("listPiPersistedAgents", () => {
test("lists Pi sessions from the configured agent dir", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const sessionsDir = path.join(root, "agent", "sessions", "--project--");
const sessionFile = writeJsonl(
path.join(sessionsDir, "20260101_session-a.jsonl"),
piSession({
id: "session-a",
cwd,
entries: [
message({
role: "user",
content: "first prompt",
timestamp: "2026-01-01T00:00:01.000Z",
}),
message({
role: "assistant",
content: [{ type: "text", text: "answer" }],
timestamp: "2026-01-01T00:00:02.000Z",
}),
message({
role: "user",
content: [{ type: "text", text: "last prompt" }],
timestamp: "2026-01-01T00:00:03.000Z",
}),
sessionInfo("Named Pi Session", "2026-01-01T00:00:10.000Z"),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors).toEqual([
{
provider: "pi",
sessionId: "session-a",
cwd,
title: "Named Pi Session",
lastActivityAt: new Date("2026-01-01T00:00:03.000Z"),
persistence: {
provider: "pi",
sessionId: "session-a",
nativeHandle: sessionFile,
metadata: {
provider: "pi",
cwd,
},
},
timeline: [
{ type: "user_message", text: "first prompt" },
{ type: "user_message", text: "last prompt" },
],
},
]);
});
test("uses the runtime session-dir override before Pi settings", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const agentDir = path.join(root, "agent");
const runtimeSessionsDir = path.join(root, "runtime-sessions");
const settingsSessionsDir = path.join(root, "settings-sessions");
mkdirSync(agentDir, { recursive: true });
writeFileSync(
path.join(agentDir, "settings.json"),
JSON.stringify({ sessionDir: settingsSessionsDir }),
"utf8",
);
writeJsonl(
path.join(settingsSessionsDir, "ignored.jsonl"),
piSession({ id: "settings-session", cwd }),
);
writeJsonl(
path.join(runtimeSessionsDir, "selected.jsonl"),
piSession({
id: "runtime-session",
cwd,
entries: [
message({
role: "user",
content: "from runtime dir",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd,
runtimeSettings: {
env: {
PI_CODING_AGENT_DIR: agentDir,
PI_CODING_AGENT_SESSION_DIR: runtimeSessionsDir,
},
},
env: {},
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["runtime-session"]);
});
test("resolves project settings sessionDir relative to the requested cwd", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const projectSettingsDir = path.join(cwd, ".pi");
const sessionsDir = path.join(cwd, "relative-sessions");
mkdirSync(projectSettingsDir, { recursive: true });
writeFileSync(
path.join(projectSettingsDir, "settings.json"),
JSON.stringify({ sessionDir: "relative-sessions" }),
"utf8",
);
writeJsonl(
path.join(sessionsDir, "relative.jsonl"),
piSession({
id: "relative-session",
cwd,
entries: [
message({
role: "user",
content: "relative settings",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["relative-session"]);
});
test("matches sessions stored with the real cwd when the requested cwd is symlinked", async () => {
const root = tempRoot();
const realCwd = path.join(root, "real-project");
const linkedCwd = path.join(root, "linked-project");
mkdirSync(realCwd, { recursive: true });
symlinkSync(realCwd, linkedCwd, directorySymlinkType);
const persistedCwd = realpathSync(linkedCwd);
const sessionsDir = path.join(root, "agent", "sessions");
writeJsonl(
path.join(sessionsDir, "symlinked.jsonl"),
piSession({
id: "symlinked-session",
cwd: persistedCwd,
entries: [
message({
role: "user",
content: "through symlink",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd: linkedCwd,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["symlinked-session"]);
});
test("filters by cwd, sorts by activity, and ignores invalid session files", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const otherCwd = path.join(root, "other");
const sessionsDir = path.join(root, "agent", "sessions");
writeJsonl(
path.join(sessionsDir, "older.jsonl"),
piSession({
id: "older",
cwd,
entries: [
message({
role: "user",
content: "older",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
writeJsonl(
path.join(sessionsDir, "newer.jsonl"),
piSession({
id: "newer",
cwd,
entries: [
message({
role: "user",
content: "newer",
timestamp: "2026-01-01T00:00:02.000Z",
}),
],
}),
);
writeJsonl(
path.join(sessionsDir, "outside.jsonl"),
piSession({
id: "outside",
cwd: otherCwd,
entries: [
message({
role: "user",
content: "outside",
timestamp: "2026-01-01T00:00:03.000Z",
}),
],
}),
);
writeFileSync(path.join(sessionsDir, "broken.jsonl"), '{"type":"nope"}\n', "utf8");
const descriptors = await listPiPersistedAgents({
cwd,
limit: 1,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["newer"]);
});
});
@@ -0,0 +1,372 @@
import { open, readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import type {
AgentPersistenceHandle,
AgentTimelineItem,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
const PI_PROVIDER = "pi";
const PI_CONFIG_DIR_NAME = ".pi";
const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
const HEAD_BYTES = 64 * 1024;
const TAIL_BYTES = 256 * 1024;
const FULL_SCAN_LINE_LIMIT = 2_000;
interface PiSessionDescriptorOptions extends ListPersistedAgentsOptions {
runtimeSettings?: ProviderRuntimeSettings;
env?: NodeJS.ProcessEnv;
homeDir?: string;
}
interface PiSessionHeader {
sessionId: string;
cwd: string;
createdAt: Date | null;
}
interface PiSessionTail {
title: string | null;
lastActivityAt: Date | null;
lastUserMessage: string | null;
}
export async function listPiPersistedAgents(
options: PiSessionDescriptorOptions = {},
): Promise<PersistedAgentDescriptor[]> {
const sessionsDir = await resolvePiSessionsDir(options);
const files = await walkJsonlFiles(sessionsDir);
const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null;
const limit = options.limit ?? 20;
const descriptors: PersistedAgentDescriptor[] = [];
for (const file of files) {
const descriptor = await readPiSessionDescriptor(file);
if (!descriptor) continue;
if (matchesCwd && !matchesCwd(descriptor.cwd)) continue;
descriptors.push(descriptor);
}
return descriptors
.sort((left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime())
.slice(0, limit);
}
async function resolvePiSessionsDir(options: PiSessionDescriptorOptions): Promise<string> {
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? homedir();
const baseDir = options.cwd ?? process.cwd();
const agentDir = resolvePiAgentDir({ runtimeSettings: options.runtimeSettings, env, homeDir });
const envSessionDir =
options.runtimeSettings?.env?.[PI_SESSION_DIR_ENV] ?? env[PI_SESSION_DIR_ENV];
if (envSessionDir?.trim()) {
return resolveConfigPath(envSessionDir, { baseDir, homeDir });
}
const settingsSessionDir = await readConfiguredSessionDir({
agentDir,
cwd: options.cwd,
});
if (settingsSessionDir?.trim()) {
return resolveConfigPath(settingsSessionDir, { baseDir, homeDir });
}
return path.join(agentDir, "sessions");
}
function resolvePiAgentDir(input: {
runtimeSettings?: ProviderRuntimeSettings;
env: NodeJS.ProcessEnv;
homeDir: string;
}): string {
const configured = input.runtimeSettings?.env?.[PI_AGENT_DIR_ENV] ?? input.env[PI_AGENT_DIR_ENV];
if (configured?.trim()) {
return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir });
}
return path.join(input.homeDir, PI_CONFIG_DIR_NAME, "agent");
}
async function readConfiguredSessionDir(input: {
agentDir: string;
cwd: string | undefined;
}): Promise<string | null> {
const values = await Promise.all([
readSessionDirFromSettings(path.join(input.agentDir, "settings.json")),
input.cwd
? readSessionDirFromSettings(path.join(input.cwd, PI_CONFIG_DIR_NAME, "settings.json"))
: null,
]);
return values[1] ?? values[0] ?? null;
}
async function readSessionDirFromSettings(settingsPath: string): Promise<string | null> {
try {
const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const sessionDir = Reflect.get(parsed, "sessionDir");
return typeof sessionDir === "string" && sessionDir.trim() ? sessionDir : null;
} catch {
return null;
}
}
function resolveConfigPath(value: string, options: { baseDir: string; homeDir: string }): string {
if (value === "~") {
return options.homeDir;
}
if (value.startsWith("~/")) {
return path.join(options.homeDir, value.slice(2));
}
return path.isAbsolute(value) ? value : path.resolve(options.baseDir, value);
}
async function walkJsonlFiles(root: string): Promise<string[]> {
let entries: import("node:fs").Dirent[];
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return [];
}
const files = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) {
return await walkJsonlFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith(".jsonl") ? [entryPath] : [];
}),
);
return files.flat();
}
async function readPiSessionDescriptor(filePath: string): Promise<PersistedAgentDescriptor | null> {
const firstLine = await readFirstLine(filePath);
if (!firstLine) return null;
const header = parseSessionHeader(firstLine);
if (!header) return null;
const tail = await readTail(filePath).catch(() => "");
const tailInfo = parseSessionTail(tail);
const headInfo = await scanSessionHead(filePath);
const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage;
const lastActivityAt =
tailInfo.lastActivityAt ?? (await readFileMtime(filePath)) ?? header.createdAt ?? new Date(0);
const timeline = buildPreviewTimeline({
firstUserMessage: headInfo.firstUserMessage,
lastUserMessage: tailInfo.lastUserMessage,
});
const persistence: AgentPersistenceHandle = {
provider: PI_PROVIDER,
sessionId: header.sessionId,
nativeHandle: filePath,
metadata: {
provider: PI_PROVIDER,
cwd: header.cwd,
},
};
return {
provider: PI_PROVIDER,
sessionId: header.sessionId,
cwd: header.cwd,
title,
lastActivityAt,
persistence,
timeline,
};
}
async function readFirstLine(filePath: string): Promise<string | null> {
const handle = await open(filePath, "r").catch(() => null);
if (!handle) return null;
try {
const buffer = Buffer.alloc(HEAD_BYTES);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
if (bytesRead <= 0) return null;
const chunk = buffer.subarray(0, bytesRead).toString("utf8");
const newlineIndex = chunk.indexOf("\n");
return (newlineIndex === -1 ? chunk : chunk.slice(0, newlineIndex)).trim();
} finally {
await handle.close().catch(() => undefined);
}
}
async function readTail(filePath: string): Promise<string> {
const fileStats = await stat(filePath);
const start = Math.max(0, fileStats.size - TAIL_BYTES);
const length = fileStats.size - start;
const handle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await handle.close().catch(() => undefined);
}
}
async function readFileMtime(filePath: string): Promise<Date | null> {
try {
return (await stat(filePath)).mtime;
} catch {
return null;
}
}
function parseSessionHeader(firstLine: string): PiSessionHeader | null {
const entry = parseJsonRecord(firstLine);
if (!entry || entry.type !== "session") return null;
const sessionId = typeof entry.id === "string" ? entry.id : null;
const cwd = typeof entry.cwd === "string" ? entry.cwd : null;
if (!sessionId || !cwd) return null;
const createdAt = parseDate(entry.timestamp);
return { sessionId, cwd, createdAt };
}
function parseSessionTail(tail: string): PiSessionTail {
const lines = tail.split(/\r?\n/);
let title: string | null = null;
let lastActivityAt: Date | null = null;
let fallbackTimestamp: Date | null = null;
let lastUserMessage: string | null = null;
for (let index = lines.length - 1; index >= 0; index -= 1) {
const entry = parseJsonRecord(lines[index].trim());
if (!entry) continue;
if (!title && entry.type === "session_info") {
title = readNonEmptyString(entry.name);
}
const entryTimestamp = parseDate(entry.timestamp);
if (!fallbackTimestamp && entryTimestamp) {
fallbackTimestamp = entryTimestamp;
}
if (entry.type !== "message") {
continue;
}
if (!lastActivityAt && entryTimestamp) {
lastActivityAt = entryTimestamp;
}
if (!lastUserMessage && isRecord(entry.message) && entry.message.role === "user") {
lastUserMessage = extractMessageText(entry.message.content);
}
if (title && lastActivityAt && lastUserMessage) {
break;
}
}
return { title, lastActivityAt: lastActivityAt ?? fallbackTimestamp, lastUserMessage };
}
async function scanSessionHead(filePath: string): Promise<{
title: string | null;
firstUserMessage: string | null;
}> {
let content: string;
try {
content = await readFile(filePath, "utf8");
} catch {
return { title: null, firstUserMessage: null };
}
let title: string | null = null;
let firstUserMessage: string | null = null;
let lineCount = 0;
for (const rawLine of content.split(/\r?\n/)) {
lineCount += 1;
const entry = parseJsonRecord(rawLine.trim());
if (!entry) continue;
if (entry.type === "session_info") {
title = readNonEmptyString(entry.name) ?? title;
}
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
if (entry.message.role === "user") {
firstUserMessage = extractMessageText(entry.message.content);
}
}
if (title && firstUserMessage) {
break;
}
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
break;
}
}
return { title, firstUserMessage };
}
function buildPreviewTimeline(input: {
firstUserMessage: string | null;
lastUserMessage: string | null;
}): AgentTimelineItem[] {
const items: AgentTimelineItem[] = [];
if (input.firstUserMessage) {
items.push({ type: "user_message", text: input.firstUserMessage });
}
if (input.lastUserMessage && input.lastUserMessage !== input.firstUserMessage) {
items.push({ type: "user_message", text: input.lastUserMessage });
}
return items;
}
function parseJsonRecord(line: string): Record<string, unknown> | null {
if (!line) return null;
try {
const parsed = JSON.parse(line) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function parseDate(value: unknown): Date | null {
if (typeof value !== "string" && typeof value !== "number") {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function extractMessageText(content: unknown): string | null {
if (typeof content === "string") {
return content.trim() || null;
}
if (!Array.isArray(content)) {
return null;
}
const text = content
.flatMap((part) =>
isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [],
)
.join("\n\n")
.trim();
return text || null;
}
@@ -0,0 +1,149 @@
import type {
PiRuntime,
PiRuntimeLaunch,
PiRuntimeSession,
PiStartSessionInput,
} from "../runtime.js";
import type {
PiAgentMessage,
PiModel,
PiRpcSlashCommand,
PiRuntimeEvent,
PiSessionState,
PiSessionStats,
} from "../rpc-types.js";
import { buildPiLaunch } from "../runtime.js";
export class FakePi implements PiRuntime {
readonly recordedLaunches: PiRuntimeLaunch[] = [];
private readonly sessions: FakePiSession[] = [];
private readonly command: [string, ...string[]];
private readonly queuedCommands: PiRpcSlashCommand[][] = [];
constructor(command: [string, ...string[]] = ["pi"]) {
this.command = command;
}
async startSession(input: PiStartSessionInput): Promise<FakePiSession> {
const launch = buildPiLaunch({
command: this.command,
session: input,
});
this.recordedLaunches.push(launch);
const session = new FakePiSession(launch);
session.commands = this.queuedCommands.shift() ?? [];
this.sessions.push(session);
return session;
}
queueCommands(commands: PiRpcSlashCommand[]): void {
this.queuedCommands.push(commands);
}
latestSession(): FakePiSession {
const session = this.sessions.at(-1);
if (!session) {
throw new Error("FakePi has no sessions");
}
return session;
}
}
export class FakePiSession implements PiRuntimeSession {
readonly prompts: Array<{ message: string; imageCount: number }> = [];
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
readonly setThinkingLevelRequests: string[] = [];
abortRequested = false;
readonly canceledExtensionUiRequests: string[] = [];
setModelResult: PiModel | null = null;
models: PiModel[] = [];
messages: PiAgentMessage[] = [];
stats: PiSessionStats = {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
};
commands: PiRpcSlashCommand[] = [];
state: PiSessionState;
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
constructor(launch: PiRuntimeLaunch) {
this.state = {
model: null,
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
sessionFile: launch.session ?? "/tmp/pi-session.jsonl",
sessionId: "pi-session-1",
messageCount: 0,
pendingMessageCount: 0,
};
}
onEvent(callback: (event: PiRuntimeEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void> {
this.prompts.push({ message, imageCount: images?.length ?? 0 });
}
async abort(): Promise<void> {
this.abortRequested = true;
}
async getState(): Promise<PiSessionState> {
return this.state;
}
async getMessages(): Promise<PiAgentMessage[]> {
return this.messages;
}
async getAvailableModels(): Promise<PiModel[]> {
return this.models;
}
async setModel(provider: string, modelId: string): Promise<PiModel> {
this.setModelRequests.push({ provider, modelId });
if (!this.setModelResult) {
throw new Error("FakePi setModel requires setModelResult to be scripted");
}
return this.setModelResult;
}
async setThinkingLevel(level: string): Promise<void> {
this.setThinkingLevelRequests.push(level);
}
async getSessionStats(): Promise<PiSessionStats> {
return this.stats;
}
async getCommands(): Promise<PiRpcSlashCommand[]> {
return this.commands;
}
cancelExtensionUiRequest(id: string): void {
this.canceledExtensionUiRequests.push(id);
}
async close(): Promise<void> {}
emit(event: PiRuntimeEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
finishTurn(message: PiAgentMessage = { role: "assistant", content: [] }): void {
this.messages = [...this.messages, message];
this.emit({ type: "agent_end", messages: this.messages });
}
}
@@ -0,0 +1,76 @@
import { describe, expect, test } from "vitest";
import {
mapToolDetail,
parseToolArgs,
parseToolResult,
resolveToolCallName,
} from "./tool-call-mapper.js";
describe("Pi tool call mapper", () => {
test("maps bash args and result to shell detail", () => {
const toolCall = parseToolArgs("bash", { command: "echo hello" });
const result = parseToolResult({ output: "hello\n", exitCode: 0 });
expect(mapToolDetail(toolCall, result)).toEqual({
type: "shell",
command: "echo hello",
output: "hello\n",
exitCode: 0,
});
});
test("maps legacy edit args to edit detail with diff", () => {
const toolCall = parseToolArgs("edit", {
path: "app.ts",
old_string: "before",
new_string: "after",
});
const result = parseToolResult({ details: { diff: "-before\n+after" } });
expect(mapToolDetail(toolCall, result)).toEqual({
type: "edit",
filePath: "app.ts",
oldString: "before",
newString: "after",
unifiedDiff: "-before\n+after",
});
});
test("preserves unknown tool input and parsed output", () => {
const toolCall = parseToolArgs("custom_tool", { value: 42 });
const result = parseToolResult({ text: "custom result" });
expect(mapToolDetail(toolCall, result)).toEqual({
type: "unknown",
input: { value: 42 },
output: { text: "custom result" },
});
});
test("normalizes Pi MCP proxy calls from requested tool args while running", () => {
const toolCall = parseToolArgs("mcp", {
tool: "paseo_list_models",
args: '{"provider":"pi"}',
});
expect(resolveToolCallName(toolCall, null)).toBe("paseo.list_models");
});
test("normalizes Pi MCP proxy calls from result details when completed", () => {
const toolCall = parseToolArgs("mcp", {
tool: "paseo_list_models",
args: '{"provider":"pi"}',
});
const result = parseToolResult({
content: [{ type: "text", text: "(empty result)" }],
details: {
mode: "call",
server: "paseo",
tool: "list_models",
},
});
expect(resolveToolCallName(toolCall, result)).toBe("paseo.list_models");
});
});
@@ -0,0 +1,469 @@
import { z } from "zod";
import type { ToolCallDetail } from "../../agent-sdk-types.js";
interface BashToolInput {
command: string;
timeout?: number;
}
interface ReadToolInput {
path: string;
offset?: number;
limit?: number;
}
interface EditToolInput {
path: string;
edits: Array<{
oldText: string;
newText: string;
}>;
}
interface WriteToolInput {
path: string;
content: string;
}
interface FindToolInput {
pattern: string;
path?: string;
limit?: number;
}
interface GrepToolInput {
pattern: string;
path?: string;
glob?: string;
ignoreCase?: boolean;
literal?: boolean;
context?: number;
limit?: number;
}
interface LsToolInput {
path?: string;
limit?: number;
}
interface PiToolResultObject {
output?: string;
stdout?: string;
text?: string;
content?: PiToolResultContent[];
exitCode?: number;
code?: number;
details?: PiToolResultDetails;
}
interface PiToolResultDetails {
diff?: string;
mode?: string;
server?: string;
tool?: string;
mcpResult?: unknown;
}
interface PiToolResultTextContent {
type: "text";
text: string;
}
interface PiToolResultUnknownContent {
type: string;
}
type PiToolResultContent = PiToolResultTextContent | PiToolResultUnknownContent;
export type PiToolResult = string | PiToolResultObject | null;
interface PiBashToolCall {
kind: "bash";
toolName: "bash";
args: BashToolInput;
}
interface PiReadToolCall {
kind: "read";
toolName: "read";
args: ReadToolInput;
}
interface PiEditToolCall {
kind: "edit";
toolName: "edit";
args: EditToolInput;
}
interface PiWriteToolCall {
kind: "write";
toolName: "write";
args: WriteToolInput;
}
interface PiFindToolCall {
kind: "find";
toolName: "find";
args: FindToolInput;
}
interface PiGrepToolCall {
kind: "grep";
toolName: "grep";
args: GrepToolInput;
}
interface PiLsToolCall {
kind: "ls";
toolName: "ls";
args: LsToolInput;
}
interface PiUnknownToolCall {
kind: "unknown";
toolName: string;
args: unknown;
}
export type PiTrackedToolCall =
| PiBashToolCall
| PiReadToolCall
| PiEditToolCall
| PiWriteToolCall
| PiFindToolCall
| PiGrepToolCall
| PiLsToolCall
| PiUnknownToolCall;
interface ToolCallOutputSummary {
output?: string;
exitCode?: number | null;
}
const PiToolResultTextContentSchema = z.object({
type: z.literal("text"),
text: z.string(),
});
const PiToolResultUnknownContentSchema = z
.object({
type: z.string(),
})
.passthrough();
const PiToolResultContentSchema = z.union([
PiToolResultTextContentSchema,
PiToolResultUnknownContentSchema,
]);
const PiToolResultDetailsSchema = z
.object({
diff: z.string().optional(),
})
.passthrough();
const PiToolResultObjectSchema = z
.object({
output: z.string().optional(),
stdout: z.string().optional(),
text: z.string().optional(),
content: z.array(PiToolResultContentSchema).optional(),
exitCode: z.number().optional(),
code: z.number().optional(),
details: PiToolResultDetailsSchema.optional(),
})
.passthrough();
const PiToolResultSchema = z.union([z.string(), PiToolResultObjectSchema, z.null()]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
const BashToolInputSchema: z.ZodType<BashToolInput> = z.object({
command: z.string(),
timeout: z.number().optional(),
});
const ReadToolInputSchema: z.ZodType<ReadToolInput> = z.object({
path: z.string(),
offset: z.number().optional(),
limit: z.number().optional(),
});
const EditToolInputSchema: z.ZodType<EditToolInput> = z.object({
path: z.string(),
edits: z.array(
z.object({
oldText: z.string(),
newText: z.string(),
}),
),
});
const LegacyEditToolInputSchema = z.object({
path: z.string(),
old_string: z.string().optional(),
oldString: z.string().optional(),
new_string: z.string().optional(),
newString: z.string().optional(),
});
const WriteToolInputSchema: z.ZodType<WriteToolInput> = z.object({
path: z.string(),
content: z.string(),
});
const FindToolInputSchema: z.ZodType<FindToolInput> = z.object({
pattern: z.string(),
path: z.string().optional(),
limit: z.number().optional(),
});
const GrepToolInputSchema: z.ZodType<GrepToolInput> = z.object({
pattern: z.string(),
path: z.string().optional(),
glob: z.string().optional(),
ignoreCase: z.boolean().optional(),
literal: z.boolean().optional(),
context: z.number().optional(),
limit: z.number().optional(),
});
const LsToolInputSchema: z.ZodType<LsToolInput> = z.object({
path: z.string().optional(),
limit: z.number().optional(),
});
export function parseToolResult(rawResult: unknown): PiToolResult {
const parsed = PiToolResultSchema.safeParse(rawResult);
if (parsed.success) {
return parsed.data;
}
return null;
}
export function extractTextFromToolResult(result: PiToolResult): string | undefined {
if (typeof result === "string") {
return result;
}
if (!result) {
return undefined;
}
const directText = result.output ?? result.stdout ?? result.text;
if (directText) {
return directText;
}
if (!result.content) {
return undefined;
}
const textParts: string[] = [];
for (const block of result.content) {
if (block.type === "text" && "text" in block) {
textParts.push(block.text);
}
}
return textParts.length > 0 ? textParts.join("\n") : undefined;
}
export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedToolCall {
if (toolName === "edit") {
return parseEditToolArgs(rawArgs);
}
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
if (schema) {
const parsed = schema.safeParse(rawArgs);
if (parsed.success) {
return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall;
}
}
return { kind: "unknown", toolName, args: rawArgs ?? null };
}
function stripMcpProxyPrefix(toolName: string, serverName: string): string {
const prefix = `${serverName}_`;
return toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName;
}
export function resolveToolCallName(toolCall: PiTrackedToolCall, result?: PiToolResult): string {
if (toolCall.toolName !== "mcp") {
return toolCall.toolName;
}
if (result && typeof result !== "string") {
const serverName = readNonEmptyString(result.details?.server);
const toolName = readNonEmptyString(result.details?.tool);
if (serverName && toolName) {
return `${serverName}.${toolName}`;
}
}
if (isRecord(toolCall.args)) {
const requestedTool = readNonEmptyString(toolCall.args.tool);
const requestedServer = readNonEmptyString(toolCall.args.server);
if (requestedTool && requestedServer) {
return `${requestedServer}.${stripMcpProxyPrefix(requestedTool, requestedServer)}`;
}
if (requestedTool) {
const [serverName, ...toolParts] = requestedTool.split("_");
if (serverName && toolParts.length > 0) {
return `${serverName}.${toolParts.join("_")}`;
}
}
}
return toolCall.toolName;
}
export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult): ToolCallDetail {
const parsedResult = result ?? null;
switch (toolCall.kind) {
case "bash": {
const summary = resolveToolCallOutput(parsedResult);
return {
type: "shell",
command: toolCall.args.command,
output: summary.output,
exitCode: summary.exitCode,
};
}
case "read":
return {
type: "read",
filePath: toolCall.args.path,
content: extractTextFromToolResult(parsedResult),
offset: toolCall.args.offset,
limit: toolCall.args.limit,
};
case "edit": {
const firstEdit = toolCall.args.edits[0];
const unifiedDiff =
parsedResult && typeof parsedResult !== "string" ? parsedResult.details?.diff : undefined;
return {
type: "edit",
filePath: toolCall.args.path,
oldString: firstEdit?.oldText,
newString: firstEdit?.newText,
unifiedDiff,
};
}
case "write":
return {
type: "write",
filePath: toolCall.args.path,
content: toolCall.args.content,
};
case "find":
return mapFindToolDetail(toolCall.args, parsedResult);
case "grep":
return mapGrepToolDetail(toolCall.args, parsedResult);
case "ls":
return mapLsToolDetail(toolCall.args, parsedResult);
default:
return {
type: "unknown",
input: toolCall.args,
output: parsedResult,
};
}
}
function resolveToolCallOutput(result: PiToolResult): ToolCallOutputSummary {
if (typeof result === "string") {
return { output: result };
}
if (!result) {
return {};
}
const summary: ToolCallOutputSummary = {
output: extractTextFromToolResult(result),
};
if (typeof result.exitCode === "number") {
summary.exitCode = result.exitCode;
return summary;
}
if (typeof result.code === "number") {
summary.exitCode = result.code;
return summary;
}
summary.exitCode = null;
return summary;
}
function normalizeLegacyEditArgs(rawArgs: unknown): EditToolInput | null {
const parsed = LegacyEditToolInputSchema.safeParse(rawArgs);
if (!parsed.success) {
return null;
}
const oldText = parsed.data.old_string ?? parsed.data.oldString;
const newText = parsed.data.new_string ?? parsed.data.newString;
if (!oldText || newText === undefined) {
return null;
}
return {
path: parsed.data.path,
edits: [{ oldText, newText }],
};
}
function parseEditToolArgs(rawArgs: unknown): PiTrackedToolCall {
const parsed = EditToolInputSchema.safeParse(rawArgs);
if (parsed.success) {
return { kind: "edit", toolName: "edit", args: parsed.data };
}
const legacyArgs = normalizeLegacyEditArgs(rawArgs);
if (legacyArgs) {
return { kind: "edit", toolName: "edit", args: legacyArgs };
}
return { kind: "unknown", toolName: "edit", args: rawArgs ?? null };
}
type SimpleToolKind = "bash" | "read" | "write" | "find" | "grep" | "ls";
const SIMPLE_TOOL_SCHEMAS: {
[K in SimpleToolKind]: { safeParse: (data: unknown) => { success: boolean; data?: unknown } };
} = {
bash: BashToolInputSchema,
read: ReadToolInputSchema,
write: WriteToolInputSchema,
find: FindToolInputSchema,
grep: GrepToolInputSchema,
ls: LsToolInputSchema,
};
function mapFindToolDetail(args: FindToolInput, result: PiToolResult): ToolCallDetail {
return {
type: "search",
query: args.pattern,
toolName: "search",
content: typeof result === "string" ? result : undefined,
};
}
function mapGrepToolDetail(args: GrepToolInput, result: PiToolResult): ToolCallDetail {
return {
type: "search",
query: args.pattern,
toolName: "grep",
content: typeof result === "string" ? result : undefined,
};
}
function mapLsToolDetail(args: LsToolInput, result: PiToolResult): ToolCallDetail {
return {
type: "search",
query: args.path ?? "ls",
content: typeof result === "string" ? result : undefined,
};
}
@@ -166,6 +166,28 @@ describe("DaemonConfigStore", () => {
});
});
test("patch persists daemon append system prompt into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(
paseoHome,
{
mcp: { injectIntoAgents: false },
providers: {},
appendSystemPrompt: "",
},
undefined,
);
store.patch({
appendSystemPrompt: "Prefer terse replies.",
});
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
});
test("patch persists custom ACP provider overrides into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -10,7 +10,7 @@ import type {
AgentStreamEvent,
AgentTimelineItem,
} from "../agent/agent-sdk-types.js";
import { PiDirectAgentClient } from "../agent/providers/pi-direct-agent.js";
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { isProviderAvailable } from "./agent-configs.js";
@@ -26,28 +26,18 @@ function tmpCwd(prefix = "daemon-real-pi-"): string {
return mkdtempSync(path.join(tmpdir(), prefix));
}
function createPiClient(): PiDirectAgentClient {
return new PiDirectAgentClient({ logger: pino({ level: "silent" }) });
function createPiClient(): PiRpcAgentClient {
return new PiRpcAgentClient({ logger: pino({ level: "silent" }) });
}
function createPiToolDaemon() {
const logger = pino({ level: "silent" });
return createTestPaseoDaemon({
agentClients: { pi: new PiDirectAgentClient({ logger }) },
agentClients: { pi: new PiRpcAgentClient({ logger }) },
logger,
});
}
function extractAssistantText(items: AgentTimelineItem[]): string {
return items
.filter(
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
item.type === "assistant_message",
)
.map((item) => item.text)
.join("\n");
}
function extractCompletedToolCalls(items: AgentTimelineItem[]): ToolCallItem[] {
return items.filter(
(item): item is ToolCallItem => item.type === "tool_call" && item.status === "completed",
@@ -77,7 +67,10 @@ async function withConnectedPiDaemon(
run: (context: { client: DaemonClient }) => Promise<void>,
): Promise<void> {
const daemon = await createPiToolDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.45",
});
try {
await client.connect();
@@ -344,14 +337,16 @@ test(
await client.deleteAgent(agent.id);
const resumed = await client.resumeAgent(handle);
await client.sendMessage(resumed.id, "What was the code I asked you to remember?");
expect(resumed.provider).toBe("pi");
expect(resumed.cwd).toBe(cwd);
await client.sendMessage(resumed.id, "Reply with exactly: resumed");
const resumedFinish = await client.waitForFinish(resumed.id, PI_TEST_TIMEOUT_MS);
expect(resumedFinish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, resumed.id);
const assistantText = extractAssistantText(items).toUpperCase();
expect(assistantText.includes(rememberedToken) || assistantText.includes("42")).toBe(true);
expect(resumedFinish.final?.persistence).toBeTruthy();
expect(resumedFinish.final?.persistence?.provider).toBe("pi");
expect(resumedFinish.final?.persistence?.nativeHandle).toBe(handle.nativeHandle);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
@@ -361,7 +356,7 @@ test(
);
test(
"streamHistory replays user/assistant/tool_call timeline after resume",
"streamHistory replays user and assistant timeline after resume",
async () => {
const cwd = tmpCwd("pi-history-prime-");
const marker = "HISTORY_PRIME_MARKER_4242";
@@ -376,18 +371,8 @@ test(
let handle: AgentPersistenceHandle | null = null;
try {
const result = await session.run(
`Use the bash tool to run this exact bash command: echo ${marker}`,
);
const hadShellToolCall = result.timeline.some(
(item) =>
item.type === "tool_call" &&
item.status === "completed" &&
item.detail.type === "shell" &&
item.detail.command.includes(`echo ${marker}`),
);
expect(hadShellToolCall).toBe(true);
const result = await session.run(`Reply with exactly this token and nothing else: ${marker}`);
expect(result.finalText).toContain(marker);
handle = session.describePersistence();
expect(handle).toBeTruthy();
@@ -423,15 +408,7 @@ test(
item.type === "assistant_message",
);
expect(assistantItems.length).toBeGreaterThan(0);
const toolCallItem = findCompletedToolCall(
items,
(item) => item.detail.type === "shell" && item.detail.command.includes(`echo ${marker}`),
);
expect(toolCallItem).toBeDefined();
if (toolCallItem?.detail.type === "shell") {
expect(toolCallItem.detail.output).toContain(marker);
}
expect(assistantItems.some((item) => item.text.includes(marker))).toBe(true);
} finally {
await resumed.close();
rmSync(cwd, { recursive: true, force: true });
@@ -441,7 +418,7 @@ test(
);
test(
"PiDirectAgentClient.listModels returns non-empty Pi model definitions",
"PiRpcAgentClient.listModels returns non-empty Pi model definitions",
async () => {
const client = createPiClient();
const cwd = tmpCwd("pi-list-models-");
@@ -63,6 +63,18 @@ describe("PersistedConfigSchema daemon relay config", () => {
});
});
describe("PersistedConfigSchema daemon append system prompt", () => {
test("accepts optional append system prompt", () => {
const parsed = PersistedConfigSchema.parse({
daemon: {
appendSystemPrompt: "Prefer terse replies.",
},
});
expect(parsed.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
});
});
describe("PersistedConfigSchema agent provider runtime settings", () => {
test("legacy append entries are skipped during migration", () => {
const parsed = PersistedConfigSchema.parse({
@@ -3,4 +3,4 @@
* providers (gemini, copilot, generic acp) are excluded because they either
* don't expose persisted history quickly or duplicate other providers.
*/
export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode"] as const;
export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode", "pi"] as const;
+35
View File
@@ -1,4 +1,5 @@
import os from "node:os";
import { realpathSync } from "node:fs";
import nodePath from "node:path";
/**
@@ -46,6 +47,40 @@ export function createPathEquivalenceMatcher(target: string): (candidate: string
};
}
export function createRealpathAwarePathMatcher(target: string): (candidate: string) => boolean {
const targetMatchers = collectPathVariants(target).map((variant) =>
createPathEquivalenceMatcher(variant),
);
return (candidate) => {
const candidateVariants = collectPathVariants(candidate);
return candidateVariants.some((variant) => targetMatchers.some((matches) => matches(variant)));
};
}
function collectPathVariants(value: string): string[] {
const variants = new Set<string>([value]);
for (const realpath of resolveRealpathVariants(value)) {
variants.add(realpath);
}
return Array.from(variants);
}
function resolveRealpathVariants(value: string): string[] {
const variants: string[] = [];
try {
variants.push(realpathSync.native(value));
} catch {
// Path may not exist, or the platform may reject this link flavor.
}
try {
variants.push(realpathSync(value));
} catch {
// Keep string-only comparison as the fallback.
}
return variants;
}
function shouldCompareAsWindows(left: string, right: string): boolean {
return looksLikeDefiniteWindowsPath(left) || looksLikeDefiniteWindowsPath(right);
}