pidriver 0.0.2__tar.gz → 0.2.0__tar.gz

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.
Files changed (71) hide show
  1. {pidriver-0.0.2 → pidriver-0.2.0}/.gitignore +3 -0
  2. pidriver-0.2.0/.sc/last-chat-settings.json +4 -0
  3. {pidriver-0.0.2 → pidriver-0.2.0}/PKG-INFO +141 -43
  4. {pidriver-0.0.2 → pidriver-0.2.0}/README.md +140 -42
  5. {pidriver-0.0.2 → pidriver-0.2.0}/pyproject.toml +6 -1
  6. {pidriver-0.0.2 → pidriver-0.2.0}/src/pidriver/__init__.py +52 -7
  7. pidriver-0.2.0/src/pidriver/_install.py +305 -0
  8. {pidriver-0.0.2 → pidriver-0.2.0}/src/pidriver/_transport.py +5 -3
  9. pidriver-0.2.0/src/pidriver/assets/pi_login.mjs +115 -0
  10. pidriver-0.2.0/src/pidriver/auth.py +254 -0
  11. {pidriver-0.0.2 → pidriver-0.2.0}/src/pidriver/config.py +53 -28
  12. {pidriver-0.0.2 → pidriver-0.2.0}/src/pidriver/errors.py +15 -1
  13. {pidriver-0.0.2 → pidriver-0.2.0}/src/pidriver/events.py +43 -0
  14. {pidriver-0.0.2 → pidriver-0.2.0}/src/pidriver/interaction.py +18 -1
  15. pidriver-0.2.0/src/pidriver/models.py +160 -0
  16. pidriver-0.2.0/src/pidriver/workspace.py +108 -0
  17. pidriver-0.2.0/tests/test_auth.py +81 -0
  18. {pidriver-0.0.2 → pidriver-0.2.0}/tests/test_config.py +2 -2
  19. pidriver-0.2.0/tests/test_event_summary.py +56 -0
  20. pidriver-0.2.0/tests/test_install.py +173 -0
  21. pidriver-0.2.0/tests/test_install_resolve.py +62 -0
  22. pidriver-0.2.0/tests/test_models.py +96 -0
  23. pidriver-0.2.0/tests/test_workspace.py +80 -0
  24. pidriver-0.0.2/src/pidriver/_install.py +0 -180
  25. pidriver-0.0.2/tests/test_install.py +0 -184
  26. {pidriver-0.0.2 → pidriver-0.2.0}/LICENSE +0 -0
  27. {pidriver-0.0.2 → pidriver-0.2.0}/examples/basic_prompt.py +0 -0
  28. {pidriver-0.0.2 → pidriver-0.2.0}/src/pidriver/client.py +0 -0
  29. {pidriver-0.0.2 → pidriver-0.2.0}/src/pidriver/manager.py +0 -0
  30. {pidriver-0.0.2 → pidriver-0.2.0}/src/pidriver/py.typed +0 -0
  31. {pidriver-0.0.2 → pidriver-0.2.0}/src/pidriver/session.py +0 -0
  32. {pidriver-0.0.2 → pidriver-0.2.0}/src/pidriver/usage.py +0 -0
  33. {pidriver-0.0.2 → pidriver-0.2.0}/tests/conftest.py +0 -0
  34. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/README.md +0 -0
  35. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/_capture_driver.py +0 -0
  36. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/agent_end.json +0 -0
  37. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/agent_start.json +0 -0
  38. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/extension_ui_request.confirm.json +0 -0
  39. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/extension_ui_request.editor.json +0 -0
  40. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/extension_ui_request.input.json +0 -0
  41. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/extension_ui_request.select.approval.json +0 -0
  42. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/extension_ui_request.select.ask.json +0 -0
  43. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/extension_ui_request.setWidget.json +0 -0
  44. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/extension_ui_response.approve.json +0 -0
  45. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/extension_ui_response.confirm.json +0 -0
  46. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/extension_ui_response.editor.json +0 -0
  47. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/extension_ui_response.input.json +0 -0
  48. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/extension_ui_response.value.json +0 -0
  49. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/message_start_assistant.json +0 -0
  50. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/message_start_user.json +0 -0
  51. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/message_update.text_delta.json +0 -0
  52. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/message_update.thinking_delta.json +0 -0
  53. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/message_update.toolcall_end.json +0 -0
  54. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/message_update.toolcall_start.json +0 -0
  55. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/ready.json +0 -0
  56. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/response_prompt_ack.json +0 -0
  57. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/tool_execution_end.json +0 -0
  58. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/tool_execution_start.json +0 -0
  59. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/tool_execution_update.json +0 -0
  60. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/turn_end.json +0 -0
  61. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/frames/turn_start.json +0 -0
  62. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/payloads/get_session_stats.response.json +0 -0
  63. {pidriver-0.0.2 → pidriver-0.2.0}/tests/fixtures/payloads/get_state.response.json +0 -0
  64. {pidriver-0.0.2 → pidriver-0.2.0}/tests/test_events.py +0 -0
  65. {pidriver-0.0.2 → pidriver-0.2.0}/tests/test_fixtures_replay.py +0 -0
  66. {pidriver-0.0.2 → pidriver-0.2.0}/tests/test_manager.py +0 -0
  67. {pidriver-0.0.2 → pidriver-0.2.0}/tests/test_replay_session.py +0 -0
  68. {pidriver-0.0.2 → pidriver-0.2.0}/tests/test_session.py +0 -0
  69. {pidriver-0.0.2 → pidriver-0.2.0}/tests/test_transport.py +0 -0
  70. {pidriver-0.0.2 → pidriver-0.2.0}/tests/test_ui_methods.py +0 -0
  71. {pidriver-0.0.2 → pidriver-0.2.0}/tests/test_usage.py +0 -0
@@ -28,3 +28,6 @@ uv.lock
28
28
  .pi/
29
29
  *.jsonl
30
30
  scratch/
31
+
32
+ # Secrets (PyPI token, etc.)
33
+ .env
@@ -0,0 +1,4 @@
1
+ {
2
+ "provider_key": "codex",
3
+ "reasoning_effort": "high"
4
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pidriver
3
- Version: 0.0.2
3
+ Version: 0.2.0
4
4
  Summary: Async Python driver for the pi coding agent (pi --mode rpc), built for isolated, headless project work.
5
5
  Project-URL: Homepage, https://github.com/bobuk/pidriver
6
6
  Project-URL: Repository, https://github.com/bobuk/pidriver
@@ -28,9 +28,10 @@ Async Python driver for the pi coding agent, built for **isolated, headless proj
28
28
  drives any `pi`-compatible CLI in `--mode rpc` over its JSONL stdin/stdout protocol and exposes a
29
29
  small, typed Python API — with no third-party dependencies.
30
30
 
31
- The recommended CLI is [**oh-my-pi**](https://bun.sh) (the `omp` binary); the original
32
- [`pi`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) also works. Which one runs
33
- is just a `PiConfig` field (`binary`), so the same code drives either.
31
+ The default CLI is upstream [**pi**](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
32
+ (the `pi` binary); the [oh-my-pi](https://github.com/can1357/oh-my-pi) fork (`omp`) also works (set
33
+ `binary="omp"`, `mode="rpc-ui"`). Which one runs is just a `PiConfig` field, so the same code drives
34
+ either.
34
35
 
35
36
  Embed an agent inside another application (a chat bot, a scheduler, a service) and have it
36
37
  develop a project autonomously, in an environment that is **isolated** from your personal pi
@@ -41,18 +42,11 @@ handling; drop to the [raw transport](#transport) only when you need it.
41
42
 
42
43
  ## Requirements
43
44
 
44
- - Python **3.12+**
45
- - A `pi`-compatible CLI on `PATH`. Recommended — **oh-my-pi** (the `omp` binary) via
46
- [Bun](https://bun.sh), or the official installer:
47
-
48
- ```sh
49
- bun install -g @oh-my-pi/pi-coding-agent # provides the `omp` binary
50
- # or: curl -fsSL https://omp.sh/install | sh
51
- omp --version
52
- ```
53
-
54
- The original `pi` CLI works too: `npm i -g @earendil-works/pi-coding-agent`. Tell `PiConfig`
55
- which one to launch with `binary` (`"omp"` or `"pi"`); they share the `--mode rpc` protocol.
45
+ - Python **3.12+** (no third-party runtime deps).
46
+ - A `pi`-compatible CLI — but you usually **don't install one manually**: pidriver can
47
+ [auto-install](#auto-install) upstream `pi` via `npm` on first use (requires Node ≥22.19 + npm).
48
+ To use an existing CLI instead, put `pi` (or `omp`) on `PATH`, or point `PiConfig.binary` /
49
+ `install_dir` at it.
56
50
  - A provider API key — passed explicitly through `PiConfig`, see [Isolation](#isolation).
57
51
 
58
52
  ## Install
@@ -73,8 +67,7 @@ from pidriver import PiClient, PiConfig, AutoApprove, MessageDelta, ToolStart, A
73
67
 
74
68
  async def main():
75
69
  client = PiClient(PiConfig(
76
- binary="omp", # which CLI to launch (oh-my-pi); "pi" also works
77
- provider="openai",
70
+ provider="openai", # binary defaults to upstream "pi" (--mode rpc)
78
71
  model="gpt-4o",
79
72
  api_key=os.environ["OPENAI_API_KEY"],
80
73
  # Isolation defaults (scrubbed env, no host extensions/skills) are already on.
@@ -144,13 +137,24 @@ PiConfig(provider="anthropic", model="claude-sonnet-4-6",
144
137
  api_key="sk-...", workspace="/path/to/project")
145
138
  ```
146
139
 
147
- **Binary & workspace**
140
+ **Binary, mode & workspace**
148
141
 
149
142
  | Field | Default | Purpose |
150
143
  | --- | --- | --- |
151
144
  | `binary` | `"pi"` | Which `pi`-compatible CLI to launch — set to `"omp"` for oh-my-pi. |
145
+ | `mode` | `"rpc"` | Output mode → `--mode`. Upstream pi accepts `text`/`json`/`rpc`; `rpc` is the JSONL protocol this driver speaks. (`rpc-ui` is omp-only — set it explicitly when `binary="omp"`.) |
152
146
  | `workspace` | `None` | Project directory; becomes the subprocess `cwd`. |
153
147
 
148
+ **Binary auto-install** — see [Auto-install](#auto-install).
149
+
150
+ | Field | Default | Purpose |
151
+ | --- | --- | --- |
152
+ | `install_dir` | `None` | Where to `npm install`/look for a managed `pi` binary. |
153
+ | `auto_install` | `True` | `npm install` `pi` into `install_dir` if the binary can't be resolved. |
154
+ | `npm_package` | `"@earendil-works/pi-coding-agent"` | The npm package that provides `pi`. |
155
+ | `pi_version` | `"0.78.1"` | Pinned pi version to install (overridable). |
156
+ | `extensions` | `()` | Add-on sources (`npm:…`, `git:…`, path) installed via `pi install` before start. |
157
+
154
158
  **Model / provider**
155
159
 
156
160
  | Field | Default | Purpose |
@@ -164,22 +168,27 @@ PiConfig(provider="anthropic", model="claude-sonnet-4-6",
164
168
  Prefer a **fully-qualified** `model` id (e.g. `openai/gpt-4o`, `anthropic/claude-haiku-4-5`) —
165
169
  bare fuzzy names can mis-route to the wrong backend.
166
170
 
171
+ **Approvals** — `auto_approve` (`--auto-approve`, run tools without asking), `approval_mode`
172
+ (`--approval-mode <mode>`, e.g. `"always-ask"`). Leave both unset to let your
173
+ [`InteractionHandler`](#answering-the-agent) decide.
174
+
167
175
  **Tools** — `tools` (`--tools` allowlist), `exclude_tools` (`--exclude-tools`), `no_tools`,
168
176
  `no_builtin_tools`.
169
177
 
170
178
  **Session** — `session` (False → `--no-session`), `session_id`, `session_path` (`--session`),
171
- `session_name` (`--name`), `continue_session` (`--continue`), `system_prompt`,
172
- `append_system_prompt`.
179
+ `session_name` (`--name`), `continue_session` (`--continue`), `system_prompt`
180
+ (`--system-prompt`), `append_system_prompt` (`--append-system-prompt`, repeatable).
173
181
 
174
- **Isolation** — see [Isolation](#isolation): `agent_dir`, `session_dir`, `no_extensions`,
175
- `no_skills`, `no_context_files`, `no_prompt_templates`, `no_themes`, `offline`,
176
- `skip_version_check`, `inherit_env`, `env_passthrough`.
182
+ **Isolation** — see [Isolation](#isolation): `home_dir`, `agent_dir`, `session_dir`,
183
+ `package_dir`, `no_extensions`, `no_skills`, `no_context_files`, `no_prompt_templates`,
184
+ `no_themes`, `no_mcp`, `offline`, `skip_version_check`, `telemetry`, `inherit_env`,
185
+ `env_passthrough`.
177
186
 
178
187
  **Escape hatches** — `extra_args` (appended to argv), `extra_env` (merged into the child env).
179
188
 
180
189
  Methods:
181
190
 
182
- - `to_argv() -> list[str]` — full command line, starting `[binary, "--mode", "rpc", ...]`.
191
+ - `to_argv() -> list[str]` — full command line, starting `[binary, "--mode", mode, ...]`.
183
192
  - `to_env(base_environ=None) -> dict[str, str]` — the child environment (scrubbed by default).
184
193
  - `api_key_env_name() -> str | None` — which env var `api_key` resolves to (provider-mapped via
185
194
  `PROVIDER_API_KEY_ENV`, falling back to `<PROVIDER>_API_KEY`, then `ANTHROPIC_API_KEY`).
@@ -193,32 +202,68 @@ project from reading or mutating your global pi setup:
193
202
  environment plus only `env_passthrough` (`PATH`, `HOME`, `LANG`, `LC_ALL`, `TERM`, `TZ`) — so
194
203
  a stray `*_API_KEY` in your shell never leaks into the agent. The configured `api_key` is then
195
204
  injected under exactly one provider variable.
196
- - **Private agent dir.** `agent_dir` sets `PI_CODING_AGENT_DIR`, pointing pi at a private config
197
- directory instead of `~/.pi`, so it loads no personal extensions/skills/settings.
205
+ - **Private `HOME`.** `home_dir` overrides `HOME` for the child. This is the **load-bearing**
206
+ isolation lever: omp derives its config root *and logs* from `$HOME/.omp`, so `agent_dir`
207
+ alone isn't enough — without `home_dir` the agent still writes logs into your real `~/.omp`
208
+ (verified empirically against omp 15.7.3). Set `home_dir` and your personal setup stays
209
+ byte-for-byte untouched.
210
+ - **Private state dirs.** `agent_dir` → `PI_CODING_AGENT_DIR` (config/extensions/skills),
211
+ `session_dir` → `PI_CODING_AGENT_SESSION_DIR` (session `.jsonl`s), `package_dir` →
212
+ `PI_PACKAGE_DIR` (installed packages) — each points pi at a private location instead of the
213
+ defaults under `~/.omp`.
198
214
  - **No host discovery** (all default **True**): `no_extensions`, `no_skills`,
199
215
  `no_context_files`, `no_prompt_templates`, `no_themes` give a clean, reproducible agent.
200
- - **Separate session storage.** `session_dir` (`--session-dir`) keeps session `.jsonl` files out
201
- of the default location.
202
- - `offline` (`--offline` + `PI_OFFLINE=1`) and `skip_version_check` (`PI_SKIP_VERSION_CHECK=1`,
203
- default True) round out a hermetic run.
216
+ - **No MCP autoconnect** (`no_mcp=True`, default). Under `rpc-ui` the agent otherwise
217
+ auto-connects MCP servers it finds in the environment; `no_mcp` strips those trigger vars.
218
+ - `offline` (`--offline` + `PI_OFFLINE=1`), `skip_version_check` (`PI_SKIP_VERSION_CHECK=1`,
219
+ default True) and `telemetry=False` (`PI_TELEMETRY=0`) round out a hermetic run.
204
220
 
205
221
  ```python
222
+ home = Path("/var/lib/myapp/pi")
206
223
  cfg = PiConfig(
207
- provider="anthropic", model="claude-sonnet-4-6", api_key=KEY,
224
+ provider="anthropic", model="anthropic/claude-haiku-4-5", api_key=KEY,
208
225
  workspace="/srv/projects/acme",
209
- agent_dir="/var/lib/myapp/pi-home", # private ~/.pi replacement
210
- session_dir="/var/lib/myapp/pi-sessions",
211
- extra_env={"HOME": "/var/lib/myapp"}, # see note below — also override HOME
212
- # inherit_env=False and no_* discovery flags are already the defaults.
226
+ home_dir=home / "home", # overrides $HOME — the key isolation lever
227
+ agent_dir=home / "agent", # PI_CODING_AGENT_DIR
228
+ session_dir=home / "sessions", # PI_CODING_AGENT_SESSION_DIR
229
+ package_dir=home / "packages", # PI_PACKAGE_DIR
230
+ # inherit_env=False, no_* discovery, no_mcp, telemetry=False are already the defaults.
213
231
  )
214
232
  ```
215
233
 
216
- > **Override `HOME` for full isolation.** `agent_dir`/`PI_CODING_AGENT_DIR` alone is **not**
217
- > enough: omp derives its **log** root from `$HOME/.omp`, and the default `env_passthrough`
218
- > copies `HOME` from the host — so an agent can still write logs into your `~/.omp`. To leave
219
- > your personal setup byte-for-byte untouched, also point `HOME` at the private dir (via
220
- > `extra_env`, as above) or drop it from `env_passthrough`. (Verified empirically against
221
- > omp 15.7.3.)
234
+ ## Auto-install
235
+
236
+ You don't have to install a CLI yourself. Upstream `pi` is an npm package, and pidriver can
237
+ `npm install` it into a managed, isolated prefix on demand (requires **Node ≥22.19 + npm** on
238
+ `PATH` — the `pi` bin is a Node shim, not a self-contained binary).
239
+
240
+ When the transport starts, it resolves the binary in order — explicit `binary` path →
241
+ `install_dir/bin/pi` → `PATH`. If none resolves and `auto_install=True` (default), it runs
242
+ `npm install -g --prefix <install_dir> <npm_package>@<pi_version>` and runs `<install_dir>/bin/pi`.
243
+
244
+ ```python
245
+ from pidriver import ensure_binary, install_pi, ensure_extensions # also exported for explicit use
246
+ ```
247
+
248
+ ### Extensions / skills / packages
249
+
250
+ Set `PiConfig.extensions` to a tuple of add-on sources and pidriver runs `pi install <source>`
251
+ for each (into the **isolated** config dir, via `PI_CODING_AGENT_DIR`) before the agent starts —
252
+ idempotently (sources already in the dir's `settings.json` are skipped; failures are logged, not
253
+ fatal). Each source is one of `npm:<pkg>[@version]`, `git:github.com/<owner>/<repo>[@ref]`, an
254
+ `https://`/`ssh://` git url, or a local `./path`. To actually load them, turn the relevant
255
+ isolation flags off (e.g. `no_extensions=False`, `no_skills=False`).
256
+
257
+ ```python
258
+ PiConfig(extensions=("npm:pi-web-access", "git:github.com/owner/repo"),
259
+ no_extensions=False, no_skills=False)
260
+ ```
261
+
262
+ Notes:
263
+ - First `npm install` takes a few seconds; subsequent runs reuse the managed copy.
264
+ - Failures raise `PiInstallError` (missing npm/Node, install or version-verify failure).
265
+ - To skip auto-install, set `auto_install=False` and provide `binary`/`install_dir` pointing at an
266
+ existing CLI.
222
267
 
223
268
  ## Transport
224
269
 
@@ -295,7 +340,8 @@ All exceptions derive from `PiDriverError`, so one `except` catches the family:
295
340
  | Exception | Raised when |
296
341
  | --- | --- |
297
342
  | `PiDriverError` | Base class for everything below. |
298
- | `PiNotFoundError` | The `pi` executable can't be found or run. |
343
+ | `PiNotFoundError` | The `pi`/`omp` executable can't be found or run. |
344
+ | `PiInstallError` | [Auto-install](#auto-install) failed (unsupported OS/arch, download/verify error). |
299
345
  | `PiStartError` | The `pi --mode rpc` process failed to start. |
300
346
  | `PiProtocolError` | A line from pi wasn't valid JSON / violated the contract (`.raw`). |
301
347
  | `PiCommandError` | An RPC command returned `success: false` (`.command`, `.error`, `.data`). |
@@ -476,6 +522,58 @@ handler = chain(
476
522
  )
477
523
  ```
478
524
 
525
+ ## Workspace jail
526
+
527
+ Run an agent loose inside a project without letting it run anywhere on disk. `resolve_project`
528
+ maps a user-supplied *name* to a real directory only if it is explicitly allow-listed and lives
529
+ physically under `root` — rejecting path separators and symlink escapes.
530
+
531
+ ```python
532
+ from pidriver import resolve_project, list_candidate_projects, ProjectError
533
+
534
+ try:
535
+ cwd = resolve_project("myrepo", root=PROJECTS_ROOT, allowlist=("myrepo", "docs"))
536
+ except ProjectError as exc:
537
+ ... # empty / not allow-listed / not a dir / escapes root (incl. via symlink)
538
+
539
+ # Offer a picker: every immediate sub-dir of a root, flagged git / selected.
540
+ projects = list_candidate_projects(PROJECTS_ROOT, allowlist=("myrepo",)) # list[ProjectDir]
541
+ ```
542
+
543
+ ## Model discovery
544
+
545
+ `list_models` runs `pi --list-models` under the same isolation env the agent uses and parses the
546
+ table into typed `ModelInfo` rows; `parse_models` is the pure parser (handy for tests / cached
547
+ output). When a provider is set the result is narrowed to it.
548
+
549
+ ```python
550
+ from pidriver import list_models, parse_models
551
+
552
+ models = await list_models(config, provider="anthropic") # list[ModelInfo]
553
+ for m in models:
554
+ print(m.id, m.context) # "anthropic/claude-opus-4-8" "200K"
555
+ ```
556
+
557
+ ## Provider OAuth login
558
+
559
+ Some providers authenticate via OAuth (Claude Pro/Max, Codex, …) instead of an API key. `PiOAuthLogin`
560
+ drives a bundled Node helper that reuses pi's own `AuthStorage` (no version skew), writing
561
+ credentials into the isolated `<agent_dir>/auth.json`. Requires `node` on PATH and a resolvable pi
562
+ install.
563
+
564
+ ```python
565
+ from pidriver import PiOAuthLogin
566
+
567
+ login = PiOAuthLogin(config) # config.agent_dir receives auth.json
568
+ info = await login.start("anthropic") # {"session", "url", "instructions"}
569
+ # user opens info["url"], authorizes, copies the code:
570
+ await login.submit_code(info["session"], code) # accepts a bare code or a callback URL
571
+ ```
572
+
573
+ `valid_extension_source(src)` validates a `pi install` argument, and `resolve_node_modules(config)`
574
+ locates pi's bundled Node libraries (package identity from `config.npm_package`, so the omp fork
575
+ works too) — both exported for hosts building their own extension/auth tooling.
576
+
479
577
  ## Development
480
578
 
481
579
  ```sh
@@ -4,9 +4,10 @@ Async Python driver for the pi coding agent, built for **isolated, headless proj
4
4
  drives any `pi`-compatible CLI in `--mode rpc` over its JSONL stdin/stdout protocol and exposes a
5
5
  small, typed Python API — with no third-party dependencies.
6
6
 
7
- The recommended CLI is [**oh-my-pi**](https://bun.sh) (the `omp` binary); the original
8
- [`pi`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) also works. Which one runs
9
- is just a `PiConfig` field (`binary`), so the same code drives either.
7
+ The default CLI is upstream [**pi**](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
8
+ (the `pi` binary); the [oh-my-pi](https://github.com/can1357/oh-my-pi) fork (`omp`) also works (set
9
+ `binary="omp"`, `mode="rpc-ui"`). Which one runs is just a `PiConfig` field, so the same code drives
10
+ either.
10
11
 
11
12
  Embed an agent inside another application (a chat bot, a scheduler, a service) and have it
12
13
  develop a project autonomously, in an environment that is **isolated** from your personal pi
@@ -17,18 +18,11 @@ handling; drop to the [raw transport](#transport) only when you need it.
17
18
 
18
19
  ## Requirements
19
20
 
20
- - Python **3.12+**
21
- - A `pi`-compatible CLI on `PATH`. Recommended — **oh-my-pi** (the `omp` binary) via
22
- [Bun](https://bun.sh), or the official installer:
23
-
24
- ```sh
25
- bun install -g @oh-my-pi/pi-coding-agent # provides the `omp` binary
26
- # or: curl -fsSL https://omp.sh/install | sh
27
- omp --version
28
- ```
29
-
30
- The original `pi` CLI works too: `npm i -g @earendil-works/pi-coding-agent`. Tell `PiConfig`
31
- which one to launch with `binary` (`"omp"` or `"pi"`); they share the `--mode rpc` protocol.
21
+ - Python **3.12+** (no third-party runtime deps).
22
+ - A `pi`-compatible CLI — but you usually **don't install one manually**: pidriver can
23
+ [auto-install](#auto-install) upstream `pi` via `npm` on first use (requires Node ≥22.19 + npm).
24
+ To use an existing CLI instead, put `pi` (or `omp`) on `PATH`, or point `PiConfig.binary` /
25
+ `install_dir` at it.
32
26
  - A provider API key — passed explicitly through `PiConfig`, see [Isolation](#isolation).
33
27
 
34
28
  ## Install
@@ -49,8 +43,7 @@ from pidriver import PiClient, PiConfig, AutoApprove, MessageDelta, ToolStart, A
49
43
 
50
44
  async def main():
51
45
  client = PiClient(PiConfig(
52
- binary="omp", # which CLI to launch (oh-my-pi); "pi" also works
53
- provider="openai",
46
+ provider="openai", # binary defaults to upstream "pi" (--mode rpc)
54
47
  model="gpt-4o",
55
48
  api_key=os.environ["OPENAI_API_KEY"],
56
49
  # Isolation defaults (scrubbed env, no host extensions/skills) are already on.
@@ -120,13 +113,24 @@ PiConfig(provider="anthropic", model="claude-sonnet-4-6",
120
113
  api_key="sk-...", workspace="/path/to/project")
121
114
  ```
122
115
 
123
- **Binary & workspace**
116
+ **Binary, mode & workspace**
124
117
 
125
118
  | Field | Default | Purpose |
126
119
  | --- | --- | --- |
127
120
  | `binary` | `"pi"` | Which `pi`-compatible CLI to launch — set to `"omp"` for oh-my-pi. |
121
+ | `mode` | `"rpc"` | Output mode → `--mode`. Upstream pi accepts `text`/`json`/`rpc`; `rpc` is the JSONL protocol this driver speaks. (`rpc-ui` is omp-only — set it explicitly when `binary="omp"`.) |
128
122
  | `workspace` | `None` | Project directory; becomes the subprocess `cwd`. |
129
123
 
124
+ **Binary auto-install** — see [Auto-install](#auto-install).
125
+
126
+ | Field | Default | Purpose |
127
+ | --- | --- | --- |
128
+ | `install_dir` | `None` | Where to `npm install`/look for a managed `pi` binary. |
129
+ | `auto_install` | `True` | `npm install` `pi` into `install_dir` if the binary can't be resolved. |
130
+ | `npm_package` | `"@earendil-works/pi-coding-agent"` | The npm package that provides `pi`. |
131
+ | `pi_version` | `"0.78.1"` | Pinned pi version to install (overridable). |
132
+ | `extensions` | `()` | Add-on sources (`npm:…`, `git:…`, path) installed via `pi install` before start. |
133
+
130
134
  **Model / provider**
131
135
 
132
136
  | Field | Default | Purpose |
@@ -140,22 +144,27 @@ PiConfig(provider="anthropic", model="claude-sonnet-4-6",
140
144
  Prefer a **fully-qualified** `model` id (e.g. `openai/gpt-4o`, `anthropic/claude-haiku-4-5`) —
141
145
  bare fuzzy names can mis-route to the wrong backend.
142
146
 
147
+ **Approvals** — `auto_approve` (`--auto-approve`, run tools without asking), `approval_mode`
148
+ (`--approval-mode <mode>`, e.g. `"always-ask"`). Leave both unset to let your
149
+ [`InteractionHandler`](#answering-the-agent) decide.
150
+
143
151
  **Tools** — `tools` (`--tools` allowlist), `exclude_tools` (`--exclude-tools`), `no_tools`,
144
152
  `no_builtin_tools`.
145
153
 
146
154
  **Session** — `session` (False → `--no-session`), `session_id`, `session_path` (`--session`),
147
- `session_name` (`--name`), `continue_session` (`--continue`), `system_prompt`,
148
- `append_system_prompt`.
155
+ `session_name` (`--name`), `continue_session` (`--continue`), `system_prompt`
156
+ (`--system-prompt`), `append_system_prompt` (`--append-system-prompt`, repeatable).
149
157
 
150
- **Isolation** — see [Isolation](#isolation): `agent_dir`, `session_dir`, `no_extensions`,
151
- `no_skills`, `no_context_files`, `no_prompt_templates`, `no_themes`, `offline`,
152
- `skip_version_check`, `inherit_env`, `env_passthrough`.
158
+ **Isolation** — see [Isolation](#isolation): `home_dir`, `agent_dir`, `session_dir`,
159
+ `package_dir`, `no_extensions`, `no_skills`, `no_context_files`, `no_prompt_templates`,
160
+ `no_themes`, `no_mcp`, `offline`, `skip_version_check`, `telemetry`, `inherit_env`,
161
+ `env_passthrough`.
153
162
 
154
163
  **Escape hatches** — `extra_args` (appended to argv), `extra_env` (merged into the child env).
155
164
 
156
165
  Methods:
157
166
 
158
- - `to_argv() -> list[str]` — full command line, starting `[binary, "--mode", "rpc", ...]`.
167
+ - `to_argv() -> list[str]` — full command line, starting `[binary, "--mode", mode, ...]`.
159
168
  - `to_env(base_environ=None) -> dict[str, str]` — the child environment (scrubbed by default).
160
169
  - `api_key_env_name() -> str | None` — which env var `api_key` resolves to (provider-mapped via
161
170
  `PROVIDER_API_KEY_ENV`, falling back to `<PROVIDER>_API_KEY`, then `ANTHROPIC_API_KEY`).
@@ -169,32 +178,68 @@ project from reading or mutating your global pi setup:
169
178
  environment plus only `env_passthrough` (`PATH`, `HOME`, `LANG`, `LC_ALL`, `TERM`, `TZ`) — so
170
179
  a stray `*_API_KEY` in your shell never leaks into the agent. The configured `api_key` is then
171
180
  injected under exactly one provider variable.
172
- - **Private agent dir.** `agent_dir` sets `PI_CODING_AGENT_DIR`, pointing pi at a private config
173
- directory instead of `~/.pi`, so it loads no personal extensions/skills/settings.
181
+ - **Private `HOME`.** `home_dir` overrides `HOME` for the child. This is the **load-bearing**
182
+ isolation lever: omp derives its config root *and logs* from `$HOME/.omp`, so `agent_dir`
183
+ alone isn't enough — without `home_dir` the agent still writes logs into your real `~/.omp`
184
+ (verified empirically against omp 15.7.3). Set `home_dir` and your personal setup stays
185
+ byte-for-byte untouched.
186
+ - **Private state dirs.** `agent_dir` → `PI_CODING_AGENT_DIR` (config/extensions/skills),
187
+ `session_dir` → `PI_CODING_AGENT_SESSION_DIR` (session `.jsonl`s), `package_dir` →
188
+ `PI_PACKAGE_DIR` (installed packages) — each points pi at a private location instead of the
189
+ defaults under `~/.omp`.
174
190
  - **No host discovery** (all default **True**): `no_extensions`, `no_skills`,
175
191
  `no_context_files`, `no_prompt_templates`, `no_themes` give a clean, reproducible agent.
176
- - **Separate session storage.** `session_dir` (`--session-dir`) keeps session `.jsonl` files out
177
- of the default location.
178
- - `offline` (`--offline` + `PI_OFFLINE=1`) and `skip_version_check` (`PI_SKIP_VERSION_CHECK=1`,
179
- default True) round out a hermetic run.
192
+ - **No MCP autoconnect** (`no_mcp=True`, default). Under `rpc-ui` the agent otherwise
193
+ auto-connects MCP servers it finds in the environment; `no_mcp` strips those trigger vars.
194
+ - `offline` (`--offline` + `PI_OFFLINE=1`), `skip_version_check` (`PI_SKIP_VERSION_CHECK=1`,
195
+ default True) and `telemetry=False` (`PI_TELEMETRY=0`) round out a hermetic run.
180
196
 
181
197
  ```python
198
+ home = Path("/var/lib/myapp/pi")
182
199
  cfg = PiConfig(
183
- provider="anthropic", model="claude-sonnet-4-6", api_key=KEY,
200
+ provider="anthropic", model="anthropic/claude-haiku-4-5", api_key=KEY,
184
201
  workspace="/srv/projects/acme",
185
- agent_dir="/var/lib/myapp/pi-home", # private ~/.pi replacement
186
- session_dir="/var/lib/myapp/pi-sessions",
187
- extra_env={"HOME": "/var/lib/myapp"}, # see note below — also override HOME
188
- # inherit_env=False and no_* discovery flags are already the defaults.
202
+ home_dir=home / "home", # overrides $HOME — the key isolation lever
203
+ agent_dir=home / "agent", # PI_CODING_AGENT_DIR
204
+ session_dir=home / "sessions", # PI_CODING_AGENT_SESSION_DIR
205
+ package_dir=home / "packages", # PI_PACKAGE_DIR
206
+ # inherit_env=False, no_* discovery, no_mcp, telemetry=False are already the defaults.
189
207
  )
190
208
  ```
191
209
 
192
- > **Override `HOME` for full isolation.** `agent_dir`/`PI_CODING_AGENT_DIR` alone is **not**
193
- > enough: omp derives its **log** root from `$HOME/.omp`, and the default `env_passthrough`
194
- > copies `HOME` from the host — so an agent can still write logs into your `~/.omp`. To leave
195
- > your personal setup byte-for-byte untouched, also point `HOME` at the private dir (via
196
- > `extra_env`, as above) or drop it from `env_passthrough`. (Verified empirically against
197
- > omp 15.7.3.)
210
+ ## Auto-install
211
+
212
+ You don't have to install a CLI yourself. Upstream `pi` is an npm package, and pidriver can
213
+ `npm install` it into a managed, isolated prefix on demand (requires **Node ≥22.19 + npm** on
214
+ `PATH` — the `pi` bin is a Node shim, not a self-contained binary).
215
+
216
+ When the transport starts, it resolves the binary in order — explicit `binary` path →
217
+ `install_dir/bin/pi` → `PATH`. If none resolves and `auto_install=True` (default), it runs
218
+ `npm install -g --prefix <install_dir> <npm_package>@<pi_version>` and runs `<install_dir>/bin/pi`.
219
+
220
+ ```python
221
+ from pidriver import ensure_binary, install_pi, ensure_extensions # also exported for explicit use
222
+ ```
223
+
224
+ ### Extensions / skills / packages
225
+
226
+ Set `PiConfig.extensions` to a tuple of add-on sources and pidriver runs `pi install <source>`
227
+ for each (into the **isolated** config dir, via `PI_CODING_AGENT_DIR`) before the agent starts —
228
+ idempotently (sources already in the dir's `settings.json` are skipped; failures are logged, not
229
+ fatal). Each source is one of `npm:<pkg>[@version]`, `git:github.com/<owner>/<repo>[@ref]`, an
230
+ `https://`/`ssh://` git url, or a local `./path`. To actually load them, turn the relevant
231
+ isolation flags off (e.g. `no_extensions=False`, `no_skills=False`).
232
+
233
+ ```python
234
+ PiConfig(extensions=("npm:pi-web-access", "git:github.com/owner/repo"),
235
+ no_extensions=False, no_skills=False)
236
+ ```
237
+
238
+ Notes:
239
+ - First `npm install` takes a few seconds; subsequent runs reuse the managed copy.
240
+ - Failures raise `PiInstallError` (missing npm/Node, install or version-verify failure).
241
+ - To skip auto-install, set `auto_install=False` and provide `binary`/`install_dir` pointing at an
242
+ existing CLI.
198
243
 
199
244
  ## Transport
200
245
 
@@ -271,7 +316,8 @@ All exceptions derive from `PiDriverError`, so one `except` catches the family:
271
316
  | Exception | Raised when |
272
317
  | --- | --- |
273
318
  | `PiDriverError` | Base class for everything below. |
274
- | `PiNotFoundError` | The `pi` executable can't be found or run. |
319
+ | `PiNotFoundError` | The `pi`/`omp` executable can't be found or run. |
320
+ | `PiInstallError` | [Auto-install](#auto-install) failed (unsupported OS/arch, download/verify error). |
275
321
  | `PiStartError` | The `pi --mode rpc` process failed to start. |
276
322
  | `PiProtocolError` | A line from pi wasn't valid JSON / violated the contract (`.raw`). |
277
323
  | `PiCommandError` | An RPC command returned `success: false` (`.command`, `.error`, `.data`). |
@@ -452,6 +498,58 @@ handler = chain(
452
498
  )
453
499
  ```
454
500
 
501
+ ## Workspace jail
502
+
503
+ Run an agent loose inside a project without letting it run anywhere on disk. `resolve_project`
504
+ maps a user-supplied *name* to a real directory only if it is explicitly allow-listed and lives
505
+ physically under `root` — rejecting path separators and symlink escapes.
506
+
507
+ ```python
508
+ from pidriver import resolve_project, list_candidate_projects, ProjectError
509
+
510
+ try:
511
+ cwd = resolve_project("myrepo", root=PROJECTS_ROOT, allowlist=("myrepo", "docs"))
512
+ except ProjectError as exc:
513
+ ... # empty / not allow-listed / not a dir / escapes root (incl. via symlink)
514
+
515
+ # Offer a picker: every immediate sub-dir of a root, flagged git / selected.
516
+ projects = list_candidate_projects(PROJECTS_ROOT, allowlist=("myrepo",)) # list[ProjectDir]
517
+ ```
518
+
519
+ ## Model discovery
520
+
521
+ `list_models` runs `pi --list-models` under the same isolation env the agent uses and parses the
522
+ table into typed `ModelInfo` rows; `parse_models` is the pure parser (handy for tests / cached
523
+ output). When a provider is set the result is narrowed to it.
524
+
525
+ ```python
526
+ from pidriver import list_models, parse_models
527
+
528
+ models = await list_models(config, provider="anthropic") # list[ModelInfo]
529
+ for m in models:
530
+ print(m.id, m.context) # "anthropic/claude-opus-4-8" "200K"
531
+ ```
532
+
533
+ ## Provider OAuth login
534
+
535
+ Some providers authenticate via OAuth (Claude Pro/Max, Codex, …) instead of an API key. `PiOAuthLogin`
536
+ drives a bundled Node helper that reuses pi's own `AuthStorage` (no version skew), writing
537
+ credentials into the isolated `<agent_dir>/auth.json`. Requires `node` on PATH and a resolvable pi
538
+ install.
539
+
540
+ ```python
541
+ from pidriver import PiOAuthLogin
542
+
543
+ login = PiOAuthLogin(config) # config.agent_dir receives auth.json
544
+ info = await login.start("anthropic") # {"session", "url", "instructions"}
545
+ # user opens info["url"], authorizes, copies the code:
546
+ await login.submit_code(info["session"], code) # accepts a bare code or a callback URL
547
+ ```
548
+
549
+ `valid_extension_source(src)` validates a `pi install` argument, and `resolve_node_modules(config)`
550
+ locates pi's bundled Node libraries (package identity from `config.npm_package`, so the omp fork
551
+ works too) — both exported for hosts building their own extension/auth tooling.
552
+
455
553
  ## Development
456
554
 
457
555
  ```sh
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pidriver"
3
- version = "0.0.2"
3
+ version = "0.2.0"
4
4
  description = "Async Python driver for the pi coding agent (pi --mode rpc), built for isolated, headless project work."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -36,6 +36,11 @@ build-backend = "hatchling.build"
36
36
  [tool.hatch.build.targets.wheel]
37
37
  packages = ["src/pidriver"]
38
38
 
39
+ # Ship the Node OAuth helper (pidriver.auth loads it via importlib.resources). Explicit
40
+ # so the data file lands in the wheel regardless of default file selection.
41
+ [tool.hatch.build.targets.wheel.force-include]
42
+ "src/pidriver/assets/pi_login.mjs" = "pidriver/assets/pi_login.mjs"
43
+
39
44
  [tool.pytest.ini_options]
40
45
  asyncio_mode = "auto"
41
46
  testpaths = ["tests"]