@agentproto/adapter-pi 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # @agentproto/adapter-pi
2
+
3
+ AIP-45 AGENT-CLI adapter for **[earendil-works/pi](https://github.com/earendil-works/pi)**
4
+ (`@earendil-works/pi-coding-agent`) — an MIT, headless TypeScript coding
5
+ agent. This adapter drives pi over its **persistent JSON-over-stdio RPC mode**
6
+ (`pi --mode rpc`), spawned as a real child process.
7
+
8
+ ```ts
9
+ import { pi, piRuntime } from "@agentproto/adapter-pi"
10
+
11
+ const session = await piRuntime().start()
12
+ for await (const evt of session.send({ role: "user", content: "list the files" })) {
13
+ console.log(evt.kind)
14
+ }
15
+ await session.close()
16
+ ```
17
+
18
+ ## MCP support — bridged into pi tools
19
+
20
+ Pi ships **no native MCP client** — but this adapter closes that gap. When the
21
+ host injects `mcpServers` into `connect()` (the daemon's orchestration gateway,
22
+ or any scoped toolset), the adapter **bridges** them into pi by exploiting pi's
23
+ own TypeScript **extension** system:
24
+
25
+ 1. At `connect()`, it enumerates each server's tools (`tools/list`), writes a
26
+ per-session config JSON, and spawns pi with `-e <mcp-bridge-extension.mjs>`
27
+ plus `PI_MCP_BRIDGE_CONFIG`.
28
+ 2. The bundled extension registers **one pi tool per MCP tool** (namespaced
29
+ `mcp__<server>__<tool>`); each tool's `execute` proxies the call to the MCP
30
+ server over `@modelcontextprotocol/sdk`.
31
+
32
+ Net result: pi gains agentproto's full injected toolset, **including
33
+ `agent_start`** when the daemon injects its gateway (`--orchestrator` flag),
34
+ enabling sub-agent orchestration (`capabilities.sub_agents: true`). When no
35
+ MCP servers are injected, behavior is unchanged (pi runs only its own
36
+ built-in file/shell tools). Full mechanism, limitations, and caveats
37
+ (image/binary-content, cancellation): [`MCP-BRIDGE.md`](./MCP-BRIDGE.md).
38
+
39
+ ## What it is
40
+
41
+ Because pi has no native ACP/MCP protocol surface, this is a
42
+ `protocol: "proprietary"` manifest:
43
+ `createAgentCliRuntime` skips the built-in ACP/print subprocess plumbing and
44
+ instead dynamic-imports this package's `createAgentCliClient(definition)`
45
+ factory (see `createProprietaryProtocolArm` in `@agentproto/driver-agent-cli`).
46
+ Unlike `@agentproto/adapter-mastracode-inprocess` (the in-process proprietary
47
+ arm), **this arm spawns a real child** (`pi --mode rpc`) and translates pi's
48
+ RPC event stream into the canonical `StreamEvent` taxonomy.
49
+
50
+ - **Multi-provider** — Anthropic, OpenAI, Google (Gemini). One provider key
51
+ minimum. See [`SECRETS.md`](./SECRETS.md).
52
+ - **Streaming** — text + thinking deltas, tool-call/result lifecycle.
53
+ - **Live duplex** — pi's RPC mode supports `steer` / `follow_up` / `abort`
54
+ mid-turn (`capabilities.bidirectional: true`).
55
+ - **Resumable** — pi persists sessions; the client captures pi's session id
56
+ from `get_state` and reattaches via `--session <id>`
57
+ (`continuation.default: "native-resume"`).
58
+
59
+ ## Configuration
60
+
61
+ | Option | Type | Notes |
62
+ | -------- | ------ | ----- |
63
+ | `model` | string | Passed to pi's `--model` (accepts `provider/id`, e.g. `anthropic/claude-sonnet-4-5`). |
64
+ | `effort` | enum | Thinking level, mapped 1:1 to pi's `set_thinking_level`: `off \| minimal \| low \| medium \| high \| xhigh`. |
65
+
66
+ The pi binary is resolved from `definition.bin` (`pi`), overridable via the
67
+ `AGENTPROTO_PI_BIN` env var (used by the gated smoke test to point at a local
68
+ install without a global `pi` on PATH).
69
+
70
+ ## Safety
71
+
72
+ Pi has **no built-in permission system** — file, process, network and
73
+ credential access run with the launching user's permissions, and non-interactive
74
+ modes (including `--mode rpc`) show no trust prompt. Treat a pi session as
75
+ arbitrary code execution. See [`SANDBOX.md`](./SANDBOX.md).
76
+
77
+ ## Docs in this package
78
+
79
+ - [`PI.md`](./PI.md) — AIP-45 manifest overview.
80
+ - [`PI-RPC.md`](./PI-RPC.md) — the reverse-engineered RPC wire profile + the
81
+ pi-event → `StreamEvent` mapping table.
82
+ - [`SECRETS.md`](./SECRETS.md) — provider env slots.
83
+ - [`SANDBOX.md`](./SANDBOX.md) — no built-in permissions; containerization.
84
+
85
+ Built against pi **0.80.3**. The event/command wire profile was
86
+ reverse-engineered from pi source (`packages/coding-agent/src/modes/rpc/*` +
87
+ `core/agent-session.ts`); see `PI-RPC.md`.
package/SANDBOX.md ADDED
@@ -0,0 +1,63 @@
1
+ # Pi — sandbox & safety profile
2
+
3
+ ## Pi has NO built-in permission system
4
+
5
+ This is not an opinion — it is pi's own documented position. From pi's
6
+ README (`## Permissions & Containerization`):
7
+
8
+ > Pi does not include a built-in permission system for restricting filesystem,
9
+ > process, network, or credential access. By default, it runs with the
10
+ > permissions of the user and process that launched it.
11
+
12
+ And, critically for this adapter, from pi's coding-agent README:
13
+
14
+ > Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a
15
+ > trust prompt.
16
+
17
+ > **No permission popups.** Run in a container, or build your own confirmation
18
+ > flow with extensions inline with your environment and security requirements.
19
+
20
+ > **Security:** Pi packages run with full system access. Extensions execute
21
+ > arbitrary code, and skills can instruct the model to perform any action
22
+ > including running executables.
23
+
24
+ Because this adapter drives pi via `--mode rpc` — a **non-interactive** mode —
25
+ there is **no trust prompt and no approval gate**. Every `bash`, `write`,
26
+ `edit` the model decides to run executes immediately with the launching
27
+ process's full OS permissions.
28
+
29
+ **Treat a pi session as arbitrary code execution on the host.**
30
+
31
+ ## Recommendation: containerize
32
+
33
+ Pi's own guidance is to sandbox the whole process. Do not run this adapter
34
+ against untrusted repos or prompts on a host you care about. Pi documents three
35
+ patterns in `packages/coding-agent/docs/containerization.md`:
36
+
37
+ - run pi inside a Docker/OCI container scoped to the target workspace;
38
+ - restrict network egress at the container/VM boundary;
39
+ - **OpenShell** — run the whole `pi` process in a policy-controlled sandbox.
40
+
41
+ Prefer running the agentproto daemon (and therefore the spawned `pi --mode rpc`
42
+ child) inside such a boundary, with only the target working directory mounted
43
+ and only the provider API egress it needs.
44
+
45
+ ## MCP substrate is unavailable (defense-in-depth note)
46
+
47
+ Pi has no MCP, so the daemon **cannot** narrow pi's tool surface by mounting a
48
+ curated MCP toolset — pi always exposes its own built-in file/shell tools, and
49
+ `connect({ mcpServers })` is ignored (with a one-time warning). There is no
50
+ "allowed tools" lever at the protocol layer here: the only enforcement boundary
51
+ is the OS/container sandbox you place pi in. This is a meaningful difference
52
+ from the ACP adapters, where the host chooses the mounted toolset.
53
+
54
+ ## What the adapter does / doesn't isolate
55
+
56
+ - **Working directory** — the runner spawns `pi --mode rpc` with the
57
+ host-chosen `cwd`; pi's file tools operate relative to it. This is scoping,
58
+ not a security boundary.
59
+ - **Env / secrets** — only the runner-injected env (provider keys + host env)
60
+ reaches the child. Keys are never logged.
61
+ - **Process** — the adapter owns the child and kills it (`SIGTERM`, after
62
+ `stdin.end()`) on `close()` / abort. It does **not** confine what the child
63
+ does while alive. That is the sandbox's job.
package/SECRETS.md ADDED
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: pi-secrets
3
+ id: pi-secrets
4
+ description: Secret slots pi reads at boot. At minimum ONE model-provider key MUST be present — pi routes each turn to whichever provider the selected model belongs to (Anthropic / OpenAI / Google).
5
+ version: 0.1.0
6
+ slots:
7
+ - name: ANTHROPIC_API_KEY
8
+ description: Anthropic API key — enables `anthropic/claude-*` models (adapter default provider).
9
+ required: false
10
+ sensitivity: high
11
+ - name: OPENAI_API_KEY
12
+ description: OpenAI API key — enables `openai/gpt-*` models.
13
+ required: false
14
+ sensitivity: high
15
+ - name: GOOGLE_GENERATIVE_AI_API_KEY
16
+ description: Google Generative AI (Gemini) key — enables `google/gemini-*` models.
17
+ required: false
18
+ sensitivity: high
19
+ constraints:
20
+ - kind: at-least-one-of
21
+ of:
22
+ - ANTHROPIC_API_KEY
23
+ - OPENAI_API_KEY
24
+ - GOOGLE_GENERATIVE_AI_API_KEY
25
+ tags: [pi, secrets, model-providers]
26
+ ---
27
+
28
+ # Pi — secrets inventory
29
+
30
+ Pi routes each turn to the provider that owns the selected model. The adapter
31
+ declares the three provider key slots pi's supported providers read from the
32
+ process environment:
33
+
34
+ | Env var | Provider | Unlocks |
35
+ | ------- | -------- | ------- |
36
+ | `ANTHROPIC_API_KEY` | Anthropic | `anthropic/claude-*` (adapter default) |
37
+ | `OPENAI_API_KEY` | OpenAI | `openai/gpt-*` |
38
+ | `GOOGLE_GENERATIVE_AI_API_KEY` | Google | `google/gemini-*` |
39
+
40
+ **At least one** must be present, and it must match the provider of the model
41
+ you route to (`models.default` is `anthropic/claude-sonnet-4-5`, so
42
+ `ANTHROPIC_API_KEY` is the natural minimum). Keys are injected into the spawned
43
+ `pi --mode rpc` child's environment by the runner (from the workspace secrets
44
+ store) and are never logged.
45
+
46
+ Pi also accepts a per-invocation `--api-key`, but this adapter relies on the
47
+ env slots above so key material flows through the runner's secrets pipeline
48
+ rather than argv.
@@ -0,0 +1,209 @@
1
+ import { AgentCliHandle, AgentCliClient, StreamEvent, AgentCliRuntime } from '@agentproto/driver-agent-cli';
2
+ export { AgentCliHandle, AgentCliRuntime } from '@agentproto/driver-agent-cli';
3
+
4
+ /**
5
+ * `AgentCliClient` for pi (`@earendil-works/pi-coding-agent`), driven over
6
+ * its persistent JSON-over-stdio RPC mode.
7
+ *
8
+ * Unlike `@agentproto/adapter-mastracode-inprocess` (the other
9
+ * `protocol: "proprietary"` arm, which runs in-process), this arm spawns a
10
+ * real child: `pi --mode rpc`. It writes RPC commands (LF-delimited JSON) to
11
+ * the child's stdin and reads pi's response + `AgentSessionEvent` stream from
12
+ * stdout, translating the events into agentproto `StreamEvent`s.
13
+ *
14
+ * ## MCP support — bridged via a generated pi extension
15
+ *
16
+ * Pi ships neither ACP nor MCP. The proprietary arm's `connect()` receives
17
+ * `mcpServers` (the host may inject the daemon's own orchestration gateway or
18
+ * any scoped toolset). Pi cannot mount MCP natively, so this arm bridges them:
19
+ * it enumerates each server's tools up-front, writes a per-session config JSON,
20
+ * and spawns pi with `-e <mcp-bridge-extension.mjs>` + `PI_MCP_BRIDGE_CONFIG`.
21
+ * The extension registers one pi tool per MCP tool and proxies calls over
22
+ * `@modelcontextprotocol/sdk`. See ../MCP-BRIDGE.md. When no MCP servers are
23
+ * injected, behavior is unchanged (pi runs only its own file/shell tools).
24
+ */
25
+
26
+ declare function createAgentCliClient(definition: AgentCliHandle): AgentCliClient;
27
+
28
+ /**
29
+ * Pi RPC wire types + the pure pi-event → {@link StreamEvent} mapper.
30
+ *
31
+ * Pi (`@earendil-works/pi-coding-agent`) exposes a persistent
32
+ * JSON-over-stdio RPC mode (`pi --mode rpc`). Its stdout carries two
33
+ * kinds of LF-delimited JSON records:
34
+ *
35
+ * 1. **Responses** — `{ type: "response", command, success, id?, data?, error? }`,
36
+ * correlated to a command by the `id` echoed back. Handled by the
37
+ * client's request/response layer.
38
+ * 2. **Session events** — the `AgentSessionEvent` union emitted by pi's
39
+ * `session.subscribe(...)` (assistant text/thinking deltas, tool
40
+ * execution lifecycle, turn/agent lifecycle). These are what this
41
+ * module narrows and maps onto agentproto's canonical taxonomy.
42
+ *
43
+ * Everything here is pure and synchronous so it can be unit-tested
44
+ * against hand-built pi records without spawning a real `pi`.
45
+ *
46
+ * Wire profile documented in ../PI-RPC.md.
47
+ */
48
+
49
+ /** Pi `StopReason` (packages/ai/src/types.ts). */
50
+ type PiStopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
51
+ /** Pi `Usage` (packages/ai/src/types.ts) — token + cost accounting on an
52
+ * assistant message. Only the fields this adapter surfaces are modelled. */
53
+ interface PiUsage {
54
+ input: number;
55
+ output: number;
56
+ totalTokens: number;
57
+ cost: {
58
+ total: number;
59
+ };
60
+ }
61
+ /** Minimal shape of an assistant `AgentMessage` carried on `turn_end`. */
62
+ interface PiTurnMessage {
63
+ role: string;
64
+ stopReason?: PiStopReason;
65
+ usage?: PiUsage;
66
+ errorMessage?: string;
67
+ }
68
+ /** Assistant-message-event types this adapter does NOT translate but must
69
+ * still discriminate cleanly (keeps `PiAssistantMessageEvent` exhaustive). */
70
+ type PiIgnoredAssistantEventType = "start" | "text_start" | "text_end" | "thinking_start" | "thinking_end" | "toolcall_start" | "toolcall_delta" | "toolcall_end";
71
+ /** Pi `AssistantMessageEvent` (packages/ai/src/types.ts), narrowed to the
72
+ * fields this adapter reads. Streaming deltas + the terminal done/error. */
73
+ type PiAssistantMessageEvent = {
74
+ type: "text_delta";
75
+ delta: string;
76
+ } | {
77
+ type: "thinking_delta";
78
+ delta: string;
79
+ } | {
80
+ type: "done";
81
+ reason: "stop" | "length" | "toolUse";
82
+ } | {
83
+ type: "error";
84
+ reason: "error" | "aborted";
85
+ errorMessage?: string;
86
+ } | {
87
+ type: PiIgnoredAssistantEventType;
88
+ };
89
+ /** Pi `AgentSessionEvent` (packages/coding-agent/src/core/agent-session.ts +
90
+ * packages/agent/src/types.ts `AgentEvent`), narrowed to the events this
91
+ * adapter acts on. Any other pi event is dropped before it reaches here. */
92
+ type PiSessionEvent = {
93
+ type: "agent_start";
94
+ } | {
95
+ type: "agent_end";
96
+ willRetry?: boolean;
97
+ } | {
98
+ type: "turn_start";
99
+ } | {
100
+ type: "turn_end";
101
+ message?: PiTurnMessage;
102
+ } | {
103
+ type: "message_update";
104
+ assistantMessageEvent: PiAssistantMessageEvent;
105
+ } | {
106
+ type: "tool_execution_start";
107
+ toolCallId: string;
108
+ toolName: string;
109
+ args: unknown;
110
+ } | {
111
+ type: "tool_execution_end";
112
+ toolCallId: string;
113
+ toolName: string;
114
+ result: unknown;
115
+ isError: boolean;
116
+ } | {
117
+ type: "agent_settled";
118
+ };
119
+ /** A pi RPC response line (`type: "response"`). */
120
+ interface PiResponse {
121
+ type: "response";
122
+ id?: string;
123
+ command: string;
124
+ success: boolean;
125
+ error?: string;
126
+ data?: unknown;
127
+ }
128
+ /** Classified pi stdout line. `other` covers extension-UI requests, the
129
+ * json-mode session header, and anything this adapter ignores. */
130
+ type PiOutbound = {
131
+ kind: "response";
132
+ response: PiResponse;
133
+ } | {
134
+ kind: "event";
135
+ event: PiSessionEvent;
136
+ } | {
137
+ kind: "other";
138
+ };
139
+ /**
140
+ * Parse + classify one pi stdout JSON line. Returns `{ kind: "other" }` for
141
+ * malformed lines and any record this adapter does not act on, so the caller
142
+ * can uniformly ignore them.
143
+ */
144
+ declare function classifyPiLine(line: string): PiOutbound;
145
+ /** Carried across a turn's events — pi reports the stop reason mid-stream
146
+ * (`message_update` done/error, `turn_end.message.stopReason`) but the
147
+ * turn only truly closes on `agent_settled`, so it must be remembered. */
148
+ interface PiMapperState {
149
+ lastStopReason: PiStopReason | undefined;
150
+ }
151
+ declare function createPiMapperState(): PiMapperState;
152
+ /** Reset for a fresh turn (call in the client's `send`). */
153
+ declare function resetPiMapperState(state: PiMapperState): void;
154
+ /** Map a pi `StopReason` onto the canonical `turn-end` reason.
155
+ * `length` (token/context cap) has no exact equivalent; `max_turns` is the
156
+ * closest "hit a budget limit" reason. `toolUse` is never the settled
157
+ * reason (the loop keeps going) but falls back to `completed` defensively. */
158
+ declare function mapStopReason(reason: PiStopReason | undefined): "completed" | "cancelled" | "max_turns" | "error";
159
+ /**
160
+ * Translate one pi session event into zero or more {@link StreamEvent}s,
161
+ * updating `state` in place. Pure aside from the state mutation.
162
+ *
163
+ * `agent_end` (with `willRetry` false) is the turn terminator: it flushes a
164
+ * `turn-end` whose reason reflects the stop reason accumulated over the turn.
165
+ * An `agent_end` with `willRetry: true` (auto-retry) does NOT close the turn —
166
+ * pi will run again and emit a final `agent_end`. NOTE: pi's `agent_settled`
167
+ * event is emitted to in-process extension listeners but is NOT written to the
168
+ * RPC stdout stream (verified empirically against pi 0.80.3), so it cannot be
169
+ * relied on as the terminator — see PI-RPC.md.
170
+ */
171
+ declare function mapPiEvent(event: PiSessionEvent, sessionId: string, state: PiMapperState): StreamEvent[];
172
+
173
+ /**
174
+ * @agentproto/adapter-pi — AIP-45 adapter for **earendil-works/pi**
175
+ * (`@earendil-works/pi-coding-agent`), an MIT TypeScript headless coding
176
+ * agent.
177
+ *
178
+ * Pi ships **no ACP and no MCP** — but it does ship a persistent
179
+ * JSON-over-stdio RPC mode (`pi --mode rpc`). So this is a
180
+ * `protocol: "proprietary"` manifest: `createAgentCliRuntime` skips the
181
+ * built-in ACP/print subprocess plumbing and instead dynamic-imports this
182
+ * package's `createAgentCliClient(definition)` factory (see
183
+ * `createProprietaryProtocolArm` in `@agentproto/driver-agent-cli`). Unlike
184
+ * the in-process mastracode arm, THIS arm spawns a real child (`pi --mode
185
+ * rpc`) and translates pi's RPC event stream — see `./client.ts`.
186
+ *
187
+ * import { pi, piRuntime } from "@agentproto/adapter-pi"
188
+ * const session = await piRuntime().start()
189
+ * for await (const evt of session.send({ role: "user", content: "..." })) {
190
+ * console.log(evt)
191
+ * }
192
+ * await session.close()
193
+ *
194
+ * ## MCP support — bridged via a generated pi extension
195
+ *
196
+ * Pi has no native MCP client, but the proprietary arm's `connect()` receives
197
+ * `mcpServers` and now bridges them: it enumerates each server's tools, writes a
198
+ * per-session config, and spawns pi with `-e <mcp-bridge-extension.mjs>` so the
199
+ * extension registers one pi tool per MCP tool (proxying calls over
200
+ * `@modelcontextprotocol/sdk`). Injected toolsets — including the daemon's
201
+ * `agent_start` orchestration gateway — become callable from pi. When no MCP
202
+ * servers are injected, pi runs only its own built-in file/shell tools. See
203
+ * MCP-BRIDGE.md, README.md, and SANDBOX.md.
204
+ */
205
+
206
+ declare const pi: AgentCliHandle;
207
+ declare function piRuntime(): AgentCliRuntime;
208
+
209
+ export { type PiAssistantMessageEvent, type PiMapperState, type PiOutbound, type PiResponse, type PiSessionEvent, type PiStopReason, type PiTurnMessage, type PiUsage, classifyPiLine, createAgentCliClient, createPiMapperState, mapPiEvent, mapStopReason, pi, piRuntime, resetPiMapperState };