From ee83dbd13843d170e2e64b622bdf1c61267bca99 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:45:28 +0530 Subject: [PATCH 1/3] feat(pi): add rpc transport --- src/local-agent-pi-rpc.test.ts | 38 +++++++ src/local-agent-pi-rpc.ts | 197 +++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 src/local-agent-pi-rpc.test.ts create mode 100644 src/local-agent-pi-rpc.ts diff --git a/src/local-agent-pi-rpc.test.ts b/src/local-agent-pi-rpc.test.ts new file mode 100644 index 000000000..432175801 --- /dev/null +++ b/src/local-agent-pi-rpc.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { PiRpcConnection } from "./local-agent-pi-rpc.js"; + +const script = String.raw` +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + for (;;) { + const newline = buffer.indexOf("\n"); + if (newline < 0) break; + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (!line) continue; + const request = JSON.parse(line); + process.stdout.write("not-json\n"); + process.stdout.write(JSON.stringify({ type: "agent_start" }) + "\n"); + process.stdout.write(JSON.stringify({ + type: "response", + id: request.id, + command: request.type, + success: true, + data: { echoed: request.value }, + }) + "\n"); + } +}); +`; + +const connection = PiRpcConnection.spawn({ + command: process.execPath, + args: ["-e", script], + cwd: process.cwd(), + env: process.env, +}); +const response = await connection.request({ type: "echo", value: "hello" }); +assert.deepEqual(response, { echoed: "hello" }); +assert.deepEqual(await connection.nextEvent(1_000), { type: "agent_start" }); +connection.close(); diff --git a/src/local-agent-pi-rpc.ts b/src/local-agent-pi-rpc.ts new file mode 100644 index 000000000..25fa4ff40 --- /dev/null +++ b/src/local-agent-pi-rpc.ts @@ -0,0 +1,197 @@ +import { createRequire } from "node:module"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { terminateProcessTree } from "./process-platform.js"; + +const require = createRequire(import.meta.url); +const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const MAX_RECORD_CHARS = 8 * 1024 * 1024; + +export type PiRpcRecord = Record; + +interface PendingRequest { + resolve(value: unknown): void; + reject(error: Error): void; + timer: NodeJS.Timeout; +} + +export class PiRpcConnection { + private readonly pending = new Map(); + private readonly queuedEvents: PiRpcRecord[] = []; + private readonly eventWaiters: Array<{ + resolve(value: PiRpcRecord): void; + reject(error: Error): void; + timer?: NodeJS.Timeout; + }> = []; + private nextRequestId = 0; + private buffer = ""; + private closed = false; + private failure?: Error; + + private constructor(private readonly child: ChildProcessWithoutNullStreams) { + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => this.consume(chunk)); + child.once("error", (error) => this.fail(error)); + child.once("exit", (code) => { + if (!this.closed) this.fail(new Error(`Pi RPC process exited with code ${code}.`)); + }); + } + + static spawn(input: { + command: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; + }): PiRpcConnection { + const detached = process.platform !== "win32"; + const child = spawn(input.command, input.args, { + cwd: input.cwd, + env: input.env, + stdio: ["pipe", "pipe", "pipe"], + detached, + windowsHide: true, + }); + if (!child.stdin || !child.stdout || !child.stderr) { + throw new Error("Pi RPC process did not expose stdio pipes."); + } + return new PiRpcConnection(child as ChildProcessWithoutNullStreams); + } + + async request(record: PiRpcRecord, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): Promise { + this.assertOpen(); + const id = `devspace-${++this.nextRequestId}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Pi RPC ${String(record.type ?? "request")} timed out after ${timeoutMs}ms.`)); + }, timeoutMs); + timer.unref(); + this.pending.set(id, { resolve, reject, timer }); + this.write({ ...record, id }).catch((error) => { + const pending = this.pending.get(id); + if (!pending) return; + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(error); + }); + }); + } + + async send(record: PiRpcRecord): Promise { + this.assertOpen(); + await this.write(record); + } + + async nextEvent(timeoutMs?: number): Promise { + this.assertOpen(); + const queued = this.queuedEvents.shift(); + if (queued) return queued; + return new Promise((resolve, reject) => { + const waiter: (typeof this.eventWaiters)[number] = { + resolve: (value) => { + if (waiter.timer) clearTimeout(waiter.timer); + resolve(value); + }, + reject: (error) => { + if (waiter.timer) clearTimeout(waiter.timer); + reject(error); + }, + }; + if (timeoutMs !== undefined) { + waiter.timer = setTimeout(() => { + const index = this.eventWaiters.indexOf(waiter); + if (index >= 0) this.eventWaiters.splice(index, 1); + resolve(undefined); + }, timeoutMs); + waiter.timer.unref(); + } + this.eventWaiters.push(waiter); + }); + } + + isAlive(): boolean { + return !this.closed && !this.failure && this.child.exitCode === null; + } + + close(): void { + if (this.closed) return; + this.closed = true; + const detached = process.platform !== "win32"; + terminateProcessTree(this.child, "SIGTERM", detached); + this.fail(new Error("Pi RPC process closed.")); + } + + private async write(record: PiRpcRecord): Promise { + const line = `${JSON.stringify(record)}\n`; + await new Promise((resolve, reject) => { + this.child.stdin.write(line, (error) => error ? reject(error) : resolve()); + }); + } + + private consume(chunk: string): void { + this.buffer += chunk; + if (this.buffer.length > MAX_RECORD_CHARS && !this.buffer.includes("\n")) { + this.buffer = ""; + return; + } + for (;;) { + const newline = this.buffer.indexOf("\n"); + if (newline < 0) return; + let line = this.buffer.slice(0, newline); + this.buffer = this.buffer.slice(newline + 1); + if (line.endsWith("\r")) line = line.slice(0, -1); + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue; + this.route(parsed as PiRpcRecord); + } + } + + private route(record: PiRpcRecord): void { + if (record.type === "response" && typeof record.id === "string") { + const pending = this.pending.get(record.id); + if (pending) { + this.pending.delete(record.id); + clearTimeout(pending.timer); + if (record.success === true) pending.resolve(record.data); + else pending.reject(new Error(String(record.error ?? `Pi RPC ${String(record.command)} failed.`))); + return; + } + } + const waiter = this.eventWaiters.shift(); + if (waiter) waiter.resolve(record); + else this.queuedEvents.push(record); + } + + private assertOpen(): void { + if (this.failure) throw this.failure; + if (this.closed) throw new Error("Pi RPC process is closed."); + } + + private fail(error: Error): void { + if (!this.failure) this.failure = error; + for (const [id, pending] of this.pending) { + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(error); + } + for (const waiter of this.eventWaiters.splice(0)) waiter.reject(error); + } +} + +export function piRecordString(value: unknown, key: string): string | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const field = (value as Record)[key]; + return typeof field === "string" ? field : undefined; +} + +export function parsePiModelSlug(model: string): { provider: string; modelId: string } | undefined { + const separator = model.indexOf("/"); + if (separator <= 0 || separator === model.length - 1) return undefined; + return { provider: model.slice(0, separator), modelId: model.slice(separator + 1) }; +} From f46d68bdb140a07a7ed3b3d5947d30135e30c19b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:45:28 +0530 Subject: [PATCH 2/3] refactor(pi): run agent through native rpc cli --- src/local-agent-adapters.ts | 6 +- src/local-agent-availability.ts | 4 +- src/local-agent-config.test.ts | 17 +- src/local-agent-config.ts | 4 +- src/local-agent-pi.test.ts | 202 ++++++------ src/local-agent-pi.ts | 540 ++++++++++++++++---------------- 6 files changed, 403 insertions(+), 370 deletions(-) diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 106597e6b..8cb3926c0 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -27,7 +27,7 @@ import { PiLocalAgentDriver, extractPiFinalResponse, extractPiProviderError, - type PiSessionFactory, + type PiRpcFactory, } from "./local-agent-pi.js"; import type { LocalAgentDriver } from "./local-agent-runtime.js"; import { LocalAgentProviderRegistry } from "./local-agent-provider-registry.js"; @@ -39,7 +39,7 @@ export interface LocalAgentDriverOptions { subagents?: SubagentsConfig; claudeQueryFactory?: ClaudeQueryFactory; opencodeFactory?: OpencodeFactory; - piSessionFactory?: PiSessionFactory; + piRpcFactory?: PiRpcFactory; } export function createLocalAgentDrivers( @@ -62,7 +62,7 @@ export function createLocalAgentProviderRegistry( .register("codex", ({ env }) => new CodexLocalAgentDriver(env)) .register("claude", ({ env }) => new ClaudeLocalAgentDriver(options.claudeQueryFactory, env)) .register("opencode", ({ env }) => new OpencodeLocalAgentDriver({ factory: options.opencodeFactory, env })) - .register("pi", ({ envOverrides }) => new PiLocalAgentDriver(options.piSessionFactory, envOverrides)) + .register("pi", ({ env }) => new PiLocalAgentDriver(options.piRpcFactory, env)) .register("acp", ({ instance, env }) => new AcpLocalAgentDriver({ env, command: instance.command, diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index a18eb8989..31992ea38 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -53,7 +53,9 @@ function checkLocalAgentProviderAvailability( case "opencode": return commandAvailability(providerInstanceId, "opencode", providerEnv); case "pi": - return packageAvailability(providerInstanceId, "@earendil-works/pi-coding-agent"); + return providerEnv.PI_COMMAND + ? commandAvailability(providerInstanceId, providerEnv.PI_COMMAND, providerEnv) + : packageAvailability(providerInstanceId, "@earendil-works/pi-coding-agent"); case "acp": { const configured = config ? subagentProviderConfig(config, providerInstanceId) : undefined; const command = configured?.command diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index 410516313..58e1652b3 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -165,10 +165,15 @@ for (const id of ["opencode", "pi"] as const) { providers: [{ id, enabled: true, env: { HARNESS_ENV: id } }], }); assert.equal(localAgentProviderEnvironment(embedded, id, {}).HARNESS_ENV, id); - assert.throws( - () => subagentsConfigSchema.parse({ - enabled: true, - providers: [{ id, enabled: true, command: "/opt/bin/agent" }], - }), - ); } +assert.throws( + () => subagentsConfigSchema.parse({ + enabled: true, + providers: [{ id: "opencode", enabled: true, command: "/opt/bin/agent" }], + }), +); +const piCommand = parseSubagentsConfig({ + enabled: true, + providers: [{ id: "pi", enabled: true, command: "/opt/bin/pi" }], +}); +assert.equal(localAgentProviderEnvironment(piCommand, "pi", {}).PI_COMMAND, "/opt/bin/pi"); diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index ba2869493..901a83a83 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -53,7 +53,7 @@ const providerSchema = z.object({ }); return; } - if ((driver === "opencode" || driver === "pi") && provider.command !== undefined) { + if (driver === "opencode" && provider.command !== undefined) { context.addIssue({ code: "custom", path: ["command"], @@ -169,8 +169,8 @@ export function providerCommandVariable(driver: LocalAgentDriverKind): string | switch (driver) { case "codex": return "CODEX_COMMAND"; case "claude": return "CLAUDE_COMMAND"; + case "pi": return "PI_COMMAND"; case "opencode": - case "pi": case "acp": return undefined; } diff --git a/src/local-agent-pi.test.ts b/src/local-agent-pi.test.ts index 28b945eab..e3b7cf1e5 100644 --- a/src/local-agent-pi.test.ts +++ b/src/local-agent-pi.test.ts @@ -1,68 +1,87 @@ import assert from "node:assert/strict"; -import type { AgentSessionEvent, AgentSessionEventListener } from "@earendil-works/pi-coding-agent"; import { PiLocalAgentDriver, - type PiSessionFactory, - type PiSessionLike, + type PiRpcFactory, + type PiRpcRuntimeConnection, } from "./local-agent-pi.js"; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import type { LocalAgentRuntimeContext } from "./local-agent-runtime.js"; -class FakePiSession implements PiSessionLike { - readonly sessionId = "pi_session_1"; - readonly messages: any[] = []; - readonly modelRegistry = { find: () => ({ id: "model" }) } as unknown as PiSessionLike["modelRegistry"]; - private readonly listeners = new Set(); - disposeCount = 0; - model?: unknown; - effort?: unknown; - activeTools: string[] = []; - toolHistory: string[][] = []; +class FakePiRpcConnection implements PiRpcRuntimeConnection { + readonly requests: Array> = []; + readonly events: Array> = []; + sessionFile = "/sessions/one.jsonl"; + lastPrompt = ""; + alive = true; + cleanupCount = 0; - async prompt(text: string): Promise { - const message = { - role: "assistant", - content: [{ type: "text", text: `response:${text}` }], - }; - this.messages.push(message); - for (const listener of this.listeners) listener({ type: "agent_end" } as AgentSessionEvent); + async request(record: Record): Promise { + this.requests.push(record); + switch (record.type) { + case "new_session": + this.sessionFile = `/sessions/${this.requests.length}.jsonl`; + return { cancelled: false }; + case "switch_session": + this.sessionFile = String(record.sessionPath); + return { cancelled: false }; + case "get_state": + return { + sessionFile: this.sessionFile, + isStreaming: false, + isCompacting: false, + pendingMessageCount: 0, + }; + case "get_available_models": + return { models: [{ provider: "provider", id: "model" }] }; + case "set_model": + case "set_thinking_level": + case "abort": + return undefined; + case "prompt": + this.lastPrompt = String(record.message); + this.events.push( + { type: "agent_start" }, + { type: "agent_end" }, + { type: "agent_settled" }, + ); + return undefined; + case "get_last_assistant_text": + return { text: `response:${this.lastPrompt}` }; + default: + throw new Error(`unexpected request: ${String(record.type)}`); + } } - async abort(): Promise {} + async send(_record: Record): Promise {} - subscribe(listener: AgentSessionEventListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); + async nextEvent(): Promise | undefined> { + return this.events.shift(); } - async setModel(model: any): Promise { - this.model = model; + isAlive(): boolean { + return this.alive; } - setActiveToolsByName(toolNames: string[]): void { - this.activeTools = [...toolNames]; - this.toolHistory.push([...toolNames]); - } - - setThinkingLevel(level: any): void { - this.effort = level; - } - - dispose(): void { - this.disposeCount += 1; + close(): void { + this.alive = false; } } const contexts: LocalAgentRuntimeContext[] = []; -const sessions: FakePiSession[] = []; +const connections: FakePiRpcConnection[] = []; let factoryEnv: NodeJS.ProcessEnv | undefined; -const factory: PiSessionFactory = async (context, _input, env) => { +let cleanupCount = 0; +const factory: PiRpcFactory = async (context, env) => { contexts.push(context); factoryEnv = env; - const session = new FakePiSession(); - sessions.push(session); - return session; + const connection = new FakePiRpcConnection(); + connections.push(connection); + return { + connection, + cleanup: async () => { cleanupCount += 1; }, + }; }; + const driver = new PiLocalAgentDriver(factory, { HARNESS_ENV: "pi" }); const pool = new LocalAgentRuntimePool(); const context: LocalAgentRuntimeContext = { @@ -70,6 +89,7 @@ const context: LocalAgentRuntimeContext = { providerInstanceId: "pi", provider: "pi", workspaceRoot: "/tmp/project", + writeMode: "read_only", }; const sessionIds: string[] = []; @@ -83,75 +103,73 @@ const first = await pool.run(driver, context, { onSessionId: (sessionId) => { sessionIds.push(sessionId); }, }); assert.equal(factoryEnv?.HARNESS_ENV, "pi"); +assert.equal(first.isOk(), true); +if (first.isErr()) throw first.error; +assert.match(first.value.providerSessionId ?? "", /^\/sessions\/\d+\.jsonl$/); +assert.equal(first.value.finalResponse, "response:first"); +assert.deepEqual(sessionIds, [first.value.providerSessionId]); +assert.deepEqual( + connections[0]?.requests.filter((request) => request.type === "set_model")[0], + { type: "set_model", provider: "provider", modelId: "model" }, +); +assert.deepEqual( + connections[0]?.requests.filter((request) => request.type === "set_thinking_level")[0], + { type: "set_thinking_level", level: "high" }, +); +assert.ok(first.value.items.some((event: any) => event.type === "agent_end")); +assert.ok(first.value.items.some((event: any) => event.type === "agent_settled")); + const second = await pool.run(driver, context, { prompt: "second", workspaceRoot: "/tmp/project", - writeMode: "allowed", -}); -await pool.run(driver, context, { - prompt: "third", - workspaceRoot: "/tmp/project", - writeMode: "full_access", -}); -await pool.run(driver, context, { - prompt: "fourth", - workspaceRoot: "/tmp/project", + providerSessionId: first.value.providerSessionId ?? undefined, writeMode: "read_only", }); -assert.equal(contexts.length, 1, "one warm Pi session serves successive turns"); -assert.equal(first.isOk(), true); assert.equal(second.isOk(), true); -if (first.isErr()) throw first.error; -if (second.isErr()) throw second.error; -assert.equal(first.value.providerSessionId, "pi_session_1"); -assert.equal(second.value.finalResponse, "response:second"); -assert.deepEqual(sessions[0]?.model, { id: "model" }); -assert.equal(sessions[0]?.effort, "high"); -assert.deepEqual(sessionIds, ["pi_session_1"]); -assert.deepEqual(sessions[0]?.activeTools, ["read", "grep", "find", "ls"]); -assert.deepEqual(sessions[0]?.toolHistory, [ - ["read", "grep", "find", "ls"], - ["read", "grep", "find", "ls", "edit", "write", "bash"], - ["read", "grep", "find", "ls", "edit", "write", "bash"], - ["read", "grep", "find", "ls"], -]); +assert.equal(contexts.length, 1, "same agent and write mode reuse one Pi RPC process"); -await pool.run(driver, { ...context, providerSessionId: "pi_session_1" }, { - prompt: "fifth", +await pool.run(driver, { ...context, writeMode: "allowed" }, { + prompt: "allowed", workspaceRoot: "/tmp/project", - providerSessionId: "pi_session_1", + providerSessionId: first.value.providerSessionId ?? undefined, writeMode: "allowed", }); -assert.deepEqual(sessions[0]?.activeTools, ["read", "grep", "find", "ls", "edit", "write", "bash"]); +assert.equal(contexts.length, 2, "changing Pi write mode creates a process with the matching fixed permission policy"); +assert.ok(connections[1]?.requests.some((request) => request.type === "switch_session")); await pool.evictIdle(Date.now() + 10 * 60_000); -assert.equal(sessions[0]?.disposeCount, 1, "idle eviction disposes the in-process session"); +assert.equal(cleanupCount, 2, "idle eviction closes external Pi processes and sandbox wrappers"); -await pool.run(driver, { ...context, providerSessionId: "pi_session_1" }, { +await pool.run(driver, { ...context, providerSessionId: first.value.providerSessionId ?? undefined }, { prompt: "resumed", workspaceRoot: "/tmp/project", - providerSessionId: "pi_session_1", + providerSessionId: first.value.providerSessionId ?? undefined, + writeMode: "read_only", }); -assert.equal(contexts.length, 2, "cold continuation creates a new AgentSession"); -assert.equal(contexts[1]?.providerSessionId, "pi_session_1"); -assert.deepEqual(sessions[1]?.activeTools, ["read", "grep", "find", "ls", "edit", "write", "bash"]); +assert.equal(contexts.length, 3, "cold continuation starts a new Pi RPC process"); +assert.ok(connections[2]?.requests.some((request) => request.type === "switch_session")); await pool.close(); -const missingModelSession = new FakePiSession(); -Object.defineProperty(missingModelSession, "modelRegistry", { value: { find: () => undefined } }); -const missingModelDriver = new PiLocalAgentDriver(async () => missingModelSession); -const missingModelRuntime = await missingModelDriver.createRuntime(context); -assert.equal(missingModelRuntime.isOk(), true); -if (missingModelRuntime.isErr()) throw missingModelRuntime.error; -const missingModel = await missingModelRuntime.value.run({ +const missingModel = new FakePiRpcConnection(); +missingModel.request = async function(record: Record): Promise { + this.requests.push(record); + if (record.type === "get_available_models") return { models: [] }; + if (record.type === "new_session") return { cancelled: false }; + if (record.type === "get_state") return { sessionFile: "/sessions/missing.jsonl" }; + throw new Error(`unexpected request: ${String(record.type)}`); +}; +const missingModelDriver = new PiLocalAgentDriver(async () => ({ connection: missingModel })); +const missingRuntime = await missingModelDriver.createRuntime(context); +assert.equal(missingRuntime.isOk(), true); +if (missingRuntime.isErr()) throw missingRuntime.error; +const missingResult = await missingRuntime.value.run({ prompt: "inspect", workspaceRoot: "/tmp/project", - model: "provider/missing-model", + model: "missing-model", }); -assert.equal(missingModel.isErr(), true); -if (missingModel.isErr()) { - assert.equal(missingModel.error.code, "PROVIDER_PROTOCOL_ERROR"); - assert.equal(missingModel.error.retryable, false); - assert.match(missingModel.error.message, /provider\/missing-model/); +assert.equal(missingResult.isErr(), true); +if (missingResult.isErr()) { + assert.equal(missingResult.error.code, "PROVIDER_PROTOCOL_ERROR"); + assert.match(missingResult.error.message, /missing-model/); } -await missingModelRuntime.value.close(); +await missingRuntime.value.close(); diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index 4d2a5fd0a..5c5243793 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -1,5 +1,9 @@ -import { join } from "node:path"; -import type { AgentSession, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { AgentProviderExecutionError, AgentProviderProtocolError, @@ -7,6 +11,13 @@ import { captureAgentProviderResult, } from "./local-agent-errors.js"; import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; +import { resolveExecutableCommand } from "./local-agent-command.js"; +import { + PiRpcConnection, + parsePiModelSlug, + piRecordString, + type PiRpcRecord, +} from "./local-agent-pi-rpc.js"; import type { LocalAgentDriver, LocalAgentRunCallbacks, @@ -15,56 +26,36 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; -import { - createPiSandboxExtension, - createPiSandboxModeRef, - registerPiSandboxSession, - releasePiSandboxSession, - updatePiSandboxSession, -} from "./local-agent-pi-sandbox.js"; +const require = createRequire(import.meta.url); const PI_READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const; -const PI_WORKSPACE_TOOLS = ["read", "grep", "find", "ls", "edit", "write", "bash"] as const; -const PI_FULL_ACCESS_TOOLS = [...PI_WORKSPACE_TOOLS] as const; +const PI_ALLOWED_TOOLS = [...PI_READ_ONLY_TOOLS, "edit", "write", "bash"] as const; +const PI_TURN_TIMEOUT_MS = 5 * 60_000; +const PI_STATE_POLL_MS = 200; const MAX_PI_EVENTS = 10_000; -export type PiSessionLike = Pick< - AgentSession, - | "sessionId" - | "messages" - | "modelRegistry" - | "prompt" - | "subscribe" - | "setActiveToolsByName" - | "setModel" - | "setThinkingLevel" - | "dispose" - | "abort" ->; +export interface PiRpcRuntimeConnection { + request(record: PiRpcRecord, timeoutMs?: number): Promise; + send(record: PiRpcRecord): Promise; + nextEvent(timeoutMs?: number): Promise; + isAlive(): boolean; + close(): void; +} -export type PiSessionFactory = ( +export type PiRpcFactory = ( context: LocalAgentRuntimeContext, - input: LocalAgentRunInput, - env?: NodeJS.ProcessEnv, -) => Promise; + env: NodeJS.ProcessEnv, +) => Promise<{ connection: PiRpcRuntimeConnection; cleanup?: () => Promise }>; -export class PiSessionRuntime implements LocalAgentRuntime { +export class PiRpcRuntime implements LocalAgentRuntime { readonly provider = "pi" as const; - private readonly unsubscribe: () => void; - private alive = true; + private sessionFile?: string; private closed = false; - private collectingEvents = false; - private events: unknown[] = []; constructor( - private readonly session: PiSessionLike, - ) { - this.unsubscribe = session.subscribe((event) => { - if (!this.collectingEvents) return; - if (this.events.length >= MAX_PI_EVENTS) this.events.shift(); - this.events.push(event); - }); - } + private readonly connection: PiRpcRuntimeConnection, + private readonly cleanup?: () => Promise, + ) {} async run(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks) { return captureAgentProviderResult({ @@ -77,53 +68,53 @@ export class PiSessionRuntime implements LocalAgentRuntime { provider: this.provider, operation: "run", retryable: true, - message: "Pi runtime is not running.", + message: "Pi RPC process is not running.", }); } - const removeAbort = bindLocalAgentAbort(input.signal, () => this.session.abort()); - try { - await callbacks?.onSessionId?.(this.session.sessionId); + + const providerSessionId = await this.ensureSession(input.providerSessionId); + await callbacks?.onSessionId?.(providerSessionId); await this.applyOverrides(input); - this.events = []; - const messageStart = this.session.messages.length; - this.collectingEvents = true; + + const events: PiRpcRecord[] = []; + const removeAbort = bindLocalAgentAbort(input.signal, async () => { + await this.connection.request({ type: "abort" }, 2_000).catch(() => undefined); + }); try { - await this.session.prompt(input.prompt); + await this.connection.request({ type: "prompt", message: input.prompt }); + await this.waitForSettlement(events, input.signal); if (input.signal?.aborted) throw localAgentCancelledError("pi", "run"); - } catch (cause) { - if (input.signal?.aborted) throw localAgentCancelledError("pi", "run", cause); - throw cause; - } finally { - this.collectingEvents = false; - } - const currentMessages = this.session.messages.slice(messageStart); - const finalResponse = extractPiFinalResponse({ messages: currentMessages }); - if (!finalResponse) { - const providerError = extractPiProviderError(this.events) || extractPiProviderError(currentMessages); - if (providerError) { - throw new AgentProviderExecutionError({ - code: "PROVIDER_EXECUTION_ERROR", - provider: this.provider, + const response = await this.connection.request({ type: "get_last_assistant_text" }); + const finalResponse = piRecordString(response, "text")?.trim(); + if (!finalResponse) { + const providerError = extractPiProviderError(events); + if (providerError) { + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: "pi", + operation: "run", + retryable: false, + cause: new Error(providerError), + message: "Pi agent turn failed.", + }); + } + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "pi", operation: "run", retryable: false, - cause: new Error(providerError), - message: "Pi agent turn failed.", + message: "Pi did not return a final assistant response.", }); } - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", + return { provider: this.provider, - operation: "run", - retryable: false, - message: "Pi did not return a final assistant response.", - }); - } - return { - provider: this.provider, - providerSessionId: this.session.sessionId, - finalResponse, - items: [...this.events, ...currentMessages], - }; + providerSessionId, + finalResponse, + items: events, + }; + } catch (cause) { + if (input.signal?.aborted) throw localAgentCancelledError("pi", "run", cause); + throw cause; } finally { removeAbort(); } @@ -132,43 +123,94 @@ export class PiSessionRuntime implements LocalAgentRuntime { } async releaseSession(_providerSessionId: string): Promise { - // The runtime is already scoped to one logical Pi session. + // Pi owns durable session files independently of this process. } isAlive(): boolean { - return this.alive && !this.closed; + return !this.closed && this.connection.isAlive(); } async close(): Promise { if (this.closed) return; this.closed = true; - this.alive = false; - this.unsubscribe(); - try { - await releasePiSandboxSession(this.session); - } finally { - this.session.dispose(); + this.connection.close(); + await this.cleanup?.(); + } + + private async ensureSession(providerSessionId?: string): Promise { + if (providerSessionId && this.sessionFile !== providerSessionId) { + const result = await this.connection.request({ type: "switch_session", sessionPath: providerSessionId }); + if (recordBoolean(result, "cancelled")) { + throw piProtocolError("resume_session", "A Pi extension cancelled the session switch."); + } + this.sessionFile = undefined; + } else if (!providerSessionId && !this.sessionFile) { + const result = await this.connection.request({ type: "new_session" }); + if (recordBoolean(result, "cancelled")) { + throw piProtocolError("create_session", "A Pi extension cancelled new session creation."); + } + } + if (this.sessionFile) return this.sessionFile; + const state = await this.connection.request({ type: "get_state" }); + const sessionFile = piRecordString(state, "sessionFile"); + if (!sessionFile) { + throw piProtocolError("resolve_session", "Pi get_state returned no persisted session file."); } + this.sessionFile = sessionFile; + return sessionFile; } private async applyOverrides(input: LocalAgentRunInput): Promise { - await updatePiSandboxSession(this.session, input.workspaceRoot, input.writeMode ?? "allowed"); - this.session.setActiveToolsByName([...piToolsForWriteMode(input.writeMode)]); if (input.model) { - const model = resolvePiModel(this.session.modelRegistry, input.model); - if (!model) { - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "pi", - operation: "configure_model", - retryable: false, - message: `Pi model not found: ${input.model}.`, - }); - } - await this.session.setModel(model as never); + const model = await this.resolveModel(input.model); + await this.connection.request({ type: "set_model", provider: model.provider, modelId: model.modelId }); } if (input.effort) { - this.session.setThinkingLevel(input.effort as never); + await this.connection.request({ type: "set_thinking_level", level: input.effort }); + } + } + + private async resolveModel(model: string): Promise<{ provider: string; modelId: string }> { + const parsed = parsePiModelSlug(model); + if (parsed) return parsed; + const available = await this.connection.request({ type: "get_available_models" }); + const models = recordArray(available, "models"); + const matches = models.filter((entry) => piRecordString(entry, "id") === model); + if (matches.length === 1) { + const provider = piRecordString(matches[0], "provider"); + const modelId = piRecordString(matches[0], "id"); + if (provider && modelId) return { provider, modelId }; + } + throw piProtocolError("configure_model", `Pi model not found or ambiguous: ${model}.`); + } + + private async waitForSettlement(events: PiRpcRecord[], signal?: AbortSignal): Promise { + const deadline = Date.now() + PI_TURN_TIMEOUT_MS; + let sawAgentActivity = false; + for (;;) { + if (signal?.aborted) return; + if (Date.now() >= deadline) { + throw piProtocolError("prompt", "Pi did not settle before the provider timeout.", true); + } + const event = await this.connection.nextEvent(PI_STATE_POLL_MS); + if (event) { + if (events.length >= MAX_PI_EVENTS) events.shift(); + events.push(event); + if (event.type === "agent_start" || event.type === "message_start" || event.type === "tool_execution_start") { + sawAgentActivity = true; + } + if (event.type === "extension_ui_request") { + await cancelPiUiRequest(this.connection, event); + continue; + } + if (event.type === "agent_settled") return; + continue; + } + const state = await this.connection.request({ type: "get_state" }, 2_000); + const streaming = recordBoolean(state, "isStreaming"); + const compacting = recordBoolean(state, "isCompacting"); + const pending = recordNumber(state, "pendingMessageCount") ?? 0; + if (!streaming && !compacting && pending === 0 && !sawAgentActivity) return; } } } @@ -176,7 +218,11 @@ export class PiSessionRuntime implements LocalAgentRuntime { export class PiLocalAgentDriver implements LocalAgentDriver { readonly provider = "pi" as const; readonly providerInstanceId = "pi"; - readonly runtimePolicy = { scope: "agent", idleTimeoutMs: 3 * 60_000 } as const; + readonly runtimePolicy = { + scope: "agent", + authority: "write_mode", + idleTimeoutMs: 3 * 60_000, + } as const; readonly capabilities = { sessions: { resume: true, close: false }, turns: { interrupt: true }, @@ -186,8 +232,8 @@ export class PiLocalAgentDriver implements LocalAgentDriver { } as const; constructor( - private readonly factory: PiSessionFactory = defaultPiSessionFactory, - private readonly env: NodeJS.ProcessEnv = {}, + private readonly factory: PiRpcFactory = defaultPiRpcFactory, + private readonly env: NodeJS.ProcessEnv = process.env, ) {} async createRuntime(context: LocalAgentRuntimeContext) { @@ -196,170 +242,113 @@ export class PiLocalAgentDriver implements LocalAgentDriver { agentId: context.agentId, operation: "create_runtime", run: async (): Promise => { - const input: LocalAgentRunInput = { - prompt: "", - workspaceRoot: context.workspaceRoot, - providerSessionId: context.providerSessionId, - writeMode: context.writeMode, - model: context.model, - effort: context.effort, - }; - const session = await this.factory(context, input, this.env); - return new PiSessionRuntime(session); + const { connection, cleanup } = await this.factory(context, this.env); + return new PiRpcRuntime(connection, cleanup); }, }); } } -async function defaultPiSessionFactory( +async function defaultPiRpcFactory( context: LocalAgentRuntimeContext, - input: LocalAgentRunInput, - env: NodeJS.ProcessEnv = {}, -): Promise { - const { - AuthStorage, - ModelRegistry, - SessionManager, - DefaultResourceLoader, - createAgentSession, - getAgentDir, - } = await import("@earendil-works/pi-coding-agent"); - // DevSpace's agentDir is the compatibility directory used for instructions; - // Pi keeps its own native auth, model, and session state under getAgentDir(). - const agentDir = getAgentDir(); - const authStorage = AuthStorage.create(join(agentDir, "auth.json")); - const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json")); - applyPiProviderEnvironment(modelRegistry, env); - const sessionManager = await resolveSessionManager(SessionManager, input.workspaceRoot, input.providerSessionId); - const model = input.model ? resolvePiModel(modelRegistry, input.model) : undefined; - if (input.model && !model) { - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "pi", - agentId: context.agentId, - operation: "configure_model", - retryable: false, - message: `Pi model not found: ${input.model}.`, - }); - } - const modeRef = createPiSandboxModeRef(input.writeMode ?? "allowed"); - const resourceLoader = new DefaultResourceLoader({ - cwd: input.workspaceRoot, - agentDir, - extensionFactories: [createPiSandboxExtension(input.workspaceRoot, modeRef, env)], - }); - let session: PiSessionLike | undefined; + env: NodeJS.ProcessEnv, +): Promise<{ connection: PiRpcRuntimeConnection; cleanup?: () => Promise }> { + const launch = resolvePiCommand(env); + const writeMode = context.writeMode ?? "allowed"; + const sandbox = writeMode === "full_access" + ? undefined + : await materializePiSandboxExtension(context.workspaceRoot, writeMode); + const args = [ + ...launch.args, + "--mode", + "rpc", + "--approve", + ...(writeMode === "full_access" ? [] : ["--extension", sandbox!.path]), + ...(writeMode === "read_only" + ? ["--tools", PI_READ_ONLY_TOOLS.join(",")] + : writeMode === "allowed" + ? ["--tools", PI_ALLOWED_TOOLS.join(",")] + : []), + ]; try { - const result = await createAgentSession({ - cwd: input.workspaceRoot, - agentDir, - authStorage, - modelRegistry, - sessionManager: sessionManager as never, - resourceLoader, - ...(model ? { model: model as never } : {}), - ...(input.effort ? { thinkingLevel: input.effort as never } : {}), - // Keep the full built-in registry available so warm turns can narrow or - // broaden active tools without recreating the session. - tools: [...PI_FULL_ACCESS_TOOLS], - }); - session = result.session; - await registerPiSandboxSession(session, input.workspaceRoot, modeRef, input.writeMode ?? "allowed"); - session.setActiveToolsByName([...piToolsForWriteMode(input.writeMode)]); - return session; + return { + connection: PiRpcConnection.spawn({ + command: launch.command, + args, + cwd: context.workspaceRoot, + env, + }), + ...(sandbox ? { cleanup: sandbox.cleanup } : {}), + }; } catch (error) { - if (session) { - try { - await releasePiSandboxSession(session); - } finally { - session.dispose(); - } - } + await sandbox?.cleanup(); throw error; } } -function applyPiProviderEnvironment( - modelRegistry: ModelRegistry, - env: NodeJS.ProcessEnv, -): void { - const getApiKeyAndHeaders = modelRegistry.getApiKeyAndHeaders.bind(modelRegistry); - const providerEnv = Object.fromEntries( - Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ); - modelRegistry.getApiKeyAndHeaders = async (model) => { - const auth = await getApiKeyAndHeaders(model); - if (!auth.ok) return auth; - return { - ...auth, - env: { - ...auth.env, - ...providerEnv, - }, - }; - }; -} - -export function piToolsForWriteMode(writeMode: LocalAgentRunInput["writeMode"]): readonly string[] { - switch (writeMode) { - case "read_only": return PI_READ_ONLY_TOOLS; - case "full_access": return PI_FULL_ACCESS_TOOLS; - case "allowed": - case undefined: - return PI_WORKSPACE_TOOLS; +function resolvePiCommand(env: NodeJS.ProcessEnv): { command: string; args: string[] } { + const configured = env.PI_COMMAND?.trim(); + if (configured) { + const command = resolveExecutableCommand(configured, env); + if (!command) throw new Error(`Pi executable not found: ${configured}`); + return { command, args: [] }; } + const system = resolveExecutableCommand("pi", env); + if (system) return { command: system, args: [] }; + const packageJson = require.resolve("@earendil-works/pi-coding-agent/package.json"); + const cli = join(dirname(packageJson), "dist", "cli.js"); + return { command: process.execPath, args: [cli] }; } -interface PiSessionManagerApi { - create(cwd: string): unknown; - open(path: string): unknown; - list(cwd: string): Promise>; -} - -async function resolveSessionManager( - SessionManager: PiSessionManagerApi, +async function materializePiSandboxExtension( workspaceRoot: string, - providerSessionId: string | undefined, -): Promise { - if (!providerSessionId) return SessionManager.create(workspaceRoot); - const sessions = await SessionManager.list(workspaceRoot); - const match = sessions.find((session) => session.id === providerSessionId); - if (!match) { - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "pi", - operation: "session", - retryable: false, - message: `Pi session not found: ${providerSessionId}.`, - }); - } - return SessionManager.open(match.path); + writeMode: "read_only" | "allowed", +): Promise<{ path: string; cleanup: () => Promise }> { + const directory = await mkdtemp(join(tmpdir(), "devspace-pi-")); + const extensionPath = join(directory, "devspace-sandbox.ts"); + const jsModule = new URL("./local-agent-pi-sandbox.js", import.meta.url); + const tsModule = new URL("./local-agent-pi-sandbox.ts", import.meta.url); + const moduleUrl = existsSync(fileURLToPath(jsModule)) ? jsModule : tsModule; + const source = [ + `import { createPiSandboxExtension, createPiSandboxModeRef } from ${JSON.stringify(moduleUrl.href)};`, + `export default createPiSandboxExtension(${JSON.stringify(resolve(workspaceRoot))}, createPiSandboxModeRef(${JSON.stringify(writeMode)}));`, + "", + ].join("\n"); + await writeFile(extensionPath, source, "utf8"); + return { + path: extensionPath, + cleanup: () => rm(directory, { recursive: true, force: true }), + }; } -function resolvePiModel(registry: { find(provider: string, modelId: string): unknown; getAll?: () => unknown[] }, reference: string): unknown { - const separator = reference.indexOf("/"); - if (separator !== -1) { - return registry.find(reference.slice(0, separator), reference.slice(separator + 1)); - } - const all = registry.getAll?.() ?? []; - return all.find((model) => asRecord(model)?.id === reference); +async function cancelPiUiRequest(connection: PiRpcRuntimeConnection, event: PiRpcRecord): Promise { + if (typeof event.id !== "string") return; + if (event.method === "notify" || event.method === "setStatus" || event.method === "setWidget" || event.method === "setTitle") return; + await connection.send({ type: "extension_ui_response", id: event.id, cancelled: true }); } export function extractPiFinalResponse(value: unknown): string { - const root = unwrapProviderPayload(value); - const messages = Array.isArray(root) ? root : readArray(root, "messages"); - if (!messages) return ""; + if (!value || typeof value !== "object") return ""; + const root = value as Record; + const data = root.data && typeof root.data === "object" && !Array.isArray(root.data) + ? root.data as Record + : root; + const messages = Array.isArray(data.messages) ? data.messages : [data]; for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = asRecord(messages[index]); - if (!message || message.role !== "assistant") continue; - const content = message.content; + const message = messages[index]; + if (!message || typeof message !== "object") continue; + if ((message as { role?: unknown }).role !== "assistant") continue; + const content = (message as { content?: unknown }).content; + if (typeof content === "string") return content.trim(); if (!Array.isArray(content)) continue; const text = content - .map((part) => { - const record = asRecord(part); - return record?.type === "text" && typeof record.text === "string" ? record.text : ""; - }) - .filter(Boolean) + .filter((block): block is { type: string; text: string } => ( + Boolean(block) + && typeof block === "object" + && (block as { type?: unknown }).type === "text" + && typeof (block as { text?: unknown }).text === "string" + )) + .map((block) => block.text) .join("\n\n") .trim(); if (text) return text; @@ -367,35 +356,54 @@ export function extractPiFinalResponse(value: unknown): string { return ""; } -export function extractPiProviderError(value: unknown): string { - const root = unwrapProviderPayload(value); - if (Array.isArray(root)) { - for (let index = root.length - 1; index >= 0; index -= 1) { - const error = extractPiProviderError(root[index]); - if (error) return error; +export function extractPiProviderError(value: unknown): string | undefined { + const records = Array.isArray(value) ? [...value] : [value]; + if (value && typeof value === "object" && !Array.isArray(value)) { + const nested = (value as Record).messages; + if (Array.isArray(nested)) records.push(...nested); + } + for (let index = records.length - 1; index >= 0; index -= 1) { + const record = records[index]; + if (!record || typeof record !== "object") continue; + const event = record as Record; + if (event.type === "extension_error" && typeof event.error === "string") return event.error; + if (event.type === "message_end") { + const message = event.message; + if (message && typeof message === "object") { + const error = (message as Record).errorMessage; + if (typeof error === "string" && error) return error; + } + } + if (event.stopReason === "error" && typeof event.errorMessage === "string" && event.errorMessage) { + return event.errorMessage; } - return ""; } - const messages = readArray(root, "messages"); - if (messages) return extractPiProviderError(messages); - const record = asRecord(asRecord(root)?.message ?? root); - if (!record) return ""; - const error = record.errorMessage ?? record.error; - return typeof error === "string" ? error.trim() : ""; + return undefined; +} + +function recordBoolean(value: unknown, key: string): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return (value as Record)[key] === true; } -function unwrapProviderPayload(value: unknown): unknown { - const record = asRecord(value); - return record ? record.data ?? record.result ?? value : value; +function recordNumber(value: unknown, key: string): number | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const result = (value as Record)[key]; + return typeof result === "number" && Number.isFinite(result) ? result : undefined; } -function readArray(value: unknown, key: string): unknown[] | undefined { - const result = asRecord(value)?.[key]; - return Array.isArray(result) ? result : undefined; +function recordArray(value: unknown, key: string): unknown[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const result = (value as Record)[key]; + return Array.isArray(result) ? result : []; } -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record - : undefined; +function piProtocolError(operation: string, message: string, retryable = false): AgentProviderProtocolError { + return new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "pi", + operation, + retryable, + message, + }); } From 9194f10ec68d513c47e7655f2b9855c6d3e46584 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:45:28 +0530 Subject: [PATCH 3/3] docs(pi): describe native rpc sessions --- docs/agent-profile-schema.md | 8 +++++--- docs/configuration.md | 10 ++++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 0a942b9d2..6827e582a 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -84,7 +84,8 @@ to one of these built-in driver integrations: - `claude`: Claude Code SDK - `opencode`: the host-installed OpenCode CLI; DevSpace probes its major version and uses the matching 1.x or 2.x protocol internally -- `pi`: the installed Pi coding-agent SDK, one in-process session per DevSpace agent +- `pi`: Pi's normal `--mode rpc` CLI process. DevSpace preserves Pi's native + auth, settings, extensions, skills, context files, and session store. - `acp`: generic Agent Client Protocol integration. Cursor, Copilot, and Grok are default provider instances using this driver; other ACP agents can be configured without adding a DevSpace driver. @@ -94,8 +95,9 @@ as executables. Run their normal login/setup flows before using them; set `CODEX_COMMAND` when Codex is not on the normal PATH. OpenCode is discovered as `opencode`, and DevSpace automatically routes OpenCode 1.x and 2.x to their matching runtime implementations. OpenCode, Cursor, and Copilot runtimes are -started and reused by the daemon internally, while Pi is embedded through its -Node SDK. +started and reused by the daemon internally. Pi is launched in RPC mode and +uses its native persisted session file as DevSpace's continuation identity; +`PI_COMMAND` (or a provider `command`) can select another Pi executable. ### `model` diff --git a/docs/configuration.md b/docs/configuration.md index 2a86dcc5d..fab678772 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -192,14 +192,14 @@ does not expand `$NAME` references in these values. All subagent providers accept `env`. The daemon inherits its startup environment, then overlays the provider's `env` without mutating the daemon's process environment. OpenCode receives that environment on its managed server -process; embedded Pi scopes it to its provider requests and command execution. +process; Pi receives it on its managed RPC process. The `opencode` driver runs `opencode --version` before creating its first runtime and automatically selects the OpenCode 1.x or 2.x protocol. Both major versions use the same provider instance and profile configuration; `opencode2` is not a separate driver. -Codex, Claude, and ACP instances also accept `command`. OpenCode and Pi do not +Codex, Claude, Pi, and ACP instances also accept `command`. OpenCode does not expose a command override. Cursor, Copilot, and Grok retain their built-in ACP commands when `command` is omitted. An arbitrary ACP instance declares its executable and may provide fixed startup arguments under `config.args`: @@ -217,6 +217,12 @@ executable and may provide fixed startup arguments under `config.args`: `config.flavor` is reserved for the small built-in protocol quirks currently needed by `cursor`, `copilot`, and `grok`; generic ACP agents normally omit it. +Pi runs as the normal CLI in `--mode rpc`, so user-installed extensions, +skills, context files, auth, models, and settings load normally. Restricted +DevSpace modes inject a sandbox extension and pin Pi's active built-in tools to +the matching read-only/workspace-safe set; `full_access` leaves Pi's normal +tool configuration untouched. + Existing process-level overrides remain supported: `CODEX_COMMAND`, `CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, `COPILOT_COMMAND`, `GROK_COMMAND`, and `GROK_AGENT_PROFILE`. Provider configuration takes