Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ Expect some minor breaking changes.
- Session persistence
- pi stores its own sessions in `~/.pi/agent/sessions/...`
- `pi-acp` stores a small mapping file at `~/.pi/pi-acp/session-map.json` so `session/load` can reattach to a previous pi session file
- Multi-workspace support (`sessionCapabilities.additionalDirectories`)
- ACP clients can pass additional workspace roots on `session/new` / `session/load` (e.g. Zed multi-root workspaces)
- `cwd` stays the primary working directory; the additional roots are communicated to pi via `--append-system-prompt`, since pi has no native multi-root workspace concept
- Slash commands
- Loads file-based slash commands compatible with pi’s conventions
- Adds a small set of built-in commands for headless/editor usage
Expand Down Expand Up @@ -196,6 +199,7 @@ Project layout:

- No ACP filesystem delegation (`fs/*`) and no ACP terminal delegation (`terminal/*`). pi reads/writes and executes locally.
- MCP servers are accepted in ACP params and stored in session state, but not wired through to pi in this adapter. If you use [pi MCP adapter](https://github.com/nicobailon/pi-mcp-adapter) it will be available in the ACP client.
- Additional workspace roots are not a hard filesystem boundary: pi can operate outside them. They are communicated to the model (workspace awareness), not enforced as a sandbox.
- Assistant streaming is currently sent as `agent_message_chunk` (no separate thought stream).
- Queue is implemented client-side and should work like pi's `one-at-a-time`
- ~~ACP clients don't yet suport session history, but ACP sessions from `pi-acp` can be `/resume`d in pi directly~~
Expand Down
28 changes: 21 additions & 7 deletions src/acp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { existsSync, readFileSync, realpathSync, readdirSync, statSync, unlinkSy
import type { AvailableCommand } from '@agentclientprotocol/sdk'
import { join, dirname, basename } from 'node:path'
import { spawnSync } from 'node:child_process'
import { normalizeAdditionalDirectories } from './workspace-roots.js'

type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
type AdvertisedModel = {
Expand Down Expand Up @@ -180,7 +181,7 @@ export class PiAcpAgent implements ACPAgent {

private async restoreSession(
sessionId: string,
opts?: { cwd?: string; mcpServers?: LoadSessionRequest['mcpServers'] }
opts?: { cwd?: string; mcpServers?: LoadSessionRequest['mcpServers']; additionalDirectories?: string[] }
): Promise<PiAcpSession> {
const existing = this.sessions.maybeGet(sessionId)
if (existing) return existing
Expand All @@ -201,7 +202,8 @@ export class PiAcpAgent implements ACPAgent {
proc = await PiRpcProcess.spawn({
cwd,
sessionPath: stored.sessionFile,
piCommand: process.env.PI_ACP_PI_COMMAND
piCommand: process.env.PI_ACP_PI_COMMAND,
additionalDirectories: opts?.additionalDirectories
})
} catch (e: any) {
if (e?.name === 'PiRpcSpawnError') {
Expand All @@ -216,7 +218,8 @@ export class PiAcpAgent implements ACPAgent {
mcpServers: opts?.mcpServers ?? [],
conn: this.conn,
proc,
fileCommands
fileCommands,
additionalDirectories: opts?.additionalDirectories
})

this.lastSessionCwd = cwd
Expand Down Expand Up @@ -263,7 +266,8 @@ export class PiAcpAgent implements ACPAgent {
// **UNSTABLE** ACP capability used by Zed's codex-acp adapter.
// Enables a native session picker in clients that support it.
list: {},
delete: {}
delete: {},
additionalDirectories: {}
}
}
}
Expand All @@ -274,6 +278,8 @@ export class PiAcpAgent implements ACPAgent {
throw RequestError.invalidParams(`cwd must be an absolute path: ${params.cwd}`)
}

const additionalDirectories = normalizeAdditionalDirectories(params.additionalDirectories, params.cwd)

this.lastSessionCwd = params.cwd

const fileCommands = loadSlashCommands(params.cwd)
Expand All @@ -285,7 +291,8 @@ export class PiAcpAgent implements ACPAgent {
mcpServers: params.mcpServers,
conn: this.conn,
fileCommands,
piCommand: process.env.PI_ACP_PI_COMMAND
piCommand: process.env.PI_ACP_PI_COMMAND,
additionalDirectories
})

// Fetch state + models once (parallel) to reduce startup latency.
Expand Down Expand Up @@ -363,7 +370,8 @@ export class PiAcpAgent implements ACPAgent {
: buildStartupInfo({
cwd: params.cwd,
fileCommands,
updateNotice
updateNotice,
additionalDirectories
})

if (preludeText)
Expand Down Expand Up @@ -945,9 +953,11 @@ export class PiAcpAgent implements ACPAgent {
}

const enableSkillCommands = getEnableSkillCommands(params.cwd)
const additionalDirectories = normalizeAdditionalDirectories(params.additionalDirectories, params.cwd)
const session = await this.restoreSession(params.sessionId, {
cwd: params.cwd,
mcpServers: params.mcpServers
mcpServers: params.mcpServers,
additionalDirectories
})
const proc = session.proc
const fileCommands = loadSlashCommands(params.cwd)
Expand Down Expand Up @@ -1489,6 +1499,7 @@ function buildStartupInfo(opts: {
cwd: string
fileCommands: ReturnType<typeof loadSlashCommands>
updateNotice: string | null
additionalDirectories?: string[]
}): string {
void opts.fileCommands

Expand Down Expand Up @@ -1525,6 +1536,9 @@ function buildStartupInfo(opts: {
if (existsSync(contextPath)) contextItems.push(contextPath)
addSection('Context', contextItems)

// Additional workspace roots (ACP additionalDirectories)
addSection('Additional workspace roots', opts.additionalDirectories ?? [])

// Skills
const skillsItems: string[] = []

Expand Down
14 changes: 11 additions & 3 deletions src/acp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ type SessionCreateParams = {
conn: AgentSideConnection
fileCommands?: import('./slash-commands.js').FileSlashCommand[]
piCommand?: string
/** ACP additionalDirectories: extra workspace roots beyond cwd (absolute paths). */
additionalDirectories?: string[]
}

export type StopReason = 'end_turn' | 'cancelled' | 'error'
Expand Down Expand Up @@ -191,7 +193,8 @@ export class SessionManager {
try {
proc = await PiRpcProcess.spawn({
cwd: params.cwd,
piCommand: params.piCommand
piCommand: params.piCommand,
additionalDirectories: params.additionalDirectories
})
} catch (e) {
if (e instanceof PiRpcSpawnError) {
Expand Down Expand Up @@ -220,7 +223,8 @@ export class SessionManager {
mcpServers: params.mcpServers,
proc,
conn: params.conn,
fileCommands: params.fileCommands ?? []
fileCommands: params.fileCommands ?? [],
additionalDirectories: params.additionalDirectories ?? []
})

this.sessions.set(sessionId, session)
Expand All @@ -247,7 +251,8 @@ export class SessionManager {
mcpServers: params.mcpServers,
proc: params.proc,
conn: params.conn,
fileCommands: params.fileCommands ?? []
fileCommands: params.fileCommands ?? [],
additionalDirectories: params.additionalDirectories ?? []
})

this.sessions.set(sessionId, session)
Expand All @@ -259,6 +264,7 @@ export class PiAcpSession {
readonly sessionId: string
readonly cwd: string
readonly mcpServers: McpServer[]
readonly additionalDirectories: string[]

private startupInfo: string | null = null
private startupInfoSent = false
Expand Down Expand Up @@ -303,10 +309,12 @@ export class PiAcpSession {
proc: PiRpcProcess
conn: AgentSideConnection
fileCommands?: FileSlashCommand[]
additionalDirectories?: string[]
}) {
this.sessionId = opts.sessionId
this.cwd = opts.cwd
this.mcpServers = opts.mcpServers
this.additionalDirectories = opts.additionalDirectories ?? []
this.proc = opts.proc
this.conn = opts.conn
this.fileCommands = opts.fileCommands ?? []
Expand Down
34 changes: 34 additions & 0 deletions src/acp/workspace-roots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { RequestError } from '@agentclientprotocol/sdk'
import { isAbsolute, resolve as resolvePath } from 'node:path'

/**
* Validate and normalize the ACP `additionalDirectories` request field
* (https://agentclientprotocol.com/protocol/v1/session-setup#additional-workspace-roots).
*
* Every entry MUST be an absolute path. `cwd` stays the primary root, so an entry
* equal to it is dropped. Order is preserved and duplicates are removed.
*/
export function normalizeAdditionalDirectories(input: readonly string[] | null | undefined, cwd: string): string[] {
if (!input || input.length === 0) return []

const resolvedCwd = resolvePath(cwd)
const out: string[] = []

for (const entry of input) {
if (typeof entry !== 'string') {
throw RequestError.invalidParams(`additionalDirectories entries must be absolute paths: ${String(entry)}`)
}

const dir = entry.trim()
if (!dir) continue

if (!isAbsolute(dir)) {
throw RequestError.invalidParams(`additionalDirectories entries must be absolute paths: ${entry}`)
}

if (resolvePath(dir) === resolvedCwd) continue
if (!out.includes(dir)) out.push(dir)
}

return out
}
52 changes: 52 additions & 0 deletions src/pi-rpc/process.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as readline from 'node:readline'
import { getPiCommand, shouldUseShellForPiCommand } from './command.js'

Expand Down Expand Up @@ -74,6 +77,27 @@ type SpawnParams = {
piCommand?: string
/** If set, pi will persist the session to this exact file (via `--session <path>`). */
sessionPath?: string
/**
* Additional workspace roots (ACP `additionalDirectories`). Communicated to pi via
* `--append-system-prompt` since pi has no native multi-root workspace support.
*/
additionalDirectories?: readonly string[]
}

/**
* Build the system-prompt text describing the session's workspace root set.
* pi appends this to its system prompt so the model treats the additional
* directories as part of the user's workspace.
*/
export function buildWorkspaceRootsPrompt(cwd: string, dirs: readonly string[]): string {
return [
'<workspace_roots>',
`Primary working directory: ${cwd}`,
"Additional workspace roots (also part of the user's workspace):",
...dirs.map(dir => `- ${dir}`),
'</workspace_roots>',
"The user's workspace spans the primary working directory and the additional workspace roots listed above. Treat files under the additional roots as part of the workspace: read, search, and edit them using absolute paths. Relative paths still resolve against the primary working directory."
].join('\n')
}

export class PiRpcProcess {
Expand Down Expand Up @@ -137,13 +161,40 @@ export class PiRpcProcess {
const args = ['--mode', 'rpc', '--no-themes']
if (params.sessionPath) args.push('--session', params.sessionPath)

// Workspace roots go through `--append-system-prompt`, which pi resolves from a
// file when the argument is an existing path. Using a temp file (instead of inline
// text) sidesteps shell-quoting issues for long multi-line text, which matters
// because pi.cmd on Windows must be spawned with shell mode.
let workspaceRootsDir: string | null = null
if (params.additionalDirectories && params.additionalDirectories.length > 0) {
workspaceRootsDir = mkdtempSync(join(tmpdir(), 'pi-acp-'))
const promptFile = join(workspaceRootsDir, 'workspace-roots.txt')
writeFileSync(promptFile, buildWorkspaceRootsPrompt(params.cwd, params.additionalDirectories), 'utf-8')
args.push('--append-system-prompt', shouldUseShellForPiCommand(cmd) ? `"${promptFile}"` : promptFile)
}

const removeWorkspaceRootsDir = () => {
if (!workspaceRootsDir) return
try {
rmSync(workspaceRootsDir, { recursive: true, force: true })
} catch {
// best effort; the file lives in the OS temp dir
}
workspaceRootsDir = null
}

const child = spawn(cmd, args, {
cwd: params.cwd,
stdio: 'pipe',
env: process.env,
shell: shouldUseShellForPiCommand(cmd)
})

// Remove the temp file when the subprocess goes away; pi may re-read it on
// resource reloads, so it must live exactly as long as the process.
child.on('exit', removeWorkspaceRootsDir)
child.on('error', removeWorkspaceRootsDir)

// Ensure spawn failures (e.g. ENOENT when pi isn't installed) are surfaced as a
// deterministic error instead of later EPIPE/internal-error noise.
try {
Expand All @@ -165,6 +216,7 @@ export class PiRpcProcess {
child.once('error', onError)
})
} catch (e: any) {
removeWorkspaceRootsDir()
const code = typeof e?.code === 'string' ? e.code : undefined
if (code === 'ENOENT') {
throw new PiRpcSpawnError(
Expand Down
Loading